Compare commits

...

10 commits

Author SHA1 Message Date
b1aaa210a1 feat(deploy): platform handoff — agent routing, persistence, docs cleanup
Agent routing:
- Remove !agent command and manual agent selection flow
- Registry auto-assigns agent from user_agents mapping (fallback: agents[0])
- provision_workspace_chat and !new both write agent_id to room_meta
- Reconciliation backfills agent_id from registry on cold start
- Fix duplicate agent_id block in auth.py

Deployment stability:
- Add bot-state named volume to persist lambda_matrix.db and matrix_store
- Fix docker-compose.prod.yml duplicate environment: key (was silently losing all Matrix credentials)
- Fix MATRIX_AGENT_REGISTRY_PATH to use absolute container path /app/config/...
- Add bot-state volume declaration to docker-compose.fullstack.yml

Docs and config:
- Rewrite README.md for platform handoff (deploy table, working commands only)
- Rewrite docs/matrix-prototype.md (remove stale commands and mock descriptions)
- Remove !save/!load/!context/!agent from help text and welcome message
- Add !clear, !list, !remove, !yes/!no to help text
- Clean up .env.example (remove Telegram token, internal vars, real URLs)
- Update config/matrix-agents.example.yaml with user_agents section and comments
- Add explanatory comment to Dockerfile for --ignore-requires-python
- Remove silent uv sync fallbacks in Dockerfile
2026-04-28 03:05:11 +03:00
380961d6e9 docs(05-04): complete split deployment artifacts plan
- add phase summary for split deployment artifacts
- update state with phase 05 completion context
2026-04-28 01:18:47 +03:00
e73e13e758 docs(05-02): complete room-local clear plan
- add execution summary for room-local clear and strict routing
- update roadmap and state with plan 05-02 completion metadata
2026-04-28 01:17:48 +03:00
22a3a2b60a docs(05-04): document split deployment artifacts
- document prod vs fullstack compose usage
- align operator docs with shared /agents contract
2026-04-28 01:15:41 +03:00
85e2fda6bc feat(05-02): ship room-local clear semantics
- register clear as the room-context reset entrypoint when supported
- keep save and context bound to room platform chat ids and clear old upstream state
2026-04-28 01:15:39 +03:00
df6d8bf628 feat(05-04): split prod and fullstack compose artifacts
- add bot-only production compose contract
- add health-gated internal fullstack harness
2026-04-28 01:14:05 +03:00
ae37476ddf test(05-02): add failing regressions for clear routing
- cover room-local clear rotation and upstream disconnect behavior
- assert strict routed-platform failures on incomplete room bindings
2026-04-28 01:13:54 +03:00
8a80d004fd feat(05-01): reconcile matrix rooms before live sync
- rebuild room and user metadata from synced space topology at startup
- run reconciliation before sync_forever and persist legacy platform_chat_id backfills
2026-04-28 01:08:15 +03:00
6693d72cbd docs(05-03): complete shared-volume attachment hardening plan
- add 05-03 summary with task commits and verification details
- update roadmap and state for completed Phase 05 plan 03
2026-04-28 01:07:35 +03:00
a75b26a1cb test(05-01): add restart reconciliation regression coverage
- add startup reconciliation tests for recovery, idempotence, and startup ordering
- extend restart persistence coverage for legacy platform_chat_id backfill
2026-04-28 01:05:59 +03:00
31 changed files with 1423 additions and 997 deletions

View file

@ -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

View file

@ -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:** Подготовить боты к реальному деплою — конфиг, логирование, мониторинг, обработка ошибок.

View file

@ -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

View file

