feat(distill,conversations,scheduler,api,ui): close deep chats with a distiller fork, digest and index, idle and weekly state jobs
This commit is contained in:
@@ -181,6 +181,7 @@ async def _async_main() -> None:
|
||||
texts=gateway.texts,
|
||||
frontends=gateway.frontends,
|
||||
envelope=Envelope(watch=gateway.watch, tz=gateway.tz),
|
||||
distiller=gateway.distiller,
|
||||
)
|
||||
late.conversations = conversations
|
||||
scheduler = Scheduler(
|
||||
|
||||
@@ -40,6 +40,18 @@ from claude_agent_sdk import (
|
||||
from sqlmodel import col, select
|
||||
|
||||
from beaver_gateway.core.conversation_store import load_messages
|
||||
from beaver_gateway.core.distill import (
|
||||
Digest,
|
||||
DistillContext,
|
||||
Distiller,
|
||||
LineCap,
|
||||
append_index,
|
||||
check_digest,
|
||||
find_digest,
|
||||
index_line,
|
||||
trim_summary,
|
||||
written_paths,
|
||||
)
|
||||
from beaver_gateway.core.injects import InjectQueue, inject_header
|
||||
from beaver_gateway.core.kinds import KINDS, Kind, as_kind
|
||||
from beaver_gateway.core.transcript import (
|
||||
@@ -83,6 +95,7 @@ __all__ = [
|
||||
"SEEDS",
|
||||
"ConversationTexts",
|
||||
"Conversations",
|
||||
"DistillResult",
|
||||
"ForkResult",
|
||||
"SeedContext",
|
||||
]
|
||||
@@ -96,8 +109,19 @@ _DEFAULT_MERGE_PROMPT = (
|
||||
"что не сделано и почему, открытые вопросы. Идентификаторы и ссылки - "
|
||||
"дословно. Коротко, прошедшее время."
|
||||
)
|
||||
_DEFAULT_DISTILL_PROMPT = (
|
||||
"Глубокий чат «{chat}» закрыт ({reason}), сегодня {day}. Напиши выжимку "
|
||||
"файлом и слив текстом ответа: до 5 строк, третье лицо."
|
||||
)
|
||||
_DEFAULT_DISTILL_PROMPT_NO_MEMORY = (
|
||||
"Глубокий чат «{chat}» закрыт ({reason}), сегодня {day}. Память для него "
|
||||
"выключена: файл не пиши, только слив текстом ответа - до 5 строк, третье лицо."
|
||||
)
|
||||
_RELATIVE = re.compile(r"^\+(\d+)\s*([smhd])$")
|
||||
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||
_CLOSE_WAIT = 0.25
|
||||
_CLOSE_TRIES = 40
|
||||
_CAP_TRIES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -132,6 +156,18 @@ class ConversationTexts:
|
||||
"на утро, не задание - прошедшее время, без повелительного наклонения."
|
||||
)
|
||||
new_day: str = "Новый день: мастер сменился, хендаут за {day} записан."
|
||||
distill: Callable[[DistillContext], Awaitable[str] | str] | None = None
|
||||
"""The distiller fork's first message (§8.4): which chat, what day, where
|
||||
the digest goes; ``None`` uses a path-less default."""
|
||||
|
||||
closed: str = "Закрыт глубокий чат [[{chat}]]{digest}.\n{text}"
|
||||
"""What the master hears about a closed deep chat; ``{digest}`` is
|
||||
``, выжимка [[…]]`` or nothing, ``{text}`` the distiller's merge."""
|
||||
|
||||
too_long: str = (
|
||||
"`{name}`: {lines} строк при потолке {max_lines}. Запись отбита, файл "
|
||||
"возвращён к прежней версии. Сократи и перепиши."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -141,6 +177,18 @@ class ForkResult:
|
||||
capture: TurnCapture
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistillResult:
|
||||
"""A closed deep chat: the merge text and, if memory was on, the digest."""
|
||||
|
||||
conversation: Conversation
|
||||
fork: Conversation
|
||||
text: str
|
||||
digest: Digest | None
|
||||
error: str | None
|
||||
trimmed: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Runner:
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
@@ -191,8 +239,10 @@ class Conversations:
|
||||
idle_interval: float = 3600.0,
|
||||
question_timeout: float = 600.0,
|
||||
envelope: Envelope | None = None,
|
||||
distiller: Distiller | None = None,
|
||||
) -> None:
|
||||
self._db = db
|
||||
self._distiller = distiller
|
||||
self._agents = agents
|
||||
self._backends = backends
|
||||
self._bus = bus
|
||||
@@ -573,6 +623,7 @@ class Conversations:
|
||||
window: int | None = None,
|
||||
origin: str = "api",
|
||||
binding: tuple[str, str] | None = None,
|
||||
flags: dict[str, Any] | None = None,
|
||||
) -> Conversation:
|
||||
"""Create a conversation and queue its seed turn (§8.2).
|
||||
|
||||
@@ -610,6 +661,7 @@ class Conversations:
|
||||
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])
|
||||
@@ -658,13 +710,21 @@ class Conversations:
|
||||
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 and run ``prompt`` on it.
|
||||
|
||||
``agent`` runs the fork under another agent's prompt (the distiller
|
||||
closing a deep chat, §8.4); the copy then lives under that agent's
|
||||
project key. Forks get no MCP (§3.2).
|
||||
"""
|
||||
agent = agent or conv.agent_name
|
||||
session_id = await self._copy_session(
|
||||
conv, window=window, strip_tools=strip_tools
|
||||
conv, window=window, strip_tools=strip_tools, agent=agent
|
||||
)
|
||||
child = await self.create(
|
||||
kind="fork",
|
||||
agent=conv.agent_name,
|
||||
agent=agent,
|
||||
parent=conv,
|
||||
title=title or f"fork: {conv.title or conv.external_id}",
|
||||
origin="system",
|
||||
@@ -675,7 +735,7 @@ class Conversations:
|
||||
child, prompt, origin="fork", tools=False
|
||||
)
|
||||
finally:
|
||||
await self._backend(conv.agent_name).close(child.external_id)
|
||||
await self._backend(agent).close(child.external_id)
|
||||
child = await self.set_status(child, "closed")
|
||||
return ForkResult(conversation=child, text=text, capture=capture)
|
||||
|
||||
@@ -882,6 +942,261 @@ class Conversations:
|
||||
await self._backend(conv.agent_name).close(conv.external_id)
|
||||
return row
|
||||
|
||||
# ---- §8.4 closing a deep chat ---------------------------------------
|
||||
|
||||
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`` quiet for ``days``, oldest first.
|
||||
|
||||
Only those with a session (something to fork) and, with ``since``,
|
||||
with activity after it - the pile from before the system went live
|
||||
is left alone (§4.5).
|
||||
"""
|
||||
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:
|
||||
"""Close a deep chat (§8.4): fork it under the distiller, two channels.
|
||||
|
||||
The digest is a file the fork writes under ``Distiller.dir`` - the
|
||||
gateway checks it appeared with a valid frontmatter and puts a line
|
||||
into the index; the merge is the fork's text, at most five lines,
|
||||
injected into the open master. ``flags.memory=False`` skips the
|
||||
file. The chat's own file is untouched; ``status=closed``.
|
||||
"""
|
||||
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"выжимка: {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 = "файл выжимки не появился"
|
||||
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=f", выжимка [[{digest.path.stem}]]" if digest else "",
|
||||
text=text,
|
||||
)
|
||||
await self.inject(master, note, urgency="normal", origin="выжимка")
|
||||
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 = (
|
||||
_DEFAULT_DISTILL_PROMPT
|
||||
if ctx.memory
|
||||
else _DEFAULT_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 _open_master(self) -> Conversation | None:
|
||||
masters = await self.find(kind="master", status="open", limit=1)
|
||||
return masters[0] if masters else None
|
||||
|
||||
async def _window_of(self, conv: Conversation) -> str | None:
|
||||
for binding in await self.bindings(conv):
|
||||
if binding.visible:
|
||||
return binding.external_id
|
||||
return None
|
||||
|
||||
async def _chat_name(self, conv: Conversation) -> str:
|
||||
"""What ``[[…]]`` to the chat says: the file's stem when it has one."""
|
||||
window = await self._window_of(conv)
|
||||
if window and window.endswith(".md"):
|
||||
return window.rsplit("/", 1)[-1][: -len(".md")]
|
||||
return await self.implied_title(conv) or conv.external_id
|
||||
|
||||
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)
|
||||
|
||||
# ---- turn hooks ------------------------------------------------------
|
||||
|
||||
async def _before_turn(self, conv: Conversation) -> str | None:
|
||||
"""Snapshot the capped file so a too-long rewrite can be bounced."""
|
||||
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"):
|
||||
task = asyncio.create_task(self._close_after_turn(row))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
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="потолок",
|
||||
interrupt=False,
|
||||
)
|
||||
|
||||
async def reparent(self, conv: Conversation, parent: Conversation) -> Conversation:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.parent_id = parent.id
|
||||
@@ -1020,6 +1335,7 @@ class Conversations:
|
||||
runner.started_at = datetime.now(UTC)
|
||||
runner.tools = {}
|
||||
await self._mark_running(conv, turn_id)
|
||||
before = await self._before_turn(conv)
|
||||
self._bus.publish(
|
||||
"turn.start",
|
||||
conversation_id=conv.external_id,
|
||||
@@ -1053,6 +1369,11 @@ class Conversations:
|
||||
finally:
|
||||
runner.turn_id = None
|
||||
await self._mark_done(conv, capture, cut=cut)
|
||||
if stop != "error":
|
||||
try:
|
||||
await self._after_turn(conv, before)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("after-turn hook on %s failed", conv.external_id)
|
||||
self._bus.publish(
|
||||
"turn.end",
|
||||
conversation_id=conv.external_id,
|
||||
@@ -1177,7 +1498,12 @@ class Conversations:
|
||||
}
|
||||
|
||||
async def _copy_session(
|
||||
self, conv: Conversation, *, window: int | None, strip_tools: bool
|
||||
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"
|
||||
@@ -1186,21 +1512,26 @@ class Conversations:
|
||||
if live is not None and live.dirty:
|
||||
msg = f"conversation {conv.external_id} has a mirror gap; not forking"
|
||||
raise RuntimeError(msg)
|
||||
agent = self._claude_agent(conv.agent_name)
|
||||
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(agent.cwd)
|
||||
self._store, conv.session_id, directory=str(source.cwd)
|
||||
)
|
||||
if window is not None or strip_tools:
|
||||
key = {
|
||||
"project_key": project_key_for_directory(str(agent.cwd)),
|
||||
"session_id": forked.session_id,
|
||||
}
|
||||
entries = await self._store.load(cast("Any", key)) or []
|
||||
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", key))
|
||||
await self._store.append(cast("Any", key), cast("Any", 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,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Closing a deep chat: the digest, the index, the file line cap (§6.4, §8.4).
|
||||
|
||||
The gateway knows no path by itself (§0.8): ``Distiller`` from ``config.py``
|
||||
names the agent, says where digests land and where the index lives, and
|
||||
the distiller writes the file on its own. What the gateway does is check that a file
|
||||
with a valid frontmatter appeared under ``Distiller.dir`` during the fork
|
||||
turn, put one line into the index, and cap the merge text at
|
||||
``SUMMARY_LINES``. ``LineCap`` is the same idea for a file a job rewrites
|
||||
(``состояние.md``): a result longer than the cap is bounced - the file
|
||||
goes back to what it was and the job is told to shorten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import frontmatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
__all__ = [
|
||||
"SUMMARY_LINES",
|
||||
"Digest",
|
||||
"DistillContext",
|
||||
"Distiller",
|
||||
"LineCap",
|
||||
"append_index",
|
||||
"check_digest",
|
||||
"find_digest",
|
||||
"index_line",
|
||||
"trim_summary",
|
||||
"written_paths",
|
||||
]
|
||||
|
||||
SUMMARY_LINES = 5
|
||||
"""A merge into the master is at most this many lines (§6.4)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Distiller:
|
||||
"""Who closes deep chats, where digests land, where the index lives."""
|
||||
|
||||
agent: str
|
||||
dir: Path
|
||||
index: Path
|
||||
type: str = "выжимка"
|
||||
"""Value the ``type`` frontmatter key must carry."""
|
||||
|
||||
index_header: str = "# индекс\n\nстрока на выжимку: чат → его выжимка.\n" # noqa: RUF001
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistillContext:
|
||||
"""What the setup's distill prompt is built from."""
|
||||
|
||||
conversation: Conversation
|
||||
title: str | None
|
||||
source: str | None
|
||||
"""The window the chat lives in (a vault-relative path for markdown)."""
|
||||
|
||||
chat_name: str
|
||||
"""What a ``[[wikilink]]`` to the chat is called."""
|
||||
|
||||
memory: bool
|
||||
reason: str
|
||||
day: date
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Digest:
|
||||
path: Path
|
||||
source: str
|
||||
date: date
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LineCap:
|
||||
"""A file a job rewrites may not exceed ``max_lines`` after its turn."""
|
||||
|
||||
path: Path
|
||||
max_lines: int
|
||||
|
||||
def as_flags(self) -> dict[str, Any]:
|
||||
return {"path": str(self.path), "max_lines": self.max_lines}
|
||||
|
||||
@classmethod
|
||||
def from_flags(cls, value: Any) -> LineCap | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
path, max_lines = value.get("path"), value.get("max_lines")
|
||||
if not isinstance(path, str) or not isinstance(max_lines, int):
|
||||
return None
|
||||
return cls(path=Path(path), max_lines=max_lines)
|
||||
|
||||
|
||||
def written_paths(messages: Iterable[dict[str, Any]]) -> list[Path]:
|
||||
"""Paths the turn's ``Write``/``Edit`` calls targeted, in order."""
|
||||
out: list[Path] = []
|
||||
for message in messages:
|
||||
content = message.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
||||
continue
|
||||
if block.get("name") not in ("Write", "Edit", "MultiEdit"):
|
||||
continue
|
||||
target = (block.get("input") or {}).get("file_path")
|
||||
if isinstance(target, str) and target:
|
||||
out.append(Path(target))
|
||||
return out
|
||||
|
||||
|
||||
def find_digest(
|
||||
digests: Distiller, *, since: datetime, written: Sequence[Path] = ()
|
||||
) -> Path | None:
|
||||
"""The digest the fork turn produced.
|
||||
|
||||
A written path under ``dir`` first, otherwise the newest file there
|
||||
modified since ``since``.
|
||||
"""
|
||||
root = digests.dir.resolve()
|
||||
for path in reversed(list(written)):
|
||||
candidate = path if path.is_absolute() else digests.dir / path
|
||||
try:
|
||||
inside = candidate.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if (digests.dir / inside).is_file():
|
||||
return digests.dir / inside
|
||||
if not digests.dir.is_dir():
|
||||
return None
|
||||
stamp = since.timestamp() - 1
|
||||
fresh = [
|
||||
p
|
||||
for p in digests.dir.glob("*.md")
|
||||
if p.is_file() and p.stat().st_mtime >= stamp
|
||||
]
|
||||
return max(fresh, key=lambda p: p.stat().st_mtime) if fresh else None
|
||||
|
||||
|
||||
def check_digest(path: Path, digests: Distiller) -> Digest | str:
|
||||
"""The digest's frontmatter parsed, or why it is not a digest."""
|
||||
try:
|
||||
post = frontmatter.load(str(path))
|
||||
except (OSError, ValueError) as exc:
|
||||
return f"не читается: {exc}"
|
||||
meta = post.metadata
|
||||
if meta.get("type") != digests.type:
|
||||
return f"`type` должен быть `{digests.type}`, не {meta.get('type')!r}"
|
||||
source = meta.get("source")
|
||||
if not isinstance(source, str) or not source.strip():
|
||||
return "`source` пустой"
|
||||
when = meta.get("date")
|
||||
if isinstance(when, datetime):
|
||||
when = when.date()
|
||||
elif isinstance(when, str):
|
||||
try:
|
||||
when = date.fromisoformat(when.strip())
|
||||
except ValueError:
|
||||
return f"`date` не дата: {when!r}"
|
||||
if not isinstance(when, date):
|
||||
return "`date` отсутствует"
|
||||
if not post.content.strip():
|
||||
return "тело пустое"
|
||||
return Digest(path=path, source=source.strip(), date=when)
|
||||
|
||||
|
||||
def index_line(digest: Digest, chat_name: str) -> str:
|
||||
return f"- {digest.date.isoformat()} [[{chat_name}]] → [[{digest.path.stem}]]"
|
||||
|
||||
|
||||
def append_index(digests: Distiller, line: str) -> None:
|
||||
index = digests.index
|
||||
text = index.read_text(encoding="utf-8") if index.exists() else ""
|
||||
if line in text.splitlines():
|
||||
return
|
||||
if not text:
|
||||
text = digests.index_header
|
||||
if not text.endswith("\n"):
|
||||
text += "\n"
|
||||
index.parent.mkdir(parents=True, exist_ok=True)
|
||||
index.write_text(text + line + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def trim_summary(text: str, limit: int = SUMMARY_LINES) -> tuple[str, bool]:
|
||||
"""The merge text cut to ``limit`` non-empty lines; ``True`` if it was."""
|
||||
lines = [line.rstrip() for line in text.strip().splitlines() if line.strip()]
|
||||
if len(lines) <= limit:
|
||||
return "\n".join(lines), False
|
||||
return "\n".join(lines[:limit]), True
|
||||
@@ -26,7 +26,7 @@ __all__ = ["SERVER_NAME", "TOOL_NAMES", "build_tool_server"]
|
||||
_log = logging.getLogger("beaver_gateway.core.gateway_tools")
|
||||
|
||||
SERVER_NAME = "gateway"
|
||||
TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject")
|
||||
TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject", "close_chat")
|
||||
|
||||
|
||||
def build_tool_server(
|
||||
@@ -171,7 +171,23 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
|
||||
)
|
||||
return _text(f"queued #{item.id}")
|
||||
|
||||
return [read_conversation, spawn, say, schedule, inject]
|
||||
@tool(
|
||||
"close_chat",
|
||||
"Close this deep chat once the current reply is finished: the "
|
||||
"distiller forks it, writes the digest and hands a short merge to the "
|
||||
"master. Call it when the human says the discussion is over; the chat "
|
||||
"file stays as it is.",
|
||||
{"type": "object", "properties": {}},
|
||||
)
|
||||
async def close_chat(_args: dict[str, Any]) -> dict[str, Any]:
|
||||
conv = await current()
|
||||
try:
|
||||
await conversations.request_close(conv)
|
||||
except ValueError as exc:
|
||||
return _error(str(exc))
|
||||
return _text("ok: the chat closes after this reply")
|
||||
|
||||
return [read_conversation, spawn, say, schedule, inject, close_chat]
|
||||
|
||||
|
||||
def _text(text: str) -> dict[str, Any]:
|
||||
|
||||
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
from beaver_gateway.core.conversations import ConversationTexts
|
||||
from beaver_gateway.core.distill import Distiller
|
||||
from beaver_gateway.core.rotation import RotationPolicy
|
||||
from beaver_gateway.core.scheduler import Budget, Job
|
||||
from beaver_gateway.core.watch import VaultWatch
|
||||
@@ -95,6 +96,8 @@ class Gateway:
|
||||
"""Vault watcher feeding the envelope (§3.5, §4.6); ``None`` = no vault block."""
|
||||
budget: Budget | None = None
|
||||
"""Subscription window past which non-critical jobs wait (§4.5)."""
|
||||
distiller: Distiller | None = None
|
||||
"""Who closes deep chats and where the digests and the index live (§8.4)."""
|
||||
tz: str = "UTC"
|
||||
"""Local zone for the envelope clock and the rotation hour."""
|
||||
host: str = "0.0.0.0" # noqa: S104
|
||||
|
||||
@@ -36,7 +36,8 @@ if TYPE_CHECKING:
|
||||
from pgqueuer.ports.driver import Driver
|
||||
from starlette.requests import Request
|
||||
|
||||
from beaver_gateway.core.conversations import Conversations
|
||||
from beaver_gateway.core.conversations import Conversations, DistillResult
|
||||
from beaver_gateway.core.distill import LineCap
|
||||
from beaver_gateway.core.injects import Priority
|
||||
from beaver_gateway.core.rotation import Rotation
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
@@ -98,12 +99,45 @@ class JobRun:
|
||||
return True
|
||||
|
||||
async def spawn_job(
|
||||
self, *, agent: str, text: str, title: str | None = None
|
||||
self,
|
||||
*,
|
||||
agent: str,
|
||||
text: str,
|
||||
title: str | None = None,
|
||||
line_cap: LineCap | None = None,
|
||||
) -> Conversation:
|
||||
"""A headless job turn; ``line_cap`` bounces a rewrite past the cap."""
|
||||
return await self.conversations.spawn(
|
||||
kind="job", agent=agent, seed="brief", text=text, title=title, origin="job"
|
||||
kind="job",
|
||||
agent=agent,
|
||||
seed="brief",
|
||||
text=text,
|
||||
title=title,
|
||||
origin="job",
|
||||
flags={"line_cap": line_cap.as_flags()} if line_cap else None,
|
||||
)
|
||||
|
||||
async def close_idle(
|
||||
self,
|
||||
*,
|
||||
kind: str = "deep",
|
||||
days: int = 2,
|
||||
limit: int = 3,
|
||||
since: datetime | None = None,
|
||||
) -> list[DistillResult]:
|
||||
"""§4.5: close chats quiet for ``days``, at most ``limit`` per run."""
|
||||
out: list[DistillResult] = []
|
||||
for conv in await self.conversations.idle(
|
||||
kind=kind, days=days, since=since, limit=limit
|
||||
):
|
||||
try:
|
||||
out.append(
|
||||
await self.conversations.distill(conv, reason=f"idle {days}d")
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("closing idle %s failed", conv.external_id)
|
||||
return out
|
||||
|
||||
async def retry_in(self, delay: timedelta) -> None:
|
||||
await self.scheduler.trigger(
|
||||
self.job, self.payload, delay=delay, trigger=self.trigger
|
||||
|
||||
@@ -441,6 +441,36 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"text": result.text,
|
||||
}
|
||||
|
||||
@app.post("/conversations/{public_id}/close")
|
||||
async def post_close(public_id: str, request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
if conv.kind != "deep":
|
||||
closed = await conversations.close(conv)
|
||||
return {"id": closed.external_id, "status": closed.status}
|
||||
try:
|
||||
result = await conversations.distill(conv, reason="api")
|
||||
except (ValueError, LookupError) as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token}",
|
||||
kind="api_close",
|
||||
agent_name=conv.agent_name,
|
||||
conversation=conv.external_id,
|
||||
digest=str(result.digest.path) if result.digest else None,
|
||||
)
|
||||
return {
|
||||
"id": conv.external_id,
|
||||
"status": "closed",
|
||||
"fork": result.fork.external_id,
|
||||
"text": result.text,
|
||||
"digest": str(result.digest.path) if result.digest else None,
|
||||
"error": result.error,
|
||||
}
|
||||
|
||||
@app.post("/conversations/{public_id}/fork")
|
||||
async def post_fork(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
|
||||
@@ -420,3 +420,31 @@ async def test_pre_sdk_rows_show_canonical_history_and_a_title(world: World) ->
|
||||
assert shown["title"] == "так смотри, план на май" and shown["session_id"] is None
|
||||
history = await api.get(f"/conversations/{conv.external_id}/history")
|
||||
assert [m["role"] for m in history["messages"]] == ["user", "assistant"]
|
||||
|
||||
|
||||
async def test_close_distills_a_deep_chat(world: World) -> None:
|
||||
from test_distill import DistillerClient, deep_chat, distiller
|
||||
|
||||
config = distiller(world, DistillerClient)
|
||||
api = Api(world)
|
||||
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
chat = await deep_chat(world)
|
||||
res = await api.http.post(
|
||||
f"/conversations/{chat.external_id}/close", headers=HEADERS
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert body["status"] == "closed" and body["error"] is None
|
||||
assert body["digest"] == str(config.dir / "2026-08-29 - тема.md")
|
||||
assert body["text"].count("\n") == 2
|
||||
assert (await world.conversations.get(chat.external_id)).status == "closed"
|
||||
assert (await world.conversations.queue.recent(master.id))[0].origin == "выжимка"
|
||||
again = await api.http.post(
|
||||
f"/conversations/{chat.external_id}/close", headers=HEADERS
|
||||
)
|
||||
assert again.status_code == 400
|
||||
branch = await world.conversations.create(kind="branch", agent="a", origin="test")
|
||||
plain = await api.http.post(
|
||||
f"/conversations/{branch.external_id}/close", headers=HEADERS
|
||||
)
|
||||
assert plain.status_code == 200 and plain.json()["status"] == "closed"
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
ResultMessage,
|
||||
TextBlock,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UserMessage,
|
||||
project_key_for_directory,
|
||||
)
|
||||
from test_conversations import ScriptedClient, World, world
|
||||
|
||||
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
|
||||
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
|
||||
from beaver_gateway.core.conversations import ConversationTexts
|
||||
from beaver_gateway.core.distill import (
|
||||
Distiller,
|
||||
DistillContext,
|
||||
LineCap,
|
||||
check_digest,
|
||||
trim_summary,
|
||||
)
|
||||
from beaver_gateway.core.gateway_tools import _tools, build_tool_server
|
||||
from beaver_gateway.core.registry import AgentRegistry
|
||||
from beaver_gateway.core.scheduler import Job, JobRun, Scheduler
|
||||
from beaver_gateway.core.transcript import build_entries
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
__all__ = ["world"]
|
||||
|
||||
DIGEST = """---
|
||||
type: выжимка
|
||||
source: "[[{chat}]]"
|
||||
date: 2026-08-29
|
||||
---
|
||||
# тема
|
||||
|
||||
## контекст
|
||||
зачем открывали
|
||||
|
||||
## решили
|
||||
- одно
|
||||
"""
|
||||
|
||||
|
||||
class DistillerClient(ScriptedClient):
|
||||
"""The distiller: writes a digest file when told to, answers in N lines."""
|
||||
|
||||
digest_dir: Path | None = None
|
||||
lines = 3
|
||||
write = True
|
||||
frontmatter = DIGEST
|
||||
|
||||
async def receive_response(self):
|
||||
prompt = self.prompts[-1]
|
||||
written: list[ToolUseBlock] = []
|
||||
if self.write and self.digest_dir is not None and "файл не пиши" not in prompt:
|
||||
chat = prompt.split("«", 1)[1].split("»", 1)[0] if "«" in prompt else "чат"
|
||||
path = self.digest_dir / "2026-08-29 - тема.md"
|
||||
path.write_text(self.frontmatter.format(chat=chat), encoding="utf-8")
|
||||
written.append(
|
||||
ToolUseBlock(id="w1", name="Write", input={"file_path": str(path)})
|
||||
)
|
||||
for block in written:
|
||||
yield AssistantMessage(content=[block], model="m")
|
||||
yield UserMessage(content=[ToolResultBlock(tool_use_id=block.id)])
|
||||
text = "\n".join(f"строка {i} про {prompt[:20]!r}" for i in range(self.lines))
|
||||
yield AssistantMessage(content=[TextBlock(text=text)], model="m")
|
||||
yield ResultMessage(
|
||||
subtype="success",
|
||||
duration_ms=1,
|
||||
duration_api_ms=1,
|
||||
is_error=False,
|
||||
num_turns=1,
|
||||
session_id=self.session_id,
|
||||
stop_reason="end_turn",
|
||||
total_cost_usd=0.0,
|
||||
usage={"input_tokens": 1, "output_tokens": 1},
|
||||
)
|
||||
|
||||
|
||||
class ClosingClient(ScriptedClient):
|
||||
"""A deep chat that calls ``close_chat`` inside its reply."""
|
||||
|
||||
conversations: Any = None
|
||||
key = ""
|
||||
|
||||
async def receive_response(self):
|
||||
prompt = self.prompts[-1]
|
||||
if "обсудили" in prompt:
|
||||
assert "gateway" in self.options.mcp_servers
|
||||
tools = _tools(ClosingClient.conversations, ClosingClient.key)
|
||||
close = next(t for t in tools if t.name == "close_chat")
|
||||
result = await close.handler({})
|
||||
assert "closes after this reply" in result["content"][0]["text"]
|
||||
yield AssistantMessage(
|
||||
content=[
|
||||
ToolUseBlock(id="c1", name="mcp__gateway__close_chat", input={})
|
||||
],
|
||||
model="m",
|
||||
)
|
||||
yield AssistantMessage(content=[TextBlock(text=f"ok:{prompt}")], model="m")
|
||||
yield ResultMessage(
|
||||
subtype="success",
|
||||
duration_ms=1,
|
||||
duration_api_ms=1,
|
||||
is_error=False,
|
||||
num_turns=1,
|
||||
session_id=self.session_id,
|
||||
stop_reason="end_turn",
|
||||
total_cost_usd=0.0,
|
||||
usage={"input_tokens": 1, "output_tokens": 1},
|
||||
)
|
||||
|
||||
|
||||
class CapClient(ScriptedClient):
|
||||
"""A job that rewrites the capped file with ``lines`` lines."""
|
||||
|
||||
target: Path | None = None
|
||||
lines = 70
|
||||
|
||||
async def receive_response(self):
|
||||
assert self.target is not None
|
||||
self.target.write_text(
|
||||
"\n".join(f"- строка {i}" for i in range(self.lines)), encoding="utf-8"
|
||||
)
|
||||
CapClient.lines = 10
|
||||
async for event in super().receive_response():
|
||||
yield event
|
||||
|
||||
|
||||
def distiller(world: World, client: type[ScriptedClient]) -> Distiller:
|
||||
vault = world.root / "vault"
|
||||
digests = vault / "мета" / "бобер" / "выжимки"
|
||||
digests.mkdir(parents=True)
|
||||
agent = ClaudeAgent(
|
||||
name="x",
|
||||
model="m",
|
||||
system_prompt="distill",
|
||||
cwd=vault,
|
||||
kinds=("fork", "job"),
|
||||
options=ClaudeOptions(
|
||||
effort="medium", tools=("Read", "Write"), include_partial_messages=False
|
||||
),
|
||||
)
|
||||
backend = ClaudeSdkBackend(
|
||||
agent=agent,
|
||||
mcp_internal_urls={},
|
||||
session_store=world.store,
|
||||
client_factory=client,
|
||||
work_dir=world.root / "work",
|
||||
pool=world.pool,
|
||||
)
|
||||
world.conversations._agents = AgentRegistry( # noqa: SLF001
|
||||
[world.agent, world.deep_agent, agent]
|
||||
)
|
||||
world.conversations._backends["x"] = backend # noqa: SLF001
|
||||
config = Distiller(agent="x", dir=digests, index=digests.parent / "индекс.md")
|
||||
world.conversations._distiller = config # noqa: SLF001
|
||||
DistillerClient.digest_dir = digests
|
||||
DistillerClient.lines = 3
|
||||
DistillerClient.write = True
|
||||
DistillerClient.frontmatter = DIGEST
|
||||
return config
|
||||
|
||||
|
||||
async def deep_chat(world: World, name: str = "2026-08-20 - тема чата") -> Conversation:
|
||||
sid = str(uuid.uuid4())
|
||||
await world.store.append(
|
||||
world.key(sid),
|
||||
build_entries(
|
||||
[
|
||||
{"role": "user", "content": "q"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "t1", "name": "Read", "input": {}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "x"}
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "a"},
|
||||
],
|
||||
session_id=sid,
|
||||
cwd=str(world.root),
|
||||
model="m",
|
||||
),
|
||||
)
|
||||
conv = await world.conversations.create(
|
||||
kind="deep", agent="d", origin="markdown", session_id=sid
|
||||
)
|
||||
await world.conversations.bind(
|
||||
conv, frontend="markdown", external_id=f"2026-08/{name}.md"
|
||||
)
|
||||
return conv
|
||||
|
||||
|
||||
async def test_distill_writes_the_digest_indexes_it_and_merges_short(
|
||||
world: World,
|
||||
) -> None:
|
||||
config = distiller(world, DistillerClient)
|
||||
prompts: list[DistillContext] = []
|
||||
|
||||
def distill_prompt(ctx: DistillContext) -> str:
|
||||
prompts.append(ctx)
|
||||
return f"Чат «{ctx.chat_name}» закрыт ({ctx.reason})."
|
||||
|
||||
world.conversations._texts = ConversationTexts(distill=distill_prompt) # noqa: SLF001
|
||||
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
chat = await deep_chat(world)
|
||||
|
||||
result = await world.conversations.distill(chat, reason="api")
|
||||
|
||||
assert prompts[0].chat_name == "2026-08-20 - тема чата"
|
||||
assert prompts[0].memory is True
|
||||
assert result.digest is not None and result.error is None
|
||||
assert result.digest.path == config.dir / "2026-08-29 - тема.md"
|
||||
assert result.digest.source == "[[2026-08-20 - тема чата]]"
|
||||
index = config.index.read_text(encoding="utf-8")
|
||||
assert index.startswith("# индекс")
|
||||
assert "- 2026-08-29 [[2026-08-20 - тема чата]] → [[2026-08-29 - тема]]" in index
|
||||
assert result.text.count("\n") == 2 and not result.trimmed
|
||||
closed = await world.conversations.get(chat.external_id)
|
||||
assert closed.status == "closed"
|
||||
assert closed.flags["digest"] == str(result.digest.path)
|
||||
assert closed.flags["closed_reason"] == "api"
|
||||
fork = await world.conversations.get(result.fork.external_id)
|
||||
assert fork.kind == "fork" and fork.agent_name == "x" and fork.status == "closed"
|
||||
items = await world.conversations.queue.recent(master.id)
|
||||
assert items[0].origin == "выжимка"
|
||||
assert items[0].text.startswith(
|
||||
"Закрыт глубокий чат [[2026-08-20 - тема чата]], выжимка [[2026-08-29 - тема]]."
|
||||
)
|
||||
assert items[0].text.endswith(result.text)
|
||||
forked = ScriptedClient.instances[-1]
|
||||
assert forked.options.resume is not None
|
||||
vault_key = {
|
||||
"project_key": project_key_for_directory(str(config.dir.parents[2])),
|
||||
"session_id": forked.options.resume,
|
||||
}
|
||||
entries = await world.store.load(vault_key)
|
||||
assert entries
|
||||
assert all(
|
||||
not isinstance(e.get("message", {}).get("content"), list)
|
||||
or all(b.get("type") == "text" for b in e["message"]["content"])
|
||||
for e in entries
|
||||
)
|
||||
|
||||
|
||||
async def test_memory_off_merges_without_a_file(world: World) -> None:
|
||||
config = distiller(world, DistillerClient)
|
||||
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
chat = await deep_chat(world)
|
||||
await world.conversations.set_flags(chat, {"memory": False})
|
||||
|
||||
result = await world.conversations.distill(chat, reason="api")
|
||||
|
||||
assert result.digest is None and result.error is None
|
||||
assert list(config.dir.iterdir()) == []
|
||||
assert not config.index.exists()
|
||||
assert (await world.conversations.get(chat.external_id)).status == "closed"
|
||||
items = await world.conversations.queue.recent(master.id)
|
||||
assert len(items) == 1 and items[0].text.endswith(result.text)
|
||||
assert ", выжимка" not in items[0].text
|
||||
assert "файл не пиши" in ScriptedClient.instances[-1].prompts[0]
|
||||
|
||||
|
||||
async def test_bad_frontmatter_and_long_merge_are_reported(world: World) -> None:
|
||||
distiller(world, DistillerClient)
|
||||
DistillerClient.frontmatter = "---\ntype: заметка\n---\n# тема\n"
|
||||
DistillerClient.lines = 9
|
||||
await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
chat = await deep_chat(world)
|
||||
|
||||
result = await world.conversations.distill(chat, reason="idle 2d")
|
||||
|
||||
assert result.digest is None
|
||||
assert result.error is not None and "`type`" in result.error
|
||||
assert result.trimmed and result.text.count("\n") == 4
|
||||
closed = await world.conversations.get(chat.external_id)
|
||||
assert closed.status == "closed"
|
||||
assert closed.flags["digest_error"] == result.error
|
||||
|
||||
|
||||
def test_check_digest_rejects_what_is_not_a_digest(tmp_path: Path) -> None:
|
||||
config = Distiller(agent="x", dir=tmp_path, index=tmp_path / "i.md")
|
||||
path = tmp_path / "d.md"
|
||||
path.write_text("---\ntype: выжимка\nsource: ''\ndate: 2026-08-29\n---\nx\n")
|
||||
assert check_digest(path, config) == "`source` пустой"
|
||||
path.write_text("---\ntype: выжимка\nsource: '[[a]]'\ndate: вчера\n---\nx\n")
|
||||
assert "`date`" in check_digest(path, config)
|
||||
path.write_text("---\ntype: выжимка\nsource: '[[a]]'\ndate: 2026-08-29\n---\n\n")
|
||||
assert check_digest(path, config) == "тело пустое"
|
||||
path.write_text("---\ntype: выжимка\nsource: '[[a]]'\ndate: 2026-08-29\n---\nx\n")
|
||||
digest = check_digest(path, config)
|
||||
assert not isinstance(digest, str) and digest.date.isoformat() == "2026-08-29"
|
||||
assert trim_summary("a\n\nb\nc\nd\ne\nf") == ("a\nb\nc\nd\ne", True)
|
||||
|
||||
|
||||
async def test_close_chat_tool_closes_after_the_reply(world: World) -> None:
|
||||
distiller(world, DistillerClient)
|
||||
world.deep_agent = ClaudeAgent(
|
||||
name="d",
|
||||
model="m",
|
||||
system_prompt="deep",
|
||||
cwd=world.root,
|
||||
gateway_tools=("close_chat",),
|
||||
)
|
||||
world.conversations._agents = AgentRegistry( # noqa: SLF001
|
||||
[world.agent, world.deep_agent, world.conversations._agents.get("x")] # noqa: SLF001
|
||||
)
|
||||
world.conversations._backends["d"] = ClaudeSdkBackend( # noqa: SLF001
|
||||
agent=world.deep_agent,
|
||||
mcp_internal_urls={},
|
||||
session_store=world.store,
|
||||
client_factory=ClosingClient,
|
||||
work_dir=world.root / "work",
|
||||
pool=world.pool,
|
||||
tool_server=lambda key, _kind: build_tool_server(
|
||||
world.conversations, conversation_key=key, names=("close_chat",)
|
||||
),
|
||||
)
|
||||
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
chat = await deep_chat(world)
|
||||
ClosingClient.conversations = world.conversations
|
||||
ClosingClient.key = chat.external_id
|
||||
|
||||
await world.conversations.post(chat, "ок, обсудили")
|
||||
await world.settle(chat, 1)
|
||||
for _ in range(100):
|
||||
row = await world.conversations.get(chat.external_id)
|
||||
if row.status == "closed":
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
raise AssertionError("chat did not close")
|
||||
|
||||
assert row.flags["closed_reason"] == "close_chat"
|
||||
assert row.flags["digest"] is not None
|
||||
items = await world.conversations.queue.recent(master.id)
|
||||
assert items[0].origin == "выжимка"
|
||||
|
||||
|
||||
async def test_idle_picks_quiet_chats_after_launch_at_most_limit(world: World) -> None:
|
||||
distiller(world, DistillerClient)
|
||||
now = datetime.now(UTC)
|
||||
launch = now - timedelta(days=10)
|
||||
|
||||
async def aged(name: str, days: float) -> Conversation:
|
||||
conv = await deep_chat(world, name)
|
||||
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.last_activity_at = now - timedelta(days=days)
|
||||
|
||||
return await world.conversations._update(conv, apply) # noqa: SLF001
|
||||
|
||||
old = await aged("old", 30)
|
||||
a = await aged("a", 5)
|
||||
b = await aged("b", 4)
|
||||
c = await aged("c", 3)
|
||||
d = await aged("d", 2.5)
|
||||
fresh = await aged("fresh", 1)
|
||||
no_session = await world.conversations.create(kind="deep", agent="d", origin="t")
|
||||
|
||||
idle = await world.conversations.idle(kind="deep", days=2, since=launch)
|
||||
assert [x.external_id for x in idle] == [
|
||||
a.external_id,
|
||||
b.external_id,
|
||||
c.external_id,
|
||||
d.external_id,
|
||||
]
|
||||
assert old.external_id not in {x.external_id for x in idle}
|
||||
assert fresh.external_id not in {x.external_id for x in idle}
|
||||
assert no_session.external_id not in {x.external_id for x in idle}
|
||||
|
||||
scheduler = Scheduler(conversations=world.conversations)
|
||||
run = JobRun(Job("закрытие", lambda _run: asyncio.sleep(0)), "cron", {}, scheduler)
|
||||
closed = await run.close_idle(kind="deep", days=2, limit=3, since=launch)
|
||||
assert [r.conversation.external_id for r in closed] == [
|
||||
a.external_id,
|
||||
b.external_id,
|
||||
c.external_id,
|
||||
]
|
||||
assert (await world.conversations.get(d.external_id)).status == "open"
|
||||
assert len(await world.conversations.idle(kind="deep", days=2, since=launch)) == 1
|
||||
|
||||
|
||||
async def test_line_cap_bounces_a_long_rewrite_and_asks_to_shorten(
|
||||
world: World,
|
||||
) -> None:
|
||||
distiller(world, CapClient)
|
||||
state = world.root / "vault" / "состояние.md"
|
||||
state.write_text("# состояние\n- было так\n", encoding="utf-8")
|
||||
CapClient.target = state
|
||||
CapClient.lines = 70
|
||||
scheduler = Scheduler(conversations=world.conversations)
|
||||
run = JobRun(Job("память", lambda _run: asyncio.sleep(0)), "cron", {}, scheduler)
|
||||
|
||||
job = await run.spawn_job(
|
||||
agent="x", text="перепиши", line_cap=LineCap(state, max_lines=60)
|
||||
)
|
||||
await world.settle(job, 2)
|
||||
|
||||
client = ScriptedClient.instances[-1]
|
||||
assert len(client.prompts) == 2
|
||||
assert "70 строк при потолке 60" in client.prompts[1]
|
||||
assert "[инжект: потолок" in client.prompts[1]
|
||||
assert state.read_text(encoding="utf-8").count("\n") == 10 - 1
|
||||
row = await world.conversations.get(job.external_id)
|
||||
assert row.flags["line_cap_attempts"] == 1
|
||||
@@ -234,6 +234,17 @@ export class ApiClient {
|
||||
return this.post(`/api/conversations/${id}/merge`);
|
||||
}
|
||||
|
||||
close(id: string): Promise<{
|
||||
id: string;
|
||||
status: string;
|
||||
fork?: string;
|
||||
text?: string;
|
||||
digest?: string | null;
|
||||
error?: string | null;
|
||||
}> {
|
||||
return this.post(`/api/conversations/${id}/close`);
|
||||
}
|
||||
|
||||
bind(
|
||||
id: string,
|
||||
frontend: string,
|
||||
|
||||
@@ -87,6 +87,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function distill() {
|
||||
const result = await client.close(info.id);
|
||||
if (result.error) {
|
||||
toast.warning(`Digest: ${result.error}`);
|
||||
} else if (result.digest) {
|
||||
toast.message(`Digest: ${result.digest}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyId() {
|
||||
navigator.clipboard
|
||||
.writeText(info.id)
|
||||
@@ -153,6 +162,11 @@
|
||||
Merge into parent
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if info.kind === "deep" && info.status === "open"}
|
||||
<DropdownMenu.Item onclick={() => run(distill, "Chat closed")}>
|
||||
Close with digest
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if info.status === "open"}
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
|
||||
Reference in New Issue
Block a user