feat(policy,komodo,config): vault zone and firefly skill rules, komodo python_tool with exec, tests

This commit is contained in:
hh
2026-08-29 15:27:32 +02:00
parent 9dc390f271
commit fda6b7bfdd
12 changed files with 810 additions and 39 deletions
+54 -39
View File
@@ -4,7 +4,6 @@ from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import aiohttp
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
@@ -25,6 +24,19 @@ 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.komodo import Komodo
from policy import (
DEEP_DISALLOWED,
DISPATCHER_DISALLOWED,
DISTILLER_DISALLOWED,
TRIAGE_DISALLOWED,
Zones,
bash_zones,
requires_skill,
skill_tracker,
vault_zones,
)
TZ = "Europe/Warsaw"
VAULT = Path("/vault")
CHATS_DIR = VAULT / "💬 чаты"
@@ -99,6 +111,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,))
FIREFLY_WRITES = ("mcp__firefly__store_*", "mcp__firefly__update_*")
VAULT_POLICY = (
skill_tracker(),
vault_zones(ZONES),
bash_zones(ZONES),
requires_skill("firefly", FIREFLY_WRITES),
)
def chat_path(title: str, agent: str, vault: Path) -> Path: # noqa: ARG001
"""Новый файл чата в `💬 чаты/`: помесячная папка, дата и тема."""
today = date.today()
@@ -125,6 +149,21 @@ def _calendar_mcps() -> list[HttpMcp]:
calendar_mcps = _calendar_mcps()
calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_mcps)
# §4.4: komodo - python_tool с перечислимыми действиями, ключ остаётся в gateway.
KOMODO = (
Komodo(
url=os.environ["KOMODO_URL"],
key=os.environ["KOMODO_KEY"],
secret=os.environ["KOMODO_SECRET"],
)
if os.environ.get("KOMODO_URL")
else None
)
komodo_mcps = (
[McpServer.python_tool(name="komodo", tools=[KOMODO.komodo])] if KOMODO else []
)
komodo_exposed = (ExposedMcp(name="komodo"),) if KOMODO else ()
# Секреты MCP - только через env подпроцесса (mcp stdio даёт ему белый список
# + это), никогда argv: процесс модели видит `ps` всего контейнера.
mcps: list[McpServerT] = [
@@ -144,6 +183,7 @@ mcps: list[McpServerT] = [
),
McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]),
*calendar_mcps,
*komodo_mcps,
]
# Руки глубокого (§4.4): firefly без delete_*, obsidian-fs не даётся - свои
@@ -152,6 +192,7 @@ CLAUDE_MCPS = (
ExposedMcp(name="firefly", deny=("delete_*",)),
ExposedMcp(name="telegram"),
*calendar_exposed,
*komodo_exposed,
)
@@ -168,11 +209,8 @@ def dispatcher(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
prompts=DISPATCHER_PROMPTS,
skill_sets=DISPATCHER_SKILLS,
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
options=ClaudeOptions(
effort=effort,
# §3.7: у диспетчера AskUserQuestion остаётся, планов и ноутбуков нет.
disallowed_tools=("ExitPlanMode", "EnterPlanMode", "NotebookEdit"),
),
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
policy=VAULT_POLICY,
expose_mcps=CLAUDE_MCPS,
)
@@ -186,17 +224,8 @@ def deep(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
skill_sets=DEEP_SKILLS,
# §8.4: «ок, обсудили» - тулза, закрытие после ответа.
gateway_tools=("close_chat",),
options=ClaudeOptions(
effort=effort,
# §3.7: у глубоких вопросы текстом, планов и сабагентов нет.
disallowed_tools=(
"AskUserQuestion",
"ExitPlanMode",
"EnterPlanMode",
"NotebookEdit",
"Task",
),
),
options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED),
policy=VAULT_POLICY,
expose_mcps=CLAUDE_MCPS,
)
@@ -211,8 +240,9 @@ def distiller(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
options=ClaudeOptions(
effort=effort,
tools=("Read", "Write"),
disallowed_tools=("AskUserQuestion", "Task", "WebSearch", "WebFetch"),
disallowed_tools=DISTILLER_DISALLOWED,
),
policy=(vault_zones(ZONES),),
)
@@ -225,9 +255,7 @@ def triage(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
prompts=TRIAGE_PROMPTS,
gateway_tools=("say", "inject"),
options=ClaudeOptions(
effort=effort,
tools=(),
disallowed_tools=("AskUserQuestion", "Task", "WebSearch", "WebFetch"),
effort=effort, tools=(), disallowed_tools=TRIAGE_DISALLOWED
),
)
@@ -451,26 +479,13 @@ KOMODO_STACK = os.environ.get("KOMODO_STACK", "beaver-agent")
async def komodo_deploy() -> None:
url, key, secret = (
os.environ.get(k, "") for k in ("KOMODO_URL", "KOMODO_KEY", "KOMODO_SECRET")
)
if not (url and key and secret):
if KOMODO is None:
_log.warning("deploy hook: KOMODO_URL/KEY/SECRET are not set, nothing deployed")
return
body = {"type": "DeployStack", "params": {"stack": KOMODO_STACK, "services": []}}
headers = {"X-Api-Key": key, "X-Api-Secret": secret}
async with (
aiohttp.ClientSession() as http,
http.post(
f"{url.rstrip('/')}/execute",
json=body,
headers=headers,
timeout=aiohttp.ClientTimeout(total=30),
) as response,
):
_log.info(
"deploy hook: komodo %s %s", response.status, (await response.text())[:200]
)
receipt = await KOMODO.execute(
"DeployStack", {"stack": KOMODO_STACK, "services": []}
)
_log.info("deploy hook: komodo accepted %s", receipt.get("id"))
async def deploy(run: JobRun) -> None: