feat(04-02): add matrix context management commands

- add save/load/reset/context handlers and matrix interception flows
- persist current session and last token usage in prototype state
This commit is contained in:
Mikhail Putilovskij 2026-04-17 16:12:03 +03:00
parent da0b76882e
commit b52fdc4670
7 changed files with 638 additions and 21 deletions

View file

@ -19,9 +19,22 @@ from dotenv import load_dotenv
from adapter.matrix.converter import from_room_event
from adapter.matrix.handlers import register_matrix_handlers
from adapter.matrix.handlers.context_commands import (
LOAD_PROMPT,
SAVE_PROMPT,
_call_reset_endpoint,
_sanitize_session_name,
)
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.room_router import resolve_chat_id
from adapter.matrix.store import get_room_meta, set_pending_confirm
from adapter.matrix.store import (
clear_load_pending,
clear_reset_pending,
get_load_pending,
get_reset_pending,
get_room_meta,
set_pending_confirm,
)
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
@ -35,8 +48,8 @@ from core.protocol import (
)
from core.settings import SettingsManager
from core.store import InMemoryStore, SQLiteStore, StateStore
from sdk.agent_session import AgentSessionClient, AgentSessionConfig
from sdk.interface import PlatformClient
from sdk.agent_api_wrapper import AgentApiWrapper
from sdk.interface import PlatformClient, PlatformError
from sdk.mock import MockPlatformClient
from sdk.prototype_state import PrototypeStateStore
from sdk.real import RealPlatformClient
@ -60,11 +73,20 @@ def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> Event
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
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")
dispatcher = EventDispatcher(
platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr
)
register_all(dispatcher)
register_matrix_handlers(dispatcher, store=store)
register_matrix_handlers(
dispatcher,
store=store,
agent_api=agent_api,
prototype_state=prototype_state,
agent_base_url=agent_base_url,
)
return dispatcher
@ -73,7 +95,7 @@ def _build_platform_from_env() -> PlatformClient:
if backend == "real":
ws_url = os.environ["AGENT_WS_URL"]
return RealPlatformClient(
agent_sessions=AgentSessionClient(AgentSessionConfig(base_ws_url=ws_url)),
agent_api=AgentApiWrapper(agent_id="matrix-bot", url=ws_url),
prototype_state=PrototypeStateStore(),
platform="matrix",
)
@ -90,11 +112,21 @@ def build_runtime(
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
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")
dispatcher = EventDispatcher(
platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr
)
register_all(dispatcher)
register_matrix_handlers(dispatcher, client=client, store=store)
register_matrix_handlers(
dispatcher,
client=client,
store=store,
agent_api=agent_api,
prototype_state=prototype_state,
agent_base_url=agent_base_url,
)
return MatrixRuntime(
platform=platform,
store=store,
@ -113,13 +145,118 @@ class MatrixBot:
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
if getattr(event, "sender", None) == self.client.user_id:
return
chat_id = await resolve_chat_id(self.runtime.store, room.room_id, event.sender)
sender = getattr(event, "sender", None)
body = (getattr(event, "body", None) or "").strip()
load_pending = await get_load_pending(self.runtime.store, sender, room.room_id)
if load_pending is not None and (body.isdigit() or body == "!cancel"):
outgoing = await self._handle_load_selection(sender, room.room_id, body, load_pending)
await self._send_all(room.room_id, outgoing)
return
reset_pending = await get_reset_pending(self.runtime.store, sender, room.room_id)
if reset_pending is not None and (body in {"!yes", "!no"} or body.startswith("!save ")):
outgoing = await self._handle_reset_selection(sender, room.room_id, body)
await self._send_all(room.room_id, outgoing)
return
chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
incoming = from_room_event(event, room_id=room.room_id, chat_id=chat_id)
if incoming is None:
return
outgoing = await self.runtime.dispatcher.dispatch(incoming)
try:
outgoing = await self.runtime.dispatcher.dispatch(incoming)
except PlatformError as exc:
logger.warning(
"matrix_message_platform_error",
room_id=room.room_id,
sender=getattr(event, "sender", None),
code=exc.code,
error=str(exc),
)
outgoing = [
OutgoingMessage(
chat_id=chat_id,
text="Сервис временно недоступен. Попробуйте ещё раз позже."
)
]
await self._send_all(room.room_id, outgoing)
async def _handle_load_selection(
self,
user_id: str,
room_id: str,
text: str,
pending: dict,
) -> list[OutgoingEvent]:
saves = pending.get("saves", [])
if text in {"0", "!cancel"}:
await clear_load_pending(self.runtime.store, user_id, room_id)
return [OutgoingMessage(chat_id=room_id, text="Отменено.")]
index = int(text) - 1
if index < 0 or index >= len(saves):
return [
OutgoingMessage(
chat_id=room_id,
text=f"Неверный номер. Введи от 1 до {len(saves)} или 0 для отмены.",
)
]
name = saves[index]["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)
try:
await self.runtime.platform.send_message(
user_id,
room_id,
LOAD_PROMPT.format(name=name),
)
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}")]
async def _handle_reset_selection(
self,
user_id: str,
room_id: str,
text: str,
) -> list[OutgoingEvent]:
agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")
prototype_state = getattr(self.runtime.platform, "_prototype_state", None)
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 = _sanitize_session_name(text[len("!save ") :].strip())
if name is None:
return [
OutgoingMessage(
chat_id=room_id,
text="Имя сохранения может содержать только буквы, цифры, _ и -.",
)
]
try:
await self.runtime.platform.send_message(
user_id,
room_id,
SAVE_PROMPT.format(name=name),
)
if prototype_state is not None:
await prototype_state.add_saved_session(user_id, name)
except Exception as exc:
logger.warning("save_before_reset_failed", error=str(exc))
return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при сохранении: {exc}")]
if prototype_state is not None:
await prototype_state.clear_current_session(user_id)
return await _call_reset_endpoint(agent_base_url, room_id)
async def on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
if getattr(event, "sender", None) == self.client.user_id:
return
@ -236,8 +373,12 @@ async def main() -> None:
request_timeout=client_config.request_timeout,
)
try:
if isinstance(runtime.platform, RealPlatformClient):
await runtime.platform.agent_api.connect()
await client.sync_forever(timeout=30000, since=since_token)
finally:
if isinstance(runtime.platform, RealPlatformClient):
await runtime.platform.agent_api.close()
await client.close()

View file

@ -7,6 +7,12 @@ from adapter.matrix.handlers.chat import (
make_handle_rename,
)
from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm
from adapter.matrix.handlers.context_commands import (
make_handle_context,
make_handle_load,
make_handle_reset,
make_handle_save,
)
from adapter.matrix.handlers.settings import (
handle_help,
handle_settings,
@ -23,7 +29,14 @@ from core.handler import EventDispatcher
from core.protocol import IncomingCallback, IncomingCommand
def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None:
def register_matrix_handlers(
dispatcher: EventDispatcher,
client=None,
store=None,
agent_api=None,
prototype_state=None,
agent_base_url: str = "http://127.0.0.1:8000",
) -> None:
dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store))
dispatcher.register(IncomingCommand, "chats", handle_list_chats)
dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store))
@ -41,3 +54,9 @@ def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=Non
dispatcher.register(IncomingCallback, "confirm", make_handle_confirm(store))
dispatcher.register(IncomingCallback, "cancel", make_handle_cancel(store))
dispatcher.register(IncomingCallback, "toggle_skill", handle_toggle_skill)
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))

