feat(config): triage agent, rotation and vibegram jobs, deploy and komodo hooks, vault watch rules

This commit is contained in:
hh
2026-08-29 02:29:08 +02:00
parent 044f1de830
commit 16bd13994c
3 changed files with 304 additions and 5 deletions
+25
View File
@@ -104,6 +104,31 @@ docker compose up -d
Set `PUBLIC_BASE_URL=https://<DOMAIN>` in beaver-agent's `.env` so advertised endpoints use the outside origin.
## Deploy: `main` is work, `stable` is production
Komodo deploys only the `stable` ref; pushing `main` deploys nothing. Commit
during the day, then ship when the master thread is idle:
```bash
make deploy # git push origin main:stable
```
The gateway image is built from `beaver-gateway#${GATEWAY_REF}` at deploy
time, so push the gateway first. `docker-compose.yml` has a healthcheck on
`/healthz`; Komodo waits for it before it considers the deploy done.
To drain first (архитектура §8.6), call the deploy hook instead of pushing
by hand: it waits until no master turn is running, then asks Komodo:
```bash
curl -X POST https://<DOMAIN>/hooks/deploy -H "Authorization: Bearer <TOKEN>"
```
Needs `KOMODO_URL` / `KOMODO_KEY` / `KOMODO_SECRET` in `.env` (and
`KOMODO_STACK`, default `beaver-agent`). Komodo alerts can be pointed at
`POST /hooks/komodo` the same way - they land in the master as an urgent
inject. Jobs, the queue and the subscription window are on the admin **Jobs** page.
## Editing config.py on the mac
`make sync` installs `../beaver-gateway` editable (extra `local`), so
+189 -5
View File
@@ -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,
)
Generated
+90
View File
@@ -360,10 +360,12 @@ dependencies = [
{ name = "anyio" },
{ name = "argon2-cffi" },
{ name = "claude-agent-sdk" },
{ name = "croniter" },
{ name = "fastapi" },
{ name = "fastmcp" },
{ name = "greenlet" },
{ name = "itsdangerous" },
{ name = "pgqueuer" },
{ name = "psutil" },
{ name = "psycopg", extra = ["binary"], marker = "extra == 'extra-12-beaver-agent-local'" },
{ name = "pydantic" },
@@ -372,6 +374,7 @@ dependencies = [
{ name = "sqlmodel" },
{ name = "uvicorn", extra = ["standard"], marker = "extra == 'extra-12-beaver-agent-local'" },
{ name = "uvloop" },
{ name = "watchfiles" },
]
[package.metadata]
@@ -384,10 +387,12 @@ requires-dist = [
{ name = "anyio", specifier = ">=4.13.0" },
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "claude-agent-sdk", specifier = ">=0.2.146" },
{ name = "croniter", specifier = ">=6.2.4" },
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "fastmcp", specifier = ">=3.3.1" },
{ name = "greenlet", specifier = ">=3.5.0" },
{ name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "pgqueuer", specifier = ">=1.3.2" },
{ name = "psutil", specifier = ">=7.2.2" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
{ name = "pydantic", specifier = ">=2.13.4" },
@@ -398,6 +403,7 @@ requires-dist = [
{ name = "sqlmodel", specifier = ">=0.0.38" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.47.0" },
{ name = "uvloop", specifier = ">=0.22.1" },
{ name = "watchfiles", specifier = ">=1.2.0" },
]
provides-extras = ["local", "prod"]
@@ -538,6 +544,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "croniter"
version = "6.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" },
]
[[package]]
name = "cryptography"
version = "48.0.0"
@@ -1389,6 +1407,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" },
]
[[package]]
name = "pgqueuer"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "croniter" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "tabulate" },
{ name = "typer" },
{ name = "uvloop", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/10/6af18eede8a31c6d016564fd815d0cf1aaa69b8e584108a604b88f1ea56c/pgqueuer-1.3.2.tar.gz", hash = "sha256:11f4c67d9dfc343c02d8bb2e362733a0d44272f6c7e846630fe198f688fa3ed1", size = 505605, upload-time = "2026-07-27T16:58:00.864Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/16/1e501ee36fb8e6bdde6a3a4dd9b10d1b45c548e02f9c1f11e9ffc62ee922/pgqueuer-1.3.2-py3-none-any.whl", hash = "sha256:0823fcb089bb3ffd0580518e91b53888c9be742b42e706f9860b6a533f32bc44", size = 146747, upload-time = "2026-07-27T16:57:59.153Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
@@ -1714,6 +1750,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -1958,6 +2006,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
@@ -2045,6 +2111,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "tabulate"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" },
]
[[package]]
name = "ty"
version = "0.0.75"
@@ -2070,6 +2145,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/73/4542f829107468b5de4231af67f29927c093bfad11f3c1e5b2c08fb1206b/ty-0.0.75-py3-none-win_arm64.whl", hash = "sha256:541c9af5b7a0ad23d15ec315a7da81150833c359f48124ed3789ff25eacd6f42", size = 12711024, upload-time = "2026-08-26T20:23:38.159Z" },
]
[[package]]
name = "typer"
version = "0.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"