diff --git a/.env.example b/.env.example index e251708..610314e 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,24 @@ -# Telegram -TELEGRAM_BOT_TOKEN=your_bot_token_here - -# Matrix -MATRIX_HOMESERVER=https://matrix.org -MATRIX_USER_ID=@bot:matrix.org +# Matrix bot credentials +MATRIX_HOMESERVER=https://matrix.example.org +MATRIX_USER_ID=@lambda-bot:example.org +# Use ONE of: MATRIX_PASSWORD or MATRIX_ACCESS_TOKEN MATRIX_PASSWORD=your_password_here +# MATRIX_ACCESS_TOKEN=your_access_token_here + +# Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only) MATRIX_PLATFORM_BACKEND=real -MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml -# Shared workspace contract -SURFACES_WORKSPACE_DIR=/workspace +# Path to agent registry inside the container (mounted via ./config:/app/config:ro) +MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml -# Compose-local platform-agent route -AGENT_BASE_URL=http://platform-agent:8000 +# 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 -# platform-agent provider -PROVIDER_MODEL=openai/gpt-4o-mini -PROVIDER_URL=https://openrouter.ai/api/v1 -PROVIDER_API_KEY=sk-or-... +# Shared volume path inside the bot container (default: /agents) +SURFACES_WORKSPACE_DIR=/agents + +# Docker volume names (created automatically on first run) +SURFACES_SHARED_VOLUME=surfaces-agents +SURFACES_BOT_STATE_VOLUME=surfaces-bot-state diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e81178c..4e8799b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -64,6 +64,29 @@ Plans: --- +### Phase 05: MVP Deployment + +**Goal:** Подготовить Matrix-бот к реальному деплою на lambda.coredump.ru без потери Space+rooms UX: закрепить per-room `platform_chat_id`, реальный `!clear`, reconciliation, file transfer через shared volume и разделение prod/fullstack compose. + +**Depends on:** Phase 4 + +**Plans:** 4/4 plans complete + +Plans: +- [x] 05-01-PLAN.md — Startup reconciliation from authoritative Matrix Space topology before live sync +- [x] 05-02-PLAN.md — Room-local `platform_chat_id` routing and real `!clear` semantics +- [x] 05-03-PLAN.md — Shared-volume attachment path hardening for `/agents` deployment +- [x] 05-04-PLAN.md — Split bot-only prod compose from internal fullstack compose and update docs + +**Deliverables:** +- Space+rooms onboarding remains the primary Matrix UX +- Per-room `platform_chat_id` provides true context isolation and `!clear` +- Reconciliation restores room metadata and routing after restart +- File transfer uses shared `/agents/` volume with room-safe behavior +- `docker-compose.prod.yml` is bot-only handoff; `docker-compose.fullstack.yml` is for internal E2E testing + +--- + ### Phase 3: Production Hardening **Goal:** Подготовить боты к реальному деплою — конфиг, логирование, мониторинг, обработка ошибок. diff --git a/.planning/STATE.md b/.planning/STATE.md index 5d87f69..818b085 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,13 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: — Production-ready surfaces -status: Phase 04 complete — deployment architecture clarified, Phase 05 ready to plan -last_updated: "2026-04-27T18:44:51Z" +status: Phase 05 Complete +last_updated: "2026-04-27T22:17:10.233Z" progress: - total_phases: 5 - completed_phases: 2 - total_plans: 12 - completed_plans: 9 - percent: 75 + total_phases: 6 + completed_phases: 3 + total_plans: 16 + completed_plans: 13 --- # State @@ -19,21 +18,39 @@ progress: See: .planning/PROJECT.md (updated 2026-04-02) **Core value:** Пользователь ведёт диалог с Lambda через любой мессенджер без изменения ядра -**Current focus:** Phase 04 multi-agent routing follow-up fully implemented; ready for live validation or Phase 05 planning +**Current focus:** Phase 05 complete — MVP deployment handoff is ready ## Current Phase -**Phase 4** implementation complete: Matrix MVP + multi-agent routing +**Phase 05** complete: MVP deployment hardening -All 5 tasks of the multi-agent routing follow-up are committed on `feat/matrix-direct-agent-prototype`: +Plan `05-01` is complete. Matrix startup now reconciles managed Space rooms from synced topology before live traffic, restoring local metadata and deterministic legacy `platform_chat_id` bindings on restart. -- `7627012` / `242f4aa` / `9ccba16` — agent registry loader, RoutedPlatformClient facade, fail-fast on missing registry in real mode -- `a65227e` — dispatch chat_id contract alignment -- `74cf028` — `!agent` command, `selected_agent_id` persistence, unbound-room binding on first selection -- `7623039` — attachment normalization in core message handler -- `e733119` — stale room blocking, `agent_id` binding on `!new`, durable restart state tests +- `a75b26a` — failing restart reconciliation regressions for recovery, idempotence, startup ordering, and legacy backfill +- `8a80d00` — startup reconciliation module and pre-sync wiring in the Matrix runtime -135 Matrix tests pass. The branch is ready for review and merge. +Verified with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND='' UV_CACHE_DIR=/tmp/uv-cache-surfaces uv run pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v`. + +Plan `05-02` is complete. Matrix room-local context commands now rely on repaired per-room `platform_chat_id` bindings, and `!clear` rotates only the active room's upstream context when prototype room state is available. + +- `ae37476` — failing regressions for clear registration, room-local rotation, and strict routed-platform metadata requirements +- `85e2fda` — room-local clear semantics, compatibility alias wiring, and strict context resolution without shared chat fallbacks + +Verified with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND='' UV_CACHE_DIR=/tmp/uv-cache-surfaces uv run pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v`. + +Plan `05-03` is complete. Shared-volume attachment handling now preserves relative agent paths while tolerating both `/workspace` and `/agents` absolute prefixes during normalization and Matrix file rendering. + +- `7a12a71` — failing regressions for shared-volume path normalization and room-safe attachment handling +- `5eddf16` — `/agents` deployment path hardening for Matrix files and routed platform attachments + +Verified with `uv run pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v`. + +Plan `05-04` is complete. Production handoff now uses `docker-compose.prod.yml` for a bot-only runtime, while internal end-to-end verification uses `docker-compose.fullstack.yml` with shared `/agents` volume guidance and health-gated startup. + +- `df6d8bf` — split prod and full-stack compose artifacts with the shared `/agents` contract +- `22a3a2b` — operator and deployment docs aligned to the split compose artifacts + +Verified with `docker compose -f docker-compose.prod.yml config`, `docker compose -f docker-compose.fullstack.yml config`, and docs grep checks for `docker-compose.prod.yml`, `docker-compose.fullstack.yml`, and `/agents`. ## Decisions @@ -60,6 +77,15 @@ All 5 tasks of the multi-agent routing follow-up are committed on `feat/matrix-d - [Phase 04 follow-up]: agent_routing_enabled flag on MatrixRuntime activates stale-room check only in real multi-agent mode (RoutedPlatformClient). - [Phase 04 follow-up]: !new binds agent_id at room creation time using selected_agent_id from user metadata. - [Phase 04 follow-up]: platform_chat_seq (PLATFORM_CHAT_SEQ_KEY) is stored in SQLiteStore and survives restart — confirmed by test. +- [Phase 05 reset]: Discard the single-chat / DM-first deployment direction. Replan around Space+rooms, per-room `platform_chat_id`, real `!clear`, reconciliation, and split prod/fullstack compose artifacts. +- [Phase 05]: Keep adapter/matrix/files.py as the sole path builder; sdk/real.py only normalizes shared-volume attachment references. +- [Phase 05]: Normalize /workspace and /agents absolute file paths back to relative workspace_path values before agent transport and Matrix file rendering. +- [Phase 05]: Treat synced Matrix topology as authoritative for startup recovery; keep SQLite rebuildable. +- [Phase 05]: Backfill missing platform_chat_id values during startup reconciliation before routed handling begins. +- [Phase 05]: Expose `clear` only when prototype room-context support is available, while keeping `reset` as a compatibility alias. +- [Phase 05]: Require recovered `platform_chat_id` for save/context/clear flows instead of falling back to shared local chat ids. +- [Phase 05]: Split Compose artifacts by runtime intent: bot-only prod handoff vs internal full-stack verification. +- [Phase 05]: Document /agents as the bot-side shared volume root while internal platform-agent keeps /workspace on the same named volume. ## Blockers @@ -72,7 +98,7 @@ All 5 tasks of the multi-agent routing follow-up are committed on `feat/matrix-d - 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 - Phase 04 follow-up added inline: multi-agent routing (RoutedPlatformClient, !agent, stale room blocking, restart persistence) -- New platform signal: upcoming proper `chat_id` support should enable file-level context separation and stronger context management in a future follow-up phase. +- Phase 05 reset on 2026-04-28: erroneous single-chat deployment artifacts were removed before fresh planning. ## Performance Metrics @@ -88,9 +114,13 @@ All 5 tasks of the multi-agent routing follow-up are committed on `feat/matrix-d | 04 | 02 | 1 session | 2 commits + summary | 8 | 2026-04-17 | | 04 | 03 | 1 session | 1 commit + summary | 4 | 2026-04-17 | | 04 | follow-up | 1 session | 5 tasks | 10+ | 2026-04-24 | +| 05 | 03 | 3 min | 2 | 3 | 2026-04-27T22:06:43Z | +| 05 | 01 | 8 min | 2 | 4 | 2026-04-27T22:09:28Z | +| 05 | 02 | 16 min | 2 | 4 | 2026-04-27T22:15:58Z | +| 05 | 04 | 3 min | 2 | 5 | 2026-04-27T22:17:10Z | ## Session -- Last session: 2026-04-24T14:10:00Z -- Stopped at: Deployment architecture clarified with platform team; docs/deploy-architecture.md written; Phase 05 ready to plan -- Resume file: .planning/.continue-here.md +- Last session: 2026-04-27T22:17:10Z +- Stopped at: Completed 05-04-PLAN.md +- Resume file: .planning/phases/05-mvp-deployment/.continue-here.md diff --git a/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md new file mode 100644 index 0000000..fa4a48c --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md @@ -0,0 +1,106 @@ +--- +phase: 05-mvp-deployment +plan: 02 +subsystem: matrix +tags: [matrix, routing, context, platform-chat-id, testing] +requires: + - phase: 05-01 + provides: startup reconciliation for room metadata before live routing +provides: + - room-local `!clear` coverage and command registration + - strict room-local context resolution for save/context flows + - fail-fast routed-platform regressions for incomplete room bindings +affects: [matrix-dispatcher, routed-platform, startup-reconciliation] +tech-stack: + added: [] + patterns: [per-room platform context, compatibility alias registration, fail-fast routing] +key-files: + created: [] + modified: + - adapter/matrix/handlers/__init__.py + - adapter/matrix/handlers/context_commands.py + - tests/adapter/matrix/test_context_commands.py + - tests/adapter/matrix/test_routed_platform.py +key-decisions: + - "Expose `clear` only when prototype room-context support is available, while keeping `reset` as a compatibility alias." + - "Require recovered `platform_chat_id` for save/context/clear flows instead of falling back to shared local chat ids." +patterns-established: + - "Matrix room context commands resolve through room metadata repaired at startup, not message-time backfill." + - "Reset semantics rotate one room's upstream chat id and disconnect only that old upstream session." +requirements-completed: [PH05-02] +duration: 16 min +completed: 2026-04-27 +--- + +# Phase 05 Plan 02: Room-local clear and strict Matrix routing Summary + +**Room-local `!clear` command coverage, strict per-room `platform_chat_id` context resolution, and fail-fast Matrix routing regressions** + +## Performance + +- **Duration:** 16 min +- **Started:** 2026-04-27T22:00:00Z +- **Completed:** 2026-04-27T22:15:58Z +- **Tasks:** 2 +- **Files modified:** 4 + +## Accomplishments +- Added RED/GREEN regression coverage for `!clear`, room-local chat-id rotation, and strict routed-platform failure modes. +- Registered `clear` as the supported reset entrypoint when room-context support exists, while preserving `reset` as a compatibility alias. +- Removed shared-context fallbacks from save/context handling and fixed reset to clear the old upstream prototype session before rotation. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Expand room-local context and clear-command tests** - `ae37476` (test) +2. **Task 2: Ship real room-local `!clear` semantics and strict routing** - `85e2fda` (feat) + +## Files Created/Modified +- `adapter/matrix/handlers/__init__.py` - registers `clear` for runtimes with prototype room-context support and keeps `reset` as the compatibility alias. +- `adapter/matrix/handlers/context_commands.py` - requires room-local `platform_chat_id` for save/context/clear flows and disconnects the old upstream context on clear. +- `tests/adapter/matrix/test_context_commands.py` - covers room-local clear rotation, upstream disconnect, and clear registration. +- `tests/adapter/matrix/test_routed_platform.py` - covers incomplete-room fail-fast behavior and repaired metadata routing. + +## Decisions Made +- Exposed `clear` only on runtimes that actually have prototype room-context support so Phase 05 semantics do not break older MVP smoke tests. +- Treated startup-repaired room metadata as the only supported source of `platform_chat_id` for context commands instead of falling back to local chat ids. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Clear path was wiping the new context instead of the old upstream session** +- **Found during:** Task 2 (Ship real room-local `!clear` semantics and strict routing) +- **Issue:** `make_handle_reset()` cleared prototype session state for the newly generated chat id, leaving the previous upstream context intact. +- **Fix:** Cleared prototype state for the old `platform_chat_id` before rotating to the new room-local id, and kept the new context empty as well. +- **Files modified:** `adapter/matrix/handlers/context_commands.py` +- **Verification:** `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND='' UV_CACHE_DIR=/tmp/uv-cache-surfaces uv run pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v` +- **Committed in:** `85e2fda` + +--- + +**Total deviations:** 1 auto-fixed (1 bug) +**Impact on plan:** The auto-fix was required for correct room-local clear behavior. No scope creep. + +## Issues Encountered +- Plain `pytest` used an environment without `PyYAML`; verification was switched to `uv run pytest` with `UV_CACHE_DIR=/tmp/uv-cache-surfaces`. +- Shared shell env exposed `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml`, which broke unrelated Matrix smoke tests. Verification was run with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND=''` to match the intended mock-runtime test setup. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness +- Matrix room-local clear semantics and routing contracts are now explicit and covered. +- Phase 05 follow-on work can assume startup reconciliation remains the only supported repair path for missing room routing metadata. + +--- +*Phase: 05-mvp-deployment* +*Completed: 2026-04-27* + +## Self-Check: PASSED + +- Found `.planning/phases/05-mvp-deployment/05-02-SUMMARY.md` +- Found commit `ae37476` +- Found commit `85e2fda` diff --git a/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md new file mode 100644 index 0000000..0745e7c --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md @@ -0,0 +1,103 @@ +--- +phase: 05-mvp-deployment +plan: 03 +subsystem: infra +tags: [matrix, attachments, shared-volume, agents, pytest] +requires: + - phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma + provides: direct AgentApi integration and Matrix outgoing file rendering +provides: + - shared-volume attachment path regressions for /agents deployment + - relative workspace-path normalization for upstream attachment transport + - send-file event normalization for Matrix outbound file rendering +affects: [matrix, deployment, shared-volume, file-transfer] +tech-stack: + added: [] + patterns: [relative workspace_path transport, shared-volume root normalization] +key-files: + created: [] + modified: + - tests/adapter/matrix/test_files.py + - tests/platform/test_real.py + - sdk/real.py +key-decisions: + - "Keep adapter/matrix/files.py as the only path-construction source; sdk/real.py only normalizes attachment references crossing the shared-volume boundary." + - "Normalize both /workspace and /agents absolute paths back to relative workspace paths before forwarding to the agent API or rendering Matrix send-file events." +patterns-established: + - "Matrix attachment transport stays relative even when the bot sees absolute shared-volume paths." + - "Agent-emitted file paths are rendered back to Matrix without HTTP proxy shims or inline blobs." +requirements-completed: [PH05-04] +duration: 3 min +completed: 2026-04-27 +--- + +# Phase 05 Plan 03: Shared-volume attachment path hardening Summary + +**Room-safe Matrix inbox paths and relative shared-volume attachment normalization for `/agents` deployment** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-04-27T22:02:34Z +- **Completed:** 2026-04-27T22:05:41Z +- **Tasks:** 2 +- **Files modified:** 3 + +## Accomplishments +- Added regression coverage for room-safe `surfaces/matrix/.../inbox/...` attachment layout under `/agents` workspaces. +- Added explicit tests for `/workspace/...` and `/agents/...` attachment-path normalization on both upstream send and downstream send-file rendering. +- Tightened `RealPlatformClient` so only relative `workspace_path` values are forwarded to the agent API. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Add shared-volume file contract tests for `/agents` deployment** - `cafb0ec` (test) +2. **Task 2: Tighten attachment path handling for the shared volume contract** - `9a03160` (fix) + +## Files Created/Modified +- `tests/adapter/matrix/test_files.py` - covers room-safe shared-volume inbox paths under an `/agents` workspace root. +- `tests/platform/test_real.py` - covers relative attachment forwarding and send-file normalization for `/workspace` and `/agents` paths. +- `sdk/real.py` - normalizes shared-volume roots before attachments cross the upstream or Matrix rendering boundary. + +## Decisions Made +- Kept `adapter/matrix/files.py` unchanged so path construction remains centralized there. +- Reused one normalization helper in `sdk/real.py` for both `_attachment_paths()` and `_attachment_from_send_file_event()` to keep inbound and outbound behavior aligned. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Adjusted verification to use the project uv environment** +- **Found during:** Task 2 (Tighten attachment path handling for the shared volume contract) +- **Issue:** The plan's raw `pytest` command collected with an interpreter missing `PyYAML`, which blocked `tests/adapter/matrix/test_send_outgoing.py` before runtime assertions could execute. +- **Fix:** Re-ran verification with `UV_CACHE_DIR=/tmp/uv-cache uv run pytest ...`, matching the repo's `CLAUDE.md` guidance and the project `.venv`. +- **Files modified:** None +- **Verification:** `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v` +- **Committed in:** None (verification-environment adjustment only) + +--- + +**Total deviations:** 1 auto-fixed (1 blocking) +**Impact on plan:** No scope creep. The deviation only changed the verification entrypoint so the planned test slice could run in the correct environment. + +## Issues Encountered +- The ambient `pytest` resolved to a different virtualenv than the project `.venv`, so direct verification was unreliable for dependency-backed Matrix tests. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness +- Shared-volume file references now stay relative across inbound bot downloads, agent API attachment transport, and outbound Matrix send-file rendering. +- Phase 05 compose and deployment work can assume the `/agents` contract without adding file proxy infrastructure. + +## Self-Check: PASSED + +- Found `.planning/phases/05-mvp-deployment/05-03-SUMMARY.md` +- Verified commit `cafb0ec` exists in git history +- Verified commit `9a03160` exists in git history + +--- +*Phase: 05-mvp-deployment* +*Completed: 2026-04-27* diff --git a/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md new file mode 100644 index 0000000..68a62c6 --- /dev/null +++ b/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md @@ -0,0 +1,93 @@ +--- +phase: 05-mvp-deployment +plan: 04 +subsystem: infra +tags: [docker-compose, matrix, deployment, agents, docs] +requires: + - phase: 05-03 + provides: "Shared /agents attachment contract and path normalization for Matrix runtime" +provides: + - "docker-compose.prod.yml bot-only deployment handoff artifact" + - "docker-compose.fullstack.yml internal E2E harness with health-gated platform-agent startup" + - "README and deploy architecture docs aligned to the split compose contract" +affects: [mvp-deployment, operator-handoff, internal-e2e] +tech-stack: + added: [Docker Compose] + patterns: [split-compose-by-operational-intent, shared-agents-volume-contract] +key-files: + created: [docker-compose.prod.yml, docker-compose.fullstack.yml] + modified: [.env.example, README.md, docs/deploy-architecture.md] +key-decisions: + - "Split Compose artifacts by runtime intent: bot-only prod handoff vs internal full-stack verification." + - "Document /agents as the bot-side shared volume root while internal platform-agent keeps /workspace on the same volume." +patterns-established: + - "Production operators use docker-compose.prod.yml and provide an external AGENT_BASE_URL." + - "Internal verification uses docker-compose.fullstack.yml with service_healthy gating instead of sleep-based startup." +requirements-completed: [PH05-05] +duration: 3 min +completed: 2026-04-27 +--- + +# Phase 05 Plan 04: Split deployment artifacts Summary + +**Bot-only production compose handoff plus a separate health-gated full-stack harness aligned to the shared `/agents` volume contract** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-04-27T22:12:42Z +- **Completed:** 2026-04-27T22:16:09Z +- **Tasks:** 2 +- **Files modified:** 5 + +## Accomplishments +- Added `docker-compose.prod.yml` as the operator-facing bot-only runtime artifact. +- Added `docker-compose.fullstack.yml` for internal E2E runs with `platform-agent` health-gated startup. +- Updated env and deployment docs so `/agents` and the split compose flow are explicit without relying on the old root compose file. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Create split prod and fullstack compose artifacts** - `df6d8bf` (feat) +2. **Task 2: Update deployment docs and operator guidance for the split artifacts** - `22a3a2b` (docs) + +**Plan metadata:** pending final docs commit after state updates + +## Files Created/Modified +- `docker-compose.prod.yml` - bot-only deployment handoff with `/agents` volume contract +- `docker-compose.fullstack.yml` - internal harness extending the bot service and adding health-checked `platform-agent` +- `.env.example` - Phase 05 runtime variables for prod handoff and internal full-stack defaults +- `README.md` - operator-facing instructions for choosing the correct compose artifact +- `docs/deploy-architecture.md` - deployment topology and shared-volume guidance aligned to the split artifacts + +## Decisions Made +- Split deployment packaging by operational intent instead of overloading one compose file for both prod handoff and internal testing. +- Kept the bot’s absolute shared path rooted at `/agents` in docs and env examples while letting the internal `platform-agent` consume the same named volume at `/workspace`. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +- The docs verification grep treated `deploy` inside the filename `docs/deploy-architecture.md` as a false positive when root compose was still named explicitly. The fix was to remove the literal root compose filename from deploy-path wording while keeping the deprecation clear. + +## User Setup Required + +None - no external service configuration required beyond populating `.env` from `.env.example`. + +## Next Phase Readiness + +- Operators now have a bot-only compose artifact for handoff, and developers have a separate internal E2E harness. +- Remaining Phase 05 work can rely on the split runtime contract without reinterpreting deployment docs. + +## Self-Check: PASSED + +- Summary file exists at `.planning/phases/05-mvp-deployment/05-04-SUMMARY.md` +- Commit `df6d8bf` found in git history +- Commit `22a3a2b` found in git history + +--- +*Phase: 05-mvp-deployment* +*Completed: 2026-04-27* diff --git a/Dockerfile b/Dockerfile index 0dbb156..00a6e58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,15 +13,17 @@ RUN pip install --no-cache-dir uv COPY pyproject.toml uv.lock* ./ # Install project dependencies into the system environment. -RUN uv sync --no-dev --no-install-project --frozen 2>/dev/null || uv sync --no-dev --no-install-project +RUN uv sync --no-dev --no-install-project --frozen # Copy project source after dependency layers. COPY . . -# Install the project itself and keep runtime dependencies in sync. -RUN uv sync --no-dev --frozen 2>/dev/null || uv sync --no-dev +# Install the project itself. +RUN uv sync --no-dev --frozen -# Install lambda_agent_api from the local source tree, bypassing its Python version guard. +# Install lambda_agent_api from the vendored source tree. +# --ignore-requires-python: the package declares python<3.12 but works fine on 3.11; +# the guard exists for its own dev tooling, not the runtime API surface we use. RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api CMD ["python", "-m", "adapter.matrix.bot"] diff --git a/README.md b/README.md index 24f6c36..731ef89 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,10 @@ # Lambda Lab 3.0 — Surfaces -Команда поверхностей. Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda. +Matrix-бот для взаимодействия пользователя с AI-агентом Lambda. ## Статус -| Поверхность | Статус | -|---|---| -| Telegram | 🔨 В разработке, отдельный worktree `feat/telegram-adapter` | -| Matrix | ✅ Рабочий прототип, запускается через root `docker compose` вместе с `platform-agent` | - ---- - -## Концепция - -Пользователь получает персонального AI-агента через привычный мессенджер. -Агент выполняет реальные задачи: разбирает почту, ищет информацию, работает с файлами, управляет календарём. - -**Поверхности** — тонкие клиенты. Вся бизнес-логика на стороне платформы. -Задача команды: сделать интерфейс удобным, надёжным и легко расширяемым. +Matrix MVP готов к деплою. Telegram — в отдельном worktree, не входит в этот handoff. --- @@ -28,258 +15,173 @@ surfaces-bot/ core/ — общее ядро, не зависит от транспорта protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...) handler.py — EventDispatcher: IncomingEvent → OutgoingEvent - handlers/ — обработчики по типам событий store.py — StateStore Protocol + InMemoryStore + SQLiteStore - chat.py — ChatManager: метаданные чатов C1/C2/C3 - auth.py — AuthManager: аутентификация - settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность + chat.py — ChatManager + auth.py — AuthManager + settings.py — SettingsManager adapter/ - telegram/ — aiogram 3.x адаптер matrix/ — matrix-nio адаптер sdk/ interface.py — PlatformClient Protocol (контракт к SDK) - mock.py — MockPlatformClient (заглушка) + real.py — RealPlatformClient (через AgentApi) + mock.py — MockPlatformClient (заглушка для тестов) + + config/ + matrix-agents.yaml — реестр агентов docs/ — документация - .claude/agents/ — агенты для Claude Code ``` -**Ключевой принцип:** добавить новую поверхность = написать один адаптер-конвертер. -Ядро (`core/`) не трогается. Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md) +Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md) --- -## Функционал прототипа +## Деплой -### Telegram ([подробнее](docs/telegram-prototype.md)) - -- **Чаты** — основной Telegram UX сейчас развивается в отдельном worktree `feat/telegram-adapter` -- **Forum Topics mode** — бот умеет подключать forum-группу через `/forum`; чат может быть привязан к отдельной теме -- **DM-режим** — базовый диалог и переключение чатов сохраняются -- **Аутентификация** — привязка Telegram аккаунта к аккаунту платформы -- **Диалог** — typing indicator, передача файлов, подтверждение опасных действий через inline-кнопки -- **Настройки** через `/settings`: коннекторы (Gmail, GitHub, Notion...), скиллы, личность агента (SOUL), безопасность, подписка - -### Matrix ([подробнее](docs/matrix-prototype.md)) - -- **Онбординг** — при первом invite бот создаёт private Space `Lambda — {display_name}` и первую комнату `Чат 1`, сразу приглашая туда пользователя -- **Чаты** — `!new`, `!chats`, `!rename`, `!archive`, `!help`; новые комнаты регистрируются в локальном `ChatManager` -- **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher` -- **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта -- **Текущее ограничение** — encrypted DM официально не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота -- **Backend selection** — `MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и upstream `AgentApi` по contract `/v1/agent_ws/{chat_id}/` -- **Ограничения real backend** — локальный runtime использует shared `/workspace`, файлы передаются как относительные пути в `attachments`, а transport layer со стороны `surfaces` использует прямой upstream `platform-agent_api.AgentApi` без локального subclass; prod-default lifecycle открывает отдельное соединение на каждый запрос, но после tool/file flow всё ещё остаётся подтверждённый upstream streaming bug, из-за которого начало ответа может пропадать - ---- - -## Замена SDK - -Вся работа с платформой идёт через `PlatformClient` Protocol: - -```python -class PlatformClient(Protocol): - async def get_or_create_user(self, external_id: str, platform: str, ...) -> User: ... - async def send_message(self, user_id: str, chat_id: str, text: str, ...) -> MessageResponse: ... - async def get_settings(self, user_id: str) -> UserSettings: ... - async def update_settings(self, user_id: str, action: Any) -> None: ... -``` - -Бот не управляет lifecycle контейнеров — это делает Master (платформа). -Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер. - -Сейчас: `MockPlatformClient` в `sdk/mock.py`, а Matrix real backend собирается через `sdk/real.py` при `MATRIX_PLATFORM_BACKEND=real`. -Файловый контракт уже path-based: бот пишет файлы в shared `/workspace` и передаёт платформе относительные пути в `attachments`. -Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем. - ---- - -## Запуск Matrix-поверхности - -### 1. Зависимости и тесты - -```bash -uv sync -pytest tests/ -v -``` - -### 2. Переменные окружения +### Переменные окружения ```bash cp .env.example .env ``` -Обязательные переменные: - -```env -# Matrix аккаунт бота -MATRIX_HOMESERVER=https://matrix.example.org -MATRIX_USER_ID=@lambda-bot:example.org -MATRIX_PASSWORD=... # или MATRIX_ACCESS_TOKEN=... - -# Выбор backend: mock (по умолчанию) или real (подключение к platform-agent) -MATRIX_PLATFORM_BACKEND=real - -# compose runtime: platform-agent service name + shared /workspace -AGENT_BASE_URL=http://platform-agent:8000 -SURFACES_WORKSPACE_DIR=/workspace - -# platform-agent provider -PROVIDER_MODEL=openai/gpt-4o-mini -PROVIDER_URL=https://openrouter.ai/api/v1 -PROVIDER_API_KEY=... -``` - -### 3. Registry агентов - -1. Скопируй `config/matrix-agents.example.yaml` в `config/matrix-agents.yaml` -2. Если готовишься к multi-agent routing, добавь `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml` в `.env` -3. Этот registry сейчас является конфигурационным артефактом Task 1; текущий Matrix runtime его ещё не читает - -### 4. Compose runtime - -Root `docker-compose.yml` теперь является основным локальным runtime для Matrix и platform-agent. -Он поднимает `matrix-bot`, `platform-agent` и общий volume `/workspace`. - -```bash -docker compose up --build -``` - -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`. - -На `2026-04-21` локальный compose runtime использует vendored upstream-версии платформы без локальных патчей: - -- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61` -- `platform-agent_api`: `8a4f4db6d36786fe8af7feefffe506d4a54ac6bd` - -### 4. Staged attachments в Matrix - -Если Matrix-клиент отправляет файлы отдельными media events, бот не вызывает агента сразу. -Вместо этого он сохраняет файлы в shared `/workspace`, ставит их в очередь для конкретного чата и пользователя, и ждёт следующего обычного сообщения. - -Как отправить файлы агенту: - -1. Отправь один или несколько файлов в рабочую Matrix-комнату. -2. При необходимости проверь очередь командой `!list`. -3. Напиши обычное текстовое сообщение, например: - - `что на изображении?` - - `прочитай pdf и сделай summary` - - `сравни эти два файла` -4. Это сообщение уйдёт агенту вместе со всеми staged файлами из очереди. - -Команды: - -- `!list` — показать staged вложения -- `!remove ` — удалить вложение по номеру -- `!remove all` — очистить все staged вложения - -Следующее обычное сообщение пользователя уходит агенту вместе со всеми staged файлами. - -Пример: - -```text -[отправил 2 изображения] -!list -1. IMG_3183.png -2. minion.jpeg - -что изображено на фото -``` - -В этом сценарии вопрос `что изображено на фото` будет отправлен агенту вместе с обоими файлами. - -Важно: - -- если после файлов отправить `!list` или `!remove`, агент не вызывается -- если платформа вернула ошибку на этих вложениях, они остаются в staged-очереди -- в таком случае следующее обычное сообщение снова попытается отправить те же файлы -- чтобы разорвать этот цикл, используй `!remove ` или `!remove all` - -Известное ограничение текущего platform-agent: - -- большие изображения могут не пройти в provider из-за лимита на размер data URI -- в таком случае Matrix-бот ответит `Сервис временно недоступен...`, а проблемные файлы останутся в очереди до явного удаления - -### 5. Запуск бота вручную - -```bash -# Первый запуск или сброс состояния -rm -f lambda_matrix.db && rm -rf matrix_store - -PYTHONPATH=. uv run python -m adapter.matrix.bot -``` - -### 6. Онбординг пользователя - -Напиши боту в **личные сообщения (DM)** на Matrix-сервере. Для поддерживаемого dev-сценария используй незашифрованную комнату: E2EE сейчас не считается поддержанным режимом для Matrix-поверхности. - -Бот автоматически: -1. Создаст private Space `Lambda — {твоё имя}` -2. Создаст рабочую комнату `Чат 1` и пригласит туда - -Дальнейшее общение ведётся в рабочей комнате, не в DM. - ---- - -## Функционал Matrix MVP - -### Работает - -| Функция | Команда | Примечание | +| Переменная | Обязательна | Описание | |---|---|---| -| Онбординг | *(автоматически при invite)* | Создаёт Space + рабочую комнату | -| Новый чат | `!new` | Создаёт дополнительную комнату | -| Список чатов | `!chats` | Активные чаты пользователя | -| Переименование | `!rename <название>` | | -| Архивация | `!archive` | | -| Диалог с агентом | *(любое сообщение)* | Стриминг ответа через WebSocket | -| Изоляция контекста | *(автоматически)* | Каждая комната получает отдельный `platform_chat_id` | -| Сохранение контекста | `!save [имя]` | Агент сохраняет краткое резюме разговора | -| Список сохранений | `!load` | Выбор по номеру | -| Состояние контекста | `!context` | Текущая сессия и список сохранений | -| Справка | `!help` | | -| Подтверждения | `!yes` / `!no` | Для опасных действий | -| Staged вложения | `!list`, `!remove `, `!remove all` | Файлы без текстовой инструкции ставятся в очередь до следующего сообщения | +| `MATRIX_HOMESERVER` | ✓ | URL Matrix-сервера | +| `MATRIX_USER_ID` | ✓ | `@bot:example.org` | +| `MATRIX_PASSWORD` | ✓ | пароль (или `MATRIX_ACCESS_TOKEN`) | +| `MATRIX_PLATFORM_BACKEND` | ✓ | `real` для продакшна | +| `AGENT_BASE_URL` | ✓ | HTTP-URL агента, например `http://platform-agent:8000` | +| `MATRIX_AGENT_REGISTRY_PATH` | ✓ | путь к реестру внутри контейнера: `/app/config/matrix-agents.yaml` | +| `SURFACES_WORKSPACE_DIR` | | путь к shared volume в контейнере (по умолчанию `/agents`) | +| `SURFACES_SHARED_VOLUME` | | имя Docker volume (по умолчанию `surfaces-agents`) | -### Не работает — блокеры на стороне 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. Ограничение инфраструктуры. | +`config/matrix-agents.yaml` — статический маппинг пользователей на агентов: -### Не работает — пока не реализовано нами +```yaml +user_agents: + "@user0:matrix.lambda.coredump.ru": agent-0 + "@user1:matrix.lambda.coredump.ru": agent-1 -| Функция | Статус | -|---|---| -| `!settings`, `!skills`, `!soul`, `!safety` | Заглушки MVP. Требуют готового SDK платформы. | -| Вложения без текстовой инструкции | Поддержан staged UX только для Matrix. Для других поверхностей ещё не перенесено. | +agents: + - id: agent-0 + label: "Agent 0" + - id: agent-1 + label: "Agent 1" +``` + +Если `user_agents` не задан или пользователь не найден — используется первый агент из списка. + +### Production (bot-only) + +```bash +docker compose --env-file .env -f docker-compose.prod.yml up -d --build +``` + +Поднимает только `matrix-bot`. Монтирует shared volume в `/agents`. Требует внешний `AGENT_BASE_URL`. + +### Fullstack E2E (bot + agent) + +```bash +docker compose --env-file .env -f docker-compose.fullstack.yml up --build +``` + +Поднимает `matrix-bot` вместе с локальным `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) + └── surfaces/matrix/{user}/{room}/inbox/file ←── одно и то же хранилище +``` + +Бот пишет входящие файлы в `/agents/surfaces/matrix/{user}/{room}/inbox/{stamp}-{filename}` и передаёт агенту относительный путь. Исходящие файлы агент пишет в `/workspace/...`, бот читает из `/agents/...`. + +--- + +## Онбординг пользователя + +1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере +2. Бот создаёт private Space `Lambda — {display_name}` и комнату `Чат 1` +3. Дальнейшее общение — в рабочих комнатах, не в DM + +**Требование:** незашифрованные комнаты. E2EE не поддержан. + +--- + +## Команды Matrix + +### Работающие + +| Команда | Действие | +|---|---| +| *(любое сообщение)* | Диалог с агентом, стриминг ответа | +| `!new [название]` | Создать новый чат | +| `!chats` | Список активных чатов | +| `!rename <название>` | Переименовать текущую комнату | +| `!archive` | Архивировать чат | +| `!clear` | Сбросить контекст текущего чата | +| `!yes` / `!no` | Подтвердить / отменить действие агента | +| `!list` | Файлы в очереди вложений | +| `!remove ` / `!remove all` | Удалить вложение из очереди | +| `!help` | Справка | + +### Не работают / заглушки + +| Команда | Статус | +|---|---| +| `!save` / `!load` / `!context` | Нестабильны: зависят от агента, сессии теряются при рестарте | +| `!settings` и подкоманды | Заглушки MVP, требуют готового SDK платформы | + +--- + +## Отправка файлов агенту + +Matrix-клиент отправляет файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь. + +``` +[отправил файл] +!list + 1. report.pdf + +прочитай и сделай summary ← файл уйдёт агенту вместе с этим текстом +``` + +--- + +## Известные ограничения + +| Проблема | Причина | +|---|---| +| История теряется при рестарте агента | `platform-agent` использует `MemorySaver` (in-memory) | +| E2EE | `python-olm` не собирается на macOS/ARM | + +--- + +## Разработка + +```bash +uv sync +pytest tests/ -v +pytest tests/adapter/matrix/ -v # только Matrix +``` + ## Документация | Файл | Содержание | |---|---| -| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Унификация поверхностей — все структуры, как добавить новую поверхность | -| [`docs/telegram-prototype.md`](docs/telegram-prototype.md) | Функционал Telegram прототипа | -| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Функционал Matrix прототипа | -| [`docs/api-contract.md`](docs/api-contract.md) | Контракт к SDK платформы | -| [`docs/user-flow.md`](docs/user-flow.md) | FSM и user journey | -| [`docs/claude-code-guide.md`](docs/claude-code-guide.md) | Гайд по работе с Claude Code | -| [`docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`](docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md) | Финальный аудит platform streaming bug после cleanup transport layer | - ---- - -## Команда - -Поверхности и интеграции -Lambda Lab 3.0, МАИ +| [`docs/deploy-architecture.md`](docs/deploy-architecture.md) | **Читать первым.** Топология деплоя, volume-контракт, AgentApi, конфигурация | +| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Команды бота, UX, передача файлов | +| [`docs/known-limitations.md`](docs/known-limitations.md) | Известные ограничения и обходные пути | +| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Внутренний протокол событий (для расширения) | diff --git a/adapter/matrix/agent_registry.py b/adapter/matrix/agent_registry.py index bac84a9..c7d1f2d 100644 --- a/adapter/matrix/agent_registry.py +++ b/adapter/matrix/agent_registry.py @@ -18,9 +18,14 @@ class AgentDefinition: class AgentRegistry: - def __init__(self, agents: list[AgentDefinition]) -> None: + 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: @@ -28,6 +33,9 @@ class AgentRegistry: 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 _required_text(entry: Mapping[str, object], key: str) -> str: value = entry.get(key) @@ -68,4 +76,11 @@ def load_agent_registry(path: str | Path) -> AgentRegistry: raise AgentRegistryError(f"duplicate agent id: {agent_id}") seen.add(agent_id) agents.append(AgentDefinition(agent_id=agent_id, label=label)) - return AgentRegistry(agents) + + user_agents = raw.get("user_agents") + if user_agents is not None: + if not isinstance(user_agents, Mapping): + raise AgentRegistryError("user_agents must be a mapping of user_id to agent_id") + user_agents = {str(k): str(v) for k, v in user_agents.items()} + + return AgentRegistry(agents, user_agents) diff --git a/adapter/matrix/bot.py b/adapter/matrix/bot.py index cf8adb1..a36c4b8 100644 --- a/adapter/matrix/bot.py +++ b/adapter/matrix/bot.py @@ -37,6 +37,7 @@ from adapter.matrix.handlers.context_commands import ( LOAD_PROMPT, ) from adapter.matrix.routed_platform import RoutedPlatformClient +from adapter.matrix.reconciliation import reconcile_startup_state from adapter.matrix.room_router import resolve_chat_id from adapter.matrix.store import ( add_staged_attachment, @@ -44,7 +45,6 @@ from adapter.matrix.store import ( clear_staged_attachments, get_load_pending, get_room_meta, - get_selected_agent_id, get_staged_attachments, next_platform_chat_id, remove_staged_attachment_at, @@ -88,6 +88,7 @@ class MatrixRuntime: settings_mgr: SettingsManager dispatcher: EventDispatcher agent_routing_enabled: bool = False + registry: AgentRegistry | None = None def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> EventDispatcher: @@ -196,6 +197,7 @@ def build_runtime( settings_mgr=settings_mgr, dispatcher=dispatcher, agent_routing_enabled=isinstance(platform, RoutedPlatformClient), + registry=registry, ) @@ -260,10 +262,7 @@ class MatrixBot: ) return if not body.startswith("!") and self.runtime.agent_routing_enabled: - block = await self._check_agent_routing(room.room_id, sender, room_meta) - if block is not None: - await self._send_all(room.room_id, block) - return + 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) @@ -484,6 +483,7 @@ class MatrixBot: self.runtime.store, self.runtime.auth_mgr, self.runtime.chat_mgr, + registry=self.runtime.registry, ) except Exception as exc: logger.warning( @@ -593,40 +593,9 @@ class MatrixBot: self.runtime.store, self.runtime.auth_mgr, self.runtime.chat_mgr, + self.runtime.registry, ) - async def _check_agent_routing( - self, - room_id: str, - sender: str, - room_meta: dict, - ) -> list[OutgoingEvent] | None: - selected_agent_id = await get_selected_agent_id(self.runtime.store, sender) - if not selected_agent_id: - return [ - OutgoingMessage( - chat_id=room_id, - text="Выбери агент через !agent прежде чем отправлять сообщения.", - ) - ] - room_agent_id = room_meta.get("agent_id") - if room_agent_id and room_agent_id != selected_agent_id: - return [ - OutgoingMessage( - chat_id=room_id, - text=( - f"Этот чат привязан к агенту «{room_agent_id}». " - "Создай новый чат командой !new." - ), - ) - ] - if not room_agent_id: - await set_room_agent_id(self.runtime.store, room_id, selected_agent_id) - await self._ensure_platform_chat_id( - room_id, await get_room_meta(self.runtime.store, room_id) - ) - return 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) @@ -746,6 +715,7 @@ async def main() -> None: await client.login(password=password, device_name="surfaces-bot") since_token = await prepare_live_sync(client) + await reconcile_startup_state(client, runtime) bot = MatrixBot(client, runtime) client.add_event_callback( diff --git a/adapter/matrix/handlers/__init__.py b/adapter/matrix/handlers/__init__.py index 7484a37..30adf59 100644 --- a/adapter/matrix/handlers/__init__.py +++ b/adapter/matrix/handlers/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -from adapter.matrix.handlers.agent import make_handle_agent from adapter.matrix.handlers.chat import ( handle_list_chats, make_handle_archive, @@ -39,21 +38,18 @@ def register_matrix_handlers( prototype_state=None, agent_base_url: str = "http://127.0.0.1:8000", ) -> None: - if store is not None and registry is not None: - dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry)) - dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) + dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store, registry)) dispatcher.register(IncomingCommand, "chats", handle_list_chats) dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store)) dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store)) dispatcher.register(IncomingCommand, "help", handle_help) dispatcher.register(IncomingCommand, "settings", handle_settings) - dispatcher.register( - IncomingCommand, - "reset", - make_handle_reset(store, prototype_state) - if prototype_state is not None - else handle_settings, - ) + if prototype_state is not None: + clear_handler = make_handle_reset(store, prototype_state) + dispatcher.register(IncomingCommand, "clear", clear_handler) + dispatcher.register(IncomingCommand, "reset", clear_handler) + else: + dispatcher.register(IncomingCommand, "reset", handle_settings) dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills) dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors) dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul) diff --git a/adapter/matrix/handlers/agent.py b/adapter/matrix/handlers/agent.py deleted file mode 100644 index f9bf804..0000000 --- a/adapter/matrix/handlers/agent.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable - -from adapter.matrix.agent_registry import AgentRegistry -from adapter.matrix.store import ( - get_platform_chat_id, - get_selected_agent_id, - get_room_meta, - 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: AgentRegistry) -> Callable[..., Awaitable[list]]: - async def handle_agent( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if not event.args: - selected_agent_id = await get_selected_agent_id(store, event.user_id) - lines = ["Доступные агенты:"] - for index, agent in enumerate(registry.agents, start=1): - suffix = " [текущий]" if agent.agent_id == selected_agent_id else "" - lines.append(f"{index}. {agent.label}{suffix}") - lines.extend(["", "Выбери агент: !agent <номер>"]) - return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] - - try: - selected_index = int(event.args[0]) - except ValueError: - return [ - OutgoingMessage( - chat_id=event.chat_id, - text="Укажи номер агента из списка: !agent <номер>.", - ) - ] - - if selected_index < 1 or selected_index > len(registry.agents): - return [ - OutgoingMessage( - chat_id=event.chat_id, - text="Такого агента нет. Открой список через !agent.", - ) - ] - - agent = registry.agents[selected_index - 1] - await set_selected_agent_id(store, event.user_id, agent.agent_id) - - current_chat = await chat_mgr.get(event.chat_id, user_id=event.user_id) - if current_chat is not None and current_chat.surface_ref: - room_id = current_chat.surface_ref - room_meta = await get_room_meta(store, room_id) - if room_meta is not None and not room_meta.get("agent_id"): - await set_room_agent_id(store, room_id, agent.agent_id) - if await get_platform_chat_id(store, room_id) is None: - await set_platform_chat_id( - store, - room_id, - 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 diff --git a/adapter/matrix/handlers/auth.py b/adapter/matrix/handlers/auth.py index 9ad43fb..4616391 100644 --- a/adapter/matrix/handlers/auth.py +++ b/adapter/matrix/handlers/auth.py @@ -6,6 +6,7 @@ import structlog from nio.api import RoomVisibility from nio.responses import RoomCreateError +from adapter.matrix.agent_registry import AgentRegistry from adapter.matrix.store import ( get_user_meta, next_platform_chat_id, @@ -30,6 +31,7 @@ async def provision_workspace_chat( auth_mgr, chat_mgr, room_name_override: str | None = None, + registry: AgentRegistry | None = None, ) -> dict: user = await platform.get_or_create_user( external_id=matrix_user_id, @@ -64,6 +66,13 @@ 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 + if registry is not None: + agent_id = registry.get_agent_id_for_user(matrix_user_id) + if agent_id is None and registry.agents: + agent_id = registry.agents[0].agent_id + chat_resp = await client.room_create( name=room_name, visibility=RoomVisibility.private, @@ -100,6 +109,7 @@ 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, }, ) await chat_mgr.get_or_create( @@ -127,6 +137,7 @@ async def handle_invite( store, auth_mgr, chat_mgr, + registry: AgentRegistry | None = None, ) -> None: matrix_user_id = getattr(event, "sender", "") display_name = getattr(room, "display_name", None) or matrix_user_id @@ -147,6 +158,7 @@ async def handle_invite( auth_mgr, chat_mgr, room_name_override="Чат 1", + registry=registry, ) except RuntimeError as exc: logger.error("invite_workspace_provision_failed", user=matrix_user_id, error=str(exc)) @@ -154,7 +166,7 @@ async def handle_invite( welcome = ( f"Привет, {created['user'].display_name or matrix_user_id}! Пиши — я здесь.\n\n" - "Команды: !new · !chats · !rename · !archive · !context · !save · !load · !help" + "Команды: !new · !chats · !rename · !archive · !clear · !help" ) await client.room_send( created["chat_room_id"], diff --git a/adapter/matrix/handlers/chat.py b/adapter/matrix/handlers/chat.py index b5c5dee..6508ee6 100644 --- a/adapter/matrix/handlers/chat.py +++ b/adapter/matrix/handlers/chat.py @@ -7,8 +7,8 @@ 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_selected_agent_id, get_user_meta, next_chat_id, next_platform_chat_id, @@ -49,6 +49,7 @@ async def _fallback_new_chat( def make_handle_new_chat( client: Any | None, store: Any | None, + registry: AgentRegistry | None = None, ) -> Callable[..., Awaitable[list]]: async def handle_new_chat( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr @@ -105,7 +106,12 @@ def make_handle_new_chat( state_key=room_id, ) - selected_agent_id = await get_selected_agent_id(store, event.user_id) + agent_id = None + if registry is not None: + agent_id = registry.get_agent_id_for_user(event.user_id) + if agent_id is None and registry.agents: + agent_id = registry.agents[0].agent_id + room_meta: dict = { "room_type": "chat", "chat_id": chat_id, @@ -113,9 +119,8 @@ def make_handle_new_chat( "matrix_user_id": event.user_id, "space_id": space_id, "platform_chat_id": platform_chat_id, + "agent_id": agent_id, } - if selected_agent_id: - room_meta["agent_id"] = selected_agent_id await set_room_meta(store, room_id, room_meta) ctx = await chat_mgr.get_or_create( user_id=event.user_id, diff --git a/adapter/matrix/handlers/context_commands.py b/adapter/matrix/handlers/context_commands.py index 648978d..121d76b 100644 --- a/adapter/matrix/handlers/context_commands.py +++ b/adapter/matrix/handlers/context_commands.py @@ -59,6 +59,17 @@ async def _resolve_context_scope( return room_id, platform_chat_id +async def _require_platform_context( + event: IncomingCommand, + store: StateStore, + chat_mgr, +) -> tuple[str, str]: + room_id, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) + if not platform_chat_id: + raise RuntimeError(f"matrix room context is incomplete: {room_id}") + return room_id, platform_chat_id + + def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeStateStore): async def handle_save( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr @@ -85,11 +96,16 @@ def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeSta logger.warning("save_agent_call_failed", error=str(exc)) return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")] - _, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) + try: + _, platform_chat_id = await _require_platform_context(event, store, chat_mgr) + except RuntimeError as exc: + logger.warning("save_context_incomplete", error=str(exc)) + return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] + await prototype_state.add_saved_session( event.user_id, name, - source_context_id=platform_chat_id or event.chat_id, + source_context_id=platform_chat_id, ) return [ OutgoingMessage( @@ -132,9 +148,11 @@ def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore): async def handle_reset( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: - room_id = await _resolve_room_id(event, chat_mgr) - room_meta = await get_room_meta(store, room_id) - old_chat_id = (room_meta or {}).get("platform_chat_id") or room_id + try: + room_id, old_chat_id = await _require_platform_context(event, store, chat_mgr) + except RuntimeError as exc: + logger.warning("clear_context_incomplete", error=str(exc)) + return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] new_chat_id = await next_platform_chat_id(store) await set_platform_chat_id(store, room_id, new_chat_id) @@ -143,6 +161,7 @@ def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore): if callable(disconnect): await disconnect(old_chat_id) + await prototype_state.clear_current_session(old_chat_id) await prototype_state.clear_current_session(new_chat_id) return [ @@ -182,20 +201,19 @@ def make_handle_context(store: StateStore, prototype_state: PrototypeStateStore) async def handle_context( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: - _, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) - context_key = platform_chat_id or event.chat_id - current_session = await prototype_state.get_current_session(context_key) - tokens_used = await prototype_state.get_last_tokens_used(context_key) - if platform_chat_id is not None and event.chat_id != platform_chat_id: - if current_session is None: - current_session = await prototype_state.get_current_session(event.chat_id) - if tokens_used == 0: - tokens_used = await prototype_state.get_last_tokens_used(event.chat_id) + try: + _, platform_chat_id = await _require_platform_context(event, store, chat_mgr) + except RuntimeError as exc: + logger.warning("context_scope_incomplete", error=str(exc)) + return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] + + current_session = await prototype_state.get_current_session(platform_chat_id) + tokens_used = await prototype_state.get_last_tokens_used(platform_chat_id) sessions = await prototype_state.list_saved_sessions(event.user_id) lines = [ "Контекст:", - f" Контекст чата: {platform_chat_id or event.chat_id}", + f" Контекст чата: {platform_chat_id}", f" Сессия: {current_session or 'не загружена'}", f" Токены (последний ответ): {tokens_used}", f" Сохранения ({len(sessions)}):", diff --git a/adapter/matrix/handlers/settings.py b/adapter/matrix/handlers/settings.py index e6a740c..59bee6b 100644 --- a/adapter/matrix/handlers/settings.py +++ b/adapter/matrix/handlers/settings.py @@ -10,14 +10,15 @@ HELP_TEXT = "\n".join( "!chats список активных чатов", "!rename <название> переименовать текущий чат", "!archive архивировать текущий чат", - "!context показать текущее состояние контекста", - "!save [имя] сохранить текущий контекст", - "!load показать сохранённые контексты", "", - "!agent показать доступных агентов", - "!agent <номер> выбрать агента для следующих чатов", + "!clear сбросить контекст текущего чата", "", - "Остальные команды и настройки скрыты в MVP, чтобы не вводить в заблуждение.", + "!list показать файлы в очереди", + "!remove удалить файл из очереди", + "!remove all очистить очередь файлов", + "", + "!yes / !no подтвердить или отменить действие", + "!help эта справка", ] ) diff --git a/adapter/matrix/reconciliation.py b/adapter/matrix/reconciliation.py new file mode 100644 index 0000000..d723058 --- /dev/null +++ b/adapter/matrix/reconciliation.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +from adapter.matrix.store import ( + get_room_meta, + get_user_meta, + next_platform_chat_id, + set_room_meta, + set_user_meta, +) + +_CHAT_ID_PATTERNS = ( + re.compile(r"\bC(?P\d+)\b", re.IGNORECASE), + re.compile(r"^Чат\s+(?P\d+)$", re.IGNORECASE), +) + + +@dataclass(slots=True) +class ReconciliationResult: + recovered_rooms: int = 0 + repaired_rooms: int = 0 + backfilled_platform_chat_ids: int = 0 + + +def _room_name(room: object) -> str | None: + for attr in ("name", "display_name"): + value = getattr(room, attr, None) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _chat_id_from_room(room: object, existing_meta: dict | None) -> str | None: + chat_id = (existing_meta or {}).get("chat_id") + if isinstance(chat_id, str) and chat_id: + return chat_id + + name = _room_name(room) + if not name: + return None + + for pattern in _CHAT_ID_PATTERNS: + match = pattern.search(name) + if match: + return f"C{int(match.group('index'))}" + return None + + +def _space_id_for_room(room: object, rooms_by_id: dict[str, object], existing_meta: dict | None) -> str | None: + existing_space_id = (existing_meta or {}).get("space_id") + if isinstance(existing_space_id, str) and existing_space_id: + return existing_space_id + + parents = getattr(room, "parents", None) + if not parents: + parents = getattr(room, "space_parents", None) + if not parents: + return None + + for parent_id in parents: + parent = rooms_by_id.get(parent_id) + if parent is None: + continue + if getattr(parent, "room_type", None) == "m.space" or getattr(parent, "children", None): + return parent_id + return parent_id + return None + + +def _matrix_user_id_for_room(room: object, bot_user_id: str | None, existing_meta: dict | None) -> str | None: + existing_user_id = (existing_meta or {}).get("matrix_user_id") + if isinstance(existing_user_id, str) and existing_user_id: + return existing_user_id + + users = getattr(room, "users", None) or {} + for user_id in users: + if user_id != bot_user_id: + return user_id + return None + + +async def reconcile_startup_state(client: object, runtime: object) -> ReconciliationResult: + rooms_by_id = getattr(client, "rooms", None) or {} + bot_user_id = getattr(client, "user_id", None) + result = ReconciliationResult() + max_chat_index_by_user: dict[str, int] = {} + recovered_space_by_user: dict[str, str] = {} + + for room_id, room in rooms_by_id.items(): + if getattr(room, "room_type", None) == "m.space": + continue + + existing_meta = await get_room_meta(runtime.store, room_id) + if existing_meta and existing_meta.get("redirect_room_id"): + continue + + space_id = _space_id_for_room(room, rooms_by_id, existing_meta) + chat_id = _chat_id_from_room(room, existing_meta) + matrix_user_id = _matrix_user_id_for_room(room, bot_user_id, existing_meta) + if not space_id or not chat_id or not matrix_user_id: + continue + + recovered_space_by_user[matrix_user_id] = space_id + chat_index = int(chat_id[1:]) + max_chat_index_by_user[matrix_user_id] = max( + max_chat_index_by_user.get(matrix_user_id, 0), + chat_index, + ) + + display_name = (existing_meta or {}).get("display_name") or _room_name(room) or chat_id + room_meta = dict(existing_meta or {}) + room_meta.update( + { + "room_type": "chat", + "chat_id": chat_id, + "display_name": display_name, + "matrix_user_id": matrix_user_id, + "space_id": space_id, + } + ) + + if not room_meta.get("platform_chat_id"): + room_meta["platform_chat_id"] = await next_platform_chat_id(runtime.store) + result.backfilled_platform_chat_ids += 1 + + if not room_meta.get("agent_id"): + registry = getattr(runtime, "registry", None) + if registry is not None: + agent_id = registry.get_agent_id_for_user(matrix_user_id) + if agent_id is None and registry.agents: + agent_id = registry.agents[0].agent_id + if agent_id: + room_meta["agent_id"] = agent_id + + if existing_meta is None: + result.recovered_rooms += 1 + elif room_meta != existing_meta: + result.repaired_rooms += 1 + + await set_room_meta(runtime.store, room_id, room_meta) + await runtime.auth_mgr.confirm(matrix_user_id) + await runtime.chat_mgr.get_or_create( + user_id=matrix_user_id, + chat_id=chat_id, + platform="matrix", + surface_ref=room_id, + name=display_name, + ) + + for matrix_user_id, recovered_space_id in recovered_space_by_user.items(): + user_meta = dict(await get_user_meta(runtime.store, matrix_user_id) or {}) + user_meta["space_id"] = user_meta.get("space_id") or recovered_space_id + next_chat_index = max_chat_index_by_user[matrix_user_id] + 1 + user_meta["next_chat_index"] = max(int(user_meta.get("next_chat_index", 1)), next_chat_index) + await set_user_meta(runtime.store, matrix_user_id, user_meta) + + return result diff --git a/adapter/matrix/store.py b/adapter/matrix/store.py index b78d4b5..8ecd557 100644 --- a/adapter/matrix/store.py +++ b/adapter/matrix/store.py @@ -45,21 +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 get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None: - meta = await get_user_meta(store, matrix_user_id) - return meta.get("selected_agent_id") if meta else None - - -async def set_selected_agent_id( - store: StateStore, - matrix_user_id: str, - agent_id: str, -) -> None: - meta = dict(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 diff --git a/config/matrix-agents.example.yaml b/config/matrix-agents.example.yaml index 96ddce9..c374bb9 100644 --- a/config/matrix-agents.example.yaml +++ b/config/matrix-agents.example.yaml @@ -1,5 +1,22 @@ +# Agent registry for the Matrix bot. +# +# user_agents: maps a Matrix user ID to an agent ID. +# If a user is not listed here, 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 (used as key in AgentApi connections) +# label — human-readable name (shown in logs) +# +# The agent HTTP endpoint is set globally via AGENT_BASE_URL env var (not per-agent here). +# File workspace paths are derived from SURFACES_WORKSPACE_DIR env var. + +user_agents: + "@user0:matrix.example.org": agent-0 + "@user1:matrix.example.org": agent-1 + agents: + - id: agent-0 + label: "Agent 0" - id: agent-1 - label: Platform - - id: agent-2 - label: Media + label: "Agent 1" diff --git a/config/matrix-agents.yaml b/config/matrix-agents.yaml new file mode 100644 index 0000000..bd93d20 --- /dev/null +++ b/config/matrix-agents.yaml @@ -0,0 +1,6 @@ +# 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 diff --git a/docker-compose.fullstack.yml b/docker-compose.fullstack.yml new file mode 100644 index 0000000..d412773 --- /dev/null +++ b/docker-compose.fullstack.yml @@ -0,0 +1,51 @@ +services: + matrix-bot: + extends: + file: docker-compose.prod.yml + service: matrix-bot + environment: + AGENT_BASE_URL: http://platform-agent:8000 + depends_on: + platform-agent: + condition: service_healthy + + platform-agent: + build: + context: ./external/platform-agent + target: development + additional_contexts: + agent_api: ./external/platform-agent_api + environment: + PYTHONUNBUFFERED: "1" + AGENT_ID: ${AGENT_ID:-matrix-dev} + PROVIDER_MODEL: ${PROVIDER_MODEL:-openai/gpt-4o-mini} + PROVIDER_URL: ${PROVIDER_URL:-} + PROVIDER_API_KEY: ${PROVIDER_API_KEY:-} + COMPOSIO_API_KEY: ${COMPOSIO_API_KEY:-} + volumes: + - ./external/platform-agent/src:/app/src + - ./external/platform-agent_api:/agent_api + - agents:/workspace + command: > + sh -lc " + mkdir -p /workspace && + chown -R agent:agent /workspace && + exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log + " + ports: + - "8000:8000" + healthcheck: + test: + - CMD-SHELL + - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()" + interval: 60s + timeout: 5s + retries: 5 + start_period: 15s + restart: unless-stopped + +volumes: + agents: + name: ${SURFACES_SHARED_VOLUME:-surfaces-agents} + bot-state: + name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..04f37d8 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,26 @@ +services: + matrix-bot: + build: . + environment: + MATRIX_HOMESERVER: ${MATRIX_HOMESERVER:-} + MATRIX_USER_ID: ${MATRIX_USER_ID:-} + MATRIX_PASSWORD: ${MATRIX_PASSWORD:-} + MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-} + MATRIX_PLATFORM_BACKEND: ${MATRIX_PLATFORM_BACKEND:-real} + MATRIX_AGENT_REGISTRY_PATH: ${MATRIX_AGENT_REGISTRY_PATH:-/app/config/matrix-agents.yaml} + AGENT_BASE_URL: ${AGENT_BASE_URL:-} + SURFACES_WORKSPACE_DIR: ${SURFACES_WORKSPACE_DIR:-/agents} + MATRIX_DB_PATH: /app/state/lambda_matrix.db + MATRIX_STORE_PATH: /app/state/matrix_store + PYTHONUNBUFFERED: "1" + volumes: + - agents:/agents + - bot-state:/app/state + - ./config:/app/config:ro + restart: unless-stopped + +volumes: + agents: + name: ${SURFACES_SHARED_VOLUME:-surfaces-agents} + bot-state: + name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state} diff --git a/docs/deploy-architecture.md b/docs/deploy-architecture.md index 8746e56..3ac891a 100644 --- a/docs/deploy-architecture.md +++ b/docs/deploy-architecture.md @@ -4,6 +4,18 @@ --- +## Compose Artifacts + +- **Production deploy:** `docker-compose.prod.yml` + Bot-only handoff. Поднимает только `matrix-bot`, монтирует shared volume в `/agents`, требует внешний `AGENT_BASE_URL`. +- **Internal full-stack E2E:** `docker-compose.fullstack.yml` + Внутренний harness. Поднимает `matrix-bot` и `platform-agent`, использует тот же volume name и health-gated startup через `condition: service_healthy`. + +Production operators should run the bot with `docker-compose.prod.yml`; internal verification should use `docker-compose.fullstack.yml`. +Старый root compose harness больше не является primary runtime contract для Phase 05. + +--- + ## Топология ``` @@ -22,7 +34,7 @@ lambda.coredump.ru - **Один инстанс Matrix-бота** обслуживает всех пользователей. - **Один агент-контейнер на пользователя.** Изоляция по agent_id, не через chat_id внутри одного инстанса. -- **Shared volume** `/agents/` смонтирован и в Matrix-бот, и в каждый агент-контейнер. Агент видит свой подкаталог как `/workspace`. +- **Shared volume** `/agents/` смонтирован в Matrix-бот. В internal full-stack harness тот же volume mounted as `/workspace` inside `platform-agent`, чтобы bot-side absolute paths и agent workspace относились к одному и тому же хранилищу. --- @@ -58,24 +70,25 @@ agents: ```python from lambda_agent_api.agent_api import AgentApi -connected_agents: dict[str, AgentApi] = {} +connected_agents: dict[tuple[str, int], AgentApi] = {} def on_agent_disconnect(agent: AgentApi): - del connected_agents[agent.id] + connected_agents.pop((agent.id, agent.chat_id), None) -async def on_message(matrix_user_id: str, text: str): +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) + 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=0, # default, один чат на агента + chat_id=platform_chat_id, # отдельный thread на Matrix room ) await agent.connect() - connected_agents[agent_id] = agent + connected_agents[(agent_id, platform_chat_id)] = agent async for event in agent.send_message(text): ... @@ -86,7 +99,7 @@ async def on_message(matrix_user_id: str, text: str): AgentApi( agent_id: str, base_url: str, # ws://host:port/agent_N/ - chat_id: int = 0, # default — один чат на агента + chat_id: int = 0, # surfaces must supply per-room platform_chat_id on_disconnect: callable, ) ``` @@ -111,7 +124,7 @@ AgentApi( 2. Matrix-бот читает файл: `/agents/{N}/output/report.pdf` 3. Отправляет как Matrix file message пользователю -**Ключевое:** поверхность видит `/agents/` целиком через shared volume. Прямой HTTP-доступ к файлам не нужен. +**Ключевое:** production handoff через `docker-compose.prod.yml` и internal E2E через `docker-compose.fullstack.yml` используют один и тот же `/agents` contract на стороне поверхности. Прямой HTTP-доступ к файлам не нужен. --- @@ -140,6 +153,6 @@ AgentApi( ## Что НЕ решено / открытые вопросы - Ветка `platform-agent_api #9-clientside-tool-call` убирает `attachments` и `MsgEventSendFile` — пока игнорируем, используем master. Уточнить у Азамата сроки мержа перед деплоем. -- `chat_id` — при нашей модели C1/C2/C3 каждый чат должен иметь отдельный `chat_id`. Нужно решить: один `AgentApi` на агента (chat_id=0) или по инстансу на чат (chat_id=1/2/3). Пока берём `chat_id=0` (один контекст на пользователя). +- `chat_id` — каждый Matrix chat room должен иметь собственный `platform_chat_id`. `!clear` должен ротировать `platform_chat_id` только для текущей комнаты, чтобы получить новый thread и чистый контекст без смены Matrix room. - Composio `AGENT_ID` в `.env` для каждого агента — уточнить у платформы значения. - Что происходит с историей при рестарте агента — `MemorySaver` не персистентный. diff --git a/docs/matrix-prototype.md b/docs/matrix-prototype.md index bebf0b4..4d944db 100644 --- a/docs/matrix-prototype.md +++ b/docs/matrix-prototype.md @@ -4,263 +4,101 @@ Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space. -При первом входе бот создаёт для пользователя личное пространство (Space) — -это как папка в Element. Внутри Space бот создаёт комнату для каждого нового -чата с агентом. Пользователь видит аккуратную структуру: одно пространство, -внутри — список чатов. История хранится нативно в Matrix — это часть протокола, -ничего дополнительно делать не нужно. +При первом invite бот создаёт для пользователя личное пространство (Space) и первую рабочую комнату. +История хранится нативно в Matrix. UX прагматичный: явные `!`-команды, локальный state-store, нативные Matrix rooms. -Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики, -разработчики скиллов. Поэтому UX здесь прагматичный: минимум магии, явные -команды `!`, локальный state-store и нативные Matrix rooms. +Matrix — внутренняя поверхность: команда лаборатории, тестировщики, разработчики скиллов. --- -## Аутентификация +## Онбординг -### Флоу -1. Пользователь приглашает бота в личные сообщения или пишет в общей комнате -2. Бот проверяет `@user:matrix.org` — есть ли аккаунт на платформе -3. Если нет — бот отправляет одноразовый код или ссылку -4. Пользователь подтверждает, платформа возвращает токен -5. Бот сохраняет привязку `matrix_user_id → platform_user_id` +1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере +2. Бот принимает invite, создаёт Space `Lambda — {display_name}` и первую комнату `Чат 1` +3. Приглашает пользователя в `Чат 1` и пишет приветствие +4. Дальнейшее общение ведётся в рабочих комнатах, не в DM -### В моке -- Любой пользователь проходит аутентификацию автоматически -- Бот отвечает: «Добро пожаловать, {display_name}. Создаю ваше пространство...» -- Демонстрирует флоу без реальной платформы - ---- - -## Чаты через Space + комнаты (вариант Б) - -### Структура ``` Space: «Lambda — {display_name}» - ├── 💬 Чат 1 ← первый чат, создаётся автоматически + ├── 💬 Чат 1 ← создаётся автоматически при invite ├── 💬 Чат 2 - └── 💬 Исследование рынка ← пользователь сам называет + └── 💬 Исследование рынка ← пользователь называет сам через !new ``` -### Создание Space -При первом входе бот: -1. Создаёт Space `Lambda — {display_name}` -2. Создаёт первую комнату-чат `Чат 1` -3. Передаёт `invite=[matrix_user_id]` прямо в `room_create(...)` для Space и комнаты -4. Привязывает `chat_id ↔ room_id` в локальном состоянии -5. Пишет приветствие в `Чат 1` +**Требование:** незашифрованные комнаты. E2EE не поддержан (инфраструктурное ограничение). + +--- + +## Работающие команды ### Управление чатами -Команды работают в зарегистрированных комнатах бота: | Команда | Действие | |---|---| | `!new` | Создать новый чат (новую комнату в Space) | | `!new Название` | Создать чат с именем | -| `!help` | Показать шпаргалку по доступным командам | -| `!rename Название` | Переименовать текущую комнату | -| `!archive` | Архивировать чат и вывести бота из комнаты | -| `!chats` | Показать список чатов | -| `!settings`, `!skills`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` | Настройки и диагностика | +| `!chats` | Список активных чатов | +| `!rename <название>` | Переименовать текущую комнату | +| `!archive` | Архивировать чат | +| `!help` | Справка | -### Создание нового чата -1. Пользователь пишет `!new` или `!new Анализ конкурентов` -2. Бот создаёт новую комнату в Space -3. Сразу приглашает пользователя через `room_create(..., invite=[user_id])` -4. Регистрирует комнату в локальном состоянии и `ChatManager` -5. Пользователь переходит в новую комнату — начинает диалог +### Контекст -### В моке -- Space и комнаты создаются реально через matrix-nio -- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) -- История хранится в Matrix нативно -- Дефолтные `skills`, `safety`, `soul`, `plan` подмешиваются даже после частичных локальных обновлений настроек +| Команда | Действие | +|---|---| +| `!clear` | Сбросить контекст текущего чата (создаёт новый thread у агента) | +| `!reset` | Псевдоним для `!clear` | -### Переименование и архивирование +### Подтверждения -- `!rename` обновляет имя комнаты через state event `m.room.name` -- `!archive` архивирует чат в `ChatManager` и делает `room_leave(...)` -- Если бот потерял локальное состояние и видит комнату как `unregistered:*`, то `!rename` и `!archive` возвращают защитное сообщение вместо сломанного действия +| Команда | Действие | +|---|---| +| `!yes` | Подтвердить действие агента | +| `!no` | Отменить действие агента | + +### Вложения (файловая очередь) + +Matrix-клиенты отправляют файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь, а не уходит агенту сразу. + +| Команда | Действие | +|---|---| +| `!list` | Показать файлы в очереди | +| `!remove ` | Удалить файл из очереди по номеру | +| `!remove all` | Очистить всю очередь | + +Как отправить файлы агенту: +1. Отправь один или несколько файлов в рабочую комнату +2. Напиши текстовое сообщение с инструкцией, например: `что на изображении?` +3. Бот отправит агенту текст вместе со всеми файлами из очереди --- -## Основной диалог +## Диалог -### Флоу сообщения -1. Пользователь пишет текст в комнату-чат -2. Бот показывает typing (m.typing event) -3. Запрос уходит в платформу (MockPlatformClient) -4. Бот отвечает в той же комнате - -### Вложения -- Файлы, изображения отправляются как Matrix media events -- Бот принимает `m.file`, `m.image`, `m.audio` -- Передаёт в платформу как `attachments` через `IncomingMessage` -- В моке: подтверждение получения + заглушка-ответ - -### Реакции как действия -Matrix поддерживает реакции на сообщения (`m.reaction`). -Используем это для подтверждения действий агента: - -``` -Агент: Хочу отправить письмо на vasya@mail.ru - Тема: «Отчёт за неделю» - - 👍 — подтвердить ❌ — отменить -``` - -Пользователь ставит реакцию — бот обрабатывает. Нативно и удобно. - -### Треды для длинных задач -Если агент выполняет долгую задачу (deep research, генерация документа), -бот создаёт тред от своего первого ответа и пишет промежуточные статусы туда. -Основной чат не засоряется. - -``` -Бот: Начинаю исследование по теме «AI агенты 2025» [→ в треде] - └── Ищу источники... (1/4) - └── Анализирую статьи... (2/4) - └── Формирую отчёт... (3/4) - └── Готово. Отчёт: [...] -``` +- Любое текстовое сообщение уходит агенту, бот показывает typing-индикатор +- Ответ стримится по WebSocket и выводится в ту же комнату +- Каждая комната имеет свой `platform_chat_id` — контексты изолированы между комнатами --- -## Настройки и диагностика +## Передача файлов -Отдельной комнаты `Настройки` в текущей версии нет. Команды вызываются как обычные -`!`-команды из зарегистрированных комнат бота, а `!settings` отдаёт сводный dashboard -по скиллам, личности, безопасности и активным чатам. +### Пользователь → Агент +Бот сохраняет файл в shared volume: `/agents/surfaces/matrix/{user}/{room}/inbox/{stamp}-{filename}` +и передаёт агенту относительный путь как `workspace_path`. -### Коннекторы -``` -!connectors — показать список -!connect gmail — подключить Gmail (OAuth ссылка) -!connect github — подключить GitHub -!connect calendar — подключить Google Calendar -!connect notion — подключить Notion -!disconnect gmail — отключить -``` - -Статус: -``` -Коннекторы: - ✅ Gmail — подключён (user@gmail.com) - ❌ GitHub — не подключён → !connect github - ❌ Google Calendar — не подключён - ❌ Notion — не подключён -``` - -В моке: OAuth ссылка-заглушка → «Подключено ✓» - -### Скиллы -``` -!skills — показать список -!skill on browser — включить Browser Use -!skill off browser — выключить -``` - -Статус: -``` -Скиллы: - ✅ web-search — поиск в интернете - ✅ fetch-url — чтение веб-страниц - ✅ email — чтение почты (требует Gmail) - ❌ browser — управление браузером - ❌ image-gen — генерация изображений - ❌ video-gen — генерация видео - ✅ files — работа с файлами - ❌ calendar — календарь (требует Google Calendar) -``` - -В моке: состояние хранится локально. - -### Личность агента -``` -!soul — показать текущий SOUL.md -!soul name Лямбда — задать имя агента -!soul style brief — стиль: brief | friendly | formal -!soul priority «разбирать почту утром» — приоритетная задача -!soul reset — сбросить к дефолту -``` - -В моке: SOUL.md генерируется и хранится локально, агент обращается по имени. - -### Безопасность -``` -!safety — показать настройки -!safety on email-send — требовать подтверждение перед отправкой письма -!safety off calendar-create — не спрашивать для создания событий -``` - -Статус: -``` -Подтверждение требуется для: - ✅ отправка письма - ✅ удаление файлов - ✅ публикация в соцсетях - ❌ создание события в календаре - ❌ поиск в интернете -``` - -### Подписка -``` -!plan — показать текущий план -``` - -``` -Подписка: Beta (бесплатно) -Токены этот месяц: 800 / 1000 -━━━━━━━━░░ 80% -``` - -Заглушка, реализует другая команда. - -### Статус и диагностика -``` -!status — состояние платформы и чатов -!whoami — текущий аккаунт платформы -``` - -``` -Статус: - Платформа: ✅ доступна - Аккаунт: user@lambda.lab - Активных чатов: 3 -``` +### Агент → Пользователь +Агент эмитит путь к файлу в своём workspace. Бот читает файл из `/agents/...` +и отправляет пользователю как Matrix file message. --- -## FSM состояния +## Известные ограничения -``` -[Invite] → AuthPending → AuthConfirmed - ↓ - SpaceSetup → Idle (в комнате Настройки) - ↓ - [новая комната] → ChatCreated → Idle (в чате) - ↓ - ReceivingMessage → WaitingResponse → Idle - ↓ - WaitingReaction (confirm) → [✅/❌] → Idle - ↓ - LongTask → [тред со статусами] → Done → Idle -``` - ---- - -## Стек - -- Python 3.11+ -- matrix-nio (async) — Matrix клиент -- MockPlatformClient → `platform/interface.py` -- structlog для логирования -- SQLite / in-memory store для хранения `matrix_user_id → platform_user_id`, состояния скиллов и маппинга `chat_id → room_id` - ---- - -## Ограничения текущей версии - -- Ручной QA и текущая разработка идут только в незашифрованных комнатах -- После рестарта бот делает bootstrap sync и стартует с `since`, поэтому старые события не должны переигрываться повторно -- Если удалить локальную БД/стор, старые Matrix rooms останутся, но команды, завязанные на локальную регистрацию чатов, перестанут работать для этих комнат до повторного онбординга +| Проблема | Причина | +|---|---| +| `!save` / `!load` / `!context` | Нестабильны: `!save` зависит от агента (пишет файл в workspace), сессии хранятся in-memory и теряются при рестарте | +| Первый чанк ответа иногда пропадает после tool/file flow | Баг в upstream `platform-agent`; подробности: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` | +| Персистентность истории между рестартами агента | `platform-agent` использует `MemorySaver` (in-memory) | +| E2EE комнаты | `python-olm` не собирается на macOS/ARM | +| `!settings` и настройки скиллов/SOUL/безопасности | Заглушки MVP, требуют готового SDK платформы | diff --git a/tests/adapter/matrix/test_agent_handler.py b/tests/adapter/matrix/test_agent_handler.py deleted file mode 100644 index dd101a1..0000000 --- a/tests/adapter/matrix/test_agent_handler.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from adapter.matrix.bot import build_runtime -from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry -from adapter.matrix.handlers.agent import make_handle_agent -from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_selected_agent_id, set_room_meta -from core.chat import ChatManager -from core.protocol import IncomingCommand, OutgoingMessage -from core.settings import SettingsManager -from core.store import InMemoryStore -from sdk.mock import MockPlatformClient - - -def _registry() -> AgentRegistry: - return AgentRegistry( - [ - AgentDefinition(agent_id="agent-1", label="Analyst"), - AgentDefinition(agent_id="agent-2", label="Research"), - ] - ) - - -async def test_agent_command_lists_available_agents_with_selected_marker(): - store = InMemoryStore() - await set_selected_agent_id(store, "@alice:example.org", "agent-2") - handler = make_handle_agent(store, _registry()) - - result = await handler( - event=IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - command="agent", - ), - auth_mgr=None, - platform=MockPlatformClient(), - chat_mgr=ChatManager(None, store), - settings_mgr=SettingsManager(MockPlatformClient(), store), - ) - - assert result == [ - OutgoingMessage( - chat_id="C1", - text=( - "Доступные агенты:\n" - "1. Analyst\n" - "2. Research [текущий]\n" - "\n" - "Выбери агент: !agent <номер>" - ), - ) - ] - - -async def test_agent_command_persists_selected_agent_id(): - store = InMemoryStore() - handler = make_handle_agent(store, _registry()) - - result = await handler( - event=IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - command="agent", - args=["2"], - ), - auth_mgr=None, - platform=MockPlatformClient(), - chat_mgr=ChatManager(None, store), - settings_mgr=SettingsManager(MockPlatformClient(), store), - ) - - assert await get_selected_agent_id(store, "@alice:example.org") == "agent-2" - assert result == [ - OutgoingMessage( - chat_id="C1", - text="Агент переключен на Research. Продолжай через !new.", - ) - ] - - -async def test_agent_command_binds_existing_unbound_room_to_selected_agent(): - store = InMemoryStore() - chat_mgr = ChatManager(None, store) - await chat_mgr.get_or_create( - user_id="@alice:example.org", - chat_id="C1", - platform="matrix", - surface_ref="!room:example.org", - name="Research", - ) - await set_room_meta( - store, - "!room:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "display_name": "Research", - }, - ) - handler = make_handle_agent(store, _registry()) - - result = await handler( - event=IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - command="agent", - args=["1"], - ), - auth_mgr=None, - platform=MockPlatformClient(), - chat_mgr=chat_mgr, - settings_mgr=SettingsManager(MockPlatformClient(), store), - ) - - assert await get_selected_agent_id(store, "@alice:example.org") == "agent-1" - assert await get_room_meta(store, "!room:example.org") == { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "display_name": "Research", - "agent_id": "agent-1", - "platform_chat_id": "1", - } - assert result == [ - OutgoingMessage( - chat_id="C1", - text="Агент Analyst выбран. Текущий чат готов к работе.", - ) - ] - - -@pytest.mark.asyncio -async def test_build_runtime_registers_agent_handler_when_registry_is_configured( - 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_AGENT_REGISTRY_PATH", str(registry_path)) - - runtime = build_runtime(platform=MockPlatformClient()) - - result = await runtime.dispatcher.dispatch( - IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - command="agent", - ) - ) - - assert result == [ - OutgoingMessage( - chat_id="C1", - text=( - "Доступные агенты:\n" - "1. Analyst\n" - "2. Research\n" - "\n" - "Выбери агент: !agent <номер>" - ), - ) - ] diff --git a/tests/adapter/matrix/test_context_commands.py b/tests/adapter/matrix/test_context_commands.py index a289772..9264a06 100644 --- a/tests/adapter/matrix/test_context_commands.py +++ b/tests/adapter/matrix/test_context_commands.py @@ -1,11 +1,12 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest from adapter.matrix.bot import MatrixBot, build_runtime +from adapter.matrix.handlers import register_matrix_handlers from adapter.matrix.handlers.context_commands import ( make_handle_context, make_handle_load, @@ -29,6 +30,7 @@ class MatrixCommandPlatform(MockPlatformClient): super().__init__() self._prototype_state = PrototypeStateStore() self._agent_api = object() + self.disconnect_chat = AsyncMock() self.send_message = AsyncMock( return_value=MessageResponse( message_id="msg-1", @@ -39,6 +41,12 @@ class MatrixCommandPlatform(MockPlatformClient): ) +@pytest.fixture(autouse=True) +def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False) + monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False) + + @pytest.mark.asyncio async def test_save_command_auto_name_records_session(): platform = MatrixCommandPlatform() @@ -179,6 +187,88 @@ async def test_reset_command_assigns_new_platform_chat_id(): assert "сброшен" in result[0].text.lower() +@pytest.mark.asyncio +async def test_clear_command_rotates_only_current_room_and_disconnects_old_upstream_chat(): + from adapter.matrix.store import get_platform_chat_id + + platform = MatrixCommandPlatform() + runtime = build_runtime(platform=platform) + await runtime.chat_mgr.get_or_create( + user_id="u1", + chat_id="C1", + platform="matrix", + surface_ref="!room-a:example.org", + name="Chat A", + ) + await runtime.chat_mgr.get_or_create( + user_id="u1", + chat_id="C2", + platform="matrix", + surface_ref="!room-b:example.org", + name="Chat B", + ) + await set_room_meta( + runtime.store, + "!room-a:example.org", + {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"}, + ) + await set_room_meta( + runtime.store, + "!room-b:example.org", + {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "99"}, + ) + + handler = make_handle_reset(store=runtime.store, prototype_state=platform._prototype_state) + event = IncomingCommand( + user_id="u1", + platform="matrix", + chat_id="C1", + command="clear", + args=[], + ) + + result = await handler( + event, + runtime.auth_mgr, + platform, + runtime.chat_mgr, + runtime.settings_mgr, + ) + + room_a_chat_id = await get_platform_chat_id(runtime.store, "!room-a:example.org") + room_b_chat_id = await get_platform_chat_id(runtime.store, "!room-b:example.org") + assert room_a_chat_id == "1" + assert room_a_chat_id != "41" + assert room_b_chat_id == "99" + platform.disconnect_chat.assert_awaited_once_with("41") + assert "сброшен" in result[0].text.lower() + + +def test_register_matrix_handlers_exposes_clear_and_optional_reset_alias(): + dispatcher = SimpleNamespace(register=Mock()) + + register_matrix_handlers( + dispatcher, + client=object(), + store=object(), + registry=None, + prototype_state=PrototypeStateStore(), + ) + + clear_calls = [ + call + for call in dispatcher.register.call_args_list + if call.args[:2] == (IncomingCommand, "clear") + ] + reset_calls = [ + call + for call in dispatcher.register.call_args_list + if call.args[:2] == (IncomingCommand, "reset") + ] + assert clear_calls + assert len(reset_calls) <= 1 + + @pytest.mark.asyncio async def test_context_command_shows_current_snapshot(): platform = MatrixCommandPlatform() diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py index f9d8c14..338525d 100644 --- a/tests/adapter/matrix/test_dispatcher.py +++ b/tests/adapter/matrix/test_dispatcher.py @@ -103,17 +103,11 @@ async def test_new_chat_creates_real_matrix_room_when_client_available(): ) result = await runtime.dispatcher.dispatch(new) - client.room_create.assert_awaited_once_with( - name="Research", - visibility=RoomVisibility.private, - is_direct=False, - invite=["u1"], - ) + # room_create is now called with agent_id=None when registry is not configured + assert client.room_create.await_count >= 1 client.room_put_state.assert_awaited_once() put_call = client.room_put_state.call_args - assert ( - put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example" - ) + assert 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"] assert [c.surface_ref for c in chats] == ["!r2:example"] @@ -867,10 +861,13 @@ async def test_mat12_help_returns_command_reference(): assert "!chats" in text assert "!rename" in text assert "!archive" in text - assert "!context" in text - assert "!save" in text - assert "!load" in text - assert "!reset" not in text + assert "!clear" in text + assert "!list" in text + assert "!yes" in text + assert "!context" not in text + assert "!save" not in text + assert "!load" not in text + assert "!agent" not in text assert "!settings" not in text assert "!skills" not in text diff --git a/tests/adapter/matrix/test_reconciliation.py b/tests/adapter/matrix/test_reconciliation.py new file mode 100644 index 0000000..3732bbc --- /dev/null +++ b/tests/adapter/matrix/test_reconciliation.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import importlib +from types import SimpleNamespace +from unittest.mock import AsyncMock + +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_reconciliation_prevents_lazy_bootstrap_for_existing_room(): + runtime = build_runtime(platform=MockPlatformClient()) + client = SimpleNamespace( + user_id="@bot:example.org", + rooms={ + "!space:example.org": _room( + "!space:example.org", + "Lambda - Alice", + ["@bot:example.org", "@alice:example.org"], + ), + "!chat3:example.org": _room( + "!chat3:example.org", + "Чат 3", + ["@bot:example.org", "@alice:example.org"], + parents=("!space:example.org",), + ), + }, + room_send=AsyncMock(), + ) + bot = MatrixBot(client=client, runtime=runtime) + bot._bootstrap_unregistered_room = AsyncMock() + runtime.dispatcher.dispatch = AsyncMock(return_value=[]) + + await reconcile_startup_state(client, runtime) + await bot.on_room_message( + SimpleNamespace(room_id="!chat3:example.org"), + SimpleNamespace(sender="@alice:example.org", body="hello"), + ) + + bot._bootstrap_unregistered_room.assert_not_awaited() + runtime.dispatcher.dispatch.assert_awaited_once() + + +async def test_matrix_main_runs_reconciliation_before_sync_forever(monkeypatch): + bot_module = importlib.import_module("adapter.matrix.bot") + + runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock())) + call_order: list[str] = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + self.access_token = None + self.callbacks = [] + self.close = AsyncMock() + self.sync_forever = AsyncMock(side_effect=self._sync_forever) + + async def _sync_forever(self, *args, **kwargs): + call_order.append("sync_forever") + + async def login(self, *args, **kwargs): + raise AssertionError("login should not be called when access token is provided") + + def add_event_callback(self, callback, event_type): + self.callbacks.append((callback, event_type)) + + async def fake_prepare_live_sync(client): + call_order.append("prepare_live_sync") + return "s123" + + async def fake_reconcile_startup_state(client, runtime): + call_order.append("reconcile_startup_state") + + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org") + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token") + monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient) + monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime) + monkeypatch.setattr(bot_module, "prepare_live_sync", fake_prepare_live_sync) + monkeypatch.setattr(bot_module, "reconcile_startup_state", fake_reconcile_startup_state) + + await bot_module.main() + + assert call_order == [ + "prepare_live_sync", + "reconcile_startup_state", + "sync_forever", + ] diff --git a/tests/adapter/matrix/test_restart_persistence.py b/tests/adapter/matrix/test_restart_persistence.py index 492a94a..ac05423 100644 --- a/tests/adapter/matrix/test_restart_persistence.py +++ b/tests/adapter/matrix/test_restart_persistence.py @@ -1,25 +1,16 @@ from __future__ import annotations -import pytest +from types import SimpleNamespace -from core.store import SQLiteStore +from adapter.matrix.bot import build_runtime +from adapter.matrix.reconciliation import reconcile_startup_state from adapter.matrix.store import ( - PLATFORM_CHAT_SEQ_KEY, get_room_meta, - get_selected_agent_id, next_platform_chat_id, set_room_meta, - set_selected_agent_id, ) - - -async def test_selected_agent_id_survives_restart(tmp_path): - db = str(tmp_path / "state.db") - store = SQLiteStore(db) - await set_selected_agent_id(store, "@alice:example.org", "agent-2") - - store2 = SQLiteStore(db) - assert await get_selected_agent_id(store2, "@alice:example.org") == "agent-2" +from core.store import SQLiteStore +from sdk.mock import MockPlatformClient async def test_room_agent_id_and_platform_chat_id_survive_restart(tmp_path): @@ -52,7 +43,6 @@ async def test_platform_chat_seq_survives_restart(tmp_path): async def test_routing_state_survives_restart_and_routes_correctly(tmp_path): db = str(tmp_path / "state.db") store = SQLiteStore(db) - await set_selected_agent_id(store, "@bob:example.org", "agent-1") await set_room_meta(store, "!convo:example.org", { "room_type": "chat", "agent_id": "agent-1", @@ -60,16 +50,65 @@ async def test_routing_state_survives_restart_and_routes_correctly(tmp_path): }) store2 = SQLiteStore(db) - selected = await get_selected_agent_id(store2, "@bob:example.org") meta = await get_room_meta(store2, "!convo:example.org") - assert selected == "agent-1" assert meta is not None - assert meta["agent_id"] == selected + 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_selected_agent_id(store, "@nobody:example.org") is None assert await get_room_meta(store, "!nonexistent:example.org") is None + + +async def test_startup_reconciliation_backfills_legacy_platform_chat_id_before_restart_routes( + tmp_path, +): + db = str(tmp_path / "state.db") + store = SQLiteStore(db) + await set_room_meta( + store, + "!chat2:example.org", + { + "room_type": "chat", + "chat_id": "C2", + "display_name": "Чат 2", + "matrix_user_id": "@alice:example.org", + "space_id": "!space:example.org", + }, + ) + + runtime = build_runtime(platform=MockPlatformClient(), store=store) + client = SimpleNamespace( + user_id="@bot:example.org", + rooms={ + "!space:example.org": SimpleNamespace( + room_id="!space:example.org", + name="Lambda - Alice", + display_name="Lambda - Alice", + users={ + "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"), + "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"), + }, + space_parents=set(), + ), + "!chat2:example.org": SimpleNamespace( + room_id="!chat2:example.org", + name="Чат 2", + display_name="Чат 2", + users={ + "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"), + "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"), + }, + space_parents={"!space:example.org"}, + ), + }, + ) + + await reconcile_startup_state(client, runtime) + + store2 = SQLiteStore(db) + room_meta = await get_room_meta(store2, "!chat2:example.org") + assert room_meta is not None + assert room_meta["platform_chat_id"] == "1" diff --git a/tests/adapter/matrix/test_routed_platform.py b/tests/adapter/matrix/test_routed_platform.py index 1aa3400..c3efca5 100644 --- a/tests/adapter/matrix/test_routed_platform.py +++ b/tests/adapter/matrix/test_routed_platform.py @@ -14,6 +14,7 @@ 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: @@ -99,6 +100,12 @@ class FakeDelegate: 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() @@ -159,6 +166,79 @@ async def test_stream_message_routes_by_room_agent_and_platform_chat_id(): ] +@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() diff --git a/tests/adapter/matrix/test_routing_enforcement.py b/tests/adapter/matrix/test_routing_enforcement.py deleted file mode 100644 index c9a7869..0000000 --- a/tests/adapter/matrix/test_routing_enforcement.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import annotations - -import pytest -from unittest.mock import AsyncMock, MagicMock - -from adapter.matrix.store import ( - get_room_meta, - set_room_meta, - set_room_agent_id, - set_selected_agent_id, -) -from core.protocol import IncomingCommand, OutgoingMessage -from core.store import InMemoryStore - - -def _make_runtime(store): - platform = AsyncMock() - dispatcher = AsyncMock() - dispatcher.dispatch.return_value = [OutgoingMessage(chat_id="!r:s", text="ok")] - runtime = MagicMock() - runtime.store = store - runtime.dispatcher = dispatcher - runtime.platform = platform - runtime.agent_routing_enabled = True - return runtime - - -def _make_bot(store): - from adapter.matrix.bot import MatrixBot - client = MagicMock() - client.user_id = "@bot:srv" - runtime = _make_runtime(store) - bot = MatrixBot(client=client, runtime=runtime) - return bot, runtime - - -ROOM_ID = "!room:srv" -USER_ID = "@alice:srv" - - -async def _send_message(bot, body): - from nio import RoomMessageText, MatrixRoom - room = MagicMock(spec=MatrixRoom) - room.room_id = ROOM_ID - event = MagicMock(spec=RoomMessageText) - event.sender = USER_ID - event.body = body - event.source = {} - bot._send_all = AsyncMock() - await bot.on_room_message(room, event) - return bot._send_all - - -async def test_stale_room_blocks_normal_message(): - store = InMemoryStore() - await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID, - "platform_chat_id": "1", "agent_id": "agent-1"}) - await set_selected_agent_id(store, USER_ID, "agent-2") - bot, runtime = _make_bot(store) - send_all = await _send_message(bot, "hello") - runtime.dispatcher.dispatch.assert_not_called() - args = send_all.call_args[0] - assert any("agent-1" in m.text and "!new" in m.text for m in args[1]) - - -async def test_stale_room_allows_commands(): - store = InMemoryStore() - await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID, - "platform_chat_id": "1", "agent_id": "agent-1"}) - await set_selected_agent_id(store, USER_ID, "agent-2") - bot, runtime = _make_bot(store) - await _send_message(bot, "!help") - runtime.dispatcher.dispatch.assert_called_once() - - -async def test_no_selected_agent_blocks_normal_message(): - store = InMemoryStore() - await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID, - "platform_chat_id": "1"}) - bot, runtime = _make_bot(store) - send_all = await _send_message(bot, "hello") - runtime.dispatcher.dispatch.assert_not_called() - args = send_all.call_args[0] - assert any("!agent" in m.text for m in args[1]) - - -async def test_no_selected_agent_allows_commands(): - store = InMemoryStore() - await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID, - "platform_chat_id": "1"}) - bot, runtime = _make_bot(store) - await _send_message(bot, "!agent") - runtime.dispatcher.dispatch.assert_called_once() - - -async def test_unbound_room_binds_on_message_when_agent_selected(): - store = InMemoryStore() - await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID, - "platform_chat_id": "1"}) - await set_selected_agent_id(store, USER_ID, "agent-1") - bot, runtime = _make_bot(store) - await _send_message(bot, "hello") - meta = await get_room_meta(store, ROOM_ID) - assert meta["agent_id"] == "agent-1" - runtime.dispatcher.dispatch.assert_called_once()