feat(envelope,conversations): recall hook under the user text, user_sink for the setup's reply log

This commit is contained in:
hh
2026-09-01 16:08:53 +02:00
parent 90063a7cfe
commit f9bf51badf
5 changed files with 170 additions and 13 deletions
+46 -4
View File
@@ -99,6 +99,7 @@ __all__ = [
"DistillResult",
"ForkResult",
"SeedContext",
"UserSaid",
]
_log = logging.getLogger("beaver_gateway.core.conversations")
@@ -151,6 +152,17 @@ class NewDayContext:
moved: int
@dataclass(frozen=True, slots=True)
class UserSaid:
"""One message from the user as it enters a turn (``origin=user``)."""
conversation_id: str
kind: str
title: str | None
text: str
at: datetime
@dataclass(frozen=True, slots=True)
class ConversationTexts:
"""Texts the gateway cannot invent for a setup.
@@ -262,8 +274,10 @@ class Conversations:
question_timeout: float = 600.0,
envelope: Envelope | None = None,
distiller: Distiller | None = None,
user_sink: Callable[[UserSaid], Awaitable[None] | None] | None = None,
) -> None:
self._db = db
self._user_sink = user_sink
self._distiller = distiller
self._agents = agents
self._backends = backends
@@ -1696,7 +1710,8 @@ class Conversations:
if head.priority == "user":
origin = "user"
prompt = head.text
envelope = self._envelope_for(conv)
await self._note_user(conv, head.text)
envelope = self._envelope_for(conv, head.text)
if envelope:
prompt += "\n\n" + envelope
if len(batch) > 1:
@@ -1734,10 +1749,37 @@ class Conversations:
text=text,
)
def _envelope_for(self, conv: Conversation) -> str | None:
if conv.kind != "master" or self._envelope is None:
def _envelope_for(self, conv: Conversation, text: str = "") -> str | None:
"""Master gets the whole envelope, a branch only the recall lines (§3.3)."""
if self._envelope is None:
return None
return self._envelope.build()
if conv.kind == "master":
return self._envelope.build(text=text, kind="master")
if conv.kind == "branch":
return self._envelope.recall_only(text=text, kind="branch")
return None
async def _note_user(self, conv: Conversation, text: str) -> None:
"""Hand the user's text to the setup's sink.
The setup keeps its own log of what Бобёр said, outside the
transcript; a failure there never blocks the turn.
"""
if self._user_sink is None:
return
message = UserSaid(
conversation_id=conv.external_id,
kind=conv.kind,
title=conv.title,
text=text,
at=datetime.now(UTC),
)
try:
result = self._user_sink(message)
if inspect.isawaitable(result):
await result
except Exception: # noqa: BLE001
_log.exception("user sink failed for %s", conv.external_id)
def _observer(
self, conv: Conversation, runner: _Runner, turn_id: str, origin: str