diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 175285d..9f3eba8 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -50,6 +50,18 @@ Plans: - `stream_message` работает с реальным стримингом - Интеграционные тесты с реальным SDK (или staging) +### Phase 4: Matrix MVP: shared agent context and context management commands + +**Goal:** Привести Matrix-бот к рабочему состоянию для MVP-деплоя: заменить AgentSessionClient на AgentApi, добавить !save/!load/!reset/!context команды управления контекстом агента, упаковать в Docker. +**Requirements**: Replace AgentSessionClient with AgentApi; Wire AgentApi lifecycle; Implement !save, !load, !reset, !context commands; Dockerfile + docker-compose +**Depends on:** Phase 1 (Matrix adapter complete) +**Plans:** 3 plans + +Plans: +- [ ] 04-01-PLAN.md — Replace AgentSessionClient with AgentApi; update sdk/real.py, bot.py, broken tests +- [ ] 04-02-PLAN.md — !save, !load, !reset, !context handlers; PrototypeStateStore extensions; numeric interception +- [ ] 04-03-PLAN.md — Dockerfile + docker-compose.yml + .env.example update + --- ### Phase 3: Production Hardening diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md new file mode 100644 index 0000000..702a3e6 --- /dev/null +++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md @@ -0,0 +1,540 @@ +--- +phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - sdk/agent_session.py + - sdk/real.py + - adapter/matrix/bot.py + - tests/platform/test_agent_session.py + - tests/platform/test_real.py + - tests/adapter/matrix/test_dispatcher.py +autonomous: true +requirements: + - Replace AgentSessionClient with AgentApi + - Wire AgentApi lifecycle into MatrixBot + +must_haves: + truths: + - "RealPlatformClient uses AgentApi, not AgentSessionClient" + - "AgentApi is connected before sync_forever and closed in finally block of main()" + - "build_thread_key and AgentSessionClient are gone from sdk/" + - "stream_message() yields MessageChunk objects including a final chunk with tokens_used from last_tokens_used" + - "AGENT_WS_URL is used unchanged (no thread_id query param)" + - "MATRIX_PLATFORM_BACKEND=real still works end-to-end without test crash" + - "All existing tests pass after the swap" + artifacts: + - path: "sdk/real.py" + provides: "RealPlatformClient wrapping AgentApi" + contains: "AgentApi" + - path: "adapter/matrix/bot.py" + provides: "main() awaits agent_api.connect() and agent_api.close()" + contains: "agent_api.connect" + - path: "tests/platform/test_real.py" + provides: "Updated tests using FakeAgentApi instead of FakeAgentSessionClient" + key_links: + - from: "adapter/matrix/bot.py main()" + to: "RealPlatformClient._agent_api" + via: "runtime.platform.agent_api property" + pattern: "agent_api\\.connect" + - from: "sdk/real.py stream_message()" + to: "agent_api.last_tokens_used" + via: "attribute read after async-for loop" + pattern: "last_tokens_used" +--- + + +Replace the custom per-request AgentSessionClient with the persistent AgentApi from +lambda_agent_api. Remove build_thread_key and AgentSessionClient entirely. Wire +AgentApi connect/close into bot.py main(). Update all tests that referenced the +old client. + +Purpose: The existing AgentSessionClient creates a new WebSocket per message and +injects thread_id into the URL — both incompatible with origin/main platform-agent. +AgentApi maintains a single persistent WS connection managed via connect()/close() +and exposes send_message() as an AsyncIterator. + +Output: sdk/real.py, sdk/agent_session.py (deleted/emptied), adapter/matrix/bot.py +updated, tests green. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md +@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md + + + + + +From external/platform-agent_api/lambda_agent_api/agent_api.py: +```python +class AgentApi: + def __init__(self, agent_id: str, url: str, + callback=None, on_disconnect=None): ... + async def connect(self) -> None: ... # opens WS, awaits MsgStatus, starts _listen task + async def close(self) -> None: ... # cancels _listen, closes WS+session + async def send_message(self, text: str) -> AsyncIterator[AgentEventUnion]: + # yields MsgEventTextChunk only; breaks on MsgEventEnd (does NOT yield it) + # MsgEventEnd.tokens_used is consumed internally but NOT stored — executor + # MUST add self.last_tokens_used: int = 0 to AgentApi and set it at the + # break point, OR store it in a thin wrapper on RealPlatformClient. + ... + # AgentEventUnion = Union[MsgEventTextChunk, MsgEventEnd] per server.py +``` + +From external/platform-agent_api/lambda_agent_api/server.py: +```python +class MsgEventTextChunk(BaseModel): + type: Literal[EServerMessage.AGENT_EVENT_TEXT_CHUNK] + text: str + +class MsgEventEnd(BaseModel): + type: Literal[EServerMessage.AGENT_EVENT_END] + tokens_used: int +``` + +From sdk/interface.py (unchanged): +```python +class MessageChunk(BaseModel): + message_id: str + delta: str + finished: bool + tokens_used: int = 0 + +class PlatformClient(Protocol): + async def send_message(self, user_id, chat_id, text, attachments=None) -> MessageResponse: ... + async def stream_message(self, user_id, chat_id, text, attachments=None) -> AsyncIterator[MessageChunk]: ... +``` + + + + + + Task 1: Replace AgentSessionClient with AgentApi in sdk/real.py, delete sdk/agent_session.py, patch tokens_used capture + + + - sdk/real.py (full file — being replaced) + - sdk/agent_session.py (full file — being deleted) + - external/platform-agent_api/lambda_agent_api/agent_api.py (lines 134–216 — send_message generator + finally block) + - sdk/interface.py (MessageChunk, PlatformClient Protocol) + + + sdk/real.py, sdk/agent_session.py, external/platform-agent_api/lambda_agent_api/agent_api.py + + + - RealPlatformClient.__init__ accepts agent_api: AgentApi (not AgentSessionClient), prototype_state: PrototypeStateStore, platform: str = "matrix" + - RealPlatformClient exposes agent_api as property self.agent_api so bot.py main() can call connect/close + - stream_message() iterates agent_api.send_message(text) yielding MessageChunk per MsgEventTextChunk chunk; after loop yields final MessageChunk(finished=True, delta="", tokens_used=agent_api.last_tokens_used) + - send_message() collects all chunks from stream_message() and returns MessageResponse + - No thread_key, no build_thread_key references anywhere in sdk/real.py + - AgentApi.last_tokens_used: int = 0 added as instance attribute in __init__; set inside send_message() generator at the "if isinstance(chunk, MsgEventEnd): break" line — change that line to "self.last_tokens_used = chunk.tokens_used; break" + - sdk/agent_session.py: delete file contents and replace with single comment "# Deleted in Phase 4 — replaced by AgentApi from lambda_agent_api" (keep file to avoid import errors in test_real.py until tests are updated in Task 2) + + + +1. Edit external/platform-agent_api/lambda_agent_api/agent_api.py: + - In __init__: add `self.last_tokens_used: int = 0` + - In send_message() at line ~172 (`if isinstance(chunk, MsgEventEnd): break`): + replace with: + ```python + if isinstance(chunk, MsgEventEnd): + self.last_tokens_used = chunk.tokens_used + break + ``` + +2. Rewrite sdk/real.py entirely: +```python +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncIterator + +from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings +from sdk.prototype_state import PrototypeStateStore + +if TYPE_CHECKING: + from lambda_agent_api.agent_api import AgentApi + + +class RealPlatformClient(PlatformClient): + def __init__( + self, + agent_api: "AgentApi", + prototype_state: PrototypeStateStore, + platform: str = "matrix", + ) -> None: + self._agent_api = agent_api + self._prototype_state = prototype_state + self._platform = platform + + @property + def agent_api(self) -> "AgentApi": + return self._agent_api + + async def get_or_create_user( + self, + external_id: str, + platform: str, + display_name: str | None = None, + ) -> User: + return await self._prototype_state.get_or_create_user( + external_id=external_id, + platform=platform, + display_name=display_name, + ) + + async def send_message( + self, + user_id: str, + chat_id: str, + text: str, + attachments: list[Attachment] | None = None, + ) -> MessageResponse: + parts: list[str] = [] + tokens_used = 0 + async for chunk in self.stream_message(user_id, chat_id, text, attachments): + if chunk.delta: + parts.append(chunk.delta) + if chunk.finished: + tokens_used = chunk.tokens_used + return MessageResponse( + message_id=user_id, + response="".join(parts), + tokens_used=tokens_used, + finished=True, + ) + + async def stream_message( + self, + user_id: str, + chat_id: str, + text: str, + attachments: list[Attachment] | None = None, + ) -> AsyncIterator[MessageChunk]: + from lambda_agent_api.server import MsgEventTextChunk + async for event in self._agent_api.send_message(text): + if isinstance(event, MsgEventTextChunk): + yield MessageChunk( + message_id=user_id, + delta=event.text, + finished=False, + ) + yield MessageChunk( + message_id=user_id, + delta="", + finished=True, + tokens_used=self._agent_api.last_tokens_used, + ) + + async def get_settings(self, user_id: str) -> UserSettings: + return await self._prototype_state.get_settings(user_id) + + async def update_settings(self, user_id: str, action) -> None: + await self._prototype_state.update_settings(user_id, action) +``` + +3. Replace sdk/agent_session.py content with: +```python +# Deleted in Phase 4 — replaced by AgentApi from lambda_agent_api +# File kept as stub to avoid import errors during migration; remove after test_agent_session.py is updated. +``` + + + + cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from sdk.real import RealPlatformClient; print('import ok')" + + + + - sdk/real.py imports AgentApi (not AgentSessionClient), exposes self.agent_api property + - sdk/real.py stream_message yields final chunk with tokens_used from agent_api.last_tokens_used + - agent_api.py __init__ has self.last_tokens_used = 0 and send_message sets it before break + - sdk/agent_session.py contains only a comment stub (no class definitions) + - `python -c "from sdk.real import RealPlatformClient"` exits 0 + + + + + Task 2: Wire AgentApi lifecycle into bot.py main(); update all broken tests + + + - adapter/matrix/bot.py (full file — _build_platform_from_env and main() need changes) + - tests/platform/test_agent_session.py (full file — delete or rewrite) + - tests/platform/test_real.py (full file — FakeAgentSessionClient → FakeAgentApi) + - tests/adapter/matrix/test_dispatcher.py (test_build_runtime_uses_real_platform — needs update) + + + adapter/matrix/bot.py, tests/platform/test_agent_session.py, tests/platform/test_real.py, tests/adapter/matrix/test_dispatcher.py + + + - _build_platform_from_env() returns a RealPlatformClient with an unconnected AgentApi (connect() NOT called here — called in main()) + - main() calls await runtime.platform.agent_api.connect() after build_runtime() (only when backend is "real"; mock has no agent_api); wrap in `if hasattr(runtime.platform, "agent_api")` guard + - main() finally block: await agent_api.close() before await client.close() + - AGENT_WS_URL env var is passed unchanged to AgentApi(url=ws_url) — no query param manipulation + - test_agent_session.py: completely rewritten — remove all build_thread_key tests, remove AgentSessionClient tests, remove process_message tests (those tested our platform-agent patch which is being discarded); replace with 2 tests: (1) import check for lambda_agent_api module, (2) stub test that documents the deletion + - test_real.py: FakeAgentSessionClient replaced with FakeAgentApi that has send_message(text: str) -> AsyncIterator and last_tokens_used: int = 0; tests updated to construct RealPlatformClient(agent_api=FakeAgentApi(), prototype_state=PrototypeStateStore()); test_send_message no longer checks thread_key in message_id (now uses user_id); test_stream_message checks final chunk tokens_used comes from FakeAgentApi.last_tokens_used + - test_dispatcher.py: test_build_runtime_uses_real_platform_when_matrix_backend_is_real must NOT call agent_api.connect() (build_runtime only constructs, does not connect); update test to mock AgentApi so it does not attempt a real WS connection; assert isinstance(runtime.platform, RealPlatformClient) still passes + + + +1. Edit adapter/matrix/bot.py: + + a. Remove imports: `from sdk.agent_session import AgentSessionClient, AgentSessionConfig` + + b. Add import at top: `import sys; sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"))` — NO, instead add lambda_agent_api to sys.path only in bot.py startup, or better: install the package. In _build_platform_from_env(), do a lazy import: + ```python + def _build_platform_from_env() -> PlatformClient: + backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower() + if backend == "real": + import sys + _api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api" + if str(_api_root) not in sys.path: + sys.path.insert(0, str(_api_root)) + from lambda_agent_api.agent_api import AgentApi + ws_url = os.environ["AGENT_WS_URL"] + agent_api = AgentApi(agent_id="matrix-bot", url=ws_url) + return RealPlatformClient( + agent_api=agent_api, + prototype_state=PrototypeStateStore(), + platform="matrix", + ) + return MockPlatformClient() + ``` + + c. In main(), after `runtime = build_runtime(store=SQLiteStore(db_path), client=client)`, add: + ```python + if hasattr(runtime.platform, "agent_api"): + await runtime.platform.agent_api.connect() + ``` + + d. In main() finally block, add before `await client.close()`: + ```python + if hasattr(runtime.platform, "agent_api"): + await runtime.platform.agent_api.close() + ``` + +2. Rewrite tests/platform/test_agent_session.py: +```python +""" +test_agent_session.py — stub after Phase 4 migration. + +AgentSessionClient and build_thread_key were removed in Phase 4. +The platform client is now AgentApi from lambda_agent_api. +See tests/platform/test_real.py for RealPlatformClient tests. +""" +import sys +from pathlib import Path + +_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api" +if str(_api_root) not in sys.path: + sys.path.insert(0, str(_api_root)) + + +def test_lambda_agent_api_module_importable(): + from lambda_agent_api.agent_api import AgentApi # noqa: F401 + from lambda_agent_api.server import MsgEventTextChunk, MsgEventEnd # noqa: F401 + assert True + + +def test_agent_session_module_is_stub(): + """Ensure old module no longer exposes AgentSessionClient or build_thread_key.""" + import sdk.agent_session as mod + assert not hasattr(mod, "AgentSessionClient"), "AgentSessionClient should be removed" + assert not hasattr(mod, "build_thread_key"), "build_thread_key should be removed" +``` + +3. Rewrite tests/platform/test_real.py: +```python +from __future__ import annotations + +import sys +from pathlib import Path +from typing import AsyncIterator + +import pytest + +from core.protocol import SettingsAction +from sdk.interface import MessageChunk, MessageResponse, UserSettings +from sdk.prototype_state import PrototypeStateStore +from sdk.real import RealPlatformClient + +_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api" +if str(_api_root) not in sys.path: + sys.path.insert(0, str(_api_root)) + +from lambda_agent_api.server import MsgEventTextChunk, EServerMessage # noqa: E402 + + +class FakeAgentApi: + """Minimal fake for AgentApi — no real WebSocket.""" + def __init__(self) -> None: + self.last_tokens_used: int = 0 + self.send_calls: list[str] = [] + + async def send_message(self, text: str) -> AsyncIterator[MsgEventTextChunk]: + self.send_calls.append(text) + self.last_tokens_used = 7 + yield MsgEventTextChunk(type=EServerMessage.AGENT_EVENT_TEXT_CHUNK, text=text[:2]) + yield MsgEventTextChunk(type=EServerMessage.AGENT_EVENT_TEXT_CHUNK, text=text[2:]) + # send_message() in real AgentApi breaks on MsgEventEnd without yielding it; + # FakeAgentApi mirrors this by not yielding MsgEventEnd — last_tokens_used is set directly. + + +@pytest.mark.asyncio +async def test_real_platform_client_get_or_create_user_uses_local_state(): + client = RealPlatformClient( + agent_api=FakeAgentApi(), + prototype_state=PrototypeStateStore(), + ) + first = await client.get_or_create_user("u1", "matrix", "Alice") + second = await client.get_or_create_user("u1", "matrix") + + assert first.user_id == "usr-matrix-u1" + assert first.is_new is True + assert second.user_id == first.user_id + assert second.is_new is False + assert second.display_name == "Alice" + + +@pytest.mark.asyncio +async def test_real_platform_client_send_message_calls_agent_with_text(): + fake = FakeAgentApi() + client = RealPlatformClient(agent_api=fake, prototype_state=PrototypeStateStore()) + + result = await client.send_message("@alice:example.org", "C1", "hello") + + assert result.response == "hello" + assert result.tokens_used == 7 + assert fake.send_calls == ["hello"] + + +@pytest.mark.asyncio +async def test_real_platform_client_stream_message_yields_chunks_and_final_with_tokens(): + fake = FakeAgentApi() + client = RealPlatformClient(agent_api=fake, prototype_state=PrototypeStateStore()) + + chunks = [] + async for chunk in client.stream_message("@alice:example.org", "C1", "hello"): + chunks.append(chunk) + + assert chunks[-1].finished is True + assert chunks[-1].tokens_used == 7 + assert "".join(c.delta for c in chunks) == "hello" + + +@pytest.mark.asyncio +async def test_real_platform_client_settings_are_local(): + client = RealPlatformClient( + agent_api=FakeAgentApi(), + prototype_state=PrototypeStateStore(), + ) + await client.update_settings( + "usr-matrix-u1", + SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}), + ) + settings = await client.get_settings("usr-matrix-u1") + assert isinstance(settings, UserSettings) + assert settings.skills["browser"] is True +``` + +4. Edit tests/adapter/matrix/test_dispatcher.py — update `test_build_runtime_uses_real_platform_when_matrix_backend_is_real`: + - Add sys.path setup for lambda_agent_api (same pattern as above) + - Mock AgentApi so it does not open a real WS: + ```python + async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch): + import sys + from pathlib import Path + _api_root = Path(__file__).resolve().parents[3] / "external" / "platform-agent_api" + if str(_api_root) not in sys.path: + sys.path.insert(0, str(_api_root)) + + monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") + monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/") + + # Patch AgentApi to avoid real WS connection during build_runtime + import lambda_agent_api.agent_api as _mod + class _FakeAgentApi: + def __init__(self, agent_id, url, **kw): + self.last_tokens_used = 0 + async def connect(self): pass + async def close(self): pass + async def send_message(self, text): + return; yield # empty async generator + monkeypatch.setattr(_mod, "AgentApi", _FakeAgentApi) + + from adapter.matrix.bot import build_runtime + from sdk.real import RealPlatformClient + runtime = build_runtime() + assert isinstance(runtime.platform, RealPlatformClient) + ``` + + + + cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_agent_session.py tests/platform/test_real.py tests/adapter/matrix/test_dispatcher.py -v 2>&1 | tail -20 + + + + - All tests in test_agent_session.py, test_real.py, test_dispatcher.py pass + - main() in bot.py has agent_api.connect() call guarded by hasattr check + - main() finally block closes agent_api before matrix client + - grep confirms no "AgentSessionClient" or "build_thread_key" remain in sdk/real.py or adapter/matrix/bot.py + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| bot → platform-agent WS | Outbound WS to agent service; input is user text | +| env vars → bot config | AGENT_WS_URL, MATRIX_PLATFORM_BACKEND read from environment | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-01-01 | Tampering | AgentApi.send_message() text | accept | Single-user prototype; text originates from authenticated Matrix user | +| T-04-01-02 | Denial of Service | AgentBusyException from concurrent sends | mitigate | AgentApi._request_lock already prevents concurrent sends; bot must surface error to user instead of crashing | +| T-04-01-03 | Information Disclosure | AGENT_WS_URL in env | accept | Internal service URL; not exposed to users | + + + +Run full test suite after both tasks complete: + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/ -v 2>&1 | tail -30 +``` + +Grep checks: +```bash +# No old imports should remain +grep -r "AgentSessionClient\|build_thread_key" sdk/ adapter/ tests/ --include="*.py" | grep -v "stub\|Deleted\|removed" + +# AgentApi wired in bot.py +grep "agent_api.connect\|agent_api.close" adapter/matrix/bot.py + +# last_tokens_used set in agent_api.py +grep "last_tokens_used" external/platform-agent_api/lambda_agent_api/agent_api.py +``` + + + +- `pytest tests/platform/ tests/adapter/matrix/test_dispatcher.py -v` exits 0 with no failures +- `grep -r "AgentSessionClient" sdk/ adapter/` returns empty (or only the stub comment) +- `grep -r "build_thread_key" sdk/ adapter/` returns empty +- `grep "agent_api.connect" adapter/matrix/bot.py` returns a match +- `grep "last_tokens_used" external/platform-agent_api/lambda_agent_api/agent_api.py` returns the assignment line + + + +After completion, create `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md new file mode 100644 index 0000000..1b16918 --- /dev/null +++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md @@ -0,0 +1,865 @@ +--- +phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma +plan: 02 +type: execute +wave: 2 +depends_on: + - 04-01-PLAN.md +files_modified: + - sdk/prototype_state.py + - adapter/matrix/store.py + - adapter/matrix/handlers/__init__.py + - adapter/matrix/handlers/context_commands.py + - adapter/matrix/bot.py + - tests/adapter/matrix/test_context_commands.py + - tests/platform/test_prototype_state.py +autonomous: true +requirements: + - Implement !save, !load, !reset, !context commands + - PrototypeStateStore saved sessions storage + - !load pending state in Matrix store + - !reset pending state in Matrix store + - Numeric input interception for !load + +must_haves: + truths: + - "!save sends a save prompt to the agent and records session name in PrototypeStateStore" + - "!load shows a numbered list of saved sessions; numeric reply selects a session" + - "!reset shows a confirmation dialog; !yes calls POST /reset; !no cancels" + - "!context returns current session name, last tokens_used, and list of saved sessions" + - "Numeric input intercepted in on_room_message before dispatcher.dispatch when load_pending is set" + - "!yes in reset_pending context calls POST {AGENT_BASE_URL}/reset and reports unavailable on 404" + - "All context command tests pass" + artifacts: + - path: "adapter/matrix/handlers/context_commands.py" + provides: "make_handle_save, make_handle_load, make_handle_reset, make_handle_context" + - path: "adapter/matrix/store.py" + provides: "get_load_pending, set_load_pending, clear_load_pending, get_reset_pending, set_reset_pending, clear_reset_pending" + - path: "sdk/prototype_state.py" + provides: "add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used" + - path: "tests/adapter/matrix/test_context_commands.py" + provides: "tests for all four commands" + key_links: + - from: "adapter/matrix/bot.py on_room_message()" + to: "adapter/matrix/store.get_load_pending()" + via: "check before dispatcher.dispatch" + pattern: "get_load_pending" + - from: "adapter/matrix/handlers/context_commands.py make_handle_reset" + to: "httpx.AsyncClient.post(AGENT_BASE_URL + '/reset')" + via: "!yes handler inside reset_pending flow" + pattern: "httpx" + - from: "sdk/real.py stream_message()" + to: "prototype_state.set_last_tokens_used()" + via: "call after final chunk" + pattern: "set_last_tokens_used" +--- + + +Add four context management commands to the Matrix bot: !save, !load, !reset, !context. +Extend PrototypeStateStore with saved sessions and last_tokens_used tracking. Add +load_pending and reset_pending state keys to Matrix store. Wire numeric input +interception in on_room_message. Register all handlers. + +Purpose: Users need to save, load, and reset agent context, and inspect current context +state — essential for a shared-context MVP where one agent container persists across +Matrix sessions. + +Output: context_commands.py handler module, store.py extensions, prototype_state.py +extensions, bot.py updated, full test coverage. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md +@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md +@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md + + + + + +From adapter/matrix/store.py (existing pattern): +```python +PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:" + +def _pending_confirm_key(user_id: str, room_id: str | None = None) -> str: ... +async def get_pending_confirm(store, user_id, room_id=None) -> dict | None: ... +async def set_pending_confirm(store, user_id, room_id, meta) -> None: ... +async def clear_pending_confirm(store, user_id, room_id=None) -> None: ... +``` + +New store keys to add (same pattern): +```python +LOAD_PENDING_PREFIX = "matrix_load_pending:" +RESET_PENDING_PREFIX = "matrix_reset_pending:" + +# Keys: f"{PREFIX}{user_id}:{room_id}" +# load_pending data: {"saves": [{"name": str, "created_at": str}, ...], "display": str} +# reset_pending data: {"active": True} +``` + +From adapter/matrix/handlers/__init__.py (existing registration): +```python +def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None: + dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) + ... +``` + +Handler closure signature (all existing handlers follow this): +```python +async def handle_X(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]: +``` + +New handlers use make_handle_X(agent_api, store, prototype_state) closures: +```python +async def _inner(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]: + ... +return _inner +``` + +From sdk/prototype_state.py (PrototypeStateStore to extend): +```python +class PrototypeStateStore: + def __init__(self) -> None: + self._users: dict[str, User] = {} + self._settings: dict[str, dict[str, Any]] = {} + # Add: + # self._saved_sessions: dict[str, list[dict]] = {} + # self._last_tokens_used: dict[str, int] = {} +``` + +From core/protocol.py: +```python +@dataclass +class IncomingCommand: + user_id: str; platform: str; chat_id: str; command: str; args: list[str] + +@dataclass +class OutgoingMessage: + chat_id: str; text: str + +@dataclass +class OutgoingUI: + chat_id: str; text: str; buttons: list[UIButton] +``` + +From sdk/real.py (after Plan 01): +```python +class RealPlatformClient: + async def stream_message(self, user_id, chat_id, text, ...) -> AsyncIterator[MessageChunk]: + # yields chunks; last chunk has finished=True, tokens_used=agent_api.last_tokens_used +``` + +SAVE_PROMPT template (Claude's Discretion): +```python +SAVE_PROMPT = ( + "Summarize our conversation and save to /workspace/contexts/{name}.md. " + "Reply only with: Saved: {name}" +) + +LOAD_PROMPT = ( + "Load context from /workspace/contexts/{name}.md and use it as background " + "for our conversation. Reply: Loaded: {name}" +) +``` + +Auto-name format for !save without args: `context-{YYYYMMDD-HHMMSS}` UTC. +HTTP client for POST /reset: httpx.AsyncClient (already in pyproject.toml deps). +AGENT_BASE_URL env var: `os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")` + + + + + + Task 1: Extend PrototypeStateStore and Matrix store with pending state helpers + + + - sdk/prototype_state.py (full file — adding saved_sessions and last_tokens_used) + - adapter/matrix/store.py (full file — adding load_pending and reset_pending helpers) + - tests/platform/test_prototype_state.py (full file — adding new test cases) + + + sdk/prototype_state.py, adapter/matrix/store.py, tests/platform/test_prototype_state.py + + + - PrototypeStateStore.__init__ adds: self._saved_sessions: dict[str, list[dict]] = {} and self._last_tokens_used: dict[str, int] = {} + - add_saved_session(user_id: str, name: str) -> None: appends {"name": name, "created_at": datetime.now(UTC).isoformat()} to _saved_sessions[user_id] + - list_saved_sessions(user_id: str) -> list[dict]: returns copy of _saved_sessions.get(user_id, []) + - get_last_tokens_used(user_id: str) -> int: returns _last_tokens_used.get(user_id, 0) + - set_last_tokens_used(user_id: str, tokens: int) -> None: sets _last_tokens_used[user_id] = tokens + - adapter/matrix/store.py adds LOAD_PENDING_PREFIX and RESET_PENDING_PREFIX constants + - get_load_pending(store, user_id, room_id) -> dict | None + - set_load_pending(store, user_id, room_id, data: dict) -> None + - clear_load_pending(store, user_id, room_id) -> None + - get_reset_pending(store, user_id, room_id) -> dict | None + - set_reset_pending(store, user_id, room_id, data: dict) -> None + - clear_reset_pending(store, user_id, room_id) -> None + - test_prototype_state.py gets 4 new tests: add/list saved sessions, last_tokens_used get/set + + + +1. Edit sdk/prototype_state.py — add to __init__ and add four new async methods: + +In __init__ after existing attributes: +```python + self._saved_sessions: dict[str, list[dict]] = {} + self._last_tokens_used: dict[str, int] = {} +``` + +After update_settings() method, add: +```python + async def add_saved_session(self, user_id: str, name: str) -> None: + sessions = self._saved_sessions.setdefault(user_id, []) + sessions.append({"name": name, "created_at": datetime.now(UTC).isoformat()}) + + async def list_saved_sessions(self, user_id: str) -> list[dict]: + return list(self._saved_sessions.get(user_id, [])) + + async def get_last_tokens_used(self, user_id: str) -> int: + return self._last_tokens_used.get(user_id, 0) + + async def set_last_tokens_used(self, user_id: str, tokens: int) -> None: + self._last_tokens_used[user_id] = tokens +``` + +2. Edit adapter/matrix/store.py — add after existing constants and helpers: + +After PENDING_CONFIRM_PREFIX line, add: +```python +LOAD_PENDING_PREFIX = "matrix_load_pending:" +RESET_PENDING_PREFIX = "matrix_reset_pending:" +``` + +After clear_pending_confirm(), add: +```python +def _load_pending_key(user_id: str, room_id: str) -> str: + return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}" + +async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None: + return await store.get(_load_pending_key(user_id, room_id)) + +async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None: + await store.set(_load_pending_key(user_id, room_id), data) + +async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None: + await store.delete(_load_pending_key(user_id, room_id)) + + +def _reset_pending_key(user_id: str, room_id: str) -> str: + return f"{RESET_PENDING_PREFIX}{user_id}:{room_id}" + +async def get_reset_pending(store: StateStore, user_id: str, room_id: str) -> dict | None: + return await store.get(_reset_pending_key(user_id, room_id)) + +async def set_reset_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None: + await store.set(_reset_pending_key(user_id, room_id), data) + +async def clear_reset_pending(store: StateStore, user_id: str, room_id: str) -> None: + await store.delete(_reset_pending_key(user_id, room_id)) +``` + +3. Edit tests/platform/test_prototype_state.py — append four new tests: + +```python +@pytest.mark.asyncio +async def test_saved_sessions_add_and_list(): + store = PrototypeStateStore() + await store.add_saved_session("u1", "my-save") + await store.add_saved_session("u1", "another-save") + sessions = await store.list_saved_sessions("u1") + assert len(sessions) == 2 + assert sessions[0]["name"] == "my-save" + assert "created_at" in sessions[0] + assert sessions[1]["name"] == "another-save" + + +@pytest.mark.asyncio +async def test_saved_sessions_list_returns_copy(): + store = PrototypeStateStore() + await store.add_saved_session("u1", "my-save") + sessions = await store.list_saved_sessions("u1") + sessions.append({"name": "injected"}) + sessions2 = await store.list_saved_sessions("u1") + assert len(sessions2) == 1 + + +@pytest.mark.asyncio +async def test_last_tokens_used_default_zero(): + store = PrototypeStateStore() + assert await store.get_last_tokens_used("u1") == 0 + + +@pytest.mark.asyncio +async def test_last_tokens_used_set_and_get(): + store = PrototypeStateStore() + await store.set_last_tokens_used("u1", 42) + assert await store.get_last_tokens_used("u1") == 42 +``` + + + + cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_prototype_state.py -v 2>&1 | tail -15 + + + + - PrototypeStateStore has add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used + - adapter/matrix/store.py has LOAD_PENDING_PREFIX, RESET_PENDING_PREFIX, and 6 new helper functions + - All test_prototype_state.py tests pass (including 4 new ones) + - `grep "add_saved_session\|list_saved_sessions" sdk/prototype_state.py` returns matches + - `grep "LOAD_PENDING_PREFIX\|RESET_PENDING_PREFIX" adapter/matrix/store.py` returns matches + + + + + Task 2: Implement context_commands.py handlers, wire into __init__.py and bot.py, update tokens_used tracking in real.py + + + - adapter/matrix/handlers/__init__.py (full file — adding registrations) + - adapter/matrix/handlers/confirm.py (full file — example of make_handle_X closure pattern with store) + - adapter/matrix/bot.py (full file — on_room_message and build_runtime need changes) + - sdk/real.py (after Plan 01 — add set_last_tokens_used call after stream_message) + - adapter/matrix/store.py (after Task 1 — load_pending/reset_pending helpers now available) + - sdk/prototype_state.py (after Task 1 — saved_sessions methods available) + + + + adapter/matrix/handlers/context_commands.py, + adapter/matrix/handlers/__init__.py, + adapter/matrix/bot.py, + sdk/real.py, + tests/adapter/matrix/test_context_commands.py + + + + - context_commands.py exports: make_handle_save, make_handle_load, make_handle_reset, make_handle_context + - make_handle_save(agent_api, store, prototype_state) -> handler: + !save with no args: auto-name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}" + !save [name]: use args[0] as name + sends SAVE_PROMPT via platform.send_message (NOT stream — simple blocking send) + calls prototype_state.add_saved_session(event.user_id, name) + returns [OutgoingMessage(chat_id=event.chat_id, text=f"Сохранение запущено: {name}")] + - make_handle_load(agent_api, store, prototype_state) -> handler: + !load: fetches sessions = await prototype_state.list_saved_sessions(event.user_id) + if empty: returns [OutgoingMessage(chat_id=..., text="Нет сохранённых сессий. Используй !save [имя].")] + else: builds numbered display text, stores load_pending via set_load_pending(store, event.user_id, room_id, {"saves": sessions}) + room_id is in event.chat_id (in Matrix adapter, chat_id == room_id for commands) + returns [OutgoingMessage(chat_id=..., text=display_text + "\nВведи номер или 0 / !cancel для отмены.")] + - Numeric input interception in MatrixBot.on_room_message(): + Before dispatcher.dispatch, check load_pending = await get_load_pending(runtime.store, sender, room_id) + If load_pending and msg text is digit: handle_load_selection(pending, selection, ...) + handle_load_selection: if text == "0" or "!cancel" → clear_load_pending, return [OutgoingMessage("Отменено")] + if valid index → clear_load_pending, send LOAD_PROMPT via platform.send_message, add session as current_session in prototype_state (store in dict), return [OutgoingMessage("Загрузка: {name}")] + if invalid index → return [OutgoingMessage("Неверный номер. Введи от 1 до N или 0 для отмены.")] + - make_handle_reset(store, agent_base_url) -> handler: + !reset: set reset_pending, return [OutgoingMessage with text: + "Сбросить контекст агента? Выбери:\n !yes — сбросить\n !save [имя] — сохранить и сбросить\n !no — отмена")] + !yes in reset_pending: call POST {AGENT_BASE_URL}/reset via httpx; if 404 or connection error → "Reset endpoint недоступен. Обратитесь к администратору."; else "Контекст сброшен."; clear reset_pending + !no in reset_pending: clear reset_pending, return [OutgoingMessage("Отменено.")] + !save имя in reset_pending: delegate to save logic, then POST /reset (same fallback) + Reset_pending check must happen BEFORE pending_confirm in handler priority — implement by checking reset_pending in the !yes and !no handlers (make_handle_confirm must check reset_pending first) + - make_handle_context(store, prototype_state) -> handler: + reads current_session from prototype_state._current_session dict (keyed by user_id) if it exists + reads tokens = await prototype_state.get_last_tokens_used(event.user_id) + reads sessions = await prototype_state.list_saved_sessions(event.user_id) + formats: "Контекст:\n Сессия: {name or 'не загружена'}\n Токены (последний ответ): {tokens}\n Сохранения ({len}):\n {list}" + returns [OutgoingMessage(chat_id=..., text=formatted)] + - sdk/real.py: after the final yield in stream_message, call await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) — needs prototype_state reference already present in RealPlatformClient + - PrototypeStateStore gets one more dict: self._current_session: dict[str, str] = {} and methods get_current_session(user_id) -> str | None, set_current_session(user_id, name) -> None + - register_matrix_handlers() updated to accept agent_api and agent_base_url params; registers save/load/reset/context + + + +1. Add to sdk/prototype_state.py __init__: `self._current_session: dict[str, str] = {}` + Add methods: + ```python + async def get_current_session(self, user_id: str) -> str | None: + return self._current_session.get(user_id) + + async def set_current_session(self, user_id: str, name: str) -> None: + self._current_session[user_id] = name + ``` + +2. Create adapter/matrix/handlers/context_commands.py: + +```python +from __future__ import annotations + +import os +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +import httpx +import structlog + +from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage + +if TYPE_CHECKING: + from lambda_agent_api.agent_api import AgentApi + from sdk.prototype_state import PrototypeStateStore + from core.store import StateStore + +logger = structlog.get_logger(__name__) + +SAVE_PROMPT = ( + "Summarize our conversation and save to /workspace/contexts/{name}.md. " + "Reply only with: Saved: {name}" +) + +LOAD_PROMPT = ( + "Load context from /workspace/contexts/{name}.md and use it as background " + "for our conversation. Reply: Loaded: {name}" +) + + +def make_handle_save(agent_api: "AgentApi", store: "StateStore", prototype_state: "PrototypeStateStore"): + async def handle_save( + event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr + ) -> list[OutgoingEvent]: + if event.args: + name = event.args[0] + else: + name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}" + + prompt = SAVE_PROMPT.format(name=name) + try: + await platform.send_message(event.user_id, event.chat_id, prompt) + except Exception as exc: + logger.warning("save_agent_call_failed", error=str(exc)) + return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")] + + await prototype_state.add_saved_session(event.user_id, name) + return [OutgoingMessage(chat_id=event.chat_id, text=f"Сохранение запущено: {name}")] + + return handle_save + + +def make_handle_load(store: "StateStore", prototype_state: "PrototypeStateStore"): + async def handle_load( + event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr + ) -> list[OutgoingEvent]: + from adapter.matrix.store import set_load_pending + + sessions = await prototype_state.list_saved_sessions(event.user_id) + if not sessions: + return [OutgoingMessage( + chat_id=event.chat_id, + text="Нет сохранённых сессий. Используй !save [имя].", + )] + + lines = ["Сохранённые сессии:"] + for i, s in enumerate(sessions, start=1): + created = s.get("created_at", "")[:10] + lines.append(f" {i}. {s['name']} ({created})") + lines.append("\nВведи номер или 0 / !cancel для отмены.") + display = "\n".join(lines) + + await set_load_pending(store, event.user_id, event.chat_id, {"saves": sessions}) + return [OutgoingMessage(chat_id=event.chat_id, text=display)] + + return handle_load + + +def make_handle_reset(store: "StateStore", agent_base_url: str): + async def handle_reset( + event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr + ) -> list[OutgoingEvent]: + from adapter.matrix.store import set_reset_pending + + await set_reset_pending(store, event.user_id, event.chat_id, {"active": True}) + text = ( + "Сбросить контекст агента? Выбери:\n" + " !yes — сбросить\n" + " !save [имя] — сохранить и сбросить\n" + " !no — отмена" + ) + return [OutgoingMessage(chat_id=event.chat_id, text=text)] + + return handle_reset + + +async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[OutgoingEvent]: + try: + async with httpx.AsyncClient() as http: + resp = await http.post(f"{agent_base_url}/reset", timeout=5.0) + if resp.status_code == 404: + return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")] + return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")] + except (httpx.ConnectError, httpx.TimeoutException) as exc: + logger.warning("reset_endpoint_unreachable", error=str(exc)) + return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")] + + +def make_handle_context(store: "StateStore", prototype_state: "PrototypeStateStore"): + async def handle_context( + event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr + ) -> list[OutgoingEvent]: + session_name = await prototype_state.get_current_session(event.user_id) or "не загружена" + tokens = await prototype_state.get_last_tokens_used(event.user_id) + sessions = await prototype_state.list_saved_sessions(event.user_id) + + lines = [ + "Контекст:", + f" Сессия: {session_name}", + f" Токены (последний ответ): {tokens}", + f" Сохранения ({len(sessions)}):", + ] + for s in sessions: + created = s.get("created_at", "")[:10] + lines.append(f" • {s['name']} ({created})") + if not sessions: + lines.append(" (нет)") + + return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] + + return handle_context +``` + +3. Edit adapter/matrix/handlers/__init__.py: + - Add import at top: `from adapter.matrix.handlers.context_commands import make_handle_save, make_handle_load, make_handle_reset, make_handle_context` + - Change signature: `def register_matrix_handlers(dispatcher, client=None, store=None, agent_api=None, prototype_state=None, agent_base_url="http://127.0.0.1:8000") -> None:` + - Add at bottom of function before the last line: + ```python + if agent_api is not None and prototype_state is not None: + dispatcher.register(IncomingCommand, "save", make_handle_save(agent_api, store, prototype_state)) + dispatcher.register(IncomingCommand, "load", make_handle_load(store, prototype_state)) + dispatcher.register(IncomingCommand, "reset", make_handle_reset(store, agent_base_url)) + dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state)) + ``` + +4. Edit adapter/matrix/bot.py: + a. Add imports: `from adapter.matrix.store import get_load_pending, clear_load_pending, get_reset_pending, clear_reset_pending` + b. In build_event_dispatcher() and build_runtime(), extract prototype_state from platform if RealPlatformClient, otherwise create new one: + In build_runtime() after creating platform: + ```python + prototype_state = getattr(platform, "_prototype_state", None) + agent_api = getattr(platform, "_agent_api", None) + agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000") + ``` + Pass these to register_matrix_handlers: + ```python + register_matrix_handlers(dispatcher, client=client, store=store, + agent_api=agent_api, prototype_state=prototype_state, + agent_base_url=agent_base_url) + ``` + c. In MatrixBot.on_room_message(), before `incoming = from_room_event(...)`: + ```python + sender = getattr(event, "sender", None) + # !load numeric interception + load_pending = await get_load_pending(self.runtime.store, sender, room.room_id) + if load_pending is not None: + text = getattr(event, "body", "").strip() + if text.isdigit() or text == "0" or text == "!cancel": + outgoing = await self._handle_load_selection( + sender, room.room_id, text, load_pending + ) + await self._send_all(room.room_id, outgoing) + return + ``` + d. Add _handle_load_selection method to MatrixBot: + ```python + async def _handle_load_selection( + self, user_id: str, room_id: str, text: str, pending: dict + ) -> list[OutgoingEvent]: + from adapter.matrix.store import clear_load_pending + saves = pending.get("saves", []) + if text == "0" or text == "!cancel": + await clear_load_pending(self.runtime.store, user_id, room_id) + return [OutgoingMessage(chat_id=room_id, text="Отменено.")] + idx = int(text) - 1 + if idx < 0 or idx >= len(saves): + return [OutgoingMessage(chat_id=room_id, text=f"Неверный номер. Введи от 1 до {len(saves)} или 0 для отмены.")] + name = saves[idx]["name"] + await clear_load_pending(self.runtime.store, user_id, room_id) + prototype_state = getattr(self.runtime.platform, "_prototype_state", None) + if prototype_state is not None: + await prototype_state.set_current_session(user_id, name) + prompt = f"Load context from /workspace/contexts/{name}.md and use it as background for our conversation. Reply: Loaded: {name}" + try: + await self.runtime.platform.send_message(user_id, room_id, prompt) + except Exception as exc: + logger.warning("load_agent_call_failed", error=str(exc)) + return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при загрузке: {exc}")] + return [OutgoingMessage(chat_id=room_id, text=f"Загрузка: {name}")] + ``` + e. In MatrixBot.on_room_message(), also add reset_pending check for !yes/!no/!save commands: + In the block after load_pending check, before calling dispatcher.dispatch: + ```python + # !reset pending interception for !yes, !no, !save commands + reset_pending = await get_reset_pending(self.runtime.store, sender, room.room_id) + if reset_pending is not None: + body = getattr(event, "body", "").strip() + if body == "!yes" or body.startswith("!save ") or body == "!no": + outgoing = await self._handle_reset_selection(sender, room.room_id, body) + await self._send_all(room.room_id, outgoing) + return + ``` + f. Add _handle_reset_selection method to MatrixBot: + ```python + async def _handle_reset_selection( + self, user_id: str, room_id: str, text: str + ) -> list[OutgoingEvent]: + from adapter.matrix.store import clear_reset_pending + from adapter.matrix.handlers.context_commands import _call_reset_endpoint + agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000") + await clear_reset_pending(self.runtime.store, user_id, room_id) + if text == "!no": + return [OutgoingMessage(chat_id=room_id, text="Отменено.")] + if text.startswith("!save "): + name = text[len("!save "):].strip() + prototype_state = getattr(self.runtime.platform, "_prototype_state", None) + prompt = f"Summarize our conversation and save to /workspace/contexts/{name}.md. Reply only with: Saved: {name}" + try: + await self.runtime.platform.send_message(user_id, room_id, prompt) + if prototype_state: + await prototype_state.add_saved_session(user_id, name) + except Exception as exc: + logger.warning("save_before_reset_failed", error=str(exc)) + return await _call_reset_endpoint(agent_base_url, room_id) + ``` + +5. Edit sdk/real.py — in stream_message(), after the final yield, add: + ```python + await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) + ``` + (This must come after `yield MessageChunk(finished=True, ...)` — use a local variable to store tokens_used before yield, then call set_last_tokens_used after the generator resumes.) + Actually: put it before the final yield: + ```python + await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) + yield MessageChunk( + message_id=user_id, + delta="", + finished=True, + tokens_used=self._agent_api.last_tokens_used, + ) + ``` + +6. Create tests/adapter/matrix/test_context_commands.py: + +```python +from __future__ import annotations + +from typing import AsyncIterator +from unittest.mock import AsyncMock, patch + +import pytest + +from adapter.matrix.bot import MatrixBot, build_runtime +from core.protocol import IncomingCommand, OutgoingMessage +from sdk.mock import MockPlatformClient +from sdk.prototype_state import PrototypeStateStore + + +def make_runtime_with_prototype_state(): + proto = PrototypeStateStore() + platform = MockPlatformClient() + # Inject prototype_state into platform so handlers can find it + platform._prototype_state = proto + runtime = build_runtime(platform=platform) + return runtime, proto + + +@pytest.mark.asyncio +async def test_save_command_auto_name_records_session(): + proto = PrototypeStateStore() + platform = MockPlatformClient() + platform._prototype_state = proto + + from adapter.matrix.handlers.context_commands import make_handle_save + from core.store import InMemoryStore + + store = InMemoryStore() + handler = make_handle_save(agent_api=None, store=store, prototype_state=proto) + + event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!room:example", command="save", args=[]) + + class FakePlatform: + async def send_message(self, *a, **kw): pass + + result = await handler(event, None, FakePlatform(), None, None) + assert any(isinstance(r, OutgoingMessage) and "Сохранение запущено" in r.text for r in result) + sessions = await proto.list_saved_sessions("u1") + assert len(sessions) == 1 + assert sessions[0]["name"].startswith("context-") + + +@pytest.mark.asyncio +async def test_save_command_with_name_uses_given_name(): + proto = PrototypeStateStore() + from adapter.matrix.handlers.context_commands import make_handle_save + from core.store import InMemoryStore + + store = InMemoryStore() + handler = make_handle_save(agent_api=None, store=store, prototype_state=proto) + + event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="save", args=["my-session"]) + + class FakePlatform: + async def send_message(self, *a, **kw): pass + + await handler(event, None, FakePlatform(), None, None) + sessions = await proto.list_saved_sessions("u1") + assert sessions[0]["name"] == "my-session" + + +@pytest.mark.asyncio +async def test_load_command_shows_numbered_list(): + proto = PrototypeStateStore() + await proto.add_saved_session("u1", "session-A") + await proto.add_saved_session("u1", "session-B") + + from adapter.matrix.handlers.context_commands import make_handle_load + from core.store import InMemoryStore + + store = InMemoryStore() + handler = make_handle_load(store=store, prototype_state=proto) + event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[]) + + result = await handler(event, None, None, None, None) + assert len(result) == 1 + text = result[0].text + assert "1." in text and "session-A" in text + assert "2." in text and "session-B" in text + assert "0" in text + + +@pytest.mark.asyncio +async def test_load_command_empty_sessions(): + proto = PrototypeStateStore() + from adapter.matrix.handlers.context_commands import make_handle_load + from core.store import InMemoryStore + + store = InMemoryStore() + handler = make_handle_load(store=store, prototype_state=proto) + event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[]) + + result = await handler(event, None, None, None, None) + assert "Нет сохранённых сессий" in result[0].text + + +@pytest.mark.asyncio +async def test_reset_command_shows_dialog(): + proto = PrototypeStateStore() + from adapter.matrix.handlers.context_commands import make_handle_reset + from core.store import InMemoryStore + + store = InMemoryStore() + handler = make_handle_reset(store=store, agent_base_url="http://127.0.0.1:8000") + event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="reset", args=[]) + + result = await handler(event, None, None, None, None) + text = result[0].text + assert "!yes" in text + assert "!save" in text + assert "!no" in text + + +@pytest.mark.asyncio +async def test_reset_yes_reports_unavailable_when_endpoint_missing(): + from adapter.matrix.handlers.context_commands import _call_reset_endpoint + + with patch("httpx.AsyncClient") as MockClient: + import httpx + MockClient.return_value.__aenter__ = AsyncMock(return_value=MockClient.return_value) + MockClient.return_value.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value.post = AsyncMock(side_effect=httpx.ConnectError("refused")) + + result = await _call_reset_endpoint("http://127.0.0.1:8000", "!r:e") + assert "недоступен" in result[0].text + + +@pytest.mark.asyncio +async def test_context_command_shows_snapshot(): + proto = PrototypeStateStore() + await proto.set_last_tokens_used("u1", 99) + await proto.add_saved_session("u1", "my-save") + + from adapter.matrix.handlers.context_commands import make_handle_context + from core.store import InMemoryStore + + store = InMemoryStore() + handler = make_handle_context(store=store, prototype_state=proto) + event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="context", args=[]) + + result = await handler(event, None, None, None, None) + text = result[0].text + assert "99" in text + assert "my-save" in text + assert "не загружена" in text +``` + + + + cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/adapter/matrix/test_context_commands.py tests/platform/test_prototype_state.py -v 2>&1 | tail -20 + + + + - adapter/matrix/handlers/context_commands.py exists with make_handle_save, make_handle_load, make_handle_reset, make_handle_context, _call_reset_endpoint + - register_matrix_handlers() signature accepts agent_api, prototype_state, agent_base_url; registers save/load/reset/context handlers when agent_api is not None + - MatrixBot.on_room_message() checks load_pending and reset_pending before dispatcher.dispatch + - sdk/real.py calls set_last_tokens_used before final yield + - All tests in test_context_commands.py pass + - Full test suite still passes: `pytest tests/ -v` exits 0 + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Matrix user → command args | !save [name] arg is user-controlled; used in file paths | +| bot → agent (save/load prompts) | Prompt text contains user-supplied name | +| bot → POST /reset endpoint | HTTP call to AGENT_BASE_URL (internal service) | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-02-01 | Tampering | !save name arg used in /workspace/contexts/{name}.md path | mitigate | Sanitize name: only allow [a-zA-Z0-9_-] characters; reject path traversal attempts (names containing "/" or "..") | +| T-04-02-02 | Information Disclosure | !context exposes tokens_used and session names | accept | Single-user prototype; data is the user's own | +| T-04-02-03 | Denial of Service | !load numeric interception could loop if load_pending never clears | mitigate | clear_load_pending on selection (any) or disconnect; pending data is volatile in-memory | +| T-04-02-04 | Spoofing | !yes intercepted by reset_pending could conflict with pending_confirm | mitigate | Reset_pending check in on_room_message before dispatcher — takes priority; documented in code comment | +| T-04-02-05 | Tampering | POST /reset to AGENT_BASE_URL | accept | Internal service URL from env; timeout=5.0 prevents hanging | + + + +Run full suite after both tasks: + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/ -v 2>&1 | tail -30 +``` + +Grep checks: +```bash +# Handlers registered +grep "save\|load\|reset\|context" adapter/matrix/handlers/__init__.py + +# Numeric interception in bot +grep "get_load_pending\|_handle_load_selection" adapter/matrix/bot.py + +# tokens tracking in real.py +grep "set_last_tokens_used" sdk/real.py + +# context_commands module +ls adapter/matrix/handlers/context_commands.py +``` + + + +- `pytest tests/adapter/matrix/test_context_commands.py -v` exits 0 with 7+ tests passing +- `pytest tests/platform/test_prototype_state.py -v` exits 0 (including 4 new tests) +- `pytest tests/ -v` exits 0 +- !save, !load, !reset, !context all registered in register_matrix_handlers +- load_pending and reset_pending helpers exist in adapter/matrix/store.py +- MatrixBot.on_room_message contains numeric interception for !load + + + +After completion, create `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md` + diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md new file mode 100644 index 0000000..06f7f1e --- /dev/null +++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md @@ -0,0 +1,196 @@ +--- +phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma +plan: 03 +type: execute +wave: 2 +depends_on: + - 04-01-PLAN.md +files_modified: + - Dockerfile + - docker-compose.yml + - .env.example +autonomous: true +requirements: + - Dockerfile for Matrix bot + - docker-compose.yml with matrix-bot service + - .env.example updated with AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND + +must_haves: + truths: + - "Dockerfile builds successfully with python:3.11-slim base" + - "lambda_agent_api installed in container despite Python version constraint" + - "PYTHONPATH=/app set so adapter/matrix/bot.py is runnable as module" + - "docker-compose.yml defines matrix-bot service with env_file: .env" + - ".env.example contains AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND=real" + - "CMD runs python -m adapter.matrix.bot" + artifacts: + - path: "Dockerfile" + provides: "Matrix bot container image" + contains: "python:3.11-slim" + - path: "docker-compose.yml" + provides: "Service definition for matrix-bot" + contains: "matrix-bot" + - path: ".env.example" + provides: "Updated env template" + contains: "AGENT_BASE_URL" + key_links: + - from: "Dockerfile" + to: "external/platform-agent_api" + via: "COPY + pip install with --ignore-requires-python" + pattern: "ignore-requires-python" +--- + + +Package the Matrix bot in a Docker container. Create Dockerfile using python:3.11-slim, +install lambda_agent_api from the local external/ directory (bypassing the Python 3.14 +version constraint), and define a docker-compose.yml for running the matrix-bot service. +Update .env.example with new variables. + +Purpose: Enable reproducible MVP deployment of the Matrix bot in a container alongside +the separately-run platform-agent. + +Output: Dockerfile, docker-compose.yml, updated .env.example. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md +@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md + + + + + + Task 1: Create Dockerfile and docker-compose.yml + + + - .env.example (full file — adding new vars) + - external/platform-agent_api/lambda_agent_api/ (ls — verify files to copy) + - pyproject.toml (verify uv is the package manager used) + + + Dockerfile, docker-compose.yml, .env.example + + +1. Check if pyproject.toml uses uv or pip. The project uses `uv sync` per CLAUDE.md. However, in the Docker container we can use pip for simplicity since uv's lockfile-based install may need the lockfile present. Use pip for the base install of surfaces-bot deps, and install lambda_agent_api separately. + + Actually: the project uses uv. Use uv in Docker to be consistent: + - Install uv via pip (pip install uv) + - Run uv sync to install project deps + - Install lambda_agent_api with pip --ignore-requires-python + +2. Create Dockerfile: + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# Install uv +RUN pip install --no-cache-dir uv + +# Copy dependency manifests first for layer caching +COPY pyproject.toml uv.lock* ./ + +# Install project dependencies via uv (no project install yet, just deps) +RUN uv sync --no-install-project --frozen 2>/dev/null || uv sync --no-install-project + +# Copy project source +COPY . . + +# Install the project itself +RUN uv sync --frozen 2>/dev/null || uv sync + +# Install lambda_agent_api, bypassing Python version constraint +RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api + +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "-m", "adapter.matrix.bot"] +``` + +3. Create docker-compose.yml: + +```yaml +services: + matrix-bot: + build: . + env_file: .env + restart: unless-stopped + # platform-agent runs separately — not included in this compose file +``` + +4. Read current .env.example, then append new variables. Current file likely has MATRIX_* vars. Add: + - AGENT_WS_URL=ws://127.0.0.1:8000/agent_ws/ + - AGENT_BASE_URL=http://127.0.0.1:8000 + - MATRIX_PLATFORM_BACKEND=real + + Read .env.example first to see what's there, then write the full updated file. + + + + - `grep "python:3.11-slim" Dockerfile` returns a match + - `grep "ignore-requires-python" Dockerfile` returns a match (lambda_agent_api install) + - `grep "PYTHONPATH=/app" Dockerfile` returns a match + - `grep "adapter.matrix.bot" Dockerfile` returns a match (CMD) + - `grep "matrix-bot" docker-compose.yml` returns a match + - `grep "env_file" docker-compose.yml` returns a match + - `grep "AGENT_BASE_URL" .env.example` returns a match + - `grep "MATRIX_PLATFORM_BACKEND" .env.example` returns a match + + + + grep "python:3.11-slim" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile && grep "ignore-requires-python" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile && grep "AGENT_BASE_URL" /Users/a/MAI/sem2/lambda/surfaces-bot/.env.example && echo "All checks passed" + + + + - Dockerfile exists with python:3.11-slim, uv install, lambda_agent_api pip install --ignore-requires-python, PYTHONPATH=/app, CMD python -m adapter.matrix.bot + - docker-compose.yml exists with matrix-bot service, env_file: .env, restart: unless-stopped + - .env.example contains AGENT_WS_URL, AGENT_BASE_URL, MATRIX_PLATFORM_BACKEND=real + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| container → host env | .env file mounts secrets into container | +| container → platform-agent | Network call to AGENT_WS_URL / AGENT_BASE_URL | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-03-01 | Information Disclosure | .env file with secrets mounted in container | mitigate | .env in .gitignore; .env.example committed with placeholder values only, never real secrets | +| T-04-03-02 | Tampering | lambda_agent_api installed from local path via --ignore-requires-python | accept | Local package under version control; no external supply chain risk | +| T-04-03-03 | Denial of Service | restart: unless-stopped could loop on crash | accept | Expected behavior; operator can `docker compose stop` | + + + +```bash +# Verify files exist and contain expected content +grep "python:3.11-slim" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile +grep "ignore-requires-python" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile +grep "AGENT_BASE_URL" /Users/a/MAI/sem2/lambda/surfaces-bot/.env.example +grep "matrix-bot" /Users/a/MAI/sem2/lambda/surfaces-bot/docker-compose.yml +``` + + + +- Dockerfile, docker-compose.yml, .env.example all exist in project root +- Dockerfile builds without errors when platform-agent_api dir is present (docker build . exits 0) +- .env.example contains AGENT_BASE_URL, AGENT_WS_URL, MATRIX_PLATFORM_BACKEND +- docker-compose.yml service named matrix-bot uses env_file: .env + + + +After completion, create `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md` +