Files
beaver-agent/beaver_agent/hands/vibegram.py
T

346 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""вайбграм - комната агентов на хабе, как python_tool.
Хаб - https://vibegram.example.com, протокол - REST с bearer-токеном агента
(выдан при `vibegram join`, лежит в env gateway и в контекст модели не
попадает). Диспетчер сидит в комнате сам (D1: внутренний контур с соседями),
но через перечислимые действия: прочитать новое, написать, кто в комнате,
что занято, план, claim/release, карточка. Один и тот же клиент кормит
крон-проверку (`pending`) и тулзу (`vibegram`).
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
приходит. Поэтому клиент помнит последние события сам: тулза `read`
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
"""
from __future__ import annotations
import json
from collections import deque
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path # noqa: TC003 - в аннотации dataclass
from typing import Any, Literal
from zoneinfo import ZoneInfo
import aiohttp
__all__ = ["Action", "Event", "Vibegram"]
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
REMEMBER = 40
@dataclass(frozen=True, slots=True)
class Event:
id: int
nick: str
kind: str
text: str
at: str
def line(self) -> str:
return f"[{self.at}] {self.text}"
@dataclass(slots=True)
class Vibegram:
hub: str
token: str
nick: str
repo: Path | None = None
tz: str = "UTC"
"""Время в строках событий - в этой зоне (хаб отдаёт UTC)."""
timeout: float = 15.0
max_chars: int = 8_000
_cursor: int = 0
_recent: deque[Event] = field(default_factory=lambda: deque(maxlen=REMEMBER))
async def vibegram(
self,
action: Action,
*,
text: str | None = None,
paths: list[str] | None = None,
note: str | None = None,
about: str | None = None,
skills: list[str] | None = None,
) -> str:
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
action:
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
нового нет - последние уже виденные события.
- send: написать в комнату (text). Это доска объявлений, не чат:
пиши, когда добавляешь информацию; адресовать - @ник.
- who: кто в комнате, что умеет, что держит.
- work: свободные пункты плана, занятые файлы, кто что делает.
- plan: общий план комнаты.
- claim / release: занять / отпустить файлы репы (paths относительно
корня репы, note - что делаешь). Перед правкой - claim, после - release.
- card: рассказать о себе (about, skills).
Наружу личного нет: имена из `👤 люди/`, содержимое `мета/бобер/` и
`📅 дни/` в комнату не уходят - только рабочее.
"""
match action:
case "read":
return await self._read()
case "send":
return await self._send(_need(text, "text"))
case "who":
return _cards((await self._get("/api/cards"))["cards"], self.nick)
case "work":
return _work(await self._get("/api/work"), self.nick)
case "plan":
return _plan(await self._get("/api/plan"))
case "claim":
result = await self._post(
"/api/claims", {"resources": _need_list(paths), "note": note}
)
if result.get("ok"):
held = ", ".join(c["resource"] for c in result.get("claims", []))
return f"занято: {held}"
return "\n".join(
f"{c['resource']} держит {c['heldBy']}"
+ (f" ({c['note']})" if c.get("note") else "")
+ " - напиши ему через send, не обходи"
for c in result.get("conflicts", [])
)
case "release":
body = {"resources": paths} if paths else {}
result = await self._post("/api/claims/release", body)
released = result.get("released") or []
return (
"отпущено: " + ", ".join(released)
if released
else "нечего отпускать"
)
case "card":
patch: dict[str, Any] = {}
if about:
patch["description"] = about
if skills:
patch["skills"] = skills
result = await self._post("/api/card", patch)
return _cards([result["card"]], self.nick)
async def pending(self, limit: int = 50) -> list[Event]:
"""Новые события с курсора хаба (для крона); запоминает их."""
data = await self._get(f"/api/pending?limit={limit}")
events = [
e
for e in (_event(raw, self.tz) for raw in data.get("events", []))
if e is not None
]
fresh = [e for e in events if e.id > self._cursor]
for e in fresh:
self._recent.append(e)
self._cursor = max(self._cursor, e.id)
ack = data.get("planAckNeeded")
if ack is not None:
fresh.append(
Event(
id=self._cursor,
nick="system",
kind="plan",
text=f"план изменился (ревизия {ack}), ты его не подтвердил",
at=_now(self.tz),
)
)
return fresh
def mentioned(self, events: list[Event]) -> bool:
needle = f"@{self.nick}".lower()
return any(needle in e.text.lower() for e in events)
async def _read(self) -> str:
fresh = await self.pending()
if fresh:
return self._clip("\n".join(e.line() for e in fresh))
if self._recent:
tail = "\n".join(e.line() for e in list(self._recent)[-10:])
return f"нового нет; последнее, что было:\n{tail}"
return "нового нет"
async def _send(self, text: str) -> str:
try:
await self._post("/api/messages", {"body": text})
except HubError as exc:
if exc.code == "rate_limited":
return f"хаб притормозил: {exc}"
raise
return "отправлено"
async def _get(self, path: str) -> Any:
return await self._call("GET", path, None)
async def _post(self, path: str, body: dict[str, Any]) -> Any:
return await self._call("POST", path, body)
async def _call(self, method: str, path: str, body: dict[str, Any] | None) -> Any:
headers = {"authorization": f"Bearer {self.token}", "user-agent": USER_AGENT}
async with (
aiohttp.ClientSession() as http,
http.request(
method,
f"{self.hub.rstrip('/')}{path}",
json=body,
headers=headers,
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response,
):
raw = await response.text()
try:
data = json.loads(raw)
except ValueError:
data = {"error": "bad_json", "message": raw[:200]}
if response.status >= 400:
raise HubError(
response.status,
str(data.get("error") or "error"),
str(data.get("message") or data.get("error") or raw[:200]),
)
return data
def _clip(self, text: str) -> str:
if len(text) <= self.max_chars:
return text
return text[: self.max_chars] + "\n…[обрезано]"
class HubError(RuntimeError):
def __init__(self, status: int, code: str, message: str) -> None:
super().__init__(f"vibegram {status} {code}: {message}")
self.status = status
self.code = code
def _now(tz: str = "UTC") -> str:
return datetime.now(UTC).astimezone(ZoneInfo(tz)).strftime("%m-%d %H:%M")
def _when(iso: Any, tz: str = "UTC") -> str:
try:
when = datetime.fromisoformat(str(iso))
except ValueError:
return _now(tz)
if when.tzinfo is None:
when = when.replace(tzinfo=UTC)
return when.astimezone(ZoneInfo(tz)).strftime("%m-%d %H:%M")
def _event(raw: dict[str, Any], tz: str = "UTC") -> Event | None:
who = raw.get("nick") or "system"
kind = str(raw.get("kind") or "")
p = raw.get("payload") or {}
match kind:
case "message":
text = f"{who}: {p.get('body', '')}"
case "claim":
text = f"{who} занял: {', '.join(p.get('resources', []))}"
case "release":
text = f"{who} отпустил: {', '.join(p.get('resources', []))}"
case "claim_denied":
text = f"{who} пытался занять то, что держишь ты - возможно, ждёт тебя"
case "violation":
text = f"{who} полез в файл, который держишь ты"
case "plan_change":
text = f"{who}: план - {p.get('summary', '')}"
case "agent_join":
text = f"{who} вошёл"
case "agent_leave":
text = f"{who} вышел"
case _:
return None
return Event(
id=int(raw.get("id") or 0),
nick=str(who),
kind=kind,
text=text,
at=_when(raw.get("createdAt"), tz),
)
def _cards(cards: list[dict[str, Any]], me: str) -> str:
if not cards:
return "в комнате никого"
lines = []
for c in cards:
mark = "●" if c.get("status") == "online" else "○"
head = f"{mark} {c['nick']}" + (" (это ты)" if c["nick"] == me else "")
about = " · ".join(
x for x in (c.get("description"), ", ".join(c.get("skills") or [])) if x
)
lines.append(f"{head} - {about}" if about else head)
focus = c.get("focus") or {}
if focus.get("planItem"):
lines.append(f" делает: {focus['planItem'].get('text')}")
if focus.get("holding"):
lines.append(f" держит: {', '.join(focus['holding'])}")
return "\n".join(lines)
def _work(snapshot: dict[str, Any], me: str) -> str:
parts = []
free = snapshot.get("free") or []
taken = snapshot.get("taken") or []
busy = snapshot.get("busy") or []
if free:
parts.append(
"свободные пункты плана:\n"
+ "\n".join(f" {i + 1}. {it['text']}" for i, it in enumerate(free))
)
elif not snapshot.get("planRevision"):
parts.append("плана в комнате пока нет")
else:
parts.append("свободных пунктов плана нет")
if taken:
parts.append(
"занято:\n"
+ "\n".join(f" - {it['text']} ({it.get('ownerNick')})" for it in taken)
)
if busy:
parts.append(
"файлы:\n"
+ "\n".join(
f" - {b['resource']} у {b['nick']}"
+ (f" ({b['note']})" if b.get("note") else "")
for b in busy
)
)
cards = [c for c in snapshot.get("cards") or [] if c["nick"] != me]
if cards:
parts.append("кто что делает:\n" + _cards(cards, me))
return "\n\n".join(parts)
def _plan(plan: dict[str, Any]) -> str:
items = plan.get("items") or []
if not items:
return "плана нет"
lines = [
f"{i + 1}. [{it.get('status')}] {it['text']}"
+ (f" - {it['ownerNick']}" if it.get("ownerNick") else "")
for i, it in enumerate(items)
]
notes = (plan.get("notes") or {}).get("body")
if notes:
lines.append(f"заметки: {notes}")
return f"план v{plan.get('revision')}:\n" + "\n".join(lines)
def _need(value: str | None, name: str) -> str:
if not value:
msg = f"для этого действия нужен параметр {name}"
raise ValueError(msg)
return value
def _need_list(value: list[str] | None) -> list[str]:
if not value:
msg = "для этого действия нужен параметр paths"
raise ValueError(msg)
return value