feat(config): triage agent, rotation and vibegram jobs, deploy and komodo hooks, vault watch rules
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
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
|
||||
from beaver_gateway.core.conversations import ConversationTexts, SeedContext
|
||||
from beaver_gateway.core.prompt import assemble
|
||||
from beaver_gateway.core.registry import Gateway
|
||||
from beaver_gateway.core.rotation import HandoutContext, RotationPolicy
|
||||
from beaver_gateway.core.scheduler import Budget, Job, JobRun
|
||||
from beaver_gateway.core.turn_record import slugify
|
||||
from beaver_gateway.core.watch import VaultWatch, WatchRules
|
||||
from beaver_gateway.frontends.admin import AdminFrontend
|
||||
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
||||
from beaver_gateway.frontends.api import ApiFrontend
|
||||
@@ -18,12 +24,17 @@ from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
||||
from beaver_gateway.frontends.telegram import TelegramFrontend
|
||||
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
|
||||
|
||||
TZ = "Europe/Warsaw"
|
||||
VAULT = Path("/vault")
|
||||
CHATS_DIR = VAULT / "💬 чаты"
|
||||
DIARY = VAULT / "📅 дни"
|
||||
BEAVER = VAULT / "мета" / "бобер"
|
||||
PROMPTS = BEAVER / "промпты"
|
||||
GRANULES = PROMPTS / "гранулы"
|
||||
SKILLS = BEAVER / "скиллы"
|
||||
DAYS = BEAVER / "дни"
|
||||
|
||||
_log = logging.getLogger("beaver_agent.config")
|
||||
|
||||
# Гранулы - чистый markdown; xml-теги, в которые они заворачиваются,
|
||||
# живут здесь. Голос - папка, файл на тег, в этом порядке.
|
||||
@@ -68,6 +79,8 @@ DISPATCHER_PROMPTS = Prompts(
|
||||
fork=_DISTILL,
|
||||
job=_DISTILL,
|
||||
)
|
||||
# §4.1: триаж без голоса и без vault - профиль, окружение джоба, оверлей.
|
||||
TRIAGE_PROMPTS = Prompts(job=(PROFILE, environment("джоб"), overlay("triage", "триаж")))
|
||||
QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
||||
|
||||
# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault.
|
||||
@@ -175,6 +188,22 @@ def deep(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
||||
)
|
||||
|
||||
|
||||
def triage(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
||||
"""§4.1: дешёвый крон-тёрн - sonnet low, без vault, только say/inject."""
|
||||
return ClaudeAgent(
|
||||
name=name,
|
||||
model=model,
|
||||
cwd=Path("/tmp"), # noqa: S108 - не vault: у триажа нет файлов вообще
|
||||
prompts=TRIAGE_PROMPTS,
|
||||
gateway_tools=("say", "inject"),
|
||||
options=ClaudeOptions(
|
||||
effort=effort,
|
||||
tools=(),
|
||||
disallowed_tools=("AskUserQuestion", "Task", "WebSearch", "WebFetch"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def raycast(name: str, model: str, reasoning_effort: str | None = None) -> RaycastAgent:
|
||||
return RaycastAgent(
|
||||
name=name,
|
||||
@@ -201,6 +230,7 @@ agents: list[BaseAgent] = [
|
||||
deep("beaver-opus-medium", "claude-opus-5", effort="medium"),
|
||||
deep("beaver-fable-medium", "claude-fable-5", effort="medium"),
|
||||
deep("beaver-opus-xhigh", "claude-opus-5", effort="xhigh"),
|
||||
triage("beaver-triage", "claude-sonnet-5", effort="low"),
|
||||
raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", reasoning_effort="low"),
|
||||
raycast(
|
||||
"beaver-gemini-flash-high", "google-gemini-3.5-flash", reasoning_effort="high"
|
||||
@@ -252,25 +282,179 @@ def _read(path: Path) -> str | None:
|
||||
return path.read_text(encoding="utf-8").strip() if path.exists() else None
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return datetime.now(UTC).astimezone(ZoneInfo(TZ)).date()
|
||||
|
||||
|
||||
def latest_handout() -> Path | None:
|
||||
"""§4.5: сид morning берёт последний хендаут, не «вчерашний по календарю»."""
|
||||
files = sorted(DAYS.glob("????-??-??.md")) if DAYS.exists() else []
|
||||
return files[-1] if files else None
|
||||
|
||||
|
||||
def seed_body(ctx: SeedContext) -> str | None:
|
||||
"""§8.2: утренний сид - вчерашний хендаут из `дни/`; остальные сиды - gateway."""
|
||||
"""§8.2: утренний сид - последний хендаут из `дни/`; остальные сиды - gateway."""
|
||||
if ctx.seed != "morning":
|
||||
return None
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
handout = _read(BEAVER / "дни" / f"{yesterday:%Y-%m-%d}.md")
|
||||
return handout or "Хендаут за вчера не приехал."
|
||||
handout = latest_handout()
|
||||
if handout is None:
|
||||
return "Хендаут не приехал."
|
||||
state = _read(BEAVER / "состояние.md") or ""
|
||||
signal = "\n".join(state.splitlines()[:5])
|
||||
parts = [
|
||||
f"Хендаут ({handout.stem}):\n\n{handout.read_text(encoding='utf-8').strip()}"
|
||||
]
|
||||
if signal:
|
||||
parts.append(
|
||||
f"Состояние (первые строки, полностью в мета/бобер/состояние.md):\n{signal}"
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
DEFAULT_HANDOUT = (
|
||||
"Этот мастер закрывается ({reason}). Напиши хендаут за {day} в "
|
||||
"`мета/бобер/дни/{day}.md`: справку на утро, не задание."
|
||||
)
|
||||
|
||||
|
||||
def handout_prompt(ctx: HandoutContext) -> str:
|
||||
"""§6.3, §8.3: последний тёрн закрываемого мастера - гранула + факт про дневник."""
|
||||
day = ctx.day.isoformat()
|
||||
body = (
|
||||
(_read(GRANULES / "хендаут.md") or DEFAULT_HANDOUT)
|
||||
.replace("{day}", day)
|
||||
.replace("{reason}", ctx.reason)
|
||||
)
|
||||
diary = DIARY / f"{day}.md"
|
||||
if diary.exists():
|
||||
return f"{body}\n\nДневник за {day} приехал: `📅 дни/{day}.md`, возьми из него."
|
||||
return f"{body}\n\nДневник за {day} не приехал - так и напиши в разделе «дневник»."
|
||||
|
||||
|
||||
NEW_DAY = (
|
||||
"Новый день ({day}). Ты - новый мастер того же треда: хендаут и сигнал состояния "
|
||||
"в сиде выше. Скажи Бобру что-то через say, только если есть что сказать."
|
||||
)
|
||||
|
||||
texts = ConversationTexts(
|
||||
merge_prompt=_read(GRANULES / "слив.md") or ConversationTexts().merge_prompt,
|
||||
seed=seed_body,
|
||||
handout=handout_prompt,
|
||||
new_day=NEW_DAY,
|
||||
)
|
||||
|
||||
# §4.6: конверт - полный дифф только у дневника за сегодня, остальное именами;
|
||||
# самый конкретный паттерн побеждает (мета/бобер видно, остальная мета - нет).
|
||||
watch = VaultWatch(
|
||||
VAULT,
|
||||
WatchRules(
|
||||
full=("📅 дни/{today}.md",),
|
||||
names=(
|
||||
"📅 дни/**",
|
||||
"👤 люди/**",
|
||||
"📆 планирование/**",
|
||||
"📆 доски/**",
|
||||
"💻 проекты/**",
|
||||
"🧠 мысли/**",
|
||||
"📶 ресерчи/**",
|
||||
"мета/бобер/**",
|
||||
),
|
||||
ignore=("💬 чаты/**", "мета/**", "**/attachments/**", ".obsidian/**"),
|
||||
),
|
||||
tz=TZ,
|
||||
)
|
||||
|
||||
|
||||
# §4.5 джобы. Хендлер только ставит работу в очередь и выходит.
|
||||
async def rotate(run: JobRun) -> None:
|
||||
for master in await run.rotate():
|
||||
_log.info("rotated master -> %s", master.external_id)
|
||||
|
||||
|
||||
_vibegram_last_wake: datetime | None = None
|
||||
|
||||
|
||||
def vibegram_check() -> list[str]:
|
||||
"""Заглушка до M7: детерминированная проверка вайбграма, пока ничего нового."""
|
||||
return []
|
||||
|
||||
|
||||
async def vibegram(run: JobRun) -> None:
|
||||
global _vibegram_last_wake # noqa: PLW0603 - счётчик «≤ 1/ч», живёт до рестарта
|
||||
new = vibegram_check()
|
||||
if not new:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
if _vibegram_last_wake is not None and now - _vibegram_last_wake < timedelta(
|
||||
hours=1
|
||||
):
|
||||
return
|
||||
_vibegram_last_wake = now
|
||||
await run.spawn_job(
|
||||
agent="beaver-triage",
|
||||
title="вайбграм",
|
||||
text="Новое в вайбграме:\n" + "\n".join(f"- {item}" for item in new),
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
_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]
|
||||
)
|
||||
|
||||
|
||||
async def deploy(run: JobRun) -> None:
|
||||
"""§8.6: деплой ждёт conversation.idle(master) - иначе рестарт посреди тёрна."""
|
||||
master = await run.master()
|
||||
if master is not None and await run.conversations.busy(master):
|
||||
await run.retry_in(timedelta(seconds=60))
|
||||
return
|
||||
run.background(komodo_deploy())
|
||||
|
||||
|
||||
async def komodo_alert(run: JobRun) -> None:
|
||||
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")
|
||||
|
||||
|
||||
jobs = [
|
||||
Job("ротация", rotate, cron="0 * * * *"),
|
||||
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
||||
Job("deploy", deploy, webhook=True),
|
||||
Job("komodo", komodo_alert, webhook=True),
|
||||
]
|
||||
|
||||
gateway = Gateway(
|
||||
agents=agents,
|
||||
mcps=mcps,
|
||||
frontends=frontends,
|
||||
texts=texts,
|
||||
jobs=jobs,
|
||||
rotation=RotationPolicy(tz=TZ),
|
||||
watch=watch,
|
||||
budget=Budget(threshold=0.7),
|
||||
tz=TZ,
|
||||
port=62990,
|
||||
public_url=PUBLIC_BASE_URL or None,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user