feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones

This commit is contained in:
hh
2026-08-30 18:16:15 +02:00
parent 002230137e
commit 83531e3ef1
11 changed files with 1186 additions and 47 deletions
+132 -18
View File
@@ -30,7 +30,10 @@ from beaver_gateway.frontends.mcp_server import McpServerFrontend
from beaver_gateway.frontends.telegram import TelegramFrontend
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
from mcps.homeassistant import HomeAssistant
from mcps.komodo import Komodo
from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad
from mcps.vibegram import Vibegram
from policy import (
DEEP_DISALLOWED,
DISPATCHER_DISALLOWED,
@@ -117,9 +120,18 @@ DEEP_SKILLS = (SKILLS / "общие", SKILLS / "vault")
DISPATCHER_SKILLS = (SKILLS / "общие", SKILLS / "диспетчер", SKILLS / "vault")
# §3.7: зоны vault - запись только в мета/бобер, в 💬 чаты только новые файлы;
# firefly пишет только после открытого скилла. Маунт ro - первая линия.
ZONES = Zones(vault=VAULT, write=(BEAVER,), create=(CHATS_DIR,))
# §3.7 (решение h, 2026-08-30): vault - дом диспетчера. Создавать, править,
# переносить можно везде, кроме `.obsidian` и `мета` вне `бобер`; удалять -
# только своё (`мета/бобер`) и `💬 чаты`, существующие чаты не трогать.
# Дистиллятор остаётся в строгих зонах. firefly пишет только после скилла.
ZONES = Zones(
vault=VAULT,
write=(BEAVER,),
create=(CHATS_DIR,),
edit=(VAULT,),
protected=(VAULT / ".obsidian", VAULT / "мета"),
)
STRICT_ZONES = Zones(vault=VAULT, write=(BEAVER,), create=(CHATS_DIR,))
FIREFLY_WRITES = ("mcp__firefly__store_*", "mcp__firefly__update_*")
VAULT_POLICY = (
skill_tracker(),
@@ -170,6 +182,39 @@ komodo_mcps = (
)
komodo_exposed = (ExposedMcp(name="komodo"),) if KOMODO else ()
# §4.4: Home Assistant - python_tool, токен в gateway. HA живёт на хосте
# (network_mode: host), из контейнера - host.docker.internal.
HA = (
HomeAssistant(url=os.environ["HA_URL"], token=os.environ["HA_TOKEN"])
if os.environ.get("HA_URL") and os.environ.get("HA_TOKEN")
else None
)
ha_mcps = [McpServer.python_tool(name="ha", tools=[HA.ha])] if HA else []
ha_exposed = (ExposedMcp(name="ha"),) if HA else ()
# §8.5, §12.D1: вайбграм - комната агентов; диспетчер сидит в ней сам, но
# через перечислимые действия, токен агента в gateway. Клон репы комнаты -
# `vibegram` volume, /vibegram/<repo> ro, тянет сервис vibegram-repo.
VIBEGRAM_REPO = (
Path("/vibegram") / os.environ.get("VIBEGRAM_REPO", "x/room").split("/")[-1]
)
VIBEGRAM = (
Vibegram(
hub=os.environ.get("VIBEGRAM_HUB", "https://vibegram.example.com"),
token=os.environ["VIBEGRAM_TOKEN"],
nick=os.environ.get("VIBEGRAM_NICK", "beaver-test"),
repo=VIBEGRAM_REPO if VIBEGRAM_REPO.exists() else None,
)
if os.environ.get("VIBEGRAM_TOKEN")
else None
)
vibegram_mcps = (
[McpServer.python_tool(name="vibegram", tools=[VIBEGRAM.vibegram])]
if VIBEGRAM
else []
)
vibegram_exposed = (ExposedMcp(name="vibegram"),) if VIBEGRAM else ()
# §5: t3code-mcp - сосед по compose (профиль t3), машины и allowlist проектов
# в его t3code.toml; токены t3 живут в его env, gateway их не видит.
T3CODE_MCP = os.environ.get("T3CODE_MCP", "http://t3code-mcp:8000/mcp")
@@ -200,6 +245,8 @@ mcps: list[McpServerT] = [
McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]),
*calendar_mcps,
*komodo_mcps,
*ha_mcps,
*vibegram_mcps,
*t3code_mcps,
]
@@ -210,6 +257,7 @@ CLAUDE_MCPS = (
ExposedMcp(name="telegram"),
*calendar_exposed,
*komodo_exposed,
*ha_exposed,
)
@@ -228,8 +276,9 @@ def dispatcher(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
policy=VAULT_POLICY,
# §4.4: t3code - рука диспетчера (мастер, ветки), глубоким не даётся.
expose_mcps=(*CLAUDE_MCPS, *t3code_exposed),
# §4.4: t3code и вайбграм - руки диспетчера (мастер, ветки), глубоким
# не даются: наружу и в код ходит только он.
expose_mcps=(*CLAUDE_MCPS, *t3code_exposed, *vibegram_exposed),
)
@@ -260,7 +309,7 @@ def distiller(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
tools=("Read", "Write"),
disallowed_tools=DISTILLER_DISALLOWED,
),
policy=(vault_zones(ZONES),),
policy=(vault_zones(STRICT_ZONES),),
)
@@ -506,28 +555,44 @@ async def rotate(run: JobRun) -> None:
_vibegram_last_wake: datetime | None = None
_vibegram_backlog: list[str] = []
def vibegram_check() -> list[str]:
"""Заглушка до M7: детерминированная проверка вайбграма, пока ничего нового."""
return []
VIBEGRAM_BRIEF = (
"Новое в вайбграме (комната {room}, ты там {nick}; остальные - чужие агенты):\n"
"{items}\n"
"Будить мастера - inject(master, резюме до 3 строк: кто, что, чего ждёт). "
"Мастер прочитает подробности через vibegram(read) и ответит через "
"vibegram(send), если решит."
)
async def vibegram(run: JobRun) -> None:
"""§8.5: проверка → triage → в мастер ≤ 1/ч, если не ждут ответа."""
global _vibegram_last_wake # noqa: PLW0603 - счётчик «≤ 1/ч», живёт до рестарта
new = vibegram_check()
if not new:
if VIBEGRAM is None:
return
events = await VIBEGRAM.pending()
_vibegram_backlog.extend(e.line() for e in events)
if not _vibegram_backlog:
return
now = datetime.now(UTC)
if _vibegram_last_wake is not None and now - _vibegram_last_wake < timedelta(
hours=1
addressed = VIBEGRAM.mentioned(events)
if (
not addressed
and _vibegram_last_wake is not None
and now - _vibegram_last_wake < timedelta(hours=1)
):
return
_vibegram_last_wake = now
items, _vibegram_backlog[:] = list(_vibegram_backlog), []
await run.spawn_job(
agent="beaver-triage",
title="вайбграм",
text="Новое в вайбграме:\n" + "\n".join(f"- {item}" for item in new),
text=VIBEGRAM_BRIEF.format(
room=VIBEGRAM_REPO.name,
nick=VIBEGRAM.nick,
items="\n".join(f"- {item}" for item in items[-40:]),
),
)
@@ -635,10 +700,58 @@ async def t3code_event(run: JobRun) -> None:
await run.inject_master(t3code_text(run.payload), urgency="urgent", origin="t3code")
ALERTS = AlertMemory()
KOMODO_HINT = (
"Это флот Бобра; ему такие алерты приходят и так (healthbot), тебя будят, "
"когда через 5 минут само не встало. Посмотри komodo(status/logs), почини, "
"если очевидно (restart стека), иначе - коротко скажи Бобру через say, что "
"видишь. Не деплой и не чини «заодно»."
)
async def komodo_alert(run: JobRun) -> None:
"""§4.5 `/hooks/komodo`: флапы выдерживаются HOLD и перепроверяются."""
payload = run.payload
summary = payload.get("raw") or {k: v for k, v in payload.items() if k != "trigger"}
await run.inject_master(f"Komodo: {summary}", urgency="urgent", origin="komodo")
if payload.get("held"):
alert = parse(payload["alert"])
bad = await still_bad(KOMODO.read, alert) if KOMODO else True
verdict = ALERTS.after_hold(alert, still_bad=bad)
if verdict.action == "drop":
_log.info("komodo alert %s recovered during hold", alert.key)
return
ALERTS.mark_told(alert)
await run.inject_master(
f"Komodo: {describe(alert, held=HOLD)}\n{KOMODO_HINT}",
urgency=verdict.urgency,
origin="komodo",
)
return
alert = parse(payload)
verdict = ALERTS.verdict(alert)
match verdict.action:
case "drop":
_log.info("komodo alert %s dropped (%s)", alert.key, alert.kind)
case "hold":
await run.scheduler.trigger(
run.job,
{
"held": True,
"alert": {k: v for k, v in payload.items() if k != "trigger"},
},
delay=HOLD,
trigger="hold",
)
case "resolved":
await run.inject_master(
f"Komodo: отбой - {describe(alert)}", urgency="normal", origin="komodo"
)
case "inject":
ALERTS.mark_told(alert)
await run.inject_master(
f"Komodo: {describe(alert)}\n{KOMODO_HINT}",
urgency=verdict.urgency,
origin="komodo",
)
jobs = [
@@ -647,7 +760,8 @@ jobs = [
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
Job("память", memory, cron="30 4 * * 0", critical=False),
Job("deploy", deploy, webhook=True),
Job("komodo", komodo_alert, webhook=True),
# алерты идут пачками (тревога и отбой подряд) - схлопывать нельзя
Job("komodo", komodo_alert, webhook=True, dedupe=False),
# события идут подряд (вопрос и завершение) - схлопывать нельзя
Job("t3code", t3code_event, webhook=True, dedupe=False),
]