@ -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`

View file

@ -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*

View file

@ -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 bots 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*

View file

@ -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"]

378
README.md
View file

@ -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 <n>` — удалить вложение по номеру
- `!remove all` — очистить все staged вложения
Следующее обычное сообщение пользователя уходит агенту вместе со всеми staged файлами.
Пример:
```text
[отправил 2 изображения]
!list
1. IMG_3183.png
2. minion.jpeg
что изображено на фото
```
В этом сценарии вопрос `что изображено на фото` будет отправлен агенту вместе с обоими файлами.
Важно:
- если после файлов отправить `!list` или `!remove`, агент не вызывается
- если платформа вернула ошибку на этих вложениях, они остаются в staged-очереди
- в таком случае следующее обычное сообщение снова попытается отправить те же файлы
- чтобы разорвать этот цикл, используй `!remove <n>` или `!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 <n>`, `!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 <n>` / `!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) | Внутренний протокол событий (для расширения) |

View file

@ -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)

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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"],

View file

@ -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,

View file

@ -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)}):",

View file

@ -10,14 +10,15 @@ HELP_TEXT = "\n".join(
"!chats список активных чатов",
"!rename <название> переименовать текущий чат",
"!archive архивировать текущий чат",
"!context показать текущее состояние контекста",
"!save [имя] сохранить текущий контекст",
"!load показать сохранённые контексты",
"",
"!agent показать доступных агентов",
"!agent <номер> выбрать агента для следующих чатов",
"!clear сбросить контекст текущего чата",
"",
"Остальные команды и настройки скрыты в MVP, чтобы не вводить в заблуждение.",
"!list показать файлы в очереди",
"!remove <n> удалить файл из очереди",
"!remove all очистить очередь файлов",
"",
"!yes / !no подтвердить или отменить действие",
"!help эта справка",
]
)

View file

@ -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<index>\d+)\b", re.IGNORECASE),
re.compile(r"^Чат\s+(?P<index>\d+)$", re.IGNORECASE),
)
@dataclass(slots=True)
class ReconciliationResult:
recovered_rooms: int = 0
repaired_rooms: int = 0
backfilled_platform_chat_ids: int = 0
def _room_name(room: object) -> str | None:
for attr in ("name", "display_name"):
value = getattr(room, attr, None)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _chat_id_from_room(room: object, existing_meta: dict | None) -> str | None:
chat_id = (existing_meta or {}).get("chat_id")
if isinstance(chat_id, str) and chat_id:
return chat_id
name = _room_name(room)
if not name:
return None
for pattern in _CHAT_ID_PATTERNS:
match = pattern.search(name)
if match:
return f"C{int(match.group('index'))}"
return None
def _space_id_for_room(room: object, rooms_by_id: dict[str, object], existing_meta: dict | None) -> str | None:
existing_space_id = (existing_meta or {}).get("space_id")
if isinstance(existing_space_id, str) and existing_space_id:
return existing_space_id
parents = getattr(room, "parents", None)
if not parents:
parents = getattr(room, "space_parents", None)
if not parents:
return None
for parent_id in parents:
parent = rooms_by_id.get(parent_id)
if parent is None:
continue
if getattr(parent, "room_type", None) == "m.space" or getattr(parent, "children", None):
return parent_id
return parent_id
return None
def _matrix_user_id_for_room(room: object, bot_user_id: str | None, existing_meta: dict | None) -> str | None:
existing_user_id = (existing_meta or {}).get("matrix_user_id")
if isinstance(existing_user_id, str) and existing_user_id:
return existing_user_id
users = getattr(room, "users", None) or {}
for user_id in users:
if user_id != bot_user_id:
return user_id
return None
async def reconcile_startup_state(client: object, runtime: object) -> ReconciliationResult:
rooms_by_id = getattr(client, "rooms", None) or {}
bot_user_id = getattr(client, "user_id", None)
result = ReconciliationResult()
max_chat_index_by_user: dict[str, int] = {}
recovered_space_by_user: dict[str, str] = {}
for room_id, room in rooms_by_id.items():
if getattr(room, "room_type", None) == "m.space":
continue
existing_meta = await get_room_meta(runtime.store, room_id)
if existing_meta and existing_meta.get("redirect_room_id"):
continue
space_id = _space_id_for_room(room, rooms_by_id, existing_meta)
chat_id = _chat_id_from_room(room, existing_meta)
matrix_user_id = _matrix_user_id_for_room(room, bot_user_id, existing_meta)
if not space_id or not chat_id or not matrix_user_id:
continue
recovered_space_by_user[matrix_user_id] = space_id
chat_index = int(chat_id[1:])
max_chat_index_by_user[matrix_user_id] = max(
max_chat_index_by_user.get(matrix_user_id, 0),
chat_index,
)
display_name = (existing_meta or {}).get("display_name") or _room_name(room) or chat_id
room_meta = dict(existing_meta or {})
room_meta.update(
{
"room_type": "chat",
"chat_id": chat_id,
"display_name": display_name,
"matrix_user_id": matrix_user_id,
"space_id": space_id,
}
)
if not room_meta.get("platform_chat_id"):
room_meta["platform_chat_id"] = await next_platform_chat_id(runtime.store)
result.backfilled_platform_chat_ids += 1
if not room_meta.get("agent_id"):
registry = getattr(runtime, "registry", None)
if registry is not None:
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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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}

26
docker-compose.prod.yml Normal file
View file

@ -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}

View file

@ -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` не персистентный.

View file

@ -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 <n>` | Удалить файл из очереди по номеру |
| `!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 платформы |

View file

@ -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 <номер>"
),
)
]

View file

@ -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()

View file

@ -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

View file

@ -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",
]

View file

@ -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"

View file

@ -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()

View file

@ -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()