420 lines
16 KiB
Python
420 lines
16 KiB
Python
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
|