"""Conversations, threads and the one place a turn runs (§3.1, §3.4, §8.2-8.3). The service owns the rows (:class:`Conversation`, :class:`ConversationBinding`), the per-conversation queue workers, the fork/spawn/read/inject/say/schedule API that frontends, jobs and the in-process MCP tools all call, and the restart recovery: a conversation with ``running_turn`` set at startup gets its open ``tool_use`` closed in the transcript and an inject saying the turn was cut. Routing is deterministic (§0.1): a turn started by a user message streams back to whoever asked; a turn started by an inject streams nowhere - its events still hit the bus flagged ``origin="inject"`` so the panel can show activity, but the only way it speaks is ``say``. """ from __future__ import annotations import asyncio import contextlib import inspect import json import logging import re import uuid from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, cast from claude_agent_sdk import ( AssistantMessage, RateLimitEvent, ResultMessage, StreamEvent, ToolResultBlock, ToolUseBlock, UserMessage, fork_session_via_store, project_key_for_directory, ) from sqlmodel import col, select from beaver_gateway.core.conversation_store import load_messages from beaver_gateway.core.injects import InjectQueue, inject_header from beaver_gateway.core.kinds import KINDS, Kind, as_kind from beaver_gateway.core.transcript import ( messages_from_entries, render_messages, strip_tool_entries, text_of, window_entries, ) from beaver_gateway.core.turn_capture import TurnCapture from beaver_gateway.frontends._accumulate import StreamAccumulator from beaver_gateway.storage.models import ( Conversation, ConversationBinding, ConversationMessage, InjectQueueItem, RateLimit, Usage, ) if TYPE_CHECKING: from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from claude_agent_sdk import SessionStore from beaver_gateway.agents.claude import ClaudeAgent from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend from beaver_gateway.core.bus import EventBus from beaver_gateway.core.envelope import Envelope from beaver_gateway.core.events import MessageStreamEvent from beaver_gateway.core.injects import Priority from beaver_gateway.core.registry import AgentRegistry from beaver_gateway.core.rotation import HandoutContext from beaver_gateway.core.scheduler import Scheduler from beaver_gateway.core.sessions import SessionPool from beaver_gateway.frontends.base import Frontend from beaver_gateway.storage.db import Database __all__ = [ "KINDS", "SEEDS", "ConversationTexts", "Conversations", "ForkResult", "SeedContext", ] _log = logging.getLogger("beaver_gateway.core.conversations") SEEDS = ("clean", "morning", "copy", "brief") _STATUSES = ("open", "merged", "closed", "archived") _DEFAULT_MERGE_PROMPT = ( "Ветка закрывается. Напиши слив для мастера: что решили, что сделано, " "что не сделано и почему, открытые вопросы. Идентификаторы и ссылки - " "дословно. Коротко, прошедшее время." ) _RELATIVE = re.compile(r"^\+(\d+)\s*([smhd])$") _UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400} @dataclass(frozen=True, slots=True) class SeedContext: kind: Kind seed: str agent: str parent: Conversation | None text: str | None title: str | None @dataclass(frozen=True, slots=True) class ConversationTexts: """Texts the gateway cannot invent for a setup. What a merge asks for and what a seed says; ``seed`` may return the body for any seed mode (the morning handout lives in the vault the gateway knows nothing about). """ merge_prompt: str = _DEFAULT_MERGE_PROMPT interrupted: str = "прервано" answered: str = "Пользователь ответил: {answer}" unanswered: str = ( "Пользователь не ответил за {minutes} мин. Вопрос ему показан текстом; " "заверши тёрн сейчас, ответ придёт следующим сообщением." ) seed: Callable[[SeedContext], Awaitable[str | None] | str | None] | None = None handout: Callable[[HandoutContext], Awaitable[str] | str] | str = ( "Этот мастер закрывается ({reason}). Напиши хендаут за {day}: справку " "на утро, не задание - прошедшее время, без повелительного наклонения." ) new_day: str = "Новый день: мастер сменился, хендаут за {day} записан." @dataclass(frozen=True, slots=True) class ForkResult: conversation: Conversation text: str capture: TurnCapture @dataclass class _Runner: lock: asyncio.Lock = field(default_factory=asyncio.Lock) wake: asyncio.Event = field(default_factory=asyncio.Event) task: asyncio.Task[None] | None = None turn_id: str | None = None origin: str | None = None text: str | None = None started_at: datetime | None = None tools: dict[str, dict[str, Any]] = field(default_factory=dict) """Tool calls of the running turn, in order; ``describe`` hands them to a panel that subscribed mid-turn.""" def snapshot(self) -> dict[str, Any] | None: if self.turn_id is None: return None return { "id": self.turn_id, "origin": self.origin, "text": self.text, "started_at": _iso(self.started_at), "tools": list(self.tools.values()), } @dataclass(frozen=True, slots=True) class _Question: conversation_id: str turn_id: str | None questions: list[dict[str, Any]] answer: asyncio.Future[str] class Conversations: def __init__( self, *, db: Database, agents: AgentRegistry, backends: dict[str, Any], bus: EventBus, pool: SessionPool, store: SessionStore, texts: ConversationTexts | None = None, frontends: Sequence[Frontend] = (), normal_window: float = 3600.0, idle_days: Sequence[int] = (2,), idle_interval: float = 3600.0, question_timeout: float = 600.0, envelope: Envelope | None = None, ) -> None: self._db = db self._agents = agents self._backends = backends self._bus = bus self._pool = pool self._store = store self._texts = texts or ConversationTexts() self._frontends = [f for f in frontends if f.name] self._normal_window = normal_window self._idle_days = tuple(sorted(idle_days)) self._idle_interval = idle_interval self._question_timeout = question_timeout self._envelope = envelope self.scheduler: Scheduler | None = None self._questions: dict[str, _Question] = {} self._queue = InjectQueue(db) self._runners: dict[int, _Runner] = {} self._tasks: set[asyncio.Task[None]] = set() self._idle_task: asyncio.Task[None] | None = None @property def queue(self) -> InjectQueue: return self._queue @property def bus(self) -> EventBus: return self._bus @property def pool(self) -> SessionPool: return self._pool # ---- rows ---------------------------------------------------------- async def create( self, *, kind: Kind, agent: str, parent: Conversation | None = None, title: str | None = None, origin: str = "api", session_id: str | None = None, flags: dict[str, Any] | None = None, ) -> Conversation: if kind not in KINDS: msg = f"unknown conversation kind {kind!r}" raise ValueError(msg) if not self._claude_agent(agent).serves(kind): msg = f"agent {agent!r} does not serve kind {kind!r}" raise ValueError(msg) now = datetime.now(UTC) row = Conversation( frontend=origin, external_id=str(uuid.uuid4()), agent_name=agent, kind=kind, parent_id=parent.id if parent is not None else None, title=title, session_id=session_id, flags=dict(flags or {}), last_activity_at=now, ) async with self._db.session() as session: session.add(row) await session.commit() await session.refresh(row) self._bus.publish("conversation.created", **self.public(row)) return row async def get(self, public_id: str) -> Conversation | None: async with self._db.session() as session: result = await session.exec( select(Conversation).where(Conversation.external_id == public_id) ) return result.first() async def get_row(self, row_id: int) -> Conversation | None: async with self._db.session() as session: return await session.get(Conversation, row_id) async def find( self, *, status: str | None = None, kind: str | None = None, parent: Conversation | None = None, limit: int = 200, ) -> list[Conversation]: stmt = select(Conversation).order_by(col(Conversation.id).desc()).limit(limit) if status is not None: stmt = stmt.where(Conversation.status == status) if kind is not None: stmt = stmt.where(Conversation.kind == kind) if parent is not None: stmt = stmt.where(Conversation.parent_id == parent.id) async with self._db.session() as session: return list((await session.exec(stmt)).all()) async def bindings(self, conv: Conversation) -> list[ConversationBinding]: async with self._db.session() as session: result = await session.exec( select(ConversationBinding) .where(ConversationBinding.conversation_id == conv.id) .order_by(col(ConversationBinding.id)) ) return list(result.all()) async def bind( self, conv: Conversation, *, frontend: str, external_id: str, visible: bool = True, ) -> ConversationBinding: if conv.kind not in self.frontend(frontend).kinds: msg = f"frontend {frontend!r} does not show kind {conv.kind!r}" raise ValueError(msg) async with self._db.session() as session: existing = list( ( await session.exec( select(ConversationBinding).where( ConversationBinding.conversation_id == conv.id, ConversationBinding.frontend == frontend, ) ) ).all() ) row = next((b for b in existing if b.external_id == external_id), None) if visible: for other in existing: if other is not row and other.visible: other.visible = False session.add(other) same_window = await session.exec( select(ConversationBinding).where( ConversationBinding.frontend == frontend, ConversationBinding.external_id == external_id, ConversationBinding.conversation_id != conv.id, col(ConversationBinding.visible).is_(True), ) ) for other in same_window.all(): other.visible = False session.add(other) if row is None: row = ConversationBinding( conversation_id=cast("int", conv.id), frontend=frontend, external_id=external_id, visible=visible, ) else: row.visible = visible session.add(row) await session.commit() await session.refresh(row) self._bus.publish( "conversation.bound", conversation_id=conv.external_id, frontend=frontend, external_id=external_id, visible=visible, ) return row async def find_bound( self, *, frontend: str, external_id: str ) -> Conversation | None: async with self._db.session() as session: result = await session.exec( select(Conversation) .join( ConversationBinding, col(ConversationBinding.conversation_id) == col(Conversation.id), ) .where( ConversationBinding.frontend == frontend, ConversationBinding.external_id == external_id, col(ConversationBinding.visible).is_(True), ) .order_by(col(Conversation.id).desc()) ) return result.first() async def last_binding( self, *, frontend: str, kind: str ) -> ConversationBinding | None: """The window ``frontend`` last used for a conversation of ``kind``. A frontend whose window for the master outlives the master itself (the Telegram General topic) finds it here after a rotation. """ async with self._db.session() as session: result = await session.exec( select(ConversationBinding) .join( Conversation, col(Conversation.id) == col(ConversationBinding.conversation_id), ) .where( ConversationBinding.frontend == frontend, Conversation.kind == kind ) .order_by(col(ConversationBinding.id).desc()) ) return result.first() async def set_flags( self, conv: Conversation, flags: dict[str, Any] ) -> Conversation: async def apply(row: Conversation) -> None: row.flags = {**row.flags, **flags} return await self._update(conv, apply) async def set_status(self, conv: Conversation, status: str) -> Conversation: if status not in _STATUSES: msg = f"unknown status {status!r}" raise ValueError(msg) async def apply(row: Conversation) -> None: row.status = status return await self._update(conv, apply) async def set_title(self, conv: Conversation, title: str) -> Conversation: async def apply(row: Conversation) -> None: row.title = title return await self._update(conv, apply) async def touch_user(self, conv: Conversation) -> Conversation: async def apply(row: Conversation) -> None: row.last_user_activity_at = datetime.now(UTC) return await self._update(conv, apply) async def _update( self, conv: Conversation, apply: Callable[[Conversation], Awaitable[None]] ) -> Conversation: async with self._db.session() as session: row = await session.get(Conversation, conv.id) if row is None: msg = f"conversation {conv.external_id} vanished" raise LookupError(msg) await apply(row) row.updated_at = datetime.now(UTC) session.add(row) await session.commit() await session.refresh(row) self._bus.publish("conversation.updated", **self.public(row)) return row def public(self, conv: Conversation) -> dict[str, Any]: return { "id": conv.external_id, "kind": conv.kind, "agent": conv.agent_name, "title": conv.title, "status": conv.status, "parent_row": conv.parent_id, "session_id": conv.session_id, "running_turn": conv.running_turn, "pending_question": conv.pending_question, "flags": conv.flags, "origin": conv.frontend, "created_at": _iso(conv.created_at), "last_user_activity_at": _iso(conv.last_user_activity_at), "last_activity_at": _iso(conv.last_activity_at), } async def describe(self, conv: Conversation) -> dict[str, Any]: out = self.public(conv) out["title"] = await self.implied_title(conv) parent = await self.get_row(conv.parent_id) if conv.parent_id else None out["parent"] = parent.external_id if parent is not None else None out["bindings"] = [ {"frontend": b.frontend, "external_id": b.external_id, "visible": b.visible} for b in await self.bindings(conv) ] live = self._pool.get(conv.external_id) out["live"] = live is not None out["busy"] = live.busy if live is not None else False runner = self._runners.get(cast("int", conv.id)) out["turn"] = runner.snapshot() if runner is not None else None pending = self.pending_question(conv.external_id) out["question"] = ( {"id": pending[0], "questions": pending[1]} if pending else None ) return out async def rate_limits(self, *, limit: int = 100) -> list[RateLimit]: async with self._db.session() as session: result = await session.exec( select(RateLimit).order_by(col(RateLimit.id).desc()).limit(limit) ) return list(result.all()) async def context_tokens(self, conv: Conversation) -> int: """Size of the context the last turn ran with, from its usage row.""" async with self._db.session() as session: row = ( await session.exec( select(Usage) .where(Usage.conversation_id == conv.external_id) .order_by(col(Usage.id).desc()) .limit(1) ) ).first() if row is None: return 0 return row.input_tokens + row.cache_read_tokens + row.cache_creation_tokens async def usage_tokens(self, since: datetime) -> int: async with self._db.session() as session: rows = ( await session.exec( select(Usage).where( col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None) ) ) ).all() return sum( r.input_tokens + r.output_tokens + r.cache_creation_tokens for r in rows ) async def busy(self, conv: Conversation) -> bool: """A turn is running, a question is open or a message waits to run.""" row = await self.get_row(cast("int", conv.id)) or conv if row.running_turn or row.pending_question: return True live = self._pool.get(row.external_id) if live is not None and live.busy: return True pending = await self._queue.pending(cast("int", row.id)) return any(i.priority in ("user", "urgent") for i in pending) # ---- routing ------------------------------------------------------- @property def frontends(self) -> list[Frontend]: return list(self._frontends) def frontend(self, name: str) -> Frontend: for fe in self._frontends: if fe.name == name: return fe msg = f"unknown frontend {name!r}" raise ValueError(msg) def default_agent(self, kind: Kind) -> str | None: for fe in self._frontends: if kind in fe.kinds and (agent := fe.agent_for(kind)): return agent return None async def materialize(self, conv: Conversation) -> ConversationBinding | None: for fe in self._frontends: if conv.kind not in fe.kinds: continue binding = await fe.materialize(conv) if binding is not None: return binding return None # ---- §3.1 api ------------------------------------------------------ async def spawn( self, *, kind: Kind, agent: str | None = None, seed: str = "clean", parent: Conversation | None = None, text: str | None = None, title: str | None = None, window: int | None = None, origin: str = "api", binding: tuple[str, str] | None = None, ) -> Conversation: """Create a conversation and queue its seed turn (§8.2). ``binding`` = ``(frontend, external_id)`` puts it into a window that already exists (a topic the user created) instead of asking the home frontend to ``materialize`` one. ``text`` rides with the seed as the first thing the user said, whatever the seed mode; without it the seed waits in ``flags`` and opens the first turn, so a fresh window stays silent and costs nothing until someone speaks. """ if seed not in SEEDS: msg = f"unknown seed {seed!r}" raise ValueError(msg) if seed == "brief" and not text: msg = "seed=brief needs text" raise ValueError(msg) if agent is None and kind == "branch" and parent is not None: agent = parent.agent_name if kind == "branch" and parent is not None and parent.kind == "master": await self.set_flags(parent, {"streak": 0}) agent = agent or self.default_agent(kind) if agent is None: msg = f"no default agent for kind {kind!r}; pass `agent`" raise ValueError(msg) session_id: str | None = None if seed == "copy": if parent is None or parent.session_id is None: msg = "seed=copy needs a parent with a session" raise ValueError(msg) session_id = await self._copy_session( parent, window=window, strip_tools=False ) conv = await self.create( kind=kind, agent=agent, parent=parent, title=title, origin=origin, session_id=session_id, ) if binding is not None: await self.bind(conv, frontend=binding[0], external_id=binding[1]) else: await self.materialize(conv) ctx = SeedContext( kind=kind, seed=seed, agent=agent, parent=parent, text=text, title=title ) if text is None: return await self.set_flags(conv, {"seed": seed, "seed_window": window}) await self._queue.push( conversation_id=cast("int", conv.id), priority="user", origin=f"сид:{seed}" if seed == "brief" else origin, text=await self._seed_text(ctx, window=window), ) self._ensure_worker(cast("int", conv.id)) return conv async def _pending_seed(self, conv: Conversation) -> str | None: """A seed nobody has spoken after yet: rendered now, spent once.""" seed = conv.flags.get("seed") if not seed: return None parent = await self.get_row(conv.parent_id) if conv.parent_id else None ctx = SeedContext( kind=as_kind(conv.kind), seed=str(seed), agent=conv.agent_name, parent=parent, text=None, title=conv.title, ) window = conv.flags.get("seed_window") text = await self._seed_text( ctx, window=window if isinstance(window, int) else None ) await self.set_flags(conv, {"seed": None, "seed_window": None}) return text async def fork( self, conv: Conversation, prompt: str, *, window: int | None = None, strip_tools: bool = False, title: str | None = None, ) -> ForkResult: session_id = await self._copy_session( conv, window=window, strip_tools=strip_tools ) child = await self.create( kind="fork", agent=conv.agent_name, parent=conv, title=title or f"fork: {conv.title or conv.external_id}", origin="system", session_id=session_id, ) try: text, capture = await self.run_text_turn( child, prompt, origin="fork", tools=False ) finally: await self._backend(conv.agent_name).close(child.external_id) child = await self.set_status(child, "closed") return ForkResult(conversation=child, text=text, capture=capture) async def read(self, conv: Conversation, *, window: int | None = None) -> str: return render_messages(await self.history(conv), window=window) async def history(self, conv: Conversation) -> list[dict[str, Any]]: if conv.session_id is None: async with self._db.session() as session: return await load_messages( session, conversation_id=cast("int", conv.id) ) return messages_from_entries(cast("Any", await self.entries(conv))) async def first_user_texts(self, ids: Iterable[int]) -> dict[int, str]: wanted = list(ids) if not wanted: return {} async with self._db.session() as session: rows = ( await session.exec( select(ConversationMessage).where( col(ConversationMessage.conversation_id).in_(wanted), ConversationMessage.seq == 0, ConversationMessage.role == "user", ) ) ).all() return { r.conversation_id: text_of(json.loads(r.content_json)).strip() for r in rows } async def implied_title(self, conv: Conversation) -> str | None: if conv.title: return conv.title text = (await self.first_user_texts([cast("int", conv.id)])).get( cast("int", conv.id) ) return implied_title(text) async def adopt(self, *, kind: Kind, first_user_text: str) -> Conversation | None: """The one unbound, session-less conversation whose history starts here. Rows from before the SDK cut-over have canonical messages but no window and no session; a vault file that begins with the same prompt is that conversation continued. """ text = first_user_text.strip() if not text: return None bound = select(ConversationBinding.conversation_id).where( col(ConversationBinding.visible).is_(True) ) async with self._db.session() as session: rows = ( await session.exec( select(Conversation).where( Conversation.kind == kind, Conversation.status == "open", col(Conversation.session_id).is_(None), col(Conversation.id).not_in(bound), ) ) ).all() firsts = await self.first_user_texts(cast("int", r.id) for r in rows) hits = [r for r in rows if firsts.get(cast("int", r.id)) == text] return hits[0] if len(hits) == 1 else None async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]: if conv.session_id is None: return [] key = {**self._store_key(conv), "subpath": subpath} return list(await self._store.load(cast("Any", key)) or []) async def subpaths(self, conv: Conversation) -> list[str]: if conv.session_id is None: return [] return list(await self._store.list_subkeys(cast("Any", self._store_key(conv)))) async def inject( self, conv: Conversation, text: str, *, urgency: Priority = "normal", origin: str = "system", interrupt: bool = True, ) -> InjectQueueItem: item = await self._queue.push( conversation_id=cast("int", conv.id), priority=urgency, origin=origin, text=text, ) self._bus.publish( "inject.queued", conversation_id=conv.external_id, item=item.id, priority=urgency, origin=origin, ) if urgency == "urgent" and interrupt: backend = self._backend(conv.agent_name) if await backend.interrupt(conv.external_id): _log.info( "conversation %s: interrupted for urgent inject", conv.external_id ) self._ensure_worker(cast("int", conv.id)) return item async def post( self, conv: Conversation, text: str, *, origin: str = "user" ) -> InjectQueueItem: item = await self._queue.push( conversation_id=cast("int", conv.id), priority="user", origin=origin, text=text, ) await self.touch_user(conv) self._bus.publish( "message.queued", conversation_id=conv.external_id, item=item.id, origin=origin, ) self._ensure_worker(cast("int", conv.id)) return item async def say(self, conv: Conversation, text: str) -> dict[str, Any]: runner = self._runners.get(cast("int", conv.id)) _log.info("say[%s]: %s", conv.external_id, text[:200]) return self._bus.publish( "say", conversation_id=conv.external_id, text=text, turn_id=runner.turn_id if runner is not None else None, ) async def merge(self, conv: Conversation) -> ForkResult: if conv.parent_id is None: msg = "merge needs a parent conversation" raise ValueError(msg) parent = await self.get_row(conv.parent_id) if parent is None: msg = "parent conversation vanished" raise LookupError(msg) result = await self.fork( conv, self._texts.merge_prompt, title=f"слив: {conv.title or conv.external_id}", ) if result.text.strip(): await self.inject(parent, result.text, urgency="normal", origin="слив") await self.set_status(conv, "merged") self._bus.publish( "conversation.merged", conversation_id=conv.external_id, parent=parent.external_id, fork=result.conversation.external_id, ) return result async def schedule( self, conv: Conversation, at: str, text: str, *, dedupe_key: str | None = None ) -> tuple[int | None, datetime]: if self.scheduler is None: msg = "no scheduler; `schedule` is unavailable" raise RuntimeError(msg) return await self.scheduler.schedule(conv, at, text, dedupe_key=dedupe_key) async def schedules(self, conv: Conversation | None = None) -> list[dict[str, Any]]: return await self.scheduler.scheduled(conv) if self.scheduler else [] # ---- §4.5 rotation ------------------------------------------------- async def handout(self, conv: Conversation, ctx: HandoutContext) -> str: """The closing master's last turn: the handout prompt from the config.""" source = self._texts.handout if isinstance(source, str): prompt = source.format(day=ctx.day.isoformat(), reason=ctx.reason) else: produced: Any = source(ctx) prompt = await produced if inspect.isawaitable(produced) else produced self._bus.publish( "handout.start", conversation_id=conv.external_id, day=ctx.day.isoformat() ) try: text, _ = await self.run_text_turn(conv, prompt, origin="handout") except Exception: # noqa: BLE001 _log.exception("handout turn on %s failed", conv.external_id) text = "" self._bus.publish( "handout.end", conversation_id=conv.external_id, day=ctx.day.isoformat(), text=text[:2000], ) return text async def close(self, conv: Conversation) -> Conversation: row = await self.set_status(conv, "closed") with contextlib.suppress(LookupError): await self._backend(conv.agent_name).close(conv.external_id) return row async def reparent(self, conv: Conversation, parent: Conversation) -> Conversation: async def apply(row: Conversation) -> None: row.parent_id = parent.id return await self._update(conv, apply) async def mark_closed(self, conv: Conversation) -> bool: marked = False for fe in self._frontends: if conv.kind in fe.kinds: try: marked = await fe.mark_closed(conv) or marked except Exception: # noqa: BLE001 _log.exception("%s could not mark %s", fe.name, conv.external_id) return marked async def new_day(self, conv: Conversation, *, moved: int = 0) -> InjectQueueItem: day = datetime.now(UTC).astimezone().date().isoformat() text = self._texts.new_day.format(day=day) if moved: text += f" Инжектов переехало из старого мастера: {moved}." return await self.inject( conv, text, urgency="urgent", origin="ротация", interrupt=False ) # ---- §3.7 questions ------------------------------------------------ async def ask(self, key: str, payload: dict[str, Any]) -> str | None: """``AskUserQuestion`` reached ``can_use_tool``: show it, wait for the answer. Returns the answer text, or ``None`` when nobody answered within ``question_timeout`` - the caller then tells the model to finish the turn, the frontend has already rendered the question as text. """ conv = await self.get(key) if conv is None: return None runner = self._runners.get(cast("int", conv.id)) question_id = f"q_{uuid.uuid4().hex[:10]}" pending = _Question( conversation_id=key, turn_id=runner.turn_id if runner is not None else None, questions=list(payload.get("questions") or []), answer=asyncio.get_running_loop().create_future(), ) self._questions[question_id] = pending await self._set_pending_question(conv, value=True) self._bus.publish( "question", conversation_id=key, turn_id=pending.turn_id, question_id=question_id, questions=pending.questions, timeout=self._question_timeout, ) try: async with asyncio.timeout(self._question_timeout): answer = await pending.answer except TimeoutError: self._bus.publish( "question.timeout", conversation_id=key, turn_id=pending.turn_id, question_id=question_id, ) return None finally: self._questions.pop(question_id, None) await self._set_pending_question(conv, value=False) self._bus.publish( "question.answered", conversation_id=key, turn_id=pending.turn_id, question_id=question_id, answer=answer, ) return answer def answer(self, question_id: str, answer: str) -> bool: pending = self._questions.get(question_id) if pending is None or pending.answer.done(): return False pending.answer.set_result(answer) return True def pending_question(self, key: str) -> tuple[str, list[dict[str, Any]]] | None: for question_id, pending in self._questions.items(): if pending.conversation_id == key and not pending.answer.done(): return question_id, pending.questions return None def answer_text(self, answer: str | None) -> str: if answer is None: return self._texts.unanswered.format( minutes=round(self._question_timeout / 60) ) return self._texts.answered.format(answer=answer) async def _set_pending_question(self, conv: Conversation, *, value: bool) -> None: async def apply(row: Conversation) -> None: row.pending_question = value with contextlib.suppress(LookupError): await self._update(conv, apply) # ---- turns --------------------------------------------------------- async def turn( self, conv: Conversation, *, messages: Sequence[Any], origin: str, capture: TurnCapture | None = None, session_id: str | None = None, use_session: bool = True, tools: bool = True, turn_id: str | None = None, item_origin: str | None = None, ) -> AsyncIterator[MessageStreamEvent]: """Run one turn on ``conv`` under its lock; the only path to the backend. ``use_session=False`` withholds the stored ``session_id`` so the backend reseeds from ``messages`` (markdown file diverged). """ row_id = cast("int", conv.id) runner = self._runner(row_id) backend = self._backend(conv.agent_name) turn_id = turn_id or f"turn_{uuid.uuid4().hex[:12]}" capture = capture or TurnCapture() resume = session_id if session_id is not None else conv.session_id async with runner.lock: runner.turn_id = turn_id runner.origin = origin runner.text = _prompt_preview(messages) runner.started_at = datetime.now(UTC) runner.tools = {} await self._mark_running(conv, turn_id) self._bus.publish( "turn.start", conversation_id=conv.external_id, turn_id=turn_id, origin=origin, item_origin=item_origin, text=runner.text, ) stop = "error" cut = False try: events = backend.complete( agent=self._claude_agent(conv.agent_name), messages=messages, conversation_id=conv.external_id, session_id=resume if use_session else None, reseed=not use_session, capture=capture, kind=conv.kind, pinned=conv.kind == "master", tools=tools, observer=self._observer(conv, runner, turn_id, origin), turn_id=turn_id, ) async for event in events: yield event stop = "interrupted" if capture.interrupted else "end_turn" except asyncio.CancelledError: cut = True raise finally: runner.turn_id = None await self._mark_done(conv, capture, cut=cut) self._bus.publish( "turn.end", conversation_id=conv.external_id, turn_id=turn_id, origin=origin, item_origin=item_origin, stop=stop, usage=_usage_dict(capture), ) async def run_text_turn( self, conv: Conversation, text: str, *, origin: str, tools: bool = True, turn_id: str | None = None, item_origin: str | None = None, ) -> tuple[str, TurnCapture]: capture = TurnCapture() acc = StreamAccumulator() agent = self._claude_agent(conv.agent_name) async for event in self.turn( conv, messages=[{"role": "user", "content": text}], origin=origin, capture=capture, tools=tools, turn_id=turn_id, item_origin=item_origin, ): acc.feed(event) message = acc.finalize(model=agent.model) reply = "\n\n".join( getattr(b, "text", "") for b in message.content if getattr(b, "type", "") == "text" ).strip() return reply, capture # ---- lifecycle ----------------------------------------------------- async def start(self) -> None: await self.recover() for row_id in await self._queue.conversations_with_pending(): self._ensure_worker(row_id) self._idle_task = asyncio.create_task(self._idle_loop()) async def stop(self) -> None: tasks = list(self._tasks) if self._idle_task is not None: tasks.append(self._idle_task) for task in tasks: task.cancel() for task in tasks: with contextlib.suppress(BaseException): await task self._tasks.clear() self._idle_task = None async def recover(self) -> list[Conversation]: """Restart path (§8.1): repair transcripts of turns cut mid-flight.""" async with self._db.session() as session: result = await session.exec( select(Conversation).where(col(Conversation.running_turn).is_not(None)) ) cut = list(result.all()) for conv in cut: fixed = 0 if conv.session_id is not None: backend = self._backend(conv.agent_name) try: fixed = await backend.repair_session(conv.session_id) except Exception: # noqa: BLE001 _log.exception("repair of %s failed", conv.session_id) turn_id = conv.running_turn async def clear(row: Conversation) -> None: row.running_turn = None row.pending_question = False await self._update(conv, clear) note = f"тёрн {turn_id} оборван рестартом gateway" if fixed: note += ( f"; {fixed} незакрытых тул-коллов получили tool_result " f"«{self._texts.interrupted}»" ) await self.inject(conv, note, urgency="normal", origin="система") _log.warning("conversation %s: %s", conv.external_id, note) interrupted = await self._queue.interrupted() for item in interrupted: _log.warning( "queue item #%s (%s) was running at restart; marked interrupted", item.id, item.priority, ) return cut # ---- internals ----------------------------------------------------- def _backend(self, agent: str) -> ClaudeSdkBackend: backend = self._backends.get(agent) if backend is None or not hasattr(backend, "repair_session"): msg = f"agent {agent!r} has no Claude SDK backend" raise LookupError(msg) return cast("ClaudeSdkBackend", backend) def _claude_agent(self, name: str) -> ClaudeAgent: agent = self._agents.get(name) if agent is None or not hasattr(agent, "cwd"): msg = f"unknown Claude agent {name!r}" raise LookupError(msg) return cast("ClaudeAgent", agent) def _store_key(self, conv: Conversation) -> dict[str, str]: agent = self._claude_agent(conv.agent_name) return { "project_key": project_key_for_directory(str(agent.cwd)), "session_id": cast("str", conv.session_id), } async def _copy_session( self, conv: Conversation, *, window: int | None, strip_tools: bool ) -> str: if conv.session_id is None: msg = f"conversation {conv.external_id} has no session to copy" raise ValueError(msg) live = self._pool.get(conv.external_id) if live is not None and live.dirty: msg = f"conversation {conv.external_id} has a mirror gap; not forking" raise RuntimeError(msg) agent = self._claude_agent(conv.agent_name) forked = await fork_session_via_store( self._store, conv.session_id, directory=str(agent.cwd) ) if window is not None or strip_tools: key = { "project_key": project_key_for_directory(str(agent.cwd)), "session_id": forked.session_id, } entries = await self._store.load(cast("Any", key)) or [] trimmed = window_entries(cast("Any", entries), window=window) if strip_tools: trimmed = strip_tool_entries(trimmed) await self._store.delete(cast("Any", key)) await self._store.append(cast("Any", key), cast("Any", trimmed)) _log.info( "forked session %s -> %s (window=%s, strip_tools=%s)", conv.session_id, forked.session_id, window, strip_tools, ) return forked.session_id async def _seed_text(self, ctx: SeedContext, *, window: int | None) -> str: stamp = datetime.now(UTC).astimezone().strftime("%Y-%m-%d %H:%M") title = f" «{ctx.title}»" if ctx.title else "" head = f"[сид: {ctx.seed}] {ctx.kind}{title}, {stamp}." body: str | None = None if self._texts.seed is not None: produced: Any = self._texts.seed(ctx) if inspect.isawaitable(produced): produced = await produced body = cast("str | None", produced) if body is None: if ctx.seed == "brief": body = ctx.text elif ctx.seed == "copy": scope = f"последние {window} тёрнов" if window else "вся история" body = f"История родителя скопирована ({scope}); продолжай в ней." elif ctx.seed == "morning": body = "Хендаут не приехал." parts = [head, body, ctx.text if ctx.seed != "brief" else None] return "\n\n".join(p for p in parts if p) def _runner(self, row_id: int) -> _Runner: runner = self._runners.get(row_id) if runner is None: runner = _Runner() self._runners[row_id] = runner return runner def _ensure_worker(self, row_id: int) -> None: runner = self._runner(row_id) runner.wake.set() if runner.task is None or runner.task.done(): runner.task = asyncio.create_task(self._worker(row_id)) self._tasks.add(runner.task) runner.task.add_done_callback(self._tasks.discard) async def _worker(self, row_id: int) -> None: runner = self._runner(row_id) while True: items = await self._queue.pending(row_id) batch, wait = self._pick(items) if batch is None: runner.wake.clear() if wait is None: return with contextlib.suppress(TimeoutError): await asyncio.wait_for(runner.wake.wait(), timeout=wait) continue conv = await self.get_row(row_id) if conv is None: await self._queue.finish(batch, status="failed") return await self._run_batch(conv, batch) def _pick( self, items: list[InjectQueueItem] ) -> tuple[list[InjectQueueItem] | None, float | None]: if not items: return None, None normals = [i for i in items if i.priority == "normal"] head = items[0] if head.priority == "urgent": return [head], None if head.priority == "user": return [head, *normals], None age = (datetime.now(UTC) - _aware(head.created_at)).total_seconds() if age >= self._normal_window: return normals, None return None, max(self._normal_window - age, 1.0) async def _run_batch( self, conv: Conversation, batch: list[InjectQueueItem] ) -> None: head = batch[0] turn_id = f"turn_{uuid.uuid4().hex[:12]}" await self._queue.start(batch, turn_id) if head.priority == "user": origin = "user" prompt = head.text envelope = await self._envelope_for(conv, injects=len(batch) - 1) if envelope: prompt += "\n\n" + envelope if len(batch) > 1: prompt += "\n\n" + _bundle(batch[1:]) else: origin = "inject" prompt = "\n\n".join(f"{inject_header(i.origin)}\n{i.text}" for i in batch) seed = await self._pending_seed(conv) if seed: prompt = f"{seed}\n\n{prompt}" try: text, capture = await self.run_text_turn( conv, prompt, origin=origin, turn_id=turn_id, item_origin=head.origin ) except Exception: # noqa: BLE001 _log.exception("turn %s on %s failed", turn_id, conv.external_id) await self._queue.finish(batch, status="failed") return await self._queue.finish( batch, status="interrupted" if capture.interrupted else "done" ) if origin == "user": self._bus.publish( "reply", conversation_id=conv.external_id, turn_id=turn_id, item=head.id, item_origin=head.origin, source="queue", prompt=prompt, user_text=head.text, text=text, ) async def _envelope_for(self, conv: Conversation, *, injects: int) -> str | None: if conv.kind != "master": return None streak = int(conv.flags.get("streak", 0) or 0) await self.set_flags(conv, {"streak": streak + 1}) if self._envelope is None: return None return self._envelope.build(streak=streak, injects=injects) def _observer( self, conv: Conversation, runner: _Runner, turn_id: str, origin: str ) -> Callable[[Any], None]: conversation_id = conv.external_id def observe(message: Any) -> None: parent = getattr(message, "parent_tool_use_id", None) if isinstance(message, RateLimitEvent): self._observe_rate_limit(conv, message) elif isinstance(message, StreamEvent): self._bus.publish( "stream", conversation_id=conversation_id, turn_id=turn_id, origin=origin, parent_tool_use_id=parent, event=message.event, ) elif isinstance(message, AssistantMessage): for block in message.content: if isinstance(block, ToolUseBlock): event = self._bus.publish( "tool", conversation_id=conversation_id, turn_id=turn_id, origin=origin, parent_tool_use_id=parent, tool_use_id=block.id, name=block.name, input=block.input, ) runner.tools[block.id] = { "tool_use_id": block.id, "name": block.name, "input": block.input, "parent_tool_use_id": parent, "started_at": event["ts"], "ended_at": None, "is_error": None, "content": None, } elif isinstance(message, UserMessage): blocks = message.content if isinstance(message.content, list) else () for block in blocks: if isinstance(block, ToolResultBlock): event = self._bus.publish( "tool.result", conversation_id=conversation_id, turn_id=turn_id, origin=origin, parent_tool_use_id=parent, tool_use_id=block.tool_use_id, is_error=bool(block.is_error), content=_result_preview(block.content), ) node = runner.tools.get(block.tool_use_id) if node is not None: node["ended_at"] = event["ts"] node["is_error"] = event["is_error"] node["content"] = event["content"] elif isinstance(message, ResultMessage) and parent is None: self._bus.publish( "result", conversation_id=conversation_id, turn_id=turn_id, origin=origin, subtype=message.subtype, is_error=message.is_error, num_turns=message.num_turns, ) return observe def _observe_rate_limit(self, conv: Conversation, message: RateLimitEvent) -> None: info = message.rate_limit_info row = RateLimit( window=info.rate_limit_type or "unknown", status=info.status, utilization=info.utilization, resets_at=_from_unix(info.resets_at), overage_status=info.overage_status, overage_resets_at=_from_unix(info.overage_resets_at), agent_name=conv.agent_name, session_id=message.session_id, raw=dict(info.raw), ) self._bus.publish( "rate_limit", conversation_id=conv.external_id, window=row.window, status=row.status, utilization=row.utilization, resets_at=_iso(row.resets_at), overage_status=row.overage_status, ) task = asyncio.create_task(self._record_rate_limit(row)) self._tasks.add(task) task.add_done_callback(self._tasks.discard) async def _record_rate_limit(self, row: RateLimit) -> None: try: async with self._db.session() as session: session.add(row) await session.commit() except Exception: # noqa: BLE001 _log.exception("rate limit write failed") async def _mark_running(self, conv: Conversation, turn_id: str) -> None: async def apply(row: Conversation) -> None: row.running_turn = turn_id row.last_activity_at = datetime.now(UTC) await self._update(conv, apply) async def _mark_done( self, conv: Conversation, capture: TurnCapture, *, cut: bool = False ) -> None: """Close the turn; a cancelled one keeps ``running_turn`` for ``recover``.""" async def apply(row: Conversation) -> None: if not cut: row.running_turn = None row.last_activity_at = datetime.now(UTC) if capture.session_id is not None: row.session_id = capture.session_id await self._update(conv, apply) async def _idle_loop(self) -> None: while True: try: await self._emit_idle() except Exception: # noqa: BLE001 _log.exception("idle watcher failed") await asyncio.sleep(self._idle_interval) async def _emit_idle(self) -> None: if not self._idle_days: return now = datetime.now(UTC) for conv in await self.find(status="open", limit=10_000): last = _aware(conv.last_activity_at or conv.created_at) days = int((now - last).total_seconds() // 86400) due = [d for d in self._idle_days if days >= d] if not due: continue notified = int(conv.flags.get("idle_notified", 0) or 0) if due[-1] <= notified: continue await self.set_flags(conv, {"idle_notified": due[-1]}) bindings = await self.bindings(conv) self._bus.publish( "conversation.idle", conversation_id=conv.external_id, kind=conv.kind, agent=conv.agent_name, days=due[-1], bindings=[ {"frontend": b.frontend, "external_id": b.external_id} for b in bindings if b.visible ], ) def parse_at(at: str) -> datetime: raw = at.strip() match = _RELATIVE.match(raw.replace(" ", "")) if match: amount, unit = match.groups() return datetime.now(UTC) + timedelta(seconds=int(amount) * _UNITS[unit]) parsed = datetime.fromisoformat(raw) if parsed.tzinfo is None: parsed = parsed.astimezone() return parsed.astimezone(UTC) def _bundle(items: Sequence[InjectQueueItem]) -> str: lines = [f"[инжекты, накопившиеся с {_iso(items[0].created_at)}; это не Бобёр]"] # noqa: RUF001 lines.extend(f"- [{i.origin}] {i.text}" for i in items) return "\n".join(lines) def _aware(value: datetime) -> datetime: return value if value.tzinfo is not None else value.replace(tzinfo=UTC) def _iso(value: datetime | None) -> str | None: return _aware(value).isoformat(timespec="seconds") if value is not None else None TITLE_MAX = 80 def implied_title(text: str | None) -> str | None: if not text: return None line = text.strip().splitlines()[0].strip() return line if len(line) <= TITLE_MAX else line[: TITLE_MAX - 1] + "…" def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None: if not messages: return None text = text_of(messages[-1].get("content")) return text[:limit] if text else None def _from_unix(value: int | None) -> datetime | None: return datetime.fromtimestamp(value, tz=UTC) if value is not None else None def _result_preview( content: str | list[dict[str, Any]] | None, limit: int = 400 ) -> str: """Short text of a tool result for the panel: the transcript keeps the whole.""" if content is None: return "" text = ( content if isinstance(content, str) else "\n".join( str(part.get("text", "")) for part in content if isinstance(part, dict) and part.get("type") == "text" ) ) return text if len(text) <= limit else text[:limit] + "…" def _usage_dict(capture: TurnCapture) -> dict[str, Any] | None: usage = capture.usage if usage is None: return None return { "input": usage.input_tokens, "output": usage.output_tokens, "cache_read": usage.cache_read_tokens, "cache_creation": usage.cache_creation_tokens, "cost_usd": usage.cost_usd, "duration_ms": usage.duration_ms, }