View file

@ -0,0 +1,172 @@
from __future__ import annotations
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING
import httpx
import structlog
from adapter.matrix.store import set_load_pending, set_reset_pending
from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage
if TYPE_CHECKING:
from core.store import StateStore
from sdk.prototype_state import PrototypeStateStore
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}"
)
_VALID_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
def _sanitize_session_name(raw_name: str) -> str | None:
name = raw_name.strip()
if not name or not _VALID_NAME.fullmatch(name):
return None
return name
async def _resolve_room_id(event: IncomingCommand, chat_mgr) -> str:
if chat_mgr is None:
return event.chat_id
ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id)
if ctx is not None and ctx.surface_ref:
return ctx.surface_ref
return event.chat_id
def make_handle_save(agent_api, store: "StateStore", prototype_state: "PrototypeStateStore"):
async def handle_save(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]:
if event.args:
name = _sanitize_session_name(event.args[0])
if name is None:
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Имя сохранения может содержать только буквы, цифры, _ и -.",
)
]
else:
name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}"
try:
await platform.send_message(
event.user_id,
event.chat_id,
SAVE_PROMPT.format(name=name),
)
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]:
sessions = await prototype_state.list_saved_sessions(event.user_id)
if not sessions:
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Нет сохранённых сессий. Используй !save [имя].",
)
]
room_id = await _resolve_room_id(event, chat_mgr)
lines = ["Сохранённые сессии:"]
for index, session in enumerate(sessions, start=1):
created = session.get("created_at", "")[:10]
lines.append(f" {index}. {session['name']} ({created})")
lines.append("")
lines.append("Введи номер или 0 / !cancel для отмены.")
await set_load_pending(store, event.user_id, room_id, {"saves": sessions})
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
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]:
room_id = await _resolve_room_id(event, chat_mgr)
await set_reset_pending(store, event.user_id, room_id, {"active": True})
return [
OutgoingMessage(
chat_id=event.chat_id,
text=(
"Сбросить контекст агента? Выбери:\n"
" !yes - сбросить\n"
" !save [имя] - сохранить и сбросить\n"
" !no - отмена"
),
)
]
return handle_reset
async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[OutgoingEvent]:
try:
async with httpx.AsyncClient() as client:
response = await client.post(f"{agent_base_url}/reset", timeout=5.0)
except (httpx.ConnectError, httpx.TimeoutException) as exc:
logger.warning("reset_endpoint_unreachable", error=str(exc))
return [
OutgoingMessage(
chat_id=chat_id,
text="Reset endpoint недоступен. Обратитесь к администратору.",
)
]
if response.status_code == 404:
return [
OutgoingMessage(
chat_id=chat_id,
text="Reset endpoint недоступен. Обратитесь к администратору.",
)
]
return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")]
def make_handle_context(store: "StateStore", prototype_state: "PrototypeStateStore"):
async def handle_context(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]:
current_session = await prototype_state.get_current_session(event.user_id)
tokens_used = await prototype_state.get_last_tokens_used(event.user_id)
sessions = await prototype_state.list_saved_sessions(event.user_id)
lines = [
"Контекст:",
f" Сессия: {current_session or 'не загружена'}",
f" Токены (последний ответ): {tokens_used}",
f" Сохранения ({len(sessions)}):",
]
if sessions:
for session in sessions:
created = session.get("created_at", "")[:10]
lines.append(f" - {session['name']} ({created})")
else:
lines.append(" (нет)")
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
return handle_context