refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text

This commit is contained in:
hh
2026-09-02 00:13:20 +02:00
parent b96714338f
commit cae2ed4161
77 changed files with 2987 additions and 2944 deletions
+322
View File
@@ -0,0 +1,322 @@
"""Ending conversations: the distiller, the line cap, the master handover."""
from __future__ import annotations
import asyncio
import contextlib
import inspect
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from beaver_gateway.conversations.distill import (
Digest,
DistillContext,
LineCap,
append_index,
check_digest,
find_digest,
index_line,
trim_summary,
written_paths,
)
from beaver_gateway.conversations.questions import Questions
from beaver_gateway.conversations.state import aware
from beaver_gateway.conversations.texts import NewDayContext
if TYPE_CHECKING:
from beaver_gateway.conversations.rotation import HandoutContext
from beaver_gateway.storage.models import Conversation, InjectQueueItem
__all__ = ["Closing", "DistillResult"]
_log = logging.getLogger(__name__)
CLOSE_WAIT = 0.25
CLOSE_TRIES = 40
CAP_TRIES = 3
@dataclass(frozen=True, slots=True)
class DistillResult:
conversation: Conversation
fork: Conversation
text: str
digest: Digest | None
error: str | None
trimmed: bool
class Closing(Questions):
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 request_close(self, conv: Conversation) -> Conversation:
"""``close_chat`` from inside a turn: the chat closes once the turn ends."""
if conv.kind != "deep":
msg = f"only deep chats close this way, {conv.external_id} is {conv.kind}"
raise ValueError(msg)
return await self.set_flags(conv, {"close_requested": True})
async def idle(
self,
*,
kind: str,
days: int,
since: datetime | None = None,
limit: int | None = None,
) -> list[Conversation]:
"""Open conversations of ``kind`` with a session, quiet for ``days``."""
now = datetime.now(UTC)
cutoff = now - timedelta(days=days)
out: list[tuple[datetime, Conversation]] = []
for conv in await self.find(status="open", kind=kind, limit=10_000):
if conv.session_id is None:
continue
last = aware(conv.last_activity_at or conv.created_at)
if last > cutoff or (since is not None and last < since):
continue
out.append((last, conv))
out.sort(key=lambda pair: pair[0])
rows = [conv for _, conv in out]
return rows[:limit] if limit is not None else rows
async def distill(
self, conv: Conversation, *, reason: str = "api"
) -> DistillResult:
"""Fork under the distiller: digest checked and indexed, merge to the master."""
if self._distiller is None:
msg = "no distiller configured (Gateway(distiller=...))"
raise RuntimeError(msg)
if conv.kind != "deep":
msg = f"only deep chats are distilled, {conv.external_id} is {conv.kind}"
raise ValueError(msg)
row = await self.get_row(cast("int", conv.id)) or conv
if row.status != "open":
msg = f"conversation {row.external_id} is {row.status}"
raise ValueError(msg)
if await self.busy(row):
msg = f"conversation {row.external_id} is busy"
raise RuntimeError(msg)
memory = bool(row.flags.get("memory", True))
chat_name = await self.chat_name(row)
ctx = DistillContext(
conversation=row,
title=await self.implied_title(row),
source=await self.window_of(row),
chat_name=chat_name,
memory=memory,
reason=reason,
day=datetime.now(UTC).astimezone().date(),
)
prompt = await self._distill_prompt(ctx)
started = datetime.now(UTC)
self._bus.publish(
"distill.start",
conversation_id=row.external_id,
reason=reason,
memory=memory,
)
result = await self.fork(
row,
prompt,
strip_tools=True,
agent=self._distiller.agent,
title=f"digest: {chat_name}",
)
text, trimmed = trim_summary(result.text)
digest: Digest | None = None
error: str | None = None
if memory:
written = written_paths(result.capture.synthesized_messages)
path = find_digest(self._distiller, since=started, written=written)
if path is None:
error = self._texts.digest_missing
else:
checked = check_digest(path, self._distiller)
if isinstance(checked, str):
error = f"{path.name}: {checked}"
else:
digest = checked
append_index(self._distiller, index_line(digest, chat_name))
if error is not None:
_log.warning("distill of %s: %s", row.external_id, error)
master = await self.open_master()
if master is not None and text:
note = self._texts.closed.format(
chat=chat_name,
digest=(
self._texts.closed_digest.format(digest=digest.path.stem)
if digest
else ""
),
text=text,
)
await self.inject(master, note, urgency="normal", origin="digest")
await self.close(row)
row = await self.set_flags(
row,
{
"close_requested": None,
"closed_reason": reason,
"digest": str(digest.path) if digest else None,
"digest_error": error,
},
)
self._bus.publish(
"conversation.distilled",
conversation_id=row.external_id,
fork=result.conversation.external_id,
reason=reason,
memory=memory,
digest=str(digest.path) if digest else None,
error=error,
text=text,
trimmed=trimmed,
master=master.external_id if master is not None else None,
)
return DistillResult(
conversation=row,
fork=result.conversation,
text=text,
digest=digest,
error=error,
trimmed=trimmed,
)
async def _distill_prompt(self, ctx: DistillContext) -> str:
source = self._texts.distill
if source is None:
template = (
self._texts.distill_prompt
if ctx.memory
else self._texts.distill_prompt_no_memory
)
return template.format(
chat=ctx.chat_name, reason=ctx.reason, day=ctx.day.isoformat()
)
produced: Any = source(ctx)
return await produced if inspect.isawaitable(produced) else produced
async def _close_after_turn(self, conv: Conversation) -> None:
for _ in range(CLOSE_TRIES):
if await self.busy(conv):
await asyncio.sleep(CLOSE_WAIT)
continue
try:
await self.distill(conv, reason="close_chat")
except RuntimeError as exc:
_log.info("closing %s: %s, retrying", conv.external_id, exc)
await asyncio.sleep(CLOSE_WAIT)
continue
except Exception: # noqa: BLE001
_log.exception("closing %s after its turn failed", conv.external_id)
return
_log.warning("closing %s: still busy, giving up", conv.external_id)
async def before_turn(self, conv: Conversation) -> str | None:
cap = LineCap.from_flags(conv.flags.get("line_cap"))
if cap is None:
return None
try:
return cap.path.read_text(encoding="utf-8") if cap.path.exists() else ""
except OSError:
_log.exception("line cap: cannot read %s", cap.path)
return None
async def after_turn(self, conv: Conversation, before: str | None) -> None:
row = await self.get_row(cast("int", conv.id))
if row is None:
return
if row.kind == "deep" and row.flags.get("close_requested"):
self._track(asyncio.create_task(self._close_after_turn(row)))
cap = LineCap.from_flags(row.flags.get("line_cap"))
if cap is not None and before is not None:
await self._enforce_cap(row, cap, before)
async def _enforce_cap(self, conv: Conversation, cap: LineCap, before: str) -> None:
if not cap.path.exists():
return
after = cap.path.read_text(encoding="utf-8")
lines = sum(1 for line in after.splitlines() if line.strip())
if lines <= cap.max_lines:
return
if before:
cap.path.write_text(before, encoding="utf-8")
else:
cap.path.unlink()
attempts = int(conv.flags.get("line_cap_attempts", 0) or 0) + 1
await self.set_flags(conv, {"line_cap_attempts": attempts})
self._bus.publish(
"line_cap.bounced",
conversation_id=conv.external_id,
path=str(cap.path),
lines=lines,
max_lines=cap.max_lines,
attempt=attempts,
)
_log.warning(
"line cap: %s came back with %d lines (cap %d), restored; attempt %d",
cap.path,
lines,
cap.max_lines,
attempts,
)
if attempts > CAP_TRIES:
return
await self.inject(
conv,
self._texts.too_long.format(
name=cap.path.name, lines=lines, max_lines=cap.max_lines
),
urgency="urgent",
origin="cap",
interrupt=False,
)
async def handout(self, conv: Conversation, ctx: HandoutContext) -> str:
"""The closing master's last turn."""
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 new_day(
self, conv: Conversation, *, reason: str = "night", moved: int = 0
) -> InjectQueueItem:
"""The new master's first inject."""
ctx = NewDayContext(
day=datetime.now(UTC).astimezone().date(), reason=reason, moved=moved
)
source = self._texts.new_day
if isinstance(source, str):
text = source.format(day=ctx.day.isoformat(), reason=ctx.reason)
else:
produced: Any = source(ctx)
text = await produced if inspect.isawaitable(produced) else produced
if moved:
text += self._texts.moved_injects.format(moved=moved)
return await self.inject(
conv, text, urgency="urgent", origin="rotation", interrupt=False
)