Compare commits
No commits in common. "main" and "feat/matrix-direct-agent-prototype" have entirely different histories.
main
...
feat/matri
91 changed files with 4060 additions and 7069 deletions
|
|
@ -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
|
||||
|
|
|
|||
41
.env.example
41
.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-...
|
||||
|
|
|
|||
107
.planning/HANDOFF.json
Normal file
107
.planning/HANDOFF.json
Normal file
|
|
@ -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, а не ещё одна попытка локально чинить поток."
|
||||
}
|
||||
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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 <name>` inside linked topic does not rename the Telegram topic
|
||||
|
||||
- Issue: Running `/new <name>` inside a forum topic that is already linked to a chat renames the internal chat record but does not call `edit_forum_topic` to rename the actual Telegram topic.
|
||||
- Files: `.worktrees/telegram/adapter/telegram/handlers/forum.py`
|
||||
- Impact: Topic name in Telegram goes out of sync with internal chat name.
|
||||
- Tracked: Issue `#15`
|
||||
|
||||
### Matrix: `handle_invite` hardcodes `chat_id = "C1"` for all new rooms
|
||||
|
||||
- Issue: `adapter/matrix/handlers/auth.py` `handle_invite()` always assigns `chat_id = "C1"` regardless of how many rooms the user already has. If a user invites the bot into a second room before using `!new`, both rooms get `C1`.
|
||||
- Files: `adapter/matrix/handlers/auth.py` line 26
|
||||
- Impact: Two rooms mapped to the same `chat_id` causes routing collisions.
|
||||
- Fix approach: Call `next_chat_id(store, user_id)` here instead of hardcoding `"C1"`.
|
||||
|
||||
### Matrix: `remove_reaction` uses non-standard `undo` field
|
||||
|
||||
- Issue: `adapter/matrix/reactions.py` `remove_reaction()` sends a `"undo": True` field in the reaction event body. This is not part of the Matrix spec for reaction redaction. The correct approach is to redact the original reaction event via `client.room_redact()`.
|
||||
- Files: `adapter/matrix/reactions.py` lines 56–68
|
||||
- Impact: Reaction "undo" will silently fail on compliant homeservers.
|
||||
|
||||
### Matrix: E2EE not supported (blocked by `python-olm`)
|
||||
|
||||
- Issue: `matrix-nio` E2EE requires `python-olm`, which fails to build on macOS/ARM. No encrypted DM support.
|
||||
- Files: `adapter/matrix/bot.py`
|
||||
- Impact: The bot cannot operate in encrypted rooms. Users who have DM encryption enforced cannot use the Matrix bot.
|
||||
- Status: Documented as a known infrastructure constraint in `docs/reports/2026-04-01-surfaces-progress-report.md`. Needs a separate infrastructure task.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### SQLite database files not in .gitignore
|
||||
|
||||
- Risk: `lambda_bot.db` and `lambda_matrix.db` are present in the working tree (shown in `git status`) but not listed in `.gitignore`. These files may contain user data including chat content and display names.
|
||||
- Files: `lambda_bot.db`, `lambda_matrix.db`, `.gitignore`
|
||||
- Current mitigation: Files are currently untracked (not yet staged) but nothing prevents them from being accidentally committed.
|
||||
- Recommendation: Add `*.db` or specific filenames to `.gitignore` immediately.
|
||||
|
||||
### Auth flow is auto-confirmed in mock — no real validation exists
|
||||
|
||||
- Issue: `core/auth.py` `confirm()` automatically sets `state = "confirmed"` and generates a fake `platform_user_id`. There is no real verification step, no code exchange, no token validation.
|
||||
- Files: `core/auth.py` lines 39–48
|
||||
- Impact: The auth layer is decorative for the prototype. Any user who sends `!start` or `/start` is immediately authenticated. If the real SDK auth requires a different flow (e.g., OAuth, code), the current `AuthManager` interface may not match.
|
||||
- Current mitigation: Acceptable for mock stage. Must be re-evaluated before production use.
|
||||
|
||||
### Matrix room metadata stored without access control
|
||||
|
||||
- Issue: `adapter/matrix/store.py` stores room metadata keyed by `room_id`. Any call that can supply an arbitrary `room_id` can read or overwrite another user's room metadata.
|
||||
- Files: `adapter/matrix/store.py`, `adapter/matrix/room_router.py`
|
||||
- Impact: In the current single-process bot this is not exploitable. If the store is ever shared across processes or users, room metadata can be poisoned.
|
||||
|
||||
---
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
### `core/chat.py` scan-by-suffix fallback is O(N) and collision-prone
|
||||
|
||||
- Issue: `ChatManager.get()` when called without `user_id` scans all `chat:*` keys and matches by suffix (e.g., `":C1"`). If two users both have a chat named `C1` (which is always the case), this returns the first one found, non-deterministically.
|
||||
- Files: `core/chat.py` lines 76–82
|
||||
- Impact: Functions like `rename` and `archive` that call `chat_mgr.get(chat_id)` without `user_id` will operate on the wrong user's chat in a multi-user scenario.
|
||||
- Fix approach: Audit all callers and always pass `user_id`. The scan-by-suffix fallback should be removed or explicitly guarded.
|
||||
|
||||
### `adapter/matrix/handlers/chat.py` chat_id counter races under concurrency
|
||||
|
||||
- Issue: `make_handle_new_chat` calls `chat_mgr.list_active()` and uses `len(chats) + 1` to compute a new `chat_id`. This is not atomic. Two concurrent `!new` commands from the same user can produce the same `chat_id`.
|
||||
- Files: `adapter/matrix/handlers/chat.py` line 17
|
||||
- Impact: Duplicate `chat_id` values (`C2`, `C2`) for the same user, leading to state corruption.
|
||||
- Fix approach: Use `next_chat_id()` from `adapter/matrix/store.py` which increments an atomic counter in the store. The `next_chat_id()` function already exists but is not used here.
|
||||
|
||||
### `conftest.py` contains a fragile stdlib `platform` module workaround
|
||||
|
||||
- Issue: `conftest.py` patches `sys.modules` to remove the Python stdlib `platform` module so local `platform/` (which no longer exists — renamed to `sdk/`) doesn't shadow it. The comment still refers to `platform/` but the directory was renamed to `sdk/` in commit `41660fe`.
|
||||
- Files: `conftest.py` lines 1–13
|
||||
- Impact: The workaround is now a no-op (there is no `platform/` package to shadow) but adds confusion. The comment is incorrect. If someone creates a `platform/` directory again, unexpected behavior can return.
|
||||
- Fix approach: Remove the `sys.modules` patching entirely since `sdk/` does not conflict with stdlib. Update the comment.
|
||||
|
||||
### Forum onboarding `chat_shared` constructs a fake `Chat` object
|
||||
|
||||
- Issue: `adapter/telegram/handlers/forum.py` handles `chat_shared` by constructing `Chat(id=..., type="supergroup", is_forum=True)` and passing it to `_complete_group_link()`. The `is_forum=True` is hardcoded — the real value from Telegram is not verified. This means the check `if getattr(forwarded_chat, "is_forum", None) is False` in the forwarding fallback path is bypassed entirely.
|
||||
- Files: `.worktrees/telegram/adapter/telegram/handlers/forum.py` lines 162–168
|
||||
- Impact: A user could link a regular supergroup (without Topics enabled) via `chat_shared`, which would succeed in linking but fail when the bot tries to create forum topics.
|
||||
|
||||
---
|
||||
|
||||
## Gaps Between CLAUDE.md and Actual Code
|
||||
|
||||
### CLAUDE.md says `platform/` — code uses `sdk/`
|
||||
|
||||
- CLAUDE.md architecture diagram shows `platform/interface.py` and `platform/mock.py`
|
||||
- Actual code uses `sdk/interface.py` and `sdk/mock.py` (renamed in commit `41660fe`)
|
||||
- Files: `CLAUDE.md` (project instructions), `sdk/interface.py`, `sdk/mock.py`
|
||||
- Also: Agent config files at `.claude/agents/core-developer.md` still reference `platform/` throughout
|
||||
- Impact: New contributors reading CLAUDE.md will look for a `platform/` directory that does not exist.
|
||||
|
||||
### CLAUDE.md lists `core/handlers/` sub-handlers that partially do not exist
|
||||
|
||||
- CLAUDE.md lists handler modules but the actual `core/handlers/` only has: `start.py`, `message.py`, `chat.py`, `settings.py`, `callback.py`
|
||||
- No `voice.py` handler exists; voice is handled as a fallback inside `core/handlers/message.py` (returns stub response)
|
||||
- No `payment.py` handler exists; `PaymentRequired` dataclass is defined in `core/protocol.py` but never dispatched
|
||||
- Files: `core/protocol.py` (PaymentRequired defined), `core/handlers/` (no payment or voice handlers)
|
||||
|
||||
### CLAUDE.md workflow describes `@reviewer` agent but agent file references old patterns
|
||||
|
||||
- `.claude/agents/core-developer.md` still says "Твоя зона — `core/` и `platform/`"
|
||||
- The old Haiku/Sonnet researcher-developer workflow is captured in `docs/workflow-backup-2026-04-01.md`, but `.claude/agents/` configs were not updated to match
|
||||
|
||||
### `tests/adapter/test_forum_db.py` is untracked on main
|
||||
|
||||
- This test file exists in the working tree (visible in `git status`) but is not committed to `main`. It tests `adapter/telegram/db.py` which also does not exist on `main`.
|
||||
- Files: `tests/adapter/test_forum_db.py`
|
||||
- Impact: Running `pytest tests/` from main currently includes this test, which imports `adapter.telegram.db`. This import succeeds only because the test auto-reloads the module from an untracked file. This is fragile — if the file is deleted, tests silently pass with fewer tests counted.
|
||||
|
||||
---
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
### No streaming response support in adapters
|
||||
|
||||
- Both adapters use `platform.send_message()` (sync) not `platform.stream_message()` (streaming)
|
||||
- `sdk/interface.py` defines `stream_message` returning `AsyncIterator[MessageChunk]`
|
||||
- No adapter sends a typing indicator before the response arrives and then streams chunks
|
||||
- Impact: User experience with slow AI responses will show nothing until the full response is ready
|
||||
- Files: `core/handlers/message.py` line 28, `sdk/interface.py` lines 83–88
|
||||
|
||||
### No webhook/push notification handling
|
||||
|
||||
- `sdk/interface.py` defines `WebhookReceiver` Protocol with `on_agent_event()`
|
||||
- `sdk/mock.py` has `register_webhook_receiver()` and `simulate_agent_event()`
|
||||
- Neither bot entrypoint registers a `WebhookReceiver`
|
||||
- Impact: Push notifications from the platform (task completions, background jobs) cannot reach the user
|
||||
- Files: `sdk/interface.py` lines 95–97, `adapter/matrix/bot.py`, no registration present
|
||||
|
||||
### Telegram adapter uses InMemoryStore for core state
|
||||
|
||||
- `.worktrees/telegram/adapter/telegram/bot.py` calls `InMemoryStore()` for the `EventDispatcher`'s state
|
||||
- All `core/` state (auth, chat metadata in the KV layer) is lost on bot restart
|
||||
- `adapter/telegram/db.py` SQLite is used only for Telegram-specific data
|
||||
- Impact: On restart, authenticated users are logged out; core chat context is wiped
|
||||
- Files: `.worktrees/telegram/adapter/telegram/bot.py` line 46
|
||||
|
||||
### No multi-user isolation in Matrix store
|
||||
|
||||
- `adapter/matrix/store.py` keys are global (`matrix_room:ROOMID`, `matrix_user:USERID`)
|
||||
- There is no namespace or tenant isolation
|
||||
- Impact: At scale, any key collision would corrupt state. For a single-user prototype this is acceptable, but it is an architectural constraint to document before expanding scope.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
### No tests for `adapter/telegram/` in main test suite
|
||||
|
||||
- `tests/adapter/` on main only contains `matrix/` tests and the untracked `test_forum_db.py`
|
||||
- All Telegram adapter tests live in the worktree at `.worktrees/telegram/tests/`
|
||||
- Files: `tests/adapter/` (missing `telegram/` subdirectory on main)
|
||||
- Risk: Merging `feat/telegram-adapter` without also merging its tests leaves Telegram untested on main
|
||||
- Priority: High
|
||||
|
||||
### No tests for `core/handlers/callback.py` confirm/cancel real behavior
|
||||
|
||||
- `core/handlers/callback.py` `handle_confirm` and `handle_cancel` return stub text with `action_id`
|
||||
- No test verifies that a real confirmation flow (dispatch → confirm → side effect) works end to end
|
||||
- Files: `core/handlers/callback.py`, `tests/core/test_dispatcher.py`
|
||||
- Priority: Medium
|
||||
|
||||
### No tests for `adapter/matrix/handlers/auth.py` multi-room invite scenario
|
||||
|
||||
- The hardcoded `C1` bug (see Known Bugs section) is not caught by any test
|
||||
- Files: `adapter/matrix/handlers/auth.py`, `tests/adapter/matrix/test_dispatcher.py`
|
||||
- Priority: Medium
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-04-01*
|
||||
|
|
|
|||
|
|
@ -1,7 +1,195 @@
|
|||
# Конвенции (CONVENTIONS.md)
|
||||
# Coding Conventions
|
||||
|
||||
- **Асинхронность**: Весь код бота асинхронный (`asyncio`). Вызовы SDK и Matrix-клиента выполняются через `await`. Блокирующие вызовы (если они есть) должны выноситься в тредпул.
|
||||
- **Обработка ошибок**: Бот не должен падать из-за ошибок отдельного агента. Ошибки 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_<module>.py` (e.g., `test_store.py`, `test_converter.py`)
|
||||
- No index/barrel files except `__init__.py` for package registration
|
||||
|
||||
## Class Naming
|
||||
|
||||
- `PascalCase` for all classes (e.g., `EventDispatcher`, `MockPlatformClient`, `MatrixBot`)
|
||||
- Protocol/interface classes named after the capability: `StateStore`, `PlatformClient`, `WebhookReceiver`
|
||||
- Manager classes suffixed with `Manager`: `ChatManager`, `AuthManager`, `SettingsManager`
|
||||
- Dataclasses follow the same `PascalCase` rule: `IncomingMessage`, `OutgoingUI`, `MatrixRuntime`
|
||||
|
||||
## Function and Method Naming
|
||||
|
||||
- `snake_case` for all functions and methods
|
||||
- Private helpers prefixed with single underscore: `_to_dict`, `_from_dict`, `_routing_key`, `_latency`
|
||||
- Handler functions named `handle_<action>`: `handle_start`, `handle_message`, `handle_new_chat`
|
||||
- Builder functions named `build_<thing>`: `build_runtime`, `build_event_dispatcher`, `build_skills_text`
|
||||
- Converter functions named `from_<source>`: `from_room_event`, `from_command`, `from_reaction`
|
||||
- Predicate functions named `is_<state>`: `is_authenticated`, `is_new`
|
||||
|
||||
## Variable Naming
|
||||
|
||||
- `snake_case` for all variables and parameters
|
||||
- Internal state attributes prefixed with `_`: `self._store`, `self._platform`, `self._handlers`
|
||||
- Store key prefixes are module-level constants in `UPPER_SNAKE_CASE`:
|
||||
```python
|
||||
ROOM_META_PREFIX = "matrix_room:"
|
||||
USER_META_PREFIX = "matrix_user:"
|
||||
```
|
||||
- Constants for reaction strings are module-level: `CONFIRM_REACTION = "👍"`, `PLATFORM = "matrix"`
|
||||
|
||||
## Type Annotations
|
||||
|
||||
All files use `from __future__ import annotations` at the top for deferred evaluation.
|
||||
|
||||
**Annotation style:**
|
||||
- Use built-in generics (`list[str]`, `dict[str, Any]`) — not `List`, `Dict` from `typing`
|
||||
- Union types written with `|`: `str | None`, `IncomingCallback | None`
|
||||
- Type aliases at module level: `IncomingEvent = IncomingMessage | IncomingCommand | IncomingCallback`
|
||||
- Callable types use `typing.Callable` and `typing.Awaitable`:
|
||||
```python
|
||||
HandlerFn = Callable[..., Awaitable[list[OutgoingEvent]]]
|
||||
```
|
||||
- Handler functions use loose `list` return type without generics (consistent across `core/handlers/`)
|
||||
- Protocol classes use `...` as body for abstract methods:
|
||||
```python
|
||||
async def get(self, key: str) -> dict | None: ...
|
||||
```
|
||||
|
||||
**Pydantic vs dataclasses:**
|
||||
- `core/protocol.py` — plain `@dataclass` with `field(default_factory=...)` for mutable defaults
|
||||
- `sdk/interface.py` — Pydantic `BaseModel` for all SDK-facing models (`User`, `MessageResponse`, `UserSettings`)
|
||||
- Choose `@dataclass` for internal protocol structs, `BaseModel` for SDK boundary models
|
||||
|
||||
## Import Organization
|
||||
|
||||
Order (enforced by ruff `I` rules):
|
||||
1. `from __future__ import annotations`
|
||||
2. Standard library imports (grouped)
|
||||
3. Third-party imports (grouped)
|
||||
4. Local imports from project packages (grouped)
|
||||
|
||||
Example from `adapter/matrix/bot.py`:
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from nio import AsyncClient, ...
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from adapter.matrix.converter import from_reaction, from_room_event
|
||||
from core.auth import AuthManager
|
||||
from core.protocol import OutgoingEvent, ...
|
||||
from sdk.mock import MockPlatformClient
|
||||
```
|
||||
|
||||
No relative imports; all imports use absolute package paths from the project root.
|
||||
|
||||
## Async Patterns
|
||||
|
||||
All I/O methods are `async def`. There are no sync wrappers around async code.
|
||||
|
||||
**Handler signature pattern** (used uniformly across `core/handlers/`):
|
||||
```python
|
||||
async def handle_<action>(event: IncomingEvent, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
|
||||
```
|
||||
Note: manager parameters are untyped in handler signatures (accepted as `**kwargs` at call site in `EventDispatcher.dispatch`).
|
||||
|
||||
**Awaiting store calls:**
|
||||
```python
|
||||
stored = await self._store.get(f"auth:{user_id}")
|
||||
await self._store.set(f"auth:{user_id}", _to_dict(flow))
|
||||
```
|
||||
|
||||
**SQLiteStore uses sync sqlite3** inside `async def` methods — blocking I/O is not off-loaded to a thread executor. This is a known limitation (see CONCERNS.md).
|
||||
|
||||
**Mock latency simulation:**
|
||||
```python
|
||||
await self._latency(200, 600) # min_ms, max_ms
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
**Library:** `structlog`
|
||||
|
||||
**Pattern:**
|
||||
```python
|
||||
import structlog
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
logger.info("Chat created", chat_id=chat_id, user_id=user_id)
|
||||
logger.warning("No handler registered", event_type=event_type.__name__, key=key)
|
||||
```
|
||||
|
||||
- Always pass structured keyword arguments — never use f-strings in log calls
|
||||
- Logger created at module level with `structlog.get_logger(__name__)`
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Raise `ValueError` for invalid domain state (e.g., chat not found in `ChatManager.rename`)
|
||||
- `sdk/interface.py` defines `PlatformError(Exception)` with a `code` field for SDK-level errors
|
||||
- Handler functions never raise — they return `[]` or a fallback `OutgoingMessage`
|
||||
- No `try/except` blocks in core handlers; errors from the platform are expected to propagate
|
||||
|
||||
## Comments
|
||||
|
||||
- Module-level comment declaring file path at top: `# core/handler.py`
|
||||
- Docstrings for classes with non-obvious behavior:
|
||||
```python
|
||||
class MockPlatformClient:
|
||||
"""
|
||||
Заглушка SDK платформы Lambda.
|
||||
...
|
||||
"""
|
||||
```
|
||||
- Inline comments for non-obvious blocks:
|
||||
```python
|
||||
# Scan by chat_id suffix when user_id unknown (slower)
|
||||
```
|
||||
- Comments in Russian are normal and acceptable throughout the codebase
|
||||
|
||||
## Serialization Pattern
|
||||
|
||||
Dataclasses are serialized/deserialized via private module-level functions, not class methods:
|
||||
|
||||
```python
|
||||
def _to_dict(ctx: ChatContext) -> dict:
|
||||
return { "chat_id": ctx.chat_id, ... }
|
||||
|
||||
def _from_dict(d: dict) -> ChatContext:
|
||||
return ChatContext(chat_id=d["chat_id"], ...)
|
||||
```
|
||||
|
||||
This pattern is used in `core/auth.py` and `core/chat.py`. Follow this pattern for any new manager that persists to `StateStore`.
|
||||
|
||||
## Module Design
|
||||
|
||||
- No barrel `__init__.py` exports except `core/handlers/__init__.py` which exposes `register_all`
|
||||
- Manager classes take `(platform, store)` as constructor args; `platform` is often stored as `object` or not stored at all if unused
|
||||
- `@dataclass` is preferred for plain data containers, not NamedTuple or TypedDict
|
||||
- Store key namespacing follows `<namespace>:<user_id>:<entity_id>` pattern:
|
||||
`"chat:u1:C1"`, `"auth:u1"`, `"matrix_room:!r:m.org"`
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-04-01*
|
||||
|
|
|
|||
|
|
@ -1,15 +1,173 @@
|
|||
# Интеграции (INTEGRATIONS.md)
|
||||
# External Integrations
|
||||
|
||||
## Platform Agent API
|
||||
- **Тип**: 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:<room_id>` in `StateStore`
|
||||
- User metadata (next chat index) stored under `matrix_user:<matrix_user_id>`
|
||||
|
||||
### Matrix Event Types Handled
|
||||
|
||||
| nio Event Class | Handler | Action |
|
||||
|--------------------|-----------------------------|-------------------------------|
|
||||
| `RoomMessageText` | `MatrixBot.on_room_message` | Dispatch to `EventDispatcher` |
|
||||
| `ReactionEvent` | `MatrixBot.on_reaction` | Button confirmation / skill toggle |
|
||||
| `InviteMemberEvent`| `MatrixBot.on_member` | Accept room invite |
|
||||
| `RoomMemberEvent` | `MatrixBot.on_member` | Membership change handling |
|
||||
|
||||
## Lambda Platform (Internal SDK)
|
||||
|
||||
**Purpose:** AI agent backend — processes user messages, manages user accounts, returns responses
|
||||
|
||||
**Interface:** `sdk/interface.py` — `PlatformClient` Protocol
|
||||
|
||||
**Current Implementation:** `sdk/mock.py` — `MockPlatformClient`
|
||||
- Simulates network latency (10–80 ms default, 200–600 ms for message calls)
|
||||
- In-process in-memory state (users, messages, settings dicts)
|
||||
- Supports webhook simulation via `simulate_agent_event()`
|
||||
|
||||
**Production Integration (future):**
|
||||
- URL: `LAMBDA_PLATFORM_URL` (default: `http://localhost:8000`)
|
||||
- Auth: `LAMBDA_SERVICE_TOKEN` (bearer token)
|
||||
- Mode switch: `PLATFORM_MODE=mock` vs `PLATFORM_MODE=production`
|
||||
- Swap path: replace `sdk/mock.py` only; no changes to `core/` or `adapter/`
|
||||
|
||||
**Platform API Methods (from `sdk/interface.py`):**
|
||||
|
||||
```python
|
||||
async def get_or_create_user(external_id, platform, display_name) -> User
|
||||
async def send_message(user_id, chat_id, text, attachments) -> MessageResponse
|
||||
async def stream_message(user_id, chat_id, text, attachments) -> AsyncIterator[MessageChunk]
|
||||
async def get_settings(user_id) -> UserSettings
|
||||
async def update_settings(user_id, action) -> None
|
||||
```
|
||||
|
||||
**Webhook / Push (outbound from platform → bot):**
|
||||
- Interface: `WebhookReceiver` Protocol (`sdk/interface.py`)
|
||||
- Registration: `MockPlatformClient.register_webhook_receiver(receiver)`
|
||||
- Event types: `task_done`, `task_error`, `task_progress` (modelled in `AgentEvent`)
|
||||
- Production implementation not yet wired; mock supports `simulate_agent_event()` for testing
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
|
||||
*SQLite (primary persistence):*
|
||||
- Client: stdlib `sqlite3` (synchronous, called from async code without `asyncio.to_thread`)
|
||||
- Schema: single key-value table: `kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)`
|
||||
- JSON serialization for values (`json.dumps` / `json.loads`)
|
||||
- Matrix bot DB path: `MATRIX_DB_PATH` (default: `"lambda_matrix.db"`)
|
||||
- Telegram bot DB path: implicit `"lambda_bot.db"` (file present in repo root — development artifact)
|
||||
- Implementation: `core/store.py` `SQLiteStore`
|
||||
|
||||
*In-Memory (testing / development):*
|
||||
- `InMemoryStore` — plain Python dict, no persistence across restarts
|
||||
- `MockPlatformClient` internal state — also in-memory dicts
|
||||
|
||||
**File Storage:**
|
||||
- Matrix nio E2EE store: local filesystem directory at `MATRIX_STORE_PATH` (default: `"matrix_store/"`)
|
||||
- No object storage (S3/GCS/etc.) currently; mock client has `attachment_mode` flag (`"url"` | `"binary"` | `"s3"`) reserved for future real SDK
|
||||
|
||||
**Caching:**
|
||||
- None — no Redis or external cache layer
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Telegram Auth:**
|
||||
- Bot token → passed to aiogram dispatcher at startup
|
||||
- User identity: Telegram user ID mapped to platform `external_id`
|
||||
|
||||
**Matrix Auth:**
|
||||
- Password or access token (see above)
|
||||
- User identity: Matrix user ID (e.g. `@user:matrix.org`) mapped to platform `external_id`
|
||||
|
||||
**Lambda Platform User Identity:**
|
||||
- `get_or_create_user(external_id, platform)` → returns `User` with internal `user_id`
|
||||
- External IDs are platform-prefixed in mock: `"{platform}:{external_id}"`
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Logging:**
|
||||
- `structlog` 25.5.0 — structured logging (key=value pairs)
|
||||
- Logger instantiation: `structlog.get_logger(__name__)` in each module
|
||||
- Log calls use keyword arguments: `logger.info("event_name", key=value, ...)`
|
||||
- No log shipping / aggregation configured (local stdout only)
|
||||
|
||||
**Error Tracking:**
|
||||
- None — no Sentry, Datadog, or similar integration
|
||||
|
||||
**Metrics:**
|
||||
- None — `MockPlatformClient.get_stats()` returns basic in-memory counters (not exported)
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
- Not specified — no Dockerfile, docker-compose, or cloud config files present
|
||||
|
||||
**CI Pipeline:**
|
||||
- None detected — no `.github/workflows/`, `.gitlab-ci.yml`, etc.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required variables (from `.env.example`):**
|
||||
|
||||
| Variable | Required | Default | Purpose |
|
||||
|-----------------------|----------|--------------------|--------------------------------------|
|
||||
| `TELEGRAM_BOT_TOKEN` | Yes* | — | Telegram Bot API token |
|
||||
| `MATRIX_HOMESERVER` | Yes* | — | Matrix homeserver URL (e.g. `https://matrix.org`) |
|
||||
| `MATRIX_USER_ID` | Yes* | — | Bot's Matrix user ID |
|
||||
| `MATRIX_PASSWORD` | Cond. | — | Login password (if no access token) |
|
||||
| `MATRIX_ACCESS_TOKEN` | Cond. | — | Pre-issued access token (preferred) |
|
||||
| `MATRIX_DEVICE_ID` | No | `""` | Matrix device ID |
|
||||
| `MATRIX_DB_PATH` | No | `"lambda_matrix.db"` | SQLite DB file path (Matrix bot) |
|
||||
| `MATRIX_STORE_PATH` | No | `"matrix_store"` | nio E2EE store directory |
|
||||
| `LAMBDA_PLATFORM_URL` | No** | `http://localhost:8000` | Lambda platform base URL |
|
||||
| `LAMBDA_SERVICE_TOKEN`| No** | — | Service auth token for Lambda API |
|
||||
| `PLATFORM_MODE` | No | `"mock"` | `"mock"` or `"production"` |
|
||||
|
||||
\* Required for the respective bot to function.
|
||||
\*\* Only required when `PLATFORM_MODE=production`.
|
||||
|
||||
**Secrets location:**
|
||||
- `.env` file (gitignored)
|
||||
- Never committed — `.env.example` provides template
|
||||
- Loaded via `python-dotenv` at module import in each `bot.py` entry point
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming (platform → bot):**
|
||||
- `WebhookReceiver.on_agent_event(event: AgentEvent)` — receives async task completion notifications
|
||||
- Not yet wired to an HTTP endpoint; `MockPlatformClient.simulate_agent_event()` used for testing
|
||||
|
||||
**Outgoing (bot → external):**
|
||||
- Telegram: all via `aiogram` polling or webhook (no direct outbound HTTP beyond Telegram API)
|
||||
- Matrix: all via `matrix-nio` `AsyncClient.room_send()`, `room_typing()`, etc.
|
||||
- Platform: via `PlatformClient` send/stream methods
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-04-01*
|
||||
|
|
|
|||
|
|
@ -1,14 +1,113 @@
|
|||
# Технологический стек (STACK.md)
|
||||
# Technology Stack
|
||||
|
||||
## Язык и Runtime
|
||||
- **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*
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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_<behavior_under_test>` — descriptive, no abbreviations
|
||||
- Fixture names match the object they create: `mgr`, `store`, `dispatcher`, `deps`
|
||||
- Factory functions in converter tests: `text_event()`, `file_event()`, `image_event()`, `reaction_event()`
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-04-01*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
|
||||
task: 1
|
||||
total_tasks: 2
|
||||
status: paused
|
||||
last_updated: 2026-04-07T21:29:48.982Z
|
||||
---
|
||||
|
||||
<current_state>
|
||||
Formally, the most recently active execution artifact inside the roadmap is still `01.1-03-PLAN.md`, which has not been implemented yet. In parallel, the platform-integration track has moved forward: the direct-agent Matrix prototype design is now approved, the implementation plan is written, and the next useful session should evaluate that spec/plan pair against the live platform repos before starting execution.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
|
||||
- Re-analysed live platform repos on 2026-04-07 by cloning `platform/agent`, `platform/agent_api`, `platform/master`, and `platform/docs`.
|
||||
- Confirmed `master` is still only a thin HTTP skeleton with `/health` and `/users/{user_id}`, not a chat/session/settings backend for surfaces.
|
||||
- Confirmed `agent` exposes a working `/agent_ws/` WebSocket and `agent_api` provides enough protocol/client code to stream model output.
|
||||
- Identified the real technical gap for a prototype: `agent` currently uses a singleton service with a fixed `thread_id="default"`, so all conversations would share memory unless that is parameterized.
|
||||
- Derived and got approval for the prototype path: keep Matrix adapter logic largely intact, add `sdk/agent_session.py`, `sdk/prototype_state.py`, and `sdk/real.py`, keep settings local, and use the direct `agent` WebSocket for real messaging.
|
||||
- Resolved the repo-placement question: the prototype stays in this repo on its own branch, not in a separate prototype repo.
|
||||
- Resolved the platform-change minimization question: prefer patching only `platform/agent`, not `platform/agent_api`, and use a tiny local WebSocket client in this repo.
|
||||
- Wrote and committed the approved design spec: `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md`.
|
||||
- Wrote the implementation plan: `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`.
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
|
||||
- Task 1: Implement `adapter.matrix.reset` with `local-only`, `server-leave-forget`, and `--dry-run`, plus tests.
|
||||
- Task 2: Update `README.md` so restart vs explicit reset workflow is documented and the old manual reset ritual is removed.
|
||||
- Prototype evaluation follow-up: review the approved spec and plan against the platform repos before starting execution.
|
||||
- Future prototype work: introduce `sdk/real.py` plus a narrow compatibility boundary that keeps Matrix adapter logic stable while allowing later expansion toward a fuller platform split.
|
||||
</remaining_work>
|
||||
|
||||
<decisions_made>
|
||||
|
||||
- Do not integrate with `master` yet; it is still not the backend surfaces needs.
|
||||
- Use the direct `agent` WebSocket as the only realistic path for a working prototype right now.
|
||||
- Keep consumer-facing Matrix logic as intact as possible and absorb backend differences inside a shim under `sdk/`.
|
||||
- Treat future platform evolution as likely to split into at least two concerns: control-plane access and direct agent session streaming.
|
||||
- Keep the prototype in this repo on its own branch.
|
||||
- Minimize platform-side changes by patching only `platform/agent` if possible.
|
||||
</decisions_made>
|
||||
|
||||
<blockers>
|
||||
- Phase 01.1 itself is not blocked; it is simply paused.
|
||||
- Prototype blocker: the `agent` repo currently hardcodes a shared `thread_id`, so per-user/per-chat conversation isolation requires either a small upstream change or a careful workaround.
|
||||
- Platform contract blocker remains for the longer-term Phase 02 direction: `master` still lacks stable user/chat/session/settings APIs for surfaces.
|
||||
</blockers>
|
||||
|
||||
<context>
|
||||
The important mental model is now stable enough to execute. Full SDK integration through `master` is still premature, but a working Matrix prototype can be built now by talking directly to the `agent` WebSocket and hiding the split backend reality behind `sdk/real.py`. The approved design keeps the prototype in this repo, keeps settings local, and minimizes platform changes by preferring a tiny `platform/agent` patch over broader protocol churn. For evaluation and implementation context, inspect:
|
||||
- local spec: `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md`
|
||||
- local plan: `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`
|
||||
- remote repos: `https://git.lambda.coredump.ru/platform/agent`, `https://git.lambda.coredump.ru/platform/master`, `https://git.lambda.coredump.ru/platform/agent_api`
|
||||
- local clones: `/tmp/platform-agent`, `/tmp/platform-master`, `/tmp/platform-agent_api`
|
||||
</context>
|
||||
|
||||
<next_action>
|
||||
Resume with one of these depending on priority:
|
||||
1. Evaluate the approved prototype spec and implementation plan against the live platform repos and decide whether to start in this repo or patch `platform/agent` first.
|
||||
2. If staying on roadmap execution, implement `01.1-03-PLAN.md` Task 1 (`adapter.matrix.reset`) first.
|
||||
3. If starting prototype execution immediately, begin with Task 1 of `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`.
|
||||
</next_action>
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
---
|
||||
phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- adapter/matrix/reconcile.py
|
||||
- tests/adapter/matrix/test_reconcile.py
|
||||
autonomous: true
|
||||
requirements: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A normal Matrix restart can rebuild missing local metadata from already joined Space/chat rooms instead of requiring a destructive reset."
|
||||
- "Reconciliation restores the minimal local state needed for routing and chat operations: `matrix_user:*`, `matrix_room:*`, and missing `chat:{user}:{chat_id}` rows."
|
||||
- "Reconciliation never provisions new Matrix rooms or Spaces while repairing local state."
|
||||
- "Recovered users get `next_chat_index` advanced past the highest recovered `C*` chat id."
|
||||
artifacts:
|
||||
- path: "adapter/matrix/reconcile.py"
|
||||
provides: "Matrix bootstrap reconciliation helpers and structured report objects."
|
||||
- path: "tests/adapter/matrix/test_reconcile.py"
|
||||
provides: "Regression coverage for startup and single-room reconciliation behavior."
|
||||
key_links:
|
||||
- from: "adapter/matrix/reconcile.py"
|
||||
to: "adapter/matrix/store.py"
|
||||
via: "set_user_meta and set_room_meta restore Matrix metadata"
|
||||
pattern: "set_(user|room)_meta"
|
||||
- from: "adapter/matrix/reconcile.py"
|
||||
to: "core/chat.py"
|
||||
via: "chat_mgr.get_or_create repairs missing `chat:*` rows"
|
||||
pattern: "chat_mgr\\.get_or_create"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the non-destructive Matrix reconciliation layer that Phase 01.1 depends on.
|
||||
|
||||
Purpose: Per D-01 through D-07, the adapter must stop treating local SQLite state as the only truth in dev. Startup and recovery code need a single helper module that can rebuild local metadata from the homeserver room graph without creating duplicate Spaces or chats.
|
||||
Output: `adapter/matrix/reconcile.py` with full-run and single-room recovery helpers, plus targeted pytest coverage.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md
|
||||
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md
|
||||
@.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md
|
||||
@adapter/matrix/store.py
|
||||
@adapter/matrix/handlers/auth.py
|
||||
@core/chat.py
|
||||
@tests/adapter/matrix/test_invite_space.py
|
||||
|
||||
<interfaces>
|
||||
From `adapter/matrix/store.py`:
|
||||
|
||||
```python
|
||||
async def get_room_meta(store: StateStore, room_id: str) -> dict | None
|
||||
async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None
|
||||
async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None
|
||||
async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None
|
||||
```
|
||||
|
||||
From `core/chat.py`:
|
||||
|
||||
```python
|
||||
async def get_or_create(
|
||||
self,
|
||||
user_id: str,
|
||||
chat_id: str,
|
||||
platform: str,
|
||||
surface_ref: str,
|
||||
name: str | None = None,
|
||||
) -> ChatContext
|
||||
```
|
||||
|
||||
From Phase 01 room metadata shape:
|
||||
|
||||
```python
|
||||
{
|
||||
"room_type": "chat",
|
||||
"chat_id": "C4",
|
||||
"display_name": "Чат 4",
|
||||
"matrix_user_id": "@alice:example.org",
|
||||
"space_id": "!space:example.org",
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add reconciliation module for startup and single-room recovery</name>
|
||||
<files>adapter/matrix/reconcile.py, tests/adapter/matrix/test_reconcile.py</files>
|
||||
<read_first>adapter/matrix/store.py, adapter/matrix/handlers/auth.py, core/chat.py, tests/adapter/matrix/test_invite_space.py, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: `reconcile_matrix_state(...)` recreates missing `matrix_user:*`, `matrix_room:*`, and `chat:*` entries from joined Matrix rooms without calling `room_create`.
|
||||
- Test 2: `reconcile_matrix_state(...)` leaves already-correct local metadata intact and reports restored vs skipped/conflicting rooms.
|
||||
- Test 3: `reconcile_single_room(...)` can repair one `unregistered:{room_id}` chat room on demand and recompute `next_chat_index` for that user.
|
||||
- Test 4: Space rooms or unrelated joined rooms are skipped, not converted into chat rows.
|
||||
</behavior>
|
||||
<action>
|
||||
Create `adapter/matrix/reconcile.py` as the authoritative recovery module for this phase. Implement a small, explicit API that Plan 02 can wire directly:
|
||||
|
||||
```python
|
||||
async def reconcile_matrix_state(client: Any, store: StateStore, chat_mgr: ChatManager) -> dict: ...
|
||||
async def reconcile_single_room(
|
||||
client: Any, store: StateStore, chat_mgr: ChatManager, room_id: str, matrix_user_id: str
|
||||
) -> dict: ...
|
||||
```
|
||||
|
||||
Inside this module, add focused private helpers as needed for room classification, extracting room names, parsing `C<number>` ids, and recomputing `next_chat_index`. Keep the logic non-destructive per D-04:
|
||||
- never call `room_create`, `room_invite`, or provisioning code from `handlers/auth.py`
|
||||
- prefer already-hydrated room data from the post-sync client object, and only fall back to explicit room-state fetches if required for room classification
|
||||
- rebuild only the minimal metadata required by D-03: `matrix_user:*`, `matrix_room:*`, and missing `chat:{user}:{chat_id}` records
|
||||
- if `chat:*` exists but points at the wrong `surface_ref`, repair it from Matrix room metadata and include the fix in the returned report
|
||||
- derive `next_chat_index` from the highest recovered `C<number>` for that user instead of trusting stale local counters
|
||||
|
||||
Return a structured reconciliation report with stable keys such as:
|
||||
`joined_rooms`, `restored_user_meta`, `restored_room_meta`, `restored_chat_rows`, `repaired_chat_rows`, `skipped_rooms`, and `conflicts`.
|
||||
|
||||
Write `tests/adapter/matrix/test_reconcile.py` with lightweight `SimpleNamespace`/fake-client fixtures following the existing Matrix test style. Cover both full startup reconciliation and `reconcile_single_room(...)`. Assert that no provisioning calls are made during reconciliation, because D-04 forbids creating new Space/room topology while recovering local state.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_reconcile.py -q</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `adapter/matrix/reconcile.py` exports `reconcile_matrix_state` and `reconcile_single_room`.
|
||||
- Reconciliation restores missing `matrix_user:*`, `matrix_room:*`, and `chat:*` entries for already-joined Matrix chat rooms per D-02 and D-03.
|
||||
- Reconciliation does not call `room_create` or otherwise provision new server-side rooms per D-04.
|
||||
- The report returned by reconciliation clearly distinguishes restored items, skipped rooms, and conflicts.
|
||||
- `tests/adapter/matrix/test_reconcile.py` proves `next_chat_index` is recomputed from recovered chat ids rather than stale local state.
|
||||
</acceptance_criteria>
|
||||
<done>The repository has an executable, tested reconciliation layer that can rebuild local Matrix metadata after dev-state loss without duplicating server-side rooms.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Run `pytest tests/adapter/matrix/test_reconcile.py -q` and confirm startup and single-room reconciliation paths are covered.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Matrix recovery logic exists as a dedicated module instead of being scattered through handlers.
|
||||
- Reconciliation is idempotent, non-destructive, and sufficient to restore routing/chat metadata from existing Matrix rooms.
|
||||
- Plan 02 can wire startup and first-access recovery by calling exported functions rather than inventing new recovery logic.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-01-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
---
|
||||
phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["01.1-01"]
|
||||
files_modified:
|
||||
- adapter/matrix/bot.py
|
||||
- tests/adapter/matrix/test_dispatcher.py
|
||||
autonomous: true
|
||||
requirements: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The Matrix bot performs an initial sync and reconciliation before entering steady-state `sync_forever()`."
|
||||
- "If a room still arrives as `unregistered:{room_id}` after startup, the bot makes one targeted recovery attempt before dispatching or failing."
|
||||
- "When reconciliation cannot repair a room, the bot logs a clear diagnostic reason instead of crashing on downstream commands like `!rename`."
|
||||
artifacts:
|
||||
- path: "adapter/matrix/bot.py"
|
||||
provides: "Startup bootstrap flow with initial sync, reconciliation, and targeted runtime retry."
|
||||
- path: "tests/adapter/matrix/test_dispatcher.py"
|
||||
provides: "Matrix runtime coverage for pre-sync reconcile and on-message recovery behavior."
|
||||
key_links:
|
||||
- from: "adapter/matrix/bot.py"
|
||||
to: "adapter/matrix/reconcile.py"
|
||||
via: "startup bootstrap and single-room recovery calls"
|
||||
pattern: "reconcile_(matrix_state|single_room)"
|
||||
- from: "adapter/matrix/bot.py"
|
||||
to: "adapter/matrix/room_router.py"
|
||||
via: "unregistered room detection before dispatch"
|
||||
pattern: "unregistered:"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the new reconciliation layer into the actual Matrix runtime.
|
||||
|
||||
Purpose: D-05 through D-07 require restart recovery to be the default developer path. The bot must bootstrap itself from existing Matrix rooms on startup and make one on-demand repair attempt before routing an unknown room through the dispatcher.
|
||||
Output: `adapter/matrix/bot.py` performs initial sync + reconciliation before `sync_forever()`, and runtime tests prove the bot recovers or logs clearly instead of blindly dispatching broken state.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md
|
||||
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md
|
||||
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-01-PLAN.md
|
||||
@adapter/matrix/bot.py
|
||||
@adapter/matrix/room_router.py
|
||||
@adapter/matrix/reconcile.py
|
||||
@tests/adapter/matrix/test_dispatcher.py
|
||||
|
||||
<interfaces>
|
||||
From `adapter/matrix/bot.py`:
|
||||
|
||||
```python
|
||||
class MatrixBot:
|
||||
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None
|
||||
|
||||
async def main() -> None
|
||||
```
|
||||
|
||||
From `adapter/matrix/reconcile.py`:
|
||||
|
||||
```python
|
||||
async def reconcile_matrix_state(client: Any, store: StateStore, chat_mgr: ChatManager) -> dict
|
||||
async def reconcile_single_room(
|
||||
client: Any, store: StateStore, chat_mgr: ChatManager, room_id: str, matrix_user_id: str
|
||||
) -> dict
|
||||
```
|
||||
|
||||
From `adapter/matrix/room_router.py`:
|
||||
|
||||
```python
|
||||
async def resolve_chat_id(store: StateStore, room_id: str, matrix_user_id: str) -> str
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Run initial sync and reconciliation before the long-poll loop</name>
|
||||
<files>adapter/matrix/bot.py, tests/adapter/matrix/test_dispatcher.py</files>
|
||||
<read_first>adapter/matrix/bot.py, adapter/matrix/reconcile.py, tests/adapter/matrix/test_dispatcher.py, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: `main()` performs `client.sync(timeout=0, full_state=True)` before `sync_forever()`.
|
||||
- Test 2: `main()` calls `reconcile_matrix_state(...)` after the initial sync and logs the returned report.
|
||||
- Test 3: startup still reaches `sync_forever()` when reconciliation reports recoverable skips/conflicts instead of fatal failure.
|
||||
</behavior>
|
||||
<action>
|
||||
Modify `adapter/matrix/bot.py` so normal startup follows the two-phase bootstrap recommended in research:
|
||||
1. build client and runtime
|
||||
2. authenticate
|
||||
3. register callbacks
|
||||
4. run `await client.sync(timeout=0, full_state=True)`
|
||||
5. run `await reconcile_matrix_state(client, runtime.store, runtime.chat_mgr)`
|
||||
6. log a structured `matrix_reconcile_complete` event with the report fields
|
||||
7. enter `await client.sync_forever(timeout=30000)`
|
||||
|
||||
Do not move provisioning logic into startup. The startup step only rehydrates local state from server-side rooms per D-02 through D-04.
|
||||
|
||||
Update or add focused tests in `tests/adapter/matrix/test_dispatcher.py` using `monkeypatch`/fake-client patterns already used in the repo so the verify command proves the call order and logging-safe behavior. The test should fail if `sync_forever()` starts before reconciliation.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_dispatcher.py -q</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `adapter/matrix/bot.py` runs an initial full-state sync before steady-state polling.
|
||||
- `adapter/matrix/bot.py` invokes `reconcile_matrix_state(...)` exactly once during startup.
|
||||
- Startup logs a structured reconciliation summary instead of silently skipping the recovery step.
|
||||
- `tests/adapter/matrix/test_dispatcher.py` asserts the bootstrap order explicitly.
|
||||
</acceptance_criteria>
|
||||
<done>Normal Matrix bot startup now includes a recovery pass before the event loop begins handling user traffic.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Retry unknown-room routing once before dispatching broken state</name>
|
||||
<files>adapter/matrix/bot.py, tests/adapter/matrix/test_dispatcher.py</files>
|
||||
<read_first>adapter/matrix/bot.py, adapter/matrix/room_router.py, adapter/matrix/reconcile.py, tests/adapter/matrix/test_dispatcher.py, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: `MatrixBot.on_room_message(...)` detects `unregistered:{room_id}`, runs `reconcile_single_room(...)`, then retries `resolve_chat_id(...)`.
|
||||
- Test 2: if retry succeeds, the event is dispatched against the recovered logical chat id.
|
||||
- Test 3: if retry still fails, the bot does not crash; it logs a clear warning and sends a user-facing diagnostic message to that room.
|
||||
</behavior>
|
||||
<action>
|
||||
Extend `MatrixBot.on_room_message(...)` so D-07 is satisfied even when startup could not repair a room yet. Keep `resolve_chat_id(...)` as the room-router source of truth, but treat `unregistered:{room_id}` as a recovery trigger rather than a stable runtime identity:
|
||||
- first call `resolve_chat_id(...)`
|
||||
- if the result starts with `unregistered:`, call `reconcile_single_room(client, runtime.store, runtime.chat_mgr, room.room_id, event.sender)`
|
||||
- immediately retry `resolve_chat_id(...)`
|
||||
- only dispatch once a concrete logical chat id exists
|
||||
- if the retry still returns `unregistered:{room_id}`, log a structured warning with room id, matrix user id, and reconciliation report, then send a short `OutgoingMessage`-equivalent Matrix text explaining that local state could not be restored automatically and a dev reset/restart may be required
|
||||
|
||||
Do not invent a new fallback chat id and do not auto-create rooms here; that would violate D-04. Keep this change inside `adapter/matrix/bot.py` so file ownership stays isolated for this plan.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_dispatcher.py -q</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Unknown Matrix rooms trigger one targeted reconciliation attempt before dispatch.
|
||||
- Successful targeted recovery leads to normal dispatch with a real logical `chat_id`.
|
||||
- Failed targeted recovery logs a clear diagnostic and avoids a handler crash on missing chat state per D-06.
|
||||
- No code path in this task provisions new Matrix rooms or Spaces.
|
||||
</acceptance_criteria>
|
||||
<done>The runtime treats unknown rooms as recoverable state drift first, not as a silent routing failure or crash path.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Run `pytest tests/adapter/matrix/test_dispatcher.py -q` and confirm both startup-bootstrap and first-access recovery behaviors are covered.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A standard Matrix restart now attempts recovery before the bot starts processing live events.
|
||||
- Unknown-room events are diagnosable and recoverable instead of falling straight into broken command handling.
|
||||
- The runtime never provisions new server-side rooms during restart reconciliation.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-02-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- adapter/matrix/reset.py
|
||||
- tests/adapter/matrix/test_reset.py
|
||||
- README.md
|
||||
autonomous: true
|
||||
requirements: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Developers have an explicit dev-only reset command instead of relying on memory or ad hoc shell history."
|
||||
- "The default reset mode clears only local Matrix state and explains the manual Matrix-client cleanup that may still be needed."
|
||||
- "Optional server cleanup is clearly named around leave/forget semantics and supports dry-run output."
|
||||
artifacts:
|
||||
- path: "adapter/matrix/reset.py"
|
||||
provides: "Dev reset CLI for local-only, server-leave-forget, and dry-run workflows."
|
||||
- path: "tests/adapter/matrix/test_reset.py"
|
||||
provides: "CLI coverage for local reset behavior and printed operator guidance."
|
||||
- path: "README.md"
|
||||
provides: "Updated developer instructions for normal restart vs explicit reset."
|
||||
key_links:
|
||||
- from: "adapter/matrix/reset.py"
|
||||
to: "README.md"
|
||||
via: "documented invocation and manual Matrix cleanup guidance"
|
||||
pattern: "adapter\\.matrix\\.reset"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Ship the dev reset workflow that complements normal restart reconciliation.
|
||||
|
||||
Purpose: D-08 through D-10 require a repeatable, explicit reset path for clean-room QA without making destructive cleanup the default restart flow. This plan creates the tool and updates the runbook developers actually use.
|
||||
Output: `adapter/matrix/reset.py`, pytest coverage, and README instructions that replace the old `rm -f lambda_matrix.db` ritual.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md
|
||||
@.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md
|
||||
@README.md
|
||||
@adapter/matrix/bot.py
|
||||
@core/store.py
|
||||
|
||||
<interfaces>
|
||||
From `adapter/matrix/bot.py` env usage:
|
||||
|
||||
```python
|
||||
db_path = os.environ.get("MATRIX_DB_PATH", "lambda_matrix.db")
|
||||
store_path = os.environ.get("MATRIX_STORE_PATH", "matrix_store")
|
||||
homeserver = os.environ.get("MATRIX_HOMESERVER")
|
||||
user_id = os.environ.get("MATRIX_USER_ID")
|
||||
```
|
||||
|
||||
From `core/store.py`:
|
||||
|
||||
```python
|
||||
class SQLiteStore:
|
||||
def __init__(self, db_path: str) -> None: ...
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add a dev-only Matrix reset CLI with explicit modes</name>
|
||||
<files>adapter/matrix/reset.py, tests/adapter/matrix/test_reset.py</files>
|
||||
<read_first>adapter/matrix/bot.py, core/store.py, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: `--mode local-only` deletes the configured local DB/store paths or reports what would be deleted in dry-run mode.
|
||||
- Test 2: `--mode server-leave-forget --dry-run` prints the exact rooms it would leave/forget and does not mutate local files.
|
||||
- Test 3: when server cleanup is not executed, the command prints the manual Matrix-client steps required by D-10.
|
||||
</behavior>
|
||||
<action>
|
||||
Create `adapter/matrix/reset.py` as a CLI entrypoint runnable via `uv run python -m adapter.matrix.reset`. Use `argparse` and keep the tool explicitly dev-only in its help text and logs.
|
||||
|
||||
Implement the following modes from research and locked decisions:
|
||||
- `local-only` (default destructive mode for local QA): remove `MATRIX_DB_PATH` and `MATRIX_STORE_PATH` if they exist; if not, report that they were already absent
|
||||
- `server-leave-forget`: for the bot account only, log in using the same Matrix env vars as `adapter/matrix/bot.py`, inspect joined rooms, and call `room_leave()` + `room_forget()` for each joined room; support `--dry-run` so the operator can inspect the target set before mutation
|
||||
- `--dry-run` must work with both modes and print a structured summary instead of mutating files or Matrix membership
|
||||
|
||||
Always print a post-run summary that distinguishes:
|
||||
- what local files/directories were deleted or would be deleted
|
||||
- what server-side leave/forget actions were executed or would be executed
|
||||
- the manual Matrix client steps still required for a true clean-room QA rerun (leave/archive old rooms or Space in Element, accept fresh invites, etc.) when those actions are outside this phase
|
||||
|
||||
Write `tests/adapter/matrix/test_reset.py` to cover local-only deletion, dry-run output, and server-leave-forget dry-run behavior with fake clients/temporary directories. Follow the repo’s existing lightweight async test style.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_reset.py -q</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `adapter/matrix/reset.py` supports `local-only`, `server-leave-forget`, and `--dry-run`.
|
||||
- `local-only` reset targets both `lambda_matrix.db` and `matrix_store` via env-aware paths per D-09.
|
||||
- The tool never claims to globally delete Matrix rooms; it uses leave/forget semantics or prints manual cleanup instructions per D-10.
|
||||
- `tests/adapter/matrix/test_reset.py` proves dry-run mode is non-destructive.
|
||||
</acceptance_criteria>
|
||||
<done>The repository contains a repeatable dev reset tool that replaces the undocumented shell ritual and names server-side cleanup honestly.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Replace the README reset ritual with the new restart and reset workflow</name>
|
||||
<files>README.md</files>
|
||||
<read_first>README.md, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-CONTEXT.md, .planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-RESEARCH.md</read_first>
|
||||
<action>
|
||||
Update `README.md` so Matrix development instructions reflect Phase 01.1 instead of the old destructive reset ritual. Replace the current manual QA block that tells developers to `rm -f lambda_matrix.db` with a short, explicit split:
|
||||
- normal restart: `PYTHONPATH=. uv run python -m adapter.matrix.bot` now performs reconciliation automatically
|
||||
- explicit clean-room reset: `PYTHONPATH=. uv run python -m adapter.matrix.reset --mode local-only`
|
||||
- optional server cleanup preview: `PYTHONPATH=. uv run python -m adapter.matrix.reset --mode server-leave-forget --dry-run`
|
||||
|
||||
State clearly that normal restart is the default path per D-05, and that full server-side cleanup may still require manual steps in the Matrix client. Keep the README concise; do not add production guidance or Phase 2 SDK content.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m adapter.matrix.reset --help >/tmp/matrix-reset-help.txt && rg -n "adapter.matrix.reset|local-only|server-leave-forget|reconciliation" README.md /tmp/matrix-reset-help.txt</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `README.md` no longer recommends raw `rm -f lambda_matrix.db` as the default Matrix restart workflow.
|
||||
- `README.md` documents the normal restart path and the explicit reset path separately.
|
||||
- The documented reset commands match the CLI implemented in `adapter/matrix/reset.py`.
|
||||
</acceptance_criteria>
|
||||
<done>Developers can follow a repeatable README workflow for ordinary restart and clean-room QA reset without relying on tribal knowledge.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Run `pytest tests/adapter/matrix/test_reset.py -q` and `python -m adapter.matrix.reset --help`, then confirm the README commands and help text stay aligned.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Dev reset is an explicit tool, not a remembered shell sequence.
|
||||
- Local-only reset is automated and documented.
|
||||
- Server cleanup semantics are honest, dry-runnable, and accompanied by manual Matrix-client guidance where needed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/01.1-03-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
# Phase 01.1: Matrix restart reconciliation and dev reset workflow - Context
|
||||
|
||||
**Gathered:** 2026-04-03
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Сделать Matrix-адаптер пригодным для повторяемой локальной разработки и ручного QA без ручного “ритуала” удаления БД, выхода из всех комнат и пересоздания пользователя.
|
||||
|
||||
В scope этой фазы:
|
||||
- безопасный restart flow для Matrix-бота после потери локального state
|
||||
- reconciliation локального store с уже существующими Matrix rooms / Space
|
||||
- отдельный dev reset workflow для controlled clean-room QA
|
||||
- диагностируемое поведение при несогласованности local state и server-side Matrix state
|
||||
|
||||
Вне scope:
|
||||
- реальный Lambda SDK
|
||||
- новые пользовательские Matrix features
|
||||
- E2EE
|
||||
- production-grade multi-user migration framework
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Matrix state lifecycle
|
||||
|
||||
- **D-01:** Локальный SQLite store больше не должен считаться единственной точкой истины для Matrix runtime в dev workflow.
|
||||
- **D-02:** При старте бот должен пытаться восстановить минимально необходимое локальное состояние из уже существующих Matrix rooms / Space, а не требовать full reset.
|
||||
- **D-03:** Reconciliation должен восстанавливать как минимум `matrix_user:*`, `matrix_room:*` и missing `chat:{user}:{chat_id}` записи, если серверные комнаты уже существуют.
|
||||
- **D-04:** Reconciliation не должен создавать новые Space/rooms, если задача — именно восстановление локального state после рестарта.
|
||||
|
||||
### Dev restart behavior
|
||||
|
||||
- **D-05:** Обычный restart бота должен быть основным путём для разработки; удаление `lambda_matrix.db` и `matrix_store` не должно быть обязательным для проверки workflow.
|
||||
- **D-06:** Если local state неполон, бот должен либо восстановить его, либо логировать понятную причину, а не падать на командах вроде `!rename`.
|
||||
- **D-07:** Несогласованность между `room_meta` и `ChatManager` должна обнаруживаться и устраняться автоматически на startup или при первом обращении.
|
||||
|
||||
### Dev reset workflow
|
||||
|
||||
- **D-08:** Нужен отдельный dev-only reset tool/script для controlled QA, вместо ручного набора shell-команд.
|
||||
- **D-09:** Reset workflow должен как минимум поддерживать `local-only` reset: удаление `lambda_matrix.db` и `matrix_store` с понятной инструкцией, что делать с server-side Matrix rooms.
|
||||
- **D-10:** Если full server-side cleanup не автоматизируется в этой фазе, tool должен явно печатать, какие ручные шаги обязательны в Matrix client.
|
||||
|
||||
### The agent's Discretion
|
||||
|
||||
- Точное место вызова reconciliation в startup flow
|
||||
- Внутренняя структура helper-модуля (`bootstrap.py`, `reconcile.py` или аналог)
|
||||
- Формат dev reset script и уровень автоматизации server-side cleanup
|
||||
- Детали debug-logging и dry-run режима, если они помогают без раздувания scope
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Главный критерий: после обычного restart бот не должен ломаться только потому, что local DB была сброшена или частично потеряна.
|
||||
- Reset workflow должен быть явным и repeatable, а не завязанным на память разработчика.
|
||||
- Нужно различать две ситуации:
|
||||
- broken because code is wrong
|
||||
- broken because local dev state was deliberately reset and requires reconciliation
|
||||
|
||||
</specifics>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Matrix phase artifacts
|
||||
- `.planning/phases/01-matrix-qa-polish/01-CONTEXT.md` — locked Matrix decisions from Phase 1
|
||||
- `.planning/phases/01-matrix-qa-polish/01-VERIFICATION.md` — what Phase 1 already validated and what manual QA still expects
|
||||
- `.planning/phases/01-matrix-qa-polish/01-HUMAN-UAT.md` — remaining real-client Matrix checks
|
||||
|
||||
### Current Matrix runtime
|
||||
- `adapter/matrix/bot.py` — startup flow, sync loop, runtime wiring, DB/store env vars
|
||||
- `adapter/matrix/store.py` — persisted Matrix metadata and pending confirmation keys
|
||||
- `adapter/matrix/room_router.py` — room_id to chat_id resolution and current unregistered fallback
|
||||
- `adapter/matrix/handlers/auth.py` — invite bootstrap that creates Space and first chat room
|
||||
- `core/chat.py` — `ChatManager` persistence contract that currently breaks when local state is missing
|
||||
|
||||
### Supporting docs
|
||||
- `docs/matrix-prototype.md` — intended Matrix UX and architecture direction
|
||||
- `README.md` — current run instructions and existing manual QA/reset habits
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `adapter/matrix/store.py` already persists room/user metadata and is the obvious place to anchor reconciliation inputs.
|
||||
- `adapter/matrix/room_router.py` already detects unknown rooms via `unregistered:{room_id}` fallback; this is a useful reconciliation trigger point.
|
||||
- `core/chat.py` already has `get_or_create`, `rename`, `archive`, `list_active`; missing chat records can be rebuilt through this API instead of inventing a parallel format.
|
||||
|
||||
### Established Patterns
|
||||
- Matrix runtime uses `SQLiteStore` for adapter-local metadata and `matrix-nio` room callbacks for transport events.
|
||||
- Phase 1 already moved Matrix to Space+rooms and command-only confirmations, so this phase must preserve that model rather than reverting to DM-first simplifications.
|
||||
|
||||
### Integration Points
|
||||
- Startup path in `adapter/matrix/bot.py:main()` is the natural place to run reconciliation before `sync_forever`.
|
||||
- Invite/bootstrap path in `adapter/matrix/handlers/auth.py` is the existing source of truth for what metadata a healthy first room should have.
|
||||
- `ChatManager` records and `matrix_room:*` metadata must stay consistent enough that commands like `!rename`, `!archive`, and `!chats` work after restart.
|
||||
|
||||
</code_context>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Full production-grade migration of historical Matrix state across schema versions
|
||||
- Automatic server-side deletion/leave for all Matrix rooms and Space during reset, if it requires broader admin semantics
|
||||
- Any Phase 2 SDK integration work
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow*
|
||||
*Context gathered: 2026-04-03*
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
# Phase 01.1: Matrix restart reconciliation and dev reset workflow - Research
|
||||
|
||||
**Researched:** 2026-04-03
|
||||
**Domain:** Matrix adapter restart reconciliation, local state recovery, dev reset workflow
|
||||
**Confidence:** HIGH
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- **D-01:** Локальный SQLite store больше не должен считаться единственной точкой истины для Matrix runtime в dev workflow.
|
||||
- **D-02:** При старте бот должен пытаться восстановить минимально необходимое локальное состояние из уже существующих Matrix rooms / Space, а не требовать full reset.
|
||||
- **D-03:** Reconciliation должен восстанавливать как минимум `matrix_user:*`, `matrix_room:*` и missing `chat:{user}:{chat_id}` записи, если серверные комнаты уже существуют.
|
||||
- **D-04:** Reconciliation не должен создавать новые Space/rooms, если задача — именно восстановление локального state после рестарта.
|
||||
- **D-05:** Обычный restart бота должен быть основным путём для разработки; удаление `lambda_matrix.db` и `matrix_store` не должно быть обязательным для проверки workflow.
|
||||
- **D-06:** Если local state неполон, бот должен либо восстановить его, либо логировать понятную причину, а не падать на командах вроде `!rename`.
|
||||
- **D-07:** Несогласованность между `room_meta` и `ChatManager` должна обнаруживаться и устраняться автоматически на startup или при первом обращении.
|
||||
- **D-08:** Нужен отдельный dev-only reset tool/script для controlled QA, вместо ручного набора shell-команд.
|
||||
- **D-09:** Reset workflow должен как минимум поддерживать `local-only` reset: удаление `lambda_matrix.db` и `matrix_store` с понятной инструкцией, что делать с server-side Matrix rooms.
|
||||
- **D-10:** Если full server-side cleanup не автоматизируется в этой фазе, tool должен явно печатать, какие ручные шаги обязательны в Matrix client.
|
||||
|
||||
### Claude's Discretion
|
||||
- Точное место вызова reconciliation в startup flow
|
||||
- Внутренняя структура helper-модуля (`bootstrap.py`, `reconcile.py` или аналог)
|
||||
- Формат dev reset script и уровень автоматизации server-side cleanup
|
||||
- Детали debug-logging и dry-run режима, если они помогают без раздувания scope
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Full production-grade migration of historical Matrix state across schema versions
|
||||
- Automatic server-side deletion/leave for all Matrix rooms and Space during reset, if it requires broader admin semantics
|
||||
- Any Phase 2 SDK integration work
|
||||
</user_constraints>
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 01.1 should be planned as a bootstrap/recovery phase, not as another chat-feature phase. The current Matrix adapter has no startup reconciliation path: `adapter/matrix/bot.py` logs in and goes directly to `sync_forever()`, while routing and command handlers assume `matrix_room:*`, `matrix_user:*`, and `chat:*` keys already exist. That means local DB loss currently produces logical corruption, not just missing cache.
|
||||
|
||||
The safe standard approach is: perform a first sync that hydrates joined-room state, inspect the bot's current joined rooms and room state from the homeserver, rebuild the minimal local metadata needed for command routing, and only then enter the long-running sync loop. Reconciliation should be non-destructive and idempotent: if local keys already exist and match server state, leave them alone; if they are missing, recreate them; if they conflict, prefer the server room topology for Matrix-specific metadata and recreate missing `ChatManager` rows from that.
|
||||
|
||||
For reset, separate two workflows explicitly. `local-only` reset is the default and should be automated. Optional server-side cleanup may leave/forget rooms for the bot account, but it cannot promise global deletion of Matrix rooms for all members; if that is not automated, the tool must print the exact manual steps for the Matrix client.
|
||||
|
||||
**Primary recommendation:** Add a startup `reconcile_matrix_state()` step before `sync_forever()`, and ship a dev-only reset CLI with `local-only`, `server-leave-forget`, and `dry-run` modes.
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Do not treat missing Lambda SDK as a blocker.
|
||||
- Keep all platform calls behind `platform/interface.py`.
|
||||
- Current runtime implementation is `platform/mock.py`; recommendations must work with that.
|
||||
- Prefer architecture changes in adapters and core without coupling to future SDK internals.
|
||||
- Use pytest-based verification.
|
||||
- Do not recommend committing `.env`.
|
||||
- Respect dependency order: `core/` first, then `platform/`, then adapters.
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Python | 3.14.3 installed | Runtime for bot and scripts | Already available locally; codebase targets `>=3.11`. |
|
||||
| `matrix-nio` | 0.25.2, published 2024-10-04 | Matrix client, sync, room membership/state APIs | Already installed; exposes the exact bootstrap/reset APIs this phase needs. |
|
||||
| `SQLiteStore` (repo) | local | Adapter/core KV persistence | Existing persistence contract for `matrix_user:*`, `matrix_room:*`, and `chat:*`. |
|
||||
| Matrix Client-Server API | spec latest | Authoritative room membership/state semantics | Needed to reason about restart recovery and leave/forget behavior correctly. |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `pytest` | 9.0.2, published 2025-12-06 | Test runner | For targeted adapter/bootstrap regression tests. |
|
||||
| `pytest-asyncio` | 1.3.0, published 2025-11-10 | Async test execution | For async reconciliation/reset flows. |
|
||||
| `structlog` | 25.5.0, published 2025-10-27 | Diagnostics | For reconciliation summaries and conflict logging. |
|
||||
| `python-dotenv` | 1.2.2, published 2026-03-01 | Env loading | Already used by `adapter/matrix/bot.py` for Matrix config. |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Startup reconciliation from joined rooms + state | Force developers to wipe local DB and recreate rooms | Simpler code, but directly violates D-01, D-02, D-05. |
|
||||
| Non-destructive local rebuild | Full auto-recreate of Space/rooms on missing local state | Easier to implement, but causes duplicate Matrix rooms and breaks D-04. |
|
||||
| Dev reset script | README-only manual ritual | Lower code cost, but not repeatable and fails D-08..D-10. |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
**Version verification:** Verified via installed environment and PyPI metadata on 2026-04-03:
|
||||
- `matrix-nio` `0.25.2` - 2024-10-04
|
||||
- `pytest` `9.0.2` - 2025-12-06
|
||||
- `pytest-asyncio` `1.3.0` - 2025-11-10
|
||||
- `structlog` `25.5.0` - 2025-10-27
|
||||
- `python-dotenv` `1.2.2` - 2026-03-01
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```text
|
||||
adapter/matrix/
|
||||
├── bot.py # startup flow calls reconciliation before sync loop
|
||||
├── reconcile.py # bootstrap/rebuild logic from Matrix server state
|
||||
├── reset.py # dev-only reset CLI / entrypoint
|
||||
├── room_router.py # room_id -> chat_id with recovery hook
|
||||
├── store.py # metadata helpers, prefix scans, derived counters
|
||||
└── handlers/
|
||||
├── auth.py # first-time provisioning only
|
||||
└── chat.py # uses recovered state, no provisioning fallback
|
||||
```
|
||||
|
||||
### Pattern 1: Two-Phase Startup Bootstrap
|
||||
**What:** Split startup into `login -> initial sync/full_state -> reconcile -> steady-state sync_forever`.
|
||||
**When to use:** Always for Matrix bot startup when local DB may be missing or stale.
|
||||
**Example:**
|
||||
```python
|
||||
# Source: matrix-nio AsyncClient docs/source + repo startup flow
|
||||
client = AsyncClient(...)
|
||||
runtime = build_runtime(store=SQLiteStore(db_path), client=client)
|
||||
|
||||
await login_or_restore_session(client)
|
||||
await client.sync(timeout=0, full_state=True)
|
||||
report = await reconcile_matrix_state(client, runtime.store, runtime.chat_mgr)
|
||||
logger.info("matrix_reconcile_complete", **report)
|
||||
await client.sync_forever(timeout=30000)
|
||||
```
|
||||
|
||||
### Pattern 2: Rebuild Local Metadata From Joined Rooms
|
||||
**What:** Enumerate joined rooms, inspect local hydrated room objects or room state, and recreate missing `matrix_room:*`, `matrix_user:*`, and `chat:*` records.
|
||||
**When to use:** On startup and optionally on `unregistered:{room_id}` fallback at runtime.
|
||||
**Example:**
|
||||
```python
|
||||
# Source: matrix-nio AsyncClient.joined_rooms/room_get_state + repo store contracts
|
||||
joined = await client.joined_rooms()
|
||||
for room_id in joined.rooms:
|
||||
state = await client.room_get_state(room_id)
|
||||
# detect: space room vs chat room, owner user, child relationship, display name
|
||||
# rebuild matrix_room:{room_id}
|
||||
# rebuild chat:{matrix_user_id}:{chat_id} if absent
|
||||
```
|
||||
|
||||
### Pattern 3: Non-Destructive Reconciliation Report
|
||||
**What:** Return a structured report: scanned rooms, restored rooms, restored chats, conflicts, skipped rooms.
|
||||
**When to use:** Every reconciliation run, including dry-run.
|
||||
**Example:**
|
||||
```python
|
||||
{
|
||||
"joined_rooms": 4,
|
||||
"restored_user_meta": 1,
|
||||
"restored_room_meta": 3,
|
||||
"restored_chat_rows": 3,
|
||||
"conflicts": [],
|
||||
"skipped_rooms": ["!dm:example.org"],
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Reset Modes Are Explicit
|
||||
**What:** Separate `local-only`, `server-leave-forget`, and `dry-run`.
|
||||
**When to use:** For dev/QA only. Never mix destructive server cleanup into normal startup.
|
||||
**Example:**
|
||||
```bash
|
||||
uv run python -m adapter.matrix.reset --mode local-only
|
||||
uv run python -m adapter.matrix.reset --mode server-leave-forget --dry-run
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Provisioning during reconciliation:** Do not create a new Space or new rooms while trying to recover missing local state.
|
||||
- **Treating `next_chat_index` as primary truth:** Derive it from recovered `chat_id` values after scan; do not trust a missing or stale counter.
|
||||
- **Routing unknown rooms straight through:** `unregistered:{room_id}` is a signal to reconcile, not a stable runtime identity.
|
||||
- **Destructive reset by default:** Startup must never leave/forget rooms automatically.
|
||||
- **Blindly trusting local `surface_ref`:** If `chat:*` and `matrix_room:*` disagree, rebuild from Matrix room metadata and repair the chat row.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Room discovery | Custom DB-only reconstruction heuristics | `AsyncClient.joined_rooms()` plus synced room state | Server already knows which rooms the bot joined. |
|
||||
| Space membership detection | Naming-convention parsing of room names | Matrix state: `m.room.create.type`, `m.space.child`, `m.space.parent` | Names are mutable and non-authoritative. |
|
||||
| Room cleanup semantics | Custom “delete room” assumptions | `room_leave()` + `room_forget()` semantics | Client API supports leave/forget, not guaranteed global deletion. |
|
||||
| Chat ID recovery | Hardcoded `C1/C2/...` reset | Rebuild from existing `matrix_room:*`/server state and compute next index | Prevents collisions after partial DB loss. |
|
||||
| Diagnostic output | Ad hoc `print()` strings | Structured reconciliation/reset report via `structlog` | Easier manual QA and failure triage. |
|
||||
|
||||
**Key insight:** The homeserver already persists the bot’s room graph. This phase should rehydrate local cache from that graph, not attempt to replace it with a second custom truth model.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Joining the sync loop before reconciliation
|
||||
**What goes wrong:** Commands arrive while local metadata is still missing, producing `unregistered:{room_id}` routing or `ChatManager` misses.
|
||||
**Why it happens:** Current `main()` enters `sync_forever()` immediately after login.
|
||||
**How to avoid:** Perform initial sync and reconciliation first.
|
||||
**Warning signs:** `unregistered_room` logs immediately after restart; `ValueError("Chat ... not found")` on `!rename` or `!archive`.
|
||||
|
||||
### Pitfall 2: Recovering room metadata but not chat rows
|
||||
**What goes wrong:** Room routing works, but `ChatManager.rename/archive/list_active` still fails because `chat:{user}:{chat_id}` rows were not recreated.
|
||||
**Why it happens:** Matrix adapter metadata and core chat metadata live in different keyspaces.
|
||||
**How to avoid:** Reconciliation must repair both stores in one pass.
|
||||
**Warning signs:** `matrix_room:*` exists but `chat:*` keys do not.
|
||||
|
||||
### Pitfall 3: Trusting stale `next_chat_index`
|
||||
**What goes wrong:** New chats reuse existing `C` IDs after local recovery.
|
||||
**Why it happens:** `next_chat_id()` increments a persisted counter that may be absent or behind.
|
||||
**How to avoid:** After scan, set `next_chat_index = max(recovered_chat_numbers) + 1`.
|
||||
**Warning signs:** New room gets `C1` even though Space already contains prior rooms.
|
||||
|
||||
### Pitfall 4: Assuming room names identify chat rooms safely
|
||||
**What goes wrong:** Reconciliation binds the wrong room because a user renamed a room or Space.
|
||||
**Why it happens:** Names are user-facing labels, not stable identifiers.
|
||||
**How to avoid:** Prefer room state and existing `chat_id` metadata; use display names only as fallback.
|
||||
**Warning signs:** Duplicate “Чат 1” names or renamed rooms break matching.
|
||||
|
||||
### Pitfall 5: Over-promising full cleanup
|
||||
**What goes wrong:** Reset script claims a “clean slate” but rooms still exist in Element or for other members.
|
||||
**Why it happens:** Leaving/forgetting affects the bot account’s membership/history, not necessarily global room deletion.
|
||||
**How to avoid:** Name the mode accurately and print the manual client steps when needed.
|
||||
**Warning signs:** QA reruns still show old rooms in the user’s client.
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from official sources and the installed library surface:
|
||||
|
||||
### Initial Sync Before Reconcile
|
||||
```python
|
||||
# Source: matrix-nio AsyncClient.sync/sync_forever
|
||||
await client.sync(timeout=0, full_state=True)
|
||||
report = await reconcile_matrix_state(client, store, chat_mgr)
|
||||
await client.sync_forever(timeout=30000)
|
||||
```
|
||||
|
||||
### Space Child Link Creation
|
||||
```python
|
||||
# Source: Matrix client-server API state event + current auth/new-chat flow
|
||||
await client.room_put_state(
|
||||
room_id=space_id,
|
||||
event_type="m.space.child",
|
||||
content={"via": [homeserver]},
|
||||
state_key=chat_room_id,
|
||||
)
|
||||
```
|
||||
|
||||
### Bot-Side Leave/Forget Cleanup
|
||||
```python
|
||||
# Source: matrix-nio AsyncClient.room_leave / room_forget
|
||||
for room_id in room_ids:
|
||||
await client.room_leave(room_id)
|
||||
await client.room_forget(room_id)
|
||||
```
|
||||
|
||||
### Router Recovery Trigger
|
||||
```python
|
||||
# Source: repo room_router contract
|
||||
chat_id = await resolve_chat_id(store, room_id, matrix_user_id)
|
||||
if chat_id.startswith("unregistered:"):
|
||||
await reconcile_single_room(client, store, chat_mgr, room_id, matrix_user_id)
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Local adapter DB treated as the operational truth | Rebuildable local cache from server room graph | Mature Matrix client practice; supported by current Matrix CS API and `matrix-nio` | Restart no longer requires destructive local reset. |
|
||||
| Manual room cleanup in client after experiments | Scripted leave/forget plus explicit manual instructions | Current `matrix-nio` 0.25.x API surface | QA becomes repeatable and auditable. |
|
||||
| Immediate steady-state sync after login | Initial sync/full-state bootstrap before long polling | Supported by current `AsyncClient.sync()` / `sync_forever()` behavior | Reconciliation can run before any user traffic is handled. |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `README.md` Matrix manual QA instruction `rm -f lambda_matrix.db` as the primary restart flow: outdated for this phase.
|
||||
- DM-first Matrix recovery assumptions in `docs/matrix-prototype.md`: outdated relative to Phase 1 Space+rooms decisions.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **How exactly should reconciliation identify the owning Matrix user for a recovered room when local `matrix_room:*` is gone?**
|
||||
- What we know: the bot can enumerate joined rooms and fetch room state; current healthy metadata stores `matrix_user_id` and `space_id`.
|
||||
- What's unclear: whether Phase 1-created rooms also expose enough server-side structure to recover owner deterministically without existing local metadata in every case.
|
||||
- Recommendation: Plan a proof test against a real homeserver/client. If room-state-only ownership is ambiguous, persist a tiny bot-authored marker state event going forward, but keep that addition narrowly scoped.
|
||||
|
||||
2. **Should runtime recovery happen only on startup, or also lazily on first unknown room access?**
|
||||
- What we know: startup repair satisfies D-02/D-07 for common restart loss; `room_router` already surfaces unknown rooms cleanly.
|
||||
- What's unclear: whether partial DB corruption during runtime is common enough to justify lazy single-room repair in Phase 01.1.
|
||||
- Recommendation: Make startup reconciliation required, lazy room repair optional if it stays small.
|
||||
|
||||
3. **How much of server cleanup should Phase 01.1 automate?**
|
||||
- What we know: `room_leave()` and `room_forget()` are available; global room deletion is not what the client API guarantees.
|
||||
- What's unclear: whether automating bot-side leave/forget is worth the extra risk for this urgent phase.
|
||||
- Recommendation: Keep `local-only` mandatory. Make server cleanup optional and clearly labeled experimental/dev-only if included.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Python | Runtime, scripts, tests | ✓ | 3.14.3 | — |
|
||||
| `uv` | Standard install/run workflow | ✓ | 0.9.30 | `python -m` + existing venv |
|
||||
| `pytest` | Automated verification | ✓ | 9.0.2 | `uv run pytest` |
|
||||
| Matrix homeserver credentials | Real restart/reset manual QA | ✗ in current shell | — | Manual-only after `.env` is configured |
|
||||
| Matrix bot local DB/store paths | Reset workflow | ✓ | defaults in code | Can override with `MATRIX_DB_PATH` / `MATRIX_STORE_PATH` |
|
||||
|
||||
**Missing dependencies with no fallback:**
|
||||
- Live Matrix credentials for real manual reconciliation/reset QA.
|
||||
|
||||
**Missing dependencies with fallback:**
|
||||
- None for repository-only implementation and tests.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | `pytest 9.0.2` + `pytest-asyncio 1.3.0` |
|
||||
| Config file | `pyproject.toml` |
|
||||
| Quick run command | `pytest tests/adapter/matrix -v` |
|
||||
| Full suite command | `pytest tests/ -v` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| PH01.1-BOOT | Startup rebuilds missing `matrix_user:*`, `matrix_room:*`, and `chat:*` from existing rooms without creating new rooms | unit/integration | `pytest tests/adapter/matrix/test_reconcile.py -v` | ❌ Wave 0 |
|
||||
| PH01.1-ROUTER | Unknown room fallback can trigger repair or yields diagnosable warning without crashing commands | unit | `pytest tests/adapter/matrix/test_room_router_reconcile.py -v` | ❌ Wave 0 |
|
||||
| PH01.1-COUNTER | Reconciliation resets `next_chat_index` to recovered max + 1 | unit | `pytest tests/adapter/matrix/test_reconcile.py -k next_chat_index -v` | ❌ Wave 0 |
|
||||
| PH01.1-RESET | Dev reset `local-only` removes local DB/store paths and prints next steps | unit/smoke | `pytest tests/adapter/matrix/test_reset.py -v` | ❌ Wave 0 |
|
||||
| PH01.1-NONDESTRUCTIVE | Reconciliation never calls room creation APIs | unit | `pytest tests/adapter/matrix/test_reconcile.py -k no_create -v` | ❌ Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `pytest tests/adapter/matrix -v`
|
||||
- **Per wave merge:** `pytest tests/ -v`
|
||||
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `tests/adapter/matrix/test_reconcile.py` - startup reconciliation scenarios
|
||||
- [ ] `tests/adapter/matrix/test_reset.py` - CLI/script reset modes and output
|
||||
- [ ] `tests/adapter/matrix/test_room_router_reconcile.py` - lazy recovery or warning behavior
|
||||
- [ ] Integration fixture for a fake `AsyncClient` response surface matching `joined_rooms()` and `room_get_state()`
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Matrix Client-Server API - room state, leave, forget, joined rooms, Spaces semantics: https://spec.matrix.org/latest/client-server-api/index.html
|
||||
- `matrix-nio` installed 0.25.2 API surface verified locally on 2026-04-03 via `AsyncClient.sync`, `sync_forever`, `joined_rooms`, `room_get_state`, `room_leave`, `room_forget`
|
||||
- Repo code: [adapter/matrix/bot.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/bot.py), [adapter/matrix/store.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/store.py), [adapter/matrix/room_router.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/room_router.py), [adapter/matrix/handlers/auth.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/handlers/auth.py), [core/chat.py](/Users/a/MAI/sem2/lambda/surfaces-bot/core/chat.py)
|
||||
- PyPI release metadata: https://pypi.org/project/matrix-nio/ , https://pypi.org/project/pytest/ , https://pypi.org/project/pytest-asyncio/ , https://pypi.org/project/structlog/ , https://pypi.org/project/python-dotenv/
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [README.md](/Users/a/MAI/sem2/lambda/surfaces-bot/README.md) - current manual reset habit and run commands
|
||||
- [docs/matrix-prototype.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/matrix-prototype.md) - original Matrix UX intent, noting outdated DM/reaction sections
|
||||
- [01-CONTEXT.md](/Users/a/MAI/sem2/lambda/surfaces-bot/.planning/phases/01-matrix-qa-polish/01-CONTEXT.md) - locked Phase 1 Matrix decisions
|
||||
- [01-VERIFICATION.md](/Users/a/MAI/sem2/lambda/surfaces-bot/.planning/phases/01-matrix-qa-polish/01-VERIFICATION.md) - what has already been verified and what still needs human Matrix QA
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - verified against installed environment, PyPI metadata, and official Matrix spec
|
||||
- Architecture: HIGH - directly grounded in current repo flow plus current `matrix-nio`/Matrix capabilities
|
||||
- Pitfalls: HIGH - derived from concrete gaps in current startup/store/router code
|
||||
|
||||
**Research date:** 2026-04-03
|
||||
**Valid until:** 2026-05-03
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
phase: 01.1
|
||||
slug: matrix-restart-reconciliation-and-dev-reset-workflow
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-04-03
|
||||
---
|
||||
|
||||
# Phase 01.1 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | `pytest 9.0.2` + `pytest-asyncio 1.3.0` |
|
||||
| **Config file** | `pyproject.toml` |
|
||||
| **Quick run command** | `pytest tests/adapter/matrix -v` |
|
||||
| **Full suite command** | `pytest tests/ -v` |
|
||||
| **Estimated runtime** | ~20 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `pytest tests/adapter/matrix -v`
|
||||
- **After every plan wave:** Run `pytest tests/ -v`
|
||||
- **Before `$gsd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 20 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 01.1-01-01 | 01 | 1 | PH01.1-BOOT | unit/integration | `pytest tests/adapter/matrix/test_reconcile.py -v` | ❌ W0 | ⬜ pending |
|
||||
| 01.1-01-01 | 01 | 1 | PH01.1-COUNTER | unit | `pytest tests/adapter/matrix/test_reconcile.py -k next_chat_index -v` | ❌ W0 | ⬜ pending |
|
||||
| 01.1-01-01 | 01 | 1 | PH01.1-NONDESTRUCTIVE | unit | `pytest tests/adapter/matrix/test_reconcile.py -k no_create -v` | ❌ W0 | ⬜ pending |
|
||||
| 01.1-02-01 | 02 | 2 | PH01.1-BOOT | unit | `pytest tests/adapter/matrix/test_dispatcher.py -k startup -v` | ✅ | ⬜ pending |
|
||||
| 01.1-02-02 | 02 | 2 | PH01.1-ROUTER | unit | `pytest tests/adapter/matrix/test_dispatcher.py -k reconcile -v` | ✅ | ⬜ pending |
|
||||
| 01.1-03-01 | 03 | 1 | PH01.1-RESET | unit/smoke | `pytest tests/adapter/matrix/test_reset.py -v` | ❌ W0 | ⬜ pending |
|
||||
| 01.1-03-02 | 03 | 1 | PH01.1-RESET | smoke | `python -m adapter.matrix.reset --help` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `tests/adapter/matrix/test_reconcile.py` — startup reconciliation scenarios, `next_chat_index`, and no-provisioning assertions
|
||||
- [ ] `tests/adapter/matrix/test_reset.py` — CLI reset modes, dry-run behavior, and operator guidance output
|
||||
- [ ] `tests/adapter/matrix/test_dispatcher.py` — startup bootstrap order and targeted unknown-room recovery coverage
|
||||
- [ ] Fake `AsyncClient` fixture surface for joined rooms, room state, leave, and forget behavior
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Reconciled Space/chat rooms render correctly in a real Matrix client after restart | PH01.1-BOOT | Client UX and homeserver state cannot be fully trusted from fake nio fixtures | 1. Start the bot with existing Space/chat rooms. 2. Verify the bot does not create duplicate Space or chat rooms. 3. Send a command in a recovered room and confirm it routes normally. |
|
||||
| Server-side cleanup leaves the account in a usable Element state after `server-leave-forget` | PH01.1-RESET | Element/archive behavior and homeserver retention are client/server integration concerns | 1. Run `python -m adapter.matrix.reset --mode server-leave-forget --dry-run`. 2. Run without `--dry-run` on a test account. 3. Confirm joined rooms disappear for the bot and fresh invites can be accepted cleanly. |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 20s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
72
.planning/phases/02-prototype/.continue-here.md
Normal file
72
.planning/phases/02-prototype/.continue-here.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
phase: 02-prototype
|
||||
task: 4
|
||||
total_tasks: 4
|
||||
status: paused
|
||||
last_updated: 2026-04-07T23:54:30.473Z
|
||||
---
|
||||
|
||||
<current_state>
|
||||
The Matrix direct-agent prototype is implemented and manually proven working on branch `feat/matrix-direct-agent-prototype`. The current code path can log into Matrix, accept invites, provision the first Space/chat tree for a fresh user, and send live text messages to a patched local `platform-agent` over WebSocket. The immediate remaining engineering gap is not feature delivery but resilience: backend/provider failures can still bubble up as `PlatformError` and crash the Matrix bot process.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
|
||||
- Task 1: Added `sdk/agent_session.py` and transport tests for direct WebSocket messaging with collision-safe `thread_key` generation.
|
||||
- Task 2: Added `sdk/prototype_state.py` and tests for stable local user mapping, settings defaults, and mutation-safe settings copies.
|
||||
- Task 3: Added `sdk/real.py` as the `PlatformClient` implementation, fixed import-time dependency leakage, and aligned thread-key tests to the actual dispatcher contract.
|
||||
- Task 4: Wired Matrix runtime selection through `MATRIX_PLATFORM_BACKEND=real`, documented usage in `README.md`, and added dispatcher coverage for real backend selection.
|
||||
- Fixed repeat Matrix invites so the bot now `join()`s before the existing-user early return path.
|
||||
- Added Russian runbook doc `docs/matrix-direct-agent-prototype-ru.md` and pushed the branch.
|
||||
- Manually validated live bring-up using a local patched `external/platform-agent` on port 8000 plus the Matrix homeserver `https://matrix.lambda.coredump.ru`.
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
|
||||
- Add graceful degradation for backend/provider failures so `PlatformError` does not crash the Matrix process.
|
||||
- Decide whether to upstream or separately push the required `external/platform-agent` patch (`1dca2c1`) that enables WebSocket `thread_id`.
|
||||
- Optionally clean up repeat-invite UX if Space/chat reprovisioning should ever happen for already-known users.
|
||||
- Optionally prepare a PR from `feat/matrix-direct-agent-prototype`.
|
||||
</remaining_work>
|
||||
|
||||
<decisions_made>
|
||||
|
||||
- Keep the prototype in this repo, not a separate Matrix-only repo.
|
||||
- Keep Matrix adapter logic intact and absorb backend differences inside `sdk/`.
|
||||
- Split the real backend into `AgentSessionClient` and `PrototypeStateStore` behind `RealPlatformClient`.
|
||||
- Patch only `platform-agent` for per-thread memory instead of changing both `agent` and `agent_api`.
|
||||
- Use a serialized collision-safe thread key because Matrix user IDs contain colons.
|
||||
- For repeat invites, join the room but do not recreate Space/chat state if the user is already provisioned locally.
|
||||
</decisions_made>
|
||||
|
||||
<blockers>
|
||||
- Technical: provider/backend errors still crash the Matrix bot instead of returning a user-facing failure reply.
|
||||
- External: the required `platform-agent` patch exists only in the local clone under `external/` and is not yet upstream.
|
||||
- Operational: credentials used during manual bring-up were exposed in-session and should be rotated.
|
||||
</blockers>
|
||||
|
||||
<context>
|
||||
The important mental model is stable. `platform/master` is still not the backend for surfaces, so the working prototype goes directly to `platform-agent` over `/agent_ws/`. The live setup that worked was:
|
||||
- `surfaces-bot` branch: `feat/matrix-direct-agent-prototype`
|
||||
- Matrix bot env: `MATRIX_PLATFORM_BACKEND=real`, `AGENT_WS_URL=ws://127.0.0.1:8000/agent_ws/`
|
||||
- patched local `external/platform-agent` with `thread_id` support
|
||||
- provider configured through OpenRouter using model `qwen/qwen3.5-122b-a10b`
|
||||
|
||||
Important files:
|
||||
- `sdk/agent_session.py`
|
||||
- `sdk/prototype_state.py`
|
||||
- `sdk/real.py`
|
||||
- `adapter/matrix/bot.py`
|
||||
- `adapter/matrix/handlers/auth.py`
|
||||
- `docs/matrix-direct-agent-prototype-ru.md`
|
||||
|
||||
Important local-only dependency:
|
||||
- `external/platform-agent` commit `1dca2c1` (`feat: support websocket thread ids`)
|
||||
|
||||
Likely running background process at pause time:
|
||||
- local `platform-agent` server on port 8000, PID 13499
|
||||
</context>
|
||||
|
||||
<next_action>
|
||||
Start with the failure path: catch `PlatformError` around Matrix message handling so a bad provider response becomes a normal reply like “backend unavailable, try again later” instead of killing the process. After that, either upstream `external/platform-agent` commit `1dca2c1` or document it as an explicit prerequisite in the platform repo.
|
||||
</next_action>
|
||||
|
|
@ -0,0 +1,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
|
||||
---
|
||||
|
||||
<current_state>
|
||||
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`.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
|
||||
- Переведён `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`).
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
|
||||
- Передать платформенной команде финальный отчёт и дождаться triage/fix proposal.
|
||||
- После ответа платформы решить, открываем ли отдельную follow-up phase для production hardening в `surfaces`.
|
||||
- После platform fix повторить live smoke:
|
||||
- text-only
|
||||
- staged attachments
|
||||
- tool/file flow
|
||||
- large image failure path
|
||||
</remaining_work>
|
||||
|
||||
<decisions_made>
|
||||
|
||||
- Больше не трогать vendored platform repos ради рабочей реализации.
|
||||
- Больше не добавлять локальные transport hacks, маскирующие streaming bug.
|
||||
- Считать текущий missing-first-chunk баг platform-side дефектом до опровержения raw evidence.
|
||||
- Оставить `tokens_used=0` как честное ограничение current upstream contract, не симулировать это значение локально.
|
||||
</decisions_made>
|
||||
|
||||
<blockers>
|
||||
- Platform-side streaming bug: после tool/file flow начало ответа может пропадать.
|
||||
- Duplicate `END` на стороне платформы.
|
||||
- Image path на больших вложениях падает с `data-uri > 10 MB` и `WS 1009`.
|
||||
- Без ответа платформенной команды дальнейший transport-layer surgery в `surfaces` не имеет инженерного смысла.
|
||||
</blockers>
|
||||
|
||||
<context>
|
||||
Важная ментальная модель:
|
||||
|
||||
- `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
|
||||
</context>
|
||||
|
||||
<next_action>
|
||||
Start with:
|
||||
|
||||
1. Открыть `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`
|
||||
2. Отправить этот отчёт платформенной команде как основной артефакт
|
||||
3. Не менять transport layer до получения их ответа
|
||||
|
||||
Если работа продолжается автономно без ответа платформы, следующий допустимый шаг — оформлять отдельную follow-up phase на hardening `surfaces`, а не повторно "чинить" стрим локальными обходами.
|
||||
</next_action>
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
---
|
||||
phase: 05-mvp-deployment
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- adapter/matrix/reconciliation.py
|
||||
- adapter/matrix/bot.py
|
||||
- tests/adapter/matrix/test_reconciliation.py
|
||||
- tests/adapter/matrix/test_restart_persistence.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PH05-01
|
||||
- PH05-03
|
||||
must_haves:
|
||||
truths:
|
||||
- "On restart, existing Matrix Space and child-room topology is rebuilt before live sync begins."
|
||||
- "Restart recovery preserves Space+rooms UX instead of creating duplicate DM-style working rooms."
|
||||
- "Recovered rooms regain user metadata, room metadata, and chat bindings needed for normal routing."
|
||||
- "Legacy working rooms missing `platform_chat_id` are backfilled deterministically during startup before strict routing handles traffic."
|
||||
artifacts:
|
||||
- path: "adapter/matrix/reconciliation.py"
|
||||
provides: "Authoritative restart reconciliation from Matrix topology into local metadata"
|
||||
- path: "adapter/matrix/bot.py"
|
||||
provides: "Startup wiring that runs reconciliation before sync_forever"
|
||||
- path: "tests/adapter/matrix/test_reconciliation.py"
|
||||
provides: "Regression coverage for startup recovery and idempotence"
|
||||
key_links:
|
||||
- from: "adapter/matrix/bot.py"
|
||||
to: "adapter/matrix/reconciliation.py"
|
||||
via: "startup bootstrap before sync_forever"
|
||||
pattern: "reconcil"
|
||||
- from: "adapter/matrix/reconciliation.py"
|
||||
to: "core/chat.py"
|
||||
via: "chat manager rebuild for recovered rooms"
|
||||
pattern: "get_or_create"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Rebuild Matrix-local routing state from authoritative Space topology before the bot processes live traffic.
|
||||
|
||||
Purpose: Preserve the Phase 01 Space+rooms contract after restart even if SQLite metadata is partial or missing.
|
||||
Output: A startup reconciliation module, bot wiring, and regression tests proving no DM-first duplication on restart.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/05-mvp-deployment/05-RESEARCH.md
|
||||
@.planning/phases/05-mvp-deployment/05-VALIDATION.md
|
||||
@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md
|
||||
@adapter/matrix/bot.py
|
||||
@adapter/matrix/store.py
|
||||
@adapter/matrix/handlers/auth.py
|
||||
@tests/adapter/matrix/test_invite_space.py
|
||||
@tests/adapter/matrix/test_chat_space.py
|
||||
@tests/adapter/matrix/test_restart_persistence.py
|
||||
|
||||
<interfaces>
|
||||
From `adapter/matrix/bot.py`:
|
||||
|
||||
```python
|
||||
async def prepare_live_sync(client: AsyncClient) -> str | None:
|
||||
response = await client.sync(timeout=0, full_state=True)
|
||||
if isinstance(response, SyncResponse):
|
||||
return response.next_batch
|
||||
return None
|
||||
```
|
||||
|
||||
```python
|
||||
class MatrixBot:
|
||||
async def _bootstrap_unregistered_room(
|
||||
self,
|
||||
room: MatrixRoom,
|
||||
sender: str,
|
||||
) -> list[OutgoingEvent] | None: ...
|
||||
```
|
||||
|
||||
From `adapter/matrix/store.py`:
|
||||
|
||||
```python
|
||||
async def get_room_meta(store: StateStore, room_id: str) -> dict | None: ...
|
||||
async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None: ...
|
||||
async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None: ...
|
||||
async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None: ...
|
||||
async def next_platform_chat_id(store: StateStore) -> str: ...
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add restart reconciliation regression coverage</name>
|
||||
<files>tests/adapter/matrix/test_reconciliation.py, tests/adapter/matrix/test_restart_persistence.py</files>
|
||||
<read_first>tests/adapter/matrix/test_invite_space.py, tests/adapter/matrix/test_chat_space.py, tests/adapter/matrix/test_restart_persistence.py, adapter/matrix/bot.py, adapter/matrix/handlers/auth.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: startup recovery rebuilds user space metadata, room metadata, and chat bindings from Matrix topology without creating new working rooms (per D-Phase05-reset and PH05-01).
|
||||
- Test 2: reconciliation is idempotent and safe when local SQLite state is already present.
|
||||
- Test 3: reconciliation happens before lazy `_bootstrap_unregistered_room()` would run for existing rooms (per PH05-03).
|
||||
- Test 4: legacy room metadata missing `platform_chat_id` is backfilled deterministically at startup and persisted before routed handling begins.
|
||||
</behavior>
|
||||
<acceptance_criteria>
|
||||
- `tests/adapter/matrix/test_reconciliation.py` exists and names reconciliation entrypoints explicitly.
|
||||
- The new tests assert restored `space_id`, `chat_id`, `matrix_user_id`, and `platform_chat_id` values for recovered rooms.
|
||||
- The regression slice also proves existing Space onboarding behavior still passes by running `test_invite_space.py` and `test_chat_space.py`.
|
||||
- The automated command in `<verify>` fails before implementation or would fail if reconciliation is removed.
|
||||
</acceptance_criteria>
|
||||
<action>Create a dedicated `tests/adapter/matrix/test_reconciliation.py` module and extend restart persistence coverage so Phase 05 has a real Wave 0 contract. Model the recovered topology after the Phase 01 Space+rooms onboarding tests, not a DM-first flow, and explicitly keep those onboarding regressions in the verification slice so restart hardening cannot break provisioning UX. Cover recovery of `user_meta`, `room_meta`, `ChatManager` bindings, and room-local routing fields from Matrix-side state before live callbacks begin, including deterministic backfill for legacy rooms that predate `platform_chat_id`. Keep temporary UX state out of scope, per research.</action>
|
||||
<verify>
|
||||
<automated>pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v</automated>
|
||||
</verify>
|
||||
<done>Phase 05 has failing-or-red-before-code tests that define authoritative restart reconciliation behavior and exclude duplicate room provisioning.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Implement authoritative startup reconciliation and wire it before live sync</name>
|
||||
<files>adapter/matrix/reconciliation.py, adapter/matrix/bot.py</files>
|
||||
<read_first>adapter/matrix/bot.py, adapter/matrix/store.py, adapter/matrix/handlers/auth.py, tests/adapter/matrix/test_reconciliation.py, tests/adapter/matrix/test_restart_persistence.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: startup rebuild runs after login and initial full-state fetch, but before `sync_forever()` processes live events.
|
||||
- Test 2: recovered rooms keep their existing Space+rooms identity and do not trigger `_bootstrap_unregistered_room()` unless the room is genuinely new.
|
||||
- Test 3: local metadata can be rebuilt from Matrix topology when SQLite entries are missing, while existing valid metadata remains stable.
|
||||
- Test 4: startup repair assigns a deterministic `platform_chat_id` to legacy rooms missing that field and persists it before routed platform calls can occur.
|
||||
</behavior>
|
||||
<acceptance_criteria>
|
||||
- `adapter/matrix/reconciliation.py` exports a focused reconciliation entrypoint used by startup code.
|
||||
- `adapter/matrix/bot.py` invokes reconciliation before `client.sync_forever(...)`.
|
||||
- Recovered room metadata includes `room_type`, `chat_id`, `space_id`, `matrix_user_id`, and `platform_chat_id` where available or rebuildable.
|
||||
- Legacy rooms missing `platform_chat_id` follow one documented startup backfill path rather than ad hoc routing fallbacks.
|
||||
</acceptance_criteria>
|
||||
<action>Implement a restart recovery module that treats Matrix topology as authoritative, per the Phase 05 reset and research notes. Rebuild missing local metadata for Space-owned working rooms, deterministically backfill missing `platform_chat_id` values for legacy rooms, and re-create `ChatManager` entries needed by routing, while keeping SQLite as a rebuildable cache rather than the source of truth. Wire the new reconciliation step into startup after the initial full-state sync and before live sync begins, and keep the onboarding regression slice green while doing it. Do not widen into timeline scraping, new storage backends, or DM-first fallbacks.</action>
|
||||
<verify>
|
||||
<automated>pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v</automated>
|
||||
</verify>
|
||||
<done>Restart recovery restores the minimum durable state for existing Space rooms before live traffic, and the guarded regression suite passes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Run the onboarding, reconciliation, restart-persistence, and Matrix dispatcher slices together. Confirm startup now has a deterministic pre-sync recovery and legacy-room backfill step instead of relying on lazy room bootstrap or routing-time fallbacks for existing topology.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The bot can restart with partial or empty local room metadata, rebuild managed Space rooms before live sync, and continue handling those rooms without creating duplicate onboarding rooms.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/05-mvp-deployment/05-01-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
---
|
||||
phase: 05-mvp-deployment
|
||||
plan: 01
|
||||
subsystem: infra
|
||||
tags: [matrix, reconciliation, sqlite, startup, testing]
|
||||
requires:
|
||||
- phase: 01-matrix-mvp
|
||||
provides: Space+rooms onboarding, room metadata, and Matrix dispatcher behavior
|
||||
- phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
|
||||
provides: durable platform_chat_id and restart persistence primitives
|
||||
provides:
|
||||
- authoritative startup reconciliation from Matrix room topology into local metadata
|
||||
- pre-sync startup wiring that repairs managed rooms before live traffic
|
||||
- restart regression coverage for reconciliation, idempotence, and legacy platform_chat_id backfill
|
||||
affects: [matrix, startup, deployment, restart-persistence]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [matrix-topology-as-source-of-truth, sqlite-cache-rebuild, pre-sync-reconciliation]
|
||||
key-files:
|
||||
created: [adapter/matrix/reconciliation.py, tests/adapter/matrix/test_reconciliation.py]
|
||||
modified: [adapter/matrix/bot.py, tests/adapter/matrix/test_restart_persistence.py]
|
||||
key-decisions:
|
||||
- "Treat synced Matrix parent/child topology as authoritative for managed room recovery; keep SQLite rebuildable."
|
||||
- "Backfill missing platform_chat_id values during startup reconciliation instead of routing-time fallbacks."
|
||||
patterns-established:
|
||||
- "Startup runs full-state sync, then reconciliation, then sync_forever."
|
||||
- "Recovered Matrix rooms rebuild user_meta, room_meta, auth state, and ChatManager bindings idempotently."
|
||||
requirements-completed: [PH05-01, PH05-03]
|
||||
duration: 8min
|
||||
completed: 2026-04-27
|
||||
---
|
||||
|
||||
# Phase 05 Plan 01: Restart Reconciliation Summary
|
||||
|
||||
**Matrix startup now rebuilds Space-owned working rooms into durable local routing state before live sync begins**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 8 min
|
||||
- **Started:** 2026-04-27T22:00:47Z
|
||||
- **Completed:** 2026-04-27T22:08:47Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 4
|
||||
|
||||
## Accomplishments
|
||||
- Added a dedicated reconciliation module that restores `user_meta`, `room_meta`, auth state, chat bindings, and missing `platform_chat_id` values from the synced Matrix room graph.
|
||||
- Wired startup to run reconciliation immediately after the initial full-state sync and before `sync_forever()`.
|
||||
- Added regression coverage for recovery, idempotence, pre-sync ordering, onboarding compatibility, and legacy restart backfill.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Add restart reconciliation regression coverage** - `a75b26a` (test)
|
||||
2. **Task 2: Implement authoritative startup reconciliation and wire it before live sync** - `8a80d00` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
- `adapter/matrix/reconciliation.py` - Startup recovery from Matrix topology into local room and user metadata.
|
||||
- `adapter/matrix/bot.py` - Startup wiring that runs reconciliation after the bootstrap sync and before live sync.
|
||||
- `tests/adapter/matrix/test_reconciliation.py` - Recovery, idempotence, and startup-order regression coverage.
|
||||
- `tests/adapter/matrix/test_restart_persistence.py` - Legacy `platform_chat_id` backfill persistence coverage.
|
||||
|
||||
## Decisions Made
|
||||
- Used the synced Matrix room graph as the authoritative source for restart recovery, while preserving existing local metadata whenever it is already valid.
|
||||
- Kept legacy `platform_chat_id` repair on a single startup path so routed handling never needs ad hoc fallback creation for existing rooms.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Switched verification to a clean `uv run pytest` environment**
|
||||
- **Found during:** Task 1 and Task 2 verification
|
||||
- **Issue:** The default `pytest` path used a mismatched virtualenv without repo dependencies, and `.env` injected Matrix backend variables that polluted mock-mode tests.
|
||||
- **Fix:** Ran the verification slice through `uv run pytest` with `UV_CACHE_DIR=/tmp/uv-cache-surfaces` and blank `MATRIX_AGENT_REGISTRY_PATH` / `MATRIX_PLATFORM_BACKEND` values to match the intended test environment.
|
||||
- **Files modified:** None
|
||||
- **Verification:** `uv run pytest` slice passed with 50/50 tests green
|
||||
- **Committed in:** not applicable (verification-only adjustment)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking)
|
||||
**Impact on plan:** Verification needed an environment correction, but code scope stayed within the plan and owned files.
|
||||
|
||||
## Issues Encountered
|
||||
- The shell environment loaded deployment-oriented Matrix backend settings from `.env`; these had to be neutralized for the mock-mode regression slice.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Restart recovery is in place for existing Space rooms, including deterministic legacy `platform_chat_id` repair.
|
||||
- Remaining Phase 05 plans can build on a stable pre-sync recovery path instead of lazy bootstrap for existing topology.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
---
|
||||
*Phase: 05-mvp-deployment*
|
||||
*Completed: 2026-04-27*
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
---
|
||||
phase: 05-mvp-deployment
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 05-01
|
||||
files_modified:
|
||||
- adapter/matrix/handlers/__init__.py
|
||||
- adapter/matrix/handlers/context_commands.py
|
||||
- adapter/matrix/routed_platform.py
|
||||
- tests/adapter/matrix/test_context_commands.py
|
||||
- tests/adapter/matrix/test_routed_platform.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PH05-02
|
||||
must_haves:
|
||||
truths:
|
||||
- "Each working Matrix room uses its own durable `platform_chat_id` as the real agent context boundary."
|
||||
- "`!clear` resets only the current room by rotating its `platform_chat_id` and disconnecting the old upstream chat."
|
||||
- "Save, load, context, and routed send paths resolve through room-local platform context, not shared user state."
|
||||
- "Strict room routing assumes startup reconciliation has already repaired legacy rooms missing `platform_chat_id`."
|
||||
artifacts:
|
||||
- path: "adapter/matrix/handlers/context_commands.py"
|
||||
provides: "Room-local `!clear`, save/load/context resolution, and upstream disconnect behavior"
|
||||
- path: "adapter/matrix/routed_platform.py"
|
||||
provides: "Strict room -> agent_id + platform_chat_id routing"
|
||||
- path: "tests/adapter/matrix/test_context_commands.py"
|
||||
provides: "Regression coverage for `!clear` and room-local context commands"
|
||||
key_links:
|
||||
- from: "adapter/matrix/handlers/__init__.py"
|
||||
to: "adapter/matrix/handlers/context_commands.py"
|
||||
via: "IncomingCommand registration for `clear`"
|
||||
pattern: "\"clear\""
|
||||
- from: "adapter/matrix/routed_platform.py"
|
||||
to: "adapter/matrix/store.py"
|
||||
via: "room metadata lookup"
|
||||
pattern: "platform_chat_id"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make room-local platform context explicit and user-facing by shipping real `!clear` semantics and strict per-room routing.
|
||||
|
||||
Purpose: Phase 05 must preserve Space+rooms UX while giving each room a true upstream context boundary.
|
||||
Output: Updated command wiring, room-local context reset behavior, and routing regressions tied to `platform_chat_id`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/05-mvp-deployment/05-RESEARCH.md
|
||||
@.planning/phases/05-mvp-deployment/05-VALIDATION.md
|
||||
@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md
|
||||
@adapter/matrix/handlers/__init__.py
|
||||
@adapter/matrix/handlers/context_commands.py
|
||||
@adapter/matrix/routed_platform.py
|
||||
@tests/adapter/matrix/test_context_commands.py
|
||||
@tests/adapter/matrix/test_routed_platform.py
|
||||
|
||||
<interfaces>
|
||||
From `adapter/matrix/handlers/__init__.py`:
|
||||
|
||||
```python
|
||||
dispatcher.register(
|
||||
IncomingCommand,
|
||||
"reset",
|
||||
make_handle_reset(store, prototype_state)
|
||||
if prototype_state is not None
|
||||
else handle_settings,
|
||||
)
|
||||
```
|
||||
|
||||
From `adapter/matrix/handlers/context_commands.py`:
|
||||
|
||||
```python
|
||||
async def _resolve_context_scope(
|
||||
event: IncomingCommand,
|
||||
store: StateStore,
|
||||
chat_mgr,
|
||||
) -> tuple[str, str | None]: ...
|
||||
```
|
||||
|
||||
From `adapter/matrix/routed_platform.py`:
|
||||
|
||||
```python
|
||||
async def _resolve_delegate(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
|
||||
...
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Expand room-local context and clear-command tests</name>
|
||||
<files>tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py</files>
|
||||
<read_first>tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py, adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py, .planning/phases/05-mvp-deployment/05-VALIDATION.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: `!clear` rotates only the current room's `platform_chat_id` and disconnects only the old upstream chat (per PH05-02).
|
||||
- Test 2: `!clear` is the supported command name; `!reset` may remain as a compatibility alias but must not be the only registered path.
|
||||
- Test 3: routed send/stream paths fail fast when room metadata lacks `agent_id` or `platform_chat_id` instead of silently sharing context.
|
||||
- Test 4: routed behavior uses startup-repaired room metadata and does not introduce a second fallback path that invents `platform_chat_id` during message handling.
|
||||
</behavior>
|
||||
<acceptance_criteria>
|
||||
- Tests explicitly mention `clear` in command registration or command invocation.
|
||||
- The context-command tests assert old and new `platform_chat_id` values and upstream disconnect behavior.
|
||||
- The routed-platform tests assert room-local IDs are passed to delegates unchanged.
|
||||
</acceptance_criteria>
|
||||
<action>Extend the current Matrix context-command and routed-platform regressions so Phase 05 has direct coverage for `!clear`, room-local `platform_chat_id` rotation, and fail-fast routing when room bindings are incomplete. Treat startup reconciliation from `05-01` as the only supported repair path for legacy rooms missing `platform_chat_id`; the routed path must consume repaired metadata, not synthesize new room identities on demand. Preserve the Phase 04 prototype-state behavior where it still fits, but anchor new checks on per-room context isolation rather than shared session assumptions.</action>
|
||||
<verify>
|
||||
<automated>pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py -v</automated>
|
||||
</verify>
|
||||
<done>The tests define the Phase 05 room-local contract for reset/clear and for routed upstream calls.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Ship real room-local `!clear` semantics and strict routing</name>
|
||||
<files>adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py</files>
|
||||
<read_first>adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py, tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: command registration exposes `!clear` as the real context-reset entrypoint for Matrix rooms.
|
||||
- Test 2: only the active room's `platform_chat_id` rotates, and only the old upstream chat session is disconnected.
|
||||
- Test 3: all room-local context commands resolve through recovered room metadata instead of falling back to shared user scope.
|
||||
- Test 4: strict routing stays strict at runtime because legacy-room repair was already handled at startup by `05-01`, not by hidden message-path fallbacks.
|
||||
</behavior>
|
||||
<acceptance_criteria>
|
||||
- `adapter/matrix/handlers/__init__.py` registers `clear`; if `reset` remains, it is clearly a compatibility alias.
|
||||
- `adapter/matrix/handlers/context_commands.py` resolves and rotates room-local platform context without touching other rooms.
|
||||
- `adapter/matrix/routed_platform.py` keeps explicit `MATRIX_ROUTE_INCOMPLETE` behavior when bindings are missing.
|
||||
</acceptance_criteria>
|
||||
<action>Update the Matrix context command surface to match the Phase 05 contract: real `!clear`, room-local `platform_chat_id` rotation, and upstream disconnect scoped to the old room context. Keep `save`, `load`, and `context` anchored to the same room-local identity. Tighten routed-platform behavior only where needed to preserve fail-fast semantics after startup reconciliation has repaired legacy rooms; do not reintroduce shared chat state, user-level reset behavior, or message-time backfill of missing `platform_chat_id`.</action>
|
||||
<verify>
|
||||
<automated>pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v</automated>
|
||||
</verify>
|
||||
<done>Users can clear one working room without affecting others, and all routed upstream calls stay bound to room-local platform context.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Run the context and routed-platform slices plus Matrix dispatcher smoke coverage to confirm the exposed command name and room-local routing behavior are consistent.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Every working Matrix room has an independent upstream context boundary, and `!clear` resets only the room where it is invoked.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/05-mvp-deployment/05-02-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -1,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`
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
---
|
||||
phase: 05-mvp-deployment
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- adapter/matrix/files.py
|
||||
- sdk/real.py
|
||||
- tests/adapter/matrix/test_files.py
|
||||
- tests/platform/test_real.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PH05-04
|
||||
must_haves:
|
||||
truths:
|
||||
- "Incoming Matrix attachments are written into a room-safe shared-volume path and passed upstream as relative workspace paths."
|
||||
- "Agent-emitted files can be returned to Matrix users without inventing a separate file proxy."
|
||||
- "The shared-volume contract works with the Phase 05 `/agents` deployment shape."
|
||||
artifacts:
|
||||
- path: "adapter/matrix/files.py"
|
||||
provides: "Room-safe shared-volume path building and path resolution"
|
||||
- path: "sdk/real.py"
|
||||
provides: "Attachment path passthrough and send-file normalization"
|
||||
- path: "tests/adapter/matrix/test_files.py"
|
||||
provides: "Regression coverage for shared-volume path construction"
|
||||
key_links:
|
||||
- from: "adapter/matrix/files.py"
|
||||
to: "sdk/real.py"
|
||||
via: "relative `workspace_path` transport"
|
||||
pattern: "workspace_path"
|
||||
- from: "sdk/real.py"
|
||||
to: "adapter/matrix/bot.py"
|
||||
via: "OutgoingMessage attachments rendered back to Matrix"
|
||||
pattern: "MsgEventSendFile"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Harden the Matrix attachment path contract around the shared deployment volume instead of custom transport shims.
|
||||
|
||||
Purpose: Phase 05 file handling must survive real deployment with room-safe paths and outbound file delivery through the existing shared-volume model.
|
||||
Output: Attachment-path regressions and any targeted runtime fixes needed for `/agents`-backed shared volume behavior.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/05-mvp-deployment/05-RESEARCH.md
|
||||
@.planning/phases/05-mvp-deployment/05-VALIDATION.md
|
||||
@docs/deploy-architecture.md
|
||||
@docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md
|
||||
@adapter/matrix/files.py
|
||||
@sdk/real.py
|
||||
@tests/adapter/matrix/test_files.py
|
||||
@tests/platform/test_real.py
|
||||
|
||||
<interfaces>
|
||||
From `adapter/matrix/files.py`:
|
||||
|
||||
```python
|
||||
def build_workspace_attachment_path(
|
||||
*,
|
||||
workspace_root: Path,
|
||||
matrix_user_id: str,
|
||||
room_id: str,
|
||||
filename: str,
|
||||
timestamp: str | None = None,
|
||||
) -> tuple[str, Path]: ...
|
||||
```
|
||||
|
||||
From `sdk/real.py`:
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def _attachment_paths(attachments: list[Attachment] | None) -> list[str]: ...
|
||||
|
||||
@staticmethod
|
||||
def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment: ...
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add shared-volume file contract tests for `/agents` deployment</name>
|
||||
<files>tests/adapter/matrix/test_files.py, tests/platform/test_real.py</files>
|
||||
<read_first>tests/adapter/matrix/test_files.py, tests/platform/test_real.py, adapter/matrix/files.py, sdk/real.py, docs/deploy-architecture.md, .planning/phases/05-mvp-deployment/05-RESEARCH.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: incoming Matrix files land under a room-safe `surfaces/matrix/.../inbox/...` path that remains relative to the agent workspace contract.
|
||||
- Test 2: upstream file events normalize `/workspace/...` and `/agents/...`-style absolute paths into relative `workspace_path` values.
|
||||
- Test 3: attachment forwarding never switches to inline blobs or HTTP shim URLs (per PH05-04).
|
||||
</behavior>
|
||||
<acceptance_criteria>
|
||||
- `tests/adapter/matrix/test_files.py` asserts the path namespace includes sanitized user and room components.
|
||||
- `tests/platform/test_real.py` contains explicit coverage for send-file path normalization.
|
||||
- The automated test command in `<verify>` exercises both inbound and outbound sides of the shared-volume contract.
|
||||
</acceptance_criteria>
|
||||
<action>Expand the file-flow regressions around the real deployment contract described in research and `docs/deploy-architecture.md`. Keep the tests centered on relative `workspace_path` transport and room-safe on-disk layout. Do not introduce proxy URLs, base64 payload transport, or new platform endpoints.</action>
|
||||
<verify>
|
||||
<automated>pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v</automated>
|
||||
</verify>
|
||||
<done>Phase 05 has direct test coverage for `/agents`-backed shared-volume behavior across inbound and outbound file paths.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Tighten attachment path handling for the shared volume contract</name>
|
||||
<files>adapter/matrix/files.py, sdk/real.py</files>
|
||||
<read_first>adapter/matrix/files.py, sdk/real.py, tests/adapter/matrix/test_files.py, tests/platform/test_real.py, docs/deploy-architecture.md</read_first>
|
||||
<behavior>
|
||||
- Test 1: inbound attachment helpers keep returning relative paths even when the bot writes into `/agents`.
|
||||
- Test 2: outbound file normalization accepts absolute paths from the agent runtime but strips them back to relative workspace paths for Matrix rendering.
|
||||
- Test 3: no code path emits non-relative attachment references to the upstream agent API.
|
||||
</behavior>
|
||||
<acceptance_criteria>
|
||||
- `sdk/real.py` only forwards relative attachment paths to the agent API.
|
||||
- `sdk/real.py` normalizes both `/workspace` and `/agents` absolute roots if present in send-file events.
|
||||
- `adapter/matrix/files.py` remains the single source of truth for room-safe attachment path construction.
|
||||
</acceptance_criteria>
|
||||
<action>Implement only the minimum runtime changes needed to satisfy the shared-volume tests. Keep `adapter/matrix/files.py` as the single place that builds surface-owned attachment paths, and keep `sdk/real.py` responsible only for attachment passthrough and send-file normalization. Do not widen this plan into compose edits, registry redesign, or bot command changes.</action>
|
||||
<verify>
|
||||
<automated>pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v</automated>
|
||||
</verify>
|
||||
<done>Incoming and outgoing file references stay compatible with the real shared-volume deployment contract, and the targeted file/path regressions pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Run the Matrix file helper, real platform client, and outgoing-send slices together so the shared-volume contract is validated from write path through return-to-user rendering.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The Matrix bot and agent runtime can exchange file references through the shared volume using only relative workspace paths and room-safe storage layout.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/05-mvp-deployment/05-03-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -1,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*
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
---
|
||||
phase: 05-mvp-deployment
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 05-03
|
||||
files_modified:
|
||||
- docker-compose.prod.yml
|
||||
- docker-compose.fullstack.yml
|
||||
- Dockerfile
|
||||
- .env.example
|
||||
- README.md
|
||||
- docs/deploy-architecture.md
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PH05-05
|
||||
must_haves:
|
||||
truths:
|
||||
- "Production handoff uses a bot-only compose artifact instead of the internal full-stack harness."
|
||||
- "Internal E2E compose brings up the bot, platform-agent, and shared volume with explicit health-gated startup."
|
||||
- "Deployment docs and env examples match the split compose artifacts and shared `/agents` contract."
|
||||
artifacts:
|
||||
- path: "docker-compose.prod.yml"
|
||||
provides: "Bot-only deployment handoff artifact"
|
||||
- path: "docker-compose.fullstack.yml"
|
||||
provides: "Internal E2E harness with shared volume and dependency gating"
|
||||
- path: ".env.example"
|
||||
provides: "Documented runtime contract for Phase 05 deployment"
|
||||
key_links:
|
||||
- from: "docker-compose.fullstack.yml"
|
||||
to: "docker-compose.prod.yml"
|
||||
via: "shared service definition or explicit duplication"
|
||||
pattern: "matrix-bot"
|
||||
- from: "docs/deploy-architecture.md"
|
||||
to: "docker-compose.prod.yml"
|
||||
via: "operator handoff instructions"
|
||||
pattern: "prod"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Split deployment artifacts by operational intent so operator handoff and internal E2E testing stop sharing the same compose contract.
|
||||
|
||||
Purpose: Phase 05 needs an explicit bot-only production artifact and a separate full-stack compose harness aligned with the shared-volume design.
|
||||
Output: `docker-compose.prod.yml`, `docker-compose.fullstack.yml`, and updated env/docs describing when to use each.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/a/.codex/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/05-mvp-deployment/05-RESEARCH.md
|
||||
@.planning/phases/05-mvp-deployment/05-VALIDATION.md
|
||||
@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md
|
||||
@docs/deploy-architecture.md
|
||||
@docker-compose.yml
|
||||
@Dockerfile
|
||||
@.env.example
|
||||
|
||||
<interfaces>
|
||||
Current root compose contract:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
platform-agent:
|
||||
...
|
||||
matrix-bot:
|
||||
build: .
|
||||
env_file: .env
|
||||
environment:
|
||||
AGENT_BASE_URL: http://platform-agent:8000
|
||||
SURFACES_WORKSPACE_DIR: /workspace
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create split prod and fullstack compose artifacts</name>
|
||||
<files>docker-compose.prod.yml, docker-compose.fullstack.yml, Dockerfile, .env.example</files>
|
||||
<read_first>docker-compose.yml, Dockerfile, .env.example, docs/deploy-architecture.md, .planning/phases/05-mvp-deployment/05-RESEARCH.md, .planning/phases/05-mvp-deployment/05-VALIDATION.md</read_first>
|
||||
<acceptance_criteria>
|
||||
- `docker-compose.prod.yml` defines only the bot-side runtime required for operator handoff.
|
||||
- `docker-compose.fullstack.yml` includes the internal platform-agent service, shared volume mounts, and health-gated startup rather than sleep-based sequencing.
|
||||
- `.env.example` documents the Phase 05 env contract without requiring the reader to inspect the old root compose file.
|
||||
</acceptance_criteria>
|
||||
<action>Create two explicit compose artifacts per PH05-05: a bot-only `docker-compose.prod.yml` for deployment handoff and a `docker-compose.fullstack.yml` for internal E2E runs. Align mounts and env values with the Phase 05 shared `/agents` volume direction from research. Reuse the existing Dockerfile unless a small compatibility edit is required. Do not keep the old single-file compose setup as the only documented runtime.</action>
|
||||
<verify>
|
||||
<automated>docker compose -f docker-compose.prod.yml config > /tmp/phase05-prod-compose.yml && docker compose -f docker-compose.fullstack.yml config > /tmp/phase05-fullstack-compose.yml && rg -n "^services:$|^ matrix-bot:$|^volumes:$|/agents" /tmp/phase05-prod-compose.yml && test -z "$(rg -n "^ platform-agent:$" /tmp/phase05-prod-compose.yml)" && rg -n "^ matrix-bot:$|^ platform-agent:$|condition: service_healthy|healthcheck:|/agents" /tmp/phase05-fullstack-compose.yml</automated>
|
||||
</verify>
|
||||
<done>Both compose files render successfully and express distinct operational roles: prod handoff vs internal full-stack testing.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Update deployment docs and operator guidance for the split artifacts</name>
|
||||
<files>README.md, docs/deploy-architecture.md</files>
|
||||
<read_first>README.md, docs/deploy-architecture.md, docker-compose.prod.yml, docker-compose.fullstack.yml, .env.example</read_first>
|
||||
<acceptance_criteria>
|
||||
- README or deploy doc tells the operator exactly which compose file to use for production vs internal E2E.
|
||||
- The docs describe the shared `/agents` volume behavior and reference the relevant env vars.
|
||||
- The old root `docker-compose.yml` is no longer the primary documented deployment path.
|
||||
</acceptance_criteria>
|
||||
<action>Update the repo docs so the Phase 05 deployment story is executable without inference: production handoff stays bot-only, full-stack compose is for internal E2E, and shared-volume file behavior is described in the same terms as the runtime artifacts. Keep documentation narrowly scoped to the shipped compose split; do not widen into platform-master or future storage design.</action>
|
||||
<verify>
|
||||
<automated>rg -n "docker-compose\\.prod|docker-compose\\.fullstack|/agents|prod handoff|full-stack" README.md docs/deploy-architecture.md .env.example && rg -n "production|deploy" README.md docs/deploy-architecture.md | rg "docker-compose\\.prod" && test -z "$(rg -n "docker compose up|docker-compose\\.yml" README.md docs/deploy-architecture.md | rg "production|deploy")"</automated>
|
||||
</verify>
|
||||
<done>The docs and env guidance match the new compose artifacts and no longer imply a single shared deployment file.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Render both compose files, then grep the docs for the new artifact names and `/agents` references so the operator contract is explicit and consistent.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
An operator can deploy the Matrix bot with the bot-only compose file, while developers can run the internal end-to-end harness separately without reinterpreting the deployment docs.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/05-mvp-deployment/05-04-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -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*
|
||||
|
|
@ -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
|
||||
|
|
@ -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 `<automated>` 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 `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [x] Feedback latency target tightened to task slices under 60s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
92
.planning/reports/20260422-session-report.md
Normal file
92
.planning/reports/20260422-session-report.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# GSD Session Report
|
||||
|
||||
**Generated:** 2026-04-21T22:33:11.666Z
|
||||
**Project:** surfaces-bot
|
||||
**Milestone:** v1.0 — Production-ready surfaces
|
||||
|
||||
---
|
||||
|
||||
## Session Summary
|
||||
|
||||
**Duration:** Single session
|
||||
**Phase Progress:** Phase 04 implemented; current follow-up work is audit, stabilization, and platform bug localization
|
||||
**Plans Executed:** 0 formal GSD plans executed in this session; work was focused on post-implementation audit and cleanup
|
||||
**Commits Made:** 6
|
||||
|
||||
## Work Performed
|
||||
|
||||
### Phases Touched
|
||||
|
||||
- **Phase 04** — Matrix MVP follow-up after implementation:
|
||||
- completed audit of platform patches vs surface-owned responsibilities
|
||||
- removed dependence on local platform modifications for `chat_id`
|
||||
- switched Matrix integration to numeric `platform_chat_id` mapping on our side
|
||||
- cleaned transport layer to a thin adapter over upstream `AgentApi`
|
||||
- updated README and run instructions
|
||||
- produced final Russian bug report with raw-trace-based diagnosis
|
||||
|
||||
### Key Outcomes
|
||||
|
||||
- Platform repos are clean and synced to pinned upstream commits.
|
||||
- Matrix real backend works with numeric surrogate `platform_chat_id`.
|
||||
- `surfaces` transport layer no longer owns custom stream semantics.
|
||||
- Final diagnosis was narrowed: missing-first-chunk bug is now considered platform-side with direct raw evidence.
|
||||
- Working state was committed and pushed on `feat/matrix-direct-agent-prototype`.
|
||||
|
||||
### Decisions Made
|
||||
|
||||
- Do not patch vendored platform repos for the working implementation.
|
||||
- Keep `surfaces` transport layer thin and upstream-aligned.
|
||||
- Treat the current streaming bug as platform-side unless new evidence disproves it.
|
||||
- Do not add new local stream workarounds that would blur responsibility.
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `README.md`
|
||||
- `adapter/matrix/bot.py`
|
||||
- `sdk/agent_api_wrapper.py`
|
||||
- `sdk/real.py`
|
||||
- `tests/platform/test_real.py`
|
||||
- `tests/adapter/matrix/test_dispatcher.py`
|
||||
- `tests/core/test_integration.py`
|
||||
- `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`
|
||||
|
||||
Planning / handoff artifacts updated:
|
||||
|
||||
- `.planning/HANDOFF.json`
|
||||
- `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.continue-here.md`
|
||||
- `.planning/reports/20260422-session-report.md`
|
||||
|
||||
## Blockers & Open Items
|
||||
|
||||
- Platform-side streaming bug after tool/file flow.
|
||||
- Duplicate `END` from platform.
|
||||
- Image path failure on oversized `data:` URI.
|
||||
- `tokens_used` remains unavailable from pinned upstream client.
|
||||
|
||||
## Estimated Resource Usage
|
||||
|
||||
| Metric | Estimate |
|
||||
|--------|----------|
|
||||
| Commits | 6 |
|
||||
| Files changed | 8 code/docs files in the main deliverable, plus planning artifacts |
|
||||
| Plans executed | 0 formal plans in this session |
|
||||
| Subagents spawned | 0 |
|
||||
|
||||
> **Note:** Token and cost estimates require API-level instrumentation.
|
||||
> These metrics reflect observable session activity only.
|
||||
|
||||
---
|
||||
|
||||
### Recent Commits
|
||||
|
||||
- `0c2884c` — `refactor: use thin upstream transport adapter`
|
||||
- `569824e` — `refactor: shrink agent api wrapper to thin adapter`
|
||||
- `4d917ac` — `docs: add thin transport adapter plan`
|
||||
- `3a3fcdc` — `docs: add thin transport adapter design`
|
||||
- `7a2ad86` — `docs: clarify matrix file sending flow`
|
||||
- `4524a6a` — `feat: finalize matrix platform audit and docs`
|
||||
|
||||
---
|
||||
|
||||
*Generated by `$gsd-session-report`*
|
||||
133
.planning/threads/matrix-dev-prototype-agent-platform-state.md
Normal file
133
.planning/threads/matrix-dev-prototype-agent-platform-state.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Thread: Matrix dev prototype — состояние агента и платформы
|
||||
|
||||
## Status: IN PROGRESS
|
||||
|
||||
## Goal
|
||||
|
||||
Зафиксировать текущее состояние платформы для последующей разработки Matrix dev прототипа,
|
||||
в котором команды разработки скиллов смогут быстро добавлять и обкатывать скиллы.
|
||||
|
||||
## Context
|
||||
|
||||
*Исследование проведено 2026-04-14. Репозитории: `external/platform-agent`, `external/platform-agent_api`, `external/platform-master`.*
|
||||
|
||||
### Решение по деплою: локальный контейнер у каждого разработчика
|
||||
|
||||
`platform-master` не готов для общего деплоя:
|
||||
- lifecycle management контейнеров (TTL, cleanup, переиспользование сессий) — в ветке `feat/storage`, не смержено в main
|
||||
- без него при общем деплое контейнеры висят вечно, ресурсы не освобождаются
|
||||
|
||||
Локальный вариант: `make up-dev` — полностью рабочий, volume mount `./workspace:/workspace/`, hot reload src.
|
||||
|
||||
### Архитектура изоляции контекстов
|
||||
|
||||
`AgentService` — singleton с `thread_id = "default"` — это **намеренно**. Архитектура Master предполагает один контейнер `platform-agent` на один чат. Изоляция на уровне контейнеров, не thread_id. Фиксить не нужно.
|
||||
|
||||
### Система скиллов (deepagents)
|
||||
|
||||
`SkillsMiddleware` в `deepagents` полностью готов:
|
||||
- скилл = директория с `SKILL.md` (YAML frontmatter + markdown инструкции)
|
||||
- progressive disclosure: агент видит имя+описание в system prompt, читает полный файл по требованию
|
||||
- загружается один раз при старте сессии, кэшируется в LangGraph state
|
||||
|
||||
**НЕ подключено** в `platform-agent/src/agent/base.py` — отсутствует одна строка:
|
||||
```python
|
||||
skills=["/workspace/skills/"]
|
||||
```
|
||||
Это задача для команды платформы.
|
||||
|
||||
### Workflow разработчика скилла
|
||||
|
||||
```
|
||||
workspace/
|
||||
skills/
|
||||
my-skill/
|
||||
SKILL.md ← редактируешь здесь (live через volume mount)
|
||||
helper.py ← вспомогательные файлы
|
||||
config/
|
||||
my-skill.json ← токены и настройки (пишет агент при первом запуске)
|
||||
```
|
||||
|
||||
1. Редактируешь `SKILL.md`
|
||||
2. `!new` в Matrix (новая сессия = скиллы перечитываются)
|
||||
3. Проверяешь поведение
|
||||
4. Повторяешь
|
||||
|
||||
Агент может **сам установить скилл** из GitHub:
|
||||
- `execute` → git clone
|
||||
- `write_file` → положить в `/workspace/skills/`
|
||||
- после `!new` скилл активен
|
||||
|
||||
### Конфигурация скиллов (токены, API ключи)
|
||||
|
||||
Агент управляет конфигом сам:
|
||||
- первый запуск: спрашивает пользователя → пишет в `/workspace/config/skill-name.json`
|
||||
- последующие запуски: читает из файла
|
||||
- файл персистентен между сессиями (volume mount)
|
||||
|
||||
### Входящий протокол (что принимает агент)
|
||||
|
||||
`ClientMessage` — только `text: str`. Файлы и изображения не поддерживаются.
|
||||
Задача для платформы — расширить протокол.
|
||||
|
||||
### Исходящий протокол (что шлёт агент)
|
||||
|
||||
Новые события с `origin/main` (апрель 2026):
|
||||
- `AGENT_EVENT_TOOL_CALL_CHUNK` — агент вызывает инструмент
|
||||
- `AGENT_EVENT_TOOL_RESULT` — результат инструмента
|
||||
- `AGENT_EVENT_CUSTOM_UPDATE` — произвольный прогресс
|
||||
|
||||
**Наш `sdk/agent_session.py` падает на этих событиях** (`raise PlatformError("Unexpected agent message")`).
|
||||
Нужно починить — это наша задача, ~10 строк.
|
||||
|
||||
### AgentApi из lambda_agent_api
|
||||
|
||||
Готовый production-клиент с правильным lifecycle (`connect()`, `close()`, `send_message()` как `AsyncIterator`).
|
||||
Наш `sdk/agent_session.py` дублирует его функциональность. Стоит заменить.
|
||||
|
||||
### Инструменты агента из коробки
|
||||
|
||||
- `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep` — файловые операции в workspace
|
||||
- `execute` — shell под изолированным OS-пользователем `agent`
|
||||
- `write_todos` — список задач
|
||||
- `task` — вызов субагентов
|
||||
|
||||
### Запуск локально
|
||||
|
||||
```bash
|
||||
# .env минимально необходимый:
|
||||
PROVIDER_URL=https://openrouter.ai/api/v1
|
||||
PROVIDER_API_KEY=<ключ>
|
||||
PROVIDER_MODEL=anthropic/claude-sonnet-4-6
|
||||
|
||||
# Dev контейнер:
|
||||
make up-dev # требует AGENT_API_PATH=../platform-agent_api в env
|
||||
```
|
||||
|
||||
Dev Dockerfile монтирует `./workspace:/workspace/` и `./src:/app/src` (hot reload).
|
||||
|
||||
## Что нужно от платформы
|
||||
|
||||
1. Добавить `skills=["/workspace/skills/"]` в `platform-agent/src/agent/base.py`
|
||||
2. Поддержка файлов/изображений в `ClientMessage` (не срочно для MVP)
|
||||
3. Lifecycle management контейнеров в Master (для общего деплоя, не срочно)
|
||||
|
||||
## Что делаем мы
|
||||
|
||||
1. Починить `sdk/agent_session.py` — обработка tool-событий вместо исключения
|
||||
2. (опционально) Заменить `AgentSessionClient` на `AgentApi` из `lambda_agent_api`
|
||||
|
||||
## References
|
||||
|
||||
- `external/platform-agent` — локальный клон, наш патч `1dca2c1` (thread_id) поверх `1e9fa1f`
|
||||
- `external/platform-agent_api` — локальный клон, актуальный (origin/master = `bb20a84`)
|
||||
- `external/platform-master` — локальный клон, активная разработка в `feat/storage-s02`
|
||||
- `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md`
|
||||
- `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Запросить у команды платформы: подключение `SkillsMiddleware` в `base.py`
|
||||
2. Починить `sdk/agent_session.py` — обработать tool-события
|
||||
3. Написать первый тестовый скилл (`workspace/skills/hello/SKILL.md`) и проверить end-to-end
|
||||
4. Документировать workflow для разработчиков скиллов
|
||||
81
.planning/threads/matrix-file-ingestion-context.md
Normal file
81
.planning/threads/matrix-file-ingestion-context.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Thread: Matrix file ingestion and agent-visible storage contract
|
||||
|
||||
## Status: IN PROGRESS
|
||||
|
||||
## Goal
|
||||
|
||||
Сохранить текущий контекст сессии для следующего агента и зафиксировать следующую архитектурную развилку: как принимать вложения из Matrix и делать их доступными агенту.
|
||||
|
||||
## Current State
|
||||
|
||||
Phase 4 Matrix MVP уже собран и проверен на уровне per-room routing:
|
||||
- обычные сообщения теперь идут в `platform_chat_id`, а не в общий локальный `C1/C2`
|
||||
- `!context` показывает состояние текущего Matrix-чата
|
||||
- `!save` и `!load` привязаны к текущему room-context
|
||||
- `PrototypeStateStore` хранит live state per context
|
||||
- последние изменения закоммичены в `feat/matrix-direct-agent-prototype`
|
||||
|
||||
Коммиты, которые важно знать:
|
||||
- `c11c8ec` `feat(task-5): scope matrix context state per room`
|
||||
- `07c5078` `feat(task-7): verify matrix per-room context routing`
|
||||
|
||||
## What We Learned About Platform Runtime
|
||||
|
||||
Текущий `external/platform-agent` не является отдельным контейнером на чат.
|
||||
Фактическая модель сейчас такая:
|
||||
- один FastAPI-процесс
|
||||
- singleton `AgentService`
|
||||
- `thread_id` используется как ключ памяти в LangGraph, а не как контейнерная изоляция
|
||||
- файловой изоляции на чат сейчас нет
|
||||
- `/workspace` как общий mount для Matrix bot и platform-agent сейчас не настроен
|
||||
- отдельного upload API для вложений в текущем коде не видно
|
||||
|
||||
Ключевые файлы:
|
||||
- `external/platform-agent/src/api/external.py`
|
||||
- `external/platform-agent/src/agent/service.py`
|
||||
- `external/platform-agent/src/agent/base.py`
|
||||
|
||||
## File Handling Requirement
|
||||
|
||||
Пользовательский запрос на текущем этапе:
|
||||
- принимать файл или сообщение с файлом из Matrix
|
||||
- сохранять файл локально
|
||||
- передавать агенту явный сигнал, что к сообщению есть вложения
|
||||
- сообщать, где лежит файл
|
||||
|
||||
Но есть техническое ограничение:
|
||||
- если Matrix bot пишет файл только в своём контейнере, platform-agent его не увидит
|
||||
- значит нужен либо общий storage, либо upload в платформу, либо контейнеризация platform-agent с общим volume
|
||||
|
||||
## Recommended Design Direction
|
||||
|
||||
Самый прагматичный MVP-вариант:
|
||||
- хранить вложения в общем каталоге, который виден и Matrix bot, и platform-agent
|
||||
- формировать для агента структурированный payload с:
|
||||
- локальным путём
|
||||
- original filename
|
||||
- mime type
|
||||
- attachment type
|
||||
- если есть текст пользователя, дополнять сообщение краткой summary-подсказкой про вложения
|
||||
- если прислан только файл, отправлять synthetic message вроде “пользователь прислал файл”
|
||||
|
||||
Если общий каталог невозможен в текущем runtime:
|
||||
- следующий вариант это upload endpoint в platform-agent
|
||||
- Matrix surface скачивает файл и загружает его в платформу, а платформа уже кладёт его в своё доступное хранилище
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Где должен жить shared storage: host path, docker volume или platform-side volume?
|
||||
2. Нужен ли немедленный upload API в platform-agent, или сначала достаточно shared path?
|
||||
3. Должны ли файлы быть scoped per room/platform_chat_id, а не per user?
|
||||
|
||||
## Next Step For Another Agent
|
||||
|
||||
1. Подтвердить runtime-модель хранения файлов.
|
||||
2. Проверить, как сейчас запускаются Matrix bot и platform-agent в реальной dev-схеме.
|
||||
3. После выбора storage contract начать с изменений в Matrix attachment ingestion.
|
||||
|
||||
## Notes
|
||||
|
||||
- Контекст этой сессии сохранён как отдельный thread, потому что текущий следующий рискованный шаг уже не про context routing, а про файловый transport.
|
||||
- Не смешивать этот трек с незавершённой историей про `!branch`: upstream branch/snapshot API всё ещё не подтверждён.
|
||||
39
Dockerfile
39
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"]
|
||||
|
|
|
|||
429
README.md
429
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 <n>` — удалить вложение по номеру
|
||||
- `!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 <n>` / `!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 <n>` или `!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 <n>`, `!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, МАИ
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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})",
|
||||
)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}):",
|
||||
|
|
|
|||
|
|
@ -10,15 +10,11 @@ HELP_TEXT = "\n".join(
|
|||
"!chats список активных чатов",
|
||||
"!rename <название> переименовать текущий чат",
|
||||
"!archive архивировать текущий чат",
|
||||
"!context показать текущее состояние контекста",
|
||||
"!save [имя] сохранить текущий контекст",
|
||||
"!load показать сохранённые контексты",
|
||||
"",
|
||||
"!clear сбросить контекст текущего чата",
|
||||
"",
|
||||
"!list показать файлы в очереди",
|
||||
"!remove <n> удалить файл из очереди",
|
||||
"!remove all очистить очередь файлов",
|
||||
"",
|
||||
"!yes / !no подтвердить или отменить действие",
|
||||
"!help эта справка",
|
||||
"Остальные команды и настройки скрыты в MVP, чтобы не вводить в заблуждение.",
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<index>\d+)\b", re.IGNORECASE),
|
||||
re.compile(r"^Чат\s+(?P<index>\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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
agents:
|
||||
- id: agent-0
|
||||
label: "Smoke Agent 0"
|
||||
base_url: "http://agent-proxy:7000/agent_0/"
|
||||
workspace_path: "/agents/0"
|
||||
|
||||
- id: agent-1
|
||||
label: "Smoke Agent 1"
|
||||
base_url: "http://agent-proxy:7000/agent_1/"
|
||||
workspace_path: "/agents/1"
|
||||
|
|
@ -1,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"
|
||||
|
|
@ -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", [])),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
services:
|
||||
agent-proxy:
|
||||
volumes:
|
||||
- ./docker/nginx/smoke-agents-timeout.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
agent-no-status:
|
||||
condition: service_started
|
||||
|
||||
agent-no-status:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
args:
|
||||
LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
command: ["python", "-m", "tools.no_status_agent", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
services:
|
||||
surface-smoke:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
args:
|
||||
LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
SMOKE_TIMEOUT: ${SMOKE_TIMEOUT:-5}
|
||||
volumes:
|
||||
- agents:/agents
|
||||
- ./config:/app/config:ro
|
||||
depends_on:
|
||||
agent-proxy:
|
||||
condition: service_healthy
|
||||
command: >
|
||||
sh -lc "
|
||||
python -m tools.check_matrix_agents --config /app/config/matrix-agents.smoke.yaml --timeout ${SMOKE_TIMEOUT:-5}
|
||||
"
|
||||
|
||||
agent-proxy:
|
||||
image: nginx:1.27-alpine
|
||||
volumes:
|
||||
- ./docker/nginx/smoke-agents.conf:/etc/nginx/nginx.conf:ro
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- nc -z 127.0.0.1 7000
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 15
|
||||
start_period: 2s
|
||||
depends_on:
|
||||
agent-0:
|
||||
condition: service_healthy
|
||||
agent-1:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${SMOKE_PROXY_PORT:-7000}:7000"
|
||||
|
||||
agent-0:
|
||||
build:
|
||||
context: ./external/platform-agent
|
||||
target: development
|
||||
additional_contexts:
|
||||
agent_api: ./external/platform-agent_api
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
AGENT_ID: ${AGENT_0_ID:-agent-0}
|
||||
PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
|
||||
PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
|
||||
PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
|
||||
volumes:
|
||||
- ./external/platform-agent/src:/app/src
|
||||
- ./external/platform-agent_api:/agent_api
|
||||
- agents:/shared-agents
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
command: >
|
||||
sh -lc "
|
||||
mkdir -p /shared-agents/0 &&
|
||||
rm -rf /workspace &&
|
||||
ln -s /shared-agents/0 /workspace &&
|
||||
exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
|
||||
"
|
||||
|
||||
agent-1:
|
||||
build:
|
||||
context: ./external/platform-agent
|
||||
target: development
|
||||
additional_contexts:
|
||||
agent_api: ./external/platform-agent_api
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
AGENT_ID: ${AGENT_1_ID:-agent-1}
|
||||
PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
|
||||
PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
|
||||
PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
|
||||
volumes:
|
||||
- ./external/platform-agent/src:/app/src
|
||||
- ./external/platform-agent_api:/agent_api
|
||||
- agents:/shared-agents
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
command: >
|
||||
sh -lc "
|
||||
mkdir -p /shared-agents/1 &&
|
||||
rm -rf /workspace &&
|
||||
ln -s /shared-agents/1 /workspace &&
|
||||
exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
|
||||
"
|
||||
|
||||
volumes:
|
||||
agents:
|
||||
name: ${SURFACES_SMOKE_VOLUME:-surfaces-smoke-agents}
|
||||
|
|
@ -32,7 +32,6 @@ services:
|
|||
- platform-agent
|
||||
volumes:
|
||||
- workspace:/workspace
|
||||
- ./config:/app/config:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
events {}
|
||||
|
||||
http {
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 7000;
|
||||
|
||||
location /agent_0/ {
|
||||
proxy_pass http://agent-0:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location /agent_1/ {
|
||||
proxy_pass http://agent-no-status:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
events {}
|
||||
|
||||
http {
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 7000;
|
||||
|
||||
location /agent_0/ {
|
||||
proxy_pass http://agent-0:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location /agent_1/ {
|
||||
proxy_pass http://agent-1:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
}
|
||||
143
docs/api-contract.md
Normal file
143
docs/api-contract.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# API Contract — Lambda Platform
|
||||
|
||||
> **Статус:** ЧЕРНОВИК — проектируем сами, уточняем с Азаматом когда SDK будет готов
|
||||
> **Последнее обновление:** 2026-03-29
|
||||
|
||||
---
|
||||
|
||||
## Архитектурный контекст
|
||||
|
||||
Каждому пользователю выделяется **один LXC-контейнер** с workspace 10 ГБ.
|
||||
Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — файлы + `history.db` в каждом.
|
||||
|
||||
**Master** управляет lifecycle контейнера (запуск, заморозка, пробуждение).
|
||||
Бот **не управляет lifecycle** — он передаёт `user_id` + `chat_id` + сообщение.
|
||||
Master сам решает: нужно ли поднять контейнер, смонтировать нужный чат, запустить агента.
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://api.lambda-platform.io/v1
|
||||
```
|
||||
|
||||
## Аутентификация
|
||||
|
||||
```
|
||||
Authorization: Bearer {SERVICE_TOKEN}
|
||||
```
|
||||
|
||||
Сервисный токен выдаётся команде поверхностей. Не путать с токеном пользователя.
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
### GET /users/{external_id}?platform={platform}
|
||||
|
||||
Получает или создаёт пользователя.
|
||||
|
||||
**Query params:**
|
||||
- `platform` — `telegram` | `matrix`
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"user_id": "usr_abc123",
|
||||
"external_id": "12345678",
|
||||
"platform": "telegram",
|
||||
"display_name": "Иван Иванов",
|
||||
"created_at": "2025-01-15T10:30:00Z",
|
||||
"is_new": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Messages
|
||||
|
||||
Бот не управляет сессиями явно. Отправка сообщения — единственная операция.
|
||||
Master решает: нужен ли новый контейнер, или разбудить существующий.
|
||||
|
||||
### POST /users/{user_id}/chats/{chat_id}/messages
|
||||
|
||||
Отправляет сообщение пользователя агенту. Master поднимает/размораживает контейнер,
|
||||
монтирует нужный чат (`C1/`, `C2/`...), запускает агента.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"text": "Привет, что ты умеешь?",
|
||||
"attachments": []
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"message_id": "msg_qwe012",
|
||||
"response": "Я AI-агент Lambda...",
|
||||
"tokens_used": 142,
|
||||
"finished": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
### GET /users/{user_id}/settings
|
||||
|
||||
Настройки пользователя: скиллы, коннекторы, SOUL, безопасность, план.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"skills": {"web-search": true, "browser": false},
|
||||
"connectors": {"gmail": {"connected": true, "email": "user@gmail.com"}},
|
||||
"soul": {"name": "Лямбда", "style": "friendly"},
|
||||
"safety": {"email-send": true, "file-delete": true},
|
||||
"plan": {"name": "Beta", "tokens_used": 800, "tokens_limit": 1000}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /users/{user_id}/settings
|
||||
|
||||
Применяет действие над настройками.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"action": "toggle_skill",
|
||||
"payload": {"skill": "browser", "enabled": true}
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "ERROR_CODE",
|
||||
"message": "Human readable description",
|
||||
"details": {}
|
||||
}
|
||||
```
|
||||
|
||||
Коды ошибок: `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR`, `CONTAINER_UNAVAILABLE`
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы к команде платфрмы (SDK)
|
||||
|
||||
- [ ] Точный формат эндпоинта отправки сообщения — URL, поля
|
||||
- [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую?
|
||||
- [ ] Стриминговый ответ (SSE / WebSocket) или только sync?
|
||||
- [ ] Формат `SettingsAction` — совпадает с нашим или другой?
|
||||
|
|
@ -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` для каждого агент-контейнера — значения предоставляет платформа.
|
||||
|
|
@ -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.
|
||||
|
||||
## Что сделали
|
||||
|
|
|
|||
|
|
@ -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 <n>` | Удалить файл из очереди по номеру |
|
||||
| `!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 останутся, но команды, завязанные на локальную регистрацию чатов, перестанут работать для этих комнат до повторного онбординга
|
||||
|
|
|
|||
|
|
@ -1,313 +0,0 @@
|
|||
# Руководство по созданию новой поверхности
|
||||
|
||||
Этот документ описывает, как написать новую новую поверхность (например, Discord, Slack или Custom Web) по образцу текущей Matrix-поверхности в ветке `main`.
|
||||
|
||||
Он основан на актуальной реализации Matrix surface в репозитории и отражает текущую продакшн-логику, а не устаревший легаси.
|
||||
|
||||
---
|
||||
|
||||
## 1. Общая архитектура
|
||||
|
||||
### 1.1. Что такое поверхность
|
||||
|
||||
Поверхность — это тонкий адаптер между конкретной платформой (Платформа) и общим ядром бота.
|
||||
|
||||
В репозитории есть разделение:
|
||||
|
||||
- `core/` — общее ядро и бизнес-логика
|
||||
- `adapter/<platform>/` — реализация конкретной поверхности
|
||||
- `sdk/real.py` — работа с реальной платформой / агентом
|
||||
- `config/` — статическая конфигурация агентов
|
||||
- `docs/surface-protocol.md` — общий контракт поверхностей
|
||||
|
||||
### 1.2. Как это работает
|
||||
|
||||
Поверхность должна:
|
||||
|
||||
- принимать нативные события от Платформа
|
||||
- преобразовывать их в единый внутренний контракт (`IncomingMessage`, `IncomingCommand`, `IncomingCallback`)
|
||||
- передавать их в `core`
|
||||
- получать ответы из `core` (`OutgoingMessage`, `OutgoingUI`, `OutgoingTyping`, `OutgoingNotification`)
|
||||
- преобразовывать ответы обратно в нативные нативные сообщения
|
||||
|
||||
Поверхность не должна:
|
||||
|
||||
- управлять жизненным циклом агентских контейнеров
|
||||
- хранить долгую историю бесед вне `core`/платформы
|
||||
- аутентифицировать пользователей сама (если это не часть Платформа API)
|
||||
|
||||
---
|
||||
|
||||
## 2. Структура новой поверхности
|
||||
|
||||
### 2.1. Основные каталоги
|
||||
|
||||
Рекомендуемая структура для новой платформы:
|
||||
|
||||
```
|
||||
adapter/<platform>/
|
||||
bot.py
|
||||
converter.py
|
||||
agent_registry.py
|
||||
files.py
|
||||
handlers/
|
||||
store.py
|
||||
```
|
||||
|
||||
### 2.2. Принцип reuse
|
||||
|
||||
По примеру Matrix surface, New surface должен переиспользовать общий `core` и общий `sdk`.
|
||||
|
||||
Не дублируйте бизнес-логику, а реализуйте только адаптер:
|
||||
|
||||
- `adapter/<platform>/converter.py` — конвертация событий платформы ⇄ внутренние структуры
|
||||
- `adapter/<platform>/bot.py` — основной runtime, старт Платформа client, loop, отправка/прием
|
||||
- `adapter/<platform>/agent_registry.py` — загрузка `config/<platform>-agents.yaml`
|
||||
- `adapter/<platform>/files.py` — хранение входящих/исходящих вложений
|
||||
|
||||
---
|
||||
|
||||
## 3. Контракт входящих/исходящих событий
|
||||
|
||||
### 3.1. Внутренний формат
|
||||
|
||||
Смотрите `core/protocol.py`. Основные типы:
|
||||
|
||||
- `IncomingMessage` — обычное текстовое сообщение + вложения
|
||||
- `IncomingCommand` — управляющая команда
|
||||
- `IncomingCallback` — подтверждение / интерактивные действия
|
||||
- `OutgoingMessage` — ответ пользователю
|
||||
- `OutgoingUI` — интерфейсные элементы (кнопки и т.п.)
|
||||
- `OutgoingTyping` — индикатор печати
|
||||
- `OutgoingNotification` — системное уведомление
|
||||
|
||||
### 3.2. Пример конверсии Matrix
|
||||
|
||||
В Matrix-реализации `adapter/matrix/converter.py`:
|
||||
|
||||
- текст `!yes` / `!no` превращается в `IncomingCallback` с `action: confirm/cancel`
|
||||
- `!list`/`!remove` говорят не агенту, а surface-процессу
|
||||
- вложения `m.file`, `m.image`, `m.audio`, `m.video` нормализуются в `Attachment`
|
||||
|
||||
Для Платформа реализуйте аналогичную логику для native команд вашего клиента.
|
||||
|
||||
---
|
||||
|
||||
## 4. Реестр агентов и маршрутизация
|
||||
|
||||
### 4.1. Что хранит реестр
|
||||
|
||||
В текущей Matrix реализации есть `config/matrix-agents.yaml` и `adapter/matrix/agent_registry.py`.
|
||||
|
||||
Структура:
|
||||
|
||||
```yaml
|
||||
user_agents:
|
||||
"@user0:matrix.example.org": agent-0
|
||||
"@user1:matrix.example.org": agent-1
|
||||
|
||||
agents:
|
||||
- id: agent-0
|
||||
label: "Agent 0"
|
||||
base_url: "http://lambda.coredump.ru:7000/agent_0/"
|
||||
workspace_path: "/agents/0"
|
||||
```
|
||||
|
||||
### 4.2. Логика выбора агента
|
||||
|
||||
- `user_agents` маппит конкретного пользователя на `agent_id`
|
||||
- если user_id не найден, используется первый агент из списка
|
||||
- `agents[].base_url` определяет URL агента
|
||||
- `agents[].workspace_path` определяет путь внутри surface-контейнера для этого агента
|
||||
|
||||
Это важно: именно на этом контракте строится разделение агентов по рабочим каталогам.
|
||||
|
||||
### 4.3. Рекомендуемая Версия для новой платформы
|
||||
|
||||
Создайте `config/<platform>-agents.yaml` с тем же смыслом.
|
||||
|
||||
- `user_agents` — маппинг external user_id → agent_id
|
||||
- `agents` — список агентов
|
||||
- `workspace_path` для каждого агента должен быть абсолютным путем внутри surface-контейнера, например `/agents/0`
|
||||
|
||||
---
|
||||
|
||||
## 5. Файловый контракт
|
||||
|
||||
### 5.1. Shared volume
|
||||
|
||||
Текущее Matrix-решение использует shared volume:
|
||||
|
||||
- surface монтирует общий том как `/agents`
|
||||
- каждый агент видит свою поддиректорию как `/workspace`
|
||||
|
||||
Топология:
|
||||
|
||||
```
|
||||
Bot (/agents) Agent (/workspace = /agents/N/)
|
||||
/agents/0/report.pdf ←──→ /workspace/report.pdf
|
||||
```
|
||||
|
||||
### 5.2. Правила записи файлов
|
||||
|
||||
В `adapter/matrix/files.py` реализовано:
|
||||
|
||||
- входящий файл сохраняется прямо в `{workspace_root}/{filename}`
|
||||
- возвращается путь `workspace_path` относительный внутри рабочего каталога агента
|
||||
- при коллизии имен создаётся `file (1).ext`, `file (2).ext`
|
||||
- `Attachment.workspace_path` передаётся агенту
|
||||
|
||||
Для исходящих файлов:
|
||||
|
||||
- surface читает файл из `workspace_root / workspace_path`
|
||||
- загружает его в платформу
|
||||
|
||||
### 5.3. Пример поведения
|
||||
|
||||
- Пользователь отправляет файл → surface скачивает файл и кладёт его в agent workspace
|
||||
- Агент получает `attachments=["report.pdf"]` и работает с относительным `workspace_path`
|
||||
- Агент пишет результат в `/workspace/result.txt`
|
||||
- surface читает `/agents/{N}/result.txt` и отправляет файл пользователю
|
||||
|
||||
---
|
||||
|
||||
## 6. Чат-менеджмент и контекст
|
||||
|
||||
### 6.1. `platform_chat_id`
|
||||
|
||||
Matrix-реализация использует `platform_chat_id` как стабильный идентификатор чата на стороне агента.
|
||||
|
||||
- `room_meta.platform_chat_id` определяется и сохраняется в `adapter/matrix/store.py`
|
||||
- `reconcile_startup_state()` восстанавливает отсутствующие `platform_chat_id` при рестарте
|
||||
- `RoutedPlatformClient` перенаправляет запросы агенту по `agent_id` + `platform_chat_id`
|
||||
|
||||
Для New surface тот же принцип:
|
||||
|
||||
- каждая внешняя беседа должна привязываться к одному внутреннему `chat_id`
|
||||
- этот `chat_id` используется для вызовов агента
|
||||
- если в Платформа есть несколько комнат/топиков, каждая должна иметь свой `surface_ref`
|
||||
|
||||
### 6.2. Команды управления чатами
|
||||
|
||||
Matrix поддерживает следующие команды, которые нужно сохранить в Платформа:
|
||||
|
||||
- `!new [название]` — создать новый чат
|
||||
- `!chats` — список активных чатов
|
||||
- `!rename <название>` — переименовать текущий чат
|
||||
- `!archive` — архивировать чат
|
||||
- `!clear` / `!reset` — сбросить контекст текущего чата
|
||||
- `!yes` / `!no` — подтвердить или отменить действие агента
|
||||
- `!list` — показать очередь вложений
|
||||
- `!remove <n>` / `!remove all` — удалить вложение из очереди
|
||||
- `!help` — справка
|
||||
|
||||
Эти команды реализованы в Matrix через `adapter/matrix/handlers/`.
|
||||
|
||||
### 6.3. Очередь вложений
|
||||
|
||||
Matrix surface поддерживает staged attachments:
|
||||
|
||||
- файл может быть отправлен без текста
|
||||
- surface сохраняет файл в `staged_attachments` для конкретного room_id + user_id
|
||||
- следующий текст отправляется агенту вместе со всеми файлами из очереди
|
||||
|
||||
В Платформа можно реализовать ту же модель:
|
||||
|
||||
- `!list` показывает текущую очередь
|
||||
- `!remove` удаляет файл из очереди
|
||||
- команда-индикатор или следующее текстовое сообщение отправляет queued attachments агенту
|
||||
|
||||
---
|
||||
|
||||
## 7. Runtime и окружение
|
||||
|
||||
### 7.1. Переменные среды
|
||||
|
||||
Для Matrix surface текущий runtime ожидает:
|
||||
|
||||
- `MATRIX_HOMESERVER` — URL Matrix-сервера
|
||||
- `MATRIX_USER_ID` — `@bot:example.org`
|
||||
- `MATRIX_PASSWORD` или `MATRIX_ACCESS_TOKEN`
|
||||
- `MATRIX_PLATFORM_BACKEND` — должно быть `real` для продакшна
|
||||
- `MATRIX_AGENT_REGISTRY_PATH` — путь к `config/matrix-agents.yaml`
|
||||
- `AGENT_BASE_URL` — fallback URL агента
|
||||
- `SURFACES_WORKSPACE_DIR` — путь к shared volume внутри контейнера (по умолчанию `/workspace` в коде, но в docs рекомендуют `/agents`)
|
||||
|
||||
Для New surface используйте аналогичные переменные:
|
||||
|
||||
- `PLATFORM_PLATFORM_BACKEND=real`
|
||||
- `PLATFORM_AGENT_REGISTRY_PATH=/app/config/<platform>-agents.yaml`
|
||||
- `SURFACES_WORKSPACE_DIR=/agents`
|
||||
- `AGENT_BASE_URL` — если хотите общий fallback
|
||||
|
||||
### 7.2. Environment contract
|
||||
|
||||
В коде `adapter/matrix/bot.py`:
|
||||
|
||||
- `_agent_base_url_from_env()` читает `AGENT_BASE_URL` или `AGENT_WS_URL`
|
||||
- `_load_agent_registry_from_env()` читает `MATRIX_AGENT_REGISTRY_PATH`
|
||||
- `_build_platform_from_env()` выбирает `RealPlatformClient` при `MATRIX_PLATFORM_BACKEND=real`
|
||||
|
||||
В New surface реализуйте ту же логику, заменив префиксы на `PLATFORM_`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Локальное тестирование
|
||||
|
||||
Для тестирования новой поверхности вместе с одним локальным агентом используйте паттерн `docker-compose.fullstack.yml`.
|
||||
В этом режиме:
|
||||
- Запускается 1 контейнер вашей поверхности
|
||||
- Запускается 1 контейнер `platform-agent`
|
||||
- Поднимается локальный shared volume (`surfaces-agents`)
|
||||
- Поверхность настроена маршрутизировать запросы на `http://platform-agent:8000` (через `AGENT_BASE_URL`)
|
||||
- Пользователь общается с ботом, а бот напрямую общается с локальным агентом, разделяя с ним общую папку для файлов.
|
||||
|
||||
Это самый быстрый способ проверить интеграцию новой платформы без внешнего бэкенда.
|
||||
|
||||
---
|
||||
|
||||
## 9. Реализация шаг за шагом
|
||||
|
||||
1. Скопировать `adapter/matrix/` как шаблон для `adapter/<platform>/`.
|
||||
2. Сделать `adapter/<platform>/converter.py`:
|
||||
- превратить native нативные сообщения в `IncomingMessage`
|
||||
- превратить команды в `IncomingCommand`
|
||||
- превратить yes/no-подтверждения в `IncomingCallback`
|
||||
3. Сделать `adapter/<platform>/agent_registry.py` на основе `adapter/matrix/agent_registry.py`.
|
||||
4. Сделать `adapter/<platform>/files.py` на основе `adapter/matrix/files.py`.
|
||||
5. Сделать `adapter/<platform>/bot.py`:
|
||||
- инстанцировать runtime
|
||||
- читать env vars `PLATFORM_*`
|
||||
- загружать реестр агентов
|
||||
- обрабатывать входящие события
|
||||
- отправлять `Outgoing*` обратно в Платформа
|
||||
6. Реализовать команды управления чатами и очередь вложений.
|
||||
7. Прописать `config/<platform>-agents.yaml`.
|
||||
8. Прописать `docker-compose.platform.yml` или аналог, чтобы surface монтировал `/agents`.
|
||||
9. Написать тесты по аналогии с `tests/adapter/matrix/`.
|
||||
10. Проверить, что все env vars читаются из окружения и не зависят от устаревших Matrix-переменных.
|
||||
|
||||
---
|
||||
|
||||
## 10. Важные замечания
|
||||
|
||||
- Текущий Matrix surface на ветке `main` — активная реализация, а не устаревший легаси.
|
||||
- Документация и код согласованы: `agent_registry`, `files`, `routed_platform`, `reconciliation` работают вместе.
|
||||
- Обязательно явно задавайте `SURFACES_WORKSPACE_DIR=/agents` в production, если `workspace_path` в реестре указывает на `/agents/*`.
|
||||
- Для New surface сохраните ту же архитектуру: surface = thin adapter, агенты = внешние сервисы.
|
||||
- Не пытайтесь в surface реализовывать логику запуска/стопа агент-контейнеров.
|
||||
|
||||
---
|
||||
|
||||
## 11. Полезные ссылки внутри репозитория
|
||||
|
||||
- `README.md`
|
||||
- `docs/deploy-architecture.md`
|
||||
- `docs/surface-protocol.md`
|
||||
- `adapter/matrix/bot.py`
|
||||
- `adapter/matrix/converter.py`
|
||||
- `adapter/matrix/agent_registry.py`
|
||||
- `adapter/matrix/files.py`
|
||||
- `adapter/matrix/routed_platform.py`
|
||||
- `adapter/matrix/reconciliation.py`
|
||||
- `tests/adapter/matrix/`
|
||||
|
|
@ -1,855 +0,0 @@
|
|||
# Matrix Multi-Agent Routing And Restart State Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart.
|
||||
|
||||
**Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart.
|
||||
|
||||
**Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `adapter/matrix/agent_registry.py`
|
||||
Purpose: load and validate the YAML agent registry used by Matrix runtime.
|
||||
- Create: `adapter/matrix/routed_platform.py`
|
||||
Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances.
|
||||
- Create: `adapter/matrix/handlers/agent.py`
|
||||
Purpose: implement `!agent` listing and selection behavior.
|
||||
- Create: `tests/adapter/matrix/test_agent_registry.py`
|
||||
Purpose: cover YAML loading and registry validation.
|
||||
- Create: `tests/adapter/matrix/test_routed_platform.py`
|
||||
Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol.
|
||||
- Create: `tests/adapter/matrix/test_agent_handler.py`
|
||||
Purpose: cover `!agent` UX and persistence of `selected_agent_id`.
|
||||
- Create: `tests/adapter/matrix/test_restart_persistence.py`
|
||||
Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite.
|
||||
- Create: `config/matrix-agents.example.yaml`
|
||||
Purpose: document the expected agent registry format.
|
||||
- Modify: `pyproject.toml`
|
||||
Purpose: add YAML parsing dependency required by the runtime registry loader.
|
||||
- Modify: `.env.example`
|
||||
Purpose: document the config path env var for the Matrix agent registry.
|
||||
- Modify: `README.md`
|
||||
Purpose: document the new config file, `!agent`, and restart persistence expectations.
|
||||
- Modify: `adapter/matrix/store.py`
|
||||
Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics.
|
||||
- Modify: `adapter/matrix/bot.py`
|
||||
Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch.
|
||||
- Modify: `adapter/matrix/handlers/__init__.py`
|
||||
Purpose: register the new `!agent` command.
|
||||
- Modify: `adapter/matrix/handlers/chat.py`
|
||||
Purpose: require a selected agent for `!new` and bind new rooms to that agent.
|
||||
- Modify: `adapter/matrix/handlers/context_commands.py`
|
||||
Purpose: keep context commands compatible with local chat ids and routed platform delegation.
|
||||
- Modify: `adapter/matrix/handlers/settings.py`
|
||||
Purpose: expose `!agent` in help text.
|
||||
- Modify: `tests/adapter/matrix/test_dispatcher.py`
|
||||
Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics.
|
||||
- Modify: `tests/adapter/matrix/test_context_commands.py`
|
||||
Purpose: keep load/reset/context flows aligned with the routed platform facade.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add The Agent Registry And Configuration Wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `adapter/matrix/agent_registry.py`
|
||||
- Create: `tests/adapter/matrix/test_agent_registry.py`
|
||||
- Create: `config/matrix-agents.example.yaml`
|
||||
- Modify: `pyproject.toml`
|
||||
- Modify: `.env.example`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing registry tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_agent_registry.py
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry
|
||||
|
||||
|
||||
def test_load_agent_registry_reads_yaml_entries(tmp_path: Path):
|
||||
path = tmp_path / "agents.yaml"
|
||||
path.write_text(
|
||||
"agents:\n"
|
||||
" - id: agent-1\n"
|
||||
" label: Analyst\n"
|
||||
" - id: agent-2\n"
|
||||
" label: Research\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registry = load_agent_registry(path)
|
||||
|
||||
assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"]
|
||||
assert registry.get("agent-1").label == "Analyst"
|
||||
|
||||
|
||||
def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path):
|
||||
path = tmp_path / "agents.yaml"
|
||||
path.write_text(
|
||||
"agents:\n"
|
||||
" - id: agent-1\n"
|
||||
" label: Analyst\n"
|
||||
" - id: agent-1\n"
|
||||
" label: Duplicate\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(AgentRegistryError, match="duplicate agent id"):
|
||||
load_agent_registry(path)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the registry tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
|
||||
|
||||
Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`.
|
||||
|
||||
- [ ] **Step 3: Add the YAML dependency and implement the registry loader**
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
dependencies = [
|
||||
"aiogram>=3.4,<4",
|
||||
"matrix-nio>=0.21",
|
||||
"pydantic>=2.5",
|
||||
"structlog>=24.1",
|
||||
"python-dotenv>=1.0",
|
||||
"httpx>=0.27",
|
||||
"aiohttp>=3.9",
|
||||
"PyYAML>=6.0",
|
||||
]
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/agent_registry.py
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class AgentRegistryError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentDefinition:
|
||||
agent_id: str
|
||||
label: str
|
||||
|
||||
|
||||
class AgentRegistry:
|
||||
def __init__(self, agents: list[AgentDefinition]) -> None:
|
||||
self.agents = agents
|
||||
self._by_id = {agent.agent_id: agent for agent in agents}
|
||||
|
||||
def get(self, agent_id: str) -> AgentDefinition:
|
||||
try:
|
||||
return self._by_id[agent_id]
|
||||
except KeyError as exc:
|
||||
raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
|
||||
|
||||
|
||||
def load_agent_registry(path: str | Path) -> AgentRegistry:
|
||||
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
||||
entries = raw.get("agents")
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise AgentRegistryError("agents registry must contain a non-empty agents list")
|
||||
|
||||
agents: list[AgentDefinition] = []
|
||||
seen: set[str] = set()
|
||||
for entry in entries:
|
||||
agent_id = str(entry.get("id", "")).strip()
|
||||
label = str(entry.get("label", "")).strip()
|
||||
if not agent_id or not label:
|
||||
raise AgentRegistryError("each agent entry requires id and label")
|
||||
if agent_id in seen:
|
||||
raise AgentRegistryError(f"duplicate agent id: {agent_id}")
|
||||
seen.add(agent_id)
|
||||
agents.append(AgentDefinition(agent_id=agent_id, label=label))
|
||||
return AgentRegistry(agents)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the example config and runtime wiring docs**
|
||||
|
||||
```yaml
|
||||
# config/matrix-agents.example.yaml
|
||||
agents:
|
||||
- id: agent-1
|
||||
label: Analyst
|
||||
- id: agent-2
|
||||
label: Research
|
||||
```
|
||||
|
||||
```env
|
||||
# .env.example
|
||||
MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml
|
||||
```
|
||||
|
||||
```markdown
|
||||
# README.md
|
||||
1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml`
|
||||
2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml`
|
||||
3. Use `!agent` in Matrix to select the active upstream agent
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the registry tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py
|
||||
git commit -m "feat: add matrix agent registry loader"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient`
|
||||
|
||||
**Files:**
|
||||
- Create: `adapter/matrix/routed_platform.py`
|
||||
- Create: `tests/adapter/matrix/test_routed_platform.py`
|
||||
- Modify: `adapter/matrix/bot.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing routed-platform tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_routed_platform.py
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.routed_platform import RoutedPlatformClient
|
||||
from adapter.matrix.store import set_room_meta
|
||||
from core.chat import ChatManager
|
||||
from core.store import InMemoryStore
|
||||
from sdk.interface import MessageResponse
|
||||
from sdk.prototype_state import PrototypeStateStore
|
||||
|
||||
|
||||
class FakeDelegate:
|
||||
def __init__(self, agent_id: str) -> None:
|
||||
self.agent_id = agent_id
|
||||
self.calls = []
|
||||
|
||||
async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
|
||||
self.calls.append((user_id, chat_id, text, attachments))
|
||||
return MessageResponse(
|
||||
message_id=user_id,
|
||||
response=f"{self.agent_id}:{text}",
|
||||
tokens_used=0,
|
||||
finished=True,
|
||||
)
|
||||
|
||||
async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
|
||||
return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name)
|
||||
|
||||
async def get_settings(self, user_id: str):
|
||||
return await PrototypeStateStore().get_settings(user_id)
|
||||
|
||||
async def update_settings(self, user_id: str, action):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id():
|
||||
store = InMemoryStore()
|
||||
chat_mgr = ChatManager(None, store)
|
||||
await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
|
||||
await set_room_meta(
|
||||
store,
|
||||
"!room:example.org",
|
||||
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
|
||||
)
|
||||
|
||||
delegates = {"agent-2": FakeDelegate("agent-2")}
|
||||
platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
|
||||
|
||||
response = await platform.send_message("u1", "C1", "hello")
|
||||
|
||||
assert response.response == "agent-2:hello"
|
||||
assert delegates["agent-2"].calls == [("u1", "41", "hello", None)]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the routed-platform tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
|
||||
|
||||
Expected: FAIL with `ImportError` for `RoutedPlatformClient`.
|
||||
|
||||
- [ ] **Step 3: Implement the routing facade and integrate runtime construction**
|
||||
|
||||
```python
|
||||
# adapter/matrix/routed_platform.py
|
||||
from __future__ import annotations
|
||||
|
||||
from sdk.interface import PlatformClient
|
||||
|
||||
|
||||
class RoutedPlatformClient(PlatformClient):
|
||||
def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None:
|
||||
self._store = store
|
||||
self._chat_mgr = chat_mgr
|
||||
self._delegates = delegates
|
||||
|
||||
async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
|
||||
ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id)
|
||||
if ctx is None:
|
||||
raise ValueError(f"Chat {local_chat_id} not found for {user_id}")
|
||||
room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}")
|
||||
if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"):
|
||||
raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target")
|
||||
delegate = self._delegates[room_meta["agent_id"]]
|
||||
return delegate, str(room_meta["platform_chat_id"])
|
||||
|
||||
async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
|
||||
delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
|
||||
return await delegate.send_message(user_id, platform_chat_id, text, attachments)
|
||||
|
||||
async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None):
|
||||
delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
|
||||
async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments):
|
||||
yield chunk
|
||||
|
||||
async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
|
||||
first_delegate = next(iter(self._delegates.values()))
|
||||
return await first_delegate.get_or_create_user(external_id, platform, display_name)
|
||||
|
||||
async def get_settings(self, user_id: str):
|
||||
first_delegate = next(iter(self._delegates.values()))
|
||||
return await first_delegate.get_settings(user_id)
|
||||
|
||||
async def update_settings(self, user_id: str, action):
|
||||
first_delegate = next(iter(self._delegates.values()))
|
||||
await first_delegate.update_settings(user_id, action)
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/bot.py
|
||||
from adapter.matrix.agent_registry import load_agent_registry
|
||||
from adapter.matrix.routed_platform import RoutedPlatformClient
|
||||
|
||||
|
||||
def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
|
||||
backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
|
||||
if backend != "real":
|
||||
return MockPlatformClient()
|
||||
|
||||
registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"])
|
||||
delegates = {
|
||||
agent.agent_id: RealPlatformClient(
|
||||
agent_id=agent.agent_id,
|
||||
agent_base_url=_agent_base_url_from_env(),
|
||||
prototype_state=PrototypeStateStore(),
|
||||
platform="matrix",
|
||||
)
|
||||
for agent in registry.agents
|
||||
}
|
||||
return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
|
||||
|
||||
|
||||
def build_runtime(...):
|
||||
store = store or InMemoryStore()
|
||||
chat_mgr = ChatManager(None, store)
|
||||
platform = platform or _build_platform_from_env(store, chat_mgr)
|
||||
auth_mgr = AuthManager(platform, store)
|
||||
settings_mgr = SettingsManager(platform, store)
|
||||
dispatcher = EventDispatcher(
|
||||
platform=platform,
|
||||
chat_mgr=chat_mgr,
|
||||
auth_mgr=auth_mgr,
|
||||
settings_mgr=settings_mgr,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the routed-platform tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py
|
||||
git commit -m "feat: add matrix routed platform facade"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add `!agent` Selection And Durable User Agent State
|
||||
|
||||
**Files:**
|
||||
- Create: `adapter/matrix/handlers/agent.py`
|
||||
- Create: `tests/adapter/matrix/test_agent_handler.py`
|
||||
- Modify: `adapter/matrix/store.py`
|
||||
- Modify: `adapter/matrix/handlers/__init__.py`
|
||||
- Modify: `adapter/matrix/handlers/settings.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing agent-handler tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_agent_handler.py
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.handlers.agent import make_handle_agent
|
||||
from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta
|
||||
from core.protocol import IncomingCommand
|
||||
from core.store import InMemoryStore
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.agents = [
|
||||
type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(),
|
||||
type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_command_lists_available_agents():
|
||||
handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry())
|
||||
result = await handler(
|
||||
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert "1. Analyst" in result[0].text
|
||||
assert "2. Research" in result[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_command_persists_selected_agent_and_binds_unbound_room():
|
||||
store = InMemoryStore()
|
||||
await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"})
|
||||
handler = make_handle_agent(store=store, registry=FakeRegistry())
|
||||
chat_mgr = type(
|
||||
"ChatMgr",
|
||||
(),
|
||||
{"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())},
|
||||
)()
|
||||
|
||||
await handler(
|
||||
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]),
|
||||
None,
|
||||
None,
|
||||
chat_mgr,
|
||||
None,
|
||||
)
|
||||
|
||||
assert await get_selected_agent_id(store, "u1") == "agent-2"
|
||||
room_meta = await get_room_meta(store, "!room:example.org")
|
||||
assert room_meta["agent_id"] == "agent-2"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the agent-handler tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
|
||||
|
||||
Expected: FAIL with missing handler or store helpers.
|
||||
|
||||
- [ ] **Step 3: Add durable store helpers and implement `!agent`**
|
||||
|
||||
```python
|
||||
# adapter/matrix/store.py
|
||||
async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None:
|
||||
meta = await get_user_meta(store, matrix_user_id) or {}
|
||||
value = meta.get("selected_agent_id")
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None:
|
||||
meta = await get_user_meta(store, matrix_user_id) or {}
|
||||
meta["selected_agent_id"] = agent_id
|
||||
await set_user_meta(store, matrix_user_id, meta)
|
||||
|
||||
|
||||
async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None:
|
||||
meta = dict(await get_room_meta(store, room_id) or {})
|
||||
meta["agent_id"] = agent_id
|
||||
await set_room_meta(store, room_id, meta)
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/handlers/agent.py
|
||||
from __future__ import annotations
|
||||
|
||||
from adapter.matrix.store import (
|
||||
get_room_meta,
|
||||
get_selected_agent_id,
|
||||
next_platform_chat_id,
|
||||
set_platform_chat_id,
|
||||
set_room_agent_id,
|
||||
set_selected_agent_id,
|
||||
)
|
||||
from core.protocol import IncomingCommand, OutgoingMessage
|
||||
|
||||
|
||||
def make_handle_agent(store, registry):
|
||||
async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr):
|
||||
if not event.args:
|
||||
current = await get_selected_agent_id(store, event.user_id)
|
||||
lines = ["Доступные агенты:"]
|
||||
for index, agent in enumerate(registry.agents, start=1):
|
||||
marker = " (текущий)" if agent.agent_id == current else ""
|
||||
lines.append(f"{index}. {agent.label}{marker}")
|
||||
lines.append("")
|
||||
lines.append("Выбери агента: !agent <номер>")
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
|
||||
|
||||
agent = registry.agents[int(event.args[0]) - 1]
|
||||
await set_selected_agent_id(store, event.user_id, agent.agent_id)
|
||||
ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None
|
||||
if ctx is not None:
|
||||
room_meta = await get_room_meta(store, ctx.surface_ref)
|
||||
if room_meta is not None and not room_meta.get("agent_id"):
|
||||
await set_room_agent_id(store, ctx.surface_ref, agent.agent_id)
|
||||
if not room_meta.get("platform_chat_id"):
|
||||
await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store))
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")]
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")]
|
||||
|
||||
return handle_agent
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register the command and update help text**
|
||||
|
||||
```python
|
||||
# adapter/matrix/handlers/__init__.py
|
||||
from adapter.matrix.handlers.agent import make_handle_agent
|
||||
|
||||
dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry))
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/handlers/settings.py
|
||||
HELP_TEXT = "\n".join(
|
||||
[
|
||||
"Команды",
|
||||
"",
|
||||
"!agent выбрать активного агента",
|
||||
"!new [название] создать новый чат",
|
||||
"!chats список активных чатов",
|
||||
"!rename <название> переименовать текущий чат",
|
||||
"!archive архивировать текущий чат",
|
||||
"!context показать текущее состояние контекста",
|
||||
"!save [имя] сохранить текущий контекст",
|
||||
"!load показать сохранённые контексты",
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the agent-handler tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py
|
||||
git commit -m "feat: add matrix agent selection command"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Bind Rooms Correctly And Block Stale Chats
|
||||
|
||||
**Files:**
|
||||
- Modify: `adapter/matrix/bot.py`
|
||||
- Modify: `adapter/matrix/handlers/chat.py`
|
||||
- Modify: `adapter/matrix/handlers/context_commands.py`
|
||||
- Modify: `tests/adapter/matrix/test_dispatcher.py`
|
||||
- Modify: `tests/adapter/matrix/test_context_commands.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing dispatcher and context-command tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_dispatcher.py
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent():
|
||||
runtime = build_runtime(platform=MockPlatformClient())
|
||||
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||
bot = MatrixBot(client, runtime)
|
||||
await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"})
|
||||
|
||||
await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello"))
|
||||
|
||||
client.room_send.assert_awaited_once()
|
||||
assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_chat_requires_selected_agent_and_binds_room_meta():
|
||||
client = SimpleNamespace(
|
||||
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
|
||||
room_put_state=AsyncMock(),
|
||||
)
|
||||
runtime = build_runtime(platform=MockPlatformClient(), client=client)
|
||||
await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"})
|
||||
|
||||
result = await runtime.dispatcher.dispatch(
|
||||
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"])
|
||||
)
|
||||
|
||||
room_meta = await get_room_meta(runtime.store, "!r2:example")
|
||||
assert room_meta["agent_id"] == "agent-2"
|
||||
assert "Создан чат" in result[0].text
|
||||
```
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_context_commands.py
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_selection_calls_platform_with_local_chat_id():
|
||||
platform = MatrixCommandPlatform()
|
||||
runtime = build_runtime(platform=platform)
|
||||
await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
|
||||
await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"})
|
||||
|
||||
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||
bot = MatrixBot(client, runtime)
|
||||
await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]})
|
||||
|
||||
await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1"))
|
||||
|
||||
platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a"))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
|
||||
|
||||
Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`.
|
||||
|
||||
- [ ] **Step 3: Implement room binding and stale-room checks in runtime**
|
||||
|
||||
```python
|
||||
# adapter/matrix/bot.py
|
||||
from adapter.matrix.store import (
|
||||
get_selected_agent_id,
|
||||
get_room_meta,
|
||||
next_platform_chat_id,
|
||||
set_platform_chat_id,
|
||||
set_room_agent_id,
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]:
|
||||
room_meta = await get_room_meta(self.runtime.store, room_id)
|
||||
selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id)
|
||||
if not selected_agent_id:
|
||||
return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.")
|
||||
if room_meta is None:
|
||||
return room_meta, None
|
||||
if not room_meta.get("agent_id"):
|
||||
await set_room_agent_id(self.runtime.store, room_id, selected_agent_id)
|
||||
if not room_meta.get("platform_chat_id"):
|
||||
await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store))
|
||||
room_meta = await get_room_meta(self.runtime.store, room_id)
|
||||
return room_meta, None
|
||||
if room_meta["agent_id"] != selected_agent_id:
|
||||
return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.")
|
||||
return room_meta, None
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/bot.py
|
||||
local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
|
||||
dispatch_chat_id = local_chat_id
|
||||
|
||||
if not body.startswith("!"):
|
||||
room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender)
|
||||
if blocking is not None:
|
||||
await self._send_all(room.room_id, [blocking])
|
||||
return
|
||||
|
||||
incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`**
|
||||
|
||||
```python
|
||||
# adapter/matrix/handlers/chat.py
|
||||
from adapter.matrix.store import get_selected_agent_id
|
||||
|
||||
selected_agent_id = await get_selected_agent_id(store, event.user_id)
|
||||
if not selected_agent_id:
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")]
|
||||
|
||||
await set_room_meta(
|
||||
store,
|
||||
room_id,
|
||||
{
|
||||
"room_type": "chat",
|
||||
"chat_id": chat_id,
|
||||
"display_name": room_name,
|
||||
"matrix_user_id": event.user_id,
|
||||
"space_id": space_id,
|
||||
"platform_chat_id": platform_chat_id,
|
||||
"agent_id": selected_agent_id,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# adapter/matrix/bot.py
|
||||
room_meta = await get_room_meta(self.runtime.store, room_id)
|
||||
local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id
|
||||
|
||||
await self.runtime.platform.send_message(
|
||||
user_id,
|
||||
local_chat_id,
|
||||
LOAD_PROMPT.format(name=name),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py
|
||||
git commit -m "feat: bind matrix rooms to selected agents"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Prove Durable Restart State And Sequence Persistence
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/adapter/matrix/test_restart_persistence.py`
|
||||
- Modify: `adapter/matrix/store.py`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing restart-persistence tests**
|
||||
|
||||
```python
|
||||
# tests/adapter/matrix/test_restart_persistence.py
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.store import (
|
||||
get_selected_agent_id,
|
||||
next_platform_chat_id,
|
||||
set_room_meta,
|
||||
set_selected_agent_id,
|
||||
)
|
||||
from core.store import SQLiteStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path):
|
||||
db_path = tmp_path / "matrix.db"
|
||||
store = SQLiteStore(str(db_path))
|
||||
await set_selected_agent_id(store, "u1", "agent-2")
|
||||
await set_room_meta(
|
||||
store,
|
||||
"!room:example.org",
|
||||
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
|
||||
)
|
||||
|
||||
reopened = SQLiteStore(str(db_path))
|
||||
assert await get_selected_agent_id(reopened, "u1") == "agent-2"
|
||||
assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2"
|
||||
assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_chat_sequence_survives_store_recreation(tmp_path):
|
||||
db_path = tmp_path / "matrix.db"
|
||||
store = SQLiteStore(str(db_path))
|
||||
|
||||
assert await next_platform_chat_id(store) == "1"
|
||||
assert await next_platform_chat_id(store) == "2"
|
||||
|
||||
reopened = SQLiteStore(str(db_path))
|
||||
assert await next_platform_chat_id(reopened) == "3"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the restart-persistence tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
|
||||
|
||||
Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered.
|
||||
|
||||
- [ ] **Step 3: Make sequence persistence explicit and document the restart boundary**
|
||||
|
||||
```python
|
||||
# adapter/matrix/store.py
|
||||
PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq"
|
||||
|
||||
|
||||
async def next_platform_chat_id(store: StateStore) -> str:
|
||||
async with _PLATFORM_CHAT_SEQ_LOCK:
|
||||
data = await store.get(PLATFORM_CHAT_SEQ_KEY)
|
||||
index = int((data or {}).get("next_platform_chat_index", 1))
|
||||
await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1})
|
||||
return str(index)
|
||||
```
|
||||
|
||||
```markdown
|
||||
# README.md
|
||||
- Matrix durable state lives in `lambda_matrix.db` and `matrix_store`
|
||||
- normal restart is supported only when those paths survive container recreation
|
||||
- staged attachments and pending confirmations are intentionally not restored
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the restart-persistence tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run the combined verification sweep**
|
||||
|
||||
Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py
|
||||
git commit -m "test: cover matrix restart state persistence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- Multi-agent agent registry: Task 1
|
||||
- Shared `PlatformClient` preserved via routing facade: Task 2
|
||||
- `!agent` UX and durable `selected_agent_id`: Task 3
|
||||
- Unbound room activation, `!new`, stale room rejection: Task 4
|
||||
- Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- No `TODO`, `TBD`, or “implement later” markers remain.
|
||||
- Each task includes exact file paths, tests, commands, and minimal code snippets.
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `selected_agent_id` lives in user metadata throughout the plan.
|
||||
- `agent_id` and `platform_chat_id` live in room metadata throughout the plan.
|
||||
- `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 — тоже реализует этот протокол, заменяя один файл.
|
||||
Адаптеры поверхностей и ядро не меняются вообще.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
# Telegram — описание прототипа
|
||||
|
||||
> **ВНИМАНИЕ: Telegram-адаптер не является частью текущего MVP-деплоя.**
|
||||
> Код Telegram-поверхности находится в отдельной ветке `feat/telegram-adapter`. Данный документ описывает возможности этого адаптера, но многие концепции (например, AuthFlow и MockPlatformClient) устарели по отношению к актуальной архитектуре `main`.
|
||||
|
||||
## Концепция
|
||||
|
||||
Один бот, несколько чатов через Topics в Forum-группе.
|
||||
|
|
|
|||
65
docs/user-flow.md
Normal file
65
docs/user-flow.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# User Flow — Lambda Bot
|
||||
|
||||
> **Статус:** ШАБЛОН — заполняет @architect после исследований
|
||||
> **Зависит от:** docs/research/telegram-flows.md, docs/research/competitor-ux.md
|
||||
|
||||
---
|
||||
|
||||
## Основной сценарий (happy path)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant Bot as Telegram/Matrix Bot
|
||||
participant Platform as Lambda Platform (Master)
|
||||
|
||||
User->>Bot: /start
|
||||
Bot->>Platform: GET /users/{tg_id}?platform=telegram
|
||||
Platform-->>Bot: {user_id, is_new}
|
||||
|
||||
alt Новый пользователь
|
||||
Bot->>User: Приветствие + инструкция
|
||||
else Существующий пользователь
|
||||
Bot->>User: Добро пожаловать обратно
|
||||
end
|
||||
|
||||
loop Диалог (бот не управляет сессиями — Master делает это автоматически)
|
||||
User->>Bot: Сообщение в чат C1/C2/...
|
||||
Bot->>Platform: POST /users/{user_id}/chats/{chat_id}/messages
|
||||
Note over Platform: Master поднимает контейнер,<br/>монтирует нужный чат, запускает агента
|
||||
Platform-->>Bot: {message_id, response, tokens_used}
|
||||
Bot->>User: Ответ агента
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Состояния FSM (Telegram)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Unauthenticated: первый контакт
|
||||
|
||||
Unauthenticated --> Idle: /start (auth confirmed)
|
||||
|
||||
Idle --> WaitingResponse: сообщение пользователя
|
||||
WaitingResponse --> Idle: ответ получен
|
||||
WaitingResponse --> Error: ошибка платформы
|
||||
|
||||
Idle --> Idle: /new (создан новый чат)
|
||||
Idle --> ConfirmAction: агент запрашивает подтверждение
|
||||
ConfirmAction --> Idle: подтверждено / отменено
|
||||
|
||||
Error --> Idle: /start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
> Заполняет @researcher и @architect после исследований
|
||||
|
||||
- [ ] Как выглядит онбординг новых пользователей у конкурентов?
|
||||
- [ ] Нужна ли кнопка "Новая сессия" или сессия стартует автоматически?
|
||||
- [ ] Что показываем пока агент думает (typing indicator)?
|
||||
- [ ] Как обрабатываем timeout ответа от платформы?
|
||||
174
docs/workflow-backup-2026-04-01.md
Normal file
174
docs/workflow-backup-2026-04-01.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Surfaces team — Lambda Lab 3.0
|
||||
|
||||
Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda.
|
||||
|
||||
## Правило №1: не быть ждуном
|
||||
|
||||
Платформа (SDK от Азамата) ещё не готова. Это **не блокер**.
|
||||
|
||||
- Все вызовы платформы — через `platform/interface.py` (Protocol)
|
||||
- Реализация сейчас — `platform/mock.py` (MockPlatformClient)
|
||||
- При подключении реального SDK — меняем только `platform/mock.py`
|
||||
- Архитектурные решения принимаем сами, фиксируем в `docs/api-contract.md`
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
surfaces-bot/
|
||||
core/
|
||||
protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...)
|
||||
handler.py — EventDispatcher: IncomingEvent → OutgoingEvent (общее для всех ботов)
|
||||
handlers/ — обработчики по типам событий (start, message, chat, settings, callback)
|
||||
store.py — StateStore Protocol + InMemoryStore + SQLiteStore
|
||||
chat.py — ChatManager: метаданные чатов C1/C2/C3
|
||||
auth.py — AuthManager: AuthFlow
|
||||
settings.py — SettingsManager: SettingsAction
|
||||
|
||||
adapter/
|
||||
telegram/ — aiogram адаптер
|
||||
converter.py — aiogram Event → IncomingEvent и обратно
|
||||
bot.py — точка входа
|
||||
handlers/ — aiogram роутеры
|
||||
keyboards/ — инлайн-клавиатуры
|
||||
states.py — FSM состояния
|
||||
matrix/ — matrix-nio адаптер
|
||||
converter.py — matrix-nio Event → IncomingEvent и обратно
|
||||
bot.py — точка входа
|
||||
handlers/ — обработчики событий
|
||||
|
||||
platform/
|
||||
interface.py — Protocol: PlatformClient (контракт к SDK)
|
||||
mock.py — MockPlatformClient (заглушка)
|
||||
|
||||
docs/ — вся документация
|
||||
tests/ — pytest тесты
|
||||
.claude/agents/ — конфиги агентов
|
||||
```
|
||||
|
||||
Подробно об унификации: `docs/surface-protocol.md`
|
||||
Telegram функционал: `docs/telegram-prototype.md`
|
||||
Matrix функционал: `docs/matrix-prototype.md`
|
||||
|
||||
---
|
||||
|
||||
## Агенты
|
||||
|
||||
| Агент | Когда запускать | Модель | Токены |
|
||||
|-------|----------------|--------|--------|
|
||||
| `@researcher` | Изучить API, найти примеры | Haiku | ~дёшево |
|
||||
| `@architect` | Спроектировать решение | Sonnet | ~средне |
|
||||
| `@tg-developer` | Писать код Telegram-адаптера | Sonnet | ~средне |
|
||||
| `@matrix-developer` | Писать код Matrix-адаптера | Sonnet | ~средне |
|
||||
| `@core-developer` | Писать core/ и platform/ | Sonnet | ~средне |
|
||||
| `@reviewer` | Проверить код перед PR | Sonnet | ~средне |
|
||||
|
||||
**Важно (Pro-лимиты):** не запускай больше двух Sonnet-агентов одновременно.
|
||||
Haiku можно запускать параллельно сколько угодно.
|
||||
|
||||
---
|
||||
|
||||
## Стратегия параллельной разработки
|
||||
|
||||
Два бота разрабатываются параллельно, но через общее ядро.
|
||||
|
||||
### Порядок работы
|
||||
|
||||
```
|
||||
1. core/ — сначала (однократно, все ждут)
|
||||
@core-developer пишет protocol.py, handler.py, session.py, auth.py, settings.py
|
||||
|
||||
2. platform/ — сразу после core/
|
||||
@core-developer пишет interface.py и mock.py
|
||||
|
||||
3. adapter/telegram/ и adapter/matrix/ — параллельно
|
||||
@tg-developer → adapter/telegram/
|
||||
@matrix-developer → adapter/matrix/
|
||||
Не пересекаются по файлам — можно одновременно в разных терминалах.
|
||||
```
|
||||
|
||||
### Что можно делать одновременно (разные терминалы)
|
||||
|
||||
```bash
|
||||
# Терминал 1 — Telegram адаптер
|
||||
claude "Use @tg-developer to implement adapter/telegram/handlers/start.py"
|
||||
|
||||
# Терминал 2 — Matrix адаптер (параллельно)
|
||||
claude "Use @matrix-developer to implement adapter/matrix/handlers/start.py"
|
||||
```
|
||||
|
||||
### Что нельзя делать одновременно
|
||||
|
||||
- Два агента в одном файле
|
||||
- @core-developer параллельно с @tg-developer или @matrix-developer
|
||||
(core/ должен быть готов до адаптеров)
|
||||
- Больше двух Sonnet-агентов одновременно (Pro-лимит)
|
||||
|
||||
---
|
||||
|
||||
## Git worktree workflow
|
||||
|
||||
Каждая фича в отдельном worktree — адаптеры не мешают друг другу:
|
||||
|
||||
```bash
|
||||
# Создать worktrees для параллельной работы
|
||||
git worktree add .worktrees/telegram -b feat/telegram-adapter
|
||||
git worktree add .worktrees/matrix -b feat/matrix-adapter
|
||||
|
||||
# Работать в каждом независимо
|
||||
cd .worktrees/telegram && claude "Use @tg-developer to ..."
|
||||
cd .worktrees/matrix && claude "Use @matrix-developer to ..."
|
||||
|
||||
# Смержить когда готово
|
||||
git checkout main
|
||||
git merge feat/telegram-adapter
|
||||
git merge feat/matrix-adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Команды запуска
|
||||
|
||||
```bash
|
||||
# Установить зависимости
|
||||
uv sync
|
||||
|
||||
# Запустить тесты
|
||||
pytest tests/ -v
|
||||
|
||||
# Запустить только тесты Telegram
|
||||
pytest tests/adapter/telegram/ -v
|
||||
|
||||
# Запустить только тесты Matrix
|
||||
pytest tests/adapter/matrix/ -v
|
||||
|
||||
# Запустить только тесты ядра
|
||||
pytest tests/core/ -v
|
||||
|
||||
# Запустить Telegram бота
|
||||
python -m adapter.telegram.bot
|
||||
|
||||
# Запустить Matrix бота
|
||||
python -m adapter.matrix.bot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Никогда не коммить `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Экономия токенов (Pro-лимиты)
|
||||
|
||||
- Исследования → всегда `@researcher` (Haiku), не Sonnet
|
||||
- Точечные правки в одном файле → напрямую без агента
|
||||
- Ревью → только перед PR, не после каждого коммита
|
||||
- Длинный контекст → дай агенту конкретный файл, не весь проект
|
||||
- Если агент "завис" в рассуждениях → прерви, переформулируй задачу точнее
|
||||
|
|
@ -16,7 +16,6 @@ dependencies = [
|
|||
"python-dotenv>=1.0",
|
||||
"httpx>=0.27",
|
||||
"aiohttp>=3.9",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
78
sdk/real.py
78
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",
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
)
|
||||
|
|
@ -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/",
|
||||
]
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Operational tools for surfaces-bot."""
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import aiohttp
|
||||
|
||||
from adapter.matrix.agent_registry import AgentDefinition, load_agent_registry
|
||||
from sdk.real import RealPlatformClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentCheckResult:
|
||||
agent_id: str
|
||||
label: str
|
||||
chat_id: str
|
||||
base_url: str
|
||||
ws_url: str
|
||||
ok: bool
|
||||
stage: str
|
||||
latency_ms: int
|
||||
error: str = ""
|
||||
response_type: str = ""
|
||||
|
||||
|
||||
def build_agent_ws_url(base_url: str, chat_id: str) -> str:
|
||||
normalized = RealPlatformClient._normalize_agent_base_url(base_url)
|
||||
return urljoin(normalized, f"v1/agent_ws/{chat_id}/")
|
||||
|
||||
|
||||
def _message_type(payload: str) -> str:
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return ""
|
||||
value = data.get("type")
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
async def _receive_text(ws: aiohttp.ClientWebSocketResponse, timeout: float) -> str:
|
||||
msg = await asyncio.wait_for(ws.receive(), timeout=timeout)
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
return str(msg.data)
|
||||
if msg.type == aiohttp.WSMsgType.ERROR:
|
||||
raise RuntimeError(f"websocket error: {ws.exception()}")
|
||||
raise RuntimeError(f"unexpected websocket message type: {msg.type.name}")
|
||||
|
||||
|
||||
async def check_agent(
|
||||
agent: AgentDefinition,
|
||||
*,
|
||||
fallback_base_url: str,
|
||||
chat_id: str,
|
||||
timeout: float,
|
||||
message: str | None,
|
||||
) -> AgentCheckResult:
|
||||
base_url = agent.base_url or fallback_base_url
|
||||
ws_url = build_agent_ws_url(base_url, chat_id) if base_url else ""
|
||||
started = time.perf_counter()
|
||||
|
||||
def result(ok: bool, stage: str, error: str = "", response_type: str = "") -> AgentCheckResult:
|
||||
return AgentCheckResult(
|
||||
agent_id=agent.agent_id,
|
||||
label=agent.label,
|
||||
chat_id=chat_id,
|
||||
base_url=base_url,
|
||||
ws_url=ws_url,
|
||||
ok=ok,
|
||||
stage=stage,
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
error=error,
|
||||
response_type=response_type,
|
||||
)
|
||||
|
||||
if not base_url:
|
||||
return result(False, "config", "missing base_url and AGENT_BASE_URL")
|
||||
|
||||
try:
|
||||
client_timeout = aiohttp.ClientTimeout(
|
||||
total=timeout,
|
||||
connect=timeout,
|
||||
sock_connect=timeout,
|
||||
sock_read=timeout,
|
||||
)
|
||||
async with aiohttp.ClientSession(timeout=client_timeout) as session:
|
||||
async with session.ws_connect(ws_url, heartbeat=30) as ws:
|
||||
raw_status = await _receive_text(ws, timeout)
|
||||
status_type = _message_type(raw_status)
|
||||
if status_type != "STATUS":
|
||||
return result(
|
||||
False,
|
||||
"status",
|
||||
f"expected STATUS, got {raw_status[:200]}",
|
||||
status_type,
|
||||
)
|
||||
|
||||
if not message:
|
||||
return result(True, "status", response_type=status_type)
|
||||
|
||||
payload = {
|
||||
"type": "USER_MESSAGE",
|
||||
"text": message,
|
||||
"attachments": [],
|
||||
}
|
||||
await ws.send_str(json.dumps(payload))
|
||||
|
||||
while True:
|
||||
raw_event = await _receive_text(ws, timeout)
|
||||
event_type = _message_type(raw_event)
|
||||
if event_type == "ERROR":
|
||||
return result(False, "message", raw_event[:200], event_type)
|
||||
if event_type == "AGENT_EVENT_END":
|
||||
return result(True, "message", response_type=event_type)
|
||||
if not event_type:
|
||||
return result(False, "message", f"invalid JSON event: {raw_event[:200]}")
|
||||
except TimeoutError:
|
||||
return result(False, "timeout", f"no response within {timeout:g}s")
|
||||
except Exception as exc:
|
||||
return result(False, "connect", str(exc))
|
||||
|
||||
|
||||
def _select_agents(
|
||||
agents: tuple[AgentDefinition, ...],
|
||||
selected: set[str],
|
||||
) -> list[AgentDefinition]:
|
||||
if not selected:
|
||||
return list(agents)
|
||||
return [agent for agent in agents if agent.agent_id in selected]
|
||||
|
||||
|
||||
async def run_checks(args: argparse.Namespace) -> list[AgentCheckResult]:
|
||||
registry = load_agent_registry(args.config)
|
||||
selected = _select_agents(registry.agents, set(args.agent))
|
||||
if not selected:
|
||||
raise SystemExit("no matching agents selected")
|
||||
|
||||
fallback_base_url = args.base_url or os.environ.get("AGENT_BASE_URL", "")
|
||||
semaphore = asyncio.Semaphore(args.concurrency)
|
||||
|
||||
async def run_one(index: int, agent: AgentDefinition) -> AgentCheckResult:
|
||||
chat_id = str(args.chat_id if args.chat_id is not None else args.chat_id_base + index)
|
||||
async with semaphore:
|
||||
return await check_agent(
|
||||
agent,
|
||||
fallback_base_url=fallback_base_url,
|
||||
chat_id=chat_id,
|
||||
timeout=args.timeout,
|
||||
message=args.message,
|
||||
)
|
||||
|
||||
return await asyncio.gather(*(run_one(index, agent) for index, agent in enumerate(selected)))
|
||||
|
||||
|
||||
def print_table(results: list[AgentCheckResult]) -> None:
|
||||
for item in results:
|
||||
status = "OK" if item.ok else "FAIL"
|
||||
detail = item.response_type or item.error
|
||||
print(
|
||||
f"{status:4} {item.agent_id:20} {item.stage:8} "
|
||||
f"{item.latency_ms:5}ms chat={item.chat_id} url={item.ws_url} {detail}"
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Smoke-check Matrix agent WebSocket endpoints from matrix-agents.yaml."
|
||||
)
|
||||
parser.add_argument("--config", type=Path, default=Path("config/matrix-agents.yaml"))
|
||||
parser.add_argument("--agent", action="append", default=[], help="Agent id to check")
|
||||
parser.add_argument("--base-url", default="", help="Fallback base URL when an agent has none")
|
||||
parser.add_argument("--timeout", type=float, default=10.0)
|
||||
parser.add_argument("--concurrency", type=int, default=5)
|
||||
parser.add_argument("--chat-id", type=int, default=None, help="Use one explicit chat id")
|
||||
parser.add_argument("--chat-id-base", type=int, default=900000)
|
||||
parser.add_argument("--message", default=None, help="Optional test message after STATUS")
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
results = asyncio.run(run_checks(args))
|
||||
if args.json:
|
||||
print(json.dumps([asdict(result) for result in results], ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print_table(results)
|
||||
return 0 if all(result.ok for result in results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
await asyncio.sleep(3600)
|
||||
return ws
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="WebSocket stub that accepts connections but sends no STATUS."
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8000)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
app = web.Application()
|
||||
app.router.add_get("/v1/agent_ws/{chat_id}/", websocket_handler)
|
||||
web.run_app(app, host=args.host, port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
57
uv.lock
generated
57
uv.lock
generated
|
|
@ -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" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue