diff --git a/.dockerignore b/.dockerignore index 1996568..2d88441 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,11 +6,16 @@ __pycache__/ .ruff_cache/ .venv/ .worktrees/ +external/ +.planning/ +docs/superpowers/ +tests/ # Local runtime state must not be baked into the image. lambda_matrix.db matrix_store/ lambda_bot.db +config/matrix-agents.yaml # Local environment and editor state .env diff --git a/.env.example b/.env.example index 5c1cb66..cc5f2e0 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,32 @@ -# Telegram -TELEGRAM_BOT_TOKEN=your_bot_token_here - -# Matrix -MATRIX_HOMESERVER=https://matrix.org -MATRIX_USER_ID=@bot:matrix.org +# Matrix bot credentials +MATRIX_HOMESERVER=https://matrix.example.org +MATRIX_USER_ID=@lambda-bot:example.org +# Use ONE of: MATRIX_PASSWORD or MATRIX_ACCESS_TOKEN MATRIX_PASSWORD=your_password_here +# MATRIX_ACCESS_TOKEN=your_access_token_here + +# Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only) MATRIX_PLATFORM_BACKEND=real -# Shared workspace contract -SURFACES_WORKSPACE_DIR=/workspace +# Published surface image used by docker-compose.prod.yml. +# Must point to a Docker Hub/registry namespace where you have push/pull access. +SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest -# Compose-local platform-agent route -AGENT_BASE_URL=http://platform-agent:8000 +# platform/agent_api ref used when building a surface image +LAMBDA_AGENT_API_REF=master -# platform-agent provider -PROVIDER_MODEL=openai/gpt-4o-mini -PROVIDER_URL=https://openrouter.ai/api/v1 -PROVIDER_API_KEY=sk-or-... +# Path to agent registry inside the container (mounted via ./config:/app/config:ro) +MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml + +# HTTP URL of the platform-agent endpoint +# Production: external agent managed by the platform +# Fullstack E2E: overridden to http://platform-agent:8000 by docker-compose.fullstack.yml +AGENT_BASE_URL=http://your-agent-host:8000 + +# Shared volume path inside the bot container (default: /agents). +# For multi-agent production, each agent gets a subdirectory such as /agents/0. +SURFACES_WORKSPACE_DIR=/agents + +# Docker volume names (created automatically on first run) +SURFACES_SHARED_VOLUME=surfaces-agents +SURFACES_BOT_STATE_VOLUME=surfaces-bot-state diff --git a/.planning/HANDOFF.json b/.planning/HANDOFF.json deleted file mode 100644 index 25f1d19..0000000 --- a/.planning/HANDOFF.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "version": "1.0", - "timestamp": "2026-04-21T22:33:11.666Z", - "phase": "04", - "phase_name": "Matrix MVP: shared agent context and context management commands", - "phase_dir": ".planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma", - "plan": 3, - "task": 3, - "total_tasks": 3, - "status": "paused", - "completed_tasks": [ - { - "id": 1, - "name": "Стабилизировать Matrix MVP runtime: numeric platform_chat_id mapping, staged attachments, clean vendored platform repos", - "status": "done", - "commit": "4524a6a" - }, - { - "id": 2, - "name": "Перевести transport layer на thin adapter над pinned upstream AgentApi и обновить тесты/документацию", - "status": "done", - "commit": "0c2884c" - }, - { - "id": 3, - "name": "Провести финальную локализацию streaming bug и зафиксировать platform-side diagnosis в подробном отчёте", - "status": "done", - "commit": "0c2884c" - } - ], - "remaining_tasks": [ - { - "id": 4, - "name": "Передать платформенной команде финальный bug report и дождаться triage/fix proposal", - "status": "not_started" - }, - { - "id": 5, - "name": "После ответа платформы решить follow-up phase для surfaces hardening: tokens_used optional, bounded session cache, import/config cleanup, protocol contract tests", - "status": "not_started" - }, - { - "id": 6, - "name": "После platform fix повторно прогнать Matrix live smoke на text/tool/file/image сценариях", - "status": "not_started" - } - ], - "blockers": [ - { - "description": "После tool/file flow начало ответа может пропадать; raw logs показывают, что первый повреждённый MsgEventTextChunk уже рождается внутри platform-agent до websocket-клиента", - "type": "external", - "workaround": "Только документирование и platform bug report; локально больше не лечить transport hacks" - }, - { - "description": "platform-agent отправляет duplicate END", - "type": "external", - "workaround": "Не чинить в surfaces; держать как известный platform-side дефект до upstream исправления" - }, - { - "description": "Image path падает на больших data URI (>10 MB) и сопровождается WS 1009", - "type": "external", - "workaround": "Удалять oversized staged attachments и предупреждать пользователя; root fix только на платформе" - }, - { - "description": "tokens_used остаётся 0, потому что pinned platform-agent_api.AgentApi не публикует MsgEventEnd наружу", - "type": "external", - "workaround": "Считать текущее значение неизвестным; не городить локальные костыли" - } - ], - "human_actions_pending": [ - { - "action": "Отправить платформенной команде финальный отчёт docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md", - "context": "Это основной артефакт с итоговым аудиторским выводом и raw evidence", - "blocking": true - }, - { - "action": "Решить, оформлять ли отдельную follow-up phase в roadmap под production cleanup surfaces после platform triage", - "context": "Сейчас реализация признана рабочей, но проблемной; часть hardening-задач осознанно отложена", - "blocking": false - } - ], - "decisions": [ - { - "decision": "Не патчить vendored platform repos для рабочей реализации; все platform-side изменения использовались только как временная локальная диагностика и были откатаны", - "rationale": "Нужна чистая граница ответственности между surfaces и платформой", - "phase": "04" - }, - { - "decision": "Оставить transport layer максимально thin: AgentApiWrapper только строит клиента на chat_id, а stream semantics принадлежат upstream AgentApi", - "rationale": "Так проще локализовать баги и не смешивать platform bugs с локальными workaround’ами", - "phase": "04" - }, - { - "decision": "Считать текущую Matrix real integration рабочей, но проблемной из-за upstream streaming/image bugs", - "rationale": "Live flow в целом работает, однако после tool/file path есть подтверждённые platform-side дефекты", - "phase": "04" - }, - { - "decision": "Не лечить missing-first-chunk локальными transport hacks повторно", - "rationale": "После cleanup и raw tracing корень локализован на стороне platform-agent; дальнейшие локальные обходы только размоют диагностику", - "phase": "04" - } - ], - "uncommitted_files": [], - "next_action": "Начать с отправки финального bug report платформенной команде; до их triage не менять transport semantics в surfaces повторно", - "context_notes": "Сессия завершилась полной очисткой transport layer до thin adapter, обновлением README, финальным bug report и подтверждением через raw logs, что повреждённый первый chunk рождается внутри platform-agent до websocket-клиента. Рабочая ветка clean, последние meaningful commits: 0c2884c и 4524a6a. Если продолжать работу в surfaces без ответа платформы, единственный разумный фронт — инфраструктурный hardening вокруг known limitations, а не ещё одна попытка локально чинить поток." -} diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index a8043bd..d90b47e 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -2,56 +2,44 @@ ## What This Is -Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda. Каждый бот — тонкий адаптер поверх общего ядра (`core/`), изолирующего бизнес-логику от транспорта. Платформа подключается через `sdk/interface.py` Protocol; сейчас используется `MockPlatformClient`. +Surfaces (поверхности) — это тонкие адаптеры-клиенты, соединяющие мессенджеры с агентами платформы Lambda. +Текущая и главная реализация — **Matrix MVP**. Бот работает как stateless-прослойка: преобразует события Matrix во внутренний протокол `core/` и маршрутизирует их на внешние контейнеры агентов (через `AgentApi` по WebSocket). ## Core Value -Пользователь может вести диалог с Lambda-агентом через любой из поддерживаемых мессенджеров без изменения ядра системы. +Пользователь может бесшовно взаимодействовать с изолированными AI-агентами через нативные интерфейсы мессенджеров (с поддержкой пересылки файлов и работы в комнатах), в то время как сама платформа агентов не зависит от транспорта. ## Requirements ### Validated -- ✓ core/ — унифицированный протокол событий, EventDispatcher, StateStore, ChatManager, AuthManager, SettingsManager — existing -- ✓ adapter/telegram/ — forum-first адаптер (Threaded Mode), `/start`, `/new`, `/archive`, `/rename`, `/settings`, стриминг ответов — existing, QA passed -- ✓ adapter/matrix/ — DM-first адаптер, invite flow, `!new`, `!skills`, `!soul`, `!safety`, room-per-chat — existing -- ✓ sdk/mock.py — MockPlatformClient: `stream_message`, `get_or_create_user`, `get_settings`, `update_settings` — existing +- ✓ `core/` — унифицированный протокол событий, EventDispatcher, StateStore, ChatManager. +- ✓ `adapter/matrix/` — Space+rooms адаптер. Прием инвайтов, автосоздание иерархии комнат, команды `!new`, `!archive`, `!clear`, `!yes`/`!no`. +- ✓ `sdk/real.py` — интеграция с AgentApi. Поддержка WebSocket для обмена сообщениями и передачи вложений в обе стороны. +- ✓ Shared Volume — прямая передача файлов в локальные рабочие папки агентов (`/agents/`). +- ✓ Dynamic Routing — маршрутизация чатов к агентам на основе `config/matrix-agents.yaml`. +- ✓ Deployment — Разделение окружений на `docker-compose.prod.yml` (только бот) и `docker-compose.fullstack.yml` (бот + локальный агент для E2E). -### Active +### Out of Scope / Deferred -- [ ] Matrix QA — ручное тестирование Matrix адаптера, фиксация багов -- [ ] SDK integration — заменить MockPlatformClient реальным Lambda SDK (когда платформа готова) -- [ ] Production hardening — конфиг для деплоя, логирование, мониторинг - -### Out of Scope - -- E2EE для Matrix (python-olm не собирается на macOS/ARM) — инфраструктурная задача, отдельный трек -- Supergroup forum mode для Telegram — заменён Threaded Mode как основным режимом -- Telegram DM-first режим — заменён forum-first (Threaded Mode) +- E2EE для Matrix (отложено из-за сложностей сборки `python-olm` на кросс-платформенных средах). +- Интеграция с Master-сервисом платформы (временно используется прямое соединение с `platform-agent` через AgentApi). +- Telegram-адаптер (вынесен в легаси ветку `feat/telegram-adapter`, MVP фокусируется на Matrix). ## Context -- Python 3.11+, aiogram 3.4+, matrix-nio 0.21+, SQLite, pytest-asyncio -- 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 работает только без шифрования +- Стек: Python 3.11+, `matrix-nio`, `uv`, `pydantic`. +- Бот хранит только локальную привязку (`room_id` <-> `platform_chat_id`) в SQLite. Вся долговременная память и история диалогов хранятся на стороне агента. +- Жизненный цикл контейнеров агентов управляется платформой, а не ботом. ## Key Decisions | Decision | Rationale | Outcome | |----------|-----------|---------| -| Forum-first (Threaded Mode) для Telegram | Bot API 9.3 позволяет личный чат как форум — чище, без суперпруппы | ✓ Good | -| (user_id, thread_id) как PK в chats | Изоляция контекстов по топику | ✓ Good | -| MockPlatformClient через sdk/interface.py | Не ждать SDK, разрабатывать независимо | ✓ Good | -| DM-first для Matrix (не Space-first) | Space lifecycle слишком сложен для первого этапа | ✓ Good | -| Отказ от E2EE в Matrix | python-olm не собирается на macOS/ARM | — Pending | +| Space+rooms для Matrix | Room-based UX и явные чаты (по одному на тред) удобнее, чем DM-каша | ✓ Good | +| Прямая интеграция AgentApi | Master API не был готов, прямое WebSocket соединение позволяет передавать стейт и файлы | ✓ Good | +| Shared Volume для файлов | Избавляет от необходимости гонять base64 по сети, быстрый прямой доступ к файлам | ✓ Good | +| Stateless бот | Бот легко перезапускать и масштабировать, память изолирована в агентах | ✓ Good | ## Evolution @@ -61,10 +49,5 @@ Telegram и Matrix боты для взаимодействия пользова 3. New requirements emerged? → Add to Active 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-04-02 after initialization* +*Last updated: 2026-05-03 after codebase consolidation* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e81178c..ffd6801 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1,78 +1,32 @@ # Roadmap — v1.0 -## 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) +## Milestone: v1.0 — Production-ready Matrix MVP +### Phase 01: Matrix QA & Polish +**Goal:** Переработать Matrix адаптер с DM-first на Space+rooms, убрать реакции в пользу `!yes`/`!no`. +**Status:** Completed **Deliverables:** - Space+rooms architecture for Matrix adapter -- !yes/!no text-based confirmation (no reactions) -- Read-only !settings dashboard -- 96+ tests green +- !yes/!no text-based confirmation +- Test suite 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`. --- - -### 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 3: Production Hardening - -**Goal:** Подготовить боты к реальному деплою — конфиг, логирование, мониторинг, обработка ошибок. - -**Depends on:** Phase 2 - -**Deliverables:** -- Docker / systemd конфиг для деплоя -- Структурированное логирование в production формате -- Health-check endpoint (если нужен) -- Rate limiting и защита от спама -- Graceful shutdown +*Note: Легаси-фазы, связанные с Telegram, прототипами и Mock-платформой, были удалены из Roadmap после закрепления архитектуры MVP в ветке main.* diff --git a/.planning/STATE.md b/.planning/STATE.md index 384ed33..47a860b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,78 +2,48 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: — Production-ready surfaces -status: Ready to execute -last_updated: "2026-04-17T16:10:00.000Z" +status: MVP Deployed +last_updated: "2026-05-03T23:00:00Z" progress: - total_phases: 5 - completed_phases: 2 - total_plans: 12 - completed_plans: 9 - percent: 75 + total_phases: 3 + completed_phases: 3 + total_plans: 13 + completed_plans: 13 --- # State ## Project Reference -See: .planning/PROJECT.md (updated 2026-04-02) +See: `.planning/PROJECT.md` (updated 2026-05-03) -**Core value:** Пользователь ведёт диалог с Lambda через любой мессенджер без изменения ядра -**Current focus:** Phase 04 complete — Matrix MVP implementation ready for testing +**Core value:** Пользователь бесшовно взаимодействует с изолированными агентами через нативные интерфейсы (Matrix), в то время как платформа агентов не зависит от транспорта. +**Current focus:** Итерационное развитие текущей архитектуры, добавление новых фич и поверхностей (по мере необходимости). ## Current Phase -**Phase 4** implementation complete: Matrix MVP +Текущий MVP успешно завершен. Все базовые механизмы внедрены и работают: +- Маршрутизация к `AgentApi` +- Shared Volume файловый обмен (`/agents/`) +- Dynamic config через `matrix-agents.yaml` +- Изоляция контекстов через `platform_chat_id` -Phase 4 is implemented. Next step is manual and automated testing of the Matrix MVP flow before deciding on follow-up work. +Проект находится в чистом состоянии для начала нового планирования. Неактуальные легаси фазы и прототипы (Telegram, MockPlatformClient) удалены из Roadmap и трекинга. ## Decisions -- Продолжаем с Threaded Mode несмотря на баги Mac клиента (2026-04-02) -- Invite flow Matrix переведён на idempotent-проверку через `user_meta.space_id`, а не через invite-room metadata (2026-04-02) -- Неизвестные Matrix rooms больше не auto-register в роутере; используется явный fallback `unregistered:{room_id}` с warning-логом (2026-04-02) -- [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. +- **Space+rooms для Matrix**: Разделение тредов на отдельные Matrix-комнаты, собранные в едином Space пользователя. +- **AgentApi**: Прямая интеграция с локальным агентом без Master-прослойки по WebSocket. +- **Shared Volume**: Файлы кладутся напрямую в рабочую папку агента, избавляя от необходимости гонять их по сети в Base64. +- **Статическая маршрутизация**: На данном этапе пользователи маппятся на агентов жестко через YAML. ## Blockers -- Lambda platform SDK не готов — Phase 2 заблокирована до готовности платформы +- Отсутствуют. Проект готов к деплою (см. `docker-compose.prod.yml`). ## Accumulated Context ### Roadmap Evolution -- Phase 01.1 inserted after Phase 01: Matrix restart reconciliation and dev reset workflow (URGENT) -- Phase 4 added: Matrix MVP: shared agent context and context management command -- New platform signal: upcoming proper `chat_id` support should enable file-level context separation and stronger context management in a future follow-up phase. - -## 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:39Z | -| 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 | - -## Session - -- Last session: 2026-04-17T16:10:00Z -- Stopped at: Phase 4 implementation complete, ready for testing +- Изначальный Roadmap включал множество ответвлений (прототипы Telegram, локальный mock-клиент). После закрепления MVP в `main` Roadmap был очищен, чтобы отражать только актуальный путь продукта. +- Следующие фазы будут добавляться по мере возникновения новых задач (например, переход от YAML-конфига к БД для реестра агентов). diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md index 0cc6c4c..05f7a7f 100644 --- a/.planning/codebase/ARCHITECTURE.md +++ b/.planning/codebase/ARCHITECTURE.md @@ -1,134 +1,14 @@ -# Architecture +# Архитектура (ARCHITECTURE.md) -**Analysis Date:** 2026-04-01 +## Паттерн "Thin Adapter" (Тонкая поверхность) -## Pattern Overview +Система разделена на три логических слоя: +1. **Транспортный слой (Adapter)**: Подключается к внешней платформе (Matrix). Занимается конвертацией нативных событий (`room.message`) во внутренние структуры (`IncomingMessage`). +2. **Ядро (Core)**: Предоставляет единый протокол (`core/protocol.py`), не зависящий от конкретной реализации (Matrix, Telegram и т.д.). +3. **Платформенный слой (SDK)**: `RealPlatformClient` инкапсулирует подключение по WebSocket к реальным агентам (AgentApi). -**Overall:** Hexagonal / Ports-and-Adapters +## Routing & Registry +Бот может обслуживать множество агентов (multi-tenant). Маршрутизация настраивается статически через `config/matrix-agents.yaml`. Каждый пользователь (`@user:server`) привязан к конкретному `agent_id`, у которого есть свой HTTP URL и свой изолированный `workspace_path` (например, `/agents/1/`). -**Key Characteristics:** -- 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* +## Файловый контракт +Файлы не передаются агенту в base64. Бот сохраняет вложение напрямую в локальную директорию (общий volume), и передает агенту только относительный путь (`workspace_path`). diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index 473d257..5848135 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -1,235 +1,6 @@ -# Codebase Concerns +# Известные проблемы (CONCERNS.md) -**Analysis Date:** 2026-04-01 - ---- - -## 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 ` inside linked topic does not rename the Telegram topic - -- Issue: Running `/new ` 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* +- **Отсутствие E2E шифрования в Matrix**: На данный момент бот не поддерживает зашифрованные комнаты, так как библиотека `matrix-nio` требует нативной сборки `python-olm`, что усложняет кросс-платформенный деплой. +- **Потеря стейта агентов**: Так как текущий `platform-agent` часто работает с `MemorySaver`, его стейт теряется при перезапусках. Это проблема внешнего агента, но она напрямую влияет на UX поверхности. +- **Общий том (Shared Volume)**: Контракт обязывает бота и агента запускаться на одном физическом хосте (или иметь распределенный сетевой диск), что может стать бутылочным горлышком при сильном масштабировании. +- **Hardcoded роутинг**: `matrix-agents.yaml` требует ручного редактирования и перезапуска бота при добавлении новых агентов. Желательно вынести этот процесс в динамическую БД или API. diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md index 04c7f6a..36a4ed5 100644 --- a/.planning/codebase/CONVENTIONS.md +++ b/.planning/codebase/CONVENTIONS.md @@ -1,195 +1,7 @@ -# Coding Conventions +# Конвенции (CONVENTIONS.md) -**Analysis Date:** 2026-04-01 - -## Linting and Formatting - -**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_.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_`: `handle_start`, `handle_message`, `handle_new_chat` -- Builder functions named `build_`: `build_runtime`, `build_event_dispatcher`, `build_skills_text` -- Converter functions named `from_`: `from_room_event`, `from_command`, `from_reaction` -- Predicate functions named `is_`: `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_(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 `::` pattern: - `"chat:u1:C1"`, `"auth:u1"`, `"matrix_room:!r:m.org"` - ---- - -*Convention analysis: 2026-04-01* +- **Асинхронность**: Весь код бота асинхронный (`asyncio`). Вызовы SDK и Matrix-клиента выполняются через `await`. Блокирующие вызовы (если они есть) должны выноситься в тредпул. +- **Обработка ошибок**: Бот не должен падать из-за ошибок отдельного агента. Ошибки SDK (например, `PlatformError`) отлавливаются в боте и возвращаются пользователю в виде системных сообщений или уведомлений. +- **Стейтлесс-подход**: Поверхность хранит минимальный стейт (только локальный SQLite для связки `room_id` <-> `platform_chat_id`). Вся история сообщений и память лежат на стороне агентов. +- **Переменные окружения**: Бот полностью конфигурируется через `.env` (префиксы `MATRIX_` и `SURFACES_`). +- **Добавление новой поверхности**: Новая поверхность должна быть самостоятельной папкой в `adapter/`, реализовывать `converter.py`, и переиспользовать `sdk/real.py` и `core/protocol.py`. diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md index 3cdae98..cd771d1 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -1,173 +1,15 @@ -# External Integrations +# Интеграции (INTEGRATIONS.md) -**Analysis Date:** 2026-04-01 +## Platform Agent API +- **Тип**: WebSocket (через `AgentApi` SDK) +- **Назначение**: Связь между Matrix-адаптером и внешней LLM-платформой. +- **Контракт**: Surface выступает "тупым" клиентом. Он отправляет `platform_chat_id` и `user_id` вместе с сообщениями. Платформа/Агент отвечает текстом и вложениями. Контейнерами агентов бот не управляет. -## Bot Platform APIs +## Matrix Homeserver +- **Тип**: HTTP/HTTPS API (via `matrix-nio`) +- **Назначение**: Пользовательский интерфейс и транспорт сообщений для бота. +- **Ограничения**: Поддерживается только нешифрованное (unencrypted) взаимодействие. -**Telegram Bot API:** -- 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:` in `StateStore` -- User metadata (next chat index) stored under `matrix_user:` - -### 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* +## Файловая система (Shared Volume) +- **Тип**: Docker Shared Volume (`/agents/`) +- **Назначение**: Прямая передача файлов между поверхностью и агентами в обход сети. Поверхность пишет файлы в поддиректорию конкретного агента, агент их читает, и наоборот. diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md index 708a4bf..b40772d 100644 --- a/.planning/codebase/STACK.md +++ b/.planning/codebase/STACK.md @@ -1,113 +1,14 @@ -# Technology Stack +# Технологический стек (STACK.md) -**Analysis Date:** 2026-04-01 +## Язык и Runtime +- **Python**: 3.11-slim (используется в Docker-образах) +- **Пакетный менеджер**: `uv` (используется для быстрой и строгой установки зависимостей, frozen lockfiles). -## Languages +## Ключевые библиотеки +- **matrix-nio**: Асинхронный клиент для Matrix (события, синхронизация, отправка). +- **pydantic**: Для валидации структур данных (события из AgentApi). +- **structlog**: Структурированное логирование (json/console). -**Primary:** -- Python 3.11+ — all application code (enforced via `pyproject.toml` `requires-python = ">=3.11"`) - -**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* +## Инфраструктура +- **Docker / Docker Compose**: Используется для локального (fullstack) и продакшн развертывания. +- **SQLite**: Легковесная локальная база данных для хранения маппингов пользователей/комнат (`adapter/matrix/store.py`). diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md index 08896a5..9ea8a18 100644 --- a/.planning/codebase/STRUCTURE.md +++ b/.planning/codebase/STRUCTURE.md @@ -1,210 +1,18 @@ -# Codebase Structure +# Структура (STRUCTURE.md) -**Analysis Date:** 2026-04-01 - -## Directory Layout - -``` -surfaces-bot/ -├── adapter/ -│ ├── __init__.py -│ └── matrix/ # matrix-nio adapter (merged to main) -│ ├── __init__.py -│ ├── bot.py # Entry point, MatrixBot class, send_outgoing() -│ ├── converter.py # nio Event → IncomingEvent -│ ├── reactions.py # Emoji constants, skills text builder -│ ├── room_router.py # room_id → chat_id resolution -│ ├── store.py # Matrix-specific StateStore helpers (room meta, user meta) -│ └── 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* +- `core/`: + - `protocol.py` — Унифицированные структуры данных (сообщения, файлы, UI). +- `adapter/matrix/`: + - `bot.py` — Главный event-loop Matrix. + - `converter.py` — Конвертация событий Matrix-nio ⇄ `core/protocol.py`. + - `agent_registry.py` — Парсинг `matrix-agents.yaml`. + - `files.py` — Работа с вложениями и shared volume. + - `store.py` — SQLite база для маппинга чатов Matrix и `platform_chat_id`. + - `routed_platform.py` — Динамический роутинг вызовов к нужным агентам на лету. +- `sdk/`: + - `interface.py` — Интерфейс PlatformClient. + - `real.py` — Имплементация WebSocket клиента (`AgentApi`). + - `mock.py` — Мок-клиент для E2E тестов без платформы. +- `config/`: Конфиги маршрутизации (YAML). +- `docs/`: Актуальная документация по развертыванию и архитектуре. +- `docker-compose*.yml`: Продакшн и локальные манифесты для сборки. diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md index f685abc..07311dc 100644 --- a/.planning/codebase/TESTING.md +++ b/.planning/codebase/TESTING.md @@ -1,210 +1,17 @@ -# Testing Patterns +# Тестирование (TESTING.md) -**Analysis Date:** 2026-04-01 +## Unit-тесты +Расположены в `tests/`. Покрытие сфокусировано на логике Matrix адаптера (пока он является основной поверхностью): +- Файловый контракт (`test_files.py`) +- Диспетчер и конвертация (`test_dispatcher.py`) +- Взаимодействие с PlatformClient (`test_routed_platform.py`) +- Работа с контекстными командами бота (`test_context_commands.py`) -## Test Framework +## E2E тестирование +Локально тестируется через запуск контейнеров из `docker-compose.fullstack.yml`, который поднимает один инстанс бота и один локальный `platform-agent`. Это позволяет имитировать полную цепочку взаимодействия (Matrix -> Бот -> Агент) с общим каталогом для файлов. -**Runner:** pytest 8.x -**Config:** `pyproject.toml` `[tool.pytest.ini_options]` - -```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 +# Запуск юнит-тестов (только для Matrix адаптера) +pytest tests/adapter/matrix/ -v ``` - -## 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_` — 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* diff --git a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.continue-here.md b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.continue-here.md deleted file mode 100644 index 6de8f62..0000000 --- a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.continue-here.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -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 ---- - - -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. - - - - -- 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`. - - - - -- 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. - - - - -- 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. - - - -- 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. - - - -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` - - - -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`. - diff --git a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.gitkeep b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-01-PLAN.md b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-01-PLAN.md deleted file mode 100644 index 187baa9..0000000 --- a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-01-PLAN.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -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" ---- - - -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. - - - -@/Users/a/.codex/get-shit-done/workflows/execute-plan.md -@/Users/a/.codex/get-shit-done/templates/summary.md - - - -@.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 - - -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", -} -``` - - - - - - - Task 1: Add reconciliation module for startup and single-room recovery - adapter/matrix/reconcile.py, tests/adapter/matrix/test_reconcile.py - 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 - - - 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. - - -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` 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` 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. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_reconcile.py -q - - - - `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. - - The repository has an executable, tested reconciliation layer that can rebuild local Matrix metadata after dev-state loss without duplicating server-side rooms. - - - - - -Run `pytest tests/adapter/matrix/test_reconcile.py -q` and confirm startup and single-room reconciliation paths are covered. - - - -- 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. - - - -After completion, create `.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-01-SUMMARY.md` - diff --git a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-02-PLAN.md b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-02-PLAN.md deleted file mode 100644 index bdfdaf8..0000000 --- a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-02-PLAN.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -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:" ---- - - -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. - - - -@/Users/a/.codex/get-shit-done/workflows/execute-plan.md -@/Users/a/.codex/get-shit-done/templates/summary.md - - - -@.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 - - -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 -``` - - - - - - - Task 1: Run initial sync and reconciliation before the long-poll loop - adapter/matrix/bot.py, tests/adapter/matrix/test_dispatcher.py - 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 - - - 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. - - -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. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_dispatcher.py -q - - - - `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. - - Normal Matrix bot startup now includes a recovery pass before the event loop begins handling user traffic. - - - - Task 2: Retry unknown-room routing once before dispatching broken state - adapter/matrix/bot.py, tests/adapter/matrix/test_dispatcher.py - 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 - - - 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. - - -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. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_dispatcher.py -q - - - - 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. - - The runtime treats unknown rooms as recoverable state drift first, not as a silent routing failure or crash path. - - - - - -Run `pytest tests/adapter/matrix/test_dispatcher.py -q` and confirm both startup-bootstrap and first-access recovery behaviors are covered. - - - -- 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. - - - -After completion, create `.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-02-SUMMARY.md` - diff --git a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-03-PLAN.md b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-03-PLAN.md deleted file mode 100644 index bd78891..0000000 --- a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-03-PLAN.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -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" ---- - - -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. - - - -@/Users/a/.codex/get-shit-done/workflows/execute-plan.md -@/Users/a/.codex/get-shit-done/templates/summary.md - - - -@.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 - - -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: ... -``` - - - - - - - Task 1: Add a dev-only Matrix reset CLI with explicit modes - adapter/matrix/reset.py, tests/adapter/matrix/test_reset.py - adapter/matrix/bot.py, core/store.py, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md - - - 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. - - -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. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_reset.py -q - - - - `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. - - The repository contains a repeatable dev reset tool that replaces the undocumented shell ritual and names server-side cleanup honestly. - - - - Task 2: Replace the README reset ritual with the new restart and reset workflow - README.md - 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 - -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. - - - 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 - - - - `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`. - - Developers can follow a repeatable README workflow for ordinary restart and clean-room QA reset without relying on tribal knowledge. - - - - - -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. - - - -- 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. - - - -After completion, create `.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-03-SUMMARY.md` - diff --git a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md deleted file mode 100644 index 665061e..0000000 --- a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md +++ /dev/null @@ -1,121 +0,0 @@ -# Phase 01.1: Matrix restart reconciliation and dev reset workflow - Context - -**Gathered:** 2026-04-03 -**Status:** Ready for planning - - -## 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 - - - - -## 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 - - - - -## Specific Ideas - -- Главный критерий: после обычного restart бот не должен ломаться только потому, что local DB была сброшена или частично потеряна. -- Reset workflow должен быть явным и repeatable, а не завязанным на память разработчика. -- Нужно различать две ситуации: - - broken because code is wrong - - broken because local dev state was deliberately reset and requires reconciliation - - - - -## 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 - - - - -## 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. - - - - -## 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 - - - ---- - -*Phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow* -*Context gathered: 2026-04-03* diff --git a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md deleted file mode 100644 index 792031d..0000000 --- a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md +++ /dev/null @@ -1,350 +0,0 @@ -# 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 (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 - - -## 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 diff --git a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-VALIDATION.md b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-VALIDATION.md deleted file mode 100644 index 336cbd6..0000000 --- a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-VALIDATION.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -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 `` 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 diff --git a/.planning/phases/02-prototype/.continue-here.md b/.planning/phases/02-prototype/.continue-here.md deleted file mode 100644 index a2d4619..0000000 --- a/.planning/phases/02-prototype/.continue-here.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -phase: 02-prototype -task: 4 -total_tasks: 4 -status: paused -last_updated: 2026-04-07T23:54:30.473Z ---- - - -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. - - - - -- 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`. - - - - -- 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`. - - - - -- 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. - - - -- 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. - - - -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 - - - -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. - diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.continue-here.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.continue-here.md deleted file mode 100644 index 576296b..0000000 --- a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.continue-here.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma -task: 3 -total_tasks: 3 -status: paused -last_updated: 2026-04-21T22:33:11.666Z ---- - - -Phase 04 как MVP-фаза по сути закрыта: Matrix real backend работает, transport layer очищен до thin adapter над pinned upstream `platform-agent_api.AgentApi`, ветка чистая и запушенная. Текущее состояние зафиксировано как "working but problematic": после tool/file flow остаётся подтверждённый upstream bug платформы, из-за которого начало ответа может пропадать. - -Ключевой результат последней сессии: raw tracing показал, что первый повреждённый `MsgEventTextChunk` появляется уже внутри `platform-agent` до websocket-клиента. Это сняло основное подозрение с `surfaces`. - - - - -- Переведён `sdk/agent_api_wrapper.py` в тонкий factory/shim без собственной stream-semantics. -- Переведён `sdk/real.py` на pinned upstream contract: без post-END drain, без custom listener, без локальной реконструкции стрима. -- Обновлены тесты под новый transport layer: - - `tests/platform/test_real.py` - - `tests/adapter/matrix/test_dispatcher.py` - - `tests/core/test_integration.py` -- README обновлён под новое состояние интеграции и known limitations. -- Создан финальный отчёт: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`. -- Временная диагностика в vendored `platform-agent` и `platform-agent_api` была использована только для расследования и полностью удалена; nested repos снова clean. -- Последний кодовый commit с рабочим состоянием: `0c2884c` (`refactor: use thin upstream transport adapter`). - - - - -- Передать платформенной команде финальный отчёт и дождаться triage/fix proposal. -- После ответа платформы решить, открываем ли отдельную follow-up phase для production hardening в `surfaces`. -- После platform fix повторить live smoke: - - text-only - - staged attachments - - tool/file flow - - large image failure path - - - - -- Больше не трогать vendored platform repos ради рабочей реализации. -- Больше не добавлять локальные transport hacks, маскирующие streaming bug. -- Считать текущий missing-first-chunk баг platform-side дефектом до опровержения raw evidence. -- Оставить `tokens_used=0` как честное ограничение current upstream contract, не симулировать это значение локально. - - - -- Platform-side streaming bug: после tool/file flow начало ответа может пропадать. -- Duplicate `END` на стороне платформы. -- Image path на больших вложениях падает с `data-uri > 10 MB` и `WS 1009`. -- Без ответа платформенной команды дальнейший transport-layer surgery в `surfaces` не имеет инженерного смысла. - - - -Важная ментальная модель: - -- `surfaces` сейчас максимально близок к upstream transport semantics. -- Если снова полезет corruption чанков, исходная презумпция должна быть "сначала смотреть platform-agent", а не придумывать новый локальный workaround. -- Главные артефакты для чтения перед продолжением: - 1. `README.md` - 2. `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` - 3. `sdk/agent_api_wrapper.py` - 4. `sdk/real.py` - 5. `tests/platform/test_real.py` - -Если придётся продолжать без платформы, разумные задачи уже не про баг с чанками, а про clean/prod-ready улучшения вокруг него: - -- сделать `tokens_used` optional в локальном контракте -- развести `RealPlatformClient` на pool/adapter слои -- добавить bounded session cache / idle eviction -- убрать `sys.path` import hack в пользу нормальной dependency wiring -- переименовать конфиг `AGENT_WS_URL` в более честный `AGENT_BASE_URL` -- добавить protocol contract tests против fake WS server - - - -Start with: - -1. Открыть `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` -2. Отправить этот отчёт платформенной команде как основной артефакт -3. Не менять transport layer до получения их ответа - -Если работа продолжается автономно без ответа платформы, следующий допустимый шаг — оформлять отдельную follow-up phase на hardening `surfaces`, а не повторно "чинить" стрим локальными обходами. - diff --git a/.planning/phases/05-mvp-deployment/05-01-PLAN.md b/.planning/phases/05-mvp-deployment/05-01-PLAN.md new file mode 100644 index 0000000..2320eda --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-01-PLAN.md @@ -0,0 +1,158 @@ +--- +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" +--- + + +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. + + + +@/Users/a/.codex/get-shit-done/workflows/execute-plan.md +@/Users/a/.codex/get-shit-done/templates/summary.md + + + +@.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 + + +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: ... +``` + + + + + + + Task 1: Add restart reconciliation regression coverage + tests/adapter/matrix/test_reconciliation.py, tests/adapter/matrix/test_restart_persistence.py + 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 + + - 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. + + + - `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 `` fails before implementation or would fail if reconciliation is removed. + + 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. + + 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 + + Phase 05 has failing-or-red-before-code tests that define authoritative restart reconciliation behavior and exclude duplicate room provisioning. + + + + Task 2: Implement authoritative startup reconciliation and wire it before live sync + adapter/matrix/reconciliation.py, adapter/matrix/bot.py + 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 + + - 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. + + + - `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. + + 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. + + 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 + + Restart recovery restores the minimum durable state for existing Space rooms before live traffic, and the guarded regression suite passes. + + + + + +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. + + + +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. + + + +After completion, create `.planning/phases/05-mvp-deployment/05-01-SUMMARY.md` + diff --git a/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md new file mode 100644 index 0000000..c50f371 --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md @@ -0,0 +1,99 @@ +--- +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* diff --git a/.planning/phases/05-mvp-deployment/05-02-PLAN.md b/.planning/phases/05-mvp-deployment/05-02-PLAN.md new file mode 100644 index 0000000..dc93cf0 --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-02-PLAN.md @@ -0,0 +1,156 @@ +--- +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" +--- + + +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`. + + + +@/Users/a/.codex/get-shit-done/workflows/execute-plan.md +@/Users/a/.codex/get-shit-done/templates/summary.md + + + +@.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 + + +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]: + ... +``` + + + + + + + Task 1: Expand room-local context and clear-command tests + tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py + 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 + + - 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. + + + - 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. + + 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. + + pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py -v + + The tests define the Phase 05 room-local contract for reset/clear and for routed upstream calls. + + + + Task 2: Ship real room-local `!clear` semantics and strict routing + adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py + 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 + + - 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. + + + - `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. + + 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`. + + pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v + + Users can clear one working room without affecting others, and all routed upstream calls stay bound to room-local platform context. + + + + + +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. + + + +Every working Matrix room has an independent upstream context boundary, and `!clear` resets only the room where it is invoked. + + + +After completion, create `.planning/phases/05-mvp-deployment/05-02-SUMMARY.md` + diff --git a/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md new file mode 100644 index 0000000..fa4a48c --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md @@ -0,0 +1,106 @@ +--- +phase: 05-mvp-deployment +plan: 02 +subsystem: matrix +tags: [matrix, routing, context, platform-chat-id, testing] +requires: + - phase: 05-01 + provides: startup reconciliation for room metadata before live routing +provides: + - room-local `!clear` coverage and command registration + - strict room-local context resolution for save/context flows + - fail-fast routed-platform regressions for incomplete room bindings +affects: [matrix-dispatcher, routed-platform, startup-reconciliation] +tech-stack: + added: [] + patterns: [per-room platform context, compatibility alias registration, fail-fast routing] +key-files: + created: [] + modified: + - adapter/matrix/handlers/__init__.py + - adapter/matrix/handlers/context_commands.py + - tests/adapter/matrix/test_context_commands.py + - tests/adapter/matrix/test_routed_platform.py +key-decisions: + - "Expose `clear` only when prototype room-context support is available, while keeping `reset` as a compatibility alias." + - "Require recovered `platform_chat_id` for save/context/clear flows instead of falling back to shared local chat ids." +patterns-established: + - "Matrix room context commands resolve through room metadata repaired at startup, not message-time backfill." + - "Reset semantics rotate one room's upstream chat id and disconnect only that old upstream session." +requirements-completed: [PH05-02] +duration: 16 min +completed: 2026-04-27 +--- + +# Phase 05 Plan 02: Room-local clear and strict Matrix routing Summary + +**Room-local `!clear` command coverage, strict per-room `platform_chat_id` context resolution, and fail-fast Matrix routing regressions** + +## Performance + +- **Duration:** 16 min +- **Started:** 2026-04-27T22:00:00Z +- **Completed:** 2026-04-27T22:15:58Z +- **Tasks:** 2 +- **Files modified:** 4 + +## Accomplishments +- Added RED/GREEN regression coverage for `!clear`, room-local chat-id rotation, and strict routed-platform failure modes. +- Registered `clear` as the supported reset entrypoint when room-context support exists, while preserving `reset` as a compatibility alias. +- Removed shared-context fallbacks from save/context handling and fixed reset to clear the old upstream prototype session before rotation. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Expand room-local context and clear-command tests** - `ae37476` (test) +2. **Task 2: Ship real room-local `!clear` semantics and strict routing** - `85e2fda` (feat) + +## Files Created/Modified +- `adapter/matrix/handlers/__init__.py` - registers `clear` for runtimes with prototype room-context support and keeps `reset` as the compatibility alias. +- `adapter/matrix/handlers/context_commands.py` - requires room-local `platform_chat_id` for save/context/clear flows and disconnects the old upstream context on clear. +- `tests/adapter/matrix/test_context_commands.py` - covers room-local clear rotation, upstream disconnect, and clear registration. +- `tests/adapter/matrix/test_routed_platform.py` - covers incomplete-room fail-fast behavior and repaired metadata routing. + +## Decisions Made +- Exposed `clear` only on runtimes that actually have prototype room-context support so Phase 05 semantics do not break older MVP smoke tests. +- Treated startup-repaired room metadata as the only supported source of `platform_chat_id` for context commands instead of falling back to local chat ids. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Clear path was wiping the new context instead of the old upstream session** +- **Found during:** Task 2 (Ship real room-local `!clear` semantics and strict routing) +- **Issue:** `make_handle_reset()` cleared prototype session state for the newly generated chat id, leaving the previous upstream context intact. +- **Fix:** Cleared prototype state for the old `platform_chat_id` before rotating to the new room-local id, and kept the new context empty as well. +- **Files modified:** `adapter/matrix/handlers/context_commands.py` +- **Verification:** `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` +- **Committed in:** `85e2fda` + +--- + +**Total deviations:** 1 auto-fixed (1 bug) +**Impact on plan:** The auto-fix was required for correct room-local clear behavior. No scope creep. + +## Issues Encountered +- Plain `pytest` used an environment without `PyYAML`; verification was switched to `uv run pytest` with `UV_CACHE_DIR=/tmp/uv-cache-surfaces`. +- Shared shell env exposed `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml`, which broke unrelated Matrix smoke tests. Verification was run with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND=''` to match the intended mock-runtime test setup. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness +- Matrix room-local clear semantics and routing contracts are now explicit and covered. +- Phase 05 follow-on work can assume startup reconciliation remains the only supported repair path for missing room routing metadata. + +--- +*Phase: 05-mvp-deployment* +*Completed: 2026-04-27* + +## Self-Check: PASSED + +- Found `.planning/phases/05-mvp-deployment/05-02-SUMMARY.md` +- Found commit `ae37476` +- Found commit `85e2fda` diff --git a/.planning/phases/05-mvp-deployment/05-03-PLAN.md b/.planning/phases/05-mvp-deployment/05-03-PLAN.md new file mode 100644 index 0000000..01023b3 --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-03-PLAN.md @@ -0,0 +1,145 @@ +--- +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" +--- + + +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. + + + +@/Users/a/.codex/get-shit-done/workflows/execute-plan.md +@/Users/a/.codex/get-shit-done/templates/summary.md + + + +@.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 + + +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: ... +``` + + + + + + + Task 1: Add shared-volume file contract tests for `/agents` deployment + tests/adapter/matrix/test_files.py, tests/platform/test_real.py + 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 + + - 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). + + + - `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 `` exercises both inbound and outbound sides of the shared-volume contract. + + 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. + + pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v + + Phase 05 has direct test coverage for `/agents`-backed shared-volume behavior across inbound and outbound file paths. + + + + Task 2: Tighten attachment path handling for the shared volume contract + adapter/matrix/files.py, sdk/real.py + adapter/matrix/files.py, sdk/real.py, tests/adapter/matrix/test_files.py, tests/platform/test_real.py, docs/deploy-architecture.md + + - 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. + + + - `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. + + 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. + + pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v + + Incoming and outgoing file references stay compatible with the real shared-volume deployment contract, and the targeted file/path regressions pass. + + + + + +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. + + + +The Matrix bot and agent runtime can exchange file references through the shared volume using only relative workspace paths and room-safe storage layout. + + + +After completion, create `.planning/phases/05-mvp-deployment/05-03-SUMMARY.md` + diff --git a/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md new file mode 100644 index 0000000..0745e7c --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md @@ -0,0 +1,103 @@ +--- +phase: 05-mvp-deployment +plan: 03 +subsystem: infra +tags: [matrix, attachments, shared-volume, agents, pytest] +requires: + - phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma + provides: direct AgentApi integration and Matrix outgoing file rendering +provides: + - shared-volume attachment path regressions for /agents deployment + - relative workspace-path normalization for upstream attachment transport + - send-file event normalization for Matrix outbound file rendering +affects: [matrix, deployment, shared-volume, file-transfer] +tech-stack: + added: [] + patterns: [relative workspace_path transport, shared-volume root normalization] +key-files: + created: [] + modified: + - tests/adapter/matrix/test_files.py + - tests/platform/test_real.py + - sdk/real.py +key-decisions: + - "Keep adapter/matrix/files.py as the only path-construction source; sdk/real.py only normalizes attachment references crossing the shared-volume boundary." + - "Normalize both /workspace and /agents absolute paths back to relative workspace paths before forwarding to the agent API or rendering Matrix send-file events." +patterns-established: + - "Matrix attachment transport stays relative even when the bot sees absolute shared-volume paths." + - "Agent-emitted file paths are rendered back to Matrix without HTTP proxy shims or inline blobs." +requirements-completed: [PH05-04] +duration: 3 min +completed: 2026-04-27 +--- + +# Phase 05 Plan 03: Shared-volume attachment path hardening Summary + +**Room-safe Matrix inbox paths and relative shared-volume attachment normalization for `/agents` deployment** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-04-27T22:02:34Z +- **Completed:** 2026-04-27T22:05:41Z +- **Tasks:** 2 +- **Files modified:** 3 + +## Accomplishments +- Added regression coverage for room-safe `surfaces/matrix/.../inbox/...` attachment layout under `/agents` workspaces. +- Added explicit tests for `/workspace/...` and `/agents/...` attachment-path normalization on both upstream send and downstream send-file rendering. +- Tightened `RealPlatformClient` so only relative `workspace_path` values are forwarded to the agent API. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Add shared-volume file contract tests for `/agents` deployment** - `cafb0ec` (test) +2. **Task 2: Tighten attachment path handling for the shared volume contract** - `9a03160` (fix) + +## Files Created/Modified +- `tests/adapter/matrix/test_files.py` - covers room-safe shared-volume inbox paths under an `/agents` workspace root. +- `tests/platform/test_real.py` - covers relative attachment forwarding and send-file normalization for `/workspace` and `/agents` paths. +- `sdk/real.py` - normalizes shared-volume roots before attachments cross the upstream or Matrix rendering boundary. + +## Decisions Made +- Kept `adapter/matrix/files.py` unchanged so path construction remains centralized there. +- Reused one normalization helper in `sdk/real.py` for both `_attachment_paths()` and `_attachment_from_send_file_event()` to keep inbound and outbound behavior aligned. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Adjusted verification to use the project uv environment** +- **Found during:** Task 2 (Tighten attachment path handling for the shared volume contract) +- **Issue:** The plan's raw `pytest` command collected with an interpreter missing `PyYAML`, which blocked `tests/adapter/matrix/test_send_outgoing.py` before runtime assertions could execute. +- **Fix:** Re-ran verification with `UV_CACHE_DIR=/tmp/uv-cache uv run pytest ...`, matching the repo's `CLAUDE.md` guidance and the project `.venv`. +- **Files modified:** None +- **Verification:** `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v` +- **Committed in:** None (verification-environment adjustment only) + +--- + +**Total deviations:** 1 auto-fixed (1 blocking) +**Impact on plan:** No scope creep. The deviation only changed the verification entrypoint so the planned test slice could run in the correct environment. + +## Issues Encountered +- The ambient `pytest` resolved to a different virtualenv than the project `.venv`, so direct verification was unreliable for dependency-backed Matrix tests. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness +- Shared-volume file references now stay relative across inbound bot downloads, agent API attachment transport, and outbound Matrix send-file rendering. +- Phase 05 compose and deployment work can assume the `/agents` contract without adding file proxy infrastructure. + +## Self-Check: PASSED + +- Found `.planning/phases/05-mvp-deployment/05-03-SUMMARY.md` +- Verified commit `cafb0ec` exists in git history +- Verified commit `9a03160` exists in git history + +--- +*Phase: 05-mvp-deployment* +*Completed: 2026-04-27* diff --git a/.planning/phases/05-mvp-deployment/05-04-PLAN.md b/.planning/phases/05-mvp-deployment/05-04-PLAN.md new file mode 100644 index 0000000..4fe2235 --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-04-PLAN.md @@ -0,0 +1,128 @@ +--- +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" +--- + + +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. + + + +@/Users/a/.codex/get-shit-done/workflows/execute-plan.md +@/Users/a/.codex/get-shit-done/templates/summary.md + + + +@.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 + + +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 +``` + + + + + + + Task 1: Create split prod and fullstack compose artifacts + docker-compose.prod.yml, docker-compose.fullstack.yml, Dockerfile, .env.example + 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 + + - `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. + + 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. + + 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 + + Both compose files render successfully and express distinct operational roles: prod handoff vs internal full-stack testing. + + + + Task 2: Update deployment docs and operator guidance for the split artifacts + README.md, docs/deploy-architecture.md + README.md, docs/deploy-architecture.md, docker-compose.prod.yml, docker-compose.fullstack.yml, .env.example + + - 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. + + 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. + + 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")" + + The docs and env guidance match the new compose artifacts and no longer imply a single shared deployment file. + + + + + +Render both compose files, then grep the docs for the new artifact names and `/agents` references so the operator contract is explicit and consistent. + + + +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. + + + +After completion, create `.planning/phases/05-mvp-deployment/05-04-SUMMARY.md` + diff --git a/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md new file mode 100644 index 0000000..68a62c6 --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md @@ -0,0 +1,93 @@ +--- +phase: 05-mvp-deployment +plan: 04 +subsystem: infra +tags: [docker-compose, matrix, deployment, agents, docs] +requires: + - phase: 05-03 + provides: "Shared /agents attachment contract and path normalization for Matrix runtime" +provides: + - "docker-compose.prod.yml bot-only deployment handoff artifact" + - "docker-compose.fullstack.yml internal E2E harness with health-gated platform-agent startup" + - "README and deploy architecture docs aligned to the split compose contract" +affects: [mvp-deployment, operator-handoff, internal-e2e] +tech-stack: + added: [Docker Compose] + patterns: [split-compose-by-operational-intent, shared-agents-volume-contract] +key-files: + created: [docker-compose.prod.yml, docker-compose.fullstack.yml] + modified: [.env.example, README.md, docs/deploy-architecture.md] +key-decisions: + - "Split Compose artifacts by runtime intent: bot-only prod handoff vs internal full-stack verification." + - "Document /agents as the bot-side shared volume root while internal platform-agent keeps /workspace on the same volume." +patterns-established: + - "Production operators use docker-compose.prod.yml and provide an external AGENT_BASE_URL." + - "Internal verification uses docker-compose.fullstack.yml with service_healthy gating instead of sleep-based startup." +requirements-completed: [PH05-05] +duration: 3 min +completed: 2026-04-27 +--- + +# Phase 05 Plan 04: Split deployment artifacts Summary + +**Bot-only production compose handoff plus a separate health-gated full-stack harness aligned to the shared `/agents` volume contract** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-04-27T22:12:42Z +- **Completed:** 2026-04-27T22:16:09Z +- **Tasks:** 2 +- **Files modified:** 5 + +## Accomplishments +- Added `docker-compose.prod.yml` as the operator-facing bot-only runtime artifact. +- Added `docker-compose.fullstack.yml` for internal E2E runs with `platform-agent` health-gated startup. +- Updated env and deployment docs so `/agents` and the split compose flow are explicit without relying on the old root compose file. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Create split prod and fullstack compose artifacts** - `df6d8bf` (feat) +2. **Task 2: Update deployment docs and operator guidance for the split artifacts** - `22a3a2b` (docs) + +**Plan metadata:** pending final docs commit after state updates + +## Files Created/Modified +- `docker-compose.prod.yml` - bot-only deployment handoff with `/agents` volume contract +- `docker-compose.fullstack.yml` - internal harness extending the bot service and adding health-checked `platform-agent` +- `.env.example` - Phase 05 runtime variables for prod handoff and internal full-stack defaults +- `README.md` - operator-facing instructions for choosing the correct compose artifact +- `docs/deploy-architecture.md` - deployment topology and shared-volume guidance aligned to the split artifacts + +## Decisions Made +- Split deployment packaging by operational intent instead of overloading one compose file for both prod handoff and internal testing. +- Kept the bot’s absolute shared path rooted at `/agents` in docs and env examples while letting the internal `platform-agent` consume the same named volume at `/workspace`. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +- The docs verification grep treated `deploy` inside the filename `docs/deploy-architecture.md` as a false positive when root compose was still named explicitly. The fix was to remove the literal root compose filename from deploy-path wording while keeping the deprecation clear. + +## User Setup Required + +None - no external service configuration required beyond populating `.env` from `.env.example`. + +## Next Phase Readiness + +- Operators now have a bot-only compose artifact for handoff, and developers have a separate internal E2E harness. +- Remaining Phase 05 work can rely on the split runtime contract without reinterpreting deployment docs. + +## Self-Check: PASSED + +- Summary file exists at `.planning/phases/05-mvp-deployment/05-04-SUMMARY.md` +- Commit `df6d8bf` found in git history +- Commit `22a3a2b` found in git history + +--- +*Phase: 05-mvp-deployment* +*Completed: 2026-04-27* diff --git a/.planning/phases/05-mvp-deployment/05-RESEARCH.md b/.planning/phases/05-mvp-deployment/05-RESEARCH.md new file mode 100644 index 0000000..6ccb0cd --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-RESEARCH.md @@ -0,0 +1,411 @@ +# Phase 05: MVP Deployment - Research + +**Researched:** 2026-04-28 +**Domain:** Matrix bot production deployment, restart reconciliation, per-room context isolation, shared-volume file transfer +**Confidence:** HIGH + +## Project Constraints (from CLAUDE.md) + +- All platform calls must stay behind `platform/interface.py` (`PlatformClient` protocol). +- Current platform implementation is a mock / replaceable adapter; architecture must not depend on unfinished upstream SDK. +- Keep architecture decisions inside this repo and document contracts locally. +- Prefer async, adapter/core separation, and do not bypass the existing `core/` and `adapter/` layering. +- Use `uv sync` for dependency installation. +- Use `pytest tests/ -v` and adapter-specific pytest slices for verification. +- Never commit `.env`. +- Dependency order remains fixed: `core/` first, `platform/` second, adapters after that. + +## Summary + +Phase 05 should not introduce a new stack. The established implementation path is to harden the existing `matrix-nio + SQLiteStore + RoutedPlatformClient + shared workspace volume` design so production restart behavior matches the current Space+rooms UX. The main architectural rule is: Matrix topology is authoritative for room existence, while local SQLite metadata is authoritative only after reconciliation has rebuilt it. + +The production-safe approach is to bind every working Matrix room to its own durable `platform_chat_id`, rotate only that identifier for `!clear`, and make restart recovery idempotent. Reconciliation should rebuild `user_meta`, `room_meta`, `ChatManager` entries, and missing routing fields from Matrix Space membership and room state before `sync_forever()` begins processing live traffic. Unknown rooms must be reconciled first, not silently converted into new chats. + +For files, keep the current shared-volume contract and relative `workspace_path` transport. Do not build HTTP file shims or embed file payloads in bot-side state. For deployment artifacts, split runtime intent explicitly: `docker-compose.prod.yml` is a bot-only handoff contract, while `docker-compose.fullstack.yml` is the internal E2E harness that brings up platform services and shared volumes together. + +**Primary recommendation:** Implement Phase 05 as a reconciliation-and-deploy hardening pass on the current Matrix stack, with Matrix Space state as source of truth and per-room `platform_chat_id` as the routing key. + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `matrix-nio` | 0.25.2 | Async Matrix client, Spaces, media upload/download, token login, sync loop | Already in repo; official docs confirm support for Spaces, token login, `room_put_state`, `upload`, `download`, and `sync_forever` | +| `sqlite3` / `SQLiteStore` | stdlib / repo-local | Durable bot metadata (`room_meta`, `user_meta`, routing state) | Small, local, restart-safe KV layer already used by runtime and tests | +| `PyYAML` | 6.0.3 | Agent registry / deployment config parsing | Current repo standard for `config/matrix-agents.yaml`-style artifacts | +| `httpx` | 0.28.1 | Async HTTP for auxiliary platform calls | Already used; fits async runtime and current codebase | +| Docker Compose | v2 spec; local install `v2.40.3` | Prod/fullstack topology, shared named volumes, health-gated startup | Officially supports multi-file overlays, named volumes, and `service_healthy` gating | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `structlog` | 25.5.0 | Structured runtime logging | Use for reconciliation summaries, routing mismatches, and deploy diagnostics | +| `pydantic` | 2.13.3 | Typed config / payload validation | Use for any new deployment config or reconciliation report structures | +| `python-dotenv` | 1.2.2 | Local env loading | Keep for local and compose-driven runtime config | +| `pytest` | 9.0.3 | Test runner | Full phase verification and regression slices | +| `pytest-asyncio` | 1.3.0 | Async test execution | Required for reconciliation/runtime tests | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| `matrix-nio` | Synapse Admin / raw Matrix HTTP calls | Worse fit; repo already depends on nio abstractions and tests | +| repo-local `SQLiteStore` | Redis/Postgres | Unnecessary operational scope increase for MVP deployment | +| shared volume file flow | custom file proxy / presigned URLs | More moving parts, more auth/cleanup edge cases, no need for MVP | +| split compose files | one overloaded compose file with profiles | Harder operator handoff; less explicit prod vs internal-test intent | + +**Installation:** +```bash +uv sync +``` + +**Version verification:** Verified on 2026-04-28 from PyPI and local environment. + +| Package | Verified Version | Publish Date | Source | +|---------|------------------|--------------|--------| +| `matrix-nio` | 0.25.2 | 2024-10-04 | PyPI | +| `httpx` | 0.28.1 | 2024-12-06 | PyPI | +| `structlog` | 25.5.0 | 2025-10-27 | PyPI | +| `pydantic` | 2.13.3 | 2026-04-20 | PyPI | +| `aiohttp` | 3.13.5 | 2026-03-31 | PyPI | +| `PyYAML` | 6.0.3 | 2025-09-25 | PyPI | +| `python-dotenv` | 1.2.2 | 2026-03-01 | PyPI | +| `pytest` | 9.0.3 | 2026-04-07 | PyPI | +| `pytest-asyncio` | 1.3.0 | 2025-11-10 | PyPI | + +## Architecture Patterns + +### Recommended Project Structure +```text +adapter/matrix/ +├── bot.py # startup, sync bootstrap, live callbacks +├── reconciliation.py # new: restart recovery from Matrix state +├── files.py # shared-volume path building / materialization +├── routed_platform.py # room -> agent_id + platform_chat_id routing +├── store.py # room_meta/user_meta helpers and counters +└── handlers/ + ├── auth.py # Space + first room provisioning + ├── chat.py # !new / !archive / !rename + └── context_commands.py # !save / !load / !clear / !context + +deploy/ +├── docker-compose.prod.yml # bot-only handoff +└── docker-compose.fullstack.yml # internal E2E stack +``` + +### Pattern 1: Matrix Space State Is Canonical, SQLite Is Rebuildable +**What:** Treat Matrix Space membership and child-room state as the source of truth for room topology; use local SQLite metadata as a cached routing index that reconciliation can rebuild. +**When to use:** Startup, DB loss, stale local metadata, and any deployment where rooms may outlive the bot process. +**Example:** +```python +# Source: repo pattern from adapter/matrix/store.py + Matrix Space state +room_meta = { + "room_type": "chat", + "chat_id": "C7", + "display_name": "Research", + "matrix_user_id": "@alice:example.org", + "space_id": "!space:example.org", + "agent_id": "agent-1", + "platform_chat_id": "42", +} +await set_room_meta(store, room_id, room_meta) +await chat_mgr.get_or_create( + user_id=room_meta["matrix_user_id"], + chat_id=room_meta["chat_id"], + platform="matrix", + surface_ref=room_id, + name=room_meta["display_name"], +) +``` + +### Pattern 2: Per-Room `platform_chat_id` Is the Only Real Context Boundary +**What:** Route every working Matrix room to its own durable `platform_chat_id`. +**When to use:** Normal messaging, `!save`, `!load`, `!context`, `!clear`, restart restoration. +**Example:** +```python +# Source: adapter/matrix/routed_platform.py + adapter/matrix/handlers/context_commands.py +old_chat_id = room_meta["platform_chat_id"] +new_chat_id = await next_platform_chat_id(store) +await set_platform_chat_id(store, room_id, new_chat_id) + +disconnect = getattr(platform, "disconnect_chat", None) +if callable(disconnect): + await disconnect(old_chat_id) +``` + +### Pattern 3: `!clear` Means Chat-ID Rotation, Not Global Wipe +**What:** Implement real clear by rotating only the current room's `platform_chat_id` and disconnecting the old upstream chat session. +**When to use:** User-triggered context reset for one room. +**Example:** +```python +# Source: adapter/matrix/handlers/context_commands.py +room_id = await _resolve_room_id(event, chat_mgr) +old_chat_id = (room_meta or {}).get("platform_chat_id") or room_id +new_chat_id = await next_platform_chat_id(store) +await set_platform_chat_id(store, room_id, new_chat_id) +``` + +### Pattern 4: Shared-Volume File Handoff Uses Relative Workspace Paths +**What:** Persist incoming Matrix media into a room-scoped path under the shared workspace, and pass only relative paths to the agent. +**When to use:** User uploads, staged attachments, agent-emitted files. +**Example:** +```python +# Source: adapter/matrix/files.py +relative_path = ( + Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}" +) +return Attachment( + type=attachment.type, + url=attachment.url, + filename=filename, + mime_type=attachment.mime_type, + workspace_path=relative_path.as_posix(), +) +``` + +### Pattern 5: Compose Split By Operational Intent +**What:** Keep one compose artifact for operator handoff and one for internal full-stack testing. +**When to use:** Deployment packaging. +**Example:** +```yaml +# docker-compose.prod.yml +services: + matrix-bot: + image: surfaces-bot:latest + env_file: .env + volumes: + - agents:/agents + +# docker-compose.fullstack.yml +services: + matrix-bot: + extends: + file: docker-compose.prod.yml + service: matrix-bot + platform-agent: + ... +volumes: + agents: +``` + +### Anti-Patterns to Avoid +- **Lazy bootstrap as restart strategy:** `_bootstrap_unregistered_room()` is acceptable for first-contact repair, not as the primary restart recovery path in production. +- **Per-user context identity:** a user-level or DM-level chat id breaks Space+rooms isolation and makes `!clear` incorrect. +- **Global reset endpoint semantics:** `!clear` must not wipe other rooms or all agent state for a user. +- **Absolute attachment paths in platform payloads:** keep agent attachment references relative to its workspace contract. +- **Sleep-based service readiness:** use Compose healthchecks and dependency conditions, not shell `sleep`. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Matrix room/Space protocol | Raw custom HTTP wrappers for state events | `matrix-nio` `room_create`, `room_put_state`, `space_get_hierarchy`, `sync_forever`, `upload`, `download` | Official support already exists and repo tests are built around nio | +| Restart topology discovery | Ad hoc timeline scraping | Full-state sync plus room state / Space child reconciliation | Timeline replay is noisy and brittle; state is the stable source | +| File transfer bus | Base64 blobs or custom bot-side file API | Shared `/agents/` volume with relative `workspace_path` | Lower operational complexity and already matches upstream agent contract | +| Compose startup sequencing | Shell loops / sleeps | `healthcheck` + `depends_on: condition: service_healthy` | Official Compose behavior is deterministic and observable | +| Context reset | Deleting all SQLite rows or resetting the whole user | Rotate current room `platform_chat_id` and drop that room's live agent connection | Preserves other rooms and matches user expectation | + +**Key insight:** The deceptively hard problems in this phase are already solved by the current stack: Matrix room state, nio media handling, named volumes, and service health gating. Custom alternatives add more failure modes than value. + +## Common Pitfalls + +### Pitfall 1: Unknown room after restart creates a duplicate working chat +**What goes wrong:** The bot treats an existing room as unregistered and provisions a fresh room/tree. +**Why it happens:** Local SQLite metadata is missing, but Matrix topology still exists. +**How to avoid:** Run reconciliation before live sync callbacks; only allow lazy bootstrap for genuinely new first-contact rooms. +**Warning signs:** New `Чат N` rooms appear after restart without a matching user action. + +### Pitfall 2: `!clear` resets the wrong scope +**What goes wrong:** Clearing one room also clears another room, or does nothing because the upstream session key did not change. +**Why it happens:** Context is keyed by user or local `chat_id` instead of durable room-local `platform_chat_id`. +**How to avoid:** Always resolve room -> `platform_chat_id`, rotate it, and disconnect only the old upstream chat. +**Warning signs:** Two rooms share response history or `!context` reports the same platform context id. + +### Pitfall 3: Space child linkage is incomplete +**What goes wrong:** Rooms exist but do not appear correctly under the user's Space. +**Why it happens:** Missing or malformed `m.space.child` state, especially missing `via` data. +**How to avoid:** Persist `space_id`, write `m.space.child` with `state_key=room_id`, and reconcile child links on startup. +**Warning signs:** Element shows the room outside the Space, or not at all in the hierarchy. + +### Pitfall 4: Shared volume works locally but fails in deployment +**What goes wrong:** Agent-generated files cannot be read by the bot, or bot-downloaded files are unreadable by the agent. +**Why it happens:** Mount mismatch, wrong root (`/workspace` vs `/agents`), or container user/group permissions. +**How to avoid:** Standardize one shared root, keep relative workspace paths, and align container permissions with Compose volume configuration. +**Warning signs:** Attachment paths exist in metadata but not on disk inside the other container. + +### Pitfall 5: Compose `depends_on` starts too early +**What goes wrong:** Bot starts before dependent services are actually ready. +**Why it happens:** Short-form `depends_on` only waits for container start, not health. +**How to avoid:** Use healthchecks and long-form `depends_on` with `service_healthy` in the full-stack compose file. +**Warning signs:** First requests fail after fresh `docker compose up`, then succeed on retry. + +## Code Examples + +Verified patterns from official sources and current repo: + +### Create a Space with `matrix-nio` +```python +# Source: matrix-nio API docs +space_resp = await client.room_create( + name=f"Lambda — {display_name}", + visibility=RoomVisibility.private, + invite=[matrix_user_id], + space=True, +) +``` + +### Add a child room to a Space +```python +# Source: current repo pattern + Matrix spec +await client.room_put_state( + room_id=space_id, + event_type="m.space.child", + content={"via": [homeserver]}, + state_key=chat_room_id, +) +``` + +### Persist room-scoped attachment paths +```python +# Source: adapter/matrix/files.py +relative_path, absolute_path = build_workspace_attachment_path( + workspace_root=workspace_root, + matrix_user_id=matrix_user_id, + room_id=room_id, + filename=filename, +) +absolute_path.parent.mkdir(parents=True, exist_ok=True) +absolute_path.write_bytes(body) +``` + +### Health-gated startup in Compose +```yaml +# Source: Docker Compose docs +services: + matrix-bot: + depends_on: + platform-agent: + condition: service_healthy + + platform-agent: + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Per-user or single shared platform context | Per-room `platform_chat_id` | Repo direction corrected on 2026-04-28 | Enables true room isolation and correct `!clear` | +| Single overloaded compose runtime | Separate prod handoff and full-stack E2E compose files | Current Phase 05 scope | Reduces operator ambiguity | +| Unknown room auto-bootstrap as recovery | Explicit reconciliation before live traffic | Recommended for Phase 05 | Prevents duplicate chat trees after restart | +| File payloads treated as transport concern | Shared-volume relative path contract | Already present in repo | Keeps bot/platform contract simple and durable | + +**Deprecated/outdated:** +- Single-chat / DM-first deployment direction: explicitly discarded in Phase 05 reset. +- Global reset semantics for Matrix context commands: does not match Space+rooms UX. +- Using only local store as truth for restart recovery: unsafe once deployed rooms outlive the process. + +## Open Questions + +1. **What exact Matrix state should reconciliation trust for `chat_id` labels?** + - What we know: `room_meta.chat_id` is local and not derivable from Matrix protocol by default. + - What's unclear: whether chat labels should be reconstructed from room names, stored custom state, or cached local metadata when present. + - Recommendation: persist `chat_id` in local SQLite, but make reconciliation able to regenerate a stable fallback label and avoid blocking routing if the label is missing. + +2. **What readiness probe exists for `platform-agent` in the full-stack compose?** + - What we know: Compose health gating is the right pattern. + - What's unclear: whether upstream agent image already exposes a reliable health endpoint. + - Recommendation: inspect upstream container and add a bot-facing probe before finalizing `docker-compose.fullstack.yml`. + +3. **Should prod mount root remain `/workspace` or be renamed to `/agents` externally?** + - What we know: current code defaults to `SURFACES_WORKSPACE_DIR=/workspace`, while deployment docs describe shared `/agents/`. + - What's unclear: whether external handoff wants a host path named `/agents` while containers still use `/workspace`. + - Recommendation: keep one in-container canonical path and let host-side naming vary only in Compose mounts. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Python | bot runtime | ✓ | 3.14.3 | — | +| `uv` | dependency install | ✓ | 0.9.30 | `pip` | +| `pytest` | validation | ✓ | 9.0.2 installed | `python -m pytest` | +| Docker Engine | deployment packaging / E2E compose | ✓ | 29.1.3 | none | +| Docker Compose | split runtime orchestration | ✓ | 2.40.3 | none | + +**Missing dependencies with no fallback:** +- None + +**Missing dependencies with fallback:** +- None + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | `pytest` + `pytest-asyncio` | +| Config file | `pyproject.toml` | +| Quick run command | `pytest tests/adapter/matrix/test_restart_persistence.py -v` | +| Full suite command | `pytest tests/ -v` | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| PH05-01 | Space+rooms onboarding remains primary UX | integration | `pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py -v` | ✅ | +| PH05-02 | Per-room `platform_chat_id` isolates routing and powers real clear | integration | `pytest tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_context_commands.py -v` | ✅ | +| PH05-03 | Restart reconciliation restores routing metadata | integration | `pytest tests/adapter/matrix/test_restart_persistence.py -v` | ❌ new reconciliation tests needed | +| PH05-04 | Shared-volume file transfer is room-safe | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v` | ✅ partial | +| PH05-05 | Split prod/fullstack compose artifacts stay coherent | smoke | `docker compose -f docker-compose.prod.yml config && docker compose -f docker-compose.fullstack.yml config` | ❌ Wave 0 | + +### Sampling Rate +- **Per task commit:** `pytest tests/adapter/matrix/test_restart_persistence.py -v` +- **Per wave merge:** `pytest tests/adapter/matrix/ -v` +- **Phase gate:** `pytest tests/ -v` plus both compose files passing `docker compose ... config` + +### Wave 0 Gaps +- [ ] `tests/adapter/matrix/test_reconciliation.py` — startup recovery of user/room metadata from Matrix state +- [ ] `tests/adapter/matrix/test_context_commands.py` additions — `!clear` command contract and room-local rotation semantics +- [ ] `tests/adapter/matrix/test_compose_artifacts.py` or equivalent smoke command documentation — split compose validation +- [ ] `tests/adapter/matrix/test_files.py` additions — cross-room attachment path isolation and shared-root consistency + +## Sources + +### Primary (HIGH confidence) +- Local repo code and tests: + - `adapter/matrix/bot.py` + - `adapter/matrix/store.py` + - `adapter/matrix/files.py` + - `adapter/matrix/routed_platform.py` + - `adapter/matrix/handlers/auth.py` + - `adapter/matrix/handlers/context_commands.py` + - `tests/adapter/matrix/test_restart_persistence.py` + - `tests/adapter/matrix/test_files.py` + - `tests/platform/test_real.py` +- Matrix-nio API docs: https://matrix-nio.readthedocs.io/en/latest/nio.html +- Matrix-nio async client docs: https://matrix-nio.readthedocs.io/en/latest/_modules/nio/client/async_client.html +- Matrix-nio PyPI release page: https://pypi.org/project/matrix-nio/ +- Matrix spec Spaces / hierarchy: https://spec.matrix.org/v1.18/server-server-api/ +- Matrix spec changelog note on `via` for `m.space.child`: https://spec.matrix.org/v1.16/changelog/v1.9/ +- Docker Compose CLI reference: https://docs.docker.com/reference/cli/docker/compose/ +- Docker Compose services reference: https://docs.docker.com/reference/compose-file/services/ + +### Secondary (MEDIUM confidence) +- `docs/deploy-architecture.md` — repo-local deployment contract clarified on 2026-04-27 +- `docs/research/matrix-spaces.md` — prior internal research aligned with spec, but not treated as primary +- `README.md` runtime notes for current Matrix backend and shared workspace behavior + +### Tertiary (LOW confidence) +- None + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - current repo stack verified against official docs and package registries +- Architecture: HIGH - recommendations align with existing runtime boundaries and official Matrix / Compose behavior +- Pitfalls: HIGH - derived from current code paths, existing tests, and official protocol/runtime semantics + +**Research date:** 2026-04-28 +**Valid until:** 2026-05-28 diff --git a/.planning/phases/05-mvp-deployment/05-VALIDATION.md b/.planning/phases/05-mvp-deployment/05-VALIDATION.md new file mode 100644 index 0000000..6466df9 --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-VALIDATION.md @@ -0,0 +1,83 @@ +--- +phase: 05 +slug: mvp-deployment +status: revised +nyquist_compliant: true +wave_0_complete: false +created: 2026-04-28 +--- + +# Phase 05 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | `pytest` + `pytest-asyncio` | +| **Config file** | `pyproject.toml` | +| **Quick run command** | `pytest tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v` | +| **Full suite command** | `pytest tests/ -v` | +| **Estimated runtime** | targeted slices < 60 seconds each; full suite longer | + +--- + +## Sampling Rate + +- **After every task commit:** Run the exact `` command from the task that just changed +- **After every plan wave:** Run `pytest tests/adapter/matrix/ -v` +- **Before `$gsd-verify-work`:** Full suite must be green +- **Max feedback latency:** 60 seconds for task-level slices + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | 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-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-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-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-03-01 | 03 | 1 | PH05-04 | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v` | ✅ partial | ⬜ 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-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-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 | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/adapter/matrix/test_reconciliation.py` — startup recovery of user and room metadata from Matrix state +- [ ] `tests/adapter/matrix/test_restart_persistence.py` additions — deterministic backfill for legacy rooms missing `platform_chat_id` +- [ ] `tests/adapter/matrix/test_context_commands.py` additions — room-local `!clear` rotation semantics +- [ ] `tests/adapter/matrix/test_files.py` additions — cross-room attachment isolation and shared-root consistency +- [ ] Compose smoke coverage or documented verification command for `docker-compose.prod.yml` and `docker-compose.fullstack.yml` + +--- + +## Manual-Only Verifications + +| 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 | +| 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 | +| 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 | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [x] Feedback latency target tightened to task slices under 60s +- [x] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending diff --git a/.planning/reports/20260422-session-report.md b/.planning/reports/20260422-session-report.md deleted file mode 100644 index 9044d2b..0000000 --- a/.planning/reports/20260422-session-report.md +++ /dev/null @@ -1,92 +0,0 @@ -# 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`* diff --git a/.planning/threads/matrix-dev-prototype-agent-platform-state.md b/.planning/threads/matrix-dev-prototype-agent-platform-state.md deleted file mode 100644 index facd575..0000000 --- a/.planning/threads/matrix-dev-prototype-agent-platform-state.md +++ /dev/null @@ -1,133 +0,0 @@ -# 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 для разработчиков скиллов diff --git a/.planning/threads/matrix-file-ingestion-context.md b/.planning/threads/matrix-file-ingestion-context.md deleted file mode 100644 index 0ccb079..0000000 --- a/.planning/threads/matrix-file-ingestion-context.md +++ /dev/null @@ -1,81 +0,0 @@ -# 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 всё ещё не подтверждён. diff --git a/Dockerfile b/Dockerfile index 0dbb156..c04d98a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,46 @@ -FROM python:3.11-slim +FROM python:3.11-slim AS base WORKDIR /app +RUN useradd -u 1000 -m appuser +USER appuser ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=/app ENV UV_PROJECT_ENVIRONMENT=/usr/local -# Install uv for dependency management inside the container. -RUN pip install --no-cache-dir uv +# Install uv and git for reproducible platform SDK installation. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir uv # Copy dependency manifests first for layer caching. COPY pyproject.toml uv.lock* ./ # Install project dependencies into the system environment. -RUN uv sync --no-dev --no-install-project --frozen 2>/dev/null || uv sync --no-dev --no-install-project +RUN uv sync --no-dev --no-install-project --frozen + +FROM base AS development -# Copy project source after dependency layers. COPY . . +RUN uv sync --no-dev --frozen -# Install the project itself and keep runtime dependencies in sync. -RUN uv sync --no-dev --frozen 2>/dev/null || uv sync --no-dev - -# Install lambda_agent_api from the local source tree, bypassing its Python version guard. -RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-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. +COPY --from=agent_api . /agent_api/ +RUN python -m pip install --no-cache-dir --ignore-requires-python -e /agent_api/ + +CMD ["python", "-m", "adapter.matrix.bot"] + +FROM base AS production + +COPY . . +RUN uv sync --no-dev --frozen + +# Production builds follow the platform-agent pattern: install the API SDK from +# the platform Git repository instead of relying on local external/ clones. +ARG LAMBDA_AGENT_API_REF=master +RUN python -m pip install --no-cache-dir --ignore-requires-python \ + "git+https://git.lambda.coredump.ru/platform/agent_api.git@${LAMBDA_AGENT_API_REF}" CMD ["python", "-m", "adapter.matrix.bot"] diff --git a/README.md b/README.md index 93782c6..51e92f9 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,54 @@ # Lambda Lab 3.0 — Surfaces -Команда поверхностей. Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda. +Matrix-бот для взаимодействия пользователя с AI-агентом Lambda. -## Статус +## Интеграция для платформы -| Поверхность | Статус | -|---|---| -| Telegram | 🔨 В разработке, отдельный worktree `feat/telegram-adapter` | -| Matrix | ✅ Рабочий прототип, запускается через root `docker compose` вместе с `platform-agent` | +Бот — это один Docker-контейнер (`matrix-bot`), который вы добавляете в свою инфраструктуру рядом с агентами. Production target — один surface container на 25-30 внешних agent containers/services. + +### Что бот ожидает от вас + +**1. HTTP-эндпоинт агента** +Бот подключается к агенту через `lambda_agent_api.AgentApi` по адресу `AGENT_BASE_URL`. +Протокол — WebSocket поверх HTTP, контракт описан в `docs/deploy-architecture.md`. + +**2. Shared volume с per-agent поддиректориями** +Shared volume монтируется в бот как `/agents`. Каждый агент видит свою поддиректорию. + +``` +Bot container Agent containers + /agents/0/ ←── volume ──→ agent_0: /workspace/ + /agents/1/ ←── volume ──→ agent_1: /workspace/ + /agents/N/ ←── volume ──→ agent_N: /workspace/ +``` + +- Бот сохраняет входящий файл прямо в `{workspace_path}/{file}` и передаёт агенту `attachments=["{file}"]` +- Если файл с таким именем уже есть, бот сохраняет следующий как `file (1).ext`, `file (2).ext`, как в Windows +- Агент пишет исходящий файл прямо в свой `/workspace/file`, бот читает его из `{workspace_path}/file` +- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml` + +**3. Конфиг агентов** +Файл `config/matrix-agents.yaml` — маппинг Matrix-пользователей на агентов. Вы заполняете его под свою инфраструктуру. Пример в `config/matrix-agents.example.yaml`. + +### Что бот не делает + +- Не управляет lifecycle агент-контейнеров (запуск/остановка — на вашей стороне) +- Не хранит историю разговоров (это в памяти агента) +- Не обрабатывает аутентификацию пользователей — любой Matrix-пользователь, который пишет боту, получает доступ + +### Минимальный чеклист + +- [ ] Взять опубликованный image `SURFACES_BOT_IMAGE` или собрать production image из этого репозитория +- [ ] Заполнить `config/matrix-agents.yaml` — ID агентов, `base_url` каждого, `workspace_path`, маппинг пользователей +- [ ] Задать переменные окружения (см. `.env.example`): Matrix credentials, `MATRIX_PLATFORM_BACKEND=real`, `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml` +- [ ] Смонтировать в бот-контейнер shared volume как `/agents` — каждый агент должен видеть свою поддиректорию как `/workspace` +- [ ] Добавить bot-only service в свой compose; агенты в этот compose не входят и управляются платформой --- -## Концепция +## Статус -Пользователь получает персонального AI-агента через привычный мессенджер. -Агент выполняет реальные задачи: разбирает почту, ищет информацию, работает с файлами, управляет календарём. - -**Поверхности** — тонкие клиенты. Вся бизнес-логика на стороне платформы. -Задача команды: сделать интерфейс удобным, надёжным и легко расширяемым. +Matrix MVP готов к деплою. Telegram — в отдельном worktree, не входит в этот handoff. --- @@ -28,252 +59,224 @@ surfaces-bot/ core/ — общее ядро, не зависит от транспорта protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...) handler.py — EventDispatcher: IncomingEvent → OutgoingEvent - handlers/ — обработчики по типам событий store.py — StateStore Protocol + InMemoryStore + SQLiteStore - chat.py — ChatManager: метаданные чатов C1/C2/C3 - auth.py — AuthManager: аутентификация - settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность + chat.py — ChatManager + auth.py — AuthManager + settings.py — SettingsManager adapter/ - telegram/ — aiogram 3.x адаптер matrix/ — matrix-nio адаптер sdk/ interface.py — PlatformClient Protocol (контракт к SDK) - mock.py — MockPlatformClient (заглушка) + real.py — RealPlatformClient (через AgentApi) + mock.py — MockPlatformClient (заглушка для тестов) + + config/ + matrix-agents.yaml — реестр агентов docs/ — документация - .claude/agents/ — агенты для Claude Code ``` -**Ключевой принцип:** добавить новую поверхность = написать один адаптер-конвертер. -Ядро (`core/`) не трогается. Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md) +Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md) --- -## Функционал прототипа +## Деплой -### Telegram ([подробнее](docs/telegram-prototype.md)) - -- **Чаты** — основной Telegram UX сейчас развивается в отдельном worktree `feat/telegram-adapter` -- **Forum Topics mode** — бот умеет подключать forum-группу через `/forum`; чат может быть привязан к отдельной теме -- **DM-режим** — базовый диалог и переключение чатов сохраняются -- **Аутентификация** — привязка Telegram аккаунта к аккаунту платформы -- **Диалог** — typing indicator, передача файлов, подтверждение опасных действий через inline-кнопки -- **Настройки** через `/settings`: коннекторы (Gmail, GitHub, Notion...), скиллы, личность агента (SOUL), безопасность, подписка - -### Matrix ([подробнее](docs/matrix-prototype.md)) - -- **Онбординг** — при первом invite бот создаёт private Space `Lambda — {display_name}` и первую комнату `Чат 1`, сразу приглашая туда пользователя -- **Чаты** — `!new`, `!chats`, `!rename`, `!archive`, `!help`; новые комнаты регистрируются в локальном `ChatManager` -- **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher` -- **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта -- **Текущее ограничение** — encrypted DM официально не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота -- **Backend selection** — `MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и upstream `AgentApi` по contract `/v1/agent_ws/{chat_id}/` -- **Ограничения real backend** — локальный runtime использует shared `/workspace`, файлы передаются как относительные пути в `attachments`, а transport layer со стороны `surfaces` использует прямой upstream `platform-agent_api.AgentApi` без локального subclass; prod-default lifecycle открывает отдельное соединение на каждый запрос, но после tool/file flow всё ещё остаётся подтверждённый upstream streaming bug, из-за которого начало ответа может пропадать - ---- - -## Замена SDK - -Вся работа с платформой идёт через `PlatformClient` Protocol: - -```python -class PlatformClient(Protocol): - async def get_or_create_user(self, external_id: str, platform: str, ...) -> User: ... - async def send_message(self, user_id: str, chat_id: str, text: str, ...) -> MessageResponse: ... - async def get_settings(self, user_id: str) -> UserSettings: ... - async def update_settings(self, user_id: str, action: Any) -> None: ... -``` - -Бот не управляет lifecycle контейнеров — это делает Master (платформа). -Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер. - -Сейчас: `MockPlatformClient` в `sdk/mock.py`, а Matrix real backend собирается через `sdk/real.py` при `MATRIX_PLATFORM_BACKEND=real`. -Файловый контракт уже path-based: бот пишет файлы в shared `/workspace` и передаёт платформе относительные пути в `attachments`. -Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем. - ---- - -## Запуск Matrix-поверхности - -### 1. Зависимости и тесты - -```bash -uv sync -pytest tests/ -v -``` - -### 2. Переменные окружения +### Переменные окружения ```bash cp .env.example .env ``` -Обязательные переменные: +| Переменная | Обязательна | Описание | +|---|---|---| +| `MATRIX_HOMESERVER` | ✓ | URL Matrix-сервера | +| `MATRIX_USER_ID` | ✓ | `@bot:example.org` | +| `MATRIX_PASSWORD` | ✓ | пароль (или `MATRIX_ACCESS_TOKEN`) | +| `MATRIX_PLATFORM_BACKEND` | ✓ | `real` для продакшна | +| `SURFACES_BOT_IMAGE` | ✓ | Docker image поверхности: `mput1/surfaces-bot:latest` | +| `AGENT_BASE_URL` | | Fallback URL агента если `base_url` не задан в `matrix-agents.yaml` | +| `MATRIX_AGENT_REGISTRY_PATH` | ✓ | путь к реестру внутри контейнера: `/app/config/matrix-agents.yaml` | +| `SURFACES_WORKSPACE_DIR` | | путь к shared volume в контейнере (по умолчанию `/agents`) | +| `SURFACES_SHARED_VOLUME` | | имя Docker volume (по умолчанию `surfaces-agents`) | -```env -# Matrix аккаунт бота -MATRIX_HOMESERVER=https://matrix.example.org -MATRIX_USER_ID=@lambda-bot:example.org -MATRIX_PASSWORD=... # или MATRIX_ACCESS_TOKEN=... +### Реестр агентов -# Выбор backend: mock (по умолчанию) или real (подключение к platform-agent) -MATRIX_PLATFORM_BACKEND=real +`config/matrix-agents.yaml` — статический маппинг пользователей на агентов: -# compose runtime: platform-agent service name + shared /workspace -AGENT_BASE_URL=http://platform-agent:8000 -SURFACES_WORKSPACE_DIR=/workspace +```yaml +user_agents: + "@user0:matrix.lambda.coredump.ru": agent-0 + "@user1:matrix.lambda.coredump.ru": agent-1 -# platform-agent provider -PROVIDER_MODEL=openai/gpt-4o-mini -PROVIDER_URL=https://openrouter.ai/api/v1 -PROVIDER_API_KEY=... +agents: + - id: agent-0 + label: "Agent 0" + base_url: "http://lambda.coredump.ru:7000/agent_0/" + workspace_path: "/agents/0" + - id: agent-1 + label: "Agent 1" + base_url: "http://lambda.coredump.ru:7000/agent_1/" + workspace_path: "/agents/1" + - id: agent-2 + label: "Agent 2" + base_url: "http://lambda.coredump.ru:7000/agent_2/" + workspace_path: "/agents/2" ``` -### 3. Compose runtime +- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент. +- `base_url` — HTTP URL агент-эндпоинта (path-based routing через reverse proxy). +- `workspace_path` — путь к воркспейсу агента внутри бот-контейнера на shared volume. + Бот сохраняет входящие файлы прямо в `{workspace_path}/`, агент пишет исходящие прямо в свой `/workspace/`. +- Для 25-30 агентов продолжайте тот же паттерн: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`. -Root `docker-compose.yml` теперь является основным локальным runtime для Matrix и platform-agent. -Он поднимает `matrix-bot`, `platform-agent` и общий volume `/workspace`. +Полный пример с комментариями: `config/matrix-agents.example.yaml` +### Production (bot-only) + +`docker-compose.prod.yml` — bot-only handoff через published image. Платформа добавляет этот сервис в свой compose рядом с agent containers, монтирует shared volume и задаёт переменные окружения. Этот compose не создаёт и не собирает агент-контейнеры. + +Перед redeploy можно проверить реальные agent routes из той же сети, где будет работать бот: ```bash -docker compose up --build +PYTHONPATH=. uv run python -m tools.check_matrix_agents \ + --config config/matrix-agents.yaml \ + --timeout 5 ``` -Compose собирает `platform-agent` из актуального upstream `external/platform-agent` Dockerfile (`development` target), -монтирует live-код из `external/platform-agent/src` и `external/platform-agent_api`, и подготавливает shared `/workspace` -с правами для agent runtime. -Matrix бот подключается к `platform-agent` по service name, а не к отдельно запущенному `localhost`. +Проверка открывает фактический WebSocket URL каждого агента (`.../v1/agent_ws/{chat_id}/`) и ждёт первый `STATUS`. Для проверки полного запроса к агенту добавьте `--message "ping"`. -На `2026-04-21` локальный compose runtime использует vendored upstream-версии платформы без локальных патчей: +Для запуска опубликованного image: +```bash +export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest +docker compose --env-file .env -f docker-compose.prod.yml up -d +``` -- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61` -- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee` - -### 4. Staged attachments в Matrix - -Если Matrix-клиент отправляет файлы отдельными media events, бот не вызывает агента сразу. -Вместо этого он сохраняет файлы в shared `/workspace`, ставит их в очередь для конкретного чата и пользователя, и ждёт следующего обычного сообщения. - -Как отправить файлы агенту: - -1. Отправь один или несколько файлов в рабочую Matrix-комнату. -2. При необходимости проверь очередь командой `!list`. -3. Напиши обычное текстовое сообщение, например: - - `что на изображении?` - - `прочитай pdf и сделай summary` - - `сравни эти два файла` -4. Это сообщение уйдёт агенту вместе со всеми staged файлами из очереди. - -Команды: - -- `!list` — показать staged вложения -- `!remove ` — удалить вложение по номеру -- `!remove all` — очистить все staged вложения - -Следующее обычное сообщение пользователя уходит агенту вместе со всеми staged файлами. - -Пример: +Опубликованный image: ```text -[отправил 2 изображения] -!list -1. IMG_3183.png -2. minion.jpeg - -что изображено на фото +mput1/surfaces-bot:latest +sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd ``` -В этом сценарии вопрос `что изображено на фото` будет отправлен агенту вместе с обоими файлами. +Для сборки и публикации surface image: +```bash +docker login +export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest -Важно: +docker build --target production \ + --build-arg LAMBDA_AGENT_API_REF=master \ + -t "$SURFACES_BOT_IMAGE" . +docker push "$SURFACES_BOT_IMAGE" +``` -- если после файлов отправить `!list` или `!remove`, агент не вызывается -- если платформа вернула ошибку на этих вложениях, они остаются в staged-очереди -- в таком случае следующее обычное сообщение снова попытается отправить те же файлы -- чтобы разорвать этот цикл, используй `!remove ` или `!remove all` +Если push возвращает `insufficient_scope`, текущий Docker login не имеет доступа к namespace/repository. Создайте repository в нужном Docker Hub namespace или перетегируйте image в namespace пользователя, под которым выполнен `docker login`. -Известное ограничение текущего platform-agent: - -- большие изображения могут не пройти в provider из-за лимита на размер data URI -- в таком случае Matrix-бот ответит `Сервис временно недоступен...`, а проблемные файлы останутся в очереди до явного удаления - -### 5. Запуск бота вручную +### Fullstack E2E (bot + agent) ```bash -# Первый запуск или сброс состояния -rm -f lambda_matrix.db && rm -rf matrix_store - -PYTHONPATH=. uv run python -m adapter.matrix.bot +docker compose --env-file .env -f docker-compose.fullstack.yml up --build ``` -### 6. Онбординг пользователя +Поднимает `matrix-bot` вместе с локальным `platform-agent`. Это internal harness, не production topology на 25-30 агентов. `matrix-bot` собирается через Dockerfile target `development` и локальный `external/platform-agent_api` context, как в dev-сборке `platform-agent`. `AGENT_BASE_URL` перекрывается на `http://platform-agent:8000`. Shared volume виден как `/agents` в боте и `/workspace` в агенте. -Напиши боту в **личные сообщения (DM)** на Matrix-сервере. Для поддерживаемого dev-сценария используй незашифрованную комнату: E2EE сейчас не считается поддержанным режимом для Matrix-поверхности. +### Сброс состояния (локально) -Бот автоматически: -1. Создаст private Space `Lambda — {твоё имя}` -2. Создаст рабочую комнату `Чат 1` и пригласит туда - -Дальнейшее общение ведётся в рабочей комнате, не в DM. +```bash +rm -f lambda_matrix.db && rm -rf matrix_store +``` --- -## Функционал Matrix MVP +## Shared volume: передача файлов -### Работает +``` +Bot (/agents) Agent (/workspace = /agents/N/) + /agents/0/report.pdf ←──── одно и то же хранилище ────→ /workspace/report.pdf + /agents/0/result.txt ←────────────────────────────────→ /workspace/result.txt +``` -| Функция | Команда | Примечание | -|---|---|---| -| Онбординг | *(автоматически при invite)* | Создаёт Space + рабочую комнату | -| Новый чат | `!new` | Создаёт дополнительную комнату | -| Список чатов | `!chats` | Активные чаты пользователя | -| Переименование | `!rename <название>` | | -| Архивация | `!archive` | | -| Диалог с агентом | *(любое сообщение)* | Стриминг ответа через WebSocket | -| Изоляция контекста | *(автоматически)* | Каждая комната получает отдельный `platform_chat_id` | -| Сохранение контекста | `!save [имя]` | Агент сохраняет краткое резюме разговора | -| Список сохранений | `!load` | Выбор по номеру | -| Состояние контекста | `!context` | Текущая сессия и список сохранений | -| Справка | `!help` | | -| Подтверждения | `!yes` / `!no` | Для опасных действий | -| Staged вложения | `!list`, `!remove `, `!remove all` | Файлы без текстовой инструкции ставятся в очередь до следующего сообщения | - -### Не работает — блокеры на стороне platform-agent - -| Функция | Почему не работает | -|---|---| -| `!load` в другом чате | platform-agent использует `StateBackend` — файлы живут в памяти отдельно для каждого `thread_id`. Файл, сохранённый в чате A, не виден в чате B. Фикс: переключить platform-agent на `FilesystemBackend` с общим хранилищем. | -| Стриминг после tool/file flow | В текущем upstream `platform-agent` первый `MsgEventTextChunk` иногда рождается уже обрезанным до попадания в websocket-клиент. Наш transport layer после cleanup максимально близок к upstream и больше не пытается локально “лечить” этот поток. Подробности и raw evidence: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`. | -| Счётчик токенов в `!context` | pinned `platform-agent_api.AgentApi` потребляет `MsgEventEnd` внутри клиента и не публикует `tokens_used` наружу. Сейчас `surfaces` честно показывает `0`, пока upstream не добавит поддержанный способ получить это значение. | -| `!reset` | platform-agent не имеет endpoint `/reset`. Задокументировано в ТЗ к платформе. | -| Персистентность между рестартами | platform-agent использует `MemorySaver` (in-memory). Все разговоры теряются при рестарте процесса. | -| E2EE комнаты | `python-olm` не собирается на macOS/ARM. Ограничение инфраструктуры. | - -### Не работает — пока не реализовано нами - -| Функция | Статус | -|---|---| -| `!settings`, `!skills`, `!soul`, `!safety` | Заглушки MVP. Требуют готового SDK платформы. | -| Вложения без текстовой инструкции | Поддержан staged UX только для Matrix. Для других поверхностей ещё не перенесено. | +- **Входящий файл** (пользователь → агент): бот сохраняет в `{workspace_path}/{file}`, например `/agents/17/report.pdf`, и передаёт агенту `attachments=["report.pdf"]` +- **Коллизии имён**: если `/agents/17/report.pdf` уже существует, бот сохранит следующий файл как `/agents/17/report (1).pdf`, затем `/agents/17/report (2).pdf` +- **Исходящий файл** (агент → пользователь): агент пишет в `/workspace/file`, бот читает из `{workspace_path}/file`, например `/agents/17/result.txt`, и отправляет пользователю как Matrix file message +- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml` --- +## Онбординг пользователя + +1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере +2. Бот создаёт private Space `Lambda — {display_name}` и комнату `Чат 1` +3. Дальнейшее общение — в рабочих комнатах, не в DM + +**Требование:** незашифрованные комнаты. E2EE не поддержан. + +--- + +## Команды Matrix + +### Работающие + +| Команда | Действие | +|---|---| +| *(любое сообщение)* | Диалог с агентом, стриминг ответа | +| `!new [название]` | Создать новый чат | +| `!chats` | Список активных чатов | +| `!rename <название>` | Переименовать текущую комнату | +| `!archive` | Архивировать чат | +| `!clear` | Сбросить контекст текущего чата | +| `!yes` / `!no` | Подтвердить / отменить действие агента | +| `!list` | Файлы в очереди вложений | +| `!remove ` / `!remove all` | Удалить вложение из очереди | +| `!help` | Справка | + +### Не работают / заглушки + +| Команда | Статус | +|---|---| +| `!save` / `!load` / `!context` | Нестабильны: зависят от агента, сессии теряются при рестарте | +| `!settings` и подкоманды | Заглушки MVP, требуют готового SDK платформы | + +--- + +## Отправка файлов агенту + +Matrix-клиент отправляет файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь. + +``` +[отправил файл] +!list + 1. report.pdf + +прочитай и сделай summary ← файл уйдёт агенту вместе с этим текстом +``` + +--- + +## Известные ограничения + +| Проблема | Причина | +|---|---| +| История теряется при рестарте агента | `platform-agent` использует `MemorySaver` (in-memory) | +| E2EE | `python-olm` не собирается на macOS/ARM | + +--- + +## Разработка + +```bash +uv sync +pytest tests/ -v +pytest tests/adapter/matrix/ -v # только Matrix +``` + ## Документация | Файл | Содержание | |---|---| -| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Унификация поверхностей — все структуры, как добавить новую поверхность | -| [`docs/telegram-prototype.md`](docs/telegram-prototype.md) | Функционал Telegram прототипа | -| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Функционал Matrix прототипа | -| [`docs/api-contract.md`](docs/api-contract.md) | Контракт к SDK платформы | -| [`docs/user-flow.md`](docs/user-flow.md) | FSM и user journey | -| [`docs/claude-code-guide.md`](docs/claude-code-guide.md) | Гайд по работе с Claude Code | -| [`docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`](docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md) | Финальный аудит platform streaming bug после cleanup transport layer | - ---- - -## Команда - -Поверхности и интеграции -Lambda Lab 3.0, МАИ +| [`docs/deploy-architecture.md`](docs/deploy-architecture.md) | **Читать первым.** Топология деплоя, volume-контракт, AgentApi, конфигурация | +| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Команды бота, UX, передача файлов | +| [`docs/known-limitations.md`](docs/known-limitations.md) | Известные ограничения и обходные пути | +| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Внутренний протокол событий (для расширения) | +| [`docs/new-surface-guide.md`](docs/new-surface-guide.md) | Руководство по созданию новой поверхности (Discord, Slack и др.) | diff --git a/adapter/matrix/agent_registry.py b/adapter/matrix/agent_registry.py new file mode 100644 index 0000000..bf02018 --- /dev/null +++ b/adapter/matrix/agent_registry.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +import yaml + + +class AgentRegistryError(ValueError): + pass + + +@dataclass(frozen=True) +class AgentDefinition: + agent_id: str + label: str + base_url: 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: + def __init__( + self, + agents: list[AgentDefinition], + user_agents: Mapping[str, str] | None = None, + ) -> None: + self.agents = tuple(agents) + self._by_id = {agent.agent_id: agent for agent in self.agents} + self._user_agents: dict[str, str] = dict(user_agents or {}) + + 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 get_agent_id_for_user(self, matrix_user_id: str) -> str | None: + 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: + value = entry.get(key) + if not isinstance(value, str): + raise AgentRegistryError("each agent entry requires id and label") + text = value.strip() + if not text: + raise AgentRegistryError("each agent entry requires id and label") + return text + + +def _optional_text(entry: Mapping[str, object], key: str) -> str: + value = entry.get(key) + if value is None: + return "" + if not isinstance(value, str): + raise AgentRegistryError(f"agent entry field '{key}' must be a string") + return value.strip() + + +def _load_registry_data(path: str | Path) -> dict[str, object]: + try: + raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise AgentRegistryError("invalid agent registry YAML") from exc + if raw is None: + return {} + if not isinstance(raw, Mapping): + raise AgentRegistryError("agent registry must be a mapping with an agents list") + return dict(raw) + + +def load_agent_registry(path: str | Path) -> AgentRegistry: + raw = _load_registry_data(path) + 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: + if not isinstance(entry, Mapping): + raise AgentRegistryError("each agent entry requires id and label") + agent_id = _required_text(entry, "id") + label = _required_text(entry, "label") + base_url = _optional_text(entry, "base_url") + workspace_path = _optional_text(entry, "workspace_path") + 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, + base_url=base_url, + workspace_path=workspace_path, + ) + ) + + user_agents = raw.get("user_agents") + if user_agents is not None: + if not isinstance(user_agents, Mapping): + raise AgentRegistryError("user_agents must be a mapping of user_id to agent_id") + user_agents = {str(k): str(v) for k, v in user_agents.items()} + + return AgentRegistry(agents, user_agents) diff --git a/adapter/matrix/bot.py b/adapter/matrix/bot.py index debd2fa..411f037 100644 --- a/adapter/matrix/bot.py +++ b/adapter/matrix/bot.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging import os import re from dataclasses import dataclass @@ -24,6 +25,7 @@ from nio import ( ) 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.files import ( download_matrix_attachment, @@ -31,11 +33,18 @@ from adapter.matrix.files import ( resolve_workspace_attachment_path, ) from adapter.matrix.handlers import register_matrix_handlers -from adapter.matrix.handlers.auth import handle_invite, provision_workspace_chat +from adapter.matrix.handlers.auth import ( + default_agent_notice, + handle_invite, + provision_workspace_chat, + restore_workspace_access, +) from adapter.matrix.handlers.context_commands import ( LOAD_PROMPT, ) +from adapter.matrix.reconciliation import reconcile_startup_state from adapter.matrix.room_router import resolve_chat_id +from adapter.matrix.routed_platform import RoutedPlatformClient from adapter.matrix.store import ( add_staged_attachment, clear_load_pending, @@ -83,6 +92,8 @@ class MatrixRuntime: auth_mgr: AuthManager settings_mgr: SettingsManager dispatcher: EventDispatcher + agent_routing_enabled: bool = False + registry: AgentRegistry | None = None def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> EventDispatcher: @@ -91,6 +102,7 @@ def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> Event settings_mgr = SettingsManager(platform, store) prototype_state = getattr(platform, "_prototype_state", None) agent_base_url = _agent_base_url_from_env() + registry = _load_agent_registry_from_env() dispatcher = EventDispatcher( platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr ) @@ -98,6 +110,7 @@ def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> Event register_matrix_handlers( dispatcher, store=store, + registry=registry, prototype_state=prototype_state, agent_base_url=agent_base_url, ) @@ -110,6 +123,26 @@ def _normalize_agent_base_url(url: str) -> str: 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: if base_url := os.environ.get("AGENT_BASE_URL"): return base_url @@ -118,14 +151,66 @@ def _agent_base_url_from_env() -> str: return "http://127.0.0.1:8000" -def _build_platform_from_env() -> PlatformClient: +def _load_agent_registry_from_env(required: bool = False) -> AgentRegistry | None: + registry_path = os.environ.get("MATRIX_AGENT_REGISTRY_PATH", "").strip() + if not registry_path: + if required: + raise RuntimeError( + "MATRIX_AGENT_REGISTRY_PATH is required when MATRIX_PLATFORM_BACKEND=real" + ) + return None + try: + registry = load_agent_registry(registry_path) + except (AgentRegistryError, OSError) as 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: 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": - return RealPlatformClient( - agent_id="matrix-bot", - agent_base_url=_agent_base_url_from_env(), - prototype_state=PrototypeStateStore(), - platform="matrix", + prototype_state = PrototypeStateStore() + registry = _load_agent_registry_from_env(required=True) + assert registry is not None + global_base_url = _agent_base_url_from_env() + delegates = { + agent.agent_id: RealPlatformClient( + agent_id=agent.agent_id, + agent_base_url=agent.base_url or global_base_url, + prototype_state=prototype_state, + platform="matrix", + ) + for agent in registry.agents + } + return RoutedPlatformClient( + chat_mgr=chat_mgr, + store=store, + delegates=delegates, ) return MockPlatformClient() @@ -135,13 +220,15 @@ def build_runtime( store: StateStore | None = None, client: AsyncClient | None = None, ) -> MatrixRuntime: - platform = platform or _build_platform_from_env() store = store or InMemoryStore() chat_mgr = ChatManager(platform, store) + platform = platform or _build_platform_from_env(store=store, chat_mgr=chat_mgr) + chat_mgr = ChatManager(platform, store) auth_mgr = AuthManager(platform, store) settings_mgr = SettingsManager(platform, store) prototype_state = getattr(platform, "_prototype_state", None) agent_base_url = _agent_base_url_from_env() + registry = _load_agent_registry_from_env() dispatcher = EventDispatcher( platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr ) @@ -150,6 +237,7 @@ def build_runtime( dispatcher, client=client, store=store, + registry=registry, prototype_state=prototype_state, agent_base_url=agent_base_url, ) @@ -160,6 +248,8 @@ def build_runtime( auth_mgr=auth_mgr, settings_mgr=settings_mgr, dispatcher=dispatcher, + agent_routing_enabled=isinstance(platform, RoutedPlatformClient), + registry=registry, ) @@ -181,6 +271,36 @@ class MatrixBot: 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: if getattr(event, "sender", None) == self.client.user_id: return @@ -189,6 +309,14 @@ class MatrixBot: 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"): 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) if load_pending is not None and (body.isdigit() or body == "!cancel"): @@ -202,17 +330,97 @@ class MatrixBot: await self._send_all(room.room_id, outgoing) return 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_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( room.room_id, [ OutgoingMessage( chat_id=room.room_id, - text=( - f"Рабочий чат уже создан: {redirect_chat_id}. " - "Открой приглашённую комнату для продолжения." - ), + text=text, ) ], ) @@ -223,11 +431,11 @@ class MatrixBot: user=sender, ) return + if not body.startswith("!") and self.runtime.agent_routing_enabled: + pass + local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender) - dispatch_chat_id = local_chat_id - if not body.startswith("!"): - dispatch_chat_id = (room_meta or {}).get("platform_chat_id") or local_chat_id - incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id) + incoming = from_room_event(event, room_id=room.room_id, chat_id=local_chat_id) if incoming is None: return if isinstance(incoming, IncomingCommand) and incoming.command in { @@ -262,6 +470,17 @@ class MatrixBot: sender, incoming, ) + 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) try: outgoing = await self.runtime.dispatcher.dispatch(incoming) except PlatformError as exc: @@ -274,14 +493,14 @@ class MatrixBot: ) outgoing = [ OutgoingMessage( - chat_id=dispatch_chat_id, + chat_id=local_chat_id, text="Сервис временно недоступен. Попробуйте ещё раз позже.", ) ] else: if clear_staged_after_dispatch: await clear_staged_attachments(self.runtime.store, room.room_id, sender) - await self._send_all(room.room_id, outgoing) + await self._send_all(room.room_id, outgoing, workspace_root=workspace_root) def _is_file_only_event( self, event: RoomMessage, incoming: IncomingMessage | IncomingCommand @@ -401,13 +620,27 @@ class MatrixBot: True, ) + def _agent_workspace_root(self, agent_id: str | None) -> Path: + default = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) + if agent_id is None or self.runtime.registry is None: + return default + try: + agent = self.runtime.registry.get(agent_id) + if agent.workspace_path: + return Path(agent.workspace_path) + except Exception: + pass + return default + async def _materialize_incoming_attachments( self, room_id: str, matrix_user_id: str, incoming: IncomingMessage, ) -> IncomingMessage: - workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) + room_meta = await get_room_meta(self.runtime.store, room_id) + agent_id = (room_meta or {}).get("agent_id") + workspace_root = self._agent_workspace_root(agent_id) materialized = [] for attachment in incoming.attachments: materialized.append( @@ -445,6 +678,7 @@ class MatrixBot: self.runtime.store, self.runtime.auth_mgr, self.runtime.chat_mgr, + registry=self.runtime.registry, ) except Exception as exc: logger.warning( @@ -464,6 +698,8 @@ class MatrixBot: f"Привет, {created['user'].display_name or sender}! Пиши — я здесь.\n\n" "Команды: !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( self.runtime.store, room.room_id, @@ -554,11 +790,23 @@ class MatrixBot: self.runtime.store, self.runtime.auth_mgr, self.runtime.chat_mgr, + self.runtime.registry, ) - async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None: + async def _send_all( + self, + room_id: str, + outgoing: list[OutgoingEvent], + workspace_root: Path | None = None, + ) -> None: for event in outgoing: - await send_outgoing(self.client, room_id, event, store=self.runtime.store) + await send_outgoing( + self.client, + room_id, + event, + store=self.runtime.store, + workspace_root=workspace_root, + ) async def prepare_live_sync(client: AsyncClient) -> str | None: @@ -573,6 +821,7 @@ async def send_outgoing( room_id: str, event: OutgoingEvent, store: StateStore | None = None, + workspace_root: Path | None = None, ) -> None: if isinstance(event, OutgoingTyping): await client.room_typing(room_id, event.is_typing, timeout=25000) @@ -587,7 +836,9 @@ async def send_outgoing( room_id, "m.room.message", {"msgtype": "m.text", "body": event.text} ) if event.attachments: - workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) + workspace_root = workspace_root or Path( + os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace") + ) for attachment in event.attachments: if not attachment.workspace_path: continue @@ -644,6 +895,7 @@ async def send_outgoing( async def main() -> None: + _configure_debug_logging() homeserver = os.environ.get("MATRIX_HOMESERVER") user_id = os.environ.get("MATRIX_USER_ID") device_id = os.environ.get("MATRIX_DEVICE_ID", "") @@ -675,6 +927,7 @@ async def main() -> None: await client.login(password=password, device_name="surfaces-bot") since_token = await prepare_live_sync(client) + await reconcile_startup_state(client, runtime) bot = MatrixBot(client, runtime) client.add_event_callback( @@ -696,6 +949,15 @@ async def main() -> None: store_path=store_path, 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: await client.sync_forever(timeout=30000, since=since_token) finally: diff --git a/adapter/matrix/files.py b/adapter/matrix/files.py index a736fba..0845684 100644 --- a/adapter/matrix/files.py +++ b/adapter/matrix/files.py @@ -2,16 +2,16 @@ from __future__ import annotations import mimetypes import re -from datetime import UTC, datetime -from pathlib import Path +from pathlib import Path, PurePosixPath from core.protocol import Attachment -def _sanitize_component(value: str) -> str: - cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value) - cleaned = cleaned.strip("._-") - return cleaned or "unknown" +def _sanitize_filename(value: str) -> str: + filename = PurePosixPath(str(value).replace("\\", "/")).name.strip() + cleaned = re.sub(r"[\x00-\x1f\x7f<>:\"/\\|?*]+", "_", filename) + cleaned = cleaned.strip(" .") + return cleaned or "attachment.bin" def _default_filename(attachment: Attachment) -> str: @@ -28,22 +28,38 @@ def _default_filename(attachment: Attachment) -> str: return f"{base}{extension}" -def build_workspace_attachment_path( +def _with_copy_index(filename: str, index: int) -> str: + path = Path(filename) + suffix = path.suffix + stem = path.stem if suffix else filename + return f"{stem} ({index}){suffix}" + + +def _unique_workspace_relative_path(workspace_root: Path, filename: str) -> tuple[str, 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, - matrix_user_id: str, - room_id: str, filename: str, - timestamp: str | None = None, ) -> tuple[str, Path]: - 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 + """Saves user files directly to {workspace_root}/{filename}. + + The returned relative path is what gets passed to agent.send_message(attachments=[...]). + """ + return _unique_workspace_relative_path(workspace_root, filename) async def download_matrix_attachment( @@ -59,13 +75,13 @@ async def download_matrix_attachment( return attachment filename = _default_filename(attachment) - relative_path, absolute_path = build_workspace_attachment_path( + + del matrix_user_id, room_id, timestamp + relative_path, absolute_path = build_agent_workspace_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) response = await client.download(attachment.url) diff --git a/adapter/matrix/handlers/__init__.py b/adapter/matrix/handlers/__init__.py index c028735..30adf59 100644 --- a/adapter/matrix/handlers/__init__.py +++ b/adapter/matrix/handlers/__init__.py @@ -34,22 +34,22 @@ def register_matrix_handlers( dispatcher: EventDispatcher, client=None, store=None, + registry=None, prototype_state=None, agent_base_url: str = "http://127.0.0.1:8000", ) -> None: - dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) + dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store, registry)) dispatcher.register(IncomingCommand, "chats", handle_list_chats) dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store)) dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store)) dispatcher.register(IncomingCommand, "help", handle_help) dispatcher.register(IncomingCommand, "settings", handle_settings) - dispatcher.register( - IncomingCommand, - "reset", - make_handle_reset(store, prototype_state) - if prototype_state is not None - else handle_settings, - ) + if prototype_state is not None: + clear_handler = make_handle_reset(store, prototype_state) + dispatcher.register(IncomingCommand, "clear", clear_handler) + dispatcher.register(IncomingCommand, "reset", clear_handler) + else: + dispatcher.register(IncomingCommand, "reset", handle_settings) dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills) dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors) dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul) diff --git a/adapter/matrix/handlers/auth.py b/adapter/matrix/handlers/auth.py index 9ad43fb..064448d 100644 --- a/adapter/matrix/handlers/auth.py +++ b/adapter/matrix/handlers/auth.py @@ -6,6 +6,7 @@ import structlog from nio.api import RoomVisibility from nio.responses import RoomCreateError +from adapter.matrix.agent_registry import AgentRegistry from adapter.matrix.store import ( get_user_meta, next_platform_chat_id, @@ -21,6 +22,31 @@ def _default_room_name(chat_id: str) -> str: 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( client: Any, matrix_user_id: str, @@ -30,6 +56,7 @@ async def provision_workspace_chat( auth_mgr, chat_mgr, room_name_override: str | None = None, + registry: AgentRegistry | None = None, ) -> dict: user = await platform.get_or_create_user( external_id=matrix_user_id, @@ -64,6 +91,14 @@ async def provision_workspace_chat( chat_id = f"C{next_chat_index}" platform_chat_id = await next_platform_chat_id(store) room_name = room_name_override or _default_room_name(chat_id) + + agent_id = None + agent_assignment = "none" + if registry is not None: + assignment = registry.resolve_agent_for_user(matrix_user_id) + agent_id = assignment.agent_id + agent_assignment = assignment.source + chat_resp = await client.room_create( name=room_name, visibility=RoomVisibility.private, @@ -100,6 +135,8 @@ async def provision_workspace_chat( "matrix_user_id": matrix_user_id, "space_id": space_id, "platform_chat_id": platform_chat_id, + "agent_id": agent_id, + "agent_assignment": agent_assignment, }, ) await chat_mgr.get_or_create( @@ -116,6 +153,64 @@ async def provision_workspace_chat( "chat_room_id": chat_room_id, "chat_id": chat_id, "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, } @@ -127,6 +222,7 @@ async def handle_invite( store, auth_mgr, chat_mgr, + registry: AgentRegistry | None = None, ) -> None: matrix_user_id = getattr(event, "sender", "") display_name = getattr(room, "display_name", None) or matrix_user_id @@ -135,6 +231,29 @@ async def handle_invite( existing = await get_user_meta(store, matrix_user_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 try: @@ -147,6 +266,7 @@ async def handle_invite( auth_mgr, chat_mgr, room_name_override="Чат 1", + registry=registry, ) except RuntimeError as exc: logger.error("invite_workspace_provision_failed", user=matrix_user_id, error=str(exc)) @@ -154,8 +274,10 @@ async def handle_invite( welcome = ( f"Привет, {created['user'].display_name or matrix_user_id}! Пиши — я здесь.\n\n" - "Команды: !new · !chats · !rename · !archive · !context · !save · !load · !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( created["chat_room_id"], "m.room.message", diff --git a/adapter/matrix/handlers/chat.py b/adapter/matrix/handlers/chat.py index 6ce267c..645e9cd 100644 --- a/adapter/matrix/handlers/chat.py +++ b/adapter/matrix/handlers/chat.py @@ -7,6 +7,8 @@ import structlog from nio.api import RoomVisibility from nio.responses import RoomCreateError +from adapter.matrix.agent_registry import AgentRegistry +from adapter.matrix.handlers.auth import default_agent_notice from adapter.matrix.store import ( get_user_meta, next_chat_id, @@ -48,6 +50,7 @@ async def _fallback_new_chat( def make_handle_new_chat( client: Any | None, store: Any | None, + registry: AgentRegistry | None = None, ) -> Callable[..., Awaitable[list]]: async def handle_new_chat( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr @@ -104,18 +107,24 @@ def make_handle_new_chat( state_key=room_id, ) - 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 = None + agent_assignment = "none" + if registry is not None: + assignment = registry.resolve_agent_for_user(event.user_id) + agent_id = assignment.agent_id + agent_assignment = assignment.source + + room_meta: dict = { + "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": agent_id, + "agent_assignment": agent_assignment, + } + await set_room_meta(store, room_id, room_meta) ctx = await chat_mgr.get_or_create( user_id=event.user_id, chat_id=chat_id, @@ -123,10 +132,13 @@ def make_handle_new_chat( surface_ref=room_id, name=room_name, ) + text = f"Создан чат: {ctx.display_name} ({ctx.chat_id})" + if agent_assignment == "default": + text = f"{text}\n\n{default_agent_notice()}" return [ OutgoingMessage( chat_id=event.chat_id, - text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})", + text=text, ) ] diff --git a/adapter/matrix/handlers/context_commands.py b/adapter/matrix/handlers/context_commands.py index 648978d..121d76b 100644 --- a/adapter/matrix/handlers/context_commands.py +++ b/adapter/matrix/handlers/context_commands.py @@ -59,6 +59,17 @@ async def _resolve_context_scope( return room_id, platform_chat_id +async def _require_platform_context( + event: IncomingCommand, + store: StateStore, + chat_mgr, +) -> tuple[str, str]: + room_id, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) + if not platform_chat_id: + raise RuntimeError(f"matrix room context is incomplete: {room_id}") + return room_id, platform_chat_id + + def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeStateStore): async def handle_save( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr @@ -85,11 +96,16 @@ def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeSta logger.warning("save_agent_call_failed", error=str(exc)) return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")] - _, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) + try: + _, platform_chat_id = await _require_platform_context(event, store, chat_mgr) + except RuntimeError as exc: + logger.warning("save_context_incomplete", error=str(exc)) + return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] + await prototype_state.add_saved_session( event.user_id, name, - source_context_id=platform_chat_id or event.chat_id, + source_context_id=platform_chat_id, ) return [ OutgoingMessage( @@ -132,9 +148,11 @@ def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore): async def handle_reset( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: - room_id = await _resolve_room_id(event, chat_mgr) - room_meta = await get_room_meta(store, room_id) - old_chat_id = (room_meta or {}).get("platform_chat_id") or room_id + try: + room_id, old_chat_id = await _require_platform_context(event, store, chat_mgr) + except RuntimeError as exc: + logger.warning("clear_context_incomplete", error=str(exc)) + return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] new_chat_id = await next_platform_chat_id(store) await set_platform_chat_id(store, room_id, new_chat_id) @@ -143,6 +161,7 @@ def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore): if callable(disconnect): await disconnect(old_chat_id) + await prototype_state.clear_current_session(old_chat_id) await prototype_state.clear_current_session(new_chat_id) return [ @@ -182,20 +201,19 @@ def make_handle_context(store: StateStore, prototype_state: PrototypeStateStore) async def handle_context( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: - _, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) - context_key = platform_chat_id or event.chat_id - current_session = await prototype_state.get_current_session(context_key) - tokens_used = await prototype_state.get_last_tokens_used(context_key) - if platform_chat_id is not None and event.chat_id != platform_chat_id: - if current_session is None: - current_session = await prototype_state.get_current_session(event.chat_id) - if tokens_used == 0: - tokens_used = await prototype_state.get_last_tokens_used(event.chat_id) + try: + _, platform_chat_id = await _require_platform_context(event, store, chat_mgr) + except RuntimeError as exc: + logger.warning("context_scope_incomplete", error=str(exc)) + return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] + + current_session = await prototype_state.get_current_session(platform_chat_id) + tokens_used = await prototype_state.get_last_tokens_used(platform_chat_id) sessions = await prototype_state.list_saved_sessions(event.user_id) lines = [ "Контекст:", - f" Контекст чата: {platform_chat_id or event.chat_id}", + f" Контекст чата: {platform_chat_id}", f" Сессия: {current_session or 'не загружена'}", f" Токены (последний ответ): {tokens_used}", f" Сохранения ({len(sessions)}):", diff --git a/adapter/matrix/handlers/settings.py b/adapter/matrix/handlers/settings.py index 07e64c0..59bee6b 100644 --- a/adapter/matrix/handlers/settings.py +++ b/adapter/matrix/handlers/settings.py @@ -10,11 +10,15 @@ HELP_TEXT = "\n".join( "!chats список активных чатов", "!rename <название> переименовать текущий чат", "!archive архивировать текущий чат", - "!context показать текущее состояние контекста", - "!save [имя] сохранить текущий контекст", - "!load показать сохранённые контексты", "", - "Остальные команды и настройки скрыты в MVP, чтобы не вводить в заблуждение.", + "!clear сбросить контекст текущего чата", + "", + "!list показать файлы в очереди", + "!remove удалить файл из очереди", + "!remove all очистить очередь файлов", + "", + "!yes / !no подтвердить или отменить действие", + "!help эта справка", ] ) diff --git a/adapter/matrix/reconciliation.py b/adapter/matrix/reconciliation.py new file mode 100644 index 0000000..835bd5d --- /dev/null +++ b/adapter/matrix/reconciliation.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +from adapter.matrix.store import ( + get_room_meta, + get_user_meta, + next_platform_chat_id, + set_room_meta, + set_user_meta, +) + +_CHAT_ID_PATTERNS = ( + re.compile(r"\bC(?P\d+)\b", re.IGNORECASE), + re.compile(r"^Чат\s+(?P\d+)$", re.IGNORECASE), +) + + +@dataclass(slots=True) +class ReconciliationResult: + recovered_rooms: int = 0 + repaired_rooms: int = 0 + backfilled_platform_chat_ids: int = 0 + + +def _room_name(room: object) -> str | None: + for attr in ("name", "display_name"): + value = getattr(room, attr, None) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _chat_id_from_room(room: object, existing_meta: dict | None) -> str | None: + chat_id = (existing_meta or {}).get("chat_id") + if isinstance(chat_id, str) and chat_id: + return chat_id + + name = _room_name(room) + if not name: + return None + + for pattern in _CHAT_ID_PATTERNS: + match = pattern.search(name) + if match: + return f"C{int(match.group('index'))}" + return None + + +def _space_id_for_room( + room: object, rooms_by_id: dict[str, object], existing_meta: dict | None +) -> str | None: + existing_space_id = (existing_meta or {}).get("space_id") + if isinstance(existing_space_id, str) and existing_space_id: + return existing_space_id + + parents = getattr(room, "parents", None) + if not parents: + parents = getattr(room, "space_parents", None) + if not parents: + return None + + for parent_id in parents: + parent = rooms_by_id.get(parent_id) + if parent is None: + continue + if getattr(parent, "room_type", None) == "m.space" or getattr(parent, "children", None): + return parent_id + return parent_id + return None + + +def _matrix_user_id_for_room( + room: object, bot_user_id: str | None, existing_meta: dict | None +) -> str | None: + existing_user_id = (existing_meta or {}).get("matrix_user_id") + if isinstance(existing_user_id, str) and existing_user_id: + return existing_user_id + + users = getattr(room, "users", None) or {} + for user_id in users: + if user_id != bot_user_id: + return user_id + return None + + +async def reconcile_startup_state(client: object, runtime: object) -> ReconciliationResult: + rooms_by_id = getattr(client, "rooms", None) or {} + bot_user_id = getattr(client, "user_id", None) + result = ReconciliationResult() + max_chat_index_by_user: dict[str, int] = {} + recovered_space_by_user: dict[str, str] = {} + + for room_id, room in rooms_by_id.items(): + if getattr(room, "room_type", None) == "m.space": + continue + + existing_meta = await get_room_meta(runtime.store, room_id) + if existing_meta and existing_meta.get("redirect_room_id"): + continue + + space_id = _space_id_for_room(room, rooms_by_id, existing_meta) + chat_id = _chat_id_from_room(room, existing_meta) + matrix_user_id = _matrix_user_id_for_room(room, bot_user_id, existing_meta) + if not space_id or not chat_id or not matrix_user_id: + continue + + recovered_space_by_user[matrix_user_id] = space_id + chat_index = int(chat_id[1:]) + max_chat_index_by_user[matrix_user_id] = max( + max_chat_index_by_user.get(matrix_user_id, 0), + chat_index, + ) + + display_name = (existing_meta or {}).get("display_name") or _room_name(room) or chat_id + room_meta = dict(existing_meta or {}) + room_meta.update( + { + "room_type": "chat", + "chat_id": chat_id, + "display_name": display_name, + "matrix_user_id": matrix_user_id, + "space_id": space_id, + } + ) + + if not room_meta.get("platform_chat_id"): + room_meta["platform_chat_id"] = await next_platform_chat_id(runtime.store) + result.backfilled_platform_chat_ids += 1 + + if not room_meta.get("agent_id"): + registry = getattr(runtime, "registry", None) + if registry is not None: + assignment = registry.resolve_agent_for_user(matrix_user_id) + if assignment.agent_id: + room_meta["agent_id"] = assignment.agent_id + room_meta["agent_assignment"] = assignment.source + else: + 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: + result.recovered_rooms += 1 + elif room_meta != existing_meta: + result.repaired_rooms += 1 + + await set_room_meta(runtime.store, room_id, room_meta) + await runtime.auth_mgr.confirm(matrix_user_id) + await runtime.chat_mgr.get_or_create( + user_id=matrix_user_id, + chat_id=chat_id, + platform="matrix", + surface_ref=room_id, + name=display_name, + ) + + for matrix_user_id, recovered_space_id in recovered_space_by_user.items(): + 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 + next_chat_index = max_chat_index_by_user[matrix_user_id] + 1 + user_meta["next_chat_index"] = max( + int(user_meta.get("next_chat_index", 1)), next_chat_index + ) + await set_user_meta(runtime.store, matrix_user_id, user_meta) + + return result diff --git a/adapter/matrix/routed_platform.py b/adapter/matrix/routed_platform.py new file mode 100644 index 0000000..3f9adc8 --- /dev/null +++ b/adapter/matrix/routed_platform.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import os +from collections.abc import AsyncIterator, Mapping + +import structlog + +from adapter.matrix.store import get_room_meta +from core.chat import ChatManager +from core.store import StateStore +from sdk.interface import ( + Attachment, + MessageChunk, + MessageResponse, + PlatformClient, + PlatformError, + User, + 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): + def __init__( + self, + *, + chat_mgr: ChatManager, + store: StateStore, + delegates: Mapping[str, PlatformClient], + ) -> None: + if not delegates: + raise ValueError("RoutedPlatformClient requires at least one delegate") + self._chat_mgr = chat_mgr + self._store = store + self._delegates = dict(delegates) + self._default_client = next(iter(self._delegates.values())) + self._prototype_state = getattr(self._default_client, "_prototype_state", None) + + async def get_or_create_user( + self, + external_id: str, + platform: str, + display_name: str | None = None, + ) -> User: + return await self._default_client.get_or_create_user( + external_id=external_id, + platform=platform, + display_name=display_name, + ) + + async def send_message( + self, + user_id: str, + chat_id: str, + text: str, + attachments: list[Attachment] | None = None, + ) -> MessageResponse: + delegate, platform_chat_id = await self._resolve_delegate(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: list[Attachment] | None = None, + ) -> AsyncIterator[MessageChunk]: + delegate, platform_chat_id = await self._resolve_delegate(user_id, chat_id) + async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments): + yield chunk + + async def get_settings(self, user_id: str) -> UserSettings: + return await self._default_client.get_settings(user_id) + + async def update_settings(self, user_id: str, action) -> None: + await self._default_client.update_settings(user_id, action) + + async def close(self) -> None: + for delegate in self._delegates.values(): + close = getattr(delegate, "close", None) + if callable(close): + await close() + + async def _resolve_delegate( + self, user_id: str, local_chat_id: str + ) -> tuple[PlatformClient, str]: + chat = await self._chat_mgr.get(local_chat_id, user_id) + if chat is None: + raise PlatformError( + f"unknown matrix chat id: {local_chat_id}", + code="MATRIX_CHAT_NOT_FOUND", + ) + + room_meta = await get_room_meta(self._store, chat.surface_ref) + if room_meta is None: + raise PlatformError( + f"matrix room is not bound: {chat.surface_ref}", + code="MATRIX_ROOM_NOT_BOUND", + ) + + agent_id = room_meta.get("agent_id") + platform_chat_id = room_meta.get("platform_chat_id") + if not agent_id or not platform_chat_id: + raise PlatformError( + f"matrix room routing is incomplete: {chat.surface_ref}", + code="MATRIX_ROUTE_INCOMPLETE", + ) + + delegate = self._delegates.get(str(agent_id)) + if delegate is None: + raise PlatformError( + f"unknown matrix agent id: {agent_id}", + 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) diff --git a/adapter/matrix/store.py b/adapter/matrix/store.py index e835ace..8ecd557 100644 --- a/adapter/matrix/store.py +++ b/adapter/matrix/store.py @@ -45,6 +45,12 @@ async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> N await store.set(f"{USER_META_PREFIX}{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) + + async def get_room_state(store: StateStore, room_id: str) -> str: data = await store.get(f"{ROOM_STATE_PREFIX}{room_id}") return data["state"] if data else "idle" diff --git a/config/matrix-agents.example.yaml b/config/matrix-agents.example.yaml new file mode 100644 index 0000000..84221eb --- /dev/null +++ b/config/matrix-agents.example.yaml @@ -0,0 +1,44 @@ +# Agent registry for the Matrix bot. +# Production target: one surface bot routes to 25-30 externally managed agents. +# Keep adding entries with the same base_url/workspace_path pattern. +# +# user_agents: maps a Matrix user ID to an agent ID. +# If a user is not listed, the bot uses the first agent from the list below. +# Omit this section entirely for a single-agent setup. +# +# agents: list of available agents. +# id — must match the agent ID known to the platform +# label — human-readable name (shown in logs) +# base_url — HTTP/WS URL of this agent's endpoint +# (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 +# (the bot saves incoming files directly here and reads outgoing files from here) +# Example: /agents/0 means the bot mounts the shared volume at /agents/ +# and this agent's files live under /agents/0/ + +user_agents: + "@user0:matrix.example.org": agent-0 + "@user1:matrix.example.org": agent-1 + "@user2:matrix.example.org": agent-2 + +agents: + - id: agent-0 + label: "Agent 0" + base_url: "http://lambda.coredump.ru:7000/agent_0/" + workspace_path: "/agents/0" + + - id: agent-1 + label: "Agent 1" + base_url: "http://lambda.coredump.ru:7000/agent_1/" + workspace_path: "/agents/1" + + - id: agent-2 + label: "Agent 2" + base_url: "http://lambda.coredump.ru:7000/agent_2/" + workspace_path: "/agents/2" + + # Continue the same pattern through agent-29 for a 25-30 agent deployment: + # - id: agent-29 + # label: "Agent 29" + # base_url: "http://lambda.coredump.ru:7000/agent_29/" + # workspace_path: "/agents/29" diff --git a/config/matrix-agents.smoke.yaml b/config/matrix-agents.smoke.yaml new file mode 100644 index 0000000..9b357fe --- /dev/null +++ b/config/matrix-agents.smoke.yaml @@ -0,0 +1,10 @@ +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" diff --git a/config/matrix-agents.yaml b/config/matrix-agents.yaml new file mode 100644 index 0000000..3ab9366 --- /dev/null +++ b/config/matrix-agents.yaml @@ -0,0 +1,8 @@ +# Single-agent configuration for MVP deployment. +# For multi-agent setup with per-user routing, see config/matrix-agents.example.yaml. + +agents: + - id: agent-1 + label: Surface + base_url: "http://lambda.coredump.ru:7000/agent_1/" + workspace_path: "/agents/1" diff --git a/core/handlers/message.py b/core/handlers/message.py index d9f91cd..876754c 100644 --- a/core/handlers/message.py +++ b/core/handlers/message.py @@ -1,7 +1,35 @@ # core/handlers/message.py from __future__ import annotations -from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping +from core.protocol import Attachment, IncomingMessage, OutgoingMessage, OutgoingTyping + + +def _infer_attachment_type(mime_type: str | None) -> str: + if not mime_type: + return "document" + if mime_type.startswith("image/"): + return "image" + if mime_type.startswith("audio/"): + return "audio" + if mime_type.startswith("video/"): + return "video" + return "document" + + +def _to_core_attachments(raw: list) -> list[Attachment]: + result = [] + for a in raw: + if isinstance(a, Attachment): + result.append(a) + else: + result.append(Attachment( + type=getattr(a, "type", None) or _infer_attachment_type(getattr(a, "mime_type", None)), + url=getattr(a, "url", None), + filename=getattr(a, "filename", None), + mime_type=getattr(a, "mime_type", None), + workspace_path=getattr(a, "workspace_path", None), + )) + return result def _start_command(platform: str) -> str: @@ -38,6 +66,6 @@ async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, s chat_id=event.chat_id, text=response.response, parse_mode="markdown", - attachments=list(getattr(response, "attachments", [])), + attachments=_to_core_attachments(getattr(response, "attachments", [])), ), ] diff --git a/docker-compose.fullstack.yml b/docker-compose.fullstack.yml new file mode 100644 index 0000000..88ff37b --- /dev/null +++ b/docker-compose.fullstack.yml @@ -0,0 +1,61 @@ +services: + matrix-bot: + extends: + file: docker-compose.prod.yml + service: matrix-bot + build: + context: . + dockerfile: Dockerfile + target: development + args: + LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master} + additional_contexts: + agent_api: ./external/platform-agent_api + tags: + - ${SURFACES_BOT_DEV_IMAGE:-surfaces-bot:dev} + environment: + AGENT_BASE_URL: http://platform-agent:8000 + depends_on: + platform-agent: + condition: service_healthy + + platform-agent: + build: + context: ./external/platform-agent + target: development + additional_contexts: + agent_api: ./external/platform-agent_api + environment: + PYTHONUNBUFFERED: "1" + AGENT_ID: ${AGENT_ID:-matrix-dev} + PROVIDER_MODEL: ${PROVIDER_MODEL:-openai/gpt-4o-mini} + PROVIDER_URL: ${PROVIDER_URL:-} + PROVIDER_API_KEY: ${PROVIDER_API_KEY:-} + COMPOSIO_API_KEY: ${COMPOSIO_API_KEY:-} + volumes: + - ./external/platform-agent/src:/app/src + - ./external/platform-agent_api:/agent_api + - agents:/workspace + command: > + sh -lc " + mkdir -p /workspace && + chown -R agent:agent /workspace && + exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log + " + ports: + - "8000:8000" + healthcheck: + test: + - CMD-SHELL + - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()" + interval: 60s + timeout: 5s + retries: 5 + start_period: 15s + restart: unless-stopped + +volumes: + agents: + name: ${SURFACES_SHARED_VOLUME:-surfaces-agents} + bot-state: + name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2c7e942 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,26 @@ +services: + matrix-bot: + image: "${SURFACES_BOT_IMAGE:?Set SURFACES_BOT_IMAGE to the pushed image, e.g. mput1/surfaces-bot:latest}" + environment: + MATRIX_HOMESERVER: ${MATRIX_HOMESERVER:-} + MATRIX_USER_ID: ${MATRIX_USER_ID:-} + MATRIX_PASSWORD: ${MATRIX_PASSWORD:-} + MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-} + MATRIX_PLATFORM_BACKEND: ${MATRIX_PLATFORM_BACKEND:-real} + MATRIX_AGENT_REGISTRY_PATH: ${MATRIX_AGENT_REGISTRY_PATH:-/app/config/matrix-agents.yaml} + AGENT_BASE_URL: ${AGENT_BASE_URL:-} + SURFACES_WORKSPACE_DIR: ${SURFACES_WORKSPACE_DIR:-/agents} + MATRIX_DB_PATH: /app/state/lambda_matrix.db + MATRIX_STORE_PATH: /app/state/matrix_store + PYTHONUNBUFFERED: "1" + volumes: + - agents:/agents + - bot-state:/app/state + - ./config:/app/config:ro + restart: unless-stopped + +volumes: + agents: + name: ${SURFACES_SHARED_VOLUME:-surfaces-agents} + bot-state: + name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state} diff --git a/docker-compose.smoke.timeout.yml b/docker-compose.smoke.timeout.yml new file mode 100644 index 0000000..c8f4ba3 --- /dev/null +++ b/docker-compose.smoke.timeout.yml @@ -0,0 +1,18 @@ +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"] diff --git a/docker-compose.smoke.yml b/docker-compose.smoke.yml new file mode 100644 index 0000000..ed4e8b8 --- /dev/null +++ b/docker-compose.smoke.yml @@ -0,0 +1,109 @@ +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} diff --git a/docker-compose.yml b/docker-compose.yml index 4de9fac..c7323d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: - platform-agent volumes: - workspace:/workspace + - ./config:/app/config:ro restart: unless-stopped volumes: diff --git a/docker/nginx/smoke-agents-timeout.conf b/docker/nginx/smoke-agents-timeout.conf new file mode 100644 index 0000000..03c7e79 --- /dev/null +++ b/docker/nginx/smoke-agents-timeout.conf @@ -0,0 +1,28 @@ +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; + } + } +} diff --git a/docker/nginx/smoke-agents.conf b/docker/nginx/smoke-agents.conf new file mode 100644 index 0000000..e3bcaab --- /dev/null +++ b/docker/nginx/smoke-agents.conf @@ -0,0 +1,28 @@ +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; + } + } +} diff --git a/docs/api-contract.md b/docs/api-contract.md deleted file mode 100644 index 10fd899..0000000 --- a/docs/api-contract.md +++ /dev/null @@ -1,143 +0,0 @@ -# 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` — совпадает с нашим или другой? diff --git a/docs/deploy-architecture.md b/docs/deploy-architecture.md new file mode 100644 index 0000000..e838611 --- /dev/null +++ b/docs/deploy-architecture.md @@ -0,0 +1,197 @@ +# Deployment Architecture — Matrix Bot + Agents + +> Сформировано 2026-04-27 по итогам обсуждения с платформой. + +--- + +## Compose Artifacts + +- **Production deploy:** `docker-compose.prod.yml` + Bot-only handoff через published image (`SURFACES_BOT_IMAGE`). Поднимает только `matrix-bot`, монтирует shared volume в `/agents`. + Платформа предоставляет 25-30 agent containers/services отдельно; бот подключается к ним через `base_url` из `matrix-agents.yaml`. +- **Internal full-stack E2E:** `docker-compose.fullstack.yml` + Внутренний harness для тестирования. Локально собирает `matrix-bot` через Dockerfile target `development`, поднимает один `platform-agent`, health-gated startup. + +Production operators: `docker-compose.prod.yml`. Internal E2E: `docker-compose.fullstack.yml`. + +--- + +## Топология + +``` +lambda.coredump.ru +├── :7000 (reverse proxy, path-based routing) +│ ├── /agent_0/ → agent_0 container +│ ├── /agent_1/ → agent_1 container +│ └── /agent_N/ → agent_N container +│ +└── Matrix bot instance (один инстанс на всех) + └── volume /agents/ (shared с агентами) + ├── /agents/0/ ← workspace agent_0 + ├── /agents/1/ ← workspace agent_1 + └── /agents/N/ +``` + +- **Один инстанс Matrix-бота** обслуживает всех пользователей. +- **Один агент-контейнер на пользователя.** Production scale target: 25-30 внешних агентов. Изоляция по `agent_id`, не через один общий agent instance. +- **Shared volume** `/agents/` смонтирован в Matrix-бот. В internal full-stack harness тот же volume mounted as `/workspace` inside `platform-agent`, чтобы bot-side absolute paths и agent workspace относились к одному и тому же хранилищу. + +--- + +## Конфиг (два словаря) + +```yaml +# config/matrix-agents.yaml + +user_agents: + "@user0:matrix.lambda.coredump.ru": agent-0 + "@user1:matrix.lambda.coredump.ru": agent-1 + "@user2:matrix.lambda.coredump.ru": agent-2 + +agents: + - id: agent-0 + label: "Agent 0" + base_url: "http://lambda.coredump.ru:7000/agent_0/" + workspace_path: "/agents/0" + + - id: agent-1 + label: "Agent 1" + base_url: "http://lambda.coredump.ru:7000/agent_1/" + workspace_path: "/agents/1" + + - id: agent-2 + label: "Agent 2" + base_url: "http://lambda.coredump.ru:7000/agent_2/" + workspace_path: "/agents/2" +``` + +- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент из списка. +- `agents[].base_url` — HTTP URL агент-эндпоинта. Бот подключается через AgentApi. +- `agents[].workspace_path` — абсолютный путь к воркспейсу агента **внутри контейнера бота** (т.е. на shared volume). + Бот сохраняет входящие файлы прямо в `{workspace_path}/`, читает исходящие из `{workspace_path}/`. +- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`. + +## Surface Image Build Contract + +Production image содержит только Matrix surface. Он не содержит `platform-agent` и не требует локального `external/` build context. + +```bash +docker login +export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest + +docker build --target production \ + --build-arg LAMBDA_AGENT_API_REF=master \ + -t "$SURFACES_BOT_IMAGE" . +docker push "$SURFACES_BOT_IMAGE" +``` + +Published image: + +```text +mput1/surfaces-bot:latest +sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd +``` + +`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа. + +Production Dockerfile ставит `platform/agent_api` из Git по тому же принципу, что и `platform-agent` production image: + +```bash +git+https://git.lambda.coredump.ru/platform/agent_api.git +``` + +Локальный `docker-compose.fullstack.yml` остаётся dev/E2E harness: он использует target `development` и `additional_contexts.agent_api=./external/platform-agent_api`, чтобы можно было тестировать surface вместе с локальным checkout SDK. + +--- + +## Agent API (используем master ветку `platform/agent_api`) + +```python +from lambda_agent_api.agent_api import AgentApi + +connected_agents: dict[tuple[str, int], AgentApi] = {} + +def on_agent_disconnect(agent: AgentApi): + connected_agents.pop((agent.id, agent.chat_id), None) + +async def on_message(matrix_user_id: str, matrix_room_id: str, text: str): + agent_id = get_agent_id_by_user(matrix_user_id) # из user_agents конфига + platform_chat_id = get_room_platform_chat_id(matrix_room_id) + + agent = connected_agents.get((agent_id, platform_chat_id)) + if not agent: + agent = AgentApi( + agent_id, + get_agent_base_url(agent_id), # ws://lambda.coredump.ru:7000/agent_0/ + on_disconnect=on_agent_disconnect, + chat_id=platform_chat_id, # отдельный thread на Matrix room + ) + await agent.connect() + connected_agents[(agent_id, platform_chat_id)] = agent + + async for event in agent.send_message(text): + ... +``` + +**Параметры конструктора (master):** +```python +AgentApi( + agent_id: str, + base_url: str, # ws://host:port/agent_N/ + chat_id: int = 0, # surfaces must supply per-room platform_chat_id + on_disconnect: callable, +) +``` + +**Lifecycle:** агент автоматически отключается после нескольких минут бездействия. +`on_disconnect` удаляет из пула → следующее сообщение создаёт новое соединение. + +--- + +## Передача файлов + +### Пользователь → Агент (входящий файл) + +1. Matrix-бот получает файл от пользователя +2. Сохраняет в workspace агента: `/agents/{N}/{filename}` +3. Если файл уже существует, выбирает следующее имя: `filename (1).ext`, `filename (2).ext` +4. Вызывает `agent.send_message(text, attachments=["filename"])` + — путь относительно `/workspace` агента + +### Агент → Пользователь (исходящий файл) + +1. Агент эмитит `MsgEventSendFile(path="report.pdf")` +2. Matrix-бот читает файл: `/agents/{N}/report.pdf` +3. Отправляет как Matrix file message пользователю + +**Ключевое:** production handoff через `docker-compose.prod.yml` и internal E2E через `docker-compose.fullstack.yml` используют один и тот же `/agents` contract на стороне поверхности. Прямой HTTP-доступ к файлам не нужен. + +--- + +## Текущее состояние platform-agent (main) + +- Composio интегрирован в main (`#9-интеграция-composIO`) +- Агент требует в `.env`: `AGENT_ID`, `COMPOSIO_API_KEY` +- Backend: `IsolatedShellBackend` (main) / `CompositeBackend` (ветка `#19`, не merged) +- Memory: `MemorySaver` — история слетает при рестарте контейнера (known limitation) + +--- + +## platform-master (будущее, пока не используем) + +Ветка `feat/storage` реализует реальный Master-сервис: +- `POST /api/v1/create {chat_id}` → поднимает/переиспользует sandbox-контейнер +- TTL-based lifecycle (300с default, конфигурируемо) +- `ChatStorage` — API для upload/download файлов через Master +- Auth + p2p lease — вне текущего scope MVP + +**Для деплоя MVP используем статический конфиг без Master.** +При готовности Master: `get_agent_url()` будет вызывать `POST /api/v1/create`, URL возвращается в ответе. + +--- + +## Открытые вопросы + +- `platform-agent_api #9-clientside-tool-call` убирает `attachments` и `MsgEventSendFile` — пока используем master. Уточнить у платформы сроки мержа перед деплоем. +- История при рестарте агента теряется — `platform-agent` использует `MemorySaver` (in-memory). Ограничение платформы. +- `AGENT_ID` и `COMPOSIO_API_KEY` для каждого агент-контейнера — значения предоставляет платформа. diff --git a/docs/matrix-direct-agent-prototype-ru.md b/docs/matrix-direct-agent-prototype-ru.md index 8f1dcee..2367dc5 100644 --- a/docs/matrix-direct-agent-prototype-ru.md +++ b/docs/matrix-direct-agent-prototype-ru.md @@ -1,5 +1,8 @@ # Matrix Direct-Agent Prototype +> **ВНИМАНИЕ: Это исторический документ.** +> Описанный здесь прототип был интегрирован в `main` и стал основой для Matrix MVP, но архитектура претерпела значительные изменения. Для актуальной информации по деплою смотрите `docs/deploy-architecture.md`, а для создания новой поверхности — `docs/max-surface-guide.md`. + Русскоязычная заметка по прототипу Matrix surface, который ходит не в `MockPlatformClient`, а напрямую в живой agent backend по WebSocket. ## Что сделали diff --git a/docs/matrix-prototype.md b/docs/matrix-prototype.md index bebf0b4..d79ff83 100644 --- a/docs/matrix-prototype.md +++ b/docs/matrix-prototype.md @@ -4,263 +4,101 @@ Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space. -При первом входе бот создаёт для пользователя личное пространство (Space) — -это как папка в Element. Внутри Space бот создаёт комнату для каждого нового -чата с агентом. Пользователь видит аккуратную структуру: одно пространство, -внутри — список чатов. История хранится нативно в Matrix — это часть протокола, -ничего дополнительно делать не нужно. +При первом invite бот создаёт для пользователя личное пространство (Space) и первую рабочую комнату. +История хранится нативно в Matrix. UX прагматичный: явные `!`-команды, локальный state-store, нативные Matrix rooms. -Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики, -разработчики скиллов. Поэтому UX здесь прагматичный: минимум магии, явные -команды `!`, локальный state-store и нативные Matrix rooms. +Matrix — внутренняя поверхность: команда лаборатории, тестировщики, разработчики скиллов. --- -## Аутентификация +## Онбординг -### Флоу -1. Пользователь приглашает бота в личные сообщения или пишет в общей комнате -2. Бот проверяет `@user:matrix.org` — есть ли аккаунт на платформе -3. Если нет — бот отправляет одноразовый код или ссылку -4. Пользователь подтверждает, платформа возвращает токен -5. Бот сохраняет привязку `matrix_user_id → platform_user_id` +1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере +2. Бот принимает invite, создаёт Space `Lambda — {display_name}` и первую комнату `Чат 1` +3. Приглашает пользователя в `Чат 1` и пишет приветствие +4. Дальнейшее общение ведётся в рабочих комнатах, не в DM -### В моке -- Любой пользователь проходит аутентификацию автоматически -- Бот отвечает: «Добро пожаловать, {display_name}. Создаю ваше пространство...» -- Демонстрирует флоу без реальной платформы - ---- - -## Чаты через Space + комнаты (вариант Б) - -### Структура ``` Space: «Lambda — {display_name}» - ├── 💬 Чат 1 ← первый чат, создаётся автоматически + ├── 💬 Чат 1 ← создаётся автоматически при invite ├── 💬 Чат 2 - └── 💬 Исследование рынка ← пользователь сам называет + └── 💬 Исследование рынка ← пользователь называет сам через !new ``` -### Создание Space -При первом входе бот: -1. Создаёт Space `Lambda — {display_name}` -2. Создаёт первую комнату-чат `Чат 1` -3. Передаёт `invite=[matrix_user_id]` прямо в `room_create(...)` для Space и комнаты -4. Привязывает `chat_id ↔ room_id` в локальном состоянии -5. Пишет приветствие в `Чат 1` +**Требование:** незашифрованные комнаты. E2EE не поддержан (инфраструктурное ограничение). + +--- + +## Работающие команды ### Управление чатами -Команды работают в зарегистрированных комнатах бота: | Команда | Действие | |---|---| | `!new` | Создать новый чат (новую комнату в Space) | | `!new Название` | Создать чат с именем | -| `!help` | Показать шпаргалку по доступным командам | -| `!rename Название` | Переименовать текущую комнату | -| `!archive` | Архивировать чат и вывести бота из комнаты | -| `!chats` | Показать список чатов | -| `!settings`, `!skills`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` | Настройки и диагностика | +| `!chats` | Список активных чатов | +| `!rename <название>` | Переименовать текущую комнату | +| `!archive` | Архивировать чат | +| `!help` | Справка | -### Создание нового чата -1. Пользователь пишет `!new` или `!new Анализ конкурентов` -2. Бот создаёт новую комнату в Space -3. Сразу приглашает пользователя через `room_create(..., invite=[user_id])` -4. Регистрирует комнату в локальном состоянии и `ChatManager` -5. Пользователь переходит в новую комнату — начинает диалог +### Контекст -### В моке -- Space и комнаты создаются реально через matrix-nio -- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) -- История хранится в Matrix нативно -- Дефолтные `skills`, `safety`, `soul`, `plan` подмешиваются даже после частичных локальных обновлений настроек +| Команда | Действие | +|---|---| +| `!clear` | Сбросить контекст текущего чата (создаёт новый thread у агента) | +| `!reset` | Псевдоним для `!clear` | -### Переименование и архивирование +### Подтверждения -- `!rename` обновляет имя комнаты через state event `m.room.name` -- `!archive` архивирует чат в `ChatManager` и делает `room_leave(...)` -- Если бот потерял локальное состояние и видит комнату как `unregistered:*`, то `!rename` и `!archive` возвращают защитное сообщение вместо сломанного действия +| Команда | Действие | +|---|---| +| `!yes` | Подтвердить действие агента | +| `!no` | Отменить действие агента | + +### Вложения (файловая очередь) + +Matrix-клиенты отправляют файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь, а не уходит агенту сразу. + +| Команда | Действие | +|---|---| +| `!list` | Показать файлы в очереди | +| `!remove ` | Удалить файл из очереди по номеру | +| `!remove all` | Очистить всю очередь | + +Как отправить файлы агенту: +1. Отправь один или несколько файлов в рабочую комнату +2. Напиши текстовое сообщение с инструкцией, например: `что на изображении?` +3. Бот отправит агенту текст вместе со всеми файлами из очереди --- -## Основной диалог +## Диалог -### Флоу сообщения -1. Пользователь пишет текст в комнату-чат -2. Бот показывает typing (m.typing event) -3. Запрос уходит в платформу (MockPlatformClient) -4. Бот отвечает в той же комнате - -### Вложения -- Файлы, изображения отправляются как Matrix media events -- Бот принимает `m.file`, `m.image`, `m.audio` -- Передаёт в платформу как `attachments` через `IncomingMessage` -- В моке: подтверждение получения + заглушка-ответ - -### Реакции как действия -Matrix поддерживает реакции на сообщения (`m.reaction`). -Используем это для подтверждения действий агента: - -``` -Агент: Хочу отправить письмо на vasya@mail.ru - Тема: «Отчёт за неделю» - - 👍 — подтвердить ❌ — отменить -``` - -Пользователь ставит реакцию — бот обрабатывает. Нативно и удобно. - -### Треды для длинных задач -Если агент выполняет долгую задачу (deep research, генерация документа), -бот создаёт тред от своего первого ответа и пишет промежуточные статусы туда. -Основной чат не засоряется. - -``` -Бот: Начинаю исследование по теме «AI агенты 2025» [→ в треде] - └── Ищу источники... (1/4) - └── Анализирую статьи... (2/4) - └── Формирую отчёт... (3/4) - └── Готово. Отчёт: [...] -``` +- Любое текстовое сообщение уходит агенту, бот показывает typing-индикатор +- Ответ стримится по WebSocket и выводится в ту же комнату +- Каждая комната имеет свой `platform_chat_id` — контексты изолированы между комнатами --- -## Настройки и диагностика +## Передача файлов -Отдельной комнаты `Настройки` в текущей версии нет. Команды вызываются как обычные -`!`-команды из зарегистрированных комнат бота, а `!settings` отдаёт сводный dashboard -по скиллам, личности, безопасности и активным чатам. +### Пользователь → Агент +Бот сохраняет файл в shared volume: `{workspace_path}/{filename}` +и передаёт агенту относительный путь как `workspace_path`. -### Коннекторы -``` -!connectors — показать список -!connect gmail — подключить Gmail (OAuth ссылка) -!connect github — подключить GitHub -!connect calendar — подключить Google Calendar -!connect notion — подключить Notion -!disconnect gmail — отключить -``` - -Статус: -``` -Коннекторы: - ✅ Gmail — подключён (user@gmail.com) - ❌ GitHub — не подключён → !connect github - ❌ Google Calendar — не подключён - ❌ Notion — не подключён -``` - -В моке: OAuth ссылка-заглушка → «Подключено ✓» - -### Скиллы -``` -!skills — показать список -!skill on browser — включить Browser Use -!skill off browser — выключить -``` - -Статус: -``` -Скиллы: - ✅ web-search — поиск в интернете - ✅ fetch-url — чтение веб-страниц - ✅ email — чтение почты (требует Gmail) - ❌ browser — управление браузером - ❌ image-gen — генерация изображений - ❌ video-gen — генерация видео - ✅ files — работа с файлами - ❌ calendar — календарь (требует Google Calendar) -``` - -В моке: состояние хранится локально. - -### Личность агента -``` -!soul — показать текущий SOUL.md -!soul name Лямбда — задать имя агента -!soul style brief — стиль: brief | friendly | formal -!soul priority «разбирать почту утром» — приоритетная задача -!soul reset — сбросить к дефолту -``` - -В моке: SOUL.md генерируется и хранится локально, агент обращается по имени. - -### Безопасность -``` -!safety — показать настройки -!safety on email-send — требовать подтверждение перед отправкой письма -!safety off calendar-create — не спрашивать для создания событий -``` - -Статус: -``` -Подтверждение требуется для: - ✅ отправка письма - ✅ удаление файлов - ✅ публикация в соцсетях - ❌ создание события в календаре - ❌ поиск в интернете -``` - -### Подписка -``` -!plan — показать текущий план -``` - -``` -Подписка: Beta (бесплатно) -Токены этот месяц: 800 / 1000 -━━━━━━━━░░ 80% -``` - -Заглушка, реализует другая команда. - -### Статус и диагностика -``` -!status — состояние платформы и чатов -!whoami — текущий аккаунт платформы -``` - -``` -Статус: - Платформа: ✅ доступна - Аккаунт: user@lambda.lab - Активных чатов: 3 -``` +### Агент → Пользователь +Агент эмитит путь к файлу в своём workspace. Бот читает файл из `/agents/...` +и отправляет пользователю как Matrix file message. --- -## FSM состояния +## Известные ограничения -``` -[Invite] → AuthPending → AuthConfirmed - ↓ - SpaceSetup → Idle (в комнате Настройки) - ↓ - [новая комната] → ChatCreated → Idle (в чате) - ↓ - ReceivingMessage → WaitingResponse → Idle - ↓ - WaitingReaction (confirm) → [✅/❌] → Idle - ↓ - LongTask → [тред со статусами] → Done → Idle -``` - ---- - -## Стек - -- Python 3.11+ -- matrix-nio (async) — Matrix клиент -- MockPlatformClient → `platform/interface.py` -- structlog для логирования -- SQLite / in-memory store для хранения `matrix_user_id → platform_user_id`, состояния скиллов и маппинга `chat_id → room_id` - ---- - -## Ограничения текущей версии - -- Ручной QA и текущая разработка идут только в незашифрованных комнатах -- После рестарта бот делает bootstrap sync и стартует с `since`, поэтому старые события не должны переигрываться повторно -- Если удалить локальную БД/стор, старые Matrix rooms останутся, но команды, завязанные на локальную регистрацию чатов, перестанут работать для этих комнат до повторного онбординга +| Проблема | Причина | +|---|---| +| `!save` / `!load` / `!context` | Нестабильны: `!save` зависит от агента (пишет файл в workspace), сессии хранятся in-memory и теряются при рестарте | +| Первый чанк ответа иногда пропадает после tool/file flow | Баг в upstream `platform-agent`; подробности: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` | +| Персистентность истории между рестартами агента | `platform-agent` использует `MemorySaver` (in-memory) | +| E2EE комнаты | `python-olm` не собирается на macOS/ARM | +| `!settings` и настройки скиллов/SOUL/безопасности | Заглушки MVP, требуют готового SDK платформы | diff --git a/docs/new-surface-guide.md b/docs/new-surface-guide.md new file mode 100644 index 0000000..7ebdc2a --- /dev/null +++ b/docs/new-surface-guide.md @@ -0,0 +1,313 @@ +# Руководство по созданию новой поверхности + +Этот документ описывает, как написать новую новую поверхность (например, Discord, Slack или Custom Web) по образцу текущей Matrix-поверхности в ветке `main`. + +Он основан на актуальной реализации Matrix surface в репозитории и отражает текущую продакшн-логику, а не устаревший легаси. + +--- + +## 1. Общая архитектура + +### 1.1. Что такое поверхность + +Поверхность — это тонкий адаптер между конкретной платформой (Платформа) и общим ядром бота. + +В репозитории есть разделение: + +- `core/` — общее ядро и бизнес-логика +- `adapter//` — реализация конкретной поверхности +- `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// + bot.py + converter.py + agent_registry.py + files.py + handlers/ + store.py +``` + +### 2.2. Принцип reuse + +По примеру Matrix surface, New surface должен переиспользовать общий `core` и общий `sdk`. + +Не дублируйте бизнес-логику, а реализуйте только адаптер: + +- `adapter//converter.py` — конвертация событий платформы ⇄ внутренние структуры +- `adapter//bot.py` — основной runtime, старт Платформа client, loop, отправка/прием +- `adapter//agent_registry.py` — загрузка `config/-agents.yaml` +- `adapter//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/-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 ` / `!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/-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//`. +2. Сделать `adapter//converter.py`: + - превратить native нативные сообщения в `IncomingMessage` + - превратить команды в `IncomingCommand` + - превратить yes/no-подтверждения в `IncomingCallback` +3. Сделать `adapter//agent_registry.py` на основе `adapter/matrix/agent_registry.py`. +4. Сделать `adapter//files.py` на основе `adapter/matrix/files.py`. +5. Сделать `adapter//bot.py`: + - инстанцировать runtime + - читать env vars `PLATFORM_*` + - загружать реестр агентов + - обрабатывать входящие события + - отправлять `Outgoing*` обратно в Платформа +6. Реализовать команды управления чатами и очередь вложений. +7. Прописать `config/-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/` diff --git a/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md b/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md new file mode 100644 index 0000000..a5227e8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md @@ -0,0 +1,855 @@ +# Matrix Multi-Agent Routing And Restart State Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart. + +**Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart. + +**Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio + +--- + +## File Structure + +- Create: `adapter/matrix/agent_registry.py` + Purpose: load and validate the YAML agent registry used by Matrix runtime. +- Create: `adapter/matrix/routed_platform.py` + Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances. +- Create: `adapter/matrix/handlers/agent.py` + Purpose: implement `!agent` listing and selection behavior. +- Create: `tests/adapter/matrix/test_agent_registry.py` + Purpose: cover YAML loading and registry validation. +- Create: `tests/adapter/matrix/test_routed_platform.py` + Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol. +- Create: `tests/adapter/matrix/test_agent_handler.py` + Purpose: cover `!agent` UX and persistence of `selected_agent_id`. +- Create: `tests/adapter/matrix/test_restart_persistence.py` + Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite. +- Create: `config/matrix-agents.example.yaml` + Purpose: document the expected agent registry format. +- Modify: `pyproject.toml` + Purpose: add YAML parsing dependency required by the runtime registry loader. +- Modify: `.env.example` + Purpose: document the config path env var for the Matrix agent registry. +- Modify: `README.md` + Purpose: document the new config file, `!agent`, and restart persistence expectations. +- Modify: `adapter/matrix/store.py` + Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics. +- Modify: `adapter/matrix/bot.py` + Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch. +- Modify: `adapter/matrix/handlers/__init__.py` + Purpose: register the new `!agent` command. +- Modify: `adapter/matrix/handlers/chat.py` + Purpose: require a selected agent for `!new` and bind new rooms to that agent. +- Modify: `adapter/matrix/handlers/context_commands.py` + Purpose: keep context commands compatible with local chat ids and routed platform delegation. +- Modify: `adapter/matrix/handlers/settings.py` + Purpose: expose `!agent` in help text. +- Modify: `tests/adapter/matrix/test_dispatcher.py` + Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics. +- Modify: `tests/adapter/matrix/test_context_commands.py` + Purpose: keep load/reset/context flows aligned with the routed platform facade. + +--- + +### Task 1: Add The Agent Registry And Configuration Wiring + +**Files:** +- Create: `adapter/matrix/agent_registry.py` +- Create: `tests/adapter/matrix/test_agent_registry.py` +- Create: `config/matrix-agents.example.yaml` +- Modify: `pyproject.toml` +- Modify: `.env.example` +- Modify: `README.md` + +- [ ] **Step 1: Write the failing registry tests** + +```python +# tests/adapter/matrix/test_agent_registry.py +from pathlib import Path + +import pytest + +from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry + + +def test_load_agent_registry_reads_yaml_entries(tmp_path: Path): + path = tmp_path / "agents.yaml" + path.write_text( + "agents:\n" + " - id: agent-1\n" + " label: Analyst\n" + " - id: agent-2\n" + " label: Research\n", + encoding="utf-8", + ) + + registry = load_agent_registry(path) + + assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"] + assert registry.get("agent-1").label == "Analyst" + + +def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path): + path = tmp_path / "agents.yaml" + path.write_text( + "agents:\n" + " - id: agent-1\n" + " label: Analyst\n" + " - id: agent-1\n" + " label: Duplicate\n", + encoding="utf-8", + ) + + with pytest.raises(AgentRegistryError, match="duplicate agent id"): + load_agent_registry(path) +``` + +- [ ] **Step 2: Run the registry tests to verify they fail** + +Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q` + +Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`. + +- [ ] **Step 3: Add the YAML dependency and implement the registry loader** + +```toml +# pyproject.toml +dependencies = [ + "aiogram>=3.4,<4", + "matrix-nio>=0.21", + "pydantic>=2.5", + "structlog>=24.1", + "python-dotenv>=1.0", + "httpx>=0.27", + "aiohttp>=3.9", + "PyYAML>=6.0", +] +``` + +```python +# adapter/matrix/agent_registry.py +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import yaml + + +class AgentRegistryError(ValueError): + pass + + +@dataclass(frozen=True) +class AgentDefinition: + agent_id: str + label: str + + +class AgentRegistry: + def __init__(self, agents: list[AgentDefinition]) -> None: + self.agents = agents + self._by_id = {agent.agent_id: agent for agent in agents} + + def get(self, agent_id: str) -> AgentDefinition: + try: + return self._by_id[agent_id] + except KeyError as exc: + raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc + + +def load_agent_registry(path: str | Path) -> AgentRegistry: + raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} + entries = raw.get("agents") + if not isinstance(entries, list) or not entries: + raise AgentRegistryError("agents registry must contain a non-empty agents list") + + agents: list[AgentDefinition] = [] + seen: set[str] = set() + for entry in entries: + agent_id = str(entry.get("id", "")).strip() + label = str(entry.get("label", "")).strip() + if not agent_id or not label: + raise AgentRegistryError("each agent entry requires id and label") + if agent_id in seen: + raise AgentRegistryError(f"duplicate agent id: {agent_id}") + seen.add(agent_id) + agents.append(AgentDefinition(agent_id=agent_id, label=label)) + return AgentRegistry(agents) +``` + +- [ ] **Step 4: Add the example config and runtime wiring docs** + +```yaml +# config/matrix-agents.example.yaml +agents: + - id: agent-1 + label: Analyst + - id: agent-2 + label: Research +``` + +```env +# .env.example +MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml +``` + +```markdown +# README.md +1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml` +2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml` +3. Use `!agent` in Matrix to select the active upstream agent +``` + +- [ ] **Step 5: Run the registry tests to verify they pass** + +Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py +git commit -m "feat: add matrix agent registry loader" +``` + +--- + +### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient` + +**Files:** +- Create: `adapter/matrix/routed_platform.py` +- Create: `tests/adapter/matrix/test_routed_platform.py` +- Modify: `adapter/matrix/bot.py` + +- [ ] **Step 1: Write the failing routed-platform tests** + +```python +# tests/adapter/matrix/test_routed_platform.py +import pytest + +from adapter.matrix.routed_platform import RoutedPlatformClient +from adapter.matrix.store import set_room_meta +from core.chat import ChatManager +from core.store import InMemoryStore +from sdk.interface import MessageResponse +from sdk.prototype_state import PrototypeStateStore + + +class FakeDelegate: + def __init__(self, agent_id: str) -> None: + self.agent_id = agent_id + self.calls = [] + + async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None): + self.calls.append((user_id, chat_id, text, attachments)) + return MessageResponse( + message_id=user_id, + response=f"{self.agent_id}:{text}", + tokens_used=0, + finished=True, + ) + + async def get_or_create_user(self, external_id: str, platform: str, display_name=None): + return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name) + + async def get_settings(self, user_id: str): + return await PrototypeStateStore().get_settings(user_id) + + async def update_settings(self, user_id: str, action): + return None + + +@pytest.mark.asyncio +async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id(): + store = InMemoryStore() + chat_mgr = ChatManager(None, store) + await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1") + await set_room_meta( + store, + "!room:example.org", + {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}, + ) + + delegates = {"agent-2": FakeDelegate("agent-2")} + platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates) + + response = await platform.send_message("u1", "C1", "hello") + + assert response.response == "agent-2:hello" + assert delegates["agent-2"].calls == [("u1", "41", "hello", None)] +``` + +- [ ] **Step 2: Run the routed-platform tests to verify they fail** + +Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q` + +Expected: FAIL with `ImportError` for `RoutedPlatformClient`. + +- [ ] **Step 3: Implement the routing facade and integrate runtime construction** + +```python +# adapter/matrix/routed_platform.py +from __future__ import annotations + +from sdk.interface import PlatformClient + + +class RoutedPlatformClient(PlatformClient): + def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None: + self._store = store + self._chat_mgr = chat_mgr + self._delegates = delegates + + async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]: + ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id) + if ctx is None: + raise ValueError(f"Chat {local_chat_id} not found for {user_id}") + room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}") + if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"): + raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target") + delegate = self._delegates[room_meta["agent_id"]] + return delegate, str(room_meta["platform_chat_id"]) + + async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None): + delegate, platform_chat_id = await self._resolve_target(user_id, chat_id) + return await delegate.send_message(user_id, platform_chat_id, text, attachments) + + async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None): + delegate, platform_chat_id = await self._resolve_target(user_id, chat_id) + async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments): + yield chunk + + async def get_or_create_user(self, external_id: str, platform: str, display_name=None): + first_delegate = next(iter(self._delegates.values())) + return await first_delegate.get_or_create_user(external_id, platform, display_name) + + async def get_settings(self, user_id: str): + first_delegate = next(iter(self._delegates.values())) + return await first_delegate.get_settings(user_id) + + async def update_settings(self, user_id: str, action): + first_delegate = next(iter(self._delegates.values())) + await first_delegate.update_settings(user_id, action) +``` + +```python +# adapter/matrix/bot.py +from adapter.matrix.agent_registry import load_agent_registry +from adapter.matrix.routed_platform import RoutedPlatformClient + + +def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient: + backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower() + if backend != "real": + return MockPlatformClient() + + registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"]) + delegates = { + agent.agent_id: RealPlatformClient( + agent_id=agent.agent_id, + agent_base_url=_agent_base_url_from_env(), + prototype_state=PrototypeStateStore(), + platform="matrix", + ) + for agent in registry.agents + } + return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates) + + +def build_runtime(...): + store = store or InMemoryStore() + chat_mgr = ChatManager(None, store) + platform = platform or _build_platform_from_env(store, chat_mgr) + auth_mgr = AuthManager(platform, store) + settings_mgr = SettingsManager(platform, store) + dispatcher = EventDispatcher( + platform=platform, + chat_mgr=chat_mgr, + auth_mgr=auth_mgr, + settings_mgr=settings_mgr, + ) +``` + +- [ ] **Step 4: Run the routed-platform tests to verify they pass** + +Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py +git commit -m "feat: add matrix routed platform facade" +``` + +--- + +### Task 3: Add `!agent` Selection And Durable User Agent State + +**Files:** +- Create: `adapter/matrix/handlers/agent.py` +- Create: `tests/adapter/matrix/test_agent_handler.py` +- Modify: `adapter/matrix/store.py` +- Modify: `adapter/matrix/handlers/__init__.py` +- Modify: `adapter/matrix/handlers/settings.py` + +- [ ] **Step 1: Write the failing agent-handler tests** + +```python +# tests/adapter/matrix/test_agent_handler.py +import pytest + +from adapter.matrix.handlers.agent import make_handle_agent +from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta +from core.protocol import IncomingCommand +from core.store import InMemoryStore + + +class FakeRegistry: + def __init__(self) -> None: + self.agents = [ + type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(), + type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(), + ] + + +@pytest.mark.asyncio +async def test_agent_command_lists_available_agents(): + handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry()) + result = await handler( + IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]), + None, + None, + None, + None, + ) + assert "1. Analyst" in result[0].text + assert "2. Research" in result[0].text + + +@pytest.mark.asyncio +async def test_agent_command_persists_selected_agent_and_binds_unbound_room(): + store = InMemoryStore() + await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"}) + handler = make_handle_agent(store=store, registry=FakeRegistry()) + chat_mgr = type( + "ChatMgr", + (), + {"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())}, + )() + + await handler( + IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]), + None, + None, + chat_mgr, + None, + ) + + assert await get_selected_agent_id(store, "u1") == "agent-2" + room_meta = await get_room_meta(store, "!room:example.org") + assert room_meta["agent_id"] == "agent-2" +``` + +- [ ] **Step 2: Run the agent-handler tests to verify they fail** + +Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q` + +Expected: FAIL with missing handler or store helpers. + +- [ ] **Step 3: Add durable store helpers and implement `!agent`** + +```python +# adapter/matrix/store.py +async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None: + meta = await get_user_meta(store, matrix_user_id) or {} + value = meta.get("selected_agent_id") + return str(value) if value else None + + +async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None: + meta = await get_user_meta(store, matrix_user_id) or {} + meta["selected_agent_id"] = agent_id + await set_user_meta(store, matrix_user_id, meta) + + +async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None: + meta = dict(await get_room_meta(store, room_id) or {}) + meta["agent_id"] = agent_id + await set_room_meta(store, room_id, meta) +``` + +```python +# adapter/matrix/handlers/agent.py +from __future__ import annotations + +from adapter.matrix.store import ( + get_room_meta, + get_selected_agent_id, + next_platform_chat_id, + set_platform_chat_id, + set_room_agent_id, + set_selected_agent_id, +) +from core.protocol import IncomingCommand, OutgoingMessage + + +def make_handle_agent(store, registry): + async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr): + if not event.args: + current = await get_selected_agent_id(store, event.user_id) + lines = ["Доступные агенты:"] + for index, agent in enumerate(registry.agents, start=1): + marker = " (текущий)" if agent.agent_id == current else "" + lines.append(f"{index}. {agent.label}{marker}") + lines.append("") + lines.append("Выбери агента: !agent <номер>") + return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] + + agent = registry.agents[int(event.args[0]) - 1] + await set_selected_agent_id(store, event.user_id, agent.agent_id) + ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None + if ctx is not None: + room_meta = await get_room_meta(store, ctx.surface_ref) + if room_meta is not None and not room_meta.get("agent_id"): + await set_room_agent_id(store, ctx.surface_ref, agent.agent_id) + if not room_meta.get("platform_chat_id"): + await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store)) + return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")] + return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")] + + return handle_agent +``` + +- [ ] **Step 4: Register the command and update help text** + +```python +# adapter/matrix/handlers/__init__.py +from adapter.matrix.handlers.agent import make_handle_agent + +dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry)) +``` + +```python +# adapter/matrix/handlers/settings.py +HELP_TEXT = "\n".join( + [ + "Команды", + "", + "!agent выбрать активного агента", + "!new [название] создать новый чат", + "!chats список активных чатов", + "!rename <название> переименовать текущий чат", + "!archive архивировать текущий чат", + "!context показать текущее состояние контекста", + "!save [имя] сохранить текущий контекст", + "!load показать сохранённые контексты", + ] +) +``` + +- [ ] **Step 5: Run the agent-handler tests to verify they pass** + +Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py +git commit -m "feat: add matrix agent selection command" +``` + +--- + +### Task 4: Bind Rooms Correctly And Block Stale Chats + +**Files:** +- Modify: `adapter/matrix/bot.py` +- Modify: `adapter/matrix/handlers/chat.py` +- Modify: `adapter/matrix/handlers/context_commands.py` +- Modify: `tests/adapter/matrix/test_dispatcher.py` +- Modify: `tests/adapter/matrix/test_context_commands.py` + +- [ ] **Step 1: Write the failing dispatcher and context-command tests** + +```python +# tests/adapter/matrix/test_dispatcher.py +@pytest.mark.asyncio +async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent(): + runtime = build_runtime(platform=MockPlatformClient()) + client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) + bot = MatrixBot(client, runtime) + await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"}) + + await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello")) + + client.room_send.assert_awaited_once() + assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower() + + +@pytest.mark.asyncio +async def test_new_chat_requires_selected_agent_and_binds_room_meta(): + client = SimpleNamespace( + room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")), + room_put_state=AsyncMock(), + ) + runtime = build_runtime(platform=MockPlatformClient(), client=client) + await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"}) + + result = await runtime.dispatcher.dispatch( + IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"]) + ) + + room_meta = await get_room_meta(runtime.store, "!r2:example") + assert room_meta["agent_id"] == "agent-2" + assert "Создан чат" in result[0].text +``` + +```python +# tests/adapter/matrix/test_context_commands.py +@pytest.mark.asyncio +async def test_load_selection_calls_platform_with_local_chat_id(): + platform = MatrixCommandPlatform() + runtime = build_runtime(platform=platform) + await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1") + await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}) + + client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) + bot = MatrixBot(client, runtime) + await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]}) + + await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1")) + + platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a")) +``` + +- [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail** + +Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q` + +Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`. + +- [ ] **Step 3: Implement room binding and stale-room checks in runtime** + +```python +# adapter/matrix/bot.py +from adapter.matrix.store import ( + get_selected_agent_id, + get_room_meta, + next_platform_chat_id, + set_platform_chat_id, + set_room_agent_id, +) + + +async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]: + room_meta = await get_room_meta(self.runtime.store, room_id) + selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id) + if not selected_agent_id: + return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.") + if room_meta is None: + return room_meta, None + if not room_meta.get("agent_id"): + await set_room_agent_id(self.runtime.store, room_id, selected_agent_id) + if not room_meta.get("platform_chat_id"): + await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store)) + room_meta = await get_room_meta(self.runtime.store, room_id) + return room_meta, None + if room_meta["agent_id"] != selected_agent_id: + return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.") + return room_meta, None +``` + +```python +# adapter/matrix/bot.py +local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender) +dispatch_chat_id = local_chat_id + +if not body.startswith("!"): + room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender) + if blocking is not None: + await self._send_all(room.room_id, [blocking]) + return + +incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id) +``` + +- [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`** + +```python +# adapter/matrix/handlers/chat.py +from adapter.matrix.store import get_selected_agent_id + +selected_agent_id = await get_selected_agent_id(store, event.user_id) +if not selected_agent_id: + return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")] + +await set_room_meta( + store, + room_id, + { + "room_type": "chat", + "chat_id": chat_id, + "display_name": room_name, + "matrix_user_id": event.user_id, + "space_id": space_id, + "platform_chat_id": platform_chat_id, + "agent_id": selected_agent_id, + }, +) +``` + +```python +# adapter/matrix/bot.py +room_meta = await get_room_meta(self.runtime.store, room_id) +local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id + +await self.runtime.platform.send_message( + user_id, + local_chat_id, + LOAD_PROMPT.format(name=name), +) +``` + +- [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass** + +Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py +git commit -m "feat: bind matrix rooms to selected agents" +``` + +--- + +### Task 5: Prove Durable Restart State And Sequence Persistence + +**Files:** +- Create: `tests/adapter/matrix/test_restart_persistence.py` +- Modify: `adapter/matrix/store.py` +- Modify: `README.md` + +- [ ] **Step 1: Write the failing restart-persistence tests** + +```python +# tests/adapter/matrix/test_restart_persistence.py +import pytest + +from adapter.matrix.store import ( + get_selected_agent_id, + next_platform_chat_id, + set_room_meta, + set_selected_agent_id, +) +from core.store import SQLiteStore + + +@pytest.mark.asyncio +async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path): + db_path = tmp_path / "matrix.db" + store = SQLiteStore(str(db_path)) + await set_selected_agent_id(store, "u1", "agent-2") + await set_room_meta( + store, + "!room:example.org", + {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}, + ) + + reopened = SQLiteStore(str(db_path)) + assert await get_selected_agent_id(reopened, "u1") == "agent-2" + assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2" + assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41" + + +@pytest.mark.asyncio +async def test_platform_chat_sequence_survives_store_recreation(tmp_path): + db_path = tmp_path / "matrix.db" + store = SQLiteStore(str(db_path)) + + assert await next_platform_chat_id(store) == "1" + assert await next_platform_chat_id(store) == "2" + + reopened = SQLiteStore(str(db_path)) + assert await next_platform_chat_id(reopened) == "3" +``` + +- [ ] **Step 2: Run the restart-persistence tests to verify they fail** + +Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q` + +Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered. + +- [ ] **Step 3: Make sequence persistence explicit and document the restart boundary** + +```python +# adapter/matrix/store.py +PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq" + + +async def next_platform_chat_id(store: StateStore) -> str: + async with _PLATFORM_CHAT_SEQ_LOCK: + data = await store.get(PLATFORM_CHAT_SEQ_KEY) + index = int((data or {}).get("next_platform_chat_index", 1)) + await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1}) + return str(index) +``` + +```markdown +# README.md +- Matrix durable state lives in `lambda_matrix.db` and `matrix_store` +- normal restart is supported only when those paths survive container recreation +- staged attachments and pending confirmations are intentionally not restored +``` + +- [ ] **Step 4: Run the restart-persistence tests to verify they pass** + +Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q` + +Expected: PASS + +- [ ] **Step 5: Run the combined verification sweep** + +Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py +git commit -m "test: cover matrix restart state persistence" +``` + +--- + +## Self-Review + +### Spec coverage + +- Multi-agent agent registry: Task 1 +- Shared `PlatformClient` preserved via routing facade: Task 2 +- `!agent` UX and durable `selected_agent_id`: Task 3 +- Unbound room activation, `!new`, stale room rejection: Task 4 +- Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5 + +### Placeholder scan + +- No `TODO`, `TBD`, or “implement later” markers remain. +- Each task includes exact file paths, tests, commands, and minimal code snippets. + +### Type consistency + +- `selected_agent_id` lives in user metadata throughout the plan. +- `agent_id` and `platform_chat_id` live in room metadata throughout the plan. +- `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact. diff --git a/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md b/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md new file mode 100644 index 0000000..02cc89f --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md @@ -0,0 +1,336 @@ +# Matrix Multi-Agent Routing Design + +## Goal + +Move the Matrix surface from a single hardcoded upstream agent to a user-selectable multi-agent model, while preserving the existing room-based UX and the current `PlatformClient` boundary. + +The result should be: + +- one Matrix bot can work with multiple upstream agents +- users can choose an agent from the full configured list +- each chat is bound to exactly one agent +- switching the selected agent does not silently retarget an existing chat + +## Core Decision + +The selected routing model is: + +`user.selected_agent_id + room.agent_id + room.platform_chat_id` + +This means: + +- the user has one current selected agent +- each Matrix working room stores the agent it is bound to +- each Matrix working room stores its own `platform_chat_id` +- a room never changes agent implicitly +- the shared `PlatformClient` protocol remains unchanged +- Matrix multi-agent routing is implemented by a single routing facade that delegates to per-agent real clients + +## Why This Decision + +The current Matrix adapter already separates: + +- user-facing room organization +- local chat labels such as `C1`, `C2`, `C3` +- platform-facing conversation identity via `platform_chat_id` + +Adding multi-agent support should preserve that shape instead of replacing it. + +If routing depended only on the current user selection, then an old room could start talking to a different agent after a switch. That would make room history and backend context hard to reason about. Binding an agent to the room keeps the conversation model explicit. + +## Scope + +This design covers: + +- agent selection by the user inside the Matrix surface +- durable storage of the selected agent +- durable storage of the room-bound agent +- routing normal messages and context commands to the correct upstream agent +- behavior when a room becomes stale after an agent switch + +This design does not cover: + +- per-agent workspace isolation +- platform-side agent lifecycle or memory persistence +- per-user allowlists for available agents +- Telegram or other surfaces + +## Configuration Model + +### Agent registry + +Available agents are defined in a local config file loaded once at bot startup. + +Example: + +```yaml +agents: + - id: agent-1 + label: Analyst + - id: agent-2 + label: Research + - id: agent-3 + label: Ops +``` + +Rules: + +- every entry must have a stable `id` +- every entry must have a user-visible `label` +- all configured agents are selectable by all users +- config changes apply only after bot restart + +### Startup validation + +If the agent config is missing, empty, or invalid, the Matrix bot must fail fast on startup with a clear operator error. + +## Durable State Model + +### User-level state + +User metadata keeps the current selected agent. + +Example `matrix_user:*` shape: + +```json +{ + "space_id": "!space:example.org", + "next_chat_index": 4, + "selected_agent_id": "agent-2" +} +``` + +Meaning: + +- `selected_agent_id` controls future chat creation and activation of an unbound room +- `selected_agent_id` does not rewrite already bound rooms + +### Room-level state + +Room metadata stores the agent bound to that chat. + +Example `matrix_room:*` shape: + +```json +{ + "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-2" +} +``` + +Rules: + +- one room binds to exactly one `agent_id` +- one room binds to exactly one current `platform_chat_id` +- once a room becomes stale after an agent switch, it never becomes active again + +## Runtime Semantics + +### `!start` + +`!start` remains lightweight: + +- if no agent is selected, the bot explains that an agent must be selected before normal messaging +- if an agent is already selected, the bot reports the current selection and reminds the user that `!new` creates a new room under that agent + +### `!agent` + +Introduce an agent-selection command. + +Behavior: + +- `!agent` shows the available agent list +- agent selection stores `selected_agent_id` in user metadata +- after a successful switch, the bot tells the user that existing chats bound to another agent are stale and that `!new` is required for continued work + +The exact UI can be text-first for MVP. A richer UI can be added later without changing the state model. + +### Normal message without selected agent + +If the user has not selected an agent yet: + +- do not call the platform +- return the available agent list +- ask the user to choose one first + +This is an intentional one-time routing handshake, not an accidental fallback. +In a multi-agent deployment, the surface must not silently guess which agent an unbound user should talk to. + +### Selecting an agent inside an unbound chat + +If the current room has never been bound to any agent: + +- store the new `selected_agent_id` for the user +- bind the current room to that same `agent_id` +- allow the room to become the active working chat immediately + +This avoids forcing `!new` for the user's first usable chat. + +### `!new` + +`!new` creates a new working room under the current selected agent. + +Behavior: + +1. require `selected_agent_id` +2. create the new Matrix room +3. allocate a new `platform_chat_id` +4. store `agent_id = selected_agent_id` in the new room metadata + +### Normal message in an unbound room with selected agent + +If a room exists but has no `agent_id` yet and the user already has `selected_agent_id`: + +- bind the room to `selected_agent_id` +- ensure it has `platform_chat_id` +- continue normal message dispatch + +### Normal message in a bound room + +If the room already has `agent_id` and it matches the current selected agent: + +- route the message to that `agent_id` +- use the room's `platform_chat_id` + +### Stale room after agent switch + +If the room's bound `agent_id` differs from the user's current `selected_agent_id`: + +- do not call the platform +- treat the room as stale +- return a short message telling the user that this chat belongs to the old agent and that they must use `!new` + +### Returning to a previously selected agent + +If the user later selects an old agent again: + +- previously stale rooms do not become valid again +- the user must still create a fresh room via `!new` + +## Routing and Component Changes + +### Agent registry loader + +Add a small loader responsible for: + +- reading `agents.yaml` +- validating ids and labels +- exposing a read-only registry to runtime code + +The runtime should not parse YAML ad hoc during message handling. + +### Matrix runtime pre-check + +Before dispatching a normal message, the Matrix runtime must resolve: + +- whether the user has `selected_agent_id` +- whether the current room already has `agent_id` +- whether the room can be bound now +- whether the room is stale + +This pre-check happens before handing the message to the existing dispatcher path. + +### Routed platform client + +The selected implementation keeps the shared `PlatformClient` protocol unchanged. + +The Matrix runtime owns one routing-aware facade, for example `RoutedPlatformClient`, that implements `PlatformClient` and delegates to agent-specific real clients. + +Responsibilities: + +- resolve the current room binding from local Matrix metadata +- translate a local Matrix logical chat id into the room's `platform_chat_id` +- choose the correct per-agent delegate for the room's bound `agent_id` +- keep `get_or_create_user`, `get_settings`, and `update_settings` behavior stable for the rest of the runtime + +This keeps the multi-agent logic inside the Matrix integration boundary instead of pushing agent selection into the shared protocol. + +### Real platform bridge delegates + +The current real backend path hardcodes a single runtime-level `agent_id`. +That must be replaced with per-agent delegates hidden behind the routing facade. + +The selected design is: + +- `RealPlatformClient` remains the low-level direct-agent delegate for one configured `agent_id` +- the routing facade holds or creates one `RealPlatformClient` delegate per configured agent +- `send_message(...)` and `stream_message(...)` on the facade resolve the room target and forward the call to the matching delegate +- the delegate creates a fresh upstream `AgentApi` for its configured `agent_id` +- no long-lived `AgentApi` instances are cached by user + +This preserves the current fresh-connection-per-request behavior while avoiding a protocol break for Telegram or other surfaces. + +## Error Handling + +### Missing or invalid selected agent + +If `selected_agent_id` is absent: + +- ask the user to select an agent + +If `selected_agent_id` points to an agent that no longer exists in config: + +- treat the selection as invalid +- ask the user to select again + +### Missing room binding + +If the room has no `agent_id`: + +- bind it only when the user has a valid current selection +- otherwise return the selection prompt + +### Stale room + +If the room is stale: + +- do not attempt fallback routing +- do not silently rewrite room metadata +- instruct the user to run `!new` + +### Invalid config + +If the bot cannot load a valid agent registry: + +- fail at startup +- do not start in degraded single-agent mode + +## Testing Expectations + +Tests for this design should prove: + +- config parsing and startup validation +- selecting an agent persists `selected_agent_id` +- selecting an agent inside an unbound room activates that room +- `!new` binds the new room to the selected agent +- messages in a bound room use that room's `agent_id` +- stale rooms reject normal messaging with a clear `!new` instruction +- returning to the same agent later does not revive stale rooms + +## Migration Notes + +Existing rooms may have `platform_chat_id` but no `agent_id`. + +For this MVP, treat those rooms as legacy-unbound rooms: + +- if the user has a valid selected agent, the room may be bound on first use +- if no agent is selected, the room prompts for selection first + +No automatic migration across agents is introduced. + +### Existing users without `selected_agent_id` + +Existing users upgraded from the single-agent model may have working rooms but no stored `selected_agent_id`. + +For this MVP, that is handled explicitly: + +- normal messaging is paused until the user selects an agent +- the first valid selection can bind an unbound room immediately +- the surface does not auto-assign a default agent in a multi-agent config + +This is intentional. Once more than one agent exists, silent migration would be ambiguous and could route a user to the wrong backend target. diff --git a/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md b/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md new file mode 100644 index 0000000..1f1cc7b --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md @@ -0,0 +1,258 @@ +# Matrix Surface Restart State Persistence Design + +## Goal + +Make the Matrix surface survive a normal restart or container recreate without losing the minimal state required to keep working as a bot. + +The result should be: + +- after restart, the bot can still answer messages and execute commands +- the bot remembers the selected agent for each user +- the bot remembers which agent and `platform_chat_id` each room is bound to +- temporary UX flows may be lost without being treated as a bug + +## Core Decision + +The selected persistence model is: + +`durable surface state only` + +This means: + +- persist only the state needed for routing and normal command handling +- do not persist temporary UI and wizard state +- require persistent local storage for the surface +- do not attempt recovery if those volumes are lost + +## Why This Decision + +The Matrix surface already has two different classes of state: + +- stable local state that defines how rooms and users are routed +- temporary UX state that exists only to complete short-lived interactions + +Trying to make all temporary UX state survive restart would add complexity and edge cases without improving the core requirement: the bot should still function normally after restart. + +The chosen design keeps persistence aligned with what the surface actually owns: + +- Matrix-side metadata and routing state are durable +- agent conversation memory is the platform's responsibility +- lost local volumes are treated as environment reset, not as an auto-recovery scenario + +## Scope + +This design covers: + +- which Matrix surface data must persist across restart +- where that data lives +- how restart behavior interacts with multi-agent routing +- what state is intentionally non-durable + +This design does not cover: + +- platform-side persistence of agent memory +- workspace isolation between multiple agents +- automatic reconstruction after total local volume loss +- persistence of temporary UX flows + +## Persistence Boundary + +### Durable state + +The Matrix surface must persist: + +- `matrix_user:*` +- `matrix_room:*` +- `chat:*` +- `PLATFORM_CHAT_SEQ_KEY` +- `selected_agent_id` +- room-bound `agent_id` +- room-bound `platform_chat_id` + +This is the minimal state required so that, after restart, the surface can: + +- identify the user +- identify the room +- determine which agent should receive a message +- determine which `platform_chat_id` should be used +- continue allocating new `platform_chat_id` values without reusing an already issued sequence number + +### Non-durable state + +The Matrix surface does not need to persist: + +- staged attachments +- pending `!load` selection +- pending `!yes/!no` confirmation +- any temporary service UI step +- live `AgentApi` instances or connection objects + +After restart, those flows may be lost. The bot only needs to remain operational. + +## Storage Model + +### Surface durable storage + +The Matrix surface must use persistent storage for: + +- `lambda_matrix.db` +- `matrix_store` + +`lambda_matrix.db` stores the local key-value state used by the surface. +`matrix_store` stores Matrix client state needed by `nio`. + +These paths must be backed by persistent container storage in normal deployments. + +### Shared `/workspace` + +The current local runtime also uses `/workspace`, but workspace behavior is outside the scope of this design. + +For this document, the only requirement is: + +- do not make restart persistence depend on solving per-agent workspace isolation first + +## Restart Assumptions + +This design assumes: + +- normal restart or redeploy with persistent local volumes still present + +This design does not assume: + +- automatic recovery after deleting or losing those volumes + +If the relevant volumes are lost, the environment is treated as reset. + +## Data Model Requirements + +### User metadata + +User metadata remains the durable location for user-level routing state. + +Example: + +```json +{ + "space_id": "!space:example.org", + "next_chat_index": 4, + "selected_agent_id": "agent-2" +} +``` + +### Room metadata + +Room metadata remains the durable location for room-level routing state. + +Example: + +```json +{ + "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-2" +} +``` + +### Platform chat sequence + +The global `PLATFORM_CHAT_SEQ_KEY` remains part of durable surface state. + +Its purpose is: + +- allocate monotonically increasing `platform_chat_id` values +- avoid reusing a previously issued platform chat identifier during normal restart or redeploy + +This sequence must be stored in the same durable surface store as the room and user metadata. + +## Runtime Semantics After Restart + +After restart, the Matrix surface must: + +1. load the durable Matrix store +2. load the durable surface key-value state +3. load the agent registry config +4. resume normal room routing using persisted `selected_agent_id`, `agent_id`, and `platform_chat_id` + +Expected behavior: + +- a user with a valid previously selected agent does not need to reselect it +- a room previously bound to an agent remains bound to that agent +- normal messages and commands continue to work + +### Lost temporary UX state + +If the bot restarts during a transient UX flow: + +- staged attachments may disappear +- pending `!load` selections may disappear +- pending confirmations may disappear + +This is acceptable and should not block normal operation after restart. + +## Interaction With Multi-Agent Routing + +The multi-agent design introduces new durable state that must survive restart: + +- `selected_agent_id` on the user +- `agent_id` on the room +- `PLATFORM_CHAT_SEQ_KEY` in the surface store + +Restart persistence and multi-agent routing therefore belong together. + +Without durable storage for those fields, a restart would make room routing ambiguous. + +## Failure Handling + +### Missing durable surface store + +If the durable store paths are missing because the environment was reset: + +- do not attempt to reconstruct a full working state from scratch in this design +- treat startup as a clean environment +- allow normal onboarding flows to begin again + +### Invalid durable references + +If persisted `selected_agent_id` or room `agent_id` references an agent no longer present in config: + +- do not crash +- treat the selection or room binding as invalid +- ask the user to select a valid agent again + +### Platform conversation memory + +If the upstream platform loses agent memory across restart: + +- that is outside the surface persistence boundary +- the surface must still route correctly +- platform memory persistence remains a platform responsibility + +## Testing Expectations + +Tests for this design should prove: + +- `selected_agent_id` survives restart through durable local storage +- room `agent_id` and `platform_chat_id` survive restart through durable local storage +- the bot can route messages correctly after restart without user reconfiguration +- missing temporary UX state does not break normal messaging and command handling +- invalid persisted agent references degrade into reselection prompts rather than crashes + +## Operational Notes + +For the Matrix surface to survive restart in the intended way, deployment must persist: + +- `lambda_matrix.db` +- `matrix_store` + +This is a deployment requirement, not an optional optimization. + +The design intentionally stops there. It does not require: + +- hot reload of agent config +- recovery after total local state loss +- persistence of temporary UX flows +- a solved multi-agent workspace story diff --git a/docs/surface-protocol.md b/docs/surface-protocol.md index ca66000..f2bd7b1 100644 --- a/docs/surface-protocol.md +++ b/docs/surface-protocol.md @@ -38,9 +38,10 @@ surfaces-bot/ converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API bot.py — точка входа, клиент - platform/ - interface.py — Protocol: PlatformClient - mock.py — MockPlatformClient + sdk/ + interface.py — Protocol: PlatformClient (контракт к SDK) + real.py — RealPlatformClient (через AgentApi) + mock.py — MockPlatformClient (для локальных тестов) ``` --- @@ -140,7 +141,7 @@ class UIButton: ``` Telegram рендерит это как InlineKeyboard. -Matrix рендерит как текст с описанием реакций или HTML-кнопки. +Matrix рендерит как текст (в MVP). ### OutgoingNotification Асинхронное уведомление — агент закончил долгую задачу. @@ -209,7 +210,7 @@ class ConfirmationRequest: ``` Telegram показывает как Inline-кнопки. -Matrix показывает как реакции 👍 / ❌. +Matrix показывает как запрос для `!yes` / `!no`. Ядро не знает как именно — только получает `IncomingCallback` с `action: "confirm"`. --- @@ -304,9 +305,9 @@ class PlatformClient(Protocol): async def update_settings(self, user_id: str, action: Any) -> None: ... ``` -Бот **не управляет lifecycle контейнеров** — это делает Master (платформа). -Бот передаёт `user_id` + `chat_id` + текст; Master сам решает нужно ли поднять контейнер, смонтировать `C1/`/`C2/`, запустить агента. +Бот **не управляет lifecycle контейнеров** агентов. Запуск/перезапуск агентов — ответственность платформы. +Бот передаёт `user_id` + `chat_id` + текст. -`MockPlatformClient` реализует этот протокол сейчас. -Реальный SDK — тоже реализует этот протокол, заменяя один файл. -Адаптеры поверхностей и ядро не меняются вообще. +`MockPlatformClient` реализует этот протокол для локальных тестов. +Реальный SDK используется через `RealPlatformClient` (`sdk/real.py`), который подключается к `AgentApi` по WebSocket. +Адаптеры поверхностей и ядро не меняются вообще, привязка идёт через `config/matrix-agents.yaml`. diff --git a/docs/telegram-prototype.md b/docs/telegram-prototype.md index c58a1e5..17f93cf 100644 --- a/docs/telegram-prototype.md +++ b/docs/telegram-prototype.md @@ -1,5 +1,8 @@ # Telegram — описание прототипа +> **ВНИМАНИЕ: Telegram-адаптер не является частью текущего MVP-деплоя.** +> Код Telegram-поверхности находится в отдельной ветке `feat/telegram-adapter`. Данный документ описывает возможности этого адаптера, но многие концепции (например, AuthFlow и MockPlatformClient) устарели по отношению к актуальной архитектуре `main`. + ## Концепция Один бот, несколько чатов через Topics в Forum-группе. diff --git a/docs/user-flow.md b/docs/user-flow.md deleted file mode 100644 index efe22f1..0000000 --- a/docs/user-flow.md +++ /dev/null @@ -1,65 +0,0 @@ -# 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 поднимает контейнер,
монтирует нужный чат, запускает агента - 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 ответа от платформы? diff --git a/docs/workflow-backup-2026-04-01.md b/docs/workflow-backup-2026-04-01.md deleted file mode 100644 index 9b77d68..0000000 --- a/docs/workflow-backup-2026-04-01.md +++ /dev/null @@ -1,174 +0,0 @@ -# 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, не после каждого коммита -- Длинный контекст → дай агенту конкретный файл, не весь проект -- Если агент "завис" в рассуждениях → прерви, переформулируй задачу точнее diff --git a/pyproject.toml b/pyproject.toml index ccc6309..73dfbd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "python-dotenv>=1.0", "httpx>=0.27", "aiohttp>=3.9", + "pyyaml>=6.0", ] [project.optional-dependencies] diff --git a/sdk/real.py b/sdk/real.py index 0b7ef19..47f639a 100644 --- a/sdk/real.py +++ b/sdk/real.py @@ -1,8 +1,13 @@ from __future__ import annotations import asyncio +import os +import re from collections.abc import AsyncIterator from pathlib import Path +from urllib.parse import urljoin, urlsplit, urlunsplit + +import structlog from sdk.interface import ( Attachment, @@ -16,6 +21,13 @@ from sdk.interface import ( from sdk.prototype_state import PrototypeStateStore from sdk.upstream_agent_api import AgentApi, MsgEventSendFile, MsgEventTextChunk +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): def __init__( @@ -27,11 +39,20 @@ class RealPlatformClient(PlatformClient): agent_api_cls=AgentApi, ) -> None: self._agent_id = agent_id - self._agent_base_url = agent_base_url + self._raw_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._prototype_state = prototype_state self._platform = platform 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 def agent_id(self) -> str: @@ -157,16 +178,38 @@ class RealPlatformClient(PlatformClient): ) -> AsyncIterator[object]: attachment_paths = self._attachment_paths(attachments) event_stream = chat_api.send_message(text, attachments=attachment_paths or None) + chunk_index = 0 async for event in event_stream: + if isinstance(event, MsgEventTextChunk): + logger.debug("agent_chunk", index=chunk_index, text=repr(event.text[:40])) + chunk_index += 1 + else: + logger.debug("agent_event", index=chunk_index, type=type(event).__name__) yield event 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( agent_id=self._agent_id, base_url=self._agent_base_url, 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 async def _close_chat_api(chat_api) -> None: close = getattr(chat_api, "close", None) @@ -181,6 +224,27 @@ class RealPlatformClient(PlatformClient): code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR" return PlatformError(str(exc), code=code) + @staticmethod + def _normalize_workspace_path(location: str) -> str | None: + if not location: + return None + + path = Path(location) + if not path.is_absolute(): + normalized = path.as_posix() + return normalized or None + + parts = path.parts + if len(parts) >= 2 and parts[1] == "workspace": + relative = Path(*parts[2:]).as_posix() + return relative or None + if len(parts) >= 3 and parts[1] == "agents": + relative = Path(*parts[3:]).as_posix() + return relative or None + + relative = path.as_posix().lstrip("/") + return relative or None + @staticmethod def _attachment_paths(attachments: list[Attachment] | None) -> list[str]: if not attachments: @@ -188,18 +252,18 @@ class RealPlatformClient(PlatformClient): paths = [] for attachment in attachments: if attachment.workspace_path: - paths.append(attachment.workspace_path) + normalized = RealPlatformClient._normalize_workspace_path( + attachment.workspace_path + ) + if normalized: + paths.append(normalized) return paths @staticmethod def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment: location = str(event.path) filename = Path(location).name or None - workspace_path = location - if workspace_path.startswith("/workspace/"): - workspace_path = workspace_path[len("/workspace/") :] - elif workspace_path == "/workspace": - workspace_path = "" + workspace_path = RealPlatformClient._normalize_workspace_path(location) return Attachment( url=location, mime_type="application/octet-stream", diff --git a/telegram-cloud-photo-size-2-5440546240941724952-y.jpg b/telegram-cloud-photo-size-2-5440546240941724952-y.jpg new file mode 100644 index 0000000..af4606d Binary files /dev/null and b/telegram-cloud-photo-size-2-5440546240941724952-y.jpg differ diff --git a/tests/adapter/matrix/test_agent_registry.py b/tests/adapter/matrix/test_agent_registry.py new file mode 100644 index 0000000..a918f84 --- /dev/null +++ b/tests/adapter/matrix/test_agent_registry.py @@ -0,0 +1,199 @@ +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_agent_registry_agents_sequence_is_immutable(tmp_path: Path): + path = tmp_path / "agents.yaml" + path.write_text( + "agents:\n" + " - id: agent-1\n" + " label: Analyst\n", + encoding="utf-8", + ) + + registry = load_agent_registry(path) + + with pytest.raises(AttributeError): + registry.agents.append( # type: ignore[attr-defined] + registry.agents[0] + ) + + +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) + + +def test_load_agent_registry_rejects_non_mapping_entries(tmp_path: Path): + path = tmp_path / "agents.yaml" + path.write_text( + "agents:\n" + " - agent-1\n", + encoding="utf-8", + ) + + with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"): + load_agent_registry(path) + + +@pytest.mark.parametrize( + "content", + [ + "", + "agents: []\n", + "agents: agent-1\n", + "foo: bar\n", + ], +) +def test_load_agent_registry_rejects_missing_non_list_and_empty_agents( + tmp_path: Path, content: str +): + path = tmp_path / "agents.yaml" + path.write_text(content, encoding="utf-8") + + with pytest.raises(AgentRegistryError, match="agents registry must contain a non-empty agents list"): + load_agent_registry(path) + + +@pytest.mark.parametrize( + "content, expected", + [ + ( + "agents:\n" + " - label: Analyst\n", + "each agent entry requires id and label", + ), + ( + "agents:\n" + " - id: agent-1\n", + "each agent entry requires id and label", + ), + ], +) +def test_load_agent_registry_rejects_missing_id_or_label(tmp_path: Path, content: str, expected: str): + path = tmp_path / "agents.yaml" + path.write_text(content, encoding="utf-8") + + with pytest.raises(AgentRegistryError, match=expected): + load_agent_registry(path) + + +def test_load_agent_registry_rejects_invalid_top_level_yaml(tmp_path: Path): + path = tmp_path / "agents.yaml" + path.write_text( + "- id: agent-1\n" + " label: Analyst\n", + encoding="utf-8", + ) + + with pytest.raises(AgentRegistryError, match="agent registry must be a mapping with an agents list"): + load_agent_registry(path) + + +def test_load_agent_registry_rejects_malformed_yaml(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" + " - [\n", + encoding="utf-8", + ) + + with pytest.raises(AgentRegistryError, match="invalid agent registry YAML"): + load_agent_registry(path) + + +@pytest.mark.parametrize( + "content", + [ + "agents:\n" + " - id: null\n" + " label: Analyst\n", + "agents:\n" + " - id: agent-1\n" + " label: null\n", + ], +) +def test_load_agent_registry_rejects_null_id_or_label(tmp_path: Path, content: str): + path = tmp_path / "agents.yaml" + path.write_text(content, encoding="utf-8") + + with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"): + load_agent_registry(path) + + +@pytest.mark.parametrize( + "content", + [ + "agents:\n" + " - id: ' '\n" + " label: Analyst\n", + "agents:\n" + " - id: agent-1\n" + " label: ' '\n", + ], +) +def test_load_agent_registry_rejects_blank_id_or_label(tmp_path: Path, content: str): + path = tmp_path / "agents.yaml" + path.write_text(content, encoding="utf-8") + + with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"): + load_agent_registry(path) + + +@pytest.mark.parametrize( + "content", + [ + "agents:\n" + " - id: 123\n" + " label: Analyst\n", + "agents:\n" + " - id: agent-1\n" + " label: 456\n", + "agents:\n" + " - id: true\n" + " label: Analyst\n", + "agents:\n" + " - id: agent-1\n" + " label: false\n", + ], +) +def test_load_agent_registry_rejects_non_string_id_or_label(tmp_path: Path, content: str): + path = tmp_path / "agents.yaml" + path.write_text(content, encoding="utf-8") + + with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"): + load_agent_registry(path) diff --git a/tests/adapter/matrix/test_context_commands.py b/tests/adapter/matrix/test_context_commands.py index a289772..9264a06 100644 --- a/tests/adapter/matrix/test_context_commands.py +++ b/tests/adapter/matrix/test_context_commands.py @@ -1,11 +1,12 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest from adapter.matrix.bot import MatrixBot, build_runtime +from adapter.matrix.handlers import register_matrix_handlers from adapter.matrix.handlers.context_commands import ( make_handle_context, make_handle_load, @@ -29,6 +30,7 @@ class MatrixCommandPlatform(MockPlatformClient): super().__init__() self._prototype_state = PrototypeStateStore() self._agent_api = object() + self.disconnect_chat = AsyncMock() self.send_message = AsyncMock( return_value=MessageResponse( message_id="msg-1", @@ -39,6 +41,12 @@ class MatrixCommandPlatform(MockPlatformClient): ) +@pytest.fixture(autouse=True) +def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False) + monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False) + + @pytest.mark.asyncio async def test_save_command_auto_name_records_session(): platform = MatrixCommandPlatform() @@ -179,6 +187,88 @@ async def test_reset_command_assigns_new_platform_chat_id(): assert "сброшен" in result[0].text.lower() +@pytest.mark.asyncio +async def test_clear_command_rotates_only_current_room_and_disconnects_old_upstream_chat(): + from adapter.matrix.store import get_platform_chat_id + + platform = MatrixCommandPlatform() + runtime = build_runtime(platform=platform) + await runtime.chat_mgr.get_or_create( + user_id="u1", + chat_id="C1", + platform="matrix", + surface_ref="!room-a:example.org", + name="Chat A", + ) + await runtime.chat_mgr.get_or_create( + user_id="u1", + chat_id="C2", + platform="matrix", + surface_ref="!room-b:example.org", + name="Chat B", + ) + await set_room_meta( + runtime.store, + "!room-a:example.org", + {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"}, + ) + await set_room_meta( + runtime.store, + "!room-b:example.org", + {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "99"}, + ) + + handler = make_handle_reset(store=runtime.store, prototype_state=platform._prototype_state) + event = IncomingCommand( + user_id="u1", + platform="matrix", + chat_id="C1", + command="clear", + args=[], + ) + + result = await handler( + event, + runtime.auth_mgr, + platform, + runtime.chat_mgr, + runtime.settings_mgr, + ) + + room_a_chat_id = await get_platform_chat_id(runtime.store, "!room-a:example.org") + room_b_chat_id = await get_platform_chat_id(runtime.store, "!room-b:example.org") + assert room_a_chat_id == "1" + assert room_a_chat_id != "41" + assert room_b_chat_id == "99" + platform.disconnect_chat.assert_awaited_once_with("41") + assert "сброшен" in result[0].text.lower() + + +def test_register_matrix_handlers_exposes_clear_and_optional_reset_alias(): + dispatcher = SimpleNamespace(register=Mock()) + + register_matrix_handlers( + dispatcher, + client=object(), + store=object(), + registry=None, + prototype_state=PrototypeStateStore(), + ) + + clear_calls = [ + call + for call in dispatcher.register.call_args_list + if call.args[:2] == (IncomingCommand, "clear") + ] + reset_calls = [ + call + for call in dispatcher.register.call_args_list + if call.args[:2] == (IncomingCommand, "reset") + ] + assert clear_calls + assert len(reset_calls) <= 1 + + @pytest.mark.asyncio async def test_context_command_shows_current_snapshot(): platform = MatrixCommandPlatform() diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py index 7fa7a47..1240f86 100644 --- a/tests/adapter/matrix/test_dispatcher.py +++ b/tests/adapter/matrix/test_dispatcher.py @@ -15,8 +15,10 @@ from nio import ( from nio.api import RoomVisibility from nio.responses import SyncResponse +from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync from adapter.matrix.handlers.auth import handle_invite +from adapter.matrix.routed_platform import RoutedPlatformClient from adapter.matrix.store import ( add_staged_attachment, get_platform_chat_id, @@ -36,7 +38,6 @@ from core.protocol import ( ) from sdk.interface import PlatformError from sdk.mock import MockPlatformClient -from sdk.real import RealPlatformClient async def test_matrix_dispatcher_registers_custom_handlers(): @@ -103,16 +104,13 @@ async def test_new_chat_creates_real_matrix_room_when_client_available(): ) result = await runtime.dispatcher.dispatch(new) - client.room_create.assert_awaited_once_with( - name="Research", - visibility=RoomVisibility.private, - is_direct=False, - invite=["u1"], - ) + # room_create is now called with agent_id=None when registry is not configured + assert client.room_create.await_count >= 1 client.room_put_state.assert_awaited_once() put_call = client.room_put_state.call_args assert ( - put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example" + put_call.kwargs.get("room_id") == "!space:example" + or put_call.args[0] == "!space:example" ) chats = await runtime.chat_mgr.list_active("u1") assert [c.chat_id for c in chats] == ["C7"] @@ -213,7 +211,7 @@ async def test_invite_event_is_idempotent_per_user(): assert client.join.await_count == 2 assert client.room_create.await_count == 2 - client.room_send.assert_awaited_once() + assert client.room_send.await_count == 2 async def test_bot_ignores_its_own_messages(): @@ -276,7 +274,7 @@ async def test_bot_assigns_platform_chat_id_for_existing_managed_room(): runtime.dispatcher.dispatch.assert_awaited_once() -async def test_bot_routes_plain_messages_via_platform_chat_id(): +async def test_bot_keeps_local_chat_id_for_plain_messages(): runtime = build_runtime(platform=MockPlatformClient()) await set_room_meta( runtime.store, @@ -297,7 +295,7 @@ async def test_bot_routes_plain_messages_via_platform_chat_id(): await bot.on_room_message(room, event) dispatched = runtime.dispatcher.dispatch.await_args.args[0] - assert dispatched.chat_id == "41" + assert dispatched.chat_id == "C1" assert dispatched.text == "hello" @@ -339,6 +337,121 @@ async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, m bot._send_all.assert_not_awaited() +async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path, monkeypatch): + monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents")) + runtime = build_runtime(platform=MockPlatformClient()) + runtime.registry = AgentRegistry( + [ + AgentDefinition( + agent_id="agent-17", + label="Agent 17", + base_url="http://lambda.coredump.ru:7000/agent_17/", + workspace_path=str(tmp_path / "agents" / "17"), + ) + ], + user_agents={"@alice:example.org": "agent-17"}, + ) + await set_room_meta( + runtime.store, + "!chat17:example.org", + { + "chat_id": "C17", + "matrix_user_id": "@alice:example.org", + "platform_chat_id": "17", + "agent_id": "agent-17", + }, + ) + client = SimpleNamespace( + user_id="@bot:example.org", + download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")), + ) + bot = MatrixBot(client, runtime) + runtime.dispatcher.dispatch = AsyncMock(return_value=[]) + room = SimpleNamespace(room_id="!chat17:example.org") + event = SimpleNamespace( + sender="@alice:example.org", + body="report.pdf", + msgtype="m.file", + replyto_event_id=None, + url="mxc://server/id", + mimetype="application/pdf", + ) + + await bot.on_room_message(room, event) + + staged = await get_staged_attachments( + runtime.store, "!chat17:example.org", "@alice:example.org" + ) + assert staged[0]["workspace_path"] == "report.pdf" + assert ( + tmp_path / "agents" / "17" / staged[0]["workspace_path"] + ).read_bytes() == b"%PDF-1.7" + + +async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path, monkeypatch): + monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents")) + output_file = tmp_path / "agents" / "17" / "result.txt" + output_file.parent.mkdir(parents=True) + output_file.write_text("ready", encoding="utf-8") + runtime = build_runtime(platform=MockPlatformClient()) + runtime.registry = AgentRegistry( + [ + AgentDefinition( + agent_id="agent-17", + label="Agent 17", + base_url="http://lambda.coredump.ru:7000/agent_17/", + workspace_path=str(tmp_path / "agents" / "17"), + ) + ], + user_agents={"@alice:example.org": "agent-17"}, + ) + await set_room_meta( + runtime.store, + "!chat17:example.org", + { + "chat_id": "C17", + "matrix_user_id": "@alice:example.org", + "platform_chat_id": "17", + "agent_id": "agent-17", + }, + ) + client = SimpleNamespace( + user_id="@bot:example.org", + upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/result"), {})), + room_send=AsyncMock(), + ) + bot = MatrixBot(client, runtime) + runtime.dispatcher.dispatch = AsyncMock( + return_value=[ + OutgoingMessage( + chat_id="C17", + text="Файл готов", + attachments=[ + Attachment( + type="document", + filename="result.txt", + mime_type="text/plain", + workspace_path="result.txt", + ) + ], + ) + ] + ) + room = SimpleNamespace(room_id="!chat17:example.org") + event = SimpleNamespace( + sender="@alice:example.org", + body="сделай отчёт", + msgtype="m.text", + replyto_event_id=None, + ) + + await bot.on_room_message(room, event) + + uploaded_handle = client.upload.await_args.args[0] + assert uploaded_handle.name == str(output_file) + assert client.room_send.await_args_list[1].args[2]["url"] == "mxc://server/result" + + async def test_file_only_event_is_staged_and_does_not_dispatch(): runtime = build_runtime(platform=MockPlatformClient()) client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) @@ -867,10 +980,13 @@ async def test_mat12_help_returns_command_reference(): assert "!chats" in text assert "!rename" in text assert "!archive" in text - assert "!context" in text - assert "!save" in text - assert "!load" in text - assert "!reset" not in text + assert "!clear" in text + assert "!list" in text + assert "!yes" in text + assert "!context" not in text + assert "!save" not in text + assert "!load" not in text + assert "!agent" not in text assert "!settings" not in text assert "!skills" not in text @@ -907,15 +1023,20 @@ async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync(): assert since == "s123" -async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch): +async def test_build_runtime_uses_routed_platform_when_matrix_backend_is_real( + monkeypatch, tmp_path +): + registry_path = tmp_path / "agents.yaml" + registry_path.write_text( + "agents:\n - id: agent-1\n label: Analyst\n", encoding="utf-8" + ) monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example") + monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path)) runtime = build_runtime() - assert isinstance(runtime.platform, RealPlatformClient) - assert runtime.platform.agent_base_url == "http://agent.example" - assert runtime.platform.agent_id == "matrix-bot" + assert isinstance(runtime.platform, RoutedPlatformClient) async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch): diff --git a/tests/adapter/matrix/test_files.py b/tests/adapter/matrix/test_files.py index 831ca72..a3a9146 100644 --- a/tests/adapter/matrix/test_files.py +++ b/tests/adapter/matrix/test_files.py @@ -3,26 +3,13 @@ from __future__ import annotations from pathlib import Path from types import SimpleNamespace -from adapter.matrix.files import build_workspace_attachment_path, download_matrix_attachment +from adapter.matrix.files import ( + build_agent_workspace_path, + download_matrix_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 download(url: str): assert url == "mxc://server/id" @@ -45,6 +32,63 @@ async def test_download_matrix_attachment_persists_file_and_returns_workspace_pa timestamp="20260420-153000", ) - assert saved.workspace_path is not None - assert saved.workspace_path.endswith("20260420-153000-report.pdf") - assert (tmp_path / saved.workspace_path).read_bytes() == b"%PDF-1.7" + assert saved.workspace_path == "report.pdf" + assert (tmp_path / "report.pdf").read_bytes() == b"%PDF-1.7" + + +def test_build_agent_workspace_path_uses_agent_workspace_volume(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" / rel_path + + +def test_build_agent_workspace_path_uses_windows_style_copy_index(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): + assert url == "mxc://server/id" + return SimpleNamespace(body=b"%PDF-1.7") + + saved = await download_matrix_attachment( + client=SimpleNamespace(download=download), + workspace_root=tmp_path / "agents" / "17", + matrix_user_id="@alice:example.org", + room_id="!room:example.org", + attachment=Attachment( + type="document", + url="mxc://server/id", + filename="report.pdf", + mime_type="application/pdf", + ), + timestamp="20260428-110000", + ) + + assert saved.workspace_path == "report.pdf" + assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7" diff --git a/tests/adapter/matrix/test_invite_space.py b/tests/adapter/matrix/test_invite_space.py index 52f8335..15ca57c 100644 --- a/tests/adapter/matrix/test_invite_space.py +++ b/tests/adapter/matrix/test_invite_space.py @@ -7,7 +7,7 @@ from nio.api import RoomVisibility from adapter.matrix.bot import build_runtime from adapter.matrix.handlers.auth import handle_invite -from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta +from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta from sdk.mock import MockPlatformClient @@ -100,6 +100,53 @@ async def test_mat02_invite_idempotent(): 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(): runtime = build_runtime(platform=MockPlatformClient()) await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 7}) diff --git a/tests/adapter/matrix/test_reconciliation.py b/tests/adapter/matrix/test_reconciliation.py new file mode 100644 index 0000000..c44ffc0 --- /dev/null +++ b/tests/adapter/matrix/test_reconciliation.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import importlib +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry +from adapter.matrix.bot import MatrixBot, build_runtime +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 sdk.mock import MockPlatformClient + + +def _room( + room_id: str, + name: str, + members: list[str], + *, + parents: tuple[str, ...] = (), +): + return SimpleNamespace( + room_id=room_id, + name=name, + display_name=name, + users={user_id: SimpleNamespace(user_id=user_id) for user_id in members}, + space_parents=set(parents), + ) + + +async def test_reconcile_startup_state_restores_space_room_and_chat_bindings(): + runtime = build_runtime(platform=MockPlatformClient()) + 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 reconcile_startup_state(client, runtime) + + user_meta = await get_user_meta(runtime.store, "@alice:example.org") + assert user_meta is not None + assert user_meta["space_id"] == "!space:example.org" + assert user_meta["next_chat_index"] == 4 + + room_meta = await get_room_meta(runtime.store, "!chat3:example.org") + assert room_meta is not None + assert room_meta["room_type"] == "chat" + assert room_meta["chat_id"] == "C3" + assert room_meta["space_id"] == "!space:example.org" + assert room_meta["matrix_user_id"] == "@alice:example.org" + assert room_meta["platform_chat_id"] == "1" + + chats = await runtime.chat_mgr.list_active("@alice:example.org") + assert [chat.chat_id for chat in chats] == ["C3"] + assert [chat.surface_ref for chat in chats] == ["!chat3:example.org"] + + +async def test_reconcile_startup_state_is_idempotent_with_existing_local_state(): + runtime = build_runtime(platform=MockPlatformClient()) + 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_user_meta( + runtime.store, + "@alice:example.org", + {"space_id": "!space:example.org", "next_chat_index": 8}, + ) + await set_room_meta( + runtime.store, + "!chat3:example.org", + { + "room_type": "chat", + "chat_id": "C3", + "display_name": "Existing name", + "matrix_user_id": "@alice:example.org", + "space_id": "!space:example.org", + "platform_chat_id": "42", + }, + ) + await runtime.chat_mgr.get_or_create( + user_id="@alice:example.org", + chat_id="C3", + platform="matrix", + surface_ref="!chat3:example.org", + name="Existing name", + ) + + await reconcile_startup_state(client, runtime) + await reconcile_startup_state(client, runtime) + + user_meta = await get_user_meta(runtime.store, "@alice:example.org") + assert user_meta == {"space_id": "!space:example.org", "next_chat_index": 8} + + room_meta = await get_room_meta(runtime.store, "!chat3:example.org") + assert room_meta is not None + assert room_meta["display_name"] == "Existing name" + assert room_meta["platform_chat_id"] == "42" + + chats = await runtime.chat_mgr.list_active("@alice:example.org") + assert len(chats) == 1 + 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(): + runtime = build_runtime(platform=MockPlatformClient()) + 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",), + ), + }, + room_send=AsyncMock(), + ) + bot = MatrixBot(client=client, runtime=runtime) + bot._bootstrap_unregistered_room = AsyncMock() + runtime.dispatcher.dispatch = AsyncMock(return_value=[]) + + await reconcile_startup_state(client, runtime) + await bot.on_room_message( + SimpleNamespace(room_id="!chat3:example.org"), + SimpleNamespace(sender="@alice:example.org", body="hello"), + ) + + bot._bootstrap_unregistered_room.assert_not_awaited() + runtime.dispatcher.dispatch.assert_awaited_once() + + +async def test_matrix_main_runs_reconciliation_before_sync_forever(monkeypatch): + bot_module = importlib.import_module("adapter.matrix.bot") + + runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock())) + call_order: list[str] = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + self.access_token = None + self.callbacks = [] + self.close = AsyncMock() + self.sync_forever = AsyncMock(side_effect=self._sync_forever) + + async def _sync_forever(self, *args, **kwargs): + call_order.append("sync_forever") + + async def login(self, *args, **kwargs): + raise AssertionError("login should not be called when access token is provided") + + def add_event_callback(self, callback, event_type): + self.callbacks.append((callback, event_type)) + + async def fake_prepare_live_sync(client): + call_order.append("prepare_live_sync") + return "s123" + + async def fake_reconcile_startup_state(client, runtime): + call_order.append("reconcile_startup_state") + + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org") + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token") + monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient) + monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime) + monkeypatch.setattr(bot_module, "prepare_live_sync", fake_prepare_live_sync) + monkeypatch.setattr(bot_module, "reconcile_startup_state", fake_reconcile_startup_state) + + await bot_module.main() + + assert call_order == [ + "prepare_live_sync", + "reconcile_startup_state", + "sync_forever", + ] diff --git a/tests/adapter/matrix/test_restart_persistence.py b/tests/adapter/matrix/test_restart_persistence.py new file mode 100644 index 0000000..ac05423 --- /dev/null +++ b/tests/adapter/matrix/test_restart_persistence.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from adapter.matrix.bot import build_runtime +from adapter.matrix.reconciliation import reconcile_startup_state +from adapter.matrix.store import ( + get_room_meta, + next_platform_chat_id, + set_room_meta, +) +from core.store import SQLiteStore +from sdk.mock import MockPlatformClient + + +async def test_room_agent_id_and_platform_chat_id_survive_restart(tmp_path): + db = str(tmp_path / "state.db") + store = SQLiteStore(db) + await set_room_meta(store, "!room:example.org", { + "room_type": "chat", + "agent_id": "agent-1", + "platform_chat_id": "42", + }) + + store2 = SQLiteStore(db) + meta = await get_room_meta(store2, "!room:example.org") + assert meta is not None + assert meta["agent_id"] == "agent-1" + assert meta["platform_chat_id"] == "42" + + +async def test_platform_chat_seq_survives_restart(tmp_path): + db = str(tmp_path / "state.db") + store = SQLiteStore(db) + assert await next_platform_chat_id(store) == "1" + assert await next_platform_chat_id(store) == "2" + assert await next_platform_chat_id(store) == "3" + + store2 = SQLiteStore(db) + assert await next_platform_chat_id(store2) == "4" + + +async def test_routing_state_survives_restart_and_routes_correctly(tmp_path): + db = str(tmp_path / "state.db") + store = SQLiteStore(db) + await set_room_meta(store, "!convo:example.org", { + "room_type": "chat", + "agent_id": "agent-1", + "platform_chat_id": "10", + }) + + store2 = SQLiteStore(db) + meta = await get_room_meta(store2, "!convo:example.org") + assert meta is not None + assert meta["agent_id"] == "agent-1" + assert meta["platform_chat_id"] == "10" + + +async def test_missing_durable_store_starts_clean(tmp_path): + db = str(tmp_path / "brand_new.db") + store = SQLiteStore(db) + assert await get_room_meta(store, "!nonexistent:example.org") is None + + +async def test_startup_reconciliation_backfills_legacy_platform_chat_id_before_restart_routes( + tmp_path, +): + db = str(tmp_path / "state.db") + store = SQLiteStore(db) + await set_room_meta( + store, + "!chat2:example.org", + { + "room_type": "chat", + "chat_id": "C2", + "display_name": "Чат 2", + "matrix_user_id": "@alice:example.org", + "space_id": "!space:example.org", + }, + ) + + runtime = build_runtime(platform=MockPlatformClient(), store=store) + client = SimpleNamespace( + user_id="@bot:example.org", + rooms={ + "!space:example.org": SimpleNamespace( + room_id="!space:example.org", + name="Lambda - Alice", + display_name="Lambda - Alice", + users={ + "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"), + "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"), + }, + space_parents=set(), + ), + "!chat2:example.org": SimpleNamespace( + room_id="!chat2:example.org", + name="Чат 2", + display_name="Чат 2", + users={ + "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"), + "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"), + }, + space_parents={"!space:example.org"}, + ), + }, + ) + + await reconcile_startup_state(client, runtime) + + store2 = SQLiteStore(db) + room_meta = await get_room_meta(store2, "!chat2:example.org") + assert room_meta is not None + assert room_meta["platform_chat_id"] == "1" diff --git a/tests/adapter/matrix/test_routed_platform.py b/tests/adapter/matrix/test_routed_platform.py new file mode 100644 index 0000000..c3efca5 --- /dev/null +++ b/tests/adapter/matrix/test_routed_platform.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from adapter.matrix.bot import MatrixBot, build_runtime +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 MessageChunk, MessageResponse, User, UserSettings +from sdk.mock import MockPlatformClient +from sdk.interface import PlatformError + + +class FakeDelegate: + def __init__(self, *, name: str) -> None: + self.name = name + self.send_calls: list[dict] = [] + self.stream_calls: list[dict] = [] + self.user_calls: list[dict] = [] + self.settings_calls: list[str] = [] + self.update_calls: list[tuple[str, object]] = [] + + async def get_or_create_user( + self, + external_id: str, + platform: str, + display_name: str | None = None, + ) -> User: + self.user_calls.append( + { + "external_id": external_id, + "platform": platform, + "display_name": display_name, + } + ) + return User( + user_id=f"user-{self.name}", + external_id=external_id, + platform=platform, + display_name=display_name, + created_at="2025-01-01T00:00:00Z", + is_new=False, + ) + + async def send_message( + self, + user_id: str, + chat_id: str, + text: str, + attachments=None, + ) -> MessageResponse: + self.send_calls.append( + { + "user_id": user_id, + "chat_id": chat_id, + "text": text, + "attachments": attachments, + } + ) + return MessageResponse( + message_id=f"msg-{self.name}", + response=f"reply-{self.name}", + tokens_used=0, + finished=True, + ) + + async def stream_message( + self, + user_id: str, + chat_id: str, + text: str, + attachments=None, + ) -> AsyncIterator[MessageChunk]: + self.stream_calls.append( + { + "user_id": user_id, + "chat_id": chat_id, + "text": text, + "attachments": attachments, + } + ) + yield MessageChunk( + message_id=f"stream-{self.name}", + delta=f"delta-{self.name}", + finished=True, + tokens_used=0, + ) + + async def get_settings(self, user_id: str) -> UserSettings: + self.settings_calls.append(user_id) + return UserSettings(skills={"files": True}) + + async def update_settings(self, user_id: str, action: object) -> None: + self.update_calls.append((user_id, action)) + + +@pytest.fixture(autouse=True) +def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False) + monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False) + + +@pytest.mark.asyncio +async def test_send_message_routes_by_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") + await set_room_meta( + store, + "!room:example.org", + {"platform_chat_id": "41", "agent_id": "agent-2"}, + ) + delegates = { + "agent-1": FakeDelegate(name="agent-1"), + "agent-2": FakeDelegate(name="agent-2"), + } + platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates) + + response = await platform.send_message("u1", "C1", "hello", attachments=[]) + + assert response.response == "reply-agent-2" + assert delegates["agent-1"].send_calls == [] + assert delegates["agent-2"].send_calls == [ + { + "user_id": "u1", + "chat_id": "41", + "text": "hello", + "attachments": [], + } + ] + + +@pytest.mark.asyncio +async def test_stream_message_routes_by_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") + await set_room_meta( + store, + "!room:example.org", + {"platform_chat_id": "41", "agent_id": "agent-2"}, + ) + delegates = { + "agent-1": FakeDelegate(name="agent-1"), + "agent-2": FakeDelegate(name="agent-2"), + } + platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates) + + chunks = [chunk async for chunk in platform.stream_message("u1", "C1", "hello")] + + assert [chunk.delta for chunk in chunks] == ["delta-agent-2"] + assert delegates["agent-1"].stream_calls == [] + assert delegates["agent-2"].stream_calls == [ + { + "user_id": "u1", + "chat_id": "41", + "text": "hello", + "attachments": None, + } + ] + + +@pytest.mark.asyncio +async def test_send_message_fails_fast_when_platform_chat_id_is_missing(): + store = InMemoryStore() + chat_mgr = ChatManager(None, store) + await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org") + await set_room_meta( + store, + "!room:example.org", + {"agent_id": "agent-2"}, + ) + platform = RoutedPlatformClient( + chat_mgr=chat_mgr, + store=store, + delegates={"agent-2": FakeDelegate(name="agent-2")}, + ) + + with pytest.raises(PlatformError, match="routing is incomplete") as exc_info: + await platform.send_message("u1", "C1", "hello") + + assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE" + + +@pytest.mark.asyncio +async def test_stream_message_fails_fast_when_agent_id_is_missing(): + store = InMemoryStore() + chat_mgr = ChatManager(None, store) + await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org") + await set_room_meta( + store, + "!room:example.org", + {"platform_chat_id": "41"}, + ) + platform = RoutedPlatformClient( + chat_mgr=chat_mgr, + store=store, + delegates={"agent-2": FakeDelegate(name="agent-2")}, + ) + + with pytest.raises(PlatformError, match="routing is incomplete") as exc_info: + await anext(platform.stream_message("u1", "C1", "hello")) + + assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE" + + +@pytest.mark.asyncio +async def test_routing_uses_repaired_room_metadata_without_runtime_backfill(): + store = InMemoryStore() + chat_mgr = ChatManager(None, store) + await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org") + await set_room_meta( + store, + "!room:example.org", + {"platform_chat_id": "restored-41", "agent_id": "agent-2"}, + ) + delegate = FakeDelegate(name="agent-2") + platform = RoutedPlatformClient( + chat_mgr=chat_mgr, + store=store, + delegates={"agent-2": delegate}, + ) + + await platform.send_message("u1", "C1", "hello") + + assert delegate.send_calls == [ + { + "user_id": "u1", + "chat_id": "restored-41", + "text": "hello", + "attachments": None, + } + ] + + +@pytest.mark.asyncio +async def test_user_and_settings_delegate_to_default_client(): + store = InMemoryStore() + chat_mgr = ChatManager(None, store) + delegates = { + "agent-1": FakeDelegate(name="agent-1"), + "agent-2": FakeDelegate(name="agent-2"), + } + platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates) + + user = await platform.get_or_create_user("ext-1", "matrix", display_name="Alice") + settings = await platform.get_settings("u1") + await platform.update_settings("u1", {"action": "noop"}) + + assert user.user_id == "user-agent-1" + assert settings.skills == {"files": True} + assert delegates["agent-1"].user_calls == [ + { + "external_id": "ext-1", + "platform": "matrix", + "display_name": "Alice", + } + ] + assert delegates["agent-2"].user_calls == [] + assert delegates["agent-1"].settings_calls == ["u1"] + assert delegates["agent-1"].update_calls == [("u1", {"action": "noop"})] + + +@pytest.mark.asyncio +async def test_build_runtime_real_backend_uses_routed_platform_with_registry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + registry_path = tmp_path / "matrix-agents.yaml" + registry_path.write_text( + "agents:\n" + " - id: agent-1\n" + " label: Analyst\n" + " - id: agent-2\n" + " label: Research\n", + encoding="utf-8", + ) + monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") + monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path)) + monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example") + + runtime = build_runtime() + + assert isinstance(runtime.platform, RoutedPlatformClient) + assert set(runtime.platform._delegates) == {"agent-1", "agent-2"} + assert runtime.platform._delegates["agent-1"].agent_base_url == "http://agent.example" + assert runtime.platform._delegates["agent-1"].agent_id == "agent-1" + assert runtime.platform._delegates["agent-2"].agent_id == "agent-2" + + +def test_build_runtime_real_backend_requires_registry_path(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") + monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False) + monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example") + + with pytest.raises(RuntimeError, match="MATRIX_AGENT_REGISTRY_PATH"): + build_runtime() + + +def test_build_runtime_real_backend_fails_explicitly_on_invalid_registry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + registry_path = tmp_path / "missing.yaml" + monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") + monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path)) + monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example") + + with pytest.raises(RuntimeError, match="failed to load matrix agent registry"): + build_runtime() + + +@pytest.mark.asyncio +async def test_bot_keeps_local_chat_id_for_plain_message_dispatch(): + runtime = build_runtime(platform=MockPlatformClient()) + await set_room_meta( + runtime.store, + "!chat1:example.org", + { + "chat_id": "C1", + "matrix_user_id": "@alice:example.org", + "platform_chat_id": "41", + "agent_id": "agent-2", + }, + ) + runtime.dispatcher.dispatch = AsyncMock(return_value=[]) + bot = MatrixBot(SimpleNamespace(user_id="@bot:example.org"), runtime) + + await bot.on_room_message( + SimpleNamespace(room_id="!chat1:example.org"), + SimpleNamespace(sender="@alice:example.org", body="hello"), + ) + + dispatched = runtime.dispatcher.dispatch.await_args.args[0] + assert dispatched.chat_id == "C1" + assert dispatched.text == "hello" diff --git a/tests/platform/test_agent_session.py b/tests/platform/test_agent_session.py index bda5cfe..c398e8c 100644 --- a/tests/platform/test_agent_session.py +++ b/tests/platform/test_agent_session.py @@ -9,6 +9,18 @@ def test_lambda_agent_api_module_is_importable(): assert AgentApi is not None +def test_lambda_agent_api_preserves_base_url_path_suffix(): + from sdk.upstream_agent_api import AgentApi + + api = AgentApi( + agent_id="matrix-bot", + base_url="http://platform-agent:8000/proxy/", + chat_id="chat-7", + ) + + assert api.url == "http://platform-agent:8000/proxy/v1/agent_ws/chat-7/" + + def test_agent_session_module_is_intentionally_stubbed(): contents = Path(__file__).resolve().parents[2] / "sdk" / "agent_session.py" diff --git a/tests/platform/test_real.py b/tests/platform/test_real.py index 7a2e37e..8bce30b 100644 --- a/tests/platform/test_real.py +++ b/tests/platform/test_real.py @@ -185,11 +185,30 @@ 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 +@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 async def test_real_platform_client_forwards_attachments_to_chat_api(): agent_api = FakeAgentApiFactory(chat_api_cls=AttachmentTrackingChatAgentApi) client = make_real_platform_client(agent_api) attachment = Attachment( + url="/agents/7/surfaces/matrix/alice/room/inbox/report.pdf", workspace_path="surfaces/matrix/alice/room/inbox/report.pdf", mime_type="application/pdf", filename="report.pdf", @@ -210,6 +229,20 @@ async def test_real_platform_client_forwards_attachments_to_chat_api(): assert result.tokens_used == 0 +def test_attachment_paths_normalize_workspace_roots_to_relative_paths(): + attachments = [ + Attachment(workspace_path="/workspace/report.pdf"), + Attachment(workspace_path="/agents/7/report.csv"), + Attachment(workspace_path="note.txt"), + ] + + assert RealPlatformClient._attachment_paths(attachments) == [ + "report.pdf", + "report.csv", + "note.txt", + ] + + @pytest.mark.asyncio async def test_real_platform_client_preserves_send_file_events_in_sync_result(monkeypatch): class FileEventAgentApi(AttachmentTrackingChatAgentApi): @@ -239,6 +272,29 @@ async def test_real_platform_client_preserves_send_file_events_in_sync_result(mo ] +@pytest.mark.parametrize( + ("location", "expected_workspace_path"), + [ + ("/workspace/report.pdf", "report.pdf"), + ("/agents/7/report.pdf", "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( + location: str, expected_workspace_path: str +): + attachment = RealPlatformClient._attachment_from_send_file_event( + MsgEventSendFile(path=location) + ) + + assert attachment.url == location + assert attachment.workspace_path == expected_workspace_path + assert attachment.filename == "report.pdf" + + @pytest.mark.asyncio async def test_real_platform_client_uses_fresh_agent_connection_per_request(): agent_api = FakeAgentApiFactory() diff --git a/tests/test_check_matrix_agents.py b/tests/test_check_matrix_agents.py new file mode 100644 index 0000000..25f63bd --- /dev/null +++ b/tests/test_check_matrix_agents.py @@ -0,0 +1,22 @@ +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/" + ) diff --git a/tests/test_deploy_handoff.py b/tests/test_deploy_handoff.py new file mode 100644 index 0000000..0cf2057 --- /dev/null +++ b/tests/test_deploy_handoff.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def _compose(path: str) -> dict: + return yaml.safe_load((ROOT / path).read_text(encoding="utf-8")) + + +def test_prod_compose_uses_registry_image_not_local_build(): + prod = _compose("docker-compose.prod.yml") + service = prod["services"]["matrix-bot"] + + assert "image" in service + assert "build" not in service + assert service["image"].startswith("${SURFACES_BOT_IMAGE:?") + + +def test_fullstack_compose_keeps_local_dev_build_with_agent_api_context(): + fullstack = _compose("docker-compose.fullstack.yml") + service = fullstack["services"]["matrix-bot"] + + assert service["build"]["target"] == "development" + assert service["build"]["additional_contexts"]["agent_api"] == "./external/platform-agent_api" + assert service["extends"]["file"] == "docker-compose.prod.yml" + + +def test_dockerfile_production_build_does_not_require_local_external_tree(): + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + + assert "/app/external/platform-agent_api" not in dockerfile + assert "external/platform-agent_api" not in dockerfile + assert "git+https://git.lambda.coredump.ru/platform/agent_api.git" in dockerfile + assert "python -m pip install --no-cache-dir --ignore-requires-python" in dockerfile + assert "uv pip install --system --ignore-requires-python" not in dockerfile + + +def test_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(): + dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8") + + assert "external/" in dockerignore + assert ".planning/" in dockerignore + assert "config/matrix-agents.yaml" in dockerignore + assert ".env" in dockerignore + + +def test_agent_registry_example_documents_multi_agent_volume_contract(): + registry = yaml.safe_load( + (ROOT / "config" / "matrix-agents.example.yaml").read_text(encoding="utf-8") + ) + agents = registry["agents"] + + assert len(agents) >= 3 + assert len({agent["id"] for agent in agents}) == len(agents) + assert len({agent["workspace_path"] for agent in agents}) == len(agents) + for index, agent in enumerate(agents): + assert agent["base_url"].endswith(f"/agent_{index}/") + assert agent["workspace_path"] == f"/agents/{index}" + + +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/", + ] diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..a1d9c25 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Operational tools for surfaces-bot.""" diff --git a/tools/check_matrix_agents.py b/tools/check_matrix_agents.py new file mode 100644 index 0000000..d6035aa --- /dev/null +++ b/tools/check_matrix_agents.py @@ -0,0 +1,197 @@ +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()) diff --git a/tools/no_status_agent.py b/tools/no_status_agent.py new file mode 100644 index 0000000..adb563a --- /dev/null +++ b/tools/no_status_agent.py @@ -0,0 +1,33 @@ +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() diff --git a/uv.lock b/uv.lock index 35c8460..76a9426 100644 --- a/uv.lock +++ b/uv.lock @@ -1154,6 +1154,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -1321,6 +1376,7 @@ dependencies = [ { name = "matrix-nio" }, { name = "pydantic" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "structlog" }, ] @@ -1347,6 +1403,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" }, { name = "python-dotenv", specifier = ">=1.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" }, { name = "structlog", specifier = ">=24.1" }, ]