Compare commits

..
Author SHA1 Message Date
hhandClaude Fable 5 ee2dc918ee docs(env): .env.example for local development
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:45:53 +02:00
hhandClaude Fable 5 33cc769ca2 chore: make ruff format and ty clean
Format two mcp modules with the current ruff, and narrow the text-block
check in conversation_store through a typed helper so ty stops flagging
dict.get on an unnarrowed block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:22:23 +02:00
hhandClaude Fable 5 e4282b38cd feat(ui,infra): SvelteKit ui scaffold, Makefile, pre-commit, claude hooks
ui/: SvelteKit 2 + Svelte 5 + Tailwind 4 + shadcn-svelte (luma, lucide)
on bun with ultracite; adapter-static with SPA fallback; theme tokens
taken from msos. Dockerfile gains a bun stage that builds ui/build.
Makefile and pre-commit cover ruff, ty, ultracite and svelte-check;
.claude settings run the matching fixer after edits. docs/PRODUCT.md
captures product truth for the UI work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:22:18 +02:00
338 changed files with 5929 additions and 34543 deletions

No files matched your search

+1 -3
View File
@@ -6,11 +6,9 @@
- **NEVER write comments** - code should be self-documenting
## Layout
- `src/beaver_gateway` - python, unopinionated primitives (no vault paths, no agent names, no Russian): one package per capability - `agents`, `backends`, `conversations`, `jobs`, `frontends`, `mcp`, `vault`, `security`, `events`, `storage`; `config.py` loads a setup, `app.py` builds the runtime, `cli.py` is the entrypoint
- every string shown to a model or a user lives in a `*Texts` dataclass with English defaults (`conversations/texts.py`, `frontends/telegram/texts.py`); a setup overrides them
- `src/beaver_gateway` - python, unopinionated primitives (no vault paths, no agent names)
- `ui` - SvelteKit SPA (admin + Obsidian panel), served by the gateway from `ui/build`
- `docs/MISSION.md`, `docs/PRD.md`, `docs/PLAN.md`, `docs/PROGRESS.md` - keep PROGRESS.md current
- docstrings: one line per module saying what it is for, one line per public configuration field; no section references, phases or history
## Checking commands
After writing code, always run (or `make check`):
-9
View File
@@ -23,12 +23,3 @@ CLAUDE_CODE_OAUTH_TOKEN=
RAYCAST_CONFIG_PATH=../raycast-api/config.json
RAYCAST_DEVICE_ID=
RAYCAST_BEARER=
# Telegram (нужно только если в config.py есть TelegramFrontend):
# токен от @BotFather, свой user id - @userinfobot
TELEGRAM_BOT_TOKEN=
TELEGRAM_USER_ID=
# Откуда тулза send_file имеет право брать файлы (через ':').
# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд.
SEND_FILE_ROOTS=/mnt/hole/shared
-4
View File
@@ -11,7 +11,6 @@ wheels/
# Sloppy
docs/
t/
# IDE
.idea
@@ -19,6 +18,3 @@ t/
# Local env
.env
db.db
# Impeccable review screenshots
.impeccable/review/
-628
View File
@@ -1,628 +0,0 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-29T00:00:00Z",
"title": "Design System: Beaver admin",
"extensions": {
"colorMeta": {
"primary": {
"role": "primary",
"displayName": "Mulberry",
"canonical": "oklch(0.47 0.13 340)",
"dark": "#7c3871",
"tonalRamp": [
"oklch(0.15 0.13 340)",
"oklch(0.26 0.13 340)",
"oklch(0.37 0.13 340)",
"oklch(0.47 0.13 340)",
"oklch(0.58 0.13 340)",
"oklch(0.70 0.13 340)",
"oklch(0.83 0.13 340)",
"oklch(0.95 0.13 340)"
]
},
"signal": {
"role": "primary",
"displayName": "Orchid Signal",
"canonical": "oklch(0.48 0.21 342)",
"dark": "#ff82f3",
"tonalRamp": [
"oklch(0.15 0.21 342)",
"oklch(0.26 0.21 342)",
"oklch(0.37 0.21 342)",
"oklch(0.48 0.21 342)",
"oklch(0.60 0.21 342)",
"oklch(0.72 0.21 342)",
"oklch(0.84 0.21 342)",
"oklch(0.95 0.21 342)"
]
},
"ring": {
"role": "primary",
"displayName": "Focus Ring",
"canonical": "oklch(0.55 0.2 342)",
"dark": "#ff82f3",
"tonalRamp": [
"oklch(0.15 0.2 342)",
"oklch(0.26 0.2 342)",
"oklch(0.38 0.2 342)",
"oklch(0.55 0.2 342)",
"oklch(0.64 0.2 342)",
"oklch(0.74 0.2 342)",
"oklch(0.85 0.2 342)",
"oklch(0.95 0.2 342)"
]
},
"status-reply": {
"role": "secondary",
"displayName": "Branch Blue",
"canonical": "oklch(0.55 0.14 235)",
"dark": "oklch(0.72 0.13 235)",
"tonalRamp": [
"oklch(0.15 0.14 235)",
"oklch(0.26 0.14 235)",
"oklch(0.40 0.14 235)",
"oklch(0.55 0.14 235)",
"oklch(0.65 0.14 235)",
"oklch(0.75 0.14 235)",
"oklch(0.85 0.14 235)",
"oklch(0.95 0.14 235)"
]
},
"status-meeting": {
"role": "secondary",
"displayName": "Deep Violet",
"canonical": "oklch(0.5 0.2 300)",
"dark": "oklch(0.72 0.16 300)",
"tonalRamp": [
"oklch(0.15 0.2 300)",
"oklch(0.26 0.2 300)",
"oklch(0.38 0.2 300)",
"oklch(0.50 0.2 300)",
"oklch(0.62 0.2 300)",
"oklch(0.74 0.2 300)",
"oklch(0.85 0.2 300)",
"oklch(0.95 0.2 300)"
]
},
"status-work": {
"role": "secondary",
"displayName": "Job Teal",
"canonical": "oklch(0.55 0.13 195)",
"dark": "oklch(0.75 0.12 195)",
"tonalRamp": [
"oklch(0.15 0.13 195)",
"oklch(0.26 0.13 195)",
"oklch(0.40 0.13 195)",
"oklch(0.55 0.13 195)",
"oklch(0.65 0.13 195)",
"oklch(0.75 0.13 195)",
"oklch(0.85 0.13 195)",
"oklch(0.95 0.13 195)"
]
},
"status-skip": {
"role": "secondary",
"displayName": "Fork Grey",
"canonical": "oklch(0.5 0.04 340)",
"dark": "#a989a3",
"tonalRamp": [
"oklch(0.15 0.04 340)",
"oklch(0.26 0.04 340)",
"oklch(0.38 0.04 340)",
"oklch(0.50 0.04 340)",
"oklch(0.62 0.04 340)",
"oklch(0.74 0.04 340)",
"oklch(0.85 0.04 340)",
"oklch(0.95 0.04 340)"
]
},
"status-done": {
"role": "tertiary",
"displayName": "OK Green",
"canonical": "oklch(0.55 0.14 155)",
"dark": "oklch(0.74 0.14 155)",
"tonalRamp": [
"oklch(0.15 0.14 155)",
"oklch(0.26 0.14 155)",
"oklch(0.40 0.14 155)",
"oklch(0.55 0.14 155)",
"oklch(0.65 0.14 155)",
"oklch(0.75 0.14 155)",
"oklch(0.85 0.14 155)",
"oklch(0.95 0.14 155)"
]
},
"warn": {
"role": "tertiary",
"displayName": "Warn Amber",
"canonical": "oklch(0.55 0.13 75)",
"dark": "oklch(0.8 0.13 75)",
"tonalRamp": [
"oklch(0.15 0.13 75)",
"oklch(0.26 0.13 75)",
"oklch(0.40 0.13 75)",
"oklch(0.55 0.13 75)",
"oklch(0.65 0.13 75)",
"oklch(0.75 0.13 75)",
"oklch(0.85 0.13 75)",
"oklch(0.95 0.13 75)"
]
},
"note": {
"role": "tertiary",
"displayName": "Inject Amber",
"canonical": "oklch(0.52 0.13 78)",
"dark": "oklch(0.84 0.14 88)",
"tonalRamp": [
"oklch(0.15 0.13 78)",
"oklch(0.26 0.13 78)",
"oklch(0.40 0.13 78)",
"oklch(0.52 0.13 78)",
"oklch(0.64 0.13 78)",
"oklch(0.75 0.13 78)",
"oklch(0.85 0.13 78)",
"oklch(0.95 0.13 78)"
]
},
"destructive": {
"role": "tertiary",
"displayName": "Error Red",
"canonical": "oklch(0.58 0.22 25)",
"dark": "oklch(0.62 0.2 25)",
"tonalRamp": [
"oklch(0.15 0.22 25)",
"oklch(0.26 0.22 25)",
"oklch(0.40 0.22 25)",
"oklch(0.58 0.22 25)",
"oklch(0.66 0.22 25)",
"oklch(0.75 0.22 25)",
"oklch(0.85 0.22 25)",
"oklch(0.95 0.22 25)"
]
},
"background": {
"role": "neutral",
"displayName": "Blush Paper",
"canonical": "oklch(0.99 0.004 340)",
"dark": "#22111e",
"tonalRamp": [
"oklch(0.15 0.004 340)",
"oklch(0.26 0.004 340)",
"oklch(0.40 0.004 340)",
"oklch(0.55 0.004 340)",
"oklch(0.70 0.004 340)",
"oklch(0.85 0.004 340)",
"oklch(0.95 0.004 340)",
"oklch(0.99 0.004 340)"
]
},
"foreground": {
"role": "neutral",
"displayName": "Plum Ink",
"canonical": "oklch(0.24 0.03 340)",
"dark": "#f5f5f5",
"tonalRamp": [
"oklch(0.15 0.03 340)",
"oklch(0.24 0.03 340)",
"oklch(0.38 0.03 340)",
"oklch(0.50 0.03 340)",
"oklch(0.62 0.03 340)",
"oklch(0.75 0.03 340)",
"oklch(0.87 0.03 340)",
"oklch(0.95 0.03 340)"
]
},
"sidebar": {
"role": "neutral",
"displayName": "Sidebar Layer",
"canonical": "oklch(0.975 0.01 340)",
"dark": "#2c1b29",
"tonalRamp": [
"oklch(0.15 0.01 340)",
"oklch(0.26 0.01 340)",
"oklch(0.40 0.01 340)",
"oklch(0.55 0.01 340)",
"oklch(0.70 0.01 340)",
"oklch(0.85 0.01 340)",
"oklch(0.94 0.01 340)",
"oklch(0.975 0.01 340)"
]
},
"sidebar-accent": {
"role": "neutral",
"displayName": "Sidebar Accent",
"canonical": "oklch(0.94 0.016 340)",
"dark": "#341d2f",
"tonalRamp": [
"oklch(0.15 0.016 340)",
"oklch(0.26 0.016 340)",
"oklch(0.40 0.016 340)",
"oklch(0.55 0.016 340)",
"oklch(0.70 0.016 340)",
"oklch(0.85 0.016 340)",
"oklch(0.94 0.016 340)",
"oklch(0.98 0.016 340)"
]
},
"muted": {
"role": "neutral",
"displayName": "Muted",
"canonical": "oklch(0.96 0.012 340)",
"dark": "#40283a",
"tonalRamp": [
"oklch(0.15 0.012 340)",
"oklch(0.26 0.012 340)",
"oklch(0.40 0.012 340)",
"oklch(0.55 0.012 340)",
"oklch(0.70 0.012 340)",
"oklch(0.85 0.012 340)",
"oklch(0.96 0.012 340)",
"oklch(0.99 0.012 340)"
]
},
"muted-foreground": {
"role": "neutral",
"displayName": "Muted Ink",
"canonical": "oklch(0.5 0.04 340)",
"dark": "#a989a3",
"tonalRamp": [
"oklch(0.15 0.04 340)",
"oklch(0.26 0.04 340)",
"oklch(0.38 0.04 340)",
"oklch(0.50 0.04 340)",
"oklch(0.62 0.04 340)",
"oklch(0.74 0.04 340)",
"oklch(0.85 0.04 340)",
"oklch(0.95 0.04 340)"
]
},
"icon": {
"role": "neutral",
"displayName": "Icon",
"canonical": "oklch(0.55 0.03 340)",
"dark": "#877384",
"tonalRamp": [
"oklch(0.15 0.03 340)",
"oklch(0.26 0.03 340)",
"oklch(0.40 0.03 340)",
"oklch(0.55 0.03 340)",
"oklch(0.65 0.03 340)",
"oklch(0.75 0.03 340)",
"oklch(0.85 0.03 340)",
"oklch(0.95 0.03 340)"
]
},
"border": {
"role": "neutral",
"displayName": "Hairline",
"canonical": "oklch(0.9 0.02 340)",
"dark": "oklch(0.92 0.04 340 / 10%)",
"tonalRamp": [
"oklch(0.15 0.02 340)",
"oklch(0.26 0.02 340)",
"oklch(0.40 0.02 340)",
"oklch(0.55 0.02 340)",
"oklch(0.70 0.02 340)",
"oklch(0.80 0.02 340)",
"oklch(0.90 0.02 340)",
"oklch(0.97 0.02 340)"
]
}
},
"typographyMeta": {
"headline": {
"displayName": "Headline",
"purpose": "Page titles in page-header.svelte; the sidebar wordmark shares weight and tracking."
},
"title": {
"displayName": "Title",
"purpose": "Thread title, stat values (tabular), memory file path."
},
"body": {
"displayName": "Body",
"purpose": "Rows, cells, bubbles, dialogs; primary row text at 500."
},
"label": {
"displayName": "Label",
"purpose": "Uppercase section headings; same size without uppercase for table headers, badges, pills, meta lines."
},
"mono": {
"displayName": "Mono",
"purpose": "Raw JSON, tool input/result blocks, event tape, ids, the plaintext token."
}
},
"shadows": [
{
"name": "login-lift",
"value": "0 8px 24px -12px rgb(0 0 0 / 0.25)",
"purpose": "The sign-in card only (routes/login/+page.svelte). The single application-layer shadow."
},
{
"name": "popover",
"value": "shadow-xl plus ring-1 ring-foreground/5 (10% dark)",
"purpose": "Luma dialogs, sheets, menus, selects. Overlay surfaces only."
}
],
"motion": [
{
"name": "pulse-dot",
"value": "pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite",
"purpose": "Signal-colored halo (0 to 5px, 55% to 0% alpha) on live and running dots."
},
{
"name": "rise",
"value": "rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both",
"purpose": "Opacity 0 to 1 and translateY 3px to 0 when a turn card lands in the activity feed."
},
{
"name": "row-hover",
"value": "background-color 150ms ease-out",
"purpose": "Ink wash at 4% on interactive rows."
},
{
"name": "chevron",
"value": "transform 150ms",
"purpose": "90 degree rotation of expand chevrons in tool nodes, raw entries, memory tree."
},
{
"name": "bar-fill",
"value": "width 300ms",
"purpose": "Utilization bar fill in limit-bar.svelte."
},
{
"name": "dialog",
"value": "fade-in and zoom-in-95 over 100ms",
"purpose": "Luma dialog open and close."
},
{
"name": "reduced-motion",
"value": "transition-duration and animation-duration 0.01ms under prefers-reduced-motion",
"purpose": "Global kill switch in layout.css."
}
],
"breakpoints": [
{
"name": "sm",
"value": "640px",
"purpose": "Sidebar replaces bottom nav; header padding 24px; hidden row columns return."
},
{
"name": "md",
"value": "768px",
"purpose": "Memory tree and file split side by side."
},
{
"name": "lg",
"value": "1024px",
"purpose": "Thread meta rail (18rem) and two-column dashboard sections."
},
{
"name": "panel-rail",
"value": "768px",
"purpose": "Panel container width, not viewport: below it the switcher bar names the thread, from it the 18rem conversation rail shows."
}
],
"obsidian": {
"root": ".beaver-root",
"source": "beaver-plugin-obsidian/src/tailwind.css",
"aliases": {
"background": "--background-primary",
"card": "--background-primary-alt",
"popover": "--background-secondary",
"sidebar": "--background-secondary",
"foreground": "--text-normal",
"muted-foreground": "--text-muted",
"icon": "--text-muted",
"status-skip": "--text-faint",
"border": "--background-modifier-border",
"input": "--background-modifier-border",
"muted": "--background-modifier-hover",
"secondary": "--background-modifier-hover",
"accent": "--background-modifier-hover",
"sidebar-accent": "--background-modifier-hover",
"primary": "--color-accent",
"signal": "--color-accent",
"ring": "--color-accent",
"status-new": "--color-accent",
"primary-foreground": "--text-on-accent",
"link": "--link-color, --text-accent",
"destructive": "--text-error",
"font-sans": "--font-interface",
"font-mono": "--font-monospace"
},
"radii": {
"button, select-trigger": "--button-radius",
"input, tabs-trigger, menu items": "--input-radius",
"tabs-list, skeleton, dialog, popover, menus": "--radius-m (8px)",
"rows and notes": "--radius 0.45rem"
},
"overlays": {
"border": "1px var(--border)",
"shadow": "var(--shadow-s)",
"menuBackground": "var(--popover), opaque, no backdrop",
"portal": ".beaver-root.beaver-portal on body at --layer-menu (65)"
},
"tabs": {
"list": "color-mix(in oklab, var(--text-normal) 6%, transparent)",
"active": "color-mix(in oklab, var(--text-normal) 14%, transparent)"
}
}
},
"components": [
{
"name": "Sidebar nav item",
"kind": "nav",
"refersTo": "sidebar-nav-item",
"description": "Section link in the 13rem sidebar; the active item takes the sidebar accent fill, the Now item carries the running count in signal.",
"html": "<nav class=\"ds-nav\"><a class=\"ds-nav-item ds-nav-item--active\" href=\"#\" aria-current=\"page\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2\"/></svg><span class=\"ds-nav-label\">Now</span><span class=\"ds-nav-count\">1</span></a><a class=\"ds-nav-item\" href=\"#\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2z\"/><path d=\"M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1\"/></svg><span class=\"ds-nav-label\">Conversations</span></a></nav>",
"css": ".ds-nav { display: flex; flex-direction: column; gap: 2px; padding: 8px; width: 208px; background: var(--sidebar); color: var(--sidebar-foreground); font-family: 'Inter Variable', sans-serif; } .ds-nav-item { display: flex; align-items: center; gap: 10px; height: 32px; padding: 0 10px; border-radius: 0.36rem; font-size: 0.8125rem; color: color-mix(in oklab, var(--sidebar-foreground) 80%, transparent); text-decoration: none; transition: background-color 150ms, color 150ms; } .ds-nav-item svg { color: var(--icon); flex-shrink: 0; } .ds-nav-item:hover { background: color-mix(in oklab, var(--sidebar-accent) 60%, transparent); color: var(--sidebar-foreground); } .ds-nav-item--active { background: var(--sidebar-accent); color: var(--sidebar-foreground); font-weight: 500; } .ds-nav-item:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } .ds-nav-label { flex: 1; } .ds-nav-count { font-variant-numeric: tabular-nums; font-size: 0.75rem; font-weight: 500; padding: 0 6px; border-radius: 999px; color: var(--signal); background: color-mix(in oklab, var(--signal) 12%, transparent); }"
},
{
"name": "Section heading",
"kind": "custom",
"refersTo": "section-heading",
"description": "The 12px uppercase muted h2 that opens every ledger section, optionally followed by a tabular summary.",
"html": "<div class=\"ds-section-head\"><h2 class=\"ds-section-heading\">Live sessions</h2><span class=\"ds-section-summary\">1 processes · 263 MB</span></div>",
"css": ".ds-section-head { display: flex; align-items: baseline; gap: 12px; font-family: 'Inter Variable', sans-serif; } .ds-section-heading { margin: 0; font-size: 0.75rem; font-weight: 500; letter-spacing: 0.025em; text-transform: uppercase; color: var(--muted-foreground); } .ds-section-summary { font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }"
},
{
"name": "Ledger row (running)",
"kind": "card",
"refersTo": "ledger-row",
"description": "A running conversation on the Now page: pulsing signal dot, kind badge, two-line title, tabular elapsed time, hairline below, ink-wash hover.",
"html": "<a class=\"ds-row\" href=\"#\"><span class=\"ds-row-dot\"></span><span class=\"ds-badge ds-badge--master\">master</span><span class=\"ds-row-title\"><span class=\"ds-row-name\">master e56eabe0</span><span class=\"ds-row-sub\">beaver-dispatcher</span></span><span class=\"ds-row-time\">20.1s</span></a>",
"css": ".ds-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; column-gap: 12px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; color: var(--foreground); text-decoration: none; transition: background-color 150ms ease-out; } .ds-row:hover { background: color-mix(in oklab, var(--foreground) 4%, transparent); } .ds-row:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } .ds-row-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--signal); animation: ds-pulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite; } @keyframes ds-pulse { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); } 50% { box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent); } } .ds-row-title { display: flex; flex-direction: column; min-width: 0; } .ds-row-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ds-row-sub { font-size: 0.75rem; color: var(--muted-foreground); } .ds-row-time { font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; } .ds-badge { display: inline-flex; align-items: center; height: 20px; padding: 0 6px; border-radius: 0.36rem; font-size: 0.75rem; font-weight: 500; line-height: 1; } .ds-badge--master { color: var(--signal); background: color-mix(in oklab, var(--signal) 12%, transparent); }"
},
{
"name": "Kind badge",
"kind": "chip",
"refersTo": "kind-badge",
"description": "Conversation kind as its hue at 12% behind hue text; master, branch, deep, job, fork.",
"html": "<span class=\"ds-kinds\"><span class=\"ds-badge ds-badge--master\">master</span><span class=\"ds-badge ds-badge--branch\">branch</span><span class=\"ds-badge ds-badge--deep\">deep</span><span class=\"ds-badge ds-badge--job\">job</span><span class=\"ds-badge ds-badge--fork\">fork</span></span>",
"css": ".ds-kinds { display: inline-flex; gap: 4px; font-family: 'Inter Variable', sans-serif; } .ds-badge { display: inline-flex; align-items: center; height: 20px; padding: 0 6px; border-radius: 0.36rem; font-size: 0.75rem; font-weight: 500; line-height: 1; } .ds-badge--master { color: var(--signal); background: color-mix(in oklab, var(--signal) 12%, transparent); } .ds-badge--branch { color: var(--status-reply); background: color-mix(in oklab, var(--status-reply) 12%, transparent); } .ds-badge--deep { color: var(--status-meeting); background: color-mix(in oklab, var(--status-meeting) 12%, transparent); } .ds-badge--job { color: var(--status-work); background: color-mix(in oklab, var(--status-work) 12%, transparent); } .ds-badge--fork { color: var(--status-skip); background: color-mix(in oklab, var(--status-skip) 12%, transparent); }"
},
{
"name": "Status pill",
"kind": "chip",
"refersTo": "status-pill",
"description": "Outcome as a 6px current-color dot plus 12px text; running pulses in signal.",
"html": "<span class=\"ds-pills\"><span class=\"ds-pill ds-pill--running\"><span class=\"ds-pill-dot ds-pill-dot--pulse\"></span>running</span><span class=\"ds-pill ds-pill--done\"><span class=\"ds-pill-dot\"></span>done</span><span class=\"ds-pill ds-pill--warn\"><span class=\"ds-pill-dot\"></span>interrupted</span><span class=\"ds-pill ds-pill--error\"><span class=\"ds-pill-dot\"></span>failed</span><span class=\"ds-pill ds-pill--muted\"><span class=\"ds-pill-dot\"></span>closed</span></span>",
"css": ".ds-pills { display: inline-flex; gap: 12px; font-family: 'Inter Variable', sans-serif; } .ds-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 500; } .ds-pill-dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; } .ds-pill-dot--pulse { animation: ds-pulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite; } @keyframes ds-pulse { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); } 50% { box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent); } } .ds-pill--running { color: var(--signal); } .ds-pill--done { color: var(--status-done); } .ds-pill--warn { color: var(--warn); } .ds-pill--error { color: var(--destructive); } .ds-pill--muted { color: var(--muted-foreground); }"
},
{
"name": "Tool node",
"kind": "custom",
"refersTo": "ledger-row",
"description": "One row of the tool tree: chevron, per-node status dot, tool label (deep-tinted for subagents) with input summary, tabular duration; children nest under a left rail.",
"html": "<div class=\"ds-tool\"><button class=\"ds-tool-row\" type=\"button\" aria-expanded=\"false\"><svg class=\"ds-tool-chev\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m9 18 6-6-6-6\"/></svg><span class=\"ds-tool-dot ds-tool-dot--running\"></span><span class=\"ds-tool-main\"><span class=\"ds-tool-name\">Bash</span><span class=\"ds-tool-sum\">python3 -c \"import time; time.sleep(30)\"</span></span><span class=\"ds-tool-time\">13.5s</span></button><div class=\"ds-tool ds-tool--child\"><button class=\"ds-tool-row\" type=\"button\" aria-expanded=\"false\"><svg class=\"ds-tool-chev\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m9 18 6-6-6-6\"/></svg><span class=\"ds-tool-dot ds-tool-dot--done\"></span><span class=\"ds-tool-main\"><span class=\"ds-tool-name ds-tool-name--subagent\">Agent</span><span class=\"ds-tool-sum\">Read role.md and summarize</span></span><span class=\"ds-tool-time\">12.1s</span></button></div></div>",
"css": ".ds-tool { display: flex; flex-direction: column; font-family: 'Inter Variable', sans-serif; } .ds-tool--child { margin-left: 12px; padding-left: 8px; border-left: 1px solid var(--border); } .ds-tool-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; column-gap: 12px; align-items: center; padding: 4px 6px; border: 0; border-radius: 0.36rem; background: transparent; color: var(--foreground); font: inherit; font-size: 0.8125rem; text-align: left; cursor: pointer; transition: background-color 150ms ease-out; } .ds-tool-row:hover { background: color-mix(in oklab, var(--foreground) 4%, transparent); } .ds-tool-row:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } .ds-tool-chev { color: var(--icon); transition: transform 150ms; } .ds-tool-row[aria-expanded=\"true\"] .ds-tool-chev { transform: rotate(90deg); } .ds-tool-dot { width: 8px; height: 8px; border-radius: 999px; } .ds-tool-dot--running { background: var(--signal); animation: ds-pulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite; } .ds-tool-dot--done { background: var(--status-done); } @keyframes ds-pulse { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); } 50% { box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent); } } .ds-tool-main { display: flex; align-items: baseline; gap: 8px; min-width: 0; } .ds-tool-name { font-weight: 500; flex-shrink: 0; } .ds-tool-name--subagent { color: var(--status-meeting); } .ds-tool-sum { color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ds-tool-time { font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }"
},
{
"name": "Primary button",
"kind": "button",
"refersTo": "button-primary",
"description": "Luma pill button in mulberry; the one filled action per surface (Send, New, Create, Sign in).",
"html": "<button class=\"ds-btn ds-btn--primary\" type=\"button\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14\"/><path d=\"M12 5v14\"/></svg>New token</button>",
"css": ".ds-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; height: 32px; padding: 0 12px; border: 1px solid transparent; border-radius: 2rem; font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; font-weight: 500; white-space: nowrap; cursor: pointer; user-select: none; transition: all 150ms; } .ds-btn:active { transform: translateY(1px); } .ds-btn:disabled { opacity: 0.5; pointer-events: none; } .ds-btn:focus-visible { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent); outline: none; } .ds-btn--primary { background: var(--primary); color: var(--primary-foreground); } .ds-btn--primary:hover { background: color-mix(in oklab, var(--primary) 80%, transparent); }"
},
{
"name": "Outline button",
"kind": "button",
"refersTo": "button-outline",
"description": "Secondary action everywhere (Branch, Retry, Copy, answer options): hairline pill on the background surface.",
"html": "<button class=\"ds-btn ds-btn--outline\" type=\"button\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"6\" x2=\"6\" y1=\"3\" y2=\"15\"/><circle cx=\"18\" cy=\"6\" r=\"3\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><path d=\"M18 9a9 9 0 0 1-9 9\"/></svg>Branch</button>",
"css": ".ds-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; height: 32px; padding: 0 12px; border: 1px solid transparent; border-radius: 2rem; font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; font-weight: 500; white-space: nowrap; cursor: pointer; user-select: none; transition: all 150ms; } .ds-btn:active { transform: translateY(1px); } .ds-btn:focus-visible { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent); outline: none; } .ds-btn--outline { border-color: var(--border); background: var(--background); color: var(--foreground); } .ds-btn--outline:hover { background: var(--muted); }"
},
{
"name": "Ghost button",
"kind": "button",
"refersTo": "button-ghost",
"description": "Quiet action (Cancel, Revoke, Done, icon buttons): transparent until hover.",
"html": "<button class=\"ds-btn ds-btn--ghost\" type=\"button\">Cancel</button>",
"css": ".ds-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; height: 32px; padding: 0 12px; border: 1px solid transparent; border-radius: 2rem; font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; font-weight: 500; white-space: nowrap; cursor: pointer; user-select: none; transition: all 150ms; } .ds-btn:active { transform: translateY(1px); } .ds-btn:focus-visible { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent); outline: none; } .ds-btn--ghost { background: transparent; color: var(--foreground); } .ds-btn--ghost:hover { background: var(--muted); }"
},
{
"name": "Text input",
"kind": "input",
"refersTo": "input",
"description": "Luma pill input on a 50% input tint; focus is a ring-colored border plus a 30% halo.",
"html": "<label class=\"ds-field\"><span class=\"ds-label\">Name</span><input class=\"ds-input\" type=\"text\" placeholder=\"cursor-laptop\"></label>",
"css": ".ds-field { display: flex; flex-direction: column; gap: 6px; font-family: 'Inter Variable', sans-serif; max-width: 320px; } .ds-label { font-size: 0.8125rem; font-weight: 500; color: var(--foreground); } .ds-input { height: 36px; width: 100%; padding: 4px 12px; border: 1px solid transparent; border-radius: 1.5rem; background: color-mix(in oklab, var(--input) 50%, transparent); color: var(--foreground); font: inherit; font-size: 0.8125rem; caret-color: var(--primary); outline: none; transition: color 150ms, box-shadow 150ms, background-color 150ms; } .ds-input::placeholder { color: var(--muted-foreground); } .ds-input:focus-visible { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent); } .ds-input:disabled { opacity: 0.5; cursor: not-allowed; }"
},
{
"name": "Empty state",
"kind": "card",
"refersTo": "empty-state",
"description": "The only dashed border in the system: a bordered note with a 500 title, a muted hint and an optional outline action.",
"html": "<div class=\"ds-empty\"><p class=\"ds-empty-title\">The agents are idle</p><p class=\"ds-empty-hint\">Nothing is in a turn. The ledger fills in the moment a message or an inject lands.</p></div>",
"css": ".ds-empty { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 20px 16px; border: 1px dashed var(--border); border-radius: 0.45rem; font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; color: var(--foreground); } .ds-empty p { margin: 0; } .ds-empty-title { font-weight: 500; } .ds-empty-hint { color: var(--muted-foreground); }"
}
],
"narrative": {
"northStar": "The Live Ledger",
"overview": "The console is a ledger, not a dashboard. It opens on what the agents are doing right now, as rows of running turns and tool calls, and refuses the grid of metric cards (direction contract, ui/src/app.html). Everything is a row on a rail: hairline borders separate rows, tabular numerals line up columns, and small uppercase section headings name each ledger. Cards exist only as bordered notes for exceptions (an empty state, an error, a pending question, the login form), never as containers for other cards.\n\nThe palette is the msos mauve/pink theme on Inter, inherited verbatim from Kit's msos project (ui/src/routes/layout.css, :root and .dark). Two neutral layers do the spatial work: a slightly cooler, tinted sidebar layer for navigation and rails, and the content surface. One saturated accent (signal) is reserved for two meanings only: something is live right now, or something is selected. Every other hue is a status or kind hue from the msos status palette, used at low alpha as a tint and at full strength as text.\n\nDensity is that of an operator console: 13px body, 12px labels, 32px nav items, 8px row padding, 48px page header. Motion is limited to a soft pulse on live dots, a 220ms rise when a turn lands, and 150ms hover fades; everything collapses under prefers-reduced-motion.",
"keyCharacteristics": [
"Rows and rails with hairline borders; no cards inside cards",
"Tabular numerals on every number; right-aligned numeric columns",
"Uppercase 12px section headings in muted ink",
"Pink (signal) means live or selected, nothing else",
"Kind hues (master, branch, deep, job, fork) carried by low-alpha badges and text tints",
"Pill-shaped luma primitives (buttons, inputs, tabs) on top of small-radius application rows",
"Light and dark modes, both tinted toward hue 340; dark borders are 10% alpha overlays"
],
"rules": [
{
"name": "The Pink Ration Rule",
"body": "signal marks live state (pulsing dots, running rows, busy sessions, the running count) and selection (active mobile nav, master kind). It never decorates a heading, a button, or a static surface. Hover is a 4% ink wash, not pink.",
"section": "colors"
},
{
"name": "The Twelve Percent Rule",
"body": "Every colored chip is its hue at 12% alpha behind full-strength text of the same hue (kind-badge.svelte, sidebar running count). Solid colored chips do not exist.",
"section": "colors"
},
{
"name": "The Two Layers Rule",
"body": "Navigation and rails sit on sidebar (or sidebar/50); content sits on background. There is no third surface color; depth beyond that is a hairline.",
"section": "colors"
},
{
"name": "The Tabular Rule",
"body": "Every number - durations, token counts, money, percentages, timestamps, ids, byte sizes - carries the tabular utility (font-variant-numeric: tabular-nums, layout.css). Numeric table columns are right-aligned.",
"section": "typography"
},
{
"name": "The One Heading Rule",
"body": "A page has one headline (the header) and any number of uppercase 12px section labels; there is no intermediate heading size. Section labels are h2 elements, not decorative kickers.",
"section": "typography"
},
{
"name": "The Flat Ledger Rule",
"body": "Nothing inside the main column casts a shadow. Overlays (dialog, sheet, menu) and the login card are the only lifted surfaces.",
"section": "elevation"
},
{
"name": "The Host Accent Rule",
"body": "Inside Obsidian signal and primary are one variable. Live dots, running rows, the running count, the selected row, the primary button and the focus ring all take the user's accent; the Pink Ration Rule becomes an accent ration with the same two meanings, live and selected.",
"section": "obsidian"
},
{
"name": "The Borrowed Corner Rule",
"body": "No control chooses its own radius. Rows and notes use --radius; luma primitives are re-rounded to Obsidian's --button-radius, --input-radius and --radius-m so the panel sits beside the app's own settings and menus without a seam.",
"section": "obsidian"
},
{
"name": "The Alias Only Rule",
"body": "Panel components use token aliases (bg-primary/5, text-link, border-b) and the panel.css utilities. A literal colour, font or radius in ui/src/lib/panel/ breaks the Obsidian mapping.",
"section": "obsidian"
}
],
"dos": [
"Do build lists as ledger-grid rows with border-b and a per-row column template; use a native table when there are more than four numeric columns.",
"Do put tabular on every number and right-align numeric columns.",
"Do start each section with the 12px uppercase muted h2 and stack sections with gap-8.",
"Do use signal only for live state (pulsing dots, running rows, running counts) and selection.",
"Do color kinds and statuses through kind-badge.svelte and status-pill.svelte; extend their maps rather than inventing new hue pairs.",
"Do give hover to interactive rows only, via row-hover; keep non-interactive rows still.",
"Do put secondary surfaces (rails, trees) on bg-sidebar/50 with a hairline, not on a card.",
"Do show every error as error-note.svelte with a retry and every empty list as empty-state.svelte with a one-line hint.",
"Do respect prefers-reduced-motion; new motion must be a CSS transition or one of animate-pulse-dot and animate-rise.",
"Do keep the luma primitives as shipped; compose them with size=\"sm\" and variant=\"outline\" or \"ghost\" for in-page actions.",
"Do write panel components (ui/src/lib/panel/) against token aliases and panel.css utilities only, and check them in both the admin and the plugin.",
"Do decide panel layout from the panel's own width (@container, bind:clientWidth); 768px is where the rail replaces the switcher.",
"Do add conversation actions to actions.svelte.ts so the header menu and the row menu stay one list."
],
"donts": [
"Don't put a card inside a card, or wrap ledger rows in a bordered container.",
"Don't add drop shadows to in-page surfaces; only overlays and the login card are lifted.",
"Don't use signal pink for headings, buttons, or static decoration; primary mulberry is the button color.",
"Don't introduce a heading size between the page headline (1rem) and the section label (0.75rem uppercase).",
"Don't render solid colored chips; chips are hue/12 behind hue text.",
"Don't use hue-chip, hairline, touch-shown or scrollbar-none from layout.css as if they were system rules - they are defined and currently unused by the admin.",
"Don't apply this palette inside Obsidian; the panel takes Obsidian's variables (PRODUCT.md brand commitment).",
"Don't expect pills inside Obsidian; every radius there is the host's (--button-radius, --input-radius, --radius-m).",
"Don't mark an active tab with bg-background in panel code; the plugin overrides it to ink at 14%.",
"Don't let a menu or dialog render inside the sidedock; overlays mount in .beaver-root.beaver-portal at --layer-menu."
]
}
}
@@ -1,13 +0,0 @@
---
version: 1
slug: "beaver-gateway-ui-src-lib-panel"
primary_target: "beaver-gateway/ui/src/lib/panel"
related_targets: ["beaver-gateway/ui/src/routes/panel","beaver-plugin-obsidian/src/ui"]
---
Scope: the Beaver panel as an Obsidian view (sidedock column, tab, mobile) and its browser twin at /admin/panel. Mode: Operate.
Audience and job: the operator (Kit) watching and steering the agent next to their notes; the task is to see what runs, answer, branch, send a branch back to Telegram, close a deep chat.
Constraints: shared components in ui/src/lib/panel, bundled into the plugin by esbuild ($lib alias, one Svelte runtime, svelte-sonner shimmed to Notice); container queries, never viewport breakpoints, because the same tree lives in a 300px column and a 1400px tab; Obsidian's variables only through .beaver-root (beaver-calendar is the reference); nothing pink inside Obsidian.
Direction: the agent's terminal, thread always on screen; below 48rem the switcher popover in the header, above it the grouped rail (master, branches, deep chats, jobs) on the left; one action list (actions.svelte.ts) behind the header menu and the row context menu; markdown through Obsidian's renderer with [[links]] opening notes.
Memorable moment: the panel follows the note you open and the running turn's tool tree grows in place.
Unresolved: a "new conversation" affordance in the panel; keyboard navigation beyond ↑/↓ and /; the pinned one-line summary when a branch returns to Telegram (§8.2) is not produced by the gateway yet.
+17 -13
View File
@@ -44,22 +44,26 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
# Bun native binary (glibc): `bunx` runs the stdio MCP servers declared in
# the user's config. The claude CLI itself ships inside the
# claude-agent-sdk wheel (`_bundled/claude`), nothing to install here.
# Bun native binary (glibc) — fast `npm install` replacement, only used
# to drop the claude CLI into the image. Not invoked at runtime.
COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
# The model process runs as `beaver-runner`, not as the gateway: the
# adapter spawns claude under this uid with a whitelisted environment.
# `acl` lets the entrypoint grant it write access to the vault sub-mounts
# without chowning files that Obsidian Sync keeps rewriting as root.
RUN apt-get update \
&& apt-get install -y --no-install-recommends acl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 1001 --create-home --shell /usr/sbin/nologin beaver-runner
ENV PATH=/app/.venv/bin:$PATH
# `--trust` is required: without it bun skips the postinstall step that
# fetches claude's native binary (anthropics/claude-code#50203). The
# postinstall itself is bun's smoke check — if it fails the layer
# fails. We deliberately DO NOT run `claude --version` here: claude
# touches `$HOME` on every invocation (creates `/root/.claude/`,
# `/root/.claude.json`, sometimes `/root/.config/claude/`), and those
# build-time artifacts seed the runtime named-volume `claude-home`
# with stale "haven't onboarded" state, so the user gets re-prompted
# for trust/bypass dialogs on every rebuild and the subscription auth
# can land on a tainted credential file.
ENV BUN_INSTALL=/usr/local/bun-global \
PATH=/usr/local/bun-global/bin:/app/.venv/bin:$PATH
RUN bun install -g --trust @anthropic-ai/claude-code \
&& test -x "$(command -v claude)" \
&& rm -rf /root/.claude /root/.claude.json /root/.config/claude
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app /app
+2 -2
View File
@@ -25,7 +25,7 @@ ui-build:
cd ui && bun run build
db:
docker compose -f ../beaver-agent/docker-compose.yml --project-directory ../beaver-agent --profile db up -d postgres
docker compose --project-directory ../beaver-agent --profile db up -d postgres
db-down:
docker compose -f ../beaver-agent/docker-compose.yml --project-directory ../beaver-agent --profile db down
docker compose --project-directory ../beaver-agent --profile db down
+2 -74
View File
@@ -1,74 +1,2 @@
# beaver-gateway
A gateway for personal agents built on the Claude Agent SDK: conversations
with a lifecycle, windows to talk through, jobs, memory hooks, and a policy
boundary - without an opinion about what the agent is for. A setup is one
`config.py` (or a package next to it) that assembles a `Gateway`; this repo
holds the primitives, [beaver-agent](https://git.kotikot.com/beaver/beaver-agent)
is a full setup built on them.
![The admin: what is in motion, what waits for an answer, the context and the quotas, the graph around the master](preview/shots/admin-now.webp)
## What it gives a setup
- **agents/** - what an agent is: a Claude agent with a prompt per
conversation kind, skill sets per kind, gateway tools, a `PreToolUse`
policy; a Raycast agent for cheap fast answers.
- **backends/** - how a turn runs: the Claude Agent SDK subprocess with a
session pool, transcripts and session stores; the Raycast wire.
- **conversations/** - the model of a conversation: kinds (master, branch,
deep, job, fork), rows and windows, the inject queue with priorities,
seeds, turns, questions, closing through a distiller, master rotation,
the envelope, the in-process gateway tools.
- **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects,
the subscription budget.
- **frontends/** - the windows: Telegram (master = General, topic = branch,
files out of the agent through `send_file`),
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`, and
`WebhookFrontend` for a window a setup declares itself - its own request
and response schema, its own agent, its own token scope
(`docs/FRONTEND-PLUGINS.md`).
- **mcp/** - MCP servers a setup declares (stdio, http, python tools),
aggregated in-process and handed to agents by name.
- **vault/** - watching a directory of notes for the envelope.
- **security/** - bearer tokens, the audit log, credential redaction.
- **events/** - the in-process bus every frontend taps, and the stream
event protocol backends emit.
- **storage/** - SQLModel tables and the Postgres session store.
`config.py` defines `Gateway` and loads a setup; `app.py` builds the runtime
from it; `cli.py` is the entrypoint. The admin SPA in `ui/` is the browser
window on all of it: conversations by day, the thread with the tree of tool
calls, the context of the last turn, memory, jobs, usage, tokens and the audit.
![A conversation in the admin: the thread, the queue, the details](preview/shots/admin-thread.webp)
![The Context tab: what the model held on the last turn - harness, prompt granules, skills, the notes the thread reached](preview/shots/admin-context.webp) Every string the gateway puts in front of
a model or a user has an English default and is overridable
(`ConversationTexts`, `TelegramTexts`).
## Run
```sh
cd examples
CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token) docker compose up --build
```
`examples/config.py` is the smallest setup that starts: one agent, one
python tool, one cron job, the built-in frontends on port 8000; the compose
file next to it adds Postgres. Without Docker: `uv sync`, build the SPA with
`cd ui && bun install && bun run build`, then run `uv run beaver-gateway` with
`DATABASE_URL` and `CONFIG_PATH` set. A setup's own secrets come from `.env`
next to its config (`load_dotenv` runs before the config is executed).
## Develop
```sh
make check # ruff format --check, ruff check, ty, pytest
make fix
```
No comments in code: modules carry a one-line docstring saying what they are
for, public configuration fields carry a one-line docstring saying what they
mean; everything else is the code.
# Beaver Gateway
[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net)
+198 -38
View File
@@ -1,64 +1,224 @@
"""The smallest setup: one agent, one tool, one job, the built-in frontends.
The loader execs this file with ``ClaudeAgent``, ``RaycastAgent``,
``McpServer``, ``ExposedMcp`` and ``Gateway`` already bound; the imports
below are for type checkers. A real setup grows into a package next to
this file (see beaver-agent).
"""
import logging
# Sample user config — grows alongside the implementation phases.
#
# Loader (beaver_gateway/config_loader.py) execs this file with
# ClaudeAgent, RaycastAgent, McpServer, ExposedMcp, Gateway already
# bound — so importing them is optional. We import explicitly here so
# IDEs and type-checkers see real symbols instead of free variables.
import tempfile
from datetime import UTC, datetime
from datetime import date
from pathlib import Path
from beaver_gateway.agents.base import ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent, Prompts
from beaver_gateway.config import Gateway
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
from beaver_gateway.core.registry import Gateway
from beaver_gateway.core.turn_record import TurnRecord, slugify
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.markdown import MarkdownFrontend
from beaver_gateway.frontends.mcp_server import McpServerFrontend
from beaver_gateway.jobs.scheduler import Job, JobRun
from beaver_gateway.mcp.types import McpServer
log = logging.getLogger("example")
VAULT = Path(tempfile.mkdtemp(prefix="beaver-vault-")).resolve()
PROMPT = VAULT / "assistant.md"
PROMPT.write_text(
"You are a concise assistant. When asked the time, call `current_time`.\n"
)
def chat_log_path(record: TurnRecord, vault: Path) -> Path:
"""Decide where a logged chat from another frontend lands in the vault.
Called by ``MarkdownFrontend`` (with ``log_all_chats=True``) the first
time a conversation needs a file — continuation turns are matched by
fingerprint and stick to the file picked here. Return value can be
absolute or relative; relative paths are anchored under ``vault``.
Layout below: ``<vault>/<YYYY-MM>/<YYYY-MM-DD>_<topic>.md`` where
``topic`` is a slug of the very first user message in the chat.
"""
today = date.today()
topic = slugify(record.first_user_text, maxlen=40)
return vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d}_{topic}.md"
def current_time() -> str:
return datetime.now(UTC).astimezone().isoformat()
"""Return the current local time as an ISO-8601 string.
Trivial demo tool for the Phase 2.1 internal MCP aggregator —
confirms a ``python_tool`` namespace is reachable on
``http://127.0.0.1:<INTERNAL_MCP_PORT>/mcp/time``.
"""
from datetime import datetime
async def heartbeat(run: JobRun) -> None:
master = await run.master()
log.info("heartbeat: master=%s", master.external_id if master else None)
return datetime.now().astimezone().isoformat()
gateway = Gateway(
agents=[
# Phase 2.2 — ClaudeCodeBackendAdapter routes this agent's
# ``/v1/messages`` calls through ``claude-code-api``. The
# ``time`` MCP gets exposed as ``mcp__time__current_time`` to
# the subscription claude session via
# ``BackendOptions.mcp_servers`` pointing at the internal
# aggregator on ``127.0.0.1:INTERNAL_MCP_PORT/mcp/time/``.
#
# Fresh empty tempdir (not a hardcoded ``/tmp``) for two
# reasons: claude-code-api derives the JSONL project-key from
# ``cwd``, but claude itself writes the JSONL using the cwd's
# realpath — on macOS ``/tmp`` and ``/var/folders/...`` are
# both ``/private/...`` symlinks, so unresolved cwds make
# ``JsonlWatcher`` time out waiting on the wrong path. The
# explicit ``.resolve()`` collapses the symlink before claude
# ever sees the dir, and ``mkdtemp`` guarantees the directory
# is empty so claude does not pick up leftover files.
ClaudeAgent(
name="assistant",
model="claude-sonnet-5",
cwd=VAULT,
prompts=Prompts(master=(PROMPT,), branch=(PROMPT,), deep=(PROMPT,)),
gateway_tools=("spawn", "say", "schedule"),
name="stub",
model="claude-sonnet-4-6",
# ``system_prompt`` is appended to claude's built-in agent
# prompt (via ``--append-system-prompt``) — so it adds the
# agent's identity on top of claude-code's baseline tool
# knowledge, rather than replacing it. Same shape as the
# RaycastAgent's ``system_prompt → additional_system_instructions``
# mapping. For full ``BackendOptions`` knobs (timeouts,
# extra_args, history mode, etc.) import ``ClaudeCodeOptions``
# and pass ``options=ClaudeCodeOptions(...)``.
system_prompt=(
"You are a stub agent used to validate the Phase 0 skeleton.\n"
"If asked the current time, call the `current_time`"
" MCP tool instead of guessing."
),
cwd=Path(tempfile.mkdtemp(prefix="beaver-stub-cwd-")).resolve(),
expose_mcps=(ExposedMcp(name="time"),),
)
),
# Phase 1.2 — a RaycastAgent the AnthropicMessagesFrontend will
# route via RaycastBackend. Phase 1.5 added the per-agent knobs
# (`temperature` / `additional_system_instructions` / etc.) —
# only `model`, `system_prompt`, and at least one of the others
# is mandatory.
RaycastAgent(
name="research",
model="Gemini 3.1 Flash Lite",
system_prompt=(
"You are a research assistant. "
"Reply in the user's language. Cite URLs when you use web search."
),
temperature=0.5,
available_native_tools=(RemoteTool.WEB_SEARCH, RemoteTool.READ_PAGE),
# Lambda so today's date is rebuilt on every request while
# locale/timezone stay pinned. ``True`` would give the same
# fresh-date behaviour but would also auto-pick host locale
# and timezone (``en-US`` / system tz), which isn't what we
# want here.
user_preferences=lambda: UserPreferences(
locale="en-GB",
timezone="Europe/Berlin",
current_date=date.today().isoformat(), # noqa: DTZ011 — local date is intended
),
),
],
mcps=[
# Phase 2.1 — bundle of plain Python callables exposed as one
# FastMCP namespace. The internal aggregator mounts it under
# ``/mcp/time`` on ``127.0.0.1:INTERNAL_MCP_PORT``; Phase 2.2's
# ClaudeCode adapter forwards that URL into
# ``BackendOptions.mcp_servers``. Phase 3's ``McpServerFrontend``
# reverse-proxies the same internal URL out to external clients.
McpServer.python_tool(name="time", tools=[current_time])
# Phase 3 — illustrates the ``lenient`` flag. Real-world stdio MCPs
# sometimes print "Processing..." or other chatter to stdout before
# their actual JSON-RPC frames; the default mcp client forwards
# those parse failures downstream as warnings (visible in
# Cursor/Cline). With ``lenient=True`` we silently drop non-JSON
# lines, so downstream UIs see clean JSON-RPC only. The command
# below is just a placeholder — replace with whatever stdio MCP
# you actually want gateway to ingest (e.g. an obsidian-mcp).
#
# Commented out by default: example users won't have the binary
# installed and an unreachable command makes ``docker compose up``
# surface a confusing "command not found" line at first request.
# Uncomment after pointing ``command`` at a real stdio MCP.
#
# McpServer.stdio(
# name="obsidian",
# command=["uvx", "mcp-obsidian"],
# env={"OBSIDIAN_API_KEY": "..."},
# lenient=True,
# ),
],
mcps=[McpServer.python_tool(name="time", tools=[current_time])],
frontends=[
AnthropicMessagesFrontend(),
McpServerFrontend(),
AdminFrontend(),
ApiFrontend(master_agent="assistant", branch_agent="assistant"),
MarkdownFrontend(vault_path=VAULT / "chats", default_agent="assistant"),
# Phase 1.4 — expose the agents as `model=<name>` on an
# Anthropic-compatible Messages endpoint. Auth comes from
# `BOOTSTRAP_TOKENS` in the env (`name1:value1,name2:value2`).
#
# Behind a reverse proxy (Caddy / nginx / Cloudflare) pass
# `public_base_url=` so the admin dashboard advertises the
# outside URL instead of `host:port`. Caddy strips its own
# prefix and the frontend's internal paths (`/v1/messages`,
# `/v1/models`) get appended:
# Caddy: handle_path /ai/* { reverse_proxy localhost:8000 }
# Config: AnthropicMessagesFrontend(
# port=8000,
# public_base_url="https://domain.com/ai")
# Result: https://domain.com/ai/v1/messages
AnthropicMessagesFrontend(host="0.0.0.0", port=8000),
# Phase 3 — re-exposes every declared `McpServer` outside the
# gateway with bearer auth + audit log. Each namespace lives
# at `/<name>/` on this port (the port itself disambiguates
# MCP traffic — no extra `/mcp` segment in the route); a flat
# bundle is published at `/all/`. Discovery page (HTML,
# auth-gated) at `/` with copy-pastable Cursor / Claude
# Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`.
#
# Same `public_base_url=` knob as above. Caddy strips its
# prefix; the frontend's `/<name>/` segment gets appended:
# Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 }
# Config: McpServerFrontend(
# port=8001,
# public_base_url="https://domain.com/mcp")
# Result: https://domain.com/mcp/<name>/ (and /mcp/all/)
McpServerFrontend(host="0.0.0.0", port=8001),
# Phase 4.3 — browser admin UI. Creds come from
# `ADMIN_USER`/`ADMIN_PASS`; the session cookie is signed with
# `SESSION_SECRET`. Use it to mint tokens (Argon2-hashed in
# the DB), revoke them, and watch the audit log. Scope is
# enforced on the bearer frontends: tokens minted with scope
# `messages` only work on `/v1/messages`; `mcp` only on
# `/mcp/<name>`; `*` works everywhere.
AdminFrontend(host="0.0.0.0", port=8002),
# Obsidian-vault chat frontend. Each `.md` is one conversation
# (User/Assistant turn pairs). The Obsidian companion plugin
# POSTs `{filename, content?}` to `/chat` — the frontend reads
# the file, runs the agent if the last turn is `user`, and
# appends the assistant reply back. With `log_all_chats=True`
# *every* turn (from Anthropic Messages too) is mirrored into
# `{vault}/_logs/<agent>/` so the vault is the central archive.
#
# `vault_path` here points at a per-restart tempdir so the
# example boots cleanly; in real deployments mount the
# Obsidian-sync container's vault volume to a stable path and
# pass that instead.
MarkdownFrontend(
host="0.0.0.0",
port=8003,
# Point at the dedicated chats subdir of your real Obsidian
# vault — the gateway has no idea (and no need) about other
# notes outside it. Path resolution / vault-escape checks
# are anchored here, so absolute-path attempts (and ``..``
# tricks) can't reach notes alongside it.
#
# vault_path=Path("/Users/me/Obsidian/Personal/chats"),
#
# Per-restart tempdir kept here so the example boots even
# without a real vault on the host.
vault_path=Path(tempfile.mkdtemp(prefix="beaver-vault-")).resolve(),
default_agent="research",
log_all_chats=True,
# ``log_path`` (optional) overrides the default
# ``{vault}/_logs/<agent>/<date>_<hex>.md`` layout for chats
# logged from OTHER frontends (Anthropic Messages, admin
# in-browser chat). Defined as a top-of-file function so the
# types are explicit and the IDE can hover them; a lambda
# works too, but a real ``def`` keeps the signature visible
# and lets you docstring it. Heads up: any custom path
# forces ``warm_index`` to scan the entire vault on startup
# so the fingerprint→file map survives a restart no matter
# where you put files.
log_path=chat_log_path,
),
],
jobs=[Job("heartbeat", heartbeat, cron="0 * * * *")],
tz="UTC",
)
+25 -27
View File
@@ -1,36 +1,34 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: beaver
POSTGRES_PASSWORD: beaver
POSTGRES_DB: beaver
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U beaver -d beaver"]
interval: 5s
timeout: 3s
retries: 20
# Minimal compose for the Phase 0 DoD: build the image, mount a
# single-file user config, watch the process print its summary line
# and exit cleanly.
#
# Real deployments add a postgres service, raycast-config.json bind,
# ~/.config/claude bind, etc. — Phase 5 lives in a separate example
# repo (see PLAN §5.1).
services:
gateway:
build:
context: ..
depends_on:
postgres:
condition: service_healthy
dockerfile: Dockerfile
environment:
CONFIG_PATH: /config/config.py
DATABASE_URL: postgresql+psycopg://beaver:beaver@postgres:5432/beaver
ADMIN_USER: admin
ADMIN_PASS: change-me
SESSION_SECRET: change-me-too
BOOTSTRAP_TOKENS: dev:change-me:*
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
DATABASE_URL: "sqlite:///./gateway.db"
ADMIN_USER: "admin"
ADMIN_PASS: "change-me"
SESSION_SECRET: "dev-secret-change-me"
# Phase 1.4 — comma-separated `name:value` pairs. The frontend
# 401s every request until at least one token is set.
BOOTSTRAP_TOKENS: "cursor:dev-token-change-me"
# RAYCAST_BEARER / RAYCAST_DEVICE_ID + a raycast.json bind go
# here once you wire up an actual RaycastAgent. The example
# config.py declares one, so set these (or remove the agent)
# before exposing port 8000.
ports:
# /v1/messages frontend
- "8000:8000"
# MCP server frontend
- "8001:8001"
# Admin UI (Phase 4.3) — change ADMIN_USER/ADMIN_PASS/SESSION_SECRET
- "8002:8002"
volumes:
- ./config.py:/config/config.py:ro
volumes:
postgres-data:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

+7 -10
View File
@@ -8,20 +8,16 @@ authors = [
requires-python = ">=3.13"
dependencies = [
"aiofile>=3.11.1",
"aiogram>=3.31.0",
"aiohttp>=3.13.5",
"aiosqlite>=0.22.1",
"anthropic>=0.103.0",
"anyio>=4.13.0",
"argon2-cffi>=25.1.0",
"claude-agent-sdk>=0.2.150",
"croniter>=6.2.4",
"fastapi>=0.136.1",
"fastmcp>=3.3.1",
"greenlet>=3.5.0",
"itsdangerous>=2.2.0",
"pgqueuer>=1.3.2",
"psutil>=7.2.2",
"jinja2>=3.1.6",
"psycopg[binary]>=3.3.4",
"pydantic>=2.13.4",
"pydantic-settings>=2.14.1",
@@ -29,15 +25,16 @@ dependencies = [
"sqlmodel>=0.0.38",
"uvicorn[standard]>=0.47.0",
"uvloop>=0.22.1",
"watchfiles>=1.2.0",
]
[project.optional-dependencies]
local = [
"raycast-api",
"claude-code-api",
]
prod = [
"raycast-api",
"claude-code-api",
]
[tool.uv]
@@ -50,6 +47,10 @@ raycast-api = [
{ path = "../raycast-api", editable = true, extra = "local" },
{ git = "https://git.kotikot.com/beaver/raycast-api.git", extra = "prod" },
]
claude-code-api = [
{ path = "../claude-code-api", editable = true, extra = "local" },
{ git = "https://git.kotikot.com/beaver/claude-code-api.git", extra = "prod" },
]
[project.scripts]
beaver-gateway = "beaver_gateway:main"
@@ -66,7 +67,3 @@ dev = [
"ruff>=0.15.13",
"ty>=0.0.37",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+2 -7
View File
@@ -2,7 +2,7 @@
``BaseAgent`` is the structural contract every backend-specific agent
honours. Subclasses add **capabilities as fields** (``ClaudeAgent.cwd``,
``RaycastAgent.streaming``) - backends dispatch on type, not on a runtime
``RaycastAgent.streaming``) — backends dispatch on type, not on a runtime
matrix.
"""
@@ -15,15 +15,10 @@ from pydantic import BaseModel, ConfigDict
@dataclass(frozen=True, slots=True)
class ExposedMcp:
"""Reference to an ``McpServer`` (by name) exposed to a single agent.
``tools`` allowlists tool names (``None`` = all); ``deny`` removes
``fnmatch`` patterns on top, e.g. ``("delete_*",)``.
"""
"""Reference to an ``McpServer`` (by name) exposed to a single agent."""
name: str
tools: tuple[str, ...] | None = None
deny: tuple[str, ...] = ()
class BaseAgent(BaseModel):
+230 -102
View File
@@ -1,130 +1,258 @@
"""Claude agent definition, backed by the Claude Agent SDK.
"""Claude Code agent definition.
``system_prompt`` is used verbatim, or per-kind granules from ``prompts``
are assembled via ``agents/prompts.py``. ``skill_sets`` are
``<skill>/SKILL.md`` directories, each becoming a local SDK plugin.
There is no ``streaming`` field, but there is
:attr:`ClaudeCodeOptions.include_partial_messages`, and the difference
matters. Token-level deltas are not a property of the agent, they are a
property of the transport: the PTY transport reads a JSONL that only
ever contains finished blocks, so it cannot stream no matter what the
agent asks for, while ``transport="stream_json"`` can. The flag lives
next to the transport that grants it rather than pretending to be a
free-standing capability.
``BaseAgent.system_prompt`` maps onto the claude CLI's
``--system-prompt`` — i.e. it really *is* the agent's system prompt,
not "added on top of claude-code's giant built-in". The additive slot
``--append-system-prompt`` is exposed via
:attr:`ClaudeCodeOptions.append_system_prompt` for the rare case
when the user wants claude-code's planning conventions / dynamic
sections *and* a delta on top. Difference, measured empirically:
* tools (names, JSON schemas, embedded guidance like Bash's "prefer
Read over cat/head/tail") survive both flags — they ride the
Anthropic API ``tools=[]`` field, not the system prompt text;
* ``--system-prompt`` drops ~8.6k tokens of claude-code's *textual*
baseline — agent persona, multi-step-work conventions ("use
TaskCreate proactively", etc.), and the dynamic per-machine sections
(cwd, env info, git status, memory paths — see the
``--exclude-dynamic-system-prompt-sections`` flag note in
``claude --help``: "Only applies with the default system prompt
(ignored with --system-prompt)").
We pick override as the default because it preserves the principle of
least surprise: the ``system_prompt=...`` you wrote on the agent is
what claude actually receives. If you need claude-code's full
planning/dynamic-context behaviour on top of your prompt, opt in via
``options=ClaudeCodeOptions(append_system_prompt=...)`` and leave
``system_prompt`` for your agent's identity (or vice versa — set
``system_prompt=""``-ish and put everything in ``append_*``).
Per-agent passthrough of ``claude_code_api.BackendOptions`` lives in
:class:`ClaudeCodeOptions`, attached to :class:`ClaudeAgent` as
``options``. Every tunable knob ``BackendOptions`` supports — except
the ones derived from the agent's own primary surface
(``cwd`` / ``model`` / ``system_prompt`` / ``available_native_tools`` →
``allowed_tools`` / ``expose_mcps`` → ``mcp_servers``) — is exposed
there. Defaults match upstream except where correctness demands
otherwise (``wait_for_turn_duration=True``,
``dangerously_skip_permissions=True``).
"""
from __future__ import annotations
from collections.abc import Mapping # noqa: TC003
from pathlib import Path # noqa: TC003
from collections.abc import Mapping # noqa: TC003 — pydantic runtime
from pathlib import Path # noqa: TC003 — pydantic runtime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.agents.policy import PolicyRule # noqa: TC001
from beaver_gateway.agents.prompts import PromptSource # noqa: TC001
from beaver_gateway.conversations.kinds import KINDS, Kind
__all__ = ["ClaudeAgent", "ClaudeOptions", "Prompts", "SkillSets"]
HistoryInjectionMode = Literal["native_jsonl", "concat_message"]
"""Mirrors ``claude_code_api.HistoryInjectionMode`` to avoid an import
cycle on the user-facing path (configs may be loaded without
``claude-code-api`` installed, e.g. ``--extra prod`` minus claude)."""
Transport = Literal["pty", "stream_json"]
"""Mirrors ``claude_code_api.Transport``, for the same reason."""
class ClaudeOptions(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
class ClaudeCodeOptions(BaseModel):
"""Per-agent passthrough for ``claude_code_api.BackendOptions``.
effort: str | None = None
include_partial_messages: bool = True
"""Token-level deltas on the wire; off means one delta per finished block."""
Mirrors every field of ``BackendOptions`` except those that come
from the agent's primary surface
(``cwd`` / ``model`` / ``system_prompt`` /
``available_native_tools`` / ``expose_mcps``). Defaults match
upstream, with two exceptions for correctness:
permission_mode: str = "bypassPermissions"
tools: tuple[str, ...] | None = None
"""Base set of built-in tools; ``None`` keeps the CLI default set."""
* ``wait_for_turn_duration=True`` — without it, extended-thinking
turns drop the text response because ``TurnManager`` returns on
the first terminal assistant record (the thinking snapshot) and
never reads the second one (the actual text). Phase 2.2 PROGRESS
describes the incident in detail.
* ``dangerously_skip_permissions=True`` — Beaver Gateway is a
single-user trusted-config product; the user already wrote the
``config.py`` that spawns claude. Permission prompts on a
headless backend mean stuck turns.
disallowed_tools: tuple[str, ...] = ()
add_dirs: tuple[str, ...] = ()
env: Mapping[str, str] = Field(default_factory=dict)
"""Extra environment for the claude subprocess (always passed through)."""
env_keep: tuple[str, ...] = ()
"""Extra inherited variable names to let through the env whitelist."""
max_turns: int | None = None
session_store_flush: str = "eager"
"""``eager`` mirrors every transcript frame as it lands, so a gateway
killed mid-turn loses at most the frame in flight."""
class Prompts(BaseModel):
"""Prompt assembly per conversation kind: the granules, in order.
A kind left ``None`` is not served; ``ClaudeAgent.kinds`` follows from
the kinds set here.
Any field on :class:`claude_code_api.BackendOptions` we forward
here lives on this model with the same name and type, so a future
``BackendOptions`` field addition is a one-liner here plus a
one-liner in :mod:`backends.claude_code`.
"""
model_config = ConfigDict(frozen=True)
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
master: tuple[PromptSource, ...] | None = None
branch: tuple[PromptSource, ...] | None = None
deep: tuple[PromptSource, ...] | None = None
job: tuple[PromptSource, ...] | None = None
fork: tuple[PromptSource, ...] | None = None
transport: Transport = "pty"
"""Which claude process backs this agent.
def for_kind(self, kind: Kind) -> tuple[PromptSource, ...] | None:
return getattr(self, kind)
``stream_json`` runs ``claude -p`` with stream-json on stdin and
stdout: prompts go into a pipe, events come back as they happen, and
a native ``result`` record closes each turn. ``pty`` (the default,
kept for backwards compatibility) drives the interactive TUI and
tails the session JSONL, which costs a multi-second readiness wait
per spawn — the thing ``startup_delay`` bounds — and brings the
swallowed-paste failure mode with it.
@property
def kinds(self) -> tuple[Kind, ...]:
return tuple(k for k in KINDS if self.for_kind(k) is not None)
Switching also makes the PTY-only knobs below inert
(``startup_delay`` / ``file_wait_timeout`` /
``turn_duration_timeout`` / ``wait_for_turn_duration``) and drops
interactive-only ``extra_args`` such as ``--remote-control``, which
would take claude out of print mode. Everything else — model,
prompts, tools, MCPs, history seeding — behaves identically."""
warmup_turn: bool | None = None
"""Spend a throwaway turn when a session spawns, so MCP tools are
live on the agent's first reply.
class SkillSets(BaseModel):
"""Skill-set directories per conversation kind; ``None`` means none."""
``None`` (the default) decides per agent: on when it exposes any
MCP, off when it doesn't. Set it explicitly to override.
model_config = ConfigDict(frozen=True)
It exists because the CLI begins connecting MCP servers when a turn
starts and doesn't wait for them — measured on the pi, turn one saw
29 tools with both servers ``pending``, turn two saw 90 with both
``connected``. For an agent whose whole job is those tools, a first
reply without them is worse than a couple of extra seconds. Nothing
cheaper works: sleeping longer changes nothing, and neither a
``/status`` command nor the SDK's ``initialize`` handshake touches
the MCP client.
master: tuple[Path, ...] | None = None
branch: tuple[Path, ...] | None = None
deep: tuple[Path, ...] | None = None
job: tuple[Path, ...] | None = None
fork: tuple[Path, ...] | None = None
``stream_json`` only — the PTY transport's readiness wait covers
this incidentally."""
def for_kind(self, kind: Kind) -> tuple[Path, ...]:
return getattr(self, kind) or ()
include_partial_messages: bool = False
"""Stream token-level deltas to the client instead of one delta per
finished block. Requires ``transport="stream_json"``; inert on the
PTY transport, whose JSONL contains only finished blocks. Off by
default because it multiplies the SSE event count — worth it for
interactive chat, pointless for a client that waits for the whole
message anyway."""
append_system_prompt: str | None = None
"""Maps to claude CLI's ``--append-system-prompt``. Opting in
re-attaches claude-code's full built-in prompt (persona, planning
conventions like "use TaskCreate proactively", and the dynamic
per-machine sections — cwd, env info, git status, memory paths)
plus this string on top. By default we ship only
:attr:`BaseAgent.system_prompt` via ``--system-prompt`` (~8.6k
tokens lighter); use this when the agent should behave like a
real claude-code coding session and your text is a delta on top
of those built-ins. Tool schemas survive either way — they ride
the Anthropic ``tools=[]`` channel, not the prompt text."""
disallowed_tools: tuple[str, ...] = ()
"""Tool names that claude must refuse to call. Combines with the
agent's ``available_native_tools`` allowlist — disallow wins."""
permission_mode: str = "bypassPermissions"
"""``claude --permission-mode`` value. Only meaningful when
``dangerously_skip_permissions=False``; otherwise the CLI bypasses
the prompt path entirely."""
dangerously_skip_permissions: bool = True
"""Pass ``--dangerously-skip-permissions`` to claude. ``True`` by
default — see the class docstring for why."""
effort: str | None = None
"""``claude --effort`` value (typically ``low`` / ``medium`` /
``high``). Tunes reasoning effort budget for newer models."""
add_dir: tuple[str, ...] = ()
"""Extra directories claude is allowed to read/edit beyond
``cwd``. Maps to repeated ``--add-dir`` flags."""
settings: str | None = None
"""Path to a ``--settings`` JSON file claude should load (hooks,
MCP servers, etc. that don't belong in our internal aggregator)."""
extra_args: tuple[str, ...] = ()
"""Raw extra argv to append to the claude command. Escape hatch
for flags we haven't surfaced as first-class options."""
extra_env: Mapping[str, str] = Field(default_factory=dict)
"""Additional environment variables for the spawned claude
process. Layered on top of the gateway's own env after
``preserve_provider_env`` is applied."""
preserve_provider_env: bool = False
"""When ``False`` (default), the spawn env strips
``ANTHROPIC_API_KEY`` / ``ANTHROPIC_AUTH_TOKEN`` /
``ANTHROPIC_BASE_URL`` so claude uses subscription auth instead of
leaking through whatever the gateway process inherited."""
history_injection_mode: HistoryInjectionMode = "native_jsonl"
"""How prior turns are seeded into a fresh session when no live
session matches: ``native_jsonl`` writes a hand-crafted transcript
and ``--resume``s; ``concat_message`` instead folds history into
the first user prompt. ``native_jsonl`` is more faithful."""
wait_for_turn_duration: bool = True
"""Keep reading JSONL until the ``turn_duration`` heartbeat
arrives, instead of returning on the first terminal assistant
record. ``True`` by default — see class docstring."""
include_meta_user: bool = False
"""Surface claude's ``isMeta=True`` user records (local-command
caveats) as ``UserMessage`` events. Off by default — they're not
part of the real conversation."""
startup_delay: float = 60.0
"""Upper bound (seconds) on waiting for claude's TUI to enable
bracketed-paste mode after spawn. claude-code-api polls PTY output
for the DECSET 2004 marker and returns as soon as it arrives, so a
generous cap costs nothing on a warm host — it only bounds the case
where the marker never shows.
It has to be generous, because timing out here means writing the
prompt into a terminal that hasn't enabled bracketed paste, where our
``ESC[200~`` framing is parsed as junk and the message is silently
dropped. Observed on a cold Raspberry Pi: 10s elapsed with *zero*
bytes of PTY output, the prompt went into the void, and the turn hung
until a human typed into the session by hand."""
file_wait_timeout: float = 30.0
"""How long to wait for the session JSONL to appear after spawn.
Failure here usually means a CLI auth / config problem."""
turn_duration_timeout: float = 5.0
"""How long to wait for the ``turn_duration`` heartbeat once a
terminal assistant has been seen. Bound on extra latency when
``wait_for_turn_duration=True``."""
idle_session_ttl: float = 1800.0
"""Seconds a pooled claude session may sit unused before it is
terminated. The backend pools live PTYs by conversation-history
fingerprint; when a conversation forks, the session behind the old
fingerprint becomes unreachable but stays resident — one live
``claude`` (hundreds of MB) each. 30 minutes keeps an active chat
warm across a coffee break while bounding what a fork storm can
strand. There is no count cap: concurrent conversations should all
stay warm. ``0`` disables reaping."""
class ClaudeAgent(BaseAgent):
"""Agent backed by ``claude-code-api``.
``cwd`` is the working directory the claude CLI is spawned in;
typed as ``Path`` (Pydantic coerces strings) since claude-code-api
accepts ``str | os.PathLike[str]``. ``available_native_tools`` stays
``tuple[str, ...]`` because Claude Code's tool set is a moving target
(Bash/Read/Edit/Write/WebSearch/etc. — versions add and rename), so
pinning it as a ``Literal`` would rot the type each release.
"""
cwd: Path
system_prompt: str = ""
"""Verbatim prompt for agents without ``prompts`` (tests, one-offs)."""
prompts: Prompts = Field(default_factory=Prompts)
kinds: tuple[Kind, ...] = ()
"""Conversation kinds this agent serves; ``create``/``spawn`` reject the rest.
Defaults to what ``prompts`` covers, or ``("deep",)`` for a verbatim prompt."""
skill_sets: tuple[Path, ...] | SkillSets = ()
"""Skill-set directories, each a local plugin: one tuple for every kind,
or a ``SkillSets`` giving each kind its own (``None`` = no skills)."""
gateway_tools: tuple[str, ...] = ()
"""Gateway tools exposed in-process (``read_conversation``, ``spawn``,
``say``, ``schedule``, ``inject``); empty = no gateway MCP server."""
options: ClaudeOptions = Field(default_factory=ClaudeOptions)
policy: tuple[PolicyRule, ...] = ()
"""``PreToolUse`` rules, run in order on every tool call; the first
``Deny`` is what the model reads back. See ``agents/policy.py``."""
@model_validator(mode="after")
def _kinds_follow_prompts(self) -> ClaudeAgent:
covered = self.prompts.kinds
if not self.kinds:
object.__setattr__(self, "kinds", covered or ("deep",))
elif covered:
missing = [k for k in self.kinds if k not in covered]
if missing:
msg = f"agent {self.name!r} serves {missing} without a prompt"
raise ValueError(msg)
return self
def prompt_for(self, kind: Kind) -> tuple[PromptSource, ...] | None:
return self.prompts.for_kind(kind)
def skills_for(self, kind: Kind) -> tuple[Path, ...]:
if isinstance(self.skill_sets, SkillSets):
return self.skill_sets.for_kind(kind)
return self.skill_sets
def serves(self, kind: str) -> bool:
return kind in self.kinds
available_native_tools: tuple[str, ...] = ()
options: ClaudeCodeOptions = Field(default_factory=ClaudeCodeOptions)
"""Per-agent passthrough for the underlying claude-code-api
``BackendOptions``. See :class:`ClaudeCodeOptions`."""
-167
View File
@@ -1,167 +0,0 @@
"""PreToolUse policy - the boundary without permission prompts.
A rule is a callable ``(ToolCall) -> Deny | None`` declared per agent
(``ClaudeAgent.policy``); the first :class:`Deny` becomes the tool result
the model reads back. A rule that raises is a deny too: fails closed.
"""
from __future__ import annotations
import inspect
import json
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, MutableMapping
__all__ = [
"AuditSink",
"Decision",
"Deny",
"PolicyRule",
"ToolAudit",
"ToolCall",
"brief",
"evaluate",
"hook_output",
]
_log = logging.getLogger("beaver_gateway.policy")
PATH_KEYS: tuple[str, ...] = ("file_path", "notebook_path", "path")
"""Input keys built-in file tools use for their target."""
Decision = Literal["allow", "deny"]
@dataclass(frozen=True, slots=True)
class Deny:
"""Refusal with the reason the model reads as the tool result."""
reason: str
@dataclass(frozen=True, slots=True)
class ToolCall:
"""One tool invocation as the hook sees it.
``state`` is a mutable per-session dict rules may write to (which
skills were opened, what was already asked); it lives as long as the
claude process and is empty again after a resume.
"""
tool: str
input: Mapping[str, Any]
agent: str
kind: str
conversation: str
cwd: Path
state: MutableMapping[str, Any] = field(default_factory=dict)
@property
def mcp(self) -> tuple[str, str] | None:
"""``(server, tool)`` for ``mcp__<server>__<tool>`` names, else ``None``."""
if not self.tool.startswith("mcp__"):
return None
_, _, rest = self.tool.partition("__")
server, sep, name = rest.partition("__")
return (server, name) if sep else (server, "")
def path(self, *keys: str) -> Path | None:
"""Target of a file tool, absolute; relative paths resolve against ``cwd``."""
for key in keys or PATH_KEYS:
raw = self.input.get(key)
if isinstance(raw, str) and raw:
return self.resolve(raw)
return None
def resolve(self, raw: str) -> Path:
"""Absolute path for ``raw``, normalised lexically.
Not via ``Path.resolve``, which would follow symlinks on the host,
not the model's view.
"""
path = Path(raw).expanduser()
if not path.is_absolute():
path = self.cwd / path
return Path(*_normalize(path.parts))
def _normalize(parts: Iterable[str]) -> list[str]:
out: list[str] = []
for part in parts:
if part == "..":
if len(out) > 1:
out.pop()
elif part != ".":
out.append(part)
return out
type PolicyRule = Callable[[ToolCall], Deny | None | Awaitable[Deny | None]]
"""A rule; sync or async. First ``Deny`` wins, ``None`` passes to the next."""
@dataclass(frozen=True, slots=True)
class ToolAudit:
agent: str
conversation: str
kind: str
tool: str
decision: Decision
reason: str | None
brief: str
AuditSink = "Callable[[ToolAudit], Awaitable[None]]"
async def evaluate(
rules: Iterable[Callable[[ToolCall], Any]], call: ToolCall
) -> Deny | None:
"""Run ``rules`` in order; the first deny wins, an exception is a deny."""
for rule in rules:
try:
verdict = rule(call)
if inspect.isawaitable(verdict):
verdict = await verdict
except Exception: # noqa: BLE001
name = getattr(rule, "__name__", repr(rule))
_log.exception("policy rule %s failed on %s", name, call.tool)
return Deny(reason=f"policy rule {name} failed; the call is refused")
if isinstance(verdict, Deny):
return verdict
return None
def hook_output(deny: Deny | None) -> dict[str, Any]:
"""``PreToolUse`` hook JSON for a verdict: empty means allow."""
if deny is None:
return {}
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": deny.reason,
}
}
def brief(tool_input: Mapping[str, Any], *, limit: int = 240) -> str:
"""Short, log-safe summary of a call: the path, the command, or the args."""
for key in (*PATH_KEYS, "command", "skill", "pattern", "query", "url"):
value = tool_input.get(key)
if isinstance(value, str) and value:
return _cut(value, limit)
try:
return _cut(json.dumps(tool_input, ensure_ascii=False, sort_keys=True), limit)
except (TypeError, ValueError):
return _cut(repr(tool_input), limit)
def _cut(text: str, limit: int) -> str:
return text if len(text) <= limit else text[: limit - 1] + "…"
-38
View File
@@ -1,38 +0,0 @@
"""System prompt assembly from a list of source files.
:func:`assemble` concatenates an agent's granules in order. A source is a
path, or a ``(tag, path)`` pair whose content is wrapped in ``<tag>...</tag>``.
"""
from __future__ import annotations
import hashlib
import logging
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable
__all__ = ["PromptSource", "assemble"]
_log = logging.getLogger("beaver_gateway.agents.prompts")
PromptSource = str | Path | tuple[str, str | Path]
def assemble(sources: Iterable[PromptSource]) -> str:
"""Concatenate ``sources`` in order into one system prompt."""
parts: list[str] = []
for source in sources:
tag, raw = source if isinstance(source, tuple) else (None, source)
path = Path(raw)
text = path.read_text(encoding="utf-8").strip()
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
_log.info(
"prompt granule %s tag=%s sha=%s bytes=%d", path, tag, digest, len(text)
)
if not text:
continue
parts.append(f"<{tag}>\n{text}\n</{tag}>" if tag else text)
return "\n\n".join(parts) + "\n"
+54 -12
View File
@@ -1,12 +1,31 @@
"""Raycast agent definition.
Field set is the union of ``raycast_api.ChatAPI.stream`` parameters useful
as per-agent defaults; per-request values override them where both exist.
Field set is the union of ``raycast_api.ChatAPI.stream`` parameters that
make sense as **per-agent defaults**. Per-request values (currently:
``temperature``) win when both are set; the rest fall back to whatever
the agent declared, then to Raycast's own defaults.
``BaseAgent.system_prompt`` maps onto Raycast's wire field
``additional_system_instructions`` (the slot the real client uses for
*content*); the wire field ``system_instructions`` stays at the Raycast
source default — ``"markdown"`` for ``AI_CHAT``, ``"plain"`` otherwise.
We don't expose that wire dichotomy to the user — they get one
conceptual "system prompt".
Excluded on purpose:
* ``buffer_id``/``message_id``/``current_date`` — per-call ephemeral
* ``provider`` override — escape hatch for non-catalog models, no clear
use case yet (revisit in PRD §14 when discovery lands)
* ``locale`` — process-wide via ``Settings.raycast_locale`` because we
only spin up one ``raycast_api.Client`` per gateway
* ``system_instructions`` (wire) — that's a format marker, not content;
the SDK fills it from the source default and we let it
"""
from __future__ import annotations
from raycast_api import ( # noqa: F401
from raycast_api import ( # noqa: F401 — UserPreferences re-exported for user configs
RemoteTool,
Source,
UserPreferences,
@@ -17,21 +36,44 @@ from beaver_gateway.agents.base import BaseAgent
class RaycastAgent(BaseAgent):
"""Agent backed by ``raycast-api``."""
"""Agent backed by ``raycast-api``.
``available_native_tools`` is the closed set of Raycast's server-side
"remote tools" (``web_search``, ``search_images``, ``read_page``) —
typed as ``RemoteTool`` so config-time IDE completion lists exactly
the three valid values. Pydantic also coerces string literals, so
``("web_search", "read_page")`` keeps working unchanged.
``user_preferences`` toggles the auto-generated ``<user-preferences>``
block Raycast prepends to ``additional_system_instructions``:
* ``True`` (default) → auto from host locale/timezone/today, rebuilt
every request so the date stays fresh;
* ``False`` → omit the block entirely;
* ``UserPreferences(...)`` instance → used verbatim (frozen at the
time the agent was loaded, so the date won't auto-update);
* ``Callable[[], UserPreferencesArg]`` → re-invoked on every request.
Use this for the common case "fresh date but custom
locale/timezone": ``user_preferences=lambda:
UserPreferences(locale="ru-RU", timezone="Europe/Berlin",
current_date=date.today().isoformat())``. Callables may nest
(a lambda returning a lambda…) but there's no real reason to.
The library uses this block for date/locale-aware formatting, not
for personalisation/memory — those are out of scope upstream.
``reasoning_effort`` values vary by model: GPT-5 takes
``"minimal"|"low"|"medium"|"high"``; Anthropic exposes nothing here
(Claude reasoning lives in a separate ``…-reasoning`` model variant
in the catalog). Unknown effort for the chosen model is ignored
server-side, so we stay loose as ``str | None``.
"""
streaming: bool = True
available_native_tools: tuple[RemoteTool, ...] = ()
"""Raycast's server-side "remote tools": ``web_search``, ``search_images``,
``read_page``."""
source: Source = Source.AI_CHAT
temperature: float | None = None
reasoning_effort: str | None = None
"""Model-specific (e.g. GPT-5: ``"minimal"``/``"low"``/``"medium"``/``"high"``);
ignored server-side if the model doesn't support it."""
tool_choice: str | None = None
user_preferences: UserPreferencesArg = True
"""Auto-fills the ``<user-preferences>`` block from host locale/timezone/date;
``False`` omits it, or pass ``UserPreferences(...)`` / a zero-arg callable."""
-447
View File
@@ -1,447 +0,0 @@
"""Build the runtime from a ``Gateway`` and run every part of it until shutdown."""
from __future__ import annotations
import asyncio
import functools
import logging
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any
import psycopg
import uvicorn
from pgqueuer import PsycopgDriver
from raycast_api import Client as RaycastClient
from raycast_api.config import Config as RaycastConfig
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.backends.claude_sdk import (
ClaudeSdkBackend,
RunnerConfig,
UsageEvent,
)
from beaver_gateway.backends.raycast import RaycastBackend
from beaver_gateway.backends.sessions import SessionPool
from beaver_gateway.conversations.envelope import Envelope
from beaver_gateway.conversations.rotation import Rotation, RotationPolicy
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.conversations.tools import build_tool_server
from beaver_gateway.events.bus import EventBus
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.frontends.root import build_root_app
from beaver_gateway.jobs.scheduler import Scheduler
from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.security.auth import TokenStore, scopes_with
from beaver_gateway.storage import (
Database,
PostgresSessionStore,
Usage,
append_audit,
append_usage,
)
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from claude_agent_sdk import McpSdkServerConfig
from fastmcp import FastMCP
from fastmcp.tools.base import Tool as FastMCPTool
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.types import ASGIApp
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.agents.policy import ToolAudit
from beaver_gateway.backends.base import Backend
from beaver_gateway.config import Gateway
from beaver_gateway.mcp.types import McpServerT
from beaver_gateway.settings import Settings
__all__ = ["AgentRegistry", "McpRegistry", "run"]
_log = logging.getLogger("beaver_gateway.app")
class AgentRegistry:
def __init__(self, agents: Iterable[BaseAgent]) -> None:
self._agents: dict[str, BaseAgent] = {}
for a in agents:
if a.name in self._agents:
msg = f"duplicate agent name: {a.name!r}"
raise ValueError(msg)
self._agents[a.name] = a
def __getitem__(self, name: str) -> BaseAgent:
return self._agents[name]
def get(self, name: str) -> BaseAgent | None:
return self._agents.get(name)
def __iter__(self) -> Iterator[BaseAgent]:
return iter(self._agents.values())
def __len__(self) -> int:
return len(self._agents)
def __contains__(self, name: object) -> bool:
return name in self._agents
class McpRegistry:
def __init__(self, mcps: Iterable[McpServerT]) -> None:
self._mcps: dict[str, McpServerT] = {}
for m in mcps:
if m.name in self._mcps:
msg = f"duplicate mcp name: {m.name!r}"
raise ValueError(msg)
self._mcps[m.name] = m
def __getitem__(self, name: str) -> McpServerT:
return self._mcps[name]
def get(self, name: str) -> McpServerT | None:
return self._mcps.get(name)
def __iter__(self) -> Iterator[McpServerT]:
return iter(self._mcps.values())
def __len__(self) -> int:
return len(self._mcps)
def __contains__(self, name: object) -> bool:
return name in self._mcps
async def run(gateway: Gateway, settings: Settings) -> None:
agents = AgentRegistry(gateway.agents)
mcps = McpRegistry(gateway.mcps)
db = Database(settings.database_url)
await db.create_all()
token_store = TokenStore(
db,
bootstrap=TokenStore.parse_bootstrap(settings.bootstrap_tokens),
bootstrap_scopes=TokenStore.parse_bootstrap_scopes(settings.bootstrap_tokens),
)
async with AsyncExitStack() as stack:
stack.push_async_callback(db.dispose)
await token_store.start()
stack.push_async_callback(token_store.stop)
internal_app, internal_urls, mcp_servers = _build_internal_mcp(
gateway.mcps, settings=settings
)
mcp_tools = await _prefetch_mcp_tools(mcp_servers)
pool = SessionPool()
bus = EventBus()
late = _LateConversations()
session_store = PostgresSessionStore(db)
backends = await _build_backends(
settings=settings,
agents=agents,
stack=stack,
db=db,
session_store=session_store,
mcp_internal_urls=internal_urls,
mcp_servers=mcp_servers,
mcp_tools=mcp_tools,
pool=pool,
late=late,
)
conversations = Conversations(
db=db,
agents=agents,
backends=backends,
bus=bus,
pool=pool,
store=session_store,
texts=gateway.texts,
frontends=gateway.frontends,
envelope=Envelope(
watch=gateway.watch, tz=gateway.tz, recall=gateway.recall
),
distiller=gateway.distiller,
user_sink=gateway.user_sink,
)
late.conversations = conversations
scheduler = Scheduler(
conversations=conversations,
jobs=gateway.jobs,
driver=await _pgqueuer_driver(settings.database_url, stack),
budget=gateway.budget,
rotation=Rotation(
conversations, gateway.rotation or RotationPolicy(tz=gateway.tz)
),
tz=gateway.tz,
)
conversations.scheduler = scheduler
runtime = GatewayRuntime(
agents=agents,
mcps=mcps,
backends=backends,
token_store=token_store,
db=db,
mcp_internal_urls=internal_urls,
admin_user=settings.admin_user,
admin_pass=settings.admin_pass,
session_secret=settings.session_secret,
frontends=tuple(gateway.frontends),
conversations=conversations,
bus=bus,
pool=pool,
scheduler=scheduler,
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
scopes=scopes_with(fe.scope for fe in gateway.frontends),
send_file_roots=_roots(settings.send_file_roots),
)
for fe in gateway.frontends:
fe.configure(runtime)
_log.info(
"beaver-gateway: loaded %d agents, %d mcps, %d frontends",
len(agents),
len(mcps),
len(gateway.frontends),
)
if not gateway.frontends:
return
await conversations.start()
stack.push_async_callback(conversations.stop)
await scheduler.start()
stack.push_async_callback(scheduler.stop)
hooks = scheduler.app(
functools.partial(_authorize_hook, runtime=runtime, scope="api")
)
async with asyncio.TaskGroup() as tg:
tg.create_task(pool.reap_loop())
if internal_app is not None:
tg.create_task(_serve_internal_mcp(internal_app, settings=settings))
tg.create_task(_serve_root(gateway, extra={"/hooks": hooks}))
if gateway.watch is not None:
tg.create_task(gateway.watch.run())
for fe in gateway.frontends:
tg.create_task(fe.serve())
async def _authorize_hook(
request: Request, *, runtime: GatewayRuntime, scope: str
) -> str:
return await require_token(request, runtime, scope=scope)
async def _pgqueuer_driver(url: str, stack: AsyncExitStack) -> PsycopgDriver | None:
plain = _plain_postgres_url(url)
if plain is None:
return None
conn = await psycopg.AsyncConnection.connect(plain, autocommit=True)
stack.push_async_callback(conn.close)
return PsycopgDriver(conn)
def _plain_postgres_url(url: str) -> str | None:
for prefix in ("postgresql+psycopg://", "postgresql://", "postgres://"):
if url.startswith(prefix):
return "postgresql://" + url[len(prefix) :]
return None
async def _serve_root(gateway: Gateway, *, extra: dict[str, ASGIApp]) -> None:
app = build_root_app(gateway.frontends, extra=extra)
config = uvicorn.Config(app, host=gateway.host, port=gateway.port, log_level="info")
_log.info(
"gateway on http://%s:%d - %s",
gateway.host,
gateway.port,
", ".join([*(fe.path for fe in gateway.frontends if fe.path), *extra])
or "no http frontends",
)
await uvicorn.Server(config).serve()
def _build_internal_mcp(
mcps: list[McpServerT], *, settings: Settings
) -> tuple[Starlette | None, dict[str, str], dict[str, FastMCP]]:
if not mcps:
return None, {}, {}
return build_internal_app(mcps, host="127.0.0.1", port=settings.internal_mcp_port)
async def _prefetch_mcp_tools(
servers: dict[str, FastMCP],
) -> dict[str, list[FastMCPTool]]:
out: dict[str, list[FastMCPTool]] = {}
for name, server in servers.items():
try:
out[name] = list(await server.list_tools())
except Exception: # noqa: BLE001
_log.exception("failed to list tools for MCP %r, skipping", name)
out[name] = []
return out
async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
config = uvicorn.Config(
app,
host="127.0.0.1",
port=settings.internal_mcp_port,
log_level="warning",
loop="uvloop",
)
_log.info(
"internal MCP aggregator on http://127.0.0.1:%d/mcp/<name>",
settings.internal_mcp_port,
)
await uvicorn.Server(config).serve()
def _roots(value: str) -> tuple[Path, ...]:
return tuple(Path(p) for p in value.split(":") if p.strip())
class _LateConversations:
conversations: Conversations | None = None
def server(
self, key: str, _kind: str, names: tuple[str, ...]
) -> McpSdkServerConfig | None:
if self.conversations is None or not names:
return None
return build_tool_server(self.conversations, conversation_key=key, names=names)
async def ask(self, key: str, payload: dict[str, Any]) -> str:
if self.conversations is None:
msg = "conversations service is not up yet"
raise RuntimeError(msg)
answer = await self.conversations.ask(key, payload)
return self.conversations.answer_text(answer)
async def _build_backends(
*,
settings: Settings,
agents: AgentRegistry,
stack: AsyncExitStack,
db: Database,
session_store: PostgresSessionStore,
mcp_internal_urls: dict[str, str],
mcp_servers: dict[str, FastMCP],
mcp_tools: dict[str, list[FastMCPTool]],
pool: SessionPool,
late: _LateConversations,
) -> dict[str, Backend]:
backends: dict[str, Backend] = {}
raycast_agents = [a for a in agents if isinstance(a, RaycastAgent)]
if raycast_agents:
client = await _try_open_raycast_client(settings, stack)
if client is not None:
raycast_backend = RaycastBackend(
client, mcp_servers=mcp_servers, mcp_tools=mcp_tools
)
for a in raycast_agents:
backends[a.name] = raycast_backend
runner = RunnerConfig(user=settings.claude_runner_user, home=settings.claude_home)
mcp_tool_names = {
name: [t.name for t in tools] for name, tools in mcp_tools.items()
}
async def record_usage(event: UsageEvent) -> None:
row = Usage(
agent_name=event.agent_name,
conversation_id=event.conversation_id,
session_id=event.session_id,
model=event.model,
effort=event.effort,
input_tokens=event.usage.input_tokens,
output_tokens=event.usage.output_tokens,
cache_read_tokens=event.usage.cache_read_tokens,
cache_creation_tokens=event.usage.cache_creation_tokens,
context_tokens=event.usage.context_tokens,
cost_usd=event.usage.cost_usd,
duration_ms=event.usage.duration_ms,
num_turns=event.usage.num_turns,
model_usage=event.usage.model_usage,
)
try:
async with db.session() as session:
await append_usage(session, row)
except Exception: # noqa: BLE001
_log.exception("usage write failed for %s", event.agent_name)
async def record_tool(event: ToolAudit) -> None:
detail = {
"conversation": event.conversation,
"kind": event.kind,
"tool": event.tool,
"decision": event.decision,
"reason": event.reason,
"brief": event.brief,
}
try:
async with db.session() as session:
await append_audit(
session,
actor=f"agent:{event.agent}",
kind="tool_call",
agent_name=event.agent,
detail=detail,
)
except Exception: # noqa: BLE001
_log.exception("tool audit write failed for %s", event.agent)
for a in agents:
if isinstance(a, ClaudeAgent):
adapter = ClaudeSdkBackend(
agent=a,
mcp_internal_urls=mcp_internal_urls,
session_store=session_store,
mcp_tool_names=mcp_tool_names,
runner=runner,
usage_sink=record_usage,
pool=pool,
tool_server=functools.partial(late.server, names=a.gateway_tools),
asker=late.ask,
audit_sink=record_tool,
)
await stack.enter_async_context(adapter)
backends[a.name] = adapter
return backends
async def _try_open_raycast_client(
settings: Settings, stack: AsyncExitStack
) -> RaycastClient | None:
if not settings.raycast_bearer:
_log.warning(
"RaycastAgent present but RAYCAST_BEARER is unset; those agents 503"
)
return None
if not settings.raycast_device_id:
_log.warning(
"RaycastAgent present but RAYCAST_DEVICE_ID is unset, those agents 503 "
"(generate once with `python -c 'import secrets; "
"print(secrets.token_hex(32))'`)"
)
return None
if not settings.raycast_config_path.exists():
_log.warning(
"RaycastAgent present but %s is missing, those agents 503",
settings.raycast_config_path,
)
return None
config = RaycastConfig.load(settings.raycast_config_path)
client = RaycastClient(
config=config,
bearer_token=settings.raycast_bearer,
device_id=settings.raycast_device_id,
locale=settings.raycast_locale,
)
return await stack.enter_async_context(client)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Backend adapters.
Each backend wraps a provider SDK (``raycast-api``, ``claude-agent-sdk``)
and yields the unified :class:`~beaver_gateway.events.stream.MessageStreamEvent`
Each backend wraps a provider-specific SDK (``raycast-api``, ``claude-code-api``)
and yields the unified :class:`~beaver_gateway.core.events.MessageStreamEvent`
family. The Anthropic-style frontend serialises events straight to SSE.
"""
+8 -12
View File
@@ -1,8 +1,12 @@
"""Backend protocol.
A backend turns an Anthropic-style turn (``messages`` + agent definition)
into a stream of :class:`~beaver_gateway.events.stream.MessageStreamEvent`
records; provider quirks are translated here, not in the frontend.
into a stream of :class:`~beaver_gateway.core.events.MessageStreamEvent`
records. The frontend serializes whatever comes out straight to SSE, so
backends are the only place where provider quirks are translated.
Implementations are plain :class:`typing.Protocol` conformers — no ABC
subclassing — to keep them swappable in tests with bare async generators.
"""
from __future__ import annotations
@@ -15,7 +19,7 @@ if TYPE_CHECKING:
from anthropic.types import MessageParam
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.events.stream import MessageStreamEvent
from beaver_gateway.core.events import MessageStreamEvent
class Backend(Protocol):
@@ -29,13 +33,5 @@ class Backend(Protocol):
system: str | None = None,
**options: Any,
) -> AsyncIterator[MessageStreamEvent]:
"""Yield Anthropic stream events for one turn against ``agent``.
``**options`` keys are optional and ignored by backends that don't
keep state: ``conversation_id`` (stable id the backend may pin a
live session to), ``session_id`` (backend session to resume when
nothing is live), ``capture`` (a
:class:`~beaver_gateway.backends.capture.TurnCapture` the backend
fills after the stream closes).
"""
"""Yield Anthropic stream events for one turn against ``agent``."""
...
-44
View File
@@ -1,44 +0,0 @@
"""Side channel for what a backend learned during one turn.
Frontends pass ``capture=TurnCapture()`` through ``Backend.complete``'s
``**options``; only backends that keep state per conversation fill it in.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
__all__ = ["TurnCapture", "TurnUsage"]
@dataclass(frozen=True, slots=True)
class TurnUsage:
input_tokens: int = 0
output_tokens: int = 0
cache_read_tokens: int = 0
cache_creation_tokens: int = 0
cost_usd: float | None = None
duration_ms: int | None = None
num_turns: int | None = None
model_usage: dict[str, Any] | None = None
"""``ResultMessage.model_usage`` verbatim: per-model tokens, cost, web searches."""
context_tokens: int = 0
"""Input of the last API call in the turn (fresh + cached + written to
cache) - the context size the model actually ran with, what Claude Code
shows as the context. The token fields above are sums over every API
call of the turn and grow with the number of tool calls."""
@dataclass
class TurnCapture:
synthesized_messages: list[dict[str, Any]] = field(default_factory=list)
"""The assistant/tool_result cycle of the turn as Anthropic messages."""
session_id: str | None = None
"""Backend session that ran the turn; persist and pass back as ``session_id``."""
usage: TurnUsage | None = None
interrupted: bool = False
"""The turn was cut by ``interrupt()`` (urgent inject), not by an error."""
+502
View File
@@ -0,0 +1,502 @@
"""Claude Code backend adapter.
One :class:`ClaudeCodeBackendAdapter` per :class:`ClaudeAgent`. The
underlying :class:`claude_code_api.ClaudeCodeBackend` bakes ``cwd`` /
``model`` / ``system_prompt`` / MCP wiring into a single
:class:`~claude_code_api.BackendOptions` at construction time, so a
single backend instance is conceptually bound to one agent (different
agents would mean different cwds / system prompts / exposed MCPs and
thus different live-session pools).
Per :meth:`complete` we:
* hand the full Anthropic-style ``messages`` list to
``ClaudeCodeBackend.complete`` — it does its own fingerprint-based
session lookup, so we never need to track sessions ourselves;
* turn its events into one ``message_start`` … ``message_stop``
envelope per ``complete`` call, with content-block indices increasing
monotonically across the whole turn;
* close the envelope on the ``ResultMessage``.
How the blocks inside that envelope are produced depends on the agent's
transport. By default each ``AssistantMessage`` is re-emitted as
``content_block_start`` + one delta + ``content_block_stop`` per block —
a "stream" of finished blocks, because the PTY transport has nothing
finer to offer. With ``transport="stream_json"`` *and*
``include_partial_messages``, the backend instead feeds us the real
token-level ``StreamEvent``s and we emit from those; the whole-block
records still arrive and are still kept for :class:`TurnCapture`, but
emitting from both would duplicate every block on the wire.
The per-request ``system`` parameter is intentionally **ignored** —
``BackendOptions.system_prompt`` is fixed at session-spawn time, and the
agent's ``system_prompt`` is the canonical identity of the agent.
"""
from __future__ import annotations
import json
import logging
import uuid
from collections.abc import Mapping # runtime import: isinstance in _emit_stream_event
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Self
from claude_code_api import (
AssistantMessage,
BackendOptions,
ClaudeCodeBackend,
ResultMessage,
StreamEvent,
TextBlock,
ThinkingBlock,
ToolUseBlock,
synthesize_turn_messages,
)
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.core.events import (
StopReason,
build_content_block_stop,
build_input_json_delta,
build_message_delta,
build_message_start,
build_message_stop,
build_signature_delta,
build_text_block_start,
build_text_delta,
build_thinking_block_start,
build_thinking_delta,
build_tool_use_block_start,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable
from anthropic.types import MessageParam
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.core.events import MessageStreamEvent
_log = logging.getLogger("beaver_gateway.backends.claude_code")
__all__ = ["ClaudeCodeBackendAdapter", "TurnCapture"]
@dataclass
class TurnCapture:
"""Side-channel sink for per-turn metadata.
Pass an instance via ``ClaudeCodeBackendAdapter.complete(capture=...)``.
After the stream finishes, :attr:`synthesized_messages` holds the
full assistant↔tool-result cycle (from
:func:`claude_code_api.synthesize_turn_messages`) — i.e. the exact
list of canonical Anthropic-shape messages claude-code-api stashed
the live session under. The markdown frontend uses this to write the
conversation history to its DB so a subsequent turn's prefix
fingerprint hits the same session.
Other backends (anthropic, raycast) ignore the kwarg — it lands in
their ``**options`` and is silently dropped.
"""
synthesized_messages: list[dict[str, Any]] = field(default_factory=list)
_CLAUDE_TO_ANTHROPIC_STOP: dict[str, StopReason] = {
"end_turn": "end_turn",
"tool_use": "tool_use",
"max_tokens": "max_tokens",
"stop_sequence": "stop_sequence",
"refusal": "refusal",
}
def _map_stop_reason(raw: str | None) -> StopReason:
"""Map claude-code's stop reason into Anthropic's vocabulary.
Unknown / missing values collapse to ``end_turn`` so the client sees
a clean finish rather than a wire-format error.
"""
if raw is None:
return "end_turn"
return _CLAUDE_TO_ANTHROPIC_STOP.get(raw, "end_turn")
def _build_mcp_servers(
agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
) -> dict[str, dict[str, Any]] | None:
"""Render ``agent.expose_mcps`` into ``BackendOptions.mcp_servers``.
Each exposed MCP is a streamable-HTTP pointer at the gateway's
internal aggregator (built by :mod:`beaver_gateway.mcp.internal_app`).
``None`` keeps claude-code from materializing an ``--mcp-config``
file when the agent exposes nothing.
"""
if not agent.expose_mcps:
return None
servers: dict[str, dict[str, Any]] = {}
for em in agent.expose_mcps:
url = mcp_internal_urls.get(em.name)
if url is None:
msg = (
f"agent {agent.name!r} exposes MCP {em.name!r} "
"but no internal URL is registered for it"
)
raise ValueError(msg)
servers[em.name] = {"type": "http", "url": url}
return servers
def _build_backend_options(
agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
) -> BackendOptions:
"""Compose the per-agent :class:`BackendOptions`.
Agent-primary fields:
* ``cwd`` / ``model`` come from the agent directly;
* ``system_prompt`` carries :attr:`BaseAgent.system_prompt`
verbatim — i.e. wire-level ``--system-prompt`` (~8.6k tokens
lighter than ``--append-system-prompt`` because claude-code's
persona/planning conventions and dynamic sections drop out;
tool schemas survive via the API ``tools=[]`` channel);
* ``append_system_prompt`` carries
:attr:`ClaudeCodeOptions.append_system_prompt`, normally
``None``. Setting it re-attaches claude-code's built-in prompt
*and* this delta — opt-in for "claude as a real coding session";
* ``allowed_tools`` follows the PLAN: when the user lists native
tools we restrict to those *plus* a per-MCP wildcard so MCP tools
stay reachable; when no native list is declared we leave
``allowed_tools`` empty (= all tools allowed by claude-code's
default);
* ``mcp_servers`` comes from :func:`_build_mcp_servers`.
Every other tunable knob is passed through from
:attr:`ClaudeAgent.options`. Our default overrides
(``wait_for_turn_duration=True``,
``dangerously_skip_permissions=True``) live on
:class:`ClaudeCodeOptions`, not here, so a user who builds
``ClaudeCodeOptions(...)`` explicitly inherits the same defaults
instead of getting whatever claude-code-api ships.
"""
allowed_tools: tuple[str, ...] = ()
if agent.available_native_tools:
mcp_wildcards = tuple(f"mcp__{em.name}" for em in agent.expose_mcps)
allowed_tools = tuple(agent.available_native_tools) + mcp_wildcards
opt = agent.options
return BackendOptions(
cwd=agent.cwd,
model=agent.model or None,
system_prompt=agent.system_prompt,
append_system_prompt=opt.append_system_prompt,
allowed_tools=allowed_tools,
mcp_servers=_build_mcp_servers(agent, mcp_internal_urls),
transport=opt.transport,
include_partial_messages=opt.include_partial_messages,
# ``None`` means "decide from the agent": an agent that exposes
# MCPs needs them on its first reply, one that doesn't shouldn't
# pay for a warm-up it gains nothing from.
warmup_turn=(
bool(agent.expose_mcps) if opt.warmup_turn is None else opt.warmup_turn
),
disallowed_tools=opt.disallowed_tools,
permission_mode=opt.permission_mode,
dangerously_skip_permissions=opt.dangerously_skip_permissions,
effort=opt.effort,
add_dir=opt.add_dir,
settings=opt.settings,
extra_args=opt.extra_args,
extra_env=opt.extra_env,
preserve_provider_env=opt.preserve_provider_env,
history_injection_mode=opt.history_injection_mode,
wait_for_turn_duration=opt.wait_for_turn_duration,
include_meta_user=opt.include_meta_user,
startup_delay=opt.startup_delay,
file_wait_timeout=opt.file_wait_timeout,
turn_duration_timeout=opt.turn_duration_timeout,
idle_session_ttl=opt.idle_session_ttl,
)
class ClaudeCodeBackendAdapter:
"""One ``claude-code-api`` backend bound to a single :class:`ClaudeAgent`.
Owns the underlying :class:`ClaudeCodeBackend`'s lifecycle through
the async-context-manager protocol so :mod:`beaver_gateway.cli` can
park it in its ``AsyncExitStack``.
"""
def __init__(
self, *, agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
) -> None:
self._agent = agent
options = _build_backend_options(agent, mcp_internal_urls)
self._backend = ClaudeCodeBackend(options)
self._streaming = (
options.transport == "stream_json" and options.include_partial_messages
)
"""Whether the backend feeds us token-level ``StreamEvent``s.
When it does, blocks are emitted from those and the whole-block
``AssistantMessage`` records are used only for bookkeeping —
emitting from both would duplicate every block on the wire."""
@property
def agent(self) -> ClaudeAgent:
return self._agent
@property
def live_session_count(self) -> int:
return self._backend.live_session_count
@property
def live_sessions(self) -> dict[str, Any]:
"""Live claude processes keyed by claude session_id.
Pass-through to the underlying ``ClaudeCodeBackend``. The value
is a ``PtyClaudeProcess`` or a ``StreamClaudeProcess`` depending
on the agent's transport; both implement the
``captured_output()`` / ``add_output_listener`` / ``write``
surface the admin terminal consumes, so it works either way —
with the caveat that the stream transport's "terminal" is a
read-only view of the JSON event stream rather than a TUI you
can type into. Typed as ``Any`` to avoid leaking the lower
layer's type into the gateway's public surface.
"""
return self._backend.live_sessions
async def __aenter__(self) -> Self:
await self._backend.__aenter__()
return self
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
await self._backend.__aexit__(exc_type, exc, tb)
async def aclose(self) -> None:
await self._backend.aclose()
async def complete(
self,
*,
agent: BaseAgent,
messages: Iterable[MessageParam],
system: str | None = None, # noqa: ARG002 — see module docstring
capture: TurnCapture | None = None,
**options: Any, # noqa: ARG002 — no per-request knobs for claude-code yet
) -> AsyncIterator[MessageStreamEvent]:
if not isinstance(agent, ClaudeAgent):
msg = (
"ClaudeCodeBackendAdapter requires ClaudeAgent, "
f"got {type(agent).__name__}"
)
raise TypeError(msg)
if agent.name != self._agent.name:
# Adapter is per-agent; routing a different agent through it
# would mean a different cwd / system_prompt / MCP set than
# the live-session pool was spawned with.
msg = (
f"ClaudeCodeBackendAdapter bound to {self._agent.name!r} "
f"got request for {agent.name!r}"
)
raise ValueError(msg)
msgs_list: list[Mapping[str, Any]] = list(messages)
_log.info(
"complete: agent=%s n_messages=%d capture=%s live_sessions=%d",
agent.name,
len(msgs_list),
capture is not None,
self._backend.live_session_count,
)
message_id = f"msg_{uuid.uuid4().hex}"
yield build_message_start(message_id=message_id, model=agent.model)
next_index = 0
stop_reason: str | None = None
usage: Mapping[str, Any] | None = None
n_text = 0
n_thinking = 0
n_tool_use = 0
# We keep raw events so we can hand them to
# ``synthesize_turn_messages`` after the stream closes — the
# markdown frontend stores the result in its conversation
# history so the next turn's prefix matches the backend's
# session-pool fingerprint. UserMessage (tool_result) events
# are silently discarded from the wire but kept here.
raw_events: list[Any] = []
async for event in self._backend.complete(msgs_list):
raw_events.append(event)
if isinstance(event, StreamEvent):
# Only ever arrives when ``self._streaming`` — the
# backend doesn't emit these otherwise. Indices are
# already rebased onto the whole turn by claude-code-api.
for ev in _emit_stream_event(event.event):
yield ev
elif isinstance(event, AssistantMessage):
for block in event.content:
if isinstance(block, TextBlock):
n_text += 1
elif isinstance(block, ThinkingBlock):
n_thinking += 1
elif isinstance(block, ToolUseBlock):
n_tool_use += 1
if not self._streaming:
for ev in _emit_block(block, next_index):
yield ev
next_index += 1
elif isinstance(event, ResultMessage):
# ResultMessage is the terminal event from TurnManager
# — we capture its stop_reason / usage for the envelope
# below. We DO NOT break here: an early break would
# raise GeneratorExit inside claude-code-api's
# ``complete`` coroutine before it gets a chance to
# stash the live session under the post-turn
# fingerprint, so every continuation would miss the
# cache and reseed. Let the inner generator exit
# naturally instead.
stop_reason = event.stop_reason
usage = event.usage
# UserMessage (tool_result records) and SystemMessage
# (turn_duration heartbeats) carry no content for the
# /v1/messages caller — skip silently on the wire, but they
# ARE retained in ``raw_events`` for synthesis below.
if capture is not None:
capture.synthesized_messages = synthesize_turn_messages(raw_events)
_log.info(
"complete: agent=%s DONE text=%d thinking=%d tool_use=%d stop=%s synth=%d",
agent.name,
n_text,
n_thinking,
n_tool_use,
stop_reason,
len(capture.synthesized_messages) if capture is not None else 0,
)
yield build_message_delta(
stop_reason=_map_stop_reason(stop_reason), usage=_normalize_usage(usage)
)
yield build_message_stop()
def _emit_stream_event(event: Mapping[str, Any]) -> Iterable[MessageStreamEvent]:
"""Render one raw Anthropic streaming event through our own builders.
The payload is already Anthropic-shaped, so forwarding it verbatim
is tempting — but it arrives as a plain dict and the frontend
serializes pydantic models, so it would have to be validated against
the SDK's ``RawMessageStreamEvent`` union anyway. Rebuilding it from
the fields we recognize is the same amount of work and fails the
same way :func:`_emit_block` already does: a block or delta type
this gateway doesn't know is dropped rather than raising mid-stream
on a claude release that added one.
Only ``content_block_*`` events reach here — claude-code-api strips
the inner per-request message envelopes, because the caller owns the
single envelope spanning the whole turn.
"""
etype = event.get("type")
index = event.get("index")
if not isinstance(index, int):
return ()
if etype == "content_block_start":
block = event.get("content_block")
if not isinstance(block, Mapping):
return ()
btype = block.get("type")
if btype == "text":
return (build_text_block_start(index),)
if btype == "thinking":
return (build_thinking_block_start(index),)
if btype == "tool_use":
return (
build_tool_use_block_start(
index,
tool_use_id=str(block.get("id", "")),
name=str(block.get("name", "")),
),
)
return ()
if etype == "content_block_delta":
delta = event.get("delta")
if not isinstance(delta, Mapping):
return ()
dtype = delta.get("type")
if dtype == "text_delta":
return (build_text_delta(index, str(delta.get("text", ""))),)
if dtype == "thinking_delta":
return (build_thinking_delta(index, str(delta.get("thinking", ""))),)
if dtype == "signature_delta":
return (build_signature_delta(index, str(delta.get("signature", ""))),)
if dtype == "input_json_delta":
return (build_input_json_delta(index, str(delta.get("partial_json", ""))),)
return ()
if etype == "content_block_stop":
return (build_content_block_stop(index),)
return ()
def _emit_block(
block: TextBlock | ThinkingBlock | ToolUseBlock | Any, index: int
) -> Iterable[MessageStreamEvent]:
"""Render one ``claude-code`` content block as Anthropic stream events.
``ToolResultBlock`` would arrive only on user-role records — we
don't emit it here because :meth:`complete` skips ``UserMessage``.
"""
if isinstance(block, TextBlock):
return (
build_text_block_start(index),
build_text_delta(index, block.text),
build_content_block_stop(index),
)
if isinstance(block, ThinkingBlock):
return (
build_thinking_block_start(index),
build_thinking_delta(index, block.thinking),
build_signature_delta(index, block.signature),
build_content_block_stop(index),
)
if isinstance(block, ToolUseBlock):
partial = json.dumps(block.input, separators=(",", ":"), ensure_ascii=False)
return (
build_tool_use_block_start(index, tool_use_id=block.id, name=block.name),
build_input_json_delta(index, partial),
build_content_block_stop(index),
)
return ()
def _normalize_usage(usage: Mapping[str, Any] | None) -> dict[str, int] | None:
"""Coerce claude-code's ``usage`` dict to Anthropic ``MessageDeltaUsage`` shape.
claude-code copies whatever the JSONL ``usage`` record carried —
fields can be missing, strings, or ints. We pass through only the
fields ``MessageDeltaUsage`` knows about and discard the rest so an
odd ``cache_creation`` object structure doesn't fail pydantic
validation downstream.
"""
if not usage:
return None
out: dict[str, int] = {}
for key in (
"input_tokens",
"output_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
):
value = usage.get(key)
if isinstance(value, int):
out[key] = value
return out or None
File diff suppressed because it is too large. Load diff
+89 -36
View File
@@ -1,13 +1,36 @@
"""Raycast backend adapter.
Translates between Anthropic's ``/v1/messages`` wire vocabulary and the
``raycast-api`` SDK, splicing MCP tools into Raycast's ``tool_calls`` and
routing them back in-process where the agent declares ``expose_mcps``.
Translates between Anthropic's ``/v1/messages`` wire vocabulary (incoming
``MessageParam`` history, outgoing ``MessageStreamEvent`` SSE) and the
``raycast-api`` SDK (``Message`` history, ``ChatStreamChunk`` SSE).
Two halves live here:
* :func:`_to_raycast_messages` — pure conversion of an Anthropic message
list into ``list[raycast_api.Message]``. ``tool_result`` blocks carry no
tool name in Anthropic; we recover it by remembering each ``tool_use``
id we saw upstream.
* :meth:`RaycastBackend.complete` — opens a ``client.chat.stream`` and
walks chunks through a tiny block-state machine. The state machine
exists only because Raycast streams ``tool_calls`` in three phases
(open with id+name, deltas with empty id, final summary with the full
``arguments``) — Anthropic wants one ``content_block_start`` → deltas
→ ``content_block_stop`` per block, so we de-duplicate the final
summary against the per-delta increments already emitted.
MCP wiring: Raycast has no native MCP concept, so when an agent declares
``expose_mcps`` we splice each MCP's tools into the wire request as
``Tool.local(name=f"{mcp}__{tool}", ...)`` and run a gateway-internal
loop. Every time the model emits a tool_call for one of those local
tools we route it back to the underlying MCP in-process, append the
result as a ``tool`` message, and re-issue the stream — all inside one
Anthropic envelope (one ``message_start`` … one ``message_stop``). The
tool_use blocks DO surface to the caller (mirrors what
``ClaudeCodeBackendAdapter`` does), but tool_results stay internal.
"""
from __future__ import annotations
import fnmatch
import json
import logging
import uuid
@@ -18,7 +41,7 @@ from raycast_api import Message as RaycastMessage
from raycast_api import RemoteTool, Tool, ToolCall
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.events.stream import (
from beaver_gateway.core.events import (
StopReason,
build_content_block_stop,
build_input_json_delta,
@@ -41,7 +64,7 @@ if TYPE_CHECKING:
from raycast_api import ChatStreamChunk, Client
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.events.stream import MessageStreamEvent
from beaver_gateway.core.events import MessageStreamEvent
else:
from collections.abc import Mapping
@@ -52,9 +75,11 @@ __all__ = ["RaycastBackend"]
_log = logging.getLogger("beaver_gateway.backends.raycast")
# Cap on consecutive tool-call turns inside one Anthropic envelope.
# Real conversations rarely chain more than a handful; the limit only
# fires on a model that loops, and surfaces as a clean ``end_turn``
# with an error tool_result rather than a hang.
_MAX_TOOL_TURNS = 20
"""Cap on consecutive tool-call turns inside one Anthropic envelope; a model
that loops hits this and gets a clean ``end_turn`` instead of a hang."""
_RAYCAST_TO_ANTHROPIC_STOP: dict[str, StopReason] = {
@@ -272,8 +297,6 @@ def _build_agent_catalog(
for mt in mcp_tools.get(em.name, []):
if em.tools is not None and mt.name not in em.tools:
continue
if any(fnmatch.fnmatchcase(mt.name, pat) for pat in em.deny):
continue
wire_name = f"{em.name}__{mt.name}"
routing[wire_name] = (em.name, mt.name)
local_tools.append(
@@ -366,7 +389,7 @@ class _BlockState:
def __init__(self) -> None:
self.index: int = -1
self.kind: str | None = None
self.kind: str | None = None # "text" | "thinking" | "tool_use" | None
self.tool_id_to_block: dict[str, int] = {}
self.tool_idx_to_id: dict[int, str] = {}
@@ -402,14 +425,11 @@ class RaycastBackend:
self._client = client
self._mcp_servers: Mapping[str, FastMCP] = mcp_servers or {}
self._mcp_tools: Mapping[str, list[FastMCPTool]] = mcp_tools or {}
# Cached per-agent catalog — agents are immutable, so a single
# render at first use covers the gateway's lifetime.
self._agent_catalog: dict[str, _AgentToolCatalog] = {}
def _catalog_for(self, agent: RaycastAgent) -> _AgentToolCatalog:
"""Build (or reuse) the tool catalog for ``agent``.
Agents are immutable, so a single render at first use covers the
gateway's lifetime.
"""
cached = self._agent_catalog.get(agent.name)
if cached is not None:
return cached
@@ -425,26 +445,31 @@ class RaycastBackend:
system: str | None = None,
**options: Any,
) -> AsyncIterator[MessageStreamEvent]:
"""Run one turn against ``agent``, yielding Anthropic stream events.
On the wire Raycast uses ``system_instructions`` as a format marker
(``"markdown"``/``"plain"``, filled from the source default when we
pass ``None``) and ``additional_system_instructions`` for the actual
prompt content; ``system`` (or ``agent.system_prompt``) flows into
the latter.
"""
if not isinstance(agent, RaycastAgent):
msg = f"RaycastBackend requires RaycastAgent, got {type(agent).__name__}"
raise TypeError(msg)
raycast_messages = _to_raycast_messages(messages)
catalog = self._catalog_for(agent)
# Native remote tools + spliced MCP locals. ``None`` keeps the
# SDK from sending a ``tools`` field at all when neither is
# declared.
tools_arg: list[Tool | RemoteTool | str] | None = (
list(catalog.tools) if catalog.tools else None
)
# On the wire Raycast uses ``system_instructions`` as a format
# marker (``"markdown"`` for AI_CHAT, ``"plain"`` otherwise —
# filled in by the SDK from the source default when we pass
# ``None``) and ``additional_system_instructions`` as the actual
# prompt content. So our ``system_prompt`` (or the per-request
# Anthropic ``system``, if present) flows into the *additional*
# slot. The SDK still prepends ``<user-preferences>`` to whatever
# we hand it via ``_build_preamble``.
prompt_content = system if system is not None else agent.system_prompt
# Per-request options win over agent defaults; agent defaults
# win over Raycast SDK defaults. ``None`` means "fall back".
async for event in self._stream(
agent=agent,
raycast_messages=raycast_messages,
@@ -471,16 +496,12 @@ class RaycastBackend:
reasoning_effort: str | None,
tool_choice: str | None,
) -> AsyncIterator[MessageStreamEvent]:
"""Yield one Anthropic envelope spanning one or more ``chat.stream`` calls.
A tool_call not in ``mcp_routing`` is left to bubble out as a
regular ``tool_use`` block instead, for the caller to answer with a
``tool_result`` the usual way.
"""
message_id = f"msg_{uuid.uuid4().hex}"
yield build_message_start(message_id=message_id, model=agent.model)
state = _BlockState()
# ``working_messages`` is the rolling history fed back to
# Raycast as we resolve MCP tool_calls turn by turn.
working_messages = list(raycast_messages)
last_usage: dict[str, int] | None = None
last_finish: str | None = None
@@ -493,6 +514,9 @@ class RaycastBackend:
model=agent.model,
messages=working_messages,
source=agent.source,
# ``system_instructions=None`` → SDK substitutes the
# source default (``"markdown"`` / ``"plain"``). Real
# prompt goes into ``additional_system_instructions``.
additional_system_instructions=prompt_content,
user_preferences=agent.user_preferences,
tools=tools,
@@ -513,16 +537,27 @@ class RaycastBackend:
if acc.usage:
last_usage = acc.usage
# Figure out which (if any) tool_calls land on our MCP
# routing table. Anything not in the table is left to bubble
# out of the envelope as a regular tool_use block — the
# Anthropic caller can then respond with a tool_result the
# usual way, and the next ``complete`` invocation will
# carry it back in.
pending_mcp_calls = [
tid for tid in acc.tool_order if acc.tool_names.get(tid) in mcp_routing
]
if not pending_mcp_calls:
break
# Close whatever block is still open before we step into
# tool execution — the next turn's chunks start a fresh
# block sequence.
if state.kind is not None:
yield build_content_block_stop(state.index)
state.kind = None
# Echo the assistant turn so Raycast sees its own reply +
# tool_calls in subsequent context.
assistant_text = "".join(acc.text_parts)
assistant_tool_calls = [
ToolCall(
@@ -538,6 +573,10 @@ class RaycastBackend:
)
)
# Dispatch each MCP-routed call and append a ``tool``
# message. Calls not in the routing table get a placeholder
# error so the model can correct itself rather than the
# gateway hanging the conversation.
for tid in acc.tool_order:
tool_name = acc.tool_names.get(tid, "")
args_str = "".join(acc.tool_args.get(tid, [])) or "{}"
@@ -546,12 +585,16 @@ class RaycastBackend:
tool_name=tool_name, args_json=args_str, mcp_routing=mcp_routing
)
else:
# Non-MCP tool — shouldn't really happen because we
# haven't surfaced any other locals, but defend.
result_text = f"Tool {tool_name!r} is not handled by the gateway."
working_messages.append(
RaycastMessage.tool(
tool_call_id=tid, name=tool_name, result=result_text
)
)
# Loop: re-stream with the new history.
else:
_log.warning(
"raycast tool-call loop hit %d-turn cap for agent %r; "
@@ -560,6 +603,7 @@ class RaycastBackend:
agent.name,
)
# Close whatever block is still open before the final delta.
if state.kind is not None:
yield build_content_block_stop(state.index)
state.kind = None
@@ -669,12 +713,6 @@ class RaycastBackend:
the final-summary chunk's arguments string is dropped because the
deltas already streamed it.
Providers disagree on how ``arguments`` arrives: streaming ones
(GPT) send deltas across chunks and then restate the full string in
a final summary chunk (skip it, already streamed); non-streaming
ones (Gemini) send it only in that final summary (emit it once,
since nothing streamed it first).
Side-effects on ``acc`` mirror what gets emitted to the wire so
the gateway can rebuild a full ``ToolCall`` for the next Raycast
turn (it needs the joined ``arguments`` JSON string, which the
@@ -687,6 +725,9 @@ class RaycastBackend:
raw_tc = raw_tcs[i] if i < len(raw_tcs) else {}
idx_field = raw_tc.get("index") if isinstance(raw_tc, dict) else None
# Resolve this entry to a tool-id key, mirroring
# `raycast_api.ChatResult._merge_tool_calls`. Phase 1 carries
# id+index, phase 2 only index, phase 3 only id.
tool_id: str | None = None
if tc.id:
tool_id = tc.id
@@ -699,6 +740,7 @@ class RaycastBackend:
block_idx = state.tool_id_to_block.get(tool_id)
if block_idx is None:
# New tool_use block. Close any open text/thinking block first.
if state.kind is not None:
events.append(build_content_block_stop(state.index))
state.index += 1
@@ -711,11 +753,22 @@ class RaycastBackend:
block_idx, tool_use_id=tool_id, name=tc.name or ""
)
)
# Streaming providers (GPT) deliver arguments as deltas
# across chunks and re-state the full string in a final
# summary chunk; non-streaming-args providers (Gemini)
# only send args in the final summary. Emit args in
# both cases when this is the first appearance — final
# summary then IS the full string.
if tc.arguments:
events.append(build_input_json_delta(block_idx, tc.arguments))
acc.add_tool_args(tool_id, tc.arguments)
continue
# Existing block. Final summary chunks restate the full args
# string; if we already streamed deltas, that restatement is
# a duplicate (skip). If we streamed nothing (the streaming
# provider didn't send mid-arg deltas — Gemini path again),
# the summary IS the args — emit it once.
if is_final_summary:
if tc.arguments and not acc.tool_args.get(tool_id):
events.append(build_input_json_delta(block_idx, tc.arguments))
-283
View File
@@ -1,283 +0,0 @@
"""Pool of live Agent SDK sessions across every Claude agent.
One :class:`Session` is one ``ClaudeSDKClient`` (one claude subprocess). The
pool decides when a session is closed for idleness (TTL by conversation
kind) and which one goes when memory runs out (RSS of the subprocess tree
against the cgroup limit, or ``max_live`` with no limit).
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
import psutil
if TYPE_CHECKING:
from collections.abc import (
AsyncIterable,
AsyncIterator,
Callable,
Iterator,
Mapping,
)
__all__ = ["DEFAULT_TTL", "Session", "SessionClient", "SessionPool", "cgroup_limit"]
_log = logging.getLogger("beaver_gateway.backends.sessions")
DEFAULT_TTL: Mapping[str, float | None] = {
"master": None,
"branch": 7200.0,
"deep": 1800.0,
"job": 0.0,
"fork": 0.0,
}
"""Idle seconds before a session is closed; ``None`` = never (pinned kinds)."""
_EVICT_ORDER = {"fork": 0, "job": 0, "deep": 1, "branch": 2, "master": 3}
_RSS_HEADROOM = 0.8
class SessionClient(Protocol):
async def connect(self) -> None: ...
async def query(self, prompt: str | AsyncIterable[dict[str, Any]]) -> None: ...
def receive_messages(self) -> AsyncIterator[Any]: ...
async def interrupt(self) -> None: ...
async def disconnect(self) -> None: ...
@dataclass
class Session:
key: str
agent: str
kind: str
client: SessionClient
session_id: str | None
resumed: bool
pinned: bool = False
dirty: bool = False
running_turn: str | None = None
pending_question: bool = False
interrupt_requested: bool = False
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
last_used: float = field(default_factory=time.monotonic)
created_at: float = field(default_factory=time.monotonic)
turns: int = 0
state: dict[str, Any] = field(default_factory=dict)
"""Scratch for policy rules (``agents/policy.py``); dies with the process."""
reader: asyncio.Task[None] | None = None
"""Reads the client for the session's whole life, not only during a turn."""
inbox: asyncio.Queue[Any] = field(default_factory=asyncio.Queue)
"""Where the reader puts messages while a turn is attached."""
pending: list[Any] = field(default_factory=list)
"""A turn the CLI started on its own (a background task reported back),
buffered until a consumer attaches."""
attached: bool = False
woken: bool = False
note: str | None = None
"""Summary of the last task notification, the pending turn's prompt."""
observer: Callable[[Any], None] | None = None
"""The last turn's observer; sees subagent traffic between turns."""
@property
def busy(self) -> bool:
return self.lock.locked() or self.running_turn is not None
@property
def evictable(self) -> bool:
"""Never evict a pinned (master) session or a ``dirty`` one.
``dirty`` means a mirror gap not yet repaired.
"""
return not (self.pinned or self.dirty or self.busy or self.pending_question)
@property
def pid(self) -> int | None:
transport = getattr(self.client, "_transport", None)
process = getattr(transport, "_process", None)
pid = getattr(process, "pid", None)
return pid if isinstance(pid, int) else None
class SessionPool:
def __init__(
self,
*,
rss_limit: int | None = None,
max_live: int = 8,
ttl: Mapping[str, float | None] = DEFAULT_TTL,
reap_interval: float = 60.0,
) -> None:
self._sessions: dict[str, Session] = {}
self._rss_limit = rss_limit if rss_limit is not None else cgroup_limit()
self._max_live = max_live
self._ttl = dict(ttl)
self._reap_interval = reap_interval
def __len__(self) -> int:
return len(self._sessions)
def __iter__(self) -> Iterator[Session]:
return iter(list(self._sessions.values()))
def __contains__(self, key: object) -> bool:
return key in self._sessions
@property
def rss_limit(self) -> int | None:
return self._rss_limit
def get(self, key: str) -> Session | None:
return self._sessions.get(key)
def add(self, session: Session) -> Session:
self._sessions[session.key] = session
return session
def pop(self, key: str) -> Session | None:
return self._sessions.pop(key, None)
def rekey(self, old: str, new: str) -> Session | None:
session = self._sessions.pop(old, None)
if session is None:
return None
stale = self._sessions.pop(new, None)
session.key = new
self._sessions[new] = session
return stale if stale is not session else None
def ttl_for(self, kind: str) -> float | None:
return self._ttl.get(kind, self._ttl.get("deep"))
def rss(self) -> int:
try:
children = psutil.Process(os.getpid()).children(recursive=True)
except psutil.Error:
return 0
total = 0
for child in children:
with contextlib.suppress(psutil.Error):
total += child.memory_info().rss
return total
@staticmethod
def rss_of(session: Session) -> int | None:
pid = session.pid
if pid is None:
return None
try:
process = psutil.Process(pid)
return process.memory_info().rss + sum(
c.memory_info().rss for c in process.children(recursive=True)
)
except psutil.Error:
return None
def over_limit(self) -> bool:
if self._rss_limit is not None:
return self.rss() > self._rss_limit * _RSS_HEADROOM
return len(self._sessions) >= self._max_live
def victims(self) -> list[Session]:
"""Evictable sessions, forks/jobs first, then oldest idle."""
candidates = [s for s in self._sessions.values() if s.evictable]
candidates.sort(key=lambda s: (_EVICT_ORDER.get(s.kind, 1), s.last_used))
return candidates
async def make_room(self) -> int:
closed = 0
while self.over_limit():
victims = self.victims()
if not victims:
_log.warning(
"session pool over limit (%d live, rss=%d) but nothing evictable",
len(self._sessions),
self.rss(),
)
break
await self.close(victims[0].key)
closed += 1
return closed
async def close(self, key: str) -> None:
session = self._sessions.pop(key, None)
if session is None:
return
_log.info("closing session %s (%s, %s)", session.session_id, session.kind, key)
if session.reader is not None and session.reader is not asyncio.current_task():
session.reader.cancel()
try:
await session.client.disconnect()
except Exception: # noqa: BLE001
_log.exception("disconnect failed for session %s", session.session_id)
async def close_all(self, *, agent: str | None = None) -> None:
for session in list(self._sessions.values()):
if agent is None or session.agent == agent:
await self.close(session.key)
async def reap_once(self) -> int:
now = time.monotonic()
closed = 0
for session in list(self._sessions.values()):
ttl = self.ttl_for(session.kind)
if ttl is None or not session.evictable:
continue
if now - session.last_used > ttl:
await self.close(session.key)
closed += 1
return closed
async def reap_loop(self) -> None:
while True:
await asyncio.sleep(self._reap_interval)
try:
await self.reap_once()
await self.make_room()
except Exception: # noqa: BLE001
_log.exception("session reaper failed")
def snapshot(self) -> list[dict[str, Any]]:
now = time.monotonic()
return [
{
"key": s.key,
"agent": s.agent,
"kind": s.kind,
"session_id": s.session_id,
"pid": s.pid,
"rss": self.rss_of(s),
"idle_seconds": round(now - s.last_used, 1),
"age_seconds": round(now - s.created_at, 1),
"turns": s.turns,
"busy": s.busy,
"running_turn": s.running_turn,
"pending": len(s.pending),
"pending_question": s.pending_question,
"pinned": s.pinned,
"dirty": s.dirty,
}
for s in self._sessions.values()
]
def cgroup_limit() -> int | None:
for path in (
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
):
try:
raw = Path(path).read_text(encoding="ascii").strip()
except OSError:
continue
if raw.isdigit() and int(raw) < 1 << 60:
return int(raw)
return None
-554
View File
@@ -1,554 +0,0 @@
"""Anthropic messages <-> Agent SDK transcript entries.
:func:`build_entries` renders a message list as entries the Claude CLI
itself writes; :func:`messages_from_entries` is the projection back.
"""
from __future__ import annotations
import hashlib
import uuid as _uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
try:
from claude_agent_sdk._cli_version import __cli_version__
_cli_version = str(__cli_version__)
except ImportError: # pragma: no cover
_cli_version = "2.1.248"
if TYPE_CHECKING:
from collections.abc import Iterable
__all__ = [
"CLI_VERSION",
"build_entries",
"close_open_tool_uses",
"fingerprint",
"messages_from_entries",
"open_tool_uses",
"prompt_count",
"render_messages",
"strip_tool_entries",
"text_of",
"window_entries",
]
CLI_VERSION = _cli_version
_ENTRYPOINT = "sdk-py"
_USER_TYPE = "external"
_PROMPT_SOURCE = "sdk"
_GIT_BRANCH = "HEAD"
def build_entries(
messages: Iterable[Mapping[str, Any]],
*,
session_id: str,
cwd: str,
model: str,
permission_mode: str = "bypassPermissions",
now: datetime | None = None,
) -> list[dict[str, Any]]:
"""Render ``messages`` as entries a session store can resume from.
One ``user`` entry per prompt, one ``assistant`` entry per content
block sharing a message id, one ``user`` entry per ``tool_result``
parented on the matching ``tool_use``. Appending the result to a
session store and resuming that session id seeds external history
into the SDK.
"""
stamp = (now or datetime.now(UTC)).strftime("%Y-%m-%dT%H:%M:%S.") + (
f"{(now or datetime.now(UTC)).microsecond // 1000:03d}Z"
)
common = {
"isSidechain": False,
"userType": _USER_TYPE,
"entrypoint": _ENTRYPOINT,
"cwd": cwd,
"sessionId": session_id,
"version": CLI_VERSION,
"gitBranch": _GIT_BRANCH,
}
entries: list[dict[str, Any]] = []
parent: str | None = None
prompt_id: str | None = None
tool_use_owner: dict[str, str] = {}
for message in messages:
role = message.get("role")
content = message.get("content")
if role == "user":
results = _tool_results(content)
if results:
for block in results:
owner = tool_use_owner.get(
str(block.get("tool_use_id", "")), parent
)
uid = _new_uuid()
entries.append(
{
"parentUuid": owner,
"promptId": prompt_id,
"type": "user",
"message": {"role": "user", "content": [block]},
"uuid": uid,
"timestamp": stamp,
"toolUseResult": _result_text(block),
"sourceToolAssistantUUID": owner,
**common,
}
)
parent = uid
continue
prompt_id = _new_uuid()
uid = _new_uuid()
entries.append(
{
"parentUuid": parent,
"promptId": prompt_id,
"type": "user",
"message": {"role": "user", "content": _user_content(content)},
"uuid": uid,
"timestamp": stamp,
"permissionMode": permission_mode,
"promptSource": _PROMPT_SOURCE,
**common,
}
)
parent = uid
elif role == "assistant":
blocks = _assistant_blocks(content)
message_id = f"msg_{_uuid.uuid4().hex[:24]}"
request_id = f"req_{_uuid.uuid4().hex[:24]}"
stop_reason = (
"tool_use"
if any(b.get("type") == "tool_use" for b in blocks)
else "end_turn"
)
for block in blocks:
uid = _new_uuid()
entries.append(
{
"parentUuid": parent,
"message": {
"model": model,
"id": message_id,
"type": "message",
"role": "assistant",
"content": [block],
"stop_reason": stop_reason,
"stop_sequence": None,
"stop_details": None,
"usage": _zero_usage(),
"diagnostics": None,
},
"requestId": request_id,
"type": "assistant",
"uuid": uid,
"timestamp": stamp,
**common,
}
)
if block.get("type") == "tool_use" and block.get("id"):
tool_use_owner[str(block["id"])] = uid
parent = uid
else:
msg = f"message role must be user or assistant, got {role!r}"
raise ValueError(msg)
return entries
def messages_from_entries(
entries: Iterable[Mapping[str, Any]], *, with_ts: bool = False
) -> list[dict[str, Any]]:
"""Messages as the model saw them; ``with_ts`` adds when each one was written."""
out: list[dict[str, Any]] = []
last_message_id: str | None = None
for entry in entries:
kind = entry.get("type")
message = entry.get("message")
if kind not in ("user", "assistant") or not isinstance(message, dict):
continue
content = message.get("content")
if kind == "user":
results = _tool_results(content)
if (
results
and out
and out[-1]["role"] == "user"
and _tool_results(out[-1]["content"])
):
out[-1]["content"].extend(results)
else:
message_out: dict[str, Any] = {
"role": "user",
"content": _user_content(content),
}
if with_ts:
message_out["ts"] = entry.get("timestamp")
out.append(message_out)
last_message_id = None
continue
blocks = _assistant_blocks(content)
message_id = message.get("id")
if (
out
and out[-1]["role"] == "assistant"
and message_id is not None
and message_id == last_message_id
):
out[-1]["content"].extend(blocks)
else:
message_out: dict[str, Any] = {"role": "assistant", "content": blocks}
if with_ts:
message_out["ts"] = entry.get("timestamp")
out.append(message_out)
last_message_id = message_id
return out
def _new_uuid() -> str:
return str(_uuid.uuid4())
def _tool_results(content: Any) -> list[dict[str, Any]]:
if not isinstance(content, list):
return []
return [
_clean_block(b)
for b in content
if isinstance(b, dict) and b.get("type") == "tool_result"
]
def _user_content(content: Any) -> Any:
if isinstance(content, str):
return content
if isinstance(content, list):
return [_clean_block(b) for b in content if isinstance(b, dict)]
msg = f"user content must be str or list, got {type(content).__name__}"
raise TypeError(msg)
def _assistant_blocks(content: Any) -> list[dict[str, Any]]:
if isinstance(content, str):
return [{"type": "text", "text": content}]
if isinstance(content, list):
blocks = [_clean_block(b) for b in content if isinstance(b, dict)]
if blocks:
return blocks
msg = "assistant content must be a non-empty list of blocks"
raise ValueError(msg)
def _clean_block(block: dict[str, Any]) -> dict[str, Any]:
kind = block.get("type")
if kind == "tool_use":
return {
"type": "tool_use",
"id": block.get("id", ""),
"name": block.get("name", ""),
"input": block.get("input", {}),
}
if kind == "tool_result":
out: dict[str, Any] = {
"type": "tool_result",
"tool_use_id": block.get("tool_use_id", ""),
"content": block.get("content", ""),
}
if block.get("is_error") is not None:
out["is_error"] = bool(block["is_error"])
return out
if kind == "thinking":
return {
"type": "thinking",
"thinking": block.get("thinking", ""),
"signature": block.get("signature", ""),
}
if kind == "text":
return {"type": "text", "text": str(block.get("text", ""))}
return dict(block)
def _result_text(block: dict[str, Any]) -> str:
content = block.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
str(b.get("text", ""))
for b in content
if isinstance(b, dict) and b.get("type") == "text"
)
return str(content)
def _zero_usage() -> dict[str, Any]:
return {
"input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 0,
"service_tier": "standard",
"server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0},
"cache_creation": {
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 0,
},
"inference_geo": "not_available",
"iterations": [],
"speed": "standard",
}
_PROMPT_TYPES = ("user", "assistant")
_INTERRUPTED = "interrupted"
def open_tool_uses(
entries: Iterable[Mapping[str, Any]],
) -> list[tuple[Mapping[str, Any], dict[str, Any]]]:
"""``(assistant entry, tool_use block)`` pairs that never got a ``tool_result``."""
closed: set[str] = set()
uses: list[tuple[Mapping[str, Any], dict[str, Any]]] = []
for entry in entries:
content = _entry_content(entry)
if entry.get("type") == "user":
closed.update(
str(b.get("tool_use_id", ""))
for b in content
if b.get("type") == "tool_result"
)
elif entry.get("type") == "assistant":
uses.extend((entry, b) for b in content if b.get("type") == "tool_use")
return [
(owner, block)
for owner, block in uses
if str(block.get("id", "")) not in closed
]
def close_open_tool_uses(
entries: list[Mapping[str, Any]], *, text: str = _INTERRUPTED
) -> list[dict[str, Any]]:
"""Synthetic ``tool_result`` entries on the leaf, one per open ``tool_use``.
Appending the result to the session store gives the next ``resume`` a
transcript the CLI accepts: an assistant message ending in ``tool_use``
without its result is rejected by the API on the next call.
"""
pending = open_tool_uses(entries)
if not pending:
return []
leaf = next(
(
e
for e in reversed(entries)
if e.get("type") in _PROMPT_TYPES and e.get("uuid")
),
None,
)
parent = str(leaf["uuid"]) if leaf is not None else None
stamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
out: list[dict[str, Any]] = []
for owner, block in pending:
uid = _new_uuid()
result = {
"type": "tool_result",
"tool_use_id": block.get("id", ""),
"content": text,
"is_error": True,
}
out.append(
{
"parentUuid": parent,
"promptId": owner.get("promptId"),
"type": "user",
"message": {"role": "user", "content": [result]},
"uuid": uid,
"timestamp": stamp,
"toolUseResult": text,
"sourceToolAssistantUUID": owner.get("uuid"),
**{k: owner[k] for k in _COMMON_KEYS if k in owner},
}
)
parent = uid
return out
_COMMON_KEYS = (
"isSidechain",
"userType",
"entrypoint",
"cwd",
"sessionId",
"version",
"gitBranch",
)
def window_entries(
entries: Iterable[Mapping[str, Any]], *, window: int | None = None
) -> list[dict[str, Any]]:
"""``user``/``assistant`` entries only, last ``window`` prompts, relinked."""
kept = [
dict(e)
for e in entries
if e.get("type") in _PROMPT_TYPES and isinstance(e.get("uuid"), str)
]
if window is not None and window > 0:
starts = [i for i, e in enumerate(kept) if _is_prompt_entry(e)]
if len(starts) > window:
kept = kept[starts[-window] :]
return _relink(kept)
def strip_tool_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""Drop ``tool_result`` entries and ``tool_use``/``thinking`` blocks; relink."""
out: list[dict[str, Any]] = []
for raw in entries:
entry = dict(raw)
message = entry.get("message")
if not isinstance(message, dict):
out.append(entry)
continue
content = message.get("content")
if isinstance(content, list):
blocks = [
b
for b in content
if isinstance(b, dict)
and b.get("type") not in ("tool_use", "tool_result", "thinking")
]
if not blocks:
continue
entry["message"] = {**message, "content": blocks}
out.append(entry)
return _relink(out)
def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int:
return sum(1 for e in entries if _is_prompt_entry(e))
def render_messages(
messages: Iterable[Mapping[str, Any]], *, window: int | None = None
) -> str:
"""Plain-text projection for ``read_conversation``: ``user:``/``assistant:`` turns.
Tool calls collapse to a one-line summary per assistant turn; tool
results and thinking are dropped.
"""
turns: list[str] = []
tools: list[str] = []
current: list[str] = []
def flush() -> None:
if not current and not tools:
return
body = "\n\n".join(current).strip()
if tools:
body = (body + "\n" if body else "") + "(tools: " + ", ".join(tools) + ")"
turns.append("assistant:\n" + body)
current.clear()
tools.clear()
for message in messages:
role = message.get("role")
content = message.get("content")
if role == "user":
if _tool_results(content):
continue
flush()
turns.append("user:\n" + _text_of_content(content))
continue
for block in _assistant_blocks(content):
if block.get("type") == "text" and block.get("text"):
current.append(str(block["text"]))
elif block.get("type") == "tool_use":
tools.append(str(block.get("name", "")))
flush()
if window is not None and window > 0:
starts = [i for i, t in enumerate(turns) if t.startswith("user:")]
if len(starts) > window:
turns = turns[starts[-window] :]
return "\n\n".join(turns)
def _entry_content(entry: Mapping[str, Any]) -> list[dict[str, Any]]:
message = entry.get("message")
if not isinstance(message, dict):
return []
content = message.get("content")
if not isinstance(content, list):
return []
return [b for b in content if isinstance(b, dict)]
def _is_prompt_entry(entry: Mapping[str, Any]) -> bool:
if entry.get("type") != "user":
return False
message = entry.get("message")
if not isinstance(message, dict):
return False
content = message.get("content")
if isinstance(content, str):
return True
return not _tool_results(content)
def _relink(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
parent: str | None = None
for entry in entries:
entry["parentUuid"] = parent
parent = entry.get("uuid")
return entries
def _text_of_content(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n\n".join(
str(b.get("text", ""))
for b in content
if isinstance(b, dict) and b.get("type") == "text" and b.get("text")
)
return ""
def fingerprint(messages: Iterable[Mapping[str, Any]]) -> str:
"""Text-only hash of a history; stateless callers are keyed by it."""
turns: list[tuple[str, str]] = []
for message in messages:
text = text_of(message.get("content"))
if not text:
continue
role = str(message.get("role", ""))
if turns and turns[-1][0] == role:
turns[-1] = (role, turns[-1][1] + "\n" + text)
else:
turns.append((role, text))
digest = hashlib.sha1(usedforsecurity=False)
for role, text in turns:
digest.update(role.encode("utf-8"))
digest.update(b"\x00")
digest.update(text.strip().encode("utf-8"))
digest.update(b"\x01")
return digest.hexdigest()
def text_of(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [
str(b.get("text", ""))
for b in content
if isinstance(b, Mapping) and b.get("type") == "text"
]
return "\n".join(p for p in parts if p)
return ""
+305 -14
View File
@@ -1,4 +1,22 @@
"""Process entrypoint: logging, signals, ``.env``, the config, then ``app.run``."""
"""Process entrypoint.
Phase 1.4 — async ``main``: install uvloop, load the user config, build
registries + per-agent backends (only ``RaycastBackend`` so far), wire
each frontend with a ``GatewayRuntime``, and run all
``frontend.serve()`` coroutines concurrently. Without any frontends we
still print the Phase 0 DoD line and exit cleanly so the bare skeleton
keeps working.
Phase 2.1 — when the user declares any ``McpServer``, we additionally
build the internal MCP aggregator app and run it on
``127.0.0.1:INTERNAL_MCP_PORT`` as another task inside the same
TaskGroup. URLs are surfaced through ``GatewayRuntime.mcp_internal_urls``
so Phase 2.2's ClaudeCode adapter can find them.
Phase 3 — the same aggregator backs the external ``McpServerFrontend``;
``cli`` doesn't have to know that the frontend reverse-proxies into it,
it just keeps the aggregator running for anyone who needs it.
"""
from __future__ import annotations
@@ -6,36 +24,309 @@ import asyncio
import contextlib
import logging
import signal
from contextlib import AsyncExitStack
from typing import TYPE_CHECKING
import uvicorn
import uvloop
from dotenv import load_dotenv
from raycast_api import Client as RaycastClient
from raycast_api.config import Config as RaycastConfig
from beaver_gateway import app, config
from beaver_gateway.security.redact import install as install_redaction
from beaver_gateway.security.redact import load_secrets as load_secrets_to_mask
from beaver_gateway import config_loader
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.backends.claude_code import ClaudeCodeBackendAdapter
from beaver_gateway.backends.raycast import RaycastBackend
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.settings import Settings
from beaver_gateway.storage import Database
if TYPE_CHECKING:
from fastmcp import FastMCP
from fastmcp.tools.base import Tool as FastMCPTool
from starlette.applications import Starlette
from beaver_gateway.backends.base import Backend
from beaver_gateway.mcp.types import McpServerT
_log = logging.getLogger("beaver_gateway.cli")
def main() -> None:
"""Sync wrapper: uvloop loop factory + asyncio.run."""
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
install_redaction()
_sigterm_as_interrupt()
asyncio.run(_run(), loop_factory=uvloop.new_event_loop)
_install_sigterm_handler()
asyncio.run(_async_main(), loop_factory=uvloop.new_event_loop)
async def _run() -> None:
load_dotenv(override=False)
load_secrets_to_mask()
settings = Settings() # ty: ignore[missing-argument]
gateway = config.load(settings.config_path)
await app.run(gateway, settings)
def _install_sigterm_handler() -> None:
"""Turn SIGTERM into a normal interpreter exit.
Python's default SIGTERM disposition kills the process outright, so
neither ``AsyncExitStack`` unwinding nor ``atexit`` hooks run. That
matters because every ``claude`` we spawn lives in its own session
(ptyprocess calls ``setsid``), which makes it immune to the signal
that took us down — a hard SIGTERM leaves one orphaned CLI per live
session, each holding hundreds of MB. Raising ``KeyboardInterrupt``
instead routes ``docker stop`` / ``systemctl stop`` through the same
shutdown path as Ctrl-C, which does reap them.
Only installed when we own the main thread's signal handlers; under
an embedding host that isn't ours to take.
"""
def _sigterm_as_interrupt() -> None:
def _raise_interrupt(_signum: int, _frame: object) -> None:
raise KeyboardInterrupt
with contextlib.suppress(ValueError, OSError):
signal.signal(signal.SIGTERM, _raise_interrupt)
async def _async_main() -> None:
# Populate ``os.environ`` from ``.env`` before anything else so the
# user's ``config.py`` can read its own secrets via ``os.environ[...]``
# (Firefly PAT, third-party MCP creds, etc.). ``Settings`` already
# reads ``.env`` independently via pydantic-settings, but that path
# populates Settings fields, not the process environment.
# ``override=False``: real env vars (Docker, systemd) win over .env.
load_dotenv(override=False)
settings = Settings() # ty: ignore[missing-argument]
gateway = config_loader.load(settings.config_path)
agents = AgentRegistry(gateway.agents)
mcps = McpRegistry(gateway.mcps)
# Phase 4.1 — open the async DB and run create_all once. Engine
# pool is process-wide; ``dispose()`` after the TaskGroup unwinds.
db = Database(settings.database_url)
await db.create_all()
# Phase 4.2 — TokenStore now reads from the DB (in-memory cache
# primed at start, TTL-refreshed, last_used_at flushed by a
# background task). BOOTSTRAP_TOKENS layers on top so first-run /
# examples still work without DB writes.
token_store = TokenStore(
db, bootstrap=TokenStore.parse_bootstrap(settings.bootstrap_tokens)
)
async with AsyncExitStack() as stack:
stack.push_async_callback(db.dispose)
await token_store.start()
stack.push_async_callback(token_store.stop)
# Internal MCP URLs must exist before we construct any
# ClaudeCodeBackendAdapter — adapters bake the URLs into their
# ``BackendOptions.mcp_servers`` at construction time. The
# ``mcp_servers`` map is used by the Raycast backend, which
# needs in-process ``list_tools`` / ``call_tool`` access (the
# Raycast wire has no native MCP concept).
internal_app, internal_urls, mcp_servers = _build_internal_mcp(
gateway.mcps, settings=settings
)
# Prefetch tool catalogs for every MCP so RaycastAgent requests
# don't pay a per-turn list_tools roundtrip and so a broken MCP
# surfaces at startup instead of mid-conversation.
mcp_tools = await _prefetch_mcp_tools(mcp_servers)
backends: dict[str, Backend] = await _build_backends(
settings=settings,
agents=agents,
stack=stack,
mcp_internal_urls=internal_urls,
mcp_servers=mcp_servers,
mcp_tools=mcp_tools,
)
runtime = GatewayRuntime(
agents=agents,
mcps=mcps,
backends=backends,
token_store=token_store,
db=db,
mcp_internal_urls=internal_urls,
admin_user=settings.admin_user,
admin_pass=settings.admin_pass,
session_secret=settings.session_secret,
frontends=tuple(gateway.frontends),
)
for fe in gateway.frontends:
fe.configure(runtime)
_log.info(
"beaver-gateway: loaded %d agents, %d mcps, %d frontends",
len(agents),
len(mcps),
len(gateway.frontends),
)
# Keep the Phase 0 DoD line on stdout for grep-friendly smoke
# tests, in addition to the structured log line above.
print(
f"beaver-gateway: loaded {len(agents)} agents, "
f"{len(mcps)} mcps, {len(gateway.frontends)} frontends"
)
if not gateway.frontends:
# No external listeners → nothing to serve. The internal
# MCP app has no consumer on its own, so we skip running
# it in this path and exit cleanly (Phase 0 DoD).
return
async with asyncio.TaskGroup() as tg:
if internal_app is not None:
tg.create_task(_serve_internal_mcp(internal_app, settings=settings))
for fe in gateway.frontends:
tg.create_task(fe.serve())
def _build_internal_mcp(
mcps: list[McpServerT], *, settings: Settings
) -> tuple[Starlette | None, dict[str, str], dict[str, FastMCP]]:
"""Build the aggregator app + URL map + server map, or empty equivalents.
The URL map is always handed out (frontends may still introspect
``runtime.mcp_internal_urls`` even if nothing is configured); the
app is ``None`` when there are no MCPs to mount, so the caller
skips the uvicorn task entirely. The server map is the in-process
handle the Raycast backend needs to splice MCP tools into its
requests — empty when no MCPs are configured.
"""
if not mcps:
return None, {}, {}
return build_internal_app(mcps, host="127.0.0.1", port=settings.internal_mcp_port)
async def _prefetch_mcp_tools(
servers: dict[str, FastMCP],
) -> dict[str, list[FastMCPTool]]:
"""Eagerly enumerate tools per MCP so the Raycast loop has a static catalog.
Each underlying proxy is allowed to fail independently — a broken
MCP shouldn't take down the whole gateway. The result has one entry
per MCP that responded; agents that ``expose_mcps`` a missing entry
will simply expose no tools from it (logged once per request).
"""
out: dict[str, list[FastMCPTool]] = {}
for name, server in servers.items():
try:
out[name] = list(await server.list_tools())
except Exception: # noqa: BLE001 — proxy can raise any transport error; we degrade per-MCP rather than fail the whole gateway
_log.exception("failed to list tools for MCP %r — skipping", name)
out[name] = []
return out
async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
"""Run the internal MCP aggregator on loopback.
Bound to ``127.0.0.1`` (never EXPOSE'd) — only the in-process
ClaudeCode subprocess reaches it. Logged at ``warning`` level so
we don't drown the gateway's own logs in per-request noise.
"""
config = uvicorn.Config(
app,
host="127.0.0.1",
port=settings.internal_mcp_port,
log_level="warning",
loop="uvloop",
)
server = uvicorn.Server(config)
_log.info(
"internal MCP aggregator on http://127.0.0.1:%d/mcp/<name>",
settings.internal_mcp_port,
)
await server.serve()
async def _build_backends(
*,
settings: Settings,
agents: AgentRegistry,
stack: AsyncExitStack,
mcp_internal_urls: dict[str, str],
mcp_servers: dict[str, FastMCP],
mcp_tools: dict[str, list[FastMCPTool]],
) -> dict[str, Backend]:
"""Construct one backend per agent name.
The Raycast ``Client`` is shared across every ``RaycastAgent``
(bearer + device-id are process-wide), so we open it lazily — only
when at least one ``RaycastAgent`` is present — and close it via
the caller's exit stack.
Each :class:`ClaudeAgent` gets its own
:class:`ClaudeCodeBackendAdapter`: ``BackendOptions`` pins
``cwd`` / ``model`` / ``system_prompt`` / ``mcp_servers`` for the
lifetime of the underlying ``ClaudeCodeBackend``, so different
agents can't share one.
"""
backends: dict[str, Backend] = {}
raycast_agents = [a for a in agents if isinstance(a, RaycastAgent)]
if raycast_agents:
client = await _try_open_raycast_client(settings, stack)
if client is not None:
raycast_backend = RaycastBackend(
client, mcp_servers=mcp_servers, mcp_tools=mcp_tools
)
for a in raycast_agents:
backends[a.name] = raycast_backend
for a in agents:
if isinstance(a, ClaudeAgent):
adapter = ClaudeCodeBackendAdapter(
agent=a, mcp_internal_urls=mcp_internal_urls
)
await stack.enter_async_context(adapter)
backends[a.name] = adapter
return backends
async def _try_open_raycast_client(
settings: Settings, stack: AsyncExitStack
) -> RaycastClient | None:
"""Open a ``raycast_api.Client`` if creds are available, else warn + skip.
Skipping is gentler than failing startup: the user can bring up the
gateway, list their agents, and still hit a ``ClaudeAgent``; affected
``RaycastAgent`` instances 503 with a specific message at request time.
Missing ``raycast.json`` falls into the same bucket — first-run
users won't have it yet.
"""
if not settings.raycast_bearer:
_log.warning(
"RaycastAgent present but RAYCAST_BEARER is unset — those agents will 503"
)
return None
if not settings.raycast_device_id:
_log.warning(
"RaycastAgent present but RAYCAST_DEVICE_ID is unset — those agents "
"will 503 (generate once with `python -c 'import secrets; "
"print(secrets.token_hex(32))'`)"
)
return None
if not settings.raycast_config_path.exists():
_log.warning(
"RaycastAgent present but %s is missing — those agents will 503",
settings.raycast_config_path,
)
return None
config = RaycastConfig.load(settings.raycast_config_path)
client = RaycastClient(
config=config,
bearer_token=settings.raycast_bearer,
device_id=settings.raycast_device_id,
locale=settings.raycast_locale,
)
return await stack.enter_async_context(client)
-158
View File
@@ -1,158 +0,0 @@
"""``Gateway`` - the one object a setup's ``config.py`` assembles - and its loader."""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from croniter import croniter
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.conversations.texts import ConversationTexts
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.jobs.scheduler import Job
from beaver_gateway.mcp.types import HttpMcp, McpServer, PythonToolMcp, StdioMcp
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from pathlib import Path
from beaver_gateway.conversations.distill import Distiller
from beaver_gateway.conversations.envelope import RecallContext
from beaver_gateway.conversations.rotation import RotationPolicy
from beaver_gateway.conversations.texts import UserSaid
from beaver_gateway.jobs.scheduler import Budget
from beaver_gateway.mcp.types import McpServerT
from beaver_gateway.vault.watch import VaultWatch
__all__ = ["ConfigError", "Gateway", "load"]
_SCOPE = re.compile(r"[a-z][a-z0-9_-]*")
@dataclass(slots=True)
class Gateway:
agents: list[BaseAgent] = field(default_factory=list)
mcps: list[McpServerT] = field(default_factory=list)
frontends: list[Frontend] = field(default_factory=list)
texts: ConversationTexts | None = None
"""Every string the gateway says to a model; ``None`` = the English defaults."""
jobs: list[Job] = field(default_factory=list)
"""Cron, webhook and event jobs run by ``jobs.scheduler``."""
rotation: RotationPolicy | None = None
"""When a master is replaced by a fresh one; ``None`` keeps the defaults."""
watch: VaultWatch | None = None
"""Directory watcher feeding the envelope; ``None`` = no change block."""
recall: Callable[[RecallContext], str | None] | None = None
"""Envelope lookup on the user's text: pointers the setup derives from its files."""
user_sink: Callable[[UserSaid], Awaitable[None] | None] | None = None
"""Sees every user message entering a master or branch turn."""
budget: Budget | None = None
"""Subscription utilisation past which non-critical jobs wait."""
distiller: Distiller | None = None
"""Who closes deep chats and where digests and the index live."""
tz: str = "UTC"
"""Zone for the envelope clock, cron expressions and the rotation hour."""
host: str = "0.0.0.0" # noqa: S104
port: int = 8000
"""The one listener; every HTTP frontend is mounted under its ``path``."""
public_url: str | None = None
"""Origin the reverse proxy shows the world; ``None`` derives it per request."""
class ConfigError(Exception):
pass
_PUBLIC_NAMES: dict[str, Any] = {
"ClaudeAgent": ClaudeAgent,
"RaycastAgent": RaycastAgent,
"McpServer": McpServer,
"ExposedMcp": ExposedMcp,
"Gateway": Gateway,
"ConversationTexts": ConversationTexts,
}
_McpInstance = StdioMcp | HttpMcp | PythonToolMcp
def load(path: Path) -> Gateway:
"""Execute a setup's ``config.py`` and return its top-level ``gateway``."""
try:
source = path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
msg = f"config file not found: {path}"
raise ConfigError(msg) from exc
except OSError as exc:
msg = f"could not read config file {path}: {exc}"
raise ConfigError(msg) from exc
code = compile(source, str(path), "exec")
parent = str(path.resolve().parent)
if parent not in sys.path:
sys.path.insert(0, parent)
namespace: dict[str, Any] = {"__file__": str(path), **_PUBLIC_NAMES}
exec(code, namespace) # noqa: S102
try:
gw = namespace["gateway"]
except KeyError as exc:
msg = f"{path}: no top-level `gateway = Gateway(...)` found"
raise ConfigError(msg) from exc
if not isinstance(gw, Gateway):
msg = (
f"{path}: top-level `gateway` must be a Gateway instance, "
f"got {type(gw).__name__}"
)
raise ConfigError(msg)
_validate(gw, path)
return gw
def _validate(gw: Gateway, path: Path) -> None:
for i, a in enumerate(gw.agents):
if not isinstance(a, BaseAgent):
msg = (
f"{path}: gateway.agents[{i}] must be a ClaudeAgent / "
f"RaycastAgent instance, got {type(a).__name__}"
)
raise ConfigError(msg)
for i, m in enumerate(gw.mcps):
if not isinstance(m, _McpInstance):
msg = (
f"{path}: gateway.mcps[{i}] must be built via "
f"McpServer.stdio/.http/.python_tool, "
f"got {type(m).__name__}"
)
raise ConfigError(msg)
for i, f in enumerate(gw.frontends):
if not isinstance(f, Frontend):
msg = (
f"{path}: gateway.frontends[{i}] must be a Frontend, "
f"got {type(f).__name__}"
)
raise ConfigError(msg)
if f.scope is not None and not _SCOPE.fullmatch(f.scope):
msg = (
f"{path}: gateway.frontends[{i}] has scope {f.scope!r}; "
f"a frontend scope is a lowercase name like 'voice', never '*'"
)
raise ConfigError(msg)
names: set[str] = set()
for i, j in enumerate(gw.jobs):
if not isinstance(j, Job):
msg = f"{path}: gateway.jobs[{i}] must be a Job, got {type(j).__name__}"
raise ConfigError(msg)
if j.name in names:
msg = f"{path}: duplicate job name {j.name!r}"
raise ConfigError(msg)
names.add(j.name)
if j.cron is not None and not croniter.is_valid(j.cron):
msg = f"{path}: job {j.name!r} has an invalid cron {j.cron!r}"
raise ConfigError(msg)
+99
View File
@@ -0,0 +1,99 @@
"""Load the user's ``/config/config.py``.
The config file is regular Python. We ``exec`` it in a namespace seeded
with the public surface the user is expected to use (``ClaudeAgent``,
``RaycastAgent``, ``McpServer``, ``ExposedMcp``, ``Gateway``). The file
must assign a top-level ``gateway = Gateway(...)``.
Element-level validation already happens at construction time — agents
and ``McpServer`` factories are pydantic models that reject garbage.
What we *can't* validate at construction is the ``Gateway`` container
itself (deliberately a plain dataclass per PRD), so we type-check its
contents here before handing it back.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.core.registry import Gateway
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.types import HttpMcp, McpServer, PythonToolMcp, StdioMcp
if TYPE_CHECKING:
from pathlib import Path
class ConfigError(Exception):
"""User config file is missing, unreadable, or structurally wrong."""
_PUBLIC_NAMES: dict[str, Any] = {
"ClaudeAgent": ClaudeAgent,
"RaycastAgent": RaycastAgent,
"McpServer": McpServer,
"ExposedMcp": ExposedMcp,
"Gateway": Gateway,
}
_McpInstance = StdioMcp | HttpMcp | PythonToolMcp
def load(path: Path) -> Gateway:
"""Execute ``path`` and return its top-level ``gateway`` object."""
try:
source = path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
msg = f"config file not found: {path}"
raise ConfigError(msg) from exc
except OSError as exc:
msg = f"could not read config file {path}: {exc}"
raise ConfigError(msg) from exc
code = compile(source, str(path), "exec")
namespace: dict[str, Any] = {"__file__": str(path), **_PUBLIC_NAMES}
exec(code, namespace) # noqa: S102 - exec'ing user config is the feature
try:
gw = namespace["gateway"]
except KeyError as exc:
msg = f"{path}: no top-level `gateway = Gateway(...)` found"
raise ConfigError(msg) from exc
if not isinstance(gw, Gateway):
msg = (
f"{path}: top-level `gateway` must be a Gateway instance, "
f"got {type(gw).__name__}"
)
raise ConfigError(msg)
_validate(gw, path)
return gw
def _validate(gw: Gateway, path: Path) -> None:
for i, a in enumerate(gw.agents):
if not isinstance(a, BaseAgent):
msg = (
f"{path}: gateway.agents[{i}] must be a ClaudeAgent / "
f"RaycastAgent instance, got {type(a).__name__}"
)
raise ConfigError(msg)
for i, m in enumerate(gw.mcps):
if not isinstance(m, _McpInstance):
msg = (
f"{path}: gateway.mcps[{i}] must be built via "
f"McpServer.stdio/.http/.python_tool, "
f"got {type(m).__name__}"
)
raise ConfigError(msg)
for i, f in enumerate(gw.frontends):
if not isinstance(f, Frontend):
msg = (
f"{path}: gateway.frontends[{i}] must be a Frontend, "
f"got {type(f).__name__}"
)
raise ConfigError(msg)
@@ -1 +0,0 @@
"""Conversations: rows, queue, seeds, turns, questions, closing, rotation, envelope."""
-322
View File
@@ -1,322 +0,0 @@
"""Ending conversations: the distiller, the line cap, the master handover."""
from __future__ import annotations
import asyncio
import contextlib
import inspect
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from beaver_gateway.conversations.distill import (
Digest,
DistillContext,
LineCap,
append_index,
check_digest,
find_digest,
index_line,
trim_summary,
written_paths,
)
from beaver_gateway.conversations.questions import Questions
from beaver_gateway.conversations.state import aware
from beaver_gateway.conversations.texts import NewDayContext
if TYPE_CHECKING:
from beaver_gateway.conversations.rotation import HandoutContext
from beaver_gateway.storage.models import Conversation, InjectQueueItem
__all__ = ["Closing", "DistillResult"]
_log = logging.getLogger(__name__)
CLOSE_WAIT = 0.25
CLOSE_TRIES = 40
CAP_TRIES = 3
@dataclass(frozen=True, slots=True)
class DistillResult:
conversation: Conversation
fork: Conversation
text: str
digest: Digest | None
error: str | None
trimmed: bool
class Closing(Questions):
async def close(self, conv: Conversation) -> Conversation:
row = await self.set_status(conv, "closed")
with contextlib.suppress(LookupError):
await self._backend(conv.agent_name).close(conv.external_id)
return row
async def request_close(self, conv: Conversation) -> Conversation:
"""``close_chat`` from inside a turn: the chat closes once the turn ends."""
if conv.kind != "deep":
msg = f"only deep chats close this way, {conv.external_id} is {conv.kind}"
raise ValueError(msg)
return await self.set_flags(conv, {"close_requested": True})
async def idle(
self,
*,
kind: str,
days: int,
since: datetime | None = None,
limit: int | None = None,
) -> list[Conversation]:
"""Open conversations of ``kind`` with a session, quiet for ``days``."""
now = datetime.now(UTC)
cutoff = now - timedelta(days=days)
out: list[tuple[datetime, Conversation]] = []
for conv in await self.find(status="open", kind=kind, limit=10_000):
if conv.session_id is None:
continue
last = aware(conv.last_activity_at or conv.created_at)
if last > cutoff or (since is not None and last < since):
continue
out.append((last, conv))
out.sort(key=lambda pair: pair[0])
rows = [conv for _, conv in out]
return rows[:limit] if limit is not None else rows
async def distill(
self, conv: Conversation, *, reason: str = "api"
) -> DistillResult:
"""Fork under the distiller: digest checked and indexed, merge to the master."""
if self._distiller is None:
msg = "no distiller configured (Gateway(distiller=...))"
raise RuntimeError(msg)
if conv.kind != "deep":
msg = f"only deep chats are distilled, {conv.external_id} is {conv.kind}"
raise ValueError(msg)
row = await self.get_row(cast("int", conv.id)) or conv
if row.status != "open":
msg = f"conversation {row.external_id} is {row.status}"
raise ValueError(msg)
if await self.busy(row):
msg = f"conversation {row.external_id} is busy"
raise RuntimeError(msg)
memory = bool(row.flags.get("memory", True))
chat_name = await self.chat_name(row)
ctx = DistillContext(
conversation=row,
title=await self.implied_title(row),
source=await self.window_of(row),
chat_name=chat_name,
memory=memory,
reason=reason,
day=datetime.now(UTC).astimezone().date(),
)
prompt = await self._distill_prompt(ctx)
started = datetime.now(UTC)
self._bus.publish(
"distill.start",
conversation_id=row.external_id,
reason=reason,
memory=memory,
)
result = await self.fork(
row,
prompt,
strip_tools=True,
agent=self._distiller.agent,
title=f"digest: {chat_name}",
)
text, trimmed = trim_summary(result.text)
digest: Digest | None = None
error: str | None = None
if memory:
written = written_paths(result.capture.synthesized_messages)
path = find_digest(self._distiller, since=started, written=written)
if path is None:
error = self._texts.digest_missing
else:
checked = check_digest(path, self._distiller)
if isinstance(checked, str):
error = f"{path.name}: {checked}"
else:
digest = checked
append_index(self._distiller, index_line(digest, chat_name))
if error is not None:
_log.warning("distill of %s: %s", row.external_id, error)
master = await self.open_master()
if master is not None and text:
note = self._texts.closed.format(
chat=chat_name,
digest=(
self._texts.closed_digest.format(digest=digest.path.stem)
if digest
else ""
),
text=text,
)
await self.inject(master, note, urgency="normal", origin="digest")
await self.close(row)
row = await self.set_flags(
row,
{
"close_requested": None,
"closed_reason": reason,
"digest": str(digest.path) if digest else None,
"digest_error": error,
},
)
self._bus.publish(
"conversation.distilled",
conversation_id=row.external_id,
fork=result.conversation.external_id,
reason=reason,
memory=memory,
digest=str(digest.path) if digest else None,
error=error,
text=text,
trimmed=trimmed,
master=master.external_id if master is not None else None,
)
return DistillResult(
conversation=row,
fork=result.conversation,
text=text,
digest=digest,
error=error,
trimmed=trimmed,
)
async def _distill_prompt(self, ctx: DistillContext) -> str:
source = self._texts.distill
if source is None:
template = (
self._texts.distill_prompt
if ctx.memory
else self._texts.distill_prompt_no_memory
)
return template.format(
chat=ctx.chat_name, reason=ctx.reason, day=ctx.day.isoformat()
)
produced: Any = source(ctx)
return await produced if inspect.isawaitable(produced) else produced
async def _close_after_turn(self, conv: Conversation) -> None:
for _ in range(CLOSE_TRIES):
if await self.busy(conv):
await asyncio.sleep(CLOSE_WAIT)
continue
try:
await self.distill(conv, reason="close_chat")
except RuntimeError as exc:
_log.info("closing %s: %s, retrying", conv.external_id, exc)
await asyncio.sleep(CLOSE_WAIT)
continue
except Exception: # noqa: BLE001
_log.exception("closing %s after its turn failed", conv.external_id)
return
_log.warning("closing %s: still busy, giving up", conv.external_id)
async def before_turn(self, conv: Conversation) -> str | None:
cap = LineCap.from_flags(conv.flags.get("line_cap"))
if cap is None:
return None
try:
return cap.path.read_text(encoding="utf-8") if cap.path.exists() else ""
except OSError:
_log.exception("line cap: cannot read %s", cap.path)
return None
async def after_turn(self, conv: Conversation, before: str | None) -> None:
row = await self.get_row(cast("int", conv.id))
if row is None:
return
if row.kind == "deep" and row.flags.get("close_requested"):
self._track(asyncio.create_task(self._close_after_turn(row)))
cap = LineCap.from_flags(row.flags.get("line_cap"))
if cap is not None and before is not None:
await self._enforce_cap(row, cap, before)
async def _enforce_cap(self, conv: Conversation, cap: LineCap, before: str) -> None:
if not cap.path.exists():
return
after = cap.path.read_text(encoding="utf-8")
lines = sum(1 for line in after.splitlines() if line.strip())
if lines <= cap.max_lines:
return
if before:
cap.path.write_text(before, encoding="utf-8")
else:
cap.path.unlink()
attempts = int(conv.flags.get("line_cap_attempts", 0) or 0) + 1
await self.set_flags(conv, {"line_cap_attempts": attempts})
self._bus.publish(
"line_cap.bounced",
conversation_id=conv.external_id,
path=str(cap.path),
lines=lines,
max_lines=cap.max_lines,
attempt=attempts,
)
_log.warning(
"line cap: %s came back with %d lines (cap %d), restored; attempt %d",
cap.path,
lines,
cap.max_lines,
attempts,
)
if attempts > CAP_TRIES:
return
await self.inject(
conv,
self._texts.too_long.format(
name=cap.path.name, lines=lines, max_lines=cap.max_lines
),
urgency="urgent",
origin="cap",
interrupt=False,
)
async def handout(self, conv: Conversation, ctx: HandoutContext) -> str:
"""The closing master's last turn."""
source = self._texts.handout
if isinstance(source, str):
prompt = source.format(day=ctx.day.isoformat(), reason=ctx.reason)
else:
produced: Any = source(ctx)
prompt = await produced if inspect.isawaitable(produced) else produced
self._bus.publish(
"handout.start", conversation_id=conv.external_id, day=ctx.day.isoformat()
)
try:
text, _ = await self.run_text_turn(conv, prompt, origin="handout")
except Exception: # noqa: BLE001
_log.exception("handout turn on %s failed", conv.external_id)
text = ""
self._bus.publish(
"handout.end",
conversation_id=conv.external_id,
day=ctx.day.isoformat(),
text=text[:2000],
)
return text
async def new_day(
self, conv: Conversation, *, reason: str = "night", moved: int = 0
) -> InjectQueueItem:
"""The new master's first inject."""
ctx = NewDayContext(
day=datetime.now(UTC).astimezone().date(), reason=reason, moved=moved
)
source = self._texts.new_day
if isinstance(source, str):
text = source.format(day=ctx.day.isoformat(), reason=ctx.reason)
else:
produced: Any = source(ctx)
text = await produced if inspect.isawaitable(produced) else produced
if moved:
text += self._texts.moved_injects.format(moved=moved)
return await self.inject(
conv, text, urgency="urgent", origin="rotation", interrupt=False
)
-246
View File
@@ -1,246 +0,0 @@
"""What a conversation's model holds: prompt granules, skills, tools, files touched."""
from __future__ import annotations
import re
from collections import Counter
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.conversations.kinds import Kind
__all__ = ["bash_paths", "compose", "files_touched", "granules", "skill_sets"]
CHARS_PER_TOKEN = 3.2
READ_TOOLS = frozenset({"Read", "NotebookRead"})
WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"})
SEARCH_TOOLS = frozenset({"Glob", "Grep"})
PATH_KEYS = ("file_path", "notebook_path", "path", "file", "filename")
_FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
# A shell command names files as quoted strings or bare words; only notes
# and plain text files count, code and binaries are the agent's own business.
_SHELL_TOKEN = re.compile(r'"([^"\n]+)"|\'([^\'\n]+)\'|([^\s"\'`;|&()<>]+)')
_NOTE_SUFFIXES = (".md", ".canvas", ".txt", ".csv", ".json", ".yaml", ".yml")
_SHELL_CD = re.compile(r'(?:^|&&|;|\|\|)\s*cd\s+("([^"]+)"|\'([^\']+)\'|(\S+))')
_SHELL_WRITES = re.compile(
r"write_text|write_bytes|>>?\s*[\"\']?[^\s&|]|\btee\b|\bsed\s+-i|\bmv\b|\brm\b"
r"|\btouch\b|\bcp\b|open\([^)]*[\"\'][wa]"
)
def bash_paths(command: str, cwd: Path | None) -> tuple[list[str], Path | None, bool]:
"""Files a shell command names, where it leaves the shell, whether it writes.
Relative names resolve against ``cwd``, or against the directory of a
leading ``cd`` in the same command. Absolute names stay as they are.
"""
here = cwd
for match in _SHELL_CD.finditer(command):
target = match.group(2) or match.group(3) or match.group(4) or ""
target = target.strip()
if not target:
continue
here = Path(target) if target.startswith("/") else (here or Path()) / target
found: list[str] = []
for match in _SHELL_TOKEN.finditer(command):
raw = (match.group(1) or match.group(2) or match.group(3) or "").strip()
if not raw.lower().endswith(_NOTE_SUFFIXES) or "*" in raw:
continue
path = Path(raw) if raw.startswith("/") else (here or Path()) / raw
text = str(path)
if text not in found:
found.append(text)
return found, here, bool(_SHELL_WRITES.search(command))
def _tokens(chars: int) -> int:
return round(chars / CHARS_PER_TOKEN)
def _relative(path: str, cwd: Path | None) -> str:
if cwd is None:
return path
try:
return str(Path(path).relative_to(cwd))
except ValueError:
return path
def _tool_uses(entries: Iterable[Mapping[str, Any]]) -> Iterable[tuple[str, Any, str]]:
for entry in entries:
message = entry.get("message")
if entry.get("type") != "assistant" or not isinstance(message, dict):
continue
content = message.get("content")
if not isinstance(content, list):
continue
stamp = str(entry.get("timestamp") or "")
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
yield str(block.get("name") or "?"), block.get("input"), stamp
def files_touched(
entries: Iterable[Mapping[str, Any]], *, cwd: Path | None = None
) -> tuple[list[dict[str, Any]], dict[str, int]]:
"""Files named in tool inputs, with how they were touched, plus tool counts."""
files: dict[str, dict[str, Any]] = {}
counts: Counter[str] = Counter()
# The shell keeps its directory between calls; follow it.
shell_cwd = cwd
def touch(raw: str, how: str, stamp: str) -> None:
key = _relative(raw, cwd)
row = files.setdefault(
key, {"path": key, "reads": 0, "writes": 0, "other": 0, "last_at": stamp}
)
row[how] += 1
row["last_at"] = max(row["last_at"], stamp)
for name, tool_input, stamp in _tool_uses(entries):
counts[name] += 1
if not isinstance(tool_input, dict):
continue
if name == "Bash" and isinstance(tool_input.get("command"), str):
paths, shell_cwd, writes = bash_paths(tool_input["command"], shell_cwd)
for raw in paths:
touch(raw, "writes" if writes else "reads", stamp)
continue
raw = next(
(v for k in PATH_KEYS if isinstance(v := tool_input.get(k), str) and v),
None,
)
if raw is None or (name in SEARCH_TOOLS and "file_path" not in tool_input):
continue
if name in READ_TOOLS:
touch(raw, "reads", stamp)
elif name in WRITE_TOOLS:
touch(raw, "writes", stamp)
else:
touch(raw, "other", stamp)
ordered = sorted(files.values(), key=lambda r: r["last_at"], reverse=True)
return ordered, dict(counts)
def granules(agent: ClaudeAgent, kind: Kind) -> dict[str, Any]:
"""The system prompt as its sources: tag, path, size, token estimate."""
sources = agent.prompt_for(kind) if agent.serves(kind) else None
rows: list[dict[str, Any]] = []
if sources is None:
chars = len(agent.system_prompt)
if chars:
rows.append(
{
"tag": None,
"path": None,
"bytes": chars,
"tokens_est": _tokens(chars),
}
)
else:
for source in sources:
tag, raw = source if isinstance(source, tuple) else (None, source)
path = Path(raw)
try:
chars = len(path.read_text(encoding="utf-8"))
except OSError:
chars = 0
rows.append(
{
"tag": tag,
"path": _relative(str(path), agent.cwd),
"bytes": chars,
"tokens_est": _tokens(chars),
}
)
total = sum(r["bytes"] for r in rows)
return {"bytes": total, "tokens_est": _tokens(total), "granules": rows}
def _skill_meta(skill_md: Path) -> dict[str, str]:
try:
text = skill_md.read_text(encoding="utf-8")
except OSError:
return {}
match = _FRONTMATTER.match(text)
meta: dict[str, str] = {}
if match:
for line in match.group(1).splitlines():
key, sep, value = line.partition(":")
if sep:
meta[key.strip()] = value.strip()
return meta
def skill_sets(agent: ClaudeAgent, kind: Kind) -> list[dict[str, Any]]:
"""Skill-set directories the kind loads, with each skill's name and description."""
out: list[dict[str, Any]] = []
for directory in agent.skills_for(kind):
skills = []
try:
children = sorted(p for p in directory.iterdir() if p.is_dir())
except OSError:
children = []
for child in children:
skill_md = child / "SKILL.md"
if skill_md.is_file():
meta = _skill_meta(skill_md)
skills.append(
{
"name": meta.get("name") or child.name,
"description": meta.get("description", ""),
"path": _relative(str(child), agent.cwd),
}
)
out.append(
{
"set": directory.name,
"path": _relative(str(directory), agent.cwd),
"skills": skills,
}
)
return out
def compose(
agent: ClaudeAgent,
kind: Kind,
entries: Iterable[Mapping[str, Any]],
*,
context_tokens: int,
) -> dict[str, Any]:
files, counts = files_touched(entries, cwd=agent.cwd)
prompt = granules(agent, kind)
skills = skill_sets(agent, kind)
skills_chars = sum(
len(s["name"]) + len(s["description"])
for group in skills
for s in group["skills"]
)
return {
"agent": {
"name": agent.name,
"model": agent.model,
"effort": agent.options.effort,
},
"kind": kind,
"context_tokens": context_tokens,
"prompt": prompt,
"skills": skills,
"skills_tokens_est": _tokens(skills_chars),
"tools": {
"allowed": list(agent.options.tools) if agent.options.tools else None,
"disallowed": list(agent.options.disallowed_tools),
"gateway": list(agent.gateway_tools),
"mcps": [m.name for m in agent.expose_mcps],
},
"files": files,
"tool_counts": counts,
"history_tokens_est": max(
0, context_tokens - prompt["tokens_est"] - _tokens(skills_chars)
),
}
-192
View File
@@ -1,192 +0,0 @@
"""Closing a deep chat: the digest, the index, the file line cap.
``Distiller`` says where digests and the index live; the gateway checks the
digest's frontmatter and caps the merge text. ``LineCap`` is the same idea
for a file a job rewrites: too long, and it is bounced back unchanged.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
import frontmatter
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beaver_gateway.storage.models import Conversation
__all__ = [
"SUMMARY_LINES",
"Digest",
"DistillContext",
"Distiller",
"LineCap",
"append_index",
"check_digest",
"find_digest",
"index_line",
"trim_summary",
"written_paths",
]
SUMMARY_LINES = 5
"""A merge into the master is at most this many lines."""
@dataclass(frozen=True, slots=True)
class Distiller:
"""Who closes deep chats, where digests land, where the index lives."""
agent: str
dir: Path
index: Path
type: str = "digest"
"""Value the ``type`` frontmatter key must carry."""
index_header: str = "# index\n\none line per digest: chat → its digest.\n"
@dataclass(frozen=True, slots=True)
class DistillContext:
"""What the setup's distill prompt is built from."""
conversation: Conversation
title: str | None
source: str | None
"""The window the chat lives in (a vault-relative path for markdown)."""
chat_name: str
"""What a ``[[wikilink]]`` to the chat is called."""
memory: bool
reason: str
day: date
@dataclass(frozen=True, slots=True)
class Digest:
path: Path
source: str
date: date
@dataclass(frozen=True, slots=True)
class LineCap:
"""A file a job rewrites may not exceed ``max_lines`` after its turn."""
path: Path
max_lines: int
def as_flags(self) -> dict[str, Any]:
return {"path": str(self.path), "max_lines": self.max_lines}
@classmethod
def from_flags(cls, value: Any) -> LineCap | None:
if not isinstance(value, dict):
return None
path, max_lines = value.get("path"), value.get("max_lines")
if not isinstance(path, str) or not isinstance(max_lines, int):
return None
return cls(path=Path(path), max_lines=max_lines)
def written_paths(messages: Iterable[dict[str, Any]]) -> list[Path]:
"""Paths the turn's ``Write``/``Edit`` calls targeted, in order."""
out: list[Path] = []
for message in messages:
content = message.get("content")
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_use":
continue
if block.get("name") not in ("Write", "Edit", "MultiEdit"):
continue
target = (block.get("input") or {}).get("file_path")
if isinstance(target, str) and target:
out.append(Path(target))
return out
def find_digest(
digests: Distiller, *, since: datetime, written: Sequence[Path] = ()
) -> Path | None:
"""The digest the fork turn produced.
A written path under ``dir`` first, otherwise the newest file there
modified since ``since``.
"""
root = digests.dir.resolve()
for path in reversed(list(written)):
candidate = path if path.is_absolute() else digests.dir / path
try:
inside = candidate.resolve().relative_to(root)
except (OSError, ValueError):
continue
if (digests.dir / inside).is_file():
return digests.dir / inside
if not digests.dir.is_dir():
return None
stamp = since.timestamp() - 1
fresh = [
p
for p in digests.dir.glob("*.md")
if p.is_file() and p.stat().st_mtime >= stamp
]
return max(fresh, key=lambda p: p.stat().st_mtime) if fresh else None
def check_digest(path: Path, digests: Distiller) -> Digest | str:
"""The digest's frontmatter parsed, or why it is not a digest."""
try:
post = frontmatter.load(str(path))
except (OSError, ValueError) as exc:
return f"unreadable: {exc}"
meta = post.metadata
if meta.get("type") != digests.type:
return f"`type` must be `{digests.type}`, not {meta.get('type')!r}"
source = meta.get("source")
if not isinstance(source, str) or not source.strip():
return "`source` is empty"
when = meta.get("date")
if isinstance(when, datetime):
when = when.date()
elif isinstance(when, str):
try:
when = date.fromisoformat(when.strip())
except ValueError:
return f"`date` is not a date: {when!r}"
if not isinstance(when, date):
return "`date` is missing"
if not post.content.strip():
return "empty body"
return Digest(path=path, source=source.strip(), date=when)
def index_line(digest: Digest, chat_name: str) -> str:
return f"- {digest.date.isoformat()} [[{chat_name}]] → [[{digest.path.stem}]]"
def append_index(digests: Distiller, line: str) -> None:
index = digests.index
text = index.read_text(encoding="utf-8") if index.exists() else ""
if line in text.splitlines():
return
if not text:
text = digests.index_header
if not text.endswith("\n"):
text += "\n"
index.parent.mkdir(parents=True, exist_ok=True)
index.write_text(text + line + "\n", encoding="utf-8")
def trim_summary(text: str, limit: int = SUMMARY_LINES) -> tuple[str, bool]:
"""The merge text cut to ``limit`` non-empty lines; ``True`` if it was."""
lines = [line.rstrip() for line in text.strip().splitlines() if line.strip()]
if len(lines) <= limit:
return "\n".join(lines), False
return "\n".join(lines[:limit]), True
@@ -1,154 +0,0 @@
"""The envelope: a background block under the user's text - clock, changes, recall."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
from beaver_gateway.conversations.texts import EnvelopeTexts
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from beaver_gateway.vault.watch import Change, VaultWatch
__all__ = ["Envelope", "RecallContext", "render"]
_log = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class RecallContext:
text: str
kind: str
now: datetime
agent: str = ""
"""Agent whose turn it is; a setup with several tells them apart by it."""
@dataclass(slots=True)
class Envelope:
watch: VaultWatch | None = None
tz: str = "UTC"
max_lines: int = 120
per_file: int = 30
names_only_within: float = 600.0
last_at: datetime | None = None
recall: Callable[[RecallContext], str | None] | None = None
"""Setup-side lookup on the user's text; its lines go under the change block."""
texts: EnvelopeTexts = field(default_factory=EnvelopeTexts)
def build(
self,
*,
now: datetime | None = None,
text: str = "",
kind: str = "master",
agent: str = "",
) -> str:
now = now or datetime.now(UTC)
changes = self.watch.take() if self.watch is not None else []
names_only = (
self.last_at is not None
and (now - self.last_at).total_seconds() < self.names_only_within
)
out = render(
now=now,
tz=self.tz,
changes=changes,
since=self.last_at,
names_only=names_only,
max_lines=self.max_lines,
per_file=self.per_file,
texts=self.texts,
)
self.last_at = now
block = self.recall_block(text=text, kind=kind, now=now, agent=agent)
return f"{out}\n{block}" if block else out
def recall_only(
self, *, text: str, kind: str, now: datetime | None = None, agent: str = ""
) -> str | None:
block = self.recall_block(
text=text, kind=kind, now=now or datetime.now(UTC), agent=agent
)
return f"{self.texts.header}\n{block}" if block else None
def recall_block(
self, *, text: str, kind: str, now: datetime, agent: str = ""
) -> str | None:
if self.recall is None or not text.strip():
return None
try:
block = self.recall(
RecallContext(text=text, kind=kind, now=now, agent=agent)
)
except Exception: # noqa: BLE001
_log.exception("recall hook failed")
return None
return block.strip() or None if block else None
def render(
*,
now: datetime,
tz: str,
changes: Sequence[Change],
since: datetime | None,
names_only: bool,
max_lines: int = 120,
per_file: int = 30,
texts: EnvelopeTexts | None = None,
) -> str:
texts = texts or EnvelopeTexts()
zone = ZoneInfo(tz)
stamp = now.astimezone(zone)
lines = [
texts.header,
texts.time.format(stamp=f"{stamp:%Y-%m-%d %H:%M}", zone=_zone_label(tz)),
]
ordered = sorted(changes, key=lambda c: (not c.full, c.path))
since_label = (
texts.since.format(time=f"{since.astimezone(zone):%H:%M}")
if since is not None
else texts.since_start
)
if ordered:
names = ", ".join(f"{c.path} (+{c.added_count})" for c in ordered)
lines.append(texts.changed.format(since=since_label, names=names))
if not names_only:
_append_diffs(
lines, ordered, max_lines=max_lines, per_file=per_file, texts=texts
)
return "\n".join(lines[:max_lines])
def _append_diffs(
lines: list[str],
changes: Sequence[Change],
*,
max_lines: int,
per_file: int,
texts: EnvelopeTexts,
) -> None:
budget = max_lines - len(lines) - 1
for change in changes:
if not change.full or not change.added:
continue
if budget < 3:
lines.append(texts.truncated)
return
shown = change.added[: min(per_file, budget - 2)]
lines.append(texts.file_header.format(path=change.path))
lines.extend(f"+ {line}" for line in shown)
budget -= 1 + len(shown)
if len(change.added) > len(shown):
lines.append(texts.more_lines.format(count=len(change.added) - len(shown)))
budget -= 1
def _zone_label(tz: str) -> str:
return tz.rsplit("/", 1)[-1].replace("_", " ")
-219
View File
@@ -1,219 +0,0 @@
"""Persisted per-conversation queue: ``urgent > user > wake > normal``.
conversations/service.py runs one worker per conversation over these rows.
A row still ``running`` when the gateway starts was cut by a restart and is
flagged ``interrupted``, never re-run.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Literal, cast
from sqlalchemy import func
from sqlmodel import col, select
from beaver_gateway.storage.models import InjectQueueItem
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beaver_gateway.storage.db import Database
from beaver_gateway.storage.models import Conversation
__all__ = [
"PRIORITY_RANK",
"URGENCY",
"InjectContext",
"InjectQueue",
"Priority",
"inject_header",
]
Priority = Literal["urgent", "user", "wake", "normal"]
PRIORITY_RANK: dict[str, int] = {"urgent": 0, "user": 1, "wake": 2, "normal": 3}
URGENCY: tuple[Priority, ...] = ("normal", "wake", "urgent")
"""What the API and the tools accept for ``urgency``: every priority but ``user``."""
INTERRUPTED_TURN = (
"[this inject cut the previous turn: the «Request interrupted» above is "
"an interruption, not a refused tool call]"
)
@dataclass(frozen=True, slots=True)
class InjectContext:
"""What a header renderer knows about one queued inject."""
origin: str
priority: str
interrupted_turn: bool
def inject_header(ctx: InjectContext) -> str:
"""Default framing; a setup overrides it via ``ConversationTexts.inject_header``."""
head = f"[inject: {ctx.origin} - not the user, no reply needed]"
return f"{head}\n{INTERRUPTED_TURN}" if ctx.interrupted_turn else head
def context_of(item: InjectQueueItem) -> InjectContext:
return InjectContext(
origin=item.origin,
priority=item.priority,
interrupted_turn=bool(item.interrupted_turn),
)
class InjectQueue:
def __init__(self, db: Database) -> None:
self._db = db
async def push(
self,
*,
conversation_id: int,
priority: Priority,
origin: str,
text: str,
attachments: list[dict[str, Any]] | None = None,
) -> InjectQueueItem:
if priority not in PRIORITY_RANK:
msg = f"unknown priority {priority!r}"
raise ValueError(msg)
row = InjectQueueItem(
conversation_id=conversation_id,
priority=priority,
origin=origin,
text=text,
attachments=attachments or None,
)
async with self._db.session() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return row
async def pending(self, conversation_id: int) -> list[InjectQueueItem]:
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem)
.where(
InjectQueueItem.conversation_id == conversation_id,
InjectQueueItem.status == "queued",
)
.order_by(col(InjectQueueItem.created_at), col(InjectQueueItem.id))
)
rows = list(result.all())
rows.sort(key=lambda r: (PRIORITY_RANK.get(r.priority, 9), r.created_at))
return rows
async def conversations_with_pending(self) -> list[int]:
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem.conversation_id)
.where(InjectQueueItem.status == "queued")
.distinct()
)
return list(result.all())
async def start(self, items: Iterable[InjectQueueItem], turn_id: str) -> None:
await self._mark(items, status="running", turn_id=turn_id, delivered=True)
async def finish(
self, items: Iterable[InjectQueueItem], status: str = "done"
) -> None:
await self._mark(items, status=status)
async def mark_interrupting(self, item: InjectQueueItem) -> None:
item.interrupted_turn = True
async with self._db.session() as session:
row = await session.get(InjectQueueItem, item.id)
if row is not None:
row.interrupted_turn = True
session.add(row)
await session.commit()
async def interrupted(self) -> Sequence[InjectQueueItem]:
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem).where(InjectQueueItem.status == "running")
)
rows = list(result.all())
for row in rows:
row.status = "interrupted"
session.add(row)
await session.commit()
return rows
async def move(
self, source: Conversation, target: Conversation, *, priority: Priority
) -> int:
"""Re-home queued items of one priority (rotation carries normal over)."""
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem).where(
InjectQueueItem.conversation_id == source.id,
InjectQueueItem.status == "queued",
InjectQueueItem.priority == priority,
)
)
rows = list(result.all())
for row in rows:
row.conversation_id = cast("int", target.id)
session.add(row)
await session.commit()
return len(rows)
async def recent(
self, conversation_id: int, *, limit: int = 50
) -> list[InjectQueueItem]:
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem)
.where(InjectQueueItem.conversation_id == conversation_id)
.order_by(col(InjectQueueItem.id).desc())
.limit(limit)
)
return list(result.all())
async def latest(
self, conversation_ids: Iterable[int]
) -> dict[int, InjectQueueItem]:
ids = list(conversation_ids)
if not ids:
return {}
newest = (
select(func.max(InjectQueueItem.id))
.where(col(InjectQueueItem.conversation_id).in_(ids))
.group_by(col(InjectQueueItem.conversation_id))
)
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem).where(col(InjectQueueItem.id).in_(newest))
)
return {row.conversation_id: row for row in result.all()}
async def _mark(
self,
items: Iterable[InjectQueueItem],
*,
status: str,
turn_id: str | None = None,
delivered: bool = False,
) -> None:
ids = [i.id for i in items if i.id is not None]
if not ids:
return
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem).where(col(InjectQueueItem.id).in_(ids))
)
for row in result.all():
row.status = status
if turn_id is not None:
row.turn_id = turn_id
if delivered:
row.delivered_at = datetime.now(UTC)
session.add(row)
await session.commit()
-18
View File
@@ -1,18 +0,0 @@
"""Conversation kinds as one closed type for agents, frontends, service."""
from __future__ import annotations
from typing import Literal, get_args
__all__ = ["KINDS", "Kind", "as_kind"]
Kind = Literal["master", "branch", "deep", "job", "fork"]
KINDS: tuple[Kind, ...] = get_args(Kind)
def as_kind(value: str) -> Kind:
for kind in KINDS:
if value == kind:
return kind
msg = f"unknown conversation kind {value!r}"
raise ValueError(msg)
@@ -1,156 +0,0 @@
"""Putting words into a conversation: a message, an inject, ``say``, a file."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, cast
from beaver_gateway.conversations.turns import Turns
from beaver_gateway.frontends.files import SendFileError
if TYPE_CHECKING:
from datetime import datetime
from beaver_gateway.conversations.injects import Priority
from beaver_gateway.storage.models import Conversation, InjectQueueItem
__all__ = ["Messaging"]
_log = logging.getLogger(__name__)
class Messaging(Turns):
async def post(
self,
conv: Conversation,
text: str,
*,
origin: str = "user",
attachments: list[dict[str, Any]] | None = None,
) -> InjectQueueItem:
item = await self._queue.push(
conversation_id=cast("int", conv.id),
priority="user",
origin=origin,
text=text,
attachments=attachments,
)
await self.touch_user(conv)
self._bus.publish(
"message.queued",
conversation_id=conv.external_id,
item=item.id,
origin=origin,
)
self._ensure_worker(cast("int", conv.id))
return item
async def inject(
self,
conv: Conversation,
text: str,
*,
urgency: Priority = "normal",
origin: str = "system",
interrupt: bool = True,
) -> InjectQueueItem:
if conv.kind == "master" and conv.status != "open":
live = await self.open_master()
if live is None:
_log.error(
"inject (%s) for closed master %s: no open master, it stays there",
origin,
conv.external_id,
)
else:
_log.info(
"inject (%s) for closed master %s goes to %s",
origin,
conv.external_id,
live.external_id,
)
conv = live
item = await self._queue.push(
conversation_id=cast("int", conv.id),
priority=urgency,
origin=origin,
text=text,
)
self._bus.publish(
"inject.queued",
conversation_id=conv.external_id,
item=item.id,
priority=urgency,
origin=origin,
)
if urgency == "urgent" and interrupt:
backend = self._backend(conv.agent_name)
if await backend.interrupt(conv.external_id):
_log.info(
"conversation %s: interrupted for urgent inject", conv.external_id
)
await self._queue.mark_interrupting(item)
self._ensure_worker(cast("int", conv.id))
return item
async def interrupt(self, conv: Conversation) -> bool:
"""Cut the running turn; the reply so far still lands."""
try:
backend = self._backend(conv.agent_name)
except LookupError:
return False
return await backend.interrupt(conv.external_id)
async def say(self, conv: Conversation, text: str) -> dict[str, Any]:
runner = self._runners.get(cast("int", conv.id))
_log.info("say[%s]: %s", conv.external_id, text[:200])
return self._bus.publish(
"say",
conversation_id=conv.external_id,
text=text,
turn_id=runner.turn_id if runner is not None else None,
)
async def send_file(
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
) -> str:
"""Hand a file to the human: this conversation's window, else the master's."""
for target in (conv, await self.open_master()):
if target is None:
continue
for fe in self._frontends:
if target.kind in fe.kinds and fe.sends_files:
note = await fe.send_file(
target, path, caption=caption, method=method
)
_log.info("send_file[%s]: %s", target.external_id, note)
return note
msg = (
"nowhere to send: no frontend that sends files shows this "
"conversation, and there is no open master"
)
raise SendFileError(msg)
def file_note(self) -> str:
"""What the ``send_file`` tool tells the model about roots and size."""
sender = next((f for f in self._frontends if f.sends_files), None)
return sender.file_note() if sender else "no frontend here sends files"
async def schedule(
self,
conv: Conversation,
at: str,
text: str,
*,
urgency: Priority = "wake",
dedupe_key: str | None = None,
) -> tuple[int | None, datetime]:
if self.scheduler is None:
msg = "no scheduler; `schedule` is unavailable"
raise RuntimeError(msg)
return await self.scheduler.schedule(
conv, at, text, urgency=urgency, dedupe_key=dedupe_key
)
async def schedules(self, conv: Conversation | None = None) -> list[dict[str, Any]]:
return await self.scheduler.scheduled(conv) if self.scheduler else []
@@ -1,85 +0,0 @@
"""``AskUserQuestion`` from inside a turn: answered or timed out."""
from __future__ import annotations
import asyncio
import contextlib
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
from beaver_gateway.conversations.spawning import Spawning
from beaver_gateway.conversations.state import Question
if TYPE_CHECKING:
from beaver_gateway.storage.models import Conversation
__all__ = ["Questions"]
class Questions(Spawning):
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
"""Show the question and wait; ``None`` when nobody answered in time."""
conv = await self.get(key)
if conv is None:
return None
runner = self._runners.get(cast("int", conv.id))
question_id = f"q_{uuid4().hex[:10]}"
pending = Question(
conversation_id=key,
turn_id=runner.turn_id if runner is not None else None,
questions=list(payload.get("questions") or []),
answer=asyncio.get_running_loop().create_future(),
)
self._questions[question_id] = pending
await self._set_pending_question(conv, value=True)
self._bus.publish(
"question",
conversation_id=key,
turn_id=pending.turn_id,
question_id=question_id,
questions=pending.questions,
timeout=self._question_timeout,
)
try:
async with asyncio.timeout(self._question_timeout):
answer = await pending.answer
except TimeoutError:
self._bus.publish(
"question.timeout",
conversation_id=key,
turn_id=pending.turn_id,
question_id=question_id,
)
return None
finally:
self._questions.pop(question_id, None)
await self._set_pending_question(conv, value=False)
self._bus.publish(
"question.answered",
conversation_id=key,
turn_id=pending.turn_id,
question_id=question_id,
answer=answer,
)
return answer
def answer(self, question_id: str, answer: str) -> bool:
pending = self._questions.get(question_id)
if pending is None or pending.answer.done():
return False
pending.answer.set_result(answer)
return True
def answer_text(self, answer: str | None) -> str:
if answer is None:
return self._texts.unanswered.format(
minutes=round(self._question_timeout / 60)
)
return self._texts.answered.format(answer=answer)
async def _set_pending_question(self, conv: Conversation, *, value: bool) -> None:
async def apply(row: Conversation) -> None:
row.pending_question = value
with contextlib.suppress(LookupError):
await self._update(conv, apply)
@@ -1,127 +0,0 @@
"""Master rotation: one logical master thread, many physical sessions.
When the policy says so, a new master takes over the window; the old one
hands out, closes, and its branches and queued normal injects move over.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.storage.models import Conversation
__all__ = ["HandoutContext", "Rotation", "RotationPolicy"]
_log = logging.getLogger("beaver_gateway.conversations.rotation")
@dataclass(frozen=True, slots=True)
class RotationPolicy:
tz: str = "UTC"
hour: int = 4
night_silence: timedelta = timedelta(hours=3)
max_age: timedelta = timedelta(hours=36)
short_silence: timedelta = timedelta(minutes=30)
max_context_tokens: int = 80_000
def reason(
self, master: Conversation, *, now: datetime, context_tokens: int
) -> str | None:
zone = ZoneInfo(self.tz)
created = _aware(master.created_at)
silence = now - _aware(master.last_user_activity_at or master.created_at)
boundary = now.astimezone(zone).replace(
hour=self.hour, minute=0, second=0, microsecond=0
)
if now.astimezone(zone) < boundary:
boundary -= timedelta(days=1)
if created < boundary and silence > self.night_silence:
return "night"
if now - created > self.max_age and silence > self.short_silence:
return "age"
if context_tokens > self.max_context_tokens and silence > self.short_silence:
return "context"
return None
def day_of(self, master: Conversation) -> date:
return _aware(master.created_at).astimezone(ZoneInfo(self.tz)).date()
@dataclass(frozen=True, slots=True)
class HandoutContext:
day: date
master: Conversation
reason: str
class Rotation:
def __init__(
self, conversations: Conversations, policy: RotationPolicy | None = None
) -> None:
self._conversations = conversations
self.policy = policy or RotationPolicy()
async def due(self, now: datetime | None = None) -> list[tuple[Conversation, str]]:
now = now or datetime.now(UTC)
out: list[tuple[Conversation, str]] = []
for master in await self._conversations.find(kind="master", status="open"):
tokens = await self._conversations.context_tokens(master)
reason = self.policy.reason(master, now=now, context_tokens=tokens)
if reason is not None:
out.append((master, reason))
return out
async def tick(self, now: datetime | None = None) -> list[Conversation]:
rotated: list[Conversation] = []
for master, reason in await self.due(now):
new = await self.rotate(master, reason)
if new is not None:
rotated.append(new)
return rotated
async def rotate(self, old: Conversation, reason: str) -> Conversation | None:
conversations = self._conversations
if await conversations.busy(old):
_log.info("rotation of %s skipped: busy", old.external_id)
return None
new = await conversations.spawn(
kind="master", agent=old.agent_name, seed="morning", origin="rotation"
)
day = self.policy.day_of(old)
_log.info(
"rotation (%s): %s -> %s, handout for %s",
reason,
old.external_id,
new.external_id,
day,
)
await conversations.handout(
old, HandoutContext(day=day, master=old, reason=reason)
)
await conversations.close(old)
for branch in await conversations.find(parent=old, limit=1000):
if branch.status == "open":
await conversations.reparent(branch, new)
elif not branch.running_turn:
await conversations.mark_closed(branch)
moved = await conversations.queue.move(old, new, priority="normal")
await conversations.new_day(new, reason=reason, moved=moved)
conversations.bus.publish(
"conversation.rotated",
conversation_id=new.external_id,
closed=old.external_id,
reason=reason,
handout_day=day.isoformat(),
moved=moved,
)
return new
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
-563
View File
@@ -1,563 +0,0 @@
"""The conversation rows: create, find, bind to windows, flags, status, history."""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
from sqlalchemy import String, func
from sqlalchemy import cast as sa_cast
from sqlmodel import col, select
from beaver_gateway.backends.transcript import (
messages_from_entries,
render_messages,
text_of,
)
from beaver_gateway.conversations.kinds import KINDS, Kind
from beaver_gateway.conversations.state import State, iso
from beaver_gateway.frontends.markdown.history import load_messages
from beaver_gateway.storage.models import (
Conversation,
ConversationBinding,
ConversationMessage,
RateLimit,
TranscriptEntry,
Usage,
)
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Iterable
from beaver_gateway.frontends.base import Frontend
__all__ = [
"MASTER_ALIAS",
"PARENT_ALIAS",
"STATUSES",
"TITLE_MAX",
"Rows",
"context_of",
"implied_title",
]
_log = logging.getLogger(__name__)
MASTER_ALIAS = "master"
PARENT_ALIAS = "parent"
STATUSES = ("open", "merged", "closed", "archived")
TITLE_MAX = 80
class Rows(State):
async def create(
self,
*,
kind: Kind,
agent: str,
parent: Conversation | None = None,
title: str | None = None,
origin: str = "api",
session_id: str | None = None,
flags: dict[str, Any] | None = None,
) -> Conversation:
if kind not in KINDS:
msg = f"unknown conversation kind {kind!r}"
raise ValueError(msg)
if not self._claude_agent(agent).serves(kind):
msg = f"agent {agent!r} does not serve kind {kind!r}"
raise ValueError(msg)
now = datetime.now(UTC)
row = Conversation(
frontend=origin,
external_id=str(uuid4()),
agent_name=agent,
kind=kind,
parent_id=parent.id if parent is not None else None,
title=title,
session_id=session_id,
flags=dict(flags or {}),
last_activity_at=now,
)
async with self._db.session() as session:
session.add(row)
await session.commit()
await session.refresh(row)
self._bus.publish("conversation.created", **self.public(row))
return row
async def get(self, public_id: str) -> Conversation | None:
async with self._db.session() as session:
result = await session.exec(
select(Conversation).where(Conversation.external_id == public_id)
)
return result.first()
async def get_row(self, row_id: int) -> Conversation | None:
async with self._db.session() as session:
return await session.get(Conversation, row_id)
async def resolve(
self, key: str, *, origin: Conversation | None = None
) -> Conversation | None:
"""By public id, or ``master`` / ``parent`` relative to ``origin``."""
key = key.strip()
if key == MASTER_ALIAS:
return await self.open_master()
if key == PARENT_ALIAS:
if origin is None or origin.parent_id is None:
return None
return await self.get_row(origin.parent_id)
return await self.get(key)
async def open_master(self) -> Conversation | None:
masters = await self.find(kind="master", status="open", limit=1)
return masters[0] if masters else None
async def find(
self,
*,
status: str | None = None,
kind: str | None = None,
parent: Conversation | None = None,
limit: int = 200,
) -> list[Conversation]:
stmt = select(Conversation).order_by(col(Conversation.id).desc()).limit(limit)
if status is not None:
stmt = stmt.where(Conversation.status == status)
if kind is not None:
stmt = stmt.where(Conversation.kind == kind)
if parent is not None:
stmt = stmt.where(Conversation.parent_id == parent.id)
async with self._db.session() as session:
return list((await session.exec(stmt)).all())
async def search(self, query: str, *, limit: int = 20) -> list[Conversation]:
needle = f"%{query}%"
by_title = select(Conversation.id).where(col(Conversation.title).ilike(needle))
by_first = select(ConversationMessage.conversation_id).where(
ConversationMessage.seq == 0,
col(ConversationMessage.content_json).ilike(needle),
)
by_entry = (
select(Conversation.id)
.join(
TranscriptEntry,
col(TranscriptEntry.session_id) == col(Conversation.session_id),
)
.where(sa_cast(col(TranscriptEntry.entry), String).ilike(needle))
)
stmt = (
select(Conversation)
.where(col(Conversation.id).in_(by_title.union(by_first, by_entry)))
.order_by(col(Conversation.id).desc())
.limit(limit)
)
async with self._db.session() as session:
return list((await session.exec(stmt)).all())
async def bindings(self, conv: Conversation) -> list[ConversationBinding]:
async with self._db.session() as session:
result = await session.exec(
select(ConversationBinding)
.where(ConversationBinding.conversation_id == conv.id)
.order_by(col(ConversationBinding.id))
)
return list(result.all())
async def bind(
self,
conv: Conversation,
*,
frontend: str,
external_id: str,
visible: bool = True,
) -> ConversationBinding:
if conv.kind not in self.frontend(frontend).kinds:
msg = f"frontend {frontend!r} does not show kind {conv.kind!r}"
raise ValueError(msg)
async with self._db.session() as session:
existing = list(
(
await session.exec(
select(ConversationBinding).where(
ConversationBinding.conversation_id == conv.id,
ConversationBinding.frontend == frontend,
)
)
).all()
)
row = next((b for b in existing if b.external_id == external_id), None)
if visible:
for other in existing:
if other is not row and other.visible:
other.visible = False
session.add(other)
same_window = await session.exec(
select(ConversationBinding).where(
ConversationBinding.frontend == frontend,
ConversationBinding.external_id == external_id,
ConversationBinding.conversation_id != conv.id,
col(ConversationBinding.visible).is_(True),
)
)
for other in same_window.all():
other.visible = False
session.add(other)
if row is None:
row = ConversationBinding(
conversation_id=cast("int", conv.id),
frontend=frontend,
external_id=external_id,
visible=visible,
)
else:
row.visible = visible
session.add(row)
await session.commit()
await session.refresh(row)
self._bus.publish(
"conversation.bound",
conversation_id=conv.external_id,
frontend=frontend,
external_id=external_id,
visible=visible,
)
return row
async def find_bound(
self, *, frontend: str, external_id: str
) -> Conversation | None:
async with self._db.session() as session:
result = await session.exec(
select(Conversation)
.join(
ConversationBinding,
col(ConversationBinding.conversation_id) == col(Conversation.id),
)
.where(
ConversationBinding.frontend == frontend,
ConversationBinding.external_id == external_id,
col(ConversationBinding.visible).is_(True),
)
.order_by(col(Conversation.id).desc())
)
return result.first()
async def last_binding(
self, *, frontend: str, kind: str
) -> ConversationBinding | None:
"""The window ``frontend`` last used for ``kind``; outlives a rotation."""
async with self._db.session() as session:
result = await session.exec(
select(ConversationBinding)
.join(
Conversation,
col(Conversation.id) == col(ConversationBinding.conversation_id),
)
.where(
ConversationBinding.frontend == frontend, Conversation.kind == kind
)
.order_by(col(ConversationBinding.id).desc())
)
return result.first()
async def window_of(self, conv: Conversation) -> str | None:
for binding in await self.bindings(conv):
if binding.visible:
return binding.external_id
return None
async def set_flags(
self, conv: Conversation, flags: dict[str, Any]
) -> Conversation:
async def apply(row: Conversation) -> None:
row.flags = {**row.flags, **flags}
return await self._update(conv, apply)
async def set_status(self, conv: Conversation, status: str) -> Conversation:
if status not in STATUSES:
msg = f"unknown status {status!r}"
raise ValueError(msg)
async def apply(row: Conversation) -> None:
row.status = status
return await self._update(conv, apply)
async def set_title(self, conv: Conversation, title: str) -> Conversation:
async def apply(row: Conversation) -> None:
row.title = title
return await self._update(conv, apply)
async def touch_user(self, conv: Conversation) -> Conversation:
async def apply(row: Conversation) -> None:
row.last_user_activity_at = datetime.now(UTC)
return await self._update(conv, apply)
async def reparent(self, conv: Conversation, parent: Conversation) -> Conversation:
async def apply(row: Conversation) -> None:
row.parent_id = parent.id
return await self._update(conv, apply)
async def _update(
self, conv: Conversation, apply: Callable[[Conversation], Awaitable[None]]
) -> Conversation:
async with self._db.session() as session:
row = await session.get(Conversation, conv.id)
if row is None:
msg = f"conversation {conv.external_id} vanished"
raise LookupError(msg)
await apply(row)
row.updated_at = datetime.now(UTC)
session.add(row)
await session.commit()
await session.refresh(row)
self._bus.publish("conversation.updated", **self.public(row))
return row
def public(self, conv: Conversation) -> dict[str, Any]:
return {
"id": conv.external_id,
"kind": conv.kind,
"agent": conv.agent_name,
"title": conv.title,
"status": conv.status,
"parent_row": conv.parent_id,
"session_id": conv.session_id,
"running_turn": conv.running_turn,
"pending_question": conv.pending_question,
"flags": conv.flags,
"origin": conv.frontend,
"created_at": iso(conv.created_at),
"last_user_activity_at": iso(conv.last_user_activity_at),
"last_activity_at": iso(conv.last_activity_at),
}
async def describe(self, conv: Conversation) -> dict[str, Any]:
out = self.public(conv)
out["title"] = await self.implied_title(conv)
out["context_tokens"] = await self.context_tokens(conv)
parent = await self.get_row(conv.parent_id) if conv.parent_id else None
out["parent"] = parent.external_id if parent is not None else None
out["bindings"] = [
{"frontend": b.frontend, "external_id": b.external_id, "visible": b.visible}
for b in await self.bindings(conv)
]
live = self._pool.get(conv.external_id)
out["live"] = live is not None
out["busy"] = live.busy if live is not None else False
runner = self._runners.get(cast("int", conv.id))
out["turn"] = runner.snapshot() if runner is not None else None
pending = self.pending_question(conv.external_id)
out["question"] = (
{"id": pending[0], "questions": pending[1]} if pending else None
)
return out
def pending_question(self, key: str) -> tuple[str, list[dict[str, Any]]] | None:
for question_id, pending in self._questions.items():
if pending.conversation_id == key and not pending.answer.done():
return question_id, pending.questions
return None
async def rate_limits(self, *, limit: int = 100) -> list[RateLimit]:
async with self._db.session() as session:
result = await session.exec(
select(RateLimit).order_by(col(RateLimit.id).desc()).limit(limit)
)
return list(result.all())
async def context_tokens(self, conv: Conversation) -> int:
async with self._db.session() as session:
row = (
await session.exec(
select(Usage)
.where(Usage.conversation_id == conv.external_id)
.order_by(col(Usage.id).desc())
.limit(1)
)
).first()
return context_of(row)
async def context_tokens_by_id(self, ids: Iterable[str]) -> dict[str, int]:
wanted = list(ids)
if not wanted:
return {}
latest = (
select(func.max(Usage.id))
.where(col(Usage.conversation_id).in_(wanted))
.group_by(Usage.conversation_id)
)
async with self._db.session() as session:
rows = (
await session.exec(select(Usage).where(col(Usage.id).in_(latest)))
).all()
return {
cast("str", row.conversation_id): context_of(row)
for row in rows
if row.conversation_id
}
async def usage_tokens(self, since: datetime) -> int:
async with self._db.session() as session:
rows = (
await session.exec(
select(Usage).where(
col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None)
)
)
).all()
return sum(
r.input_tokens + r.output_tokens + r.cache_creation_tokens for r in rows
)
async def busy(self, conv: Conversation) -> bool:
row = await self.get_row(cast("int", conv.id)) or conv
if row.running_turn or row.pending_question:
return True
live = self._pool.get(row.external_id)
if live is not None and live.busy:
return True
pending = await self._queue.pending(cast("int", row.id))
return any(i.priority in ("user", "urgent", "wake") for i in pending)
@property
def frontends(self) -> list[Frontend]:
return list(self._frontends)
def frontend(self, name: str) -> Frontend:
for fe in self._frontends:
if fe.name == name:
return fe
msg = f"unknown frontend {name!r}"
raise ValueError(msg)
def default_agent(self, kind: Kind) -> str | None:
for fe in self._frontends:
if kind in fe.kinds and (agent := fe.agent_for(kind)):
return agent
return None
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
for fe in self._frontends:
if conv.kind not in fe.kinds:
continue
binding = await fe.materialize(conv)
if binding is not None:
return binding
return None
async def mark_closed(self, conv: Conversation) -> bool:
marked = False
for fe in self._frontends:
if conv.kind in fe.kinds:
try:
marked = await fe.mark_closed(conv) or marked
except Exception: # noqa: BLE001
_log.exception("%s could not mark %s", fe.name, conv.external_id)
return marked
async def read(self, conv: Conversation, *, window: int | None = None) -> str:
return render_messages(await self.history(conv), window=window)
async def history(self, conv: Conversation) -> list[dict[str, Any]]:
if conv.session_id is None:
async with self._db.session() as session:
return await load_messages(
session, conversation_id=cast("int", conv.id)
)
return messages_from_entries(
cast("Any", await self.entries(conv)), with_ts=True
)
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
if conv.session_id is None:
return []
key = {**self._store_key(conv), "subpath": subpath}
return list(await self._store.load(cast("Any", key)) or [])
async def subpaths(self, conv: Conversation) -> list[str]:
if conv.session_id is None:
return []
return list(await self._store.list_subkeys(cast("Any", self._store_key(conv))))
async def first_user_texts(self, ids: Iterable[int]) -> dict[int, str]:
wanted = list(ids)
if not wanted:
return {}
async with self._db.session() as session:
rows = (
await session.exec(
select(ConversationMessage).where(
col(ConversationMessage.conversation_id).in_(wanted),
ConversationMessage.seq == 0,
ConversationMessage.role == "user",
)
)
).all()
return {
r.conversation_id: text_of(json.loads(r.content_json)).strip() for r in rows
}
async def implied_title(self, conv: Conversation) -> str | None:
if conv.title:
return conv.title
text = (await self.first_user_texts([cast("int", conv.id)])).get(
cast("int", conv.id)
)
return implied_title(text)
async def chat_name(self, conv: Conversation) -> str:
"""What a ``[[wikilink]]`` to the chat says: the file's stem when it has one."""
window = await self.window_of(conv)
if window and window.endswith(".md"):
return window.rsplit("/", 1)[-1][: -len(".md")]
return await self.implied_title(conv) or conv.external_id
async def adopt(self, *, kind: Kind, first_user_text: str) -> Conversation | None:
"""The one unbound, session-less conversation whose history starts here."""
text = first_user_text.strip()
if not text:
return None
bound = select(ConversationBinding.conversation_id).where(
col(ConversationBinding.visible).is_(True)
)
async with self._db.session() as session:
rows = (
await session.exec(
select(Conversation).where(
Conversation.kind == kind,
Conversation.status == "open",
col(Conversation.session_id).is_(None),
col(Conversation.id).not_in(bound),
)
)
).all()
firsts = await self.first_user_texts(cast("int", r.id) for r in rows)
hits = [r for r in rows if firsts.get(cast("int", r.id)) == text]
return hits[0] if len(hits) == 1 else None
def implied_title(text: str | None) -> str | None:
if not text:
return None
line = text.strip().splitlines()[0].strip()
return line if len(line) <= TITLE_MAX else line[: TITLE_MAX - 1] + "…"
def context_of(row: Usage | None) -> int:
"""The last API call's input, or the per-call average for older rows."""
if row is None:
return 0
if row.context_tokens:
return row.context_tokens
total = row.input_tokens + row.cache_read_tokens + row.cache_creation_tokens
return round(total / max(row.num_turns or 1, 1))
-71
View File
@@ -1,71 +0,0 @@
"""How a new conversation starts: the seed rendered into its first prompt."""
from __future__ import annotations
import inspect
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
from beaver_gateway.conversations.kinds import as_kind
from beaver_gateway.conversations.rows import Rows
from beaver_gateway.conversations.texts import SeedContext
if TYPE_CHECKING:
from beaver_gateway.storage.models import Conversation
__all__ = ["SEEDS", "Seeds"]
SEEDS = ("clean", "morning", "copy", "brief")
class Seeds(Rows):
async def pending_seed(self, conv: Conversation) -> str | None:
"""A seed nobody has spoken after yet: rendered now, spent once."""
seed = conv.flags.get("seed")
if not seed:
return None
parent = await self.get_row(conv.parent_id) if conv.parent_id else None
ctx = SeedContext(
kind=as_kind(conv.kind),
seed=str(seed),
agent=conv.agent_name,
parent=parent,
text=None,
title=conv.title,
)
window = conv.flags.get("seed_window")
text = await self.seed_text(
ctx, window=window if isinstance(window, int) else None
)
await self.set_flags(conv, {"seed": None, "seed_window": None})
return text
async def seed_text(self, ctx: SeedContext, *, window: int | None) -> str:
texts = self._texts
stamp = datetime.now(UTC).astimezone().strftime("%Y-%m-%d %H:%M")
head = texts.seed_head.format(
seed=ctx.seed,
kind=ctx.kind,
title=f" «{ctx.title}»" if ctx.title else "",
stamp=stamp,
)
body: str | None = None
if texts.seed is not None:
produced: Any = texts.seed(ctx)
if inspect.isawaitable(produced):
produced = await produced
body = cast("str | None", produced)
if body is None:
if ctx.seed == "brief":
body = ctx.text
elif ctx.seed == "copy":
scope = (
texts.seed_copy_window.format(window=window)
if window
else texts.seed_copy_all
)
body = texts.seed_copy.format(scope=scope)
elif ctx.seed == "morning":
body = texts.seed_morning_missing
parts = [head, body, ctx.text if ctx.seed != "brief" else None]
return "\n\n".join(p for p in parts if p)
-158
View File
@@ -1,158 +0,0 @@
"""``Conversations`` - the service every frontend, job and gateway tool talks to.
Built as layers, one file each: rows → seeds → turns → messaging →
spawning → questions → closing; this file adds start, stop and restart
recovery. A turn started by a user message streams back to whoever asked;
a turn started by an inject streams nowhere and can only speak via ``say``.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import re
from datetime import UTC, datetime, timedelta, tzinfo
from sqlmodel import col, select
from beaver_gateway.conversations.closing import Closing, DistillResult
from beaver_gateway.conversations.kinds import KINDS
from beaver_gateway.conversations.rows import context_of, implied_title
from beaver_gateway.conversations.seeds import SEEDS
from beaver_gateway.conversations.spawning import ForkResult
from beaver_gateway.conversations.state import aware
from beaver_gateway.conversations.texts import (
ConversationTexts,
NewDayContext,
SeedContext,
UserSaid,
)
from beaver_gateway.storage.models import Conversation
__all__ = [
"KINDS",
"SEEDS",
"ConversationTexts",
"Conversations",
"DistillResult",
"ForkResult",
"NewDayContext",
"SeedContext",
"UserSaid",
"context_of",
"implied_title",
"parse_at",
]
_log = logging.getLogger(__name__)
_RELATIVE = re.compile(r"^\+(\d+)\s*([smhd])$")
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
class Conversations(Closing):
async def start(self) -> None:
await self.recover()
for row_id in await self._queue.conversations_with_pending():
self._ensure_worker(row_id)
self._idle_task = asyncio.create_task(self._idle_loop())
async def stop(self) -> None:
tasks = list(self._tasks)
if self._idle_task is not None:
tasks.append(self._idle_task)
for task in tasks:
task.cancel()
for task in tasks:
with contextlib.suppress(BaseException):
await task
self._tasks.clear()
self._idle_task = None
async def recover(self) -> list[Conversation]:
"""Repair the transcripts of turns a restart cut and tell each conversation."""
async with self._db.session() as session:
result = await session.exec(
select(Conversation).where(col(Conversation.running_turn).is_not(None))
)
cut = list(result.all())
for conv in cut:
fixed = 0
if conv.session_id is not None:
backend = self._backend(conv.agent_name)
try:
fixed = await backend.repair_session(
conv.session_id, text=self._texts.interrupted
)
except Exception: # noqa: BLE001
_log.exception("repair of %s failed", conv.session_id)
turn_id = conv.running_turn
async def clear(row: Conversation) -> None:
row.running_turn = None
row.pending_question = False
await self._update(conv, clear)
note = self._texts.cut_by_restart.format(turn_id=turn_id)
if fixed:
note += self._texts.repaired_tools.format(
fixed=fixed, interrupted=self._texts.interrupted
)
await self.inject(conv, note, urgency="normal", origin="system")
_log.warning("conversation %s: %s", conv.external_id, note)
for item in await self._queue.interrupted():
_log.warning(
"queue item #%s (%s) was running at restart; marked interrupted",
item.id,
item.priority,
)
return cut
async def _idle_loop(self) -> None:
while True:
try:
await self._emit_idle()
except Exception: # noqa: BLE001
_log.exception("idle watcher failed")
await asyncio.sleep(self._idle_interval)
async def _emit_idle(self) -> None:
if not self._idle_days:
return
now = datetime.now(UTC)
for conv in await self.find(status="open", limit=10_000):
last = aware(conv.last_activity_at or conv.created_at)
days = int((now - last).total_seconds() // 86400)
due = [d for d in self._idle_days if days >= d]
if not due:
continue
notified = int(conv.flags.get("idle_notified", 0) or 0)
if due[-1] <= notified:
continue
await self.set_flags(conv, {"idle_notified": due[-1]})
bindings = await self.bindings(conv)
self._bus.publish(
"conversation.idle",
conversation_id=conv.external_id,
kind=conv.kind,
agent=conv.agent_name,
days=due[-1],
bindings=[
{"frontend": b.frontend, "external_id": b.external_id}
for b in bindings
if b.visible
],
)
def parse_at(at: str, tz: tzinfo = UTC) -> datetime:
raw = at.strip()
match = _RELATIVE.match(raw.replace(" ", ""))
if match:
amount, unit = match.groups()
return datetime.now(UTC) + timedelta(seconds=int(amount) * _UNITS[unit])
parsed = datetime.fromisoformat(raw)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=tz)
return parsed.astimezone(UTC)
@@ -1,203 +0,0 @@
"""New conversations from old ones: spawn with a seed, fork a copy, merge back."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import fork_session_via_store, project_key_for_directory
from beaver_gateway.backends.transcript import strip_tool_entries, window_entries
from beaver_gateway.conversations.messaging import Messaging
from beaver_gateway.conversations.seeds import SEEDS
from beaver_gateway.conversations.texts import SeedContext
if TYPE_CHECKING:
from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.conversations.kinds import Kind
from beaver_gateway.storage.models import Conversation
__all__ = ["ForkResult", "Spawning"]
_log = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class ForkResult:
conversation: Conversation
text: str
capture: TurnCapture
class Spawning(Messaging):
async def spawn(
self,
*,
kind: Kind,
agent: str | None = None,
seed: str = "clean",
parent: Conversation | None = None,
text: str | None = None,
title: str | None = None,
window: int | None = None,
origin: str = "api",
binding: tuple[str, str] | None = None,
flags: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> Conversation:
"""Create a conversation in a window and queue its seed.
``binding`` reuses a window that already exists instead of asking the
home frontend for one. Without ``text`` the seed waits in ``flags``
and opens the first turn, so a fresh window costs nothing until
someone speaks.
"""
if seed not in SEEDS:
msg = f"unknown seed {seed!r}"
raise ValueError(msg)
if seed == "brief" and not text:
msg = "seed=brief needs text"
raise ValueError(msg)
if agent is None and kind == "branch" and parent is not None:
agent = parent.agent_name
agent = agent or self.default_agent(kind)
if agent is None:
msg = f"no default agent for kind {kind!r}; pass `agent`"
raise ValueError(msg)
session_id: str | None = None
if seed == "copy":
if parent is None or parent.session_id is None:
msg = "seed=copy needs a parent with a session"
raise ValueError(msg)
session_id = await self._copy_session(
parent, window=window, strip_tools=False
)
conv = await self.create(
kind=kind,
agent=agent,
parent=parent,
title=title,
origin=origin,
session_id=session_id,
flags=flags,
)
if binding is not None:
await self.bind(conv, frontend=binding[0], external_id=binding[1])
else:
await self.materialize(conv)
ctx = SeedContext(
kind=kind, seed=seed, agent=agent, parent=parent, text=text, title=title
)
if text is None:
return await self.set_flags(conv, {"seed": seed, "seed_window": window})
await self._queue.push(
conversation_id=cast("int", conv.id),
priority="user",
origin=f"seed:{seed}" if seed == "brief" else origin,
text=await self.seed_text(ctx, window=window),
attachments=attachments,
)
self._ensure_worker(cast("int", conv.id))
return conv
async def fork(
self,
conv: Conversation,
prompt: str,
*,
window: int | None = None,
strip_tools: bool = False,
title: str | None = None,
agent: str | None = None,
) -> ForkResult:
"""Copy the history into a one-off session, run ``prompt`` on it, close it."""
agent = agent or conv.agent_name
session_id = await self._copy_session(
conv, window=window, strip_tools=strip_tools, agent=agent
)
child = await self.create(
kind="fork",
agent=agent,
parent=conv,
title=title or f"fork: {conv.title or conv.external_id}",
origin="system",
session_id=session_id,
)
try:
text, capture = await self.run_text_turn(
child, prompt, origin="fork", tools=False
)
finally:
await self._backend(agent).close(child.external_id)
child = await self.set_status(child, "closed")
return ForkResult(conversation=child, text=text, capture=capture)
async def merge(self, conv: Conversation) -> ForkResult:
if conv.parent_id is None:
msg = "merge needs a parent conversation"
raise ValueError(msg)
parent = await self.get_row(conv.parent_id)
if parent is None:
msg = "parent conversation vanished"
raise LookupError(msg)
result = await self.fork(
conv,
self._texts.merge_prompt,
title=f"merge: {conv.title or conv.external_id}",
)
if result.text.strip():
await self.inject(parent, result.text, urgency="normal", origin="merge")
await self.set_status(conv, "merged")
await self.mark_closed(conv)
self._bus.publish(
"conversation.merged",
conversation_id=conv.external_id,
parent=parent.external_id,
fork=result.conversation.external_id,
)
return result
async def _copy_session(
self,
conv: Conversation,
*,
window: int | None,
strip_tools: bool,
agent: str | None = None,
) -> str:
if conv.session_id is None:
msg = f"conversation {conv.external_id} has no session to copy"
raise ValueError(msg)
live = self._pool.get(conv.external_id)
if live is not None and live.dirty:
msg = f"conversation {conv.external_id} has a mirror gap; not forking"
raise RuntimeError(msg)
source = self._claude_agent(conv.agent_name)
target = self._claude_agent(agent) if agent else source
forked = await fork_session_via_store(
self._store, conv.session_id, directory=str(source.cwd)
)
source_key = {
"project_key": project_key_for_directory(str(source.cwd)),
"session_id": forked.session_id,
}
target_key = {
"project_key": project_key_for_directory(str(target.cwd)),
"session_id": forked.session_id,
}
if window is not None or strip_tools or target_key != source_key:
entries = await self._store.load(cast("Any", source_key)) or []
trimmed = window_entries(cast("Any", entries), window=window)
if strip_tools:
trimmed = strip_tool_entries(trimmed)
await self._store.delete(cast("Any", source_key))
await self._store.append(cast("Any", target_key), cast("Any", trimmed))
_log.info(
"forked session %s -> %s (window=%s, strip_tools=%s)",
conv.session_id,
forked.session_id,
window,
strip_tools,
)
return forked.session_id
-191
View File
@@ -1,191 +0,0 @@
"""What every layer of the conversations service shares: wiring and lookups."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import project_key_for_directory
from beaver_gateway.conversations.injects import InjectQueue
from beaver_gateway.conversations.texts import ConversationTexts
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Sequence
from claude_agent_sdk import SessionStore
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.app import AgentRegistry
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
from beaver_gateway.backends.sessions import SessionPool
from beaver_gateway.conversations.distill import Distiller
from beaver_gateway.conversations.envelope import Envelope
from beaver_gateway.conversations.texts import UserSaid
from beaver_gateway.events.bus import EventBus
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.jobs.scheduler import Scheduler
from beaver_gateway.storage.db import Database
from beaver_gateway.storage.models import Conversation
__all__ = ["Question", "Runner", "State", "aware", "iso"]
@dataclass
class Runner:
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
wake: asyncio.Event = field(default_factory=asyncio.Event)
task: asyncio.Task[None] | None = None
turn_id: str | None = None
origin: str | None = None
text: str | None = None
started_at: datetime | None = None
tools: dict[str, dict[str, Any]] = field(default_factory=dict)
def snapshot(self) -> dict[str, Any] | None:
if self.turn_id is None:
return None
return {
"id": self.turn_id,
"origin": self.origin,
"text": self.text,
"started_at": iso(self.started_at),
"tools": list(self.tools.values()),
}
@dataclass(frozen=True, slots=True)
class Question:
conversation_id: str
turn_id: str | None
questions: list[dict[str, Any]]
answer: asyncio.Future[str]
class State:
_db: Database
_agents: AgentRegistry
_backends: dict[str, Any]
_bus: EventBus
_pool: SessionPool
_store: SessionStore
_texts: ConversationTexts
_frontends: list[Frontend]
_normal_window: float
_idle_days: tuple[int, ...]
_idle_interval: float
_question_timeout: float
_envelope: Envelope | None
_distiller: Distiller | None
_user_sink: Callable[[UserSaid], Awaitable[None] | None] | None
_queue: InjectQueue
_runners: dict[int, Runner]
_questions: dict[str, Question]
_tasks: set[asyncio.Task[None]]
_idle_task: asyncio.Task[None] | None
scheduler: Scheduler | None
def __init__(
self,
*,
db: Database,
agents: AgentRegistry,
backends: dict[str, Any],
bus: EventBus,
pool: SessionPool,
store: SessionStore,
texts: ConversationTexts | None = None,
frontends: Sequence[Frontend] = (),
normal_window: float = 3600.0,
idle_days: Sequence[int] = (2,),
idle_interval: float = 3600.0,
question_timeout: float = 600.0,
envelope: Envelope | None = None,
distiller: Distiller | None = None,
user_sink: Callable[[UserSaid], Awaitable[None] | None] | None = None,
) -> None:
self._db = db
self._agents = agents
self._backends = backends
self._bus = bus
self._pool = pool
self._store = store
self._texts = texts or ConversationTexts()
self._frontends = [f for f in frontends if f.name]
self._normal_window = normal_window
self._idle_days = tuple(sorted(idle_days))
self._idle_interval = idle_interval
self._question_timeout = question_timeout
self._envelope = envelope
self._distiller = distiller
self._user_sink = user_sink
self._queue = InjectQueue(db)
self._runners = {}
self._questions = {}
self._tasks = set()
self._idle_task = None
self.scheduler = None
for backend in backends.values():
if hasattr(backend, "waker"):
backend.waker = self._woken
async def _woken(self, key: str, note: str) -> None:
raise NotImplementedError
@property
def db(self) -> Database:
return self._db
@property
def queue(self) -> InjectQueue:
return self._queue
@property
def bus(self) -> EventBus:
return self._bus
@property
def pool(self) -> SessionPool:
return self._pool
def _backend(self, agent: str) -> ClaudeSdkBackend:
backend = self._backends.get(agent)
if backend is None or not hasattr(backend, "repair_session"):
msg = f"agent {agent!r} has no Claude SDK backend"
raise LookupError(msg)
return cast("ClaudeSdkBackend", backend)
def _claude_agent(self, name: str) -> ClaudeAgent:
agent = self._agents.get(name)
if agent is None or not hasattr(agent, "cwd"):
msg = f"unknown Claude agent {name!r}"
raise LookupError(msg)
return cast("ClaudeAgent", agent)
def _store_key(self, conv: Conversation) -> dict[str, str]:
agent = self._claude_agent(conv.agent_name)
return {
"project_key": project_key_for_directory(str(agent.cwd)),
"session_id": cast("str", conv.session_id),
}
def _runner(self, row_id: int) -> Runner:
runner = self._runners.get(row_id)
if runner is None:
runner = Runner()
self._runners[row_id] = runner
return runner
def _track(self, task: asyncio.Task[None]) -> None:
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
def aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def iso(value: datetime | None) -> str | None:
return aware(value).isoformat(timespec="seconds") if value is not None else None
-129
View File
@@ -1,129 +0,0 @@
"""Every string the gateway puts in front of a model; English defaults, overridable."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from beaver_gateway.conversations import injects
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from datetime import date, datetime
from beaver_gateway.conversations.distill import DistillContext
from beaver_gateway.conversations.kinds import Kind
from beaver_gateway.conversations.rotation import HandoutContext
from beaver_gateway.storage.models import Conversation
__all__ = [
"ConversationTexts",
"EnvelopeTexts",
"NewDayContext",
"SeedContext",
"UserSaid",
]
@dataclass(frozen=True, slots=True)
class SeedContext:
kind: Kind
seed: str
agent: str
parent: Conversation | None
text: str | None
title: str | None
@dataclass(frozen=True, slots=True)
class NewDayContext:
"""``reason`` is ``night``, ``age`` or ``context``; only ``night`` is a new day."""
day: date
reason: str
moved: int
@dataclass(frozen=True, slots=True)
class UserSaid:
conversation_id: str
kind: str
title: str | None
text: str
at: datetime
agent: str = ""
"""Agent whose turn it is; a setup with several tells them apart by it."""
@dataclass(frozen=True, slots=True)
class EnvelopeTexts:
header: str = (
"[envelope - background signal, not a message; react only if it bears "
"on the question]"
)
time: str = "time: {stamp} ({zone})"
changed: str = "vault, changed {since}: {names}"
since: str = "since {time}"
since_start: str = "since start"
truncated: str = "… (envelope cap)"
file_header: str = "--- {path}, added lines only ---"
more_lines: str = "+ … {count} more"
@dataclass(frozen=True, slots=True)
class ConversationTexts:
merge_prompt: str = (
"This branch is closing. Write a merge note for the master: what was "
"decided, what was done, what was not and why, open questions. "
"Identifiers and links verbatim. Brief, past tense."
)
inject_header: Callable[[injects.InjectContext], str] = injects.inject_header
bundle_header: str = "[injects accumulated since {since}; not the user]"
task_prompt: str = "[subagent reported back: {note}]"
"""Stands in for the user's words in mirrors of a turn a subagent started."""
interrupted: str = "interrupted"
answered: str = "The user answered: {answer}"
unanswered: str = (
"The user did not answer within {minutes} min. The question was shown "
"to them as text; finish the turn now, the answer comes as the next "
"message."
)
seed: Callable[[SeedContext], Awaitable[str | None] | str | None] | None = None
"""Body of a seed by mode; ``None`` from it falls back to the defaults below."""
seed_head: str = "[seed: {seed}] {kind}{title}, {stamp}."
seed_copy: str = "The parent's history is copied ({scope}); continue in it."
seed_copy_window: str = "last {window} turns"
seed_copy_all: str = "whole history"
seed_morning_missing: str = "No handout arrived."
handout: Callable[[HandoutContext], Awaitable[str] | str] | str = (
"This master is closing ({reason}). Write the handout for {day}: a "
"briefing for the morning, not a task list - past tense, no imperatives."
)
new_day: Callable[[NewDayContext], Awaitable[str] | str] | str = (
"The master was replaced ({reason}); the handout for {day} is written."
)
moved_injects: str = " {moved} queued injects moved over from the old master."
distill: Callable[[DistillContext], Awaitable[str] | str] | None = None
"""The distiller fork's first message; ``None`` uses the two templates below."""
distill_prompt: str = (
"Deep chat «{chat}» is closed ({reason}), today is {day}. Write the "
"digest as a file and the merge as your reply: up to 5 lines, third "
"person."
)
distill_prompt_no_memory: str = (
"Deep chat «{chat}» is closed ({reason}), today is {day}. Memory is off "
"for it: write no file, only the merge as your reply - up to 5 lines, "
"third person."
)
closed: str = "Deep chat [[{chat}]] closed{digest}.\n{text}"
closed_digest: str = ", digest [[{digest}]]"
digest_missing: str = "the digest file did not appear"
too_long: str = (
"`{name}`: {lines} lines against a cap of {max_lines}. The write was "
"rejected and the file restored. Shorten and rewrite."
)
cut_by_restart: str = "turn {turn_id} was cut by a gateway restart"
repaired_tools: str = (
"; {fixed} open tool calls received tool_result «{interrupted}»"
)
envelope: EnvelopeTexts = field(default_factory=EnvelopeTexts)
-303
View File
@@ -1,303 +0,0 @@
"""In-process MCP server with the gateway's own tools.
One server per live session so every tool knows which conversation is
calling. Which names a session gets comes from ``ClaudeAgent.gateway_tools``.
"""
from __future__ import annotations
import logging
from dataclasses import replace
from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import create_sdk_mcp_server, tool
from beaver_gateway.conversations.injects import URGENCY
from beaver_gateway.conversations.kinds import as_kind
from beaver_gateway.frontends.files import AUTO, METHODS, SendFileError
from beaver_gateway.security.redact import redact_data
URGENCY_HELP = (
"normal waits for the hourly batch or rides with the next turn, wake "
"starts a turn as soon as the target is idle, urgent cuts a running turn"
)
TARGET_HELP = (
"conversation id, or `master` for the open master, `parent` for the "
"parent of this conversation"
)
if TYPE_CHECKING:
from collections.abc import Iterable
from claude_agent_sdk import McpSdkServerConfig, SdkMcpTool
from beaver_gateway.conversations.service import Conversations
__all__ = ["SERVER_NAME", "TOOL_NAMES", "build_tool_server"]
_log = logging.getLogger("beaver_gateway.conversations.tools")
SERVER_NAME = "gateway"
SAY_IN_USER_TURN = (
"not delivered: this turn was started by the human's message, so your "
"reply text reaches them by itself - put what you wanted to say into the "
"reply instead of repeating it here"
)
TOOL_NAMES = (
"read_conversation",
"spawn",
"say",
"schedule",
"inject",
"close_chat",
"send_file",
)
SEND_FILE_HELP = (
"Send a file from disk to the human, into this conversation's window (the "
"master's if it has none): {note}. `method` is how Telegram renders it; "
"`auto` reads the extension - image is a photo, mp3/flac/m4a audio, ogg "
"voice, mp4/mov video, anything else a document. Photo and video are "
"recompressed, so pass `document` when the bytes must arrive untouched. "
"`caption` takes the same markdown as your replies."
)
def build_tool_server(
conversations: Conversations, *, conversation_key: str, names: Iterable[str]
) -> McpSdkServerConfig | None:
wanted = set(names)
unknown = wanted - set(TOOL_NAMES)
if unknown:
msg = f"unknown gateway tools: {sorted(unknown)}"
raise ValueError(msg)
tools = [
_redacting(t)
for t in _tools(conversations, conversation_key)
if t.name in wanted
]
if not tools:
return None
server = create_sdk_mcp_server(SERVER_NAME, tools=tools)
return cast("McpSdkServerConfig", {**server, "alwaysLoad": True})
def _redacting(spec: SdkMcpTool[Any]) -> SdkMcpTool[Any]:
"""Put a tool's result through the same mask as every other MCP.
These tools are mounted in-process, bypassing the ``FastMCP`` middleware
in :mod:`beaver_gateway.mcp.redacting`, so the filter is attached here
instead. ``read_conversation`` earns it: a replayed transcript can still
hold a credential written before redaction existed.
"""
inner = spec.handler
async def handler(args: Any) -> dict[str, Any]:
return cast("dict[str, Any]", redact_data(await inner(args)))
return replace(spec, handler=handler)
def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
async def current() -> Any:
conv = await conversations.get(key)
if conv is None:
msg = f"conversation {key} not found"
raise LookupError(msg)
return conv
async def target(raw: Any) -> Any:
conv = await conversations.resolve(str(raw), origin=await current())
if conv is None:
_log.warning("gateway tool in %s: conversation %r not found", key, raw)
return conv
@tool(
"read_conversation",
"Read another conversation (a branch, the master, a deep chat) as plain "
"text. `window` limits it to the last N user turns.",
{
"type": "object",
"properties": {
"id": {"type": "string", "description": TARGET_HELP},
"window": {"type": "integer", "minimum": 1},
},
"required": ["id"],
},
)
async def read_conversation(args: dict[str, Any]) -> dict[str, Any]:
conv = await target(args["id"])
if conv is None:
return _error(f"conversation {args['id']} not found ({TARGET_HELP})")
text = await conversations.read(conv, window=args.get("window"))
return _text(text or "(empty)")
@tool(
"spawn",
"Open a new conversation of the given kind (branch = your own thread, "
"deep = a long research chat, job = a headless task). `seed` is how it "
"starts: clean (nothing), morning (handout), copy (copy of this "
"conversation, last `window` turns), brief (your `text`). A branch "
"keeps your agent, other kinds get their frontend's default unless "
"`agent` says otherwise. Returns the id.",
{
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["branch", "deep", "job"]},
"agent": {"type": "string", "description": "agent name"},
"seed": {
"type": "string",
"enum": ["clean", "morning", "copy", "brief"],
"default": "clean",
},
"text": {"type": "string", "description": "brief for seed=brief"},
"title": {"type": "string"},
"window": {"type": "integer", "minimum": 1},
},
"required": ["kind"],
},
)
async def spawn(args: dict[str, Any]) -> dict[str, Any]:
parent = await current()
try:
child = await conversations.spawn(
kind=as_kind(str(args["kind"])),
agent=args.get("agent"),
seed=str(args.get("seed") or "clean"),
parent=parent,
text=args.get("text"),
title=args.get("title"),
window=args.get("window"),
origin="mcp",
)
except (ValueError, LookupError) as exc:
return _error(str(exc))
return _text(f"spawned {child.kind} {child.external_id}")
@tool(
"say",
"Say something to the human in the frontend this conversation is bound "
"to. The only way an inject-started turn can speak; silence is simply "
"not calling it. Refused in a turn started by the human's own message: "
"there your reply text reaches them by itself, so just write the reply.",
{"text": str},
)
async def say(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
if conversations.turn_origin(conv) == "user":
return _error(SAY_IN_USER_TURN)
await conversations.say(conv, str(args["text"]))
return _text("ok")
@tool(
"schedule",
"Promise yourself an inject later: `at` is `+15m`, `+2h`, `+1d` or an "
"ISO datetime; `text` arrives in this conversation at that time and "
"starts a turn (urgency wake). " + URGENCY_HELP + ".",
{
"type": "object",
"properties": {
"at": {"type": "string"},
"text": {"type": "string"},
"urgency": {"type": "string", "enum": list(URGENCY), "default": "wake"},
},
"required": ["at", "text"],
},
)
async def schedule(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
try:
job_id, when = await conversations.schedule(
conv,
str(args["at"]),
str(args["text"]),
urgency=cast("Any", args.get("urgency") or "wake"),
)
except (RuntimeError, ValueError) as exc:
return _error(str(exc))
return _text(f"scheduled #{job_id} at {when.isoformat(timespec='minutes')}")
@tool(
"inject",
"Put a system-origin message into another conversation's queue. "
+ URGENCY_HELP
+ ". An error here means nothing was delivered.",
{
"type": "object",
"properties": {
"conversation": {"type": "string", "description": TARGET_HELP},
"text": {"type": "string"},
"urgency": {
"type": "string",
"enum": list(URGENCY),
"default": "normal",
},
},
"required": ["conversation", "text"],
},
)
async def inject(args: dict[str, Any]) -> dict[str, Any]:
conv = await target(args["conversation"])
if conv is None:
return _error(
f"conversation {args['conversation']} not found ({TARGET_HELP})"
)
item = await conversations.inject(
conv,
str(args["text"]),
urgency=cast("Any", args.get("urgency") or "normal"),
origin="agent",
)
return _text(f"queued #{item.id}")
@tool(
"send_file",
SEND_FILE_HELP.format(note=conversations.file_note()),
{
"type": "object",
"properties": {
"path": {"type": "string", "description": "absolute path on disk"},
"caption": {"type": "string"},
"method": {"type": "string", "enum": [AUTO, *METHODS], "default": AUTO},
},
"required": ["path"],
},
)
async def send_file(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
try:
return _text(
await conversations.send_file(
conv,
str(args["path"]),
caption=str(args.get("caption") or ""),
method=str(args.get("method") or AUTO),
)
)
except SendFileError as exc:
return _error(str(exc))
@tool(
"close_chat",
"Close this deep chat once the current reply is finished: the "
"distiller forks it, writes the digest and hands a short merge to the "
"master. Call it when the human says the discussion is over; the chat "
"file stays as it is.",
{"type": "object", "properties": {}},
)
async def close_chat(_args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
try:
await conversations.request_close(conv)
except ValueError as exc:
return _error(str(exc))
return _text("ok: the chat closes after this reply")
return [read_conversation, spawn, say, schedule, inject, send_file, close_chat]
def _text(text: str) -> dict[str, Any]:
return {"content": [{"type": "text", "text": text}]}
def _error(text: str) -> dict[str, Any]:
return {"content": [{"type": "text", "text": text}], "is_error": True}
-555
View File
@@ -1,555 +0,0 @@
"""Running turns: one worker per conversation over its queue, the backend call."""
from __future__ import annotations
import asyncio
import base64
import contextlib
import inspect
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
from claude_agent_sdk import (
AssistantMessage,
RateLimitEvent,
ResultMessage,
StreamEvent,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.backends.transcript import text_of
from beaver_gateway.conversations import injects
from beaver_gateway.conversations.seeds import Seeds
from beaver_gateway.conversations.state import Runner, aware, iso
from beaver_gateway.conversations.texts import UserSaid
from beaver_gateway.frontends.accumulate import StreamAccumulator
from beaver_gateway.storage.models import Conversation, InjectQueueItem, RateLimit
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Sequence
from beaver_gateway.events.stream import MessageStreamEvent
__all__ = ["Turns", "content_of", "image_block"]
_log = logging.getLogger(__name__)
class Turns(Seeds):
async def turn(
self,
conv: Conversation,
*,
messages: Sequence[Any],
origin: str,
capture: TurnCapture | None = None,
session_id: str | None = None,
use_session: bool = True,
tools: bool = True,
turn_id: str | None = None,
item_origin: str | None = None,
shown: str | None = None,
attach: bool = False,
) -> AsyncIterator[MessageStreamEvent]:
"""Run one turn under the conversation's lock; the only path to the backend.
``shown`` is what frontends display as the turn's prompt (the user's
words, not the envelope). ``attach`` sends nothing: the turn is one
the live session started by itself, a subagent reporting back.
"""
row_id = cast("int", conv.id)
runner = self._runner(row_id)
backend = self._backend(conv.agent_name)
turn_id = turn_id or f"turn_{uuid4().hex[:12]}"
capture = capture or TurnCapture()
resume = session_id if session_id is not None else conv.session_id
async with runner.lock:
runner.turn_id = turn_id
runner.origin = origin
runner.text = shown if shown is not None else _prompt_preview(messages)
runner.started_at = datetime.now(UTC)
runner.tools = {}
await self._mark_running(conv, turn_id)
before = await self.before_turn(conv)
self._bus.publish(
"turn.start",
conversation_id=conv.external_id,
turn_id=turn_id,
origin=origin,
item_origin=item_origin,
text=runner.text,
)
stop = "error"
cut = False
try:
observer = self._observer(conv, runner, turn_id, origin)
events = (
backend.attach(
key=conv.external_id,
capture=capture,
observer=observer,
turn_id=turn_id,
)
if attach
else backend.complete(
agent=self._claude_agent(conv.agent_name),
messages=messages,
conversation_id=conv.external_id,
session_id=resume if use_session else None,
reseed=not use_session,
capture=capture,
kind=conv.kind,
pinned=conv.kind == "master",
tools=tools,
observer=observer,
turn_id=turn_id,
)
)
async for event in events:
yield event
stop = "interrupted" if capture.interrupted else "end_turn"
except asyncio.CancelledError:
cut = True
raise
finally:
runner.turn_id = None
await self._mark_done(conv, capture, cut=cut)
if stop != "error":
try:
await self.after_turn(conv, before)
except Exception: # noqa: BLE001
_log.exception("after-turn hook on %s failed", conv.external_id)
self._bus.publish(
"turn.end",
conversation_id=conv.external_id,
turn_id=turn_id,
origin=origin,
item_origin=item_origin,
stop=stop,
usage=_usage_dict(capture),
)
async def run_text_turn(
self,
conv: Conversation,
text: str,
*,
origin: str,
tools: bool = True,
turn_id: str | None = None,
item_origin: str | None = None,
attachments: Sequence[dict[str, Any]] | None = None,
shown: str | None = None,
attach: bool = False,
) -> tuple[str, TurnCapture]:
capture = TurnCapture()
acc = StreamAccumulator()
agent = self._claude_agent(conv.agent_name)
async for event in self.turn(
conv,
messages=[{"role": "user", "content": content_of(text, attachments)}],
origin=origin,
capture=capture,
tools=tools,
turn_id=turn_id,
item_origin=item_origin,
shown=shown,
attach=attach,
):
acc.feed(event)
message = acc.finalize(model=agent.model)
reply = "\n\n".join(
getattr(b, "text", "")
for b in message.content
if getattr(b, "type", "") == "text"
).strip()
return reply, capture
async def before_turn(self, conv: Conversation) -> str | None: # noqa: ARG002
return None
async def after_turn(self, conv: Conversation, before: str | None) -> None: # noqa: ARG002
return
def turn_origin(self, conv: Conversation) -> str | None:
runner = self._runners.get(cast("int", conv.id))
return runner.origin if runner is not None and runner.turn_id else None
async def _woken(self, key: str, note: str) -> None:
"""The live session of ``key`` started a turn by itself; run it next."""
conv = await self.get(key)
if conv is None:
_log.warning("self-started turn on unknown conversation %s: %s", key, note)
return
_log.info("conversation %s woke by itself: %s", key, note)
self._ensure_worker(cast("int", conv.id))
async def _run_pending(self, conv: Conversation) -> None:
turn_id = f"turn_{uuid4().hex[:12]}"
note = self._backend(conv.agent_name).pending_note(conv.external_id) or ""
try:
text, _ = await self.run_text_turn(
conv,
note,
origin="task",
turn_id=turn_id,
item_origin="task",
shown=note,
attach=True,
)
except Exception: # noqa: BLE001
_log.exception(
"self-started turn %s on %s failed", turn_id, conv.external_id
)
return
if not text.strip():
return
self._bus.publish(
"reply",
conversation_id=conv.external_id,
turn_id=turn_id,
item=None,
item_origin="task",
source="task",
prompt=self._texts.task_prompt.format(note=note),
user_text=None,
text=text,
)
def _ensure_worker(self, row_id: int) -> None:
runner = self._runner(row_id)
runner.wake.set()
if runner.task is None or runner.task.done():
runner.task = asyncio.create_task(self._worker(row_id))
self._track(runner.task)
async def _worker(self, row_id: int) -> None:
runner = self._runner(row_id)
while True:
conv = await self.get_row(row_id)
if conv is not None and self._has_pending(conv):
await self._run_pending(conv)
continue
items = await self._queue.pending(row_id)
batch, wait = self._pick(items)
if batch is None:
runner.wake.clear()
if wait is None:
return
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(runner.wake.wait(), timeout=wait)
continue
conv = await self.get_row(row_id)
if conv is None:
await self._queue.finish(batch, status="failed")
return
await self._run_batch(conv, batch)
def _pick(
self, items: list[InjectQueueItem]
) -> tuple[list[InjectQueueItem] | None, float | None]:
if not items:
return None, None
head = items[0]
if head.priority == "urgent":
return [head], None
tail = [i for i in items if i is not head and i.priority in ("wake", "normal")]
if head.priority in ("user", "wake"):
return [head, *tail], None
age = (datetime.now(UTC) - aware(head.created_at)).total_seconds()
if age >= self._normal_window:
return [head, *tail], None
return None, max(self._normal_window - age, 1.0)
async def _run_batch(
self, conv: Conversation, batch: list[InjectQueueItem]
) -> None:
head = batch[0]
turn_id = f"turn_{uuid4().hex[:12]}"
await self._queue.start(batch, turn_id)
if head.priority == "user":
origin = "user"
prompt = head.text
await self._note_user(conv, head.text)
envelope = self._envelope_for(conv, head.text)
if envelope:
prompt += "\n\n" + envelope
if len(batch) > 1:
prompt += "\n\n" + self._bundle(batch[1:])
else:
origin = "inject"
prompt = "\n\n".join(
f"{self._texts.inject_header(injects.context_of(i))}\n{i.text}"
for i in batch
)
seed = await self.pending_seed(conv)
if seed:
prompt = f"{seed}\n\n{prompt}"
try:
text, capture = await self.run_text_turn(
conv,
prompt,
origin=origin,
turn_id=turn_id,
item_origin=head.origin,
attachments=head.attachments if origin == "user" else None,
shown=head.text if origin == "user" else None,
)
except Exception: # noqa: BLE001
_log.exception("turn %s on %s failed", turn_id, conv.external_id)
await self._queue.finish(batch, status="failed")
return
await self._queue.finish(
batch, status="interrupted" if capture.interrupted else "done"
)
if origin == "user":
self._bus.publish(
"reply",
conversation_id=conv.external_id,
turn_id=turn_id,
item=head.id,
item_origin=head.origin,
source="queue",
prompt=prompt,
user_text=head.text,
text=text,
)
def _has_pending(self, conv: Conversation) -> bool:
try:
return self._backend(conv.agent_name).has_pending(conv.external_id)
except LookupError:
return False
def _bundle(self, items: Sequence[InjectQueueItem]) -> str:
lines = [self._texts.bundle_header.format(since=iso(items[0].created_at))]
lines.extend(f"- [{i.origin}] {i.text}" for i in items)
return "\n".join(lines)
def _envelope_for(self, conv: Conversation, text: str = "") -> str | None:
if self._envelope is None:
return None
if conv.kind == "master":
return self._envelope.build(text=text, kind="master", agent=conv.agent_name)
if conv.kind == "branch":
return self._envelope.recall_only(
text=text, kind="branch", agent=conv.agent_name
)
return None
async def _note_user(self, conv: Conversation, text: str) -> None:
if self._user_sink is None:
return
message = UserSaid(
conversation_id=conv.external_id,
kind=conv.kind,
title=conv.title,
text=text,
at=datetime.now(UTC),
agent=conv.agent_name,
)
try:
result = self._user_sink(message)
if inspect.isawaitable(result):
await result
except Exception: # noqa: BLE001
_log.exception("user sink failed for %s", conv.external_id)
def _observer(
self, conv: Conversation, runner: Runner, turn_id: str, origin: str
) -> Callable[[Any], None]:
conversation_id = conv.external_id
def observe(message: Any) -> None:
parent = getattr(message, "parent_tool_use_id", None)
if isinstance(message, RateLimitEvent):
self._observe_rate_limit(conv, message)
elif isinstance(message, StreamEvent):
self._bus.publish(
"stream",
conversation_id=conversation_id,
turn_id=turn_id,
origin=origin,
parent_tool_use_id=parent,
event=message.event,
)
elif isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
event = self._bus.publish(
"tool",
conversation_id=conversation_id,
turn_id=turn_id,
origin=origin,
parent_tool_use_id=parent,
tool_use_id=block.id,
name=block.name,
input=block.input,
)
runner.tools[block.id] = {
"tool_use_id": block.id,
"name": block.name,
"input": block.input,
"parent_tool_use_id": parent,
"started_at": event["ts"],
"ended_at": None,
"is_error": None,
"content": None,
}
elif isinstance(message, UserMessage):
blocks = message.content if isinstance(message.content, list) else ()
for block in blocks:
if isinstance(block, ToolResultBlock):
event = self._bus.publish(
"tool.result",
conversation_id=conversation_id,
turn_id=turn_id,
origin=origin,
parent_tool_use_id=parent,
tool_use_id=block.tool_use_id,
is_error=bool(block.is_error),
content=_result_preview(block.content),
)
node = runner.tools.get(block.tool_use_id)
if node is not None:
node["ended_at"] = event["ts"]
node["is_error"] = event["is_error"]
node["content"] = event["content"]
elif isinstance(message, ResultMessage) and parent is None:
self._bus.publish(
"result",
conversation_id=conversation_id,
turn_id=turn_id,
origin=origin,
subtype=message.subtype,
is_error=message.is_error,
num_turns=message.num_turns,
)
return observe
def _observe_rate_limit(self, conv: Conversation, message: RateLimitEvent) -> None:
info = message.rate_limit_info
row = RateLimit(
window=info.rate_limit_type or "unknown",
status=info.status,
utilization=info.utilization,
resets_at=_from_unix(info.resets_at),
overage_status=info.overage_status,
overage_resets_at=_from_unix(info.overage_resets_at),
agent_name=conv.agent_name,
session_id=message.session_id,
raw=dict(info.raw),
)
self._bus.publish(
"rate_limit",
conversation_id=conv.external_id,
window=row.window,
status=row.status,
utilization=row.utilization,
resets_at=iso(row.resets_at),
overage_status=row.overage_status,
)
self._track(asyncio.create_task(self._record_rate_limit(row)))
async def _record_rate_limit(self, row: RateLimit) -> None:
try:
async with self._db.session() as session:
session.add(row)
await session.commit()
except Exception: # noqa: BLE001
_log.exception("rate limit write failed")
async def _mark_running(self, conv: Conversation, turn_id: str) -> None:
async def apply(row: Conversation) -> None:
row.running_turn = turn_id
row.last_activity_at = datetime.now(UTC)
await self._update(conv, apply)
async def _mark_done(
self, conv: Conversation, capture: TurnCapture, *, cut: bool = False
) -> None:
async def apply(row: Conversation) -> None:
if not cut:
row.running_turn = None
row.last_activity_at = datetime.now(UTC)
if capture.session_id is not None:
row.session_id = capture.session_id
await self._update(conv, apply)
IMAGE_MAX_BYTES = 5 * 1024 * 1024
def content_of(
text: str, attachments: Sequence[dict[str, Any]] | None
) -> str | list[dict[str, Any]]:
"""Plain text, or text plus the images that ride with it."""
blocks = [b for a in attachments or () if (b := image_block(a)) is not None]
if not blocks:
return text
return [{"type": "text", "text": text}, *blocks]
def image_block(attachment: dict[str, Any]) -> dict[str, Any] | None:
media_type = str(attachment.get("media_type") or "")
path = Path(str(attachment.get("path") or ""))
if not media_type.startswith("image/") or not path.is_file():
return None
if path.stat().st_size > IMAGE_MAX_BYTES:
return None
data = base64.b64encode(path.read_bytes()).decode("ascii")
return {
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data},
}
def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None:
if not messages:
return None
text = text_of(messages[-1].get("content"))
return text[:limit] if text else None
def _from_unix(value: int | None) -> datetime | None:
return datetime.fromtimestamp(value, tz=UTC) if value is not None else None
def _result_preview(
content: str | list[dict[str, Any]] | None, limit: int = 400
) -> str:
if content is None:
return ""
text = (
content
if isinstance(content, str)
else "\n".join(
str(part.get("text", ""))
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
)
return text if len(text) <= limit else text[:limit] + "…"
def _usage_dict(capture: TurnCapture) -> dict[str, Any] | None:
usage = capture.usage
if usage is None:
return None
return {
"input": usage.input_tokens,
"output": usage.output_tokens,
"cache_read": usage.cache_read_tokens,
"cache_creation": usage.cache_creation_tokens,
"cost_usd": usage.cost_usd,
"duration_ms": usage.duration_ms,
}
+7
View File
@@ -0,0 +1,7 @@
"""Cross-cutting machinery: registries, event protocol, auth, sessions."""
from __future__ import annotations
from beaver_gateway.core.registry import AgentRegistry, Gateway, McpRegistry
__all__ = ["AgentRegistry", "Gateway", "McpRegistry"]
+93
View File
@@ -0,0 +1,93 @@
"""Single entry point for writing :class:`AuditLog` rows.
Every frontend ends up needing the same three-line pattern — open a DB
session, append a row, swallow failures so the user-visible request
still succeeds. Phase 4.3 inlined that pattern in the admin frontend
under a private ``_audit()`` helper; Phase 4.4 lifts it here so the
Messages and MCP frontends can call the same function and so the
swallow-and-log policy lives in one place.
The contract:
* ``log(runtime, actor=..., kind=...)`` is fire-and-forget. It awaits
the DB write (so callers can ``await`` it before responding and get
ordering), but never raises — if the audit insert fails, the function
emits an ``exception`` log line and returns.
* ``actor`` is a free-form string. By convention: ``"token:<name>"``
for bearer-authenticated traffic, ``"admin:<user>"`` for admin-UI
actions, ``"anon"`` for failed-auth paths we still want to record.
* ``kind`` is a short tag — see :data:`KNOWN_KINDS` for the set the
current frontends emit; new tags don't need a code change here, the
column is free-form.
* ``**detail`` is JSON-serialised by :func:`append_audit`. Keep it
small: paths, methods, status codes — not request bodies. Anything
passed here lands in ``AuditLog.detail_json`` verbatim.
Why a thin wrapper rather than ``append_audit`` directly: callers want
"write if you can, otherwise carry on", and pulling the try/except into
every frontend was already starting to drift (admin had it, bearer
frontends would have copy-pasted). One module, one policy.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from beaver_gateway.storage import append_audit
if TYPE_CHECKING:
from beaver_gateway.frontends.base import GatewayRuntime
_log = logging.getLogger("beaver_gateway.audit")
# Tags currently emitted by the gateway. The set is informational —
# ``AuditLog.kind`` is free-form so new code can introduce new tags
# without touching this list — but listing them here gives the admin UI
# and any downstream log consumers one canonical reference.
KNOWN_KINDS: frozenset[str] = frozenset(
{
"messages", # POST /v1/messages accepted
"mcp_call", # /mcp/<ns>/... proxied
"login_ok",
"login_failed",
"logout",
"token_create",
"token_revoke",
}
)
async def log(
runtime: GatewayRuntime,
*,
actor: str,
kind: str,
agent_name: str | None = None,
**detail: Any,
) -> None:
"""Best-effort audit insert. Never raises.
Opens its own short-lived :class:`AsyncSession` so callers don't
have to thread one through. If the DB hiccups (table missing,
disk full, connection drop), we log and move on — the audit trail
is observability, not a hard precondition for serving the request.
"""
try:
async with runtime.db.session() as session:
await append_audit(
session,
actor=actor,
kind=kind,
agent_name=agent_name,
detail=detail or None,
)
except Exception: # noqa: BLE001
_log.exception(
"audit write failed: actor=%s kind=%s agent=%s", actor, kind, agent_name
)
__all__ = ["KNOWN_KINDS", "log"]
@@ -1,7 +1,32 @@
"""Bearer-token verification: a DB-backed store with an in-memory cache.
"""Bearer-token verification (Phase 4.2 — DB-backed with in-memory cache).
Optional ``BOOTSTRAP_TOKENS`` env entries are checked first. Verify takes
a raw token or ``Authorization`` header and returns a :class:`TokenIdentity`.
The store is fed by two sources:
1. **DB** (``Token`` table from Phase 4.1) — the primary source. Rows
carry Argon2id hashes; the admin UI (Phase 4.3) will be the only
writer at steady state.
2. **`BOOTSTRAP_TOKENS`** env — a name→plaintext map kept around for
first-run, disaster-recovery, and ``examples/`` smoke tests. These
entries live alongside DB rows in the cache and are never persisted.
Hot path is in-memory: at :meth:`start` we pull every non-revoked DB
row and stash it in a list; subsequent :meth:`verify` calls re-load
when the cache is older than ``ttl_seconds``. ``last_used_at`` updates
are coalesced into a small dict and flushed by a background task every
``flush_interval`` seconds — one transaction per flush rather than one
per request.
We can't index DB rows by a derived plaintext key because Argon2 salts
are random — so verify does a linear scan over candidates, calling
``argon2.PasswordHasher.verify`` on each. N is small by design (single
operator, ~10 tokens at most); the cost is irrelevant. The scan runs
through ``asyncio.to_thread`` to keep the event loop free of the ~50ms
KDF block.
The module knows nothing about HTTP frameworks. It takes a raw token
(or a verbatim ``Authorization`` header value) and returns a
:class:`TokenIdentity` (name + scope + db-id), or ``None`` for a miss.
Frontends own the 401 response shape.
"""
from __future__ import annotations
@@ -21,7 +46,7 @@ from argon2.exceptions import InvalidHashError, VerifyMismatchError
from beaver_gateway.storage import list_active_tokens, touch_token
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from collections.abc import Mapping
from beaver_gateway.storage import Database
@@ -35,34 +60,25 @@ class TokenStoreError(ValueError):
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
BUILTIN_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
"""The scopes the gateway's own frontends define.
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin"})
"""The scopes a ``Token.scope`` may hold (Phase 4.3 admin UI enforces).
* ``*`` — wildcard, may use any frontend
* ``messages`` — Anthropic Messages frontend only
* ``mcp`` — MCP server frontend only
* ``api`` — conversations API (``/api``) and its SSE
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
itself authenticates via session cookies, not bearer tokens, so this
scope is unused today.
A setup's own frontend adds its scope on top; see :func:`scopes_with` and
``GatewayRuntime.scopes``, which is what ``POST /api/tokens`` validates.
scope is unused today and kept for forward compatibility.
"""
def scopes_with(extra: Iterable[str | None]) -> frozenset[str]:
"""The builtin scopes plus every frontend's own; ``None`` entries drop out."""
return BUILTIN_SCOPES | {s for s in extra if s}
@dataclass(frozen=True, slots=True)
class TokenIdentity:
"""What :meth:`TokenStore.verify` resolves to on success.
``token_id`` is the DB row id for persisted tokens, or ``None`` for
an env-bootstrap match (those have no DB row to touch). ``scope``
gates which frontend the token may hit (see :data:`BUILTIN_SCOPES`);
gates which frontend the token may hit (see :data:`VALID_SCOPES`);
bootstrap tokens implicitly get ``"*"``.
"""
@@ -75,8 +91,8 @@ class TokenIdentity:
``"*"`` is the wildcard; an exact match satisfies a single scope.
Unknown ``required`` values intentionally fall through to a
strict equality check — callers should pass a scope the runtime
knows (:data:`BUILTIN_SCOPES` plus every frontend's own).
strict equality check — callers should pass one of
:data:`VALID_SCOPES`.
"""
return self.scope in ("*", required)
@@ -91,23 +107,33 @@ class _CachedToken:
hashed_value: str
# Default Argon2id parameters from ``argon2-cffi`` are fine for our scope.
# They target ~50ms on a modern CPU — enough to make a stolen-hash brute
# force expensive, cheap enough to verify a handful per request.
_HASHER = PasswordHasher()
def hash_token(plaintext: str) -> str:
"""Return an Argon2id hash for ``plaintext``, for admin/seed use."""
"""Return an Argon2id hash for ``plaintext`` (admin / seed-only path).
Phase 4.3 will call this when the admin creates a token; Phase 4.2
exposes it so smoke scripts can seed the DB without re-implementing
the same line.
"""
return _HASHER.hash(plaintext)
class TokenStore:
"""DB-backed verifier with an in-memory cache, TTL, and batched touches.
"""DB-backed verifier with in-memory cache + TTL + batched touches.
Call ``await store.start()`` after construction to prime the cache
and start the flusher; ``await store.stop()`` drains the touch queue.
Construct in ``cli.main`` after :class:`Database` is up, then
``await store.start()`` to prime the cache and spin up the flusher
task. ``await store.stop()`` on shutdown drains the touch queue.
Bootstrap entries (``BOOTSTRAP_TOKENS``) are checked first and stay
usable even if the DB is unreachable; they never get flushed to
``last_used_at`` since they have no DB row.
Bootstrap entries (from ``BOOTSTRAP_TOKENS``) sit alongside DB rows
in the same lookup path; we check them first, in constant time, so
they remain usable even if the DB is unreachable. They never appear
in ``last_used_at`` flushes because they have no DB row.
"""
__slots__ = (
@@ -128,15 +154,11 @@ class TokenStore:
db: Database | None = None,
*,
bootstrap: Mapping[str, str] | None = None,
bootstrap_scopes: Mapping[str, str] | None = None,
ttl_seconds: float = 30.0,
flush_interval: float = 5.0,
) -> None:
"""Index bootstrap tokens for O(1) verify lookup.
Each one's scope defaults to ``"*"`` unless narrowed via
``name:value:scope``.
"""
# Bootstrap is keyed by value internally so verify is O(1) over
# plaintext. Each value also keeps its name for audit lines.
by_value: dict[str, str] = {}
for name, value in (bootstrap or {}).items():
if not name or not value:
@@ -150,10 +172,9 @@ class TokenStore:
raise TokenStoreError(msg)
by_value[value] = name
self._bootstrap_by_value: dict[str, str] = by_value
self._bootstrap_scopes: dict[str, str] = {
name: (bootstrap_scopes or {}).get(name, _BOOTSTRAP_SCOPE)
for name in by_value.values()
}
self._bootstrap_scopes: dict[str, str] = dict.fromkeys(
by_value.values(), _BOOTSTRAP_SCOPE
)
self._db = db
self._ttl = ttl_seconds
@@ -165,25 +186,38 @@ class TokenStore:
self._touch_queue: dict[int, datetime] = {}
self._flusher_task: asyncio.Task[None] | None = None
@staticmethod
def parse_bootstrap(raw: str) -> dict[str, str]:
"""Parse ``name1:value1,name2:value2[:scope]`` (``BOOTSTRAP_TOKENS``)."""
return {name: value for name, (value, _scope) in _parse_entries(raw).items()}
# ---- bootstrap parsing (kept for `cli` / tests) ---------------------
@staticmethod
def parse_bootstrap_scopes(raw: str) -> dict[str, str]:
"""Scopes given as a third field in ``BOOTSTRAP_TOKENS``; the rest get ``*``."""
return {
name: scope
for name, (_value, scope) in _parse_entries(raw).items()
if scope is not None
}
def parse_bootstrap(raw: str) -> dict[str, str]:
"""Parse ``name1:value1,name2:value2`` (the ``BOOTSTRAP_TOKENS`` form)."""
tokens: dict[str, str] = {}
for chunk in raw.split(","):
entry = chunk.strip()
if not entry:
continue
name, sep, value = entry.partition(":")
if not sep:
msg = f"token entry missing ':' separator: {entry!r}"
raise TokenStoreError(msg)
name, value = name.strip(), value.strip()
if name in tokens:
msg = f"duplicate token name: {name!r}"
raise TokenStoreError(msg)
tokens[name] = value
return tokens
@classmethod
def from_env(cls, raw: str, db: Database | None = None) -> TokenStore:
"""Bootstrap-only (or bootstrap + db) convenience constructor."""
"""Legacy entrypoint: bootstrap-only (or bootstrap + db).
Phase 1.3 call sites still expect a one-liner; we keep the
classmethod so they don't have to learn the new constructor.
"""
return cls(db, bootstrap=cls.parse_bootstrap(raw))
# ---- lifecycle ------------------------------------------------------
async def start(self) -> None:
"""Prime the cache and (if a DB is attached) start the flusher loop."""
await self._refresh()
@@ -201,25 +235,20 @@ class TokenStore:
self._flusher_task = None
await self._flush_now()
def grant(self, name: str, value: str, *, scope: str = _BOOTSTRAP_SCOPE) -> None:
"""Add an in-memory token for the process lifetime (the admin UI's bearer)."""
self._bootstrap_by_value[value] = name
self._bootstrap_scopes[name] = scope
async def invalidate(self) -> None:
"""Force the next verify to re-read from DB."""
"""Force the next verify to re-read from DB (Phase 4.3 admin hook)."""
self._loaded_at = 0.0
async def verify(self, token: str | None) -> TokenIdentity | None:
"""Return the matching identity, or ``None`` for unknown/empty tokens.
# ---- verify path ----------------------------------------------------
Bootstrap entries are checked first via constant-time compare;
the DB cache list is swapped rather than mutated on refresh, so
a concurrent reload can't affect an in-progress scan.
"""
async def verify(self, token: str | None) -> TokenIdentity | None:
"""Return the matching identity, or ``None`` for unknown/empty tokens."""
if not token:
return None
# Bootstrap first: constant-time compare per entry, never hits DB.
# `compare_digest` is overkill for a name→value lookup but cheap
# and removes one timing variable for free.
for value, name in self._bootstrap_by_value.items():
if hmac.compare_digest(token, value):
return TokenIdentity(
@@ -233,6 +262,9 @@ class TokenStore:
await self._ensure_fresh()
# Snapshot the cache reference so a refresh mid-scan doesn't
# surprise us. List itself is immutable per refresh (we swap,
# not mutate).
cache = self._cache
for entry in cache:
try:
@@ -268,18 +300,17 @@ class TokenStore:
def __bool__(self) -> bool:
return bool(self._cache) or bool(self._bootstrap_by_value)
async def _ensure_fresh(self) -> None:
"""Reload the cache when stale.
# ---- internals ------------------------------------------------------
Double-checked under the lock so concurrent callers don't all
trigger a reload.
"""
async def _ensure_fresh(self) -> None:
if self._db is None:
return
now = time.monotonic()
if now - self._loaded_at <= self._ttl:
return
async with self._lock:
# Re-check under the lock — first arrival reloaded, others
# should fall through.
now = time.monotonic()
if now - self._loaded_at <= self._ttl:
return
@@ -294,6 +325,8 @@ class TokenStore:
next_cache: list[_CachedToken] = []
for row in rows:
if row.id is None:
# Defensive: SQLModel will assign an id on insert; a
# None here would mean someone handed us an unsaved row.
continue
next_cache.append(
_CachedToken(
@@ -314,48 +347,28 @@ class TokenStore:
await self._flush_now()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001 — never let the flusher die silently
_log.exception("token flusher crashed; touches will stop")
async def _flush_now(self) -> None:
"""Flush queued touches in one transaction.
Detaches the queue first so concurrent ``verify()`` writes land
in the next flush.
"""
if self._db is None or not self._touch_queue:
return
# Detach the queue so concurrent verify() writes don't bleed
# into the in-flight transaction.
pending, self._touch_queue = self._touch_queue, {}
async with self._db.session() as session:
for token_id in pending:
# We don't pass the timestamp through — `touch_token`
# stamps `now` itself, and we'd rather have one source
# of truth than reconcile clocks.
await touch_token(session, token_id=token_id)
_log.debug("flushed %d token touch(es)", len(pending))
__all__ = [
"BUILTIN_SCOPES",
"VALID_SCOPES",
"TokenIdentity",
"TokenStore",
"TokenStoreError",
"hash_token",
"scopes_with",
]
def _parse_entries(raw: str) -> dict[str, tuple[str, str | None]]:
entries: dict[str, tuple[str, str | None]] = {}
for chunk in raw.split(","):
entry = chunk.strip()
if not entry:
continue
name, sep, rest = entry.partition(":")
if not sep:
msg = f"token entry missing ':' separator: {entry!r}"
raise TokenStoreError(msg)
value, sep, scope = rest.partition(":")
name, value, scope = name.strip(), value.strip(), scope.strip()
if name in entries:
msg = f"duplicate token name: {name!r}"
raise TokenStoreError(msg)
entries[name] = (value, scope if sep and scope else None)
return entries
@@ -1,7 +1,41 @@
"""Stored conversation history for the markdown frontend.
"""Stateful conversation history for the markdown frontend.
Aligns the file the user is editing against the DB-stored Anthropic-shape
message history, splicing text edits or forking on structural changes.
The gateway used to be stateless about identity: claude-code-api's
in-memory session pool was keyed by a fingerprint of the messages the
gateway forwarded, and on a fingerprint miss the same fingerprint was
used to seed a fresh PTY's JSONL transcript. That worked as long as
the frontend could round-trip the *exact* content blocks the live
session had observed. The markdown frontend can't — the parser strips
``[!tool]-`` callouts because the human is allowed to edit the prose,
and the rendered tool callouts don't carry the canonical ``tool_use``
block fields anyway. So a continuation hit was *only* reliable for
turns that never used a tool; once tools entered the picture, every
subsequent turn missed the cache and reseeded from a tool-less
transcript, leading to "assistant doesn't remember the tool calls it
just made."
This module makes the gateway stateful for the markdown frontend (and
any other frontend that wants in). The DB stores the full
Anthropic-shape message list — text blocks, ``tool_use`` blocks,
``tool_result`` blocks, thinking signatures — exactly as
claude-code-api would have seen on the wire. Before each turn we
align the file the user is editing against the stored history:
* If the user just appended a new user turn at the bottom, we feed
the backend our stored-plus-new history and the fingerprint hits.
* If the user edited the *text* inside an assistant turn but left the
tool callouts alone, we splice the new text into the stored
``tool_use`` blocks and feed *that* — the fingerprint misses (text
differs), claude-code-api reseeds with a full transcript (tools and
all), the new live session has memory of the prior tool calls.
* If the user changed the *structure* (added/removed/reordered a tool
callout, edited an old user turn, etc.) we fork: take stored history
up to the divergence, take incoming text-only past the divergence.
The fingerprint misses; claude-code-api reseeds with a clean
truncated history; downstream turns continue from there.
"Divergence point" is found by walking the file's turns and the
stored display turns in lockstep. See :func:`diff_and_fork`.
"""
from __future__ import annotations
@@ -29,27 +63,32 @@ __all__ = [
"load_messages",
"mint_conversation",
"rewrite_messages",
"set_session_id",
]
# ---- types --------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class ForkOutcome:
"""Result of aligning the incoming file against stored history.
``messages`` is the backend input; ``persist_messages`` is history to
store before the new reply; ``divergence_index`` is where they diverged.
``messages`` is what the gateway feeds to the backend (already
includes the new user prompt at the tail). ``persist_messages``
is the canonical conversation state the gateway should hold in
the DB *up to but not including* the new assistant reply — the
caller appends the synthesized turn from the backend onto this
and writes the result back. ``divergence_index`` is the
display-turn index at which incoming first disagreed with stored
(``None`` if everything matched; the new tail is appended cleanly).
"""
messages: list[MessageParam]
persist_messages: list[dict[str, Any]]
divergence_index: int | None
edited: bool = False
"""An earlier assistant turn's prose was rewritten in the file."""
@property
def reuse_session(self) -> bool:
return self.divergence_index is None and not self.edited
# ---- public store API ---------------------------------------------------
async def load_conversation(
@@ -69,7 +108,9 @@ async def mint_conversation(
) -> Conversation:
"""Create a fresh conversation row with a new uuid for external_id.
Caller must persist the returned ``external_id`` so future requests can find it.
Caller is responsible for persisting the returned ``external_id`` on
the frontend side (frontmatter, response header, …) so future
requests can find this conversation again.
"""
row = Conversation(
frontend=frontend, external_id=str(uuid.uuid4()), agent_name=agent_name
@@ -80,24 +121,14 @@ async def mint_conversation(
return row
async def set_session_id(
session: AsyncSession, *, conversation_id: int, session_id: str | None
) -> None:
conv = await session.get(Conversation, conversation_id)
if conv is None or conv.session_id == session_id:
return
conv.session_id = session_id
session.add(conv)
await session.commit()
async def load_messages(
session: AsyncSession, *, conversation_id: int
) -> list[dict[str, Any]]:
"""Return stored messages ordered by ``seq`` ascending.
Each entry is a canonical Anthropic ``MessageParam`` dict, the same
shape fed to the backend on continuation.
Each entry is a canonical Anthropic ``MessageParam`` dict — ``role``
plus ``content`` (string or list of block dicts). The same shape
we feed to the backend on continuation.
"""
stmt = (
select(ConversationMessage)
@@ -115,8 +146,10 @@ async def load_messages(
def _sanitize_content(content: Any) -> Any:
"""Strip wire-illegal fields from stored Anthropic content blocks.
Drops ``tool_result.is_error: null`` — the API rejects null there
though the field is optional.
Older capture code emitted ``"is_error": null`` on ``tool_result``
blocks; the Anthropic API rejects null there (the field is optional
but, when present, must be boolean). We omit the key on read so
historical rows don't break continuation.
"""
if not isinstance(content, list):
return content
@@ -137,17 +170,27 @@ def _sanitize_content(content: Any) -> Any:
async def rewrite_messages(
session: AsyncSession, *, conversation_id: int, messages: list[dict[str, Any]]
) -> None:
"""Replace the conversation's stored messages (full overwrite, no branch history).
"""Replace the conversation's stored messages with ``messages``.
Deletes and flushes before inserting — SQLAlchemy's default INSERT-before-DELETE
flush order would otherwise collide with ``uq_msg_conv_seq``.
The user said no branch history — we overwrite on fork. Cheap at
our volume; if it ever matters we can switch to soft-delete +
branch pointers.
"""
# Bulk-delete and flush before inserting the new sequence: SQLAlchemy's
# unit-of-work flushes INSERTs before DELETEs by default, which would
# trip ``uq_msg_conv_seq`` when the new rows reuse the same seq numbers
# as the soon-to-be-deleted ones.
# SQLModel descriptors resolve to ColumnElement at runtime but to bare
# ``int`` in ty's stubs; the select-path at line 135 lives behind sqlmodel's
# own ``select`` overloads that hide it, but ``sqlalchemy.delete().where``
# uses the raw stubs.
await session.execute( # ty: ignore[deprecated]
delete(ConversationMessage).where(
ConversationMessage.conversation_id == conversation_id # ty: ignore[invalid-argument-type]
)
)
await session.flush()
# Insert the new sequence.
for seq, m in enumerate(messages):
session.add(
ConversationMessage(
@@ -157,6 +200,7 @@ async def rewrite_messages(
content_json=json.dumps(m["content"], separators=(",", ":")),
)
)
# Bump conversation.updated_at.
conv = await session.get(Conversation, conversation_id)
if conv is not None:
from datetime import UTC, datetime
@@ -166,11 +210,21 @@ async def rewrite_messages(
await session.commit()
# ---- alignment ----------------------------------------------------------
@dataclass(frozen=True, slots=True)
class _StoredDisplayTurn:
"""A "display turn" reconstructed from stored raw messages.
Parser-equivalent view used to diff the file against the DB.
``role`` is ``"user"`` (single user-prompt message) or
``"assistant"`` (one or more assistant messages, optionally
interleaved with user-only-tool_result messages). ``messages`` is
the slice of stored raw messages this display turn covers, in
order. ``spoken_text`` and ``skeleton`` are the
parser-equivalents for diff purposes; ``text_segment_count`` lets
us refuse a splice when the user edited across a tool boundary in
a way we can't safely undo.
"""
role: str
@@ -183,8 +237,10 @@ class _StoredDisplayTurn:
def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTurn]:
"""Walk raw stored messages, group them into Obsidian-visible turns.
A user-prompt message (no ``tool_result`` blocks) opens a new turn;
tool-result-only messages roll into the current assistant turn.
A user-prompt message (``role=user`` with string content, or list
content with no ``tool_result`` blocks) opens a user display turn.
Otherwise it's a tool-result follow-up and rolls into the current
assistant display turn.
"""
out: list[_StoredDisplayTurn] = []
i = 0
@@ -203,6 +259,7 @@ def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTur
)
i += 1
continue
# Assistant display turn: collect consecutive non-prompt messages.
group: list[dict[str, Any]] = []
while i < len(stored):
m = stored[i]
@@ -224,16 +281,14 @@ def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTur
def _is_user_prompt(content: Any) -> bool:
"""A user message is a *prompt* unless its content carries tool_result blocks.
Unknown content shapes are conservatively treated as a prompt.
"""
"""A user message is a *prompt* unless its content carries tool_result blocks."""
if isinstance(content, str):
return True
if isinstance(content, list):
return not any(
isinstance(b, dict) and b.get("type") == "tool_result" for b in content
)
# Unknown shape — be conservative, treat as prompt.
return True
@@ -255,9 +310,13 @@ def _summarize_assistant_group(
) -> tuple[str, list[str], int]:
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
Must mirror ``parser.parse_assistant_structure``: text blocks collapse into
one segment, tool_use becomes a skeleton entry, tool_result/thinking are invisible.
Mirrors what ``parser.parse_assistant_structure`` would produce when
re-parsing the rendered version of this group: consecutive text
blocks across assistant messages collapse into one text segment;
tool_use blocks become skeleton entries; tool_result messages and
thinking blocks are invisible.
"""
# See ``diff_and_fork`` for why the parser-type imports are deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
segments: list[TextSegment | ToolSegment] = []
@@ -274,6 +333,7 @@ def _summarize_assistant_group(
for msg in group:
if msg["role"] == "user":
# tool_result message — boundary for text but emits no segment.
_flush()
continue
content = msg.get("content")
@@ -290,6 +350,7 @@ def _summarize_assistant_group(
elif btype == "tool_use":
_flush()
segments.append(ToolSegment(name=str(blk.get("name", ""))))
# thinking: skip silently
_flush()
spoken_chunks = [s.text for s in segments if isinstance(s, TextSegment)]
spoken = "\n\n".join(c for c in spoken_chunks if c).strip()
@@ -298,14 +359,30 @@ def _summarize_assistant_group(
return spoken, skeleton, text_count
# ---- the core algorithm -------------------------------------------------
def diff_and_fork(
*, stored: list[dict[str, Any]], incoming: list[ParsedTurn]
) -> ForkOutcome:
"""Align the incoming parsed file against stored history.
``incoming`` must end with a user turn (the new prompt); raises otherwise.
Segment-class imports below are deferred to avoid a cycle with ``parser``.
``stored`` is the raw Anthropic-shape message list from the DB
(one entry per ``ConversationMessage`` row). ``incoming`` is the
user-visible turn list from the markdown parser. The last
``incoming`` entry must be a user turn — that's the new prompt
triggering this request.
Returns a :class:`ForkOutcome` whose ``messages`` is what the
backend should run on and whose ``persist_messages`` is the
canonical history to store in the DB once the backend's
synthesized cycle is appended.
"""
# ``parser`` lives under ``frontends/markdown/`` whose ``__init__``
# eagerly loads ``frontend.py``, which in turn imports this module
# — pulling the parser at module-import time creates a cycle. The
# helpers below import the segment classes lazily inside their own
# function bodies to break it.
if not incoming or incoming[-1].role != "user":
msg = (
"diff_and_fork expects incoming to end with a user turn "
@@ -318,14 +395,20 @@ def diff_and_fork(
new_user_turn = incoming[-1]
prior_incoming = incoming[:-1]
spliced_groups, divergence, edited = _walk_prefix(prior_incoming, stored_groups)
spliced_groups, divergence = _walk_prefix(prior_incoming, stored_groups)
if divergence is None and len(prior_incoming) < len(stored_groups):
if _file_lags_store(stored_groups, len(prior_incoming), new_user_turn):
# Not a deletion — the file simply never received turns we
# already ran. Adopt the stored tail verbatim so history stays
# structured and its fingerprint still matches the live
# session's. See ``_file_lags_store`` for why this matters.
spliced_groups.extend(
list(g.messages) for g in stored_groups[len(prior_incoming) :]
)
else:
# Incoming truncated stored (user deleted some past turns).
# Truncate stored to match.
divergence = len(prior_incoming)
backend_msgs, persist_msgs = _assemble_tail(
@@ -338,7 +421,6 @@ def diff_and_fork(
messages=backend_msgs,
persist_messages=persist_msgs,
divergence_index=divergence,
edited=edited,
)
@@ -347,9 +429,26 @@ def _file_lags_store(
) -> bool:
"""Is the shorter incoming file a stale view rather than a deletion?
Tell: if the DB already holds the submitted prompt at the position the
file stops at, it's a lagging render, not a deletion — misjudging this
forks history, breaks the fingerprint, and respawns the backend session.
A file with fewer display turns than the DB has two possible causes,
and they need opposite handling:
* the user deleted trailing turns — we should truncate to match;
* the turn ran, was persisted, but its reply never made it back into
the ``.md`` (the render lost a race with the user's next prompt, or
the reply rendered to nothing visible). The file is simply behind.
The tell is the prompt the user is submitting right now: if the DB
already holds it at exactly the position the file stops at, this is a
re-submission of a turn we've already run, not a deletion. Nobody
deletes a turn and immediately retypes it verbatim.
Getting this wrong is expensive and self-sustaining. Forking here
flattens every post-divergence turn into plain text (losing tool_use /
tool_result structure), persists that flattened history, and changes
the conversation fingerprint — so the backend's session pool misses,
spawns a fresh ``claude``, reseeds it from a multi-MB JSONL, and
strands the previous process. The file still lags afterwards, so the
next turn does it again.
"""
if prior_len >= len(stored_groups):
return False
@@ -359,44 +458,44 @@ def _file_lags_store(
def _walk_prefix(
prior_incoming: list[ParsedTurn], stored_groups: list[_StoredDisplayTurn]
) -> tuple[list[list[dict[str, Any]]], int | None, bool]:
) -> tuple[list[list[dict[str, Any]]], int | None]:
"""Walk incoming vs stored side-by-side until first divergence.
An empty incoming skeleton (no tool callouts rendered) means prose
alone decides the match; a mismatch forces a fresh backend session.
Returns the spliced/matched group list (one entry per matched
display turn, each carrying the raw messages we'll feed to the
backend for that turn) and the divergence index (``None`` if all
of ``prior_incoming`` matched).
"""
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
spliced_groups: list[list[dict[str, Any]]] = []
edited = False
for i, inc in enumerate(prior_incoming):
if i >= len(stored_groups):
return spliced_groups, i, edited
return spliced_groups, i
st = stored_groups[i]
if inc.role != st.role:
return spliced_groups, i, edited
return spliced_groups, i
if inc.role == "user":
if inc.text != st.spoken_text:
return spliced_groups, i, edited
return spliced_groups, i
spliced_groups.append(list(st.messages))
continue
inc_skeleton = tuple(
s.name for s in inc.structure if isinstance(s, ToolSegment)
)
inc_text_count = sum(1 for s in inc.structure if isinstance(s, TextSegment))
if inc_skeleton and inc_skeleton != st.skeleton:
return spliced_groups, i, edited
if inc_skeleton != st.skeleton:
return spliced_groups, i
if inc.text == st.spoken_text:
spliced_groups.append(list(st.messages))
continue
if inc_skeleton and inc_text_count != st.text_segment_count:
return spliced_groups, i, edited
if inc_text_count != st.text_segment_count:
return spliced_groups, i
spliced = _splice_assistant_group(stored_group=st, incoming=inc)
if spliced is None:
return spliced_groups, i, edited
return spliced_groups, i
spliced_groups.append(spliced)
edited = True
return spliced_groups, None, edited
return spliced_groups, None
def _assemble_tail(
@@ -452,10 +551,21 @@ def _splice_in_place(
) -> list[dict[str, Any]] | None:
"""Copy the stored messages, swapping only their text block contents.
Preserves message/thinking-block boundaries that a rebuild would lose
(and that a cache-hit continuation depends on); returns ``None`` if
text blocks aren't 1:1 with incoming segments, so the caller rebuilds instead.
Rebuilding a turn from the file loses everything the markdown never
carried — thinking blocks, and the message boundaries claude chose.
Both matter: an ``assistant[thinking] + assistant[text]`` pair (what
claude emits for a reasoning turn) collapses into a single message,
so the history is one message shorter than the one the backend
pooled its live session under, and the next turn misses the cache
and respawns. Substituting in place keeps the message count and the
invisible blocks exactly as stored.
Returns ``None`` when stored text blocks and incoming text segments
aren't one-to-one — consecutive text blocks merge into a single
rendered segment, so there'd be no way to know how to split the
edited prose back apart. The caller then rebuilds instead.
"""
# See ``diff_and_fork`` for why the parser-type import is deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment
new_texts = [
@@ -491,9 +601,18 @@ def _splice_by_rebuild(
) -> list[dict[str, Any]] | None:
"""Rebuild an assistant display turn with new text + stored tool_use blocks.
Matches tool_use blocks to incoming ``ToolSegment``s by position; returns
``None`` (caller forks) if a matching stored ``tool_result`` is missing.
Walks the incoming structure; for each ``TextSegment`` emits a
text block into the current assistant message; for each
``ToolSegment`` consumes the next stored ``tool_use`` block (by
position), closes the current assistant message, emits the
matching ``tool_result`` user message, and opens a new assistant
message. Final ``TextSegment`` closes the last assistant message.
Returns ``None`` if we can't find a matching tool_result for some
tool_use (stored history is malformed) — caller falls back to
fork.
"""
# See ``diff_and_fork`` for why this import is deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment
tool_uses, tool_results_by_id = _harvest_tool_blocks(stored_group)
@@ -520,6 +639,8 @@ def _splice_by_rebuild(
if current_asst:
spliced.append({"role": "assistant", "content": current_asst})
elif not spliced:
# Defensive: assistant turn with no text and no tools makes no
# sense; caller will treat as fork.
return None
return spliced
@@ -1,7 +1,11 @@
"""Streaming-event protocol shared by every backend adapter.
"""Unified streaming-event protocol shared by every backend adapter.
Aliases over :mod:`anthropic.types` plus ``build_*`` helpers that keep
verbose constructors out of adapters.
We piggyback on :mod:`anthropic.types` rather than reinvent the wire format:
``AnthropicMessagesFrontend`` ultimately serializes whatever a backend yields
straight to SSE via :py:meth:`pydantic.BaseModel.model_dump_json`, so events
must be valid Anthropic ``message_stream`` records. The aliases below give
the rest of the codebase one import path; the ``build_*`` helpers keep the
verbose constructors out of every adapter.
"""
from __future__ import annotations
+83
View File
@@ -0,0 +1,83 @@
"""Agent / MCP registries + the user-facing ``Gateway`` collector.
The user's ``/config/config.py`` ends with::
gateway = Gateway(agents=[...], mcps=[...], frontends=[...])
``cli.main`` picks that object up and builds the registries.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.types import McpServerT
class AgentRegistry:
"""Name → agent lookup with duplicate detection."""
def __init__(self, agents: Iterable[BaseAgent]) -> None:
self._agents: dict[str, BaseAgent] = {}
for a in agents:
if a.name in self._agents:
msg = f"duplicate agent name: {a.name!r}"
raise ValueError(msg)
self._agents[a.name] = a
def __getitem__(self, name: str) -> BaseAgent:
return self._agents[name]
def get(self, name: str) -> BaseAgent | None:
return self._agents.get(name)
def __iter__(self) -> Iterator[BaseAgent]:
return iter(self._agents.values())
def __len__(self) -> int:
return len(self._agents)
def __contains__(self, name: object) -> bool:
return name in self._agents
class McpRegistry:
"""Name → MCP server lookup with duplicate detection."""
def __init__(self, mcps: Iterable[McpServerT]) -> None:
self._mcps: dict[str, McpServerT] = {}
for m in mcps:
if m.name in self._mcps:
msg = f"duplicate mcp name: {m.name!r}"
raise ValueError(msg)
self._mcps[m.name] = m
def __getitem__(self, name: str) -> McpServerT:
return self._mcps[name]
def get(self, name: str) -> McpServerT | None:
return self._mcps.get(name)
def __iter__(self) -> Iterator[McpServerT]:
return iter(self._mcps.values())
def __len__(self) -> int:
return len(self._mcps)
def __contains__(self, name: object) -> bool:
return name in self._mcps
@dataclass(slots=True)
class Gateway:
"""Top-level object the user assembles in ``/config/config.py``."""
agents: list[BaseAgent] = field(default_factory=list)
mcps: list[McpServerT] = field(default_factory=list)
frontends: list[Frontend] = field(default_factory=list)
+133
View File
@@ -0,0 +1,133 @@
"""Cross-frontend turn record.
Frontends that finish a turn (the Anthropic Messages frontend, the
markdown frontend) emit a :class:`TurnRecord` to every handler in
``GatewayRuntime.turn_log_handlers``. The markdown frontend uses this
to persist chats from other frontends into the Obsidian vault — see
``frontends/markdown/crossfront.py``.
Kept tiny on purpose: it carries the structured-enough payload a logger
needs (which agent ran, input history, the assembled assistant reply)
and nothing else. The full event stream is gone by the time handlers
run — if a future consumer needs deltas it would subscribe at a lower
level, not here.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from anthropic.types import Message, MessageParam
__all__ = ["TurnRecord", "slugify"]
# Filesystem-safe slug: collapse anything that isn't a word char or
# space/hyphen to a hyphen, then squash runs of separators. Aimed at
# letting users build filenames from ``record.first_user_text`` without
# hand-rolling sanitization in every config.
_SLUG_BAD_RE = re.compile(r"[^\w\s\-]+", flags=re.UNICODE)
_SLUG_SEP_RE = re.compile(r"[\s\-]+", flags=re.UNICODE)
def slugify(text: str, *, maxlen: int = 40) -> str:
"""Sanitize ``text`` for use as a filename fragment.
Strips punctuation, collapses whitespace/hyphens into single ``-``,
and truncates to ``maxlen``. Returns ``"untitled"`` for empty input.
Unicode letters are preserved (Obsidian handles them fine; macOS
and modern Linux fs's too).
"""
cleaned = _SLUG_BAD_RE.sub(" ", text).strip()
cleaned = _SLUG_SEP_RE.sub("-", cleaned).strip("-")
if not cleaned:
return "untitled"
if len(cleaned) > maxlen:
cleaned = cleaned[:maxlen].rstrip("-")
return cleaned or "untitled"
@dataclass(frozen=True, slots=True)
class TurnRecord:
"""One completed turn, as seen by a frontend.
``input_messages`` is the conversation history sent to the backend
(everything *before* the assistant reply). ``output_message`` is the
finalized assistant ``Message`` (post-accumulation, with all content
blocks attached). ``system`` is the per-request system prompt if any
— agents own their own ``system_prompt``, this is the override the
caller passed; most handlers can ignore it.
``source`` names the frontend that produced the record so the
cross-frontend logger can avoid logging its own turns (markdown
frontend writing the file would otherwise also receive its own
broadcast and double-write).
"""
agent_name: str
input_messages: list[MessageParam]
output_message: Message
system: str | None = None
source: str = "anthropic"
@property
def first_user_text(self) -> str:
"""Plain text of the *earliest* user turn in this conversation.
Useful for naming new files by topic. Empty string if the input
history somehow has no user turn (shouldn't happen — turns are
broadcast only after a user → assistant cycle).
"""
for msg in self.input_messages:
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
return ""
@property
def last_user_text(self) -> str:
"""Plain text of the most recent user turn (the trigger)."""
for msg in reversed(self.input_messages):
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
return ""
@property
def assistant_text(self) -> str:
"""Plain text of the assistant reply, with thinking/tool blocks dropped."""
chunks = [
getattr(block, "text", "") or ""
for block in getattr(self.output_message, "content", ())
if getattr(block, "type", None) == "text"
]
return "\n\n".join(c for c in chunks if c)
def _text_of(content: object) -> str:
"""Flatten an Anthropic ``MessageParam.content`` field to a plain string.
Handles both shapes the SDK accepts: raw string, or a list of block
dicts (we pull ``text`` blocks only). Anything we don't recognize is
silently skipped — these helpers exist for naming files, not for
faithful content reconstruction.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for blk in content:
if not isinstance(blk, dict):
continue
# ``MessageParam.content`` is typed as a union of typed-dicts
# per Anthropic SDK; we only care about plain ``text`` blocks
# and look them up via ``Any`` to dodge the keyed-typed-dict
# variance gymnastics (``ty`` won't let an open dict alias a
# closed-keyed one).
d: Any = blk
if d.get("type") == "text":
parts.append(str(d.get("text", "")))
return "\n\n".join(p for p in parts if p)
return ""
-1
View File
@@ -1 +0,0 @@
"""The in-process event bus and the stream event protocol backends emit."""
-66
View File
@@ -1,66 +0,0 @@
"""In-process event bus: what the gateway does, as a stream frontends can tap.
Events are plain dicts. Publishers never block; a subscriber that falls
behind loses its oldest events rather than stalling the publisher.
"""
from __future__ import annotations
import asyncio
import contextlib
import itertools
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import AsyncIterator
__all__ = ["Event", "EventBus"]
Event = dict[str, Any]
class EventBus:
def __init__(self, *, maxsize: int = 2000) -> None:
self._maxsize = maxsize
self._subscribers: set[asyncio.Queue[Event]] = set()
self._seq = itertools.count(1)
def publish(self, type_: str, **data: Any) -> Event:
event: Event = {
"type": type_,
"seq": next(self._seq),
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
**data,
}
for queue in list(self._subscribers):
if queue.full():
with contextlib.suppress(asyncio.QueueEmpty):
queue.get_nowait()
queue.put_nowait(event)
return event
@contextlib.asynccontextmanager
async def subscribe(self) -> AsyncIterator[asyncio.Queue[Event]]:
queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=self._maxsize)
self._subscribers.add(queue)
try:
yield queue
finally:
self._subscribers.discard(queue)
async def stream(
self, *, conversation_id: str | None = None
) -> AsyncIterator[Event]:
async with self.subscribe() as queue:
while True:
event = await queue.get()
if (
conversation_id is None
or event.get("conversation_id") == conversation_id
):
yield event
@property
def subscribers(self) -> int:
return len(self._subscribers)
@@ -1,7 +1,11 @@
"""Collapse an Anthropic event stream into one ``Message``.
Mirrors the Anthropic SDK's own accumulator: folds content-block deltas
into finalized blocks, indexed by their ``content_block`` index.
Extracted from ``AnthropicMessagesFrontend`` so the markdown frontend
can run the same accumulation logic when it wants the finalized turn
rather than raw SSE chunks. Mirrors the Anthropic SDK's own accumulator:
walks events, builds block dicts indexed by their ``content_block``
index, folds text / thinking deltas in, buffers ``input_json_delta``
chunks until the block closes (then JSON-parses them once).
"""
from __future__ import annotations
@@ -29,7 +33,7 @@ from anthropic.types import (
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from beaver_gateway.events.stream import MessageStreamEvent, StopReason
from beaver_gateway.core.events import MessageStreamEvent, StopReason
__all__ = ["StreamAccumulator", "accumulate"]
@@ -38,7 +42,9 @@ __all__ = ["StreamAccumulator", "accumulate"]
class StreamAccumulator:
"""Folds a stream of events into one ``Message``, incrementally.
Call :meth:`feed` for each event, then :meth:`finalize` once.
Use when you need to *both* forward events somewhere (SSE) *and*
keep a finalized ``Message`` for post-stream work (audit, logging
to disk). Call :meth:`feed` for each event, :meth:`finalize` once.
"""
__slots__ = (
@@ -61,11 +67,10 @@ class StreamAccumulator:
self._stop_sequence: str | None = None
def feed(self, ev: MessageStreamEvent) -> None:
"""Fold one stream event into the in-progress message state.
Uses ``isinstance`` rather than ``ev.type == ...`` so ty narrows
the event type from the class, not a string comparison.
"""
# isinstance, not ``ev.type == "..."``: ty narrows on the
# discriminator only via the class, and the raw event union
# carries its own discriminators (``Raw*Event``) the SDK
# already promises.
if isinstance(ev, RawMessageStartEvent):
self._message_id = ev.message.id
self._role = ev.message.role
@@ -104,11 +109,6 @@ class StreamAccumulator:
)
def finalize(self, *, model: str) -> Message:
"""Build the finalized ``Message`` from accumulated block state.
``role`` is always ``"assistant"`` at the wire level; the cast
avoids a runtime check ty would otherwise require.
"""
content: list[Any] = []
for idx in sorted(self._blocks):
bd = self._blocks[idx]
@@ -120,6 +120,10 @@ class StreamAccumulator:
elif btype == "thinking":
content.append(ThinkingBlock.model_validate(bd))
# ``role`` is always ``"assistant"`` at the wire level — we
# initialised the field to that and only overwrite from a
# ``RawMessageStartEvent`` which itself carries the same literal.
# The cast keeps both type-checkers happy without a runtime check.
return Message(
id=self._message_id or "msg_unknown",
type="message",
@@ -1,7 +1,8 @@
"""Shared bearer-token verification for HTTP frontends.
Reused by every bearer-protected frontend instead of duplicating the
header-parsing logic.
Extracted from ``AnthropicMessagesFrontend`` so the markdown frontend
(and any future bearer-protected frontend) can reuse one canonical
verifier instead of copy-pasting the header-parsing dance.
"""
from __future__ import annotations
@@ -22,21 +23,22 @@ __all__ = ["require_token"]
async def require_token(
request: Request, runtime: GatewayRuntime, *, scope: str
) -> str:
"""Verify the request's bearer token and scope; return its audit name.
"""Verify the request's bearer + scope, return the token's audit name.
Checks ``X-Api-Key``, then ``Authorization: Bearer``, then ``?token=``.
401 on a missing/unknown token, 403 if the token's scope doesn't cover
``scope``. Bootstrap tokens carry ``"*"`` and pass every scope check.
Accepts both ``X-Api-Key: <token>`` (Anthropic SDK / LibreChat) and
``Authorization: Bearer <token>`` (curl, Cursor). 401 on missing /
unknown token; 403 on a known token whose scope doesn't cover
``scope``. Bootstrap tokens implicitly carry ``"*"`` and pass every
scope check.
"""
api_key = request.headers.get("x-api-key")
authorization = request.headers.get("authorization")
if api_key:
identity = await runtime.token_store.verify(api_key)
elif authorization:
identity = await runtime.token_store.verify_bearer(authorization)
else:
qs_token = request.query_params.get("token")
identity = await runtime.token_store.verify(qs_token) if qs_token else None
identity = (
await runtime.token_store.verify(api_key)
if api_key
else await runtime.token_store.verify_bearer(
request.headers.get("authorization")
)
)
if identity is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
@@ -1,4 +1,10 @@
"""Admin console: the ``ui/`` SPA served from the admin port plus its login."""
"""Admin UI (Phase 4.3).
Browser-facing console: login, dashboard, token CRUD, audit viewer.
Templates live in ``./templates``; the package loader picks them up via
``importlib.resources`` so the layout works editable and inside the
Docker image without copy hacks.
"""
from beaver_gateway.frontends.admin.frontend import AdminFrontend
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,206 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}beaver-gateway · admin{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
<style>
:root {
--bg: #fbfbfd;
--fg: #1d1d1f;
--muted: #6e6e73;
--line: #e5e5ea;
--accent: #0071e3;
--danger: #d70015;
--surface: #ffffff;
--code-bg: #f5f5f7;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
body {
font-family: -apple-system, "SF Pro Display", "SF Pro Text",
BlinkMacSystemFont, system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
letter-spacing: -0.005em;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre, kbd, samp {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.92em;
}
pre {
background: var(--code-bg);
padding: 1rem 1.25rem;
border-radius: 12px;
overflow-x: auto;
}
header.top {
border-bottom: 1px solid var(--line);
background: var(--surface);
}
header.top .inner {
max-width: 1080px;
margin: 0 auto;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1.25rem;
}
header.top h1 {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
nav.tabs { display: flex; gap: 1.1rem; flex: 1; }
nav.tabs a {
color: var(--fg);
padding: 0.25rem 0.1rem;
border-bottom: 2px solid transparent;
}
nav.tabs a.active {
border-bottom-color: var(--fg);
}
header.top .actor {
color: var(--muted);
font-size: 0.9em;
}
main {
max-width: 1080px;
margin: 0 auto;
padding: 2.25rem 1.5rem 4rem;
}
h2 {
font-weight: 600;
letter-spacing: -0.015em;
margin: 2rem 0 0.75rem;
}
h2:first-of-type { margin-top: 0; }
.muted { color: var(--muted); }
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
}
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left;
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
font-weight: 500;
color: var(--muted);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.04em;
}
tr:last-child td { border-bottom: none; }
.revoked td { color: var(--muted); }
button, .btn {
font-family: inherit;
font-size: 0.92em;
padding: 0.45rem 0.95rem;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--fg);
cursor: pointer;
transition: background 120ms ease;
}
button:hover, .btn:hover { background: var(--code-bg); }
button.primary, .btn.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
button.primary:hover, .btn.primary:hover {
background: #005bb5;
}
button.danger { color: var(--danger); border-color: #f0c5c9; }
button.danger:hover { background: #fdeff1; }
form.inline { display: inline; margin: 0; }
.form-grid {
display: grid;
grid-template-columns: minmax(180px, 1fr) 160px auto;
gap: 0.75rem;
align-items: end;
}
.form-grid label {
display: block;
font-size: 0.8em;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.3rem;
}
input[type="text"], input[type="password"], select {
width: 100%;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line);
border-radius: 8px;
font-family: inherit;
font-size: 0.95em;
background: var(--surface);
color: var(--fg);
}
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.banner {
padding: 1rem 1.25rem;
border-radius: 12px;
background: #f0f9ff;
border: 1px solid #cfe8ff;
margin: 0 0 1.25rem;
}
.banner.error {
background: #fff0f0;
border-color: #f5c2c5;
color: var(--danger);
}
.banner.warn {
background: #fff7e6;
border-color: #fadf9c;
}
.pill {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 999px;
background: var(--code-bg);
font-size: 0.78em;
color: var(--muted);
}
.pill.scope-wild { background: #ecfdf3; color: #027a48; }
.pill.scope-admin { background: #fff4e6; color: #b54708; }
</style>
</head>
<body>
{% block header %}
<header class="top">
<div class="inner">
<h1>beaver-gateway</h1>
<nav class="tabs">
<a href="{{ p }}/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
<a href="{{ p }}/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
<a href="{{ p }}/pty" class="{% if active == 'pty' %}active{% endif %}">PTY</a>
<a href="{{ p }}/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
<a href="{{ p }}/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
</nav>
<span class="actor">Signed in as <strong>{{ user }}</strong></span>
<form class="inline" method="post" action="{{ p }}/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button type="submit">Log out</button>
</form>
</div>
</header>
{% endblock %}
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,12 @@
{# Renders into #token-create-result (hx-target on the form). The
two OOB swaps reach the rest of the page: prepend the new row,
and erase the empty-state placeholder if it's still in the DOM. #}
<div class="banner warn">
<strong>Token created: {{ token.name }}</strong>
<p class="muted" style="margin:0.25rem 0 0.5rem;">Copy it now — this is the only time it will be shown.</p>
<pre style="margin:0;">{{ plaintext }}</pre>
</div>
<tbody id="tokens-rows" hx-swap-oob="afterbegin">
{% include "_token_row.html" %}
</tbody>
<tr id="tokens-empty" hx-swap-oob="delete"></tr>
@@ -0,0 +1 @@
<div class="banner error">{{ message }}</div>
@@ -0,0 +1,25 @@
<tr id="token-row-{{ token.id }}" {% if token.revoked_at %}class="revoked"{% endif %}>
<td><strong>{{ token.name }}</strong></td>
<td>
<span class="pill {% if token.scope == '*' %}scope-wild{% elif token.scope == 'admin' %}scope-admin{% endif %}">{{ token.scope }}</span>
</td>
<td><code>{{ token.created_at | fmt_dt }}</code></td>
<td><code>{{ token.last_used_at | fmt_dt }}</code></td>
<td><code>{{ token.revoked_at | fmt_dt }}</code></td>
<td style="text-align:right;">
{% if not token.revoked_at %}
<form
class="inline"
hx-post="{{ p }}/tokens/{{ token.id }}/revoke"
hx-target="#token-row-{{ token.id }}"
hx-swap="outerHTML"
hx-confirm="Revoke token {{ token.name }}? This cannot be undone."
>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="danger" type="submit">Revoke</button>
</form>
{% else %}
<span class="muted">revoked</span>
{% endif %}
</td>
</tr>
@@ -0,0 +1,37 @@
{% extends "_layout.html" %}
{% set active = "audit" %}
{% block title %}beaver-gateway · audit{% endblock %}
{% block content %}
<h2>Audit log</h2>
<div class="card">
{% if audit %}
<table>
<thead>
<tr>
<th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th>
</tr>
</thead>
<tbody>
{% for row in audit %}
<tr>
<td><code>{{ row.ts | fmt_dt }}</code></td>
<td>{{ row.actor }}</td>
<td><span class="pill">{{ row.kind }}</span></td>
<td>{{ row.agent_name or "—" }}</td>
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted" style="margin-top:1rem;">
{% if next_before %}
<a href="{{ p }}/audit?before={{ next_before }}">Older entries →</a>
{% else %}
End of log.
{% endif %}
</p>
{% else %}
<p class="muted">Nothing logged yet.</p>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,355 @@
{% extends "_layout.html" %}
{% set active = "chat" %}
{% block title %}beaver-gateway · chat{% endblock %}
{% block content %}
<div class="chat-wrap">
<div class="chat-toolbar">
<label>
<span>Agent</span>
<select id="agent-select">
{% for a in agents %}
<option value="{{ a.name }}">{{ a.name }} · {{ a.model }}</option>
{% else %}
<option disabled>no agents with a backend</option>
{% endfor %}
</select>
</label>
<div class="spacer"></div>
<button id="new-chat-btn" type="button">New chat</button>
</div>
<div id="messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" class="chat-input">
<textarea id="chat-text" rows="3" placeholder="Message… (⌘/Ctrl+Enter to send)" required></textarea>
<button type="submit" class="primary" id="send-btn">Send</button>
</form>
</div>
<style>
.chat-wrap {
display: flex; flex-direction: column;
height: calc(100vh - 200px); min-height: 480px;
}
.chat-toolbar {
display: flex; gap: 0.85rem; align-items: end;
margin-bottom: 1rem;
}
.chat-toolbar .spacer { flex: 1; }
.chat-toolbar label {
display: flex; flex-direction: column; gap: 0.3rem;
min-width: 260px;
}
.chat-toolbar label > span {
font-size: 0.8em; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em;
}
.chat-messages {
flex: 1; overflow-y: auto;
background: var(--surface); border: 1px solid var(--line);
border-radius: 14px; padding: 1.25rem;
display: flex; flex-direction: column; gap: 1rem;
}
.chat-empty { color: var(--muted); margin: auto; }
.msg { display: flex; }
.msg.user { justify-content: flex-end; }
.msg.user .bubble {
max-width: 78%; padding: 0.7rem 0.95rem; border-radius: 14px;
background: var(--accent); color: white;
white-space: pre-wrap; word-wrap: break-word;
}
.msg.assistant .blocks {
display: flex; flex-direction: column; gap: 0.5rem;
max-width: 88%;
}
.block-text {
background: var(--code-bg); padding: 0.7rem 0.95rem;
border-radius: 14px;
white-space: pre-wrap; word-wrap: break-word;
}
details.tool-call, details.thinking-block {
border: 1px solid var(--line); border-radius: 10px;
background: var(--surface); padding: 0 0.85rem;
font-size: 0.9em;
}
details.tool-call > summary,
details.thinking-block > summary {
cursor: pointer; padding: 0.55rem 0;
list-style: none;
display: flex; align-items: center; gap: 0.55rem;
}
details > summary::-webkit-details-marker { display: none; }
details.tool-call > summary::before,
details.thinking-block > summary::before {
content: "▸"; color: var(--muted); font-size: 0.78em;
}
details.tool-call[open] > summary::before,
details.thinking-block[open] > summary::before { content: "▾"; }
.tool-name {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-weight: 500;
}
.tool-id, .tool-label {
color: var(--muted); font-size: 0.78em;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
details.tool-call pre, details.thinking-block pre {
margin: 0 0 0.6rem; padding: 0.7rem 0.85rem;
background: var(--code-bg); border-radius: 8px;
font-size: 0.85em; max-height: 360px; overflow: auto;
}
.tool-label { display: block; margin: 0.15rem 0 0.25rem; }
.chat-input { display: flex; gap: 0.75rem; margin-top: 1rem; }
.chat-input textarea {
flex: 1; padding: 0.7rem 0.9rem;
border: 1px solid var(--line); border-radius: 12px;
font-family: inherit; font-size: 0.95em;
background: var(--surface); color: var(--fg); resize: vertical;
}
.chat-input textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chat-input button { align-self: stretch; padding-left: 1.4rem; padding-right: 1.4rem; }
.chat-error {
color: var(--danger); font-size: 0.85em;
padding: 0.55rem 0.75rem;
background: #fff0f0; border-radius: 10px;
border: 1px solid #f5c2c5;
}
.chat-input button:disabled { opacity: 0.55; cursor: progress; }
</style>
<script>
(() => {
const CSRF = {{ csrf | tojson }};
const URL_PREFIX = {{ p | tojson }};
const agentSelect = document.getElementById("agent-select");
const messagesEl = document.getElementById("messages");
const form = document.getElementById("chat-form");
const textEl = document.getElementById("chat-text");
const sendBtn = document.getElementById("send-btn");
const newBtn = document.getElementById("new-chat-btn");
// Anthropic-style history sent to the backend. We keep assistant
// content text-only — tool_use blocks can't round-trip without
// matching tool_result, and backends (claude-code) run tools
// internally anyway.
let apiMessages = [];
function renderEmpty() {
messagesEl.innerHTML = '<div class="chat-empty">No messages yet — say something.</div>';
}
renderEmpty();
newBtn.addEventListener("click", () => {
apiMessages = [];
renderEmpty();
});
function clearEmpty() {
const e = messagesEl.querySelector(".chat-empty");
if (e) e.remove();
}
function scrollDown() { messagesEl.scrollTop = messagesEl.scrollHeight; }
function appendUser(text) {
clearEmpty();
const row = document.createElement("div");
row.className = "msg user";
const bub = document.createElement("div");
bub.className = "bubble";
bub.textContent = text;
row.appendChild(bub);
messagesEl.appendChild(row);
scrollDown();
}
function appendAssistant() {
clearEmpty();
const row = document.createElement("div");
row.className = "msg assistant";
const blocks = document.createElement("div");
blocks.className = "blocks";
row.appendChild(blocks);
messagesEl.appendChild(row);
scrollDown();
return blocks;
}
function appendError(text) {
clearEmpty();
const e = document.createElement("div");
e.className = "chat-error";
e.textContent = text;
messagesEl.appendChild(e);
scrollDown();
}
function ensureBlock(state, index, type, extra) {
if (state.blocks[index]) return state.blocks[index];
const block = { type };
if (type === "text") {
const el = document.createElement("div");
el.className = "block-text";
state.container.appendChild(el);
block.el = el;
} else if (type === "thinking") {
const det = document.createElement("details");
det.className = "thinking-block";
const sum = document.createElement("summary");
sum.textContent = "Thinking";
det.appendChild(sum);
const pre = document.createElement("pre");
det.appendChild(pre);
state.container.appendChild(det);
block.el = pre;
} else if (type === "tool_use") {
const det = document.createElement("details");
det.className = "tool-call";
const sum = document.createElement("summary");
const name = document.createElement("span");
name.className = "tool-name";
name.textContent = "🔧 " + (extra.name || "tool");
sum.appendChild(name);
if (extra.id) {
const idEl = document.createElement("span");
idEl.className = "tool-id";
idEl.textContent = extra.id;
sum.appendChild(idEl);
}
det.appendChild(sum);
const label = document.createElement("span");
label.className = "tool-label";
label.textContent = "input";
det.appendChild(label);
const pre = document.createElement("pre");
det.appendChild(pre);
state.container.appendChild(det);
block.el = pre;
block.jsonBuf = "";
block.seedInput = extra.input;
}
state.blocks[index] = block;
return block;
}
function applyEvent(state, ev) {
const t = ev.type;
if (t === "content_block_start") {
const cb = ev.content_block || {};
if (cb.type === "text") {
ensureBlock(state, ev.index, "text", {});
} else if (cb.type === "thinking") {
ensureBlock(state, ev.index, "thinking", {});
} else if (cb.type === "tool_use") {
ensureBlock(state, ev.index, "tool_use",
{ name: cb.name, id: cb.id, input: cb.input });
}
} else if (t === "content_block_delta") {
const d = ev.delta || {};
const b = state.blocks[ev.index];
if (!b) return;
if (d.type === "text_delta") {
b.el.textContent += d.text || "";
state.assistantText += d.text || "";
scrollDown();
} else if (d.type === "thinking_delta") {
b.el.textContent += d.thinking || "";
scrollDown();
} else if (d.type === "input_json_delta") {
b.jsonBuf += d.partial_json || "";
}
} else if (t === "content_block_stop") {
const b = state.blocks[ev.index];
if (!b) return;
if (b.type === "tool_use") {
let input = null;
if (b.jsonBuf && b.jsonBuf.trim()) {
try { input = JSON.parse(b.jsonBuf); }
catch { input = b.jsonBuf; }
} else if (b.seedInput !== undefined && b.seedInput !== null) {
input = b.seedInput;
}
b.el.textContent = input === null
? "(no input)"
: (typeof input === "string"
? input
: JSON.stringify(input, null, 2));
}
} else if (t === "error") {
const msg = (ev.error && ev.error.message) || "stream error";
appendError(msg);
}
}
async function send() {
const text = textEl.value.trim();
if (!text) return;
const model = agentSelect.value;
if (!model) { appendError("no agent selected"); return; }
apiMessages.push({ role: "user", content: text });
appendUser(text);
textEl.value = "";
sendBtn.disabled = true;
const container = appendAssistant();
const state = { container, blocks: {}, assistantText: "" };
let resp;
try {
resp = await fetch(URL_PREFIX + "/chat/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF,
},
body: JSON.stringify({ model, messages: apiMessages }),
});
} catch (e) {
appendError("network error: " + e.message);
sendBtn.disabled = false;
return;
}
if (!resp.ok || !resp.body) {
let msg = resp.status + " " + resp.statusText;
try { const body = await resp.text(); if (body) msg = body; } catch {}
appendError(msg);
sendBtn.disabled = false;
return;
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const raw = buf.slice(0, i);
buf = buf.slice(i + 2);
if (!raw) continue;
let dataLine = "";
for (const line of raw.split("\n")) {
if (line.startsWith("data:")) dataLine += line.slice(5).trimStart();
}
if (!dataLine) continue;
let payload;
try { payload = JSON.parse(dataLine); } catch { continue; }
applyEvent(state, payload);
}
}
if (state.assistantText) {
apiMessages.push({ role: "assistant", content: state.assistantText });
}
sendBtn.disabled = false;
textEl.focus();
}
form.addEventListener("submit", (e) => { e.preventDefault(); send(); });
textEl.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
send();
}
});
})();
</script>
{% endblock %}
@@ -0,0 +1,490 @@
{% extends "_layout.html" %}
{% set active = "dashboard" %}
{% block title %}beaver-gateway · dashboard{% endblock %}
{% block content %}
<h2>Agents</h2>
<div class="card">
{% if agents %}
<table>
<thead><tr><th>Name</th><th>Type</th><th>Model</th><th>Exposed MCPs</th></tr></thead>
<tbody>
{% for a in agents %}
<tr>
<td><code>{{ a.name }}</code></td>
<td><span class="pill">{{ a.__class__.__name__ }}</span></td>
<td><code>{{ a.model }}</code></td>
<td>
{% for em in a.expose_mcps %}<code>{{ em.name }}</code>{% if not loop.last %}, {% endif %}{% else %}<span class="muted">—</span>{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No agents configured.</p>
{% endif %}
</div>
<h2>MCP namespaces</h2>
<div class="card">
{% if mcps %}
<table>
<thead><tr><th>Name</th><th>Kind</th></tr></thead>
<tbody>
{% for m in mcps %}
<tr>
<td><code>{{ m.name }}</code></td>
<td><span class="pill">{{ m.kind }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No MCP servers configured.</p>
{% endif %}
</div>
<h2>Endpoints</h2>
<div class="card endpoints">
{% if endpoints.agents or endpoints.mcps %}
<div class="ep-controls">
<label class="ep-token">
<span>Token (hint)</span>
<select id="ep-token-select">
<option value="" data-scope="">— select a known token —</option>
{% for t in tokens %}
<option value="{{ t.name }}" data-scope="{{ t.scope }}">{{ t.name }} · scope {{ t.scope }}</option>
{% endfor %}
</select>
</label>
<label class="ep-secret">
<span>Bearer secret</span>
<input type="password" id="ep-token-secret" autocomplete="off" spellcheck="false"
placeholder="paste plaintext (we only store the Argon2 hash)">
</label>
<label class="ep-show">
<input type="checkbox" id="ep-show-secret"> <span>show</span>
</label>
</div>
<p class="muted ep-note">
Beaver stores only an Argon2 hash of each token, so the plaintext can't be reconstructed.
Paste the value you saved at creation; if you've lost it, <a href="{{ p }}/tokens">mint a new one</a>.
Below: pick a token to see which endpoints its scope covers, paste the secret to fill it
into URL / curl, then click Copy.
</p>
{% if endpoints.agents %}
<h3 class="ep-h3">Agents — <code>POST /v1/messages</code></h3>
<table class="ep-table" data-required-scope="messages">
<thead><tr><th>Agent</th><th>Model</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
{% for ep in endpoints.agents %}
<tr class="ep-row" data-kind="messages" data-agent="{{ ep.agent }}" data-url="{{ ep.url }}">
<td>
<code>{{ ep.agent }}</code>
<span class="pill">{{ ep.agent_type }}</span>
<span class="ep-scope-warn" hidden>scope mismatch</span>
</td>
<td><code>{{ ep.model }}</code></td>
<td><code class="ep-url">{{ ep.url }}</code></td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
{% endfor %}
</tbody>
</table>
{% elif endpoints.anthropic_base is none and agents %}
<p class="muted">
Agents are declared but no <code>AnthropicMessagesFrontend</code> is configured —
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
</p>
{% endif %}
{% if endpoints.mcps %}
<h3 class="ep-h3">MCP — streamable HTTP</h3>
<table class="ep-table" data-required-scope="mcp">
<thead><tr><th>Namespace</th><th>Kind</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
{% for ep in endpoints.mcps %}
<tr class="ep-row" data-kind="mcp" data-namespace="{{ ep.namespace }}" data-url="{{ ep.url }}">
<td>
<code>{{ ep.namespace }}</code>
<span class="ep-scope-warn" hidden>scope mismatch</span>
</td>
<td><span class="pill">{{ ep.kind }}</span></td>
<td><code class="ep-url">{{ ep.url }}</code></td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-url-token">Copy URL+token</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
{% endfor %}
</tbody>
</table>
{% elif endpoints.mcp_base is none and mcps %}
<p class="muted">
MCP servers are declared but no <code>McpServerFrontend</code> is configured —
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
</p>
{% endif %}
{% if endpoints.markdown %}
<h3 class="ep-h3">Markdown — <code>POST /chat</code></h3>
<table class="ep-table" data-required-scope="messages">
<thead><tr><th>Vault</th><th>Default agent</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
<tr class="ep-row"
data-kind="markdown"
data-url="{{ endpoints.markdown.url }}"
data-base="{{ endpoints.markdown.base }}"
data-sample-agent="{{ endpoints.markdown.sample_agent }}">
<td>
<code>{{ endpoints.markdown.vault_path }}</code>
<span class="ep-scope-warn" hidden>scope mismatch</span>
{% if endpoints.markdown.log_all_chats %}
<span class="pill">log_all_chats · {{ endpoints.markdown.logged_subdir }}/</span>
{% endif %}
</td>
<td>
{% if endpoints.markdown.default_agent %}
<code>{{ endpoints.markdown.default_agent }}</code>
{% else %}
<span class="muted">— (request must set <code>agent</code> or frontmatter)</span>
{% endif %}
</td>
<td>
<code class="ep-url">{{ endpoints.markdown.url }}</code>
<div class="muted" style="font-size:0.85em; margin-top:0.25rem;">
plugin base: <code>{{ endpoints.markdown.base }}</code>
</div>
</td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-base">Copy plugin base</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
</tbody>
</table>
{% endif %}
{% else %}
<p class="muted">
Nothing exposed yet — declare agents / MCPs and the matching frontends
(<code>AnthropicMessagesFrontend</code>, <code>McpServerFrontend</code>) in your config.
</p>
{% endif %}
</div>
<h2>Recent activity</h2>
<div class="card">
{% if audit %}
<table>
<thead><tr><th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th></tr></thead>
<tbody>
{% for row in audit %}
<tr>
<td><code>{{ row.ts | fmt_dt }}</code></td>
<td>{{ row.actor }}</td>
<td><span class="pill">{{ row.kind }}</span></td>
<td>{{ row.agent_name or "—" }}</td>
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted" style="margin-top:0.85rem;"><a href="{{ p }}/audit">Full log →</a></p>
{% else %}
<p class="muted">Nothing logged yet.</p>
{% endif %}
</div>
<style>
.endpoints .ep-controls {
display: grid;
grid-template-columns: minmax(220px, 1fr) 2fr auto;
gap: 0.85rem;
align-items: end;
margin-bottom: 0.5rem;
}
.endpoints .ep-controls label > span {
display: block;
font-size: 0.8em; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 0.3rem;
}
.endpoints .ep-show {
display: flex; align-items: center; gap: 0.4rem;
padding-bottom: 0.6rem; color: var(--muted); font-size: 0.9em;
}
.endpoints .ep-note {
font-size: 0.85em; margin: 0.25rem 0 1.2rem;
}
.endpoints .ep-h3 {
font-size: 0.95rem; font-weight: 600;
margin: 1.5rem 0 0.55rem;
}
.endpoints .ep-table { table-layout: auto; }
.endpoints .ep-table th.ep-actions-th { width: 1%; }
.endpoints .ep-url {
word-break: break-all;
}
.endpoints .ep-actions {
white-space: nowrap;
text-align: right;
}
.endpoints .ep-actions button {
padding: 0.32rem 0.7rem;
font-size: 0.85em;
margin-left: 0.3rem;
}
.endpoints .ep-scope-warn {
display: inline-block;
margin-left: 0.5rem;
padding: 0.05rem 0.45rem;
background: #fff7e6;
border: 1px solid #fadf9c;
color: #8a5a00;
border-radius: 999px;
font-size: 0.72em;
}
.endpoints .ep-row.scope-mismatch td:not(.ep-actions) { opacity: 0.55; }
.endpoints .ep-row.scope-mismatch .ep-actions button { opacity: 0.7; }
.endpoints .ep-curl-row td { padding-top: 0; padding-bottom: 0; }
.endpoints .ep-curl {
margin: 0.4rem 0 1rem;
font-size: 0.82em;
max-height: 280px;
}
.endpoints button[data-copied="1"] {
background: #ecfdf3 !important;
border-color: #b6e6cb !important;
color: #027a48;
}
@media (max-width: 760px) {
.endpoints .ep-controls { grid-template-columns: 1fr; }
.endpoints .ep-actions { text-align: left; padding-top: 0.4rem; }
}
</style>
<script>
(() => {
const card = document.querySelector(".endpoints");
if (!card) return;
const sel = card.querySelector("#ep-token-select");
const secret = card.querySelector("#ep-token-secret");
const showCb = card.querySelector("#ep-show-secret");
if (!sel || !secret || !showCb) return;
const PLACEHOLDER = "<YOUR_TOKEN>";
function currentToken() {
const v = secret.value;
return v && v.length > 0 ? v : null;
}
function selectedScope() {
const o = sel.options[sel.selectedIndex];
return o ? (o.getAttribute("data-scope") || "") : "";
}
function scopeCovers(have, need) {
if (!have) return true; // no token selected — don't grey anything out
if (have === "*") return true;
return have === need;
}
function escSh(s) {
// single-quote shell escape: close, escape, reopen.
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}
function buildCurl(row) {
const kind = row.getAttribute("data-kind");
const url = row.getAttribute("data-url");
const tok = currentToken() || PLACEHOLDER;
if (kind === "messages") {
const agent = row.getAttribute("data-agent");
const body = JSON.stringify({
model: agent,
messages: [{ role: "user", content: "hello" }],
stream: true,
});
return [
"curl -N \\",
" -H " + escSh("x-api-key: " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -d " + escSh(body) + " \\",
" " + url,
].join("\n");
}
if (kind === "markdown") {
const agent = row.getAttribute("data-sample-agent") || "";
const body = JSON.stringify({
filename: "example.md",
content: "### User:\n\nhello\n",
agent: agent,
});
return [
"curl \\",
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -d " + escSh(body) + " \\",
" " + url,
].join("\n");
}
if (kind === "mcp") {
// Streamable-HTTP MCP wants `initialize` first — the response
// carries the `Mcp-Session-Id` header you must echo back on
// every subsequent call (tools/list, tools/call, ...). We use
// `-i` so the session-id is visible in the response, and ship
// the proper handshake body so a fresh paste actually works.
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "beaver-admin-curl", version: "0" },
},
});
return [
"# 1) initialize — grab Mcp-Session-Id from the response headers",
"curl -N -i \\",
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -H 'accept: application/json, text/event-stream' \\",
" -d " + escSh(body) + " \\",
" " + url,
"",
"# 2) reuse that id on follow-up calls, e.g. tools/list:",
"# curl -N \\",
"# -H " + escSh("Authorization: Bearer " + tok) + " \\",
"# -H 'Mcp-Session-Id: <paste-from-step-1>' \\",
"# -H 'content-type: application/json' \\",
"# -H 'accept: application/json, text/event-stream' \\",
"# -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}' \\",
"# " + url,
].join("\n");
}
return "";
}
function refreshScopeMarks() {
const have = selectedScope();
card.querySelectorAll(".ep-table").forEach((tbl) => {
const need = tbl.getAttribute("data-required-scope") || "";
const ok = scopeCovers(have, need);
tbl.querySelectorAll("tr.ep-row").forEach((row) => {
const warn = row.querySelector(".ep-scope-warn");
if (!ok) {
row.classList.add("scope-mismatch");
if (warn) warn.hidden = false;
} else {
row.classList.remove("scope-mismatch");
if (warn) warn.hidden = true;
}
});
});
}
function refreshOpenCurls() {
card.querySelectorAll("tr.ep-curl-row").forEach((cr) => {
if (cr.hidden) return;
const row = cr.previousElementSibling;
const pre = cr.querySelector(".ep-curl");
if (row && pre) pre.textContent = buildCurl(row);
});
}
function copyText(btn, text) {
const done = () => {
btn.setAttribute("data-copied", "1");
const prev = btn.textContent;
btn.textContent = "✓ copied";
setTimeout(() => {
btn.removeAttribute("data-copied");
btn.textContent = prev;
}, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, () => {
// Fallback for non-secure contexts.
fallbackCopy(text);
done();
});
} else {
fallbackCopy(text);
done();
}
}
function fallbackCopy(text) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch {}
document.body.removeChild(ta);
}
sel.addEventListener("change", () => {
const name = sel.value;
secret.placeholder = name
? "paste plaintext for '" + name + "' (we only store the hash)"
: "paste plaintext (we only store the Argon2 hash)";
refreshScopeMarks();
});
secret.addEventListener("input", refreshOpenCurls);
showCb.addEventListener("change", () => {
secret.type = showCb.checked ? "text" : "password";
});
card.addEventListener("click", (ev) => {
const btn = ev.target.closest("button[data-action]");
if (!btn) return;
const row = btn.closest("tr.ep-row");
if (!row) return;
const action = btn.getAttribute("data-action");
const url = row.getAttribute("data-url");
const tok = currentToken() || PLACEHOLDER;
if (action === "copy-url") {
copyText(btn, url);
} else if (action === "copy-base") {
const base = row.getAttribute("data-base");
if (base) copyText(btn, base);
} else if (action === "copy-url-token") {
const sep = url.indexOf("?") >= 0 ? "&" : "?";
copyText(btn, url + sep + "token=" + encodeURIComponent(tok));
} else if (action === "copy-curl") {
copyText(btn, buildCurl(row));
} else if (action === "toggle-curl") {
const curlRow = row.nextElementSibling;
if (!curlRow || !curlRow.classList.contains("ep-curl-row")) return;
const pre = curlRow.querySelector(".ep-curl");
if (curlRow.hidden) {
if (pre) pre.textContent = buildCurl(row);
curlRow.hidden = false;
btn.textContent = "▾ curl";
btn.setAttribute("aria-expanded", "true");
} else {
curlRow.hidden = true;
btn.textContent = "▸ curl";
btn.setAttribute("aria-expanded", "false");
}
}
});
refreshScopeMarks();
})();
</script>
{% endblock %}
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>beaver-gateway · log in</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #fbfbfd; color: #1d1d1f; }
body {
font-family: -apple-system, "SF Pro Display", system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.card {
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 16px;
padding: 2rem 2.25rem;
width: min(360px, 92vw);
box-shadow: 0 10px 24px rgba(0,0,0,0.04);
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
}
p.muted { color: #6e6e73; margin: 0 0 1.4rem; font-size: 0.92em; }
label { display: block; margin: 0.85rem 0 0.3rem; font-size: 0.82em; color: #6e6e73; }
input {
width: 100%;
padding: 0.6rem 0.75rem;
font-family: inherit;
font-size: 0.95em;
border: 1px solid #e5e5ea;
border-radius: 8px;
background: #fff;
box-sizing: border-box;
}
input:focus { outline: 2px solid #0071e3; outline-offset: 1px; }
button {
width: 100%;
margin-top: 1.25rem;
padding: 0.65rem;
font-family: inherit;
font-size: 0.95em;
background: #0071e3;
border: 1px solid #0071e3;
color: white;
border-radius: 8px;
cursor: pointer;
}
button:hover { background: #005bb5; }
.error {
background: #fff0f0;
border: 1px solid #f5c2c5;
color: #d70015;
padding: 0.65rem 0.85rem;
border-radius: 8px;
font-size: 0.9em;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="card">
<h1>beaver-gateway</h1>
<p class="muted">Sign in to manage tokens and view audit logs.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post" action="{{ p }}/login">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>
@@ -0,0 +1,146 @@
{% extends "_layout.html" %}
{% set active = "pty" %}
{% block title %}PTY {{ session_id }} · beaver-gateway{% endblock %}
{% block content %}
<style>
/* xterm.js styles inlined from the CDN bundle — keep them tight and
constrained to the terminal block so they don't leak into the rest
of the admin UI. */
.term-wrap {
background: #000;
border-radius: 12px;
padding: 12px;
border: 1px solid var(--line);
}
.term-host {
height: 70vh;
}
.term-host .xterm {
height: 100%;
}
.term-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.term-toolbar .status {
margin-left: auto;
font-size: 0.85em;
color: var(--muted);
}
.term-toolbar .status.connected { color: #027a48; }
.term-toolbar .status.dropped { color: var(--danger); }
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<h2>PTY <code>{{ session_id }}</code></h2>
<p class="muted">
Live view of the claude subprocess TUI. Keystrokes you type here go
straight into its stdin — Enter, arrows, Ctrl-C, paste, all work.
<a href="{{ p }}/pty">← back to list</a>
</p>
<div class="term-toolbar">
<button id="reconnect-btn" type="button">Reconnect</button>
<button id="enter-btn" type="button" title="Send a single \r — handy if a paste is stuck in the input box">Send Enter</button>
<button id="ctrl-c-btn" type="button">Send Ctrl-C</button>
<span id="status" class="status">connecting…</span>
</div>
<div class="term-wrap">
<div id="term" class="term-host"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script>
(function () {
const sessionId = {{ session_id|tojson }};
const wsBase =
(location.protocol === "https:" ? "wss://" : "ws://") +
location.host +
{{ p|tojson }} +
"/pty/" + encodeURIComponent(sessionId) + "/ws";
const term = new Terminal({
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
fontSize: 13,
theme: {
background: "#000000",
foreground: "#d0d0d0",
},
convertEol: false,
cursorBlink: true,
scrollback: 5000,
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById("term"));
// Defer fit until after layout settles; xterm's measurement reads
// computed CSS that isn't stable until the page paints once.
requestAnimationFrame(() => fit.fit());
window.addEventListener("resize", () => fit.fit());
const statusEl = document.getElementById("status");
function setStatus(text, cls) {
statusEl.textContent = text;
statusEl.className = "status" + (cls ? " " + cls : "");
}
let ws = null;
let manuallyClosed = false;
function connect() {
manuallyClosed = false;
setStatus("connecting…", "");
ws = new WebSocket(wsBase);
ws.binaryType = "arraybuffer";
ws.onopen = () => setStatus("connected", "connected");
ws.onclose = () => {
if (!manuallyClosed) setStatus("disconnected", "dropped");
};
ws.onerror = () => setStatus("error", "dropped");
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
term.write(ev.data);
} else {
// ArrayBuffer — feed raw bytes to xterm. It expects either
// string or Uint8Array; the latter preserves byte boundaries
// exactly, which matters for ANSI sequences split across frames.
term.write(new Uint8Array(ev.data));
}
};
}
// Keystrokes -> server. xterm.js gives us the exact byte sequence
// the terminal would emit (e.g. Enter -> "\r", arrows -> "\x1b[A"
// etc.), so we just pass it through.
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
document.getElementById("reconnect-btn").addEventListener("click", () => {
if (ws) {
manuallyClosed = true;
ws.close();
}
connect();
});
document.getElementById("enter-btn").addEventListener("click", () => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\r");
term.focus();
});
document.getElementById("ctrl-c-btn").addEventListener("click", () => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x03");
term.focus();
});
connect();
})();
</script>
{% endblock %}
@@ -0,0 +1,46 @@
{% extends "_layout.html" %}
{% set active = "pty" %}
{% block title %}PTY · beaver-gateway · admin{% endblock %}
{% block content %}
<h2>Live PTY sessions</h2>
<p class="muted">
Each row is a running <code>claude</code> subprocess, most recently used first.
Open one to see what's currently rendered on its TUI and (if needed) type into
it directly. Sessions idle past the agent's TTL are terminated automatically.
</p>
{% if sessions %}
<div class="card" style="padding: 0;">
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Agent</th>
<th>Idle</th>
<th>Age</th>
<th>PID</th>
<th>Buffer</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td><code>{{ s.session_id }}</code></td>
<td>{{ s.agent }}</td>
<td>{{ s.idle }}</td>
<td class="muted">{{ s.age }}</td>
<td><span class="pill">{{ s.pid or "?" }}</span></td>
<td>{{ s.buffer_size }} bytes</td>
<td>
<a class="btn primary" href="{{ p }}/pty/{{ s.session_id }}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="banner">No live PTY sessions right now. Start a turn and refresh.</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,64 @@
{% extends "_layout.html" %}
{% set active = "tokens" %}
{% block title %}beaver-gateway · tokens{% endblock %}
{% block content %}
<h2>Create token</h2>
<div class="card">
<p class="muted">Plaintext is shown <strong>once</strong>, immediately after creation. Copy it before you navigate away — the database only ever holds the Argon2 hash.</p>
<div id="token-create-result"></div>
<form
hx-post="{{ p }}/tokens"
hx-target="#token-create-result"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){this.reset();}"
>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<div class="form-grid">
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" placeholder="cursor / claude-desktop / mobile …" required>
</div>
<div>
<label for="scope">Scope</label>
<select id="scope" name="scope">
{% for s in scopes %}
<option value="{{ s }}" {% if s == "*" %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div>
<button class="primary" type="submit">Create</button>
</div>
</div>
</form>
</div>
<h2>
Tokens
<span class="muted" style="font-size:0.8em; font-weight:400; margin-left:0.5rem;">
{% if include_revoked %}
<a href="{{ p }}/tokens">Hide revoked</a>
{% else %}
<a href="{{ p }}/tokens?include_revoked=1">Show revoked</a>
{% endif %}
</span>
</h2>
<div class="card">
<table>
<thead>
<tr>
<th>Name</th><th>Scope</th><th>Created</th><th>Last used</th><th>Revoked</th><th></th>
</tr>
</thead>
{# Render the tbody unconditionally so the HTMX OOB swap on
create has a target even when the table starts empty. #}
<tbody id="tokens-rows">
{% for token in tokens %}
{% include "_token_row.html" %}
{% else %}
<tr id="tokens-empty"><td colspan="6" class="muted">No tokens yet. Create one above.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+117 -142
View File
@@ -1,38 +1,42 @@
"""``POST /v1/messages`` frontend.
Exposes the gateway as an Anthropic-compatible Messages endpoint, so any
client that already speaks Anthropic can reach a configured agent by
passing its name as ``model``.
client that already speaks Anthropic (Cursor, Cline, the official SDK,
``curl``) can hit a configured agent by passing its name as ``model``.
The frontend is intentionally thin: it authenticates the bearer token,
resolves ``body.model`` to an agent + its backend, and then either
streams the backend's events straight to SSE or accumulates them into a
single ``Message`` for ``stream=false`` callers. All provider quirks
already live in the backend adapters; we don't translate here.
Phase 1.4 wires only ``RaycastAgent`` through ``RaycastBackend``;
``ClaudeAgent`` lands in Phase 2 and will plug into the same dispatch
table without changes to this module.
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import TYPE_CHECKING, Any
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.backends.transcript import fingerprint, text_of
from beaver_gateway.frontends.accumulate import StreamAccumulator
from beaver_gateway.core import audit
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator, accumulate
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.frontends.turn_record import TurnRecord
from beaver_gateway.security import audit
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable
from collections.abc import AsyncIterator
from anthropic.types import Message, MessageParam
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.events.stream import MessageStreamEvent
from beaver_gateway.core.events import MessageStreamEvent
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation
_log = logging.getLogger("beaver_gateway.frontends.anthropic")
@@ -40,18 +44,28 @@ _log = logging.getLogger("beaver_gateway.frontends.anthropic")
__all__ = ["AnthropicMessagesFrontend"]
FRONTEND = "anthropic"
_TITLE_LEN = 80
class AnthropicMessagesFrontend(Frontend):
"""FastAPI app behind ``POST /v1/messages`` + ``GET /v1/models``."""
name = FRONTEND
kinds = ("deep",)
path = "/anthropic"
def __init__(self) -> None:
def __init__(
self,
*,
host: str = "0.0.0.0", # noqa: S104
port: int = 8000,
public_base_url: str | None = None,
) -> None:
self.host = host
self.port = port
# External URL prefix the reverse proxy (Caddy/nginx/Cloudflare/…)
# uses to reach this frontend, e.g. ``https://api.example.com/ai``.
# The frontend's internal paths (``/v1/messages``, ``/v1/models``)
# are appended to it when the admin dashboard renders copy-pastable
# URLs. Trailing slash is stripped so the concatenation is
# idempotent. ``None`` means "advertise raw ``host:port``" (dev /
# no proxy) — the dashboard then derives the base from the
# browser's own request hostname.
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None
@@ -59,8 +73,21 @@ class AnthropicMessagesFrontend(Frontend):
self._runtime = runtime
self._app = self._build_app(runtime)
def app(self) -> FastAPI | None:
return self._app
async def serve(self) -> None:
# Local import: uvicorn pulls in a lot, no reason to load it when
# something else (a test, a script) imports this module just for
# the FastAPI factory.
import uvicorn
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
)
server = uvicorn.Server(config)
await server.serve()
def _build_app(self, runtime: GatewayRuntime) -> FastAPI:
app = FastAPI(title="beaver-gateway / Anthropic Messages")
@@ -107,6 +134,9 @@ class AnthropicMessagesFrontend(Frontend):
backend = runtime.backends.get(agent.name)
if backend is None:
# Agent exists in config but its backend isn't wired in
# this phase (e.g. ClaudeAgent before Phase 2, or a
# RaycastAgent without RAYCAST_BEARER set at startup).
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
f"no backend configured for agent {agent.name!r}",
@@ -114,12 +144,7 @@ class AnthropicMessagesFrontend(Frontend):
messages = body.get("messages") or []
system = body.get("system")
system_str = system if isinstance(system, str) else None
stream_flag = bool(body.get("stream", False))
if not messages or messages[-1].get("role") != "user":
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "the last message must be a user turn"
)
_log.info(
"messages: actor=%s agent=%s stream=%s msgs=%d",
@@ -128,6 +153,10 @@ class AnthropicMessagesFrontend(Frontend):
stream_flag,
len(messages),
)
# Record at request acceptance, not at stream completion:
# a long streaming response can be aborted mid-flight by
# the client, and we still want the row in the audit trail.
# Detail stays small — no message bodies, no system prompt.
await audit.log(
runtime,
actor=f"token:{token_name}",
@@ -137,37 +166,41 @@ class AnthropicMessagesFrontend(Frontend):
msgs=len(messages),
)
if isinstance(agent, ClaudeAgent) and runtime.conversations is not None:
conv, history = await _resolve(runtime.conversations, agent, messages)
turn_id = f"turn_{uuid.uuid4().hex[:12]}"
capture = TurnCapture()
events = runtime.conversations.turn(
conv,
messages=messages,
origin="user",
capture=capture,
turn_id=turn_id,
)
async def after(message: Message) -> None:
await _finish(
runtime,
conv,
messages=messages,
message=message,
capture=capture,
turn_id=turn_id,
history=history,
)
else:
# Forward per-request knobs the Anthropic body may carry —
# backend adapters layer these over per-agent defaults. Only
# values explicitly present (not Anthropic-defaulted ones we
# never received) are forwarded, so the agent's default still
# wins when the caller omits the field.
options: dict[str, Any] = {}
if isinstance(body.get("temperature"), int | float):
options["temperature"] = body["temperature"]
events = backend.complete(
agent=agent, messages=messages, system=system_str, **options
agent=agent,
messages=messages,
system=system if isinstance(system, str) else None,
**options,
)
async def after(message: Message) -> None:
system_str = system if isinstance(system, str) else None
if stream_flag:
# Side-accumulate while streaming so we can still emit a
# ``TurnRecord`` after the response closes. The buffered
# ``Message`` lives only in this coroutine's frame; SSE
# bytes still flow to the client unchanged.
return StreamingResponse(
_sse_and_broadcast(
events,
runtime=runtime,
agent_name=agent.name,
input_messages=messages,
system=system_str,
model=model,
),
media_type="text/event-stream",
)
message = await accumulate(events, model=model)
await _broadcast_turn(
runtime,
agent_name=agent.name,
@@ -175,99 +208,32 @@ class AnthropicMessagesFrontend(Frontend):
output_message=message,
system=system_str,
)
if stream_flag:
return StreamingResponse(
_sse(events, model=model, after=after),
media_type="text/event-stream",
)
acc = StreamAccumulator()
async for ev in events:
acc.feed(ev)
message = acc.finalize(model=model)
await after(message)
return JSONResponse(content=message.model_dump(mode="json"))
return app
async def _resolve(
conversations: Conversations, agent: ClaudeAgent, messages: list[MessageParam]
) -> tuple[Conversation, list[dict[str, str]] | None]:
prior = [dict(m) for m in messages[:-1]]
if prior:
conv = await conversations.find_bound(
frontend=FRONTEND, external_id=fingerprint(prior)
)
if conv is not None:
if conv.agent_name != agent.name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"this chat runs on {conv.agent_name!r}, not {agent.name!r}",
)
return conv, None
first = next(
(text_of(m.get("content")) for m in messages if m.get("role") == "user"), ""
)
try:
conv = await conversations.create(
kind="deep",
agent=agent.name,
origin=FRONTEND,
title=" ".join(first.split())[:_TITLE_LEN] or None,
)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await conversations.materialize(conv)
history = [
{"role": str(m.get("role", "")), "text": text_of(m.get("content"))}
for m in prior
]
return conv, history
async def _finish(
runtime: GatewayRuntime,
conv: Conversation,
*,
messages: list[MessageParam],
message: Message,
capture: TurnCapture,
turn_id: str,
history: list[dict[str, str]] | None,
) -> None:
reply = "\n\n".join(
getattr(b, "text", "")
for b in message.content
if getattr(b, "type", "") == "text"
).strip()
tail = capture.synthesized_messages or [
{"role": "assistant", "content": [{"type": "text", "text": reply}]}
]
await runtime.conversations.bind(
conv, frontend=FRONTEND, external_id=fingerprint([*messages, *tail])
)
runtime.bus.publish(
"reply",
conversation_id=conv.external_id,
turn_id=turn_id,
source=FRONTEND,
prompt=text_of(messages[-1].get("content")),
text=reply,
history=history,
)
async def _sse(
async def _sse_and_broadcast(
events: AsyncIterator[MessageStreamEvent],
*,
runtime: GatewayRuntime,
agent_name: str,
input_messages: list[MessageParam],
system: str | None,
model: str,
after: Callable[[Message], Awaitable[None]],
) -> AsyncIterator[bytes]:
r"""Serialize an event stream to SSE, then hand the assembled ``Message`` on.
r"""Serialize an event stream to SSE; broadcast a :class:`TurnRecord` after.
Wire format is ``event: <type>\ndata: <json>\n\n``; mid-stream errors
become a synthetic ``error`` event instead of a hung connection.
Each event becomes ``event: <type>\ndata: <json>\n\n`` — the shape
the Anthropic SDK's SSE decoder expects. Errors mid-stream are
swallowed into a synthetic ``error`` event so the client sees the
failure rather than a hung connection.
The same events feed a :class:`StreamAccumulator` on the side so that
once the SSE response closes we can hand a fully-assembled
``Message`` to every ``runtime.turn_log_handlers`` entry (the
markdown frontend's archive logger lives in there). Broadcast
failures are caught — they must never bubble up to the client.
"""
acc = StreamAccumulator()
try:
@@ -282,10 +248,14 @@ async def _sse(
)
yield f"event: error\ndata: {err}\n\n".encode()
return
try:
await after(acc.finalize(model=model))
except Exception: # noqa: BLE001
_log.exception("post-turn hook failed")
message = acc.finalize(model=model)
await _broadcast_turn(
runtime,
agent_name=agent_name,
input_messages=input_messages,
output_message=message,
system=system,
)
async def _broadcast_turn(
@@ -296,6 +266,11 @@ async def _broadcast_turn(
output_message: Message,
system: str | None,
) -> None:
"""Fire each ``turn_log_handlers`` entry with a fresh :class:`TurnRecord`.
Handler exceptions are caught and logged — they're observability
plumbing, not part of the user-visible request path.
"""
if not runtime.turn_log_handlers:
return
record = TurnRecord(
@@ -303,7 +278,7 @@ async def _broadcast_turn(
input_messages=list(input_messages),
output_message=output_message,
system=system,
source=FRONTEND,
source="anthropic",
)
for handler in runtime.turn_log_handlers:
try:
@@ -1,5 +0,0 @@
"""``ApiFrontend`` - the conversations API and event stream."""
from beaver_gateway.frontends.api.frontend import ApiFrontend
__all__ = ["ApiFrontend"]
File diff suppressed because it is too large. Load diff
+48 -87
View File
@@ -1,31 +1,27 @@
"""Frontend ABC and the runtime context handed to ``configure``.
"""Frontend ABC + the runtime context handed to ``configure``.
A frontend routes inbound traffic into the gateway (an HTTP mount, a
poller, or both); ``GatewayRuntime`` carries the built state each needs.
A frontend is anything that listens on a port and routes inbound traffic
into the gateway. ``GatewayRuntime`` carries everything a frontend may
need that isn't user-config: built registries, per-agent backends, and
the in-memory token store. The user's ``/config/config.py`` defines a
``Gateway`` (lists); ``cli.main`` turns that into a ``GatewayRuntime``
and hands it to each frontend's ``configure``.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from beaver_gateway.frontends.files import SendFileError
from beaver_gateway.security.auth import BUILTIN_SCOPES
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping, Sequence
from pathlib import Path
from starlette.types import ASGIApp
from beaver_gateway.app import AgentRegistry, McpRegistry
from beaver_gateway.backends.base import Backend
from beaver_gateway.conversations.kinds import Kind
from beaver_gateway.frontends.turn_record import TurnRecord
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.storage import Database
from beaver_gateway.storage.models import Conversation, ConversationBinding
TurnLogHandler = Callable[[TurnRecord], Awaitable[None]]
@@ -34,8 +30,20 @@ if TYPE_CHECKING:
class GatewayRuntime:
"""Post-build state of the gateway, shared with every frontend.
Backends are keyed by agent name, not type: one backend instance may
serve several agents, so the indirection lives at lookup time.
Backends are keyed by **agent name**, not type — one ``RaycastBackend``
instance can serve many ``RaycastAgent`` instances, but the lookup
site (an inbound request with ``model=<agent.name>``) already has
the name in hand, so the indirection lives one step earlier.
``mcp_internal_urls`` is filled in Phase 2.1: one loopback URL per
declared ``McpServer`` so ``ClaudeCodeBackendAdapter`` (Phase 2.2)
can pass them to ``BackendOptions.mcp_servers`` without re-running
discovery.
``db`` (Phase 4.1) is the shared :class:`Database` handle. Phase 4.2
will switch ``TokenStore`` to read from it; Phase 4.3 admin/audit
write through it. Phase 4.1 only attaches it — existing frontends
ignore it.
"""
agents: AgentRegistry
@@ -44,85 +52,38 @@ class GatewayRuntime:
token_store: TokenStore
db: Database
mcp_internal_urls: Mapping[str, str] = field(default_factory=dict)
# Phase 4.3 — AdminFrontend reads creds + cookie-signing key from
# the runtime so the user's ``config.py`` doesn't have to know
# anything about env wiring. Defaulted to empty so existing tests /
# call sites that don't touch the admin path keep building; the
# admin frontend ``configure()`` itself rejects empty values.
admin_user: str = ""
"""Operator login for the admin console, checked by ``AdminFrontend``."""
admin_pass: str = ""
session_secret: str = ""
# The full sibling-frontends list, in declaration order. AdminFrontend
# uses it to advertise concrete bearer-endpoint URLs (host/port) on
# the dashboard so the operator can copy ready-to-use links / curl
# snippets. Other frontends ignore it.
frontends: Sequence[Frontend] = field(default_factory=tuple)
"""Every frontend in declaration order, for advertising their URLs."""
# Frontends that finish a turn (Anthropic Messages, Markdown) iterate
# this list and ``await`` each handler with a ``TurnRecord``. Handlers
# are appended during ``configure()`` by frontends that want a
# cross-frontend chat archive — currently the markdown frontend's
# ``log_all_chats`` mode. Handler exceptions are caught at the call
# site; they never block the user-visible response.
#
# The field is typed as ``list[Any]`` rather than the precise
# ``list[TurnLogHandler]`` because the alias lives under TYPE_CHECKING
# to keep ``anthropic.types`` out of the runtime import graph for
# this base module.
turn_log_handlers: list[TurnLogHandler] = field(default_factory=list)
"""Called with a ``TurnRecord`` after each turn; failures never reach the user."""
conversations: Any = None
bus: Any = None
pool: Any = None
scheduler: Any = None
public_url: str | None = None
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
scopes: frozenset[str] = BUILTIN_SCOPES
"""Every token scope this gateway knows: the builtins plus each frontend's."""
send_file_roots: tuple[Path, ...] = ()
"""Directories ``send_file`` may read, from ``SEND_FILE_ROOTS``."""
class Frontend(ABC):
"""Routes inbound traffic into the gateway.
HTTP frontends set ``path`` and return their ASGI app from ``app()``;
these are mounted under that path on the one gateway port. ``serve()``
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
``landing`` marks the app that ``/`` redirects to.
``scope`` is the token scope this frontend's routes are gated by; a
frontend that names its own scope gets keys nobody else's token opens,
and ``POST /api/tokens`` will mint them (see ``security/auth.py``).
A frontend that shows conversations declares ``name`` (the binding
key) and ``kinds`` (which conversation kinds it shows). The first
frontend whose ``materialize`` returns a binding is the *home* of
that kind, used by ``spawn`` for new conversations; ``agent_for``
names the default agent for a kind. Stateless frontends (MCP, admin)
leave these at their defaults.
"""
name: str = ""
kinds: tuple[Kind, ...] = ()
path: str | None = None
landing: bool = False
scope: str | None = None
sends_files: bool = False
"""Whether ``send_file`` reaches a human through this frontend."""
"""Listens on a port, dispatches into the gateway."""
@abstractmethod
def configure(self, runtime: GatewayRuntime) -> None: ...
def app(self) -> ASGIApp | None:
return None
async def serve(self) -> None:
return
def agent_for(self, kind: Kind) -> str | None: # noqa: ARG002
return None
async def materialize(self, conv: Conversation) -> ConversationBinding | None: # noqa: ARG002
return None
async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002
"""Show in the window that the conversation is over (a renamed topic)."""
return False
async def send_file(
self,
conv: Conversation, # noqa: ARG002
path: str, # noqa: ARG002
*,
caption: str = "", # noqa: ARG002
method: str = "auto", # noqa: ARG002
) -> str:
"""Put a file in this conversation's window; the answer goes to the model."""
msg = f"frontend {self.name!r} cannot send files"
raise SendFileError(msg)
def file_note(self) -> str:
"""Roots and ceiling, for the tool description the model reads."""
return ""
@abstractmethod
async def serve(self) -> None: ...
-95
View File
@@ -1,95 +0,0 @@
"""What an agent may hand to a human: an allowed path, a ceiling, a method.
The ceiling and the method names belong to the frontend's API; the check
lives here so every frontend refuses the same way, in words the model reads.
"""
from __future__ import annotations
import mimetypes
from dataclasses import dataclass, replace
from pathlib import Path
__all__ = ["AUTO", "DEFAULTS", "METHODS", "SendFileError", "SendFiles", "method_for"]
AUTO = "auto"
METHODS = ("document", "photo", "audio", "voice", "video", "video_note", "animation")
_BY_SUFFIX = {
".jpg": "photo",
".jpeg": "photo",
".png": "photo",
".webp": "photo",
".gif": "animation",
".mp3": "audio",
".flac": "audio",
".m4a": "audio",
".wav": "audio",
".aac": "audio",
".ogg": "voice",
".oga": "voice",
".opus": "voice",
".mp4": "video",
".m4v": "video",
".mov": "video",
}
_BY_TYPE = {"image": "photo", "audio": "audio", "video": "video"}
class SendFileError(Exception):
"""Why a file was not sent, worded for the model that asked."""
@dataclass(frozen=True, slots=True)
class SendFiles:
"""Which files a frontend accepts from an agent and how big they may be."""
roots: tuple[Path, ...] = ()
"""Directories a path must resolve inside; nothing outside is sendable."""
max_bytes: int = 50 * 1024 * 1024
"""Upload ceiling of the API behind the frontend; the Bot API gives 50 MB."""
def with_roots(self, *roots: Path) -> SendFiles:
return replace(self, roots=tuple(dict.fromkeys((*self.roots, *roots))))
@property
def note(self) -> str:
"""Roots and ceiling as one line for the tool description."""
where = ", ".join(str(r) for r in self.roots) or "none, so nothing is sendable"
return f"paths under {where}, up to {mb(self.max_bytes)} MB"
def resolve(self, raw: str) -> Path:
path = Path(raw.strip()).expanduser().resolve()
if not any(path.is_relative_to(r.expanduser().resolve()) for r in self.roots):
msg = f"{path} is outside the allowed roots ({self.note}); copy it there"
raise SendFileError(msg)
if not path.is_file():
msg = f"{path} is not a file"
raise SendFileError(msg)
size = path.stat().st_size
if size > self.max_bytes:
msg = (
f"{path.name} is {mb(size)} MB, over the {mb(self.max_bytes)} MB limit"
)
raise SendFileError(msg)
return path
DEFAULTS = SendFiles()
def method_for(path: Path, method: str = AUTO) -> str:
if method and method != AUTO:
if method not in METHODS:
msg = f"unknown method {method!r}; one of: {', '.join((AUTO, *METHODS))}"
raise SendFileError(msg)
return method
guessed = _BY_SUFFIX.get(path.suffix.lower())
if guessed:
return guessed
mime, _ = mimetypes.guess_type(path.name)
return _BY_TYPE.get(mime.split("/")[0], "document") if mime else "document"
def mb(size: int) -> str:
return f"{round(size / 1024 / 1024, 2):g}"
@@ -1,7 +1,13 @@
"""Markdown frontend — chat archive backed by ``.md`` files in an Obsidian vault.
"""Markdown frontend — turn-by-turn chat archive backed by ``.md`` files.
An Obsidian plugin POSTs ``{filename, content?}`` to ``/chat``; the
frontend runs the agent on a trailing user turn and appends the reply.
The user maintains chats as plain markdown files in an Obsidian vault.
A plugin in Obsidian POSTs ``{filename, content?}`` to ``/chat`` and the
frontend parses the file, finds the last turn, and runs the agent if
the last turn is ``user``. The full response is appended back to the
file as an ``### Assistant:`` turn. With ``log_all_chats=True`` the
frontend also subscribes to every other frontend's turns and writes
them into ``{vault_path}/{logged_subdir}/`` so the vault is the single
chronological archive of all conversations.
"""
from beaver_gateway.frontends.markdown.frontend import MarkdownFrontend
@@ -1,7 +1,17 @@
"""Cross-frontend chat logger.
Mirrors turns completed by other frontends into the vault as ``.md``
files, matching a conversation's continuation by content-hash fingerprint.
When ``MarkdownFrontend(log_all_chats=True)`` is configured, every turn
completed by any other frontend (currently the Anthropic Messages
frontend) is mirrored into the vault as a ``.md`` file. Subsequent
turns of the same conversation append to the same file — matched by a
content-hash fingerprint stored in YAML frontmatter.
The fingerprint hashes the message history *before* the new assistant
reply. So the next request's input history (which now includes the
prior assistant reply) hashes to the value we just persisted —
``hash(prev_input + [assistant_reply])`` — and the lookup hits the
same file. New conversations (no prior fingerprint match) get a fresh
file under ``{vault_path}/{logged_subdir}/{agent_name}/``.
"""
from __future__ import annotations
@@ -22,29 +32,31 @@ if TYPE_CHECKING:
from anthropic.types import MessageParam
from beaver_gateway.frontends.turn_record import TurnRecord
from beaver_gateway.core.turn_record import TurnRecord
_log = logging.getLogger("beaver_gateway.frontends.markdown.crossfront")
__all__ = [
"ChatPathFn",
"CrossFrontendLogger",
"fingerprint_messages",
"strip_trailing_user_scaffold",
]
# User hook: take a turn + vault root, return where the new file should
# live. Returning a relative ``Path`` is treated as relative to the
# vault. ``None`` (the default) keeps the built-in
# ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{hex8}.md`` layout.
LogPathFn = "Callable[[TurnRecord, Path], Path]"
ChatPathFn = "Callable[[str, str, Path], Path]"
"""``(title, agent, vault) -> path`` of a new chat file; relative = under the
vault. ``None`` keeps ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{slug}.md``."""
__all__ = ["CrossFrontendLogger", "LogPathFn", "fingerprint_messages"]
def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
"""Stable, short hex hash of a conversation prefix.
Hashes ``(role, text-only content)`` pairs so differently-shaped message
histories that carry the same text still fingerprint identically.
Built from ``(role, normalized_content)`` pairs only — so the
Markdown frontend's parser-shaped messages (text-only) and the
Anthropic frontend's raw ``messages`` payload (which may also be
string-only at v1) hash compatibly when they represent the same
conversation. Tool blocks / images would diverge, but those aren't
in the v1 ingest path.
"""
h = hashlib.sha1(usedforsecurity=False)
for msg in messages:
@@ -71,8 +83,12 @@ def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
class CrossFrontendLogger:
"""Maintains the fingerprint→file map and writes turns to disk.
The map is in-process, rebuilt by ``warm_index`` on startup; all disk
writes funnel through one lock to sidestep races.
The map is in-process; on startup ``warm_index`` rebuilds it from
YAML frontmatter of every file under ``logged_subdir``. A miss
creates a new file, a hit appends to the existing one. All disk
work funnels through one ``asyncio.Lock`` because the writes are
cheap and serializing them sidesteps a class of races we don't need
to think about.
"""
def __init__(
@@ -80,20 +96,27 @@ class CrossFrontendLogger:
*,
vault_path: Path,
logged_subdir: str,
chat_path: Callable[[str, str, Path], Path] | None = None,
log_path: Callable[[TurnRecord, Path], Path] | None = None,
) -> None:
self._vault = vault_path
self._root = vault_path / logged_subdir
self._index: dict[str, Path] = {}
self._lock = asyncio.Lock()
self._chat_path_fn = chat_path
self._scan_root = vault_path if chat_path is not None else self._root
self._log_path_fn = log_path
# When the user supplies a custom path function, files can land
# anywhere in the vault — so we have to scan the whole vault on
# startup to rebuild the fingerprint→path map. With the default
# layout we can bound the scan to ``_logs/``.
self._scan_root = vault_path if log_path is not None else self._root
def warm_index(self) -> None:
"""Scan logged files synchronously, populating the fingerprint map.
Called before any cross-frontend turn arrives; a custom ``chat_path``
forces a full-vault walk instead of scanning just ``logged_subdir``.
Called from ``MarkdownFrontend.configure`` so the map is ready
before any cross-frontend turn arrives. ``frontmatter.load``
reads only enough of the file to parse the YAML head, so the
scan is cheap even on large vaults — but a custom ``log_path``
forces a full-vault walk; mention that in the constructor doc.
"""
if not self._scan_root.exists():
return
@@ -115,19 +138,29 @@ class CrossFrontendLogger:
async def handle(self, record: TurnRecord) -> None:
"""Append or create a logged file for ``record``.
Skips ``source == "markdown"`` records (already on disk); matches
the target file by fingerprinting ``input_messages`` sans the new turn.
Records that the markdown frontend itself produced
(``source=="markdown"``) are skipped — those already live in the
user's hand-written file and shouldn't be duplicated into the
``_logs`` shadow tree.
"""
if record.source == "markdown":
return
async with self._lock:
# ``input_messages`` is the *full* history sent to the backend
# (last entry is the new user turn). Match against the prefix
# that excludes the new user turn — that's what the previous
# write stored as its fingerprint. Empty prefix is the
# well-known "brand new chat" sentinel.
prefix = record.input_messages[:-1]
prev_fp = fingerprint_messages(prefix) if prefix else None
target = self._index.get(prev_fp) if prev_fp else None
if target is None:
target = self._new_file_path(record)
# Build the full history including the assistant reply; the
# new fingerprint matches *that* prefix, so the next user
# turn (history grows by one user msg) will hit this file.
assistant_msg: MessageParam = {
"role": "assistant",
"content": _flatten_text(record.output_message),
@@ -138,7 +171,10 @@ class CrossFrontendLogger:
if target.exists():
existing = target.read_text(encoding="utf-8")
parsed = frontmatter.loads(existing)
body = strip_trailing_user_scaffold(parsed.content)
body = _strip_trailing_user_scaffold(parsed.content)
# We append only the *new* user turn (the last one in
# input_messages, since prior turns are already on disk)
# plus the assistant reply.
new_user = record.input_messages[-1]
new_block = renderer.render_user_param(new_user)
new_block = renderer.append_to_body(
@@ -147,6 +183,7 @@ class CrossFrontendLogger:
new_body = renderer.append_to_body(body, new_block)
metadata = dict(parsed.metadata)
else:
# Materialize the whole conversation from scratch.
new_body = _render_full_history(
record.input_messages, record.output_message
)
@@ -158,20 +195,24 @@ class CrossFrontendLogger:
metadata["fingerprint"] = new_fp
metadata["source"] = record.source
self._write(target, metadata, new_body)
# Maintain the index: drop the old fp (it's stale once we
# write the new turn), add the new one.
if prev_fp:
self._index.pop(prev_fp, None)
self._index[new_fp] = target
# ---- internals -----------------------------------------------------
def _new_file_path(self, record: TurnRecord) -> Path:
"""Pick a fresh filename for a brand-new conversation.
Delegates to ``chat_path`` if set; otherwise
``{logged_subdir}/{agent}/{date}_{hex8}.md``.
With a user-supplied ``log_path`` we delegate to it (joining a
relative result with the vault root). Without one, we fall back
to ``{logged_subdir}/{agent}/{date}_{hex8}.md`` and ensure the
``.md`` suffix in case the user picks a non-md extension by hand.
"""
if self._chat_path_fn is not None:
result = self._chat_path_fn(
record.first_user_text, record.agent_name, self._vault
)
if self._log_path_fn is not None:
result = self._log_path_fn(record, self._vault)
if not result.is_absolute():
result = self._vault / result
if result.suffix != ".md":
@@ -179,6 +220,8 @@ class CrossFrontendLogger:
result.parent.mkdir(parents=True, exist_ok=True)
return result
day = datetime.now(UTC).strftime("%Y-%m-%d")
# Short hex from the input hash so two same-day chats sort
# stably and don't collide.
salt = fingerprint_messages(record.input_messages)[:8]
agent_dir = self._root / record.agent_name
agent_dir.mkdir(parents=True, exist_ok=True)
@@ -191,6 +234,9 @@ class CrossFrontendLogger:
text = frontmatter.dumps(post) + "\n"
else:
text = body if body.endswith("\n") else body + "\n"
# Sync write inside the lock — keeps the implementation tiny;
# individual logged turns are small enough that the blocking
# write doesn't matter at human conversation rates.
path.write_text(text, encoding="utf-8")
@@ -224,16 +270,21 @@ def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
return body
def strip_trailing_user_scaffold(body: str) -> str:
def _strip_trailing_user_scaffold(body: str) -> str:
"""Drop a trailing empty ``### User:`` block if present.
Avoids leaving two ``### User:`` headers in a row when appending a
turn that wasn't typed into the file by hand.
Cross-frontend turns aren't typed into the file by the human — they
arrive whole from another frontend. If we leave the previous run's
scaffold in place, we'd write the new user turn right after an
empty marker (visual noise, two ``### User:`` headers in a row).
Trim it and let the append flow add a fresh scaffold at the end.
"""
stripped = body.rstrip()
marker = "### User:"
if not stripped.endswith(marker):
return body
# Walk back: the scaffold is the marker preceded by either start-of-file
# or an HR/blank line. Find the last newline before the marker, cut.
head = stripped[: -len(marker)].rstrip()
if head.endswith("---"):
head = head[: -len("---")].rstrip()
@@ -1,50 +0,0 @@
"""Vault file IO shared by the markdown frontend and its mirror."""
from __future__ import annotations
import contextlib
import os
import tempfile
from typing import TYPE_CHECKING, Any
import aiofile
import frontmatter
if TYPE_CHECKING:
from pathlib import Path
__all__ = ["read_or_empty", "reattach_frontmatter", "write_atomic"]
async def read_or_empty(path: Path) -> str:
if not path.exists(): # noqa: ASYNC240
return ""
async with aiofile.async_open(path, "r", encoding="utf-8") as f:
return await f.read()
async def write_atomic(path: Path, content: str) -> None:
"""Write via tmp + ``os.replace`` in the same directory: no torn reads."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
)
try:
async with aiofile.async_open(tmp_path, "w", encoding="utf-8") as f:
await f.write(content)
os.close(fd)
os.replace(tmp_path, path) # noqa: PTH105
except BaseException:
with contextlib.suppress(OSError):
os.close(fd)
with contextlib.suppress(OSError):
os.unlink(tmp_path) # noqa: PTH108
raise
def reattach_frontmatter(metadata: dict[str, Any], body: str) -> str:
"""Re-emit a ``.md`` with YAML frontmatter; no block at all for empty metadata."""
if not metadata:
return body if body.endswith("\n") else body + "\n"
post = frontmatter.Post(content=body, **metadata)
return frontmatter.dumps(post) + "\n"
+413 -199
View File
@@ -1,55 +1,71 @@
"""``MarkdownFrontend`` — chat-via-markdown-files frontend.
``POST /chat`` (and SSE ``/chat/stream``) parses the vault file, runs
the agent on the trailing user turn, and appends the reply.
Wires:
* ``POST /chat {filename, content?, agent?}`` — bearer-authenticated
trigger. The plugin in Obsidian fires this after the user edits a
``.md`` and the file gets synced (or with ``content`` to short-circuit
the sync delay). We parse the file, check the last turn — assistant
→ no-op, user → run the agent and append.
* ``GET /healthz`` — liveness.
Concurrency model: an in-memory ``set[Path]`` of files currently in
flight. Two concurrent requests for the same file → the second gets
409. The set is single-process (one gateway instance) — that's by
design; the markdown frontend is the only writer in its vault from
the gateway side.
Cross-frontend logging: when ``log_all_chats=True``, ``configure()``
registers a handler on ``runtime.turn_log_handlers`` so every other
frontend's completed turns also land in the vault. The handler logic
lives in :mod:`.crossfront` so this module stays focused on the HTTP
shape.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import tempfile
import time
from collections.abc import AsyncIterator
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
import aiofile
from anthropic.types import RawContentBlockStopEvent
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.frontends.accumulate import StreamAccumulator
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.frontends.markdown import parser, renderer
from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger
from beaver_gateway.frontends.markdown.files import (
read_or_empty,
reattach_frontmatter,
write_atomic,
)
from beaver_gateway.frontends.markdown.history import (
from beaver_gateway.backends.claude_code import ClaudeCodeBackendAdapter, TurnCapture
from beaver_gateway.core import audit
from beaver_gateway.core.conversation_store import (
diff_and_fork,
load_conversation,
load_messages,
mint_conversation,
rewrite_messages,
)
from beaver_gateway.frontends.markdown.mirror import FRONTEND, ChatMirror
from beaver_gateway.frontends.sse import (
KEEPALIVE,
SSE_HEADERS,
events_with_heartbeat,
sse_pack,
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.markdown import parser, renderer
from beaver_gateway.frontends.markdown.crossfront import (
CrossFrontendLogger,
fingerprint_messages,
)
from beaver_gateway.frontends.turn_record import TurnRecord
from beaver_gateway.security import audit
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable
from beaver_gateway.conversations.kinds import Kind
from anthropic.types import MessageParam
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation, ConversationBinding
_log = logging.getLogger("beaver_gateway.frontends.markdown")
@@ -58,92 +74,102 @@ _log = logging.getLogger("beaver_gateway.frontends.markdown")
__all__ = ["MarkdownFrontend"]
# How often we re-render the assistant turn into the .md file while the
# backend stream is still open. Trades responsiveness (faster updates to
# Obsidian sync / Raycast tailers) against write amplification. Each
# ``RawContentBlockStopEvent`` also forces a flush regardless of the
# timer, so block boundaries always land in the file.
_STREAM_FLUSH_DEBOUNCE = 0.4
# Debounce for the SSE ``/chat/stream`` path. Network IO is cheaper than
# atomic file rewrites, so we send updates more frequently — the client
# wants the lowest possible latency and we control the renderer on the
# other end (the Obsidian plugin splices deltas into the editor, no
# disk round-trip).
_SSE_FLUSH_DEBOUNCE = 0.1
# Interval between SSE comment-frames sent when the backend is silent
# (e.g. claude is mid-thinking on a large context). The Obsidian plugin
# and any intermediate proxies will hold the connection open as long as
# bytes keep flowing; a comment-frame is the cheapest legal SSE keepalive.
# Set well under typical proxy/client idle timeouts (60s).
_SSE_HEARTBEAT_INTERVAL = 15.0
class MarkdownFrontend(Frontend):
"""FastAPI app behind ``POST /chat`` driven by Obsidian-vault files.
``_busy`` tracks in-flight files; check-and-add must stay atomic (no
``await`` between them) so concurrent requests reliably lose to 409.
"""
name = FRONTEND
kinds = ("deep",)
path = "/md"
"""FastAPI app behind ``POST /chat`` driven by Obsidian-vault files."""
def __init__(
self,
*,
vault_path: Path | str,
host: str = "0.0.0.0", # noqa: S104
port: int = 8003,
default_agent: str | None = None,
log_all_chats: bool = False,
logged_subdir: str = "_logs",
chat_path: Callable[[str, str, Path], Path] | None = None,
log_path: Callable[[TurnRecord, Path], Path] | None = None,
public_base_url: str | None = None,
) -> None:
"""Configure the vault-backed frontend.
``chat_path``, if given, overrides where new chat files are created;
``logged_subdir`` holds cross-frontend logs when ``log_all_chats`` is set.
"""
self.vault_path = Path(vault_path).expanduser().resolve()
self.host = host
self.port = port
self.default_agent = default_agent
self.log_all_chats = log_all_chats
self.logged_subdir = logged_subdir
self.chat_path = chat_path
self.log_path = log_path
# External URL prefix when behind a reverse proxy — same role as
# on the other bearer frontends. Trailing slash trimmed for
# idempotent concatenation; ``None`` means "no proxy / advertise
# raw host:port".
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None
# Files currently being processed by an in-flight ``POST /chat``.
# Checked-and-added atomically in the request handler (no
# ``await`` between the check and the insert) so a concurrent
# request reliably loses the race to 409.
self._busy: set[Path] = set()
self._crossfront: CrossFrontendLogger | None = None
self._mirror: ChatMirror | None = None
def configure(self, runtime: GatewayRuntime) -> None:
if runtime.conversations is None:
msg = "MarkdownFrontend needs runtime.conversations"
raise RuntimeError(msg)
self._runtime = runtime
self.vault_path.mkdir(parents=True, exist_ok=True)
self._mirror = ChatMirror(
vault_path=self.vault_path,
runtime=runtime,
logged_subdir=self.logged_subdir,
chat_path=self.chat_path,
)
if self.log_all_chats:
self._crossfront = CrossFrontendLogger(
vault_path=self.vault_path,
logged_subdir=self.logged_subdir,
chat_path=self.chat_path,
log_path=self.log_path,
)
# Scan the existing logged files synchronously here so the
# fingerprint→path map is populated before the first
# cross-frontend turn arrives. Cheap: frontmatter-only read.
self._crossfront.warm_index()
runtime.turn_log_handlers.append(self._crossfront.handle)
self._app = self._build_app(runtime)
@property
def mirror(self) -> ChatMirror:
if self._mirror is None:
msg = "configure() must be called first"
raise RuntimeError(msg)
return self._mirror
def agent_for(self, kind: Kind) -> str | None:
return self.default_agent if kind == "deep" else None
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
return await self.mirror.materialize(conv)
def app(self) -> FastAPI | None:
return self._app
async def serve(self) -> None:
await self.mirror.run()
import uvicorn
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
)
server = uvicorn.Server(config)
await server.serve()
# ---- app builder ---------------------------------------------------
def _build_app(self, runtime: GatewayRuntime) -> FastAPI:
"""CORS is wide open here since auth is bearer-token, not cookie-based."""
app = FastAPI(title="beaver-gateway / Markdown")
# ``/chat/stream`` is consumed via ``fetch`` from the Obsidian
# plugin (``requestUrl`` can't read a body incrementally), and
# ``fetch`` is subject to CORS. Auth is bearer-token so we don't
# need credentialed mode; allow any origin and the standard
# methods/headers. The other endpoints are happy to ride along.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -185,6 +211,8 @@ class MarkdownFrontend(Frontend):
file_path = self._resolve_path(filename)
# Atomic check-and-claim: both ops run between awaits, so a
# second request can't slip into the same file slot.
if file_path in self._busy:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
@@ -205,10 +233,12 @@ class MarkdownFrontend(Frontend):
@app.post("/chat/stream")
async def chat_stream(request: Request) -> Any:
"""SSE variant of ``/chat``: ``delta`` events, one disk write at the end.
The 409-conflict response is still plain JSON — the stream hasn't started.
"""
# Same contract as ``/chat`` (bearer auth, identical body),
# but the response is ``text/event-stream`` and intermediate
# rendered states are pushed as ``delta`` events. The
# gateway-side disk write only happens once, at end of turn,
# so streaming consumers (Obsidian plugin) and Obsidian Sync
# don't fight over the same file mid-stream.
token_name = await require_token(request, runtime, scope="messages")
try:
body = await request.json()
@@ -231,6 +261,8 @@ class MarkdownFrontend(Frontend):
file_path = self._resolve_path(filename)
# 409 path stays JSON — the stream hasn't started yet, so
# the caller can read it the same way as on ``/chat``.
if file_path in self._busy:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
@@ -253,11 +285,23 @@ class MarkdownFrontend(Frontend):
self._busy.discard(file_path)
return StreamingResponse(
gen(), media_type="text/event-stream", headers=SSE_HEADERS
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
# nginx default-buffers SSE bodies; this header tells
# both nginx and uvicorn-behind-proxy to flush as we
# write. Harmless if the deployment has no reverse
# proxy in front.
"X-Accel-Buffering": "no",
},
)
return app
# ---- dispatch ------------------------------------------------------
async def _handle_chat(
self,
*,
@@ -268,12 +312,11 @@ class MarkdownFrontend(Frontend):
content_override: Any,
agent_override: str | None,
) -> Any:
"""Non-streaming ``/chat`` handler: parse, align, run, persist."""
write_disk = content_override is None
if isinstance(content_override, str):
await _write_atomic(file_path, content_override)
file_text = content_override
elif content_override is None:
file_text = await read_or_empty(file_path)
file_text = await _read_or_empty(file_path)
else:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "`content` must be a string when present"
@@ -292,6 +335,8 @@ class MarkdownFrontend(Frontend):
"or configure `default_agent`",
)
# When the parser produced no messages (file empty / only
# frontmatter), there's nothing to dispatch.
if not parsed.messages:
return {
"status": "nothing_to_do",
@@ -334,21 +379,24 @@ class MarkdownFrontend(Frontend):
msgs=len(parsed.messages),
)
# Resolve / mint the conversation row, align incoming against
# stored history, and feed the aligned messages to the backend
# — see ``core/conversation_store.py`` for the full rationale.
# If the backend isn't claude-code (no ``TurnCapture`` support)
# we fall through to the legacy parser-only path.
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
runtime=runtime,
metadata=parsed.metadata,
agent_name=agent.name,
file_path=file_path,
first_user_text=parsed.turns[0].text,
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
)
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
capture = TurnCapture()
events = runtime.conversations.turn(
conv,
messages=outcome.messages,
origin="user",
capture=capture,
use_session=outcome.reuse_session,
capture: TurnCapture | None = (
TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None
)
kwargs: dict[str, Any] = {}
if capture is not None:
kwargs["capture"] = capture
events = backend.complete(
agent=agent, messages=outcome.messages, system=None, **kwargs
)
try:
message = await self._stream_to_file(
@@ -357,7 +405,6 @@ class MarkdownFrontend(Frontend):
parsed=parsed,
model=agent.model or agent.name,
filename=filename,
write_disk=write_disk,
)
except HTTPException:
raise
@@ -372,18 +419,20 @@ class MarkdownFrontend(Frontend):
message=message,
agent_name=agent.name,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
await self._persist_canonical_history(
runtime=runtime,
conversation_id=cast("int", conv.id),
conversation_id=conv.id,
persist_messages=outcome.persist_messages,
new_user_text=parsed.turns[-1].text,
capture=capture,
message=message,
)
# Broadcast our own turn so other handlers (none today, but the
# symmetry is worth keeping) see what happened. ``source`` marks
# the origin so ``CrossFrontendLogger`` can skip its own files.
record = TurnRecord(
agent_name=agent.name,
input_messages=list(parsed.messages),
@@ -404,7 +453,9 @@ class MarkdownFrontend(Frontend):
"new_content": new_content,
}
async def _handle_chat_streaming( # noqa: PLR0915
# ---- streaming dispatch (SSE) --------------------------------------
async def _handle_chat_streaming( # noqa: PLR0915 — mirrors _handle_chat, splitting only doubles read cost
self,
*,
runtime: GatewayRuntime,
@@ -416,16 +467,30 @@ class MarkdownFrontend(Frontend):
) -> AsyncIterator[bytes]:
"""SSE counterpart of :meth:`_handle_chat`.
Errors surface as ``error`` frames (HTTP is already 200 by then); disk
is only written once at the end, avoiding a write race with Obsidian Sync.
Mirrors the same pipeline (resolve file → parse → resolve agent →
run backend → persist), but emits ``event: delta`` frames as the
rendered turn grows and a single terminal ``event: done`` /
``event: error``. Errors that ``_handle_chat`` would surface as
``HTTPException`` go out as ``error`` frames here (the HTTP
envelope is already 200 by the time the stream starts).
Intermediate disk writes are deliberately skipped — only the
post-stream :meth:`_write_assistant_reply` lands on disk, so the
gateway-side vault and the plugin-side editor are the only
writers in their respective halves of Obsidian Sync. Final
content is identical on both sides, so Sync no-ops.
"""
write_disk = content_override is None
# File-text resolution + early bailouts. ``content_override`` is
# still written to disk on the gateway side because that's the
# state the rest of the request consumes; it just doesn't keep
# ticking after that single write.
if isinstance(content_override, str):
await _write_atomic(file_path, content_override)
file_text = content_override
elif content_override is None:
file_text = await read_or_empty(file_path)
file_text = await _read_or_empty(file_path)
else:
yield sse_pack(
yield _sse_pack(
"error",
{
"status_code": status.HTTP_400_BAD_REQUEST,
@@ -441,7 +506,7 @@ class MarkdownFrontend(Frontend):
default=self.default_agent,
)
if not agent_name:
yield sse_pack(
yield _sse_pack(
"error",
{
"status_code": status.HTTP_400_BAD_REQUEST,
@@ -454,7 +519,7 @@ class MarkdownFrontend(Frontend):
return
if not parsed.messages:
yield sse_pack(
yield _sse_pack(
"done",
{
"status": "nothing_to_do",
@@ -465,7 +530,7 @@ class MarkdownFrontend(Frontend):
return
if parser.last_role(parsed.messages) == "assistant":
yield sse_pack(
yield _sse_pack(
"done",
{
"status": "nothing_to_do",
@@ -477,7 +542,7 @@ class MarkdownFrontend(Frontend):
agent = runtime.agents.get(agent_name)
if agent is None:
yield sse_pack(
yield _sse_pack(
"error",
{
"status_code": status.HTTP_404_NOT_FOUND,
@@ -487,7 +552,7 @@ class MarkdownFrontend(Frontend):
return
backend = runtime.backends.get(agent.name)
if backend is None:
yield sse_pack(
yield _sse_pack(
"error",
{
"status_code": status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -512,19 +577,9 @@ class MarkdownFrontend(Frontend):
msgs=len(parsed.messages),
)
try:
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
runtime=runtime,
metadata=parsed.metadata,
agent_name=agent.name,
file_path=file_path,
first_user_text=parsed.turns[0].text,
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
)
except HTTPException as exc:
yield sse_pack(
"error", {"status_code": exc.status_code, "detail": exc.detail}
)
return
_log.info(
"chat/stream: file=%s conv_external_id=%s conv_id=%d "
"stored_msgs=%d incoming_turns=%d",
@@ -543,19 +598,21 @@ class MarkdownFrontend(Frontend):
len(outcome.messages),
len(outcome.persist_messages),
)
capture = TurnCapture()
capture: TurnCapture | None = (
TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None
)
kwargs: dict[str, Any] = {}
if capture is not None:
kwargs["capture"] = capture
_log.info(
"chat/stream: file=%s calling backend.complete agent=%s session=%s",
"chat/stream: file=%s calling backend.complete agent=%s capture=%s",
filename,
agent.name,
conv.session_id,
capture is not None,
)
events = runtime.conversations.turn(
conv,
messages=outcome.messages,
origin="user",
capture=capture,
use_session=outcome.reuse_session,
events = backend.complete(
agent=agent, messages=outcome.messages, system=None, **kwargs
)
acc = StreamAccumulator()
@@ -569,12 +626,16 @@ class MarkdownFrontend(Frontend):
return None
rendered = renderer.render_assistant_message(partial)
new_body = renderer.append_to_body(parsed.body, rendered)
return reattach_frontmatter(parsed.metadata, new_body)
return _reattach_frontmatter(parsed.metadata, new_body)
try:
async for ev in events_with_heartbeat(events):
async for ev in _events_with_heartbeat(events):
if ev is None:
yield KEEPALIVE
# Backend is quiet (claude mid-thinking, MCP slow,
# whatever). SSE comment-frame keeps the TCP socket
# warm so the plugin / uvicorn / any reverse proxy
# doesn't time the request out before we finish.
yield b": keepalive\n\n"
continue
acc.feed(ev)
now = time.monotonic()
@@ -583,12 +644,18 @@ class MarkdownFrontend(Frontend):
or (now - last_flush) >= _SSE_FLUSH_DEBOUNCE
):
payload = snapshot()
# Skip duplicate snapshots — e.g. tool_use blocks
# render to the same prefix as before they closed
# (we don't surface the tool-call args in markdown).
if payload is not None and payload != last_payload:
yield sse_pack("delta", {"new_content": payload})
yield _sse_pack("delta", {"new_content": payload})
last_payload = payload
last_flush = now
except Exception as exc: # noqa: BLE001
except Exception as exc: # noqa: BLE001 — wire any backend failure as an SSE error frame
_log.exception("backend failed for %s", filename)
# Mirror the legacy path: write the last partial + an error
# callout to disk so other consumers (logs, file watchers)
# see what arrived. The client gets a clean SSE ``error``.
partial = acc.finalize(model=model)
new_body = parsed.body
if partial.content:
@@ -596,11 +663,10 @@ class MarkdownFrontend(Frontend):
new_body, renderer.render_assistant_message(partial)
)
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
if write_disk:
await write_atomic(
file_path, reattach_frontmatter(parsed.metadata, new_body)
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
yield sse_pack(
yield _sse_pack(
"error",
{
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -617,12 +683,11 @@ class MarkdownFrontend(Frontend):
message=message,
agent_name=agent.name,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
await self._persist_canonical_history(
runtime=runtime,
conversation_id=cast("int", conv.id),
conversation_id=conv.id,
persist_messages=outcome.persist_messages,
new_user_text=parsed.turns[-1].text,
capture=capture,
@@ -642,7 +707,7 @@ class MarkdownFrontend(Frontend):
except Exception: # noqa: BLE001
_log.exception("turn_log_handler raised; continuing")
yield sse_pack(
yield _sse_pack(
"done",
{
"status": "ok",
@@ -652,6 +717,8 @@ class MarkdownFrontend(Frontend):
},
)
# ---- helpers -------------------------------------------------------
async def _stream_to_file(
self,
*,
@@ -660,25 +727,31 @@ class MarkdownFrontend(Frontend):
parsed: parser.ParsedFile,
model: str,
filename: str,
write_disk: bool = True,
) -> Any:
"""Drain ``events`` into a ``Message``, flushing partials to disk.
Flushes on each block boundary and on a debounce timer; on backend
failure it still flushes a partial + error callout, then re-raises.
Flushes happen on each ``RawContentBlockStopEvent`` (natural
block boundary, content is markdown-consistent) and on the
``_STREAM_FLUSH_DEBOUNCE`` timer between events. The partial
write keeps the as-parsed frontmatter; the post-stream final
write in ``_write_assistant_reply`` is what stamps the refreshed
fingerprint / agent / conversation_id.
On backend exception we still flush the last partial and append
an error callout, so the human sees both what arrived and why it
stopped. The exception propagates so ``_handle_chat`` can map it
to a 500.
"""
acc = StreamAccumulator()
async def flush_partial() -> None:
if not write_disk:
return
partial = acc.finalize(model=model)
if not partial.content:
return
rendered = renderer.render_assistant_message(partial)
new_body = renderer.append_to_body(parsed.body, rendered)
await write_atomic(
file_path, reattach_frontmatter(parsed.metadata, new_body)
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
try:
@@ -701,9 +774,8 @@ class MarkdownFrontend(Frontend):
new_body, renderer.render_assistant_message(partial)
)
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
if write_disk:
await write_atomic(
file_path, reattach_frontmatter(parsed.metadata, new_body)
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
raise
return acc.finalize(model=model)
@@ -716,67 +788,75 @@ class MarkdownFrontend(Frontend):
message: Any,
agent_name: str,
conv_external_id: str,
write_disk: bool = True,
) -> str:
"""Render the assistant turn, refresh frontmatter, write if we own the file."""
"""Render the assistant turn, append to the file, refresh frontmatter."""
rendered = renderer.render_assistant_message(message)
new_body = renderer.append_to_body(parsed.body, rendered)
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
# Recompute fingerprint so a future cross-frontend hit on this
# same conversation can find it. Stored as hex string in
# frontmatter — only the markdown frontend reads it.
assistant_param: MessageParam = {
"role": "assistant",
"content": _flatten_assistant_text(message),
}
updated_messages: list[MessageParam] = [*parsed.messages, assistant_param]
updated_metadata = dict(parsed.metadata)
updated_metadata.pop("fingerprint", None)
updated_metadata["agent"] = agent_name
updated_metadata["conversation_id"] = conv_external_id
new_content = reattach_frontmatter(updated_metadata, new_body)
if write_disk:
await write_atomic(file_path, new_content)
updated_metadata["fingerprint"] = fingerprint_messages(updated_messages)
new_content = _reattach_frontmatter(updated_metadata, new_body)
await _write_atomic(file_path, new_content)
return new_content
async def _resolve_conversation(
self,
*,
runtime: GatewayRuntime,
metadata: dict[str, Any],
agent_name: str,
file_path: Path,
first_user_text: str = "",
) -> tuple[Conversation, str, list[dict[str, Any]]]:
"""Resolve the ``deep`` conversation for this file + its stored messages.
self, *, runtime: GatewayRuntime, metadata: dict[str, Any], agent_name: str
) -> tuple[Any, str, list[dict[str, Any]]]:
"""Resolve the conversation row + stored messages for this request.
Precedence: frontmatter ``conversation_id`` > existing path binding >
an adopted unbound conversation with the same first prompt > a new one.
Looks up by frontmatter ``conversation_id``, mints a new row if
missing, and returns ``(conv, external_id, stored_messages)``.
``conv.id`` is guaranteed non-None because both
``load_conversation`` (after refresh on a committed row) and
``mint_conversation`` (post-commit refresh) populate it. We
coerce with a runtime check so the rest of the handler can
treat it as ``int``.
"""
conversations = runtime.conversations
rel = file_path.relative_to(self.vault_path).as_posix()
raw = metadata.get("conversation_id")
conv = await conversations.get(raw) if isinstance(raw, str) and raw else None
if conv is None:
conv = await conversations.find_bound(frontend=FRONTEND, external_id=rel)
if conv is None:
conv = await conversations.adopt(
kind="deep", first_user_text=first_user_text
lookup_id = raw if isinstance(raw, str) and raw else None
async with runtime.db.session() as session:
conv = None
if lookup_id is not None:
conv = await load_conversation(
session, frontend="markdown", external_id=lookup_id
)
if conv is not None:
_log.info("adopted conversation %s for %s", conv.external_id, rel)
if conv is None:
try:
conv = await conversations.create(
kind="deep", agent=agent_name, origin=FRONTEND, title=file_path.stem
_log.info(
"_resolve_conversation: frontmatter conv_id=%s "
"not found in DB, will mint new",
lookup_id,
)
else:
_log.info(
"_resolve_conversation: LOADED existing conv "
"id=%d external_id=%s",
conv.id or -1,
conv.external_id,
)
if conv is None:
conv = await mint_conversation(
session, frontend="markdown", agent_name=agent_name
)
_log.info(
"_resolve_conversation: MINTED new conv "
"id=%d external_id=%s agent=%s",
conv.id or -1,
conv.external_id,
agent_name,
)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
_log.info("minted conversation %s for %s", conv.external_id, rel)
bound = [
b
for b in await conversations.bindings(conv)
if b.frontend == FRONTEND and b.visible and b.external_id == rel
]
if not bound:
await conversations.bind(conv, frontend=FRONTEND, external_id=rel)
await conversations.touch_user(conv)
if conv.id is None:
msg = "conversation row missing primary key after commit"
raise RuntimeError(msg)
async with runtime.db.session() as session:
stored = await load_messages(session, conversation_id=conv.id)
return conv, conv.external_id, stored
@@ -787,16 +867,21 @@ class MarkdownFrontend(Frontend):
conversation_id: int,
persist_messages: list[dict[str, Any]],
new_user_text: str,
capture: TurnCapture,
capture: TurnCapture | None,
message: Any,
) -> None:
"""Stamp the DB with the post-turn canonical Anthropic-shape history.
Combines prior state + new user prompt + the backend's synthesized
cycle, falling back to text-only if ``capture`` is empty.
Combines the matched/spliced prior state, the new user prompt,
and the synthesized assistant↔tool cycle from the backend (or
a text-only fallback for backends without ``TurnCapture``).
"""
new_user_msg = {"role": "user", "content": new_user_text}
synthesized = capture.synthesized_messages or _fallback_synthesized(message)
synthesized = (
capture.synthesized_messages
if capture is not None
else _fallback_synthesized(message)
)
canonical = [*persist_messages, new_user_msg, *synthesized]
_log.info(
"_persist_canonical_history: conv_id=%d writing %d msgs "
@@ -815,11 +900,12 @@ class MarkdownFrontend(Frontend):
)
def _resolve_path(self, filename: str) -> Path:
"""Resolve ``filename`` under the vault; reject escapes.
Leading slashes are stripped first — ``Path.__truediv__`` would
otherwise discard ``vault_path`` for an absolute ``filename``.
"""
"""Resolve ``filename`` under the vault; reject escapes."""
# ``filename`` may be relative or absolute; we always anchor
# under ``vault_path`` so absolute paths from outside the vault
# don't sneak through. ``Path("/foo/bar")`` combined with a
# vault path keeps the absolute side; we strip leading slashes
# to coerce the rooted form into a relative path before joining.
rel = filename.lstrip("/")
if not rel.endswith(".md"):
rel = rel + ".md"
@@ -833,11 +919,124 @@ class MarkdownFrontend(Frontend):
return candidate
# ---- module-level utilities ----------------------------------------------
async def _events_with_heartbeat(
events: AsyncIterator[Any], interval: float = _SSE_HEARTBEAT_INTERVAL
) -> AsyncIterator[Any]:
"""Wrap an async event stream with idle-time heartbeat markers.
Yields ``None`` every ``interval`` seconds during silence; real
events pass through unchanged. When the wrapped iterator is
exhausted, this generator returns. Cancellation propagates: if the
outer scope is cancelled we cancel the pending ``__anext__`` task
instead of leaving it dangling.
"""
src = events.__aiter__()
next_task: asyncio.Task[Any] | None = None
try:
while True:
# Reuse the in-flight task across timeouts. Spawning a fresh
# ``__anext__()`` while the previous one is still pending
# puts two consumers on the same async generator — that
# raises ``RuntimeError: anext(): asynchronous generator is
# already running``.
if next_task is None:
next_task = asyncio.ensure_future(src.__anext__())
done, _pending = await asyncio.wait({next_task}, timeout=interval)
if not done:
yield None
continue
task = next_task
next_task = None
try:
result = task.result()
except StopAsyncIteration:
return
yield result
finally:
if next_task is not None and not next_task.done():
next_task.cancel()
with contextlib.suppress(BaseException):
await next_task
def _sse_pack(event: str, data: dict[str, Any]) -> bytes:
r"""Format one Server-Sent Event frame.
Uses named events (``event: <name>``) so the plugin can dispatch on
type without parsing JSON discriminators. ``ensure_ascii=False`` so
multibyte content rides through verbatim instead of becoming
``\uXXXX`` blobs that bloat the wire.
"""
body = json.dumps(data, ensure_ascii=False)
return f"event: {event}\ndata: {body}\n\n".encode()
async def _read_or_empty(path: Path) -> str:
"""Return file contents, or empty string if the file doesn't exist."""
# ``path.exists()`` here is a metadata stat — microseconds — and
# gating an async read on whether the file is there is exactly the
# check we want. Switching to anyio.Path / aiofiles.os just to
# silence the async-pathlib lint would cost a dep edge for no
# practical win.
if not path.exists(): # noqa: ASYNC240
return ""
async with aiofile.async_open(path, "r", encoding="utf-8") as f:
return await f.read()
async def _write_atomic(path: Path, content: str) -> None:
"""Write ``content`` to ``path`` via tmp + ``os.replace`` (atomic)."""
path.parent.mkdir(parents=True, exist_ok=True)
# ``NamedTemporaryFile`` keeps the file open which complicates
# ``os.replace`` on some platforms. Build the tmp name manually,
# write+fsync, then rename. Same-directory so the rename is atomic.
tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
)
fd, tmp_path = tmp_name
try:
async with aiofile.async_open(tmp_path, "w", encoding="utf-8") as f:
await f.write(content)
os.close(fd)
# ``os.replace`` is the atomic primitive — ``Path.replace`` is a
# thin wrapper around the same syscall; either works, ``os.`` is
# the one Linux/POSIX docs reach for.
os.replace(tmp_path, path) # noqa: PTH105
except BaseException:
# Cleanup on failure: close fd, remove tmp.
with contextlib.suppress(OSError):
os.close(fd)
with contextlib.suppress(OSError):
os.unlink(tmp_path) # noqa: PTH108
raise
def _reattach_frontmatter(metadata: dict[str, Any], body: str) -> str:
r"""Re-emit a ``.md`` file with YAML frontmatter at the top.
Empty metadata → no frontmatter block (avoid littering every file
with a hollow ``---\n---``).
"""
if not metadata:
return body if body.endswith("\n") else body + "\n"
import frontmatter as _fm
post = _fm.Post(content=body, **metadata)
return _fm.dumps(post) + "\n"
def _fallback_synthesized(message: Any) -> list[dict[str, Any]]:
"""Build a single-assistant ``synthesized_messages`` list from a raw ``Message``.
Fallback for backends that don't populate :class:`TurnCapture` (anthropic
HTTP, raycast, …); the reply lands as one canonical-block message.
For backends that don't populate a :class:`TurnCapture` (anthropic
HTTP, raycast, …) we don't have access to per-tool-cycle
granularity, so the assistant reply lands in the DB as one
canonical-block message. Tool memory across cache misses would
degrade in that case, but those backends don't have the cache-miss
re-seed problem to begin with — they manage history client-side.
"""
content: list[dict[str, Any]] = []
for block in getattr(message, "content", ()):
@@ -866,6 +1065,21 @@ def _fallback_synthesized(message: Any) -> list[dict[str, Any]]:
return [{"role": "assistant", "content": content}]
def _flatten_assistant_text(message: Any) -> str:
"""Pull all text blocks from an assistant ``Message`` and join them.
Used when we need the assistant content as a plain string for
fingerprinting / equality with a parser-shaped history (parser
already drops thinking + tool_use from assistant turns).
"""
chunks = [
getattr(block, "text", "") or ""
for block in getattr(message, "content", ())
if getattr(block, "type", None) == "text"
]
return "\n\n".join(c for c in chunks if c)
def _render_error_block(exc: BaseException) -> str:
"""Render a backend failure as an Assistant turn with a ``[!error]-`` callout."""
msg = str(exc) or exc.__class__.__name__
@@ -1,164 +0,0 @@
"""Vault files for ``deep`` conversations that were not typed into a file.
``materialize`` creates the file for a conversation spawned elsewhere;
``run`` appends replies produced by other origins and keeps the canonical
history in sync so a continuation typed in Obsidian resumes the same session.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import frontmatter
from beaver_gateway.frontends.markdown import renderer
from beaver_gateway.frontends.markdown.crossfront import strip_trailing_user_scaffold
from beaver_gateway.frontends.markdown.files import (
read_or_empty,
reattach_frontmatter,
write_atomic,
)
from beaver_gateway.frontends.markdown.history import load_messages, rewrite_messages
from beaver_gateway.frontends.turn_record import slugify
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from pathlib import Path
from beaver_gateway.events.bus import Event
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation, ConversationBinding
__all__ = ["FRONTEND", "ChatMirror"]
_log = logging.getLogger("beaver_gateway.frontends.markdown.mirror")
FRONTEND = "markdown"
class ChatMirror:
def __init__(
self,
*,
vault_path: Path,
runtime: GatewayRuntime,
logged_subdir: str,
chat_path: Callable[[str, str, Path], Path] | None = None,
) -> None:
self._vault = vault_path
self._runtime = runtime
self._root = vault_path / logged_subdir
self._chat_path_fn = chat_path
self._lock = asyncio.Lock()
def path_for(self, title: str, agent: str) -> Path:
if self._chat_path_fn is not None:
path = self._chat_path_fn(title, agent, self._vault)
if not path.is_absolute():
path = self._vault / path
else:
day = datetime.now(UTC).strftime("%Y-%m-%d")
path = self._root / agent / f"{day}_{slugify(title, maxlen=60)}.md"
if path.suffix != ".md":
path = path.with_suffix(".md")
path = path.resolve()
path.relative_to(self._vault)
candidate, n = path, 1
while candidate.exists():
n += 1
candidate = path.with_name(f"{path.stem} ({n}){path.suffix}")
return candidate
async def materialize(self, conv: Conversation) -> ConversationBinding:
path = self.path_for(conv.title or conv.external_id, conv.agent_name)
rel = path.relative_to(self._vault).as_posix()
async with self._lock:
await write_atomic(path, reattach_frontmatter(_frontmatter(conv), ""))
binding = await self._runtime.conversations.bind(
conv, frontend=FRONTEND, external_id=rel
)
_log.info("materialized %s as %s", conv.external_id, rel)
return binding
async def bound_path(self, conv: Conversation) -> Path | None:
for b in await self._runtime.conversations.bindings(conv):
if b.frontend == FRONTEND and b.visible:
return self._vault / b.external_id
return None
async def run(self) -> None:
async for event in self._runtime.bus.stream():
if event.get("type") != "reply":
continue
try:
await self._on_reply(event)
except Exception: # noqa: BLE001
_log.exception("mirror of reply %s failed", event.get("turn_id"))
async def _on_reply(self, event: Event) -> None:
conv = await self._runtime.conversations.get(str(event["conversation_id"]))
if conv is None:
return
await self.append(
conv,
prompt=str(event.get("prompt") or ""),
text=str(event.get("text") or ""),
history=event.get("history"),
)
async def append(
self,
conv: Conversation,
*,
prompt: str,
text: str,
history: Sequence[dict[str, Any]] | None = None,
) -> None:
path = await self.bound_path(conv)
if path is None:
return
prior = [
{"role": str(h["role"]), "content": str(h["text"])}
for h in history or ()
if h.get("text")
]
async with self._lock:
parsed = frontmatter.loads(await read_or_empty(path))
body = strip_trailing_user_scaffold(parsed.content)
if prior and "### " not in body:
for m in prior:
body = renderer.append_to_body(body, _render(m))
body = renderer.append_to_body(body, renderer.render_user_text(prompt))
body = renderer.append_to_body(body, renderer.render_assistant_text(text))
body = renderer.append_to_body(body, renderer.USER_SCAFFOLD)
metadata = {**parsed.metadata, **_frontmatter(conv)}
metadata.pop("fingerprint", None)
await write_atomic(path, reattach_frontmatter(metadata, body))
await self._persist(conv, prior=prior, prompt=prompt, text=text)
async def _persist(
self, conv: Conversation, *, prior: list[dict[str, Any]], prompt: str, text: str
) -> None:
if conv.id is None:
return
async with self._runtime.db.session() as session:
stored = await load_messages(session, conversation_id=conv.id)
canonical = [
*(stored or prior),
{"role": "user", "content": prompt},
{"role": "assistant", "content": [{"type": "text", "text": text}]},
]
await rewrite_messages(session, conversation_id=conv.id, messages=canonical)
def _frontmatter(conv: Conversation) -> dict[str, Any]:
return {"agent": conv.agent_name, "conversation_id": conv.external_id}
def _render(message: dict[str, Any]) -> str:
if message["role"] == "user":
return renderer.render_user_text(message["content"])
return renderer.render_assistant_text(message["content"])
+100 -17
View File
@@ -1,7 +1,18 @@
"""Parse a markdown chat file into Anthropic ``MessageParam`` history.
Turn markers are ``### User:`` / ``### Assistant:`` H3 headers; backend
input drops thinking/tool callouts, keeping assistant turns text-only.
The file format is documented in ``frontends/markdown/__init__.py``:
``### User:`` / ``### Assistant:`` H3 headers split turns, optional
``---`` HRs between turns are visual-only, ``> [!thinking]-`` and
``> [!tool]- <name>`` callouts mark structured assistant content.
For backend consumption we strip thinking and tool_use callouts —
assistant turns become text-only. Rationale: history replay through
claude-code's JSONL injection only needs the *narrated* answer (the
thinking signatures expire and the original tool_results aren't
captured in the renderer's output, so a faithful tool_use round-trip
isn't possible today). The renderer keeps callouts in the file because
they're informational for the human reader; the parser drops them when
shaping the backend's input.
"""
from __future__ import annotations
@@ -40,7 +51,11 @@ class TextSegment:
class ToolSegment:
"""A ``> [!tool]- <name>`` callout placeholder.
Only the tool name is captured; the summary suffix is decorative.
Only the tool ``name`` is captured — the " · summary" suffix on the
callout title and the JSON body inside the quote block are
decorative for the human reader; the canonical tool_use block lives
in the DB and is keyed by *position+name* against the structure
parsed here.
"""
name: str
@@ -49,11 +64,26 @@ class ToolSegment:
AssistantSegment = TextSegment | ToolSegment
# Turn marker — must be exactly ``### User:`` or ``### Assistant:`` on
# its own line. Trailing whitespace tolerated; nothing after the colon
# on the same line (any inline content would mean the user typed
# something that just happens to look like a header, and we'd rather
# misparse than silently fold inline content into a turn).
_TURN_RE = re.compile(r"^###\s+(User|Assistant):\s*$", re.MULTILINE)
# Callout-start lines we strip from assistant turns when extracting
# text. We don't try to parse the contents — for backend input we just
# need to drop the whole quoted block.
_CALLOUT_START_RE = re.compile(r"^>\s+\[!(thinking|tool)\]")
# Tool-callout title line: ``> [!tool]- <name>`` or ``> [!tool]- <name> · <summary>``.
# We only need the ``<name>`` part for skeleton matching; the summary is
# decorative (built by ``renderer.summarize_tool_input`` from inputs the
# user can edit visually without semantic consequence).
_TOOL_TITLE_RE = re.compile(r"^>\s+\[!tool\]-\s*(.*?)\s*$")
# Renderer joins name + summary with " · " (U+00B7) — see
# ``renderer.summarize_tool_input``. We split on it to recover the
# bare tool name.
_TOOL_TITLE_SEP = " · "
@@ -61,8 +91,14 @@ _TOOL_TITLE_SEP = " · "
class ParsedTurn:
"""One turn extracted from the chat file.
``text`` is spoken content only; ``structure`` (assistant turns only)
carries the ordered text/tool segments used to align with stored history.
``role`` is ``"user"`` or ``"assistant"``. ``text`` is the spoken
content with callouts stripped and HRs dropped — used both as the
backend's ``MessageParam.content`` (back-compat with the existing
parser shape) and as the diff key against stored turns.
``structure`` is non-empty only for assistant turns: an ordered
list of ``TextSegment`` / ``ToolSegment`` reflecting the visible
layout of the assistant block, used by the conversation store to
align with the canonical tool_use blocks held in DB.
"""
role: str
@@ -74,8 +110,16 @@ class ParsedTurn:
class ParsedFile:
"""Result of parsing a single chat ``.md``.
``messages`` is text-only history for the backend; ``turns`` carries
per-turn structure; ``body`` is the raw content after frontmatter.
``metadata`` is the YAML frontmatter as a plain dict (empty if the
file has none). ``messages`` is the conversation history shaped for
``Backend.complete`` — assistant turns are text-only. ``turns`` is
1:1 with ``messages`` and carries the per-turn structure (for
assistant turns) that the conversation store needs to detect
text-only edits vs. structural forks. ``body`` is the raw markdown
content *after* the frontmatter is stripped; the renderer needs it
when it appends a new assistant turn so it can preserve whatever
the human typed verbatim (including any callouts or HRs they
added).
"""
metadata: dict[str, Any]
@@ -87,9 +131,15 @@ class ParsedFile:
def parse(text: str) -> ParsedFile:
"""Parse a chat ``.md`` into ``(metadata, body, messages, turns)``.
A bare file (no turn markers) is treated as a single user turn; a
tool-only assistant turn gets placeholder ``" "`` content since the
backend rejects an empty string.
A file with no turn markers but non-empty body is treated as a
single user turn — the friendly path for "user types into a new
file and hits send" before any turn markers exist.
Assistant turns that have *only* tool callouts (no spoken text) are
preserved here even though their ``MessageParam.content`` is empty
— the structure carries tool-segment information the conversation
store needs for skeleton matching. The renderer in practice always
emits at least a trailing text block, so this branch is defensive.
"""
parsed = frontmatter.loads(text)
metadata = dict(parsed.metadata)
@@ -125,6 +175,14 @@ def parse(text: str) -> ParsedFile:
)
)
elif has_tools:
# Tool-only assistant turn: nothing to feed the backend
# as ``content`` (it'd reject an empty string), but the
# structure must survive so the store can align it
# against stored tool_use blocks. We synthesize a
# single-space text content for backend round-trip; the
# conversation store will replace this payload with the
# canonical stored blocks before the backend ever sees
# it on a continuation.
messages.append({"role": "assistant", "content": " "})
parsed_turns.append(
ParsedTurn(role="assistant", text="", structure=tuple(structure))
@@ -138,8 +196,18 @@ def parse(text: str) -> ParsedFile:
def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
"""Walk an assistant turn body, return its ordered text/tool segments.
Tool callouts become :class:`ToolSegment` (name only); thinking callouts
and HR lines are stripped; boundary-empty text segments are dropped.
Tool callouts become :class:`ToolSegment` with just the tool name —
the title's optional ``" · summary"`` suffix and the JSON body
inside the quote block are decorative; the canonical tool_use
block is held in the conversation store. Thinking callouts are
stripped entirely (they were never round-trippable through the
file — signatures expire). HR separator lines drop out.
Empty / whitespace-only text segments at the boundaries (start,
end, between adjacent tool callouts) are dropped so the skeleton
is robust against renderer whitespace choices; a non-empty text
segment with surrounding whitespace is trimmed on both ends but
preserved.
"""
segments: list[TextSegment | ToolSegment] = []
pending_text: list[str] = []
@@ -148,6 +216,9 @@ def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
if not pending_text:
return
joined = "\n".join(pending_text)
# Collapse runs of >2 blank lines (created when we stripped a
# mid-block callout) into one so the diff against a re-render
# is stable.
cleaned = re.sub(r"\n{3,}", "\n\n", joined).strip()
pending_text.clear()
if cleaned:
@@ -160,6 +231,7 @@ def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
callout_match = _CALLOUT_START_RE.match(line)
if callout_match:
kind = callout_match.group(1)
# Capture tool name *before* advancing past the block.
if kind == "tool":
title_match = _TOOL_TITLE_RE.match(line)
title = title_match.group(1) if title_match else ""
@@ -167,7 +239,9 @@ def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
_flush_text()
segments.append(ToolSegment(name=name))
else:
# Thinking callout — drop the whole block, emit nothing.
_flush_text()
# Skip the rest of the quote block.
while i < len(lines) and lines[i].lstrip().startswith(">"):
i += 1
continue
@@ -183,7 +257,11 @@ def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
def _segments_to_spoken_text(segments: list[TextSegment | ToolSegment]) -> str:
r"""Reduce a structure list to the spoken-text view the backend sees.
Concatenates text segments with ``\n\n``, dropping tool segments.
Concatenates :class:`TextSegment` contents with ``\n\n`` between
them, dropping :class:`ToolSegment` entries. Equivalent to what
the pre-Conversation-store parser did — we keep that behavior so
existing fingerprints (frontmatter ``fingerprint`` field) stay
valid.
"""
chunks = [s.text for s in segments if isinstance(s, TextSegment)]
return "\n\n".join(c for c in chunks if c).strip()
@@ -212,11 +290,15 @@ def resolve_agent(
return default
# ---- internals ---------------------------------------------------------
def _split_turns(body: str) -> list[tuple[str, str]]:
"""Walk turn markers, return ``[(role_lc, raw_body), ...]``.
A marker is an exact ``### User:`` / ``### Assistant:`` line; trailing
content after the colon means it's not a marker, not a turn boundary.
Body for each turn is everything between this marker and the next
(or EOF). Leading marker line itself is dropped. We don't trim
whitespace here — that's per-role.
"""
matches = list(_TURN_RE.finditer(body))
if not matches:
@@ -233,8 +315,9 @@ def _split_turns(body: str) -> list[tuple[str, str]]:
def _strip_hrs(raw: str) -> str:
"""Drop decorative ``---`` separator lines (whole-line HRs only).
Only lines that are exactly ``---`` (with optional surrounding
whitespace) are removed; a ``---`` mid-paragraph stays.
A ``---`` mid-paragraph (rare, but possible) stays. Only lines that
are *exactly* the HR after optional surrounding whitespace are
removed — those are the ones the renderer emits between turns.
"""
lines = raw.splitlines()
kept = [ln for ln in lines if ln.strip() != "---"]
@@ -1,15 +1,19 @@
"""Render Anthropic ``Message`` (and user turns) into markdown.
"""Render Anthropic ``Message`` (and individual user turns) into markdown.
One-way: produces the human-facing artifact only. The parser strips
tool/thinking callouts separately when reshaping history for backend replay.
The renderer is one-way: it produces the human-facing artifact in the
vault. The parser strips tool/thinking callouts when reshaping the file
for backend replay — so what we write here is purely for the human
reader (and for the cross-frontend logger, which materializes turns
from other frontends).
"""
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, Any, cast
from anthropic.types import Message, TextBlock, ThinkingBlock
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock
if TYPE_CHECKING:
from collections.abc import Iterable
@@ -27,11 +31,21 @@ __all__ = [
]
USER_SCAFFOLD = "### User:\n\n"
# Empty ``### User:`` block appended after each assistant reply so the
# human has an obvious place to type the next turn. Parser drops empty
# user blocks, so this doesn't re-trigger dispatch on its own.
USER_SCAFFOLD = "### User:\n"
# Default 4-backtick fence so tool results that contain literal ```` ``` ````
# don't collide. JSON inputs use 3 backticks because they almost never
# contain ``` and we get language syntax highlighting in Obsidian for free.
FENCE = "````"
# Input keys we dangle after the tool name in the callout title, best
# first — purely cosmetic. ``description`` wins because when a tool
# offers one it's a human-written summary of the call, which beats a
# truncated shell command or path.
_TITLE_KEYS = ("description", "path", "file", "filename", "url", "command", "query")
@@ -40,28 +54,23 @@ def render_user_text(content: str) -> str:
return f"### User:\n\n{content.strip()}\n"
def render_assistant_text(text: str) -> str:
r"""Render a text-only assistant turn as ``### Assistant:\n\n<text>``."""
return f"### Assistant:\n\n{text.strip()}\n"
def render_assistant_message(message: Message) -> str:
"""Render an assistant ``Message`` into a turn block.
"""Render an assistant ``Message`` (with content blocks) into a turn block.
Tool-use blocks render to nothing; of the thinking blocks between two
texts only the first one reaches the file.
Blocks render in their original order:
* ``ThinkingBlock`` → ``> [!thinking]-`` collapsed callout
* ``TextBlock`` → plain text (the spoken answer)
* ``ToolUseBlock`` → ``> [!tool]- <name>`` callout with the ``input``
JSON quoted inside. Tool *results* are not persisted — see
module docstring on ``parser.py`` for why.
Blank lines separate adjacent blocks; trailing newline guarantees
the next ``---`` / ``### User:`` marker lands on its own line.
"""
parts: list[str] = ["### Assistant:", ""]
after_thinking = False
for block in message.content:
thinking = isinstance(block, ThinkingBlock)
if thinking and after_thinking:
continue
lines = list(_render_block(block))
if not lines:
continue
after_thinking = thinking
parts.extend(lines)
parts.extend(_render_block(block))
parts.append("")
return "\n".join(parts).rstrip() + "\n"
@@ -69,12 +78,19 @@ def render_assistant_message(message: Message) -> str:
def render_user_param(param: MessageParam) -> str:
"""Render a ``MessageParam`` user message into a ``### User:`` block.
Tool_result blocks in the content list are dropped silently.
Used by the cross-frontend logger when materializing turns from
other frontends. Tool_result blocks in the content list are dropped
silently — the markdown view doesn't track them (see ``parser.py``).
"""
content = param.get("content", "")
if isinstance(content, str):
text = content
else:
# The Anthropic SDK types ``content`` as a union of typed-dict
# *Param classes plus pydantic block models — both shapes appear
# in practice (raw incoming JSON yields dicts, SDK-built params
# yield BaseModels). Treat each entry as a dict-like and pull
# ``text`` opportunistically.
chunks = [
str(blk.get("text", ""))
for blk in content
@@ -85,9 +101,11 @@ def render_user_param(param: MessageParam) -> str:
def append_to_body(existing: str, new_block: str) -> str:
"""Append ``new_block`` to ``existing`` with a decorative ``---`` separator.
"""Append ``new_block`` to ``existing`` with a decorative HR separator.
The separator is visual only; the parser ignores it.
Preserves the original body verbatim (whitespace, callouts, any
formatting the human added). The HR is purely visual: parser ignores
it.
"""
head = existing.rstrip()
if head:
@@ -96,9 +114,18 @@ def append_to_body(existing: str, new_block: str) -> str:
def summarize_tool_input(name: str, tool_input: object) -> str:
"""Build the ``[!tool]- <summary>`` title, picking one salient input field."""
"""Build the ``[!tool]- <summary>`` title string.
Tries to pick a single salient field (``description``, ``path``,
``command``, etc.) from the input dict so the collapsed callout shows
something meaningful in Obsidian. Falls back to just the tool name.
"""
if not isinstance(tool_input, dict):
return name
# Anthropic ``ToolUseBlock.input`` is typed as ``object`` — the
# SDK's runtime value is always a JSON dict (str→Any), so a local
# cast keeps the rest of the function readable without sprinkling
# per-line type narrowing on every ``.get`` call.
d = cast("dict[str, Any]", tool_input)
for key in _TITLE_KEYS:
value = d.get(key)
@@ -108,6 +135,9 @@ def summarize_tool_input(name: str, tool_input: object) -> str:
return name
# ---- internals ---------------------------------------------------------
def _render_block(block: object) -> Iterable[str]:
if isinstance(block, TextBlock):
text = (block.text or "").strip()
@@ -117,6 +147,10 @@ def _render_block(block: object) -> Iterable[str]:
if isinstance(block, ThinkingBlock):
yield from _render_thinking(block.thinking or "")
return
if isinstance(block, ToolUseBlock):
yield from _render_tool_use(block)
return
# Unknown block type — skip silently rather than corrupting the file.
def _render_thinking(text: str) -> Iterable[str]:
@@ -125,8 +159,24 @@ def _render_thinking(text: str) -> Iterable[str]:
yield f"> {line}" if line else ">"
def _render_tool_use(block: ToolUseBlock) -> Iterable[str]:
title = summarize_tool_input(block.name, block.input)
yield f"> [!tool]- {title}"
yield "> **input:**"
yield "> ```json"
pretty = json.dumps(block.input, indent=2, ensure_ascii=False, sort_keys=True)
for line in pretty.splitlines():
yield f"> {line}" if line else ">"
yield "> ```"
def adaptive_fence(content: str) -> str:
"""Return a backtick fence longer than any backtick run in ``content``."""
"""Return a backtick fence at least one longer than the longest run in ``content``.
Currently unused (tool *results* aren't persisted yet) — kept here
so when result capture lands the rendering side already has the
primitive.
"""
longest = 0
for match in re.finditer(r"`+", content):
longest = max(longest, len(match.group(0)))
+155 -43
View File
@@ -1,13 +1,43 @@
"""External MCP frontend.
"""External MCP frontend (Phase 3.1).
Streamable-HTTP gateway that reverse-proxies the internal MCP aggregator,
adding bearer auth, an audit log line per request, and a discovery page.
A streamable-HTTP gateway in front of the internal MCP aggregator
(``beaver_gateway.mcp.internal_app``). The aggregator hosts every
declared ``McpServer`` (``python_tool``, stdio proxy, HTTP proxy)
under ``/mcp/<name>`` plus a flat ``/mcp/all`` bundle on
``127.0.0.1:INTERNAL_MCP_PORT`` — that's the *internal* shape.
This frontend re-exposes those namespaces on its own port directly at
``/<name>/`` (no ``/mcp/`` prefix in the external routes — the port
itself already disambiguates). Caddy / nginx / Cloudflare in front
typically strips a prefix back on: ``domain.com/mcp/* → :8001/*``,
controlled by the operator's reverse-proxy config and surfaced to the
admin dashboard via ``public_base_url``. Three additions on top of the
raw aggregator:
* **Bearer auth** — ``Authorization: Bearer <token>``, ``X-Api-Key``,
or ``?token=<…>`` query string. All three forms verify against the
same :class:`TokenStore` as ``AnthropicMessagesFrontend``.
* **Audit log** — one line per request (token name, namespace,
request method/path, response status). The DB-backed audit log lives
in Phase 4; for now we just emit a structured log line.
* **Discovery page** at ``GET /`` (auth-gated) — HTML rendered with a
tiny inline Jinja2 template listing every namespace plus copy-pastable
config snippets for Cursor / claude.ai / Claude Desktop.
Why a reverse proxy and not a second mount? FastMCP's session managers
are tied to the lifespan they were created in; running the same
aggregator under two uvicorn servers double-initializes state. Building
two parallel aggregators would double upstream connections (two
subprocesses for every stdio MCP, two HTTP clients for every remote).
A loopback proxy keeps one source of truth — the internal aggregator —
and lets us layer policy on the outside.
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
@@ -16,10 +46,9 @@ from starlette.applications import Starlette
from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
from starlette.routing import Route
from beaver_gateway.core import audit
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.urls import external_base
from beaver_gateway.mcp.internal_app import ALL_NAMESPACE
from beaver_gateway.security import audit
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Mapping
@@ -32,6 +61,10 @@ if TYPE_CHECKING:
_log = logging.getLogger("beaver_gateway.frontends.mcp_server")
# Hop-by-hop headers that must NOT be forwarded across an HTTP proxy
# (RFC 7230 §6.1). Bypassing this filter would break chunked transfer
# encoding when ``Content-Length`` arrives, or upstream-aware proxies
# would refuse the second hop's connection-pool reuse.
_HOP_BY_HOP_HEADERS = frozenset(
{
"connection",
@@ -47,6 +80,11 @@ _HOP_BY_HOP_HEADERS = frozenset(
}
)
# Standard auth-bearing headers we *do not* forward to the internal app —
# the internal app is on loopback with no auth of its own, and forwarding
# the inbound bearer would only confuse it. Each method-specific MCP
# request from the upstream Cursor/etc. carries a fresh ``mcp-session-id``
# that we *must* forward.
_AUTH_HEADERS = frozenset({"authorization", "x-api-key"})
@@ -56,41 +94,87 @@ __all__ = ["McpServerFrontend"]
class McpServerFrontend(Frontend):
"""Auth + audit + reverse-proxy in front of the internal MCP aggregator."""
path = "/mcp"
def __init__(self) -> None:
def __init__(
self,
*,
host: str = "0.0.0.0", # noqa: S104
port: int = 8001,
public_base_url: str | None = None,
) -> None:
self.host = host
self.port = port
# External URL prefix the reverse proxy uses to reach this
# frontend. Internal routes mount namespaces at the port root
# (``/<ns>/`` and ``/all/``) — Caddy / nginx / Cloudflare in
# front decides what external prefix they sit under. Typical
# symmetric setup:
#
# Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 }
# config: public_base_url -> https://api.example.com/mcp
# dashboard advertises: https://api.example.com/mcp/<ns>/
#
# The frontend's ``/<ns>/`` segment gets appended verbatim. Set
# it to whatever matches your proxy. ``None`` means "advertise
# raw ``host:port`` derived from the inbound request" (dev /
# no proxy).
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self._runtime: GatewayRuntime | None = None
self._app: Starlette | None = None
# Single shared aiohttp session, opened once when uvicorn starts
# the Starlette lifespan. Reused across every proxied request —
# MCP clients (esp. claude.ai) reconnect frequently, and a fresh
# ClientSession per call would cost a TCP handshake every time.
self._http: aiohttp.ClientSession | None = None
def configure(self, runtime: GatewayRuntime) -> None:
self._runtime = runtime
self._app = self._build_app(runtime)
def app(self) -> Starlette | None:
return self._app
async def serve(self) -> None:
import uvicorn
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
)
server = uvicorn.Server(config)
await server.serve()
def _build_app(self, runtime: GatewayRuntime) -> Starlette: # noqa: ARG002
# The Starlette lifespan owns the shared aiohttp session: opened
# at startup, closed at shutdown so we don't leak sockets when
# uvicorn is restarted under the same process. Starlette wants a
# plain context manager, not an async-generator function — we
# decorate to match.
@asynccontextmanager
async def lifespan(_app: Starlette) -> AsyncIterator[None]:
self._http = aiohttp.ClientSession(
# Long sock_read because MCP tool calls can take a while
# (a claude tool over HTTP can easily stretch beyond 30s
# on a real tool).
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
)
try:
await asyncio.Event().wait()
yield
finally:
if self._http is not None:
await self._http.close()
self._http = None
def _build_app(self, runtime: GatewayRuntime) -> Starlette: # noqa: ARG002
"""Build the Starlette app.
Literal routes (``/``, ``/healthz``) are listed before the
namespace wildcard so they win the match instead of being
swallowed by it; two routes per namespace cover both ``/x`` and
``/x/y`` since Starlette won't fold them into one.
"""
routes = [
Route("/", self._discovery, methods=["GET"]),
Route("/healthz", self._healthz, methods=["GET"]),
# Namespaces mount at the root of this port — the port
# itself already disambiguates this from any other gateway
# surface. Two routes per namespace so both the
# trailing-slash and sub-path forms work (``/time`` AND
# ``/time/foo``); Starlette doesn't fold them into one
# route automatically. The literal routes above (``/``,
# ``/healthz``) are listed first and win the match, so
# they're not eaten by ``/{namespace}``.
Route(
"/{namespace}",
self._proxy_endpoint,
@@ -102,7 +186,7 @@ class McpServerFrontend(Frontend):
methods=["GET", "POST", "DELETE", "OPTIONS"],
),
]
return Starlette(routes=routes)
return Starlette(routes=routes, lifespan=lifespan)
async def _healthz(self, _request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"})
@@ -112,8 +196,14 @@ class McpServerFrontend(Frontend):
token_name, err = await _verify_request(request, runtime)
if err is not None:
return err
assert token_name is not None # noqa: S101
base = external_base(request, runtime)
assert token_name is not None # noqa: S101 — narrow for ty
# ``public_base_url`` wins if configured — it's the operator's
# explicit statement of "this is the URL my reverse proxy puts
# in front of me". Otherwise: use the request's own scheme+host
# so snippets work behind generic reverse proxies / tunnels;
# and fall back to the configured host:port if the client
# didn't send Host (curl --raw).
base = self.public_base_url or _external_base_url(request, self.host, self.port)
html = _render_discovery_page(
base_url=base, namespaces=list(runtime.mcps), actor=token_name
)
@@ -126,7 +216,7 @@ class McpServerFrontend(Frontend):
token_name, err = await _verify_request(request, runtime)
if err is not None:
return err
assert token_name is not None # noqa: S101
assert token_name is not None # noqa: S101 — narrow for ty
namespace = request.path_params["namespace"]
subpath = request.path_params.get("path", "")
@@ -150,6 +240,7 @@ class McpServerFrontend(Frontend):
)
if self._http is None:
# Lifespan hasn't run yet (shouldn't happen with uvicorn).
return JSONResponse({"error": "frontend not ready"}, status_code=503)
return await _reverse_proxy(
@@ -162,12 +253,11 @@ class McpServerFrontend(Frontend):
)
def _upstream_url(self, namespace: str, subpath: str) -> str | None:
"""Resolve ``namespace`` to its internal loopback URL.
``ALL_NAMESPACE`` isn't in the URL map, so its URL is synthesized
from any per-domain entry's authority.
"""
runtime = self._require_runtime()
# ``all`` is built by the aggregator unconditionally when at least
# one MCP is configured; the URL map only contains per-domain
# entries (see ``build_internal_app``), so we synthesize ``all``'s
# loopback URL from any per-domain URL's authority.
if namespace == ALL_NAMESPACE:
sample = next(iter(runtime.mcp_internal_urls.values()), None)
if sample is None:
@@ -194,8 +284,13 @@ async def _verify_request(
) -> tuple[str | None, JSONResponse | None]:
"""Accept ``Authorization: Bearer``, ``X-Api-Key``, or ``?token=``.
Returns ``(actor_name, None)`` on success, else ``(None, error_response)``
— 401 for a missing/unknown token, 403 for one whose scope doesn't cover this call.
The third form is the escape hatch for clients that can only put
secrets in the URL (claude.ai's MCP config historically did this).
All three roads end at the same :class:`TokenStore`. Returns
``(actor_name, None)`` on success, ``(None, 401|403)`` otherwise
— the caller forwards the response as-is. Splitting auth vs scope
failures matters: 401 says "send me a token", 403 says "this token
is real but not for this endpoint".
"""
api_key = request.headers.get("x-api-key")
if api_key:
@@ -233,13 +328,23 @@ def _forbidden(scope: str, required: str) -> JSONResponse:
def _join_subpath(base_url: str, subpath: str) -> str:
"""Concatenate the loopback URL with the proxied sub-path.
``base_url`` always ends in ``/`` to avoid Starlette's redirect dance.
``base_url`` always ends in ``/`` (the aggregator publishes URLs
that way to avoid Starlette's 307 redirect dance); the sub-path is
appended verbatim, with the query string handled by the caller.
"""
if subpath:
return base_url + subpath.lstrip("/")
return base_url
def _external_base_url(request: Request, fallback_host: str, fallback_port: int) -> str:
host = request.headers.get("host")
if host:
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
return f"{scheme}://{host}"
return f"http://{fallback_host}:{fallback_port}"
async def _reverse_proxy(
*,
client: aiohttp.ClientSession,
@@ -249,14 +354,18 @@ async def _reverse_proxy(
actor: str,
runtime: GatewayRuntime,
) -> StreamingResponse | JSONResponse:
"""Bidirectionally stream an MCP request between client and internal aggregator.
"""Bidirectionally stream an MCP request between client ↔ internal aggregator.
The audit row is written right after the upstream response headers
arrive, before relaying its body, so a client-truncated stream is
still audited.
Streamable-HTTP MCP responses can be a long-running SSE stream
(tools that emit partial progress) or a one-shot JSON body; we
don't peek — just relay chunks as they arrive in either direction
until both sides close.
"""
qs = request.url.query
if qs:
# Drop ``?token=`` from the forwarded URL — internal app doesn't
# need it, and propagating creds further than necessary widens
# the leak surface (logs, metrics, traces all see query strings).
scrubbed = _scrub_query(qs, drop={"token"})
if scrubbed:
upstream_url = f"{upstream_url}?{scrubbed}"
@@ -302,6 +411,9 @@ async def _reverse_proxy(
request.url.path,
upstream_resp.status,
)
# Audit at upstream-response time: status reflects the MCP call's
# outcome (200 / tool-error / 4xx). Streaming relay below may be
# cut short by the client, but the row is already in by then.
await audit.log(
runtime,
actor=f"token:{actor}",
@@ -318,6 +430,7 @@ async def _reverse_proxy(
async for chunk in upstream_resp.content.iter_any():
yield chunk
except (aiohttp.ClientError, asyncio.CancelledError):
# Caller hung up or upstream dropped — just stop relaying.
return
finally:
upstream_resp.release()
@@ -337,11 +450,6 @@ async def _request_body_iter(request: Request) -> AsyncIterator[bytes]:
def _forward_headers(request: Request) -> dict[str, str]:
"""Drop hop-by-hop headers (RFC 7230 §6.1) and inbound auth headers.
The internal aggregator is loopback-only with no auth of its own,
so forwarding the caller's bearer would only confuse it.
"""
out: dict[str, str] = {}
for key, value in request.headers.items():
lowered = key.lower()
@@ -374,7 +482,11 @@ def _scrub_query(query: str, *, drop: frozenset[str] | set[str]) -> str:
def _render_discovery_page(*, base_url: str, namespaces: list[Any], actor: str) -> str:
"""Render the auth-gated namespace + config-snippet page."""
"""Render the auth-gated namespace + config-snippet page.
Inline HTML (no Jinja file) — keeps Phase 3 free of template-dir
plumbing that Phase 4's AdminFrontend will own.
"""
name_list = [getattr(ns, "name", str(ns)) for ns in namespaces]
rows = (
"\n".join(
@@ -408,7 +520,7 @@ def _escape(value: str) -> str:
)
_DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002
_DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002 — readability beats one-string-blob
[
"<!doctype html>",
'<html lang="en">',
-54
View File
@@ -1,54 +0,0 @@
"""The one ASGI app the gateway listens with.
Every HTTP frontend is mounted under its ``path`` (``/anthropic``,
``/mcp``, ``/md``, ``/api``, ``/admin``); ``/healthz`` answers for the
whole process and ``/`` redirects to the landing frontend (the admin
console) when one is configured.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from starlette.applications import Starlette
from starlette.responses import JSONResponse, RedirectResponse
from starlette.routing import Mount, Route
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from starlette.requests import Request
from starlette.types import ASGIApp
from beaver_gateway.frontends.base import Frontend
__all__ = ["build_root_app"]
def build_root_app(
frontends: Iterable[Frontend], *, extra: Mapping[str, ASGIApp] | None = None
) -> Starlette:
mounted = [fe for fe in frontends if fe.path and fe.app() is not None]
landing = next((fe for fe in mounted if fe.landing), None)
paths = [*(fe.path for fe in mounted), *(extra or {})]
async def healthz(_request: Request) -> JSONResponse:
return JSONResponse({"status": "ok", "frontends": paths})
async def index(_request: Request) -> JSONResponse | RedirectResponse:
if landing is not None:
return RedirectResponse(f"{landing.path}/", status_code=307)
return JSONResponse({"frontends": paths})
routes: list[Route | Mount] = [
Route("/healthz", healthz, methods=["GET"]),
Route("/", index, methods=["GET"]),
]
for fe in mounted:
app = fe.app()
assert app is not None # noqa: S101
assert fe.path is not None # noqa: S101
routes.append(Mount(fe.path, app=app, name=fe.name or fe.path.strip("/")))
for path, app in (extra or {}).items():
routes.append(Mount(path, app=app, name=path.strip("/")))
return Starlette(routes=routes)
-61
View File
@@ -1,61 +0,0 @@
"""Server-sent events helpers shared by the markdown and API frontends."""
from __future__ import annotations
import asyncio
import contextlib
import json
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import AsyncIterator
__all__ = ["HEARTBEAT_INTERVAL", "SSE_HEADERS", "events_with_heartbeat", "sse_pack"]
HEARTBEAT_INTERVAL = 15.0
"""Seconds of backend silence before a comment frame keeps the socket warm."""
SSE_HEADERS = {
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
KEEPALIVE = b": keepalive\n\n"
async def events_with_heartbeat(
events: AsyncIterator[Any], interval: float = HEARTBEAT_INTERVAL
) -> AsyncIterator[Any]:
"""Pass ``events`` through, yielding ``None`` after ``interval`` seconds of silence.
Only one consumer may iterate the result at a time; cancelling the
outer scope cancels the in-flight upstream fetch instead of leaking it.
"""
src = events.__aiter__()
next_task: asyncio.Task[Any] | None = None
try:
while True:
if next_task is None:
next_task = asyncio.ensure_future(src.__anext__())
done, _pending = await asyncio.wait({next_task}, timeout=interval)
if not done:
yield None
continue
task = next_task
next_task = None
try:
result = task.result()
except StopAsyncIteration:
return
yield result
finally:
if next_task is not None and not next_task.done():
next_task.cancel()
with contextlib.suppress(BaseException):
await next_task
def sse_pack(event: str, data: dict[str, Any]) -> bytes:
body = json.dumps(data, ensure_ascii=False)
return f"event: {event}\ndata: {body}\n\n".encode()
@@ -1,6 +0,0 @@
"""Telegram frontend: General = master, topic = branch, drafts, inbox/outbox."""
from beaver_gateway.frontends.telegram.frontend import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.texts import TelegramTexts
__all__ = ["Attachments", "TelegramFrontend", "TelegramTexts"]
@@ -1,153 +0,0 @@
"""One ``sendMessageDraft`` stream per running turn.
Telegram folds a draft into the following message only when their texts are
identical, so the last push is the final text verbatim. Drafts live 30s and
throttle to about one edit/s, so this refreshes on a timer, not every delta.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
import zlib
from typing import TYPE_CHECKING
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
from beaver_gateway.frontends.telegram.render import to_html, to_html_tail
if TYPE_CHECKING:
from aiogram import Bot
__all__ = ["Draft"]
_log = logging.getLogger("beaver_gateway.frontends.telegram.drafts")
_TAIL = 3500
_KEEPALIVE = 20.0
class Draft:
def __init__(
self,
bot: Bot,
*,
chat_id: int,
thread_id: int | None,
turn_id: str,
interval: float = 0.7,
status: str = "⏳ thinking",
) -> None:
self._bot = bot
self._chat_id = chat_id
self._thread_id = thread_id
self._draft_id = (zlib.crc32(turn_id.encode()) & 0x7FFFFFFF) or 1
self._interval = interval
self.status = status
self.text = ""
self._paused = False
self._dirty = True
self._broken = False
self._last_sent = 0.0
self._task: asyncio.Task[None] | None = None
self._inflight: asyncio.Future[None] | None = None
@property
def draft_id(self) -> int:
return self._draft_id
def start(self) -> None:
if self._task is None:
self._task = asyncio.create_task(self._run())
def set_status(self, status: str) -> None:
if status != self.status:
self.status = status
self._dirty = True
self._paused = bool(self.text.strip())
def append(self, text: str) -> None:
"""Add streamed text; a tool call in between starts a new paragraph.
The model ends a sentence, calls a tool, then goes on without a
break - the same text the final message shows as paragraphs.
"""
if not text:
return
if self._paused and not self.text.endswith("\n"):
self.text = self.text.rstrip() + "\n\n" + text.lstrip()
else:
self.text += text
self._paused = False
self._dirty = True
async def stop(self) -> None:
"""Stop pushing and wait for the push in flight.
The final ``sendMessage`` must reach Telegram after the last draft,
or the draft lands on top of the message and lingers for its 30 s.
"""
if self._task is not None:
self._task.cancel()
await asyncio.gather(self._task, return_exceptions=True)
self._task = None
if self._inflight is not None:
with contextlib.suppress(Exception):
await self._inflight
self._inflight = None
async def finish(self, text: str) -> None:
"""Last push: the final text verbatim, so the message replaces the draft."""
await self.stop()
if self._broken or not text.strip():
return
try:
try:
await self._send(to_html(text), "HTML")
except TelegramBadRequest:
await self._send(text, None)
except TelegramAPIError as exc:
_log.warning(
"final draft to %s/%s failed: %s", self._chat_id, self._thread_id, exc
)
async def _run(self) -> None:
while not self._broken:
if self._dirty or time.monotonic() - self._last_sent > _KEEPALIVE:
self._inflight = asyncio.ensure_future(self._push())
await asyncio.shield(self._inflight)
await asyncio.sleep(self._interval)
async def _push(self) -> None:
self._dirty = False
self._last_sent = time.monotonic()
rendered, plain = self._render()
try:
try:
await self._send(rendered, "HTML")
except TelegramBadRequest:
await self._send(plain, None)
except TelegramAPIError as exc:
self._broken = True
_log.warning(
"draft to %s/%s stopped: %s", self._chat_id, self._thread_id, exc
)
async def _send(self, text: str, parse_mode: str | None) -> None:
await self._bot.send_message_draft(
chat_id=self._chat_id,
draft_id=self._draft_id,
message_thread_id=self._thread_id,
text=text,
parse_mode=parse_mode,
can_stop=True,
)
def _render(self) -> tuple[str, str]:
tail = self.text[-_TAIL:]
if not tail.strip():
return to_html(self.status), self.status
rendered = f"{to_html(self.status)}\n\n{to_html_tail(self.text, _TAIL)}"
return rendered, f"{self.status}\n\n{tail}"
File diff suppressed because it is too large. Load diff
@@ -1,143 +0,0 @@
"""Long-polling inbox.
Every update lands in ``telegram_updates`` before the offset moves past it;
a worker handles rows from the table, oldest first, resuming after a restart.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from aiogram.exceptions import TelegramConflictError, TelegramNetworkError
from aiogram.types import Update
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, select
from beaver_gateway.storage.models import TelegramUpdate
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from aiogram import Bot
from beaver_gateway.storage.db import Database
__all__ = ["Inbox"]
_log = logging.getLogger("beaver_gateway.frontends.telegram.inbox")
ALLOWED_UPDATES = ("message", "callback_query", "stopped_message_generation")
def _unset(_value: object) -> None:
"""Unset aiogram ``Default`` sentinels (link previews) are stored as null."""
class Inbox:
def __init__(
self,
db: Database,
bot: Bot,
*,
handler: Callable[[Update], Awaitable[None]],
poll_timeout: int = 30,
) -> None:
self._db = db
self._bot = bot
self._handler = handler
self._poll_timeout = poll_timeout
self._wake = asyncio.Event()
async def run(self) -> None:
async with asyncio.TaskGroup() as tg:
tg.create_task(self._poll())
tg.create_task(self._work())
async def _poll(self) -> None:
offset = await self._next_offset()
backoff = 1.0
while True:
try:
updates = await self._bot.get_updates(
offset=offset,
timeout=self._poll_timeout,
allowed_updates=list(ALLOWED_UPDATES),
request_timeout=self._poll_timeout + 10,
)
except TelegramConflictError:
_log.error("another poller holds this bot token; retrying in 10s")
await asyncio.sleep(10)
continue
except (TimeoutError, TelegramNetworkError, OSError) as exc:
_log.warning("getUpdates failed (%s); retrying in %.0fs", exc, backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60.0)
continue
backoff = 1.0
for update in updates:
await self._store(update)
offset = update.update_id + 1
if updates:
self._wake.set()
async def _store(self, update: Update) -> None:
row = TelegramUpdate(
update_id=update.update_id,
payload=update.model_dump(
mode="json", by_alias=True, exclude_none=True, fallback=_unset
),
)
async with self._db.session() as session:
session.add(row)
try:
await session.commit()
except IntegrityError:
await session.rollback()
async def _next_offset(self) -> int | None:
async with self._db.session() as session:
latest = (
await session.exec(select(func.max(col(TelegramUpdate.update_id))))
).one()
return int(latest) + 1 if latest is not None else None
async def _work(self) -> None:
while True:
rows = await self._pending()
if not rows:
self._wake.clear()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._wake.wait(), timeout=5.0)
continue
for row in rows:
await self._handle(row)
async def _pending(self, limit: int = 50) -> list[TelegramUpdate]:
async with self._db.session() as session:
result = await session.exec(
select(TelegramUpdate)
.where(col(TelegramUpdate.processed_at).is_(None))
.order_by(col(TelegramUpdate.update_id))
.limit(limit)
)
return list(result.all())
async def _handle(self, row: TelegramUpdate) -> None:
error: str | None = None
try:
await self._handler(Update.model_validate(row.payload))
except Exception as exc: # noqa: BLE001
error = f"{type(exc).__name__}: {exc}"[:500]
_log.exception("update %s failed", row.update_id)
async with self._db.session() as session:
stored = await session.get(TelegramUpdate, row.update_id)
if stored is not None:
stored.processed_at = datetime.now(UTC)
stored.error = error
session.add(stored)
await session.commit()
@@ -1,235 +0,0 @@
"""Outbox: a reply is a ``deliveries`` row first, a message second.
Rows are sent oldest first, retried with backoff on network errors, resent
as plain text when Telegram rejects our HTML, and given up once it's gone.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from aiogram.exceptions import (
TelegramBadRequest,
TelegramForbiddenError,
TelegramNetworkError,
TelegramNotFound,
TelegramRetryAfter,
TelegramServerError,
)
from aiogram.types import LinkPreviewOptions
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, select
from beaver_gateway.frontends.telegram.render import chunks, to_html
from beaver_gateway.storage.models import Delivery
if TYPE_CHECKING:
from aiogram import Bot
from beaver_gateway.events.bus import EventBus
from beaver_gateway.storage.db import Database
__all__ = ["Outbox"]
_log = logging.getLogger("beaver_gateway.frontends.telegram.outbox")
_MAX_BACKOFF = 300.0
_GONE = ("thread not found", "chat not found", "topic_deleted", "topic_closed")
_NO_PREVIEW = LinkPreviewOptions(is_disabled=True)
class Outbox:
def __init__(
self, db: Database, bot: Bot, *, bus: EventBus, backoff: float = 2.0
) -> None:
self._db = db
self._bot = bot
self._bus = bus
self._backoff = backoff
self._wake = asyncio.Event()
async def enqueue(
self,
*,
chat_id: int,
thread_id: int | None,
text: str,
conversation_id: int | None = None,
turn_id: str | None = None,
dedupe_key: str | None = None,
) -> list[Delivery]:
rows: list[Delivery] = []
for n, part in enumerate(chunks(text)):
row = Delivery(
conversation_id=conversation_id,
chat_id=chat_id,
thread_id=thread_id,
text=part,
turn_id=turn_id,
dedupe_key=f"{dedupe_key}:{n}" if dedupe_key else None,
)
async with self._db.session() as session:
session.add(row)
try:
await session.commit()
except IntegrityError:
await session.rollback()
continue
await session.refresh(row)
rows.append(row)
if rows:
self._wake.set()
return rows
async def run(self) -> None:
while True:
rows = await self._due()
if not rows:
self._wake.clear()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(
self._wake.wait(), timeout=await self._wait_for_next()
)
continue
for row in rows:
await self._send(row)
async def _wait_for_next(self, cap: float = 5.0) -> float:
async with self._db.session() as session:
earliest = (
await session.exec(
select(func.min(col(Delivery.next_attempt_at))).where(
Delivery.status == "queued"
)
)
).one()
if earliest is None:
return cap
now = datetime.now(UTC).replace(tzinfo=None)
return max(0.05, min(cap, (earliest - now).total_seconds()))
async def _due(self, limit: int = 50) -> list[Delivery]:
now = datetime.now(UTC).replace(tzinfo=None)
async with self._db.session() as session:
result = await session.exec(
select(Delivery)
.where(
Delivery.status == "queued", col(Delivery.next_attempt_at) <= now
)
.order_by(col(Delivery.id))
.limit(limit)
)
return list(result.all())
async def _send(self, row: Delivery) -> None:
try:
message = await self._bot.send_message(
row.chat_id,
row.text if row.plain else to_html(row.text),
message_thread_id=row.thread_id,
parse_mode=None if row.plain else "HTML",
link_preview_options=_NO_PREVIEW,
)
except TelegramRetryAfter as exc:
await self._retry(row, str(exc), delay=float(exc.retry_after))
except TelegramBadRequest as exc:
text = str(exc).lower()
if "parse" in text and not row.plain:
await self._retry(row, str(exc), delay=0.0, plain=True)
elif any(marker in text for marker in _GONE):
await self._fail(row, str(exc), gone=True)
else:
await self._fail(row, str(exc))
except (TelegramNotFound, TelegramForbiddenError) as exc:
await self._fail(row, str(exc))
except (TelegramNetworkError, TelegramServerError, OSError) as exc:
await self._retry(
row,
str(exc),
delay=min(self._backoff ** (row.attempts + 1), _MAX_BACKOFF),
)
else:
await self._mark(row, status="sent", message_id=message.message_id)
self._bus.publish(
"delivery.sent",
delivery=row.id,
conversation_row=row.conversation_id,
chat_id=row.chat_id,
thread_id=row.thread_id,
message_id=message.message_id,
turn_id=row.turn_id,
)
async def _retry(
self, row: Delivery, error: str, *, delay: float, plain: bool = False
) -> None:
_log.warning(
"delivery #%s attempt %d failed: %s (retry in %.0fs)",
row.id,
row.attempts + 1,
error,
delay,
)
await self._mark(row, status="queued", error=error, delay=delay, plain=plain)
if delay < 5.0:
self._wake.set()
async def _fail(self, row: Delivery, error: str, *, gone: bool = False) -> None:
_log.error(
"delivery #%s to %s/%s given up: %s",
row.id,
row.chat_id,
row.thread_id,
error,
)
await self._mark(row, status="failed", error=error)
self._bus.publish(
"delivery.failed",
delivery=row.id,
conversation_row=row.conversation_id,
chat_id=row.chat_id,
thread_id=row.thread_id,
error=error,
gone=gone,
)
async def _mark(
self,
row: Delivery,
*,
status: str,
error: str | None = None,
delay: float = 0.0,
plain: bool = False,
message_id: int | None = None,
) -> None:
async with self._db.session() as session:
stored = await session.get(Delivery, row.id)
if stored is None:
return
stored.status = status
stored.attempts += 1
stored.last_error = error[:500] if error else None
stored.next_attempt_at = (
datetime.now(UTC) + timedelta(seconds=delay)
).replace(tzinfo=None)
if plain:
stored.plain = True
if message_id is not None:
stored.message_id = message_id
if status == "sent":
stored.sent_at = datetime.now(UTC)
session.add(stored)
await session.commit()
async def pending(self) -> int:
async with self._db.session() as session:
result = await session.exec(
select(Delivery).where(Delivery.status == "queued")
)
return len(list(result.all()))
@@ -1,346 +0,0 @@
"""Model markdown → Telegram HTML, chunking, and the status line for drafts."""
from __future__ import annotations
import html
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
from beaver_gateway.frontends.telegram.texts import TOOL_LABELS
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
__all__ = ["LIMIT", "chunks", "status_label", "to_html", "to_html_tail"]
LIMIT = 4000
_SNAP = 200
_FENCE_OPEN = re.compile(r"^\s*```(.*)$")
_FENCE_CLOSE = re.compile(r"^\s*```\s*$")
_LANG = re.compile(r"[\w+#.-]+")
_QUOTE = re.compile(r"^\s*(?:>\s?)+")
_TABLE_SEP = re.compile(r"^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$")
_CELL_SPLIT = re.compile(r"(?<!\\)\|")
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$")
_RULE = re.compile(r"^\s*([-*_])(?:\s*\1){2,}\s*$")
_TASK = re.compile(r"^(\s*)[-*+]\s+\[([ xX])\]\s*(.*)$")
_BULLET = re.compile(r"^(\s*)[-*+]\s+(.*)$")
_NUMBERED = re.compile(r"^(\s*)(\d+)[.)]\s+(.*)$")
_TOKEN = re.compile(
r"`[^`\n]+`|\[\[[^\]\n]+\]\]|!?\[[^\]]*\]\((?:https?|tg)://[^)\s]+\)"
)
_WIKILINK = re.compile(r"\[\[([^\]|#]+)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]")
_LINK = re.compile(r"(!?)\[([^\]]*)\]\(([^)\s]+)\)")
_BOLD = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
_ITALIC = re.compile(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])")
_ITALIC_U = re.compile(r"(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])")
_STRIKE = re.compile(r"~~(.+?)~~")
_SPOILER = re.compile(r"\|\|(.+?)\|\|")
_Kind = Literal["fence", "quote", "table", "text"]
@dataclass(slots=True)
class _Block:
kind: _Kind
lines: list[str]
def to_html(text: str) -> str:
return "\n".join(_render(block) for block in _blocks(text)).strip()
def to_html_tail(text: str, tail_chars: int) -> str:
start = max(len(text) - tail_chars, 0)
if start:
newline = text.find("\n", start, start + _SNAP)
if newline >= 0:
start = newline + 1
opening = _open_fence(text[:start])
tail = text[start:]
return to_html(f"{opening}\n{tail}" if opening else tail)
def chunks(text: str, limit: int = LIMIT) -> list[str]:
text = text.strip()
if not text:
return []
if _fits(text, limit):
return [text]
out: list[str] = []
cur: list[str] = []
for block in _blocks(text):
for piece in _pieces(block, limit):
if cur and not _fits("\n".join([*cur, piece]), limit):
_flush(out, cur)
cur.append(piece)
_flush(out, cur)
return out
def status_label(
name: str,
tool_input: dict[str, Any] | None = None,
labels: Mapping[str, str] | None = None,
) -> str:
label = (labels if labels is not None else TOOL_LABELS).get(name)
if label is not None:
return label
if name.startswith("mcp__"):
parts = name.split("__", 2)
server = parts[1]
tool = parts[2] if len(parts) == 3 else ""
return f"{server}: {tool}" if tool else server
hint = ""
if tool_input:
for key in ("description", "command", "file_path", "pattern", "query"):
value = tool_input.get(key)
if isinstance(value, str) and value.strip():
hint = value.strip().splitlines()[0][:60]
break
return f"{name} {hint}" if hint else name
def _blocks(text: str) -> list[_Block]:
lines = text.split("\n")
blocks: list[_Block] = []
i = 0
while i < len(lines):
line = lines[i]
if _FENCE_OPEN.match(line):
j = i + 1
while j < len(lines) and not _FENCE_CLOSE.match(lines[j]):
j += 1
end = min(j + 1, len(lines))
blocks.append(_Block("fence", lines[i:end]))
i = end
elif _QUOTE.match(line):
j = i + 1
while j < len(lines) and _QUOTE.match(lines[j]):
j += 1
blocks.append(_Block("quote", lines[i:j]))
i = j
elif _is_table_start(lines, i):
j = i + 2
while j < len(lines) and "|" in lines[j] and lines[j].strip():
j += 1
blocks.append(_Block("table", lines[i:j]))
i = j
elif not line.strip():
blocks.append(_Block("text", [line]))
i += 1
else:
j = i + 1
while j < len(lines) and _is_plain(lines, j):
j += 1
blocks.append(_Block("text", lines[i:j]))
i = j
return blocks
def _is_table_start(lines: list[str], i: int) -> bool:
if "|" not in lines[i] or i + 1 >= len(lines):
return False
sep = lines[i + 1]
return "|" in sep and _TABLE_SEP.match(sep) is not None
def _is_plain(lines: list[str], i: int) -> bool:
line = lines[i]
return bool(
line.strip()
and not _FENCE_OPEN.match(line)
and not _QUOTE.match(line)
and not _is_table_start(lines, i)
)
def _open_fence(text: str) -> str | None:
opening: str | None = None
for line in text.split("\n"):
if opening is None:
if _FENCE_OPEN.match(line):
opening = line
elif _FENCE_CLOSE.match(line):
opening = None
return opening
def _render(block: _Block) -> str:
if block.kind == "fence":
return _render_fence(block.lines)
if block.kind == "quote":
body = "\n".join(_line(_QUOTE.sub("", line, 1)) for line in block.lines)
return f"<blockquote>{body}</blockquote>" if body.strip() else ""
if block.kind == "table":
return _render_table(block.lines)
return "\n".join(_line(line) for line in block.lines)
def _fence_parts(lines: list[str]) -> tuple[str, list[str]]:
body = lines[1:]
if body and _FENCE_CLOSE.match(body[-1]):
body = body[:-1]
return lines[0], body
def _render_fence(lines: list[str]) -> str:
head, body = _fence_parts(lines)
code = html.escape("\n".join(body).rstrip())
if not code:
return ""
lang = _language(head)
if lang:
return f'<pre><code class="language-{lang}">{code}</code></pre>'
return f"<pre>{code}</pre>"
def _language(head: str) -> str:
match = _FENCE_OPEN.match(head)
info = match.group(1).strip() if match else ""
token = info.split()[0] if info else ""
return token if _LANG.fullmatch(token) else ""
def _render_table(lines: list[str]) -> str:
rows = [_cells(line) for i, line in enumerate(lines) if i != 1]
width = max(len(row) for row in rows)
rows = [[*row, *[""] * (width - len(row))] for row in rows]
widths = [max(len(row[c]) for row in rows) for c in range(width)]
text = "\n".join(_table_row(row, widths) for row in rows)
return f"<pre>{html.escape(text)}</pre>"
def _table_row(row: list[str], widths: list[int]) -> str:
cells = (cell.ljust(w) for cell, w in zip(row, widths, strict=True))
return " | ".join(cells).rstrip(" |")
def _cells(line: str) -> list[str]:
inner = line.strip()
inner = inner.removeprefix("|").removesuffix("|")
return [cell.strip().replace("\\|", "|") for cell in _CELL_SPLIT.split(inner)]
def _line(line: str) -> str:
if _RULE.match(line):
return "———"
if match := _HEADING.match(line):
return f"<b>{_inline(match.group(1))}</b>"
if match := _TASK.match(line):
box = "☐" if match.group(2) == " " else "☑"
return f"{match.group(1)}{box} {_inline(match.group(3))}"
if match := _BULLET.match(line):
return f"{match.group(1)}• {_inline(match.group(2))}"
if match := _NUMBERED.match(line):
return f"{match.group(1)}{match.group(2)}. {_inline(match.group(3))}"
return _inline(line)
def _inline(text: str) -> str:
out: list[str] = []
pos = 0
for match in _TOKEN.finditer(text):
out.append(_format(text[pos : match.start()]))
out.append(_token(match.group()))
pos = match.end()
out.append(_format(text[pos:]))
return "".join(out)
def _token(token: str) -> str:
if token.startswith("`"):
return f"<code>{html.escape(token[1:-1])}</code>"
if token.startswith("[["):
return _wikilink(token)
match = _LINK.fullmatch(token)
if match is None:
return html.escape(token, quote=False)
href = html.escape(match.group(3), quote=True)
label = _format(match.group(2)) or href
return f'<a href="{href}">{label}</a>'
def _wikilink(token: str) -> str:
match = _WIKILINK.fullmatch(token)
if match is None:
return html.escape(token, quote=False)
note, heading, alias = (g.strip() if g else "" for g in match.groups())
label = alias or (f"{note} › {heading}" if heading else note) # noqa: RUF001
return f"<u>{html.escape(label, quote=False)}</u>"
def _format(text: str) -> str:
s = html.escape(text, quote=False)
s = _BOLD.sub(lambda m: f"<b>{m.group(1) or m.group(2)}</b>", s)
s = _ITALIC.sub(r"<i>\1</i>", s)
s = _ITALIC_U.sub(r"<i>\1</i>", s)
s = _STRIKE.sub(r"<s>\1</s>", s)
return _SPOILER.sub(r"<tg-spoiler>\1</tg-spoiler>", s)
def _fits(text: str, limit: int) -> bool:
return len(to_html(text)) <= limit
def _flush(out: list[str], cur: list[str]) -> None:
chunk = "\n".join(cur).strip()
if chunk:
out.append(chunk)
cur.clear()
def _pieces(block: _Block, limit: int) -> list[str]:
whole = "\n".join(block.lines)
if _fits(whole, limit):
return [whole]
if block.kind == "fence":
head, body = _fence_parts(block.lines)
return _pack(body, lambda ls: "\n".join([head, *ls, "```"]), limit)
if block.kind == "quote":
inner = [_QUOTE.sub("", line, 1) for line in block.lines]
return _pack(inner, lambda ls: "\n".join(f"> {s}" for s in ls), limit)
if block.kind == "table":
header = block.lines[:2]
return _pack(block.lines[2:], lambda ls: "\n".join(header + ls), limit)
return _pack(block.lines, "\n".join, limit)
def _pack(lines: list[str], wrap: Callable[[list[str]], str], limit: int) -> list[str]:
pieces: list[str] = []
cur: list[str] = []
for line in lines:
if cur and not _fits(wrap([*cur, line]), limit):
pieces.append(wrap(cur))
cur = []
if _fits(wrap([line]), limit):
cur.append(line)
else:
pieces.extend(wrap([part]) for part in _shatter(line, wrap, limit))
if cur:
pieces.append(wrap(cur))
return pieces
def _shatter(line: str, wrap: Callable[[list[str]], str], limit: int) -> list[str]:
parts: list[str] = []
while line:
cut = len(line)
while cut > 1 and not _fits(wrap([line[:cut].rstrip()]), limit):
rendered = len(to_html(wrap([line[:cut]])))
cut = min(cut - 1, _cut_point(line, max(1, cut * limit // rendered)))
if part := line[:cut].rstrip():
parts.append(part)
line = line[cut:].lstrip()
return parts
def _cut_point(text: str, limit: int) -> int:
for sep in ("\n\n", "\n", ". ", " "):
cut = text.rfind(sep, limit // 2, limit)
if cut > 0:
return cut + len(sep)
return limit
@@ -1,106 +0,0 @@
"""Every string the Telegram frontend shows the user; English defaults, overridable."""
from __future__ import annotations
from dataclasses import dataclass, field
__all__ = ["TOOL_LABELS", "TelegramTexts"]
TOOL_LABELS: dict[str, str] = {
"Read": "reading",
"Glob": "looking for files",
"Grep": "searching",
"Edit": "editing a file",
"Write": "writing a file",
"MultiEdit": "editing files",
"Bash": "running a command",
"WebSearch": "searching the web",
"WebFetch": "reading a page",
"Task": "started a subagent",
"Agent": "started a subagent",
"AskUserQuestion": "asking",
"TodoWrite": "planning",
"Skill": "opening a skill",
"mcp__gateway__spawn": "opening a conversation",
"mcp__gateway__read_conversation": "reading a conversation",
"mcp__gateway__say": "speaking",
"mcp__gateway__schedule": "setting a reminder",
"mcp__gateway__inject": "passing to another conversation",
"mcp__gateway__send_file": "sending a file",
}
@dataclass(frozen=True, slots=True)
class TelegramTexts:
commands: tuple[tuple[str, str], ...] = (
("merge", "merge this branch into the master"),
("new", "new branch: in General a new topic, in a topic a fresh one here"),
("chat", "open a deep chat in the vault: /chat topic"),
("status", "this conversation"),
("help", "commands"),
)
help_head: str = "General is the master, any other topic is a branch."
branch: str = "branch"
closed_prefix: str = "✅ "
waiting: str = "⏳ thinking"
thinking: str = "🤔 thinking"
writing: str = "✍️ writing"
tool: str = "⏳ {label}"
tool_labels: dict[str, str] = field(default_factory=lambda: dict(TOOL_LABELS))
attachment_sticker: str = "[attachment: sticker - not supported]"
attachment_animation: str = "[attachment: animation - not supported]"
attachment_kinds: dict[str, str] = field(
default_factory=lambda: {
"photo": "photo",
"document": "file",
"voice": "voice message",
"audio": "audio",
"video": "video",
"video_note": "video note",
}
)
attachment_too_big: str = (
"[attachment: {kind} {name}, {mb} MB - over 20 MB, Telegram does not "
"serve it to bots]"
)
attachment_failed: str = "[attachment: {kind} {name} - download failed: {error}]"
attachment_size: str = ", {kb} KB"
attachment: str = "[attachment: {kind} {path}{size}]"
attachment_kept: str = (
"[attachment: {kind} {path}{size}; kept {days} days, move it into the "
"vault if it matters]"
)
merge_nothing: str = "nothing to merge: this is not an open branch"
merging: str = "🔀 merging into the master"
merged: str = "✅ merged into the master"
merge_failed: str = "⚠️ merge failed: {error}"
new_branch_topic: str = "🌿 branch «{title}» - in a new topic"
new_branch_here: str = "🌿 new branch on this topic"
chat_usage: str = "/chat <topic>"
chat_failed: str = "could not open: {error}"
chat_opened: str = "💬 deep chat: {where}"
status_unbound: str = "this topic is bound to nothing - write, and a branch opens"
status_line: str = "session: {live} · turn: {turn} · queued: {queued}"
status_live: str = "live"
status_dead: str = "none"
status_running: str = "running"
status_idle: str = "idle"
status_question: str = "❓ waiting for an answer"
status_pool: str = "pool: {sessions} sessions, rss {mb} MB"
status_outbox: str = "outbox: {pending} queued"
turn_failed: str = "⚠️ the turn failed, see the gateway logs"
background_failed: str = (
"⚠️ a background turn ({origin}) failed, see the gateway logs"
)
question_closed: str = "the question is already closed"
question_timeout: str = "⌛ time is up - answer as text"
question_answered: str = "✅ {answer}"
question_typed: str = "✍️ {text}"
done_button: str = "✅ done"
mirrored: str = "📝 from the panel:\n{text}"
@property
def help(self) -> str:
return (
self.help_head + "\n" + "\n".join(f"/{c} - {d}" for c, d in self.commands)
)
@@ -1,95 +0,0 @@
"""Cross-frontend turn record.
Frontends emit a :class:`TurnRecord` to every handler in
``GatewayRuntime.turn_log_handlers`` after a turn finishes.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from anthropic.types import Message, MessageParam
__all__ = ["TurnRecord", "slugify"]
_SLUG_BAD_RE = re.compile(r"[^\w\s\-]+", flags=re.UNICODE)
_SLUG_SEP_RE = re.compile(r"[\s\-]+", flags=re.UNICODE)
def slugify(text: str, *, maxlen: int = 40) -> str:
"""Sanitize ``text`` for use as a filename fragment.
Truncates to ``maxlen`` and returns ``"untitled"`` for empty input.
"""
cleaned = _SLUG_BAD_RE.sub(" ", text).strip()
cleaned = _SLUG_SEP_RE.sub("-", cleaned).strip("-")
if not cleaned:
return "untitled"
if len(cleaned) > maxlen:
cleaned = cleaned[:maxlen].rstrip("-")
return cleaned or "untitled"
@dataclass(frozen=True, slots=True)
class TurnRecord:
"""One completed turn, as seen by a frontend.
``input_messages`` excludes the assistant reply; ``source`` names the
producing frontend so a cross-frontend logger can skip its own turns.
"""
agent_name: str
input_messages: list[MessageParam]
output_message: Message
system: str | None = None
source: str = "anthropic"
@property
def first_user_text(self) -> str:
"""Empty string if the input history has no user turn."""
for msg in self.input_messages:
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
return ""
@property
def last_user_text(self) -> str:
for msg in reversed(self.input_messages):
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
return ""
@property
def assistant_text(self) -> str:
"""Plain text of the assistant reply, with thinking/tool blocks dropped."""
chunks = [
getattr(block, "text", "") or ""
for block in getattr(self.output_message, "content", ())
if getattr(block, "type", None) == "text"
]
return "\n\n".join(c for c in chunks if c)
def _text_of(content: object) -> str:
"""Flatten an Anthropic ``MessageParam.content`` field to a plain string.
Handles both raw-string and block-list shapes; unrecognized content is
silently skipped since this exists for naming, not faithful reconstruction.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for blk in content:
if not isinstance(blk, dict):
continue
d: Any = blk
if d.get("type") == "text":
parts.append(str(d.get("text", "")))
return "\n\n".join(p for p in parts if p)
return ""
-30
View File
@@ -1,30 +0,0 @@
"""Where a frontend is reachable from outside, as seen by one request."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from starlette.requests import Request
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
def request_origin(request: Request, runtime: GatewayRuntime) -> str:
"""``Gateway.public_url`` if configured, else the request's own origin."""
if runtime.public_url:
return runtime.public_url
host = request.headers.get("host")
if not host:
return f"http://{request.url.hostname}:{request.url.port}"
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
return f"{scheme}://{host}"
def external_base(request: Request, runtime: GatewayRuntime) -> str:
"""Origin plus the mount path of the app handling ``request``."""
return request_origin(request, runtime) + str(request.scope.get("root_path", ""))
def frontend_url(request: Request, runtime: GatewayRuntime, fe: Frontend) -> str | None:
return request_origin(request, runtime) + fe.path if fe.path else None
-1
View File
@@ -1 +0,0 @@
"""Cron, webhook and event jobs, deferred injects, the subscription budget."""
-126
View File
@@ -1,126 +0,0 @@
"""What a job is: its triggers, the budget it respects, and what a run may do."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Coroutine
from beaver_gateway.conversations.distill import LineCap
from beaver_gateway.conversations.injects import Priority
from beaver_gateway.conversations.service import Conversations, DistillResult
from beaver_gateway.jobs.scheduler import Scheduler
from beaver_gateway.storage.models import Conversation
__all__ = ["Budget", "Job", "JobRun"]
_log = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class Job:
name: str
run: Callable[[JobRun], Awaitable[None]]
cron: str | None = None
webhook: bool = False
events: tuple[str, ...] = ()
critical: bool = True
dedupe: bool = True
@property
def entrypoint(self) -> str:
return f"job:{self.name}"
@dataclass(frozen=True, slots=True)
class Budget:
threshold: float = 0.7
tokens: int | None = None
window: timedelta = timedelta(hours=5)
limit_window: str = "five_hour"
@dataclass(slots=True)
class JobRun:
job: Job
trigger: str
payload: dict[str, Any]
scheduler: Scheduler
@property
def conversations(self) -> Conversations:
return self.scheduler.conversations
async def master(self) -> Conversation | None:
masters = await self.conversations.find(kind="master", status="open", limit=1)
return masters[0] if masters else None
async def inject_master(
self, text: str, *, urgency: Priority = "normal", origin: str | None = None
) -> bool:
master = await self.master()
if master is None:
_log.error(
"job %s: no open master, inject lost: %s", self.job.name, text[:200]
)
return False
await self.conversations.inject(
master, text, urgency=urgency, origin=origin or self.job.name
)
return True
async def spawn_job(
self,
*,
agent: str,
text: str,
title: str | None = None,
line_cap: LineCap | None = None,
) -> Conversation:
"""A headless job turn; ``line_cap`` bounces a rewrite past the cap."""
return await self.conversations.spawn(
kind="job",
agent=agent,
seed="brief",
text=text,
title=title,
origin="job",
flags={"line_cap": line_cap.as_flags()} if line_cap else None,
)
async def close_idle(
self,
*,
kind: str = "deep",
days: int = 2,
limit: int = 3,
since: datetime | None = None,
) -> list[DistillResult]:
"""Close chats quiet for ``days``, at most ``limit`` per run."""
out: list[DistillResult] = []
for conv in await self.conversations.idle(
kind=kind, days=days, since=since, limit=limit
):
try:
out.append(
await self.conversations.distill(conv, reason=f"idle {days}d")
)
except Exception: # noqa: BLE001
_log.exception("closing idle %s failed", conv.external_id)
return out
async def retry_in(self, delay: timedelta) -> None:
await self.scheduler.trigger(
self.job, self.payload, delay=delay, trigger=self.trigger
)
async def rotate(self) -> list[Conversation]:
rotation = self.scheduler.rotation
return await rotation.tick() if rotation is not None else []
def background(self, coro: Coroutine[Any, Any, Any]) -> None:
self.scheduler.background(coro)
-494
View File
@@ -1,494 +0,0 @@
"""Jobs and deferred injects on pgqueuer.
A job is a name, a handler and its triggers (cron, webhook, bus events); a
handler only queues work, and the turn itself runs in the conversation's worker.
"""
from __future__ import annotations
import asyncio
import contextlib
import functools
import json
import logging
import traceback
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast
from zoneinfo import ZoneInfo
from croniter import croniter
from pgqueuer import PgQueuer, Queries
from pgqueuer.core.executors import ScheduleExecutor
from pgqueuer.models import JobId
from sqlalchemy import func
from sqlmodel import col, select
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from beaver_gateway.conversations.service import parse_at
from beaver_gateway.jobs.job import Budget, Job, JobRun
from beaver_gateway.storage.models import JobRunRecord
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Coroutine, Sequence
from pgqueuer.models import Job as PgJob
from pgqueuer.models import Schedule as PgSchedule
from pgqueuer.ports.driver import Driver
from starlette.requests import Request
from beaver_gateway.conversations.injects import Priority
from beaver_gateway.conversations.rotation import Rotation
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.storage.models import Conversation
__all__ = ["INJECT", "Budget", "Job", "JobRun", "LocalCron", "Scheduler", "next_run"]
_log = logging.getLogger("beaver_gateway.jobs.scheduler")
INJECT = "inject"
RETRY = timedelta(minutes=15)
ERROR_MAX = 2000
def next_run(expression: str, tz: tzinfo, now: datetime | None = None) -> datetime:
"""The cron's next fire time, read in ``tz``, returned in UTC."""
start = (now or datetime.now(UTC)).astimezone(tz)
return croniter(expression, start_time=start).get_next(datetime).astimezone(UTC)
@dataclass
class LocalCron(ScheduleExecutor):
tz: tzinfo = UTC
def get_next(self) -> datetime:
return next_run(self.parameters.expression, self.tz)
class Scheduler:
def __init__(
self,
*,
conversations: Conversations,
jobs: Sequence[Job] = (),
driver: Driver | None = None,
budget: Budget | None = None,
rotation: Rotation | None = None,
heartbeat: timedelta = timedelta(seconds=30),
tz: str = "UTC",
) -> None:
self.conversations = conversations
self.rotation = rotation
self.tz = ZoneInfo(tz)
self.budget = budget or Budget()
self._jobs = {job.name: job for job in jobs}
self._driver = driver
self._queries: Queries | None = None
self._heartbeat = heartbeat
self._tasks: set[asyncio.Task[Any]] = set()
self._runs: dict[str, dict[str, Any]] = {}
@property
def enabled(self) -> bool:
return self._queries is not None
@property
def jobs(self) -> list[Job]:
return list(self._jobs.values())
def job(self, name: str) -> Job | None:
return self._jobs.get(name)
async def start(self) -> None:
if self._driver is not None:
queries = Queries(self._driver)
if not await queries.has_table("pgqueuer"):
await queries.install()
pgq = PgQueuer(self._driver, queries=queries)
self._register(pgq)
self._queries = queries
self._spawn(pgq.run(heartbeat_timeout=self._heartbeat))
else:
_log.warning("scheduler: no postgres driver - cron and webhooks are off")
self._spawn(self._events())
async def stop(self) -> None:
for task in list(self._tasks):
task.cancel()
for task in list(self._tasks):
with contextlib.suppress(BaseException):
await task
self._tasks.clear()
self._queries = None
def _register(self, pgq: PgQueuer) -> None:
@pgq.entrypoint(INJECT)
async def deliver(job: PgJob) -> None:
await self._deliver(job)
for job in self._jobs.values():
self._register_job(pgq, job)
def _register_job(self, pgq: PgQueuer, job: Job) -> None:
@pgq.entrypoint(job.entrypoint)
async def queued(pg_job: PgJob) -> None:
data = _decode(pg_job.payload)
await self._dispatch(
job, trigger=str(data.pop("trigger", "queue")), payload=data
)
if job.cron:
@pgq.schedule(
job.name,
job.cron,
clean_old=True,
executor_factory=functools.partial(LocalCron, tz=self.tz),
)
async def cron(_schedule: PgSchedule) -> None:
await self._dispatch(job, trigger="cron", payload={})
async def _events(self) -> None:
listeners = [job for job in self._jobs.values() if job.events]
if not listeners:
return
async for event in self.conversations.bus.stream():
for job in listeners:
if event.get("type") in job.events:
self._spawn(
self._dispatch(job, trigger="event", payload=dict(event))
)
async def _dispatch(
self, job: Job, *, trigger: str, payload: dict[str, Any]
) -> None:
bus = self.conversations.bus
if not job.critical and await self.throttled():
bus.publish("job.deferred", job=job.name, trigger=trigger)
if trigger != "cron":
await self.trigger(job, payload, delay=RETRY, trigger=trigger)
return
started = datetime.now(UTC)
bus.publish("job.start", job=job.name, trigger=trigger)
status, error = "done", None
try:
await job.run(JobRun(job, trigger, payload, self))
except Exception as exc: # noqa: BLE001
status, error = "failed", _error_text(exc)
_log.exception("job %s (%s) failed", job.name, trigger)
record = JobRunRecord(
job=job.name,
trigger=trigger,
started_at=started,
finished_at=datetime.now(UTC),
status=status,
error=error,
payload=payload,
)
await self._record(record)
self._runs[job.name] = self._run_public(record)
bus.publish("job.end", job=job.name, trigger=trigger, status=status)
async def _record(self, record: JobRunRecord) -> None:
try:
async with self.conversations.db.session() as session:
session.add(record)
await session.commit()
except Exception: # noqa: BLE001
_log.exception("job %s: run not recorded", record.job)
async def trigger(
self,
job: Job,
payload: dict[str, Any] | None = None,
*,
delay: timedelta | None = None,
trigger: str = "manual",
) -> int | None:
"""Queue one run of ``job``; runs inline when there is no executor."""
data = {**(payload or {}), "trigger": trigger}
if self._queries is None:
self._spawn(
self._dispatch(job, trigger=trigger, payload=payload or {}), delay=delay
)
return None
ids = await self._queries.enqueue(
job.entrypoint, _encode(data), execute_after=delay
)
return int(ids[0]) if ids and ids[0] is not None else None
async def hook(self, name: str, payload: dict[str, Any]) -> int | None:
job = self._jobs.get(name)
if job is None or not job.webhook:
msg = f"no webhook job {name!r}"
raise LookupError(msg)
self.conversations.bus.publish("hook", job=name)
if self._queries is None:
return await self.trigger(job, payload, trigger="webhook")
ids = await self._queries.enqueue(
job.entrypoint,
_encode({**payload, "trigger": "webhook"}),
dedupe_key=f"hook:{name}" if job.dedupe else None,
on_conflict="skip" if job.dedupe else "raise",
)
return int(ids[0]) if ids and ids[0] is not None else None
async def schedule(
self,
conv: Conversation,
at: str,
text: str,
*,
urgency: Priority = "wake",
dedupe_key: str | None = None,
) -> tuple[int | None, datetime]:
"""A deferred inject.
``wake`` by default: it was promised for a time, so it starts a turn
then instead of waiting for the normal window.
"""
if self._queries is None:
msg = "scheduler needs postgres; `schedule` is unavailable"
raise RuntimeError(msg)
when = parse_at(at, self.tz).astimezone(self.tz)
delay = max(when - datetime.now(UTC), timedelta(0))
payload = _encode(
{
"conversation": conv.external_id,
"text": text,
"urgency": urgency,
"at": when.isoformat(timespec="minutes"),
}
)
ids = await self._queries.enqueue(
INJECT,
payload,
execute_after=delay,
dedupe_key=dedupe_key,
on_conflict="skip" if dedupe_key else "raise",
)
job_id = int(ids[0]) if ids and ids[0] is not None else None
self.conversations.bus.publish(
"schedule.created",
conversation_id=conv.external_id,
job=job_id,
execute_at=when.isoformat(timespec="minutes"),
)
return job_id, when
async def cancel(self, job_id: int) -> bool:
if self._queries is None:
return False
row = await self._queries.queue_job_by_id(JobId(job_id))
if row is None:
return False
await self._queries.mark_job_as_cancelled([JobId(job_id)])
self.conversations.bus.publish("schedule.cancelled", job=job_id)
return True
async def _deliver(self, job: PgJob) -> None:
data = _decode(job.payload)
key = str(data.get("conversation", ""))
conv = await self.conversations.resolve(key)
if conv is None:
_log.error(
"scheduled inject #%s lost: conversation %r is gone: %s",
job.id,
key,
str(data.get("text", ""))[:200],
)
return
await self.conversations.inject(
conv,
str(data.get("text", "")),
urgency=cast("Priority", data.get("urgency") or "wake"),
origin="schedule",
)
async def utilization(self) -> float | None:
now = datetime.now(UTC)
values: list[float] = []
for row in await self.conversations.rate_limits(limit=100):
if row.window != self.budget.limit_window or row.utilization is None:
continue
fresh = now - _aware(row.ts) < self.budget.window
live = row.resets_at is None or _aware(row.resets_at) > now
if fresh and live:
values.append(float(row.utilization))
break
if self.budget.tokens:
tokens = await self.conversations.usage_tokens(now - self.budget.window)
values.append(tokens / self.budget.tokens)
return max(values) if values else None
async def throttled(self) -> bool:
utilization = await self.utilization()
return utilization is not None and utilization > self.budget.threshold
async def snapshot(self) -> dict[str, Any]:
crons: dict[str, PgSchedule] = {}
queue: list[dict[str, Any]] = []
if self._queries is not None:
for row in await self._queries.peek_schedule():
crons[str(row.entrypoint)] = row
queue = [
_job_public(row, self.tz)
for row in await self._queries.browse_queue(limit=200)
]
utilization = await self.utilization()
last = await self._last_runs()
return {
"enabled": self.enabled,
"tz": str(self.tz),
"utilization": utilization,
"throttled": utilization is not None
and utilization > self.budget.threshold,
"threshold": self.budget.threshold,
"jobs": [
self._job_public(job, crons.get(job.name), last.get(job.name))
for job in self.jobs
],
"queue": queue,
}
async def scheduled(self, conv: Conversation | None = None) -> list[dict[str, Any]]:
if self._queries is None:
return []
rows = await self._queries.browse_queue(limit=500, entrypoints=[INJECT])
out = [_job_public(row, self.tz) for row in rows]
if conv is not None:
out = [
j for j in out if j["payload"].get("conversation") == conv.external_id
]
return out
async def runs(self, name: str, limit: int = 50) -> list[dict[str, Any]]:
stmt = (
select(JobRunRecord)
.where(JobRunRecord.job == name)
.order_by(col(JobRunRecord.id).desc())
.limit(limit)
)
async with self.conversations.db.session() as session:
rows = (await session.exec(stmt)).all()
return [self._run_public(row) for row in rows]
async def _last_runs(self) -> dict[str, dict[str, Any]]:
newest = select(func.max(JobRunRecord.id)).group_by(JobRunRecord.job)
stmt = select(JobRunRecord).where(col(JobRunRecord.id).in_(newest))
try:
async with self.conversations.db.session() as session:
rows = (await session.exec(stmt)).all()
except Exception: # noqa: BLE001
_log.exception("job runs unreadable, using memory")
return dict(self._runs)
return {row.job: self._run_public(row) for row in rows}
def _job_public(
self, job: Job, cron: PgSchedule | None, run: dict[str, Any] | None
) -> dict[str, Any]:
return {
"name": job.name,
"cron": job.cron,
"webhook": job.webhook,
"events": list(job.events),
"critical": job.critical,
"next_run": _iso(cron.next_run, self.tz) if cron is not None else None,
"last_run": _iso(cron.last_run, self.tz) if cron is not None else None,
"status": str(cron.status) if cron is not None else None,
"run": run or self._runs.get(job.name),
}
def _run_public(self, row: JobRunRecord) -> dict[str, Any]:
return {
"id": row.id,
"job": row.job,
"trigger": row.trigger,
"started_at": _iso(row.started_at, self.tz),
"finished_at": _iso(row.finished_at, self.tz),
"status": row.status,
"error": row.error,
"payload": row.payload,
}
def app(self, authorize: Callable[[Request], Awaitable[Any]]) -> Starlette:
async def hook(request: Request) -> JSONResponse:
await authorize(request)
name = request.path_params["name"]
payload = await _payload(request)
try:
job_id = await self.hook(name, payload)
except LookupError as exc:
return JSONResponse({"error": str(exc)}, status_code=404)
return JSONResponse({"job": job_id, "name": name}, status_code=202)
return Starlette(routes=[Route("/{name}", hook, methods=["POST"])])
def background(self, coro: Coroutine[Any, Any, Any]) -> None:
self._spawn(coro)
def _spawn(
self, coro: Coroutine[Any, Any, Any], *, delay: timedelta | None = None
) -> None:
async def later() -> None:
if delay is not None:
await asyncio.sleep(delay.total_seconds())
await coro
task = asyncio.create_task(later() if delay is not None else coro)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _payload(request: Request) -> dict[str, Any]:
body = await request.body()
if not body:
return dict(request.query_params)
try:
data = json.loads(body)
except ValueError:
return {"raw": body.decode("utf-8", errors="replace")}
return data if isinstance(data, dict) else {"raw": data}
def _encode(data: dict[str, Any]) -> bytes:
return json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
def _decode(payload: bytes | None) -> dict[str, Any]:
if not payload:
return {}
try:
data = json.loads(payload)
except ValueError:
return {"raw": payload.decode("utf-8", errors="replace")}
return data if isinstance(data, dict) else {"raw": data}
def _job_public(row: PgJob, tz: tzinfo) -> dict[str, Any]:
return {
"id": int(row.id),
"entrypoint": row.entrypoint,
"status": str(row.status),
"execute_after": _iso(row.execute_after, tz),
"created": _iso(row.created, tz),
"attempts": row.attempts,
"payload": _decode(row.payload),
}
def _error_text(exc: BaseException) -> str:
head = f"{type(exc).__name__}: {exc}"
return f"{head}\n{''.join(traceback.format_exception(exc))}"[:ERROR_MAX]
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _iso(value: datetime | None, tz: tzinfo = UTC) -> str | None:
if value is None:
return None
return _aware(value).astimezone(tz).isoformat(timespec="seconds")
Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff. Show more