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

This commit is contained in:
hh committed 2026-09-02 00:13:20 +02:00
1 parent 253bde1b11
commit 9aaddbed75
77 files changed
+2987 -2944

No files matched your search

@@ -0,0 +1,201 @@
"""New conversations from old ones: spawn with a seed, fork a copy, merge back."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import fork_session_via_store, project_key_for_directory
from beaver_gateway.backends.transcript import strip_tool_entries, window_entries
from beaver_gateway.conversations.messaging import Messaging
from beaver_gateway.conversations.seeds import SEEDS
from beaver_gateway.conversations.texts import SeedContext
if TYPE_CHECKING:
from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.conversations.kinds import Kind
from beaver_gateway.storage.models import Conversation
__all__ = ["ForkResult", "Spawning"]
_log = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class ForkResult:
conversation: Conversation
text: str
capture: TurnCapture
class Spawning(Messaging):
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,
flags: dict[str, Any] | None = None,
) -> Conversation:
"""Create a conversation in a window and queue its seed.
``binding`` reuses a window that already exists instead of asking the
home frontend for one. Without ``text`` the seed waits in ``flags``
and opens the first turn, so a fresh window 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
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,
flags=flags,
)
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:{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 fork(
self,
conv: Conversation,
prompt: str,
*,
window: int | None = None,
strip_tools: bool = False,
title: str | None = None,
agent: str | None = None,
) -> ForkResult:
"""Copy the history into a one-off session, run ``prompt`` on it, close it."""
agent = agent or conv.agent_name
session_id = await self._copy_session(
conv, window=window, strip_tools=strip_tools, agent=agent
)
child = await self.create(
kind="fork",
agent=agent,
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(agent).close(child.external_id)
child = await self.set_status(child, "closed")
return ForkResult(conversation=child, text=text, capture=capture)
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"merge: {conv.title or conv.external_id}",
)
if result.text.strip():
await self.inject(parent, result.text, urgency="normal", origin="merge")
await self.set_status(conv, "merged")
await self.mark_closed(conv)
self._bus.publish(
"conversation.merged",
conversation_id=conv.external_id,
parent=parent.external_id,
fork=result.conversation.external_id,
)
return result
async def _copy_session(
self,
conv: Conversation,
*,
window: int | None,
strip_tools: bool,
agent: str | None = None,
) -> 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)
source = self._claude_agent(conv.agent_name)
target = self._claude_agent(agent) if agent else source
forked = await fork_session_via_store(
self._store, conv.session_id, directory=str(source.cwd)
)
source_key = {
"project_key": project_key_for_directory(str(source.cwd)),
"session_id": forked.session_id,
}
target_key = {
"project_key": project_key_for_directory(str(target.cwd)),
"session_id": forked.session_id,
}
if window is not None or strip_tools or target_key != source_key:
entries = await self._store.load(cast("Any", source_key)) or []
trimmed = window_entries(cast("Any", entries), window=window)
if strip_tools:
trimmed = strip_tool_entries(trimmed)
await self._store.delete(cast("Any", source_key))
await self._store.append(cast("Any", target_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