Compare commits
No commits in common. "main" and "feat/deploy" have entirely different histories.
main
...
feat/deplo
70 changed files with 3975 additions and 3201 deletions
72
.planning/.continue-here.md
Normal file
72
.planning/.continue-here.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
---
|
||||||
|
context: pre-planning
|
||||||
|
phase: 05-deployment
|
||||||
|
task: 0
|
||||||
|
total_tasks: 0
|
||||||
|
status: ready-to-plan
|
||||||
|
last_updated: 2026-04-27T18:44:51.832Z
|
||||||
|
---
|
||||||
|
|
||||||
|
<current_state>
|
||||||
|
Phase 04 полностью завершена и закоммичена на ветке `feat/matrix-direct-agent-prototype` (135 тестов зелёные). Этот сеанс был посвящён архитектуре деплоя — изучили платформенные репозитории и обсудили топологию с командой платформы. Вся информация о деплое зафиксирована в `docs/deploy-architecture.md`. Phase 05 не спланирована, следующий шаг — `/gsd-plan-phase`.
|
||||||
|
</current_state>
|
||||||
|
|
||||||
|
<completed_work>
|
||||||
|
|
||||||
|
- Изучены актуальные версии platform-agent, platform-agent_api, platform-master
|
||||||
|
- Уточнена топология деплоя с платформой (схема с reverse proxy и shared volume)
|
||||||
|
- Созданы `docs/deploy-architecture.md` — полное summary архитектуры деплоя
|
||||||
|
</completed_work>
|
||||||
|
|
||||||
|
<remaining_work>
|
||||||
|
|
||||||
|
- Смержить `feat/matrix-direct-agent-prototype` → `main`
|
||||||
|
- Спланировать Phase 05 (деплой)
|
||||||
|
- Выполнить Phase 05:
|
||||||
|
- Обновить `config/matrix-agents.yaml` (добавить `base_url`, `workspace_path`, `user_agents`)
|
||||||
|
- Обновить `sdk/real.py` (AgentApi конструктор, file transfer)
|
||||||
|
- Обработка `MsgEventSendFile` в Matrix адаптере (скачать файл из volume, отправить пользователю)
|
||||||
|
- Обработка входящих файлов от Matrix пользователей (сохранить в workspace, передать в attachments)
|
||||||
|
- Написать `docker-compose.yml` для деплоя
|
||||||
|
</remaining_work>
|
||||||
|
|
||||||
|
<decisions_made>
|
||||||
|
|
||||||
|
- **Топология**: один инстанс Matrix-бота, один агент-контейнер на пользователя, reverse proxy на `lambda.coredump.ru:7000` роутит по пути `/agent_N/`
|
||||||
|
- **Файлы**: через shared volume `/agents/`. Surface пишет файл в `/agents/{N}/`, передаёт относительный путь в `attachments=["file.txt"]`. При `MsgEventSendFile(path)` — читает файл из `/agents/{N}/{path}` и шлёт в Matrix.
|
||||||
|
- **Agent API**: используем master (`attachments` и `MsgEventSendFile` есть). Ветку `#9-clientside-tool-call` игнорируем — она в разработке и убирает нужные фичи.
|
||||||
|
- **Конфиг**: два словаря — `user_id → agent_id` и `agent_id → {base_url, workspace_path}`
|
||||||
|
- **Master**: не используем для MVP. Статический конфиг. При готовности Master — мигрируем.
|
||||||
|
- **chat_id**: пока `chat_id=0` (один контекст на пользователя)
|
||||||
|
</decisions_made>
|
||||||
|
|
||||||
|
<blockers>
|
||||||
|
|
||||||
|
- **AGENT_ID + COMPOSIO_API_KEY**: Composio смержен в main platform-agent, теперь обязателен. Значения нужны от Азамата перед деплоем.
|
||||||
|
- **agent_api #9**: убирает `attachments` и `MsgEventSendFile` — если смержат до деплоя, сломает наш file transfer. Нужно уточнить сроки.
|
||||||
|
</blockers>
|
||||||
|
|
||||||
|
## Required Reading (in order)
|
||||||
|
|
||||||
|
1. `docs/deploy-architecture.md` — полная архитектура деплоя, топология, API, файловый обмен, конфиг
|
||||||
|
2. `adapter/matrix/routed_platform.py` — текущий RoutedPlatformClient
|
||||||
|
3. `sdk/real.py` — текущий AgentApi wrapper
|
||||||
|
4. `config/matrix-agents.yaml` и `config/matrix-agents.example.yaml` — текущий формат конфига (нужно расширить)
|
||||||
|
|
||||||
|
## Infrastructure State
|
||||||
|
|
||||||
|
- Ветка: `feat/matrix-direct-agent-prototype` — готова к merge, 135 тестов зелёные
|
||||||
|
- `config/matrix-agents.yaml` — незакоммичен (live config, добавить в `.gitignore`)
|
||||||
|
- `docs/deploy-architecture.md` — незакоммичен (новый файл этого сеанса)
|
||||||
|
- platform-agent main: Composio уже смержен (требует `AGENT_ID`, `COMPOSIO_API_KEY` в env)
|
||||||
|
|
||||||
|
<context>
|
||||||
|
Архитектура деплоя полностью прояснена. Нет неизвестных блокеров (кроме env-переменных от платформы). Phase 05 — чисто инженерная задача: обновить конфиг, sdk, Matrix адаптер, написать compose. Всё что нужно знать — в docs/deploy-architecture.md.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<next_action>
|
||||||
|
1. /clear
|
||||||
|
2. /gsd-resume-work — прочитает этот файл и предложит план Phase 05
|
||||||
|
3. Прочитать docs/deploy-architecture.md
|
||||||
|
4. /gsd-plan-phase 05
|
||||||
|
</next_action>
|
||||||
100
.planning/HANDOFF.json
Normal file
100
.planning/HANDOFF.json
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"timestamp": "2026-04-27T18:44:51.832Z",
|
||||||
|
"phase": "05",
|
||||||
|
"phase_name": "deployment",
|
||||||
|
"phase_dir": null,
|
||||||
|
"plan": 0,
|
||||||
|
"task": 0,
|
||||||
|
"total_tasks": 0,
|
||||||
|
"status": "pre-planning",
|
||||||
|
"completed_tasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Research platform repos (agent, agent_api, master)",
|
||||||
|
"status": "done",
|
||||||
|
"commit": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Clarify deployment topology with platform team",
|
||||||
|
"status": "done",
|
||||||
|
"commit": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Create docs/deploy-architecture.md",
|
||||||
|
"status": "done",
|
||||||
|
"commit": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"remaining_tasks": [
|
||||||
|
{"id": 4, "name": "Merge feat/matrix-direct-agent-prototype → main", "status": "not_started"},
|
||||||
|
{"id": 5, "name": "Plan Phase 05 (deployment)", "status": "not_started"},
|
||||||
|
{"id": 6, "name": "Execute Phase 05", "status": "not_started"}
|
||||||
|
],
|
||||||
|
"blockers": [
|
||||||
|
{
|
||||||
|
"description": "agent_api #9-clientside-tool-call убирает attachments и MsgEventSendFile — если смержат до деплоя, сломает file transfer",
|
||||||
|
"type": "external",
|
||||||
|
"workaround": "Используем master пока #9 не merged. Уточнить у Азамата сроки."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "AGENT_ID и COMPOSIO_API_KEY значения для каждого агента — нужны от платформы",
|
||||||
|
"type": "human_action",
|
||||||
|
"workaround": "Запросить у Азамата перед деплоем"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"human_actions_pending": [
|
||||||
|
{
|
||||||
|
"action": "Получить значения AGENT_ID и COMPOSIO_API_KEY для каждого агента от платформы",
|
||||||
|
"context": "Composio смержен в main platform-agent, теперь обязателен",
|
||||||
|
"blocking": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "Уточнить у Азамата сроки мержа agent_api #9 (убирает attachments/MsgEventSendFile)",
|
||||||
|
"context": "Мы строим file transfer на этих фичах из master",
|
||||||
|
"blocking": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "Уточнить: chat_id=0 для всех или используем разные chat_id для C1/C2/C3",
|
||||||
|
"context": "Платформа показала пример с одним AgentApi на агента без явного chat_id",
|
||||||
|
"blocking": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decisions": [
|
||||||
|
{
|
||||||
|
"decision": "Один инстанс Matrix-бота на всех пользователей, один агент-контейнер на пользователя",
|
||||||
|
"rationale": "Подтверждено платформой. Reverse proxy на lambda.coredump.ru:7000 роутит по пути /agent_N/",
|
||||||
|
"phase": "pre-05"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"decision": "Файлы через shared volume /agents/, не через API",
|
||||||
|
"rationale": "Surface и агент видят один volume. Surface пишет файл → передаёт путь в attachments. Агент эмитит MsgEventSendFile → Surface читает файл и шлёт в Matrix",
|
||||||
|
"phase": "pre-05"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"decision": "Используем agent_api master (с attachments и MsgEventSendFile), не ветку #9",
|
||||||
|
"rationale": "master стабильный, #9 в разработке и убирает нужные нам фичи",
|
||||||
|
"phase": "pre-05"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"decision": "Конфиг: два словаря — user_id→agent_id и agent_id→{base_url, workspace_path}",
|
||||||
|
"rationale": "Платформа подтвердила статический маппинг для MVP без Master",
|
||||||
|
"phase": "pre-05"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"decision": "Master (platform-master feat/storage) не используем для MVP",
|
||||||
|
"rationale": "Ещё в разработке. Используем статический конфиг. При готовности Master — мигрируем.",
|
||||||
|
"phase": "pre-05"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"uncommitted_files": [
|
||||||
|
"docs/deploy-architecture.md",
|
||||||
|
"docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md",
|
||||||
|
"config/matrix-agents.yaml",
|
||||||
|
".planning/STATE.md"
|
||||||
|
],
|
||||||
|
"next_action": "Запустить /gsd-plan-phase 05 для планирования фазы деплоя. Прочитать docs/deploy-architecture.md перед планированием.",
|
||||||
|
"context_notes": "Phase 04 полностью завершена, ветка feat/matrix-direct-agent-prototype готова к merge. Этот сеанс был посвящён архитектуре деплоя — исследовали платформу, обсуждали с командой. Всё что знаем про деплой — в docs/deploy-architecture.md. Phase 05 = деплой: обновить конфиг, sdk/real.py, добавить file transfer в Matrix адаптер, написать docker-compose."
|
||||||
|
}
|
||||||
|
|
@ -2,44 +2,56 @@
|
||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
Surfaces (поверхности) — это тонкие адаптеры-клиенты, соединяющие мессенджеры с агентами платформы Lambda.
|
Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda. Каждый бот — тонкий адаптер поверх общего ядра (`core/`), изолирующего бизнес-логику от транспорта. Платформа подключается через `sdk/interface.py` Protocol; сейчас используется `MockPlatformClient`.
|
||||||
Текущая и главная реализация — **Matrix MVP**. Бот работает как stateless-прослойка: преобразует события Matrix во внутренний протокол `core/` и маршрутизирует их на внешние контейнеры агентов (через `AgentApi` по WebSocket).
|
|
||||||
|
|
||||||
## Core Value
|
## Core Value
|
||||||
|
|
||||||
Пользователь может бесшовно взаимодействовать с изолированными AI-агентами через нативные интерфейсы мессенджеров (с поддержкой пересылки файлов и работы в комнатах), в то время как сама платформа агентов не зависит от транспорта.
|
Пользователь может вести диалог с Lambda-агентом через любой из поддерживаемых мессенджеров без изменения ядра системы.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Validated
|
### Validated
|
||||||
|
|
||||||
- ✓ `core/` — унифицированный протокол событий, EventDispatcher, StateStore, ChatManager.
|
- ✓ core/ — унифицированный протокол событий, EventDispatcher, StateStore, ChatManager, AuthManager, SettingsManager — existing
|
||||||
- ✓ `adapter/matrix/` — Space+rooms адаптер. Прием инвайтов, автосоздание иерархии комнат, команды `!new`, `!archive`, `!clear`, `!yes`/`!no`.
|
- ✓ adapter/telegram/ — forum-first адаптер (Threaded Mode), `/start`, `/new`, `/archive`, `/rename`, `/settings`, стриминг ответов — existing, QA passed
|
||||||
- ✓ `sdk/real.py` — интеграция с AgentApi. Поддержка WebSocket для обмена сообщениями и передачи вложений в обе стороны.
|
- ✓ adapter/matrix/ — DM-first адаптер, invite flow, `!new`, `!skills`, `!soul`, `!safety`, room-per-chat — existing
|
||||||
- ✓ Shared Volume — прямая передача файлов в локальные рабочие папки агентов (`/agents/`).
|
- ✓ sdk/mock.py — MockPlatformClient: `stream_message`, `get_or_create_user`, `get_settings`, `update_settings` — existing
|
||||||
- ✓ Dynamic Routing — маршрутизация чатов к агентам на основе `config/matrix-agents.yaml`.
|
|
||||||
- ✓ Deployment — Разделение окружений на `docker-compose.prod.yml` (только бот) и `docker-compose.fullstack.yml` (бот + локальный агент для E2E).
|
|
||||||
|
|
||||||
### Out of Scope / Deferred
|
### Active
|
||||||
|
|
||||||
- E2EE для Matrix (отложено из-за сложностей сборки `python-olm` на кросс-платформенных средах).
|
- [ ] Matrix QA — ручное тестирование Matrix адаптера, фиксация багов
|
||||||
- Интеграция с Master-сервисом платформы (временно используется прямое соединение с `platform-agent` через AgentApi).
|
- [ ] SDK integration — заменить MockPlatformClient реальным Lambda SDK (когда платформа готова)
|
||||||
- Telegram-адаптер (вынесен в легаси ветку `feat/telegram-adapter`, MVP фокусируется на Matrix).
|
- [ ] Production hardening — конфиг для деплоя, логирование, мониторинг
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- E2EE для Matrix (python-olm не собирается на macOS/ARM) — инфраструктурная задача, отдельный трек
|
||||||
|
- Supergroup forum mode для Telegram — заменён Threaded Mode как основным режимом
|
||||||
|
- Telegram DM-first режим — заменён forum-first (Threaded Mode)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
- Стек: Python 3.11+, `matrix-nio`, `uv`, `pydantic`.
|
- Python 3.11+, aiogram 3.4+, matrix-nio 0.21+, SQLite, pytest-asyncio
|
||||||
- Бот хранит только локальную привязку (`room_id` <-> `platform_chat_id`) в SQLite. Вся долговременная память и история диалогов хранятся на стороне агента.
|
- Threaded Mode — Bot API 9.3, Mac клиент имеет известные баги (новые топики не сразу видны в сайдбаре)
|
||||||
- Жизненный цикл контейнеров агентов управляется платформой, а не ботом.
|
- Lambda platform SDK ещё не готов, всё работает через MockPlatformClient
|
||||||
|
- Архитектура: Hexagonal / Ports-and-Adapters; `core/` не зависит от транспорта
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Tech stack**: aiogram 3.x для Telegram, matrix-nio для Matrix — не менять без обсуждения
|
||||||
|
- **Platform**: SDK подключается только через `sdk/interface.py` Protocol — core/ и adapters не трогаются при смене реализации
|
||||||
|
- **Telegram**: Threaded Mode — единственный поддерживаемый режим; `closeForumTopic`/`deleteForumTopic` не работают в personal chat forums
|
||||||
|
- **E2EE**: python-olm не собирается на текущей среде — Matrix работает только без шифрования
|
||||||
|
|
||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
| Decision | Rationale | Outcome |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| Space+rooms для Matrix | Room-based UX и явные чаты (по одному на тред) удобнее, чем DM-каша | ✓ Good |
|
| Forum-first (Threaded Mode) для Telegram | Bot API 9.3 позволяет личный чат как форум — чище, без суперпруппы | ✓ Good |
|
||||||
| Прямая интеграция AgentApi | Master API не был готов, прямое WebSocket соединение позволяет передавать стейт и файлы | ✓ Good |
|
| (user_id, thread_id) как PK в chats | Изоляция контекстов по топику | ✓ Good |
|
||||||
| Shared Volume для файлов | Избавляет от необходимости гонять base64 по сети, быстрый прямой доступ к файлам | ✓ Good |
|
| MockPlatformClient через sdk/interface.py | Не ждать SDK, разрабатывать независимо | ✓ Good |
|
||||||
| Stateless бот | Бот легко перезапускать и масштабировать, память изолирована в агентах | ✓ Good |
|
| DM-first для Matrix (не Space-first) | Space lifecycle слишком сложен для первого этапа | ✓ Good |
|
||||||
|
| Отказ от E2EE в Matrix | python-olm не собирается на macOS/ARM | — Pending |
|
||||||
|
|
||||||
## Evolution
|
## Evolution
|
||||||
|
|
||||||
|
|
@ -49,5 +61,10 @@ Surfaces (поверхности) — это тонкие адаптеры-кл
|
||||||
3. New requirements emerged? → Add to Active
|
3. New requirements emerged? → Add to Active
|
||||||
4. Decisions to log? → Add to Key Decisions
|
4. Decisions to log? → Add to Key Decisions
|
||||||
|
|
||||||
|
**After each milestone:**
|
||||||
|
1. Full review of all sections
|
||||||
|
2. Core Value check — still the right priority?
|
||||||
|
3. Update Context with current state
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last updated: 2026-05-03 after codebase consolidation*
|
*Last updated: 2026-04-02 after initialization*
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,101 @@
|
||||||
# Roadmap — v1.0
|
# Roadmap — v1.0
|
||||||
|
|
||||||
## Milestone: v1.0 — Production-ready Matrix MVP
|
## Milestone: v1.0 — Production-ready surfaces
|
||||||
|
|
||||||
|
### Phase 1: Matrix QA & Polish
|
||||||
|
|
||||||
|
**Goal:** Переработать Matrix адаптер с DM-first на Space+rooms, убрать реакции в пользу !yes/!no, довести до уровня "приемлемо работает" как Telegram.
|
||||||
|
|
||||||
|
**Depends on:** Telegram QA complete
|
||||||
|
|
||||||
|
**Plans:** 6 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 01-01-PLAN.md — Space+rooms infrastructure (store helpers, handle_invite rewrite, room_router)
|
||||||
|
- [x] 01-02-PLAN.md — Chat command handlers (!new, !archive, !rename) Space-aware
|
||||||
|
- [x] 01-03-PLAN.md — Reaction removal + !yes/!no confirmation + settings dashboard
|
||||||
|
- [x] 01-04-PLAN.md — Test suite (fix 4 broken + 12 new MAT-01..MAT-12)
|
||||||
|
- [x] 01-05-PLAN.md — Gap closure for Matrix `!yes` / `!no` pending-confirm scope
|
||||||
|
- [x] 01-06-PLAN.md — Remaining Phase 01 gap closure work (completed 2026-04-03)
|
||||||
|
|
||||||
### Phase 01: Matrix QA & Polish
|
|
||||||
**Goal:** Переработать Matrix адаптер с DM-first на Space+rooms, убрать реакции в пользу `!yes`/`!no`.
|
|
||||||
**Status:** Completed
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- Space+rooms architecture for Matrix adapter
|
- Space+rooms architecture for Matrix adapter
|
||||||
- !yes/!no text-based confirmation
|
- !yes/!no text-based confirmation (no reactions)
|
||||||
- Test suite green
|
- Read-only !settings dashboard
|
||||||
|
- 96+ tests green
|
||||||
### Phase 04: Matrix MVP: Agent Integration
|
|
||||||
**Goal:** Подключить реального агента через `AgentApi`, добавить команды управления контекстом (`!clear`).
|
|
||||||
**Status:** Completed
|
|
||||||
**Deliverables:**
|
|
||||||
- `sdk/real.py` — реализация `PlatformClient` через реальный SDK (`AgentApi`).
|
|
||||||
- Поддержка WebSocket стриминга.
|
|
||||||
- Команды управления контекстом.
|
|
||||||
- Обертка в Docker.
|
|
||||||
|
|
||||||
### Phase 05: MVP Deployment
|
|
||||||
**Goal:** Подготовить Matrix-бот к реальному деплою на lambda.coredump.ru с маршрутизацией по агентам и передачей файлов.
|
|
||||||
**Status:** Completed
|
|
||||||
**Deliverables:**
|
|
||||||
- Загрузка `matrix-agents.yaml` для маппинга пользователей к агентам.
|
|
||||||
- Per-room `platform_chat_id` routing.
|
|
||||||
- File transfer через shared `/agents/` volume.
|
|
||||||
- Разделение `docker-compose.prod.yml` и `docker-compose.fullstack.yml`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*Note: Легаси-фазы, связанные с Telegram, прототипами и Mock-платформой, были удалены из Roadmap после закрепления архитектуры MVP в ветке main.*
|
|
||||||
|
### Phase 01.1: Matrix restart reconciliation and dev reset workflow (INSERTED)
|
||||||
|
|
||||||
|
**Goal:** Сделать Matrix-адаптер пригодным для повторяемого локального рестарта и ручного QA: бот восстанавливает минимальный local state из существующих Space/rooms и даёт явный dev reset workflow вместо ручного ritual reset.
|
||||||
|
**Requirements**: none explicitly mapped
|
||||||
|
**Depends on:** Phase 1
|
||||||
|
**Plans:** 3 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 01.1-01-PLAN.md — Non-destructive Matrix reconciliation module and tests
|
||||||
|
- [ ] 01.1-02-PLAN.md — Wire startup/bootstrap recovery into the Matrix runtime
|
||||||
|
- [ ] 01.1-03-PLAN.md — Dev reset CLI and updated Matrix restart runbook
|
||||||
|
|
||||||
|
### Phase 2: SDK Integration
|
||||||
|
|
||||||
|
**Goal:** Заменить MockPlatformClient реальным Lambda SDK — бот начинает работать с настоящим AI-агентом.
|
||||||
|
|
||||||
|
**Depends on:** Phase 1, Lambda platform SDK готов
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- `sdk/real.py` — реализация PlatformClient через реальный SDK
|
||||||
|
- `bot.py` для обоих адаптеров переключается на реальный клиент через env var
|
||||||
|
- `stream_message` работает с реальным стримингом
|
||||||
|
- Интеграционные тесты с реальным SDK (или staging)
|
||||||
|
|
||||||
|
### Phase 4: Matrix MVP: shared agent context and context management commands
|
||||||
|
|
||||||
|
**Goal:** Привести Matrix-бот к рабочему состоянию для MVP-деплоя: заменить AgentSessionClient на AgentApi, добавить !save/!load/!reset/!context команды управления контекстом агента, упаковать в Docker.
|
||||||
|
**Requirements**: Replace AgentSessionClient with AgentApi; Wire AgentApi lifecycle; Implement !save, !load, !reset, !context commands; Dockerfile + docker-compose
|
||||||
|
**Depends on:** Phase 1 (Matrix adapter complete)
|
||||||
|
**Plans:** 3 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 04-01-PLAN.md — Replace AgentSessionClient with AgentApi; update sdk/real.py, bot.py, broken tests
|
||||||
|
- [x] 04-02-PLAN.md — !save, !load, !reset, !context handlers; PrototypeStateStore extensions; numeric interception
|
||||||
|
- [x] 04-03-PLAN.md — Dockerfile + docker-compose.yml + .env.example update
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 05: MVP Deployment
|
||||||
|
|
||||||
|
**Goal:** Подготовить Matrix-бот к реальному деплою на lambda.coredump.ru без потери Space+rooms UX: закрепить per-room `platform_chat_id`, реальный `!clear`, reconciliation, file transfer через shared volume и разделение prod/fullstack compose.
|
||||||
|
|
||||||
|
**Depends on:** Phase 4
|
||||||
|
|
||||||
|
**Plans:** 4/4 plans complete
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 05-01-PLAN.md — Startup reconciliation from authoritative Matrix Space topology before live sync
|
||||||
|
- [x] 05-02-PLAN.md — Room-local `platform_chat_id` routing and real `!clear` semantics
|
||||||
|
- [x] 05-03-PLAN.md — Shared-volume attachment path hardening for `/agents` deployment
|
||||||
|
- [x] 05-04-PLAN.md — Split bot-only prod compose from internal fullstack compose and update docs
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- Space+rooms onboarding remains the primary Matrix UX
|
||||||
|
- Per-room `platform_chat_id` provides true context isolation and `!clear`
|
||||||
|
- Reconciliation restores room metadata and routing after restart
|
||||||
|
- File transfer uses shared `/agents/` volume with room-safe behavior
|
||||||
|
- `docker-compose.prod.yml` is bot-only handoff; `docker-compose.fullstack.yml` is for internal E2E testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Production Hardening
|
||||||
|
|
||||||
|
**Goal:** Подготовить боты к реальному деплою — конфиг, логирование, мониторинг, обработка ошибок.
|
||||||
|
|
||||||
|
**Depends on:** Phase 2
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- Docker / systemd конфиг для деплоя
|
||||||
|
- Структурированное логирование в production формате
|
||||||
|
- Health-check endpoint (если нужен)
|
||||||
|
- Rate limiting и защита от спама
|
||||||
|
- Graceful shutdown
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: — Production-ready surfaces
|
milestone_name: — Production-ready surfaces
|
||||||
status: MVP Deployed
|
status: Phase 05 Complete
|
||||||
last_updated: "2026-05-03T23:00:00Z"
|
last_updated: "2026-04-27T22:17:10.233Z"
|
||||||
progress:
|
progress:
|
||||||
total_phases: 3
|
total_phases: 6
|
||||||
completed_phases: 3
|
completed_phases: 3
|
||||||
total_plans: 13
|
total_plans: 16
|
||||||
completed_plans: 13
|
completed_plans: 13
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -15,35 +15,112 @@ progress:
|
||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
|
|
||||||
See: `.planning/PROJECT.md` (updated 2026-05-03)
|
See: .planning/PROJECT.md (updated 2026-04-02)
|
||||||
|
|
||||||
**Core value:** Пользователь бесшовно взаимодействует с изолированными агентами через нативные интерфейсы (Matrix), в то время как платформа агентов не зависит от транспорта.
|
**Core value:** Пользователь ведёт диалог с Lambda через любой мессенджер без изменения ядра
|
||||||
**Current focus:** Итерационное развитие текущей архитектуры, добавление новых фич и поверхностей (по мере необходимости).
|
**Current focus:** Phase 05 complete — MVP deployment handoff is ready
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
|
|
||||||
Текущий MVP успешно завершен. Все базовые механизмы внедрены и работают:
|
**Phase 05** complete: MVP deployment hardening
|
||||||
- Маршрутизация к `AgentApi`
|
|
||||||
- Shared Volume файловый обмен (`/agents/`)
|
|
||||||
- Dynamic config через `matrix-agents.yaml`
|
|
||||||
- Изоляция контекстов через `platform_chat_id`
|
|
||||||
|
|
||||||
Проект находится в чистом состоянии для начала нового планирования. Неактуальные легаси фазы и прототипы (Telegram, MockPlatformClient) удалены из Roadmap и трекинга.
|
Plan `05-01` is complete. Matrix startup now reconciles managed Space rooms from synced topology before live traffic, restoring local metadata and deterministic legacy `platform_chat_id` bindings on restart.
|
||||||
|
|
||||||
|
- `a75b26a` — failing restart reconciliation regressions for recovery, idempotence, startup ordering, and legacy backfill
|
||||||
|
- `8a80d00` — startup reconciliation module and pre-sync wiring in the Matrix runtime
|
||||||
|
|
||||||
|
Verified with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND='' UV_CACHE_DIR=/tmp/uv-cache-surfaces uv run pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v`.
|
||||||
|
|
||||||
|
Plan `05-02` is complete. Matrix room-local context commands now rely on repaired per-room `platform_chat_id` bindings, and `!clear` rotates only the active room's upstream context when prototype room state is available.
|
||||||
|
|
||||||
|
- `ae37476` — failing regressions for clear registration, room-local rotation, and strict routed-platform metadata requirements
|
||||||
|
- `85e2fda` — room-local clear semantics, compatibility alias wiring, and strict context resolution without shared chat fallbacks
|
||||||
|
|
||||||
|
Verified with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND='' UV_CACHE_DIR=/tmp/uv-cache-surfaces uv run pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v`.
|
||||||
|
|
||||||
|
Plan `05-03` is complete. Shared-volume attachment handling now preserves relative agent paths while tolerating both `/workspace` and `/agents` absolute prefixes during normalization and Matrix file rendering.
|
||||||
|
|
||||||
|
- `7a12a71` — failing regressions for shared-volume path normalization and room-safe attachment handling
|
||||||
|
- `5eddf16` — `/agents` deployment path hardening for Matrix files and routed platform attachments
|
||||||
|
|
||||||
|
Verified with `uv run pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v`.
|
||||||
|
|
||||||
|
Plan `05-04` is complete. Production handoff now uses `docker-compose.prod.yml` for a bot-only runtime, while internal end-to-end verification uses `docker-compose.fullstack.yml` with shared `/agents` volume guidance and health-gated startup.
|
||||||
|
|
||||||
|
- `df6d8bf` — split prod and full-stack compose artifacts with the shared `/agents` contract
|
||||||
|
- `22a3a2b` — operator and deployment docs aligned to the split compose artifacts
|
||||||
|
|
||||||
|
Verified with `docker compose -f docker-compose.prod.yml config`, `docker compose -f docker-compose.fullstack.yml config`, and docs grep checks for `docker-compose.prod.yml`, `docker-compose.fullstack.yml`, and `/agents`.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
- **Space+rooms для Matrix**: Разделение тредов на отдельные Matrix-комнаты, собранные в едином Space пользователя.
|
- Продолжаем с Threaded Mode несмотря на баги Mac клиента (2026-04-02)
|
||||||
- **AgentApi**: Прямая интеграция с локальным агентом без Master-прослойки по WebSocket.
|
- Invite flow Matrix переведён на idempotent-проверку через `user_meta.space_id`, а не через invite-room metadata (2026-04-02)
|
||||||
- **Shared Volume**: Файлы кладутся напрямую в рабочую папку агента, избавляя от необходимости гонять их по сети в Base64.
|
- Неизвестные Matrix rooms больше не auto-register в роутере; используется явный fallback `unregistered:{room_id}` с warning-логом (2026-04-02)
|
||||||
- **Статическая маршрутизация**: На данном этапе пользователи маппятся на агентов жестко через YAML.
|
- [Phase 01]: Use ChatContext.surface_ref as the Matrix room identifier for !rename updates.
|
||||||
|
- [Phase 01]: Keep !archive limited to core archive state in Phase 1; Space child removal remains deferred.
|
||||||
|
- [Phase 01]: Matrix OutgoingUI no longer emits reactions; confirmation state is persisted and resumed via `!yes` / `!no`.
|
||||||
|
- [Phase 01]: `!settings` now renders a dashboard snapshot instead of advertising mutable subcommands.
|
||||||
|
- [Phase 01]: Split Matrix regression coverage into dedicated invite/chat/send_outgoing/confirm test modules.
|
||||||
|
- [Phase 01]: Kept 01-04 scoped to test coverage without widening into production-code changes.
|
||||||
|
- [Phase 01]: Matrix command callbacks now include room_id in payload for !yes and !no so confirm handlers can resolve runtime state without changing core protocol types.
|
||||||
|
- [Phase 01]: Pending confirmations are stored under the D-08 composite key of matrix user id plus room id, with a narrow legacy fallback only for callers that omit room context.
|
||||||
|
- [Phase 01]: Removed Matrix reaction conversion entirely and kept command callbacks limited to !yes/!no.
|
||||||
|
- [Phase 01]: Kept !settings as a pure snapshot surface while preserving mutable subcommands outside the dashboard.
|
||||||
|
- [Phase 01]: Seeded invite and dispatcher tests with explicit next_chat_index and room ids instead of treating C1 as Matrix transport identity.
|
||||||
|
- [Phase 04]: Replaced AgentSessionClient with AgentApiWrapper and persistent agent connection lifecycle in Matrix runtime.
|
||||||
|
- [Phase 04]: Added !save, !load, !reset, and !context commands with pending-state interception and local prototype session metadata.
|
||||||
|
- [Phase 04]: Added Matrix-only Docker packaging for MVP deployment; platform services remain external to this compose setup.
|
||||||
|
- [Phase 04]: Replaced the Matrix prod path again with direct upstream `AgentApi` per request; removed the local runtime wrapper from the prod flow.
|
||||||
|
- [Phase 04]: Adopted `AGENT_BASE_URL` as the primary runtime contract and kept `AGENT_WS_URL` only as backward-compatible env fallback.
|
||||||
|
- [Phase 04 follow-up]: Kept shared PlatformClient unchanged; introduced Matrix-specific RoutedPlatformClient to avoid breaking Telegram adapter.
|
||||||
|
- [Phase 04 follow-up]: agent_routing_enabled flag on MatrixRuntime activates stale-room check only in real multi-agent mode (RoutedPlatformClient).
|
||||||
|
- [Phase 04 follow-up]: !new binds agent_id at room creation time using selected_agent_id from user metadata.
|
||||||
|
- [Phase 04 follow-up]: platform_chat_seq (PLATFORM_CHAT_SEQ_KEY) is stored in SQLiteStore and survives restart — confirmed by test.
|
||||||
|
- [Phase 05 reset]: Discard the single-chat / DM-first deployment direction. Replan around Space+rooms, per-room `platform_chat_id`, real `!clear`, reconciliation, and split prod/fullstack compose artifacts.
|
||||||
|
- [Phase 05]: Keep adapter/matrix/files.py as the sole path builder; sdk/real.py only normalizes shared-volume attachment references.
|
||||||
|
- [Phase 05]: Normalize /workspace and /agents absolute file paths back to relative workspace_path values before agent transport and Matrix file rendering.
|
||||||
|
- [Phase 05]: Treat synced Matrix topology as authoritative for startup recovery; keep SQLite rebuildable.
|
||||||
|
- [Phase 05]: Backfill missing platform_chat_id values during startup reconciliation before routed handling begins.
|
||||||
|
- [Phase 05]: Expose `clear` only when prototype room-context support is available, while keeping `reset` as a compatibility alias.
|
||||||
|
- [Phase 05]: Require recovered `platform_chat_id` for save/context/clear flows instead of falling back to shared local chat ids.
|
||||||
|
- [Phase 05]: Split Compose artifacts by runtime intent: bot-only prod handoff vs internal full-stack verification.
|
||||||
|
- [Phase 05]: Document /agents as the bot-side shared volume root while internal platform-agent keeps /workspace on the same named volume.
|
||||||
|
|
||||||
## Blockers
|
## Blockers
|
||||||
|
|
||||||
- Отсутствуют. Проект готов к деплою (см. `docker-compose.prod.yml`).
|
- Lambda platform SDK не готов — Phase 2 заблокирована до готовности платформы
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
### Roadmap Evolution
|
### Roadmap Evolution
|
||||||
|
|
||||||
- Изначальный Roadmap включал множество ответвлений (прототипы Telegram, локальный mock-клиент). После закрепления MVP в `main` Roadmap был очищен, чтобы отражать только актуальный путь продукта.
|
- Phase 01.1 inserted after Phase 01: Matrix restart reconciliation and dev reset workflow (URGENT)
|
||||||
- Следующие фазы будут добавляться по мере возникновения новых задач (например, переход от YAML-конфига к БД для реестра агентов).
|
- Phase 4 added: Matrix MVP: shared agent context and context management command
|
||||||
|
- Phase 04 follow-up added inline: multi-agent routing (RoutedPlatformClient, !agent, stale room blocking, restart persistence)
|
||||||
|
- Phase 05 reset on 2026-04-28: erroneous single-chat deployment artifacts were removed before fresh planning.
|
||||||
|
|
||||||
|
## Performance Metrics
|
||||||
|
|
||||||
|
| Phase | Plan | Duration | Tasks | Files | Recorded |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 01 | 01 | 1 min | 3 | 3 | 2026-04-02T19:50:50Z |
|
||||||
|
| 01 | 02 | 1 min | 2 | 2 | 2026-04-02 |
|
||||||
|
| 01 | 03 | 3 min | 2 | 5 | 2026-04-02T19:57:34Z |
|
||||||
|
| 01 | 04 | 3 min | 2 | 7 | 2026-04-02T20:03:38Z |
|
||||||
|
| 01 | 05 | 2 min | 2 | 7 | 2026-04-03T09:28:47Z |
|
||||||
|
| 01 | 06 | 4 min | 2 | 7 | 2026-04-03T09:35:47Z |
|
||||||
|
| 04 | 01 | 1 session | 1 wave | 8 | 2026-04-17 |
|
||||||
|
| 04 | 02 | 1 session | 2 commits + summary | 8 | 2026-04-17 |
|
||||||
|
| 04 | 03 | 1 session | 1 commit + summary | 4 | 2026-04-17 |
|
||||||
|
| 04 | follow-up | 1 session | 5 tasks | 10+ | 2026-04-24 |
|
||||||
|
| 05 | 03 | 3 min | 2 | 3 | 2026-04-27T22:06:43Z |
|
||||||
|
| 05 | 01 | 8 min | 2 | 4 | 2026-04-27T22:09:28Z |
|
||||||
|
| 05 | 02 | 16 min | 2 | 4 | 2026-04-27T22:15:58Z |
|
||||||
|
| 05 | 04 | 3 min | 2 | 5 | 2026-04-27T22:17:10Z |
|
||||||
|
|
||||||
|
## Session
|
||||||
|
|
||||||
|
- Last session: 2026-04-27T22:17:10Z
|
||||||
|
- Stopped at: Completed 05-04-PLAN.md
|
||||||
|
- Resume file: .planning/phases/05-mvp-deployment/.continue-here.md
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,134 @@
|
||||||
# Архитектура (ARCHITECTURE.md)
|
# Architecture
|
||||||
|
|
||||||
## Паттерн "Thin Adapter" (Тонкая поверхность)
|
**Analysis Date:** 2026-04-01
|
||||||
|
|
||||||
Система разделена на три логических слоя:
|
## Pattern Overview
|
||||||
1. **Транспортный слой (Adapter)**: Подключается к внешней платформе (Matrix). Занимается конвертацией нативных событий (`room.message`) во внутренние структуры (`IncomingMessage`).
|
|
||||||
2. **Ядро (Core)**: Предоставляет единый протокол (`core/protocol.py`), не зависящий от конкретной реализации (Matrix, Telegram и т.д.).
|
|
||||||
3. **Платформенный слой (SDK)**: `RealPlatformClient` инкапсулирует подключение по WebSocket к реальным агентам (AgentApi).
|
|
||||||
|
|
||||||
## Routing & Registry
|
**Overall:** Hexagonal / Ports-and-Adapters
|
||||||
Бот может обслуживать множество агентов (multi-tenant). Маршрутизация настраивается статически через `config/matrix-agents.yaml`. Каждый пользователь (`@user:server`) привязан к конкретному `agent_id`, у которого есть свой HTTP URL и свой изолированный `workspace_path` (например, `/agents/1/`).
|
|
||||||
|
|
||||||
## Файловый контракт
|
**Key Characteristics:**
|
||||||
Файлы не передаются агенту в base64. Бот сохраняет вложение напрямую в локальную директорию (общий volume), и передает агенту только относительный путь (`workspace_path`).
|
- A platform-neutral `core/` defines all business logic and unified event types
|
||||||
|
- Adapters (`adapter/telegram/`, `adapter/matrix/`) translate platform-specific events into core types and back
|
||||||
|
- The AI platform SDK is hidden behind a `PlatformClient` Protocol; the current implementation (`sdk/mock.py`) is swappable without touching core or adapters
|
||||||
|
- All state is stored through a `StateStore` Protocol, with `InMemoryStore` for tests and `SQLiteStore` for production
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
**Protocol Layer:**
|
||||||
|
- Purpose: Defines every data structure crossing layer boundaries
|
||||||
|
- Location: `core/protocol.py`
|
||||||
|
- Contains: `IncomingMessage`, `IncomingCommand`, `IncomingCallback`, `OutgoingMessage`, `OutgoingUI`, `OutgoingNotification`, `OutgoingTyping`, `ChatContext`, `AuthFlow`, `SettingsAction`, type aliases `IncomingEvent` and `OutgoingEvent`
|
||||||
|
- Depends on: Python stdlib only
|
||||||
|
- Used by: All other layers
|
||||||
|
|
||||||
|
**Core / Business Logic Layer:**
|
||||||
|
- Purpose: Handles all domain logic independent of any platform
|
||||||
|
- Location: `core/`
|
||||||
|
- Contains:
|
||||||
|
- `core/handler.py` — `EventDispatcher`: routes `IncomingEvent` to registered handler functions; returns `list[OutgoingEvent]`
|
||||||
|
- `core/handlers/` — one module per event category (`start`, `message`, `chat`, `settings`, `callback`)
|
||||||
|
- `core/store.py` — `StateStore` Protocol + `InMemoryStore` + `SQLiteStore`
|
||||||
|
- `core/chat.py` — `ChatManager`: creates/renames/archives chat workspaces (C1/C2/C3); persists via `StateStore`
|
||||||
|
- `core/auth.py` — `AuthManager`: tracks auth flow state (`pending` → `confirmed`); persists via `StateStore`
|
||||||
|
- `core/settings.py` — `SettingsManager`: fetches/caches user settings from SDK; invalidates on write
|
||||||
|
- Depends on: `core/protocol.py`, `sdk/interface.py` (Protocol only), `core/store.py`
|
||||||
|
- Used by: Adapters
|
||||||
|
|
||||||
|
**SDK / Platform Layer:**
|
||||||
|
- Purpose: Wraps the external Lambda AI platform; isolated behind a Protocol
|
||||||
|
- Location: `sdk/`
|
||||||
|
- Contains:
|
||||||
|
- `sdk/interface.py` — `PlatformClient` Protocol: `get_or_create_user`, `send_message`, `stream_message`, `get_settings`, `update_settings`; also `WebhookReceiver` Protocol, Pydantic models (`User`, `MessageResponse`, `MessageChunk`, `UserSettings`, `AgentEvent`)
|
||||||
|
- `sdk/mock.py` — `MockPlatformClient`: full in-memory implementation with simulated latency; supports both sync (`send_message`) and streaming (`stream_message`, currently returns single chunk); includes webhook simulation via `simulate_agent_event()`
|
||||||
|
- Depends on: `sdk/interface.py`
|
||||||
|
- Used by: `core/` managers, adapters during bot startup
|
||||||
|
|
||||||
|
**Adapter Layer:**
|
||||||
|
- Purpose: Translates platform-native events into `IncomingEvent` and `OutgoingEvent` back to platform-native calls
|
||||||
|
- Location: `adapter/matrix/`, adapter/telegram/ (in `.worktrees/telegram/`)
|
||||||
|
- Contains per adapter: `bot.py` (entry point + send logic), `converter.py` (native event → protocol), `handlers/` (adapter-specific handler overrides registered on top of core handlers), optional `store.py` / `room_router.py` / `reactions.py` for adapter state
|
||||||
|
- Depends on: `core/`, `sdk/`, platform SDK (aiogram or matrix-nio)
|
||||||
|
- Used by: `__main__` / `asyncio.run(main())`
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
**Incoming Message (Matrix example):**
|
||||||
|
|
||||||
|
1. `matrix-nio` fires `RoomMessageText` callback → `MatrixBot.on_room_message()` in `adapter/matrix/bot.py`
|
||||||
|
2. `resolve_chat_id()` in `adapter/matrix/room_router.py` maps `room_id` → logical `chat_id` (e.g. `C1`), persisted in `StateStore`
|
||||||
|
3. `from_room_event()` in `adapter/matrix/converter.py` converts the nio event to `IncomingMessage` or `IncomingCommand`
|
||||||
|
4. `EventDispatcher.dispatch(incoming)` in `core/handler.py` selects the handler by routing key (command name, callback action, or `"*"` for messages)
|
||||||
|
5. Handler (e.g. `core/handlers/message.py:handle_message`) calls `platform.send_message()` on `MockPlatformClient`, receives `MessageResponse`
|
||||||
|
6. Handler returns `list[OutgoingEvent]` (e.g. `[OutgoingTyping(..., False), OutgoingMessage(...)]`)
|
||||||
|
7. `MatrixBot._send_all()` iterates the list; `send_outgoing()` converts each to a `client.room_send()` / `client.room_typing()` call
|
||||||
|
|
||||||
|
**Incoming Reaction (Matrix):**
|
||||||
|
|
||||||
|
1. `ReactionEvent` callback → `MatrixBot.on_reaction()`
|
||||||
|
2. `from_reaction()` maps emoji key to `IncomingCallback` with `action="confirm"`, `"cancel"`, or `"toggle_skill"`
|
||||||
|
3. Dispatch → `core/handlers/callback.py`
|
||||||
|
|
||||||
|
**Command Routing:**
|
||||||
|
|
||||||
|
The `EventDispatcher` uses a routing key per event type:
|
||||||
|
- `IncomingCommand` → `event.command` (e.g. `"start"`, `"new"`, `"settings"`)
|
||||||
|
- `IncomingCallback` → `event.action` (e.g. `"confirm"`, `"toggle_skill"`)
|
||||||
|
- `IncomingMessage` → `"*"` (catch-all), or `event.attachments[0].type` if attachments present
|
||||||
|
|
||||||
|
Adapters call `register_all(dispatcher)` first (core handlers), then `register_matrix_handlers(dispatcher, ...)` to override or add platform-specific variants (e.g. `!new` creates a real Matrix room via the nio client).
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- All persistent state goes through `StateStore` (key-value, async interface)
|
||||||
|
- Key namespaces: `chat:{user_id}:{chat_id}`, `auth:{user_id}`, `settings:{user_id}`, `matrix_room:{room_id}`, `matrix_user:{matrix_user_id}`, `matrix_state:{room_id}`, `matrix_skills_msg:{room_id}`
|
||||||
|
- Production uses `SQLiteStore` (row-per-key, JSON-serialised values); tests use `InMemoryStore`
|
||||||
|
|
||||||
|
## Key Abstractions
|
||||||
|
|
||||||
|
**EventDispatcher (`core/handler.py`):**
|
||||||
|
- Purpose: Single dispatch table for all event types; decouples handler logic from transport
|
||||||
|
- Pattern: Registry (map of `event_type → {key → HandlerFn}`); wildcard `"*"` as fallback
|
||||||
|
- Handler signature: `async def handler(event, chat_mgr, auth_mgr, settings_mgr, platform) → list[OutgoingEvent]`
|
||||||
|
|
||||||
|
**StateStore Protocol (`core/store.py`):**
|
||||||
|
- Purpose: Pluggable persistence behind a minimal `get/set/delete/keys` interface
|
||||||
|
- Implementations: `InMemoryStore` (tests/dev), `SQLiteStore` (production)
|
||||||
|
- Key pattern: `"{namespace}:{discriminator}"`
|
||||||
|
|
||||||
|
**PlatformClient Protocol (`sdk/interface.py`):**
|
||||||
|
- Purpose: Contracts the entire surface of the Lambda AI SDK
|
||||||
|
- Current implementation: `MockPlatformClient` in `sdk/mock.py`
|
||||||
|
- Swap path: Replace `sdk/mock.py` with a real SDK client; no changes needed elsewhere
|
||||||
|
|
||||||
|
**Converter functions (`adapter/matrix/converter.py`):**
|
||||||
|
- Purpose: One-way transformation from platform-native event to `IncomingEvent`
|
||||||
|
- Always produce canonical protocol types; adapters never pass raw library objects to core
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
**Matrix Bot:**
|
||||||
|
- Location: `adapter/matrix/bot.py:main()`
|
||||||
|
- Run: `python -m adapter.matrix.bot`
|
||||||
|
- Startup sequence: load `.env` → build `AsyncClient` → `build_runtime()` → register callbacks → `client.sync_forever()`
|
||||||
|
|
||||||
|
**Telegram Bot:**
|
||||||
|
- Location: `.worktrees/telegram/adapter/telegram/bot.py` (feature branch, not merged to main yet)
|
||||||
|
- Run: `python -m adapter.telegram.bot`
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Strategy:** Errors propagate up to the adapter's event callback. The adapter logs and drops the event; the bot keeps running.
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- `EventDispatcher.dispatch()` returns `[]` (empty list) when no handler is found and logs a warning
|
||||||
|
- `AuthManager` and `ChatManager` raise `ValueError` for not-found entities; callers are responsible for catching
|
||||||
|
- `MockPlatformClient` raises `PlatformError` (defined in `sdk/interface.py`) on unexpected states
|
||||||
|
|
||||||
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Logging:** `structlog` throughout; all managers and the dispatcher use `structlog.get_logger(__name__)`
|
||||||
|
**Validation:** Pydantic models in `sdk/interface.py` for SDK responses; plain dataclasses in `core/protocol.py` for internal events
|
||||||
|
**Authentication:** `AuthManager.is_authenticated()` is checked in `handle_message` before forwarding to platform; unauthenticated users receive a prompt to run `!start` / `/start`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Architecture analysis: 2026-04-01*
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,235 @@
|
||||||
# Известные проблемы (CONCERNS.md)
|
# Codebase Concerns
|
||||||
|
|
||||||
- **Отсутствие E2E шифрования в Matrix**: На данный момент бот не поддерживает зашифрованные комнаты, так как библиотека `matrix-nio` требует нативной сборки `python-olm`, что усложняет кросс-платформенный деплой.
|
**Analysis Date:** 2026-04-01
|
||||||
- **Потеря стейта агентов**: Так как текущий `platform-agent` часто работает с `MemorySaver`, его стейт теряется при перезапусках. Это проблема внешнего агента, но она напрямую влияет на UX поверхности.
|
|
||||||
- **Общий том (Shared Volume)**: Контракт обязывает бота и агента запускаться на одном физическом хосте (или иметь распределенный сетевой диск), что может стать бутылочным горлышком при сильном масштабировании.
|
---
|
||||||
- **Hardcoded роутинг**: `matrix-agents.yaml` требует ручного редактирования и перезапуска бота при добавлении новых агентов. Желательно вынести этот процесс в динамическую БД или API.
|
|
||||||
|
## Tech Debt
|
||||||
|
|
||||||
|
### Telegram adapter not merged to main
|
||||||
|
|
||||||
|
- Issue: The entire `adapter/telegram/` directory exists only in the `feat/telegram-adapter` branch (worktree at `.worktrees/telegram/`). `main` has no Telegram adapter at all.
|
||||||
|
- Files: `.worktrees/telegram/adapter/telegram/` and remote branch `origin/feat/telegram-adapter`
|
||||||
|
- Impact: Running `python -m adapter.telegram.bot` from `main` fails with ImportError. Tests referencing `adapter/telegram/` (e.g., `tests/adapter/test_forum_db.py`) only exist in the worktree and are absent from `main`.
|
||||||
|
- Fix approach: Merge `feat/telegram-adapter` into `main` after final manual QA pass. The branch is ahead of main by 5 commits (`a1b7a14` being the most recent).
|
||||||
|
|
||||||
|
### Divergent core/handlers between main and feat/telegram-adapter
|
||||||
|
|
||||||
|
- Issue: `feat/telegram-adapter` removed platform-awareness from `core/handlers/chat.py` and `core/handlers/message.py` — the `_command()` and `_start_command()` helpers that format Matrix `!cmd` vs Telegram `/cmd` prompts were deleted. The branch hardcodes `/start` everywhere.
|
||||||
|
- Files: `core/handlers/chat.py`, `core/handlers/message.py` (differ between branches)
|
||||||
|
- Impact: If the Matrix adapter relies on these platform-aware helpers being in `main`'s version of core, merging `feat/telegram-adapter` will break Matrix `!start` prompt text for unauthenticated users.
|
||||||
|
- Fix approach: Before merging, decide which version of `core/handlers/` is canonical. The Matrix adapter in `main` currently passes because `main` still has the platform-aware helpers.
|
||||||
|
|
||||||
|
### SQLiteStore uses blocking I/O in async context
|
||||||
|
|
||||||
|
- Issue: `core/store.py` `SQLiteStore` methods are declared `async` but perform synchronous blocking `sqlite3.connect()` calls without `asyncio.to_thread` or `aiosqlite`.
|
||||||
|
- Files: `core/store.py` lines 46–73
|
||||||
|
- Impact: Each database call blocks the asyncio event loop. Under any concurrent load (e.g., two Matrix users sending messages simultaneously) this will cause visible latency spikes and potential event loop starvation.
|
||||||
|
- Fix approach: Replace `sqlite3` calls with `aiosqlite` or wrap each call in `asyncio.to_thread()`.
|
||||||
|
|
||||||
|
### Telegram adapter has its own separate SQLite database layer
|
||||||
|
|
||||||
|
- Issue: `adapter/telegram/db.py` is a fully independent SQLite database (file: `lambda_bot.db`) with its own schema (`tg_users`, `chats`). Meanwhile, `core/store.py` has `SQLiteStore` with a KV schema (`lambda_matrix.db` for Matrix). The two stores are incompatible and do not share data.
|
||||||
|
- Files: `.worktrees/telegram/adapter/telegram/db.py`, `core/store.py`
|
||||||
|
- Impact: There is no unified storage layer. Chat state is split across two databases. A user's Telegram chats cannot be seen from Matrix and vice versa (even conceptually). Violates the "single core" architecture principle from CLAUDE.md.
|
||||||
|
- Fix approach: This is a fundamental design gap. Either extend `StateStore` to support the Telegram-specific data model, or accept separate stores as intentional for the prototype stage and document the constraint.
|
||||||
|
|
||||||
|
### MockPlatformClient hardcoded throughout — no production path wired
|
||||||
|
|
||||||
|
- Issue: Both `adapter/matrix/bot.py` and `.worktrees/telegram/adapter/telegram/bot.py` instantiate `MockPlatformClient()` directly. `PLATFORM_MODE` is defined in `.env.example` but is never read or acted upon anywhere in the codebase.
|
||||||
|
- Files: `adapter/matrix/bot.py` line 71, `sdk/mock.py`, `sdk/interface.py`
|
||||||
|
- Impact: There is no runtime switch to connect a real SDK. Switching to production requires code changes, not configuration.
|
||||||
|
- Fix approach: Add a factory function in `sdk/` that reads `PLATFORM_MODE` and returns either `MockPlatformClient` or a real `PlatformClient`. Both bot entrypoints should use this factory.
|
||||||
|
|
||||||
|
### MatrixRuntime type annotation leaks MockPlatformClient
|
||||||
|
|
||||||
|
- Issue: `adapter/matrix/bot.py` `MatrixRuntime.platform` is typed as `MockPlatformClient` (not `PlatformClient`). `build_event_dispatcher` and `build_runtime` signatures also use `MockPlatformClient` as the parameter type.
|
||||||
|
- Files: `adapter/matrix/bot.py` lines 46, 54, 67
|
||||||
|
- Impact: The isolation promise ("replace only `sdk/mock.py` when real SDK arrives") is broken — the bot layer is coupled to the mock concrete type, not the Protocol.
|
||||||
|
- Fix approach: Change type annotations to `PlatformClient` from `sdk.interface`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Bugs / Open Issues
|
||||||
|
|
||||||
|
### Telegram forum: global commands visible inside topic context
|
||||||
|
|
||||||
|
- Issue: Telegram shows the full bot command menu (including `/chats`, `/new`, `/settings`) even when the user is inside a forum topic. The code blocks `switch` and `new_chat` callbacks inside topics but the commands themselves still appear in the UI.
|
||||||
|
- Files: `.worktrees/telegram/adapter/telegram/handlers/forum.py`, `.worktrees/telegram/adapter/telegram/bot.py`
|
||||||
|
- Impact: Users can tap `/settings` or `/chats` inside a topic and get confusing behavior.
|
||||||
|
- Tracked: Issue `#15` — `Telegram forum topics: remaining UX and synchronization gaps`
|
||||||
|
|
||||||
|
### Telegram forum: `/new <name>` inside linked topic does not rename the Telegram topic
|
||||||
|
|
||||||
|
- Issue: Running `/new <name>` inside a forum topic that is already linked to a chat renames the internal chat record but does not call `edit_forum_topic` to rename the actual Telegram topic.
|
||||||
|
- Files: `.worktrees/telegram/adapter/telegram/handlers/forum.py`
|
||||||
|
- Impact: Topic name in Telegram goes out of sync with internal chat name.
|
||||||
|
- Tracked: Issue `#15`
|
||||||
|
|
||||||
|
### Matrix: `handle_invite` hardcodes `chat_id = "C1"` for all new rooms
|
||||||
|
|
||||||
|
- Issue: `adapter/matrix/handlers/auth.py` `handle_invite()` always assigns `chat_id = "C1"` regardless of how many rooms the user already has. If a user invites the bot into a second room before using `!new`, both rooms get `C1`.
|
||||||
|
- Files: `adapter/matrix/handlers/auth.py` line 26
|
||||||
|
- Impact: Two rooms mapped to the same `chat_id` causes routing collisions.
|
||||||
|
- Fix approach: Call `next_chat_id(store, user_id)` here instead of hardcoding `"C1"`.
|
||||||
|
|
||||||
|
### Matrix: `remove_reaction` uses non-standard `undo` field
|
||||||
|
|
||||||
|
- Issue: `adapter/matrix/reactions.py` `remove_reaction()` sends a `"undo": True` field in the reaction event body. This is not part of the Matrix spec for reaction redaction. The correct approach is to redact the original reaction event via `client.room_redact()`.
|
||||||
|
- Files: `adapter/matrix/reactions.py` lines 56–68
|
||||||
|
- Impact: Reaction "undo" will silently fail on compliant homeservers.
|
||||||
|
|
||||||
|
### Matrix: E2EE not supported (blocked by `python-olm`)
|
||||||
|
|
||||||
|
- Issue: `matrix-nio` E2EE requires `python-olm`, which fails to build on macOS/ARM. No encrypted DM support.
|
||||||
|
- Files: `adapter/matrix/bot.py`
|
||||||
|
- Impact: The bot cannot operate in encrypted rooms. Users who have DM encryption enforced cannot use the Matrix bot.
|
||||||
|
- Status: Documented as a known infrastructure constraint in `docs/reports/2026-04-01-surfaces-progress-report.md`. Needs a separate infrastructure task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### SQLite database files not in .gitignore
|
||||||
|
|
||||||
|
- Risk: `lambda_bot.db` and `lambda_matrix.db` are present in the working tree (shown in `git status`) but not listed in `.gitignore`. These files may contain user data including chat content and display names.
|
||||||
|
- Files: `lambda_bot.db`, `lambda_matrix.db`, `.gitignore`
|
||||||
|
- Current mitigation: Files are currently untracked (not yet staged) but nothing prevents them from being accidentally committed.
|
||||||
|
- Recommendation: Add `*.db` or specific filenames to `.gitignore` immediately.
|
||||||
|
|
||||||
|
### Auth flow is auto-confirmed in mock — no real validation exists
|
||||||
|
|
||||||
|
- Issue: `core/auth.py` `confirm()` automatically sets `state = "confirmed"` and generates a fake `platform_user_id`. There is no real verification step, no code exchange, no token validation.
|
||||||
|
- Files: `core/auth.py` lines 39–48
|
||||||
|
- Impact: The auth layer is decorative for the prototype. Any user who sends `!start` or `/start` is immediately authenticated. If the real SDK auth requires a different flow (e.g., OAuth, code), the current `AuthManager` interface may not match.
|
||||||
|
- Current mitigation: Acceptable for mock stage. Must be re-evaluated before production use.
|
||||||
|
|
||||||
|
### Matrix room metadata stored without access control
|
||||||
|
|
||||||
|
- Issue: `adapter/matrix/store.py` stores room metadata keyed by `room_id`. Any call that can supply an arbitrary `room_id` can read or overwrite another user's room metadata.
|
||||||
|
- Files: `adapter/matrix/store.py`, `adapter/matrix/room_router.py`
|
||||||
|
- Impact: In the current single-process bot this is not exploitable. If the store is ever shared across processes or users, room metadata can be poisoned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragile Areas
|
||||||
|
|
||||||
|
### `core/chat.py` scan-by-suffix fallback is O(N) and collision-prone
|
||||||
|
|
||||||
|
- Issue: `ChatManager.get()` when called without `user_id` scans all `chat:*` keys and matches by suffix (e.g., `":C1"`). If two users both have a chat named `C1` (which is always the case), this returns the first one found, non-deterministically.
|
||||||
|
- Files: `core/chat.py` lines 76–82
|
||||||
|
- Impact: Functions like `rename` and `archive` that call `chat_mgr.get(chat_id)` without `user_id` will operate on the wrong user's chat in a multi-user scenario.
|
||||||
|
- Fix approach: Audit all callers and always pass `user_id`. The scan-by-suffix fallback should be removed or explicitly guarded.
|
||||||
|
|
||||||
|
### `adapter/matrix/handlers/chat.py` chat_id counter races under concurrency
|
||||||
|
|
||||||
|
- Issue: `make_handle_new_chat` calls `chat_mgr.list_active()` and uses `len(chats) + 1` to compute a new `chat_id`. This is not atomic. Two concurrent `!new` commands from the same user can produce the same `chat_id`.
|
||||||
|
- Files: `adapter/matrix/handlers/chat.py` line 17
|
||||||
|
- Impact: Duplicate `chat_id` values (`C2`, `C2`) for the same user, leading to state corruption.
|
||||||
|
- Fix approach: Use `next_chat_id()` from `adapter/matrix/store.py` which increments an atomic counter in the store. The `next_chat_id()` function already exists but is not used here.
|
||||||
|
|
||||||
|
### `conftest.py` contains a fragile stdlib `platform` module workaround
|
||||||
|
|
||||||
|
- Issue: `conftest.py` patches `sys.modules` to remove the Python stdlib `platform` module so local `platform/` (which no longer exists — renamed to `sdk/`) doesn't shadow it. The comment still refers to `platform/` but the directory was renamed to `sdk/` in commit `41660fe`.
|
||||||
|
- Files: `conftest.py` lines 1–13
|
||||||
|
- Impact: The workaround is now a no-op (there is no `platform/` package to shadow) but adds confusion. The comment is incorrect. If someone creates a `platform/` directory again, unexpected behavior can return.
|
||||||
|
- Fix approach: Remove the `sys.modules` patching entirely since `sdk/` does not conflict with stdlib. Update the comment.
|
||||||
|
|
||||||
|
### Forum onboarding `chat_shared` constructs a fake `Chat` object
|
||||||
|
|
||||||
|
- Issue: `adapter/telegram/handlers/forum.py` handles `chat_shared` by constructing `Chat(id=..., type="supergroup", is_forum=True)` and passing it to `_complete_group_link()`. The `is_forum=True` is hardcoded — the real value from Telegram is not verified. This means the check `if getattr(forwarded_chat, "is_forum", None) is False` in the forwarding fallback path is bypassed entirely.
|
||||||
|
- Files: `.worktrees/telegram/adapter/telegram/handlers/forum.py` lines 162–168
|
||||||
|
- Impact: A user could link a regular supergroup (without Topics enabled) via `chat_shared`, which would succeed in linking but fail when the bot tries to create forum topics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gaps Between CLAUDE.md and Actual Code
|
||||||
|
|
||||||
|
### CLAUDE.md says `platform/` — code uses `sdk/`
|
||||||
|
|
||||||
|
- CLAUDE.md architecture diagram shows `platform/interface.py` and `platform/mock.py`
|
||||||
|
- Actual code uses `sdk/interface.py` and `sdk/mock.py` (renamed in commit `41660fe`)
|
||||||
|
- Files: `CLAUDE.md` (project instructions), `sdk/interface.py`, `sdk/mock.py`
|
||||||
|
- Also: Agent config files at `.claude/agents/core-developer.md` still reference `platform/` throughout
|
||||||
|
- Impact: New contributors reading CLAUDE.md will look for a `platform/` directory that does not exist.
|
||||||
|
|
||||||
|
### CLAUDE.md lists `core/handlers/` sub-handlers that partially do not exist
|
||||||
|
|
||||||
|
- CLAUDE.md lists handler modules but the actual `core/handlers/` only has: `start.py`, `message.py`, `chat.py`, `settings.py`, `callback.py`
|
||||||
|
- No `voice.py` handler exists; voice is handled as a fallback inside `core/handlers/message.py` (returns stub response)
|
||||||
|
- No `payment.py` handler exists; `PaymentRequired` dataclass is defined in `core/protocol.py` but never dispatched
|
||||||
|
- Files: `core/protocol.py` (PaymentRequired defined), `core/handlers/` (no payment or voice handlers)
|
||||||
|
|
||||||
|
### CLAUDE.md workflow describes `@reviewer` agent but agent file references old patterns
|
||||||
|
|
||||||
|
- `.claude/agents/core-developer.md` still says "Твоя зона — `core/` и `platform/`"
|
||||||
|
- The old Haiku/Sonnet researcher-developer workflow is captured in `docs/workflow-backup-2026-04-01.md`, but `.claude/agents/` configs were not updated to match
|
||||||
|
|
||||||
|
### `tests/adapter/test_forum_db.py` is untracked on main
|
||||||
|
|
||||||
|
- This test file exists in the working tree (visible in `git status`) but is not committed to `main`. It tests `adapter/telegram/db.py` which also does not exist on `main`.
|
||||||
|
- Files: `tests/adapter/test_forum_db.py`
|
||||||
|
- Impact: Running `pytest tests/` from main currently includes this test, which imports `adapter.telegram.db`. This import succeeds only because the test auto-reloads the module from an untracked file. This is fragile — if the file is deleted, tests silently pass with fewer tests counted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Critical Features
|
||||||
|
|
||||||
|
### No streaming response support in adapters
|
||||||
|
|
||||||
|
- Both adapters use `platform.send_message()` (sync) not `platform.stream_message()` (streaming)
|
||||||
|
- `sdk/interface.py` defines `stream_message` returning `AsyncIterator[MessageChunk]`
|
||||||
|
- No adapter sends a typing indicator before the response arrives and then streams chunks
|
||||||
|
- Impact: User experience with slow AI responses will show nothing until the full response is ready
|
||||||
|
- Files: `core/handlers/message.py` line 28, `sdk/interface.py` lines 83–88
|
||||||
|
|
||||||
|
### No webhook/push notification handling
|
||||||
|
|
||||||
|
- `sdk/interface.py` defines `WebhookReceiver` Protocol with `on_agent_event()`
|
||||||
|
- `sdk/mock.py` has `register_webhook_receiver()` and `simulate_agent_event()`
|
||||||
|
- Neither bot entrypoint registers a `WebhookReceiver`
|
||||||
|
- Impact: Push notifications from the platform (task completions, background jobs) cannot reach the user
|
||||||
|
- Files: `sdk/interface.py` lines 95–97, `adapter/matrix/bot.py`, no registration present
|
||||||
|
|
||||||
|
### Telegram adapter uses InMemoryStore for core state
|
||||||
|
|
||||||
|
- `.worktrees/telegram/adapter/telegram/bot.py` calls `InMemoryStore()` for the `EventDispatcher`'s state
|
||||||
|
- All `core/` state (auth, chat metadata in the KV layer) is lost on bot restart
|
||||||
|
- `adapter/telegram/db.py` SQLite is used only for Telegram-specific data
|
||||||
|
- Impact: On restart, authenticated users are logged out; core chat context is wiped
|
||||||
|
- Files: `.worktrees/telegram/adapter/telegram/bot.py` line 46
|
||||||
|
|
||||||
|
### No multi-user isolation in Matrix store
|
||||||
|
|
||||||
|
- `adapter/matrix/store.py` keys are global (`matrix_room:ROOMID`, `matrix_user:USERID`)
|
||||||
|
- There is no namespace or tenant isolation
|
||||||
|
- Impact: At scale, any key collision would corrupt state. For a single-user prototype this is acceptable, but it is an architectural constraint to document before expanding scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage Gaps
|
||||||
|
|
||||||
|
### No tests for `adapter/telegram/` in main test suite
|
||||||
|
|
||||||
|
- `tests/adapter/` on main only contains `matrix/` tests and the untracked `test_forum_db.py`
|
||||||
|
- All Telegram adapter tests live in the worktree at `.worktrees/telegram/tests/`
|
||||||
|
- Files: `tests/adapter/` (missing `telegram/` subdirectory on main)
|
||||||
|
- Risk: Merging `feat/telegram-adapter` without also merging its tests leaves Telegram untested on main
|
||||||
|
- Priority: High
|
||||||
|
|
||||||
|
### No tests for `core/handlers/callback.py` confirm/cancel real behavior
|
||||||
|
|
||||||
|
- `core/handlers/callback.py` `handle_confirm` and `handle_cancel` return stub text with `action_id`
|
||||||
|
- No test verifies that a real confirmation flow (dispatch → confirm → side effect) works end to end
|
||||||
|
- Files: `core/handlers/callback.py`, `tests/core/test_dispatcher.py`
|
||||||
|
- Priority: Medium
|
||||||
|
|
||||||
|
### No tests for `adapter/matrix/handlers/auth.py` multi-room invite scenario
|
||||||
|
|
||||||
|
- The hardcoded `C1` bug (see Known Bugs section) is not caught by any test
|
||||||
|
- Files: `adapter/matrix/handlers/auth.py`, `tests/adapter/matrix/test_dispatcher.py`
|
||||||
|
- Priority: Medium
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Concerns audit: 2026-04-01*
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,195 @@
|
||||||
# Конвенции (CONVENTIONS.md)
|
# Coding Conventions
|
||||||
|
|
||||||
- **Асинхронность**: Весь код бота асинхронный (`asyncio`). Вызовы SDK и Matrix-клиента выполняются через `await`. Блокирующие вызовы (если они есть) должны выноситься в тредпул.
|
**Analysis Date:** 2026-04-01
|
||||||
- **Обработка ошибок**: Бот не должен падать из-за ошибок отдельного агента. Ошибки SDK (например, `PlatformError`) отлавливаются в боте и возвращаются пользователю в виде системных сообщений или уведомлений.
|
|
||||||
- **Стейтлесс-подход**: Поверхность хранит минимальный стейт (только локальный SQLite для связки `room_id` <-> `platform_chat_id`). Вся история сообщений и память лежат на стороне агентов.
|
## Linting and Formatting
|
||||||
- **Переменные окружения**: Бот полностью конфигурируется через `.env` (префиксы `MATRIX_` и `SURFACES_`).
|
|
||||||
- **Добавление новой поверхности**: Новая поверхность должна быть самостоятельной папкой в `adapter/`, реализовывать `converter.py`, и переиспользовать `sdk/real.py` и `core/protocol.py`.
|
**Tool:** ruff (configured in `pyproject.toml`)
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
- Line length: 100 characters
|
||||||
|
- Target: Python 3.11
|
||||||
|
- Active rule sets: `E` (pycodestyle errors), `F` (pyflakes), `I` (isort), `UP` (pyupgrade), `B` (bugbear)
|
||||||
|
|
||||||
|
**Type checking:** mypy (available as dev dependency; not enforced in CI at this time)
|
||||||
|
|
||||||
|
Run linting:
|
||||||
|
```bash
|
||||||
|
ruff check .
|
||||||
|
ruff format .
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
- Module files: `snake_case.py` (e.g., `room_router.py`, `test_dispatcher.py`)
|
||||||
|
- Each module starts with a comment declaring its path: `# core/handler.py`
|
||||||
|
- Test files: `test_<module>.py` (e.g., `test_store.py`, `test_converter.py`)
|
||||||
|
- No index/barrel files except `__init__.py` for package registration
|
||||||
|
|
||||||
|
## Class Naming
|
||||||
|
|
||||||
|
- `PascalCase` for all classes (e.g., `EventDispatcher`, `MockPlatformClient`, `MatrixBot`)
|
||||||
|
- Protocol/interface classes named after the capability: `StateStore`, `PlatformClient`, `WebhookReceiver`
|
||||||
|
- Manager classes suffixed with `Manager`: `ChatManager`, `AuthManager`, `SettingsManager`
|
||||||
|
- Dataclasses follow the same `PascalCase` rule: `IncomingMessage`, `OutgoingUI`, `MatrixRuntime`
|
||||||
|
|
||||||
|
## Function and Method Naming
|
||||||
|
|
||||||
|
- `snake_case` for all functions and methods
|
||||||
|
- Private helpers prefixed with single underscore: `_to_dict`, `_from_dict`, `_routing_key`, `_latency`
|
||||||
|
- Handler functions named `handle_<action>`: `handle_start`, `handle_message`, `handle_new_chat`
|
||||||
|
- Builder functions named `build_<thing>`: `build_runtime`, `build_event_dispatcher`, `build_skills_text`
|
||||||
|
- Converter functions named `from_<source>`: `from_room_event`, `from_command`, `from_reaction`
|
||||||
|
- Predicate functions named `is_<state>`: `is_authenticated`, `is_new`
|
||||||
|
|
||||||
|
## Variable Naming
|
||||||
|
|
||||||
|
- `snake_case` for all variables and parameters
|
||||||
|
- Internal state attributes prefixed with `_`: `self._store`, `self._platform`, `self._handlers`
|
||||||
|
- Store key prefixes are module-level constants in `UPPER_SNAKE_CASE`:
|
||||||
|
```python
|
||||||
|
ROOM_META_PREFIX = "matrix_room:"
|
||||||
|
USER_META_PREFIX = "matrix_user:"
|
||||||
|
```
|
||||||
|
- Constants for reaction strings are module-level: `CONFIRM_REACTION = "👍"`, `PLATFORM = "matrix"`
|
||||||
|
|
||||||
|
## Type Annotations
|
||||||
|
|
||||||
|
All files use `from __future__ import annotations` at the top for deferred evaluation.
|
||||||
|
|
||||||
|
**Annotation style:**
|
||||||
|
- Use built-in generics (`list[str]`, `dict[str, Any]`) — not `List`, `Dict` from `typing`
|
||||||
|
- Union types written with `|`: `str | None`, `IncomingCallback | None`
|
||||||
|
- Type aliases at module level: `IncomingEvent = IncomingMessage | IncomingCommand | IncomingCallback`
|
||||||
|
- Callable types use `typing.Callable` and `typing.Awaitable`:
|
||||||
|
```python
|
||||||
|
HandlerFn = Callable[..., Awaitable[list[OutgoingEvent]]]
|
||||||
|
```
|
||||||
|
- Handler functions use loose `list` return type without generics (consistent across `core/handlers/`)
|
||||||
|
- Protocol classes use `...` as body for abstract methods:
|
||||||
|
```python
|
||||||
|
async def get(self, key: str) -> dict | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pydantic vs dataclasses:**
|
||||||
|
- `core/protocol.py` — plain `@dataclass` with `field(default_factory=...)` for mutable defaults
|
||||||
|
- `sdk/interface.py` — Pydantic `BaseModel` for all SDK-facing models (`User`, `MessageResponse`, `UserSettings`)
|
||||||
|
- Choose `@dataclass` for internal protocol structs, `BaseModel` for SDK boundary models
|
||||||
|
|
||||||
|
## Import Organization
|
||||||
|
|
||||||
|
Order (enforced by ruff `I` rules):
|
||||||
|
1. `from __future__ import annotations`
|
||||||
|
2. Standard library imports (grouped)
|
||||||
|
3. Third-party imports (grouped)
|
||||||
|
4. Local imports from project packages (grouped)
|
||||||
|
|
||||||
|
Example from `adapter/matrix/bot.py`:
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from nio import AsyncClient, ...
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from adapter.matrix.converter import from_reaction, from_room_event
|
||||||
|
from core.auth import AuthManager
|
||||||
|
from core.protocol import OutgoingEvent, ...
|
||||||
|
from sdk.mock import MockPlatformClient
|
||||||
|
```
|
||||||
|
|
||||||
|
No relative imports; all imports use absolute package paths from the project root.
|
||||||
|
|
||||||
|
## Async Patterns
|
||||||
|
|
||||||
|
All I/O methods are `async def`. There are no sync wrappers around async code.
|
||||||
|
|
||||||
|
**Handler signature pattern** (used uniformly across `core/handlers/`):
|
||||||
|
```python
|
||||||
|
async def handle_<action>(event: IncomingEvent, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
|
||||||
|
```
|
||||||
|
Note: manager parameters are untyped in handler signatures (accepted as `**kwargs` at call site in `EventDispatcher.dispatch`).
|
||||||
|
|
||||||
|
**Awaiting store calls:**
|
||||||
|
```python
|
||||||
|
stored = await self._store.get(f"auth:{user_id}")
|
||||||
|
await self._store.set(f"auth:{user_id}", _to_dict(flow))
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQLiteStore uses sync sqlite3** inside `async def` methods — blocking I/O is not off-loaded to a thread executor. This is a known limitation (see CONCERNS.md).
|
||||||
|
|
||||||
|
**Mock latency simulation:**
|
||||||
|
```python
|
||||||
|
await self._latency(200, 600) # min_ms, max_ms
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
**Library:** `structlog`
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```python
|
||||||
|
import structlog
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
logger.info("Chat created", chat_id=chat_id, user_id=user_id)
|
||||||
|
logger.warning("No handler registered", event_type=event_type.__name__, key=key)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Always pass structured keyword arguments — never use f-strings in log calls
|
||||||
|
- Logger created at module level with `structlog.get_logger(__name__)`
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Raise `ValueError` for invalid domain state (e.g., chat not found in `ChatManager.rename`)
|
||||||
|
- `sdk/interface.py` defines `PlatformError(Exception)` with a `code` field for SDK-level errors
|
||||||
|
- Handler functions never raise — they return `[]` or a fallback `OutgoingMessage`
|
||||||
|
- No `try/except` blocks in core handlers; errors from the platform are expected to propagate
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
- Module-level comment declaring file path at top: `# core/handler.py`
|
||||||
|
- Docstrings for classes with non-obvious behavior:
|
||||||
|
```python
|
||||||
|
class MockPlatformClient:
|
||||||
|
"""
|
||||||
|
Заглушка SDK платформы Lambda.
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
- Inline comments for non-obvious blocks:
|
||||||
|
```python
|
||||||
|
# Scan by chat_id suffix when user_id unknown (slower)
|
||||||
|
```
|
||||||
|
- Comments in Russian are normal and acceptable throughout the codebase
|
||||||
|
|
||||||
|
## Serialization Pattern
|
||||||
|
|
||||||
|
Dataclasses are serialized/deserialized via private module-level functions, not class methods:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _to_dict(ctx: ChatContext) -> dict:
|
||||||
|
return { "chat_id": ctx.chat_id, ... }
|
||||||
|
|
||||||
|
def _from_dict(d: dict) -> ChatContext:
|
||||||
|
return ChatContext(chat_id=d["chat_id"], ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern is used in `core/auth.py` and `core/chat.py`. Follow this pattern for any new manager that persists to `StateStore`.
|
||||||
|
|
||||||
|
## Module Design
|
||||||
|
|
||||||
|
- No barrel `__init__.py` exports except `core/handlers/__init__.py` which exposes `register_all`
|
||||||
|
- Manager classes take `(platform, store)` as constructor args; `platform` is often stored as `object` or not stored at all if unused
|
||||||
|
- `@dataclass` is preferred for plain data containers, not NamedTuple or TypedDict
|
||||||
|
- Store key namespacing follows `<namespace>:<user_id>:<entity_id>` pattern:
|
||||||
|
`"chat:u1:C1"`, `"auth:u1"`, `"matrix_room:!r:m.org"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Convention analysis: 2026-04-01*
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,173 @@
|
||||||
# Интеграции (INTEGRATIONS.md)
|
# External Integrations
|
||||||
|
|
||||||
## Platform Agent API
|
**Analysis Date:** 2026-04-01
|
||||||
- **Тип**: WebSocket (через `AgentApi` SDK)
|
|
||||||
- **Назначение**: Связь между Matrix-адаптером и внешней LLM-платформой.
|
|
||||||
- **Контракт**: Surface выступает "тупым" клиентом. Он отправляет `platform_chat_id` и `user_id` вместе с сообщениями. Платформа/Агент отвечает текстом и вложениями. Контейнерами агентов бот не управляет.
|
|
||||||
|
|
||||||
## Matrix Homeserver
|
## Bot Platform APIs
|
||||||
- **Тип**: HTTP/HTTPS API (via `matrix-nio`)
|
|
||||||
- **Назначение**: Пользовательский интерфейс и транспорт сообщений для бота.
|
|
||||||
- **Ограничения**: Поддерживается только нешифрованное (unencrypted) взаимодействие.
|
|
||||||
|
|
||||||
## Файловая система (Shared Volume)
|
**Telegram Bot API:**
|
||||||
- **Тип**: Docker Shared Volume (`/agents/`)
|
- Purpose: Primary messaging surface for user ↔ Lambda agent interaction
|
||||||
- **Назначение**: Прямая передача файлов между поверхностью и агентами в обход сети. Поверхность пишет файлы в поддиректорию конкретного агента, агент их читает, и наоборот.
|
- Client library: `aiogram` 3.26.0 (async, wraps Telegram Bot API v7+)
|
||||||
|
- Authentication: Bot token via `TELEGRAM_BOT_TOKEN`
|
||||||
|
- Entry point: `adapter/telegram/bot.py` (planned; aiogram worktree branch `feat/telegram-adapter`)
|
||||||
|
- Transport: Long-polling or webhook (aiogram supports both; mode not yet locked in)
|
||||||
|
- Bot API docs: https://core.telegram.org/bots/api
|
||||||
|
|
||||||
|
**Matrix Client-Server API:**
|
||||||
|
- Purpose: Secondary messaging surface (Matrix/Element clients)
|
||||||
|
- Client library: `matrix-nio` 0.25.2 (async)
|
||||||
|
- Authentication: password login or pre-existing access token (`MATRIX_ACCESS_TOKEN`)
|
||||||
|
- Login flow in `adapter/matrix/bot.py` `main()`:
|
||||||
|
- If `MATRIX_ACCESS_TOKEN` is set → assigned directly to `client.access_token`
|
||||||
|
- Else if `MATRIX_PASSWORD` is set → `client.login(password=..., device_name="surfaces-bot")`
|
||||||
|
- Sync method: `client.sync_forever(timeout=30000)` (30-second long-poll)
|
||||||
|
- E2EE store: nio file-based store at path from `MATRIX_STORE_PATH` (default: `"matrix_store"`)
|
||||||
|
- Matrix C-S API docs: https://spec.matrix.org/latest/client-server-api/
|
||||||
|
|
||||||
|
### Matrix Room Model
|
||||||
|
|
||||||
|
Rooms are mapped to Lambda chat slots (C1, C2, C3…) via `adapter/matrix/room_router.py`:
|
||||||
|
- First message in a room → assigns next chat ID (C1, C2, …) and persists mapping to store
|
||||||
|
- Room metadata stored under key `matrix_room:<room_id>` in `StateStore`
|
||||||
|
- User metadata (next chat index) stored under `matrix_user:<matrix_user_id>`
|
||||||
|
|
||||||
|
### Matrix Event Types Handled
|
||||||
|
|
||||||
|
| nio Event Class | Handler | Action |
|
||||||
|
|--------------------|-----------------------------|-------------------------------|
|
||||||
|
| `RoomMessageText` | `MatrixBot.on_room_message` | Dispatch to `EventDispatcher` |
|
||||||
|
| `ReactionEvent` | `MatrixBot.on_reaction` | Button confirmation / skill toggle |
|
||||||
|
| `InviteMemberEvent`| `MatrixBot.on_member` | Accept room invite |
|
||||||
|
| `RoomMemberEvent` | `MatrixBot.on_member` | Membership change handling |
|
||||||
|
|
||||||
|
## Lambda Platform (Internal SDK)
|
||||||
|
|
||||||
|
**Purpose:** AI agent backend — processes user messages, manages user accounts, returns responses
|
||||||
|
|
||||||
|
**Interface:** `sdk/interface.py` — `PlatformClient` Protocol
|
||||||
|
|
||||||
|
**Current Implementation:** `sdk/mock.py` — `MockPlatformClient`
|
||||||
|
- Simulates network latency (10–80 ms default, 200–600 ms for message calls)
|
||||||
|
- In-process in-memory state (users, messages, settings dicts)
|
||||||
|
- Supports webhook simulation via `simulate_agent_event()`
|
||||||
|
|
||||||
|
**Production Integration (future):**
|
||||||
|
- URL: `LAMBDA_PLATFORM_URL` (default: `http://localhost:8000`)
|
||||||
|
- Auth: `LAMBDA_SERVICE_TOKEN` (bearer token)
|
||||||
|
- Mode switch: `PLATFORM_MODE=mock` vs `PLATFORM_MODE=production`
|
||||||
|
- Swap path: replace `sdk/mock.py` only; no changes to `core/` or `adapter/`
|
||||||
|
|
||||||
|
**Platform API Methods (from `sdk/interface.py`):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_or_create_user(external_id, platform, display_name) -> User
|
||||||
|
async def send_message(user_id, chat_id, text, attachments) -> MessageResponse
|
||||||
|
async def stream_message(user_id, chat_id, text, attachments) -> AsyncIterator[MessageChunk]
|
||||||
|
async def get_settings(user_id) -> UserSettings
|
||||||
|
async def update_settings(user_id, action) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Webhook / Push (outbound from platform → bot):**
|
||||||
|
- Interface: `WebhookReceiver` Protocol (`sdk/interface.py`)
|
||||||
|
- Registration: `MockPlatformClient.register_webhook_receiver(receiver)`
|
||||||
|
- Event types: `task_done`, `task_error`, `task_progress` (modelled in `AgentEvent`)
|
||||||
|
- Production implementation not yet wired; mock supports `simulate_agent_event()` for testing
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
**Databases:**
|
||||||
|
|
||||||
|
*SQLite (primary persistence):*
|
||||||
|
- Client: stdlib `sqlite3` (synchronous, called from async code without `asyncio.to_thread`)
|
||||||
|
- Schema: single key-value table: `kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)`
|
||||||
|
- JSON serialization for values (`json.dumps` / `json.loads`)
|
||||||
|
- Matrix bot DB path: `MATRIX_DB_PATH` (default: `"lambda_matrix.db"`)
|
||||||
|
- Telegram bot DB path: implicit `"lambda_bot.db"` (file present in repo root — development artifact)
|
||||||
|
- Implementation: `core/store.py` `SQLiteStore`
|
||||||
|
|
||||||
|
*In-Memory (testing / development):*
|
||||||
|
- `InMemoryStore` — plain Python dict, no persistence across restarts
|
||||||
|
- `MockPlatformClient` internal state — also in-memory dicts
|
||||||
|
|
||||||
|
**File Storage:**
|
||||||
|
- Matrix nio E2EE store: local filesystem directory at `MATRIX_STORE_PATH` (default: `"matrix_store/"`)
|
||||||
|
- No object storage (S3/GCS/etc.) currently; mock client has `attachment_mode` flag (`"url"` | `"binary"` | `"s3"`) reserved for future real SDK
|
||||||
|
|
||||||
|
**Caching:**
|
||||||
|
- None — no Redis or external cache layer
|
||||||
|
|
||||||
|
## Authentication & Identity
|
||||||
|
|
||||||
|
**Telegram Auth:**
|
||||||
|
- Bot token → passed to aiogram dispatcher at startup
|
||||||
|
- User identity: Telegram user ID mapped to platform `external_id`
|
||||||
|
|
||||||
|
**Matrix Auth:**
|
||||||
|
- Password or access token (see above)
|
||||||
|
- User identity: Matrix user ID (e.g. `@user:matrix.org`) mapped to platform `external_id`
|
||||||
|
|
||||||
|
**Lambda Platform User Identity:**
|
||||||
|
- `get_or_create_user(external_id, platform)` → returns `User` with internal `user_id`
|
||||||
|
- External IDs are platform-prefixed in mock: `"{platform}:{external_id}"`
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
**Logging:**
|
||||||
|
- `structlog` 25.5.0 — structured logging (key=value pairs)
|
||||||
|
- Logger instantiation: `structlog.get_logger(__name__)` in each module
|
||||||
|
- Log calls use keyword arguments: `logger.info("event_name", key=value, ...)`
|
||||||
|
- No log shipping / aggregation configured (local stdout only)
|
||||||
|
|
||||||
|
**Error Tracking:**
|
||||||
|
- None — no Sentry, Datadog, or similar integration
|
||||||
|
|
||||||
|
**Metrics:**
|
||||||
|
- None — `MockPlatformClient.get_stats()` returns basic in-memory counters (not exported)
|
||||||
|
|
||||||
|
## CI/CD & Deployment
|
||||||
|
|
||||||
|
**Hosting:**
|
||||||
|
- Not specified — no Dockerfile, docker-compose, or cloud config files present
|
||||||
|
|
||||||
|
**CI Pipeline:**
|
||||||
|
- None detected — no `.github/workflows/`, `.gitlab-ci.yml`, etc.
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
**Required variables (from `.env.example`):**
|
||||||
|
|
||||||
|
| Variable | Required | Default | Purpose |
|
||||||
|
|-----------------------|----------|--------------------|--------------------------------------|
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | Yes* | — | Telegram Bot API token |
|
||||||
|
| `MATRIX_HOMESERVER` | Yes* | — | Matrix homeserver URL (e.g. `https://matrix.org`) |
|
||||||
|
| `MATRIX_USER_ID` | Yes* | — | Bot's Matrix user ID |
|
||||||
|
| `MATRIX_PASSWORD` | Cond. | — | Login password (if no access token) |
|
||||||
|
| `MATRIX_ACCESS_TOKEN` | Cond. | — | Pre-issued access token (preferred) |
|
||||||
|
| `MATRIX_DEVICE_ID` | No | `""` | Matrix device ID |
|
||||||
|
| `MATRIX_DB_PATH` | No | `"lambda_matrix.db"` | SQLite DB file path (Matrix bot) |
|
||||||
|
| `MATRIX_STORE_PATH` | No | `"matrix_store"` | nio E2EE store directory |
|
||||||
|
| `LAMBDA_PLATFORM_URL` | No** | `http://localhost:8000` | Lambda platform base URL |
|
||||||
|
| `LAMBDA_SERVICE_TOKEN`| No** | — | Service auth token for Lambda API |
|
||||||
|
| `PLATFORM_MODE` | No | `"mock"` | `"mock"` or `"production"` |
|
||||||
|
|
||||||
|
\* Required for the respective bot to function.
|
||||||
|
\*\* Only required when `PLATFORM_MODE=production`.
|
||||||
|
|
||||||
|
**Secrets location:**
|
||||||
|
- `.env` file (gitignored)
|
||||||
|
- Never committed — `.env.example` provides template
|
||||||
|
- Loaded via `python-dotenv` at module import in each `bot.py` entry point
|
||||||
|
|
||||||
|
## Webhooks & Callbacks
|
||||||
|
|
||||||
|
**Incoming (platform → bot):**
|
||||||
|
- `WebhookReceiver.on_agent_event(event: AgentEvent)` — receives async task completion notifications
|
||||||
|
- Not yet wired to an HTTP endpoint; `MockPlatformClient.simulate_agent_event()` used for testing
|
||||||
|
|
||||||
|
**Outgoing (bot → external):**
|
||||||
|
- Telegram: all via `aiogram` polling or webhook (no direct outbound HTTP beyond Telegram API)
|
||||||
|
- Matrix: all via `matrix-nio` `AsyncClient.room_send()`, `room_typing()`, etc.
|
||||||
|
- Platform: via `PlatformClient` send/stream methods
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Integration audit: 2026-04-01*
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,113 @@
|
||||||
# Технологический стек (STACK.md)
|
# Technology Stack
|
||||||
|
|
||||||
## Язык и Runtime
|
**Analysis Date:** 2026-04-01
|
||||||
- **Python**: 3.11-slim (используется в Docker-образах)
|
|
||||||
- **Пакетный менеджер**: `uv` (используется для быстрой и строгой установки зависимостей, frozen lockfiles).
|
|
||||||
|
|
||||||
## Ключевые библиотеки
|
## Languages
|
||||||
- **matrix-nio**: Асинхронный клиент для Matrix (события, синхронизация, отправка).
|
|
||||||
- **pydantic**: Для валидации структур данных (события из AgentApi).
|
|
||||||
- **structlog**: Структурированное логирование (json/console).
|
|
||||||
|
|
||||||
## Инфраструктура
|
**Primary:**
|
||||||
- **Docker / Docker Compose**: Используется для локального (fullstack) и продакшн развертывания.
|
- Python 3.11+ — all application code (enforced via `pyproject.toml` `requires-python = ">=3.11"`)
|
||||||
- **SQLite**: Легковесная локальная база данных для хранения маппингов пользователей/комнат (`adapter/matrix/store.py`).
|
|
||||||
|
**Type Annotations:**
|
||||||
|
- Full `from __future__ import annotations` usage throughout
|
||||||
|
- `typing.Protocol` used for dependency inversion (`core/store.py`, `sdk/interface.py`)
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- CPython — runtime (development host currently runs 3.14.3)
|
||||||
|
- Minimum: Python 3.11 (uses `match`-compatible union syntax, `Self`, `X | Y` type hints)
|
||||||
|
|
||||||
|
**Package Manager:**
|
||||||
|
- `uv` 0.9.30 (Homebrew)
|
||||||
|
- Lockfile: `uv.lock` present and committed
|
||||||
|
- Install: `uv sync`
|
||||||
|
|
||||||
|
## Frameworks
|
||||||
|
|
||||||
|
**Telegram Bot:**
|
||||||
|
- `aiogram` 3.26.0 — async Telegram Bot API framework
|
||||||
|
- Used in `adapter/telegram/` (planned; directory not yet present in main branch)
|
||||||
|
- Brings in `aiohttp` 3.13.3 as its HTTP transport
|
||||||
|
|
||||||
|
**Matrix Bot:**
|
||||||
|
- `matrix-nio` 0.25.2 — async Matrix Client-Server API client
|
||||||
|
- Used in `adapter/matrix/bot.py`
|
||||||
|
- Key classes: `AsyncClient`, `AsyncClientConfig`, `RoomMessageText`, `ReactionEvent`, `InviteMemberEvent`, `RoomMemberEvent`, `MatrixRoom`
|
||||||
|
- Long-polling via `client.sync_forever(timeout=30000)`
|
||||||
|
|
||||||
|
**Data Validation:**
|
||||||
|
- `pydantic` 2.12.5 — data models in `sdk/interface.py`
|
||||||
|
- `User`, `Attachment`, `MessageResponse`, `MessageChunk`, `UserSettings`, `AgentEvent`, `PlatformError`
|
||||||
|
- Core protocol structs (`core/protocol.py`) use plain `dataclasses` instead
|
||||||
|
|
||||||
|
**Build/Dev:**
|
||||||
|
- `setuptools` ≥68 + `setuptools-scm` + `wheel` — build backend (`pyproject.toml`)
|
||||||
|
- `ruff` 0.15.8 — linting and import sorting (`line-length = 100`, `target-version = "py311"`, rules: E, F, I, UP, B)
|
||||||
|
- `mypy` 1.19.1 — static type checking
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
**Critical:**
|
||||||
|
- `aiogram>=3.4,<4` (resolved: 3.26.0) — Telegram adapter; pin avoids breaking v4 API
|
||||||
|
- `matrix-nio>=0.21` (resolved: 0.25.2) — Matrix adapter; async-only client
|
||||||
|
- `pydantic>=2.5` (resolved: 2.12.5) — SDK interface models; v2 required (v1 incompatible)
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- `structlog` 25.5.0 — structured logging throughout; used via `structlog.get_logger(__name__)`
|
||||||
|
- `python-dotenv` 1.2.2 — loads `.env` at bot startup (`load_dotenv(Path(...) / ".env")`)
|
||||||
|
- `httpx` 0.28.1 — available for HTTP calls (future SDK integration, not yet used in core logic)
|
||||||
|
|
||||||
|
**Async I/O:**
|
||||||
|
- `aiohttp` 3.13.3 — transitive via aiogram; provides HTTP session to Telegram Bot API
|
||||||
|
- `asyncio` — stdlib; used directly in `sdk/mock.py` (`asyncio.sleep` for latency simulation) and all bot entry points (`asyncio.run(main())`)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Runner:**
|
||||||
|
- `pytest` 9.0.2
|
||||||
|
- `pytest-asyncio` 1.3.0 — `asyncio_mode = "auto"` (set in `pyproject.toml`)
|
||||||
|
- `pytest-cov` 7.1.0 — coverage reporting
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- `pyproject.toml` `[tool.pytest.ini_options]`: `testpaths = ["tests"]`, `pythonpath = ["."]`
|
||||||
|
- `conftest.py` at project root
|
||||||
|
|
||||||
|
## Internal Module Structure
|
||||||
|
|
||||||
|
**Core (no external deps except stdlib + pydantic via sdk):**
|
||||||
|
- `core/protocol.py` — `dataclasses`-based unified event types
|
||||||
|
- `core/store.py` — `StateStore` Protocol + `InMemoryStore` (dict) + `SQLiteStore` (stdlib `sqlite3`)
|
||||||
|
- `core/handler.py` — `EventDispatcher`
|
||||||
|
- `core/auth.py`, `core/chat.py`, `core/settings.py` — domain managers
|
||||||
|
|
||||||
|
**SDK Layer:**
|
||||||
|
- `sdk/interface.py` — `PlatformClient` Protocol (pydantic models)
|
||||||
|
- `sdk/mock.py` — `MockPlatformClient` in-process stub; simulates latency via `asyncio.sleep`
|
||||||
|
|
||||||
|
**Adapters:**
|
||||||
|
- `adapter/matrix/` — matrix-nio integration (active)
|
||||||
|
- `adapter/telegram/` — aiogram integration (referenced in deps, worktree branch exists)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Loaded from `.env` via `python-dotenv` at startup
|
||||||
|
- See `INTEGRATIONS.md` for full variable list
|
||||||
|
|
||||||
|
**Build:**
|
||||||
|
- `pyproject.toml` — single source of truth for deps, build, lint, test config
|
||||||
|
|
||||||
|
## Platform Requirements
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
- Python ≥3.11
|
||||||
|
- `uv` for dependency management
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
- Any environment with Python ≥3.11
|
||||||
|
- Matrix bot: requires writable filesystem path for `matrix_store/` (nio E2EE store) and SQLite DB
|
||||||
|
- Telegram bot: stateless beyond env vars (or optionally SQLite for persistence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stack analysis: 2026-04-01*
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,210 @@
|
||||||
# Структура (STRUCTURE.md)
|
# Codebase Structure
|
||||||
|
|
||||||
- `core/`:
|
**Analysis Date:** 2026-04-01
|
||||||
- `protocol.py` — Унифицированные структуры данных (сообщения, файлы, UI).
|
|
||||||
- `adapter/matrix/`:
|
## Directory Layout
|
||||||
- `bot.py` — Главный event-loop Matrix.
|
|
||||||
- `converter.py` — Конвертация событий Matrix-nio ⇄ `core/protocol.py`.
|
```
|
||||||
- `agent_registry.py` — Парсинг `matrix-agents.yaml`.
|
surfaces-bot/
|
||||||
- `files.py` — Работа с вложениями и shared volume.
|
├── adapter/
|
||||||
- `store.py` — SQLite база для маппинга чатов Matrix и `platform_chat_id`.
|
│ ├── __init__.py
|
||||||
- `routed_platform.py` — Динамический роутинг вызовов к нужным агентам на лету.
|
│ └── matrix/ # matrix-nio adapter (merged to main)
|
||||||
- `sdk/`:
|
│ ├── __init__.py
|
||||||
- `interface.py` — Интерфейс PlatformClient.
|
│ ├── bot.py # Entry point, MatrixBot class, send_outgoing()
|
||||||
- `real.py` — Имплементация WebSocket клиента (`AgentApi`).
|
│ ├── converter.py # nio Event → IncomingEvent
|
||||||
- `mock.py` — Мок-клиент для E2E тестов без платформы.
|
│ ├── reactions.py # Emoji constants, skills text builder
|
||||||
- `config/`: Конфиги маршрутизации (YAML).
|
│ ├── room_router.py # room_id → chat_id resolution
|
||||||
- `docs/`: Актуальная документация по развертыванию и архитектуре.
|
│ ├── store.py # Matrix-specific StateStore helpers (room meta, user meta)
|
||||||
- `docker-compose*.yml`: Продакшн и локальные манифесты для сборки.
|
│ └── handlers/
|
||||||
|
│ ├── __init__.py # register_matrix_handlers()
|
||||||
|
│ ├── auth.py # handle_invite (invite member event)
|
||||||
|
│ ├── chat.py # Chat creation (creates real Matrix rooms)
|
||||||
|
│ ├── confirm.py # Confirmation flow callbacks
|
||||||
|
│ └── settings.py # Settings sub-commands and toggle_skill
|
||||||
|
├── core/
|
||||||
|
│ ├── auth.py # AuthManager: start_flow, confirm, is_authenticated
|
||||||
|
│ ├── chat.py # ChatManager: get_or_create, list_active, rename, archive
|
||||||
|
│ ├── handler.py # EventDispatcher: register, dispatch, _routing_key
|
||||||
|
│ ├── protocol.py # All shared dataclasses and type aliases
|
||||||
|
│ ├── settings.py # SettingsManager: get (cached), apply (invalidates cache)
|
||||||
|
│ ├── store.py # StateStore Protocol, InMemoryStore, SQLiteStore
|
||||||
|
│ └── handlers/
|
||||||
|
│ ├── __init__.py # register_all() — binds all core handlers to dispatcher
|
||||||
|
│ ├── callback.py # handle_confirm, handle_cancel, handle_toggle_skill
|
||||||
|
│ ├── chat.py # handle_new_chat, handle_rename, handle_archive, handle_list_chats
|
||||||
|
│ ├── message.py # handle_message — auth guard + platform.send_message
|
||||||
|
│ ├── settings.py # handle_settings — displays settings menu
|
||||||
|
│ └── start.py # handle_start — get_or_create_user + welcome message
|
||||||
|
├── sdk/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── interface.py # PlatformClient Protocol, WebhookReceiver Protocol, Pydantic models
|
||||||
|
│ └── mock.py # MockPlatformClient — full in-memory implementation
|
||||||
|
├── tests/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── conftest.py # (root conftest — sys.path fix for local sdk/ shadowing stdlib)
|
||||||
|
│ ├── adapter/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── matrix/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── test_converter.py
|
||||||
|
│ │ │ ├── test_dispatcher.py
|
||||||
|
│ │ │ ├── test_reactions.py
|
||||||
|
│ │ │ └── test_store.py
|
||||||
|
│ │ └── test_forum_db.py # untracked — forum DB exploration
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── test_auth.py
|
||||||
|
│ │ ├── test_chat.py
|
||||||
|
│ │ ├── test_dispatcher.py
|
||||||
|
│ │ ├── test_integration.py
|
||||||
|
│ │ ├── test_protocol.py
|
||||||
|
│ │ ├── test_settings.py
|
||||||
|
│ │ ├── test_store.py
|
||||||
|
│ │ └── test_voice_slot.py
|
||||||
|
│ └── platform/
|
||||||
|
│ └── test_mock.py
|
||||||
|
├── docs/ # All human documentation
|
||||||
|
├── .planning/ # GSD planning artefacts
|
||||||
|
│ └── codebase/ # Codebase map documents (this directory)
|
||||||
|
├── .claude/
|
||||||
|
│ └── agents/ # Agent configuration files
|
||||||
|
├── .worktrees/
|
||||||
|
│ └── telegram/ # Telegram adapter on feat/telegram-adapter branch
|
||||||
|
│ └── ... # Mirrors main layout; merged separately
|
||||||
|
├── conftest.py # Root pytest conftest: sys.path hack for local sdk/
|
||||||
|
├── pyproject.toml # Project metadata, dependencies, ruff + pytest config
|
||||||
|
├── uv.lock # Lockfile (uv)
|
||||||
|
├── lambda_matrix.db # SQLite DB written by Matrix bot (gitignored)
|
||||||
|
└── .env.example # Environment variable template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Purposes
|
||||||
|
|
||||||
|
**`core/`:**
|
||||||
|
- Purpose: Platform-neutral business logic. Never imports from `adapter/`.
|
||||||
|
- Key files: `protocol.py` (all shared types), `handler.py` (dispatcher), `store.py` (persistence interface)
|
||||||
|
- Add new domain logic here; keep it free of aiogram/matrix-nio imports
|
||||||
|
|
||||||
|
**`core/handlers/`:**
|
||||||
|
- Purpose: One async function per command/callback/message type. Each returns `list[OutgoingEvent]`.
|
||||||
|
- Registration: `register_all()` in `core/handlers/__init__.py` binds them to the dispatcher
|
||||||
|
- Adapters can override any key by calling `dispatcher.register(event_type, key, fn)` after `register_all()`
|
||||||
|
|
||||||
|
**`sdk/`:**
|
||||||
|
- Purpose: Contract (`interface.py`) and mock (`mock.py`) for the Lambda AI platform SDK
|
||||||
|
- Note: The directory is named `sdk/` in actual code (not `platform/` as CLAUDE.md describes); `handler.py` imports from `sdk.interface`
|
||||||
|
- When real SDK arrives: replace `sdk/mock.py` only; `sdk/interface.py` must not change unless the contract changes
|
||||||
|
|
||||||
|
**`adapter/matrix/`:**
|
||||||
|
- Purpose: Everything matrix-nio-specific. Translates between nio and core protocol.
|
||||||
|
- `bot.py` owns `MatrixBot`, `build_runtime()`, `send_outgoing()`, and `main()`
|
||||||
|
- `store.py` provides key-namespaced helpers on top of `StateStore` (not a separate store implementation)
|
||||||
|
- `room_router.py` maintains the `room_id → chat_id` mapping persisted in `StateStore`
|
||||||
|
|
||||||
|
**`adapter/telegram/`:**
|
||||||
|
- Purpose: aiogram 3.x adapter. Lives in `.worktrees/telegram/` on `feat/telegram-adapter` branch.
|
||||||
|
- Uses aiogram FSM states (`states.py`) and inline keyboards (`keyboards/`)
|
||||||
|
- Not yet merged to `main`
|
||||||
|
|
||||||
|
**`tests/`:**
|
||||||
|
- Purpose: pytest test suite mirroring the source tree
|
||||||
|
- `tests/core/` — unit tests for each core module
|
||||||
|
- `tests/adapter/matrix/` — Matrix adapter tests (converter, dispatcher, reactions, store)
|
||||||
|
- `tests/platform/` — MockPlatformClient tests
|
||||||
|
|
||||||
|
**`docs/`:**
|
||||||
|
- Purpose: Human-readable design documents; not consumed by code
|
||||||
|
- Key docs: `docs/surface-protocol.md` (unification rationale), `docs/api-contract.md` (SDK contract), `docs/telegram-prototype.md`, `docs/matrix-prototype.md`
|
||||||
|
|
||||||
|
## Key File Locations
|
||||||
|
|
||||||
|
**Entry Points:**
|
||||||
|
- `adapter/matrix/bot.py` — Matrix bot `main()`, run via `python -m adapter.matrix.bot`
|
||||||
|
- `.worktrees/telegram/adapter/telegram/bot.py` — Telegram bot entry (feature branch)
|
||||||
|
|
||||||
|
**Shared Protocol:**
|
||||||
|
- `core/protocol.py` — single source of truth for all inter-layer data types
|
||||||
|
|
||||||
|
**SDK Contract:**
|
||||||
|
- `sdk/interface.py` — `PlatformClient` Protocol; defines the API surface for the real SDK
|
||||||
|
- `sdk/mock.py` — `MockPlatformClient`; current runtime implementation
|
||||||
|
|
||||||
|
**Dispatcher Registration:**
|
||||||
|
- `core/handlers/__init__.py` — `register_all()` for platform-agnostic handlers
|
||||||
|
- `adapter/matrix/handlers/__init__.py` — `register_matrix_handlers()` for Matrix overrides
|
||||||
|
|
||||||
|
**Persistence:**
|
||||||
|
- `core/store.py` — `StateStore` Protocol, `InMemoryStore`, `SQLiteStore`
|
||||||
|
- `adapter/matrix/store.py` — Matrix-specific store helper functions (not a store implementation)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- `pyproject.toml` — dependencies, pytest config (`asyncio_mode = "auto"`, `pythonpath = ["."]`), ruff config
|
||||||
|
- `conftest.py` — `sys.path` insert so local `sdk/` shadows stdlib `platform` module
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modules: `snake_case.py`
|
||||||
|
- Entry points: `bot.py` per adapter
|
||||||
|
- Converter: `converter.py` per adapter
|
||||||
|
- Handlers directory: `handlers/` per layer
|
||||||
|
|
||||||
|
**Classes:**
|
||||||
|
- Managers: `{Domain}Manager` (e.g. `ChatManager`, `AuthManager`, `SettingsManager`)
|
||||||
|
- Bot runtime: `{Platform}Bot` (e.g. `MatrixBot`)
|
||||||
|
- Protocol types: PascalCase dataclasses (e.g. `IncomingMessage`, `OutgoingUI`)
|
||||||
|
- SDK types: PascalCase Pydantic models (e.g. `MessageResponse`, `UserSettings`)
|
||||||
|
|
||||||
|
**Handler functions:**
|
||||||
|
- `handle_{command}` for command handlers (e.g. `handle_start`, `handle_new_chat`)
|
||||||
|
- `make_handle_{command}` for factory functions that close over adapter state (e.g. `make_handle_new_chat(client, store)`)
|
||||||
|
|
||||||
|
**State keys:**
|
||||||
|
- `"{namespace}:{discriminator}"` — always use the prefix constants defined in `adapter/matrix/store.py`
|
||||||
|
|
||||||
|
## Where to Add New Code
|
||||||
|
|
||||||
|
**New core command handler:**
|
||||||
|
1. Add `async def handle_{cmd}(event, chat_mgr, auth_mgr, settings_mgr, platform) -> list` in `core/handlers/{category}.py`
|
||||||
|
2. Register it in `core/handlers/__init__.py:register_all()` with `dispatcher.register(IncomingCommand, "{cmd}", handle_{cmd})`
|
||||||
|
3. Write tests in `tests/core/test_dispatcher.py` or a dedicated `tests/core/test_{category}.py`
|
||||||
|
|
||||||
|
**New Matrix-specific handler (needs nio client or matrix store):**
|
||||||
|
1. Add handler in `adapter/matrix/handlers/{category}.py`
|
||||||
|
2. Register in `adapter/matrix/handlers/__init__.py:register_matrix_handlers()` — this overrides the core handler for that key
|
||||||
|
|
||||||
|
**New protocol type:**
|
||||||
|
- Add dataclass to `core/protocol.py`; update `IncomingEvent` or `OutgoingEvent` union aliases if it crosses layer boundaries
|
||||||
|
- Update `EventDispatcher._routing_key()` if it requires a new dispatch strategy
|
||||||
|
|
||||||
|
**New StateStore key namespace:**
|
||||||
|
- Add prefix constant and helper functions in `adapter/matrix/store.py` (for Matrix-specific state) or directly in the relevant manager (for core state)
|
||||||
|
|
||||||
|
**New test:**
|
||||||
|
- Unit tests for core logic: `tests/core/test_{module}.py`
|
||||||
|
- Adapter tests: `tests/adapter/matrix/test_{module}.py`
|
||||||
|
- Use `InMemoryStore` as the store; use `MockPlatformClient` as the platform client
|
||||||
|
|
||||||
|
## Special Directories
|
||||||
|
|
||||||
|
**`.worktrees/telegram/`:**
|
||||||
|
- Purpose: Git worktree for `feat/telegram-adapter` branch; full copy of the repo root
|
||||||
|
- Generated: Yes (via `git worktree add`)
|
||||||
|
- Committed: No (worktrees are local)
|
||||||
|
|
||||||
|
**`.planning/`:**
|
||||||
|
- Purpose: GSD planning artefacts — phase plans and codebase maps
|
||||||
|
- Generated: Yes (by `/gsd:` commands)
|
||||||
|
- Committed: Yes (tracked with the repo)
|
||||||
|
|
||||||
|
**`.claude/agents/`:**
|
||||||
|
- Purpose: Agent role configuration files for the multi-agent workflow
|
||||||
|
- Committed: Yes
|
||||||
|
|
||||||
|
**`src/`:**
|
||||||
|
- Purpose: Contains only `surfaces_bot.egg-info/` (setuptools build artefact); no source code
|
||||||
|
- Generated: Yes
|
||||||
|
- Committed: No
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Structure analysis: 2026-04-01*
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,210 @@
|
||||||
# Тестирование (TESTING.md)
|
# Testing Patterns
|
||||||
|
|
||||||
## Unit-тесты
|
**Analysis Date:** 2026-04-01
|
||||||
Расположены в `tests/`. Покрытие сфокусировано на логике Matrix адаптера (пока он является основной поверхностью):
|
|
||||||
- Файловый контракт (`test_files.py`)
|
|
||||||
- Диспетчер и конвертация (`test_dispatcher.py`)
|
|
||||||
- Взаимодействие с PlatformClient (`test_routed_platform.py`)
|
|
||||||
- Работа с контекстными командами бота (`test_context_commands.py`)
|
|
||||||
|
|
||||||
## E2E тестирование
|
## Test Framework
|
||||||
Локально тестируется через запуск контейнеров из `docker-compose.fullstack.yml`, который поднимает один инстанс бота и один локальный `platform-agent`. Это позволяет имитировать полную цепочку взаимодействия (Matrix -> Бот -> Агент) с общим каталогом для файлов.
|
|
||||||
|
|
||||||
## Запуск тестов
|
**Runner:** pytest 8.x
|
||||||
```bash
|
**Config:** `pyproject.toml` `[tool.pytest.ini_options]`
|
||||||
# Запуск юнит-тестов (только для Matrix адаптера)
|
|
||||||
pytest tests/adapter/matrix/ -v
|
```toml
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Async support:** pytest-asyncio with `asyncio_mode = "auto"` — all `async def` test functions run automatically without decorators.
|
||||||
|
|
||||||
|
**Coverage:** pytest-cov (available but no minimum threshold configured)
|
||||||
|
|
||||||
|
**Run commands:**
|
||||||
|
```bash
|
||||||
|
pytest tests/ -v # all tests
|
||||||
|
pytest tests/core/ -v # core layer only
|
||||||
|
pytest tests/adapter/telegram/ -v # telegram adapter only
|
||||||
|
pytest tests/adapter/matrix/ -v # matrix adapter only
|
||||||
|
pytest tests/ --cov=. --cov-report=term # with coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── __init__.py
|
||||||
|
├── core/
|
||||||
|
│ ├── test_auth.py — AuthManager unit tests
|
||||||
|
│ ├── test_chat.py — ChatManager unit tests
|
||||||
|
│ ├── test_dispatcher.py — EventDispatcher routing tests
|
||||||
|
│ ├── test_integration.py — full flow smoke tests (dispatcher + managers + mock)
|
||||||
|
│ ├── test_protocol.py — dataclass defaults and construction
|
||||||
|
│ ├── test_settings.py — SettingsManager unit tests
|
||||||
|
│ ├── test_store.py — InMemoryStore + SQLiteStore tests
|
||||||
|
│ └── test_voice_slot.py — handle_message() handler unit tests
|
||||||
|
├── adapter/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── test_forum_db.py — Telegram SQLite DB helpers (untracked, new)
|
||||||
|
│ └── matrix/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── test_converter.py — matrix-nio event → IncomingEvent converter
|
||||||
|
│ ├── test_dispatcher.py — full Matrix bot integration (build_runtime)
|
||||||
|
│ ├── test_reactions.py — reaction text builders and emoji mapping
|
||||||
|
│ └── test_store.py — Matrix store helper functions
|
||||||
|
└── platform/
|
||||||
|
└── test_mock.py — MockPlatformClient behavior
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests mirror the source tree. New tests for `adapter/telegram/` go in `tests/adapter/telegram/` (directory exists in `.worktrees/telegram` branch, not yet merged to main).
|
||||||
|
|
||||||
|
## conftest.py
|
||||||
|
|
||||||
|
`conftest.py` at project root (`/Users/a/MAI/sem2/lambda/surfaces-bot/conftest.py`) handles a sys.path conflict: the project has a local `platform/` (now `sdk/`) package that shadows Python's stdlib `platform` module. It inserts the project root at `sys.path[0]` and removes the cached stdlib `platform` module.
|
||||||
|
|
||||||
|
No shared fixtures are defined in `conftest.py`. All fixtures are local to test files.
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
**Fixture pattern — local to each test file:**
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
def mgr():
|
||||||
|
return AuthManager(MockPlatformClient(), InMemoryStore())
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store() -> InMemoryStore:
|
||||||
|
return InMemoryStore()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Async tests require no decorator** (asyncio_mode = "auto"):
|
||||||
|
```python
|
||||||
|
async def test_not_authenticated_initially(mgr):
|
||||||
|
assert await mgr.is_authenticated("u1") is False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sync tests** are used for pure-function tests (protocol dataclass construction, reaction text builders):
|
||||||
|
```python
|
||||||
|
def test_incoming_message_defaults():
|
||||||
|
msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hi")
|
||||||
|
assert msg.attachments == []
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration fixture pattern** — builds full runtime in-process:
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
def dispatcher():
|
||||||
|
platform = MockPlatformClient()
|
||||||
|
store = InMemoryStore()
|
||||||
|
d = EventDispatcher(
|
||||||
|
platform=platform,
|
||||||
|
chat_mgr=ChatManager(platform, store),
|
||||||
|
auth_mgr=AuthManager(platform, store),
|
||||||
|
settings_mgr=SettingsManager(platform, store),
|
||||||
|
)
|
||||||
|
register_all(d)
|
||||||
|
return d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mocking Strategy
|
||||||
|
|
||||||
|
**Primary mock: `MockPlatformClient`** from `sdk/mock.py`
|
||||||
|
|
||||||
|
All tests use `MockPlatformClient()` directly — it is the real mock for the SDK layer. No unittest.mock patching of `MockPlatformClient` is needed.
|
||||||
|
|
||||||
|
**`unittest.mock.AsyncMock`** is used only when testing integration with external clients (matrix-nio `AsyncClient`):
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
client = SimpleNamespace(
|
||||||
|
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example"))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`types.SimpleNamespace`** is used to fabricate matrix-nio event objects without importing the full nio library:
|
||||||
|
```python
|
||||||
|
def text_event(body: str, sender: str = "@a:m.org", event_id: str = "$e1"):
|
||||||
|
return SimpleNamespace(
|
||||||
|
sender=sender, body=body, event_id=event_id, msgtype="m.text", replyto_event_id=None
|
||||||
|
)
|
||||||
|
```
|
||||||
|
This is the pattern for all matrix converter tests — define factory functions at module level that return `SimpleNamespace` objects.
|
||||||
|
|
||||||
|
**`tmp_path` pytest fixture** is used for SQLiteStore tests to get a throwaway database file:
|
||||||
|
```python
|
||||||
|
async def test_sqlite_set_and_get(tmp_path):
|
||||||
|
store = SQLiteStore(str(tmp_path / "test.db"))
|
||||||
|
```
|
||||||
|
|
||||||
|
**`monkeypatch.setenv`** is used in `tests/adapter/test_forum_db.py` to inject `DB_PATH` env var and reload the module with a fresh database:
|
||||||
|
```python
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(tmp_path, monkeypatch):
|
||||||
|
db_file = str(tmp_path / "test.db")
|
||||||
|
monkeypatch.setenv("DB_PATH", db_file)
|
||||||
|
import importlib
|
||||||
|
import adapter.telegram.db as db_mod
|
||||||
|
importlib.reload(db_mod)
|
||||||
|
db_mod.init_db()
|
||||||
|
return db_mod
|
||||||
|
```
|
||||||
|
|
||||||
|
**What NOT to mock:**
|
||||||
|
- `InMemoryStore` — use it directly; it's a real in-memory implementation
|
||||||
|
- `MockPlatformClient` — use it directly; patching it defeats the purpose
|
||||||
|
- Core manager classes (`AuthManager`, `ChatManager`, `SettingsManager`) — always instantiate real ones
|
||||||
|
|
||||||
|
## Test Data Patterns
|
||||||
|
|
||||||
|
**User IDs:** short strings like `"u1"`, `"u2"`, `"tg_123"`, `"@alice:m.org"`
|
||||||
|
|
||||||
|
**Chat IDs:** `"C1"`, `"C2"`, `"C3"` — matches the workspace slot naming
|
||||||
|
|
||||||
|
**Platform strings:** literal `"telegram"` or `"matrix"`
|
||||||
|
|
||||||
|
**Room IDs:** `"!r:m.org"`, `"!dm:example.org"` — valid Matrix room ID format
|
||||||
|
|
||||||
|
No shared factories or fixtures files. Test data is constructed inline or via simple factory functions local to the test module.
|
||||||
|
|
||||||
|
## What Is Tested
|
||||||
|
|
||||||
|
| Area | Status |
|
||||||
|
|------|--------|
|
||||||
|
| `core/protocol.py` — dataclass defaults | Covered (`test_protocol.py`) |
|
||||||
|
| `core/store.py` — InMemoryStore + SQLiteStore | Covered (`test_store.py`) |
|
||||||
|
| `core/auth.py` — AuthManager | Covered (`test_auth.py`) |
|
||||||
|
| `core/chat.py` — ChatManager | Covered (`test_chat.py`) |
|
||||||
|
| `core/settings.py` — SettingsManager | Covered (`test_settings.py`) |
|
||||||
|
| `core/handler.py` — EventDispatcher routing | Covered (`test_dispatcher.py`) |
|
||||||
|
| `core/handlers/message.py` — handle_message | Covered (`test_voice_slot.py`) |
|
||||||
|
| Full dispatcher + all core handlers integration | Covered (`test_integration.py`) |
|
||||||
|
| `sdk/mock.py` — MockPlatformClient | Covered (`test_mock.py`) |
|
||||||
|
| `adapter/matrix/converter.py` — event parsing | Covered (`test_converter.py`) |
|
||||||
|
| `adapter/matrix/store.py` — store helpers | Covered (`test_store.py`) |
|
||||||
|
| `adapter/matrix/reactions.py` — text builders | Covered (`test_reactions.py`) |
|
||||||
|
| `adapter/matrix/bot.py` — MatrixBot + build_runtime | Covered (`test_dispatcher.py`) |
|
||||||
|
| `adapter/telegram/db.py` — SQLite helpers | Covered (`test_forum_db.py`, untracked) |
|
||||||
|
|
||||||
|
## Coverage Gaps
|
||||||
|
|
||||||
|
**Telegram adapter handlers** — `adapter/telegram/handlers/` (`auth.py`, `chat.py`, `confirm.py`, `forum.py`, `settings.py`) have no tests in `main`. Tests exist only in `.worktrees/telegram` branch (not yet merged).
|
||||||
|
|
||||||
|
**Telegram converter** — `adapter/telegram/converter.py` has no tests in `main`.
|
||||||
|
|
||||||
|
**`core/handlers/callback.py` and `core/handlers/settings.py`** — tested indirectly through integration tests but lack dedicated unit tests.
|
||||||
|
|
||||||
|
**`adapter/matrix/room_router.py`** — `resolve_chat_id` has no direct unit tests; exercised only through `MatrixBot.on_room_message` integration path.
|
||||||
|
|
||||||
|
**`adapter/matrix/handlers/`** — individual handler files (`auth.py`, `chat.py`, `confirm.py`, `settings.py`) are tested only via `test_dispatcher.py` integration; no isolated unit tests.
|
||||||
|
|
||||||
|
**`sdk/mock.py` streaming** — `stream_message` is not tested; only `send_message` is covered.
|
||||||
|
|
||||||
|
**Error paths** — `ChatManager.rename` raises `ValueError` when chat not found; no test exercises this path. Same for `ChatManager.archive`.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- Test functions: `test_<behavior_under_test>` — descriptive, no abbreviations
|
||||||
|
- Fixture names match the object they create: `mgr`, `store`, `dispatcher`, `deps`
|
||||||
|
- Factory functions in converter tests: `text_event()`, `file_event()`, `image_event()`, `reaction_event()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Testing analysis: 2026-04-01*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
---
|
||||||
|
phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
|
||||||
|
task: 1
|
||||||
|
total_tasks: 2
|
||||||
|
status: paused
|
||||||
|
last_updated: 2026-04-07T21:29:48.982Z
|
||||||
|
---
|
||||||
|
|
||||||
|
<current_state>
|
||||||
|
Formally, the most recently active execution artifact inside the roadmap is still `01.1-03-PLAN.md`, which has not been implemented yet. In parallel, the platform-integration track has moved forward: the direct-agent Matrix prototype design is now approved, the implementation plan is written, and the next useful session should evaluate that spec/plan pair against the live platform repos before starting execution.
|
||||||
|
</current_state>
|
||||||
|
|
||||||
|
<completed_work>
|
||||||
|
|
||||||
|
- Re-analysed live platform repos on 2026-04-07 by cloning `platform/agent`, `platform/agent_api`, `platform/master`, and `platform/docs`.
|
||||||
|
- Confirmed `master` is still only a thin HTTP skeleton with `/health` and `/users/{user_id}`, not a chat/session/settings backend for surfaces.
|
||||||
|
- Confirmed `agent` exposes a working `/agent_ws/` WebSocket and `agent_api` provides enough protocol/client code to stream model output.
|
||||||
|
- Identified the real technical gap for a prototype: `agent` currently uses a singleton service with a fixed `thread_id="default"`, so all conversations would share memory unless that is parameterized.
|
||||||
|
- Derived and got approval for the prototype path: keep Matrix adapter logic largely intact, add `sdk/agent_session.py`, `sdk/prototype_state.py`, and `sdk/real.py`, keep settings local, and use the direct `agent` WebSocket for real messaging.
|
||||||
|
- Resolved the repo-placement question: the prototype stays in this repo on its own branch, not in a separate prototype repo.
|
||||||
|
- Resolved the platform-change minimization question: prefer patching only `platform/agent`, not `platform/agent_api`, and use a tiny local WebSocket client in this repo.
|
||||||
|
- Wrote and committed the approved design spec: `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md`.
|
||||||
|
- Wrote the implementation plan: `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`.
|
||||||
|
</completed_work>
|
||||||
|
|
||||||
|
<remaining_work>
|
||||||
|
|
||||||
|
- Task 1: Implement `adapter.matrix.reset` with `local-only`, `server-leave-forget`, and `--dry-run`, plus tests.
|
||||||
|
- Task 2: Update `README.md` so restart vs explicit reset workflow is documented and the old manual reset ritual is removed.
|
||||||
|
- Prototype evaluation follow-up: review the approved spec and plan against the platform repos before starting execution.
|
||||||
|
- Future prototype work: introduce `sdk/real.py` plus a narrow compatibility boundary that keeps Matrix adapter logic stable while allowing later expansion toward a fuller platform split.
|
||||||
|
</remaining_work>
|
||||||
|
|
||||||
|
<decisions_made>
|
||||||
|
|
||||||
|
- Do not integrate with `master` yet; it is still not the backend surfaces needs.
|
||||||
|
- Use the direct `agent` WebSocket as the only realistic path for a working prototype right now.
|
||||||
|
- Keep consumer-facing Matrix logic as intact as possible and absorb backend differences inside a shim under `sdk/`.
|
||||||
|
- Treat future platform evolution as likely to split into at least two concerns: control-plane access and direct agent session streaming.
|
||||||
|
- Keep the prototype in this repo on its own branch.
|
||||||
|
- Minimize platform-side changes by patching only `platform/agent` if possible.
|
||||||
|
</decisions_made>
|
||||||
|
|
||||||
|
<blockers>
|
||||||
|
- Phase 01.1 itself is not blocked; it is simply paused.
|
||||||
|
- Prototype blocker: the `agent` repo currently hardcodes a shared `thread_id`, so per-user/per-chat conversation isolation requires either a small upstream change or a careful workaround.
|
||||||
|
- Platform contract blocker remains for the longer-term Phase 02 direction: `master` still lacks stable user/chat/session/settings APIs for surfaces.
|
||||||
|
</blockers>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
The important mental model is now stable enough to execute. Full SDK integration through `master` is still premature, but a working Matrix prototype can be built now by talking directly to the `agent` WebSocket and hiding the split backend reality behind `sdk/real.py`. The approved design keeps the prototype in this repo, keeps settings local, and minimizes platform changes by preferring a tiny `platform/agent` patch over broader protocol churn. For evaluation and implementation context, inspect:
|
||||||
|
- local spec: `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md`
|
||||||
|
- local plan: `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`
|
||||||
|
- remote repos: `https://git.lambda.coredump.ru/platform/agent`, `https://git.lambda.coredump.ru/platform/master`, `https://git.lambda.coredump.ru/platform/agent_api`
|
||||||
|
- local clones: `/tmp/platform-agent`, `/tmp/platform-master`, `/tmp/platform-agent_api`
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<next_action>
|
||||||
|
Resume with one of these depending on priority:
|
||||||
|
1. Evaluate the approved prototype spec and implementation plan against the live platform repos and decide whether to start in this repo or patch `platform/agent` first.
|
||||||
|
2. If staying on roadmap execution, implement `01.1-03-PLAN.md` Task 1 (`adapter.matrix.reset`) first.
|
||||||
|
3. If starting prototype execution immediately, begin with Task 1 of `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`.
|
||||||
|
</next_action>
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
---
|
||||||
|
phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- adapter/matrix/reconcile.py
|
||||||
|
- tests/adapter/matrix/test_reconcile.py
|
||||||
|
autonomous: true
|
||||||
|
requirements: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A normal Matrix restart can rebuild missing local metadata from already joined Space/chat rooms instead of requiring a destructive reset."
|
||||||
|
- "Reconciliation restores the minimal local state needed for routing and chat operations: `matrix_user:*`, `matrix_room:*`, and missing `chat:{user}:{chat_id}` rows."
|
||||||
|
- "Reconciliation never provisions new Matrix rooms or Spaces while repairing local state."
|
||||||
|
- "Recovered users get `next_chat_index` advanced past the highest recovered `C*` chat id."
|
||||||
|
artifacts:
|
||||||
|
- path: "adapter/matrix/reconcile.py"
|
||||||
|
provides: "Matrix bootstrap reconciliation helpers and structured report objects."
|
||||||
|
- path: "tests/adapter/matrix/test_reconcile.py"
|
||||||
|
provides: "Regression coverage for startup and single-room reconciliation behavior."
|
||||||
|
key_links:
|
||||||
|
- from: "adapter/matrix/reconcile.py"
|
||||||
|
to: "adapter/matrix/store.py"
|
||||||
|
via: "set_user_meta and set_room_meta restore Matrix metadata"
|
||||||
|
pattern: "set_(user|room)_meta"
|
||||||
|
- from: "adapter/matrix/reconcile.py"
|
||||||
|
to: "core/chat.py"
|
||||||
|
via: "chat_mgr.get_or_create repairs missing `chat:*` rows"
|
||||||
|
pattern: "chat_mgr\\.get_or_create"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the non-destructive Matrix reconciliation layer that Phase 01.1 depends on.
|
||||||
|
|
||||||
|
Purpose: Per D-01 through D-07, the adapter must stop treating local SQLite state as the only truth in dev. Startup and recovery code need a single helper module that can rebuild local metadata from the homeserver room graph without creating duplicate Spaces or chats.
|
||||||
|
Output: `adapter/matrix/reconcile.py` with full-run and single-room recovery helpers, plus targeted pytest coverage.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md
|
||||||
|
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md
|
||||||
|
@.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md
|
||||||
|
@adapter/matrix/store.py
|
||||||
|
@adapter/matrix/handlers/auth.py
|
||||||
|
@core/chat.py
|
||||||
|
@tests/adapter/matrix/test_invite_space.py
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
From `adapter/matrix/store.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_room_meta(store: StateStore, room_id: str) -> dict | None
|
||||||
|
async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None
|
||||||
|
async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None
|
||||||
|
async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
From `core/chat.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_or_create(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
chat_id: str,
|
||||||
|
platform: str,
|
||||||
|
surface_ref: str,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> ChatContext
|
||||||
|
```
|
||||||
|
|
||||||
|
From Phase 01 room metadata shape:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"room_type": "chat",
|
||||||
|
"chat_id": "C4",
|
||||||
|
"display_name": "Чат 4",
|
||||||
|
"matrix_user_id": "@alice:example.org",
|
||||||
|
"space_id": "!space:example.org",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Add reconciliation module for startup and single-room recovery</name>
|
||||||
|
<files>adapter/matrix/reconcile.py, tests/adapter/matrix/test_reconcile.py</files>
|
||||||
|
<read_first>adapter/matrix/store.py, adapter/matrix/handlers/auth.py, core/chat.py, tests/adapter/matrix/test_invite_space.py, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: `reconcile_matrix_state(...)` recreates missing `matrix_user:*`, `matrix_room:*`, and `chat:*` entries from joined Matrix rooms without calling `room_create`.
|
||||||
|
- Test 2: `reconcile_matrix_state(...)` leaves already-correct local metadata intact and reports restored vs skipped/conflicting rooms.
|
||||||
|
- Test 3: `reconcile_single_room(...)` can repair one `unregistered:{room_id}` chat room on demand and recompute `next_chat_index` for that user.
|
||||||
|
- Test 4: Space rooms or unrelated joined rooms are skipped, not converted into chat rows.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `adapter/matrix/reconcile.py` as the authoritative recovery module for this phase. Implement a small, explicit API that Plan 02 can wire directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def reconcile_matrix_state(client: Any, store: StateStore, chat_mgr: ChatManager) -> dict: ...
|
||||||
|
async def reconcile_single_room(
|
||||||
|
client: Any, store: StateStore, chat_mgr: ChatManager, room_id: str, matrix_user_id: str
|
||||||
|
) -> dict: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside this module, add focused private helpers as needed for room classification, extracting room names, parsing `C<number>` ids, and recomputing `next_chat_index`. Keep the logic non-destructive per D-04:
|
||||||
|
- never call `room_create`, `room_invite`, or provisioning code from `handlers/auth.py`
|
||||||
|
- prefer already-hydrated room data from the post-sync client object, and only fall back to explicit room-state fetches if required for room classification
|
||||||
|
- rebuild only the minimal metadata required by D-03: `matrix_user:*`, `matrix_room:*`, and missing `chat:{user}:{chat_id}` records
|
||||||
|
- if `chat:*` exists but points at the wrong `surface_ref`, repair it from Matrix room metadata and include the fix in the returned report
|
||||||
|
- derive `next_chat_index` from the highest recovered `C<number>` for that user instead of trusting stale local counters
|
||||||
|
|
||||||
|
Return a structured reconciliation report with stable keys such as:
|
||||||
|
`joined_rooms`, `restored_user_meta`, `restored_room_meta`, `restored_chat_rows`, `repaired_chat_rows`, `skipped_rooms`, and `conflicts`.
|
||||||
|
|
||||||
|
Write `tests/adapter/matrix/test_reconcile.py` with lightweight `SimpleNamespace`/fake-client fixtures following the existing Matrix test style. Cover both full startup reconciliation and `reconcile_single_room(...)`. Assert that no provisioning calls are made during reconciliation, because D-04 forbids creating new Space/room topology while recovering local state.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_reconcile.py -q</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `adapter/matrix/reconcile.py` exports `reconcile_matrix_state` and `reconcile_single_room`.
|
||||||
|
- Reconciliation restores missing `matrix_user:*`, `matrix_room:*`, and `chat:*` entries for already-joined Matrix chat rooms per D-02 and D-03.
|
||||||
|
- Reconciliation does not call `room_create` or otherwise provision new server-side rooms per D-04.
|
||||||
|
- The report returned by reconciliation clearly distinguishes restored items, skipped rooms, and conflicts.
|
||||||
|
- `tests/adapter/matrix/test_reconcile.py` proves `next_chat_index` is recomputed from recovered chat ids rather than stale local state.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The repository has an executable, tested reconciliation layer that can rebuild local Matrix metadata after dev-state loss without duplicating server-side rooms.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Run `pytest tests/adapter/matrix/test_reconcile.py -q` and confirm startup and single-room reconciliation paths are covered.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Matrix recovery logic exists as a dedicated module instead of being scattered through handlers.
|
||||||
|
- Reconciliation is idempotent, non-destructive, and sufficient to restore routing/chat metadata from existing Matrix rooms.
|
||||||
|
- Plan 02 can wire startup and first-access recovery by calling exported functions rather than inventing new recovery logic.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
---
|
||||||
|
phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["01.1-01"]
|
||||||
|
files_modified:
|
||||||
|
- adapter/matrix/bot.py
|
||||||
|
- tests/adapter/matrix/test_dispatcher.py
|
||||||
|
autonomous: true
|
||||||
|
requirements: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "The Matrix bot performs an initial sync and reconciliation before entering steady-state `sync_forever()`."
|
||||||
|
- "If a room still arrives as `unregistered:{room_id}` after startup, the bot makes one targeted recovery attempt before dispatching or failing."
|
||||||
|
- "When reconciliation cannot repair a room, the bot logs a clear diagnostic reason instead of crashing on downstream commands like `!rename`."
|
||||||
|
artifacts:
|
||||||
|
- path: "adapter/matrix/bot.py"
|
||||||
|
provides: "Startup bootstrap flow with initial sync, reconciliation, and targeted runtime retry."
|
||||||
|
- path: "tests/adapter/matrix/test_dispatcher.py"
|
||||||
|
provides: "Matrix runtime coverage for pre-sync reconcile and on-message recovery behavior."
|
||||||
|
key_links:
|
||||||
|
- from: "adapter/matrix/bot.py"
|
||||||
|
to: "adapter/matrix/reconcile.py"
|
||||||
|
via: "startup bootstrap and single-room recovery calls"
|
||||||
|
pattern: "reconcile_(matrix_state|single_room)"
|
||||||
|
- from: "adapter/matrix/bot.py"
|
||||||
|
to: "adapter/matrix/room_router.py"
|
||||||
|
via: "unregistered room detection before dispatch"
|
||||||
|
pattern: "unregistered:"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Wire the new reconciliation layer into the actual Matrix runtime.
|
||||||
|
|
||||||
|
Purpose: D-05 through D-07 require restart recovery to be the default developer path. The bot must bootstrap itself from existing Matrix rooms on startup and make one on-demand repair attempt before routing an unknown room through the dispatcher.
|
||||||
|
Output: `adapter/matrix/bot.py` performs initial sync + reconciliation before `sync_forever()`, and runtime tests prove the bot recovers or logs clearly instead of blindly dispatching broken state.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md
|
||||||
|
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md
|
||||||
|
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-01-PLAN.md
|
||||||
|
@adapter/matrix/bot.py
|
||||||
|
@adapter/matrix/room_router.py
|
||||||
|
@adapter/matrix/reconcile.py
|
||||||
|
@tests/adapter/matrix/test_dispatcher.py
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
From `adapter/matrix/bot.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MatrixBot:
|
||||||
|
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None
|
||||||
|
|
||||||
|
async def main() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
From `adapter/matrix/reconcile.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def reconcile_matrix_state(client: Any, store: StateStore, chat_mgr: ChatManager) -> dict
|
||||||
|
async def reconcile_single_room(
|
||||||
|
client: Any, store: StateStore, chat_mgr: ChatManager, room_id: str, matrix_user_id: str
|
||||||
|
) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
From `adapter/matrix/room_router.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def resolve_chat_id(store: StateStore, room_id: str, matrix_user_id: str) -> str
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Run initial sync and reconciliation before the long-poll loop</name>
|
||||||
|
<files>adapter/matrix/bot.py, tests/adapter/matrix/test_dispatcher.py</files>
|
||||||
|
<read_first>adapter/matrix/bot.py, adapter/matrix/reconcile.py, tests/adapter/matrix/test_dispatcher.py, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: `main()` performs `client.sync(timeout=0, full_state=True)` before `sync_forever()`.
|
||||||
|
- Test 2: `main()` calls `reconcile_matrix_state(...)` after the initial sync and logs the returned report.
|
||||||
|
- Test 3: startup still reaches `sync_forever()` when reconciliation reports recoverable skips/conflicts instead of fatal failure.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Modify `adapter/matrix/bot.py` so normal startup follows the two-phase bootstrap recommended in research:
|
||||||
|
1. build client and runtime
|
||||||
|
2. authenticate
|
||||||
|
3. register callbacks
|
||||||
|
4. run `await client.sync(timeout=0, full_state=True)`
|
||||||
|
5. run `await reconcile_matrix_state(client, runtime.store, runtime.chat_mgr)`
|
||||||
|
6. log a structured `matrix_reconcile_complete` event with the report fields
|
||||||
|
7. enter `await client.sync_forever(timeout=30000)`
|
||||||
|
|
||||||
|
Do not move provisioning logic into startup. The startup step only rehydrates local state from server-side rooms per D-02 through D-04.
|
||||||
|
|
||||||
|
Update or add focused tests in `tests/adapter/matrix/test_dispatcher.py` using `monkeypatch`/fake-client patterns already used in the repo so the verify command proves the call order and logging-safe behavior. The test should fail if `sync_forever()` starts before reconciliation.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_dispatcher.py -q</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `adapter/matrix/bot.py` runs an initial full-state sync before steady-state polling.
|
||||||
|
- `adapter/matrix/bot.py` invokes `reconcile_matrix_state(...)` exactly once during startup.
|
||||||
|
- Startup logs a structured reconciliation summary instead of silently skipping the recovery step.
|
||||||
|
- `tests/adapter/matrix/test_dispatcher.py` asserts the bootstrap order explicitly.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Normal Matrix bot startup now includes a recovery pass before the event loop begins handling user traffic.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Retry unknown-room routing once before dispatching broken state</name>
|
||||||
|
<files>adapter/matrix/bot.py, tests/adapter/matrix/test_dispatcher.py</files>
|
||||||
|
<read_first>adapter/matrix/bot.py, adapter/matrix/room_router.py, adapter/matrix/reconcile.py, tests/adapter/matrix/test_dispatcher.py, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: `MatrixBot.on_room_message(...)` detects `unregistered:{room_id}`, runs `reconcile_single_room(...)`, then retries `resolve_chat_id(...)`.
|
||||||
|
- Test 2: if retry succeeds, the event is dispatched against the recovered logical chat id.
|
||||||
|
- Test 3: if retry still fails, the bot does not crash; it logs a clear warning and sends a user-facing diagnostic message to that room.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Extend `MatrixBot.on_room_message(...)` so D-07 is satisfied even when startup could not repair a room yet. Keep `resolve_chat_id(...)` as the room-router source of truth, but treat `unregistered:{room_id}` as a recovery trigger rather than a stable runtime identity:
|
||||||
|
- first call `resolve_chat_id(...)`
|
||||||
|
- if the result starts with `unregistered:`, call `reconcile_single_room(client, runtime.store, runtime.chat_mgr, room.room_id, event.sender)`
|
||||||
|
- immediately retry `resolve_chat_id(...)`
|
||||||
|
- only dispatch once a concrete logical chat id exists
|
||||||
|
- if the retry still returns `unregistered:{room_id}`, log a structured warning with room id, matrix user id, and reconciliation report, then send a short `OutgoingMessage`-equivalent Matrix text explaining that local state could not be restored automatically and a dev reset/restart may be required
|
||||||
|
|
||||||
|
Do not invent a new fallback chat id and do not auto-create rooms here; that would violate D-04. Keep this change inside `adapter/matrix/bot.py` so file ownership stays isolated for this plan.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_dispatcher.py -q</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Unknown Matrix rooms trigger one targeted reconciliation attempt before dispatch.
|
||||||
|
- Successful targeted recovery leads to normal dispatch with a real logical `chat_id`.
|
||||||
|
- Failed targeted recovery logs a clear diagnostic and avoids a handler crash on missing chat state per D-06.
|
||||||
|
- No code path in this task provisions new Matrix rooms or Spaces.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The runtime treats unknown rooms as recoverable state drift first, not as a silent routing failure or crash path.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Run `pytest tests/adapter/matrix/test_dispatcher.py -q` and confirm both startup-bootstrap and first-access recovery behaviors are covered.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- A standard Matrix restart now attempts recovery before the bot starts processing live events.
|
||||||
|
- Unknown-room events are diagnosable and recoverable instead of falling straight into broken command handling.
|
||||||
|
- The runtime never provisions new server-side rooms during restart reconciliation.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
---
|
||||||
|
phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- adapter/matrix/reset.py
|
||||||
|
- tests/adapter/matrix/test_reset.py
|
||||||
|
- README.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Developers have an explicit dev-only reset command instead of relying on memory or ad hoc shell history."
|
||||||
|
- "The default reset mode clears only local Matrix state and explains the manual Matrix-client cleanup that may still be needed."
|
||||||
|
- "Optional server cleanup is clearly named around leave/forget semantics and supports dry-run output."
|
||||||
|
artifacts:
|
||||||
|
- path: "adapter/matrix/reset.py"
|
||||||
|
provides: "Dev reset CLI for local-only, server-leave-forget, and dry-run workflows."
|
||||||
|
- path: "tests/adapter/matrix/test_reset.py"
|
||||||
|
provides: "CLI coverage for local reset behavior and printed operator guidance."
|
||||||
|
- path: "README.md"
|
||||||
|
provides: "Updated developer instructions for normal restart vs explicit reset."
|
||||||
|
key_links:
|
||||||
|
- from: "adapter/matrix/reset.py"
|
||||||
|
to: "README.md"
|
||||||
|
via: "documented invocation and manual Matrix cleanup guidance"
|
||||||
|
pattern: "adapter\\.matrix\\.reset"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Ship the dev reset workflow that complements normal restart reconciliation.
|
||||||
|
|
||||||
|
Purpose: D-08 through D-10 require a repeatable, explicit reset path for clean-room QA without making destructive cleanup the default restart flow. This plan creates the tool and updates the runbook developers actually use.
|
||||||
|
Output: `adapter/matrix/reset.py`, pytest coverage, and README instructions that replace the old `rm -f lambda_matrix.db` ritual.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md
|
||||||
|
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md
|
||||||
|
@README.md
|
||||||
|
@adapter/matrix/bot.py
|
||||||
|
@core/store.py
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
From `adapter/matrix/bot.py` env usage:
|
||||||
|
|
||||||
|
```python
|
||||||
|
db_path = os.environ.get("MATRIX_DB_PATH", "lambda_matrix.db")
|
||||||
|
store_path = os.environ.get("MATRIX_STORE_PATH", "matrix_store")
|
||||||
|
homeserver = os.environ.get("MATRIX_HOMESERVER")
|
||||||
|
user_id = os.environ.get("MATRIX_USER_ID")
|
||||||
|
```
|
||||||
|
|
||||||
|
From `core/store.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SQLiteStore:
|
||||||
|
def __init__(self, db_path: str) -> None: ...
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Add a dev-only Matrix reset CLI with explicit modes</name>
|
||||||
|
<files>adapter/matrix/reset.py, tests/adapter/matrix/test_reset.py</files>
|
||||||
|
<read_first>adapter/matrix/bot.py, core/store.py, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: `--mode local-only` deletes the configured local DB/store paths or reports what would be deleted in dry-run mode.
|
||||||
|
- Test 2: `--mode server-leave-forget --dry-run` prints the exact rooms it would leave/forget and does not mutate local files.
|
||||||
|
- Test 3: when server cleanup is not executed, the command prints the manual Matrix-client steps required by D-10.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `adapter/matrix/reset.py` as a CLI entrypoint runnable via `uv run python -m adapter.matrix.reset`. Use `argparse` and keep the tool explicitly dev-only in its help text and logs.
|
||||||
|
|
||||||
|
Implement the following modes from research and locked decisions:
|
||||||
|
- `local-only` (default destructive mode for local QA): remove `MATRIX_DB_PATH` and `MATRIX_STORE_PATH` if they exist; if not, report that they were already absent
|
||||||
|
- `server-leave-forget`: for the bot account only, log in using the same Matrix env vars as `adapter/matrix/bot.py`, inspect joined rooms, and call `room_leave()` + `room_forget()` for each joined room; support `--dry-run` so the operator can inspect the target set before mutation
|
||||||
|
- `--dry-run` must work with both modes and print a structured summary instead of mutating files or Matrix membership
|
||||||
|
|
||||||
|
Always print a post-run summary that distinguishes:
|
||||||
|
- what local files/directories were deleted or would be deleted
|
||||||
|
- what server-side leave/forget actions were executed or would be executed
|
||||||
|
- the manual Matrix client steps still required for a true clean-room QA rerun (leave/archive old rooms or Space in Element, accept fresh invites, etc.) when those actions are outside this phase
|
||||||
|
|
||||||
|
Write `tests/adapter/matrix/test_reset.py` to cover local-only deletion, dry-run output, and server-leave-forget dry-run behavior with fake clients/temporary directories. Follow the repo’s existing lightweight async test style.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_reset.py -q</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `adapter/matrix/reset.py` supports `local-only`, `server-leave-forget`, and `--dry-run`.
|
||||||
|
- `local-only` reset targets both `lambda_matrix.db` and `matrix_store` via env-aware paths per D-09.
|
||||||
|
- The tool never claims to globally delete Matrix rooms; it uses leave/forget semantics or prints manual cleanup instructions per D-10.
|
||||||
|
- `tests/adapter/matrix/test_reset.py` proves dry-run mode is non-destructive.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The repository contains a repeatable dev reset tool that replaces the undocumented shell ritual and names server-side cleanup honestly.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Replace the README reset ritual with the new restart and reset workflow</name>
|
||||||
|
<files>README.md</files>
|
||||||
|
<read_first>README.md, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md</read_first>
|
||||||
|
<action>
|
||||||
|
Update `README.md` so Matrix development instructions reflect Phase 01.1 instead of the old destructive reset ritual. Replace the current manual QA block that tells developers to `rm -f lambda_matrix.db` with a short, explicit split:
|
||||||
|
- normal restart: `PYTHONPATH=. uv run python -m adapter.matrix.bot` now performs reconciliation automatically
|
||||||
|
- explicit clean-room reset: `PYTHONPATH=. uv run python -m adapter.matrix.reset --mode local-only`
|
||||||
|
- optional server cleanup preview: `PYTHONPATH=. uv run python -m adapter.matrix.reset --mode server-leave-forget --dry-run`
|
||||||
|
|
||||||
|
State clearly that normal restart is the default path per D-05, and that full server-side cleanup may still require manual steps in the Matrix client. Keep the README concise; do not add production guidance or Phase 2 SDK content.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m adapter.matrix.reset --help >/tmp/matrix-reset-help.txt && rg -n "adapter.matrix.reset|local-only|server-leave-forget|reconciliation" README.md /tmp/matrix-reset-help.txt</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `README.md` no longer recommends raw `rm -f lambda_matrix.db` as the default Matrix restart workflow.
|
||||||
|
- `README.md` documents the normal restart path and the explicit reset path separately.
|
||||||
|
- The documented reset commands match the CLI implemented in `adapter/matrix/reset.py`.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Developers can follow a repeatable README workflow for ordinary restart and clean-room QA reset without relying on tribal knowledge.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Run `pytest tests/adapter/matrix/test_reset.py -q` and `python -m adapter.matrix.reset --help`, then confirm the README commands and help text stay aligned.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Dev reset is an explicit tool, not a remembered shell sequence.
|
||||||
|
- Local-only reset is automated and documented.
|
||||||
|
- Server cleanup semantics are honest, dry-runnable, and accompanied by manual Matrix-client guidance where needed.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Phase 01.1: Matrix restart reconciliation and dev reset workflow - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-04-03
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Сделать Matrix-адаптер пригодным для повторяемой локальной разработки и ручного QA без ручного “ритуала” удаления БД, выхода из всех комнат и пересоздания пользователя.
|
||||||
|
|
||||||
|
В scope этой фазы:
|
||||||
|
- безопасный restart flow для Matrix-бота после потери локального state
|
||||||
|
- reconciliation локального store с уже существующими Matrix rooms / Space
|
||||||
|
- отдельный dev reset workflow для controlled clean-room QA
|
||||||
|
- диагностируемое поведение при несогласованности local state и server-side Matrix state
|
||||||
|
|
||||||
|
Вне scope:
|
||||||
|
- реальный Lambda SDK
|
||||||
|
- новые пользовательские Matrix features
|
||||||
|
- E2EE
|
||||||
|
- production-grade multi-user migration framework
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Matrix state lifecycle
|
||||||
|
|
||||||
|
- **D-01:** Локальный SQLite store больше не должен считаться единственной точкой истины для Matrix runtime в dev workflow.
|
||||||
|
- **D-02:** При старте бот должен пытаться восстановить минимально необходимое локальное состояние из уже существующих Matrix rooms / Space, а не требовать full reset.
|
||||||
|
- **D-03:** Reconciliation должен восстанавливать как минимум `matrix_user:*`, `matrix_room:*` и missing `chat:{user}:{chat_id}` записи, если серверные комнаты уже существуют.
|
||||||
|
- **D-04:** Reconciliation не должен создавать новые Space/rooms, если задача — именно восстановление локального state после рестарта.
|
||||||
|
|
||||||
|
### Dev restart behavior
|
||||||
|
|
||||||
|
- **D-05:** Обычный restart бота должен быть основным путём для разработки; удаление `lambda_matrix.db` и `matrix_store` не должно быть обязательным для проверки workflow.
|
||||||
|
- **D-06:** Если local state неполон, бот должен либо восстановить его, либо логировать понятную причину, а не падать на командах вроде `!rename`.
|
||||||
|
- **D-07:** Несогласованность между `room_meta` и `ChatManager` должна обнаруживаться и устраняться автоматически на startup или при первом обращении.
|
||||||
|
|
||||||
|
### Dev reset workflow
|
||||||
|
|
||||||
|
- **D-08:** Нужен отдельный dev-only reset tool/script для controlled QA, вместо ручного набора shell-команд.
|
||||||
|
- **D-09:** Reset workflow должен как минимум поддерживать `local-only` reset: удаление `lambda_matrix.db` и `matrix_store` с понятной инструкцией, что делать с server-side Matrix rooms.
|
||||||
|
- **D-10:** Если full server-side cleanup не автоматизируется в этой фазе, tool должен явно печатать, какие ручные шаги обязательны в Matrix client.
|
||||||
|
|
||||||
|
### The agent's Discretion
|
||||||
|
|
||||||
|
- Точное место вызова reconciliation в startup flow
|
||||||
|
- Внутренняя структура helper-модуля (`bootstrap.py`, `reconcile.py` или аналог)
|
||||||
|
- Формат dev reset script и уровень автоматизации server-side cleanup
|
||||||
|
- Детали debug-logging и dry-run режима, если они помогают без раздувания scope
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- Главный критерий: после обычного restart бот не должен ломаться только потому, что local DB была сброшена или частично потеряна.
|
||||||
|
- Reset workflow должен быть явным и repeatable, а не завязанным на память разработчика.
|
||||||
|
- Нужно различать две ситуации:
|
||||||
|
- broken because code is wrong
|
||||||
|
- broken because local dev state was deliberately reset and requires reconciliation
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
### Matrix phase artifacts
|
||||||
|
- `.planning/phases/01-matrix-qa-polish/01-CONTEXT.md` — locked Matrix decisions from Phase 1
|
||||||
|
- `.planning/phases/01-matrix-qa-polish/01-VERIFICATION.md` — what Phase 1 already validated and what manual QA still expects
|
||||||
|
- `.planning/phases/01-matrix-qa-polish/01-HUMAN-UAT.md` — remaining real-client Matrix checks
|
||||||
|
|
||||||
|
### Current Matrix runtime
|
||||||
|
- `adapter/matrix/bot.py` — startup flow, sync loop, runtime wiring, DB/store env vars
|
||||||
|
- `adapter/matrix/store.py` — persisted Matrix metadata and pending confirmation keys
|
||||||
|
- `adapter/matrix/room_router.py` — room_id to chat_id resolution and current unregistered fallback
|
||||||
|
- `adapter/matrix/handlers/auth.py` — invite bootstrap that creates Space and first chat room
|
||||||
|
- `core/chat.py` — `ChatManager` persistence contract that currently breaks when local state is missing
|
||||||
|
|
||||||
|
### Supporting docs
|
||||||
|
- `docs/matrix-prototype.md` — intended Matrix UX and architecture direction
|
||||||
|
- `README.md` — current run instructions and existing manual QA/reset habits
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `adapter/matrix/store.py` already persists room/user metadata and is the obvious place to anchor reconciliation inputs.
|
||||||
|
- `adapter/matrix/room_router.py` already detects unknown rooms via `unregistered:{room_id}` fallback; this is a useful reconciliation trigger point.
|
||||||
|
- `core/chat.py` already has `get_or_create`, `rename`, `archive`, `list_active`; missing chat records can be rebuilt through this API instead of inventing a parallel format.
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Matrix runtime uses `SQLiteStore` for adapter-local metadata and `matrix-nio` room callbacks for transport events.
|
||||||
|
- Phase 1 already moved Matrix to Space+rooms and command-only confirmations, so this phase must preserve that model rather than reverting to DM-first simplifications.
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- Startup path in `adapter/matrix/bot.py:main()` is the natural place to run reconciliation before `sync_forever`.
|
||||||
|
- Invite/bootstrap path in `adapter/matrix/handlers/auth.py` is the existing source of truth for what metadata a healthy first room should have.
|
||||||
|
- `ChatManager` records and `matrix_room:*` metadata must stay consistent enough that commands like `!rename`, `!archive`, and `!chats` work after restart.
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Full production-grade migration of historical Matrix state across schema versions
|
||||||
|
- Automatic server-side deletion/leave for all Matrix rooms and Space during reset, if it requires broader admin semantics
|
||||||
|
- Any Phase 2 SDK integration work
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow*
|
||||||
|
*Context gathered: 2026-04-03*
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
# Phase 01.1: Matrix restart reconciliation and dev reset workflow - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-03
|
||||||
|
**Domain:** Matrix adapter restart reconciliation, local state recovery, dev reset workflow
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
- **D-01:** Локальный SQLite store больше не должен считаться единственной точкой истины для Matrix runtime в dev workflow.
|
||||||
|
- **D-02:** При старте бот должен пытаться восстановить минимально необходимое локальное состояние из уже существующих Matrix rooms / Space, а не требовать full reset.
|
||||||
|
- **D-03:** Reconciliation должен восстанавливать как минимум `matrix_user:*`, `matrix_room:*` и missing `chat:{user}:{chat_id}` записи, если серверные комнаты уже существуют.
|
||||||
|
- **D-04:** Reconciliation не должен создавать новые Space/rooms, если задача — именно восстановление локального state после рестарта.
|
||||||
|
- **D-05:** Обычный restart бота должен быть основным путём для разработки; удаление `lambda_matrix.db` и `matrix_store` не должно быть обязательным для проверки workflow.
|
||||||
|
- **D-06:** Если local state неполон, бот должен либо восстановить его, либо логировать понятную причину, а не падать на командах вроде `!rename`.
|
||||||
|
- **D-07:** Несогласованность между `room_meta` и `ChatManager` должна обнаруживаться и устраняться автоматически на startup или при первом обращении.
|
||||||
|
- **D-08:** Нужен отдельный dev-only reset tool/script для controlled QA, вместо ручного набора shell-команд.
|
||||||
|
- **D-09:** Reset workflow должен как минимум поддерживать `local-only` reset: удаление `lambda_matrix.db` и `matrix_store` с понятной инструкцией, что делать с server-side Matrix rooms.
|
||||||
|
- **D-10:** Если full server-side cleanup не автоматизируется в этой фазе, tool должен явно печатать, какие ручные шаги обязательны в Matrix client.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Точное место вызова reconciliation в startup flow
|
||||||
|
- Внутренняя структура helper-модуля (`bootstrap.py`, `reconcile.py` или аналог)
|
||||||
|
- Формат dev reset script и уровень автоматизации server-side cleanup
|
||||||
|
- Детали debug-logging и dry-run режима, если они помогают без раздувания scope
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
- Full production-grade migration of historical Matrix state across schema versions
|
||||||
|
- Automatic server-side deletion/leave for all Matrix rooms and Space during reset, if it requires broader admin semantics
|
||||||
|
- Any Phase 2 SDK integration work
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 01.1 should be planned as a bootstrap/recovery phase, not as another chat-feature phase. The current Matrix adapter has no startup reconciliation path: `adapter/matrix/bot.py` logs in and goes directly to `sync_forever()`, while routing and command handlers assume `matrix_room:*`, `matrix_user:*`, and `chat:*` keys already exist. That means local DB loss currently produces logical corruption, not just missing cache.
|
||||||
|
|
||||||
|
The safe standard approach is: perform a first sync that hydrates joined-room state, inspect the bot's current joined rooms and room state from the homeserver, rebuild the minimal local metadata needed for command routing, and only then enter the long-running sync loop. Reconciliation should be non-destructive and idempotent: if local keys already exist and match server state, leave them alone; if they are missing, recreate them; if they conflict, prefer the server room topology for Matrix-specific metadata and recreate missing `ChatManager` rows from that.
|
||||||
|
|
||||||
|
For reset, separate two workflows explicitly. `local-only` reset is the default and should be automated. Optional server-side cleanup may leave/forget rooms for the bot account, but it cannot promise global deletion of Matrix rooms for all members; if that is not automated, the tool must print the exact manual steps for the Matrix client.
|
||||||
|
|
||||||
|
**Primary recommendation:** Add a startup `reconcile_matrix_state()` step before `sync_forever()`, and ship a dev-only reset CLI with `local-only`, `server-leave-forget`, and `dry-run` modes.
|
||||||
|
|
||||||
|
## Project Constraints (from CLAUDE.md)
|
||||||
|
|
||||||
|
- Do not treat missing Lambda SDK as a blocker.
|
||||||
|
- Keep all platform calls behind `platform/interface.py`.
|
||||||
|
- Current runtime implementation is `platform/mock.py`; recommendations must work with that.
|
||||||
|
- Prefer architecture changes in adapters and core without coupling to future SDK internals.
|
||||||
|
- Use pytest-based verification.
|
||||||
|
- Do not recommend committing `.env`.
|
||||||
|
- Respect dependency order: `core/` first, then `platform/`, then adapters.
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| Python | 3.14.3 installed | Runtime for bot and scripts | Already available locally; codebase targets `>=3.11`. |
|
||||||
|
| `matrix-nio` | 0.25.2, published 2024-10-04 | Matrix client, sync, room membership/state APIs | Already installed; exposes the exact bootstrap/reset APIs this phase needs. |
|
||||||
|
| `SQLiteStore` (repo) | local | Adapter/core KV persistence | Existing persistence contract for `matrix_user:*`, `matrix_room:*`, and `chat:*`. |
|
||||||
|
| Matrix Client-Server API | spec latest | Authoritative room membership/state semantics | Needed to reason about restart recovery and leave/forget behavior correctly. |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| `pytest` | 9.0.2, published 2025-12-06 | Test runner | For targeted adapter/bootstrap regression tests. |
|
||||||
|
| `pytest-asyncio` | 1.3.0, published 2025-11-10 | Async test execution | For async reconciliation/reset flows. |
|
||||||
|
| `structlog` | 25.5.0, published 2025-10-27 | Diagnostics | For reconciliation summaries and conflict logging. |
|
||||||
|
| `python-dotenv` | 1.2.2, published 2026-03-01 | Env loading | Already used by `adapter/matrix/bot.py` for Matrix config. |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| Startup reconciliation from joined rooms + state | Force developers to wipe local DB and recreate rooms | Simpler code, but directly violates D-01, D-02, D-05. |
|
||||||
|
| Non-destructive local rebuild | Full auto-recreate of Space/rooms on missing local state | Easier to implement, but causes duplicate Matrix rooms and breaks D-04. |
|
||||||
|
| Dev reset script | README-only manual ritual | Lower code cost, but not repeatable and fails D-08..D-10. |
|
||||||
|
|
||||||
|
**Installation:**
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
**Version verification:** Verified via installed environment and PyPI metadata on 2026-04-03:
|
||||||
|
- `matrix-nio` `0.25.2` - 2024-10-04
|
||||||
|
- `pytest` `9.0.2` - 2025-12-06
|
||||||
|
- `pytest-asyncio` `1.3.0` - 2025-11-10
|
||||||
|
- `structlog` `25.5.0` - 2025-10-27
|
||||||
|
- `python-dotenv` `1.2.2` - 2026-03-01
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
```text
|
||||||
|
adapter/matrix/
|
||||||
|
├── bot.py # startup flow calls reconciliation before sync loop
|
||||||
|
├── reconcile.py # bootstrap/rebuild logic from Matrix server state
|
||||||
|
├── reset.py # dev-only reset CLI / entrypoint
|
||||||
|
├── room_router.py # room_id -> chat_id with recovery hook
|
||||||
|
├── store.py # metadata helpers, prefix scans, derived counters
|
||||||
|
└── handlers/
|
||||||
|
├── auth.py # first-time provisioning only
|
||||||
|
└── chat.py # uses recovered state, no provisioning fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Two-Phase Startup Bootstrap
|
||||||
|
**What:** Split startup into `login -> initial sync/full_state -> reconcile -> steady-state sync_forever`.
|
||||||
|
**When to use:** Always for Matrix bot startup when local DB may be missing or stale.
|
||||||
|
**Example:**
|
||||||
|
```python
|
||||||
|
# Source: matrix-nio AsyncClient docs/source + repo startup flow
|
||||||
|
client = AsyncClient(...)
|
||||||
|
runtime = build_runtime(store=SQLiteStore(db_path), client=client)
|
||||||
|
|
||||||
|
await login_or_restore_session(client)
|
||||||
|
await client.sync(timeout=0, full_state=True)
|
||||||
|
report = await reconcile_matrix_state(client, runtime.store, runtime.chat_mgr)
|
||||||
|
logger.info("matrix_reconcile_complete", **report)
|
||||||
|
await client.sync_forever(timeout=30000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Rebuild Local Metadata From Joined Rooms
|
||||||
|
**What:** Enumerate joined rooms, inspect local hydrated room objects or room state, and recreate missing `matrix_room:*`, `matrix_user:*`, and `chat:*` records.
|
||||||
|
**When to use:** On startup and optionally on `unregistered:{room_id}` fallback at runtime.
|
||||||
|
**Example:**
|
||||||
|
```python
|
||||||
|
# Source: matrix-nio AsyncClient.joined_rooms/room_get_state + repo store contracts
|
||||||
|
joined = await client.joined_rooms()
|
||||||
|
for room_id in joined.rooms:
|
||||||
|
state = await client.room_get_state(room_id)
|
||||||
|
# detect: space room vs chat room, owner user, child relationship, display name
|
||||||
|
# rebuild matrix_room:{room_id}
|
||||||
|
# rebuild chat:{matrix_user_id}:{chat_id} if absent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Non-Destructive Reconciliation Report
|
||||||
|
**What:** Return a structured report: scanned rooms, restored rooms, restored chats, conflicts, skipped rooms.
|
||||||
|
**When to use:** Every reconciliation run, including dry-run.
|
||||||
|
**Example:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"joined_rooms": 4,
|
||||||
|
"restored_user_meta": 1,
|
||||||
|
"restored_room_meta": 3,
|
||||||
|
"restored_chat_rows": 3,
|
||||||
|
"conflicts": [],
|
||||||
|
"skipped_rooms": ["!dm:example.org"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Reset Modes Are Explicit
|
||||||
|
**What:** Separate `local-only`, `server-leave-forget`, and `dry-run`.
|
||||||
|
**When to use:** For dev/QA only. Never mix destructive server cleanup into normal startup.
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
uv run python -m adapter.matrix.reset --mode local-only
|
||||||
|
uv run python -m adapter.matrix.reset --mode server-leave-forget --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
- **Provisioning during reconciliation:** Do not create a new Space or new rooms while trying to recover missing local state.
|
||||||
|
- **Treating `next_chat_index` as primary truth:** Derive it from recovered `chat_id` values after scan; do not trust a missing or stale counter.
|
||||||
|
- **Routing unknown rooms straight through:** `unregistered:{room_id}` is a signal to reconcile, not a stable runtime identity.
|
||||||
|
- **Destructive reset by default:** Startup must never leave/forget rooms automatically.
|
||||||
|
- **Blindly trusting local `surface_ref`:** If `chat:*` and `matrix_room:*` disagree, rebuild from Matrix room metadata and repair the chat row.
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Room discovery | Custom DB-only reconstruction heuristics | `AsyncClient.joined_rooms()` plus synced room state | Server already knows which rooms the bot joined. |
|
||||||
|
| Space membership detection | Naming-convention parsing of room names | Matrix state: `m.room.create.type`, `m.space.child`, `m.space.parent` | Names are mutable and non-authoritative. |
|
||||||
|
| Room cleanup semantics | Custom “delete room” assumptions | `room_leave()` + `room_forget()` semantics | Client API supports leave/forget, not guaranteed global deletion. |
|
||||||
|
| Chat ID recovery | Hardcoded `C1/C2/...` reset | Rebuild from existing `matrix_room:*`/server state and compute next index | Prevents collisions after partial DB loss. |
|
||||||
|
| Diagnostic output | Ad hoc `print()` strings | Structured reconciliation/reset report via `structlog` | Easier manual QA and failure triage. |
|
||||||
|
|
||||||
|
**Key insight:** The homeserver already persists the bot’s room graph. This phase should rehydrate local cache from that graph, not attempt to replace it with a second custom truth model.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Joining the sync loop before reconciliation
|
||||||
|
**What goes wrong:** Commands arrive while local metadata is still missing, producing `unregistered:{room_id}` routing or `ChatManager` misses.
|
||||||
|
**Why it happens:** Current `main()` enters `sync_forever()` immediately after login.
|
||||||
|
**How to avoid:** Perform initial sync and reconciliation first.
|
||||||
|
**Warning signs:** `unregistered_room` logs immediately after restart; `ValueError("Chat ... not found")` on `!rename` or `!archive`.
|
||||||
|
|
||||||
|
### Pitfall 2: Recovering room metadata but not chat rows
|
||||||
|
**What goes wrong:** Room routing works, but `ChatManager.rename/archive/list_active` still fails because `chat:{user}:{chat_id}` rows were not recreated.
|
||||||
|
**Why it happens:** Matrix adapter metadata and core chat metadata live in different keyspaces.
|
||||||
|
**How to avoid:** Reconciliation must repair both stores in one pass.
|
||||||
|
**Warning signs:** `matrix_room:*` exists but `chat:*` keys do not.
|
||||||
|
|
||||||
|
### Pitfall 3: Trusting stale `next_chat_index`
|
||||||
|
**What goes wrong:** New chats reuse existing `C` IDs after local recovery.
|
||||||
|
**Why it happens:** `next_chat_id()` increments a persisted counter that may be absent or behind.
|
||||||
|
**How to avoid:** After scan, set `next_chat_index = max(recovered_chat_numbers) + 1`.
|
||||||
|
**Warning signs:** New room gets `C1` even though Space already contains prior rooms.
|
||||||
|
|
||||||
|
### Pitfall 4: Assuming room names identify chat rooms safely
|
||||||
|
**What goes wrong:** Reconciliation binds the wrong room because a user renamed a room or Space.
|
||||||
|
**Why it happens:** Names are user-facing labels, not stable identifiers.
|
||||||
|
**How to avoid:** Prefer room state and existing `chat_id` metadata; use display names only as fallback.
|
||||||
|
**Warning signs:** Duplicate “Чат 1” names or renamed rooms break matching.
|
||||||
|
|
||||||
|
### Pitfall 5: Over-promising full cleanup
|
||||||
|
**What goes wrong:** Reset script claims a “clean slate” but rooms still exist in Element or for other members.
|
||||||
|
**Why it happens:** Leaving/forgetting affects the bot account’s membership/history, not necessarily global room deletion.
|
||||||
|
**How to avoid:** Name the mode accurately and print the manual client steps when needed.
|
||||||
|
**Warning signs:** QA reruns still show old rooms in the user’s client.
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
Verified patterns from official sources and the installed library surface:
|
||||||
|
|
||||||
|
### Initial Sync Before Reconcile
|
||||||
|
```python
|
||||||
|
# Source: matrix-nio AsyncClient.sync/sync_forever
|
||||||
|
await client.sync(timeout=0, full_state=True)
|
||||||
|
report = await reconcile_matrix_state(client, store, chat_mgr)
|
||||||
|
await client.sync_forever(timeout=30000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Space Child Link Creation
|
||||||
|
```python
|
||||||
|
# Source: Matrix client-server API state event + current auth/new-chat flow
|
||||||
|
await client.room_put_state(
|
||||||
|
room_id=space_id,
|
||||||
|
event_type="m.space.child",
|
||||||
|
content={"via": [homeserver]},
|
||||||
|
state_key=chat_room_id,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bot-Side Leave/Forget Cleanup
|
||||||
|
```python
|
||||||
|
# Source: matrix-nio AsyncClient.room_leave / room_forget
|
||||||
|
for room_id in room_ids:
|
||||||
|
await client.room_leave(room_id)
|
||||||
|
await client.room_forget(room_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Router Recovery Trigger
|
||||||
|
```python
|
||||||
|
# Source: repo room_router contract
|
||||||
|
chat_id = await resolve_chat_id(store, room_id, matrix_user_id)
|
||||||
|
if chat_id.startswith("unregistered:"):
|
||||||
|
await reconcile_single_room(client, store, chat_mgr, room_id, matrix_user_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Local adapter DB treated as the operational truth | Rebuildable local cache from server room graph | Mature Matrix client practice; supported by current Matrix CS API and `matrix-nio` | Restart no longer requires destructive local reset. |
|
||||||
|
| Manual room cleanup in client after experiments | Scripted leave/forget plus explicit manual instructions | Current `matrix-nio` 0.25.x API surface | QA becomes repeatable and auditable. |
|
||||||
|
| Immediate steady-state sync after login | Initial sync/full-state bootstrap before long polling | Supported by current `AsyncClient.sync()` / `sync_forever()` behavior | Reconciliation can run before any user traffic is handled. |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- `README.md` Matrix manual QA instruction `rm -f lambda_matrix.db` as the primary restart flow: outdated for this phase.
|
||||||
|
- DM-first Matrix recovery assumptions in `docs/matrix-prototype.md`: outdated relative to Phase 1 Space+rooms decisions.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **How exactly should reconciliation identify the owning Matrix user for a recovered room when local `matrix_room:*` is gone?**
|
||||||
|
- What we know: the bot can enumerate joined rooms and fetch room state; current healthy metadata stores `matrix_user_id` and `space_id`.
|
||||||
|
- What's unclear: whether Phase 1-created rooms also expose enough server-side structure to recover owner deterministically without existing local metadata in every case.
|
||||||
|
- Recommendation: Plan a proof test against a real homeserver/client. If room-state-only ownership is ambiguous, persist a tiny bot-authored marker state event going forward, but keep that addition narrowly scoped.
|
||||||
|
|
||||||
|
2. **Should runtime recovery happen only on startup, or also lazily on first unknown room access?**
|
||||||
|
- What we know: startup repair satisfies D-02/D-07 for common restart loss; `room_router` already surfaces unknown rooms cleanly.
|
||||||
|
- What's unclear: whether partial DB corruption during runtime is common enough to justify lazy single-room repair in Phase 01.1.
|
||||||
|
- Recommendation: Make startup reconciliation required, lazy room repair optional if it stays small.
|
||||||
|
|
||||||
|
3. **How much of server cleanup should Phase 01.1 automate?**
|
||||||
|
- What we know: `room_leave()` and `room_forget()` are available; global room deletion is not what the client API guarantees.
|
||||||
|
- What's unclear: whether automating bot-side leave/forget is worth the extra risk for this urgent phase.
|
||||||
|
- Recommendation: Keep `local-only` mandatory. Make server cleanup optional and clearly labeled experimental/dev-only if included.
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
| Dependency | Required By | Available | Version | Fallback |
|
||||||
|
|------------|------------|-----------|---------|----------|
|
||||||
|
| Python | Runtime, scripts, tests | ✓ | 3.14.3 | — |
|
||||||
|
| `uv` | Standard install/run workflow | ✓ | 0.9.30 | `python -m` + existing venv |
|
||||||
|
| `pytest` | Automated verification | ✓ | 9.0.2 | `uv run pytest` |
|
||||||
|
| Matrix homeserver credentials | Real restart/reset manual QA | ✗ in current shell | — | Manual-only after `.env` is configured |
|
||||||
|
| Matrix bot local DB/store paths | Reset workflow | ✓ | defaults in code | Can override with `MATRIX_DB_PATH` / `MATRIX_STORE_PATH` |
|
||||||
|
|
||||||
|
**Missing dependencies with no fallback:**
|
||||||
|
- Live Matrix credentials for real manual reconciliation/reset QA.
|
||||||
|
|
||||||
|
**Missing dependencies with fallback:**
|
||||||
|
- None for repository-only implementation and tests.
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | `pytest 9.0.2` + `pytest-asyncio 1.3.0` |
|
||||||
|
| Config file | `pyproject.toml` |
|
||||||
|
| Quick run command | `pytest tests/adapter/matrix -v` |
|
||||||
|
| Full suite command | `pytest tests/ -v` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| PH01.1-BOOT | Startup rebuilds missing `matrix_user:*`, `matrix_room:*`, and `chat:*` from existing rooms without creating new rooms | unit/integration | `pytest tests/adapter/matrix/test_reconcile.py -v` | ❌ Wave 0 |
|
||||||
|
| PH01.1-ROUTER | Unknown room fallback can trigger repair or yields diagnosable warning without crashing commands | unit | `pytest tests/adapter/matrix/test_room_router_reconcile.py -v` | ❌ Wave 0 |
|
||||||
|
| PH01.1-COUNTER | Reconciliation resets `next_chat_index` to recovered max + 1 | unit | `pytest tests/adapter/matrix/test_reconcile.py -k next_chat_index -v` | ❌ Wave 0 |
|
||||||
|
| PH01.1-RESET | Dev reset `local-only` removes local DB/store paths and prints next steps | unit/smoke | `pytest tests/adapter/matrix/test_reset.py -v` | ❌ Wave 0 |
|
||||||
|
| PH01.1-NONDESTRUCTIVE | Reconciliation never calls room creation APIs | unit | `pytest tests/adapter/matrix/test_reconcile.py -k no_create -v` | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `pytest tests/adapter/matrix -v`
|
||||||
|
- **Per wave merge:** `pytest tests/ -v`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `tests/adapter/matrix/test_reconcile.py` - startup reconciliation scenarios
|
||||||
|
- [ ] `tests/adapter/matrix/test_reset.py` - CLI/script reset modes and output
|
||||||
|
- [ ] `tests/adapter/matrix/test_room_router_reconcile.py` - lazy recovery or warning behavior
|
||||||
|
- [ ] Integration fixture for a fake `AsyncClient` response surface matching `joined_rooms()` and `room_get_state()`
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Matrix Client-Server API - room state, leave, forget, joined rooms, Spaces semantics: https://spec.matrix.org/latest/client-server-api/index.html
|
||||||
|
- `matrix-nio` installed 0.25.2 API surface verified locally on 2026-04-03 via `AsyncClient.sync`, `sync_forever`, `joined_rooms`, `room_get_state`, `room_leave`, `room_forget`
|
||||||
|
- Repo code: [adapter/matrix/bot.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/bot.py), [adapter/matrix/store.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/store.py), [adapter/matrix/room_router.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/room_router.py), [adapter/matrix/handlers/auth.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/handlers/auth.py), [core/chat.py](/Users/a/MAI/sem2/lambda/surfaces-bot/core/chat.py)
|
||||||
|
- PyPI release metadata: https://pypi.org/project/matrix-nio/ , https://pypi.org/project/pytest/ , https://pypi.org/project/pytest-asyncio/ , https://pypi.org/project/structlog/ , https://pypi.org/project/python-dotenv/
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- [README.md](/Users/a/MAI/sem2/lambda/surfaces-bot/README.md) - current manual reset habit and run commands
|
||||||
|
- [docs/matrix-prototype.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/matrix-prototype.md) - original Matrix UX intent, noting outdated DM/reaction sections
|
||||||
|
- [01-CONTEXT.md](/Users/a/MAI/sem2/lambda/surfaces-bot/.planning/phases/01-matrix-qa-polish/01-CONTEXT.md) - locked Phase 1 Matrix decisions
|
||||||
|
- [01-VERIFICATION.md](/Users/a/MAI/sem2/lambda/surfaces-bot/.planning/phases/01-matrix-qa-polish/01-VERIFICATION.md) - what has already been verified and what still needs human Matrix QA
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- None
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH - verified against installed environment, PyPI metadata, and official Matrix spec
|
||||||
|
- Architecture: HIGH - directly grounded in current repo flow plus current `matrix-nio`/Matrix capabilities
|
||||||
|
- Pitfalls: HIGH - derived from concrete gaps in current startup/store/router code
|
||||||
|
|
||||||
|
**Research date:** 2026-04-03
|
||||||
|
**Valid until:** 2026-05-03
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
phase: 01.1
|
||||||
|
slug: matrix-restart-reconciliation-and-dev-reset-workflow
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-04-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 01.1 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | `pytest 9.0.2` + `pytest-asyncio 1.3.0` |
|
||||||
|
| **Config file** | `pyproject.toml` |
|
||||||
|
| **Quick run command** | `pytest tests/adapter/matrix -v` |
|
||||||
|
| **Full suite command** | `pytest tests/ -v` |
|
||||||
|
| **Estimated runtime** | ~20 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `pytest tests/adapter/matrix -v`
|
||||||
|
- **After every plan wave:** Run `pytest tests/ -v`
|
||||||
|
- **Before `$gsd-verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 20 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 01.1-01-01 | 01 | 1 | PH01.1-BOOT | unit/integration | `pytest tests/adapter/matrix/test_reconcile.py -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 01.1-01-01 | 01 | 1 | PH01.1-COUNTER | unit | `pytest tests/adapter/matrix/test_reconcile.py -k next_chat_index -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 01.1-01-01 | 01 | 1 | PH01.1-NONDESTRUCTIVE | unit | `pytest tests/adapter/matrix/test_reconcile.py -k no_create -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 01.1-02-01 | 02 | 2 | PH01.1-BOOT | unit | `pytest tests/adapter/matrix/test_dispatcher.py -k startup -v` | ✅ | ⬜ pending |
|
||||||
|
| 01.1-02-02 | 02 | 2 | PH01.1-ROUTER | unit | `pytest tests/adapter/matrix/test_dispatcher.py -k reconcile -v` | ✅ | ⬜ pending |
|
||||||
|
| 01.1-03-01 | 03 | 1 | PH01.1-RESET | unit/smoke | `pytest tests/adapter/matrix/test_reset.py -v` | ❌ W0 | ⬜ pending |
|
||||||
|
| 01.1-03-02 | 03 | 1 | PH01.1-RESET | smoke | `python -m adapter.matrix.reset --help` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `tests/adapter/matrix/test_reconcile.py` — startup reconciliation scenarios, `next_chat_index`, and no-provisioning assertions
|
||||||
|
- [ ] `tests/adapter/matrix/test_reset.py` — CLI reset modes, dry-run behavior, and operator guidance output
|
||||||
|
- [ ] `tests/adapter/matrix/test_dispatcher.py` — startup bootstrap order and targeted unknown-room recovery coverage
|
||||||
|
- [ ] Fake `AsyncClient` fixture surface for joined rooms, room state, leave, and forget behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Reconciled Space/chat rooms render correctly in a real Matrix client after restart | PH01.1-BOOT | Client UX and homeserver state cannot be fully trusted from fake nio fixtures | 1. Start the bot with existing Space/chat rooms. 2. Verify the bot does not create duplicate Space or chat rooms. 3. Send a command in a recovered room and confirm it routes normally. |
|
||||||
|
| Server-side cleanup leaves the account in a usable Element state after `server-leave-forget` | PH01.1-RESET | Element/archive behavior and homeserver retention are client/server integration concerns | 1. Run `python -m adapter.matrix.reset --mode server-leave-forget --dry-run`. 2. Run without `--dry-run` on a test account. 3. Confirm joined rooms disappear for the bot and fresh invites can be accepted cleanly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 20s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
72
.planning/phases/02-prototype/.continue-here.md
Normal file
72
.planning/phases/02-prototype/.continue-here.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
---
|
||||||
|
phase: 02-prototype
|
||||||
|
task: 4
|
||||||
|
total_tasks: 4
|
||||||
|
status: paused
|
||||||
|
last_updated: 2026-04-07T23:54:30.473Z
|
||||||
|
---
|
||||||
|
|
||||||
|
<current_state>
|
||||||
|
The Matrix direct-agent prototype is implemented and manually proven working on branch `feat/matrix-direct-agent-prototype`. The current code path can log into Matrix, accept invites, provision the first Space/chat tree for a fresh user, and send live text messages to a patched local `platform-agent` over WebSocket. The immediate remaining engineering gap is not feature delivery but resilience: backend/provider failures can still bubble up as `PlatformError` and crash the Matrix bot process.
|
||||||
|
</current_state>
|
||||||
|
|
||||||
|
<completed_work>
|
||||||
|
|
||||||
|
- Task 1: Added `sdk/agent_session.py` and transport tests for direct WebSocket messaging with collision-safe `thread_key` generation.
|
||||||
|
- Task 2: Added `sdk/prototype_state.py` and tests for stable local user mapping, settings defaults, and mutation-safe settings copies.
|
||||||
|
- Task 3: Added `sdk/real.py` as the `PlatformClient` implementation, fixed import-time dependency leakage, and aligned thread-key tests to the actual dispatcher contract.
|
||||||
|
- Task 4: Wired Matrix runtime selection through `MATRIX_PLATFORM_BACKEND=real`, documented usage in `README.md`, and added dispatcher coverage for real backend selection.
|
||||||
|
- Fixed repeat Matrix invites so the bot now `join()`s before the existing-user early return path.
|
||||||
|
- Added Russian runbook doc `docs/matrix-direct-agent-prototype-ru.md` and pushed the branch.
|
||||||
|
- Manually validated live bring-up using a local patched `external/platform-agent` on port 8000 plus the Matrix homeserver `https://matrix.lambda.coredump.ru`.
|
||||||
|
</completed_work>
|
||||||
|
|
||||||
|
<remaining_work>
|
||||||
|
|
||||||
|
- Add graceful degradation for backend/provider failures so `PlatformError` does not crash the Matrix process.
|
||||||
|
- Decide whether to upstream or separately push the required `external/platform-agent` patch (`1dca2c1`) that enables WebSocket `thread_id`.
|
||||||
|
- Optionally clean up repeat-invite UX if Space/chat reprovisioning should ever happen for already-known users.
|
||||||
|
- Optionally prepare a PR from `feat/matrix-direct-agent-prototype`.
|
||||||
|
</remaining_work>
|
||||||
|
|
||||||
|
<decisions_made>
|
||||||
|
|
||||||
|
- Keep the prototype in this repo, not a separate Matrix-only repo.
|
||||||
|
- Keep Matrix adapter logic intact and absorb backend differences inside `sdk/`.
|
||||||
|
- Split the real backend into `AgentSessionClient` and `PrototypeStateStore` behind `RealPlatformClient`.
|
||||||
|
- Patch only `platform-agent` for per-thread memory instead of changing both `agent` and `agent_api`.
|
||||||
|
- Use a serialized collision-safe thread key because Matrix user IDs contain colons.
|
||||||
|
- For repeat invites, join the room but do not recreate Space/chat state if the user is already provisioned locally.
|
||||||
|
</decisions_made>
|
||||||
|
|
||||||
|
<blockers>
|
||||||
|
- Technical: provider/backend errors still crash the Matrix bot instead of returning a user-facing failure reply.
|
||||||
|
- External: the required `platform-agent` patch exists only in the local clone under `external/` and is not yet upstream.
|
||||||
|
- Operational: credentials used during manual bring-up were exposed in-session and should be rotated.
|
||||||
|
</blockers>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
The important mental model is stable. `platform/master` is still not the backend for surfaces, so the working prototype goes directly to `platform-agent` over `/agent_ws/`. The live setup that worked was:
|
||||||
|
- `surfaces-bot` branch: `feat/matrix-direct-agent-prototype`
|
||||||
|
- Matrix bot env: `MATRIX_PLATFORM_BACKEND=real`, `AGENT_WS_URL=ws://127.0.0.1:8000/agent_ws/`
|
||||||
|
- patched local `external/platform-agent` with `thread_id` support
|
||||||
|
- provider configured through OpenRouter using model `qwen/qwen3.5-122b-a10b`
|
||||||
|
|
||||||
|
Important files:
|
||||||
|
- `sdk/agent_session.py`
|
||||||
|
- `sdk/prototype_state.py`
|
||||||
|
- `sdk/real.py`
|
||||||
|
- `adapter/matrix/bot.py`
|
||||||
|
- `adapter/matrix/handlers/auth.py`
|
||||||
|
- `docs/matrix-direct-agent-prototype-ru.md`
|
||||||
|
|
||||||
|
Important local-only dependency:
|
||||||
|
- `external/platform-agent` commit `1dca2c1` (`feat: support websocket thread ids`)
|
||||||
|
|
||||||
|
Likely running background process at pause time:
|
||||||
|
- local `platform-agent` server on port 8000, PID 13499
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<next_action>
|
||||||
|
Start with the failure path: catch `PlatformError` around Matrix message handling so a bad provider response becomes a normal reply like “backend unavailable, try again later” instead of killing the process. After that, either upstream `external/platform-agent` commit `1dca2c1` or document it as an explicit prerequisite in the platform repo.
|
||||||
|
</next_action>
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
context: phase
|
||||||
|
phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
|
||||||
|
task: 4
|
||||||
|
total_tasks: 6
|
||||||
|
status: in_progress
|
||||||
|
last_updated: 2026-04-24T12:16:09.301Z
|
||||||
|
---
|
||||||
|
|
||||||
|
<current_state>
|
||||||
|
Debugging first-chunk truncation bug in Matrix bot. Logging added to both sdk/real.py and external/platform-agent/src/agent/service.py. Waiting for user to run docker compose up --build and share platform-agent logs with stream_event lines.
|
||||||
|
</current_state>
|
||||||
|
|
||||||
|
<completed_work>
|
||||||
|
|
||||||
|
- docker-compose.yml: added `./config:/app/config:ro` volume mount so MATRIX_AGENT_REGISTRY_PATH works
|
||||||
|
- config/matrix-agents.example.yaml: updated labels to Platform/Media
|
||||||
|
- sdk/real.py: added structlog debug logging in _stream_agent_events (logs each chunk index + text[:40])
|
||||||
|
- external/platform-agent/src/agent/service.py: added logging of langgraph_node, content_type, content[:60] for every on_chat_model_stream event
|
||||||
|
|
||||||
|
Bot is running and user confirmed it starts correctly with MATRIX_PLATFORM_BACKEND=real.
|
||||||
|
</completed_work>
|
||||||
|
|
||||||
|
<remaining_work>
|
||||||
|
|
||||||
|
- Task 4: Get platform-agent debug logs (docker compose up --build, reproduce truncation, share stream_event lines)
|
||||||
|
- Task 5: Analyze: check content_type (str vs list), check langgraph_node (which graph node produces the first chunk)
|
||||||
|
- Task 6: Fix service.py based on findings
|
||||||
|
</remaining_work>
|
||||||
|
|
||||||
|
<decisions_made>
|
||||||
|
|
||||||
|
- Bug confirmed to be in platform-agent, NOT in surfaces bot: our sdk/real.py logs show chunk index=0 already has truncated text (e.g. ' Д Е Ё...' instead of 'А Б В Г Д...')
|
||||||
|
- deepagents framework uses SubAgentMiddleware: main dispatcher agent + general-purpose subagent
|
||||||
|
- service.py processes ALL on_chat_model_stream events from astream_events v2 with no node filtering
|
||||||
|
- Two leading hypotheses: (A) chunk.content is a list for some events (multimodal), causing silent skip/error; (B) events from wrong graph node are being captured/not captured
|
||||||
|
</decisions_made>
|
||||||
|
|
||||||
|
<blockers>
|
||||||
|
- Need user to run docker compose up --build and share platform-agent logs with DEBUG output
|
||||||
|
</blockers>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
The deepagents architecture: create_deep_agent creates a main orchestrator with SubAgentMiddleware wrapping a general-purpose subagent. When astream_events v2 runs, it may emit on_chat_model_stream from both the main agent's LLM call AND the subagent's LLM call. service.py captures ALL of them. The first chunk of the actual response might be from the subagent (not forwarded to client properly), while the main agent's response starts mid-sentence because it "sees" the subagent's output in its tool result context.
|
||||||
|
|
||||||
|
Two key things to look for in logs:
|
||||||
|
1. content_type=list → fix is `chunk.content[0].get("text", "")` or similar
|
||||||
|
2. langgraph_node varies between chunks → fix is to filter to the correct node (e.g. only "agent" node)
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<next_action>
|
||||||
|
Start with: docker compose up --build. Then send a message with image context (e.g. send an image first, then ask 'Напомни алфавит'). Share platform-agent-1 logs — specifically the stream_event lines showing ns= and content_type= values.
|
||||||
|
</next_action>
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
---
|
|
||||||
phase: 05-mvp-deployment
|
|
||||||
plan: 01
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- adapter/matrix/reconciliation.py
|
|
||||||
- adapter/matrix/bot.py
|
|
||||||
- tests/adapter/matrix/test_reconciliation.py
|
|
||||||
- tests/adapter/matrix/test_restart_persistence.py
|
|
||||||
autonomous: true
|
|
||||||
requirements:
|
|
||||||
- PH05-01
|
|
||||||
- PH05-03
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "On restart, existing Matrix Space and child-room topology is rebuilt before live sync begins."
|
|
||||||
- "Restart recovery preserves Space+rooms UX instead of creating duplicate DM-style working rooms."
|
|
||||||
- "Recovered rooms regain user metadata, room metadata, and chat bindings needed for normal routing."
|
|
||||||
- "Legacy working rooms missing `platform_chat_id` are backfilled deterministically during startup before strict routing handles traffic."
|
|
||||||
artifacts:
|
|
||||||
- path: "adapter/matrix/reconciliation.py"
|
|
||||||
provides: "Authoritative restart reconciliation from Matrix topology into local metadata"
|
|
||||||
- path: "adapter/matrix/bot.py"
|
|
||||||
provides: "Startup wiring that runs reconciliation before sync_forever"
|
|
||||||
- path: "tests/adapter/matrix/test_reconciliation.py"
|
|
||||||
provides: "Regression coverage for startup recovery and idempotence"
|
|
||||||
key_links:
|
|
||||||
- from: "adapter/matrix/bot.py"
|
|
||||||
to: "adapter/matrix/reconciliation.py"
|
|
||||||
via: "startup bootstrap before sync_forever"
|
|
||||||
pattern: "reconcil"
|
|
||||||
- from: "adapter/matrix/reconciliation.py"
|
|
||||||
to: "core/chat.py"
|
|
||||||
via: "chat manager rebuild for recovered rooms"
|
|
||||||
pattern: "get_or_create"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Rebuild Matrix-local routing state from authoritative Space topology before the bot processes live traffic.
|
|
||||||
|
|
||||||
Purpose: Preserve the Phase 01 Space+rooms contract after restart even if SQLite metadata is partial or missing.
|
|
||||||
Output: A startup reconciliation module, bot wiring, and regression tests proving no DM-first duplication on restart.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
|
||||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/ROADMAP.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/05-mvp-deployment/05-RESEARCH.md
|
|
||||||
@.planning/phases/05-mvp-deployment/05-VALIDATION.md
|
|
||||||
@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md
|
|
||||||
@adapter/matrix/bot.py
|
|
||||||
@adapter/matrix/store.py
|
|
||||||
@adapter/matrix/handlers/auth.py
|
|
||||||
@tests/adapter/matrix/test_invite_space.py
|
|
||||||
@tests/adapter/matrix/test_chat_space.py
|
|
||||||
@tests/adapter/matrix/test_restart_persistence.py
|
|
||||||
|
|
||||||
<interfaces>
|
|
||||||
From `adapter/matrix/bot.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def prepare_live_sync(client: AsyncClient) -> str | None:
|
|
||||||
response = await client.sync(timeout=0, full_state=True)
|
|
||||||
if isinstance(response, SyncResponse):
|
|
||||||
return response.next_batch
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MatrixBot:
|
|
||||||
async def _bootstrap_unregistered_room(
|
|
||||||
self,
|
|
||||||
room: MatrixRoom,
|
|
||||||
sender: str,
|
|
||||||
) -> list[OutgoingEvent] | None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
From `adapter/matrix/store.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def get_room_meta(store: StateStore, room_id: str) -> dict | None: ...
|
|
||||||
async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None: ...
|
|
||||||
async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None: ...
|
|
||||||
async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None: ...
|
|
||||||
async def next_platform_chat_id(store: StateStore) -> str: ...
|
|
||||||
```
|
|
||||||
</interfaces>
|
|
||||||
</context>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="auto" tdd="true">
|
|
||||||
<name>Task 1: Add restart reconciliation regression coverage</name>
|
|
||||||
<files>tests/adapter/matrix/test_reconciliation.py, tests/adapter/matrix/test_restart_persistence.py</files>
|
|
||||||
<read_first>tests/adapter/matrix/test_invite_space.py, tests/adapter/matrix/test_chat_space.py, tests/adapter/matrix/test_restart_persistence.py, adapter/matrix/bot.py, adapter/matrix/handlers/auth.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md</read_first>
|
|
||||||
<behavior>
|
|
||||||
- Test 1: startup recovery rebuilds user space metadata, room metadata, and chat bindings from Matrix topology without creating new working rooms (per D-Phase05-reset and PH05-01).
|
|
||||||
- Test 2: reconciliation is idempotent and safe when local SQLite state is already present.
|
|
||||||
- Test 3: reconciliation happens before lazy `_bootstrap_unregistered_room()` would run for existing rooms (per PH05-03).
|
|
||||||
- Test 4: legacy room metadata missing `platform_chat_id` is backfilled deterministically at startup and persisted before routed handling begins.
|
|
||||||
</behavior>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `tests/adapter/matrix/test_reconciliation.py` exists and names reconciliation entrypoints explicitly.
|
|
||||||
- The new tests assert restored `space_id`, `chat_id`, `matrix_user_id`, and `platform_chat_id` values for recovered rooms.
|
|
||||||
- The regression slice also proves existing Space onboarding behavior still passes by running `test_invite_space.py` and `test_chat_space.py`.
|
|
||||||
- The automated command in `<verify>` fails before implementation or would fail if reconciliation is removed.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<action>Create a dedicated `tests/adapter/matrix/test_reconciliation.py` module and extend restart persistence coverage so Phase 05 has a real Wave 0 contract. Model the recovered topology after the Phase 01 Space+rooms onboarding tests, not a DM-first flow, and explicitly keep those onboarding regressions in the verification slice so restart hardening cannot break provisioning UX. Cover recovery of `user_meta`, `room_meta`, `ChatManager` bindings, and room-local routing fields from Matrix-side state before live callbacks begin, including deterministic backfill for legacy rooms that predate `platform_chat_id`. Keep temporary UX state out of scope, per research.</action>
|
|
||||||
<verify>
|
|
||||||
<automated>pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v</automated>
|
|
||||||
</verify>
|
|
||||||
<done>Phase 05 has failing-or-red-before-code tests that define authoritative restart reconciliation behavior and exclude duplicate room provisioning.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto" tdd="true">
|
|
||||||
<name>Task 2: Implement authoritative startup reconciliation and wire it before live sync</name>
|
|
||||||
<files>adapter/matrix/reconciliation.py, adapter/matrix/bot.py</files>
|
|
||||||
<read_first>adapter/matrix/bot.py, adapter/matrix/store.py, adapter/matrix/handlers/auth.py, tests/adapter/matrix/test_reconciliation.py, tests/adapter/matrix/test_restart_persistence.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md</read_first>
|
|
||||||
<behavior>
|
|
||||||
- Test 1: startup rebuild runs after login and initial full-state fetch, but before `sync_forever()` processes live events.
|
|
||||||
- Test 2: recovered rooms keep their existing Space+rooms identity and do not trigger `_bootstrap_unregistered_room()` unless the room is genuinely new.
|
|
||||||
- Test 3: local metadata can be rebuilt from Matrix topology when SQLite entries are missing, while existing valid metadata remains stable.
|
|
||||||
- Test 4: startup repair assigns a deterministic `platform_chat_id` to legacy rooms missing that field and persists it before routed platform calls can occur.
|
|
||||||
</behavior>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `adapter/matrix/reconciliation.py` exports a focused reconciliation entrypoint used by startup code.
|
|
||||||
- `adapter/matrix/bot.py` invokes reconciliation before `client.sync_forever(...)`.
|
|
||||||
- Recovered room metadata includes `room_type`, `chat_id`, `space_id`, `matrix_user_id`, and `platform_chat_id` where available or rebuildable.
|
|
||||||
- Legacy rooms missing `platform_chat_id` follow one documented startup backfill path rather than ad hoc routing fallbacks.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<action>Implement a restart recovery module that treats Matrix topology as authoritative, per the Phase 05 reset and research notes. Rebuild missing local metadata for Space-owned working rooms, deterministically backfill missing `platform_chat_id` values for legacy rooms, and re-create `ChatManager` entries needed by routing, while keeping SQLite as a rebuildable cache rather than the source of truth. Wire the new reconciliation step into startup after the initial full-state sync and before live sync begins, and keep the onboarding regression slice green while doing it. Do not widen into timeline scraping, new storage backends, or DM-first fallbacks.</action>
|
|
||||||
<verify>
|
|
||||||
<automated>pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v</automated>
|
|
||||||
</verify>
|
|
||||||
<done>Restart recovery restores the minimum durable state for existing Space rooms before live traffic, and the guarded regression suite passes.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
Run the onboarding, reconciliation, restart-persistence, and Matrix dispatcher slices together. Confirm startup now has a deterministic pre-sync recovery and legacy-room backfill step instead of relying on lazy room bootstrap or routing-time fallbacks for existing topology.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
The bot can restart with partial or empty local room metadata, rebuild managed Space rooms before live sync, and continue handling those rooms without creating duplicate onboarding rooms.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
After completion, create `.planning/phases/05-mvp-deployment/05-01-SUMMARY.md`
|
|
||||||
</output>
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
---
|
|
||||||
phase: 05-mvp-deployment
|
|
||||||
plan: 01
|
|
||||||
subsystem: infra
|
|
||||||
tags: [matrix, reconciliation, sqlite, startup, testing]
|
|
||||||
requires:
|
|
||||||
- phase: 01-matrix-mvp
|
|
||||||
provides: Space+rooms onboarding, room metadata, and Matrix dispatcher behavior
|
|
||||||
- phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
|
|
||||||
provides: durable platform_chat_id and restart persistence primitives
|
|
||||||
provides:
|
|
||||||
- authoritative startup reconciliation from Matrix room topology into local metadata
|
|
||||||
- pre-sync startup wiring that repairs managed rooms before live traffic
|
|
||||||
- restart regression coverage for reconciliation, idempotence, and legacy platform_chat_id backfill
|
|
||||||
affects: [matrix, startup, deployment, restart-persistence]
|
|
||||||
tech-stack:
|
|
||||||
added: []
|
|
||||||
patterns: [matrix-topology-as-source-of-truth, sqlite-cache-rebuild, pre-sync-reconciliation]
|
|
||||||
key-files:
|
|
||||||
created: [adapter/matrix/reconciliation.py, tests/adapter/matrix/test_reconciliation.py]
|
|
||||||
modified: [adapter/matrix/bot.py, tests/adapter/matrix/test_restart_persistence.py]
|
|
||||||
key-decisions:
|
|
||||||
- "Treat synced Matrix parent/child topology as authoritative for managed room recovery; keep SQLite rebuildable."
|
|
||||||
- "Backfill missing platform_chat_id values during startup reconciliation instead of routing-time fallbacks."
|
|
||||||
patterns-established:
|
|
||||||
- "Startup runs full-state sync, then reconciliation, then sync_forever."
|
|
||||||
- "Recovered Matrix rooms rebuild user_meta, room_meta, auth state, and ChatManager bindings idempotently."
|
|
||||||
requirements-completed: [PH05-01, PH05-03]
|
|
||||||
duration: 8min
|
|
||||||
completed: 2026-04-27
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 05 Plan 01: Restart Reconciliation Summary
|
|
||||||
|
|
||||||
**Matrix startup now rebuilds Space-owned working rooms into durable local routing state before live sync begins**
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **Duration:** 8 min
|
|
||||||
- **Started:** 2026-04-27T22:00:47Z
|
|
||||||
- **Completed:** 2026-04-27T22:08:47Z
|
|
||||||
- **Tasks:** 2
|
|
||||||
- **Files modified:** 4
|
|
||||||
|
|
||||||
## Accomplishments
|
|
||||||
- Added a dedicated reconciliation module that restores `user_meta`, `room_meta`, auth state, chat bindings, and missing `platform_chat_id` values from the synced Matrix room graph.
|
|
||||||
- Wired startup to run reconciliation immediately after the initial full-state sync and before `sync_forever()`.
|
|
||||||
- Added regression coverage for recovery, idempotence, pre-sync ordering, onboarding compatibility, and legacy restart backfill.
|
|
||||||
|
|
||||||
## Task Commits
|
|
||||||
|
|
||||||
Each task was committed atomically:
|
|
||||||
|
|
||||||
1. **Task 1: Add restart reconciliation regression coverage** - `a75b26a` (test)
|
|
||||||
2. **Task 2: Implement authoritative startup reconciliation and wire it before live sync** - `8a80d00` (feat)
|
|
||||||
|
|
||||||
## Files Created/Modified
|
|
||||||
- `adapter/matrix/reconciliation.py` - Startup recovery from Matrix topology into local room and user metadata.
|
|
||||||
- `adapter/matrix/bot.py` - Startup wiring that runs reconciliation after the bootstrap sync and before live sync.
|
|
||||||
- `tests/adapter/matrix/test_reconciliation.py` - Recovery, idempotence, and startup-order regression coverage.
|
|
||||||
- `tests/adapter/matrix/test_restart_persistence.py` - Legacy `platform_chat_id` backfill persistence coverage.
|
|
||||||
|
|
||||||
## Decisions Made
|
|
||||||
- Used the synced Matrix room graph as the authoritative source for restart recovery, while preserving existing local metadata whenever it is already valid.
|
|
||||||
- Kept legacy `platform_chat_id` repair on a single startup path so routed handling never needs ad hoc fallback creation for existing rooms.
|
|
||||||
|
|
||||||
## Deviations from Plan
|
|
||||||
|
|
||||||
### Auto-fixed Issues
|
|
||||||
|
|
||||||
**1. [Rule 3 - Blocking] Switched verification to a clean `uv run pytest` environment**
|
|
||||||
- **Found during:** Task 1 and Task 2 verification
|
|
||||||
- **Issue:** The default `pytest` path used a mismatched virtualenv without repo dependencies, and `.env` injected Matrix backend variables that polluted mock-mode tests.
|
|
||||||
- **Fix:** Ran the verification slice through `uv run pytest` with `UV_CACHE_DIR=/tmp/uv-cache-surfaces` and blank `MATRIX_AGENT_REGISTRY_PATH` / `MATRIX_PLATFORM_BACKEND` values to match the intended test environment.
|
|
||||||
- **Files modified:** None
|
|
||||||
- **Verification:** `uv run pytest` slice passed with 50/50 tests green
|
|
||||||
- **Committed in:** not applicable (verification-only adjustment)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Total deviations:** 1 auto-fixed (1 blocking)
|
|
||||||
**Impact on plan:** Verification needed an environment correction, but code scope stayed within the plan and owned files.
|
|
||||||
|
|
||||||
## Issues Encountered
|
|
||||||
- The shell environment loaded deployment-oriented Matrix backend settings from `.env`; these had to be neutralized for the mock-mode regression slice.
|
|
||||||
|
|
||||||
## User Setup Required
|
|
||||||
|
|
||||||
None - no external service configuration required.
|
|
||||||
|
|
||||||
## Next Phase Readiness
|
|
||||||
- Restart recovery is in place for existing Space rooms, including deterministic legacy `platform_chat_id` repair.
|
|
||||||
- Remaining Phase 05 plans can build on a stable pre-sync recovery path instead of lazy bootstrap for existing topology.
|
|
||||||
|
|
||||||
## Self-Check: PASSED
|
|
||||||
|
|
||||||
---
|
|
||||||
*Phase: 05-mvp-deployment*
|
|
||||||
*Completed: 2026-04-27*
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
---
|
|
||||||
phase: 05-mvp-deployment
|
|
||||||
plan: 02
|
|
||||||
type: execute
|
|
||||||
wave: 2
|
|
||||||
depends_on:
|
|
||||||
- 05-01
|
|
||||||
files_modified:
|
|
||||||
- adapter/matrix/handlers/__init__.py
|
|
||||||
- adapter/matrix/handlers/context_commands.py
|
|
||||||
- adapter/matrix/routed_platform.py
|
|
||||||
- tests/adapter/matrix/test_context_commands.py
|
|
||||||
- tests/adapter/matrix/test_routed_platform.py
|
|
||||||
autonomous: true
|
|
||||||
requirements:
|
|
||||||
- PH05-02
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "Each working Matrix room uses its own durable `platform_chat_id` as the real agent context boundary."
|
|
||||||
- "`!clear` resets only the current room by rotating its `platform_chat_id` and disconnecting the old upstream chat."
|
|
||||||
- "Save, load, context, and routed send paths resolve through room-local platform context, not shared user state."
|
|
||||||
- "Strict room routing assumes startup reconciliation has already repaired legacy rooms missing `platform_chat_id`."
|
|
||||||
artifacts:
|
|
||||||
- path: "adapter/matrix/handlers/context_commands.py"
|
|
||||||
provides: "Room-local `!clear`, save/load/context resolution, and upstream disconnect behavior"
|
|
||||||
- path: "adapter/matrix/routed_platform.py"
|
|
||||||
provides: "Strict room -> agent_id + platform_chat_id routing"
|
|
||||||
- path: "tests/adapter/matrix/test_context_commands.py"
|
|
||||||
provides: "Regression coverage for `!clear` and room-local context commands"
|
|
||||||
key_links:
|
|
||||||
- from: "adapter/matrix/handlers/__init__.py"
|
|
||||||
to: "adapter/matrix/handlers/context_commands.py"
|
|
||||||
via: "IncomingCommand registration for `clear`"
|
|
||||||
pattern: "\"clear\""
|
|
||||||
- from: "adapter/matrix/routed_platform.py"
|
|
||||||
to: "adapter/matrix/store.py"
|
|
||||||
via: "room metadata lookup"
|
|
||||||
pattern: "platform_chat_id"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Make room-local platform context explicit and user-facing by shipping real `!clear` semantics and strict per-room routing.
|
|
||||||
|
|
||||||
Purpose: Phase 05 must preserve Space+rooms UX while giving each room a true upstream context boundary.
|
|
||||||
Output: Updated command wiring, room-local context reset behavior, and routing regressions tied to `platform_chat_id`.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
|
||||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/ROADMAP.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/05-mvp-deployment/05-RESEARCH.md
|
|
||||||
@.planning/phases/05-mvp-deployment/05-VALIDATION.md
|
|
||||||
@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md
|
|
||||||
@adapter/matrix/handlers/__init__.py
|
|
||||||
@adapter/matrix/handlers/context_commands.py
|
|
||||||
@adapter/matrix/routed_platform.py
|
|
||||||
@tests/adapter/matrix/test_context_commands.py
|
|
||||||
@tests/adapter/matrix/test_routed_platform.py
|
|
||||||
|
|
||||||
<interfaces>
|
|
||||||
From `adapter/matrix/handlers/__init__.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
dispatcher.register(
|
|
||||||
IncomingCommand,
|
|
||||||
"reset",
|
|
||||||
make_handle_reset(store, prototype_state)
|
|
||||||
if prototype_state is not None
|
|
||||||
else handle_settings,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
From `adapter/matrix/handlers/context_commands.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def _resolve_context_scope(
|
|
||||||
event: IncomingCommand,
|
|
||||||
store: StateStore,
|
|
||||||
chat_mgr,
|
|
||||||
) -> tuple[str, str | None]: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
From `adapter/matrix/routed_platform.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def _resolve_delegate(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
</interfaces>
|
|
||||||
</context>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="auto" tdd="true">
|
|
||||||
<name>Task 1: Expand room-local context and clear-command tests</name>
|
|
||||||
<files>tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py</files>
|
|
||||||
<read_first>tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py, adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py, .planning/phases/05-mvp-deployment/05-VALIDATION.md</read_first>
|
|
||||||
<behavior>
|
|
||||||
- Test 1: `!clear` rotates only the current room's `platform_chat_id` and disconnects only the old upstream chat (per PH05-02).
|
|
||||||
- Test 2: `!clear` is the supported command name; `!reset` may remain as a compatibility alias but must not be the only registered path.
|
|
||||||
- Test 3: routed send/stream paths fail fast when room metadata lacks `agent_id` or `platform_chat_id` instead of silently sharing context.
|
|
||||||
- Test 4: routed behavior uses startup-repaired room metadata and does not introduce a second fallback path that invents `platform_chat_id` during message handling.
|
|
||||||
</behavior>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- Tests explicitly mention `clear` in command registration or command invocation.
|
|
||||||
- The context-command tests assert old and new `platform_chat_id` values and upstream disconnect behavior.
|
|
||||||
- The routed-platform tests assert room-local IDs are passed to delegates unchanged.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<action>Extend the current Matrix context-command and routed-platform regressions so Phase 05 has direct coverage for `!clear`, room-local `platform_chat_id` rotation, and fail-fast routing when room bindings are incomplete. Treat startup reconciliation from `05-01` as the only supported repair path for legacy rooms missing `platform_chat_id`; the routed path must consume repaired metadata, not synthesize new room identities on demand. Preserve the Phase 04 prototype-state behavior where it still fits, but anchor new checks on per-room context isolation rather than shared session assumptions.</action>
|
|
||||||
<verify>
|
|
||||||
<automated>pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py -v</automated>
|
|
||||||
</verify>
|
|
||||||
<done>The tests define the Phase 05 room-local contract for reset/clear and for routed upstream calls.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto" tdd="true">
|
|
||||||
<name>Task 2: Ship real room-local `!clear` semantics and strict routing</name>
|
|
||||||
<files>adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py</files>
|
|
||||||
<read_first>adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py, tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md</read_first>
|
|
||||||
<behavior>
|
|
||||||
- Test 1: command registration exposes `!clear` as the real context-reset entrypoint for Matrix rooms.
|
|
||||||
- Test 2: only the active room's `platform_chat_id` rotates, and only the old upstream chat session is disconnected.
|
|
||||||
- Test 3: all room-local context commands resolve through recovered room metadata instead of falling back to shared user scope.
|
|
||||||
- Test 4: strict routing stays strict at runtime because legacy-room repair was already handled at startup by `05-01`, not by hidden message-path fallbacks.
|
|
||||||
</behavior>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `adapter/matrix/handlers/__init__.py` registers `clear`; if `reset` remains, it is clearly a compatibility alias.
|
|
||||||
- `adapter/matrix/handlers/context_commands.py` resolves and rotates room-local platform context without touching other rooms.
|
|
||||||
- `adapter/matrix/routed_platform.py` keeps explicit `MATRIX_ROUTE_INCOMPLETE` behavior when bindings are missing.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<action>Update the Matrix context command surface to match the Phase 05 contract: real `!clear`, room-local `platform_chat_id` rotation, and upstream disconnect scoped to the old room context. Keep `save`, `load`, and `context` anchored to the same room-local identity. Tighten routed-platform behavior only where needed to preserve fail-fast semantics after startup reconciliation has repaired legacy rooms; do not reintroduce shared chat state, user-level reset behavior, or message-time backfill of missing `platform_chat_id`.</action>
|
|
||||||
<verify>
|
|
||||||
<automated>pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v</automated>
|
|
||||||
</verify>
|
|
||||||
<done>Users can clear one working room without affecting others, and all routed upstream calls stay bound to room-local platform context.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
Run the context and routed-platform slices plus Matrix dispatcher smoke coverage to confirm the exposed command name and room-local routing behavior are consistent.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
Every working Matrix room has an independent upstream context boundary, and `!clear` resets only the room where it is invoked.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
After completion, create `.planning/phases/05-mvp-deployment/05-02-SUMMARY.md`
|
|
||||||
</output>
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
---
|
|
||||||
phase: 05-mvp-deployment
|
|
||||||
plan: 03
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- adapter/matrix/files.py
|
|
||||||
- sdk/real.py
|
|
||||||
- tests/adapter/matrix/test_files.py
|
|
||||||
- tests/platform/test_real.py
|
|
||||||
autonomous: true
|
|
||||||
requirements:
|
|
||||||
- PH05-04
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "Incoming Matrix attachments are written into a room-safe shared-volume path and passed upstream as relative workspace paths."
|
|
||||||
- "Agent-emitted files can be returned to Matrix users without inventing a separate file proxy."
|
|
||||||
- "The shared-volume contract works with the Phase 05 `/agents` deployment shape."
|
|
||||||
artifacts:
|
|
||||||
- path: "adapter/matrix/files.py"
|
|
||||||
provides: "Room-safe shared-volume path building and path resolution"
|
|
||||||
- path: "sdk/real.py"
|
|
||||||
provides: "Attachment path passthrough and send-file normalization"
|
|
||||||
- path: "tests/adapter/matrix/test_files.py"
|
|
||||||
provides: "Regression coverage for shared-volume path construction"
|
|
||||||
key_links:
|
|
||||||
- from: "adapter/matrix/files.py"
|
|
||||||
to: "sdk/real.py"
|
|
||||||
via: "relative `workspace_path` transport"
|
|
||||||
pattern: "workspace_path"
|
|
||||||
- from: "sdk/real.py"
|
|
||||||
to: "adapter/matrix/bot.py"
|
|
||||||
via: "OutgoingMessage attachments rendered back to Matrix"
|
|
||||||
pattern: "MsgEventSendFile"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Harden the Matrix attachment path contract around the shared deployment volume instead of custom transport shims.
|
|
||||||
|
|
||||||
Purpose: Phase 05 file handling must survive real deployment with room-safe paths and outbound file delivery through the existing shared-volume model.
|
|
||||||
Output: Attachment-path regressions and any targeted runtime fixes needed for `/agents`-backed shared volume behavior.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
|
||||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/ROADMAP.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/05-mvp-deployment/05-RESEARCH.md
|
|
||||||
@.planning/phases/05-mvp-deployment/05-VALIDATION.md
|
|
||||||
@docs/deploy-architecture.md
|
|
||||||
@docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md
|
|
||||||
@adapter/matrix/files.py
|
|
||||||
@sdk/real.py
|
|
||||||
@tests/adapter/matrix/test_files.py
|
|
||||||
@tests/platform/test_real.py
|
|
||||||
|
|
||||||
<interfaces>
|
|
||||||
From `adapter/matrix/files.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def build_workspace_attachment_path(
|
|
||||||
*,
|
|
||||||
workspace_root: Path,
|
|
||||||
matrix_user_id: str,
|
|
||||||
room_id: str,
|
|
||||||
filename: str,
|
|
||||||
timestamp: str | None = None,
|
|
||||||
) -> tuple[str, Path]: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
From `sdk/real.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@staticmethod
|
|
||||||
def _attachment_paths(attachments: list[Attachment] | None) -> list[str]: ...
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment: ...
|
|
||||||
```
|
|
||||||
</interfaces>
|
|
||||||
</context>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="auto" tdd="true">
|
|
||||||
<name>Task 1: Add shared-volume file contract tests for `/agents` deployment</name>
|
|
||||||
<files>tests/adapter/matrix/test_files.py, tests/platform/test_real.py</files>
|
|
||||||
<read_first>tests/adapter/matrix/test_files.py, tests/platform/test_real.py, adapter/matrix/files.py, sdk/real.py, docs/deploy-architecture.md, .planning/phases/05-mvp-deployment/05-RESEARCH.md</read_first>
|
|
||||||
<behavior>
|
|
||||||
- Test 1: incoming Matrix files land under a room-safe `surfaces/matrix/.../inbox/...` path that remains relative to the agent workspace contract.
|
|
||||||
- Test 2: upstream file events normalize `/workspace/...` and `/agents/...`-style absolute paths into relative `workspace_path` values.
|
|
||||||
- Test 3: attachment forwarding never switches to inline blobs or HTTP shim URLs (per PH05-04).
|
|
||||||
</behavior>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `tests/adapter/matrix/test_files.py` asserts the path namespace includes sanitized user and room components.
|
|
||||||
- `tests/platform/test_real.py` contains explicit coverage for send-file path normalization.
|
|
||||||
- The automated test command in `<verify>` exercises both inbound and outbound sides of the shared-volume contract.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<action>Expand the file-flow regressions around the real deployment contract described in research and `docs/deploy-architecture.md`. Keep the tests centered on relative `workspace_path` transport and room-safe on-disk layout. Do not introduce proxy URLs, base64 payload transport, or new platform endpoints.</action>
|
|
||||||
<verify>
|
|
||||||
<automated>pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v</automated>
|
|
||||||
</verify>
|
|
||||||
<done>Phase 05 has direct test coverage for `/agents`-backed shared-volume behavior across inbound and outbound file paths.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto" tdd="true">
|
|
||||||
<name>Task 2: Tighten attachment path handling for the shared volume contract</name>
|
|
||||||
<files>adapter/matrix/files.py, sdk/real.py</files>
|
|
||||||
<read_first>adapter/matrix/files.py, sdk/real.py, tests/adapter/matrix/test_files.py, tests/platform/test_real.py, docs/deploy-architecture.md</read_first>
|
|
||||||
<behavior>
|
|
||||||
- Test 1: inbound attachment helpers keep returning relative paths even when the bot writes into `/agents`.
|
|
||||||
- Test 2: outbound file normalization accepts absolute paths from the agent runtime but strips them back to relative workspace paths for Matrix rendering.
|
|
||||||
- Test 3: no code path emits non-relative attachment references to the upstream agent API.
|
|
||||||
</behavior>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `sdk/real.py` only forwards relative attachment paths to the agent API.
|
|
||||||
- `sdk/real.py` normalizes both `/workspace` and `/agents` absolute roots if present in send-file events.
|
|
||||||
- `adapter/matrix/files.py` remains the single source of truth for room-safe attachment path construction.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<action>Implement only the minimum runtime changes needed to satisfy the shared-volume tests. Keep `adapter/matrix/files.py` as the single place that builds surface-owned attachment paths, and keep `sdk/real.py` responsible only for attachment passthrough and send-file normalization. Do not widen this plan into compose edits, registry redesign, or bot command changes.</action>
|
|
||||||
<verify>
|
|
||||||
<automated>pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v</automated>
|
|
||||||
</verify>
|
|
||||||
<done>Incoming and outgoing file references stay compatible with the real shared-volume deployment contract, and the targeted file/path regressions pass.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
Run the Matrix file helper, real platform client, and outgoing-send slices together so the shared-volume contract is validated from write path through return-to-user rendering.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
The Matrix bot and agent runtime can exchange file references through the shared volume using only relative workspace paths and room-safe storage layout.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
After completion, create `.planning/phases/05-mvp-deployment/05-03-SUMMARY.md`
|
|
||||||
</output>
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
---
|
|
||||||
phase: 05-mvp-deployment
|
|
||||||
plan: 04
|
|
||||||
type: execute
|
|
||||||
wave: 2
|
|
||||||
depends_on:
|
|
||||||
- 05-03
|
|
||||||
files_modified:
|
|
||||||
- docker-compose.prod.yml
|
|
||||||
- docker-compose.fullstack.yml
|
|
||||||
- Dockerfile
|
|
||||||
- .env.example
|
|
||||||
- README.md
|
|
||||||
- docs/deploy-architecture.md
|
|
||||||
autonomous: true
|
|
||||||
requirements:
|
|
||||||
- PH05-05
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "Production handoff uses a bot-only compose artifact instead of the internal full-stack harness."
|
|
||||||
- "Internal E2E compose brings up the bot, platform-agent, and shared volume with explicit health-gated startup."
|
|
||||||
- "Deployment docs and env examples match the split compose artifacts and shared `/agents` contract."
|
|
||||||
artifacts:
|
|
||||||
- path: "docker-compose.prod.yml"
|
|
||||||
provides: "Bot-only deployment handoff artifact"
|
|
||||||
- path: "docker-compose.fullstack.yml"
|
|
||||||
provides: "Internal E2E harness with shared volume and dependency gating"
|
|
||||||
- path: ".env.example"
|
|
||||||
provides: "Documented runtime contract for Phase 05 deployment"
|
|
||||||
key_links:
|
|
||||||
- from: "docker-compose.fullstack.yml"
|
|
||||||
to: "docker-compose.prod.yml"
|
|
||||||
via: "shared service definition or explicit duplication"
|
|
||||||
pattern: "matrix-bot"
|
|
||||||
- from: "docs/deploy-architecture.md"
|
|
||||||
to: "docker-compose.prod.yml"
|
|
||||||
via: "operator handoff instructions"
|
|
||||||
pattern: "prod"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Split deployment artifacts by operational intent so operator handoff and internal E2E testing stop sharing the same compose contract.
|
|
||||||
|
|
||||||
Purpose: Phase 05 needs an explicit bot-only production artifact and a separate full-stack compose harness aligned with the shared-volume design.
|
|
||||||
Output: `docker-compose.prod.yml`, `docker-compose.fullstack.yml`, and updated env/docs describing when to use each.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
|
||||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/ROADMAP.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/05-mvp-deployment/05-RESEARCH.md
|
|
||||||
@.planning/phases/05-mvp-deployment/05-VALIDATION.md
|
|
||||||
@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md
|
|
||||||
@docs/deploy-architecture.md
|
|
||||||
@docker-compose.yml
|
|
||||||
@Dockerfile
|
|
||||||
@.env.example
|
|
||||||
|
|
||||||
<interfaces>
|
|
||||||
Current root compose contract:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
platform-agent:
|
|
||||||
...
|
|
||||||
matrix-bot:
|
|
||||||
build: .
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
AGENT_BASE_URL: http://platform-agent:8000
|
|
||||||
SURFACES_WORKSPACE_DIR: /workspace
|
|
||||||
```
|
|
||||||
</interfaces>
|
|
||||||
</context>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 1: Create split prod and fullstack compose artifacts</name>
|
|
||||||
<files>docker-compose.prod.yml, docker-compose.fullstack.yml, Dockerfile, .env.example</files>
|
|
||||||
<read_first>docker-compose.yml, Dockerfile, .env.example, docs/deploy-architecture.md, .planning/phases/05-mvp-deployment/05-RESEARCH.md, .planning/phases/05-mvp-deployment/05-VALIDATION.md</read_first>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `docker-compose.prod.yml` defines only the bot-side runtime required for operator handoff.
|
|
||||||
- `docker-compose.fullstack.yml` includes the internal platform-agent service, shared volume mounts, and health-gated startup rather than sleep-based sequencing.
|
|
||||||
- `.env.example` documents the Phase 05 env contract without requiring the reader to inspect the old root compose file.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<action>Create two explicit compose artifacts per PH05-05: a bot-only `docker-compose.prod.yml` for deployment handoff and a `docker-compose.fullstack.yml` for internal E2E runs. Align mounts and env values with the Phase 05 shared `/agents` volume direction from research. Reuse the existing Dockerfile unless a small compatibility edit is required. Do not keep the old single-file compose setup as the only documented runtime.</action>
|
|
||||||
<verify>
|
|
||||||
<automated>docker compose -f docker-compose.prod.yml config > /tmp/phase05-prod-compose.yml && docker compose -f docker-compose.fullstack.yml config > /tmp/phase05-fullstack-compose.yml && rg -n "^services:$|^ matrix-bot:$|^volumes:$|/agents" /tmp/phase05-prod-compose.yml && test -z "$(rg -n "^ platform-agent:$" /tmp/phase05-prod-compose.yml)" && rg -n "^ matrix-bot:$|^ platform-agent:$|condition: service_healthy|healthcheck:|/agents" /tmp/phase05-fullstack-compose.yml</automated>
|
|
||||||
</verify>
|
|
||||||
<done>Both compose files render successfully and express distinct operational roles: prod handoff vs internal full-stack testing.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Update deployment docs and operator guidance for the split artifacts</name>
|
|
||||||
<files>README.md, docs/deploy-architecture.md</files>
|
|
||||||
<read_first>README.md, docs/deploy-architecture.md, docker-compose.prod.yml, docker-compose.fullstack.yml, .env.example</read_first>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- README or deploy doc tells the operator exactly which compose file to use for production vs internal E2E.
|
|
||||||
- The docs describe the shared `/agents` volume behavior and reference the relevant env vars.
|
|
||||||
- The old root `docker-compose.yml` is no longer the primary documented deployment path.
|
|
||||||
</acceptance_criteria>
|
|
||||||
<action>Update the repo docs so the Phase 05 deployment story is executable without inference: production handoff stays bot-only, full-stack compose is for internal E2E, and shared-volume file behavior is described in the same terms as the runtime artifacts. Keep documentation narrowly scoped to the shipped compose split; do not widen into platform-master or future storage design.</action>
|
|
||||||
<verify>
|
|
||||||
<automated>rg -n "docker-compose\\.prod|docker-compose\\.fullstack|/agents|prod handoff|full-stack" README.md docs/deploy-architecture.md .env.example && rg -n "production|deploy" README.md docs/deploy-architecture.md | rg "docker-compose\\.prod" && test -z "$(rg -n "docker compose up|docker-compose\\.yml" README.md docs/deploy-architecture.md | rg "production|deploy")"</automated>
|
|
||||||
</verify>
|
|
||||||
<done>The docs and env guidance match the new compose artifacts and no longer imply a single shared deployment file.</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
Render both compose files, then grep the docs for the new artifact names and `/agents` references so the operator contract is explicit and consistent.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
An operator can deploy the Matrix bot with the bot-only compose file, while developers can run the internal end-to-end harness separately without reinterpreting the deployment docs.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
After completion, create `.planning/phases/05-mvp-deployment/05-04-SUMMARY.md`
|
|
||||||
</output>
|
|
||||||
157
.planning/phases/05-mvp-deployment/05-CONTEXT.md
Normal file
157
.planning/phases/05-mvp-deployment/05-CONTEXT.md
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
# Phase 05: MVP Deployment — Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-04-27
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Подготовить Matrix-бот к реальному деплою на lambda.coredump.ru:
|
||||||
|
1. Перейти на single-chat архитектуру (chat_id=0, один контекст на пользователя)
|
||||||
|
2. Упростить онбординг: DM-first без Space/rooms provisioning, welcome-сообщение при invite
|
||||||
|
3. Расширить config/matrix-agents.yaml — добавить user_agents (Matrix user_id → agent_id) и per-agent base_url/workspace_path
|
||||||
|
4. Обновить AgentRegistry и _build_platform_from_env для per-agent URL routing
|
||||||
|
5. Реализовать file transfer через shared volume /agents/: входящие → incoming/{filename}, исходящие через MsgEventSendFile
|
||||||
|
6. Добавить !clear (сброс контекста через переподключение AgentApi)
|
||||||
|
7. Написать docker-compose.prod.yml с полным стеком (matrix-bot + placeholder agent + named volume agents)
|
||||||
|
8. Удалить legacy: !agent, !new, !archive, !rename, !save, !load, Space-creation, C1/C2/C3 room provisioning
|
||||||
|
|
||||||
|
НЕ входит:
|
||||||
|
- Конфигурация агентских контейнеров (платформа)
|
||||||
|
- Telegram-адаптер
|
||||||
|
- E2EE
|
||||||
|
- platform-master интеграция
|
||||||
|
- !save / !load (ненадёжны без persistent memory в агенте)
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Single-chat архитектура
|
||||||
|
- **D-01:** chat_id=0 для всех сообщений. Один контекст агента на пользователя. Изоляции между разными разговорами нет — вместо этого `!clear` сбрасывает контекст.
|
||||||
|
- **D-02:** Удалить всю multi-room инфраструктуру: C1/C2/C3, `!new`, `!archive`, `!rename`, Space-creation, room provisioning. Matrix-бот работает только в DM-комнате (личка с ботом).
|
||||||
|
- **D-03:** Удалить `!save` и `!load` — ненадёжны без persistent memory в агенте (MemorySaver сбрасывается на рестарте).
|
||||||
|
|
||||||
|
### Онбординг (DM-first)
|
||||||
|
- **D-04:** При получении invite в DM-комнату — принять, отправить welcome-сообщение: "Привет! Я Lambda AI-агент. Просто напиши — и я отвечу. `!clear` чтобы начать новый разговор, `!context` чтобы посмотреть статус."
|
||||||
|
- **D-05:** Никакого Space, никаких дочерних комнат. Вся переписка в одной DM-комнате.
|
||||||
|
|
||||||
|
### !clear (новая команда)
|
||||||
|
- **D-06:** Сбросить контекст агента — закрыть текущий AgentApi connection и создать новый (`await agent.close()` + `await agent.connect()`). Это сбрасывает MemorySaver. Подтвердить пользователю: "Контекст сброшен. Начнём с чистого листа."
|
||||||
|
|
||||||
|
### !agent команда
|
||||||
|
- **D-07:** Удалить полностью. Маппинг user→agent теперь статический из config. Пользователь не может менять агента.
|
||||||
|
|
||||||
|
### Конфиг агентов (config/matrix-agents.yaml)
|
||||||
|
- **D-02:** Расширить текущий matrix-agents.yaml — добавить user_agents dict и поля base_url/workspace_path к каждому агенту. Один файл, один парсер. Формат по docs/deploy-architecture.md:
|
||||||
|
```yaml
|
||||||
|
user_agents:
|
||||||
|
"@user0:matrix.lambda.coredump.ru": agent-0
|
||||||
|
"@user1:matrix.lambda.coredump.ru": agent-1
|
||||||
|
|
||||||
|
agents:
|
||||||
|
- id: agent-0
|
||||||
|
label: "Agent 0"
|
||||||
|
base_url: "ws://lambda.coredump.ru:7000/agent_0/"
|
||||||
|
workspace_path: "/agents/0/"
|
||||||
|
```
|
||||||
|
- **D-03:** AgentDefinition расширяется полями base_url (str) и workspace_path (str). AgentRegistry добавляет user_agents dict (Matrix user_id → agent_id) и метод get_agent_id_by_user(matrix_user_id).
|
||||||
|
|
||||||
|
### Роутинг user → agent в _build_platform_from_env
|
||||||
|
- **D-04:** Вместо глобального AGENT_BASE_URL — per-agent URL из конфига. _build_platform_from_env строит delegates с правильным base_url для каждого агента. RoutedPlatformClient._resolve_delegate использует user_agents из registry для определения delegate по Matrix user_id.
|
||||||
|
|
||||||
|
### Входящие файлы (пользователь → агент)
|
||||||
|
- **D-05:** Путь внутри workspace агента: `incoming/{filename}`. Абсолютный путь: `{workspace_path}/incoming/{filename}` (например `/agents/0/incoming/photo.jpg`). Обновить files.py: `build_workspace_attachment_path` принимает workspace_path агента и строит путь `incoming/{filename}`. Передавать в agent.send_message() как attachments=["incoming/{filename}"] (относительно /workspace).
|
||||||
|
- **D-06:** workspace_path агента берётся из AgentDefinition по agent_id пользователя.
|
||||||
|
|
||||||
|
### Исходящие файлы (агент → пользователь)
|
||||||
|
- **D-07:** При получении MsgEventSendFile(path="output/report.pdf") — читать файл из `{workspace_path}/{path}`. Отправлять как Matrix file message. Обработчик в Matrix bot.py при обработке stream-ответов от агента.
|
||||||
|
|
||||||
|
### docker-compose для prod
|
||||||
|
- **D-08:** `docker-compose.prod.yml` включает полный стек: Matrix-бот + агент-контейнер (placeholder image `lambda-agent:latest` — уточнить у платформы) + named volume `agents`. Это позволяет тестировать полный стек самостоятельно. Платформа берёт отсюда схему интеграции для своего деплоя.
|
||||||
|
- **D-09:** Named volume `agents` монтируется в Matrix-бот как `/agents/` и в агент-контейнер как `/workspace`. Env vars из `.env.prod`. Запуск: `docker compose -f docker-compose.prod.yml up`.
|
||||||
|
|
||||||
|
### Неавторизованные пользователи
|
||||||
|
- **D-10:** Если Matrix user_id не найден в `user_agents` — принять invite, отправить сообщение: "К вашему аккаунту не привязан агент. Напишите @og_mput в Telegram для получения доступа." Дальнейшие сообщения игнорировать (или повторять то же сообщение).
|
||||||
|
|
||||||
|
### !clear
|
||||||
|
- **D-11:** Без диалога подтверждения — сбрасывает немедленно. Закрыть текущий AgentApi connection, создать новый. Ответ пользователю: "Контекст сброшен."
|
||||||
|
|
||||||
|
### !settings и прочие команды настроек
|
||||||
|
- **D-12:** Удалить `!settings`, `!settings soul`, `!settings skills`, `!settings safety` — agent_api не предоставляет настроек, всё равно возвращало "недоступно в MVP".
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- MATRIX_AGENT_REGISTRY_PATH — оставить как env var для пути к конфигу (уже существует)
|
||||||
|
- Формат .env.prod
|
||||||
|
- Group room invites (не-DM) — отклонять автоматически
|
||||||
|
- Существующие Space+rooms у старых пользователей — игнорировать, не мигрировать
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
### Deployment architecture (PRIMARY)
|
||||||
|
- `docs/deploy-architecture.md` — Топология, формат конфига, AgentApi lifecycle, file transfer protocol, открытые вопросы
|
||||||
|
|
||||||
|
### Существующий код (изменяем)
|
||||||
|
- `adapter/matrix/agent_registry.py` — AgentRegistry, AgentDefinition, load_agent_registry — расширяем
|
||||||
|
- `adapter/matrix/bot.py` — _build_platform_from_env, _load_agent_registry_from_env — обновляем роутинг
|
||||||
|
- `adapter/matrix/routed_platform.py` — RoutedPlatformClient._resolve_delegate — обновляем логику
|
||||||
|
- `adapter/matrix/files.py` — build_workspace_attachment_path, download_matrix_attachment — меняем путь
|
||||||
|
- `adapter/matrix/handlers/agent.py` — удаляем или делаем no-op (!agent handler)
|
||||||
|
- `config/matrix-agents.yaml` — расширяем формат
|
||||||
|
- `docker-compose.yml` — существующий dev compose (за основу для prod варианта)
|
||||||
|
|
||||||
|
### SDK (используем как есть)
|
||||||
|
- `sdk/real.py` — RealPlatformClient — base_url теперь per-instance, но сам класс не меняется
|
||||||
|
- `sdk/upstream_agent_api.py` — AgentApi, MsgEventSendFile — читаем MsgEventSendFile в стриме
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `adapter/matrix/files.py::build_workspace_attachment_path` — уже строит путь к файлу, нужно заменить логику `surfaces/matrix/...` на `incoming/{filename}`
|
||||||
|
- `adapter/matrix/files.py::download_matrix_attachment` — скачивает файл, нужно передавать workspace_path агента
|
||||||
|
- `adapter/matrix/agent_registry.py::load_agent_registry` — парсер YAML, расширяем без переписывания
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- `RoutedPlatformClient` + delegates: dict[agent_id, RealPlatformClient] — паттерн уже есть, нужно только per-agent URL при создании delegates
|
||||||
|
- `MATRIX_PLATFORM_BACKEND=real` активирует prod-path — сохраняем
|
||||||
|
- `MATRIX_AGENT_REGISTRY_PATH` — env var для пути к конфигу — сохраняем
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `_build_platform_from_env` создаёт delegates — здесь меняется источник URL (из конфига, не из env)
|
||||||
|
- `RoutedPlatformClient._resolve_delegate` — здесь добавляется lookup по user_agents
|
||||||
|
- Matrix bot stream handler — здесь добавляется обработка MsgEventSendFile
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- AgentApi конструктор в master ветке: `AgentApi(agent_id, base_url, on_disconnect=..., chat_id=0)` — base_url это ws:// URL агента
|
||||||
|
- Входящий файл: bot скачивает из Matrix → пишет в `{workspace_path}/incoming/{filename}` → вызывает `agent.send_message(text, attachments=["incoming/{filename}"])` (путь relative to /workspace)
|
||||||
|
- Исходящий файл: при `MsgEventSendFile(path="output/report.pdf")` → читаем `{workspace_path}/output/report.pdf` → отправляем в Matrix через `client.upload()` → `client.room_send(m.file)`
|
||||||
|
- docker-compose.prod.yml монтирует volume: `volumes: ["/agents/:/agents/"]` — хост обеспечивает директорию
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- platform-master интеграция (динамический get_agent_url через POST /api/v1/create) — когда feat/storage будет готов
|
||||||
|
- !agent как admin-override — не нужен для MVP, можно добавить позже если потребуется
|
||||||
|
- Per-chat context isolation через разные chat_id (сейчас chat_id=0 для всех) — ждём platform сигнал
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 05-mvp-deployment*
|
||||||
|
*Context gathered: 2026-04-27*
|
||||||
65
.planning/phases/05-mvp-deployment/05-DISCUSSION-LOG.md
Normal file
65
.planning/phases/05-mvp-deployment/05-DISCUSSION-LOG.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Phase 05: MVP Deployment — Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-04-27
|
||||||
|
**Phase:** 05-mvp-deployment
|
||||||
|
**Areas discussed:** !agent legacy, file transfer path, config format, docker-compose scope
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## !agent команда
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Удалить | Убираем полностью — маппинг статический из конфига | ✓ |
|
||||||
|
| Оставить как no-op | Команда остаётся но ничего не делает | |
|
||||||
|
| Только для dev-режима | Работает когда нет user_agents в конфиге | |
|
||||||
|
|
||||||
|
**User's choice:** Удалить
|
||||||
|
**Notes:** Команда была legacy от эпохи когда роутинг был динамическим. С user_agents в конфиге она не нужна.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Путь входящих файлов
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| incoming/{filename} | По docs/deploy-architecture.md — /agents/N/incoming/file | ✓ |
|
||||||
|
| surfaces/matrix/{user}/{room}/inbox/{file} | Текущий формат files.py | |
|
||||||
|
|
||||||
|
**User's choice:** incoming/{filename}
|
||||||
|
**Notes:** Пользователь указал — это решение от платформенной команды, зафиксировано в docs/deploy-architecture.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Формат config/matrix-agents.yaml
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Расширить текущий YAML | Добавить user_agents + base_url/workspace_path в тот же файл | ✓ |
|
||||||
|
| Отдельный prod-config.yaml | Два файла: registry (id/label) + prod конфиг (URL/user_agents) | |
|
||||||
|
|
||||||
|
**User's choice:** Расширить текущий YAML
|
||||||
|
**Notes:** Один файл проще. Формат уже определён в docs/deploy-architecture.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## docker-compose prod scope
|
||||||
|
|
||||||
|
**User's choice:** docker-compose.prod.yml только для Matrix-бота
|
||||||
|
**Notes:** Платформа отвечает за агентские контейнеры — мы их не трогаем. Matrix-бот монтирует /agents/ как external host path, платформа обеспечивает содержимое.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- Обработка Matrix user_id не найденного в user_agents
|
||||||
|
- Имена env переменных для prod
|
||||||
|
- Формат .env.prod
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- platform-master интеграция
|
||||||
|
- Per-chat chat_id isolation
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
---
|
---
|
||||||
phase: 05
|
phase: 5
|
||||||
slug: mvp-deployment
|
slug: mvp-deployment
|
||||||
status: revised
|
status: draft
|
||||||
nyquist_compliant: true
|
nyquist_compliant: false
|
||||||
wave_0_complete: false
|
wave_0_complete: false
|
||||||
created: 2026-04-28
|
created: 2026-04-27
|
||||||
---
|
---
|
||||||
|
|
||||||
# Phase 05 — Validation Strategy
|
# Phase 5 — Validation Strategy
|
||||||
|
|
||||||
> Per-phase validation contract for feedback sampling during execution.
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
|
@ -17,35 +17,35 @@ created: 2026-04-28
|
||||||
|
|
||||||
| Property | Value |
|
| Property | Value |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| **Framework** | `pytest` + `pytest-asyncio` |
|
| **Framework** | pytest |
|
||||||
| **Config file** | `pyproject.toml` |
|
| **Config file** | pyproject.toml |
|
||||||
| **Quick run command** | `pytest tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v` |
|
| **Quick run command** | `pytest tests/adapter/matrix/ -v -x` |
|
||||||
| **Full suite command** | `pytest tests/ -v` |
|
| **Full suite command** | `pytest tests/ -v` |
|
||||||
| **Estimated runtime** | targeted slices < 60 seconds each; full suite longer |
|
| **Estimated runtime** | ~30 seconds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sampling Rate
|
## Sampling Rate
|
||||||
|
|
||||||
- **After every task commit:** Run the exact `<automated>` command from the task that just changed
|
- **After every task commit:** Run `pytest tests/adapter/matrix/ -v -x`
|
||||||
- **After every plan wave:** Run `pytest tests/adapter/matrix/ -v`
|
- **After every plan wave:** Run `pytest tests/ -v`
|
||||||
- **Before `$gsd-verify-work`:** Full suite must be green
|
- **Before `/gsd-verify-work`:** Full suite must be green
|
||||||
- **Max feedback latency:** 60 seconds for task-level slices
|
- **Max feedback latency:** 30 seconds
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Per-Task Verification Map
|
## Per-Task Verification Map
|
||||||
|
|
||||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
| 05-01-01 | 01 | 1 | PH05-01 | integration | `pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v` | ❌ W0 | ⬜ pending |
|
| 05-A-01 | A | 1 | D-02/D-03 | — | agent_id lookup by matrix_user_id only | unit | `pytest tests/adapter/matrix/test_agent_registry.py -v` | ❌ W0 | ⬜ pending |
|
||||||
| 05-01-02 | 01 | 1 | PH05-03 | integration | `pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v` | ❌ W0 | ⬜ pending |
|
| 05-A-02 | A | 1 | D-04 | — | per-agent URL used in delegates | unit | `pytest tests/adapter/matrix/test_routed_platform.py -v` | ❌ W0 | ⬜ pending |
|
||||||
| 05-02-01 | 02 | 2 | PH05-02 | integration | `pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py -v` | ✅ partial | ⬜ pending |
|
| 05-B-01 | B | 1 | D-04/D-05 | — | welcome message sent on invite | unit | `pytest tests/adapter/matrix/test_onboarding.py -v` | ❌ W0 | ⬜ pending |
|
||||||
| 05-02-02 | 02 | 2 | PH05-02 | integration | `pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v` | ✅ partial | ⬜ pending |
|
| 05-B-02 | B | 1 | D-10 | — | unauthorized user gets access-denied message | unit | `pytest tests/adapter/matrix/test_onboarding.py::test_unauthorized -v` | ❌ W0 | ⬜ pending |
|
||||||
| 05-03-01 | 03 | 1 | PH05-04 | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v` | ✅ partial | ⬜ pending |
|
| 05-B-03 | B | 1 | D-11 | — | !clear closes and reopens AgentApi | unit | `pytest tests/adapter/matrix/test_commands.py::test_clear -v` | ❌ W0 | ⬜ pending |
|
||||||
| 05-03-02 | 03 | 1 | PH05-04 | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v` | ✅ partial | ⬜ pending |
|
| 05-C-01 | C | 2 | D-05/D-06 | — | incoming file written to workspace_path/incoming/ | unit | `pytest tests/adapter/matrix/test_files.py -v` | ✅ | ⬜ pending |
|
||||||
| 05-04-01 | 04 | 2 | PH05-05 | smoke | `docker compose -f docker-compose.prod.yml config && docker compose -f docker-compose.fullstack.yml config` | ❌ W0 | ⬜ pending |
|
| 05-C-02 | C | 2 | D-07 | — | outgoing MsgEventSendFile reads from workspace_path | unit | `pytest tests/adapter/matrix/test_files.py::test_outgoing_file -v` | ❌ W0 | ⬜ pending |
|
||||||
| 05-04-02 | 04 | 2 | PH05-05 | docs smoke | `rg -n "docker-compose\\.prod|docker-compose\\.fullstack|/agents|prod handoff|full-stack" README.md docs/deploy-architecture.md .env.example` | ✅ | ⬜ pending |
|
| 05-C-03 | C | 2 | D-08/D-09 | — | docker-compose.prod.yml has agents volume and both services | manual | see below | N/A | ⬜ pending |
|
||||||
|
|
||||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
|
@ -53,11 +53,13 @@ created: 2026-04-28
|
||||||
|
|
||||||
## Wave 0 Requirements
|
## Wave 0 Requirements
|
||||||
|
|
||||||
- [ ] `tests/adapter/matrix/test_reconciliation.py` — startup recovery of user and room metadata from Matrix state
|
- [ ] `tests/adapter/matrix/test_agent_registry.py` — tests for user_agents lookup and per-agent base_url/workspace_path
|
||||||
- [ ] `tests/adapter/matrix/test_restart_persistence.py` additions — deterministic backfill for legacy rooms missing `platform_chat_id`
|
- [ ] `tests/adapter/matrix/test_routed_platform.py` — updated tests for _resolve_delegate using user_agents
|
||||||
- [ ] `tests/adapter/matrix/test_context_commands.py` additions — room-local `!clear` rotation semantics
|
- [ ] `tests/adapter/matrix/test_onboarding.py` — tests for invite handling, welcome message, unauthorized user response
|
||||||
- [ ] `tests/adapter/matrix/test_files.py` additions — cross-room attachment isolation and shared-root consistency
|
- [ ] `tests/adapter/matrix/test_commands.py` — tests for !clear command behavior
|
||||||
- [ ] Compose smoke coverage or documented verification command for `docker-compose.prod.yml` and `docker-compose.fullstack.yml`
|
- [ ] Update `tests/adapter/matrix/test_files.py` — add outgoing file test
|
||||||
|
|
||||||
|
*Existing: `tests/adapter/matrix/test_files.py` — already exists, covers incoming file path logic*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -65,9 +67,8 @@ created: 2026-04-28
|
||||||
|
|
||||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|----------|-------------|------------|-------------------|
|
|----------|-------------|------------|-------------------|
|
||||||
| Restart after real Matrix room topology exists | PH05-03 | Full recovery depends on live Space hierarchy and persisted homeserver state | Start the bot, provision a Space and chat rooms, stop the bot, remove local SQLite metadata, restart, confirm routing and room labels are rebuilt before live messages are handled |
|
| docker-compose.prod.yml full-stack launch | D-08/D-09 | Requires Docker daemon and lambda-agent:latest image | `docker compose -f docker-compose.prod.yml up` — verify both services start, volume mounts at /agents/ |
|
||||||
| Shared `/agents` volume behavior across bot and platform containers | PH05-04 | Container mounts and permissions are environment-dependent | Run `docker compose -f docker-compose.fullstack.yml up`, upload a file in Matrix, confirm the agent sees the relative `workspace_path`, then confirm an agent-created file is readable back from the bot side |
|
| Matrix bot invite + DM flow | D-04/D-05 | Requires live Matrix homeserver | Invite bot to DM, verify welcome message appears |
|
||||||
| Operator handoff of prod compose | PH05-05 | Final deploy contract depends on real env files and target host conventions | Run `docker compose -f docker-compose.prod.yml config` on the target deployment checkout and confirm only bot services, required env vars, and shared volumes are present |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -77,7 +78,7 @@ created: 2026-04-28
|
||||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
- [ ] Wave 0 covers all MISSING references
|
- [ ] Wave 0 covers all MISSING references
|
||||||
- [ ] No watch-mode flags
|
- [ ] No watch-mode flags
|
||||||
- [x] Feedback latency target tightened to task slices under 60s
|
- [ ] Feedback latency < 30s
|
||||||
- [x] `nyquist_compliant: true` set in frontmatter
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
**Approval:** pending
|
**Approval:** pending
|
||||||
|
|
|
||||||
92
.planning/reports/20260422-session-report.md
Normal file
92
.planning/reports/20260422-session-report.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# GSD Session Report
|
||||||
|
|
||||||
|
**Generated:** 2026-04-21T22:33:11.666Z
|
||||||
|
**Project:** surfaces-bot
|
||||||
|
**Milestone:** v1.0 — Production-ready surfaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Summary
|
||||||
|
|
||||||
|
**Duration:** Single session
|
||||||
|
**Phase Progress:** Phase 04 implemented; current follow-up work is audit, stabilization, and platform bug localization
|
||||||
|
**Plans Executed:** 0 formal GSD plans executed in this session; work was focused on post-implementation audit and cleanup
|
||||||
|
**Commits Made:** 6
|
||||||
|
|
||||||
|
## Work Performed
|
||||||
|
|
||||||
|
### Phases Touched
|
||||||
|
|
||||||
|
- **Phase 04** — Matrix MVP follow-up after implementation:
|
||||||
|
- completed audit of platform patches vs surface-owned responsibilities
|
||||||
|
- removed dependence on local platform modifications for `chat_id`
|
||||||
|
- switched Matrix integration to numeric `platform_chat_id` mapping on our side
|
||||||
|
- cleaned transport layer to a thin adapter over upstream `AgentApi`
|
||||||
|
- updated README and run instructions
|
||||||
|
- produced final Russian bug report with raw-trace-based diagnosis
|
||||||
|
|
||||||
|
### Key Outcomes
|
||||||
|
|
||||||
|
- Platform repos are clean and synced to pinned upstream commits.
|
||||||
|
- Matrix real backend works with numeric surrogate `platform_chat_id`.
|
||||||
|
- `surfaces` transport layer no longer owns custom stream semantics.
|
||||||
|
- Final diagnosis was narrowed: missing-first-chunk bug is now considered platform-side with direct raw evidence.
|
||||||
|
- Working state was committed and pushed on `feat/matrix-direct-agent-prototype`.
|
||||||
|
|
||||||
|
### Decisions Made
|
||||||
|
|
||||||
|
- Do not patch vendored platform repos for the working implementation.
|
||||||
|
- Keep `surfaces` transport layer thin and upstream-aligned.
|
||||||
|
- Treat the current streaming bug as platform-side unless new evidence disproves it.
|
||||||
|
- Do not add new local stream workarounds that would blur responsibility.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `adapter/matrix/bot.py`
|
||||||
|
- `sdk/agent_api_wrapper.py`
|
||||||
|
- `sdk/real.py`
|
||||||
|
- `tests/platform/test_real.py`
|
||||||
|
- `tests/adapter/matrix/test_dispatcher.py`
|
||||||
|
- `tests/core/test_integration.py`
|
||||||
|
- `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`
|
||||||
|
|
||||||
|
Planning / handoff artifacts updated:
|
||||||
|
|
||||||
|
- `.planning/HANDOFF.json`
|
||||||
|
- `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.continue-here.md`
|
||||||
|
- `.planning/reports/20260422-session-report.md`
|
||||||
|
|
||||||
|
## Blockers & Open Items
|
||||||
|
|
||||||
|
- Platform-side streaming bug after tool/file flow.
|
||||||
|
- Duplicate `END` from platform.
|
||||||
|
- Image path failure on oversized `data:` URI.
|
||||||
|
- `tokens_used` remains unavailable from pinned upstream client.
|
||||||
|
|
||||||
|
## Estimated Resource Usage
|
||||||
|
|
||||||
|
| Metric | Estimate |
|
||||||
|
|--------|----------|
|
||||||
|
| Commits | 6 |
|
||||||
|
| Files changed | 8 code/docs files in the main deliverable, plus planning artifacts |
|
||||||
|
| Plans executed | 0 formal plans in this session |
|
||||||
|
| Subagents spawned | 0 |
|
||||||
|
|
||||||
|
> **Note:** Token and cost estimates require API-level instrumentation.
|
||||||
|
> These metrics reflect observable session activity only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Recent Commits
|
||||||
|
|
||||||
|
- `0c2884c` — `refactor: use thin upstream transport adapter`
|
||||||
|
- `569824e` — `refactor: shrink agent api wrapper to thin adapter`
|
||||||
|
- `4d917ac` — `docs: add thin transport adapter plan`
|
||||||
|
- `3a3fcdc` — `docs: add thin transport adapter design`
|
||||||
|
- `7a2ad86` — `docs: clarify matrix file sending flow`
|
||||||
|
- `4524a6a` — `feat: finalize matrix platform audit and docs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated by `$gsd-session-report`*
|
||||||
133
.planning/threads/matrix-dev-prototype-agent-platform-state.md
Normal file
133
.planning/threads/matrix-dev-prototype-agent-platform-state.md
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# Thread: Matrix dev prototype — состояние агента и платформы
|
||||||
|
|
||||||
|
## Status: IN PROGRESS
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Зафиксировать текущее состояние платформы для последующей разработки Matrix dev прототипа,
|
||||||
|
в котором команды разработки скиллов смогут быстро добавлять и обкатывать скиллы.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
*Исследование проведено 2026-04-14. Репозитории: `external/platform-agent`, `external/platform-agent_api`, `external/platform-master`.*
|
||||||
|
|
||||||
|
### Решение по деплою: локальный контейнер у каждого разработчика
|
||||||
|
|
||||||
|
`platform-master` не готов для общего деплоя:
|
||||||
|
- lifecycle management контейнеров (TTL, cleanup, переиспользование сессий) — в ветке `feat/storage`, не смержено в main
|
||||||
|
- без него при общем деплое контейнеры висят вечно, ресурсы не освобождаются
|
||||||
|
|
||||||
|
Локальный вариант: `make up-dev` — полностью рабочий, volume mount `./workspace:/workspace/`, hot reload src.
|
||||||
|
|
||||||
|
### Архитектура изоляции контекстов
|
||||||
|
|
||||||
|
`AgentService` — singleton с `thread_id = "default"` — это **намеренно**. Архитектура Master предполагает один контейнер `platform-agent` на один чат. Изоляция на уровне контейнеров, не thread_id. Фиксить не нужно.
|
||||||
|
|
||||||
|
### Система скиллов (deepagents)
|
||||||
|
|
||||||
|
`SkillsMiddleware` в `deepagents` полностью готов:
|
||||||
|
- скилл = директория с `SKILL.md` (YAML frontmatter + markdown инструкции)
|
||||||
|
- progressive disclosure: агент видит имя+описание в system prompt, читает полный файл по требованию
|
||||||
|
- загружается один раз при старте сессии, кэшируется в LangGraph state
|
||||||
|
|
||||||
|
**НЕ подключено** в `platform-agent/src/agent/base.py` — отсутствует одна строка:
|
||||||
|
```python
|
||||||
|
skills=["/workspace/skills/"]
|
||||||
|
```
|
||||||
|
Это задача для команды платформы.
|
||||||
|
|
||||||
|
### Workflow разработчика скилла
|
||||||
|
|
||||||
|
```
|
||||||
|
workspace/
|
||||||
|
skills/
|
||||||
|
my-skill/
|
||||||
|
SKILL.md ← редактируешь здесь (live через volume mount)
|
||||||
|
helper.py ← вспомогательные файлы
|
||||||
|
config/
|
||||||
|
my-skill.json ← токены и настройки (пишет агент при первом запуске)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Редактируешь `SKILL.md`
|
||||||
|
2. `!new` в Matrix (новая сессия = скиллы перечитываются)
|
||||||
|
3. Проверяешь поведение
|
||||||
|
4. Повторяешь
|
||||||
|
|
||||||
|
Агент может **сам установить скилл** из GitHub:
|
||||||
|
- `execute` → git clone
|
||||||
|
- `write_file` → положить в `/workspace/skills/`
|
||||||
|
- после `!new` скилл активен
|
||||||
|
|
||||||
|
### Конфигурация скиллов (токены, API ключи)
|
||||||
|
|
||||||
|
Агент управляет конфигом сам:
|
||||||
|
- первый запуск: спрашивает пользователя → пишет в `/workspace/config/skill-name.json`
|
||||||
|
- последующие запуски: читает из файла
|
||||||
|
- файл персистентен между сессиями (volume mount)
|
||||||
|
|
||||||
|
### Входящий протокол (что принимает агент)
|
||||||
|
|
||||||
|
`ClientMessage` — только `text: str`. Файлы и изображения не поддерживаются.
|
||||||
|
Задача для платформы — расширить протокол.
|
||||||
|
|
||||||
|
### Исходящий протокол (что шлёт агент)
|
||||||
|
|
||||||
|
Новые события с `origin/main` (апрель 2026):
|
||||||
|
- `AGENT_EVENT_TOOL_CALL_CHUNK` — агент вызывает инструмент
|
||||||
|
- `AGENT_EVENT_TOOL_RESULT` — результат инструмента
|
||||||
|
- `AGENT_EVENT_CUSTOM_UPDATE` — произвольный прогресс
|
||||||
|
|
||||||
|
**Наш `sdk/agent_session.py` падает на этих событиях** (`raise PlatformError("Unexpected agent message")`).
|
||||||
|
Нужно починить — это наша задача, ~10 строк.
|
||||||
|
|
||||||
|
### AgentApi из lambda_agent_api
|
||||||
|
|
||||||
|
Готовый production-клиент с правильным lifecycle (`connect()`, `close()`, `send_message()` как `AsyncIterator`).
|
||||||
|
Наш `sdk/agent_session.py` дублирует его функциональность. Стоит заменить.
|
||||||
|
|
||||||
|
### Инструменты агента из коробки
|
||||||
|
|
||||||
|
- `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep` — файловые операции в workspace
|
||||||
|
- `execute` — shell под изолированным OS-пользователем `agent`
|
||||||
|
- `write_todos` — список задач
|
||||||
|
- `task` — вызов субагентов
|
||||||
|
|
||||||
|
### Запуск локально
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env минимально необходимый:
|
||||||
|
PROVIDER_URL=https://openrouter.ai/api/v1
|
||||||
|
PROVIDER_API_KEY=<ключ>
|
||||||
|
PROVIDER_MODEL=anthropic/claude-sonnet-4-6
|
||||||
|
|
||||||
|
# Dev контейнер:
|
||||||
|
make up-dev # требует AGENT_API_PATH=../platform-agent_api в env
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev Dockerfile монтирует `./workspace:/workspace/` и `./src:/app/src` (hot reload).
|
||||||
|
|
||||||
|
## Что нужно от платформы
|
||||||
|
|
||||||
|
1. Добавить `skills=["/workspace/skills/"]` в `platform-agent/src/agent/base.py`
|
||||||
|
2. Поддержка файлов/изображений в `ClientMessage` (не срочно для MVP)
|
||||||
|
3. Lifecycle management контейнеров в Master (для общего деплоя, не срочно)
|
||||||
|
|
||||||
|
## Что делаем мы
|
||||||
|
|
||||||
|
1. Починить `sdk/agent_session.py` — обработка tool-событий вместо исключения
|
||||||
|
2. (опционально) Заменить `AgentSessionClient` на `AgentApi` из `lambda_agent_api`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `external/platform-agent` — локальный клон, наш патч `1dca2c1` (thread_id) поверх `1e9fa1f`
|
||||||
|
- `external/platform-agent_api` — локальный клон, актуальный (origin/master = `bb20a84`)
|
||||||
|
- `external/platform-master` — локальный клон, активная разработка в `feat/storage-s02`
|
||||||
|
- `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md`
|
||||||
|
- `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Запросить у команды платформы: подключение `SkillsMiddleware` в `base.py`
|
||||||
|
2. Починить `sdk/agent_session.py` — обработать tool-события
|
||||||
|
3. Написать первый тестовый скилл (`workspace/skills/hello/SKILL.md`) и проверить end-to-end
|
||||||
|
4. Документировать workflow для разработчиков скиллов
|
||||||
81
.planning/threads/matrix-file-ingestion-context.md
Normal file
81
.planning/threads/matrix-file-ingestion-context.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Thread: Matrix file ingestion and agent-visible storage contract
|
||||||
|
|
||||||
|
## Status: IN PROGRESS
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Сохранить текущий контекст сессии для следующего агента и зафиксировать следующую архитектурную развилку: как принимать вложения из Matrix и делать их доступными агенту.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
Phase 4 Matrix MVP уже собран и проверен на уровне per-room routing:
|
||||||
|
- обычные сообщения теперь идут в `platform_chat_id`, а не в общий локальный `C1/C2`
|
||||||
|
- `!context` показывает состояние текущего Matrix-чата
|
||||||
|
- `!save` и `!load` привязаны к текущему room-context
|
||||||
|
- `PrototypeStateStore` хранит live state per context
|
||||||
|
- последние изменения закоммичены в `feat/matrix-direct-agent-prototype`
|
||||||
|
|
||||||
|
Коммиты, которые важно знать:
|
||||||
|
- `c11c8ec` `feat(task-5): scope matrix context state per room`
|
||||||
|
- `07c5078` `feat(task-7): verify matrix per-room context routing`
|
||||||
|
|
||||||
|
## What We Learned About Platform Runtime
|
||||||
|
|
||||||
|
Текущий `external/platform-agent` не является отдельным контейнером на чат.
|
||||||
|
Фактическая модель сейчас такая:
|
||||||
|
- один FastAPI-процесс
|
||||||
|
- singleton `AgentService`
|
||||||
|
- `thread_id` используется как ключ памяти в LangGraph, а не как контейнерная изоляция
|
||||||
|
- файловой изоляции на чат сейчас нет
|
||||||
|
- `/workspace` как общий mount для Matrix bot и platform-agent сейчас не настроен
|
||||||
|
- отдельного upload API для вложений в текущем коде не видно
|
||||||
|
|
||||||
|
Ключевые файлы:
|
||||||
|
- `external/platform-agent/src/api/external.py`
|
||||||
|
- `external/platform-agent/src/agent/service.py`
|
||||||
|
- `external/platform-agent/src/agent/base.py`
|
||||||
|
|
||||||
|
## File Handling Requirement
|
||||||
|
|
||||||
|
Пользовательский запрос на текущем этапе:
|
||||||
|
- принимать файл или сообщение с файлом из Matrix
|
||||||
|
- сохранять файл локально
|
||||||
|
- передавать агенту явный сигнал, что к сообщению есть вложения
|
||||||
|
- сообщать, где лежит файл
|
||||||
|
|
||||||
|
Но есть техническое ограничение:
|
||||||
|
- если Matrix bot пишет файл только в своём контейнере, platform-agent его не увидит
|
||||||
|
- значит нужен либо общий storage, либо upload в платформу, либо контейнеризация platform-agent с общим volume
|
||||||
|
|
||||||
|
## Recommended Design Direction
|
||||||
|
|
||||||
|
Самый прагматичный MVP-вариант:
|
||||||
|
- хранить вложения в общем каталоге, который виден и Matrix bot, и platform-agent
|
||||||
|
- формировать для агента структурированный payload с:
|
||||||
|
- локальным путём
|
||||||
|
- original filename
|
||||||
|
- mime type
|
||||||
|
- attachment type
|
||||||
|
- если есть текст пользователя, дополнять сообщение краткой summary-подсказкой про вложения
|
||||||
|
- если прислан только файл, отправлять synthetic message вроде “пользователь прислал файл”
|
||||||
|
|
||||||
|
Если общий каталог невозможен в текущем runtime:
|
||||||
|
- следующий вариант это upload endpoint в platform-agent
|
||||||
|
- Matrix surface скачивает файл и загружает его в платформу, а платформа уже кладёт его в своё доступное хранилище
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Где должен жить shared storage: host path, docker volume или platform-side volume?
|
||||||
|
2. Нужен ли немедленный upload API в platform-agent, или сначала достаточно shared path?
|
||||||
|
3. Должны ли файлы быть scoped per room/platform_chat_id, а не per user?
|
||||||
|
|
||||||
|
## Next Step For Another Agent
|
||||||
|
|
||||||
|
1. Подтвердить runtime-модель хранения файлов.
|
||||||
|
2. Проверить, как сейчас запускаются Matrix bot и platform-agent в реальной dev-схеме.
|
||||||
|
3. После выбора storage contract начать с изменений в Matrix attachment ingestion.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Контекст этой сессии сохранён как отдельный thread, потому что текущий следующий рискованный шаг уже не про context routing, а про файловый transport.
|
||||||
|
- Не смешивать этот трек с незавершённой историей про `!branch`: upstream branch/snapshot API всё ещё не подтверждён.
|
||||||
14
Dockerfile
14
Dockerfile
|
|
@ -1,8 +1,6 @@
|
||||||
FROM python:3.11-slim AS base
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN useradd -u 1000 -m appuser
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
|
|
@ -22,25 +20,25 @@ RUN uv sync --no-dev --no-install-project --frozen
|
||||||
|
|
||||||
FROM base AS development
|
FROM base AS development
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN uv sync --no-dev --frozen
|
|
||||||
|
|
||||||
# Local fullstack/dev builds can override the SDK with a checked-out agent_api
|
# Local fullstack/dev builds can override the SDK with a checked-out agent_api
|
||||||
# build context, matching platform-agent's development Dockerfile pattern.
|
# build context, matching platform-agent's development Dockerfile pattern.
|
||||||
COPY --from=agent_api . /agent_api/
|
COPY --from=agent_api . /agent_api/
|
||||||
RUN python -m pip install --no-cache-dir --ignore-requires-python -e /agent_api/
|
RUN python -m pip install --no-cache-dir --ignore-requires-python -e /agent_api/
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN uv sync --no-dev --frozen
|
||||||
|
|
||||||
CMD ["python", "-m", "adapter.matrix.bot"]
|
CMD ["python", "-m", "adapter.matrix.bot"]
|
||||||
|
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN uv sync --no-dev --frozen
|
|
||||||
|
|
||||||
# Production builds follow the platform-agent pattern: install the API SDK from
|
# Production builds follow the platform-agent pattern: install the API SDK from
|
||||||
# the platform Git repository instead of relying on local external/ clones.
|
# the platform Git repository instead of relying on local external/ clones.
|
||||||
ARG LAMBDA_AGENT_API_REF=master
|
ARG LAMBDA_AGENT_API_REF=master
|
||||||
RUN python -m pip install --no-cache-dir --ignore-requires-python \
|
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}"
|
"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"]
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -22,9 +22,8 @@ Bot container Agent containers
|
||||||
/agents/N/ ←── volume ──→ agent_N: /workspace/
|
/agents/N/ ←── volume ──→ agent_N: /workspace/
|
||||||
```
|
```
|
||||||
|
|
||||||
- Бот сохраняет входящий файл прямо в `{workspace_path}/{file}` и передаёт агенту `attachments=["{file}"]`
|
- Бот сохраняет входящий файл в `{workspace_path}/incoming/{stamp}-{file}` и передаёт агенту `attachments=["incoming/{stamp}-{file}"]`
|
||||||
- Если файл с таким именем уже есть, бот сохраняет следующий как `file (1).ext`, `file (2).ext`, как в Windows
|
- Агент пишет исходящий файл в свой `/workspace/output/file`, бот читает его из `{workspace_path}/output/file`
|
||||||
- Агент пишет исходящий файл прямо в свой `/workspace/file`, бот читает его из `{workspace_path}/file`
|
|
||||||
- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml`
|
- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml`
|
||||||
|
|
||||||
**3. Конфиг агентов**
|
**3. Конфиг агентов**
|
||||||
|
|
@ -129,7 +128,7 @@ agents:
|
||||||
- `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}/`, агент пишет исходящие прямо в свой `/workspace/`.
|
Бот сохраняет входящие файлы в `{workspace_path}/incoming/`, агент пишет исходящие в свой `/workspace/`.
|
||||||
- Для 25-30 агентов продолжайте тот же паттерн: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
|
- Для 25-30 агентов продолжайте тот же паттерн: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
|
||||||
|
|
||||||
Полный пример с комментариями: `config/matrix-agents.example.yaml`
|
Полный пример с комментариями: `config/matrix-agents.example.yaml`
|
||||||
|
|
@ -138,15 +137,6 @@ agents:
|
||||||
|
|
||||||
`docker-compose.prod.yml` — bot-only handoff через published image. Платформа добавляет этот сервис в свой compose рядом с agent containers, монтирует shared volume и задаёт переменные окружения. Этот compose не создаёт и не собирает агент-контейнеры.
|
`docker-compose.prod.yml` — bot-only handoff через published image. Платформа добавляет этот сервис в свой compose рядом с agent containers, монтирует shared volume и задаёт переменные окружения. Этот compose не создаёт и не собирает агент-контейнеры.
|
||||||
|
|
||||||
Перед redeploy можно проверить реальные agent routes из той же сети, где будет работать бот:
|
|
||||||
```bash
|
|
||||||
PYTHONPATH=. uv run python -m tools.check_matrix_agents \
|
|
||||||
--config config/matrix-agents.yaml \
|
|
||||||
--timeout 5
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка открывает фактический WebSocket URL каждого агента (`.../v1/agent_ws/{chat_id}/`) и ждёт первый `STATUS`. Для проверки полного запроса к агенту добавьте `--message "ping"`.
|
|
||||||
|
|
||||||
Для запуска опубликованного image:
|
Для запуска опубликованного image:
|
||||||
```bash
|
```bash
|
||||||
export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
|
export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
|
||||||
|
|
@ -157,7 +147,7 @@ docker compose --env-file .env -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
```text
|
```text
|
||||||
mput1/surfaces-bot:latest
|
mput1/surfaces-bot:latest
|
||||||
sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd
|
sha256:26ba3a49290ab7c1cf0fa97f3de3fefdc70b59df7e6f1e0c2255728f8e2369be
|
||||||
```
|
```
|
||||||
|
|
||||||
Для сборки и публикации surface image:
|
Для сборки и публикации surface image:
|
||||||
|
|
@ -193,13 +183,12 @@ rm -f lambda_matrix.db && rm -rf matrix_store
|
||||||
|
|
||||||
```
|
```
|
||||||
Bot (/agents) Agent (/workspace = /agents/N/)
|
Bot (/agents) Agent (/workspace = /agents/N/)
|
||||||
/agents/0/report.pdf ←──── одно и то же хранилище ────→ /workspace/report.pdf
|
/agents/0/incoming/ ←──── одно и то же хранилище ────→ /workspace/incoming/
|
||||||
/agents/0/result.txt ←────────────────────────────────→ /workspace/result.txt
|
/agents/0/output/ ←────────────────────────────────→ /workspace/output/
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Входящий файл** (пользователь → агент): бот сохраняет в `{workspace_path}/{file}`, например `/agents/17/report.pdf`, и передаёт агенту `attachments=["report.pdf"]`
|
- **Входящий файл** (пользователь → агент): бот сохраняет в `{workspace_path}/incoming/{stamp}-{file}`, например `/agents/17/incoming/report.pdf`, и передаёт агенту `attachments=["incoming/{stamp}-{file}"]`
|
||||||
- **Коллизии имён**: если `/agents/17/report.pdf` уже существует, бот сохранит следующий файл как `/agents/17/report (1).pdf`, затем `/agents/17/report (2).pdf`
|
- **Исходящий файл** (агент → пользователь): агент пишет в `/workspace/output/file`, бот читает из `{workspace_path}/output/file`, например `/agents/17/output/file`, и отправляет пользователю как Matrix file message
|
||||||
- **Исходящий файл** (агент → пользователь): агент пишет в `/workspace/file`, бот читает из `{workspace_path}/file`, например `/agents/17/result.txt`, и отправляет пользователю как Matrix file message
|
|
||||||
- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml`
|
- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -279,4 +268,3 @@ pytest tests/adapter/matrix/ -v # только Matrix
|
||||||
| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Команды бота, UX, передача файлов |
|
| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Команды бота, UX, передача файлов |
|
||||||
| [`docs/known-limitations.md`](docs/known-limitations.md) | Известные ограничения и обходные пути |
|
| [`docs/known-limitations.md`](docs/known-limitations.md) | Известные ограничения и обходные пути |
|
||||||
| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Внутренний протокол событий (для расширения) |
|
| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Внутренний протокол событий (для расширения) |
|
||||||
| [`docs/new-surface-guide.md`](docs/new-surface-guide.md) | Руководство по созданию новой поверхности (Discord, Slack и др.) |
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
@ -20,16 +19,6 @@ class AgentDefinition:
|
||||||
workspace_path: str = field(default="")
|
workspace_path: str = field(default="")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AgentAssignment:
|
|
||||||
agent_id: str | None
|
|
||||||
source: Literal["configured", "default", "none"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_default(self) -> bool:
|
|
||||||
return self.source == "default"
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRegistry:
|
class AgentRegistry:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -49,14 +38,6 @@ class AgentRegistry:
|
||||||
def get_agent_id_for_user(self, matrix_user_id: str) -> str | None:
|
def get_agent_id_for_user(self, matrix_user_id: str) -> str | None:
|
||||||
return self._user_agents.get(matrix_user_id)
|
return self._user_agents.get(matrix_user_id)
|
||||||
|
|
||||||
def resolve_agent_for_user(self, matrix_user_id: str) -> AgentAssignment:
|
|
||||||
agent_id = self.get_agent_id_for_user(matrix_user_id)
|
|
||||||
if agent_id is not None:
|
|
||||||
return AgentAssignment(agent_id=agent_id, source="configured")
|
|
||||||
if self.agents:
|
|
||||||
return AgentAssignment(agent_id=self.agents[0].agent_id, source="default")
|
|
||||||
return AgentAssignment(agent_id=None, source="none")
|
|
||||||
|
|
||||||
|
|
||||||
def _required_text(entry: Mapping[str, object], key: str) -> str:
|
def _required_text(entry: Mapping[str, object], key: str) -> str:
|
||||||
value = entry.get(key)
|
value = entry.get(key)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -25,26 +24,21 @@ from nio import (
|
||||||
)
|
)
|
||||||
from nio.responses import SyncResponse
|
from nio.responses import SyncResponse
|
||||||
|
|
||||||
from adapter.matrix.agent_registry import AgentRegistry, AgentRegistryError, load_agent_registry
|
|
||||||
from adapter.matrix.converter import from_room_event
|
from adapter.matrix.converter import from_room_event
|
||||||
from adapter.matrix.files import (
|
from adapter.matrix.files import (
|
||||||
download_matrix_attachment,
|
download_matrix_attachment,
|
||||||
matrix_msgtype_for_attachment,
|
matrix_msgtype_for_attachment,
|
||||||
resolve_workspace_attachment_path,
|
resolve_workspace_attachment_path,
|
||||||
)
|
)
|
||||||
|
from adapter.matrix.agent_registry import AgentRegistry, AgentRegistryError, load_agent_registry
|
||||||
from adapter.matrix.handlers import register_matrix_handlers
|
from adapter.matrix.handlers import register_matrix_handlers
|
||||||
from adapter.matrix.handlers.auth import (
|
from adapter.matrix.handlers.auth import handle_invite, provision_workspace_chat
|
||||||
default_agent_notice,
|
|
||||||
handle_invite,
|
|
||||||
provision_workspace_chat,
|
|
||||||
restore_workspace_access,
|
|
||||||
)
|
|
||||||
from adapter.matrix.handlers.context_commands import (
|
from adapter.matrix.handlers.context_commands import (
|
||||||
LOAD_PROMPT,
|
LOAD_PROMPT,
|
||||||
)
|
)
|
||||||
|
from adapter.matrix.routed_platform import RoutedPlatformClient
|
||||||
from adapter.matrix.reconciliation import reconcile_startup_state
|
from adapter.matrix.reconciliation import reconcile_startup_state
|
||||||
from adapter.matrix.room_router import resolve_chat_id
|
from adapter.matrix.room_router import resolve_chat_id
|
||||||
from adapter.matrix.routed_platform import RoutedPlatformClient
|
|
||||||
from adapter.matrix.store import (
|
from adapter.matrix.store import (
|
||||||
add_staged_attachment,
|
add_staged_attachment,
|
||||||
clear_load_pending,
|
clear_load_pending,
|
||||||
|
|
@ -56,6 +50,7 @@ from adapter.matrix.store import (
|
||||||
remove_staged_attachment_at,
|
remove_staged_attachment_at,
|
||||||
set_pending_confirm,
|
set_pending_confirm,
|
||||||
set_platform_chat_id,
|
set_platform_chat_id,
|
||||||
|
set_room_agent_id,
|
||||||
set_room_meta,
|
set_room_meta,
|
||||||
)
|
)
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager
|
||||||
|
|
@ -123,26 +118,6 @@ def _normalize_agent_base_url(url: str) -> str:
|
||||||
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
||||||
|
|
||||||
|
|
||||||
def _ws_debug_enabled() -> bool:
|
|
||||||
value = os.environ.get("SURFACES_DEBUG_WS", "")
|
|
||||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_debug_logging() -> None:
|
|
||||||
if not _ws_debug_enabled():
|
|
||||||
return
|
|
||||||
root_logger = logging.getLogger()
|
|
||||||
if not root_logger.handlers:
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)-8s] %(name)s %(message)s",
|
|
||||||
)
|
|
||||||
elif root_logger.level > logging.INFO:
|
|
||||||
root_logger.setLevel(logging.INFO)
|
|
||||||
logging.getLogger("lambda_agent_api").setLevel(logging.INFO)
|
|
||||||
logging.getLogger("lambda_agent_api.agent_api").setLevel(logging.INFO)
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_base_url_from_env() -> str:
|
def _agent_base_url_from_env() -> str:
|
||||||
if base_url := os.environ.get("AGENT_BASE_URL"):
|
if base_url := os.environ.get("AGENT_BASE_URL"):
|
||||||
return base_url
|
return base_url
|
||||||
|
|
@ -160,39 +135,13 @@ def _load_agent_registry_from_env(required: bool = False) -> AgentRegistry | Non
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
registry = load_agent_registry(registry_path)
|
return load_agent_registry(registry_path)
|
||||||
except (AgentRegistryError, OSError) as exc:
|
except (AgentRegistryError, OSError) as exc:
|
||||||
raise RuntimeError(f"failed to load matrix agent registry: {registry_path}") from exc
|
raise RuntimeError(f"failed to load matrix agent registry: {registry_path}") from exc
|
||||||
if _ws_debug_enabled():
|
|
||||||
logger.warning(
|
|
||||||
"matrix_agent_registry_loaded",
|
|
||||||
registry_path=registry_path,
|
|
||||||
agent_count=len(registry.agents),
|
|
||||||
)
|
|
||||||
for agent in registry.agents:
|
|
||||||
logger.warning(
|
|
||||||
"matrix_agent_registry_entry",
|
|
||||||
registry_path=registry_path,
|
|
||||||
agent_id=agent.agent_id,
|
|
||||||
label=agent.label,
|
|
||||||
configured_base_url=agent.base_url,
|
|
||||||
normalized_base_url=_normalize_agent_base_url(agent.base_url)
|
|
||||||
if agent.base_url
|
|
||||||
else "",
|
|
||||||
workspace_path=agent.workspace_path,
|
|
||||||
)
|
|
||||||
return registry
|
|
||||||
|
|
||||||
|
|
||||||
def _build_platform_from_env(*, store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
|
def _build_platform_from_env(*, store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
|
||||||
backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
|
backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
|
||||||
if _ws_debug_enabled():
|
|
||||||
logger.warning(
|
|
||||||
"matrix_platform_backend_selected",
|
|
||||||
backend=backend,
|
|
||||||
global_agent_base_url=_agent_base_url_from_env(),
|
|
||||||
registry_path=os.environ.get("MATRIX_AGENT_REGISTRY_PATH", "").strip(),
|
|
||||||
)
|
|
||||||
if backend == "real":
|
if backend == "real":
|
||||||
prototype_state = PrototypeStateStore()
|
prototype_state = PrototypeStateStore()
|
||||||
registry = _load_agent_registry_from_env(required=True)
|
registry = _load_agent_registry_from_env(required=True)
|
||||||
|
|
@ -271,36 +220,6 @@ class MatrixBot:
|
||||||
await next_platform_chat_id(self.runtime.store),
|
await next_platform_chat_id(self.runtime.store),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _refresh_room_agent_assignment(
|
|
||||||
self, room_id: str, matrix_user_id: str, room_meta: dict | None
|
|
||||||
) -> tuple[dict | None, bool]:
|
|
||||||
if not room_meta or room_meta.get("redirect_room_id") or self.runtime.registry is None:
|
|
||||||
return room_meta, False
|
|
||||||
|
|
||||||
assignment = self.runtime.registry.resolve_agent_for_user(matrix_user_id)
|
|
||||||
updated = dict(room_meta)
|
|
||||||
should_warn_default = False
|
|
||||||
|
|
||||||
if assignment.source == "configured" and (
|
|
||||||
updated.get("agent_id") != assignment.agent_id
|
|
||||||
or updated.get("agent_assignment") != "configured"
|
|
||||||
):
|
|
||||||
updated["agent_id"] = assignment.agent_id
|
|
||||||
updated["agent_assignment"] = "configured"
|
|
||||||
updated.pop("default_agent_notice_sent", None)
|
|
||||||
elif assignment.source == "default":
|
|
||||||
if not updated.get("agent_id"):
|
|
||||||
updated["agent_id"] = assignment.agent_id
|
|
||||||
if updated.get("agent_id") == assignment.agent_id:
|
|
||||||
updated["agent_assignment"] = "default"
|
|
||||||
should_warn_default = not updated.get("default_agent_notice_sent")
|
|
||||||
updated["default_agent_notice_sent"] = True
|
|
||||||
|
|
||||||
if updated != room_meta:
|
|
||||||
await set_room_meta(self.runtime.store, room_id, updated)
|
|
||||||
return updated, should_warn_default
|
|
||||||
return room_meta, should_warn_default
|
|
||||||
|
|
||||||
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
|
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
|
||||||
if getattr(event, "sender", None) == self.client.user_id:
|
if getattr(event, "sender", None) == self.client.user_id:
|
||||||
return
|
return
|
||||||
|
|
@ -309,14 +228,6 @@ class MatrixBot:
|
||||||
room_meta = await get_room_meta(self.runtime.store, room.room_id)
|
room_meta = await get_room_meta(self.runtime.store, room.room_id)
|
||||||
if room_meta is not None and not room_meta.get("redirect_room_id"):
|
if room_meta is not None and not room_meta.get("redirect_room_id"):
|
||||||
await self._ensure_platform_chat_id(room.room_id, room_meta)
|
await self._ensure_platform_chat_id(room.room_id, room_meta)
|
||||||
room_meta, warn_default_agent = await self._refresh_room_agent_assignment(
|
|
||||||
room.room_id, sender, room_meta
|
|
||||||
)
|
|
||||||
if warn_default_agent and not body.startswith("!"):
|
|
||||||
await self._send_all(
|
|
||||||
room.room_id,
|
|
||||||
[OutgoingMessage(chat_id=room.room_id, text=default_agent_notice())],
|
|
||||||
)
|
|
||||||
|
|
||||||
load_pending = await get_load_pending(self.runtime.store, sender, room.room_id)
|
load_pending = await get_load_pending(self.runtime.store, sender, room.room_id)
|
||||||
if load_pending is not None and (body.isdigit() or body == "!cancel"):
|
if load_pending is not None and (body.isdigit() or body == "!cancel"):
|
||||||
|
|
@ -330,97 +241,17 @@ class MatrixBot:
|
||||||
await self._send_all(room.room_id, outgoing)
|
await self._send_all(room.room_id, outgoing)
|
||||||
return
|
return
|
||||||
elif room_meta.get("redirect_room_id"):
|
elif room_meta.get("redirect_room_id"):
|
||||||
display_name = getattr(room, "display_name", None) or sender
|
|
||||||
if body == "!new":
|
|
||||||
try:
|
|
||||||
created = await provision_workspace_chat(
|
|
||||||
self.client,
|
|
||||||
sender,
|
|
||||||
display_name,
|
|
||||||
self.runtime.platform,
|
|
||||||
self.runtime.store,
|
|
||||||
self.runtime.auth_mgr,
|
|
||||||
self.runtime.chat_mgr,
|
|
||||||
registry=self.runtime.registry,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"matrix_entry_room_new_chat_failed",
|
|
||||||
room_id=room.room_id,
|
|
||||||
sender=sender,
|
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
await self._send_all(
|
|
||||||
room.room_id,
|
|
||||||
[
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id=room.room_id,
|
|
||||||
text="Не удалось создать новый рабочий чат.",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
welcome = f"Создал новый рабочий чат {created['room_name']}."
|
|
||||||
if created.get("agent_assignment") == "default":
|
|
||||||
welcome = f"{welcome}\n\n{default_agent_notice()}"
|
|
||||||
await self.client.room_send(
|
|
||||||
created["chat_room_id"],
|
|
||||||
"m.room.message",
|
|
||||||
{"msgtype": "m.text", "body": welcome},
|
|
||||||
)
|
|
||||||
await set_room_meta(
|
|
||||||
self.runtime.store,
|
|
||||||
room.room_id,
|
|
||||||
{
|
|
||||||
**room_meta,
|
|
||||||
"redirect_room_id": created["chat_room_id"],
|
|
||||||
"redirect_chat_id": created["chat_id"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await self._send_all(
|
|
||||||
room.room_id,
|
|
||||||
[
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id=room.room_id,
|
|
||||||
text=(
|
|
||||||
f"Создал рабочий чат {created['room_name']} "
|
|
||||||
f"({created['chat_id']}) и отправил приглашение."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
restored = await restore_workspace_access(
|
|
||||||
self.client,
|
|
||||||
sender,
|
|
||||||
display_name,
|
|
||||||
self.runtime.platform,
|
|
||||||
self.runtime.store,
|
|
||||||
self.runtime.auth_mgr,
|
|
||||||
self.runtime.chat_mgr,
|
|
||||||
registry=self.runtime.registry,
|
|
||||||
)
|
|
||||||
redirect_room_id = room_meta["redirect_room_id"]
|
redirect_room_id = room_meta["redirect_room_id"]
|
||||||
redirect_chat_id = room_meta.get("redirect_chat_id", "рабочий чат")
|
redirect_chat_id = room_meta.get("redirect_chat_id", "рабочий чат")
|
||||||
if restored.get("created_new_chat"):
|
|
||||||
text = (
|
|
||||||
f"Создал новый рабочий чат {restored['room_name']} "
|
|
||||||
f"({restored['chat_id']}) и отправил приглашение."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
text = (
|
|
||||||
f"Рабочий чат уже создан: {redirect_chat_id}. "
|
|
||||||
"Я повторно отправил приглашения в пространство Lambda и рабочие чаты. "
|
|
||||||
"Чтобы создать новый чат, напишите !new здесь."
|
|
||||||
)
|
|
||||||
await self._send_all(
|
await self._send_all(
|
||||||
room.room_id,
|
room.room_id,
|
||||||
[
|
[
|
||||||
OutgoingMessage(
|
OutgoingMessage(
|
||||||
chat_id=room.room_id,
|
chat_id=room.room_id,
|
||||||
text=text,
|
text=(
|
||||||
|
f"Рабочий чат уже создан: {redirect_chat_id}. "
|
||||||
|
"Открой приглашённую комнату для продолжения."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
@ -471,15 +302,6 @@ class MatrixBot:
|
||||||
incoming,
|
incoming,
|
||||||
)
|
)
|
||||||
agent_id = (room_meta or {}).get("agent_id")
|
agent_id = (room_meta or {}).get("agent_id")
|
||||||
if _ws_debug_enabled() and not body.startswith("!"):
|
|
||||||
logger.warning(
|
|
||||||
"matrix_incoming_message_route",
|
|
||||||
room_id=room.room_id,
|
|
||||||
sender=sender,
|
|
||||||
local_chat_id=local_chat_id,
|
|
||||||
agent_id=agent_id,
|
|
||||||
platform_chat_id=(room_meta or {}).get("platform_chat_id"),
|
|
||||||
)
|
|
||||||
workspace_root = self._agent_workspace_root(agent_id)
|
workspace_root = self._agent_workspace_root(agent_id)
|
||||||
try:
|
try:
|
||||||
outgoing = await self.runtime.dispatcher.dispatch(incoming)
|
outgoing = await self.runtime.dispatcher.dispatch(incoming)
|
||||||
|
|
@ -698,8 +520,6 @@ class MatrixBot:
|
||||||
f"Привет, {created['user'].display_name or sender}! Пиши — я здесь.\n\n"
|
f"Привет, {created['user'].display_name or sender}! Пиши — я здесь.\n\n"
|
||||||
"Команды: !new · !chats · !rename · !archive · !context · !save · !load · !help"
|
"Команды: !new · !chats · !rename · !archive · !context · !save · !load · !help"
|
||||||
)
|
)
|
||||||
if created.get("agent_assignment") == "default":
|
|
||||||
welcome = f"{welcome}\n\n{default_agent_notice()}"
|
|
||||||
await set_room_meta(
|
await set_room_meta(
|
||||||
self.runtime.store,
|
self.runtime.store,
|
||||||
room.room_id,
|
room.room_id,
|
||||||
|
|
@ -895,7 +715,6 @@ async def send_outgoing(
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
_configure_debug_logging()
|
|
||||||
homeserver = os.environ.get("MATRIX_HOMESERVER")
|
homeserver = os.environ.get("MATRIX_HOMESERVER")
|
||||||
user_id = os.environ.get("MATRIX_USER_ID")
|
user_id = os.environ.get("MATRIX_USER_ID")
|
||||||
device_id = os.environ.get("MATRIX_DEVICE_ID", "")
|
device_id = os.environ.get("MATRIX_DEVICE_ID", "")
|
||||||
|
|
@ -949,15 +768,6 @@ async def main() -> None:
|
||||||
store_path=store_path,
|
store_path=store_path,
|
||||||
request_timeout=client_config.request_timeout,
|
request_timeout=client_config.request_timeout,
|
||||||
)
|
)
|
||||||
if _ws_debug_enabled():
|
|
||||||
logger.warning(
|
|
||||||
"matrix_ws_debug_enabled",
|
|
||||||
homeserver=homeserver,
|
|
||||||
user_id=user_id,
|
|
||||||
backend=os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower(),
|
|
||||||
global_agent_base_url=_agent_base_url_from_env(),
|
|
||||||
registry_path=os.environ.get("MATRIX_AGENT_REGISTRY_PATH", "").strip(),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await client.sync_forever(timeout=30000, since=since_token)
|
await client.sync_forever(timeout=30000, since=since_token)
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
from pathlib import Path, PurePosixPath
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from core.protocol import Attachment
|
from core.protocol import Attachment
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_filename(value: str) -> str:
|
def _sanitize_component(value: str) -> str:
|
||||||
filename = PurePosixPath(str(value).replace("\\", "/")).name.strip()
|
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value)
|
||||||
cleaned = re.sub(r"[\x00-\x1f\x7f<>:\"/\\|?*]+", "_", filename)
|
cleaned = cleaned.strip("._-")
|
||||||
cleaned = cleaned.strip(" .")
|
return cleaned or "unknown"
|
||||||
return cleaned or "attachment.bin"
|
|
||||||
|
|
||||||
|
|
||||||
def _default_filename(attachment: Attachment) -> str:
|
def _default_filename(attachment: Attachment) -> str:
|
||||||
|
|
@ -28,38 +28,38 @@ def _default_filename(attachment: Attachment) -> str:
|
||||||
return f"{base}{extension}"
|
return f"{base}{extension}"
|
||||||
|
|
||||||
|
|
||||||
def _with_copy_index(filename: str, index: int) -> str:
|
def build_workspace_attachment_path(
|
||||||
path = Path(filename)
|
*,
|
||||||
suffix = path.suffix
|
workspace_root: Path,
|
||||||
stem = path.stem if suffix else filename
|
matrix_user_id: str,
|
||||||
return f"{stem} ({index}){suffix}"
|
room_id: str,
|
||||||
|
filename: str,
|
||||||
|
timestamp: str | None = None,
|
||||||
|
) -> tuple[str, Path]:
|
||||||
|
"""Legacy path builder used when no per-agent workspace_path is configured."""
|
||||||
|
stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||||
|
safe_user = _sanitize_component(matrix_user_id.lstrip("@"))
|
||||||
|
safe_room = _sanitize_component(room_id.lstrip("!"))
|
||||||
|
safe_name = _sanitize_component(filename) or "attachment.bin"
|
||||||
|
relative_path = (
|
||||||
|
Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}"
|
||||||
|
)
|
||||||
|
return relative_path.as_posix(), workspace_root / relative_path
|
||||||
|
|
||||||
|
|
||||||
def _unique_workspace_relative_path(workspace_root: Path, filename: str) -> tuple[str, Path]:
|
def build_agent_incoming_path(
|
||||||
safe_name = _sanitize_filename(filename)
|
|
||||||
candidate = workspace_root / safe_name
|
|
||||||
if not candidate.exists():
|
|
||||||
return safe_name, candidate
|
|
||||||
|
|
||||||
index = 1
|
|
||||||
while True:
|
|
||||||
indexed_name = _with_copy_index(safe_name, index)
|
|
||||||
candidate = workspace_root / indexed_name
|
|
||||||
if not candidate.exists():
|
|
||||||
return indexed_name, candidate
|
|
||||||
index += 1
|
|
||||||
|
|
||||||
|
|
||||||
def build_agent_workspace_path(
|
|
||||||
*,
|
*,
|
||||||
workspace_root: Path,
|
workspace_root: Path,
|
||||||
filename: str,
|
filename: str,
|
||||||
|
timestamp: str | None = None,
|
||||||
) -> tuple[str, Path]:
|
) -> tuple[str, Path]:
|
||||||
"""Saves user files directly to {workspace_root}/{filename}.
|
"""Per-agent path builder: saves to {workspace_root}/incoming/{stamp}-{filename}.
|
||||||
|
|
||||||
The returned relative path is what gets passed to agent.send_message(attachments=[...]).
|
The returned relative path is what gets passed to agent.send_message(attachments=[...]).
|
||||||
"""
|
"""
|
||||||
return _unique_workspace_relative_path(workspace_root, filename)
|
stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||||
|
safe_name = _sanitize_component(filename) or "attachment.bin"
|
||||||
|
relative_path = Path("incoming") / f"{stamp}-{safe_name}"
|
||||||
|
return relative_path.as_posix(), workspace_root / relative_path
|
||||||
|
|
||||||
|
|
||||||
async def download_matrix_attachment(
|
async def download_matrix_attachment(
|
||||||
|
|
@ -76,11 +76,21 @@ async def download_matrix_attachment(
|
||||||
|
|
||||||
filename = _default_filename(attachment)
|
filename = _default_filename(attachment)
|
||||||
|
|
||||||
del matrix_user_id, room_id, timestamp
|
if workspace_root.name and str(workspace_root) not in (".", "/workspace", "/agents"):
|
||||||
relative_path, absolute_path = build_agent_workspace_path(
|
# Per-agent workspace configured — use simple incoming/ layout
|
||||||
workspace_root=workspace_root,
|
relative_path, absolute_path = build_agent_incoming_path(
|
||||||
filename=filename,
|
workspace_root=workspace_root,
|
||||||
)
|
filename=filename,
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
relative_path, absolute_path = build_workspace_attachment_path(
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
matrix_user_id=matrix_user_id,
|
||||||
|
room_id=room_id,
|
||||||
|
filename=filename,
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,31 +22,6 @@ def _default_room_name(chat_id: str) -> str:
|
||||||
return f"Чат {suffix}"
|
return f"Чат {suffix}"
|
||||||
|
|
||||||
|
|
||||||
def default_agent_notice() -> str:
|
|
||||||
return (
|
|
||||||
"Внимание: ваш Matrix ID не найден в конфиге агентов. "
|
|
||||||
"Пока используется агент по умолчанию. После добавления вас в конфиг "
|
|
||||||
"бот переключит существующие комнаты на назначенного агента."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _invite_if_possible(client: Any, room_id: str, matrix_user_id: str) -> bool:
|
|
||||||
room_invite = getattr(client, "room_invite", None)
|
|
||||||
if not callable(room_invite):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
await room_invite(room_id, matrix_user_id)
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"matrix_workspace_reinvite_failed",
|
|
||||||
room_id=room_id,
|
|
||||||
user=matrix_user_id,
|
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def provision_workspace_chat(
|
async def provision_workspace_chat(
|
||||||
client: Any,
|
client: Any,
|
||||||
matrix_user_id: str,
|
matrix_user_id: str,
|
||||||
|
|
@ -93,11 +68,10 @@ async def provision_workspace_chat(
|
||||||
room_name = room_name_override or _default_room_name(chat_id)
|
room_name = room_name_override or _default_room_name(chat_id)
|
||||||
|
|
||||||
agent_id = None
|
agent_id = None
|
||||||
agent_assignment = "none"
|
|
||||||
if registry is not None:
|
if registry is not None:
|
||||||
assignment = registry.resolve_agent_for_user(matrix_user_id)
|
agent_id = registry.get_agent_id_for_user(matrix_user_id)
|
||||||
agent_id = assignment.agent_id
|
if agent_id is None and registry.agents:
|
||||||
agent_assignment = assignment.source
|
agent_id = registry.agents[0].agent_id
|
||||||
|
|
||||||
chat_resp = await client.room_create(
|
chat_resp = await client.room_create(
|
||||||
name=room_name,
|
name=room_name,
|
||||||
|
|
@ -136,7 +110,6 @@ async def provision_workspace_chat(
|
||||||
"space_id": space_id,
|
"space_id": space_id,
|
||||||
"platform_chat_id": platform_chat_id,
|
"platform_chat_id": platform_chat_id,
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"agent_assignment": agent_assignment,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await chat_mgr.get_or_create(
|
await chat_mgr.get_or_create(
|
||||||
|
|
@ -153,64 +126,6 @@ async def provision_workspace_chat(
|
||||||
"chat_room_id": chat_room_id,
|
"chat_room_id": chat_room_id,
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"room_name": room_name,
|
"room_name": room_name,
|
||||||
"agent_assignment": agent_assignment,
|
|
||||||
"agent_id": agent_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def restore_workspace_access(
|
|
||||||
client: Any,
|
|
||||||
matrix_user_id: str,
|
|
||||||
display_name: str,
|
|
||||||
platform,
|
|
||||||
store,
|
|
||||||
auth_mgr,
|
|
||||||
chat_mgr,
|
|
||||||
registry: AgentRegistry | None = None,
|
|
||||||
) -> dict:
|
|
||||||
user_meta = await get_user_meta(store, matrix_user_id) or {}
|
|
||||||
space_id = user_meta.get("space_id")
|
|
||||||
if not space_id:
|
|
||||||
created = await provision_workspace_chat(
|
|
||||||
client,
|
|
||||||
matrix_user_id,
|
|
||||||
display_name,
|
|
||||||
platform,
|
|
||||||
store,
|
|
||||||
auth_mgr,
|
|
||||||
chat_mgr,
|
|
||||||
room_name_override="Чат 1",
|
|
||||||
registry=registry,
|
|
||||||
)
|
|
||||||
return {**created, "reinvited_rooms": [], "created_new_chat": True}
|
|
||||||
|
|
||||||
await auth_mgr.confirm(matrix_user_id)
|
|
||||||
await _invite_if_possible(client, space_id, matrix_user_id)
|
|
||||||
|
|
||||||
chats = await chat_mgr.list_active(matrix_user_id)
|
|
||||||
if not chats:
|
|
||||||
created = await provision_workspace_chat(
|
|
||||||
client,
|
|
||||||
matrix_user_id,
|
|
||||||
display_name,
|
|
||||||
platform,
|
|
||||||
store,
|
|
||||||
auth_mgr,
|
|
||||||
chat_mgr,
|
|
||||||
registry=registry,
|
|
||||||
)
|
|
||||||
return {**created, "reinvited_rooms": [], "created_new_chat": True}
|
|
||||||
|
|
||||||
reinvited_rooms = []
|
|
||||||
for chat in chats:
|
|
||||||
if chat.surface_ref:
|
|
||||||
if await _invite_if_possible(client, chat.surface_ref, matrix_user_id):
|
|
||||||
reinvited_rooms.append(chat.surface_ref)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"space_id": space_id,
|
|
||||||
"reinvited_rooms": reinvited_rooms,
|
|
||||||
"created_new_chat": False,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -231,29 +146,6 @@ async def handle_invite(
|
||||||
|
|
||||||
existing = await get_user_meta(store, matrix_user_id)
|
existing = await get_user_meta(store, matrix_user_id)
|
||||||
if existing and existing.get("space_id"):
|
if existing and existing.get("space_id"):
|
||||||
restored = await restore_workspace_access(
|
|
||||||
client,
|
|
||||||
matrix_user_id,
|
|
||||||
display_name,
|
|
||||||
platform,
|
|
||||||
store,
|
|
||||||
auth_mgr,
|
|
||||||
chat_mgr,
|
|
||||||
registry=registry,
|
|
||||||
)
|
|
||||||
body = "Я отправил повторные приглашения в пространство Lambda и рабочие чаты."
|
|
||||||
if restored.get("created_new_chat"):
|
|
||||||
body = (
|
|
||||||
f"Создал новый рабочий чат {restored['room_name']} "
|
|
||||||
f"({restored['chat_id']}) и отправил приглашение."
|
|
||||||
)
|
|
||||||
if restored.get("agent_assignment") == "default":
|
|
||||||
body = f"{body}\n\n{default_agent_notice()}"
|
|
||||||
await client.room_send(
|
|
||||||
room.room_id,
|
|
||||||
"m.room.message",
|
|
||||||
{"msgtype": "m.text", "body": body},
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -276,8 +168,6 @@ async def handle_invite(
|
||||||
f"Привет, {created['user'].display_name or matrix_user_id}! Пиши — я здесь.\n\n"
|
f"Привет, {created['user'].display_name or matrix_user_id}! Пиши — я здесь.\n\n"
|
||||||
"Команды: !new · !chats · !rename · !archive · !clear · !help"
|
"Команды: !new · !chats · !rename · !archive · !clear · !help"
|
||||||
)
|
)
|
||||||
if created.get("agent_assignment") == "default":
|
|
||||||
welcome = f"{welcome}\n\n{default_agent_notice()}"
|
|
||||||
await client.room_send(
|
await client.room_send(
|
||||||
created["chat_room_id"],
|
created["chat_room_id"],
|
||||||
"m.room.message",
|
"m.room.message",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ from nio.api import RoomVisibility
|
||||||
from nio.responses import RoomCreateError
|
from nio.responses import RoomCreateError
|
||||||
|
|
||||||
from adapter.matrix.agent_registry import AgentRegistry
|
from adapter.matrix.agent_registry import AgentRegistry
|
||||||
from adapter.matrix.handlers.auth import default_agent_notice
|
|
||||||
from adapter.matrix.store import (
|
from adapter.matrix.store import (
|
||||||
get_user_meta,
|
get_user_meta,
|
||||||
next_chat_id,
|
next_chat_id,
|
||||||
|
|
@ -108,11 +107,10 @@ def make_handle_new_chat(
|
||||||
)
|
)
|
||||||
|
|
||||||
agent_id = None
|
agent_id = None
|
||||||
agent_assignment = "none"
|
|
||||||
if registry is not None:
|
if registry is not None:
|
||||||
assignment = registry.resolve_agent_for_user(event.user_id)
|
agent_id = registry.get_agent_id_for_user(event.user_id)
|
||||||
agent_id = assignment.agent_id
|
if agent_id is None and registry.agents:
|
||||||
agent_assignment = assignment.source
|
agent_id = registry.agents[0].agent_id
|
||||||
|
|
||||||
room_meta: dict = {
|
room_meta: dict = {
|
||||||
"room_type": "chat",
|
"room_type": "chat",
|
||||||
|
|
@ -122,7 +120,6 @@ def make_handle_new_chat(
|
||||||
"space_id": space_id,
|
"space_id": space_id,
|
||||||
"platform_chat_id": platform_chat_id,
|
"platform_chat_id": platform_chat_id,
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"agent_assignment": agent_assignment,
|
|
||||||
}
|
}
|
||||||
await set_room_meta(store, room_id, room_meta)
|
await set_room_meta(store, room_id, room_meta)
|
||||||
ctx = await chat_mgr.get_or_create(
|
ctx = await chat_mgr.get_or_create(
|
||||||
|
|
@ -132,13 +129,10 @@ def make_handle_new_chat(
|
||||||
surface_ref=room_id,
|
surface_ref=room_id,
|
||||||
name=room_name,
|
name=room_name,
|
||||||
)
|
)
|
||||||
text = f"Создан чат: {ctx.display_name} ({ctx.chat_id})"
|
|
||||||
if agent_assignment == "default":
|
|
||||||
text = f"{text}\n\n{default_agent_notice()}"
|
|
||||||
return [
|
return [
|
||||||
OutgoingMessage(
|
OutgoingMessage(
|
||||||
chat_id=event.chat_id,
|
chat_id=event.chat_id,
|
||||||
text=text,
|
text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,7 @@ def _chat_id_from_room(room: object, existing_meta: dict | None) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _space_id_for_room(
|
def _space_id_for_room(room: object, rooms_by_id: dict[str, object], existing_meta: dict | None) -> str | None:
|
||||||
room: object, rooms_by_id: dict[str, object], existing_meta: dict | None
|
|
||||||
) -> str | None:
|
|
||||||
existing_space_id = (existing_meta or {}).get("space_id")
|
existing_space_id = (existing_meta or {}).get("space_id")
|
||||||
if isinstance(existing_space_id, str) and existing_space_id:
|
if isinstance(existing_space_id, str) and existing_space_id:
|
||||||
return existing_space_id
|
return existing_space_id
|
||||||
|
|
@ -71,9 +69,7 @@ def _space_id_for_room(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _matrix_user_id_for_room(
|
def _matrix_user_id_for_room(room: object, bot_user_id: str | None, existing_meta: dict | None) -> str | None:
|
||||||
room: object, bot_user_id: str | None, existing_meta: dict | None
|
|
||||||
) -> str | None:
|
|
||||||
existing_user_id = (existing_meta or {}).get("matrix_user_id")
|
existing_user_id = (existing_meta or {}).get("matrix_user_id")
|
||||||
if isinstance(existing_user_id, str) and existing_user_id:
|
if isinstance(existing_user_id, str) and existing_user_id:
|
||||||
return existing_user_id
|
return existing_user_id
|
||||||
|
|
@ -132,26 +128,11 @@ async def reconcile_startup_state(client: object, runtime: object) -> Reconcilia
|
||||||
if not room_meta.get("agent_id"):
|
if not room_meta.get("agent_id"):
|
||||||
registry = getattr(runtime, "registry", None)
|
registry = getattr(runtime, "registry", None)
|
||||||
if registry is not None:
|
if registry is not None:
|
||||||
assignment = registry.resolve_agent_for_user(matrix_user_id)
|
agent_id = registry.get_agent_id_for_user(matrix_user_id)
|
||||||
if assignment.agent_id:
|
if agent_id is None and registry.agents:
|
||||||
room_meta["agent_id"] = assignment.agent_id
|
agent_id = registry.agents[0].agent_id
|
||||||
room_meta["agent_assignment"] = assignment.source
|
if agent_id:
|
||||||
else:
|
room_meta["agent_id"] = agent_id
|
||||||
registry = getattr(runtime, "registry", None)
|
|
||||||
if registry is not None:
|
|
||||||
assignment = registry.resolve_agent_for_user(matrix_user_id)
|
|
||||||
if assignment.source == "configured" and (
|
|
||||||
room_meta.get("agent_id") != assignment.agent_id
|
|
||||||
or room_meta.get("agent_assignment") != "configured"
|
|
||||||
):
|
|
||||||
room_meta["agent_id"] = assignment.agent_id
|
|
||||||
room_meta["agent_assignment"] = "configured"
|
|
||||||
elif (
|
|
||||||
assignment.source == "default"
|
|
||||||
and room_meta.get("agent_id") == assignment.agent_id
|
|
||||||
and not room_meta.get("agent_assignment")
|
|
||||||
):
|
|
||||||
room_meta["agent_assignment"] = "default"
|
|
||||||
|
|
||||||
if existing_meta is None:
|
if existing_meta is None:
|
||||||
result.recovered_rooms += 1
|
result.recovered_rooms += 1
|
||||||
|
|
@ -172,9 +153,7 @@ async def reconcile_startup_state(client: object, runtime: object) -> Reconcilia
|
||||||
user_meta = dict(await get_user_meta(runtime.store, matrix_user_id) or {})
|
user_meta = dict(await get_user_meta(runtime.store, matrix_user_id) or {})
|
||||||
user_meta["space_id"] = user_meta.get("space_id") or recovered_space_id
|
user_meta["space_id"] = user_meta.get("space_id") or recovered_space_id
|
||||||
next_chat_index = max_chat_index_by_user[matrix_user_id] + 1
|
next_chat_index = max_chat_index_by_user[matrix_user_id] + 1
|
||||||
user_meta["next_chat_index"] = max(
|
user_meta["next_chat_index"] = max(int(user_meta.get("next_chat_index", 1)), next_chat_index)
|
||||||
int(user_meta.get("next_chat_index", 1)), next_chat_index
|
|
||||||
)
|
|
||||||
await set_user_meta(runtime.store, matrix_user_id, user_meta)
|
await set_user_meta(runtime.store, matrix_user_id, user_meta)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from collections.abc import AsyncIterator, Mapping
|
from collections.abc import AsyncIterator, Mapping
|
||||||
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
from adapter.matrix.store import get_room_meta
|
from adapter.matrix.store import get_room_meta
|
||||||
from core.chat import ChatManager
|
from core.chat import ChatManager
|
||||||
from core.store import StateStore
|
from core.store import StateStore
|
||||||
|
|
@ -18,13 +15,6 @@ from sdk.interface import (
|
||||||
UserSettings,
|
UserSettings,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _ws_debug_enabled() -> bool:
|
|
||||||
value = os.environ.get("SURFACES_DEBUG_WS", "")
|
|
||||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
class RoutedPlatformClient(PlatformClient):
|
class RoutedPlatformClient(PlatformClient):
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -87,9 +77,7 @@ class RoutedPlatformClient(PlatformClient):
|
||||||
if callable(close):
|
if callable(close):
|
||||||
await close()
|
await close()
|
||||||
|
|
||||||
async def _resolve_delegate(
|
async def _resolve_delegate(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
|
||||||
self, user_id: str, local_chat_id: str
|
|
||||||
) -> tuple[PlatformClient, str]:
|
|
||||||
chat = await self._chat_mgr.get(local_chat_id, user_id)
|
chat = await self._chat_mgr.get(local_chat_id, user_id)
|
||||||
if chat is None:
|
if chat is None:
|
||||||
raise PlatformError(
|
raise PlatformError(
|
||||||
|
|
@ -119,15 +107,4 @@ class RoutedPlatformClient(PlatformClient):
|
||||||
code="MATRIX_AGENT_NOT_FOUND",
|
code="MATRIX_AGENT_NOT_FOUND",
|
||||||
)
|
)
|
||||||
|
|
||||||
if _ws_debug_enabled():
|
|
||||||
logger.warning(
|
|
||||||
"matrix_route_resolved",
|
|
||||||
user_id=user_id,
|
|
||||||
local_chat_id=local_chat_id,
|
|
||||||
surface_ref=chat.surface_ref,
|
|
||||||
agent_id=str(agent_id),
|
|
||||||
platform_chat_id=str(platform_chat_id),
|
|
||||||
delegate_type=type(delegate).__name__,
|
|
||||||
)
|
|
||||||
|
|
||||||
return delegate, str(platform_chat_id)
|
return delegate, str(platform_chat_id)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
# base_url — HTTP/WS URL of this agent's endpoint
|
# base_url — HTTP/WS URL of this agent's endpoint
|
||||||
# (overrides the global AGENT_BASE_URL env var for this agent)
|
# (overrides the global AGENT_BASE_URL env var for this agent)
|
||||||
# workspace_path — absolute path to this agent's workspace directory inside the bot container
|
# workspace_path — absolute path to this agent's workspace directory inside the bot container
|
||||||
# (the bot saves incoming files directly here and reads outgoing files from here)
|
# (the bot saves incoming files here and reads outgoing files from here)
|
||||||
# Example: /agents/0 means the bot mounts the shared volume at /agents/
|
# Example: /agents/0 means the bot mounts the shared volume at /agents/
|
||||||
# and this agent's files live under /agents/0/
|
# and this agent's files live under /agents/0/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
agents:
|
|
||||||
- id: agent-0
|
|
||||||
label: "Smoke Agent 0"
|
|
||||||
base_url: "http://agent-proxy:7000/agent_0/"
|
|
||||||
workspace_path: "/agents/0"
|
|
||||||
|
|
||||||
- id: agent-1
|
|
||||||
label: "Smoke Agent 1"
|
|
||||||
base_url: "http://agent-proxy:7000/agent_1/"
|
|
||||||
workspace_path: "/agents/1"
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
services:
|
|
||||||
agent-proxy:
|
|
||||||
volumes:
|
|
||||||
- ./docker/nginx/smoke-agents-timeout.conf:/etc/nginx/nginx.conf:ro
|
|
||||||
depends_on:
|
|
||||||
agent-no-status:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
agent-no-status:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: production
|
|
||||||
args:
|
|
||||||
LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
|
|
||||||
environment:
|
|
||||||
PYTHONUNBUFFERED: "1"
|
|
||||||
command: ["python", "-m", "tools.no_status_agent", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
services:
|
|
||||||
surface-smoke:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: production
|
|
||||||
args:
|
|
||||||
LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
|
|
||||||
environment:
|
|
||||||
PYTHONUNBUFFERED: "1"
|
|
||||||
SMOKE_TIMEOUT: ${SMOKE_TIMEOUT:-5}
|
|
||||||
volumes:
|
|
||||||
- agents:/agents
|
|
||||||
- ./config:/app/config:ro
|
|
||||||
depends_on:
|
|
||||||
agent-proxy:
|
|
||||||
condition: service_healthy
|
|
||||||
command: >
|
|
||||||
sh -lc "
|
|
||||||
python -m tools.check_matrix_agents --config /app/config/matrix-agents.smoke.yaml --timeout ${SMOKE_TIMEOUT:-5}
|
|
||||||
"
|
|
||||||
|
|
||||||
agent-proxy:
|
|
||||||
image: nginx:1.27-alpine
|
|
||||||
volumes:
|
|
||||||
- ./docker/nginx/smoke-agents.conf:/etc/nginx/nginx.conf:ro
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD-SHELL
|
|
||||||
- nc -z 127.0.0.1 7000
|
|
||||||
interval: 2s
|
|
||||||
timeout: 2s
|
|
||||||
retries: 15
|
|
||||||
start_period: 2s
|
|
||||||
depends_on:
|
|
||||||
agent-0:
|
|
||||||
condition: service_healthy
|
|
||||||
agent-1:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
|
||||||
- "${SMOKE_PROXY_PORT:-7000}:7000"
|
|
||||||
|
|
||||||
agent-0:
|
|
||||||
build:
|
|
||||||
context: ./external/platform-agent
|
|
||||||
target: development
|
|
||||||
additional_contexts:
|
|
||||||
agent_api: ./external/platform-agent_api
|
|
||||||
environment:
|
|
||||||
PYTHONUNBUFFERED: "1"
|
|
||||||
AGENT_ID: ${AGENT_0_ID:-agent-0}
|
|
||||||
PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
|
|
||||||
PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
|
|
||||||
PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
|
|
||||||
volumes:
|
|
||||||
- ./external/platform-agent/src:/app/src
|
|
||||||
- ./external/platform-agent_api:/agent_api
|
|
||||||
- agents:/shared-agents
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD-SHELL
|
|
||||||
- python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 12
|
|
||||||
start_period: 5s
|
|
||||||
command: >
|
|
||||||
sh -lc "
|
|
||||||
mkdir -p /shared-agents/0 &&
|
|
||||||
rm -rf /workspace &&
|
|
||||||
ln -s /shared-agents/0 /workspace &&
|
|
||||||
exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
|
|
||||||
"
|
|
||||||
|
|
||||||
agent-1:
|
|
||||||
build:
|
|
||||||
context: ./external/platform-agent
|
|
||||||
target: development
|
|
||||||
additional_contexts:
|
|
||||||
agent_api: ./external/platform-agent_api
|
|
||||||
environment:
|
|
||||||
PYTHONUNBUFFERED: "1"
|
|
||||||
AGENT_ID: ${AGENT_1_ID:-agent-1}
|
|
||||||
PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
|
|
||||||
PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
|
|
||||||
PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
|
|
||||||
volumes:
|
|
||||||
- ./external/platform-agent/src:/app/src
|
|
||||||
- ./external/platform-agent_api:/agent_api
|
|
||||||
- agents:/shared-agents
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD-SHELL
|
|
||||||
- python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 12
|
|
||||||
start_period: 5s
|
|
||||||
command: >
|
|
||||||
sh -lc "
|
|
||||||
mkdir -p /shared-agents/1 &&
|
|
||||||
rm -rf /workspace &&
|
|
||||||
ln -s /shared-agents/1 /workspace &&
|
|
||||||
exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
|
|
||||||
"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
agents:
|
|
||||||
name: ${SURFACES_SMOKE_VOLUME:-surfaces-smoke-agents}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
events {}
|
|
||||||
|
|
||||||
http {
|
|
||||||
map $http_upgrade $connection_upgrade {
|
|
||||||
default upgrade;
|
|
||||||
'' close;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 7000;
|
|
||||||
|
|
||||||
location /agent_0/ {
|
|
||||||
proxy_pass http://agent-0:8000/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /agent_1/ {
|
|
||||||
proxy_pass http://agent-no-status:8000/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
events {}
|
|
||||||
|
|
||||||
http {
|
|
||||||
map $http_upgrade $connection_upgrade {
|
|
||||||
default upgrade;
|
|
||||||
'' close;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 7000;
|
|
||||||
|
|
||||||
location /agent_0/ {
|
|
||||||
proxy_pass http://agent-0:8000/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /agent_1/ {
|
|
||||||
proxy_pass http://agent-1:8000/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
143
docs/api-contract.md
Normal file
143
docs/api-contract.md
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
# API Contract — Lambda Platform
|
||||||
|
|
||||||
|
> **Статус:** ЧЕРНОВИК — проектируем сами, уточняем с Азаматом когда SDK будет готов
|
||||||
|
> **Последнее обновление:** 2026-03-29
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектурный контекст
|
||||||
|
|
||||||
|
Каждому пользователю выделяется **один LXC-контейнер** с workspace 10 ГБ.
|
||||||
|
Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — файлы + `history.db` в каждом.
|
||||||
|
|
||||||
|
**Master** управляет lifecycle контейнера (запуск, заморозка, пробуждение).
|
||||||
|
Бот **не управляет lifecycle** — он передаёт `user_id` + `chat_id` + сообщение.
|
||||||
|
Master сам решает: нужно ли поднять контейнер, смонтировать нужный чат, запустить агента.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.lambda-platform.io/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Аутентификация
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer {SERVICE_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервисный токен выдаётся команде поверхностей. Не путать с токеном пользователя.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
### GET /users/{external_id}?platform={platform}
|
||||||
|
|
||||||
|
Получает или создаёт пользователя.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
- `platform` — `telegram` | `matrix`
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "usr_abc123",
|
||||||
|
"external_id": "12345678",
|
||||||
|
"platform": "telegram",
|
||||||
|
"display_name": "Иван Иванов",
|
||||||
|
"created_at": "2025-01-15T10:30:00Z",
|
||||||
|
"is_new": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Messages
|
||||||
|
|
||||||
|
Бот не управляет сессиями явно. Отправка сообщения — единственная операция.
|
||||||
|
Master решает: нужен ли новый контейнер, или разбудить существующий.
|
||||||
|
|
||||||
|
### POST /users/{user_id}/chats/{chat_id}/messages
|
||||||
|
|
||||||
|
Отправляет сообщение пользователя агенту. Master поднимает/размораживает контейнер,
|
||||||
|
монтирует нужный чат (`C1/`, `C2/`...), запускает агента.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Привет, что ты умеешь?",
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message_id": "msg_qwe012",
|
||||||
|
"response": "Я AI-агент Lambda...",
|
||||||
|
"tokens_used": 142,
|
||||||
|
"finished": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
### GET /users/{user_id}/settings
|
||||||
|
|
||||||
|
Настройки пользователя: скиллы, коннекторы, SOUL, безопасность, план.
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"skills": {"web-search": true, "browser": false},
|
||||||
|
"connectors": {"gmail": {"connected": true, "email": "user@gmail.com"}},
|
||||||
|
"soul": {"name": "Лямбда", "style": "friendly"},
|
||||||
|
"safety": {"email-send": true, "file-delete": true},
|
||||||
|
"plan": {"name": "Beta", "tokens_used": 800, "tokens_limit": 1000}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /users/{user_id}/settings
|
||||||
|
|
||||||
|
Применяет действие над настройками.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "toggle_skill",
|
||||||
|
"payload": {"skill": "browser", "enabled": true}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "ERROR_CODE",
|
||||||
|
"message": "Human readable description",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Коды ошибок: `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR`, `CONTAINER_UNAVAILABLE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Открытые вопросы к команде платфрмы (SDK)
|
||||||
|
|
||||||
|
- [ ] Точный формат эндпоинта отправки сообщения — URL, поля
|
||||||
|
- [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую?
|
||||||
|
- [ ] Стриминговый ответ (SSE / WebSocket) или только sync?
|
||||||
|
- [ ] Формат `SettingsAction` — совпадает с нашим или другой?
|
||||||
|
|
@ -68,7 +68,7 @@ agents:
|
||||||
- `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}/`, читает исходящие из `{workspace_path}/`.
|
Бот сохраняет входящие файлы в `{workspace_path}/incoming/`, читает исходящие из `{workspace_path}/`.
|
||||||
- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
|
- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
|
||||||
|
|
||||||
## Surface Image Build Contract
|
## Surface Image Build Contract
|
||||||
|
|
@ -89,7 +89,7 @@ Published image:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
mput1/surfaces-bot:latest
|
mput1/surfaces-bot:latest
|
||||||
sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd
|
sha256:26ba3a49290ab7c1cf0fa97f3de3fefdc70b59df7e6f1e0c2255728f8e2369be
|
||||||
```
|
```
|
||||||
|
|
||||||
`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа.
|
`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа.
|
||||||
|
|
@ -153,15 +153,14 @@ AgentApi(
|
||||||
### Пользователь → Агент (входящий файл)
|
### Пользователь → Агент (входящий файл)
|
||||||
|
|
||||||
1. Matrix-бот получает файл от пользователя
|
1. Matrix-бот получает файл от пользователя
|
||||||
2. Сохраняет в workspace агента: `/agents/{N}/{filename}`
|
2. Сохраняет в workspace агента: `/agents/{N}/incoming/{filename}`
|
||||||
3. Если файл уже существует, выбирает следующее имя: `filename (1).ext`, `filename (2).ext`
|
3. Вызывает `agent.send_message(text, attachments=["incoming/filename"])`
|
||||||
4. Вызывает `agent.send_message(text, attachments=["filename"])`
|
|
||||||
— путь относительно `/workspace` агента
|
— путь относительно `/workspace` агента
|
||||||
|
|
||||||
### Агент → Пользователь (исходящий файл)
|
### Агент → Пользователь (исходящий файл)
|
||||||
|
|
||||||
1. Агент эмитит `MsgEventSendFile(path="report.pdf")`
|
1. Агент эмитит `MsgEventSendFile(path="output/report.pdf")`
|
||||||
2. Matrix-бот читает файл: `/agents/{N}/report.pdf`
|
2. Matrix-бот читает файл: `/agents/{N}/output/report.pdf`
|
||||||
3. Отправляет как Matrix file message пользователю
|
3. Отправляет как Matrix file message пользователю
|
||||||
|
|
||||||
**Ключевое:** production handoff через `docker-compose.prod.yml` и internal E2E через `docker-compose.fullstack.yml` используют один и тот же `/agents` contract на стороне поверхности. Прямой HTTP-доступ к файлам не нужен.
|
**Ключевое:** production handoff через `docker-compose.prod.yml` и internal E2E через `docker-compose.fullstack.yml` используют один и тот же `/agents` contract на стороне поверхности. Прямой HTTP-доступ к файлам не нужен.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
# Matrix Direct-Agent Prototype
|
# Matrix Direct-Agent Prototype
|
||||||
|
|
||||||
> **ВНИМАНИЕ: Это исторический документ.**
|
|
||||||
> Описанный здесь прототип был интегрирован в `main` и стал основой для Matrix MVP, но архитектура претерпела значительные изменения. Для актуальной информации по деплою смотрите `docs/deploy-architecture.md`, а для создания новой поверхности — `docs/max-surface-guide.md`.
|
|
||||||
|
|
||||||
Русскоязычная заметка по прототипу Matrix surface, который ходит не в `MockPlatformClient`, а напрямую в живой agent backend по WebSocket.
|
Русскоязычная заметка по прототипу Matrix surface, который ходит не в `MockPlatformClient`, а напрямую в живой agent backend по WebSocket.
|
||||||
|
|
||||||
## Что сделали
|
## Что сделали
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ Matrix-клиенты отправляют файлы и текст отдель
|
||||||
## Передача файлов
|
## Передача файлов
|
||||||
|
|
||||||
### Пользователь → Агент
|
### Пользователь → Агент
|
||||||
Бот сохраняет файл в shared volume: `{workspace_path}/{filename}`
|
Бот сохраняет файл в shared volume: `/agents/surfaces/matrix/{user}/{room}/inbox/{stamp}-{filename}`
|
||||||
и передаёт агенту относительный путь как `workspace_path`.
|
и передаёт агенту относительный путь как `workspace_path`.
|
||||||
|
|
||||||
### Агент → Пользователь
|
### Агент → Пользователь
|
||||||
|
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
# Руководство по созданию новой поверхности
|
|
||||||
|
|
||||||
Этот документ описывает, как написать новую новую поверхность (например, Discord, Slack или Custom Web) по образцу текущей Matrix-поверхности в ветке `main`.
|
|
||||||
|
|
||||||
Он основан на актуальной реализации Matrix surface в репозитории и отражает текущую продакшн-логику, а не устаревший легаси.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Общая архитектура
|
|
||||||
|
|
||||||
### 1.1. Что такое поверхность
|
|
||||||
|
|
||||||
Поверхность — это тонкий адаптер между конкретной платформой (Платформа) и общим ядром бота.
|
|
||||||
|
|
||||||
В репозитории есть разделение:
|
|
||||||
|
|
||||||
- `core/` — общее ядро и бизнес-логика
|
|
||||||
- `adapter/<platform>/` — реализация конкретной поверхности
|
|
||||||
- `sdk/real.py` — работа с реальной платформой / агентом
|
|
||||||
- `config/` — статическая конфигурация агентов
|
|
||||||
- `docs/surface-protocol.md` — общий контракт поверхностей
|
|
||||||
|
|
||||||
### 1.2. Как это работает
|
|
||||||
|
|
||||||
Поверхность должна:
|
|
||||||
|
|
||||||
- принимать нативные события от Платформа
|
|
||||||
- преобразовывать их в единый внутренний контракт (`IncomingMessage`, `IncomingCommand`, `IncomingCallback`)
|
|
||||||
- передавать их в `core`
|
|
||||||
- получать ответы из `core` (`OutgoingMessage`, `OutgoingUI`, `OutgoingTyping`, `OutgoingNotification`)
|
|
||||||
- преобразовывать ответы обратно в нативные нативные сообщения
|
|
||||||
|
|
||||||
Поверхность не должна:
|
|
||||||
|
|
||||||
- управлять жизненным циклом агентских контейнеров
|
|
||||||
- хранить долгую историю бесед вне `core`/платформы
|
|
||||||
- аутентифицировать пользователей сама (если это не часть Платформа API)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Структура новой поверхности
|
|
||||||
|
|
||||||
### 2.1. Основные каталоги
|
|
||||||
|
|
||||||
Рекомендуемая структура для новой платформы:
|
|
||||||
|
|
||||||
```
|
|
||||||
adapter/<platform>/
|
|
||||||
bot.py
|
|
||||||
converter.py
|
|
||||||
agent_registry.py
|
|
||||||
files.py
|
|
||||||
handlers/
|
|
||||||
store.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2. Принцип reuse
|
|
||||||
|
|
||||||
По примеру Matrix surface, New surface должен переиспользовать общий `core` и общий `sdk`.
|
|
||||||
|
|
||||||
Не дублируйте бизнес-логику, а реализуйте только адаптер:
|
|
||||||
|
|
||||||
- `adapter/<platform>/converter.py` — конвертация событий платформы ⇄ внутренние структуры
|
|
||||||
- `adapter/<platform>/bot.py` — основной runtime, старт Платформа client, loop, отправка/прием
|
|
||||||
- `adapter/<platform>/agent_registry.py` — загрузка `config/<platform>-agents.yaml`
|
|
||||||
- `adapter/<platform>/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`
|
|
||||||
|
|
||||||
Для Платформа реализуйте аналогичную логику для 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. Рекомендуемая Версия для новой платформы
|
|
||||||
|
|
||||||
Создайте `config/<platform>-agents.yaml` с тем же смыслом.
|
|
||||||
|
|
||||||
- `user_agents` — маппинг external 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`
|
|
||||||
|
|
||||||
Для New surface тот же принцип:
|
|
||||||
|
|
||||||
- каждая внешняя беседа должна привязываться к одному внутреннему `chat_id`
|
|
||||||
- этот `chat_id` используется для вызовов агента
|
|
||||||
- если в Платформа есть несколько комнат/топиков, каждая должна иметь свой `surface_ref`
|
|
||||||
|
|
||||||
### 6.2. Команды управления чатами
|
|
||||||
|
|
||||||
Matrix поддерживает следующие команды, которые нужно сохранить в Платформа:
|
|
||||||
|
|
||||||
- `!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
|
|
||||||
- следующий текст отправляется агенту вместе со всеми файлами из очереди
|
|
||||||
|
|
||||||
В Платформа можно реализовать ту же модель:
|
|
||||||
|
|
||||||
- `!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`)
|
|
||||||
|
|
||||||
Для New surface используйте аналогичные переменные:
|
|
||||||
|
|
||||||
- `PLATFORM_PLATFORM_BACKEND=real`
|
|
||||||
- `PLATFORM_AGENT_REGISTRY_PATH=/app/config/<platform>-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`
|
|
||||||
|
|
||||||
В New surface реализуйте ту же логику, заменив префиксы на `PLATFORM_`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Локальное тестирование
|
|
||||||
|
|
||||||
Для тестирования новой поверхности вместе с одним локальным агентом используйте паттерн `docker-compose.fullstack.yml`.
|
|
||||||
В этом режиме:
|
|
||||||
- Запускается 1 контейнер вашей поверхности
|
|
||||||
- Запускается 1 контейнер `platform-agent`
|
|
||||||
- Поднимается локальный shared volume (`surfaces-agents`)
|
|
||||||
- Поверхность настроена маршрутизировать запросы на `http://platform-agent:8000` (через `AGENT_BASE_URL`)
|
|
||||||
- Пользователь общается с ботом, а бот напрямую общается с локальным агентом, разделяя с ним общую папку для файлов.
|
|
||||||
|
|
||||||
Это самый быстрый способ проверить интеграцию новой платформы без внешнего бэкенда.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Реализация шаг за шагом
|
|
||||||
|
|
||||||
1. Скопировать `adapter/matrix/` как шаблон для `adapter/<platform>/`.
|
|
||||||
2. Сделать `adapter/<platform>/converter.py`:
|
|
||||||
- превратить native нативные сообщения в `IncomingMessage`
|
|
||||||
- превратить команды в `IncomingCommand`
|
|
||||||
- превратить yes/no-подтверждения в `IncomingCallback`
|
|
||||||
3. Сделать `adapter/<platform>/agent_registry.py` на основе `adapter/matrix/agent_registry.py`.
|
|
||||||
4. Сделать `adapter/<platform>/files.py` на основе `adapter/matrix/files.py`.
|
|
||||||
5. Сделать `adapter/<platform>/bot.py`:
|
|
||||||
- инстанцировать runtime
|
|
||||||
- читать env vars `PLATFORM_*`
|
|
||||||
- загружать реестр агентов
|
|
||||||
- обрабатывать входящие события
|
|
||||||
- отправлять `Outgoing*` обратно в Платформа
|
|
||||||
6. Реализовать команды управления чатами и очередь вложений.
|
|
||||||
7. Прописать `config/<platform>-agents.yaml`.
|
|
||||||
8. Прописать `docker-compose.platform.yml` или аналог, чтобы surface монтировал `/agents`.
|
|
||||||
9. Написать тесты по аналогии с `tests/adapter/matrix/`.
|
|
||||||
10. Проверить, что все env vars читаются из окружения и не зависят от устаревших Matrix-переменных.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Важные замечания
|
|
||||||
|
|
||||||
- Текущий Matrix surface на ветке `main` — активная реализация, а не устаревший легаси.
|
|
||||||
- Документация и код согласованы: `agent_registry`, `files`, `routed_platform`, `reconciliation` работают вместе.
|
|
||||||
- Обязательно явно задавайте `SURFACES_WORKSPACE_DIR=/agents` в production, если `workspace_path` в реестре указывает на `/agents/*`.
|
|
||||||
- Для New 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/`
|
|
||||||
|
|
@ -1,855 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -38,10 +38,9 @@ surfaces-bot/
|
||||||
converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API
|
converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API
|
||||||
bot.py — точка входа, клиент
|
bot.py — точка входа, клиент
|
||||||
|
|
||||||
sdk/
|
platform/
|
||||||
interface.py — Protocol: PlatformClient (контракт к SDK)
|
interface.py — Protocol: PlatformClient
|
||||||
real.py — RealPlatformClient (через AgentApi)
|
mock.py — MockPlatformClient
|
||||||
mock.py — MockPlatformClient (для локальных тестов)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -141,7 +140,7 @@ class UIButton:
|
||||||
```
|
```
|
||||||
|
|
||||||
Telegram рендерит это как InlineKeyboard.
|
Telegram рендерит это как InlineKeyboard.
|
||||||
Matrix рендерит как текст (в MVP).
|
Matrix рендерит как текст с описанием реакций или HTML-кнопки.
|
||||||
|
|
||||||
### OutgoingNotification
|
### OutgoingNotification
|
||||||
Асинхронное уведомление — агент закончил долгую задачу.
|
Асинхронное уведомление — агент закончил долгую задачу.
|
||||||
|
|
@ -210,7 +209,7 @@ class ConfirmationRequest:
|
||||||
```
|
```
|
||||||
|
|
||||||
Telegram показывает как Inline-кнопки.
|
Telegram показывает как Inline-кнопки.
|
||||||
Matrix показывает как запрос для `!yes` / `!no`.
|
Matrix показывает как реакции 👍 / ❌.
|
||||||
Ядро не знает как именно — только получает `IncomingCallback` с `action: "confirm"`.
|
Ядро не знает как именно — только получает `IncomingCallback` с `action: "confirm"`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -305,9 +304,9 @@ class PlatformClient(Protocol):
|
||||||
async def update_settings(self, user_id: str, action: Any) -> None: ...
|
async def update_settings(self, user_id: str, action: Any) -> None: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
Бот **не управляет lifecycle контейнеров** агентов. Запуск/перезапуск агентов — ответственность платформы.
|
Бот **не управляет lifecycle контейнеров** — это делает Master (платформа).
|
||||||
Бот передаёт `user_id` + `chat_id` + текст.
|
Бот передаёт `user_id` + `chat_id` + текст; Master сам решает нужно ли поднять контейнер, смонтировать `C1/`/`C2/`, запустить агента.
|
||||||
|
|
||||||
`MockPlatformClient` реализует этот протокол для локальных тестов.
|
`MockPlatformClient` реализует этот протокол сейчас.
|
||||||
Реальный SDK используется через `RealPlatformClient` (`sdk/real.py`), который подключается к `AgentApi` по WebSocket.
|
Реальный SDK — тоже реализует этот протокол, заменяя один файл.
|
||||||
Адаптеры поверхностей и ядро не меняются вообще, привязка идёт через `config/matrix-agents.yaml`.
|
Адаптеры поверхностей и ядро не меняются вообще.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
# Telegram — описание прототипа
|
# Telegram — описание прототипа
|
||||||
|
|
||||||
> **ВНИМАНИЕ: Telegram-адаптер не является частью текущего MVP-деплоя.**
|
|
||||||
> Код Telegram-поверхности находится в отдельной ветке `feat/telegram-adapter`. Данный документ описывает возможности этого адаптера, но многие концепции (например, AuthFlow и MockPlatformClient) устарели по отношению к актуальной архитектуре `main`.
|
|
||||||
|
|
||||||
## Концепция
|
## Концепция
|
||||||
|
|
||||||
Один бот, несколько чатов через Topics в Forum-группе.
|
Один бот, несколько чатов через Topics в Forum-группе.
|
||||||
|
|
|
||||||
65
docs/user-flow.md
Normal file
65
docs/user-flow.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# User Flow — Lambda Bot
|
||||||
|
|
||||||
|
> **Статус:** ШАБЛОН — заполняет @architect после исследований
|
||||||
|
> **Зависит от:** docs/research/telegram-flows.md, docs/research/competitor-ux.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Основной сценарий (happy path)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant Bot as Telegram/Matrix Bot
|
||||||
|
participant Platform as Lambda Platform (Master)
|
||||||
|
|
||||||
|
User->>Bot: /start
|
||||||
|
Bot->>Platform: GET /users/{tg_id}?platform=telegram
|
||||||
|
Platform-->>Bot: {user_id, is_new}
|
||||||
|
|
||||||
|
alt Новый пользователь
|
||||||
|
Bot->>User: Приветствие + инструкция
|
||||||
|
else Существующий пользователь
|
||||||
|
Bot->>User: Добро пожаловать обратно
|
||||||
|
end
|
||||||
|
|
||||||
|
loop Диалог (бот не управляет сессиями — Master делает это автоматически)
|
||||||
|
User->>Bot: Сообщение в чат C1/C2/...
|
||||||
|
Bot->>Platform: POST /users/{user_id}/chats/{chat_id}/messages
|
||||||
|
Note over Platform: Master поднимает контейнер,<br/>монтирует нужный чат, запускает агента
|
||||||
|
Platform-->>Bot: {message_id, response, tokens_used}
|
||||||
|
Bot->>User: Ответ агента
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Состояния FSM (Telegram)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Unauthenticated: первый контакт
|
||||||
|
|
||||||
|
Unauthenticated --> Idle: /start (auth confirmed)
|
||||||
|
|
||||||
|
Idle --> WaitingResponse: сообщение пользователя
|
||||||
|
WaitingResponse --> Idle: ответ получен
|
||||||
|
WaitingResponse --> Error: ошибка платформы
|
||||||
|
|
||||||
|
Idle --> Idle: /new (создан новый чат)
|
||||||
|
Idle --> ConfirmAction: агент запрашивает подтверждение
|
||||||
|
ConfirmAction --> Idle: подтверждено / отменено
|
||||||
|
|
||||||
|
Error --> Idle: /start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
> Заполняет @researcher и @architect после исследований
|
||||||
|
|
||||||
|
- [ ] Как выглядит онбординг новых пользователей у конкурентов?
|
||||||
|
- [ ] Нужна ли кнопка "Новая сессия" или сессия стартует автоматически?
|
||||||
|
- [ ] Что показываем пока агент думает (typing indicator)?
|
||||||
|
- [ ] Как обрабатываем timeout ответа от платформы?
|
||||||
174
docs/workflow-backup-2026-04-01.md
Normal file
174
docs/workflow-backup-2026-04-01.md
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# Surfaces team — Lambda Lab 3.0
|
||||||
|
|
||||||
|
Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda.
|
||||||
|
|
||||||
|
## Правило №1: не быть ждуном
|
||||||
|
|
||||||
|
Платформа (SDK от Азамата) ещё не готова. Это **не блокер**.
|
||||||
|
|
||||||
|
- Все вызовы платформы — через `platform/interface.py` (Protocol)
|
||||||
|
- Реализация сейчас — `platform/mock.py` (MockPlatformClient)
|
||||||
|
- При подключении реального SDK — меняем только `platform/mock.py`
|
||||||
|
- Архитектурные решения принимаем сами, фиксируем в `docs/api-contract.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
surfaces-bot/
|
||||||
|
core/
|
||||||
|
protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...)
|
||||||
|
handler.py — EventDispatcher: IncomingEvent → OutgoingEvent (общее для всех ботов)
|
||||||
|
handlers/ — обработчики по типам событий (start, message, chat, settings, callback)
|
||||||
|
store.py — StateStore Protocol + InMemoryStore + SQLiteStore
|
||||||
|
chat.py — ChatManager: метаданные чатов C1/C2/C3
|
||||||
|
auth.py — AuthManager: AuthFlow
|
||||||
|
settings.py — SettingsManager: SettingsAction
|
||||||
|
|
||||||
|
adapter/
|
||||||
|
telegram/ — aiogram адаптер
|
||||||
|
converter.py — aiogram Event → IncomingEvent и обратно
|
||||||
|
bot.py — точка входа
|
||||||
|
handlers/ — aiogram роутеры
|
||||||
|
keyboards/ — инлайн-клавиатуры
|
||||||
|
states.py — FSM состояния
|
||||||
|
matrix/ — matrix-nio адаптер
|
||||||
|
converter.py — matrix-nio Event → IncomingEvent и обратно
|
||||||
|
bot.py — точка входа
|
||||||
|
handlers/ — обработчики событий
|
||||||
|
|
||||||
|
platform/
|
||||||
|
interface.py — Protocol: PlatformClient (контракт к SDK)
|
||||||
|
mock.py — MockPlatformClient (заглушка)
|
||||||
|
|
||||||
|
docs/ — вся документация
|
||||||
|
tests/ — pytest тесты
|
||||||
|
.claude/agents/ — конфиги агентов
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробно об унификации: `docs/surface-protocol.md`
|
||||||
|
Telegram функционал: `docs/telegram-prototype.md`
|
||||||
|
Matrix функционал: `docs/matrix-prototype.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Агенты
|
||||||
|
|
||||||
|
| Агент | Когда запускать | Модель | Токены |
|
||||||
|
|-------|----------------|--------|--------|
|
||||||
|
| `@researcher` | Изучить API, найти примеры | Haiku | ~дёшево |
|
||||||
|
| `@architect` | Спроектировать решение | Sonnet | ~средне |
|
||||||
|
| `@tg-developer` | Писать код Telegram-адаптера | Sonnet | ~средне |
|
||||||
|
| `@matrix-developer` | Писать код Matrix-адаптера | Sonnet | ~средне |
|
||||||
|
| `@core-developer` | Писать core/ и platform/ | Sonnet | ~средне |
|
||||||
|
| `@reviewer` | Проверить код перед PR | Sonnet | ~средне |
|
||||||
|
|
||||||
|
**Важно (Pro-лимиты):** не запускай больше двух Sonnet-агентов одновременно.
|
||||||
|
Haiku можно запускать параллельно сколько угодно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стратегия параллельной разработки
|
||||||
|
|
||||||
|
Два бота разрабатываются параллельно, но через общее ядро.
|
||||||
|
|
||||||
|
### Порядок работы
|
||||||
|
|
||||||
|
```
|
||||||
|
1. core/ — сначала (однократно, все ждут)
|
||||||
|
@core-developer пишет protocol.py, handler.py, session.py, auth.py, settings.py
|
||||||
|
|
||||||
|
2. platform/ — сразу после core/
|
||||||
|
@core-developer пишет interface.py и mock.py
|
||||||
|
|
||||||
|
3. adapter/telegram/ и adapter/matrix/ — параллельно
|
||||||
|
@tg-developer → adapter/telegram/
|
||||||
|
@matrix-developer → adapter/matrix/
|
||||||
|
Не пересекаются по файлам — можно одновременно в разных терминалах.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что можно делать одновременно (разные терминалы)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Терминал 1 — Telegram адаптер
|
||||||
|
claude "Use @tg-developer to implement adapter/telegram/handlers/start.py"
|
||||||
|
|
||||||
|
# Терминал 2 — Matrix адаптер (параллельно)
|
||||||
|
claude "Use @matrix-developer to implement adapter/matrix/handlers/start.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что нельзя делать одновременно
|
||||||
|
|
||||||
|
- Два агента в одном файле
|
||||||
|
- @core-developer параллельно с @tg-developer или @matrix-developer
|
||||||
|
(core/ должен быть готов до адаптеров)
|
||||||
|
- Больше двух Sonnet-агентов одновременно (Pro-лимит)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git worktree workflow
|
||||||
|
|
||||||
|
Каждая фича в отдельном worktree — адаптеры не мешают друг другу:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать worktrees для параллельной работы
|
||||||
|
git worktree add .worktrees/telegram -b feat/telegram-adapter
|
||||||
|
git worktree add .worktrees/matrix -b feat/matrix-adapter
|
||||||
|
|
||||||
|
# Работать в каждом независимо
|
||||||
|
cd .worktrees/telegram && claude "Use @tg-developer to ..."
|
||||||
|
cd .worktrees/matrix && claude "Use @matrix-developer to ..."
|
||||||
|
|
||||||
|
# Смержить когда готово
|
||||||
|
git checkout main
|
||||||
|
git merge feat/telegram-adapter
|
||||||
|
git merge feat/matrix-adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды запуска
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установить зависимости
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Запустить тесты
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Запустить только тесты Telegram
|
||||||
|
pytest tests/adapter/telegram/ -v
|
||||||
|
|
||||||
|
# Запустить только тесты Matrix
|
||||||
|
pytest tests/adapter/matrix/ -v
|
||||||
|
|
||||||
|
# Запустить только тесты ядра
|
||||||
|
pytest tests/core/ -v
|
||||||
|
|
||||||
|
# Запустить Telegram бота
|
||||||
|
python -m adapter.telegram.bot
|
||||||
|
|
||||||
|
# Запустить Matrix бота
|
||||||
|
python -m adapter.matrix.bot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Никогда не коммить `.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Экономия токенов (Pro-лимиты)
|
||||||
|
|
||||||
|
- Исследования → всегда `@researcher` (Haiku), не Sonnet
|
||||||
|
- Точечные правки в одном файле → напрямую без агента
|
||||||
|
- Ревью → только перед PR, не после каждого коммита
|
||||||
|
- Длинный контекст → дай агенту конкретный файл, не весь проект
|
||||||
|
- Если агент "завис" в рассуждениях → прерви, переформулируй задачу точнее
|
||||||
35
sdk/real.py
35
sdk/real.py
|
|
@ -1,11 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
|
|
@ -24,11 +21,6 @@ from sdk.upstream_agent_api import AgentApi, MsgEventSendFile, MsgEventTextChunk
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _ws_debug_enabled() -> bool:
|
|
||||||
value = os.environ.get("SURFACES_DEBUG_WS", "")
|
|
||||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
class RealPlatformClient(PlatformClient):
|
class RealPlatformClient(PlatformClient):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -39,20 +31,11 @@ class RealPlatformClient(PlatformClient):
|
||||||
agent_api_cls=AgentApi,
|
agent_api_cls=AgentApi,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._agent_id = agent_id
|
self._agent_id = agent_id
|
||||||
self._raw_agent_base_url = agent_base_url
|
self._agent_base_url = agent_base_url
|
||||||
self._agent_base_url = self._normalize_agent_base_url(agent_base_url)
|
|
||||||
self._agent_api_cls = agent_api_cls
|
self._agent_api_cls = agent_api_cls
|
||||||
self._prototype_state = prototype_state
|
self._prototype_state = prototype_state
|
||||||
self._platform = platform
|
self._platform = platform
|
||||||
self._chat_send_locks: dict[str, asyncio.Lock] = {}
|
self._chat_send_locks: dict[str, asyncio.Lock] = {}
|
||||||
if _ws_debug_enabled():
|
|
||||||
logger.warning(
|
|
||||||
"agent_client_initialized",
|
|
||||||
agent_id=self._agent_id,
|
|
||||||
platform=self._platform,
|
|
||||||
raw_base_url=self._raw_agent_base_url,
|
|
||||||
normalized_base_url=self._agent_base_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def agent_id(self) -> str:
|
def agent_id(self) -> str:
|
||||||
|
|
@ -188,28 +171,12 @@ class RealPlatformClient(PlatformClient):
|
||||||
yield event
|
yield event
|
||||||
|
|
||||||
def _build_chat_api(self, chat_id: str):
|
def _build_chat_api(self, chat_id: str):
|
||||||
if _ws_debug_enabled():
|
|
||||||
logger.warning(
|
|
||||||
"agent_chat_api_build",
|
|
||||||
agent_id=self._agent_id,
|
|
||||||
chat_id=str(chat_id),
|
|
||||||
normalized_base_url=self._agent_base_url,
|
|
||||||
ws_url=urljoin(self._agent_base_url, f"v1/agent_ws/{chat_id}/"),
|
|
||||||
)
|
|
||||||
return self._agent_api_cls(
|
return self._agent_api_cls(
|
||||||
agent_id=self._agent_id,
|
agent_id=self._agent_id,
|
||||||
base_url=self._agent_base_url,
|
base_url=self._agent_base_url,
|
||||||
chat_id=str(chat_id),
|
chat_id=str(chat_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_agent_base_url(base_url: str) -> str:
|
|
||||||
parsed = urlsplit(base_url)
|
|
||||||
path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
|
|
||||||
if path:
|
|
||||||
path = f"{path}/"
|
|
||||||
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _close_chat_api(chat_api) -> None:
|
async def _close_chat_api(chat_api) -> None:
|
||||||
close = getattr(chat_api, "close", None)
|
close = getattr(chat_api, "close", None)
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
|
|
@ -211,7 +211,7 @@ async def test_invite_event_is_idempotent_per_user():
|
||||||
|
|
||||||
assert client.join.await_count == 2
|
assert client.join.await_count == 2
|
||||||
assert client.room_create.await_count == 2
|
assert client.room_create.await_count == 2
|
||||||
assert client.room_send.await_count == 2
|
client.room_send.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
async def test_bot_ignores_its_own_messages():
|
async def test_bot_ignores_its_own_messages():
|
||||||
|
|
@ -348,8 +348,7 @@ async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path,
|
||||||
base_url="http://lambda.coredump.ru:7000/agent_17/",
|
base_url="http://lambda.coredump.ru:7000/agent_17/",
|
||||||
workspace_path=str(tmp_path / "agents" / "17"),
|
workspace_path=str(tmp_path / "agents" / "17"),
|
||||||
)
|
)
|
||||||
],
|
]
|
||||||
user_agents={"@alice:example.org": "agent-17"},
|
|
||||||
)
|
)
|
||||||
await set_room_meta(
|
await set_room_meta(
|
||||||
runtime.store,
|
runtime.store,
|
||||||
|
|
@ -382,7 +381,7 @@ async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path,
|
||||||
staged = await get_staged_attachments(
|
staged = await get_staged_attachments(
|
||||||
runtime.store, "!chat17:example.org", "@alice:example.org"
|
runtime.store, "!chat17:example.org", "@alice:example.org"
|
||||||
)
|
)
|
||||||
assert staged[0]["workspace_path"] == "report.pdf"
|
assert staged[0]["workspace_path"].startswith("incoming/")
|
||||||
assert (
|
assert (
|
||||||
tmp_path / "agents" / "17" / staged[0]["workspace_path"]
|
tmp_path / "agents" / "17" / staged[0]["workspace_path"]
|
||||||
).read_bytes() == b"%PDF-1.7"
|
).read_bytes() == b"%PDF-1.7"
|
||||||
|
|
@ -390,7 +389,7 @@ async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path,
|
||||||
|
|
||||||
async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path, monkeypatch):
|
async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
|
monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
|
||||||
output_file = tmp_path / "agents" / "17" / "result.txt"
|
output_file = tmp_path / "agents" / "17" / "output" / "result.txt"
|
||||||
output_file.parent.mkdir(parents=True)
|
output_file.parent.mkdir(parents=True)
|
||||||
output_file.write_text("ready", encoding="utf-8")
|
output_file.write_text("ready", encoding="utf-8")
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
|
|
@ -402,8 +401,7 @@ async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path
|
||||||
base_url="http://lambda.coredump.ru:7000/agent_17/",
|
base_url="http://lambda.coredump.ru:7000/agent_17/",
|
||||||
workspace_path=str(tmp_path / "agents" / "17"),
|
workspace_path=str(tmp_path / "agents" / "17"),
|
||||||
)
|
)
|
||||||
],
|
]
|
||||||
user_agents={"@alice:example.org": "agent-17"},
|
|
||||||
)
|
)
|
||||||
await set_room_meta(
|
await set_room_meta(
|
||||||
runtime.store,
|
runtime.store,
|
||||||
|
|
@ -431,7 +429,7 @@ async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path
|
||||||
type="document",
|
type="document",
|
||||||
filename="result.txt",
|
filename="result.txt",
|
||||||
mime_type="text/plain",
|
mime_type="text/plain",
|
||||||
workspace_path="result.txt",
|
workspace_path="output/result.txt",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,29 @@ from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from adapter.matrix.files import (
|
from adapter.matrix.files import (
|
||||||
build_agent_workspace_path,
|
build_agent_incoming_path,
|
||||||
|
build_workspace_attachment_path,
|
||||||
download_matrix_attachment,
|
download_matrix_attachment,
|
||||||
)
|
)
|
||||||
from core.protocol import Attachment
|
from core.protocol import Attachment
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_workspace_attachment_path_scopes_by_surface_user_and_room(tmp_path: Path):
|
||||||
|
rel_path, abs_path = build_workspace_attachment_path(
|
||||||
|
workspace_root=tmp_path,
|
||||||
|
matrix_user_id="@alice:example.org",
|
||||||
|
room_id="!room:example.org",
|
||||||
|
filename="report.pdf",
|
||||||
|
timestamp="20260420-153000",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
rel_path
|
||||||
|
== "surfaces/matrix/alice_example.org/room_example.org/inbox/20260420-153000-report.pdf"
|
||||||
|
)
|
||||||
|
assert abs_path == tmp_path / rel_path
|
||||||
|
|
||||||
|
|
||||||
async def test_download_matrix_attachment_persists_file_and_returns_workspace_path(tmp_path: Path):
|
async def test_download_matrix_attachment_persists_file_and_returns_workspace_path(tmp_path: Path):
|
||||||
async def download(url: str):
|
async def download(url: str):
|
||||||
assert url == "mxc://server/id"
|
assert url == "mxc://server/id"
|
||||||
|
|
@ -32,46 +49,40 @@ async def test_download_matrix_attachment_persists_file_and_returns_workspace_pa
|
||||||
timestamp="20260420-153000",
|
timestamp="20260420-153000",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert saved.workspace_path == "report.pdf"
|
assert saved.workspace_path is not None
|
||||||
assert (tmp_path / "report.pdf").read_bytes() == b"%PDF-1.7"
|
assert saved.workspace_path.endswith("20260420-153000-report.pdf")
|
||||||
|
assert (tmp_path / saved.workspace_path).read_bytes() == b"%PDF-1.7"
|
||||||
|
|
||||||
|
|
||||||
def test_build_agent_workspace_path_uses_agent_workspace_volume(tmp_path: Path):
|
def test_build_workspace_attachment_path_keeps_room_safe_agents_relative_contract(tmp_path: Path):
|
||||||
rel_path, abs_path = build_agent_workspace_path(
|
rel_path, abs_path = build_workspace_attachment_path(
|
||||||
workspace_root=tmp_path / "agents" / "17",
|
workspace_root=tmp_path / "agents" / "7",
|
||||||
filename="quarterly status.pdf",
|
matrix_user_id="@alice+bob:example.org",
|
||||||
|
room_id="!room/ops:example.org",
|
||||||
|
filename="quarterly status (final).pdf",
|
||||||
|
timestamp="20260420-153000",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert rel_path == "quarterly status.pdf"
|
assert rel_path == (
|
||||||
|
"surfaces/matrix/alice_bob_example.org/room_ops_example.org/inbox/"
|
||||||
|
"20260420-153000-quarterly_status_final_.pdf"
|
||||||
|
)
|
||||||
|
assert not Path(rel_path).is_absolute()
|
||||||
|
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
|
assert abs_path == tmp_path / "agents" / "17" / rel_path
|
||||||
|
|
||||||
|
|
||||||
def test_build_agent_workspace_path_uses_windows_style_copy_index(tmp_path: Path):
|
async def test_download_matrix_attachment_uses_agent_workspace_incoming_dir(tmp_path: Path):
|
||||||
workspace_root = tmp_path / "agents" / "17"
|
|
||||||
workspace_root.mkdir(parents=True)
|
|
||||||
(workspace_root / "report.pdf").write_bytes(b"old")
|
|
||||||
(workspace_root / "report (1).pdf").write_bytes(b"older")
|
|
||||||
|
|
||||||
rel_path, abs_path = build_agent_workspace_path(
|
|
||||||
workspace_root=workspace_root,
|
|
||||||
filename="report.pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert rel_path == "report (2).pdf"
|
|
||||||
assert abs_path == workspace_root / "report (2).pdf"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_agent_workspace_path_sanitizes_to_basename(tmp_path: Path):
|
|
||||||
rel_path, abs_path = build_agent_workspace_path(
|
|
||||||
workspace_root=tmp_path / "agents" / "17",
|
|
||||||
filename="../../quarterly: status?.pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert rel_path == "quarterly_ status_.pdf"
|
|
||||||
assert abs_path == tmp_path / "agents" / "17" / "quarterly_ status_.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_download_matrix_attachment_uses_agent_workspace_root(tmp_path: Path):
|
|
||||||
async def download(url: str):
|
async def download(url: str):
|
||||||
assert url == "mxc://server/id"
|
assert url == "mxc://server/id"
|
||||||
return SimpleNamespace(body=b"%PDF-1.7")
|
return SimpleNamespace(body=b"%PDF-1.7")
|
||||||
|
|
@ -90,5 +101,5 @@ async def test_download_matrix_attachment_uses_agent_workspace_root(tmp_path: Pa
|
||||||
timestamp="20260428-110000",
|
timestamp="20260428-110000",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert saved.workspace_path == "report.pdf"
|
assert saved.workspace_path == "incoming/20260428-110000-report.pdf"
|
||||||
assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7"
|
assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from nio.api import RoomVisibility
|
||||||
|
|
||||||
from adapter.matrix.bot import build_runtime
|
from adapter.matrix.bot import build_runtime
|
||||||
from adapter.matrix.handlers.auth import handle_invite
|
from adapter.matrix.handlers.auth import handle_invite
|
||||||
from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
|
from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta
|
||||||
from sdk.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -100,53 +100,6 @@ async def test_mat02_invite_idempotent():
|
||||||
assert client.room_create.await_count == 2
|
assert client.room_create.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
async def test_existing_user_invite_reinvites_space_and_active_chats():
|
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
|
||||||
await set_user_meta(
|
|
||||||
runtime.store,
|
|
||||||
"@alice:example.org",
|
|
||||||
{"space_id": "!space:example.org", "next_chat_index": 2},
|
|
||||||
)
|
|
||||||
await set_room_meta(
|
|
||||||
runtime.store,
|
|
||||||
"!chat1:example.org",
|
|
||||||
{
|
|
||||||
"room_type": "chat",
|
|
||||||
"chat_id": "C1",
|
|
||||||
"display_name": "Чат 1",
|
|
||||||
"matrix_user_id": "@alice:example.org",
|
|
||||||
"space_id": "!space:example.org",
|
|
||||||
"platform_chat_id": "1",
|
|
||||||
"agent_id": "agent-1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await runtime.chat_mgr.get_or_create(
|
|
||||||
user_id="@alice:example.org",
|
|
||||||
chat_id="C1",
|
|
||||||
platform="matrix",
|
|
||||||
surface_ref="!chat1:example.org",
|
|
||||||
name="Чат 1",
|
|
||||||
)
|
|
||||||
client = _make_client()
|
|
||||||
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
|
|
||||||
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
|
|
||||||
|
|
||||||
await handle_invite(
|
|
||||||
client,
|
|
||||||
room,
|
|
||||||
event,
|
|
||||||
runtime.platform,
|
|
||||||
runtime.store,
|
|
||||||
runtime.auth_mgr,
|
|
||||||
runtime.chat_mgr,
|
|
||||||
)
|
|
||||||
|
|
||||||
client.room_create.assert_not_awaited()
|
|
||||||
client.room_invite.assert_any_await("!space:example.org", "@alice:example.org")
|
|
||||||
client.room_invite.assert_any_await("!chat1:example.org", "@alice:example.org")
|
|
||||||
client.room_send.assert_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mat03_no_hardcoded_c1():
|
async def test_mat03_no_hardcoded_c1():
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 7})
|
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 7})
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import importlib
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
|
|
||||||
from adapter.matrix.bot import MatrixBot, build_runtime
|
from adapter.matrix.bot import MatrixBot, build_runtime
|
||||||
from adapter.matrix.reconciliation import reconcile_startup_state
|
from adapter.matrix.reconciliation import reconcile_startup_state
|
||||||
from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
|
from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
|
||||||
|
|
@ -125,55 +124,6 @@ async def test_reconcile_startup_state_is_idempotent_with_existing_local_state()
|
||||||
assert chats[0].chat_id == "C3"
|
assert chats[0].chat_id == "C3"
|
||||||
|
|
||||||
|
|
||||||
async def test_reconcile_updates_default_agent_assignment_after_user_is_configured():
|
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
|
||||||
runtime.registry = AgentRegistry(
|
|
||||||
[
|
|
||||||
AgentDefinition("agent-default", "Default"),
|
|
||||||
AgentDefinition("agent-alice", "Alice"),
|
|
||||||
],
|
|
||||||
user_agents={"@alice:example.org": "agent-alice"},
|
|
||||||
)
|
|
||||||
client = SimpleNamespace(
|
|
||||||
user_id="@bot:example.org",
|
|
||||||
rooms={
|
|
||||||
"!space:example.org": _room(
|
|
||||||
"!space:example.org",
|
|
||||||
"Lambda - Alice",
|
|
||||||
["@bot:example.org", "@alice:example.org"],
|
|
||||||
),
|
|
||||||
"!chat3:example.org": _room(
|
|
||||||
"!chat3:example.org",
|
|
||||||
"Чат 3",
|
|
||||||
["@bot:example.org", "@alice:example.org"],
|
|
||||||
parents=("!space:example.org",),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await set_room_meta(
|
|
||||||
runtime.store,
|
|
||||||
"!chat3:example.org",
|
|
||||||
{
|
|
||||||
"room_type": "chat",
|
|
||||||
"chat_id": "C3",
|
|
||||||
"display_name": "Чат 3",
|
|
||||||
"matrix_user_id": "@alice:example.org",
|
|
||||||
"space_id": "!space:example.org",
|
|
||||||
"platform_chat_id": "42",
|
|
||||||
"agent_id": "agent-default",
|
|
||||||
"agent_assignment": "default",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await reconcile_startup_state(client, runtime)
|
|
||||||
|
|
||||||
room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
|
|
||||||
assert room_meta is not None
|
|
||||||
assert room_meta["agent_id"] == "agent-alice"
|
|
||||||
assert room_meta["agent_assignment"] == "configured"
|
|
||||||
assert room_meta["platform_chat_id"] == "42"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reconciliation_prevents_lazy_bootstrap_for_existing_room():
|
async def test_reconciliation_prevents_lazy_bootstrap_for_existing_room():
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
client = SimpleNamespace(
|
client = SimpleNamespace(
|
||||||
|
|
|
||||||
|
|
@ -185,24 +185,6 @@ async def test_real_platform_client_send_message_uses_direct_agent_api_per_chat(
|
||||||
assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 0
|
assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_real_platform_client_preserves_path_base_url_without_trailing_slash():
|
|
||||||
agent_api = FakeAgentApiFactory()
|
|
||||||
client = RealPlatformClient(
|
|
||||||
agent_id="agent-17",
|
|
||||||
agent_base_url="http://lambda.coredump.ru:7000/agent_17",
|
|
||||||
agent_api_cls=agent_api,
|
|
||||||
prototype_state=PrototypeStateStore(),
|
|
||||||
platform="matrix",
|
|
||||||
)
|
|
||||||
|
|
||||||
await client.send_message("@alice:example.org", "41", "hello")
|
|
||||||
|
|
||||||
assert agent_api.created_calls == [
|
|
||||||
("agent-17", "http://lambda.coredump.ru:7000/agent_17/", "41")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_real_platform_client_forwards_attachments_to_chat_api():
|
async def test_real_platform_client_forwards_attachments_to_chat_api():
|
||||||
agent_api = FakeAgentApiFactory(chat_api_cls=AttachmentTrackingChatAgentApi)
|
agent_api = FakeAgentApiFactory(chat_api_cls=AttachmentTrackingChatAgentApi)
|
||||||
|
|
@ -231,15 +213,15 @@ async def test_real_platform_client_forwards_attachments_to_chat_api():
|
||||||
|
|
||||||
def test_attachment_paths_normalize_workspace_roots_to_relative_paths():
|
def test_attachment_paths_normalize_workspace_roots_to_relative_paths():
|
||||||
attachments = [
|
attachments = [
|
||||||
Attachment(workspace_path="/workspace/report.pdf"),
|
Attachment(workspace_path="/workspace/output/report.pdf"),
|
||||||
Attachment(workspace_path="/agents/7/report.csv"),
|
Attachment(workspace_path="/agents/7/output/report.csv"),
|
||||||
Attachment(workspace_path="note.txt"),
|
Attachment(workspace_path="surfaces/matrix/alice/room/inbox/note.txt"),
|
||||||
]
|
]
|
||||||
|
|
||||||
assert RealPlatformClient._attachment_paths(attachments) == [
|
assert RealPlatformClient._attachment_paths(attachments) == [
|
||||||
"report.pdf",
|
"output/report.pdf",
|
||||||
"report.csv",
|
"output/report.csv",
|
||||||
"note.txt",
|
"surfaces/matrix/alice/room/inbox/note.txt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -275,12 +257,9 @@ async def test_real_platform_client_preserves_send_file_events_in_sync_result(mo
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("location", "expected_workspace_path"),
|
("location", "expected_workspace_path"),
|
||||||
[
|
[
|
||||||
("/workspace/report.pdf", "report.pdf"),
|
("/workspace/output/report.pdf", "output/report.pdf"),
|
||||||
("/agents/7/report.pdf", "report.pdf"),
|
("/agents/7/output/report.pdf", "output/report.pdf"),
|
||||||
(
|
("surfaces/matrix/alice/room/inbox/report.pdf", "surfaces/matrix/alice/room/inbox/report.pdf"),
|
||||||
"surfaces/matrix/alice/room/inbox/report.pdf",
|
|
||||||
"surfaces/matrix/alice/room/inbox/report.pdf",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_attachment_from_send_file_event_normalizes_shared_volume_paths(
|
def test_attachment_from_send_file_event_normalizes_shared_volume_paths(
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
from tools.check_matrix_agents import build_agent_ws_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_agent_ws_url_preserves_path_prefix_without_trailing_slash():
|
|
||||||
assert (
|
|
||||||
build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17", "41")
|
|
||||||
== "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_agent_ws_url_preserves_path_prefix_with_trailing_slash():
|
|
||||||
assert (
|
|
||||||
build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/", "41")
|
|
||||||
== "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_agent_ws_url_accepts_existing_agent_ws_url():
|
|
||||||
assert (
|
|
||||||
build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/0/", "41")
|
|
||||||
== "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
|
|
||||||
)
|
|
||||||
|
|
@ -39,21 +39,6 @@ def test_dockerfile_production_build_does_not_require_local_external_tree():
|
||||||
assert "uv pip install --system --ignore-requires-python" not in dockerfile
|
assert "uv pip install --system --ignore-requires-python" not in dockerfile
|
||||||
|
|
||||||
|
|
||||||
def test_dockerfile_installs_agent_api_after_final_uv_sync():
|
|
||||||
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
|
|
||||||
development = dockerfile.split("FROM base AS development", maxsplit=1)[1].split(
|
|
||||||
"FROM base AS production", maxsplit=1
|
|
||||||
)[0]
|
|
||||||
production = dockerfile.split("FROM base AS production", maxsplit=1)[1]
|
|
||||||
|
|
||||||
assert development.index("RUN uv sync --no-dev --frozen") < development.index(
|
|
||||||
"pip install --no-cache-dir --ignore-requires-python -e /agent_api/"
|
|
||||||
)
|
|
||||||
assert production.index("RUN uv sync --no-dev --frozen") < production.index(
|
|
||||||
"git+https://git.lambda.coredump.ru/platform/agent_api.git"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_dockerignore_excludes_local_only_and_runtime_artifacts():
|
def test_dockerignore_excludes_local_only_and_runtime_artifacts():
|
||||||
dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
|
dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
@ -75,28 +60,3 @@ def test_agent_registry_example_documents_multi_agent_volume_contract():
|
||||||
for index, agent in enumerate(agents):
|
for index, agent in enumerate(agents):
|
||||||
assert agent["base_url"].endswith(f"/agent_{index}/")
|
assert agent["base_url"].endswith(f"/agent_{index}/")
|
||||||
assert agent["workspace_path"] == f"/agents/{index}"
|
assert agent["workspace_path"] == f"/agents/{index}"
|
||||||
|
|
||||||
|
|
||||||
def test_smoke_compose_models_deploy_like_proxy_and_surface_checker():
|
|
||||||
smoke = _compose("docker-compose.smoke.yml")
|
|
||||||
|
|
||||||
assert set(smoke["services"]) >= {"surface-smoke", "agent-proxy", "agent-0", "agent-1"}
|
|
||||||
assert "tools.check_matrix_agents" in smoke["services"]["surface-smoke"]["command"]
|
|
||||||
assert smoke["services"]["agent-proxy"]["ports"] == ["${SMOKE_PROXY_PORT:-7000}:7000"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_smoke_timeout_override_routes_one_agent_to_no_status_stub():
|
|
||||||
smoke_timeout = _compose("docker-compose.smoke.timeout.yml")
|
|
||||||
|
|
||||||
assert set(smoke_timeout["services"]) >= {"agent-proxy", "agent-no-status"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_smoke_registry_targets_local_proxy_routes():
|
|
||||||
registry = yaml.safe_load(
|
|
||||||
(ROOT / "config" / "matrix-agents.smoke.yaml").read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [agent["base_url"] for agent in registry["agents"]] == [
|
|
||||||
"http://agent-proxy:7000/agent_0/",
|
|
||||||
"http://agent-proxy:7000/agent_1/",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Operational tools for surfaces-bot."""
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from adapter.matrix.agent_registry import AgentDefinition, load_agent_registry
|
|
||||||
from sdk.real import RealPlatformClient
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AgentCheckResult:
|
|
||||||
agent_id: str
|
|
||||||
label: str
|
|
||||||
chat_id: str
|
|
||||||
base_url: str
|
|
||||||
ws_url: str
|
|
||||||
ok: bool
|
|
||||||
stage: str
|
|
||||||
latency_ms: int
|
|
||||||
error: str = ""
|
|
||||||
response_type: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def build_agent_ws_url(base_url: str, chat_id: str) -> str:
|
|
||||||
normalized = RealPlatformClient._normalize_agent_base_url(base_url)
|
|
||||||
return urljoin(normalized, f"v1/agent_ws/{chat_id}/")
|
|
||||||
|
|
||||||
|
|
||||||
def _message_type(payload: str) -> str:
|
|
||||||
try:
|
|
||||||
data = json.loads(payload)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return ""
|
|
||||||
value = data.get("type")
|
|
||||||
return value if isinstance(value, str) else ""
|
|
||||||
|
|
||||||
|
|
||||||
async def _receive_text(ws: aiohttp.ClientWebSocketResponse, timeout: float) -> str:
|
|
||||||
msg = await asyncio.wait_for(ws.receive(), timeout=timeout)
|
|
||||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
||||||
return str(msg.data)
|
|
||||||
if msg.type == aiohttp.WSMsgType.ERROR:
|
|
||||||
raise RuntimeError(f"websocket error: {ws.exception()}")
|
|
||||||
raise RuntimeError(f"unexpected websocket message type: {msg.type.name}")
|
|
||||||
|
|
||||||
|
|
||||||
async def check_agent(
|
|
||||||
agent: AgentDefinition,
|
|
||||||
*,
|
|
||||||
fallback_base_url: str,
|
|
||||||
chat_id: str,
|
|
||||||
timeout: float,
|
|
||||||
message: str | None,
|
|
||||||
) -> AgentCheckResult:
|
|
||||||
base_url = agent.base_url or fallback_base_url
|
|
||||||
ws_url = build_agent_ws_url(base_url, chat_id) if base_url else ""
|
|
||||||
started = time.perf_counter()
|
|
||||||
|
|
||||||
def result(ok: bool, stage: str, error: str = "", response_type: str = "") -> AgentCheckResult:
|
|
||||||
return AgentCheckResult(
|
|
||||||
agent_id=agent.agent_id,
|
|
||||||
label=agent.label,
|
|
||||||
chat_id=chat_id,
|
|
||||||
base_url=base_url,
|
|
||||||
ws_url=ws_url,
|
|
||||||
ok=ok,
|
|
||||||
stage=stage,
|
|
||||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
||||||
error=error,
|
|
||||||
response_type=response_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not base_url:
|
|
||||||
return result(False, "config", "missing base_url and AGENT_BASE_URL")
|
|
||||||
|
|
||||||
try:
|
|
||||||
client_timeout = aiohttp.ClientTimeout(
|
|
||||||
total=timeout,
|
|
||||||
connect=timeout,
|
|
||||||
sock_connect=timeout,
|
|
||||||
sock_read=timeout,
|
|
||||||
)
|
|
||||||
async with aiohttp.ClientSession(timeout=client_timeout) as session:
|
|
||||||
async with session.ws_connect(ws_url, heartbeat=30) as ws:
|
|
||||||
raw_status = await _receive_text(ws, timeout)
|
|
||||||
status_type = _message_type(raw_status)
|
|
||||||
if status_type != "STATUS":
|
|
||||||
return result(
|
|
||||||
False,
|
|
||||||
"status",
|
|
||||||
f"expected STATUS, got {raw_status[:200]}",
|
|
||||||
status_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not message:
|
|
||||||
return result(True, "status", response_type=status_type)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"type": "USER_MESSAGE",
|
|
||||||
"text": message,
|
|
||||||
"attachments": [],
|
|
||||||
}
|
|
||||||
await ws.send_str(json.dumps(payload))
|
|
||||||
|
|
||||||
while True:
|
|
||||||
raw_event = await _receive_text(ws, timeout)
|
|
||||||
event_type = _message_type(raw_event)
|
|
||||||
if event_type == "ERROR":
|
|
||||||
return result(False, "message", raw_event[:200], event_type)
|
|
||||||
if event_type == "AGENT_EVENT_END":
|
|
||||||
return result(True, "message", response_type=event_type)
|
|
||||||
if not event_type:
|
|
||||||
return result(False, "message", f"invalid JSON event: {raw_event[:200]}")
|
|
||||||
except TimeoutError:
|
|
||||||
return result(False, "timeout", f"no response within {timeout:g}s")
|
|
||||||
except Exception as exc:
|
|
||||||
return result(False, "connect", str(exc))
|
|
||||||
|
|
||||||
|
|
||||||
def _select_agents(
|
|
||||||
agents: tuple[AgentDefinition, ...],
|
|
||||||
selected: set[str],
|
|
||||||
) -> list[AgentDefinition]:
|
|
||||||
if not selected:
|
|
||||||
return list(agents)
|
|
||||||
return [agent for agent in agents if agent.agent_id in selected]
|
|
||||||
|
|
||||||
|
|
||||||
async def run_checks(args: argparse.Namespace) -> list[AgentCheckResult]:
|
|
||||||
registry = load_agent_registry(args.config)
|
|
||||||
selected = _select_agents(registry.agents, set(args.agent))
|
|
||||||
if not selected:
|
|
||||||
raise SystemExit("no matching agents selected")
|
|
||||||
|
|
||||||
fallback_base_url = args.base_url or os.environ.get("AGENT_BASE_URL", "")
|
|
||||||
semaphore = asyncio.Semaphore(args.concurrency)
|
|
||||||
|
|
||||||
async def run_one(index: int, agent: AgentDefinition) -> AgentCheckResult:
|
|
||||||
chat_id = str(args.chat_id if args.chat_id is not None else args.chat_id_base + index)
|
|
||||||
async with semaphore:
|
|
||||||
return await check_agent(
|
|
||||||
agent,
|
|
||||||
fallback_base_url=fallback_base_url,
|
|
||||||
chat_id=chat_id,
|
|
||||||
timeout=args.timeout,
|
|
||||||
message=args.message,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await asyncio.gather(*(run_one(index, agent) for index, agent in enumerate(selected)))
|
|
||||||
|
|
||||||
|
|
||||||
def print_table(results: list[AgentCheckResult]) -> None:
|
|
||||||
for item in results:
|
|
||||||
status = "OK" if item.ok else "FAIL"
|
|
||||||
detail = item.response_type or item.error
|
|
||||||
print(
|
|
||||||
f"{status:4} {item.agent_id:20} {item.stage:8} "
|
|
||||||
f"{item.latency_ms:5}ms chat={item.chat_id} url={item.ws_url} {detail}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Smoke-check Matrix agent WebSocket endpoints from matrix-agents.yaml."
|
|
||||||
)
|
|
||||||
parser.add_argument("--config", type=Path, default=Path("config/matrix-agents.yaml"))
|
|
||||||
parser.add_argument("--agent", action="append", default=[], help="Agent id to check")
|
|
||||||
parser.add_argument("--base-url", default="", help="Fallback base URL when an agent has none")
|
|
||||||
parser.add_argument("--timeout", type=float, default=10.0)
|
|
||||||
parser.add_argument("--concurrency", type=int, default=5)
|
|
||||||
parser.add_argument("--chat-id", type=int, default=None, help="Use one explicit chat id")
|
|
||||||
parser.add_argument("--chat-id-base", type=int, default=900000)
|
|
||||||
parser.add_argument("--message", default=None, help="Optional test message after STATUS")
|
|
||||||
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
results = asyncio.run(run_checks(args))
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps([asdict(result) for result in results], ensure_ascii=False, indent=2))
|
|
||||||
else:
|
|
||||||
print_table(results)
|
|
||||||
return 0 if all(result.ok for result in results) else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
|
|
||||||
async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
|
|
||||||
ws = web.WebSocketResponse()
|
|
||||||
await ws.prepare(request)
|
|
||||||
await asyncio.sleep(3600)
|
|
||||||
return ws
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="WebSocket stub that accepts connections but sends no STATUS."
|
|
||||||
)
|
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
|
||||||
parser.add_argument("--port", type=int, default=8000)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
args = parse_args()
|
|
||||||
app = web.Application()
|
|
||||||
app.router.add_get("/v1/agent_ws/{chat_id}/", websocket_handler)
|
|
||||||
web.run_app(app, host=args.host, port=args.port)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue