Compare commits
10 commits
9a0316076a
...
b1aaa210a1
| Author | SHA1 | Date | |
|---|---|---|---|
| b1aaa210a1 | |||
| 380961d6e9 | |||
| e73e13e758 | |||
| 22a3a2b60a | |||
| 85e2fda6bc | |||
| df6d8bf628 | |||
| ae37476ddf | |||
| 8a80d004fd | |||
| 6693d72cbd | |||
| a75b26a1cb |
31 changed files with 1423 additions and 997 deletions
34
.env.example
34
.env.example
|
|
@ -1,20 +1,24 @@
|
|||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER=https://matrix.org
|
||||
MATRIX_USER_ID=@bot:matrix.org
|
||||
# Matrix bot credentials
|
||||
MATRIX_HOMESERVER=https://matrix.example.org
|
||||
MATRIX_USER_ID=@lambda-bot:example.org
|
||||
# Use ONE of: MATRIX_PASSWORD or MATRIX_ACCESS_TOKEN
|
||||
MATRIX_PASSWORD=your_password_here
|
||||
# MATRIX_ACCESS_TOKEN=your_access_token_here
|
||||
|
||||
# Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only)
|
||||
MATRIX_PLATFORM_BACKEND=real
|
||||
MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml
|
||||
|
||||
# Shared workspace contract
|
||||
SURFACES_WORKSPACE_DIR=/workspace
|
||||
# Path to agent registry inside the container (mounted via ./config:/app/config:ro)
|
||||
MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml
|
||||
|
||||
# Compose-local platform-agent route
|
||||
AGENT_BASE_URL=http://platform-agent:8000
|
||||
# HTTP URL of the platform-agent endpoint
|
||||
# Production: external agent managed by the platform
|
||||
# Fullstack E2E: overridden to http://platform-agent:8000 by docker-compose.fullstack.yml
|
||||
AGENT_BASE_URL=http://your-agent-host:8000
|
||||
|
||||
# platform-agent provider
|
||||
PROVIDER_MODEL=openai/gpt-4o-mini
|
||||
PROVIDER_URL=https://openrouter.ai/api/v1
|
||||
PROVIDER_API_KEY=sk-or-...
|
||||
# Shared volume path inside the bot container (default: /agents)
|
||||
SURFACES_WORKSPACE_DIR=/agents
|
||||
|
||||
# Docker volume names (created automatically on first run)
|
||||
SURFACES_SHARED_VOLUME=surfaces-agents
|
||||
SURFACES_BOT_STATE_VOLUME=surfaces-bot-state
|
||||
|
|
|
|||
|
|
@ -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:** Подготовить боты к реальному деплою — конфиг, логирование, мониторинг, обработка ошибок.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
.planning/phases/05-mvp-deployment/05-02-SUMMARY.md
Normal file
106
.planning/phases/05-mvp-deployment/05-02-SUMMARY.md
Normal 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`
|
||||
103
.planning/phases/05-mvp-deployment/05-03-SUMMARY.md
Normal file
103
.planning/phases/05-mvp-deployment/05-03-SUMMARY.md
Normal 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*
|
||||
93
.planning/phases/05-mvp-deployment/05-04-SUMMARY.md
Normal file
93
.planning/phases/05-mvp-deployment/05-04-SUMMARY.md
Normal 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 bot’s absolute shared path rooted at `/agents` in docs and env examples while letting the internal `platform-agent` consume the same named volume at `/workspace`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The docs verification grep treated `deploy` inside the filename `docs/deploy-architecture.md` as a false positive when root compose was still named explicitly. The fix was to remove the literal root compose filename from deploy-path wording while keeping the deprecation clear.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required beyond populating `.env` from `.env.example`.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Operators now have a bot-only compose artifact for handoff, and developers have a separate internal E2E harness.
|
||||
- Remaining Phase 05 work can rely on the split runtime contract without reinterpreting deployment docs.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Summary file exists at `.planning/phases/05-mvp-deployment/05-04-SUMMARY.md`
|
||||
- Commit `df6d8bf` found in git history
|
||||
- Commit `22a3a2b` found in git history
|
||||
|
||||
---
|
||||
*Phase: 05-mvp-deployment*
|
||||
*Completed: 2026-04-27*
|
||||
10
Dockerfile
10
Dockerfile
|
|
@ -13,15 +13,17 @@ RUN pip install --no-cache-dir uv
|
|||
COPY pyproject.toml uv.lock* ./
|
||||
|
||||
# Install project dependencies into the system environment.
|
||||
RUN uv sync --no-dev --no-install-project --frozen 2>/dev/null || uv sync --no-dev --no-install-project
|
||||
RUN uv sync --no-dev --no-install-project --frozen
|
||||
|
||||
# Copy project source after dependency layers.
|
||||
COPY . .
|
||||
|
||||
# Install the project itself and keep runtime dependencies in sync.
|
||||
RUN uv sync --no-dev --frozen 2>/dev/null || uv sync --no-dev
|
||||
# Install the project itself.
|
||||
RUN uv sync --no-dev --frozen
|
||||
|
||||
# Install lambda_agent_api from the local source tree, bypassing its Python version guard.
|
||||
# Install lambda_agent_api from the vendored source tree.
|
||||
# --ignore-requires-python: the package declares python<3.12 but works fine on 3.11;
|
||||
# the guard exists for its own dev tooling, not the runtime API surface we use.
|
||||
RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api
|
||||
|
||||
CMD ["python", "-m", "adapter.matrix.bot"]
|
||||
|
|
|
|||
378
README.md
378
README.md
|
|
@ -1,23 +1,10 @@
|
|||
# Lambda Lab 3.0 — Surfaces
|
||||
|
||||
Команда поверхностей. Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda.
|
||||
Matrix-бот для взаимодействия пользователя с AI-агентом Lambda.
|
||||
|
||||
## Статус
|
||||
|
||||
| Поверхность | Статус |
|
||||
|---|---|
|
||||
| Telegram | 🔨 В разработке, отдельный worktree `feat/telegram-adapter` |
|
||||
| Matrix | ✅ Рабочий прототип, запускается через root `docker compose` вместе с `platform-agent` |
|
||||
|
||||
---
|
||||
|
||||
## Концепция
|
||||
|
||||
Пользователь получает персонального AI-агента через привычный мессенджер.
|
||||
Агент выполняет реальные задачи: разбирает почту, ищет информацию, работает с файлами, управляет календарём.
|
||||
|
||||
**Поверхности** — тонкие клиенты. Вся бизнес-логика на стороне платформы.
|
||||
Задача команды: сделать интерфейс удобным, надёжным и легко расширяемым.
|
||||
Matrix MVP готов к деплою. Telegram — в отдельном worktree, не входит в этот handoff.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -28,258 +15,173 @@ surfaces-bot/
|
|||
core/ — общее ядро, не зависит от транспорта
|
||||
protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...)
|
||||
handler.py — EventDispatcher: IncomingEvent → OutgoingEvent
|
||||
handlers/ — обработчики по типам событий
|
||||
store.py — StateStore Protocol + InMemoryStore + SQLiteStore
|
||||
chat.py — ChatManager: метаданные чатов C1/C2/C3
|
||||
auth.py — AuthManager: аутентификация
|
||||
settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность
|
||||
chat.py — ChatManager
|
||||
auth.py — AuthManager
|
||||
settings.py — SettingsManager
|
||||
|
||||
adapter/
|
||||
telegram/ — aiogram 3.x адаптер
|
||||
matrix/ — matrix-nio адаптер
|
||||
|
||||
sdk/
|
||||
interface.py — PlatformClient Protocol (контракт к SDK)
|
||||
mock.py — MockPlatformClient (заглушка)
|
||||
real.py — RealPlatformClient (через AgentApi)
|
||||
mock.py — MockPlatformClient (заглушка для тестов)
|
||||
|
||||
config/
|
||||
matrix-agents.yaml — реестр агентов
|
||||
|
||||
docs/ — документация
|
||||
.claude/agents/ — агенты для Claude Code
|
||||
```
|
||||
|
||||
**Ключевой принцип:** добавить новую поверхность = написать один адаптер-конвертер.
|
||||
Ядро (`core/`) не трогается. Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md)
|
||||
Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md)
|
||||
|
||||
---
|
||||
|
||||
## Функционал прототипа
|
||||
## Деплой
|
||||
|
||||
### Telegram ([подробнее](docs/telegram-prototype.md))
|
||||
|
||||
- **Чаты** — основной Telegram UX сейчас развивается в отдельном worktree `feat/telegram-adapter`
|
||||
- **Forum Topics mode** — бот умеет подключать forum-группу через `/forum`; чат может быть привязан к отдельной теме
|
||||
- **DM-режим** — базовый диалог и переключение чатов сохраняются
|
||||
- **Аутентификация** — привязка Telegram аккаунта к аккаунту платформы
|
||||
- **Диалог** — typing indicator, передача файлов, подтверждение опасных действий через inline-кнопки
|
||||
- **Настройки** через `/settings`: коннекторы (Gmail, GitHub, Notion...), скиллы, личность агента (SOUL), безопасность, подписка
|
||||
|
||||
### Matrix ([подробнее](docs/matrix-prototype.md))
|
||||
|
||||
- **Онбординг** — при первом invite бот создаёт private Space `Lambda — {display_name}` и первую комнату `Чат 1`, сразу приглашая туда пользователя
|
||||
- **Чаты** — `!new`, `!chats`, `!rename`, `!archive`, `!help`; новые комнаты регистрируются в локальном `ChatManager`
|
||||
- **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher`
|
||||
- **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта
|
||||
- **Текущее ограничение** — encrypted DM официально не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота
|
||||
- **Backend selection** — `MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и upstream `AgentApi` по contract `/v1/agent_ws/{chat_id}/`
|
||||
- **Ограничения real backend** — локальный runtime использует shared `/workspace`, файлы передаются как относительные пути в `attachments`, а transport layer со стороны `surfaces` использует прямой upstream `platform-agent_api.AgentApi` без локального subclass; prod-default lifecycle открывает отдельное соединение на каждый запрос, но после tool/file flow всё ещё остаётся подтверждённый upstream streaming bug, из-за которого начало ответа может пропадать
|
||||
|
||||
---
|
||||
|
||||
## Замена SDK
|
||||
|
||||
Вся работа с платформой идёт через `PlatformClient` Protocol:
|
||||
|
||||
```python
|
||||
class PlatformClient(Protocol):
|
||||
async def get_or_create_user(self, external_id: str, platform: str, ...) -> User: ...
|
||||
async def send_message(self, user_id: str, chat_id: str, text: str, ...) -> MessageResponse: ...
|
||||
async def get_settings(self, user_id: str) -> UserSettings: ...
|
||||
async def update_settings(self, user_id: str, action: Any) -> None: ...
|
||||
```
|
||||
|
||||
Бот не управляет lifecycle контейнеров — это делает Master (платформа).
|
||||
Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер.
|
||||
|
||||
Сейчас: `MockPlatformClient` в `sdk/mock.py`, а Matrix real backend собирается через `sdk/real.py` при `MATRIX_PLATFORM_BACKEND=real`.
|
||||
Файловый контракт уже path-based: бот пишет файлы в shared `/workspace` и передаёт платформе относительные пути в `attachments`.
|
||||
Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем.
|
||||
|
||||
---
|
||||
|
||||
## Запуск Matrix-поверхности
|
||||
|
||||
### 1. Зависимости и тесты
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
### 2. Переменные окружения
|
||||
### Переменные окружения
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Обязательные переменные:
|
||||
|
||||
```env
|
||||
# Matrix аккаунт бота
|
||||
MATRIX_HOMESERVER=https://matrix.example.org
|
||||
MATRIX_USER_ID=@lambda-bot:example.org
|
||||
MATRIX_PASSWORD=... # или MATRIX_ACCESS_TOKEN=...
|
||||
|
||||
# Выбор backend: mock (по умолчанию) или real (подключение к platform-agent)
|
||||
MATRIX_PLATFORM_BACKEND=real
|
||||
|
||||
# compose runtime: platform-agent service name + shared /workspace
|
||||
AGENT_BASE_URL=http://platform-agent:8000
|
||||
SURFACES_WORKSPACE_DIR=/workspace
|
||||
|
||||
# platform-agent provider
|
||||
PROVIDER_MODEL=openai/gpt-4o-mini
|
||||
PROVIDER_URL=https://openrouter.ai/api/v1
|
||||
PROVIDER_API_KEY=...
|
||||
```
|
||||
|
||||
### 3. Registry агентов
|
||||
|
||||
1. Скопируй `config/matrix-agents.example.yaml` в `config/matrix-agents.yaml`
|
||||
2. Если готовишься к multi-agent routing, добавь `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml` в `.env`
|
||||
3. Этот registry сейчас является конфигурационным артефактом Task 1; текущий Matrix runtime его ещё не читает
|
||||
|
||||
### 4. Compose runtime
|
||||
|
||||
Root `docker-compose.yml` теперь является основным локальным runtime для Matrix и platform-agent.
|
||||
Он поднимает `matrix-bot`, `platform-agent` и общий volume `/workspace`.
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Compose собирает `platform-agent` из актуального upstream `external/platform-agent` Dockerfile (`development` target),
|
||||
монтирует live-код из `external/platform-agent/src` и `external/platform-agent_api`, и подготавливает shared `/workspace`
|
||||
с правами для agent runtime.
|
||||
Matrix бот подключается к `platform-agent` по service name, а не к отдельно запущенному `localhost`.
|
||||
|
||||
На `2026-04-21` локальный compose runtime использует vendored upstream-версии платформы без локальных патчей:
|
||||
|
||||
- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
|
||||
- `platform-agent_api`: `8a4f4db6d36786fe8af7feefffe506d4a54ac6bd`
|
||||
|
||||
### 4. Staged attachments в Matrix
|
||||
|
||||
Если Matrix-клиент отправляет файлы отдельными media events, бот не вызывает агента сразу.
|
||||
Вместо этого он сохраняет файлы в shared `/workspace`, ставит их в очередь для конкретного чата и пользователя, и ждёт следующего обычного сообщения.
|
||||
|
||||
Как отправить файлы агенту:
|
||||
|
||||
1. Отправь один или несколько файлов в рабочую Matrix-комнату.
|
||||
2. При необходимости проверь очередь командой `!list`.
|
||||
3. Напиши обычное текстовое сообщение, например:
|
||||
- `что на изображении?`
|
||||
- `прочитай pdf и сделай summary`
|
||||
- `сравни эти два файла`
|
||||
4. Это сообщение уйдёт агенту вместе со всеми staged файлами из очереди.
|
||||
|
||||
Команды:
|
||||
|
||||
- `!list` — показать staged вложения
|
||||
- `!remove <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) | Внутренний протокол событий (для расширения) |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}):",
|
||||
|
|
|
|||
|
|
@ -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 эта справка",
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
|||
159
adapter/matrix/reconciliation.py
Normal file
159
adapter/matrix/reconciliation.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
6
config/matrix-agents.yaml
Normal file
6
config/matrix-agents.yaml
Normal 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
|
||||
51
docker-compose.fullstack.yml
Normal file
51
docker-compose.fullstack.yml
Normal 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
26
docker-compose.prod.yml
Normal 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}
|
||||
|
|
@ -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` не персистентный.
|
||||
|
|
|
|||
|
|
@ -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 платформы |
|
||||
|
|
|
|||
|
|
@ -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 <номер>"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
203
tests/adapter/matrix/test_reconciliation.py
Normal file
203
tests/adapter/matrix/test_reconciliation.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue