Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c3218eeed | ||
|
|
9e60d06829 | ||
|
|
d5aaa80822 | ||
|
|
5401cbe187 | ||
|
|
82029bb10d | ||
|
|
96e18156eb | ||
|
|
1f051f261e | ||
|
|
9080eb7dee | ||
|
|
20568915db | ||
|
|
72d60b799f | ||
|
|
607ed6bc93 | ||
|
|
b6d111a610 | ||
|
|
74843451a4 | ||
|
|
836cd60bd8 | ||
|
|
99bcec2ba0 | ||
|
|
d41f18504c | ||
|
|
256b2a68d6 | ||
|
|
6b7de6f03d | ||
|
|
c3b0864e3d | ||
|
|
38ae967233 | ||
|
|
c9c2e94954 | ||
|
|
90d0fb0f00 | ||
|
|
cae2ed4161 | ||
|
|
b96714338f | ||
|
|
7853d61b94 | ||
|
|
f6a7ea8f29 | ||
|
|
1954a6e9f2 | ||
|
|
9b1127acff | ||
|
|
1e08dfb9da | ||
|
|
8e7db6baa4 | ||
|
|
acc79c2e42 | ||
|
|
63014a487d | ||
|
|
3406e2e178 | ||
|
|
878f7d6473 | ||
|
|
c438d4cb92 | ||
|
|
ba9d63667e | ||
|
|
d57f660a30 | ||
|
|
cc32307921 | ||
|
|
811035ba4f | ||
|
|
9efff6e814 | ||
|
|
a989d2909d | ||
|
|
8d252f9867 | ||
|
|
2895e677c0 | ||
|
|
23cfb16228 | ||
|
|
ab9ce50b82 | ||
|
|
662bdcdfda | ||
|
|
0479d7cdda | ||
|
|
0cc2c5291f | ||
|
|
5cd18fa2d7 | ||
|
|
e45e9960ed | ||
|
|
16b6bbddda | ||
|
|
540327efa1 | ||
|
|
645df6f2b4 | ||
|
|
b577034c83 | ||
|
|
00618ab7ad | ||
|
|
aaaf459e6d | ||
|
|
4ad014726b | ||
|
|
8c9c6eee69 | ||
|
|
21766fe2d7 | ||
|
|
1f8dae57ac | ||
|
|
66325c2879 | ||
|
|
5a44a16ba3 | ||
|
|
c711dcab13 | ||
|
|
3995741cd5 | ||
|
|
b0f280b3ca | ||
|
|
45a5eb4a94 | ||
|
|
7ae87aeeb8 | ||
|
|
fd8cbacd7b | ||
|
|
bd2d2368e1 | ||
|
|
1d8d65b69a | ||
|
|
33ccc78fec | ||
|
|
50b7057fa4 | ||
|
|
e1f242a87a | ||
|
|
ee2dc918ee | ||
|
|
33cc769ca2 | ||
|
|
e4282b38cd |
No files matched your search
@@ -0,0 +1,24 @@
|
||||
## Code Principles
|
||||
- **Simplicity**: Write simple, straightforward code
|
||||
- **Readability**: Make code easy to understand
|
||||
- **Maintainability**: Write code that's easy to update
|
||||
- **Less Code = Less Debt**: Minimize code footprint
|
||||
- **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
|
||||
- `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`):
|
||||
```shell
|
||||
ruff format # python formatter
|
||||
ruff check --fix # python linter
|
||||
ty check # python type-checker
|
||||
cd ui && bun fix # frontend linter+formatter (ultracite)
|
||||
cd ui && bun check # svelte-check
|
||||
```
|
||||
Dependencies: `uv add` / `cd ui && bun add`, never edit lock files by hand.
|
||||
@@ -0,0 +1,23 @@
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
## Available Svelte MCP Tools:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||
|
||||
### 2. get-documentation
|
||||
|
||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||
|
||||
### 3. svelte-autofixer
|
||||
|
||||
Analyzes Svelte code and returns issues and suggestions.
|
||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Ultracite Code Standards
|
||||
|
||||
This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- **Format code**: `bun x ultracite fix`
|
||||
- **Check for issues**: `bun x ultracite check`
|
||||
- **Diagnose setup**: `bun x ultracite doctor`
|
||||
|
||||
Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
|
||||
|
||||
### Type Safety & Explicitness
|
||||
|
||||
- Use explicit types for function parameters and return values when they enhance clarity
|
||||
- Prefer `unknown` over `any` when the type is genuinely unknown
|
||||
- Use const assertions (`as const`) for immutable values and literal types
|
||||
- Leverage TypeScript's type narrowing instead of type assertions
|
||||
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
|
||||
|
||||
### Modern JavaScript/TypeScript
|
||||
|
||||
- Use arrow functions for callbacks and short functions
|
||||
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
|
||||
- Prefer template literals over string concatenation
|
||||
- Use destructuring for object and array assignments
|
||||
- Use `const` by default, `let` only when reassignment is needed, never `var`
|
||||
|
||||
### Async & Promises
|
||||
|
||||
- Always `await` promises in async functions - don't forget to use the return value
|
||||
- Use `async/await` syntax instead of promise chains for better readability
|
||||
- Handle errors appropriately in async code with try-catch blocks
|
||||
- Don't use async functions as Promise executors
|
||||
|
||||
### React & JSX
|
||||
|
||||
- Use function components over class components
|
||||
- Call hooks at the top level only, never conditionally
|
||||
- Specify all dependencies in hook dependency arrays correctly
|
||||
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
|
||||
- Nest children between opening and closing tags instead of passing as props
|
||||
- Don't define components inside other components
|
||||
- Use semantic HTML and ARIA attributes for accessibility:
|
||||
- Provide meaningful alt text for images
|
||||
- Use proper heading hierarchy
|
||||
- Add labels for form inputs
|
||||
- Include keyboard event handlers alongside mouse events
|
||||
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
|
||||
|
||||
### Error Handling & Debugging
|
||||
|
||||
- Remove `console.log`, `debugger`, and `alert` statements from production code
|
||||
- Throw `Error` objects with descriptive messages, not strings or other values
|
||||
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
|
||||
- Prefer early returns over nested conditionals for error cases
|
||||
|
||||
### Code Organization
|
||||
|
||||
- Keep functions focused and under reasonable cognitive complexity limits
|
||||
- Extract complex conditions into well-named boolean variables
|
||||
- Use early returns to reduce nesting
|
||||
- Prefer simple conditionals over nested ternary operators
|
||||
- Group related code together and separate concerns
|
||||
|
||||
### Security
|
||||
|
||||
- Add `rel="noopener"` when using `target="_blank"` on links
|
||||
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
|
||||
- Don't use `eval()` or assign directly to `document.cookie`
|
||||
- Validate and sanitize user input
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid spread syntax in accumulators within loops
|
||||
- Use top-level regex literals instead of creating them in loops
|
||||
- Prefer specific imports over namespace imports
|
||||
- Avoid barrel files (index files that re-export everything)
|
||||
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
|
||||
|
||||
### Framework-Specific Guidance
|
||||
|
||||
**Next.js:**
|
||||
- Use Next.js `<Image>` component for images
|
||||
- Use `next/head` or App Router metadata API for head elements
|
||||
- Use Server Components for async data fetching instead of async Client Components
|
||||
|
||||
**React 19+:**
|
||||
- Use ref as a prop instead of `React.forwardRef`
|
||||
|
||||
**Solid/Svelte/Vue/Qwik:**
|
||||
- Use `class` and `for` attributes (not `className` or `htmlFor`)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Write assertions inside `it()` or `test()` blocks
|
||||
- Avoid done callbacks in async tests - use async/await instead
|
||||
- Don't use `.only` or `.skip` in committed code
|
||||
- Keep test suites reasonably flat - avoid excessive `describe` nesting
|
||||
|
||||
## When Biome Can't Help
|
||||
|
||||
Biome's linter will catch most issues automatically. Focus your attention on:
|
||||
|
||||
1. **Business logic correctness** - Biome can't validate your algorithms
|
||||
2. **Meaningful naming** - Use descriptive names for functions, variables, and types
|
||||
3. **Architecture decisions** - Component structure, data flow, and API design
|
||||
4. **Edge cases** - Handle boundary conditions and error states
|
||||
5. **User experience** - Accessibility, performance, and usability considerations
|
||||
6. **Documentation** - Add comments for complex logic, but prefer self-documenting code
|
||||
|
||||
---
|
||||
|
||||
Most formatting and common issues are automatically fixed by Biome. Run `bun x ultracite fix` before committing to ensure compliance.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(python3 -c 'import json,sys;print(json.load(sys.stdin).get(\"tool_input\",{}).get(\"file_path\",\"\"))'); case \"$f\" in */ui/*) cd ui && bun fix --skip=correctness/noUnusedImports ;; *.py) uv run ruff format \"$f\" && uv run ruff check --fix \"$f\" ;; esac"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,10 @@ examples
|
||||
**/.ruff_cache
|
||||
**/.ty_cache
|
||||
README.md
|
||||
ui/node_modules
|
||||
ui/.svelte-kit
|
||||
ui/build
|
||||
ui/.vscode
|
||||
.claude
|
||||
.mcp.json
|
||||
Makefile
|
||||
@@ -0,0 +1,34 @@
|
||||
# beaver-gateway - локальный dev. В проде переменные приходят из .env
|
||||
# стека beaver-agent (env_file), этот файл там не нужен.
|
||||
|
||||
# Postgres из `make db` (профиль db в ../beaver-agent) или sqlite для быстрого старта
|
||||
DATABASE_URL=postgresql+psycopg://beaver:beaver@localhost:5432/beaver
|
||||
#DATABASE_URL=sqlite+aiosqlite:///./db.db
|
||||
|
||||
# Admin UI
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASS=admin
|
||||
SESSION_SECRET=dev-only-change-me
|
||||
|
||||
# Пользовательский config.py (агенты, MCP, фронтенды)
|
||||
CONFIG_PATH=../beaver-agent/config.py
|
||||
|
||||
# Токены для Anthropic/MCP фронтендов без admin UI: name:value,name:value
|
||||
BOOTSTRAP_TOKENS=dev:dev-token-change-me
|
||||
|
||||
# Claude: `claude setup-token` на маке
|
||||
CLAUDE_CODE_OAUTH_TOKEN=
|
||||
|
||||
# Raycast (нужно только если в config.py есть RaycastAgent)
|
||||
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
|
||||
@@ -11,6 +11,7 @@ wheels/
|
||||
|
||||
# Sloppy
|
||||
docs/
|
||||
t/
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
@@ -18,3 +19,6 @@ docs/
|
||||
# Local env
|
||||
.env
|
||||
db.db
|
||||
|
||||
# Impeccable review screenshots
|
||||
.impeccable/review/
|
||||
@@ -0,0 +1,628 @@
|
||||
{
|
||||
"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."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"svelte": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -1,18 +1,32 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.13
|
||||
rev: v0.15.20
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
types_or: [python, pyi]
|
||||
args: [--fix]
|
||||
types_or: [ python, pyi ]
|
||||
args: [ --fix ]
|
||||
- id: ruff-format
|
||||
types_or: [python, pyi]
|
||||
types_or: [ python, pyi ]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ty
|
||||
name: ty check
|
||||
entry: uvx ty check
|
||||
language: python
|
||||
types_or: [python, pyi]
|
||||
entry: uv run ty check
|
||||
language: system
|
||||
types_or: [ python, pyi ]
|
||||
pass_filenames: false
|
||||
|
||||
- id: ultracite
|
||||
name: ultracite
|
||||
entry: bash -c 'cd ui && bun x ultracite fix'
|
||||
language: system
|
||||
files: ^ui/
|
||||
pass_filenames: false
|
||||
|
||||
- id: svelte-check
|
||||
name: svelte check
|
||||
entry: bash -c 'cd ui && bun check'
|
||||
language: system
|
||||
files: ^ui/
|
||||
pass_filenames: false
|
||||
+22
-17
@@ -30,35 +30,40 @@ COPY . /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --extra prod
|
||||
|
||||
# ---- UI: SvelteKit SPA built with bun ----
|
||||
FROM oven/bun:1-slim AS ui
|
||||
WORKDIR /ui
|
||||
COPY ui/package.json ui/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ui ./
|
||||
RUN bun --bun svelte-kit sync && bun --bun run build
|
||||
|
||||
# ---- Runtime ----
|
||||
FROM python:3.13-slim AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Bun native binary (glibc) — fast `npm install` replacement, only used
|
||||
# to drop the claude CLI into the image. Not invoked at runtime.
|
||||
# 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.
|
||||
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
|
||||
|
||||
# `--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
|
||||
# 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
|
||||
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=builder /app /app
|
||||
COPY --from=ui /ui/build /app/ui/build
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["python", "-m"]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
.PHONY: sync check fix run ui ui-build db db-down
|
||||
|
||||
sync:
|
||||
uv sync --extra local
|
||||
cd ui && bun install
|
||||
|
||||
check:
|
||||
uv run ruff format --check
|
||||
uv run ruff check
|
||||
uv run ty check
|
||||
cd ui && bun lint && bun check
|
||||
|
||||
fix:
|
||||
uv run ruff format
|
||||
uv run ruff check --fix
|
||||
cd ui && bun fix
|
||||
|
||||
run:
|
||||
uv run python -m beaver_gateway
|
||||
|
||||
ui:
|
||||
cd ui && bun run dev
|
||||
|
||||
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
|
||||
|
||||
db-down:
|
||||
docker compose -f ../beaver-agent/docker-compose.yml --project-directory ../beaver-agent --profile db down
|
||||
@@ -1,2 +1,74 @@
|
||||
# Beaver Gateway
|
||||
[](https://sladge.net)
|
||||
# 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
 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.
|
||||
+38
-198
@@ -1,224 +1,64 @@
|
||||
# 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.
|
||||
"""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
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from beaver_gateway.agents.base import ExposedMcp
|
||||
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.agents.claude import ClaudeAgent, Prompts
|
||||
from beaver_gateway.config import Gateway
|
||||
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")
|
||||
|
||||
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"
|
||||
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 current_time() -> str:
|
||||
"""Return the current local time as an ISO-8601 string.
|
||||
return datetime.now(UTC).astimezone().isoformat()
|
||||
|
||||
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
|
||||
|
||||
return datetime.now().astimezone().isoformat()
|
||||
async def heartbeat(run: JobRun) -> None:
|
||||
master = await run.master()
|
||||
log.info("heartbeat: master=%s", master.external_id if master else None)
|
||||
|
||||
|
||||
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="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(),
|
||||
name="assistant",
|
||||
model="claude-sonnet-5",
|
||||
cwd=VAULT,
|
||||
prompts=Prompts(master=(PROMPT,), branch=(PROMPT,), deep=(PROMPT,)),
|
||||
gateway_tools=("spawn", "say", "schedule"),
|
||||
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=[
|
||||
# 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,
|
||||
),
|
||||
AnthropicMessagesFrontend(),
|
||||
McpServerFrontend(),
|
||||
AdminFrontend(),
|
||||
ApiFrontend(master_agent="assistant", branch_agent="assistant"),
|
||||
MarkdownFrontend(vault_path=VAULT / "chats", default_agent="assistant"),
|
||||
],
|
||||
jobs=[Job("heartbeat", heartbeat, cron="0 * * * *")],
|
||||
tz="UTC",
|
||||
)
|
||||
+27
-25
@@ -1,34 +1,36 @@
|
||||
# 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:
|
||||
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
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
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.
|
||||
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}
|
||||
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.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
+10
-7
@@ -8,16 +8,20 @@ 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",
|
||||
"jinja2>=3.1.6",
|
||||
"pgqueuer>=1.3.2",
|
||||
"psutil>=7.2.2",
|
||||
"psycopg[binary]>=3.3.4",
|
||||
"pydantic>=2.13.4",
|
||||
"pydantic-settings>=2.14.1",
|
||||
@@ -25,16 +29,15 @@ 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]
|
||||
@@ -47,10 +50,6 @@ 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"
|
||||
@@ -67,3 +66,7 @@ dev = [
|
||||
"ruff>=0.15.13",
|
||||
"ty>=0.0.37",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -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,10 +15,15 @@ from pydantic import BaseModel, ConfigDict
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExposedMcp:
|
||||
"""Reference to an ``McpServer`` (by name) exposed to a single agent."""
|
||||
"""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_*",)``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
tools: tuple[str, ...] | None = None
|
||||
deny: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class BaseAgent(BaseModel):
|
||||
|
||||
@@ -1,258 +1,130 @@
|
||||
"""Claude Code agent definition.
|
||||
"""Claude agent definition, backed by the Claude Agent SDK.
|
||||
|
||||
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``).
|
||||
``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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping # noqa: TC003 — pydantic runtime
|
||||
from pathlib import Path # noqa: TC003 — pydantic runtime
|
||||
from typing import Literal
|
||||
from collections.abc import Mapping # noqa: TC003
|
||||
from pathlib import Path # noqa: TC003
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
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
|
||||
|
||||
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."""
|
||||
__all__ = ["ClaudeAgent", "ClaudeOptions", "Prompts", "SkillSets"]
|
||||
|
||||
|
||||
class ClaudeCodeOptions(BaseModel):
|
||||
"""Per-agent passthrough for ``claude_code_api.BackendOptions``.
|
||||
|
||||
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:
|
||||
|
||||
* ``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.
|
||||
|
||||
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`.
|
||||
"""
|
||||
|
||||
class ClaudeOptions(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
||||
|
||||
transport: Transport = "pty"
|
||||
"""Which claude process backs this agent.
|
||||
|
||||
``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.
|
||||
|
||||
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.
|
||||
|
||||
``None`` (the default) decides per agent: on when it exposes any
|
||||
MCP, off when it doesn't. Set it explicitly to override.
|
||||
|
||||
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.
|
||||
|
||||
``stream_json`` only — the PTY transport's readiness wait covers
|
||||
this incidentally."""
|
||||
|
||||
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."""
|
||||
effort: str | None = None
|
||||
include_partial_messages: bool = True
|
||||
"""Token-level deltas on the wire; off means one delta per finished block."""
|
||||
|
||||
permission_mode: str = "bypassPermissions"
|
||||
"""``claude --permission-mode`` value. Only meaningful when
|
||||
``dangerously_skip_permissions=False``; otherwise the CLI bypasses
|
||||
the prompt path entirely."""
|
||||
tools: tuple[str, ...] | None = None
|
||||
"""Base set of built-in tools; ``None`` keeps the CLI default set."""
|
||||
|
||||
dangerously_skip_permissions: bool = True
|
||||
"""Pass ``--dangerously-skip-permissions`` to claude. ``True`` by
|
||||
default — see the class docstring for why."""
|
||||
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)."""
|
||||
|
||||
effort: str | None = None
|
||||
"""``claude --effort`` value (typically ``low`` / ``medium`` /
|
||||
``high``). Tunes reasoning effort budget for newer models."""
|
||||
env_keep: tuple[str, ...] = ()
|
||||
"""Extra inherited variable names to let through the env whitelist."""
|
||||
|
||||
add_dir: tuple[str, ...] = ()
|
||||
"""Extra directories claude is allowed to read/edit beyond
|
||||
``cwd``. Maps to repeated ``--add-dir`` flags."""
|
||||
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."""
|
||||
|
||||
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."""
|
||||
class Prompts(BaseModel):
|
||||
"""Prompt assembly per conversation kind: the granules, in order.
|
||||
|
||||
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."""
|
||||
A kind left ``None`` is not served; ``ClaudeAgent.kinds`` follows from
|
||||
the kinds set here.
|
||||
"""
|
||||
|
||||
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."""
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
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."""
|
||||
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
|
||||
|
||||
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."""
|
||||
def for_kind(self, kind: Kind) -> tuple[PromptSource, ...] | None:
|
||||
return getattr(self, kind)
|
||||
|
||||
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."""
|
||||
@property
|
||||
def kinds(self) -> tuple[Kind, ...]:
|
||||
return tuple(k for k in KINDS if self.for_kind(k) is not None)
|
||||
|
||||
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."""
|
||||
class SkillSets(BaseModel):
|
||||
"""Skill-set directories per conversation kind; ``None`` means none."""
|
||||
|
||||
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."""
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
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``."""
|
||||
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
|
||||
|
||||
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."""
|
||||
def for_kind(self, kind: Kind) -> tuple[Path, ...]:
|
||||
return getattr(self, kind) or ()
|
||||
|
||||
|
||||
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
|
||||
available_native_tools: tuple[str, ...] = ()
|
||||
options: ClaudeCodeOptions = Field(default_factory=ClaudeCodeOptions)
|
||||
"""Per-agent passthrough for the underlying claude-code-api
|
||||
``BackendOptions``. See :class:`ClaudeCodeOptions`."""
|
||||
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
|
||||
@@ -0,0 +1,167 @@
|
||||
"""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] + "…"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""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"
|
||||
@@ -1,31 +1,12 @@
|
||||
"""Raycast agent definition.
|
||||
|
||||
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
|
||||
Field set is the union of ``raycast_api.ChatAPI.stream`` parameters useful
|
||||
as per-agent defaults; per-request values override them where both exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from raycast_api import ( # noqa: F401 — UserPreferences re-exported for user configs
|
||||
from raycast_api import ( # noqa: F401
|
||||
RemoteTool,
|
||||
Source,
|
||||
UserPreferences,
|
||||
@@ -36,44 +17,21 @@ from beaver_gateway.agents.base import BaseAgent
|
||||
|
||||
|
||||
class RaycastAgent(BaseAgent):
|
||||
"""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``.
|
||||
"""
|
||||
"""Agent backed by ``raycast-api``."""
|
||||
|
||||
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."""
|
||||
@@ -0,0 +1,447 @@
|
||||
"""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)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Backend adapters.
|
||||
|
||||
Each backend wraps a provider-specific SDK (``raycast-api``, ``claude-code-api``)
|
||||
and yields the unified :class:`~beaver_gateway.core.events.MessageStreamEvent`
|
||||
Each backend wraps a provider SDK (``raycast-api``, ``claude-agent-sdk``)
|
||||
and yields the unified :class:`~beaver_gateway.events.stream.MessageStreamEvent`
|
||||
family. The Anthropic-style frontend serialises events straight to SSE.
|
||||
"""
|
||||
@@ -1,12 +1,8 @@
|
||||
"""Backend protocol.
|
||||
|
||||
A backend turns an Anthropic-style turn (``messages`` + agent definition)
|
||||
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.
|
||||
into a stream of :class:`~beaver_gateway.events.stream.MessageStreamEvent`
|
||||
records; provider quirks are translated here, not in the frontend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,7 +15,7 @@ if TYPE_CHECKING:
|
||||
from anthropic.types import MessageParam
|
||||
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
from beaver_gateway.events.stream import MessageStreamEvent
|
||||
|
||||
|
||||
class Backend(Protocol):
|
||||
@@ -33,5 +29,13 @@ class Backend(Protocol):
|
||||
system: str | None = None,
|
||||
**options: Any,
|
||||
) -> AsyncIterator[MessageStreamEvent]:
|
||||
"""Yield Anthropic stream events for one turn against ``agent``."""
|
||||
"""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).
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,44 @@
|
||||
"""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."""
|
||||
@@ -1,502 +0,0 @@
|
||||
"""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
@@ -1,36 +1,13 @@
|
||||
"""Raycast backend adapter.
|
||||
|
||||
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.
|
||||
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``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
@@ -41,7 +18,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.core.events import (
|
||||
from beaver_gateway.events.stream import (
|
||||
StopReason,
|
||||
build_content_block_stop,
|
||||
build_input_json_delta,
|
||||
@@ -64,7 +41,7 @@ if TYPE_CHECKING:
|
||||
from raycast_api import ChatStreamChunk, Client
|
||||
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
from beaver_gateway.events.stream import MessageStreamEvent
|
||||
else:
|
||||
from collections.abc import Mapping
|
||||
|
||||
@@ -75,11 +52,9 @@ __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] = {
|
||||
@@ -297,6 +272,8 @@ 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(
|
||||
@@ -389,7 +366,7 @@ class _BlockState:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.index: int = -1
|
||||
self.kind: str | None = None # "text" | "thinking" | "tool_use" | None
|
||||
self.kind: str | None = None
|
||||
self.tool_id_to_block: dict[str, int] = {}
|
||||
self.tool_idx_to_id: dict[int, str] = {}
|
||||
|
||||
@@ -425,11 +402,14 @@ 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
|
||||
@@ -445,31 +425,26 @@ 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,
|
||||
@@ -496,12 +471,16 @@ 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
|
||||
@@ -514,9 +493,6 @@ 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,
|
||||
@@ -537,27 +513,16 @@ 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(
|
||||
@@ -573,10 +538,6 @@ 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 "{}"
|
||||
@@ -585,16 +546,12 @@ 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; "
|
||||
@@ -603,7 +560,6 @@ 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
|
||||
@@ -713,6 +669,12 @@ 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
|
||||
@@ -725,9 +687,6 @@ 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
|
||||
@@ -740,7 +699,6 @@ 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
|
||||
@@ -753,22 +711,11 @@ 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))
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""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
|
||||
@@ -0,0 +1,554 @@
|
||||
"""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 ""
|
||||
+14
-305
@@ -1,22 +1,4 @@
|
||||
"""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.
|
||||
"""
|
||||
"""Process entrypoint: logging, signals, ``.env``, the config, then ``app.run``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,309 +6,36 @@ 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 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 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.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_sigterm_handler()
|
||||
asyncio.run(_async_main(), loop_factory=uvloop.new_event_loop)
|
||||
install_redaction()
|
||||
_sigterm_as_interrupt()
|
||||
asyncio.run(_run(), loop_factory=uvloop.new_event_loop)
|
||||
|
||||
|
||||
def _install_sigterm_handler() -> None:
|
||||
"""Turn SIGTERM into a normal interpreter exit.
|
||||
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)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""``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)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Conversations: rows, queue, seeds, turns, questions, closing, rotation, envelope."""
|
||||
@@ -0,0 +1,322 @@
|
||||
"""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
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""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)
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"""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
|
||||
@@ -0,0 +1,154 @@
|
||||
"""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("_", " ")
|
||||
@@ -0,0 +1,219 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,18 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""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 []
|
||||
@@ -0,0 +1,85 @@
|
||||
"""``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)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,563 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""``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)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""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
|
||||
@@ -0,0 +1,191 @@
|
||||
"""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
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""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}
|
||||
@@ -0,0 +1,555 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,93 +0,0 @@
|
||||
"""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,83 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,133 +0,0 @@
|
||||
"""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 ""
|
||||
@@ -0,0 +1 @@
|
||||
"""The in-process event bus and the stream event protocol backends emit."""
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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,11 +1,7 @@
|
||||
"""Unified streaming-event protocol shared by every backend adapter.
|
||||
"""Streaming-event protocol shared by every backend adapter.
|
||||
|
||||
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.
|
||||
Aliases over :mod:`anthropic.types` plus ``build_*`` helpers that keep
|
||||
verbose constructors out of adapters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
+14
-18
@@ -1,11 +1,7 @@
|
||||
"""Collapse an Anthropic event stream into one ``Message``.
|
||||
|
||||
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).
|
||||
Mirrors the Anthropic SDK's own accumulator: folds content-block deltas
|
||||
into finalized blocks, indexed by their ``content_block`` index.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,7 +29,7 @@ from anthropic.types import (
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from beaver_gateway.core.events import MessageStreamEvent, StopReason
|
||||
from beaver_gateway.events.stream import MessageStreamEvent, StopReason
|
||||
|
||||
|
||||
__all__ = ["StreamAccumulator", "accumulate"]
|
||||
@@ -42,9 +38,7 @@ __all__ = ["StreamAccumulator", "accumulate"]
|
||||
class StreamAccumulator:
|
||||
"""Folds a stream of events into one ``Message``, incrementally.
|
||||
|
||||
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.
|
||||
Call :meth:`feed` for each event, then :meth:`finalize` once.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
@@ -67,10 +61,11 @@ class StreamAccumulator:
|
||||
self._stop_sequence: str | None = None
|
||||
|
||||
def feed(self, ev: MessageStreamEvent) -> None:
|
||||
# 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.
|
||||
"""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.
|
||||
"""
|
||||
if isinstance(ev, RawMessageStartEvent):
|
||||
self._message_id = ev.message.id
|
||||
self._role = ev.message.role
|
||||
@@ -109,6 +104,11 @@ 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,10 +120,6 @@ 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,10 +1,4 @@
|
||||
"""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.
|
||||
"""
|
||||
"""Admin console: the ``ui/`` SPA served from the admin port plus its login."""
|
||||
|
||||
from beaver_gateway.frontends.admin.frontend import AdminFrontend
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,206 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,12 +0,0 @@
|
||||
{# 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>
|
||||
@@ -1 +0,0 @@
|
||||
<div class="banner error">{{ message }}</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
@@ -1,37 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,355 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,490 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,83 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,146 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,46 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,64 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,42 +1,38 @@
|
||||
"""``POST /v1/messages`` frontend.
|
||||
|
||||
Exposes the gateway as an Anthropic-compatible Messages endpoint, so any
|
||||
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.
|
||||
client that already speaks Anthropic can reach a configured agent by
|
||||
passing its name as ``model``.
|
||||
"""
|
||||
|
||||
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.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.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.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
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
|
||||
from anthropic.types import Message, MessageParam
|
||||
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
from beaver_gateway.conversations.service import Conversations
|
||||
from beaver_gateway.events.stream import MessageStreamEvent
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.frontends.anthropic")
|
||||
@@ -44,28 +40,18 @@ _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``."""
|
||||
|
||||
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
|
||||
name = FRONTEND
|
||||
kinds = ("deep",)
|
||||
path = "/anthropic"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._app: FastAPI | None = None
|
||||
|
||||
@@ -73,21 +59,8 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
self._runtime = runtime
|
||||
self._app = self._build_app(runtime)
|
||||
|
||||
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 app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
def _build_app(self, runtime: GatewayRuntime) -> FastAPI:
|
||||
app = FastAPI(title="beaver-gateway / Anthropic Messages")
|
||||
@@ -134,9 +107,6 @@ 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}",
|
||||
@@ -144,7 +114,12 @@ 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",
|
||||
@@ -153,10 +128,6 @@ 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}",
|
||||
@@ -166,74 +137,137 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
msgs=len(messages),
|
||||
)
|
||||
|
||||
# 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"]
|
||||
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,
|
||||
)
|
||||
|
||||
events = backend.complete(
|
||||
agent=agent,
|
||||
messages=messages,
|
||||
system=system if isinstance(system, str) else None,
|
||||
**options,
|
||||
)
|
||||
async def after(message: Message) -> None:
|
||||
await _finish(
|
||||
runtime,
|
||||
conv,
|
||||
messages=messages,
|
||||
message=message,
|
||||
capture=capture,
|
||||
turn_id=turn_id,
|
||||
history=history,
|
||||
)
|
||||
else:
|
||||
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
|
||||
)
|
||||
|
||||
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,
|
||||
async def after(message: Message) -> None:
|
||||
await _broadcast_turn(
|
||||
runtime,
|
||||
agent_name=agent.name,
|
||||
input_messages=messages,
|
||||
output_message=message,
|
||||
system=system_str,
|
||||
model=model,
|
||||
),
|
||||
)
|
||||
|
||||
if stream_flag:
|
||||
return StreamingResponse(
|
||||
_sse(events, model=model, after=after),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
message = await accumulate(events, model=model)
|
||||
await _broadcast_turn(
|
||||
runtime,
|
||||
agent_name=agent.name,
|
||||
input_messages=messages,
|
||||
output_message=message,
|
||||
system=system_str,
|
||||
)
|
||||
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 _sse_and_broadcast(
|
||||
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(
|
||||
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; broadcast a :class:`TurnRecord` after.
|
||||
r"""Serialize an event stream to SSE, then hand the assembled ``Message`` on.
|
||||
|
||||
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.
|
||||
Wire format is ``event: <type>\ndata: <json>\n\n``; mid-stream errors
|
||||
become a synthetic ``error`` event instead of a hung connection.
|
||||
"""
|
||||
acc = StreamAccumulator()
|
||||
try:
|
||||
@@ -248,14 +282,10 @@ async def _sse_and_broadcast(
|
||||
)
|
||||
yield f"event: error\ndata: {err}\n\n".encode()
|
||||
return
|
||||
message = acc.finalize(model=model)
|
||||
await _broadcast_turn(
|
||||
runtime,
|
||||
agent_name=agent_name,
|
||||
input_messages=input_messages,
|
||||
output_message=message,
|
||||
system=system,
|
||||
)
|
||||
try:
|
||||
await after(acc.finalize(model=model))
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("post-turn hook failed")
|
||||
|
||||
|
||||
async def _broadcast_turn(
|
||||
@@ -266,11 +296,6 @@ 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(
|
||||
@@ -278,7 +303,7 @@ async def _broadcast_turn(
|
||||
input_messages=list(input_messages),
|
||||
output_message=output_message,
|
||||
system=system,
|
||||
source="anthropic",
|
||||
source=FRONTEND,
|
||||
)
|
||||
for handler in runtime.turn_log_handlers:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""``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
@@ -1,27 +1,31 @@
|
||||
"""Frontend ABC + the runtime context handed to ``configure``.
|
||||
"""Frontend ABC and the runtime context handed to ``configure``.
|
||||
|
||||
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``.
|
||||
A frontend routes inbound traffic into the gateway (an HTTP mount, a
|
||||
poller, or both); ``GatewayRuntime`` carries the built state each needs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from beaver_gateway.frontends.files import SendFileError
|
||||
from beaver_gateway.security.auth import BUILTIN_SCOPES
|
||||
|
||||
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.core.auth import TokenStore
|
||||
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
|
||||
from beaver_gateway.core.turn_record import TurnRecord
|
||||
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.storage import Database
|
||||
from beaver_gateway.storage.models import Conversation, ConversationBinding
|
||||
|
||||
TurnLogHandler = Callable[[TurnRecord], Awaitable[None]]
|
||||
|
||||
@@ -30,20 +34,8 @@ if TYPE_CHECKING:
|
||||
class GatewayRuntime:
|
||||
"""Post-build state of the gateway, shared with every frontend.
|
||||
|
||||
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.
|
||||
Backends are keyed by agent name, not type: one backend instance may
|
||||
serve several agents, so the indirection lives at lookup time.
|
||||
"""
|
||||
|
||||
agents: AgentRegistry
|
||||
@@ -52,38 +44,85 @@ 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)
|
||||
# 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.
|
||||
"""Every frontend in declaration order, for advertising their URLs."""
|
||||
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):
|
||||
"""Listens on a port, dispatches into the gateway."""
|
||||
"""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."""
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, runtime: GatewayRuntime) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def serve(self) -> 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 ""
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Shared bearer-token verification for HTTP frontends.
|
||||
|
||||
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.
|
||||
Reused by every bearer-protected frontend instead of duplicating the
|
||||
header-parsing logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,22 +22,21 @@ __all__ = ["require_token"]
|
||||
async def require_token(
|
||||
request: Request, runtime: GatewayRuntime, *, scope: str
|
||||
) -> str:
|
||||
"""Verify the request's bearer + scope, return the token's audit name.
|
||||
"""Verify the request's bearer token and scope; return its audit name.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
api_key = request.headers.get("x-api-key")
|
||||
identity = (
|
||||
await runtime.token_store.verify(api_key)
|
||||
if api_key
|
||||
else await runtime.token_store.verify_bearer(
|
||||
request.headers.get("authorization")
|
||||
)
|
||||
)
|
||||
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
|
||||
if identity is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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,13 +1,7 @@
|
||||
"""Markdown frontend — turn-by-turn chat archive backed by ``.md`` files.
|
||||
"""Markdown frontend — chat archive backed by ``.md`` files in an Obsidian vault.
|
||||
|
||||
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.
|
||||
An Obsidian plugin POSTs ``{filename, content?}`` to ``/chat``; the
|
||||
frontend runs the agent on a trailing user turn and appends the reply.
|
||||
"""
|
||||
|
||||
from beaver_gateway.frontends.markdown.frontend import MarkdownFrontend
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
"""Cross-frontend chat logger.
|
||||
|
||||
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}/``.
|
||||
Mirrors turns completed by other frontends into the vault as ``.md``
|
||||
files, matching a conversation's continuation by content-hash fingerprint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -32,31 +22,29 @@ if TYPE_CHECKING:
|
||||
|
||||
from anthropic.types import MessageParam
|
||||
|
||||
from beaver_gateway.core.turn_record import TurnRecord
|
||||
from beaver_gateway.frontends.turn_record import TurnRecord
|
||||
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.frontends.markdown.crossfront")
|
||||
|
||||
|
||||
# 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]"
|
||||
__all__ = [
|
||||
"ChatPathFn",
|
||||
"CrossFrontendLogger",
|
||||
"fingerprint_messages",
|
||||
"strip_trailing_user_scaffold",
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["CrossFrontendLogger", "LogPathFn", "fingerprint_messages"]
|
||||
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``."""
|
||||
|
||||
|
||||
def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
|
||||
"""Stable, short hex hash of a conversation prefix.
|
||||
|
||||
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.
|
||||
Hashes ``(role, text-only content)`` pairs so differently-shaped message
|
||||
histories that carry the same text still fingerprint identically.
|
||||
"""
|
||||
h = hashlib.sha1(usedforsecurity=False)
|
||||
for msg in messages:
|
||||
@@ -83,12 +71,8 @@ def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
|
||||
class CrossFrontendLogger:
|
||||
"""Maintains the fingerprint→file map and writes turns to disk.
|
||||
|
||||
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.
|
||||
The map is in-process, rebuilt by ``warm_index`` on startup; all disk
|
||||
writes funnel through one lock to sidestep races.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -96,27 +80,20 @@ class CrossFrontendLogger:
|
||||
*,
|
||||
vault_path: Path,
|
||||
logged_subdir: str,
|
||||
log_path: Callable[[TurnRecord, Path], Path] | None = None,
|
||||
chat_path: Callable[[str, str, 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._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
|
||||
self._chat_path_fn = chat_path
|
||||
self._scan_root = vault_path if chat_path is not None else self._root
|
||||
|
||||
def warm_index(self) -> None:
|
||||
"""Scan logged files synchronously, populating the fingerprint map.
|
||||
|
||||
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.
|
||||
Called before any cross-frontend turn arrives; a custom ``chat_path``
|
||||
forces a full-vault walk instead of scanning just ``logged_subdir``.
|
||||
"""
|
||||
if not self._scan_root.exists():
|
||||
return
|
||||
@@ -138,29 +115,19 @@ class CrossFrontendLogger:
|
||||
async def handle(self, record: TurnRecord) -> None:
|
||||
"""Append or create a logged file for ``record``.
|
||||
|
||||
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.
|
||||
Skips ``source == "markdown"`` records (already on disk); matches
|
||||
the target file by fingerprinting ``input_messages`` sans the new turn.
|
||||
"""
|
||||
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),
|
||||
@@ -171,10 +138,7 @@ class CrossFrontendLogger:
|
||||
if target.exists():
|
||||
existing = target.read_text(encoding="utf-8")
|
||||
parsed = frontmatter.loads(existing)
|
||||
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.
|
||||
body = strip_trailing_user_scaffold(parsed.content)
|
||||
new_user = record.input_messages[-1]
|
||||
new_block = renderer.render_user_param(new_user)
|
||||
new_block = renderer.append_to_body(
|
||||
@@ -183,7 +147,6 @@ 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
|
||||
)
|
||||
@@ -195,24 +158,20 @@ 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.
|
||||
|
||||
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.
|
||||
Delegates to ``chat_path`` if set; otherwise
|
||||
``{logged_subdir}/{agent}/{date}_{hex8}.md``.
|
||||
"""
|
||||
if self._log_path_fn is not None:
|
||||
result = self._log_path_fn(record, self._vault)
|
||||
if self._chat_path_fn is not None:
|
||||
result = self._chat_path_fn(
|
||||
record.first_user_text, record.agent_name, self._vault
|
||||
)
|
||||
if not result.is_absolute():
|
||||
result = self._vault / result
|
||||
if result.suffix != ".md":
|
||||
@@ -220,8 +179,6 @@ 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)
|
||||
@@ -234,9 +191,6 @@ 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")
|
||||
|
||||
|
||||
@@ -270,21 +224,16 @@ 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.
|
||||
|
||||
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.
|
||||
Avoids leaving two ``### User:`` headers in a row when appending a
|
||||
turn that wasn't typed into the file by hand.
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""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"
|
||||
@@ -1,71 +1,55 @@
|
||||
"""``MarkdownFrontend`` — chat-via-markdown-files frontend.
|
||||
|
||||
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.
|
||||
``POST /chat`` (and SSE ``/chat/stream``) parses the vault file, runs
|
||||
the agent on the trailing user turn, and appends the reply.
|
||||
"""
|
||||
|
||||
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
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
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.claude_code import ClaudeCodeBackendAdapter, TurnCapture
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.core.conversation_store import (
|
||||
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 (
|
||||
diff_and_fork,
|
||||
load_conversation,
|
||||
load_messages,
|
||||
mint_conversation,
|
||||
rewrite_messages,
|
||||
)
|
||||
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.markdown.mirror import FRONTEND, ChatMirror
|
||||
from beaver_gateway.frontends.sse import (
|
||||
KEEPALIVE,
|
||||
SSE_HEADERS,
|
||||
events_with_heartbeat,
|
||||
sse_pack,
|
||||
)
|
||||
from beaver_gateway.frontends.turn_record import TurnRecord
|
||||
from beaver_gateway.security import audit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
||||
from anthropic.types import MessageParam
|
||||
|
||||
from beaver_gateway.conversations.kinds import Kind
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.storage.models import Conversation, ConversationBinding
|
||||
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.frontends.markdown")
|
||||
@@ -74,102 +58,92 @@ _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."""
|
||||
"""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"
|
||||
|
||||
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",
|
||||
log_path: Callable[[TurnRecord, Path], Path] | None = None,
|
||||
public_base_url: str | None = None,
|
||||
chat_path: Callable[[str, str, Path], Path] | 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.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.chat_path = chat_path
|
||||
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,
|
||||
log_path=self.log_path,
|
||||
chat_path=self.chat_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)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
@property
|
||||
def mirror(self) -> ChatMirror:
|
||||
if self._mirror is None:
|
||||
msg = "configure() must be called first"
|
||||
raise RuntimeError(msg)
|
||||
config = uvicorn.Config(
|
||||
self._app, host=self.host, port=self.port, log_level="info"
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
return self._mirror
|
||||
|
||||
# ---- app builder ---------------------------------------------------
|
||||
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()
|
||||
|
||||
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=["*"],
|
||||
@@ -211,8 +185,6 @@ 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,
|
||||
@@ -233,12 +205,10 @@ class MarkdownFrontend(Frontend):
|
||||
|
||||
@app.post("/chat/stream")
|
||||
async def chat_stream(request: Request) -> Any:
|
||||
# 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.
|
||||
"""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.
|
||||
"""
|
||||
token_name = await require_token(request, runtime, scope="messages")
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -261,8 +231,6 @@ 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,
|
||||
@@ -285,23 +253,11 @@ class MarkdownFrontend(Frontend):
|
||||
self._busy.discard(file_path)
|
||||
|
||||
return StreamingResponse(
|
||||
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",
|
||||
},
|
||||
gen(), media_type="text/event-stream", headers=SSE_HEADERS
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
# ---- dispatch ------------------------------------------------------
|
||||
|
||||
async def _handle_chat(
|
||||
self,
|
||||
*,
|
||||
@@ -312,11 +268,12 @@ 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"
|
||||
@@ -335,8 +292,6 @@ 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",
|
||||
@@ -379,24 +334,21 @@ 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
|
||||
runtime=runtime,
|
||||
metadata=parsed.metadata,
|
||||
agent_name=agent.name,
|
||||
file_path=file_path,
|
||||
first_user_text=parsed.turns[0].text,
|
||||
)
|
||||
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
|
||||
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
|
||||
capture = TurnCapture()
|
||||
events = runtime.conversations.turn(
|
||||
conv,
|
||||
messages=outcome.messages,
|
||||
origin="user",
|
||||
capture=capture,
|
||||
use_session=outcome.reuse_session,
|
||||
)
|
||||
try:
|
||||
message = await self._stream_to_file(
|
||||
@@ -405,6 +357,7 @@ class MarkdownFrontend(Frontend):
|
||||
parsed=parsed,
|
||||
model=agent.model or agent.name,
|
||||
filename=filename,
|
||||
write_disk=write_disk,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -419,20 +372,18 @@ 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=conv.id,
|
||||
conversation_id=cast("int", 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),
|
||||
@@ -453,9 +404,7 @@ class MarkdownFrontend(Frontend):
|
||||
"new_content": new_content,
|
||||
}
|
||||
|
||||
# ---- streaming dispatch (SSE) --------------------------------------
|
||||
|
||||
async def _handle_chat_streaming( # noqa: PLR0915 — mirrors _handle_chat, splitting only doubles read cost
|
||||
async def _handle_chat_streaming( # noqa: PLR0915
|
||||
self,
|
||||
*,
|
||||
runtime: GatewayRuntime,
|
||||
@@ -467,30 +416,16 @@ class MarkdownFrontend(Frontend):
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""SSE counterpart of :meth:`_handle_chat`.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
# 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.
|
||||
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:
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"error",
|
||||
{
|
||||
"status_code": status.HTTP_400_BAD_REQUEST,
|
||||
@@ -506,7 +441,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,
|
||||
@@ -519,7 +454,7 @@ class MarkdownFrontend(Frontend):
|
||||
return
|
||||
|
||||
if not parsed.messages:
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"done",
|
||||
{
|
||||
"status": "nothing_to_do",
|
||||
@@ -530,7 +465,7 @@ class MarkdownFrontend(Frontend):
|
||||
return
|
||||
|
||||
if parser.last_role(parsed.messages) == "assistant":
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"done",
|
||||
{
|
||||
"status": "nothing_to_do",
|
||||
@@ -542,7 +477,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,
|
||||
@@ -552,7 +487,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,
|
||||
@@ -577,9 +512,19 @@ class MarkdownFrontend(Frontend):
|
||||
msgs=len(parsed.messages),
|
||||
)
|
||||
|
||||
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
|
||||
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
|
||||
)
|
||||
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,
|
||||
)
|
||||
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",
|
||||
@@ -598,21 +543,19 @@ class MarkdownFrontend(Frontend):
|
||||
len(outcome.messages),
|
||||
len(outcome.persist_messages),
|
||||
)
|
||||
capture: TurnCapture | None = (
|
||||
TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
if capture is not None:
|
||||
kwargs["capture"] = capture
|
||||
capture = TurnCapture()
|
||||
_log.info(
|
||||
"chat/stream: file=%s calling backend.complete agent=%s capture=%s",
|
||||
"chat/stream: file=%s calling backend.complete agent=%s session=%s",
|
||||
filename,
|
||||
agent.name,
|
||||
capture is not None,
|
||||
conv.session_id,
|
||||
)
|
||||
events = backend.complete(
|
||||
agent=agent, messages=outcome.messages, system=None, **kwargs
|
||||
events = runtime.conversations.turn(
|
||||
conv,
|
||||
messages=outcome.messages,
|
||||
origin="user",
|
||||
capture=capture,
|
||||
use_session=outcome.reuse_session,
|
||||
)
|
||||
|
||||
acc = StreamAccumulator()
|
||||
@@ -626,16 +569,12 @@ 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:
|
||||
# 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"
|
||||
yield KEEPALIVE
|
||||
continue
|
||||
acc.feed(ev)
|
||||
now = time.monotonic()
|
||||
@@ -644,18 +583,12 @@ 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 — wire any backend failure as an SSE error frame
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_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:
|
||||
@@ -663,10 +596,11 @@ class MarkdownFrontend(Frontend):
|
||||
new_body, renderer.render_assistant_message(partial)
|
||||
)
|
||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
yield _sse_pack(
|
||||
if write_disk:
|
||||
await write_atomic(
|
||||
file_path, reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
yield sse_pack(
|
||||
"error",
|
||||
{
|
||||
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -683,11 +617,12 @@ 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=conv.id,
|
||||
conversation_id=cast("int", conv.id),
|
||||
persist_messages=outcome.persist_messages,
|
||||
new_user_text=parsed.turns[-1].text,
|
||||
capture=capture,
|
||||
@@ -707,7 +642,7 @@ class MarkdownFrontend(Frontend):
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("turn_log_handler raised; continuing")
|
||||
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"done",
|
||||
{
|
||||
"status": "ok",
|
||||
@@ -717,8 +652,6 @@ class MarkdownFrontend(Frontend):
|
||||
},
|
||||
)
|
||||
|
||||
# ---- helpers -------------------------------------------------------
|
||||
|
||||
async def _stream_to_file(
|
||||
self,
|
||||
*,
|
||||
@@ -727,31 +660,25 @@ 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 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.
|
||||
Flushes on each block boundary and on a debounce timer; on backend
|
||||
failure it still flushes a partial + error callout, then re-raises.
|
||||
"""
|
||||
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:
|
||||
@@ -774,9 +701,10 @@ class MarkdownFrontend(Frontend):
|
||||
new_body, renderer.render_assistant_message(partial)
|
||||
)
|
||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
if write_disk:
|
||||
await write_atomic(
|
||||
file_path, reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
raise
|
||||
return acc.finalize(model=model)
|
||||
|
||||
@@ -788,75 +716,67 @@ class MarkdownFrontend(Frontend):
|
||||
message: Any,
|
||||
agent_name: str,
|
||||
conv_external_id: str,
|
||||
write_disk: bool = True,
|
||||
) -> str:
|
||||
"""Render the assistant turn, append to the file, refresh frontmatter."""
|
||||
"""Render the assistant turn, refresh frontmatter, write if we own the file."""
|
||||
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
|
||||
updated_metadata["fingerprint"] = fingerprint_messages(updated_messages)
|
||||
new_content = _reattach_frontmatter(updated_metadata, new_body)
|
||||
await _write_atomic(file_path, new_content)
|
||||
new_content = reattach_frontmatter(updated_metadata, new_body)
|
||||
if write_disk:
|
||||
await write_atomic(file_path, new_content)
|
||||
return new_content
|
||||
|
||||
async def _resolve_conversation(
|
||||
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.
|
||||
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.
|
||||
|
||||
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``.
|
||||
Precedence: frontmatter ``conversation_id`` > existing path binding >
|
||||
an adopted unbound conversation with the same first prompt > a new one.
|
||||
"""
|
||||
conversations = runtime.conversations
|
||||
rel = file_path.relative_to(self.vault_path).as_posix()
|
||||
raw = metadata.get("conversation_id")
|
||||
lookup_id = raw if isinstance(raw, str) and raw else None
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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:
|
||||
conv = None
|
||||
if lookup_id is not None:
|
||||
conv = await load_conversation(
|
||||
session, frontend="markdown", external_id=lookup_id
|
||||
)
|
||||
if conv is None:
|
||||
_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,
|
||||
)
|
||||
if conv.id is None:
|
||||
msg = "conversation row missing primary key after commit"
|
||||
raise RuntimeError(msg)
|
||||
stored = await load_messages(session, conversation_id=conv.id)
|
||||
return conv, conv.external_id, stored
|
||||
|
||||
@@ -867,21 +787,16 @@ class MarkdownFrontend(Frontend):
|
||||
conversation_id: int,
|
||||
persist_messages: list[dict[str, Any]],
|
||||
new_user_text: str,
|
||||
capture: TurnCapture | None,
|
||||
capture: TurnCapture,
|
||||
message: Any,
|
||||
) -> None:
|
||||
"""Stamp the DB with the post-turn canonical Anthropic-shape history.
|
||||
|
||||
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``).
|
||||
Combines prior state + new user prompt + the backend's synthesized
|
||||
cycle, falling back to text-only if ``capture`` is empty.
|
||||
"""
|
||||
new_user_msg = {"role": "user", "content": new_user_text}
|
||||
synthesized = (
|
||||
capture.synthesized_messages
|
||||
if capture is not None
|
||||
else _fallback_synthesized(message)
|
||||
)
|
||||
synthesized = capture.synthesized_messages or _fallback_synthesized(message)
|
||||
canonical = [*persist_messages, new_user_msg, *synthesized]
|
||||
_log.info(
|
||||
"_persist_canonical_history: conv_id=%d writing %d msgs "
|
||||
@@ -900,12 +815,11 @@ class MarkdownFrontend(Frontend):
|
||||
)
|
||||
|
||||
def _resolve_path(self, filename: str) -> Path:
|
||||
"""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.
|
||||
"""Resolve ``filename`` under the vault; reject escapes.
|
||||
|
||||
Leading slashes are stripped first — ``Path.__truediv__`` would
|
||||
otherwise discard ``vault_path`` for an absolute ``filename``.
|
||||
"""
|
||||
rel = filename.lstrip("/")
|
||||
if not rel.endswith(".md"):
|
||||
rel = rel + ".md"
|
||||
@@ -919,124 +833,11 @@ 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``.
|
||||
|
||||
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.
|
||||
Fallback for backends that don't populate :class:`TurnCapture` (anthropic
|
||||
HTTP, raycast, …); the reply lands as one canonical-block message.
|
||||
"""
|
||||
content: list[dict[str, Any]] = []
|
||||
for block in getattr(message, "content", ()):
|
||||
@@ -1065,21 +866,6 @@ 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__
|
||||
|
||||
+73
-191
@@ -1,41 +1,7 @@
|
||||
"""Stateful conversation history for the markdown frontend.
|
||||
"""Stored conversation history for the markdown frontend.
|
||||
|
||||
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`.
|
||||
Aligns the file the user is editing against the DB-stored Anthropic-shape
|
||||
message history, splicing text edits or forking on structural changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -63,32 +29,27 @@ __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 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`` is the backend input; ``persist_messages`` is history to
|
||||
store before the new reply; ``divergence_index`` is where they diverged.
|
||||
"""
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
# ---- public store API ---------------------------------------------------
|
||||
@property
|
||||
def reuse_session(self) -> bool:
|
||||
return self.divergence_index is None and not self.edited
|
||||
|
||||
|
||||
async def load_conversation(
|
||||
@@ -108,9 +69,7 @@ async def mint_conversation(
|
||||
) -> Conversation:
|
||||
"""Create a fresh conversation row with a new uuid for external_id.
|
||||
|
||||
Caller is responsible for persisting the returned ``external_id`` on
|
||||
the frontend side (frontmatter, response header, …) so future
|
||||
requests can find this conversation again.
|
||||
Caller must persist the returned ``external_id`` so future requests can find it.
|
||||
"""
|
||||
row = Conversation(
|
||||
frontend=frontend, external_id=str(uuid.uuid4()), agent_name=agent_name
|
||||
@@ -121,14 +80,24 @@ 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 — ``role``
|
||||
plus ``content`` (string or list of block dicts). The same shape
|
||||
we feed to the backend on continuation.
|
||||
Each entry is a canonical Anthropic ``MessageParam`` dict, the same
|
||||
shape fed to the backend on continuation.
|
||||
"""
|
||||
stmt = (
|
||||
select(ConversationMessage)
|
||||
@@ -146,10 +115,8 @@ async def load_messages(
|
||||
def _sanitize_content(content: Any) -> Any:
|
||||
"""Strip wire-illegal fields from stored Anthropic content blocks.
|
||||
|
||||
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.
|
||||
Drops ``tool_result.is_error: null`` — the API rejects null there
|
||||
though the field is optional.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
@@ -170,27 +137,17 @@ 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 with ``messages``.
|
||||
"""Replace the conversation's stored messages (full overwrite, no branch history).
|
||||
|
||||
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.
|
||||
Deletes and flushes before inserting — SQLAlchemy's default INSERT-before-DELETE
|
||||
flush order would otherwise collide with ``uq_msg_conv_seq``.
|
||||
"""
|
||||
# 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(
|
||||
@@ -200,7 +157,6 @@ 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
|
||||
@@ -210,21 +166,11 @@ async def rewrite_messages(
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ---- alignment ----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _StoredDisplayTurn:
|
||||
"""A "display turn" reconstructed from stored raw messages.
|
||||
|
||||
``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.
|
||||
Parser-equivalent view used to diff the file against the DB.
|
||||
"""
|
||||
|
||||
role: str
|
||||
@@ -237,10 +183,8 @@ 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 (``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.
|
||||
A user-prompt message (no ``tool_result`` blocks) opens a new turn;
|
||||
tool-result-only messages roll into the current assistant turn.
|
||||
"""
|
||||
out: list[_StoredDisplayTurn] = []
|
||||
i = 0
|
||||
@@ -259,7 +203,6 @@ 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]
|
||||
@@ -281,14 +224,16 @@ 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."""
|
||||
"""A user message is a *prompt* unless its content carries tool_result blocks.
|
||||
|
||||
Unknown content shapes are conservatively treated as a prompt.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@@ -310,13 +255,9 @@ def _summarize_assistant_group(
|
||||
) -> tuple[str, list[str], int]:
|
||||
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
|
||||
|
||||
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.
|
||||
Must mirror ``parser.parse_assistant_structure``: text blocks collapse into
|
||||
one segment, tool_use becomes a skeleton entry, tool_result/thinking 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] = []
|
||||
@@ -333,7 +274,6 @@ 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")
|
||||
@@ -350,7 +290,6 @@ 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()
|
||||
@@ -359,30 +298,14 @@ 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.
|
||||
|
||||
``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.
|
||||
``incoming`` must end with a user turn (the new prompt); raises otherwise.
|
||||
Segment-class imports below are deferred to avoid a cycle with ``parser``.
|
||||
"""
|
||||
# ``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 "
|
||||
@@ -395,20 +318,14 @@ def diff_and_fork(
|
||||
new_user_turn = incoming[-1]
|
||||
prior_incoming = incoming[:-1]
|
||||
|
||||
spliced_groups, divergence = _walk_prefix(prior_incoming, stored_groups)
|
||||
spliced_groups, divergence, edited = _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(
|
||||
@@ -421,6 +338,7 @@ def diff_and_fork(
|
||||
messages=backend_msgs,
|
||||
persist_messages=persist_msgs,
|
||||
divergence_index=divergence,
|
||||
edited=edited,
|
||||
)
|
||||
|
||||
|
||||
@@ -429,26 +347,9 @@ def _file_lags_store(
|
||||
) -> bool:
|
||||
"""Is the shorter incoming file a stale view rather than a deletion?
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
if prior_len >= len(stored_groups):
|
||||
return False
|
||||
@@ -458,44 +359,44 @@ def _file_lags_store(
|
||||
|
||||
def _walk_prefix(
|
||||
prior_incoming: list[ParsedTurn], stored_groups: list[_StoredDisplayTurn]
|
||||
) -> tuple[list[list[dict[str, Any]]], int | None]:
|
||||
) -> tuple[list[list[dict[str, Any]]], int | None, bool]:
|
||||
"""Walk incoming vs stored side-by-side until first divergence.
|
||||
|
||||
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).
|
||||
An empty incoming skeleton (no tool callouts rendered) means prose
|
||||
alone decides the match; a mismatch forces a fresh backend session.
|
||||
"""
|
||||
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
|
||||
return spliced_groups, i, edited
|
||||
st = stored_groups[i]
|
||||
if inc.role != st.role:
|
||||
return spliced_groups, i
|
||||
return spliced_groups, i, edited
|
||||
if inc.role == "user":
|
||||
if inc.text != st.spoken_text:
|
||||
return spliced_groups, i
|
||||
return spliced_groups, i, edited
|
||||
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 != st.skeleton:
|
||||
return spliced_groups, i
|
||||
if inc_skeleton and inc_skeleton != st.skeleton:
|
||||
return spliced_groups, i, edited
|
||||
if inc.text == st.spoken_text:
|
||||
spliced_groups.append(list(st.messages))
|
||||
continue
|
||||
if inc_text_count != st.text_segment_count:
|
||||
return spliced_groups, i
|
||||
if inc_skeleton and inc_text_count != st.text_segment_count:
|
||||
return spliced_groups, i, edited
|
||||
spliced = _splice_assistant_group(stored_group=st, incoming=inc)
|
||||
if spliced is None:
|
||||
return spliced_groups, i
|
||||
return spliced_groups, i, edited
|
||||
spliced_groups.append(spliced)
|
||||
return spliced_groups, None
|
||||
edited = True
|
||||
return spliced_groups, None, edited
|
||||
|
||||
|
||||
def _assemble_tail(
|
||||
@@ -539,26 +440,22 @@ def _splice_assistant_group(
|
||||
return _splice_by_rebuild(stored_group=stored_group, incoming=incoming)
|
||||
|
||||
|
||||
def _is_text_block(blk: object) -> bool:
|
||||
if not isinstance(blk, dict):
|
||||
return False
|
||||
block = cast("dict[str, Any]", blk)
|
||||
return block.get("type") == "text" and bool(str(block.get("text", "")).strip())
|
||||
|
||||
|
||||
def _splice_in_place(
|
||||
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Copy the stored messages, swapping only their text block contents.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
# See ``diff_and_fork`` for why the parser-type import is deferred.
|
||||
from beaver_gateway.frontends.markdown.parser import TextSegment
|
||||
|
||||
new_texts = [
|
||||
@@ -570,11 +467,7 @@ def _splice_in_place(
|
||||
if msg["role"] != "assistant" or not isinstance(content, list):
|
||||
continue
|
||||
positions.extend(
|
||||
(mi, bi)
|
||||
for bi, blk in enumerate(content)
|
||||
if isinstance(blk, dict)
|
||||
and blk.get("type") == "text"
|
||||
and str(blk.get("text", "")).strip()
|
||||
(mi, bi) for bi, blk in enumerate(content) if _is_text_block(blk)
|
||||
)
|
||||
if len(positions) != len(new_texts):
|
||||
return None
|
||||
@@ -598,18 +491,9 @@ def _splice_by_rebuild(
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Rebuild an assistant display turn with new text + stored tool_use blocks.
|
||||
|
||||
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.
|
||||
Matches tool_use blocks to incoming ``ToolSegment``s by position; returns
|
||||
``None`` (caller forks) if a matching stored ``tool_result`` is missing.
|
||||
"""
|
||||
# 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)
|
||||
@@ -636,8 +520,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""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"])
|
||||
@@ -1,18 +1,7 @@
|
||||
"""Parse a markdown chat file into Anthropic ``MessageParam`` history.
|
||||
|
||||
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.
|
||||
Turn markers are ``### User:`` / ``### Assistant:`` H3 headers; backend
|
||||
input drops thinking/tool callouts, keeping assistant turns text-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -51,11 +40,7 @@ class TextSegment:
|
||||
class ToolSegment:
|
||||
"""A ``> [!tool]- <name>`` callout placeholder.
|
||||
|
||||
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.
|
||||
Only the tool name is captured; the summary suffix is decorative.
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -64,26 +49,11 @@ 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 = " · "
|
||||
|
||||
|
||||
@@ -91,14 +61,8 @@ _TOOL_TITLE_SEP = " · "
|
||||
class ParsedTurn:
|
||||
"""One turn extracted from the chat file.
|
||||
|
||||
``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.
|
||||
``text`` is spoken content only; ``structure`` (assistant turns only)
|
||||
carries the ordered text/tool segments used to align with stored history.
|
||||
"""
|
||||
|
||||
role: str
|
||||
@@ -110,16 +74,8 @@ class ParsedTurn:
|
||||
class ParsedFile:
|
||||
"""Result of parsing a single chat ``.md``.
|
||||
|
||||
``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).
|
||||
``messages`` is text-only history for the backend; ``turns`` carries
|
||||
per-turn structure; ``body`` is the raw content after frontmatter.
|
||||
"""
|
||||
|
||||
metadata: dict[str, Any]
|
||||
@@ -131,15 +87,9 @@ class ParsedFile:
|
||||
def parse(text: str) -> ParsedFile:
|
||||
"""Parse a chat ``.md`` into ``(metadata, body, messages, turns)``.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
parsed = frontmatter.loads(text)
|
||||
metadata = dict(parsed.metadata)
|
||||
@@ -175,14 +125,6 @@ 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))
|
||||
@@ -196,18 +138,8 @@ 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` 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.
|
||||
Tool callouts become :class:`ToolSegment` (name only); thinking callouts
|
||||
and HR lines are stripped; boundary-empty text segments are dropped.
|
||||
"""
|
||||
segments: list[TextSegment | ToolSegment] = []
|
||||
pending_text: list[str] = []
|
||||
@@ -216,9 +148,6 @@ 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:
|
||||
@@ -231,7 +160,6 @@ 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 ""
|
||||
@@ -239,9 +167,7 @@ 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
|
||||
@@ -257,11 +183,7 @@ 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 :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.
|
||||
Concatenates text segments with ``\n\n``, dropping tool segments.
|
||||
"""
|
||||
chunks = [s.text for s in segments if isinstance(s, TextSegment)]
|
||||
return "\n\n".join(c for c in chunks if c).strip()
|
||||
@@ -290,15 +212,11 @@ def resolve_agent(
|
||||
return default
|
||||
|
||||
|
||||
# ---- internals ---------------------------------------------------------
|
||||
|
||||
|
||||
def _split_turns(body: str) -> list[tuple[str, str]]:
|
||||
"""Walk turn markers, return ``[(role_lc, raw_body), ...]``.
|
||||
|
||||
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.
|
||||
A marker is an exact ``### User:`` / ``### Assistant:`` line; trailing
|
||||
content after the colon means it's not a marker, not a turn boundary.
|
||||
"""
|
||||
matches = list(_TURN_RE.finditer(body))
|
||||
if not matches:
|
||||
@@ -315,9 +233,8 @@ def _split_turns(body: str) -> list[tuple[str, str]]:
|
||||
def _strip_hrs(raw: str) -> str:
|
||||
"""Drop decorative ``---`` separator lines (whole-line HRs only).
|
||||
|
||||
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.
|
||||
Only lines that are exactly ``---`` (with optional surrounding
|
||||
whitespace) are removed; a ``---`` mid-paragraph stays.
|
||||
"""
|
||||
lines = raw.splitlines()
|
||||
kept = [ln for ln in lines if ln.strip() != "---"]
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
"""Render Anthropic ``Message`` (and individual user turns) into markdown.
|
||||
"""Render Anthropic ``Message`` (and user turns) into markdown.
|
||||
|
||||
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).
|
||||
One-way: produces the human-facing artifact only. The parser strips
|
||||
tool/thinking callouts separately when reshaping history for backend replay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock
|
||||
from anthropic.types import Message, TextBlock, ThinkingBlock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
@@ -31,21 +27,11 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
# 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"
|
||||
USER_SCAFFOLD = "### User:\n\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")
|
||||
|
||||
|
||||
@@ -54,23 +40,28 @@ 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`` (with content blocks) into a turn block.
|
||||
"""Render an assistant ``Message`` into a turn block.
|
||||
|
||||
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.
|
||||
Tool-use blocks render to nothing; of the thinking blocks between two
|
||||
texts only the first one reaches the file.
|
||||
"""
|
||||
parts: list[str] = ["### Assistant:", ""]
|
||||
after_thinking = False
|
||||
for block in message.content:
|
||||
parts.extend(_render_block(block))
|
||||
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.append("")
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
|
||||
@@ -78,19 +69,12 @@ def render_assistant_message(message: Message) -> str:
|
||||
def render_user_param(param: MessageParam) -> str:
|
||||
"""Render a ``MessageParam`` user message into a ``### User:`` block.
|
||||
|
||||
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``).
|
||||
Tool_result blocks in the content list are dropped silently.
|
||||
"""
|
||||
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
|
||||
@@ -101,11 +85,9 @@ 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 HR separator.
|
||||
"""Append ``new_block`` to ``existing`` with a decorative ``---`` separator.
|
||||
|
||||
Preserves the original body verbatim (whitespace, callouts, any
|
||||
formatting the human added). The HR is purely visual: parser ignores
|
||||
it.
|
||||
The separator is visual only; the parser ignores it.
|
||||
"""
|
||||
head = existing.rstrip()
|
||||
if head:
|
||||
@@ -114,18 +96,9 @@ 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 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.
|
||||
"""
|
||||
"""Build the ``[!tool]- <summary>`` title, picking one salient input field."""
|
||||
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)
|
||||
@@ -135,9 +108,6 @@ 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()
|
||||
@@ -147,10 +117,6 @@ 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]:
|
||||
@@ -159,24 +125,8 @@ 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 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.
|
||||
"""
|
||||
"""Return a backtick fence longer than any backtick run in ``content``."""
|
||||
longest = 0
|
||||
for match in re.finditer(r"`+", content):
|
||||
longest = max(longest, len(match.group(0)))
|
||||
|
||||
@@ -1,43 +1,13 @@
|
||||
"""External MCP frontend (Phase 3.1).
|
||||
"""External MCP frontend.
|
||||
|
||||
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.
|
||||
Streamable-HTTP gateway that reverse-proxies the internal MCP aggregator,
|
||||
adding bearer auth, an audit log line per request, and a discovery page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -46,9 +16,10 @@ 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
|
||||
@@ -61,10 +32,6 @@ 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",
|
||||
@@ -80,11 +47,6 @@ _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"})
|
||||
|
||||
|
||||
@@ -94,87 +56,41 @@ __all__ = ["McpServerFrontend"]
|
||||
class McpServerFrontend(Frontend):
|
||||
"""Auth + audit + reverse-proxy in front of the internal MCP aggregator."""
|
||||
|
||||
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
|
||||
path = "/mcp"
|
||||
|
||||
def __init__(self) -> 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"
|
||||
self._http = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await self._http.close()
|
||||
self._http = None
|
||||
|
||||
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:
|
||||
yield
|
||||
finally:
|
||||
if self._http is not None:
|
||||
await self._http.close()
|
||||
self._http = None
|
||||
"""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,
|
||||
@@ -186,7 +102,7 @@ class McpServerFrontend(Frontend):
|
||||
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
),
|
||||
]
|
||||
return Starlette(routes=routes, lifespan=lifespan)
|
||||
return Starlette(routes=routes)
|
||||
|
||||
async def _healthz(self, _request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
@@ -196,14 +112,8 @@ 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 — 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)
|
||||
assert token_name is not None # noqa: S101
|
||||
base = external_base(request, runtime)
|
||||
html = _render_discovery_page(
|
||||
base_url=base, namespaces=list(runtime.mcps), actor=token_name
|
||||
)
|
||||
@@ -216,7 +126,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 — narrow for ty
|
||||
assert token_name is not None # noqa: S101
|
||||
|
||||
namespace = request.path_params["namespace"]
|
||||
subpath = request.path_params.get("path", "")
|
||||
@@ -240,7 +150,6 @@ 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(
|
||||
@@ -253,11 +162,12 @@ 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:
|
||||
@@ -284,13 +194,8 @@ async def _verify_request(
|
||||
) -> tuple[str | None, JSONResponse | None]:
|
||||
"""Accept ``Authorization: Bearer``, ``X-Api-Key``, or ``?token=``.
|
||||
|
||||
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".
|
||||
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.
|
||||
"""
|
||||
api_key = request.headers.get("x-api-key")
|
||||
if api_key:
|
||||
@@ -328,23 +233,13 @@ 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 ``/`` (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.
|
||||
``base_url`` always ends in ``/`` to avoid Starlette's redirect dance.
|
||||
"""
|
||||
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,
|
||||
@@ -354,18 +249,14 @@ async def _reverse_proxy(
|
||||
actor: str,
|
||||
runtime: GatewayRuntime,
|
||||
) -> StreamingResponse | JSONResponse:
|
||||
"""Bidirectionally stream an MCP request between client ↔ internal aggregator.
|
||||
"""Bidirectionally stream an MCP request between client and internal aggregator.
|
||||
|
||||
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.
|
||||
The audit row is written right after the upstream response headers
|
||||
arrive, before relaying its body, so a client-truncated stream is
|
||||
still audited.
|
||||
"""
|
||||
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}"
|
||||
@@ -411,9 +302,6 @@ 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}",
|
||||
@@ -430,7 +318,6 @@ 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()
|
||||
@@ -450,6 +337,11 @@ 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()
|
||||
@@ -482,11 +374,7 @@ 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.
|
||||
|
||||
Inline HTML (no Jinja file) — keeps Phase 3 free of template-dir
|
||||
plumbing that Phase 4's AdminFrontend will own.
|
||||
"""
|
||||
"""Render the auth-gated namespace + config-snippet page."""
|
||||
name_list = [getattr(ns, "name", str(ns)) for ns in namespaces]
|
||||
rows = (
|
||||
"\n".join(
|
||||
@@ -520,7 +408,7 @@ def _escape(value: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
_DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002 — readability beats one-string-blob
|
||||
_DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002
|
||||
[
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""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
@@ -0,0 +1,143 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""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()))
|
||||
@@ -0,0 +1,346 @@
|
||||
"""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
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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)
|
||||
)
|
||||
Loaded 100 of 358 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user