diff --git a/.dockerignore b/.dockerignore index 2d88441..1996568 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,16 +6,11 @@ __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 cc5f2e0..5c1cb66 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,19 @@ -# 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 +# Telegram +TELEGRAM_BOT_TOKEN=your_bot_token_here -# Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only) +# Matrix +MATRIX_HOMESERVER=https://matrix.org +MATRIX_USER_ID=@bot:matrix.org +MATRIX_PASSWORD=your_password_here MATRIX_PLATFORM_BACKEND=real -# 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 +# Shared workspace contract +SURFACES_WORKSPACE_DIR=/workspace -# platform/agent_api ref used when building a surface image -LAMBDA_AGENT_API_REF=master +# Compose-local platform-agent route +AGENT_BASE_URL=http://platform-agent:8000 -# 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 +# platform-agent provider +PROVIDER_MODEL=openai/gpt-4o-mini +PROVIDER_URL=https://openrouter.ai/api/v1 +PROVIDER_API_KEY=sk-or-... diff --git a/.planning/HANDOFF.json b/.planning/HANDOFF.json new file mode 100644 index 0000000..25f1d19 --- /dev/null +++ b/.planning/HANDOFF.json @@ -0,0 +1,107 @@ +{ + "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 d90b47e..a8043bd 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -2,44 +2,56 @@ ## What This Is -Surfaces (поверхности) — это тонкие адаптеры-клиенты, соединяющие мессенджеры с агентами платформы Lambda. -Текущая и главная реализация — **Matrix MVP**. Бот работает как stateless-прослойка: преобразует события Matrix во внутренний протокол `core/` и маршрутизирует их на внешние контейнеры агентов (через `AgentApi` по WebSocket). +Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda. Каждый бот — тонкий адаптер поверх общего ядра (`core/`), изолирующего бизнес-логику от транспорта. Платформа подключается через `sdk/interface.py` Protocol; сейчас используется `MockPlatformClient`. ## Core Value -Пользователь может бесшовно взаимодействовать с изолированными AI-агентами через нативные интерфейсы мессенджеров (с поддержкой пересылки файлов и работы в комнатах), в то время как сама платформа агентов не зависит от транспорта. +Пользователь может вести диалог с Lambda-агентом через любой из поддерживаемых мессенджеров без изменения ядра системы. ## Requirements ### Validated -- ✓ `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). +- ✓ 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 -### Out of Scope / Deferred +### Active -- E2EE для Matrix (отложено из-за сложностей сборки `python-olm` на кросс-платформенных средах). -- Интеграция с Master-сервисом платформы (временно используется прямое соединение с `platform-agent` через AgentApi). -- Telegram-адаптер (вынесен в легаси ветку `feat/telegram-adapter`, MVP фокусируется на Matrix). +- [ ] 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) ## Context -- Стек: Python 3.11+, `matrix-nio`, `uv`, `pydantic`. -- Бот хранит только локальную привязку (`room_id` <-> `platform_chat_id`) в SQLite. Вся долговременная память и история диалогов хранятся на стороне агента. -- Жизненный цикл контейнеров агентов управляется платформой, а не ботом. +- 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 работает только без шифрования ## Key Decisions | Decision | Rationale | Outcome | |----------|-----------|---------| -| Space+rooms для Matrix | Room-based UX и явные чаты (по одному на тред) удобнее, чем DM-каша | ✓ Good | -| Прямая интеграция AgentApi | Master API не был готов, прямое WebSocket соединение позволяет передавать стейт и файлы | ✓ Good | -| Shared Volume для файлов | Избавляет от необходимости гонять base64 по сети, быстрый прямой доступ к файлам | ✓ Good | -| Stateless бот | Бот легко перезапускать и масштабировать, память изолирована в агентах | ✓ Good | +| 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 | ## Evolution @@ -49,5 +61,10 @@ Surfaces (поверхности) — это тонкие адаптеры-кл 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-05-03 after codebase consolidation* +*Last updated: 2026-04-02 after initialization* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ffd6801..e81178c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1,32 +1,78 @@ # Roadmap — v1.0 -## Milestone: v1.0 — Production-ready Matrix MVP +## Milestone: v1.0 — Production-ready surfaces + +### Phase 1: Matrix QA & Polish + +**Goal:** Переработать Matrix адаптер с DM-first на Space+rooms, убрать реакции в пользу !yes/!no, довести до уровня "приемлемо работает" как Telegram. + +**Depends on:** Telegram QA complete + +**Plans:** 6 plans + +Plans: +- [x] 01-01-PLAN.md — Space+rooms infrastructure (store helpers, handle_invite rewrite, room_router) +- [x] 01-02-PLAN.md — Chat command handlers (!new, !archive, !rename) Space-aware +- [x] 01-03-PLAN.md — Reaction removal + !yes/!no confirmation + settings dashboard +- [x] 01-04-PLAN.md — Test suite (fix 4 broken + 12 new MAT-01..MAT-12) +- [x] 01-05-PLAN.md — Gap closure for Matrix `!yes` / `!no` pending-confirm scope +- [x] 01-06-PLAN.md — Remaining Phase 01 gap closure work (completed 2026-04-03) -### Phase 01: Matrix QA & Polish -**Goal:** Переработать Matrix адаптер с DM-first на Space+rooms, убрать реакции в пользу `!yes`/`!no`. -**Status:** Completed **Deliverables:** - Space+rooms architecture for Matrix adapter -- !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`. +- !yes/!no text-based confirmation (no reactions) +- Read-only !settings dashboard +- 96+ tests green --- -*Note: Легаси-фазы, связанные с Telegram, прототипами и Mock-платформой, были удалены из Roadmap после закрепления архитектуры MVP в ветке main.* + +### Phase 01.1: Matrix restart reconciliation and dev reset workflow (INSERTED) + +**Goal:** Сделать Matrix-адаптер пригодным для повторяемого локального рестарта и ручного QA: бот восстанавливает минимальный local state из существующих Space/rooms и даёт явный dev reset workflow вместо ручного ritual reset. +**Requirements**: none explicitly mapped +**Depends on:** Phase 1 +**Plans:** 3 plans + +Plans: +- [ ] 01.1-01-PLAN.md — Non-destructive Matrix reconciliation module and tests +- [ ] 01.1-02-PLAN.md — Wire startup/bootstrap recovery into the Matrix runtime +- [ ] 01.1-03-PLAN.md — Dev reset CLI and updated Matrix restart runbook + +### Phase 2: SDK Integration + +**Goal:** Заменить MockPlatformClient реальным Lambda SDK — бот начинает работать с настоящим AI-агентом. + +**Depends on:** Phase 1, Lambda platform SDK готов + +**Deliverables:** +- `sdk/real.py` — реализация PlatformClient через реальный SDK +- `bot.py` для обоих адаптеров переключается на реальный клиент через env var +- `stream_message` работает с реальным стримингом +- Интеграционные тесты с реальным SDK (или staging) + +### Phase 4: Matrix MVP: shared agent context and context management commands + +**Goal:** Привести Matrix-бот к рабочему состоянию для MVP-деплоя: заменить AgentSessionClient на AgentApi, добавить !save/!load/!reset/!context команды управления контекстом агента, упаковать в Docker. +**Requirements**: Replace AgentSessionClient with AgentApi; Wire AgentApi lifecycle; Implement !save, !load, !reset, !context commands; Dockerfile + docker-compose +**Depends on:** Phase 1 (Matrix adapter complete) +**Plans:** 3 plans + +Plans: +- [x] 04-01-PLAN.md — Replace AgentSessionClient with AgentApi; update sdk/real.py, bot.py, broken tests +- [x] 04-02-PLAN.md — !save, !load, !reset, !context handlers; PrototypeStateStore extensions; numeric interception +- [x] 04-03-PLAN.md — Dockerfile + docker-compose.yml + .env.example update + +--- + +### Phase 3: Production Hardening + +**Goal:** Подготовить боты к реальному деплою — конфиг, логирование, мониторинг, обработка ошибок. + +**Depends on:** Phase 2 + +**Deliverables:** +- Docker / systemd конфиг для деплоя +- Структурированное логирование в production формате +- Health-check endpoint (если нужен) +- Rate limiting и защита от спама +- Graceful shutdown diff --git a/.planning/STATE.md b/.planning/STATE.md index 47a860b..384ed33 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,48 +2,78 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: — Production-ready surfaces -status: MVP Deployed -last_updated: "2026-05-03T23:00:00Z" +status: Ready to execute +last_updated: "2026-04-17T16:10:00.000Z" progress: - total_phases: 3 - completed_phases: 3 - total_plans: 13 - completed_plans: 13 + total_phases: 5 + completed_phases: 2 + total_plans: 12 + completed_plans: 9 + percent: 75 --- # State ## Project Reference -See: `.planning/PROJECT.md` (updated 2026-05-03) +See: .planning/PROJECT.md (updated 2026-04-02) -**Core value:** Пользователь бесшовно взаимодействует с изолированными агентами через нативные интерфейсы (Matrix), в то время как платформа агентов не зависит от транспорта. -**Current focus:** Итерационное развитие текущей архитектуры, добавление новых фич и поверхностей (по мере необходимости). +**Core value:** Пользователь ведёт диалог с Lambda через любой мессенджер без изменения ядра +**Current focus:** Phase 04 complete — Matrix MVP implementation ready for testing ## Current Phase -Текущий MVP успешно завершен. Все базовые механизмы внедрены и работают: -- Маршрутизация к `AgentApi` -- Shared Volume файловый обмен (`/agents/`) -- Dynamic config через `matrix-agents.yaml` -- Изоляция контекстов через `platform_chat_id` +**Phase 4** implementation complete: Matrix MVP -Проект находится в чистом состоянии для начала нового планирования. Неактуальные легаси фазы и прототипы (Telegram, MockPlatformClient) удалены из Roadmap и трекинга. +Phase 4 is implemented. Next step is manual and automated testing of the Matrix MVP flow before deciding on follow-up work. ## Decisions -- **Space+rooms для Matrix**: Разделение тредов на отдельные Matrix-комнаты, собранные в едином Space пользователя. -- **AgentApi**: Прямая интеграция с локальным агентом без Master-прослойки по WebSocket. -- **Shared Volume**: Файлы кладутся напрямую в рабочую папку агента, избавляя от необходимости гонять их по сети в Base64. -- **Статическая маршрутизация**: На данном этапе пользователи маппятся на агентов жестко через YAML. +- Продолжаем с 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. ## Blockers -- Отсутствуют. Проект готов к деплою (см. `docker-compose.prod.yml`). +- Lambda platform SDK не готов — Phase 2 заблокирована до готовности платформы ## Accumulated Context ### Roadmap Evolution -- Изначальный Roadmap включал множество ответвлений (прототипы Telegram, локальный mock-клиент). После закрепления MVP в `main` Roadmap был очищен, чтобы отражать только актуальный путь продукта. -- Следующие фазы будут добавляться по мере возникновения новых задач (например, переход от YAML-конфига к БД для реестра агентов). +- 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 diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md index 05f7a7f..0cc6c4c 100644 --- a/.planning/codebase/ARCHITECTURE.md +++ b/.planning/codebase/ARCHITECTURE.md @@ -1,14 +1,134 @@ -# Архитектура (ARCHITECTURE.md) +# Architecture -## Паттерн "Thin Adapter" (Тонкая поверхность) +**Analysis Date:** 2026-04-01 -Система разделена на три логических слоя: -1. **Транспортный слой (Adapter)**: Подключается к внешней платформе (Matrix). Занимается конвертацией нативных событий (`room.message`) во внутренние структуры (`IncomingMessage`). -2. **Ядро (Core)**: Предоставляет единый протокол (`core/protocol.py`), не зависящий от конкретной реализации (Matrix, Telegram и т.д.). -3. **Платформенный слой (SDK)**: `RealPlatformClient` инкапсулирует подключение по WebSocket к реальным агентам (AgentApi). +## Pattern Overview -## Routing & Registry -Бот может обслуживать множество агентов (multi-tenant). Маршрутизация настраивается статически через `config/matrix-agents.yaml`. Каждый пользователь (`@user:server`) привязан к конкретному `agent_id`, у которого есть свой HTTP URL и свой изолированный `workspace_path` (например, `/agents/1/`). +**Overall:** Hexagonal / Ports-and-Adapters -## Файловый контракт -Файлы не передаются агенту в base64. Бот сохраняет вложение напрямую в локальную директорию (общий volume), и передает агенту только относительный путь (`workspace_path`). +**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* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index 5848135..473d257 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -1,6 +1,235 @@ -# Известные проблемы (CONCERNS.md) +# Codebase Concerns -- **Отсутствие E2E шифрования в Matrix**: На данный момент бот не поддерживает зашифрованные комнаты, так как библиотека `matrix-nio` требует нативной сборки `python-olm`, что усложняет кросс-платформенный деплой. -- **Потеря стейта агентов**: Так как текущий `platform-agent` часто работает с `MemorySaver`, его стейт теряется при перезапусках. Это проблема внешнего агента, но она напрямую влияет на UX поверхности. -- **Общий том (Shared Volume)**: Контракт обязывает бота и агента запускаться на одном физическом хосте (или иметь распределенный сетевой диск), что может стать бутылочным горлышком при сильном масштабировании. -- **Hardcoded роутинг**: `matrix-agents.yaml` требует ручного редактирования и перезапуска бота при добавлении новых агентов. Желательно вынести этот процесс в динамическую БД или API. +**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* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md index 36a4ed5..04c7f6a 100644 --- a/.planning/codebase/CONVENTIONS.md +++ b/.planning/codebase/CONVENTIONS.md @@ -1,7 +1,195 @@ -# Конвенции (CONVENTIONS.md) +# Coding Conventions -- **Асинхронность**: Весь код бота асинхронный (`asyncio`). Вызовы SDK и Matrix-клиента выполняются через `await`. Блокирующие вызовы (если они есть) должны выноситься в тредпул. -- **Обработка ошибок**: Бот не должен падать из-за ошибок отдельного агента. Ошибки SDK (например, `PlatformError`) отлавливаются в боте и возвращаются пользователю в виде системных сообщений или уведомлений. -- **Стейтлесс-подход**: Поверхность хранит минимальный стейт (только локальный SQLite для связки `room_id` <-> `platform_chat_id`). Вся история сообщений и память лежат на стороне агентов. -- **Переменные окружения**: Бот полностью конфигурируется через `.env` (префиксы `MATRIX_` и `SURFACES_`). -- **Добавление новой поверхности**: Новая поверхность должна быть самостоятельной папкой в `adapter/`, реализовывать `converter.py`, и переиспользовать `sdk/real.py` и `core/protocol.py`. +**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* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md index cd771d1..3cdae98 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -1,15 +1,173 @@ -# Интеграции (INTEGRATIONS.md) +# External Integrations -## Platform Agent API -- **Тип**: WebSocket (через `AgentApi` SDK) -- **Назначение**: Связь между Matrix-адаптером и внешней LLM-платформой. -- **Контракт**: Surface выступает "тупым" клиентом. Он отправляет `platform_chat_id` и `user_id` вместе с сообщениями. Платформа/Агент отвечает текстом и вложениями. Контейнерами агентов бот не управляет. +**Analysis Date:** 2026-04-01 -## Matrix Homeserver -- **Тип**: HTTP/HTTPS API (via `matrix-nio`) -- **Назначение**: Пользовательский интерфейс и транспорт сообщений для бота. -- **Ограничения**: Поддерживается только нешифрованное (unencrypted) взаимодействие. +## Bot Platform APIs -## Файловая система (Shared Volume) -- **Тип**: Docker Shared Volume (`/agents/`) -- **Назначение**: Прямая передача файлов между поверхностью и агентами в обход сети. Поверхность пишет файлы в поддиректорию конкретного агента, агент их читает, и наоборот. +**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* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md index b40772d..708a4bf 100644 --- a/.planning/codebase/STACK.md +++ b/.planning/codebase/STACK.md @@ -1,14 +1,113 @@ -# Технологический стек (STACK.md) +# Technology Stack -## Язык и Runtime -- **Python**: 3.11-slim (используется в Docker-образах) -- **Пакетный менеджер**: `uv` (используется для быстрой и строгой установки зависимостей, frozen lockfiles). +**Analysis Date:** 2026-04-01 -## Ключевые библиотеки -- **matrix-nio**: Асинхронный клиент для Matrix (события, синхронизация, отправка). -- **pydantic**: Для валидации структур данных (события из AgentApi). -- **structlog**: Структурированное логирование (json/console). +## Languages -## Инфраструктура -- **Docker / Docker Compose**: Используется для локального (fullstack) и продакшн развертывания. -- **SQLite**: Легковесная локальная база данных для хранения маппингов пользователей/комнат (`adapter/matrix/store.py`). +**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* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md index 9ea8a18..08896a5 100644 --- a/.planning/codebase/STRUCTURE.md +++ b/.planning/codebase/STRUCTURE.md @@ -1,18 +1,210 @@ -# Структура (STRUCTURE.md) +# Codebase Structure -- `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`: Продакшн и локальные манифесты для сборки. +**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* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md index 07311dc..f685abc 100644 --- a/.planning/codebase/TESTING.md +++ b/.planning/codebase/TESTING.md @@ -1,17 +1,210 @@ -# Тестирование (TESTING.md) +# Testing Patterns -## Unit-тесты -Расположены в `tests/`. Покрытие сфокусировано на логике Matrix адаптера (пока он является основной поверхностью): -- Файловый контракт (`test_files.py`) -- Диспетчер и конвертация (`test_dispatcher.py`) -- Взаимодействие с PlatformClient (`test_routed_platform.py`) -- Работа с контекстными командами бота (`test_context_commands.py`) +**Analysis Date:** 2026-04-01 -## E2E тестирование -Локально тестируется через запуск контейнеров из `docker-compose.fullstack.yml`, который поднимает один инстанс бота и один локальный `platform-agent`. Это позволяет имитировать полную цепочку взаимодействия (Matrix -> Бот -> Агент) с общим каталогом для файлов. +## Test Framework -## Запуск тестов -```bash -# Запуск юнит-тестов (только для Matrix адаптера) -pytest tests/adapter/matrix/ -v +**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 +``` + +## 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 new file mode 100644 index 0000000..6de8f62 --- /dev/null +++ b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.continue-here.md @@ -0,0 +1,63 @@ +--- +phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow +task: 1 +total_tasks: 2 +status: paused +last_updated: 2026-04-07T21:29:48.982Z +--- + + +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 new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..187baa9 --- /dev/null +++ b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-01-PLAN.md @@ -0,0 +1,157 @@ +--- +phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - adapter/matrix/reconcile.py + - tests/adapter/matrix/test_reconcile.py +autonomous: true +requirements: [] + +must_haves: + truths: + - "A normal Matrix restart can rebuild missing local metadata from already joined Space/chat rooms instead of requiring a destructive reset." + - "Reconciliation restores the minimal local state needed for routing and chat operations: `matrix_user:*`, `matrix_room:*`, and missing `chat:{user}:{chat_id}` rows." + - "Reconciliation never provisions new Matrix rooms or Spaces while repairing local state." + - "Recovered users get `next_chat_index` advanced past the highest recovered `C*` chat id." + artifacts: + - path: "adapter/matrix/reconcile.py" + provides: "Matrix bootstrap reconciliation helpers and structured report objects." + - path: "tests/adapter/matrix/test_reconcile.py" + provides: "Regression coverage for startup and single-room reconciliation behavior." + key_links: + - from: "adapter/matrix/reconcile.py" + to: "adapter/matrix/store.py" + via: "set_user_meta and set_room_meta restore Matrix metadata" + pattern: "set_(user|room)_meta" + - from: "adapter/matrix/reconcile.py" + to: "core/chat.py" + via: "chat_mgr.get_or_create repairs missing `chat:*` rows" + pattern: "chat_mgr\\.get_or_create" +--- + + +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 new file mode 100644 index 0000000..bdfdaf8 --- /dev/null +++ b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-02-PLAN.md @@ -0,0 +1,167 @@ +--- +phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow +plan: 02 +type: execute +wave: 2 +depends_on: ["01.1-01"] +files_modified: + - adapter/matrix/bot.py + - tests/adapter/matrix/test_dispatcher.py +autonomous: true +requirements: [] + +must_haves: + truths: + - "The Matrix bot performs an initial sync and reconciliation before entering steady-state `sync_forever()`." + - "If a room still arrives as `unregistered:{room_id}` after startup, the bot makes one targeted recovery attempt before dispatching or failing." + - "When reconciliation cannot repair a room, the bot logs a clear diagnostic reason instead of crashing on downstream commands like `!rename`." + artifacts: + - path: "adapter/matrix/bot.py" + provides: "Startup bootstrap flow with initial sync, reconciliation, and targeted runtime retry." + - path: "tests/adapter/matrix/test_dispatcher.py" + provides: "Matrix runtime coverage for pre-sync reconcile and on-message recovery behavior." + key_links: + - from: "adapter/matrix/bot.py" + to: "adapter/matrix/reconcile.py" + via: "startup bootstrap and single-room recovery calls" + pattern: "reconcile_(matrix_state|single_room)" + - from: "adapter/matrix/bot.py" + to: "adapter/matrix/room_router.py" + via: "unregistered room detection before dispatch" + pattern: "unregistered:" +--- + + +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 new file mode 100644 index 0000000..bd78891 --- /dev/null +++ b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-03-PLAN.md @@ -0,0 +1,149 @@ +--- +phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - adapter/matrix/reset.py + - tests/adapter/matrix/test_reset.py + - README.md +autonomous: true +requirements: [] + +must_haves: + truths: + - "Developers have an explicit dev-only reset command instead of relying on memory or ad hoc shell history." + - "The default reset mode clears only local Matrix state and explains the manual Matrix-client cleanup that may still be needed." + - "Optional server cleanup is clearly named around leave/forget semantics and supports dry-run output." + artifacts: + - path: "adapter/matrix/reset.py" + provides: "Dev reset CLI for local-only, server-leave-forget, and dry-run workflows." + - path: "tests/adapter/matrix/test_reset.py" + provides: "CLI coverage for local reset behavior and printed operator guidance." + - path: "README.md" + provides: "Updated developer instructions for normal restart vs explicit reset." + key_links: + - from: "adapter/matrix/reset.py" + to: "README.md" + via: "documented invocation and manual Matrix cleanup guidance" + pattern: "adapter\\.matrix\\.reset" +--- + + +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 new file mode 100644 index 0000000..665061e --- /dev/null +++ b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md @@ -0,0 +1,121 @@ +# 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 new file mode 100644 index 0000000..792031d --- /dev/null +++ b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md @@ -0,0 +1,350 @@ +# Phase 01.1: Matrix restart reconciliation and dev reset workflow - Research + +**Researched:** 2026-04-03 +**Domain:** Matrix adapter restart reconciliation, local state recovery, dev reset workflow +**Confidence:** HIGH + + +## User Constraints (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 new file mode 100644 index 0000000..336cbd6 --- /dev/null +++ b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-VALIDATION.md @@ -0,0 +1,80 @@ +--- +phase: 01.1 +slug: matrix-restart-reconciliation-and-dev-reset-workflow +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-04-03 +--- + +# Phase 01.1 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | `pytest 9.0.2` + `pytest-asyncio 1.3.0` | +| **Config file** | `pyproject.toml` | +| **Quick run command** | `pytest tests/adapter/matrix -v` | +| **Full suite command** | `pytest tests/ -v` | +| **Estimated runtime** | ~20 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `pytest tests/adapter/matrix -v` +- **After every plan wave:** Run `pytest tests/ -v` +- **Before `$gsd-verify-work`:** Full suite must be green +- **Max feedback latency:** 20 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 01.1-01-01 | 01 | 1 | PH01.1-BOOT | unit/integration | `pytest tests/adapter/matrix/test_reconcile.py -v` | ❌ W0 | ⬜ pending | +| 01.1-01-01 | 01 | 1 | PH01.1-COUNTER | unit | `pytest tests/adapter/matrix/test_reconcile.py -k next_chat_index -v` | ❌ W0 | ⬜ pending | +| 01.1-01-01 | 01 | 1 | PH01.1-NONDESTRUCTIVE | unit | `pytest tests/adapter/matrix/test_reconcile.py -k no_create -v` | ❌ W0 | ⬜ pending | +| 01.1-02-01 | 02 | 2 | PH01.1-BOOT | unit | `pytest tests/adapter/matrix/test_dispatcher.py -k startup -v` | ✅ | ⬜ pending | +| 01.1-02-02 | 02 | 2 | PH01.1-ROUTER | unit | `pytest tests/adapter/matrix/test_dispatcher.py -k reconcile -v` | ✅ | ⬜ pending | +| 01.1-03-01 | 03 | 1 | PH01.1-RESET | unit/smoke | `pytest tests/adapter/matrix/test_reset.py -v` | ❌ W0 | ⬜ pending | +| 01.1-03-02 | 03 | 1 | PH01.1-RESET | smoke | `python -m adapter.matrix.reset --help` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/adapter/matrix/test_reconcile.py` — startup reconciliation scenarios, `next_chat_index`, and no-provisioning assertions +- [ ] `tests/adapter/matrix/test_reset.py` — CLI reset modes, dry-run behavior, and operator guidance output +- [ ] `tests/adapter/matrix/test_dispatcher.py` — startup bootstrap order and targeted unknown-room recovery coverage +- [ ] Fake `AsyncClient` fixture surface for joined rooms, room state, leave, and forget behavior + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Reconciled Space/chat rooms render correctly in a real Matrix client after restart | PH01.1-BOOT | Client UX and homeserver state cannot be fully trusted from fake nio fixtures | 1. Start the bot with existing Space/chat rooms. 2. Verify the bot does not create duplicate Space or chat rooms. 3. Send a command in a recovered room and confirm it routes normally. | +| Server-side cleanup leaves the account in a usable Element state after `server-leave-forget` | PH01.1-RESET | Element/archive behavior and homeserver retention are client/server integration concerns | 1. Run `python -m adapter.matrix.reset --mode server-leave-forget --dry-run`. 2. Run without `--dry-run` on a test account. 3. Confirm joined rooms disappear for the bot and fresh invites can be accepted cleanly. | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` 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 new file mode 100644 index 0000000..a2d4619 --- /dev/null +++ b/.planning/phases/02-prototype/.continue-here.md @@ -0,0 +1,72 @@ +--- +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 new file mode 100644 index 0000000..576296b --- /dev/null +++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.continue-here.md @@ -0,0 +1,85 @@ +--- +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 deleted file mode 100644 index 2320eda..0000000 --- a/.planning/phases/05-mvp-deployment/05-01-PLAN.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 01 -type: execute -wave: 1 -depends_on: [] -files_modified: - - adapter/matrix/reconciliation.py - - adapter/matrix/bot.py - - tests/adapter/matrix/test_reconciliation.py - - tests/adapter/matrix/test_restart_persistence.py -autonomous: true -requirements: - - PH05-01 - - PH05-03 -must_haves: - truths: - - "On restart, existing Matrix Space and child-room topology is rebuilt before live sync begins." - - "Restart recovery preserves Space+rooms UX instead of creating duplicate DM-style working rooms." - - "Recovered rooms regain user metadata, room metadata, and chat bindings needed for normal routing." - - "Legacy working rooms missing `platform_chat_id` are backfilled deterministically during startup before strict routing handles traffic." - artifacts: - - path: "adapter/matrix/reconciliation.py" - provides: "Authoritative restart reconciliation from Matrix topology into local metadata" - - path: "adapter/matrix/bot.py" - provides: "Startup wiring that runs reconciliation before sync_forever" - - path: "tests/adapter/matrix/test_reconciliation.py" - provides: "Regression coverage for startup recovery and idempotence" - key_links: - - from: "adapter/matrix/bot.py" - to: "adapter/matrix/reconciliation.py" - via: "startup bootstrap before sync_forever" - pattern: "reconcil" - - from: "adapter/matrix/reconciliation.py" - to: "core/chat.py" - via: "chat manager rebuild for recovered rooms" - pattern: "get_or_create" ---- - - -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 deleted file mode 100644 index c50f371..0000000 --- a/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 01 -subsystem: infra -tags: [matrix, reconciliation, sqlite, startup, testing] -requires: - - phase: 01-matrix-mvp - provides: Space+rooms onboarding, room metadata, and Matrix dispatcher behavior - - phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma - provides: durable platform_chat_id and restart persistence primitives -provides: - - authoritative startup reconciliation from Matrix room topology into local metadata - - pre-sync startup wiring that repairs managed rooms before live traffic - - restart regression coverage for reconciliation, idempotence, and legacy platform_chat_id backfill -affects: [matrix, startup, deployment, restart-persistence] -tech-stack: - added: [] - patterns: [matrix-topology-as-source-of-truth, sqlite-cache-rebuild, pre-sync-reconciliation] -key-files: - created: [adapter/matrix/reconciliation.py, tests/adapter/matrix/test_reconciliation.py] - modified: [adapter/matrix/bot.py, tests/adapter/matrix/test_restart_persistence.py] -key-decisions: - - "Treat synced Matrix parent/child topology as authoritative for managed room recovery; keep SQLite rebuildable." - - "Backfill missing platform_chat_id values during startup reconciliation instead of routing-time fallbacks." -patterns-established: - - "Startup runs full-state sync, then reconciliation, then sync_forever." - - "Recovered Matrix rooms rebuild user_meta, room_meta, auth state, and ChatManager bindings idempotently." -requirements-completed: [PH05-01, PH05-03] -duration: 8min -completed: 2026-04-27 ---- - -# Phase 05 Plan 01: Restart Reconciliation Summary - -**Matrix startup now rebuilds Space-owned working rooms into durable local routing state before live sync begins** - -## Performance - -- **Duration:** 8 min -- **Started:** 2026-04-27T22:00:47Z -- **Completed:** 2026-04-27T22:08:47Z -- **Tasks:** 2 -- **Files modified:** 4 - -## Accomplishments -- Added a dedicated reconciliation module that restores `user_meta`, `room_meta`, auth state, chat bindings, and missing `platform_chat_id` values from the synced Matrix room graph. -- Wired startup to run reconciliation immediately after the initial full-state sync and before `sync_forever()`. -- Added regression coverage for recovery, idempotence, pre-sync ordering, onboarding compatibility, and legacy restart backfill. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Add restart reconciliation regression coverage** - `a75b26a` (test) -2. **Task 2: Implement authoritative startup reconciliation and wire it before live sync** - `8a80d00` (feat) - -## Files Created/Modified -- `adapter/matrix/reconciliation.py` - Startup recovery from Matrix topology into local room and user metadata. -- `adapter/matrix/bot.py` - Startup wiring that runs reconciliation after the bootstrap sync and before live sync. -- `tests/adapter/matrix/test_reconciliation.py` - Recovery, idempotence, and startup-order regression coverage. -- `tests/adapter/matrix/test_restart_persistence.py` - Legacy `platform_chat_id` backfill persistence coverage. - -## Decisions Made -- Used the synced Matrix room graph as the authoritative source for restart recovery, while preserving existing local metadata whenever it is already valid. -- Kept legacy `platform_chat_id` repair on a single startup path so routed handling never needs ad hoc fallback creation for existing rooms. - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 3 - Blocking] Switched verification to a clean `uv run pytest` environment** -- **Found during:** Task 1 and Task 2 verification -- **Issue:** The default `pytest` path used a mismatched virtualenv without repo dependencies, and `.env` injected Matrix backend variables that polluted mock-mode tests. -- **Fix:** Ran the verification slice through `uv run pytest` with `UV_CACHE_DIR=/tmp/uv-cache-surfaces` and blank `MATRIX_AGENT_REGISTRY_PATH` / `MATRIX_PLATFORM_BACKEND` values to match the intended test environment. -- **Files modified:** None -- **Verification:** `uv run pytest` slice passed with 50/50 tests green -- **Committed in:** not applicable (verification-only adjustment) - ---- - -**Total deviations:** 1 auto-fixed (1 blocking) -**Impact on plan:** Verification needed an environment correction, but code scope stayed within the plan and owned files. - -## Issues Encountered -- The shell environment loaded deployment-oriented Matrix backend settings from `.env`; these had to be neutralized for the mock-mode regression slice. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness -- Restart recovery is in place for existing Space rooms, including deterministic legacy `platform_chat_id` repair. -- Remaining Phase 05 plans can build on a stable pre-sync recovery path instead of lazy bootstrap for existing topology. - -## Self-Check: PASSED - ---- -*Phase: 05-mvp-deployment* -*Completed: 2026-04-27* diff --git a/.planning/phases/05-mvp-deployment/05-02-PLAN.md b/.planning/phases/05-mvp-deployment/05-02-PLAN.md deleted file mode 100644 index dc93cf0..0000000 --- a/.planning/phases/05-mvp-deployment/05-02-PLAN.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 02 -type: execute -wave: 2 -depends_on: - - 05-01 -files_modified: - - adapter/matrix/handlers/__init__.py - - adapter/matrix/handlers/context_commands.py - - adapter/matrix/routed_platform.py - - tests/adapter/matrix/test_context_commands.py - - tests/adapter/matrix/test_routed_platform.py -autonomous: true -requirements: - - PH05-02 -must_haves: - truths: - - "Each working Matrix room uses its own durable `platform_chat_id` as the real agent context boundary." - - "`!clear` resets only the current room by rotating its `platform_chat_id` and disconnecting the old upstream chat." - - "Save, load, context, and routed send paths resolve through room-local platform context, not shared user state." - - "Strict room routing assumes startup reconciliation has already repaired legacy rooms missing `platform_chat_id`." - artifacts: - - path: "adapter/matrix/handlers/context_commands.py" - provides: "Room-local `!clear`, save/load/context resolution, and upstream disconnect behavior" - - path: "adapter/matrix/routed_platform.py" - provides: "Strict room -> agent_id + platform_chat_id routing" - - path: "tests/adapter/matrix/test_context_commands.py" - provides: "Regression coverage for `!clear` and room-local context commands" - key_links: - - from: "adapter/matrix/handlers/__init__.py" - to: "adapter/matrix/handlers/context_commands.py" - via: "IncomingCommand registration for `clear`" - pattern: "\"clear\"" - - from: "adapter/matrix/routed_platform.py" - to: "adapter/matrix/store.py" - via: "room metadata lookup" - pattern: "platform_chat_id" ---- - - -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 deleted file mode 100644 index fa4a48c..0000000 --- a/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -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 deleted file mode 100644 index 01023b3..0000000 --- a/.planning/phases/05-mvp-deployment/05-03-PLAN.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 03 -type: execute -wave: 1 -depends_on: [] -files_modified: - - adapter/matrix/files.py - - sdk/real.py - - tests/adapter/matrix/test_files.py - - tests/platform/test_real.py -autonomous: true -requirements: - - PH05-04 -must_haves: - truths: - - "Incoming Matrix attachments are written into a room-safe shared-volume path and passed upstream as relative workspace paths." - - "Agent-emitted files can be returned to Matrix users without inventing a separate file proxy." - - "The shared-volume contract works with the Phase 05 `/agents` deployment shape." - artifacts: - - path: "adapter/matrix/files.py" - provides: "Room-safe shared-volume path building and path resolution" - - path: "sdk/real.py" - provides: "Attachment path passthrough and send-file normalization" - - path: "tests/adapter/matrix/test_files.py" - provides: "Regression coverage for shared-volume path construction" - key_links: - - from: "adapter/matrix/files.py" - to: "sdk/real.py" - via: "relative `workspace_path` transport" - pattern: "workspace_path" - - from: "sdk/real.py" - to: "adapter/matrix/bot.py" - via: "OutgoingMessage attachments rendered back to Matrix" - pattern: "MsgEventSendFile" ---- - - -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 deleted file mode 100644 index 0745e7c..0000000 --- a/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -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 deleted file mode 100644 index 4fe2235..0000000 --- a/.planning/phases/05-mvp-deployment/05-04-PLAN.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 04 -type: execute -wave: 2 -depends_on: - - 05-03 -files_modified: - - docker-compose.prod.yml - - docker-compose.fullstack.yml - - Dockerfile - - .env.example - - README.md - - docs/deploy-architecture.md -autonomous: true -requirements: - - PH05-05 -must_haves: - truths: - - "Production handoff uses a bot-only compose artifact instead of the internal full-stack harness." - - "Internal E2E compose brings up the bot, platform-agent, and shared volume with explicit health-gated startup." - - "Deployment docs and env examples match the split compose artifacts and shared `/agents` contract." - artifacts: - - path: "docker-compose.prod.yml" - provides: "Bot-only deployment handoff artifact" - - path: "docker-compose.fullstack.yml" - provides: "Internal E2E harness with shared volume and dependency gating" - - path: ".env.example" - provides: "Documented runtime contract for Phase 05 deployment" - key_links: - - from: "docker-compose.fullstack.yml" - to: "docker-compose.prod.yml" - via: "shared service definition or explicit duplication" - pattern: "matrix-bot" - - from: "docs/deploy-architecture.md" - to: "docker-compose.prod.yml" - via: "operator handoff instructions" - pattern: "prod" ---- - - -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 deleted file mode 100644 index 68a62c6..0000000 --- a/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -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 deleted file mode 100644 index 6ccb0cd..0000000 --- a/.planning/phases/05-mvp-deployment/05-RESEARCH.md +++ /dev/null @@ -1,411 +0,0 @@ -# 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 deleted file mode 100644 index 6466df9..0000000 --- a/.planning/phases/05-mvp-deployment/05-VALIDATION.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -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 new file mode 100644 index 0000000..9044d2b --- /dev/null +++ b/.planning/reports/20260422-session-report.md @@ -0,0 +1,92 @@ +# GSD Session Report + +**Generated:** 2026-04-21T22:33:11.666Z +**Project:** surfaces-bot +**Milestone:** v1.0 — Production-ready surfaces + +--- + +## Session Summary + +**Duration:** Single session +**Phase Progress:** Phase 04 implemented; current follow-up work is audit, stabilization, and platform bug localization +**Plans Executed:** 0 formal GSD plans executed in this session; work was focused on post-implementation audit and cleanup +**Commits Made:** 6 + +## Work Performed + +### Phases Touched + +- **Phase 04** — Matrix MVP follow-up after implementation: + - completed audit of platform patches vs surface-owned responsibilities + - removed dependence on local platform modifications for `chat_id` + - switched Matrix integration to numeric `platform_chat_id` mapping on our side + - cleaned transport layer to a thin adapter over upstream `AgentApi` + - updated README and run instructions + - produced final Russian bug report with raw-trace-based diagnosis + +### Key Outcomes + +- Platform repos are clean and synced to pinned upstream commits. +- Matrix real backend works with numeric surrogate `platform_chat_id`. +- `surfaces` transport layer no longer owns custom stream semantics. +- Final diagnosis was narrowed: missing-first-chunk bug is now considered platform-side with direct raw evidence. +- Working state was committed and pushed on `feat/matrix-direct-agent-prototype`. + +### Decisions Made + +- Do not patch vendored platform repos for the working implementation. +- Keep `surfaces` transport layer thin and upstream-aligned. +- Treat the current streaming bug as platform-side unless new evidence disproves it. +- Do not add new local stream workarounds that would blur responsibility. + +## Files Changed + +- `README.md` +- `adapter/matrix/bot.py` +- `sdk/agent_api_wrapper.py` +- `sdk/real.py` +- `tests/platform/test_real.py` +- `tests/adapter/matrix/test_dispatcher.py` +- `tests/core/test_integration.py` +- `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` + +Planning / handoff artifacts updated: + +- `.planning/HANDOFF.json` +- `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.continue-here.md` +- `.planning/reports/20260422-session-report.md` + +## Blockers & Open Items + +- Platform-side streaming bug after tool/file flow. +- Duplicate `END` from platform. +- Image path failure on oversized `data:` URI. +- `tokens_used` remains unavailable from pinned upstream client. + +## Estimated Resource Usage + +| Metric | Estimate | +|--------|----------| +| Commits | 6 | +| Files changed | 8 code/docs files in the main deliverable, plus planning artifacts | +| Plans executed | 0 formal plans in this session | +| Subagents spawned | 0 | + +> **Note:** Token and cost estimates require API-level instrumentation. +> These metrics reflect observable session activity only. + +--- + +### Recent Commits + +- `0c2884c` — `refactor: use thin upstream transport adapter` +- `569824e` — `refactor: shrink agent api wrapper to thin adapter` +- `4d917ac` — `docs: add thin transport adapter plan` +- `3a3fcdc` — `docs: add thin transport adapter design` +- `7a2ad86` — `docs: clarify matrix file sending flow` +- `4524a6a` — `feat: finalize matrix platform audit and docs` + +--- + +*Generated by `$gsd-session-report`* diff --git a/.planning/threads/matrix-dev-prototype-agent-platform-state.md b/.planning/threads/matrix-dev-prototype-agent-platform-state.md new file mode 100644 index 0000000..facd575 --- /dev/null +++ b/.planning/threads/matrix-dev-prototype-agent-platform-state.md @@ -0,0 +1,133 @@ +# Thread: Matrix dev prototype — состояние агента и платформы + +## Status: IN PROGRESS + +## Goal + +Зафиксировать текущее состояние платформы для последующей разработки Matrix dev прототипа, +в котором команды разработки скиллов смогут быстро добавлять и обкатывать скиллы. + +## Context + +*Исследование проведено 2026-04-14. Репозитории: `external/platform-agent`, `external/platform-agent_api`, `external/platform-master`.* + +### Решение по деплою: локальный контейнер у каждого разработчика + +`platform-master` не готов для общего деплоя: +- lifecycle management контейнеров (TTL, cleanup, переиспользование сессий) — в ветке `feat/storage`, не смержено в main +- без него при общем деплое контейнеры висят вечно, ресурсы не освобождаются + +Локальный вариант: `make up-dev` — полностью рабочий, volume mount `./workspace:/workspace/`, hot reload src. + +### Архитектура изоляции контекстов + +`AgentService` — singleton с `thread_id = "default"` — это **намеренно**. Архитектура Master предполагает один контейнер `platform-agent` на один чат. Изоляция на уровне контейнеров, не thread_id. Фиксить не нужно. + +### Система скиллов (deepagents) + +`SkillsMiddleware` в `deepagents` полностью готов: +- скилл = директория с `SKILL.md` (YAML frontmatter + markdown инструкции) +- progressive disclosure: агент видит имя+описание в system prompt, читает полный файл по требованию +- загружается один раз при старте сессии, кэшируется в LangGraph state + +**НЕ подключено** в `platform-agent/src/agent/base.py` — отсутствует одна строка: +```python +skills=["/workspace/skills/"] +``` +Это задача для команды платформы. + +### Workflow разработчика скилла + +``` +workspace/ + skills/ + my-skill/ + SKILL.md ← редактируешь здесь (live через volume mount) + helper.py ← вспомогательные файлы + config/ + my-skill.json ← токены и настройки (пишет агент при первом запуске) +``` + +1. Редактируешь `SKILL.md` +2. `!new` в Matrix (новая сессия = скиллы перечитываются) +3. Проверяешь поведение +4. Повторяешь + +Агент может **сам установить скилл** из GitHub: +- `execute` → git clone +- `write_file` → положить в `/workspace/skills/` +- после `!new` скилл активен + +### Конфигурация скиллов (токены, API ключи) + +Агент управляет конфигом сам: +- первый запуск: спрашивает пользователя → пишет в `/workspace/config/skill-name.json` +- последующие запуски: читает из файла +- файл персистентен между сессиями (volume mount) + +### Входящий протокол (что принимает агент) + +`ClientMessage` — только `text: str`. Файлы и изображения не поддерживаются. +Задача для платформы — расширить протокол. + +### Исходящий протокол (что шлёт агент) + +Новые события с `origin/main` (апрель 2026): +- `AGENT_EVENT_TOOL_CALL_CHUNK` — агент вызывает инструмент +- `AGENT_EVENT_TOOL_RESULT` — результат инструмента +- `AGENT_EVENT_CUSTOM_UPDATE` — произвольный прогресс + +**Наш `sdk/agent_session.py` падает на этих событиях** (`raise PlatformError("Unexpected agent message")`). +Нужно починить — это наша задача, ~10 строк. + +### AgentApi из lambda_agent_api + +Готовый production-клиент с правильным lifecycle (`connect()`, `close()`, `send_message()` как `AsyncIterator`). +Наш `sdk/agent_session.py` дублирует его функциональность. Стоит заменить. + +### Инструменты агента из коробки + +- `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep` — файловые операции в workspace +- `execute` — shell под изолированным OS-пользователем `agent` +- `write_todos` — список задач +- `task` — вызов субагентов + +### Запуск локально + +```bash +# .env минимально необходимый: +PROVIDER_URL=https://openrouter.ai/api/v1 +PROVIDER_API_KEY=<ключ> +PROVIDER_MODEL=anthropic/claude-sonnet-4-6 + +# Dev контейнер: +make up-dev # требует AGENT_API_PATH=../platform-agent_api в env +``` + +Dev Dockerfile монтирует `./workspace:/workspace/` и `./src:/app/src` (hot reload). + +## Что нужно от платформы + +1. Добавить `skills=["/workspace/skills/"]` в `platform-agent/src/agent/base.py` +2. Поддержка файлов/изображений в `ClientMessage` (не срочно для MVP) +3. Lifecycle management контейнеров в Master (для общего деплоя, не срочно) + +## Что делаем мы + +1. Починить `sdk/agent_session.py` — обработка tool-событий вместо исключения +2. (опционально) Заменить `AgentSessionClient` на `AgentApi` из `lambda_agent_api` + +## References + +- `external/platform-agent` — локальный клон, наш патч `1dca2c1` (thread_id) поверх `1e9fa1f` +- `external/platform-agent_api` — локальный клон, актуальный (origin/master = `bb20a84`) +- `external/platform-master` — локальный клон, активная разработка в `feat/storage-s02` +- `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md` +- `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md` + +## Next Steps + +1. Запросить у команды платформы: подключение `SkillsMiddleware` в `base.py` +2. Починить `sdk/agent_session.py` — обработать tool-события +3. Написать первый тестовый скилл (`workspace/skills/hello/SKILL.md`) и проверить end-to-end +4. Документировать workflow для разработчиков скиллов diff --git a/.planning/threads/matrix-file-ingestion-context.md b/.planning/threads/matrix-file-ingestion-context.md new file mode 100644 index 0000000..0ccb079 --- /dev/null +++ b/.planning/threads/matrix-file-ingestion-context.md @@ -0,0 +1,81 @@ +# Thread: Matrix file ingestion and agent-visible storage contract + +## Status: IN PROGRESS + +## Goal + +Сохранить текущий контекст сессии для следующего агента и зафиксировать следующую архитектурную развилку: как принимать вложения из Matrix и делать их доступными агенту. + +## Current State + +Phase 4 Matrix MVP уже собран и проверен на уровне per-room routing: +- обычные сообщения теперь идут в `platform_chat_id`, а не в общий локальный `C1/C2` +- `!context` показывает состояние текущего Matrix-чата +- `!save` и `!load` привязаны к текущему room-context +- `PrototypeStateStore` хранит live state per context +- последние изменения закоммичены в `feat/matrix-direct-agent-prototype` + +Коммиты, которые важно знать: +- `c11c8ec` `feat(task-5): scope matrix context state per room` +- `07c5078` `feat(task-7): verify matrix per-room context routing` + +## What We Learned About Platform Runtime + +Текущий `external/platform-agent` не является отдельным контейнером на чат. +Фактическая модель сейчас такая: +- один FastAPI-процесс +- singleton `AgentService` +- `thread_id` используется как ключ памяти в LangGraph, а не как контейнерная изоляция +- файловой изоляции на чат сейчас нет +- `/workspace` как общий mount для Matrix bot и platform-agent сейчас не настроен +- отдельного upload API для вложений в текущем коде не видно + +Ключевые файлы: +- `external/platform-agent/src/api/external.py` +- `external/platform-agent/src/agent/service.py` +- `external/platform-agent/src/agent/base.py` + +## File Handling Requirement + +Пользовательский запрос на текущем этапе: +- принимать файл или сообщение с файлом из Matrix +- сохранять файл локально +- передавать агенту явный сигнал, что к сообщению есть вложения +- сообщать, где лежит файл + +Но есть техническое ограничение: +- если Matrix bot пишет файл только в своём контейнере, platform-agent его не увидит +- значит нужен либо общий storage, либо upload в платформу, либо контейнеризация platform-agent с общим volume + +## Recommended Design Direction + +Самый прагматичный MVP-вариант: +- хранить вложения в общем каталоге, который виден и Matrix bot, и platform-agent +- формировать для агента структурированный payload с: + - локальным путём + - original filename + - mime type + - attachment type +- если есть текст пользователя, дополнять сообщение краткой summary-подсказкой про вложения +- если прислан только файл, отправлять synthetic message вроде “пользователь прислал файл” + +Если общий каталог невозможен в текущем runtime: +- следующий вариант это upload endpoint в platform-agent +- Matrix surface скачивает файл и загружает его в платформу, а платформа уже кладёт его в своё доступное хранилище + +## Open Questions + +1. Где должен жить shared storage: host path, docker volume или platform-side volume? +2. Нужен ли немедленный upload API в platform-agent, или сначала достаточно shared path? +3. Должны ли файлы быть scoped per room/platform_chat_id, а не per user? + +## Next Step For Another Agent + +1. Подтвердить runtime-модель хранения файлов. +2. Проверить, как сейчас запускаются Matrix bot и platform-agent в реальной dev-схеме. +3. После выбора storage contract начать с изменений в Matrix attachment ingestion. + +## Notes + +- Контекст этой сессии сохранён как отдельный thread, потому что текущий следующий рискованный шаг уже не про context routing, а про файловый transport. +- Не смешивать этот трек с незавершённой историей про `!branch`: upstream branch/snapshot API всё ещё не подтверждён. diff --git a/Dockerfile b/Dockerfile index c04d98a..0dbb156 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,27 @@ -FROM python:3.11-slim AS base +FROM python:3.11-slim WORKDIR /app -RUN useradd -u 1000 -m appuser -USER appuser ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=/app ENV UV_PROJECT_ENVIRONMENT=/usr/local -# 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 +# Install uv for dependency management inside the container. +RUN 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 - -FROM base AS development +RUN uv sync --no-dev --no-install-project --frozen 2>/dev/null || uv sync --no-dev --no-install-project +# Copy project source after dependency layers. COPY . . -RUN uv sync --no-dev --frozen -# 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}" +# 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 CMD ["python", "-m", "adapter.matrix.bot"] diff --git a/README.md b/README.md index 51e92f9..93782c6 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,23 @@ # Lambda Lab 3.0 — Surfaces -Matrix-бот для взаимодействия пользователя с AI-агентом Lambda. - -## Интеграция для платформы - -Бот — это один 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 не входят и управляются платформой - ---- +Команда поверхностей. Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda. ## Статус -Matrix MVP готов к деплою. Telegram — в отдельном worktree, не входит в этот handoff. +| Поверхность | Статус | +|---|---| +| Telegram | 🔨 В разработке, отдельный worktree `feat/telegram-adapter` | +| Matrix | ✅ Рабочий прототип, запускается через root `docker compose` вместе с `platform-agent` | + +--- + +## Концепция + +Пользователь получает персонального AI-агента через привычный мессенджер. +Агент выполняет реальные задачи: разбирает почту, ищет информацию, работает с файлами, управляет календарём. + +**Поверхности** — тонкие клиенты. Вся бизнес-логика на стороне платформы. +Задача команды: сделать интерфейс удобным, надёжным и легко расширяемым. --- @@ -59,224 +28,252 @@ surfaces-bot/ core/ — общее ядро, не зависит от транспорта protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...) handler.py — EventDispatcher: IncomingEvent → OutgoingEvent + handlers/ — обработчики по типам событий store.py — StateStore Protocol + InMemoryStore + SQLiteStore - chat.py — ChatManager - auth.py — AuthManager - settings.py — SettingsManager + chat.py — ChatManager: метаданные чатов C1/C2/C3 + auth.py — AuthManager: аутентификация + settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность adapter/ + telegram/ — aiogram 3.x адаптер matrix/ — matrix-nio адаптер sdk/ interface.py — PlatformClient Protocol (контракт к SDK) - real.py — RealPlatformClient (через AgentApi) - mock.py — MockPlatformClient (заглушка для тестов) - - config/ - matrix-agents.yaml — реестр агентов + mock.py — MockPlatformClient (заглушка) docs/ — документация + .claude/agents/ — агенты для Claude Code ``` -Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md) +**Ключевой принцип:** добавить новую поверхность = написать один адаптер-конвертер. +Ядро (`core/`) не трогается. Подробнее: [`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=... -`config/matrix-agents.yaml` — статический маппинг пользователей на агентов: +# Выбор backend: mock (по умолчанию) или real (подключение к platform-agent) +MATRIX_PLATFORM_BACKEND=real -```yaml -user_agents: - "@user0:matrix.lambda.coredump.ru": agent-0 - "@user1:matrix.lambda.coredump.ru": agent-1 +# compose runtime: platform-agent service name + shared /workspace +AGENT_BASE_URL=http://platform-agent:8000 +SURFACES_WORKSPACE_DIR=/workspace -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" +# platform-agent provider +PROVIDER_MODEL=openai/gpt-4o-mini +PROVIDER_URL=https://openrouter.ai/api/v1 +PROVIDER_API_KEY=... ``` -- `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`. +### 3. Compose runtime -Полный пример с комментариями: `config/matrix-agents.example.yaml` +Root `docker-compose.yml` теперь является основным локальным runtime для Matrix и platform-agent. +Он поднимает `matrix-bot`, `platform-agent` и общий volume `/workspace`. -### Production (bot-only) - -`docker-compose.prod.yml` — bot-only handoff через published image. Платформа добавляет этот сервис в свой compose рядом с agent containers, монтирует shared volume и задаёт переменные окружения. Этот compose не создаёт и не собирает агент-контейнеры. - -Перед redeploy можно проверить реальные agent routes из той же сети, где будет работать бот: ```bash -PYTHONPATH=. uv run python -m tools.check_matrix_agents \ - --config config/matrix-agents.yaml \ - --timeout 5 +docker compose up --build ``` -Проверка открывает фактический WebSocket URL каждого агента (`.../v1/agent_ws/{chat_id}/`) и ждёт первый `STATUS`. Для проверки полного запроса к агенту добавьте `--message "ping"`. +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`. -Для запуска опубликованного image: -```bash -export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest -docker compose --env-file .env -f docker-compose.prod.yml up -d -``` +На `2026-04-21` локальный compose runtime использует vendored upstream-версии платформы без локальных патчей: -Опубликованный image: +- `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 файлами. + +Пример: ```text -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" -``` - -Если push возвращает `insufficient_scope`, текущий Docker login не имеет доступа к namespace/repository. Создайте repository в нужном Docker Hub namespace или перетегируйте image в namespace пользователя, под которым выполнен `docker login`. - -### Fullstack E2E (bot + agent) - -```bash -docker compose --env-file .env -f docker-compose.fullstack.yml up --build -``` - -Поднимает `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` в агенте. - -### Сброс состояния (локально) - -```bash -rm -f lambda_matrix.db && rm -rf matrix_store -``` - ---- - -## Shared volume: передача файлов - -``` -Bot (/agents) Agent (/workspace = /agents/N/) - /agents/0/report.pdf ←──── одно и то же хранилище ────→ /workspace/report.pdf - /agents/0/result.txt ←────────────────────────────────→ /workspace/result.txt -``` - -- **Входящий файл** (пользователь → агент): бот сохраняет в `{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-клиент отправляет файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь. - -``` -[отправил файл] +[отправил 2 изображения] !list - 1. report.pdf +1. IMG_3183.png +2. minion.jpeg -прочитай и сделай summary ← файл уйдёт агенту вместе с этим текстом +что изображено на фото ``` ---- +В этом сценарии вопрос `что изображено на фото` будет отправлен агенту вместе с обоими файлами. -## Известные ограничения +Важно: -| Проблема | Причина | -|---|---| -| История теряется при рестарте агента | `platform-agent` использует `MemorySaver` (in-memory) | -| E2EE | `python-olm` не собирается на macOS/ARM | +- если после файлов отправить `!list` или `!remove`, агент не вызывается +- если платформа вернула ошибку на этих вложениях, они остаются в staged-очереди +- в таком случае следующее обычное сообщение снова попытается отправить те же файлы +- чтобы разорвать этот цикл, используй `!remove ` или `!remove all` ---- +Известное ограничение текущего platform-agent: -## Разработка +- большие изображения могут не пройти в provider из-за лимита на размер data URI +- в таком случае Matrix-бот ответит `Сервис временно недоступен...`, а проблемные файлы останутся в очереди до явного удаления + +### 5. Запуск бота вручную ```bash -uv sync -pytest tests/ -v -pytest tests/adapter/matrix/ -v # только Matrix +# Первый запуск или сброс состояния +rm -f lambda_matrix.db && rm -rf matrix_store + +PYTHONPATH=. uv run python -m adapter.matrix.bot ``` +### 6. Онбординг пользователя + +Напиши боту в **личные сообщения (DM)** на Matrix-сервере. Для поддерживаемого dev-сценария используй незашифрованную комнату: E2EE сейчас не считается поддержанным режимом для Matrix-поверхности. + +Бот автоматически: +1. Создаст private Space `Lambda — {твоё имя}` +2. Создаст рабочую комнату `Чат 1` и пригласит туда + +Дальнейшее общение ведётся в рабочей комнате, не в DM. + +--- + +## Функционал Matrix MVP + +### Работает + +| Функция | Команда | Примечание | +|---|---|---| +| Онбординг | *(автоматически при 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. Для других поверхностей ещё не перенесено. | + +--- + ## Документация | Файл | Содержание | |---|---| -| [`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 и др.) | +| [`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, МАИ diff --git a/adapter/matrix/agent_registry.py b/adapter/matrix/agent_registry.py deleted file mode 100644 index bf02018..0000000 --- a/adapter/matrix/agent_registry.py +++ /dev/null @@ -1,125 +0,0 @@ -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 411f037..debd2fa 100644 --- a/adapter/matrix/bot.py +++ b/adapter/matrix/bot.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import logging import os import re from dataclasses import dataclass @@ -25,7 +24,6 @@ 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, @@ -33,18 +31,11 @@ from adapter.matrix.files import ( resolve_workspace_attachment_path, ) from adapter.matrix.handlers import register_matrix_handlers -from adapter.matrix.handlers.auth import ( - default_agent_notice, - handle_invite, - provision_workspace_chat, - restore_workspace_access, -) +from adapter.matrix.handlers.auth import handle_invite, provision_workspace_chat 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, @@ -92,8 +83,6 @@ 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: @@ -102,7 +91,6 @@ 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 ) @@ -110,7 +98,6 @@ 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, ) @@ -123,26 +110,6 @@ 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 @@ -151,66 +118,14 @@ def _agent_base_url_from_env() -> str: return "http://127.0.0.1:8000" -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: +def _build_platform_from_env() -> 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": - 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 RealPlatformClient( + agent_id="matrix-bot", + agent_base_url=_agent_base_url_from_env(), + prototype_state=PrototypeStateStore(), + platform="matrix", ) return MockPlatformClient() @@ -220,15 +135,13 @@ 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 ) @@ -237,7 +150,6 @@ def build_runtime( dispatcher, client=client, store=store, - registry=registry, prototype_state=prototype_state, agent_base_url=agent_base_url, ) @@ -248,8 +160,6 @@ def build_runtime( auth_mgr=auth_mgr, settings_mgr=settings_mgr, dispatcher=dispatcher, - agent_routing_enabled=isinstance(platform, RoutedPlatformClient), - registry=registry, ) @@ -271,36 +181,6 @@ 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 @@ -309,14 +189,6 @@ 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"): @@ -330,97 +202,17 @@ 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=text, + text=( + f"Рабочий чат уже создан: {redirect_chat_id}. " + "Открой приглашённую комнату для продолжения." + ), ) ], ) @@ -431,11 +223,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) - incoming = from_room_event(event, room_id=room.room_id, chat_id=local_chat_id) + 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) if incoming is None: return if isinstance(incoming, IncomingCommand) and incoming.command in { @@ -470,17 +262,6 @@ 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: @@ -493,14 +274,14 @@ class MatrixBot: ) outgoing = [ OutgoingMessage( - chat_id=local_chat_id, + chat_id=dispatch_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, workspace_root=workspace_root) + await self._send_all(room.room_id, outgoing) def _is_file_only_event( self, event: RoomMessage, incoming: IncomingMessage | IncomingCommand @@ -620,27 +401,13 @@ 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: - 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) + workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) materialized = [] for attachment in incoming.attachments: materialized.append( @@ -678,7 +445,6 @@ class MatrixBot: self.runtime.store, self.runtime.auth_mgr, self.runtime.chat_mgr, - registry=self.runtime.registry, ) except Exception as exc: logger.warning( @@ -698,8 +464,6 @@ 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, @@ -790,23 +554,11 @@ 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], - workspace_root: Path | None = None, - ) -> None: + async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None: for event in outgoing: - await send_outgoing( - self.client, - room_id, - event, - store=self.runtime.store, - workspace_root=workspace_root, - ) + await send_outgoing(self.client, room_id, event, store=self.runtime.store) async def prepare_live_sync(client: AsyncClient) -> str | None: @@ -821,7 +573,6 @@ 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) @@ -836,9 +587,7 @@ async def send_outgoing( room_id, "m.room.message", {"msgtype": "m.text", "body": event.text} ) if event.attachments: - workspace_root = workspace_root or Path( - os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace") - ) + workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) for attachment in event.attachments: if not attachment.workspace_path: continue @@ -895,7 +644,6 @@ 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", "") @@ -927,7 +675,6 @@ 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( @@ -949,15 +696,6 @@ 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 0845684..a736fba 100644 --- a/adapter/matrix/files.py +++ b/adapter/matrix/files.py @@ -2,16 +2,16 @@ from __future__ import annotations import mimetypes import re -from pathlib import Path, PurePosixPath +from datetime import UTC, datetime +from pathlib import Path from core.protocol import Attachment -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 _sanitize_component(value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value) + cleaned = cleaned.strip("._-") + return cleaned or "unknown" def _default_filename(attachment: Attachment) -> str: @@ -28,38 +28,22 @@ def _default_filename(attachment: Attachment) -> str: return f"{base}{extension}" -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( +def build_workspace_attachment_path( *, workspace_root: Path, + matrix_user_id: str, + room_id: str, filename: str, + timestamp: str | None = None, ) -> tuple[str, 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) + 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 async def download_matrix_attachment( @@ -75,13 +59,13 @@ async def download_matrix_attachment( return attachment filename = _default_filename(attachment) - - del matrix_user_id, room_id, timestamp - relative_path, absolute_path = build_agent_workspace_path( + relative_path, absolute_path = build_workspace_attachment_path( workspace_root=workspace_root, + matrix_user_id=matrix_user_id, + room_id=room_id, filename=filename, + timestamp=timestamp, ) - absolute_path.parent.mkdir(parents=True, exist_ok=True) response = await client.download(attachment.url) diff --git a/adapter/matrix/handlers/__init__.py b/adapter/matrix/handlers/__init__.py index 30adf59..c028735 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, registry)) + dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) 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) - 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, + "reset", + make_handle_reset(store, prototype_state) + if prototype_state is not None + else 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 064448d..9ad43fb 100644 --- a/adapter/matrix/handlers/auth.py +++ b/adapter/matrix/handlers/auth.py @@ -6,7 +6,6 @@ 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, @@ -22,31 +21,6 @@ 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, @@ -56,7 +30,6 @@ 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, @@ -91,14 +64,6 @@ 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, @@ -135,8 +100,6 @@ 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( @@ -153,64 +116,6 @@ 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, } @@ -222,7 +127,6 @@ 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 @@ -231,29 +135,6 @@ 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: @@ -266,7 +147,6 @@ 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)) @@ -274,10 +154,8 @@ async def handle_invite( welcome = ( f"Привет, {created['user'].display_name or matrix_user_id}! Пиши — я здесь.\n\n" - "Команды: !new · !chats · !rename · !archive · !clear · !help" + "Команды: !new · !chats · !rename · !archive · !context · !save · !load · !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 645e9cd..6ce267c 100644 --- a/adapter/matrix/handlers/chat.py +++ b/adapter/matrix/handlers/chat.py @@ -7,8 +7,6 @@ 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, @@ -50,7 +48,6 @@ 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 @@ -107,24 +104,18 @@ def make_handle_new_chat( state_key=room_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) + 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, + }, + ) ctx = await chat_mgr.get_or_create( user_id=event.user_id, chat_id=chat_id, @@ -132,13 +123,10 @@ 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=text, + text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})", ) ] diff --git a/adapter/matrix/handlers/context_commands.py b/adapter/matrix/handlers/context_commands.py index 121d76b..648978d 100644 --- a/adapter/matrix/handlers/context_commands.py +++ b/adapter/matrix/handlers/context_commands.py @@ -59,17 +59,6 @@ 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 @@ -96,16 +85,11 @@ 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}")] - 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="Контекст комнаты не готов. Попробуй позже.")] - + _, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) await prototype_state.add_saved_session( event.user_id, name, - source_context_id=platform_chat_id, + source_context_id=platform_chat_id or event.chat_id, ) return [ OutgoingMessage( @@ -148,11 +132,9 @@ def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore): async def handle_reset( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: - 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="Контекст комнаты не готов. Попробуй позже.")] + 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 new_chat_id = await next_platform_chat_id(store) await set_platform_chat_id(store, room_id, new_chat_id) @@ -161,7 +143,6 @@ 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 [ @@ -201,19 +182,20 @@ def make_handle_context(store: StateStore, prototype_state: PrototypeStateStore) async def handle_context( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: - 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) + _, 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) sessions = await prototype_state.list_saved_sessions(event.user_id) lines = [ "Контекст:", - f" Контекст чата: {platform_chat_id}", + f" Контекст чата: {platform_chat_id or event.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 59bee6b..07e64c0 100644 --- a/adapter/matrix/handlers/settings.py +++ b/adapter/matrix/handlers/settings.py @@ -10,15 +10,11 @@ HELP_TEXT = "\n".join( "!chats список активных чатов", "!rename <название> переименовать текущий чат", "!archive архивировать текущий чат", + "!context показать текущее состояние контекста", + "!save [имя] сохранить текущий контекст", + "!load показать сохранённые контексты", "", - "!clear сбросить контекст текущего чата", - "", - "!list показать файлы в очереди", - "!remove удалить файл из очереди", - "!remove all очистить очередь файлов", - "", - "!yes / !no подтвердить или отменить действие", - "!help эта справка", + "Остальные команды и настройки скрыты в MVP, чтобы не вводить в заблуждение.", ] ) diff --git a/adapter/matrix/reconciliation.py b/adapter/matrix/reconciliation.py deleted file mode 100644 index 835bd5d..0000000 --- a/adapter/matrix/reconciliation.py +++ /dev/null @@ -1,180 +0,0 @@ -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 deleted file mode 100644 index 3f9adc8..0000000 --- a/adapter/matrix/routed_platform.py +++ /dev/null @@ -1,133 +0,0 @@ -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 8ecd557..e835ace 100644 --- a/adapter/matrix/store.py +++ b/adapter/matrix/store.py @@ -45,12 +45,6 @@ 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 deleted file mode 100644 index 84221eb..0000000 --- a/config/matrix-agents.example.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# 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 deleted file mode 100644 index 9b357fe..0000000 --- a/config/matrix-agents.smoke.yaml +++ /dev/null @@ -1,10 +0,0 @@ -agents: - - id: agent-0 - label: "Smoke Agent 0" - base_url: "http://agent-proxy:7000/agent_0/" - workspace_path: "/agents/0" - - - id: agent-1 - label: "Smoke Agent 1" - base_url: "http://agent-proxy:7000/agent_1/" - workspace_path: "/agents/1" diff --git a/config/matrix-agents.yaml b/config/matrix-agents.yaml deleted file mode 100644 index 3ab9366..0000000 --- a/config/matrix-agents.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# 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 876754c..d9f91cd 100644 --- a/core/handlers/message.py +++ b/core/handlers/message.py @@ -1,35 +1,7 @@ # core/handlers/message.py from __future__ import annotations -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 +from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping def _start_command(platform: str) -> str: @@ -66,6 +38,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=_to_core_attachments(getattr(response, "attachments", [])), + attachments=list(getattr(response, "attachments", [])), ), ] diff --git a/docker-compose.fullstack.yml b/docker-compose.fullstack.yml deleted file mode 100644 index 88ff37b..0000000 --- a/docker-compose.fullstack.yml +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 2c7e942..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index c8f4ba3..0000000 --- a/docker-compose.smoke.timeout.yml +++ /dev/null @@ -1,18 +0,0 @@ -services: - agent-proxy: - volumes: - - ./docker/nginx/smoke-agents-timeout.conf:/etc/nginx/nginx.conf:ro - depends_on: - agent-no-status: - condition: service_started - - agent-no-status: - build: - context: . - dockerfile: Dockerfile - target: production - args: - LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master} - environment: - PYTHONUNBUFFERED: "1" - command: ["python", "-m", "tools.no_status_agent", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.smoke.yml b/docker-compose.smoke.yml deleted file mode 100644 index ed4e8b8..0000000 --- a/docker-compose.smoke.yml +++ /dev/null @@ -1,109 +0,0 @@ -services: - surface-smoke: - build: - context: . - dockerfile: Dockerfile - target: production - args: - LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master} - environment: - PYTHONUNBUFFERED: "1" - SMOKE_TIMEOUT: ${SMOKE_TIMEOUT:-5} - volumes: - - agents:/agents - - ./config:/app/config:ro - depends_on: - agent-proxy: - condition: service_healthy - command: > - sh -lc " - python -m tools.check_matrix_agents --config /app/config/matrix-agents.smoke.yaml --timeout ${SMOKE_TIMEOUT:-5} - " - - agent-proxy: - image: nginx:1.27-alpine - volumes: - - ./docker/nginx/smoke-agents.conf:/etc/nginx/nginx.conf:ro - healthcheck: - test: - - CMD-SHELL - - nc -z 127.0.0.1 7000 - interval: 2s - timeout: 2s - retries: 15 - start_period: 2s - depends_on: - agent-0: - condition: service_healthy - agent-1: - condition: service_healthy - ports: - - "${SMOKE_PROXY_PORT:-7000}:7000" - - agent-0: - build: - context: ./external/platform-agent - target: development - additional_contexts: - agent_api: ./external/platform-agent_api - environment: - PYTHONUNBUFFERED: "1" - AGENT_ID: ${AGENT_0_ID:-agent-0} - PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model} - PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1} - PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key} - volumes: - - ./external/platform-agent/src:/app/src - - ./external/platform-agent_api:/agent_api - - agents:/shared-agents - healthcheck: - test: - - CMD-SHELL - - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()" - interval: 5s - timeout: 3s - retries: 12 - start_period: 5s - command: > - sh -lc " - mkdir -p /shared-agents/0 && - rm -rf /workspace && - ln -s /shared-agents/0 /workspace && - exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log - " - - agent-1: - build: - context: ./external/platform-agent - target: development - additional_contexts: - agent_api: ./external/platform-agent_api - environment: - PYTHONUNBUFFERED: "1" - AGENT_ID: ${AGENT_1_ID:-agent-1} - PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model} - PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1} - PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key} - volumes: - - ./external/platform-agent/src:/app/src - - ./external/platform-agent_api:/agent_api - - agents:/shared-agents - healthcheck: - test: - - CMD-SHELL - - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()" - interval: 5s - timeout: 3s - retries: 12 - start_period: 5s - command: > - sh -lc " - mkdir -p /shared-agents/1 && - rm -rf /workspace && - ln -s /shared-agents/1 /workspace && - exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log - " - -volumes: - agents: - name: ${SURFACES_SMOKE_VOLUME:-surfaces-smoke-agents} diff --git a/docker-compose.yml b/docker-compose.yml index c7323d0..4de9fac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,6 @@ 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 deleted file mode 100644 index 03c7e79..0000000 --- a/docker/nginx/smoke-agents-timeout.conf +++ /dev/null @@ -1,28 +0,0 @@ -events {} - -http { - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - server { - listen 7000; - - location /agent_0/ { - proxy_pass http://agent-0:8000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } - - location /agent_1/ { - proxy_pass http://agent-no-status:8000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } - } -} diff --git a/docker/nginx/smoke-agents.conf b/docker/nginx/smoke-agents.conf deleted file mode 100644 index e3bcaab..0000000 --- a/docker/nginx/smoke-agents.conf +++ /dev/null @@ -1,28 +0,0 @@ -events {} - -http { - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - server { - listen 7000; - - location /agent_0/ { - proxy_pass http://agent-0:8000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } - - location /agent_1/ { - proxy_pass http://agent-1:8000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } - } -} diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..10fd899 --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,143 @@ +# API Contract — Lambda Platform + +> **Статус:** ЧЕРНОВИК — проектируем сами, уточняем с Азаматом когда SDK будет готов +> **Последнее обновление:** 2026-03-29 + +--- + +## Архитектурный контекст + +Каждому пользователю выделяется **один LXC-контейнер** с workspace 10 ГБ. +Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — файлы + `history.db` в каждом. + +**Master** управляет lifecycle контейнера (запуск, заморозка, пробуждение). +Бот **не управляет lifecycle** — он передаёт `user_id` + `chat_id` + сообщение. +Master сам решает: нужно ли поднять контейнер, смонтировать нужный чат, запустить агента. + +--- + +## Base URL + +``` +https://api.lambda-platform.io/v1 +``` + +## Аутентификация + +``` +Authorization: Bearer {SERVICE_TOKEN} +``` + +Сервисный токен выдаётся команде поверхностей. Не путать с токеном пользователя. + +--- + +## Users + +### GET /users/{external_id}?platform={platform} + +Получает или создаёт пользователя. + +**Query params:** +- `platform` — `telegram` | `matrix` + +**Response 200:** +```json +{ + "user_id": "usr_abc123", + "external_id": "12345678", + "platform": "telegram", + "display_name": "Иван Иванов", + "created_at": "2025-01-15T10:30:00Z", + "is_new": false +} +``` + +--- + +## Messages + +Бот не управляет сессиями явно. Отправка сообщения — единственная операция. +Master решает: нужен ли новый контейнер, или разбудить существующий. + +### POST /users/{user_id}/chats/{chat_id}/messages + +Отправляет сообщение пользователя агенту. Master поднимает/размораживает контейнер, +монтирует нужный чат (`C1/`, `C2/`...), запускает агента. + +**Request:** +```json +{ + "text": "Привет, что ты умеешь?", + "attachments": [] +} +``` + +**Response 200:** +```json +{ + "message_id": "msg_qwe012", + "response": "Я AI-агент Lambda...", + "tokens_used": 142, + "finished": true +} +``` + +--- + +## Settings + +### GET /users/{user_id}/settings + +Настройки пользователя: скиллы, коннекторы, SOUL, безопасность, план. + +**Response 200:** +```json +{ + "skills": {"web-search": true, "browser": false}, + "connectors": {"gmail": {"connected": true, "email": "user@gmail.com"}}, + "soul": {"name": "Лямбда", "style": "friendly"}, + "safety": {"email-send": true, "file-delete": true}, + "plan": {"name": "Beta", "tokens_used": 800, "tokens_limit": 1000} +} +``` + +### POST /users/{user_id}/settings + +Применяет действие над настройками. + +**Request:** +```json +{ + "action": "toggle_skill", + "payload": {"skill": "browser", "enabled": true} +} +``` + +**Response 200:** +```json +{"ok": true} +``` + +--- + +## Error format + +```json +{ + "error": "ERROR_CODE", + "message": "Human readable description", + "details": {} +} +``` + +Коды ошибок: `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR`, `CONTAINER_UNAVAILABLE` + +--- + +## Открытые вопросы к команде платфрмы (SDK) + +- [ ] Точный формат эндпоинта отправки сообщения — URL, поля +- [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую? +- [ ] Стриминговый ответ (SSE / WebSocket) или только sync? +- [ ] Формат `SettingsAction` — совпадает с нашим или другой? diff --git a/docs/deploy-architecture.md b/docs/deploy-architecture.md deleted file mode 100644 index e838611..0000000 --- a/docs/deploy-architecture.md +++ /dev/null @@ -1,197 +0,0 @@ -# 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 2367dc5..8f1dcee 100644 --- a/docs/matrix-direct-agent-prototype-ru.md +++ b/docs/matrix-direct-agent-prototype-ru.md @@ -1,8 +1,5 @@ # 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 d79ff83..bebf0b4 100644 --- a/docs/matrix-prototype.md +++ b/docs/matrix-prototype.md @@ -4,101 +4,263 @@ Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space. -При первом invite бот создаёт для пользователя личное пространство (Space) и первую рабочую комнату. -История хранится нативно в Matrix. UX прагматичный: явные `!`-команды, локальный state-store, нативные Matrix rooms. +При первом входе бот создаёт для пользователя личное пространство (Space) — +это как папка в Element. Внутри Space бот создаёт комнату для каждого нового +чата с агентом. Пользователь видит аккуратную структуру: одно пространство, +внутри — список чатов. История хранится нативно в Matrix — это часть протокола, +ничего дополнительно делать не нужно. -Matrix — внутренняя поверхность: команда лаборатории, тестировщики, разработчики скиллов. +Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики, +разработчики скиллов. Поэтому UX здесь прагматичный: минимум магии, явные +команды `!`, локальный state-store и нативные Matrix rooms. --- -## Онбординг +## Аутентификация -1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере -2. Бот принимает invite, создаёт Space `Lambda — {display_name}` и первую комнату `Чат 1` -3. Приглашает пользователя в `Чат 1` и пишет приветствие -4. Дальнейшее общение ведётся в рабочих комнатах, не в DM +### Флоу +1. Пользователь приглашает бота в личные сообщения или пишет в общей комнате +2. Бот проверяет `@user:matrix.org` — есть ли аккаунт на платформе +3. Если нет — бот отправляет одноразовый код или ссылку +4. Пользователь подтверждает, платформа возвращает токен +5. Бот сохраняет привязку `matrix_user_id → platform_user_id` +### В моке +- Любой пользователь проходит аутентификацию автоматически +- Бот отвечает: «Добро пожаловать, {display_name}. Создаю ваше пространство...» +- Демонстрирует флоу без реальной платформы + +--- + +## Чаты через Space + комнаты (вариант Б) + +### Структура ``` Space: «Lambda — {display_name}» - ├── 💬 Чат 1 ← создаётся автоматически при invite + ├── 💬 Чат 1 ← первый чат, создаётся автоматически ├── 💬 Чат 2 - └── 💬 Исследование рынка ← пользователь называет сам через !new + └── 💬 Исследование рынка ← пользователь сам называет ``` -**Требование:** незашифрованные комнаты. E2EE не поддержан (инфраструктурное ограничение). - ---- - -## Работающие команды +### Создание Space +При первом входе бот: +1. Создаёт Space `Lambda — {display_name}` +2. Создаёт первую комнату-чат `Чат 1` +3. Передаёт `invite=[matrix_user_id]` прямо в `room_create(...)` для Space и комнаты +4. Привязывает `chat_id ↔ room_id` в локальном состоянии +5. Пишет приветствие в `Чат 1` ### Управление чатами +Команды работают в зарегистрированных комнатах бота: | Команда | Действие | |---|---| | `!new` | Создать новый чат (новую комнату в Space) | | `!new Название` | Создать чат с именем | -| `!chats` | Список активных чатов | -| `!rename <название>` | Переименовать текущую комнату | -| `!archive` | Архивировать чат | -| `!help` | Справка | +| `!help` | Показать шпаргалку по доступным командам | +| `!rename Название` | Переименовать текущую комнату | +| `!archive` | Архивировать чат и вывести бота из комнаты | +| `!chats` | Показать список чатов | +| `!settings`, `!skills`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` | Настройки и диагностика | -### Контекст +### Создание нового чата +1. Пользователь пишет `!new` или `!new Анализ конкурентов` +2. Бот создаёт новую комнату в Space +3. Сразу приглашает пользователя через `room_create(..., invite=[user_id])` +4. Регистрирует комнату в локальном состоянии и `ChatManager` +5. Пользователь переходит в новую комнату — начинает диалог -| Команда | Действие | -|---|---| -| `!clear` | Сбросить контекст текущего чата (создаёт новый thread у агента) | -| `!reset` | Псевдоним для `!clear` | +### В моке +- Space и комнаты создаются реально через matrix-nio +- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) +- История хранится в Matrix нативно +- Дефолтные `skills`, `safety`, `soul`, `plan` подмешиваются даже после частичных локальных обновлений настроек -### Подтверждения +### Переименование и архивирование -| Команда | Действие | -|---|---| -| `!yes` | Подтвердить действие агента | -| `!no` | Отменить действие агента | - -### Вложения (файловая очередь) - -Matrix-клиенты отправляют файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь, а не уходит агенту сразу. - -| Команда | Действие | -|---|---| -| `!list` | Показать файлы в очереди | -| `!remove ` | Удалить файл из очереди по номеру | -| `!remove all` | Очистить всю очередь | - -Как отправить файлы агенту: -1. Отправь один или несколько файлов в рабочую комнату -2. Напиши текстовое сообщение с инструкцией, например: `что на изображении?` -3. Бот отправит агенту текст вместе со всеми файлами из очереди +- `!rename` обновляет имя комнаты через state event `m.room.name` +- `!archive` архивирует чат в `ChatManager` и делает `room_leave(...)` +- Если бот потерял локальное состояние и видит комнату как `unregistered:*`, то `!rename` и `!archive` возвращают защитное сообщение вместо сломанного действия --- -## Диалог +## Основной диалог -- Любое текстовое сообщение уходит агенту, бот показывает typing-индикатор -- Ответ стримится по WebSocket и выводится в ту же комнату -- Каждая комната имеет свой `platform_chat_id` — контексты изолированы между комнатами +### Флоу сообщения +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) + └── Готово. Отчёт: [...] +``` --- -## Передача файлов +## Настройки и диагностика -### Пользователь → Агент -Бот сохраняет файл в shared volume: `{workspace_path}/{filename}` -и передаёт агенту относительный путь как `workspace_path`. +Отдельной комнаты `Настройки` в текущей версии нет. Команды вызываются как обычные +`!`-команды из зарегистрированных комнат бота, а `!settings` отдаёт сводный dashboard +по скиллам, личности, безопасности и активным чатам. -### Агент → Пользователь -Агент эмитит путь к файлу в своём workspace. Бот читает файл из `/agents/...` -и отправляет пользователю как Matrix file message. +### Коннекторы +``` +!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 +``` --- -## Известные ограничения +## FSM состояния -| Проблема | Причина | -|---|---| -| `!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 платформы | +``` +[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 останутся, но команды, завязанные на локальную регистрацию чатов, перестанут работать для этих комнат до повторного онбординга diff --git a/docs/new-surface-guide.md b/docs/new-surface-guide.md deleted file mode 100644 index 7ebdc2a..0000000 --- a/docs/new-surface-guide.md +++ /dev/null @@ -1,313 +0,0 @@ -# Руководство по созданию новой поверхности - -Этот документ описывает, как написать новую новую поверхность (например, 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 deleted file mode 100644 index a5227e8..0000000 --- a/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md +++ /dev/null @@ -1,855 +0,0 @@ -# Matrix Multi-Agent Routing And Restart State Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart. - -**Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart. - -**Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio - ---- - -## File Structure - -- Create: `adapter/matrix/agent_registry.py` - Purpose: load and validate the YAML agent registry used by Matrix runtime. -- Create: `adapter/matrix/routed_platform.py` - Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances. -- Create: `adapter/matrix/handlers/agent.py` - Purpose: implement `!agent` listing and selection behavior. -- Create: `tests/adapter/matrix/test_agent_registry.py` - Purpose: cover YAML loading and registry validation. -- Create: `tests/adapter/matrix/test_routed_platform.py` - Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol. -- Create: `tests/adapter/matrix/test_agent_handler.py` - Purpose: cover `!agent` UX and persistence of `selected_agent_id`. -- Create: `tests/adapter/matrix/test_restart_persistence.py` - Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite. -- Create: `config/matrix-agents.example.yaml` - Purpose: document the expected agent registry format. -- Modify: `pyproject.toml` - Purpose: add YAML parsing dependency required by the runtime registry loader. -- Modify: `.env.example` - Purpose: document the config path env var for the Matrix agent registry. -- Modify: `README.md` - Purpose: document the new config file, `!agent`, and restart persistence expectations. -- Modify: `adapter/matrix/store.py` - Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics. -- Modify: `adapter/matrix/bot.py` - Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch. -- Modify: `adapter/matrix/handlers/__init__.py` - Purpose: register the new `!agent` command. -- Modify: `adapter/matrix/handlers/chat.py` - Purpose: require a selected agent for `!new` and bind new rooms to that agent. -- Modify: `adapter/matrix/handlers/context_commands.py` - Purpose: keep context commands compatible with local chat ids and routed platform delegation. -- Modify: `adapter/matrix/handlers/settings.py` - Purpose: expose `!agent` in help text. -- Modify: `tests/adapter/matrix/test_dispatcher.py` - Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics. -- Modify: `tests/adapter/matrix/test_context_commands.py` - Purpose: keep load/reset/context flows aligned with the routed platform facade. - ---- - -### Task 1: Add The Agent Registry And Configuration Wiring - -**Files:** -- Create: `adapter/matrix/agent_registry.py` -- Create: `tests/adapter/matrix/test_agent_registry.py` -- Create: `config/matrix-agents.example.yaml` -- Modify: `pyproject.toml` -- Modify: `.env.example` -- Modify: `README.md` - -- [ ] **Step 1: Write the failing registry tests** - -```python -# tests/adapter/matrix/test_agent_registry.py -from pathlib import Path - -import pytest - -from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry - - -def test_load_agent_registry_reads_yaml_entries(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "agents:\n" - " - id: agent-1\n" - " label: Analyst\n" - " - id: agent-2\n" - " label: Research\n", - encoding="utf-8", - ) - - registry = load_agent_registry(path) - - assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"] - assert registry.get("agent-1").label == "Analyst" - - -def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "agents:\n" - " - id: agent-1\n" - " label: Analyst\n" - " - id: agent-1\n" - " label: Duplicate\n", - encoding="utf-8", - ) - - with pytest.raises(AgentRegistryError, match="duplicate agent id"): - load_agent_registry(path) -``` - -- [ ] **Step 2: Run the registry tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q` - -Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`. - -- [ ] **Step 3: Add the YAML dependency and implement the registry loader** - -```toml -# pyproject.toml -dependencies = [ - "aiogram>=3.4,<4", - "matrix-nio>=0.21", - "pydantic>=2.5", - "structlog>=24.1", - "python-dotenv>=1.0", - "httpx>=0.27", - "aiohttp>=3.9", - "PyYAML>=6.0", -] -``` - -```python -# adapter/matrix/agent_registry.py -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -import yaml - - -class AgentRegistryError(ValueError): - pass - - -@dataclass(frozen=True) -class AgentDefinition: - agent_id: str - label: str - - -class AgentRegistry: - def __init__(self, agents: list[AgentDefinition]) -> None: - self.agents = agents - self._by_id = {agent.agent_id: agent for agent in agents} - - def get(self, agent_id: str) -> AgentDefinition: - try: - return self._by_id[agent_id] - except KeyError as exc: - raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc - - -def load_agent_registry(path: str | Path) -> AgentRegistry: - raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} - entries = raw.get("agents") - if not isinstance(entries, list) or not entries: - raise AgentRegistryError("agents registry must contain a non-empty agents list") - - agents: list[AgentDefinition] = [] - seen: set[str] = set() - for entry in entries: - agent_id = str(entry.get("id", "")).strip() - label = str(entry.get("label", "")).strip() - if not agent_id or not label: - raise AgentRegistryError("each agent entry requires id and label") - if agent_id in seen: - raise AgentRegistryError(f"duplicate agent id: {agent_id}") - seen.add(agent_id) - agents.append(AgentDefinition(agent_id=agent_id, label=label)) - return AgentRegistry(agents) -``` - -- [ ] **Step 4: Add the example config and runtime wiring docs** - -```yaml -# config/matrix-agents.example.yaml -agents: - - id: agent-1 - label: Analyst - - id: agent-2 - label: Research -``` - -```env -# .env.example -MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml -``` - -```markdown -# README.md -1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml` -2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml` -3. Use `!agent` in Matrix to select the active upstream agent -``` - -- [ ] **Step 5: Run the registry tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py -git commit -m "feat: add matrix agent registry loader" -``` - ---- - -### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient` - -**Files:** -- Create: `adapter/matrix/routed_platform.py` -- Create: `tests/adapter/matrix/test_routed_platform.py` -- Modify: `adapter/matrix/bot.py` - -- [ ] **Step 1: Write the failing routed-platform tests** - -```python -# tests/adapter/matrix/test_routed_platform.py -import pytest - -from adapter.matrix.routed_platform import RoutedPlatformClient -from adapter.matrix.store import set_room_meta -from core.chat import ChatManager -from core.store import InMemoryStore -from sdk.interface import MessageResponse -from sdk.prototype_state import PrototypeStateStore - - -class FakeDelegate: - def __init__(self, agent_id: str) -> None: - self.agent_id = agent_id - self.calls = [] - - async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None): - self.calls.append((user_id, chat_id, text, attachments)) - return MessageResponse( - message_id=user_id, - response=f"{self.agent_id}:{text}", - tokens_used=0, - finished=True, - ) - - async def get_or_create_user(self, external_id: str, platform: str, display_name=None): - return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name) - - async def get_settings(self, user_id: str): - return await PrototypeStateStore().get_settings(user_id) - - async def update_settings(self, user_id: str, action): - return None - - -@pytest.mark.asyncio -async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id(): - store = InMemoryStore() - chat_mgr = ChatManager(None, store) - await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1") - await set_room_meta( - store, - "!room:example.org", - {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}, - ) - - delegates = {"agent-2": FakeDelegate("agent-2")} - platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates) - - response = await platform.send_message("u1", "C1", "hello") - - assert response.response == "agent-2:hello" - assert delegates["agent-2"].calls == [("u1", "41", "hello", None)] -``` - -- [ ] **Step 2: Run the routed-platform tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q` - -Expected: FAIL with `ImportError` for `RoutedPlatformClient`. - -- [ ] **Step 3: Implement the routing facade and integrate runtime construction** - -```python -# adapter/matrix/routed_platform.py -from __future__ import annotations - -from sdk.interface import PlatformClient - - -class RoutedPlatformClient(PlatformClient): - def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None: - self._store = store - self._chat_mgr = chat_mgr - self._delegates = delegates - - async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]: - ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id) - if ctx is None: - raise ValueError(f"Chat {local_chat_id} not found for {user_id}") - room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}") - if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"): - raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target") - delegate = self._delegates[room_meta["agent_id"]] - return delegate, str(room_meta["platform_chat_id"]) - - async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None): - delegate, platform_chat_id = await self._resolve_target(user_id, chat_id) - return await delegate.send_message(user_id, platform_chat_id, text, attachments) - - async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None): - delegate, platform_chat_id = await self._resolve_target(user_id, chat_id) - async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments): - yield chunk - - async def get_or_create_user(self, external_id: str, platform: str, display_name=None): - first_delegate = next(iter(self._delegates.values())) - return await first_delegate.get_or_create_user(external_id, platform, display_name) - - async def get_settings(self, user_id: str): - first_delegate = next(iter(self._delegates.values())) - return await first_delegate.get_settings(user_id) - - async def update_settings(self, user_id: str, action): - first_delegate = next(iter(self._delegates.values())) - await first_delegate.update_settings(user_id, action) -``` - -```python -# adapter/matrix/bot.py -from adapter.matrix.agent_registry import load_agent_registry -from adapter.matrix.routed_platform import RoutedPlatformClient - - -def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient: - backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower() - if backend != "real": - return MockPlatformClient() - - registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"]) - delegates = { - agent.agent_id: RealPlatformClient( - agent_id=agent.agent_id, - agent_base_url=_agent_base_url_from_env(), - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - for agent in registry.agents - } - return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates) - - -def build_runtime(...): - store = store or InMemoryStore() - chat_mgr = ChatManager(None, store) - platform = platform or _build_platform_from_env(store, chat_mgr) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - dispatcher = EventDispatcher( - platform=platform, - chat_mgr=chat_mgr, - auth_mgr=auth_mgr, - settings_mgr=settings_mgr, - ) -``` - -- [ ] **Step 4: Run the routed-platform tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py -git commit -m "feat: add matrix routed platform facade" -``` - ---- - -### Task 3: Add `!agent` Selection And Durable User Agent State - -**Files:** -- Create: `adapter/matrix/handlers/agent.py` -- Create: `tests/adapter/matrix/test_agent_handler.py` -- Modify: `adapter/matrix/store.py` -- Modify: `adapter/matrix/handlers/__init__.py` -- Modify: `adapter/matrix/handlers/settings.py` - -- [ ] **Step 1: Write the failing agent-handler tests** - -```python -# tests/adapter/matrix/test_agent_handler.py -import pytest - -from adapter.matrix.handlers.agent import make_handle_agent -from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta -from core.protocol import IncomingCommand -from core.store import InMemoryStore - - -class FakeRegistry: - def __init__(self) -> None: - self.agents = [ - type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(), - type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(), - ] - - -@pytest.mark.asyncio -async def test_agent_command_lists_available_agents(): - handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry()) - result = await handler( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]), - None, - None, - None, - None, - ) - assert "1. Analyst" in result[0].text - assert "2. Research" in result[0].text - - -@pytest.mark.asyncio -async def test_agent_command_persists_selected_agent_and_binds_unbound_room(): - store = InMemoryStore() - await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"}) - handler = make_handle_agent(store=store, registry=FakeRegistry()) - chat_mgr = type( - "ChatMgr", - (), - {"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())}, - )() - - await handler( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]), - None, - None, - chat_mgr, - None, - ) - - assert await get_selected_agent_id(store, "u1") == "agent-2" - room_meta = await get_room_meta(store, "!room:example.org") - assert room_meta["agent_id"] == "agent-2" -``` - -- [ ] **Step 2: Run the agent-handler tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q` - -Expected: FAIL with missing handler or store helpers. - -- [ ] **Step 3: Add durable store helpers and implement `!agent`** - -```python -# adapter/matrix/store.py -async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None: - meta = await get_user_meta(store, matrix_user_id) or {} - value = meta.get("selected_agent_id") - return str(value) if value else None - - -async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None: - meta = await get_user_meta(store, matrix_user_id) or {} - meta["selected_agent_id"] = agent_id - await set_user_meta(store, matrix_user_id, meta) - - -async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None: - meta = dict(await get_room_meta(store, room_id) or {}) - meta["agent_id"] = agent_id - await set_room_meta(store, room_id, meta) -``` - -```python -# adapter/matrix/handlers/agent.py -from __future__ import annotations - -from adapter.matrix.store import ( - get_room_meta, - get_selected_agent_id, - next_platform_chat_id, - set_platform_chat_id, - set_room_agent_id, - set_selected_agent_id, -) -from core.protocol import IncomingCommand, OutgoingMessage - - -def make_handle_agent(store, registry): - async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr): - if not event.args: - current = await get_selected_agent_id(store, event.user_id) - lines = ["Доступные агенты:"] - for index, agent in enumerate(registry.agents, start=1): - marker = " (текущий)" if agent.agent_id == current else "" - lines.append(f"{index}. {agent.label}{marker}") - lines.append("") - lines.append("Выбери агента: !agent <номер>") - return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] - - agent = registry.agents[int(event.args[0]) - 1] - await set_selected_agent_id(store, event.user_id, agent.agent_id) - ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None - if ctx is not None: - room_meta = await get_room_meta(store, ctx.surface_ref) - if room_meta is not None and not room_meta.get("agent_id"): - await set_room_agent_id(store, ctx.surface_ref, agent.agent_id) - if not room_meta.get("platform_chat_id"): - await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store)) - return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")] - return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")] - - return handle_agent -``` - -- [ ] **Step 4: Register the command and update help text** - -```python -# adapter/matrix/handlers/__init__.py -from adapter.matrix.handlers.agent import make_handle_agent - -dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry)) -``` - -```python -# adapter/matrix/handlers/settings.py -HELP_TEXT = "\n".join( - [ - "Команды", - "", - "!agent выбрать активного агента", - "!new [название] создать новый чат", - "!chats список активных чатов", - "!rename <название> переименовать текущий чат", - "!archive архивировать текущий чат", - "!context показать текущее состояние контекста", - "!save [имя] сохранить текущий контекст", - "!load показать сохранённые контексты", - ] -) -``` - -- [ ] **Step 5: Run the agent-handler tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py -git commit -m "feat: add matrix agent selection command" -``` - ---- - -### Task 4: Bind Rooms Correctly And Block Stale Chats - -**Files:** -- Modify: `adapter/matrix/bot.py` -- Modify: `adapter/matrix/handlers/chat.py` -- Modify: `adapter/matrix/handlers/context_commands.py` -- Modify: `tests/adapter/matrix/test_dispatcher.py` -- Modify: `tests/adapter/matrix/test_context_commands.py` - -- [ ] **Step 1: Write the failing dispatcher and context-command tests** - -```python -# tests/adapter/matrix/test_dispatcher.py -@pytest.mark.asyncio -async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent(): - runtime = build_runtime(platform=MockPlatformClient()) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"}) - - await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello")) - - client.room_send.assert_awaited_once() - assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower() - - -@pytest.mark.asyncio -async def test_new_chat_requires_selected_agent_and_binds_room_meta(): - client = SimpleNamespace( - room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")), - room_put_state=AsyncMock(), - ) - runtime = build_runtime(platform=MockPlatformClient(), client=client) - await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"}) - - result = await runtime.dispatcher.dispatch( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"]) - ) - - room_meta = await get_room_meta(runtime.store, "!r2:example") - assert room_meta["agent_id"] == "agent-2" - assert "Создан чат" in result[0].text -``` - -```python -# tests/adapter/matrix/test_context_commands.py -@pytest.mark.asyncio -async def test_load_selection_calls_platform_with_local_chat_id(): - platform = MatrixCommandPlatform() - runtime = build_runtime(platform=platform) - await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1") - await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}) - - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]}) - - await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1")) - - platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a")) -``` - -- [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q` - -Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`. - -- [ ] **Step 3: Implement room binding and stale-room checks in runtime** - -```python -# adapter/matrix/bot.py -from adapter.matrix.store import ( - get_selected_agent_id, - get_room_meta, - next_platform_chat_id, - set_platform_chat_id, - set_room_agent_id, -) - - -async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]: - room_meta = await get_room_meta(self.runtime.store, room_id) - selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id) - if not selected_agent_id: - return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.") - if room_meta is None: - return room_meta, None - if not room_meta.get("agent_id"): - await set_room_agent_id(self.runtime.store, room_id, selected_agent_id) - if not room_meta.get("platform_chat_id"): - await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store)) - room_meta = await get_room_meta(self.runtime.store, room_id) - return room_meta, None - if room_meta["agent_id"] != selected_agent_id: - return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.") - return room_meta, None -``` - -```python -# adapter/matrix/bot.py -local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender) -dispatch_chat_id = local_chat_id - -if not body.startswith("!"): - room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender) - if blocking is not None: - await self._send_all(room.room_id, [blocking]) - return - -incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id) -``` - -- [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`** - -```python -# adapter/matrix/handlers/chat.py -from adapter.matrix.store import get_selected_agent_id - -selected_agent_id = await get_selected_agent_id(store, event.user_id) -if not selected_agent_id: - return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")] - -await set_room_meta( - store, - room_id, - { - "room_type": "chat", - "chat_id": chat_id, - "display_name": room_name, - "matrix_user_id": event.user_id, - "space_id": space_id, - "platform_chat_id": platform_chat_id, - "agent_id": selected_agent_id, - }, -) -``` - -```python -# adapter/matrix/bot.py -room_meta = await get_room_meta(self.runtime.store, room_id) -local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id - -await self.runtime.platform.send_message( - user_id, - local_chat_id, - LOAD_PROMPT.format(name=name), -) -``` - -- [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -git commit -m "feat: bind matrix rooms to selected agents" -``` - ---- - -### Task 5: Prove Durable Restart State And Sequence Persistence - -**Files:** -- Create: `tests/adapter/matrix/test_restart_persistence.py` -- Modify: `adapter/matrix/store.py` -- Modify: `README.md` - -- [ ] **Step 1: Write the failing restart-persistence tests** - -```python -# tests/adapter/matrix/test_restart_persistence.py -import pytest - -from adapter.matrix.store import ( - get_selected_agent_id, - next_platform_chat_id, - set_room_meta, - set_selected_agent_id, -) -from core.store import SQLiteStore - - -@pytest.mark.asyncio -async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path): - db_path = tmp_path / "matrix.db" - store = SQLiteStore(str(db_path)) - await set_selected_agent_id(store, "u1", "agent-2") - await set_room_meta( - store, - "!room:example.org", - {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}, - ) - - reopened = SQLiteStore(str(db_path)) - assert await get_selected_agent_id(reopened, "u1") == "agent-2" - assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2" - assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41" - - -@pytest.mark.asyncio -async def test_platform_chat_sequence_survives_store_recreation(tmp_path): - db_path = tmp_path / "matrix.db" - store = SQLiteStore(str(db_path)) - - assert await next_platform_chat_id(store) == "1" - assert await next_platform_chat_id(store) == "2" - - reopened = SQLiteStore(str(db_path)) - assert await next_platform_chat_id(reopened) == "3" -``` - -- [ ] **Step 2: Run the restart-persistence tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q` - -Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered. - -- [ ] **Step 3: Make sequence persistence explicit and document the restart boundary** - -```python -# adapter/matrix/store.py -PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq" - - -async def next_platform_chat_id(store: StateStore) -> str: - async with _PLATFORM_CHAT_SEQ_LOCK: - data = await store.get(PLATFORM_CHAT_SEQ_KEY) - index = int((data or {}).get("next_platform_chat_index", 1)) - await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1}) - return str(index) -``` - -```markdown -# README.md -- Matrix durable state lives in `lambda_matrix.db` and `matrix_store` -- normal restart is supported only when those paths survive container recreation -- staged attachments and pending confirmations are intentionally not restored -``` - -- [ ] **Step 4: Run the restart-persistence tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q` - -Expected: PASS - -- [ ] **Step 5: Run the combined verification sweep** - -Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py -git commit -m "test: cover matrix restart state persistence" -``` - ---- - -## Self-Review - -### Spec coverage - -- Multi-agent agent registry: Task 1 -- Shared `PlatformClient` preserved via routing facade: Task 2 -- `!agent` UX and durable `selected_agent_id`: Task 3 -- Unbound room activation, `!new`, stale room rejection: Task 4 -- Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5 - -### Placeholder scan - -- No `TODO`, `TBD`, or “implement later” markers remain. -- Each task includes exact file paths, tests, commands, and minimal code snippets. - -### Type consistency - -- `selected_agent_id` lives in user metadata throughout the plan. -- `agent_id` and `platform_chat_id` live in room metadata throughout the plan. -- `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact. 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 deleted file mode 100644 index 02cc89f..0000000 --- a/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md +++ /dev/null @@ -1,336 +0,0 @@ -# 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 deleted file mode 100644 index 1f1cc7b..0000000 --- a/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md +++ /dev/null @@ -1,258 +0,0 @@ -# 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 f2bd7b1..ca66000 100644 --- a/docs/surface-protocol.md +++ b/docs/surface-protocol.md @@ -38,10 +38,9 @@ surfaces-bot/ converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API bot.py — точка входа, клиент - sdk/ - interface.py — Protocol: PlatformClient (контракт к SDK) - real.py — RealPlatformClient (через AgentApi) - mock.py — MockPlatformClient (для локальных тестов) + platform/ + interface.py — Protocol: PlatformClient + mock.py — MockPlatformClient ``` --- @@ -141,7 +140,7 @@ class UIButton: ``` Telegram рендерит это как InlineKeyboard. -Matrix рендерит как текст (в MVP). +Matrix рендерит как текст с описанием реакций или HTML-кнопки. ### OutgoingNotification Асинхронное уведомление — агент закончил долгую задачу. @@ -210,7 +209,7 @@ class ConfirmationRequest: ``` Telegram показывает как Inline-кнопки. -Matrix показывает как запрос для `!yes` / `!no`. +Matrix показывает как реакции 👍 / ❌. Ядро не знает как именно — только получает `IncomingCallback` с `action: "confirm"`. --- @@ -305,9 +304,9 @@ class PlatformClient(Protocol): async def update_settings(self, user_id: str, action: Any) -> None: ... ``` -Бот **не управляет lifecycle контейнеров** агентов. Запуск/перезапуск агентов — ответственность платформы. -Бот передаёт `user_id` + `chat_id` + текст. +Бот **не управляет lifecycle контейнеров** — это делает Master (платформа). +Бот передаёт `user_id` + `chat_id` + текст; Master сам решает нужно ли поднять контейнер, смонтировать `C1/`/`C2/`, запустить агента. -`MockPlatformClient` реализует этот протокол для локальных тестов. -Реальный SDK используется через `RealPlatformClient` (`sdk/real.py`), который подключается к `AgentApi` по WebSocket. -Адаптеры поверхностей и ядро не меняются вообще, привязка идёт через `config/matrix-agents.yaml`. +`MockPlatformClient` реализует этот протокол сейчас. +Реальный SDK — тоже реализует этот протокол, заменяя один файл. +Адаптеры поверхностей и ядро не меняются вообще. diff --git a/docs/telegram-prototype.md b/docs/telegram-prototype.md index 17f93cf..c58a1e5 100644 --- a/docs/telegram-prototype.md +++ b/docs/telegram-prototype.md @@ -1,8 +1,5 @@ # Telegram — описание прототипа -> **ВНИМАНИЕ: Telegram-адаптер не является частью текущего MVP-деплоя.** -> Код Telegram-поверхности находится в отдельной ветке `feat/telegram-adapter`. Данный документ описывает возможности этого адаптера, но многие концепции (например, AuthFlow и MockPlatformClient) устарели по отношению к актуальной архитектуре `main`. - ## Концепция Один бот, несколько чатов через Topics в Forum-группе. diff --git a/docs/user-flow.md b/docs/user-flow.md new file mode 100644 index 0000000..efe22f1 --- /dev/null +++ b/docs/user-flow.md @@ -0,0 +1,65 @@ +# User Flow — Lambda Bot + +> **Статус:** ШАБЛОН — заполняет @architect после исследований +> **Зависит от:** docs/research/telegram-flows.md, docs/research/competitor-ux.md + +--- + +## Основной сценарий (happy path) + +```mermaid +sequenceDiagram + actor User + participant Bot as Telegram/Matrix Bot + participant Platform as Lambda Platform (Master) + + User->>Bot: /start + Bot->>Platform: GET /users/{tg_id}?platform=telegram + Platform-->>Bot: {user_id, is_new} + + alt Новый пользователь + Bot->>User: Приветствие + инструкция + else Существующий пользователь + Bot->>User: Добро пожаловать обратно + end + + loop Диалог (бот не управляет сессиями — Master делает это автоматически) + User->>Bot: Сообщение в чат C1/C2/... + Bot->>Platform: POST /users/{user_id}/chats/{chat_id}/messages + Note over Platform: Master поднимает контейнер,
монтирует нужный чат, запускает агента + 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 new file mode 100644 index 0000000..9b77d68 --- /dev/null +++ b/docs/workflow-backup-2026-04-01.md @@ -0,0 +1,174 @@ +# Surfaces team — Lambda Lab 3.0 + +Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda. + +## Правило №1: не быть ждуном + +Платформа (SDK от Азамата) ещё не готова. Это **не блокер**. + +- Все вызовы платформы — через `platform/interface.py` (Protocol) +- Реализация сейчас — `platform/mock.py` (MockPlatformClient) +- При подключении реального SDK — меняем только `platform/mock.py` +- Архитектурные решения принимаем сами, фиксируем в `docs/api-contract.md` + +--- + +## Архитектура + +``` +surfaces-bot/ + core/ + protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...) + handler.py — EventDispatcher: IncomingEvent → OutgoingEvent (общее для всех ботов) + handlers/ — обработчики по типам событий (start, message, chat, settings, callback) + store.py — StateStore Protocol + InMemoryStore + SQLiteStore + chat.py — ChatManager: метаданные чатов C1/C2/C3 + auth.py — AuthManager: AuthFlow + settings.py — SettingsManager: SettingsAction + + adapter/ + telegram/ — aiogram адаптер + converter.py — aiogram Event → IncomingEvent и обратно + bot.py — точка входа + handlers/ — aiogram роутеры + keyboards/ — инлайн-клавиатуры + states.py — FSM состояния + matrix/ — matrix-nio адаптер + converter.py — matrix-nio Event → IncomingEvent и обратно + bot.py — точка входа + handlers/ — обработчики событий + + platform/ + interface.py — Protocol: PlatformClient (контракт к SDK) + mock.py — MockPlatformClient (заглушка) + + docs/ — вся документация + tests/ — pytest тесты + .claude/agents/ — конфиги агентов +``` + +Подробно об унификации: `docs/surface-protocol.md` +Telegram функционал: `docs/telegram-prototype.md` +Matrix функционал: `docs/matrix-prototype.md` + +--- + +## Агенты + +| Агент | Когда запускать | Модель | Токены | +|-------|----------------|--------|--------| +| `@researcher` | Изучить API, найти примеры | Haiku | ~дёшево | +| `@architect` | Спроектировать решение | Sonnet | ~средне | +| `@tg-developer` | Писать код Telegram-адаптера | Sonnet | ~средне | +| `@matrix-developer` | Писать код Matrix-адаптера | Sonnet | ~средне | +| `@core-developer` | Писать core/ и platform/ | Sonnet | ~средне | +| `@reviewer` | Проверить код перед PR | Sonnet | ~средне | + +**Важно (Pro-лимиты):** не запускай больше двух Sonnet-агентов одновременно. +Haiku можно запускать параллельно сколько угодно. + +--- + +## Стратегия параллельной разработки + +Два бота разрабатываются параллельно, но через общее ядро. + +### Порядок работы + +``` +1. core/ — сначала (однократно, все ждут) + @core-developer пишет protocol.py, handler.py, session.py, auth.py, settings.py + +2. platform/ — сразу после core/ + @core-developer пишет interface.py и mock.py + +3. adapter/telegram/ и adapter/matrix/ — параллельно + @tg-developer → adapter/telegram/ + @matrix-developer → adapter/matrix/ + Не пересекаются по файлам — можно одновременно в разных терминалах. +``` + +### Что можно делать одновременно (разные терминалы) + +```bash +# Терминал 1 — Telegram адаптер +claude "Use @tg-developer to implement adapter/telegram/handlers/start.py" + +# Терминал 2 — Matrix адаптер (параллельно) +claude "Use @matrix-developer to implement adapter/matrix/handlers/start.py" +``` + +### Что нельзя делать одновременно + +- Два агента в одном файле +- @core-developer параллельно с @tg-developer или @matrix-developer + (core/ должен быть готов до адаптеров) +- Больше двух Sonnet-агентов одновременно (Pro-лимит) + +--- + +## Git worktree workflow + +Каждая фича в отдельном worktree — адаптеры не мешают друг другу: + +```bash +# Создать worktrees для параллельной работы +git worktree add .worktrees/telegram -b feat/telegram-adapter +git worktree add .worktrees/matrix -b feat/matrix-adapter + +# Работать в каждом независимо +cd .worktrees/telegram && claude "Use @tg-developer to ..." +cd .worktrees/matrix && claude "Use @matrix-developer to ..." + +# Смержить когда готово +git checkout main +git merge feat/telegram-adapter +git merge feat/matrix-adapter +``` + +--- + +## Команды запуска + +```bash +# Установить зависимости +uv sync + +# Запустить тесты +pytest tests/ -v + +# Запустить только тесты Telegram +pytest tests/adapter/telegram/ -v + +# Запустить только тесты Matrix +pytest tests/adapter/matrix/ -v + +# Запустить только тесты ядра +pytest tests/core/ -v + +# Запустить Telegram бота +python -m adapter.telegram.bot + +# Запустить Matrix бота +python -m adapter.matrix.bot +``` + +--- + +## Переменные окружения + +```bash +cp .env.example .env +``` + +Никогда не коммить `.env`. + +--- + +## Экономия токенов (Pro-лимиты) + +- Исследования → всегда `@researcher` (Haiku), не Sonnet +- Точечные правки в одном файле → напрямую без агента +- Ревью → только перед PR, не после каждого коммита +- Длинный контекст → дай агенту конкретный файл, не весь проект +- Если агент "завис" в рассуждениях → прерви, переформулируй задачу точнее diff --git a/pyproject.toml b/pyproject.toml index 73dfbd7..ccc6309 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,6 @@ 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 47f639a..0b7ef19 100644 --- a/sdk/real.py +++ b/sdk/real.py @@ -1,13 +1,8 @@ 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, @@ -21,13 +16,6 @@ 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__( @@ -39,20 +27,11 @@ class RealPlatformClient(PlatformClient): agent_api_cls=AgentApi, ) -> None: self._agent_id = agent_id - self._raw_agent_base_url = agent_base_url - self._agent_base_url = self._normalize_agent_base_url(agent_base_url) + self._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: @@ -178,38 +157,16 @@ 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) @@ -224,27 +181,6 @@ 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: @@ -252,18 +188,18 @@ class RealPlatformClient(PlatformClient): paths = [] for attachment in attachments: if attachment.workspace_path: - normalized = RealPlatformClient._normalize_workspace_path( - attachment.workspace_path - ) - if normalized: - paths.append(normalized) + paths.append(attachment.workspace_path) return paths @staticmethod def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment: location = str(event.path) filename = Path(location).name or None - workspace_path = RealPlatformClient._normalize_workspace_path(location) + workspace_path = location + if workspace_path.startswith("/workspace/"): + workspace_path = workspace_path[len("/workspace/") :] + elif workspace_path == "/workspace": + workspace_path = "" 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 deleted file mode 100644 index af4606d..0000000 Binary files a/telegram-cloud-photo-size-2-5440546240941724952-y.jpg and /dev/null differ diff --git a/tests/adapter/matrix/test_agent_registry.py b/tests/adapter/matrix/test_agent_registry.py deleted file mode 100644 index a918f84..0000000 --- a/tests/adapter/matrix/test_agent_registry.py +++ /dev/null @@ -1,199 +0,0 @@ -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 9264a06..a289772 100644 --- a/tests/adapter/matrix/test_context_commands.py +++ b/tests/adapter/matrix/test_context_commands.py @@ -1,12 +1,11 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock 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, @@ -30,7 +29,6 @@ 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", @@ -41,12 +39,6 @@ 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() @@ -187,88 +179,6 @@ 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 1240f86..7fa7a47 100644 --- a/tests/adapter/matrix/test_dispatcher.py +++ b/tests/adapter/matrix/test_dispatcher.py @@ -15,10 +15,8 @@ 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, @@ -38,6 +36,7 @@ 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(): @@ -104,13 +103,16 @@ async def test_new_chat_creates_real_matrix_room_when_client_available(): ) result = await runtime.dispatcher.dispatch(new) - # room_create is now called with agent_id=None when registry is not configured - assert client.room_create.await_count >= 1 + client.room_create.assert_awaited_once_with( + name="Research", + visibility=RoomVisibility.private, + is_direct=False, + invite=["u1"], + ) 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"] @@ -211,7 +213,7 @@ async def test_invite_event_is_idempotent_per_user(): assert client.join.await_count == 2 assert client.room_create.await_count == 2 - assert client.room_send.await_count == 2 + client.room_send.assert_awaited_once() async def test_bot_ignores_its_own_messages(): @@ -274,7 +276,7 @@ async def test_bot_assigns_platform_chat_id_for_existing_managed_room(): runtime.dispatcher.dispatch.assert_awaited_once() -async def test_bot_keeps_local_chat_id_for_plain_messages(): +async def test_bot_routes_plain_messages_via_platform_chat_id(): runtime = build_runtime(platform=MockPlatformClient()) await set_room_meta( runtime.store, @@ -295,7 +297,7 @@ async def test_bot_keeps_local_chat_id_for_plain_messages(): await bot.on_room_message(room, event) dispatched = runtime.dispatcher.dispatch.await_args.args[0] - assert dispatched.chat_id == "C1" + assert dispatched.chat_id == "41" assert dispatched.text == "hello" @@ -337,121 +339,6 @@ 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()) @@ -980,13 +867,10 @@ async def test_mat12_help_returns_command_reference(): assert "!chats" in text assert "!rename" in text assert "!archive" 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 "!context" in text + assert "!save" in text + assert "!load" in text + assert "!reset" not in text assert "!settings" not in text assert "!skills" not in text @@ -1023,20 +907,15 @@ async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync(): assert since == "s123" -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" - ) +async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch): 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, RoutedPlatformClient) + assert isinstance(runtime.platform, RealPlatformClient) + assert runtime.platform.agent_base_url == "http://agent.example" + assert runtime.platform.agent_id == "matrix-bot" 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 a3a9146..831ca72 100644 --- a/tests/adapter/matrix/test_files.py +++ b/tests/adapter/matrix/test_files.py @@ -3,13 +3,26 @@ from __future__ import annotations from pathlib import Path from types import SimpleNamespace -from adapter.matrix.files import ( - build_agent_workspace_path, - download_matrix_attachment, -) +from adapter.matrix.files import build_workspace_attachment_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" @@ -32,63 +45,6 @@ async def test_download_matrix_attachment_persists_file_and_returns_workspace_pa timestamp="20260420-153000", ) - 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" + 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" diff --git a/tests/adapter/matrix/test_invite_space.py b/tests/adapter/matrix/test_invite_space.py index 15ca57c..52f8335 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_room_meta, set_user_meta +from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta from sdk.mock import MockPlatformClient @@ -100,53 +100,6 @@ 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 deleted file mode 100644 index c44ffc0..0000000 --- a/tests/adapter/matrix/test_reconciliation.py +++ /dev/null @@ -1,253 +0,0 @@ -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 deleted file mode 100644 index ac05423..0000000 --- a/tests/adapter/matrix/test_restart_persistence.py +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index c3efca5..0000000 --- a/tests/adapter/matrix/test_routed_platform.py +++ /dev/null @@ -1,342 +0,0 @@ -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 c398e8c..bda5cfe 100644 --- a/tests/platform/test_agent_session.py +++ b/tests/platform/test_agent_session.py @@ -9,18 +9,6 @@ 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 8bce30b..7a2e37e 100644 --- a/tests/platform/test_real.py +++ b/tests/platform/test_real.py @@ -185,30 +185,11 @@ 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", @@ -229,20 +210,6 @@ 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): @@ -272,29 +239,6 @@ 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 deleted file mode 100644 index 25f63bd..0000000 --- a/tests/test_check_matrix_agents.py +++ /dev/null @@ -1,22 +0,0 @@ -from tools.check_matrix_agents import build_agent_ws_url - - -def test_build_agent_ws_url_preserves_path_prefix_without_trailing_slash(): - assert ( - build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17", "41") - == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/" - ) - - -def test_build_agent_ws_url_preserves_path_prefix_with_trailing_slash(): - assert ( - build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/", "41") - == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/" - ) - - -def test_build_agent_ws_url_accepts_existing_agent_ws_url(): - assert ( - build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/0/", "41") - == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/" - ) diff --git a/tests/test_deploy_handoff.py b/tests/test_deploy_handoff.py deleted file mode 100644 index 0cf2057..0000000 --- a/tests/test_deploy_handoff.py +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index a1d9c25..0000000 --- a/tools/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Operational tools for surfaces-bot.""" diff --git a/tools/check_matrix_agents.py b/tools/check_matrix_agents.py deleted file mode 100644 index d6035aa..0000000 --- a/tools/check_matrix_agents.py +++ /dev/null @@ -1,197 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import json -import os -import time -from dataclasses import asdict, dataclass -from pathlib import Path -from urllib.parse import urljoin - -import aiohttp - -from adapter.matrix.agent_registry import AgentDefinition, load_agent_registry -from sdk.real import RealPlatformClient - - -@dataclass -class AgentCheckResult: - agent_id: str - label: str - chat_id: str - base_url: str - ws_url: str - ok: bool - stage: str - latency_ms: int - error: str = "" - response_type: str = "" - - -def build_agent_ws_url(base_url: str, chat_id: str) -> str: - normalized = RealPlatformClient._normalize_agent_base_url(base_url) - return urljoin(normalized, f"v1/agent_ws/{chat_id}/") - - -def _message_type(payload: str) -> str: - try: - data = json.loads(payload) - except json.JSONDecodeError: - return "" - value = data.get("type") - return value if isinstance(value, str) else "" - - -async def _receive_text(ws: aiohttp.ClientWebSocketResponse, timeout: float) -> str: - msg = await asyncio.wait_for(ws.receive(), timeout=timeout) - if msg.type == aiohttp.WSMsgType.TEXT: - return str(msg.data) - if msg.type == aiohttp.WSMsgType.ERROR: - raise RuntimeError(f"websocket error: {ws.exception()}") - raise RuntimeError(f"unexpected websocket message type: {msg.type.name}") - - -async def check_agent( - agent: AgentDefinition, - *, - fallback_base_url: str, - chat_id: str, - timeout: float, - message: str | None, -) -> AgentCheckResult: - base_url = agent.base_url or fallback_base_url - ws_url = build_agent_ws_url(base_url, chat_id) if base_url else "" - started = time.perf_counter() - - def result(ok: bool, stage: str, error: str = "", response_type: str = "") -> AgentCheckResult: - return AgentCheckResult( - agent_id=agent.agent_id, - label=agent.label, - chat_id=chat_id, - base_url=base_url, - ws_url=ws_url, - ok=ok, - stage=stage, - latency_ms=int((time.perf_counter() - started) * 1000), - error=error, - response_type=response_type, - ) - - if not base_url: - return result(False, "config", "missing base_url and AGENT_BASE_URL") - - try: - client_timeout = aiohttp.ClientTimeout( - total=timeout, - connect=timeout, - sock_connect=timeout, - sock_read=timeout, - ) - async with aiohttp.ClientSession(timeout=client_timeout) as session: - async with session.ws_connect(ws_url, heartbeat=30) as ws: - raw_status = await _receive_text(ws, timeout) - status_type = _message_type(raw_status) - if status_type != "STATUS": - return result( - False, - "status", - f"expected STATUS, got {raw_status[:200]}", - status_type, - ) - - if not message: - return result(True, "status", response_type=status_type) - - payload = { - "type": "USER_MESSAGE", - "text": message, - "attachments": [], - } - await ws.send_str(json.dumps(payload)) - - while True: - raw_event = await _receive_text(ws, timeout) - event_type = _message_type(raw_event) - if event_type == "ERROR": - return result(False, "message", raw_event[:200], event_type) - if event_type == "AGENT_EVENT_END": - return result(True, "message", response_type=event_type) - if not event_type: - return result(False, "message", f"invalid JSON event: {raw_event[:200]}") - except TimeoutError: - return result(False, "timeout", f"no response within {timeout:g}s") - except Exception as exc: - return result(False, "connect", str(exc)) - - -def _select_agents( - agents: tuple[AgentDefinition, ...], - selected: set[str], -) -> list[AgentDefinition]: - if not selected: - return list(agents) - return [agent for agent in agents if agent.agent_id in selected] - - -async def run_checks(args: argparse.Namespace) -> list[AgentCheckResult]: - registry = load_agent_registry(args.config) - selected = _select_agents(registry.agents, set(args.agent)) - if not selected: - raise SystemExit("no matching agents selected") - - fallback_base_url = args.base_url or os.environ.get("AGENT_BASE_URL", "") - semaphore = asyncio.Semaphore(args.concurrency) - - async def run_one(index: int, agent: AgentDefinition) -> AgentCheckResult: - chat_id = str(args.chat_id if args.chat_id is not None else args.chat_id_base + index) - async with semaphore: - return await check_agent( - agent, - fallback_base_url=fallback_base_url, - chat_id=chat_id, - timeout=args.timeout, - message=args.message, - ) - - return await asyncio.gather(*(run_one(index, agent) for index, agent in enumerate(selected))) - - -def print_table(results: list[AgentCheckResult]) -> None: - for item in results: - status = "OK" if item.ok else "FAIL" - detail = item.response_type or item.error - print( - f"{status:4} {item.agent_id:20} {item.stage:8} " - f"{item.latency_ms:5}ms chat={item.chat_id} url={item.ws_url} {detail}" - ) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Smoke-check Matrix agent WebSocket endpoints from matrix-agents.yaml." - ) - parser.add_argument("--config", type=Path, default=Path("config/matrix-agents.yaml")) - parser.add_argument("--agent", action="append", default=[], help="Agent id to check") - parser.add_argument("--base-url", default="", help="Fallback base URL when an agent has none") - parser.add_argument("--timeout", type=float, default=10.0) - parser.add_argument("--concurrency", type=int, default=5) - parser.add_argument("--chat-id", type=int, default=None, help="Use one explicit chat id") - parser.add_argument("--chat-id-base", type=int, default=900000) - parser.add_argument("--message", default=None, help="Optional test message after STATUS") - parser.add_argument("--json", action="store_true", help="Print machine-readable JSON") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - results = asyncio.run(run_checks(args)) - if args.json: - print(json.dumps([asdict(result) for result in results], ensure_ascii=False, indent=2)) - else: - print_table(results) - return 0 if all(result.ok for result in results) else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/no_status_agent.py b/tools/no_status_agent.py deleted file mode 100644 index adb563a..0000000 --- a/tools/no_status_agent.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio - -from aiohttp import web - - -async def websocket_handler(request: web.Request) -> web.WebSocketResponse: - ws = web.WebSocketResponse() - await ws.prepare(request) - await asyncio.sleep(3600) - return ws - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="WebSocket stub that accepts connections but sends no STATUS." - ) - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=8000) - return parser.parse_args() - - -def main() -> None: - args = parse_args() - app = web.Application() - app.router.add_get("/v1/agent_ws/{chat_id}/", websocket_handler) - web.run_app(app, host=args.host, port=args.port) - - -if __name__ == "__main__": - main() diff --git a/uv.lock b/uv.lock index 76a9426..35c8460 100644 --- a/uv.lock +++ b/uv.lock @@ -1154,61 +1154,6 @@ 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" @@ -1376,7 +1321,6 @@ dependencies = [ { name = "matrix-nio" }, { name = "pydantic" }, { name = "python-dotenv" }, - { name = "pyyaml" }, { name = "structlog" }, ] @@ -1403,7 +1347,6 @@ 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" }, ]