refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text
This commit is contained in:
1 parent
b96714338f
commit
cae2ed4161
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
|
||||
Reference in new issue
Block a user