From 80806d18d8e868008d4f5d44dabe591b52aacb0d Mon Sep 17 00:00:00 2001 From: h Date: Sat, 29 Aug 2026 02:29:08 +0200 Subject: [PATCH] feat(config): triage agent, rotation and vibegram jobs, deploy and komodo hooks, vault watch rules --- README.md | 25 +++++++ config.py | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- uv.lock | 90 +++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 69ecd0b..58b9bcb 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,31 @@ docker compose up -d Set `PUBLIC_BASE_URL=https://` 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:///hooks/deploy -H "Authorization: Bearer " +``` + +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 diff --git a/config.py b/config.py index e338f33..ee71d1c 100644 --- a/config.py +++ b/config.py @@ -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, ) diff --git a/uv.lock b/uv.lock index 035e302..d306a01 100644 --- a/uv.lock +++ b/uv.lock @@ -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"