surfaces/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md

865 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<interfaces>
<!-- Key contracts executor needs. Read source files before touching anything. -->
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")`
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Extend PrototypeStateStore and Matrix store with pending state helpers</name>
<read_first>
- 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)
</read_first>
<files>sdk/prototype_state.py, adapter/matrix/store.py, tests/platform/test_prototype_state.py</files>
<behavior>
- 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
</behavior>
<action>
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
```
</action>
<verify>
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_prototype_state.py -v 2>&1 | tail -15</automated>
</verify>
<done>
- 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
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement context_commands.py handlers, wire into __init__.py and bot.py, update tokens_used tracking in real.py</name>
<read_first>
- 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)
</read_first>
<files>
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
</files>
<behavior>
- 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
</behavior>
<action>
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
```
</action>
<verify>
<automated>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</automated>
</verify>
<done>
- 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
</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
```
</verification>
<success_criteria>
- `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
</success_criteria>
<output>
After completion, create `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md`
</output>