feat(hands): vibegram read takes since and last for a non-consuming look at history
This commit is contained in:
1 parent
78a0293819
commit
9bf897a809
2 files changed
+124
-10
No files matched your search
@@ -11,9 +11,10 @@
|
|||||||
приходит. Поэтому клиент помнит последние события сам: тулза `read`
|
приходит. Поэтому клиент помнит последние события сам: тулза `read`
|
||||||
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
|
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
|
||||||
|
|
||||||
Там, где нужен не «что нового», а «последние N строк комнаты» (сторож),
|
Там, где нужен не «что нового», а «последние N строк комнаты» (сторож) или
|
||||||
курсор не годится в принципе: `window` читает хвост истории через
|
«всё после события #N» (`read since=` / `read last=` у мастера), курсор не
|
||||||
`/api/state`, не трогая курсор и ничего не вычёркивая.
|
годится в принципе: `history` читает историю через `/api/state`, не трогая
|
||||||
|
курсор и ничего не вычёркивая.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -54,6 +55,9 @@ class Event:
|
|||||||
def line(self) -> str:
|
def line(self) -> str:
|
||||||
return f"[{self.at}] {self.text}"
|
return f"[{self.at}] {self.text}"
|
||||||
|
|
||||||
|
def tagged(self) -> str:
|
||||||
|
return f"#{self.id} {self.line()}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Window:
|
class Window:
|
||||||
@@ -87,12 +91,19 @@ class Vibegram:
|
|||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
about: str | None = None,
|
about: str | None = None,
|
||||||
skills: list[str] | None = None,
|
skills: list[str] | None = None,
|
||||||
|
since: int | None = None,
|
||||||
|
last: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
|
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
|
||||||
|
|
||||||
action:
|
action:
|
||||||
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
|
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
|
||||||
нового нет - последние уже виденные события.
|
нового нет - последние уже виденные события. Курсор общий с
|
||||||
|
крон-триажем: прочитанное здесь второй раз не придёт.
|
||||||
|
- read since=N / read last=N: история комнаты - всё после события #N
|
||||||
|
или последние N сообщений. Ничего не съедает, спрашивать можно
|
||||||
|
сколько угодно раз. Вывод «сосед молчит» брать только отсюда: пустой
|
||||||
|
read - это «крон уже выгреб», а не «никто не ответил».
|
||||||
- send: написать в комнату (text). Это доска объявлений, не чат:
|
- send: написать в комнату (text). Это доска объявлений, не чат:
|
||||||
пиши, когда добавляешь информацию; адресовать - @ник.
|
пиши, когда добавляешь информацию; адресовать - @ник.
|
||||||
- who: кто в комнате, что умеет, что держит.
|
- who: кто в комнате, что умеет, что держит.
|
||||||
@@ -107,7 +118,7 @@ class Vibegram:
|
|||||||
"""
|
"""
|
||||||
match action:
|
match action:
|
||||||
case "read":
|
case "read":
|
||||||
return await self._read()
|
return await self._read(since=since, last=last)
|
||||||
case "send":
|
case "send":
|
||||||
return await self._send(_need(text, "text"))
|
return await self._send(_need(text, "text"))
|
||||||
case "who":
|
case "who":
|
||||||
@@ -189,9 +200,19 @@ class Vibegram:
|
|||||||
`viewToken` спрашиваем каждый раз заново: протухший в кэше ключ - ровно
|
`viewToken` спрашиваем каждый раз заново: протухший в кэше ключ - ровно
|
||||||
то тихое враньё, ради которого окно и переписано.
|
то тихое враньё, ради которого окно и переписано.
|
||||||
"""
|
"""
|
||||||
|
return await self.history(limit=limit, pages=pages)
|
||||||
|
|
||||||
|
async def history(
|
||||||
|
self, *, after: int = 0, limit: int | None = None, pages: int = PAGES
|
||||||
|
) -> Window:
|
||||||
|
"""Сообщения комнаты после события `after`, хвостом не длиннее `limit`.
|
||||||
|
|
||||||
|
Непотребляющий проход по `/api/state`: курсор `/api/pending` остаётся
|
||||||
|
там, где стоял, поэтому спрашивать можно сколько угодно раз.
|
||||||
|
`limit=None` - всё, что нашлось после `after`.
|
||||||
|
"""
|
||||||
view = quote(str((await self._get("/api/room"))["room"]["viewToken"]))
|
view = quote(str((await self._get("/api/room"))["room"]["viewToken"]))
|
||||||
tail: deque[Event] = deque(maxlen=limit)
|
tail: deque[Event] = deque(maxlen=limit)
|
||||||
after = 0
|
|
||||||
full = False
|
full = False
|
||||||
for _ in range(pages):
|
for _ in range(pages):
|
||||||
page = await self._get(f"/api/state?view={view}&after={after}")
|
page = await self._get(f"/api/state?view={view}&after={after}")
|
||||||
@@ -222,15 +243,36 @@ class Vibegram:
|
|||||||
needle = f"@{self.nick}".lower()
|
needle = f"@{self.nick}".lower()
|
||||||
return any(needle in e.text.lower() for e in events)
|
return any(needle in e.text.lower() for e in events)
|
||||||
|
|
||||||
async def _read(self) -> str:
|
async def _read(self, *, since: int | None = None, last: int | None = None) -> str:
|
||||||
|
if since is not None or last is not None:
|
||||||
|
return await self._history(since=since, last=last)
|
||||||
fresh = await self.pending()
|
fresh = await self.pending()
|
||||||
if fresh:
|
if fresh:
|
||||||
return self._clip("\n".join(e.line() for e in fresh))
|
return self._clip("\n".join(e.tagged() for e in fresh))
|
||||||
if self._recent:
|
if self._recent:
|
||||||
tail = "\n".join(e.line() for e in list(self._recent)[-10:])
|
tail = "\n".join(e.tagged() for e in list(self._recent)[-10:])
|
||||||
return f"нового нет; последнее, что было:\n{tail}"
|
return f"нового нет; последнее, что было:\n{tail}"
|
||||||
return "нового нет"
|
return "нового нет"
|
||||||
|
|
||||||
|
async def _history(self, *, since: int | None, last: int | None) -> str:
|
||||||
|
if last is not None and last <= 0:
|
||||||
|
msg = "last - сколько последних сообщений показать, нужно число больше нуля"
|
||||||
|
raise ValueError(msg)
|
||||||
|
after = max(since or 0, 0)
|
||||||
|
window = await self.history(after=after, limit=last)
|
||||||
|
if not window.events:
|
||||||
|
if not window.full:
|
||||||
|
return f"историю после #{after} не дочитали до конца, пусто пока"
|
||||||
|
return (
|
||||||
|
f"после #{after} сообщений нет (история дочитана)"
|
||||||
|
if after
|
||||||
|
else "сообщений в комнате нет"
|
||||||
|
)
|
||||||
|
lines = [e.tagged() for e in window.events]
|
||||||
|
if not window.full:
|
||||||
|
lines.insert(0, "окно неполное: до конца истории не дочитали")
|
||||||
|
return self._clip("\n".join(lines))
|
||||||
|
|
||||||
async def _send(self, text: str) -> str:
|
async def _send(self, text: str) -> str:
|
||||||
try:
|
try:
|
||||||
await self._post("/api/messages", {"body": text})
|
await self._post("/api/messages", {"body": text})
|
||||||
|
|||||||
+73
-1
@@ -6,7 +6,7 @@ from beaver_gateway.mcp.wrap import build_python_tool_server
|
|||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
from fastmcp.exceptions import ToolError
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
from beaver_agent.hands.vibegram import PAGE, Vibegram, _cards, _event, _work
|
from beaver_agent.hands.vibegram import PAGE, PAGES, Vibegram, _cards, _event, _work
|
||||||
|
|
||||||
|
|
||||||
def test_events_render_and_unknown_kinds_are_skipped():
|
def test_events_render_and_unknown_kinds_are_skipped():
|
||||||
@@ -127,3 +127,75 @@ async def test_window_of_an_empty_room_is_full_and_empty(monkeypatch):
|
|||||||
_, room = _hub(monkeypatch, [])
|
_, room = _hub(monkeypatch, [])
|
||||||
window = await room.window(25)
|
window = await room.window(25)
|
||||||
assert window.full and window.events == []
|
assert window.full and window.events == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_since_walks_history_without_touching_the_cursor(monkeypatch):
|
||||||
|
events = [
|
||||||
|
_raw(i, "message" if i % 4 else "agent_join", f"строка {i}")
|
||||||
|
for i in range(1, 12)
|
||||||
|
]
|
||||||
|
calls, room = _hub(monkeypatch, events)
|
||||||
|
|
||||||
|
out = await room.vibegram("read", since=8)
|
||||||
|
|
||||||
|
assert out.splitlines() == [
|
||||||
|
"#9 [09-23 05:00] claude-kai: строка 9",
|
||||||
|
"#10 [09-23 05:00] claude-kai: строка 10",
|
||||||
|
"#11 [09-23 05:00] claude-kai: строка 11",
|
||||||
|
]
|
||||||
|
assert not any("pending" in p for p in calls)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_last_shows_only_the_tail(monkeypatch):
|
||||||
|
events = [_raw(i, "message", f"строка {i}") for i in range(1, 12)]
|
||||||
|
_, room = _hub(monkeypatch, events)
|
||||||
|
|
||||||
|
out = await room.vibegram("read", last=2)
|
||||||
|
|
||||||
|
assert out.splitlines() == [
|
||||||
|
"#10 [09-23 05:00] claude-kai: строка 10",
|
||||||
|
"#11 [09-23 05:00] claude-kai: строка 11",
|
||||||
|
]
|
||||||
|
with pytest.raises(ValueError, match="last"):
|
||||||
|
await room.vibegram("read", last=0)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_since_on_a_silent_room_says_history_is_read_out(monkeypatch):
|
||||||
|
_, room = _hub(monkeypatch, [_raw(3, "message", "строка 3")])
|
||||||
|
|
||||||
|
assert await room.vibegram("read", since=3) == (
|
||||||
|
"после #3 сообщений нет (история дочитана)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_since_admits_an_unfinished_window(monkeypatch):
|
||||||
|
events = [_raw(i, "message", f"строка {i}") for i in range(1, PAGES * PAGE + 2)]
|
||||||
|
_, room = _hub(monkeypatch, events)
|
||||||
|
|
||||||
|
out = await room.vibegram("read", since=0)
|
||||||
|
|
||||||
|
assert out.splitlines()[0] == "окно неполное: до конца истории не дочитали"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_plain_read_keeps_the_cursor_and_numbers_the_lines(monkeypatch):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def call(self, method, path, body):
|
||||||
|
seen.append(path)
|
||||||
|
return {"events": [_raw(7, "message", "привет")]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(Vibegram, "_call", call)
|
||||||
|
room = Vibegram(hub="http://hub", token="t", nick="beaver-test")
|
||||||
|
|
||||||
|
assert await room.vibegram("read") == "#7 [09-23 05:00] claude-kai: привет"
|
||||||
|
assert seen == ["/api/pending?limit=50"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_schema_exposes_since_and_last():
|
||||||
|
tool = Vibegram(hub="http://127.0.0.1:9", token="t", nick="beaver-test")
|
||||||
|
server = build_python_tool_server(
|
||||||
|
McpServer.python_tool(name="vibegram", tools=[tool.vibegram])
|
||||||
|
)
|
||||||
|
async with Client(server) as client:
|
||||||
|
(spec,) = await client.list_tools()
|
||||||
|
assert {"since", "last"} <= set(spec.inputSchema["properties"])
|
||||||
Reference in new issue
Block a user