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
+4 -1
View File
@@ -190,8 +190,11 @@ async def _async_main() -> None:
store=session_store,
texts=gateway.texts,
frontends=gateway.frontends,
envelope=Envelope(watch=gateway.watch, tz=gateway.tz),
envelope=Envelope(
watch=gateway.watch, tz=gateway.tz, recall=gateway.recall
),
distiller=gateway.distiller,
user_sink=gateway.user_sink,
)
late.conversations = conversations
scheduler = Scheduler(
+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
+42 -5
View File
@@ -9,17 +9,20 @@ it by the queue, with their own header.
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from beaver_gateway.core.watch import Change, VaultWatch
__all__ = ["Envelope", "render"]
__all__ = ["Envelope", "RecallContext", "render"]
_log = logging.getLogger(__name__)
HEADER = (
"[конверт - фоновый сигнал, не обращение; "
@@ -27,6 +30,15 @@ HEADER = (
)
@dataclass(frozen=True, slots=True)
class RecallContext:
"""What the setup's ``recall`` hook sees: the user's text and where it landed."""
text: str
kind: str
now: datetime
@dataclass(slots=True)
class Envelope:
watch: VaultWatch | None = None
@@ -35,15 +47,22 @@ class Envelope:
per_file: int = 30
names_only_within: float = 600.0
last_at: datetime | None = None
recall: Callable[[RecallContext], str | None] | None = None
"""Setup-side lookup run on the user's text at turn start: pointers into
the vault (a person's card, the agent's own notes, due dates) that the
gateway cannot know the paths of. Its lines go under the vault block;
a failure is logged and the envelope goes out without them."""
def build(self, *, now: datetime | None = None) -> str:
def build(
self, *, now: datetime | None = None, text: str = "", kind: str = "master"
) -> str:
now = now or datetime.now(UTC)
changes = self.watch.take() if self.watch is not None else []
names_only = (
self.last_at is not None
and (now - self.last_at).total_seconds() < self.names_only_within
)
text = render(
out = render(
now=now,
tz=self.tz,
changes=changes,
@@ -53,7 +72,25 @@ class Envelope:
per_file=self.per_file,
)
self.last_at = now
return text
block = self.recall_block(text=text, kind=kind, now=now)
return f"{out}\n{block}" if block else out
def recall_only(
self, *, text: str, kind: str, now: datetime | None = None
) -> str | None:
"""The recall lines under the header, without the vault diff (branches)."""
block = self.recall_block(text=text, kind=kind, now=now or datetime.now(UTC))
return f"{HEADER}\n{block}" if block else None
def recall_block(self, *, text: str, kind: str, now: datetime) -> str | None:
if self.recall is None or not text.strip():
return None
try:
block = self.recall(RecallContext(text=text, kind=kind, now=now))
except Exception: # noqa: BLE001
_log.exception("recall hook failed")
return None
return block.strip() or None if block else None
def render(
+9 -2
View File
@@ -13,11 +13,12 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from collections.abc import Awaitable, Callable, Iterable, Iterator
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.core.conversations import ConversationTexts
from beaver_gateway.core.conversations import ConversationTexts, UserSaid
from beaver_gateway.core.distill import Distiller
from beaver_gateway.core.envelope import RecallContext
from beaver_gateway.core.rotation import RotationPolicy
from beaver_gateway.core.scheduler import Budget, Job
from beaver_gateway.core.watch import VaultWatch
@@ -94,6 +95,12 @@ class Gateway:
"""When a master is rotated (§4.5); ``None`` keeps the defaults."""
watch: VaultWatch | None = None
"""Vault watcher feeding the envelope (§3.5, §4.6); ``None`` = no vault block."""
recall: Callable[[RecallContext], str | None] | None = None
"""Envelope lookup on the user's text: pointers into the vault (cards,
the agent's notes, due dates) the gateway knows no paths for (§3.3)."""
user_sink: Callable[[UserSaid], Awaitable[None] | None] | None = None
"""Sees every user message as it enters a master or branch turn - the
setup's own grep-able log of what the user said, outside the transcript."""
budget: Budget | None = None
"""Subscription window past which non-critical jobs wait (§4.5)."""
distiller: Distiller | None = None