refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""``Conversations`` - the service every frontend, job and gateway tool talks to.
|
||||
|
||||
Built as layers, one file each: rows → seeds → turns → messaging →
|
||||
spawning → questions → closing; this file adds start, stop and restart
|
||||
recovery. A turn started by a user message streams back to whoever asked;
|
||||
a turn started by an inject streams nowhere and can only speak via ``say``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta, tzinfo
|
||||
|
||||
from sqlmodel import col, select
|
||||
|
||||
from beaver_gateway.conversations.closing import Closing, DistillResult
|
||||
from beaver_gateway.conversations.kinds import KINDS
|
||||
from beaver_gateway.conversations.rows import context_of, implied_title
|
||||
from beaver_gateway.conversations.seeds import SEEDS
|
||||
from beaver_gateway.conversations.spawning import ForkResult
|
||||
from beaver_gateway.conversations.state import aware
|
||||
from beaver_gateway.conversations.texts import (
|
||||
ConversationTexts,
|
||||
NewDayContext,
|
||||
SeedContext,
|
||||
UserSaid,
|
||||
)
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
__all__ = [
|
||||
"KINDS",
|
||||
"SEEDS",
|
||||
"ConversationTexts",
|
||||
"Conversations",
|
||||
"DistillResult",
|
||||
"ForkResult",
|
||||
"NewDayContext",
|
||||
"SeedContext",
|
||||
"UserSaid",
|
||||
"context_of",
|
||||
"implied_title",
|
||||
"parse_at",
|
||||
]
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_RELATIVE = re.compile(r"^\+(\d+)\s*([smhd])$")
|
||||
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||
|
||||
|
||||
class Conversations(Closing):
|
||||
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]:
|
||||
"""Repair the transcripts of turns a restart cut and tell each conversation."""
|
||||
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, text=self._texts.interrupted
|
||||
)
|
||||
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 = self._texts.cut_by_restart.format(turn_id=turn_id)
|
||||
if fixed:
|
||||
note += self._texts.repaired_tools.format(
|
||||
fixed=fixed, interrupted=self._texts.interrupted
|
||||
)
|
||||
await self.inject(conv, note, urgency="normal", origin="system")
|
||||
_log.warning("conversation %s: %s", conv.external_id, note)
|
||||
for item in await self._queue.interrupted():
|
||||
_log.warning(
|
||||
"queue item #%s (%s) was running at restart; marked interrupted",
|
||||
item.id,
|
||||
item.priority,
|
||||
)
|
||||
return cut
|
||||
|
||||
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, tz: tzinfo = UTC) -> 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.replace(tzinfo=tz)
|
||||
return parsed.astimezone(UTC)
|
||||
Reference in New Issue
Block a user