feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones
This commit is contained in:
@@ -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),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user