feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror
This commit is contained in:
+101
-11
@@ -29,8 +29,47 @@ from beaver_gateway.core.transcript import (
|
||||
strip_tool_entries,
|
||||
window_entries,
|
||||
)
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.storage import Database
|
||||
from beaver_gateway.storage.models import InjectQueueItem
|
||||
from beaver_gateway.storage.models import (
|
||||
Conversation,
|
||||
ConversationBinding,
|
||||
InjectQueueItem,
|
||||
)
|
||||
|
||||
|
||||
class StubFrontend(Frontend):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
kinds: tuple[str, ...],
|
||||
agents: dict[str, str] | None = None,
|
||||
*,
|
||||
home: bool = False,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.kinds = kinds
|
||||
self.agents = agents or {}
|
||||
self.home = home
|
||||
self.materialized: list[str] = []
|
||||
self.conversations = None
|
||||
|
||||
def configure(self, runtime) -> None:
|
||||
pass
|
||||
|
||||
async def serve(self) -> None:
|
||||
pass
|
||||
|
||||
def agent_for(self, kind: str) -> str | None:
|
||||
return self.agents.get(kind)
|
||||
|
||||
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
|
||||
if not self.home:
|
||||
return None
|
||||
self.materialized.append(conv.external_id)
|
||||
return await self.conversations.bind(
|
||||
conv, frontend=self.name, external_id=f"{self.name}:{conv.external_id}"
|
||||
)
|
||||
|
||||
|
||||
class ScriptedClient:
|
||||
@@ -116,29 +155,41 @@ class World:
|
||||
model="m",
|
||||
system_prompt="hi",
|
||||
cwd=root,
|
||||
kinds=("master", "branch", "job", "fork"),
|
||||
gateway_tools=("say",),
|
||||
options=ClaudeOptions(effort="low"),
|
||||
)
|
||||
self.deep_agent = ClaudeAgent(
|
||||
name="d", model="m", system_prompt="deep", cwd=root
|
||||
)
|
||||
self.markdown = StubFrontend("markdown", ("deep",), {"deep": "d"}, home=True)
|
||||
self.api = StubFrontend("api", ("master", "branch", "deep", "job"))
|
||||
self.pool = SessionPool(rss_limit=1 << 40, max_live=100)
|
||||
self.backend = ClaudeSdkBackend(
|
||||
agent=self.agent,
|
||||
mcp_internal_urls={},
|
||||
session_store=self.store,
|
||||
client_factory=ScriptedClient,
|
||||
work_dir=root / "work",
|
||||
pool=self.pool,
|
||||
self.backend, self.deep_backend = (
|
||||
ClaudeSdkBackend(
|
||||
agent=agent,
|
||||
mcp_internal_urls={},
|
||||
session_store=self.store,
|
||||
client_factory=ScriptedClient,
|
||||
work_dir=root / "work",
|
||||
pool=self.pool,
|
||||
)
|
||||
for agent in (self.agent, self.deep_agent)
|
||||
)
|
||||
self.bus = EventBus()
|
||||
self.conversations = Conversations(
|
||||
db=self.db,
|
||||
agents=AgentRegistry([self.agent]),
|
||||
backends={"a": self.backend},
|
||||
agents=AgentRegistry([self.agent, self.deep_agent]),
|
||||
backends={"a": self.backend, "d": self.deep_backend},
|
||||
bus=self.bus,
|
||||
pool=self.pool,
|
||||
store=self.store,
|
||||
texts=ConversationTexts(),
|
||||
frontends=[self.api, self.markdown],
|
||||
idle_interval=3600,
|
||||
)
|
||||
self.markdown.conversations = self.conversations
|
||||
self.api.conversations = self.conversations
|
||||
|
||||
async def setup(self) -> "World":
|
||||
await self.db.create_all()
|
||||
@@ -460,7 +511,7 @@ async def test_read_and_bindings(world: World) -> None:
|
||||
build_entries(history, session_id=sid, cwd=str(world.root), model="m"),
|
||||
)
|
||||
conv = await world.conversations.create(
|
||||
kind="deep", agent="a", origin="test", session_id=sid
|
||||
kind="deep", agent="d", origin="test", session_id=sid
|
||||
)
|
||||
assert (
|
||||
await world.conversations.read(conv)
|
||||
@@ -544,3 +595,42 @@ def test_transcript_helpers() -> None:
|
||||
)
|
||||
== "user:\nq\n\nassistant:\na"
|
||||
)
|
||||
|
||||
|
||||
async def test_agent_kinds_gate_create(world: World) -> None:
|
||||
with pytest.raises(ValueError, match="does not serve kind 'deep'"):
|
||||
await world.conversations.create(kind="deep", agent="a", origin="test")
|
||||
with pytest.raises(ValueError, match="does not serve kind 'master'"):
|
||||
await world.conversations.create(kind="master", agent="d", origin="test")
|
||||
with pytest.raises(ValueError, match="'d' does not serve kind 'master'"):
|
||||
await world.conversations.spawn(kind="master", seed="clean", agent="d")
|
||||
conv = await world.conversations.create(kind="deep", agent="d", origin="test")
|
||||
assert conv.agent_name == "d"
|
||||
|
||||
|
||||
async def test_frontend_kinds_gate_bind(world: World) -> None:
|
||||
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
with pytest.raises(ValueError, match="'markdown' does not show kind 'master'"):
|
||||
await world.conversations.bind(master, frontend="markdown", external_id="m.md")
|
||||
with pytest.raises(ValueError, match="unknown frontend 'telegram'"):
|
||||
await world.conversations.bind(master, frontend="telegram", external_id="1")
|
||||
await world.conversations.bind(master, frontend="api", external_id="x")
|
||||
|
||||
|
||||
async def test_spawn_defaults_agent_and_materializes(world: World) -> None:
|
||||
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
deep = await world.conversations.spawn(
|
||||
kind="deep", seed="brief", text="dig", title="t", parent=master, origin="mcp"
|
||||
)
|
||||
assert deep.agent_name == "d"
|
||||
assert world.markdown.materialized == [deep.external_id]
|
||||
bindings = await world.conversations.bindings(deep)
|
||||
assert [(b.frontend, b.external_id) for b in bindings] == [
|
||||
("markdown", f"markdown:{deep.external_id}")
|
||||
]
|
||||
branch = await world.conversations.spawn(kind="branch", seed="clean", parent=master)
|
||||
assert branch.agent_name == "a"
|
||||
assert world.markdown.materialized == [deep.external_id]
|
||||
with pytest.raises(ValueError, match="no default agent for kind 'job'"):
|
||||
await world.conversations.spawn(kind="job", seed="clean")
|
||||
await world.settle(deep, 1)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from beaver_gateway.core.auth import TokenStore
|
||||
from beaver_gateway.core.conversation_store import load_messages
|
||||
from beaver_gateway.core.gateway_tools import _tools
|
||||
from beaver_gateway.core.registry import McpRegistry
|
||||
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
||||
from beaver_gateway.frontends.api import ApiFrontend
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.frontends.markdown import MarkdownFrontend
|
||||
from test_conversations import ScriptedClient, World
|
||||
|
||||
AUTH = {"Authorization": "Bearer tok"}
|
||||
|
||||
|
||||
class Stack:
|
||||
def __init__(self, world: World) -> None:
|
||||
self.world = world
|
||||
self.vault = world.root / "vault"
|
||||
self.api = ApiFrontend(default_agents={"master": "a"})
|
||||
self.markdown = MarkdownFrontend(vault_path=self.vault, default_agent="d")
|
||||
self.anthropic = AnthropicMessagesFrontend()
|
||||
frontends = [self.api, self.anthropic, self.markdown]
|
||||
world.conversations._frontends = frontends
|
||||
self.runtime = GatewayRuntime(
|
||||
agents=world.conversations._agents,
|
||||
mcps=McpRegistry([]),
|
||||
backends=world.conversations._backends,
|
||||
token_store=TokenStore(bootstrap={"t": "tok"}),
|
||||
db=world.db,
|
||||
conversations=world.conversations,
|
||||
bus=world.bus,
|
||||
pool=world.pool,
|
||||
frontends=tuple(frontends),
|
||||
)
|
||||
for fe in frontends:
|
||||
fe.configure(self.runtime)
|
||||
self.mirror = asyncio.create_task(self.markdown.mirror.run())
|
||||
|
||||
def client(self, fe) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=fe._app), base_url="http://t"
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.mirror.cancel()
|
||||
|
||||
async def file_of(self, conv_id: str) -> Path:
|
||||
conv = await self.world.conversations.get(conv_id)
|
||||
path = await self.markdown.mirror.bound_path(conv)
|
||||
assert path is not None
|
||||
return path
|
||||
|
||||
async def wait_file(self, path: Path, needle: str, timeout: float = 5.0) -> str:
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
if path.exists() and needle in path.read_text(encoding="utf-8"):
|
||||
return path.read_text(encoding="utf-8")
|
||||
await asyncio.sleep(0.02)
|
||||
msg = f"{needle!r} never showed up in {path}"
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def stack() -> Stack:
|
||||
root = Path(tempfile.mkdtemp(prefix="beaver-routing-"))
|
||||
world = await World(root).setup()
|
||||
await world.conversations.start()
|
||||
s = Stack(world)
|
||||
yield s
|
||||
await s.close()
|
||||
await world.conversations.stop()
|
||||
await world.pool.close_all()
|
||||
await world.db.dispose()
|
||||
|
||||
|
||||
async def test_api_rejects_deep_with_dispatcher(stack: Stack) -> None:
|
||||
async with stack.client(stack.api) as c:
|
||||
r = await c.post(
|
||||
"/api/conversations", json={"kind": "deep", "agent": "a"}, headers=AUTH
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "does not serve kind 'deep'" in r.json()["error"]
|
||||
r = await c.post("/api/conversations", json={"kind": "job"}, headers=AUTH)
|
||||
assert r.status_code == 400
|
||||
assert "no default agent" in r.json()["error"]
|
||||
r = await c.get("/api/agents", headers=AUTH)
|
||||
agents = {a["name"]: a["kinds"] for a in r.json()["agents"]}
|
||||
assert agents == {"a": ["master", "branch", "job", "fork"], "d": ["deep"]}
|
||||
homes = {f["name"]: f["default_agents"] for f in r.json()["frontends"]}
|
||||
assert homes == {
|
||||
"api": {"master": "a"},
|
||||
"anthropic": {},
|
||||
"markdown": {"deep": "d"},
|
||||
}
|
||||
|
||||
|
||||
async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None:
|
||||
async with stack.client(stack.api) as c:
|
||||
r = await c.post("/api/conversations", json={"kind": "master"}, headers=AUTH)
|
||||
assert r.status_code == 201 and r.json()["agent"] == "a"
|
||||
master = r.json()["id"]
|
||||
r = await c.post(
|
||||
"/api/conversations",
|
||||
json={"kind": "deep", "seed": "brief", "text": "dig", "title": "Тема"},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["agent"] == "d"
|
||||
assert [b["frontend"] for b in body["bindings"]] == ["markdown"]
|
||||
rel = body["bindings"][0]["external_id"]
|
||||
assert rel.endswith("_Тема.md") and rel.startswith("_logs/d/")
|
||||
r = await c.post(
|
||||
f"/api/conversations/{master}/bind",
|
||||
json={"frontend": "markdown", "external_id": "x.md"},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
path = stack.vault / rel
|
||||
text = await stack.wait_file(path, "ok:[сид: brief]")
|
||||
post = frontmatter.loads(text)
|
||||
assert post.metadata == {"agent": "d", "conversation_id": body["id"]}
|
||||
assert post.content.startswith("### User:\n\n[сид: brief] deep «Тема», ")
|
||||
assert post.content.rstrip().endswith("### User:")
|
||||
|
||||
|
||||
async def test_markdown_chat_rejects_dispatcher(stack: Stack) -> None:
|
||||
async with stack.client(stack.markdown) as c:
|
||||
r = await c.post(
|
||||
"/chat",
|
||||
json={"filename": "x.md", "agent": "a", "content": "### User:\n\nhi\n"},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "does not serve kind 'deep'" in r.json()["detail"]
|
||||
|
||||
|
||||
async def test_spawn_tool_reports_bad_pair(stack: Stack) -> None:
|
||||
master = await stack.world.conversations.create(
|
||||
kind="master", agent="a", origin="test"
|
||||
)
|
||||
spawn = next(
|
||||
t
|
||||
for t in _tools(stack.world.conversations, master.external_id)
|
||||
if t.name == "spawn"
|
||||
)
|
||||
out = await spawn.handler({"kind": "deep", "agent": "a"})
|
||||
assert (
|
||||
out.get("is_error")
|
||||
and "does not serve kind 'deep'" in out["content"][0]["text"]
|
||||
)
|
||||
out = await spawn.handler({"kind": "deep", "seed": "brief", "text": "go"})
|
||||
assert not out.get("is_error")
|
||||
conv_id = out["content"][0]["text"].split()[-1]
|
||||
path = await stack.file_of(conv_id)
|
||||
assert path.exists()
|
||||
|
||||
|
||||
async def test_anthropic_turns_become_one_deep_conversation(stack: Stack) -> None:
|
||||
async with stack.client(stack.anthropic) as c:
|
||||
r = await c.post(
|
||||
"/v1/messages",
|
||||
json={"model": "a", "messages": [{"role": "user", "content": "hi"}]},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
first = [{"role": "user", "content": "first question"}]
|
||||
r = await c.post(
|
||||
"/v1/messages", json={"model": "d", "messages": first}, headers=AUTH
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
reply = r.json()["content"][0]["text"]
|
||||
assert reply == "ok:first question"
|
||||
convs = await stack.world.conversations.find(kind="deep")
|
||||
assert len(convs) == 1
|
||||
conv = convs[0]
|
||||
assert conv.title == "first question" and conv.frontend == "anthropic"
|
||||
path = await stack.file_of(conv.external_id)
|
||||
text = await stack.wait_file(path, "ok:first question")
|
||||
assert frontmatter.loads(text).metadata == {
|
||||
"agent": "d",
|
||||
"conversation_id": conv.external_id,
|
||||
}
|
||||
|
||||
second = [
|
||||
*first,
|
||||
{"role": "assistant", "content": reply},
|
||||
{"role": "user", "content": "second"},
|
||||
]
|
||||
r = await c.post(
|
||||
"/v1/messages",
|
||||
json={"model": "d", "messages": second, "stream": True},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "ok:second" in r.text
|
||||
assert len(await stack.world.conversations.find(kind="deep")) == 1
|
||||
assert len(ScriptedClient.instances) == 1
|
||||
assert ScriptedClient.instances[0].prompts == ["first question", "second"]
|
||||
text = await stack.wait_file(path, "ok:second")
|
||||
assert text.count("### User:") == 3 and text.count("### Assistant:") == 2
|
||||
bindings = await stack.world.conversations.bindings(conv)
|
||||
assert [b.frontend for b in bindings if b.visible] == ["markdown", "anthropic"]
|
||||
async with stack.world.db.session() as session:
|
||||
stored = await load_messages(session, conversation_id=conv.id)
|
||||
assert [m["role"] for m in stored] == ["user", "assistant", "user", "assistant"]
|
||||
assert stored[-1]["content"] == [{"type": "text", "text": "ok:second"}]
|
||||
|
||||
r = await c.post(
|
||||
"/v1/messages",
|
||||
json={
|
||||
"model": "a",
|
||||
"messages": [
|
||||
*second,
|
||||
{"role": "assistant", "content": "ok:second"},
|
||||
{"role": "user", "content": "x"},
|
||||
],
|
||||
},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert r.status_code == 400 and "runs on 'd'" in r.json()["detail"]
|
||||
Reference in New Issue
Block a user