Files
beaver-agent/config.py
T

590 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import os
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
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,
NewDayContext,
SeedContext,
)
from beaver_gateway.core.distill import DistillContext, Distiller, LineCap
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
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.markdown import MarkdownFrontend
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 / "💬 чаты"
DIARY = VAULT / "📅 дни"
BEAVER = VAULT / "мета" / "бобер"
PROMPTS = BEAVER / "промпты"
GRANULES = PROMPTS / "гранулы"
SKILLS = BEAVER / "скиллы"
DAYS = BEAVER / "дни"
DIGESTS = BEAVER / "выжимки"
INDEX = BEAVER / "индекс.md"
STATE = BEAVER / "состояние.md"
STATE_MAX_LINES = 60
# §4.5: ночное закрытие трогает только чаты с активностью после запуска.
LAUNCH = datetime(2026, 8, 28, tzinfo=ZoneInfo(TZ))
_log = logging.getLogger("beaver_agent.config")
# Гранулы - чистый markdown; xml-теги, в которые они заворачиваются,
# живут здесь. Голос - папка, файл на тег, в этом порядке.
VOICE = tuple(
(tag, GRANULES / "голос" / f"{tag}.md")
for tag in (
"role",
"philosophy",
"user_profile",
"operating_modes",
"how_you_operate",
"interaction_guidelines",
)
)
PROFILE = ("profile", PROMPTS / "профиль.md")
CORRECTIONS = ("corrections", PROMPTS / "поправки.md")
VAULT_MAP = ("vault_map", GRANULES / "карта-vault.md")
def environment(kind: str) -> tuple[str, Path]:
return ("environment", GRANULES / "окружения" / f"{kind}.md")
def overlay(tag: str, name: str) -> tuple[str, Path]:
return (tag, GRANULES / f"{name}.md")
# §4.2: сборки промптов. Окружение kind'а - константная гранула, экземплярное
# (файл, топик) едет первым сообщением. Какие kind агент обслуживает, следует
# из того, какие промпты у него заданы (§3.12).
_BASE = (*VOICE, PROFILE, CORRECTIONS, VAULT_MAP)
_DISTILL = (*_BASE, environment("джоб"), overlay("distiller", "дистиллятор"))
DEEP_PROMPTS = Prompts(
deep=(*_BASE, environment("глубокий"), overlay("deep", "глубокий"))
)
# Диспетчер: тот же голос, окружение по kind, оверлей по роли.
# Форк ветки (слив) думает как дистиллятор в окружении джоба.
DISPATCHER_PROMPTS = Prompts(
master=(*_BASE, environment("мастер"), overlay("dispatcher", "диспетчер")),
branch=(*_BASE, environment("ветка"), overlay("branch", "ветка")),
fork=_DISTILL,
job=_DISTILL,
)
# §4.1: дистиллятор без голоса - иначе выжимки с матом: профиль, окружение
# джоба, оверлей. Форк закрываемого чата и недельная чистка состояния.
_DISTILLER = (PROFILE, environment("джоб"), overlay("distiller", "дистиллятор"))
DISTILLER_PROMPTS = Prompts(fork=_DISTILLER, job=_DISTILLER)
# §4.1: триаж без голоса и без vault - профиль, окружение джоба, оверлей.
TRIAGE_PROMPTS = Prompts(job=(PROFILE, environment("джоб"), overlay("triage", "триаж")))
QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault.
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()
return (
vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d} - {slugify(title, maxlen=60)}.md"
)
def _calendar_mcps() -> list[HttpMcp]:
raw = os.environ.get("CALENDAR_MCPS", "").strip()
servers: list[HttpMcp] = []
for raw_entry in raw.split(","):
entry = raw_entry.strip()
if not entry:
continue
name, sep, url = entry.partition("=")
if not sep or not url.strip():
msg = f"CALENDAR_MCPS entry must be `name=url`, got: {entry!r}"
raise ValueError(msg)
servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip()))
return servers
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] = [
McpServer.stdio(
name="obsidian-fs",
command=["bunx", "-y", "@modelcontextprotocol/server-filesystem", "/vault"],
lenient=True,
),
McpServer.stdio(
name="firefly",
command=["bunx", "-y", "@firefly-iii-mcp/local", "--preset", "default"],
env={
"FIREFLY_III_PAT": os.environ["FIREFLY_PAT"],
"FIREFLY_III_BASE_URL": os.environ["FIREFLY_BASE_URL"],
},
lenient=True,
),
McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]),
*calendar_mcps,
*komodo_mcps,
]
# Руки глубокого (§4.4): firefly без delete_*, obsidian-fs не даётся - свои
# файловые тулзы есть, vault смонтирован в /vault.
CLAUDE_MCPS = (
ExposedMcp(name="firefly", deny=("delete_*",)),
ExposedMcp(name="telegram"),
*calendar_exposed,
*komodo_exposed,
)
UserPrefsRu = lambda: UserPreferences( # noqa: E731
locale="ru-RU", timezone="Europe/Warsaw", current_date=date.today().isoformat()
)
def dispatcher(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
return ClaudeAgent(
name=name,
model=model,
cwd=VAULT,
prompts=DISPATCHER_PROMPTS,
skill_sets=DISPATCHER_SKILLS,
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
policy=VAULT_POLICY,
expose_mcps=CLAUDE_MCPS,
)
def deep(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
return ClaudeAgent(
name=name,
model=model,
cwd=VAULT,
prompts=DEEP_PROMPTS,
skill_sets=DEEP_SKILLS,
# §8.4: «ок, обсудили» - тулза, закрытие после ответа.
gateway_tools=("close_chat",),
options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED),
policy=VAULT_POLICY,
expose_mcps=CLAUDE_MCPS,
)
def distiller(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
"""§4.1: форк закрытого чата - opus medium, без MCP, Read/Write в мета/бобер."""
return ClaudeAgent(
name=name,
model=model,
cwd=VAULT,
prompts=DISTILLER_PROMPTS,
options=ClaudeOptions(
effort=effort,
tools=("Read", "Write"),
disallowed_tools=DISTILLER_DISALLOWED,
),
policy=(vault_zones(ZONES),),
)
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=TRIAGE_DISALLOWED
),
)
def raycast(name: str, model: str, reasoning_effort: str | None = None) -> RaycastAgent:
return RaycastAgent(
name=name,
model=model,
system_prompt=QUICK_PROMPT,
reasoning_effort=reasoning_effort,
available_native_tools=(RemoteTool.WEB_SEARCH, RemoteTool.READ_PAGE),
user_preferences=UserPrefsRu,
expose_mcps=(
ExposedMcp(name="obsidian-fs"),
ExposedMcp(name="firefly", deny=("delete_*",)),
ExposedMcp(name="telegram"),
*calendar_exposed,
),
)
agents: list[BaseAgent] = [
# §12.D4: диспетчер на medium; high - по руке или в ветке.
dispatcher("beaver-dispatcher", "claude-opus-5", effort="medium"),
deep("beaver-opus-high", "claude-opus-5", effort="high"),
raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", reasoning_effort="high"),
deep("beaver-fable-high", "claude-fable-5", effort="high"),
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"),
distiller("beaver-distiller", "claude-opus-5", effort="medium"),
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"
),
raycast(
"beaver-gemini-flash-low", "google-gemini-3.5-flash", reasoning_effort="low"
),
]
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/")
# §3.8: личка с ботом - домашний фронтенд мастера и веток (General и топики).
# Токен и id - секреты gateway, в env процесса модели они не попадают.
TELEGRAM = (
TelegramFrontend(
token=os.environ["TELEGRAM_BOT_TOKEN"],
user_id=int(os.environ["TELEGRAM_USER_ID"]),
master_agent="beaver-dispatcher",
branch_agent="beaver-dispatcher",
)
if os.environ.get("TELEGRAM_BOT_TOKEN")
else None
)
# Один порт: каждый HTTP-фронтенд живёт под своим путём (/anthropic, /mcp,
# /admin, /api, /md), `/` ведёт в админку. Caddy ничего не срезает.
frontends: list[Frontend] = [
*([TELEGRAM] if TELEGRAM is not None else []),
AnthropicMessagesFrontend(),
McpServerFrontend(),
AdminFrontend(),
# §3.9: /api показывает всё; дефолт master/branch здесь - запасной на случай
# gateway без телеграма, deep - у markdown.
ApiFrontend(
master_agent="beaver-dispatcher",
branch_agent="beaver-dispatcher",
memory_root=BEAVER,
),
MarkdownFrontend(
vault_path=CHATS_DIR,
default_agent="beaver-opus-high",
log_all_chats=True,
chat_path=chat_path,
),
]
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."""
if ctx.seed != "morning":
return None
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, только если есть что сказать."
)
SAME_DAY = (
"Мастер пересоздан ({why}), день тот же - {day}. Хендаут в сиде выше - справка от "
"предыдущего мастера за сегодня, не утро: продолжай с того места, ничего не "
"приветствуй. Скажи Бобру что-то через say, только если есть что сказать."
)
ROTATION_WHY = {
"возраст": "прошло больше 36 часов",
"транскрипт": "контекст стал слишком большим",
}
def new_day(ctx: NewDayContext) -> str:
"""§4.5: «новый день» - только ночная ротация; остальные - тот же день."""
day = ctx.day.isoformat()
if ctx.reason == "ночь":
return NEW_DAY.format(day=day)
return SAME_DAY.format(day=day, why=ROTATION_WHY.get(ctx.reason, ctx.reason))
DISTILL = (
"Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Выжимка - `Write` в "
'`{path}`, фронтматтер `source: "[[{chat}]]"`, `date: {day}`; слив - текст '
"ответа, до 5 строк, третье лицо."
)
DISTILL_NO_MEMORY = (
"Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Память для него "
"выключена: файл не пиши, только слив текстом ответа - до 5 строк, третье лицо."
)
def distill_prompt(ctx: DistillContext) -> str:
"""§8.4: экземплярное для дистиллятора - какой чат, куда файл."""
day = ctx.day.isoformat()
if not ctx.memory:
return DISTILL_NO_MEMORY.format(chat=ctx.chat_name, reason=ctx.reason, day=day)
path = DIGESTS / f"{day} - {slugify(ctx.chat_name, maxlen=60)}.md"
return DISTILL.format(chat=ctx.chat_name, reason=ctx.reason, day=day, path=path)
texts = ConversationTexts(
merge_prompt=_read(GRANULES / "слив.md") or ConversationTexts().merge_prompt,
seed=seed_body,
handout=handout_prompt,
new_day=new_day,
distill=distill_prompt,
)
# §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:
if KOMODO is None:
_log.warning("deploy hook: KOMODO_URL/KEY/SECRET are not set, nothing deployed")
return
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:
"""§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 close_idle(run: JobRun) -> None:
"""§4.5, §8.4: ночью, после ротации - глубокие без активности 2 дня, ≤ 3."""
for result in await run.close_idle(kind="deep", days=2, limit=3, since=LAUNCH):
_log.info(
"closed deep chat %s: digest=%s error=%s",
result.conversation.external_id,
result.digest.path if result.digest else None,
result.error,
)
MEMORY = (
"Воскресенье, {day}. Перепиши `{state}` из последних хендаутов: {handouts}. "
"`## сейчас` - только невыводимое из vault, `## мои хвосты` - capability "
"requests в работе и обещания длиннее дня. Меньше {max_lines} строк."
)
async def memory(run: JobRun) -> None:
"""§4.5, §6.2: вс 04:30 - состояние.md из 7 хендаутов, потолок держит gateway."""
handouts = sorted(DAYS.glob("????-??-??.md"))[-7:] if DAYS.exists() else []
if not handouts:
_log.info("memory: no handouts yet, nothing to rewrite")
return
await run.spawn_job(
agent="beaver-distiller",
title="память",
text=MEMORY.format(
day=_today().isoformat(),
state=STATE.relative_to(VAULT),
handouts=", ".join(f"`{h.relative_to(VAULT)}`" for h in handouts),
max_lines=STATE_MAX_LINES,
),
line_cap=LineCap(STATE, max_lines=STATE_MAX_LINES),
)
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("закрытие", 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),
]
gateway = Gateway(
agents=agents,
mcps=mcps,
frontends=frontends,
texts=texts,
jobs=jobs,
# Окно модели - 1M; context rot начинается около половины (эмпирика h по
# счётчику Claude Code), считаем как он - вход последнего API-вызова.
rotation=RotationPolicy(tz=TZ, max_context_tokens=500_000),
watch=watch,
budget=Budget(threshold=0.7),
distiller=Distiller(agent="beaver-distiller", dir=DIGESTS, index=INDEX),
tz=TZ,
port=62990,
public_url=PUBLIC_BASE_URL or None,
)