surfaces/adapter/matrix/handlers/context_commands.py
Mikhail Putilovskij 85e2fda6bc feat(05-02): ship room-local clear semantics
- register clear as the room-context reset entrypoint when supported
- keep save and context bound to room platform chat ids and clear old upstream state
2026-04-28 01:15:39 +03:00

230 lines
8.5 KiB
Python

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 (
get_room_meta,
next_platform_chat_id,
set_load_pending,
set_platform_chat_id,
)
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
async def _resolve_context_scope(
event: IncomingCommand,
store: StateStore,
chat_mgr,
) -> tuple[str, str | None]:
room_id = await _resolve_room_id(event, chat_mgr)
room_meta = await get_room_meta(store, room_id)
platform_chat_id = room_meta.get("platform_chat_id") if room_meta else None
return room_id, platform_chat_id
async def _require_platform_context(
event: IncomingCommand,
store: StateStore,
chat_mgr,
) -> tuple[str, str]:
room_id, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr)
if not platform_chat_id:
raise RuntimeError(f"matrix room context is incomplete: {room_id}")
return room_id, platform_chat_id
def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeStateStore):
async def handle_save(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> 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}")]
try:
_, platform_chat_id = await _require_platform_context(event, store, chat_mgr)
except RuntimeError as exc:
logger.warning("save_context_incomplete", error=str(exc))
return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")]
await prototype_state.add_saved_session(
event.user_id,
name,
source_context_id=platform_chat_id,
)
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_context_scope(event, store, 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, prototype_state: PrototypeStateStore):
async def handle_reset(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]:
try:
room_id, old_chat_id = await _require_platform_context(event, store, chat_mgr)
except RuntimeError as exc:
logger.warning("clear_context_incomplete", error=str(exc))
return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")]
new_chat_id = await next_platform_chat_id(store)
await set_platform_chat_id(store, room_id, new_chat_id)
disconnect = getattr(platform, "disconnect_chat", None)
if callable(disconnect):
await disconnect(old_chat_id)
await prototype_state.clear_current_session(old_chat_id)
await prototype_state.clear_current_session(new_chat_id)
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Контекст сброшен. Агент не помнит предыдущий разговор.",
)
]
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]:
try:
_, platform_chat_id = await _require_platform_context(event, store, chat_mgr)
except RuntimeError as exc:
logger.warning("context_scope_incomplete", error=str(exc))
return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")]
current_session = await prototype_state.get_current_session(platform_chat_id)
tokens_used = await prototype_state.get_last_tokens_used(platform_chat_id)
sessions = await prototype_state.list_saved_sessions(event.user_id)
lines = [
"Контекст:",
f" Контекст чата: {platform_chat_id}",
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