Compare commits
No commits in common. "main" and "feat/webui-p1" have entirely different histories.
main
...
feat/webui
32
AGENTS.md
32
AGENTS.md
@ -9,7 +9,6 @@ This file is the operational contract for coding agents working in this reposito
|
|||||||
- `cargo run -- chat` — connect to gateway as CLI client (default `ws://127.0.0.1:19876/ws`)
|
- `cargo run -- chat` — connect to gateway as CLI client (default `ws://127.0.0.1:19876/ws`)
|
||||||
- `cargo run -- run "prompt"` — send one prompt through Gateway, print the terminal Turn, and exit; stdin, JSON, verbose progress, and timeout modes are available
|
- `cargo run -- run "prompt"` — send one prompt through Gateway, print the terminal Turn, and exit; stdin, JSON, verbose progress, and timeout modes are available
|
||||||
- `cargo run -- reload` — validate and gracefully reload a running Gateway's configuration
|
- `cargo run -- reload` — validate and gracefully reload a running Gateway's configuration
|
||||||
- `cargo run -- health [--json]` — check core, configuration-dependent, and optional runtime dependencies without starting Gateway
|
|
||||||
- `docker compose up -d` — start the container with Gateway bound/published on `0.0.0.0:19876`; override `PICOBOT_GATEWAY_HOST`, `PICOBOT_PUBLISH_HOST`, or `PICOBOT_GATEWAY_PORT` as needed
|
- `docker compose up -d` — start the container with Gateway bound/published on `0.0.0.0:19876`; override `PICOBOT_GATEWAY_HOST`, `PICOBOT_PUBLISH_HOST`, or `PICOBOT_GATEWAY_PORT` as needed
|
||||||
- WebUI — start Gateway, then open `http://127.0.0.1:19876/`; no separate frontend build is required
|
- WebUI — start Gateway, then open `http://127.0.0.1:19876/`; no separate frontend build is required
|
||||||
- `cd webui && npm ci && npm run check && npm run build` — validate the Svelte WebUI independently (Node.js 20+); its local `dist/` is ignored
|
- `cd webui && npm ci && npm run check && npm run build` — validate the Svelte WebUI independently (Node.js 20+); its local `dist/` is ignored
|
||||||
@ -21,7 +20,6 @@ This file is the operational contract for coding agents working in this reposito
|
|||||||
- Config load order: `~/.picobot/config.json` then fallback to `./config.json` (`Config::load_default` in `src/config/mod.rs`)
|
- Config load order: `~/.picobot/config.json` then fallback to `./config.json` (`Config::load_default` in `src/config/mod.rs`)
|
||||||
- `.env` files use a custom parser, not dotenv: load `<config-dir>/.env`, then `<workspace_dir>/.env`, while pre-existing process variables remain highest priority; config placeholders `<VAR_NAME>` use the merged values
|
- `.env` files use a custom parser, not dotenv: load `<config-dir>/.env`, then `<workspace_dir>/.env`, while pre-existing process variables remain highest priority; config placeholders `<VAR_NAME>` use the merged values
|
||||||
- Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run)
|
- Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run)
|
||||||
- Runtime config loading ignores recoverable unknown/type-mismatched fields and invalid non-core named entries while retaining diagnostics and the raw file revision; malformed JSON, an unusable `default` Agent chain, and unsafe runtime construction remain fatal. WebUI writes are strict, and backend cleanup may remove only diagnosed paths from the same revision
|
|
||||||
- CLI TUI identity is stored in `~/.picobot/tui_client_id`; it is a non-secret stable chat scope used to restore dialogs across reconnects
|
- CLI TUI identity is stored in `~/.picobot/tui_client_id`; it is a non-secret stable chat scope used to restore dialogs across reconnects
|
||||||
- One-shot `run` uses a unique chat scope per invocation; for loopback Gateway URLs it authenticates `/ws` with `~/.picobot/web_admin_token`, while remote URLs use the existing paired CLI token
|
- One-shot `run` uses a unique chat scope per invocation; for loopback Gateway URLs it authenticates `/ws` with `~/.picobot/web_admin_token`, while remote URLs use the existing paired CLI token
|
||||||
|
|
||||||
@ -71,8 +69,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
|||||||
| `agent` | LLM call loop, tool execution, context compression, semantic Turn events | `AgentLoop`, `TurnEvent` |
|
| `agent` | LLM call loop, tool execution, context compression, semantic Turn events | `AgentLoop`, `TurnEvent` |
|
||||||
| `providers` | Native LLM streams normalized into text/reasoning/tool/usage chunks | `LLMProvider`, `ProviderChunk`, `create_provider()` |
|
| `providers` | Native LLM streams normalized into text/reasoning/tool/usage chunks | `LLMProvider`, `ProviderChunk`, `create_provider()` |
|
||||||
| `delivery` | Snapshot projection, latest-wins throttling, terminal retry, per-turn sink lifecycle | `DeliveryCoordinator`, `TurnDeliveryService`, `PresentationPolicy` |
|
| `delivery` | Snapshot projection, latest-wins throttling, terminal retry, per-turn sink lifecycle | `DeliveryCoordinator`, `TurnDeliveryService`, `PresentationPolicy` |
|
||||||
| `tools` | Agent tools and external adapters (bash, files, HTTP, browser, health) | `ToolRegistry`, `Tool`, `ToolExecutionContext` |
|
| `tools` | Agent tools (bash, file ops, http, web, get_skill) | `ToolRegistry`, `Tool` trait |
|
||||||
| `health` | Shared read-only dependency diagnostics for CLI/tool/slash entry points | `HealthService`, `HealthReport` |
|
|
||||||
| `skills` | Skills loading, management, and prompt building | `SkillsLoader`, `Skill` |
|
| `skills` | Skills loading, management, and prompt building | `SkillsLoader`, `Skill` |
|
||||||
| `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` |
|
| `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` |
|
||||||
| `scheduler` | Cron-based job scheduling, next-run computation | `Scheduler`, `Schedule`, `next_run_for_schedule()` |
|
| `scheduler` | Cron-based job scheduling, next-run computation | `Scheduler`, `Schedule`, `next_run_for_schedule()` |
|
||||||
@ -88,39 +85,26 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
|||||||
- **Channels** publish inbound messages through `MessageBus`; outbound writes arrive through `OutboundDispatcher` or a per-turn `TurnSink`. They know nothing about sessions or LLM
|
- **Channels** publish inbound messages through `MessageBus`; outbound writes arrive through `OutboundDispatcher` or a per-turn `TurnSink`. They know nothing about sessions or LLM
|
||||||
- **Inbound contract** carries normalized sender/time/media plus `ChannelContext`; core routing may interpret `reply_to` but must treat platform-private context as opaque reply data
|
- **Inbound contract** carries normalized sender/time/media plus `ChannelContext`; core routing may interpret `reply_to` but must treat platform-private context as opaque reply data
|
||||||
- **MessageBus** owns bounded inbound/outbound/control queues; outbound routing and retries belong to `OutboundDispatcher`, not the queue
|
- **MessageBus** owns bounded inbound/outbound/control queues; outbound routing and retries belong to `OutboundDispatcher`, not the queue
|
||||||
- **SessionManager** owns session state, dialog operations, context construction, per-session work queues, active-Turn steering admission, and persistence coordination
|
- **SessionManager** owns session state, dialog operations, context construction, per-session work queues, and persistence coordination
|
||||||
- **TurnController** is the only owner of active Turn state; snapshots are complete latest-wins values, not token queues, and `Completed` is published only after atomic persistence succeeds
|
- **TurnController** is the only owner of active Turn state; snapshots are complete latest-wins values, not token queues, and `Completed` is published only after atomic persistence succeeds
|
||||||
- **DeliveryCoordinator** projects active Turn snapshots without mutating history; it owns `TurnSink` lifecycle but no platform message IDs, which remain private to each sink
|
- **DeliveryCoordinator** projects active Turn snapshots without mutating history; it owns `TurnSink` lifecycle but no platform message IDs, which remain private to each sink
|
||||||
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
|
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
|
||||||
- **Scheduler** owns one unified Scheduled Run path: claim-time JobRun snapshots, isolated Root/named Agent execution, exactly-once `complete_scheduled_run`, structured outcome, and policy-driven outbox delivery. Scheduled origin propagates to descendants, forces background delegation to foreground, and disables direct messaging, signals, Inbox completion slots, and cron/config management tools; `on_alert` suppresses only structured `ok`
|
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
|
||||||
- **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
|
- **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result
|
||||||
- **Context overflow recovery** is type-driven: before tool progress Session may commit one checkpoint and retry once; after any tool batch AgentLoop may retry the current Provider step once from its in-memory transcript, preserving current tool calls/results, and Session must never restart that Turn from durable history
|
|
||||||
- **Context compaction** keeps `messages` append-only and uses one active checkpoint per Session (`summary + first_retained_seq`) for deterministic Provider projection; `/compact`, Turn-boundary auto compaction, and overflow share the same compactor/CAS commit path, Session restoration never derives context from Timeline or calls a Provider, the Model `token_limit` (default 128K) is the hard window ceiling and an optional Agent `token_limit` can only narrow it via `min(agent, model)`, summary input is bounded from that effective window rather than a fixed cap, and the only automatic threshold is `context_tokens > context_window - effective_reserve`
|
|
||||||
- **AgentCatalog** is immutable per runtime generation; candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. A definition that fails per-file validation (bad YAML, unknown provider/profile/model/tool/skill, or an explicit delegate edge to an absent target) is disabled for that generation only and reported via `load_errors` (exposed by `GET /api/agents`), never blocking startup or reload; config- and directory-trust-level failures remain fatal. Sub-Agent orchestration is an intrinsic, always-on mechanism (no feature switch). Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted
|
|
||||||
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
||||||
- **Configuration recovery** builds an effective typed `Config` from a request-local copy and never rewrites the source automatically; diagnostics use raw RFC 6901 paths, array recovery preserves original indexes, ordinary writes reject ignored fields, and cleanup requires the exact source SHA-256 revision before atomically deleting diagnosed paths
|
|
||||||
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
|
|
||||||
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
|
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
|
||||||
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
|
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
|
||||||
- **Session token statistics** persist Provider-reported usage atomically with each completed Turn; WebUI `session_stats` and `/info [--json]` must consume the same SessionStats projection, and context occupancy must use the final request's prompt usage rather than accumulated Turn totals
|
|
||||||
- **WebUI chat rendering** sanitizes Markdown before inserting HTML; durable `turn_committed` deltas calibrate normal terminal Turns without a full history reload, and history must preserve structured tool-call metadata so calls and results remain independently collapsible
|
- **WebUI chat rendering** sanitizes Markdown before inserting HTML; durable `turn_committed` deltas calibrate normal terminal Turns without a full history reload, and history must preserve structured tool-call metadata so calls and results remain independently collapsible
|
||||||
- **WebUI/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths
|
- **WebUI/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths
|
||||||
- **WebUI/TUI same-turn media delivery** stages same-session `send_message(files=...)` media on the active Turn and commits it on the final assistant message, after durable tool-call history; safe raster formats should render as an inline preview with download fallback
|
- **WebUI/TUI same-turn media delivery** stages same-session `send_message(files=...)` media on the active Turn and commits it on the final assistant message, after durable tool-call history; safe raster formats should render as an inline preview with download fallback
|
||||||
- **WebUI authentication** protects every management API and `/ws`; only static pairing assets, public health/status, and pairing submission may bypass device auth. Pair-code issuance requires both a real loopback peer and the filesystem-held admin token. The same conjunction may authenticate only `/ws` for local one-shot `run`; it must never authorize management APIs. Never put bearer or admin tokens in URLs or logs
|
- **WebUI authentication** protects every management API and `/ws`; only static pairing assets, public health/status, and pairing submission may bypass device auth. Pair-code issuance requires both a real loopback peer and the filesystem-held admin token. The same conjunction may authenticate only `/ws` for local one-shot `run`; it must never authorize management APIs. Never put bearer or admin tokens in URLs or logs
|
||||||
- **Providers** are pure HTTP clients; no bus/session/channel awareness
|
- **Providers** are pure HTTP clients; no bus/session/channel awareness
|
||||||
- **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs
|
- **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs
|
||||||
- **Tools** are executed by `AgentLoop`; every invocation is normalized to `ToolOutput` and passes through `ToolOutputProcessor`. Plain `ToolResult` implementations use the default conversion, while artifact-producing tools declare model/user audience explicitly; model capability checks, final-reply attachment, channel delivery, and Provider serialization stay outside tools
|
- **Tools** are executed by `AgentLoop`; they receive raw arguments and normally return text. Tools that produce model-consumable media use the structured `execute_with_media` side channel; model capability checks and provider content-block serialization stay outside tools
|
||||||
- **Delegated tool access**: a named Agent's tool set is decided solely by its definition file (admin-authored). `delegate`, `emit_signal`, `get_skill` and `agent_task` are runtime-injected and must never be declared in `tools` (`get_skill` is the scoped-skill switch); `allowed_tools` can only narrow the definition, never expand it
|
|
||||||
- **No foreground wait tool**: Agents wait for asynchronous work by ending the Turn and letting queued completions/signals open a continuation Turn, or by polling status tools; there is no model-callable `sleep`/wait tool. Cancelling a Turn must still normalize active tool blocks to `Cancelled`
|
|
||||||
- **Skill enable/disable**: skills default to enabled; user-disabled skill names are persisted in `<config_dir>/skills_state.json` (skill files are never modified), and disabled skills are excluded from prompts, listings, and `get_skill` at load time
|
|
||||||
- **MCP enable/disable**: `mcp.servers[].enabled` defaults to true; disabled servers are skipped at activation (no connection attempt) and by health checks
|
|
||||||
- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session, whose idle daemon timeout is controlled by `browser.idle_timeout_secs` (one hour by default). For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; persistent browser daemons disable idle auto-close, the same ID shares one agent-browser session and serialization gate across dialogs, and different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths
|
|
||||||
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, `/health`, and authenticated WebUI `GET /api/health`; the public `GET /health` remains a lightweight liveness/version probe, while full checks run only on demand and must not install/fix dependencies, call Provider APIs, or expose secrets. Treat `fd`/`fdfind` as equivalent preferred backends; browser launch diagnostics must isolate their socket namespace from active agent-browser sessions and must not use `--quick`, which skips the live launch probe
|
|
||||||
|
|
||||||
### Concurrency and Lifecycle Invariants
|
### Concurrency and Lifecycle Invariants
|
||||||
|
|
||||||
- One session runs at most one Turn; ordinary input steers its active Turn by default, `/queue` explicitly waits for the next Turn, and different sessions may run concurrently
|
- Messages in one session are processed serially through a bounded queue; different sessions may run concurrently
|
||||||
- Steering admission, final close, fallback, and `/stop` must be lossless and mutually exclusive: an input belongs to exactly the active Turn or the next-Turn FIFO, while `/stop` intentionally discards both
|
|
||||||
- Outbound messages are ordered per `(channel, chat_id)`; a slow destination must not block unrelated destinations
|
- Outbound messages are ordered per `(channel, chat_id)`; a slow destination must not block unrelated destinations
|
||||||
- Active Turn delivery and ordinary outbound delivery share the same per-`(channel, chat_id)` write lock; never enqueue token deltas into MessageBus
|
- Active Turn delivery and ordinary outbound delivery share the same per-`(channel, chat_id)` write lock; never enqueue token deltas into MessageBus
|
||||||
- Slow Turn consumers may skip intermediate snapshots but must receive an explicit bounded terminal delivery; shutdown must call sink abort so platform cleanup remains possible
|
- Slow Turn consumers may skip intermediate snapshots but must receive an explicit bounded terminal delivery; shutdown must call sink abort so platform cleanup remains possible
|
||||||
@ -137,7 +121,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
|||||||
### Key Constraints
|
### Key Constraints
|
||||||
|
|
||||||
- Gateway **changes working directory** to workspace in `GatewayState::new` (`src/gateway/mod.rs`)
|
- Gateway **changes working directory** to workspace in `GatewayState::new` (`src/gateway/mod.rs`)
|
||||||
- Session/message persistence uses SQLite via `sqlx`; DB stored in `<config_dir>/data/picobot.db` by default (`config_dir` is `~/.picobot`), independent of the workspace
|
- Session/message persistence uses SQLite via `sqlx`; DB stored in workspace as `picobot.db` by default
|
||||||
- `ChannelManager` owns the `MessageBus` and all channel instances
|
- `ChannelManager` owns the `MessageBus` and all channel instances
|
||||||
- `OutboundDispatcher` routes outbound messages to the correct channel via `ChannelManager`
|
- `OutboundDispatcher` routes outbound messages to the correct channel via `ChannelManager`
|
||||||
- Layered config/workspace `.env` loading uses `unsafe { env::set_var(...) }` during single-threaded startup — don't move it after Gateway tasks are spawned or refactor it without understanding process-wide side effects
|
- Layered config/workspace `.env` loading uses `unsafe { env::set_var(...) }` during single-threaded startup — don't move it after Gateway tasks are spawned or refactor it without understanding process-wide side effects
|
||||||
@ -161,4 +145,4 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
|||||||
- `resources/skills/about-picobot/references/` — runtime knowledge shipped to PicoBot; update it only when the assistant's built-in product knowledge must change
|
- `resources/skills/about-picobot/references/` — runtime knowledge shipped to PicoBot; update it only when the assistant's built-in product knowledge must change
|
||||||
|
|
||||||
## Version Management
|
## Version Management
|
||||||
- 在每次功能变化、架构变化后,适当地更新整个产品的版本号。功能变化增加中段数字,ui变化、bug修改增加末端数字。 注意版本号变更是提交时和仓库中的版本比较,不要在长时间的工程中,不断变化版本号。
|
- 在每次功能变化、架构变化后,适当地更新整个产品的版本号
|
||||||
|
|||||||
35
Cargo.toml
35
Cargo.toml
@ -1,23 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.22.0"
|
version = "1.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "multipart"] }
|
reqwest = { version = "0.13.3", default-features = false, features = ["json", "rustls", "multipart"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_path_to_error = "0.1"
|
regex = "1.12"
|
||||||
regex = "1.13"
|
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde_yaml = "0.9"
|
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
thiserror = "2.0.19"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.53", features = ["full"] }
|
tokio = { version = "1.52", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["rt", "io"] }
|
tokio-util = { version = "0.7", features = ["rt", "io"] }
|
||||||
dashmap = "6.2"
|
dashmap = "6.1"
|
||||||
uuid = { version = "1.24", features = ["v4"] }
|
uuid = { version = "1.23", features = ["v4"] }
|
||||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||||
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots", "rustls"] }
|
tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots", "rustls"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
@ -25,24 +23,23 @@ prost = "0.14"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] }
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
base64 = "0.23"
|
base64 = "0.22"
|
||||||
sha2 = "0.11"
|
sha2 = "0.10"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
cron = "0.17"
|
cron = "0.16"
|
||||||
chrono-tz = "0.10"
|
chrono-tz = "0.10"
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
crossterm = { version = "0.29", features = ["event-stream"] }
|
crossterm = { version = "0.29", features = ["event-stream"] }
|
||||||
termimad = "0.35"
|
termimad = "0.34"
|
||||||
textwrap = "0.16"
|
textwrap = "0.16"
|
||||||
unicode-width = "0.2"
|
unicode-width = "0.2"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
sqlx = { version = "0.9", features = ["sqlite", "macros", "chrono", "runtime-tokio"] }
|
sqlx = { version = "0.8", features = ["sqlite", "macros", "chrono", "runtime-tokio"] }
|
||||||
jieba-rs = "0.10"
|
jieba-rs = "0.9"
|
||||||
which = "8"
|
which = "8"
|
||||||
rmcp = { version = "2.2", default-features = false, features = [
|
rmcp = { version = "1.7", default-features = false, features = [
|
||||||
"client",
|
"client",
|
||||||
"transport-child-process",
|
"transport-child-process",
|
||||||
"transport-streamable-http-client-reqwest",
|
"transport-streamable-http-client-reqwest",
|
||||||
@ -52,12 +49,12 @@ http = "1"
|
|||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
|
fantoccini = { version = "0.22", default-features = false, features = ["rustls-tls"] }
|
||||||
portable-pty = "0.9"
|
portable-pty = "0.9"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tokio = { version = "1.53", features = ["test-util"] }
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@ -51,8 +51,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& pip3 install --no-cache-dir --break-system-packages uv
|
&& pip3 install --no-cache-dir --break-system-packages uv
|
||||||
|
|
||||||
# Install Node.js and npx. agent-browser's npm package requires Node.js 24+.
|
# Install Node.js and npx
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& npm config set registry https://registry.npmmirror.com \
|
&& npm config set registry https://registry.npmmirror.com \
|
||||||
&& npm cache clean --force \
|
&& npm cache clean --force \
|
||||||
@ -69,14 +69,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& ln -sf /usr/bin/fdfind /usr/local/bin/fd \
|
&& ln -sf /usr/bin/fdfind /usr/local/bin/fd \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Chromium plus the validated native agent-browser CLI. PicoBot talks
|
# Install Chromium and chromedriver for browser automation
|
||||||
# to agent-browser over its JSON CLI contract; ChromeDriver/WebDriver is not used.
|
# Debian's chromium package is real (not a snap shim like Ubuntu 24.04)
|
||||||
# Debian's chromium package is real (not a snap shim like Ubuntu 24.04).
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
chromium \
|
chromium \
|
||||||
|
chromium-driver \
|
||||||
&& ln -sf /usr/bin/chromium /usr/local/bin/chrome \
|
&& ln -sf /usr/bin/chromium /usr/local/bin/chrome \
|
||||||
&& npm install -g --registry=https://registry.npmjs.org agent-browser@0.33.0 \
|
&& ln -sf /usr/bin/chromedriver /usr/local/bin/chromedriver \
|
||||||
&& npm cache clean --force \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
@ -90,9 +89,9 @@ COPY target/release/picobot /usr/local/bin/picobot
|
|||||||
# Copy config template
|
# Copy config template
|
||||||
COPY resources/templates/config.example.json /app/config.json.example
|
COPY resources/templates/config.example.json /app/config.json.example
|
||||||
|
|
||||||
# Create persistent application directories. Transient browser data stays in
|
# Create persistent application directories. Browser temporary data stays in
|
||||||
# /tmp; optional Chrome profiles live under the persisted .picobot volume.
|
# /tmp so bind-mounting /app/.picobot cannot hide its temporary directory.
|
||||||
RUN mkdir -p /app/.picobot/workspace /app/.picobot/media /app/.picobot/browser/profiles && \
|
RUN mkdir -p /app/.picobot/workspace /app/.picobot/media && \
|
||||||
chown -R app:app /app
|
chown -R app:app /app
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
@ -100,7 +99,6 @@ ENV HOME=/app
|
|||||||
|
|
||||||
# Environment variables for Chromium in containers
|
# Environment variables for Chromium in containers
|
||||||
ENV CHROME_BIN=/usr/bin/chromium
|
ENV CHROME_BIN=/usr/bin/chromium
|
||||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
|
||||||
ENV TMPDIR=/tmp
|
ENV TMPDIR=/tmp
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/picobot"]
|
ENTRYPOINT ["/usr/local/bin/picobot"]
|
||||||
|
|||||||
152
README.md
152
README.md
@ -12,12 +12,11 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
|
|||||||
- 从脚本或命令行发送一条任务,等待完整的模型/工具循环后只输出最终结果。
|
- 从脚本或命令行发送一条任务,等待完整的模型/工具循环后只输出最终结果。
|
||||||
- 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。
|
- 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。
|
||||||
- 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。
|
- 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。
|
||||||
- 在 WebUI 顶栏查看当前会话的累计输入/输出 Token、上下文窗口和占用比例。
|
|
||||||
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent;聊天页侧栏实时显示进度。
|
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent;聊天页侧栏实时显示进度。
|
||||||
- 将同一套 Agent 能力接入飞书/Lark,并可选用单张卡片实时更新回复。
|
- 将同一套 Agent 能力接入飞书/Lark,并可选用单张卡片实时更新回复。
|
||||||
- 让 Agent 使用本地文件、Shell、搜索、HTTP、浏览器、MCP 工具完成任务。
|
- 让 Agent 使用本地文件、Shell、搜索、HTTP、浏览器、MCP 工具完成任务。
|
||||||
- 把长期偏好、事实和历史摘要存成可检索记忆。
|
- 把长期偏好、事实和历史摘要存成可检索记忆。
|
||||||
- 用 Cron 运行隔离的 Root 或命名 Agent,以结构化结果决定始终通知、异常通知或静默记录。
|
- 用 Cron 定时执行任务,并把结果发回目标渠道。
|
||||||
- 通过 Skills 为 Agent 注入项目知识和专用操作指南。
|
- 通过 Skills 为 Agent 注入项目知识和专用操作指南。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@ -61,7 +60,6 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。
|
|||||||
"model_id": "gpt-4o",
|
"model_id": "gpt-4o",
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"token_limit": 128000,
|
|
||||||
"input_type": ["text", "image"]
|
"input_type": ["text", "image"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -69,7 +67,8 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。
|
|||||||
"default": {
|
"default": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "gpt-4o",
|
"model": "gpt-4o",
|
||||||
"max_tool_iterations": 99
|
"max_tool_iterations": 99,
|
||||||
|
"token_limit": 128000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspace_dir": "~/.picobot/workspace"
|
"workspace_dir": "~/.picobot/workspace"
|
||||||
@ -90,7 +89,7 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。
|
|||||||
cargo run -- gateway
|
cargo run -- gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
默认监听 `127.0.0.1:19876`。Gateway 启动后会把进程工作目录切到 `workspace_dir`,默认 SQLite 数据库写到配置目录(`~/.picobot`)`data/` 下的 `picobot.db`,与 workspace 相互独立。
|
默认监听 `127.0.0.1:19876`。Gateway 启动后会把进程工作目录切到 `workspace_dir`,默认 SQLite 数据库也会写到该 workspace 下的 `picobot.db`。
|
||||||
|
|
||||||
监听地址可通过配置文件或命令行覆盖。命令行参数优先于 `config.json`:
|
监听地址可通过配置文件或命令行覆盖。命令行参数优先于 `config.json`:
|
||||||
|
|
||||||
@ -141,20 +140,7 @@ printf '使用浏览器打开 example.com 并返回页面标题\n' | picobot run
|
|||||||
|
|
||||||
连接本机回环地址时不需要人工配对:`run` 自动读取 `~/.picobot/web_admin_token`,Gateway 只有在真实 TCP 对端也是回环地址时才允许该凭据访问 `/ws`。每次调用使用独立的临时 chat scope,不会替换正在运行的 TUI 连接。连接远程 Gateway 时仍使用 `~/.picobot/tui_auth_token` 中已有的配对令牌。
|
连接本机回环地址时不需要人工配对:`run` 自动读取 `~/.picobot/web_admin_token`,Gateway 只有在真实 TCP 对端也是回环地址时才允许该凭据访问 `/ws`。每次调用使用独立的临时 chat scope,不会替换正在运行的 TUI 连接。连接远程 Gateway 时仍使用 `~/.picobot/tui_auth_token` 中已有的配对令牌。
|
||||||
|
|
||||||
### 5.2 健康检查
|
### 5.2 使用 WebUI
|
||||||
|
|
||||||
启动 Gateway 前可检查 PicoBot 核心命令、已启用功能的依赖、stdio MCP 命令和浏览器运行环境:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picobot health
|
|
||||||
picobot health --json
|
|
||||||
```
|
|
||||||
|
|
||||||
缺少核心或当前配置要求的依赖时退出码为 `1`;`rg` / `fd` 等有回退实现的加速项只会标记为 `DEGRADED`。运行中的 Gateway 也提供 `/health` 斜杠命令,Agent 可调用同名 `health` 工具,WebUI 的“配置 → 健康检查”可显示相同的结构化结果并手动复查;这些入口共享同一套只读检查逻辑。
|
|
||||||
|
|
||||||
Debian/Ubuntu 将同一个 fd 程序安装为 `fdfind`,两者都视为首选文件搜索后端;只有退回传统 `find` 时才提示性能警告。启用浏览器工具后,Health 除了检查 agent-browser 版本和浏览器路径,还会在隔离的临时 socket namespace 中执行完整离线 doctor,分别报告浏览器安装、真实 headless 启动和运行环境,因此可发现“文件存在但 Chrome 无法启动”或缺少 Linux 共享库等问题。Gateway 内按需检查还会报告定时任务的无效 Agent/渠道引用、投递积压、最近失败/超时/unknown、静默 unknown 和执行周期覆盖;Health 不会触发任务或连接 Provider。
|
|
||||||
|
|
||||||
### 5.3 使用 WebUI
|
|
||||||
|
|
||||||
Gateway 启动后直接打开:
|
Gateway 启动后直接打开:
|
||||||
|
|
||||||
@ -178,25 +164,24 @@ docker compose exec picobot picobot pair --gateway-url http://127.0.0.1:19876
|
|||||||
|
|
||||||
不要从宿主机经发布端口直接调用签发接口;容器会把该连接识别为非回环来源并拒绝。`picobot` 已加入正式镜像的 `PATH`,可在容器 shell 中直接调用。
|
不要从宿主机经发布端口直接调用签发接口;容器会把该连接识别为非回环来源并拒绝。`picobot` 已加入正式镜像的 `PATH`,可在容器 shell 中直接调用。
|
||||||
|
|
||||||
WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件。界面采用本地实现的 Microsoft Fluent 2 视觉系统,提供语义化中性色表面、品牌蓝交互状态、统一组件层级和完整的浅色/深色主题,并提供:
|
WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供:
|
||||||
|
|
||||||
- 在线聊天、会话创建/切换、历史回放、流式 Markdown、独立思考区、实时工具状态、可折叠历史工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
|
- 在线聊天、会话创建/切换、历史回放、流式 Markdown、独立思考区、实时工具状态、可折叠历史工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
|
||||||
- 文件选择、拖放和剪贴板图片上传;消息中的附件可预览或下载。附件按服务端路径引用,原文件移动或删除后历史附件可能不可用。
|
- 文件选择、拖放和剪贴板图片上传;消息中的附件可预览或下载。附件按服务端路径引用,原文件移动或删除后历史附件可能不可用。
|
||||||
- “配置 → 外观”提供浅色/深色模式和六套 Fluent 品牌色;选择即时生效并保存在当前浏览器中,首次访问时明暗模式跟随系统偏好。
|
- 可持久化的浅色/深色主题,首次访问时跟随系统偏好。
|
||||||
- Cron 定时任务、最近运行记录和后台子任务状态。
|
- Cron 定时任务、最近运行记录和后台子任务状态。
|
||||||
- 当前聊天 session 的可展开 Todo 侧栏;计划变化时自动展开,其他 session 的变化显示未读提示。
|
- 当前聊天 session 的可展开 Todo 侧栏;计划变化时自动展开,其他 session 的变化显示未读提示。
|
||||||
- Knowledge/Timeline 记忆的分类与全文检索。
|
- Knowledge/Timeline 记忆的分类与全文检索。
|
||||||
- 本地滚动日志的尾部查看、过滤和自动刷新。
|
- 本地滚动日志的尾部查看、过滤和自动刷新。
|
||||||
- 健康检查结果:按核心与已配置功能展示通过、警告、失败及处理建议。
|
|
||||||
- `config.json`、`~/.picobot/USER.md`、`~/.picobot/AGENTS.md` 编辑。
|
- `config.json`、`~/.picobot/USER.md`、`~/.picobot/AGENTS.md` 编辑。
|
||||||
|
|
||||||
配置接口会掩码 API Key、secret、password 和 token;保留 `********` 再保存不会覆盖原密钥。Gateway 加载历史配置时会忽略可恢复的未知字段、类型不匹配字段和不再可用的非核心命名条目,并在日志、Health 和 WebUI 配置页显示对应 JSON Pointer;原始文件不会被自动修改。WebUI 的“一键清除”由后端按配置 revision 删除这些已忽略项,文件在展示后发生变化时会拒绝覆盖;普通保存仍严格拒绝包含无效项的新配置。JSON 语法错误、不可用的 `default` Agent 链路以及无法安全构造 Gateway 的错误仍会阻止启动或重载。运行配置采用原子写入,保存或清理后可执行 `picobot reload` 或发送 `/reload` 热重载;`USER.md` 和 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
配置接口会掩码 API Key、secret、password 和 token;保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入,保存后可执行 `picobot reload` 或发送 `/reload` 热重载;`USER.md` 和 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
||||||
|
|
||||||
WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对客户端;静态配对页、公开健康检查和配对提交接口除外。唯一的 WebSocket 例外是本机 `picobot run`:请求必须同时来自真实回环对端并持有权限为 `0600` 的 `~/.picobot/web_admin_token`,该管理令牌不能绕过任何管理 API 的设备鉴权。配对令牌只以 SHA-256 哈希写入 `~/.picobot/web_auth.json`。鉴权不提供传输加密;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway,仍必须使用 TLS。可通过 `gateway.require_pairing=false` 显式关闭配对,但不建议在非隔离环境使用。
|
WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对客户端;静态配对页、公开健康检查和配对提交接口除外。唯一的 WebSocket 例外是本机 `picobot run`:请求必须同时来自真实回环对端并持有权限为 `0600` 的 `~/.picobot/web_admin_token`,该管理令牌不能绕过任何管理 API 的设备鉴权。配对令牌只以 SHA-256 哈希写入 `~/.picobot/web_auth.json`。鉴权不提供传输加密;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway,仍必须使用 TLS。可通过 `gateway.require_pairing=false` 显式关闭配对,但不建议在非隔离环境使用。
|
||||||
|
|
||||||
#### WebUI 开发
|
#### WebUI 开发
|
||||||
|
|
||||||
WebUI 源码位于 `webui/`,使用 Svelte 5、Vite 和无样式的 Bits UI 可访问组件原语。Fluent 2 外观由 `webui/src/styles.css` 中的本地语义令牌与 Svelte 组件实现,不加载 CDN 或外部 UI 运行库;明暗模式和品牌色分别保存在浏览器 `picobot-theme`、`picobot-accent` 项中,并由 `theme-init.js` 在应用挂载前恢复。运行发布版 PicoBot 不需要 Node.js;从源码编译或修改前端时需要 Node.js 20+:
|
WebUI 源码位于 `webui/`,使用 Svelte 5、Vite 和无样式的 Bits UI 可访问组件原语。运行发布版 PicoBot 不需要 Node.js;从源码编译或修改前端时需要 Node.js 20+:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd webui
|
cd webui
|
||||||
@ -259,7 +244,7 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
|
|||||||
|
|
||||||
详细时序和失败语义见 [架构文档:消息与控制数据流](docs/ARCHITECTURE.md#4-消息与控制数据流)。
|
详细时序和失败语义见 [架构文档:消息与控制数据流](docs/ARCHITECTURE.md#4-消息与控制数据流)。
|
||||||
|
|
||||||
同一 session 始终只运行一个 Turn,不同 session 可以并发。Turn 执行期间新发的普通消息默认 steering 当前工作:系统在完整工具批次后或最终回复边界把它作为真实用户消息加入下一次模型调用;使用 `/queue <message>` 可明确等当前 Turn 完成后再处理,使用 `/stop` 可中断当前 Turn 并清空等待输入。Steering mailbox 和 session 队列都有界且带可靠回退。活动 Turn 使用 latest-wins 快照,慢展示端只跳过中间状态,不反压模型。普通出站消息按 `(channel, chat_id)` 分 lane 保序,两条投递路径共享目标写锁。
|
同一 session 的普通消息由专属有界队列串行处理,不同 session 可以并发;活动 Turn 使用 latest-wins 快照,慢展示端只跳过中间状态,不反压模型。普通出站消息按 `(channel, chat_id)` 分 lane 保序,两条投递路径共享目标写锁。Gateway 的长生命周期任务统一由 `TaskSupervisor` 取消和限时回收。
|
||||||
|
|
||||||
核心边界:
|
核心边界:
|
||||||
|
|
||||||
@ -273,7 +258,7 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
|
|||||||
| `delivery` | 活动 Turn 的展示过滤、latest-wins 节流、终态投递与 TurnSink 生命周期 |
|
| `delivery` | 活动 Turn 的展示过滤、latest-wins 节流、终态投递与 TurnSink 生命周期 |
|
||||||
| `tools` | Agent 可调用工具集合 |
|
| `tools` | Agent 可调用工具集合 |
|
||||||
| `storage` | SQLite schema、CRUD、消息和任务持久化 |
|
| `storage` | SQLite schema、CRUD、消息和任务持久化 |
|
||||||
| `scheduler` | 原子领取 occurrence,运行隔离的 Scheduled Agent,并通过持久化 outbox 按策略投递结构化结果 |
|
| `scheduler` | 领取定时任务,执行普通/巡检 Agent,并按投递策略记录或发送结果 |
|
||||||
| `work` | 管理 session 级单 active plan、并行子项状态和 WebSocket 变更事件 |
|
| `work` | 管理 session 级单 active plan、并行子项状态和 WebSocket 变更事件 |
|
||||||
| `skills` | 加载 Skill,并把 Skill 指南注入系统提示 |
|
| `skills` | 加载 Skill,并把 Skill 指南注入系统提示 |
|
||||||
| `mcp` | 连接 MCP Server,将远端工具包装成普通 Tool |
|
| `mcp` | 连接 MCP Server,将远端工具包装成普通 Tool |
|
||||||
@ -309,12 +294,10 @@ Session ID 使用三段式:
|
|||||||
| `/switch <dialog_id>` | 切换 dialog |
|
| `/switch <dialog_id>` | 切换 dialog |
|
||||||
| `/rename <title>` | 重命名当前 dialog |
|
| `/rename <title>` | 重命名当前 dialog |
|
||||||
| `/delete` | 删除当前 dialog 并创建新 dialog |
|
| `/delete` | 删除当前 dialog 并创建新 dialog |
|
||||||
| `/compact` | 强制把可压缩的旧完整 Turn 汇总为活动 checkpoint;不改写原始历史 |
|
| `/compact` | 手动压缩上下文 |
|
||||||
| `/info [--json]` | 查看当前 dialog、累计 Token 与上下文窗口信息;可选 JSON 输出 |
|
| `/info` | 查看当前 dialog 信息 |
|
||||||
| `/dump` | 导出当前 dialog 为 Markdown |
|
| `/dump` | 导出当前 dialog 为 Markdown |
|
||||||
| `/mcp` | 查看 MCP 服务器和工具状态 |
|
| `/mcp` | 查看 MCP 服务器和工具状态 |
|
||||||
| `/health` | 检查 PicoBot 运行依赖 |
|
|
||||||
| `/queue <message>` | 等当前 Turn 完成后再把消息作为下一 Turn 处理 |
|
|
||||||
| `/stop` | 停止当前任务并清空队列 |
|
| `/stop` | 停止当前任务并清空队列 |
|
||||||
| `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 |
|
| `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 |
|
||||||
| `/reload` | 校验并重新加载 Gateway 配置 |
|
| `/reload` | 校验并重新加载 Gateway 配置 |
|
||||||
@ -329,9 +312,7 @@ PicoBot 有两类记忆:
|
|||||||
| Knowledge | 偏好、事实、项目规则、长期可复用信息 | 长期保留,手动删除 |
|
| Knowledge | 偏好、事实、项目规则、长期可复用信息 | 长期保留,手动删除 |
|
||||||
| Timeline | 长对话压缩后的历史摘要 | 默认保留 90 天 |
|
| Timeline | 长对话压缩后的历史摘要 | 默认保留 90 天 |
|
||||||
|
|
||||||
每轮处理用户消息时,MemoryManager 会按用户输入召回 Knowledge,并作为运行时上下文附加到本轮用户消息。自动召回是确定性的关键词检索(jieba 分词 + FTS5),按「词项相关度 + 重要度 + 时效」加权并通过相关性/综合分双门槛过滤,条数受 `memory.recall_limit` 约束,搜索受 `memory.recall_timeout_ms` 硬超时保护,超时或无关时本轮不注入。长会话使用一个活动 checkpoint:累计摘要加 `first_retained_seq` 之后的原始消息尾部构成模型上下文,原始消息、工具调用结果、ID 和 seq 均不会被压缩改写。旧工具结果会保留在原始历史中,但 checkpoint 边界推进后不再永久占用 Provider 上下文。成功的语义摘要还会 best-effort 保存为 Timeline,供 `timeline_recall` 检索;Timeline 不参与会话恢复正确性。Scheduler 默认创建一个每日维护任务,按 `memory.timeline_retention_days` 清理过期 Timeline;结果通过 `complete_scheduled_run` 结构化提交,Knowledge 不会被自动删除。
|
每轮处理用户消息时,MemoryManager 会按用户输入召回 Knowledge,并作为运行时上下文附加到本轮用户消息。当前召回上限固定为 5;`memory.recall_limit` 已支持解析但尚未接入 worker。上下文压缩产生的摘要会保存为 Timeline,后续可通过 `timeline_recall` 工具检索。Scheduler 默认创建一个每日维护巡检,按 `memory.timeline_retention_days` 清理过期 Timeline;Knowledge 不会被自动删除。
|
||||||
|
|
||||||
模型的 `models.<name>.token_limit` 给出上下文窗口上限,未配置时默认为 128,000;Agent 的 `agents.<name>.token_limit` 是可选的收紧上限,两者都有配置时有效窗口取二者最小值,因此 Agent 不能扩大模型窗口。自动压缩使用保留量阈值 `context_tokens > context_window - effective_reserve`,默认 reserve 为 16,384 tokens,并尽量原样保留最近 20,000 tokens。小窗口会自动把 reserve 限制为窗口的一半、把近期保留量限制为有效阈值的一半。摘要请求不使用固定 32K 输入上限,而是按有效窗口扣除摘要输出、提示词和安全余量;超大历史只在摘要请求副本中按“已有 checkpoint + 最新消息优先”生成有界 head/tail 转录,SQLite 原文不变。手动 `/compact` 跳过自动阈值;换成小模型后若发送前预检已发现硬超限,或首次请求返回真实 context overflow,语义摘要不可用时才使用明确标记的确定性降级裁剪,正式请求最多重试一次。若 overflow 发生在工具已经执行之后,AgentLoop 只在当前内存转录上裁掉旧完整 Turn 并重试当前模型步骤一次,不会从数据库历史重跑工具。
|
|
||||||
|
|
||||||
### 工具
|
### 工具
|
||||||
|
|
||||||
@ -347,18 +328,13 @@ PicoBot 有两类记忆:
|
|||||||
| `get_skill` | 列出或读取本地 Skill |
|
| `get_skill` | 列出或读取本地 Skill |
|
||||||
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
|
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
|
||||||
| `reload_config` | 在用户明确要求时校验并重新加载 Gateway 配置 |
|
| `reload_config` | 在用户明确要求时校验并重新加载 Gateway 配置 |
|
||||||
| `delegate` | 向具名 Agent 委托单个或批量任务;`foreground` 等待结果,`background` 异步执行。批量 foreground 会并发运行并按请求顺序聚合 |
|
| `delegate` | 启动 inline、background 或 parallel 子 Agent |
|
||||||
| `agent_task` | 查询/列出/读取结果/取消已持久化的具名 Agent run(仅编排启用时注册) |
|
|
||||||
| `emit_signal` | 后台 run 向主 Agent 发送结构化内部信号(queue/steer 投递;仅带 signal 契约的 run 注册) |
|
|
||||||
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
|
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
|
||||||
| `send_message` | 向指定渠道或当前会话发送消息,可附带文件/截图;WebUI/TUI 当前 Turn 的附件并入最终回复 |
|
| `send_message` | 向指定渠道或当前会话发送消息,可附带文件/截图;WebUI/TUI 当前 Turn 的附件并入最终回复 |
|
||||||
| `chat_manager` | 查看渠道、会话和历史消息 |
|
| `chat_manager` | 查看渠道、会话和历史消息 |
|
||||||
| `cron_add/list/remove/enable/disable/update` | 管理定时任务;`agent_id` 选择 Root/命名 Agent,`delivery_policy` 支持 `always/on_alert/never` |
|
| `cron_add/list/remove/enable/disable/update` | 管理定时任务 |
|
||||||
| `cron_runs` | 查询定时任务的结构化执行结果、诊断和投递状态,包括静默任务 |
|
|
||||||
| `routine_maintenance` | 安全清理超过保留期的 Timeline,不删除 Knowledge |
|
| `routine_maintenance` | 安全清理超过保留期的 Timeline,不删除 Knowledge |
|
||||||
| `health` | 检查核心、配置相关和可选运行依赖 |
|
| `browser` | 可选 WebDriver 浏览器自动化 |
|
||||||
| `browser` | 可选 agent-browser 浏览器自动化;默认按 dialog 临时使用,长期任务可用 `persistent_id` 复用个人 Profile |
|
|
||||||
| `browser_profiles` | 创建、设置语义标签、列出或删除浏览器持久 ID 及其 Profile 目录 |
|
|
||||||
| MCP tools | 从配置的 MCP Server 动态发现并注册 |
|
| MCP tools | 从配置的 MCP Server 动态发现并注册 |
|
||||||
|
|
||||||
### Skills
|
### Skills
|
||||||
@ -380,8 +356,6 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
|||||||
| `providers` | LLM Provider 配置 |
|
| `providers` | LLM Provider 配置 |
|
||||||
| `models` | 模型参数与输入能力 |
|
| `models` | 模型参数与输入能力 |
|
||||||
| `agents` | Agent 使用哪个 provider/model |
|
| `agents` | Agent 使用哪个 provider/model |
|
||||||
| `context_compaction` | 上下文自动压缩开关、预留 token 与近期原样保留量 |
|
|
||||||
| `agent_orchestration` | 具名子 Agent 定义目录与编排上限 |
|
|
||||||
| `gateway` | HTTP/WebSocket、数据库、调度器、后台任务限制 |
|
| `gateway` | HTTP/WebSocket、数据库、调度器、后台任务限制 |
|
||||||
| `client` | CLI 客户端默认 Gateway URL |
|
| `client` | CLI 客户端默认 Gateway URL |
|
||||||
| `channels` | 渠道配置,目前主要是飞书/Lark |
|
| `channels` | 渠道配置,目前主要是飞书/Lark |
|
||||||
@ -400,18 +374,10 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
|||||||
| `gateway.max_concurrent_background_tasks` | `10` |
|
| `gateway.max_concurrent_background_tasks` | `10` |
|
||||||
| `gateway.scheduler.enabled` | `true` |
|
| `gateway.scheduler.enabled` | `true` |
|
||||||
| `client.gateway_url` | `ws://127.0.0.1:19876/ws` |
|
| `client.gateway_url` | `ws://127.0.0.1:19876/ws` |
|
||||||
| `context_compaction.enabled` | `true` |
|
| `memory.recall_limit` | `5`(当前运行时固定为 5) |
|
||||||
| `context_compaction.reserve_tokens` | `16384` |
|
|
||||||
| `context_compaction.keep_recent_tokens` | `20000` |
|
|
||||||
| `memory.recall_limit` | `5` |
|
|
||||||
| `memory.recall_min_relevance` | `0.25` |
|
|
||||||
| `memory.recall_min_score` | `0.25` |
|
|
||||||
| `memory.recall_recency_half_life_days` | `30` |
|
|
||||||
| `memory.recall_timeout_ms` | `1000` |
|
|
||||||
| `memory.timeline_retention_days` | `90` |
|
| `memory.timeline_retention_days` | `90` |
|
||||||
| `mcp.tool_timeout_secs` | `180` |
|
| `mcp.tool_timeout_secs` | `180` |
|
||||||
| `mcp.servers[].tool_settings` | `{}`;可按工具名声明 `read_only` / `exclusive`,并发状态自动推导 |
|
| `browser.enabled` | `false` |
|
||||||
| `browser.enabled` | `true` |
|
|
||||||
| `channels.feishu.live_updates` | `false` |
|
| `channels.feishu.live_updates` | `false` |
|
||||||
| `channels.feishu.live_update_interval_ms` | `500` |
|
| `channels.feishu.live_update_interval_ms` | `500` |
|
||||||
| `channels.feishu.require_mention` | `true` |
|
| `channels.feishu.require_mention` | `true` |
|
||||||
@ -420,87 +386,8 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
|||||||
| `channels.feishu.media_dir_max_bytes` | `536870912` |
|
| `channels.feishu.media_dir_max_bytes` | `536870912` |
|
||||||
| `channels.feishu.request_timeout_secs` | `30` |
|
| `channels.feishu.request_timeout_secs` | `30` |
|
||||||
|
|
||||||
### 具名子 Agent(Phase 1)
|
|
||||||
|
|
||||||
PicoBot 在 Gateway 候选运行代构造时从配置目录下的 `definitions_dir` 加载 `*.md`(子 Agent 编排是内在机制,始终启用)。相对路径按 `config.json` 所在目录解析,且 canonical path 不得逃逸该目录;角色文件、Provider profile、工具、Skill 和委托边任一无效都会拒绝启动或热重载。支持具名 `foreground`(单/批量)与 Root 发起的具名 `background`(单任务或 `tasks[]` 批量):每个 run 独立落库、预留 completion 槽、完成后由主 Agent 的 continuation Turn 单独汇总(空闲时完成即返回),可配合 `emit_signal`(queue/steer)推送内部信号。子 Agent 发起的 background 尚未开放(旧匿名 general 已移除)。
|
|
||||||
|
|
||||||
```md
|
|
||||||
---
|
|
||||||
id: researcher
|
|
||||||
description: 搜索、阅读并整理技术资料
|
|
||||||
llm_profile: research
|
|
||||||
tools:
|
|
||||||
- file_read
|
|
||||||
- file_search
|
|
||||||
- content_search
|
|
||||||
delegates:
|
|
||||||
- reviewer
|
|
||||||
limits:
|
|
||||||
timeout_secs: 900
|
|
||||||
max_iterations: 24
|
|
||||||
---
|
|
||||||
# Role
|
|
||||||
|
|
||||||
你是一名严谨的研究 Agent,只返回与任务有关的结论和证据。
|
|
||||||
```
|
|
||||||
|
|
||||||
每个具名 Agent 的工具集完全由其 Markdown `tools` 列表决定(管理员显式授权),不再有工具侧的可派发门槛;也可内联 `provider`/`model` 直接指定模型(或沿用 `llm_profile` 引用顶层 `agents` key)。`delegate`/`emit_signal`/`get_skill`/`agent_task` 为运行时注入工具,不能写进 `tools`(分别由 `delegates`/`signal`/`skills` 字段派生),`get_skill` 例外作为启用 scoped skill 的开关。每个定义可用 `enabled: false` 单独禁用(保留在磁盘但不加载)。主 Agent 可委托给任意具名子 Agent;子 Agent 能否继续委托由 `delegates` 决定——不写该字段时默认仅可委托内置 `general-purpose`,写 `[]` 表示不可继续委托,写 `["*"]` 表示可委托任意子代理,写列表则按列表指定(self 与祖先在运行时始终被拒绝)。WebUI「子 Agent」页可直接增删改定义、启停并选择工具/Skill/Provider/Model 与委托范围。
|
|
||||||
|
|
||||||
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。
|
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。
|
||||||
|
|
||||||
## agent-browser 安装与使用
|
|
||||||
|
|
||||||
PicoBot 不再使用 Fantoccini、ChromeDriver 或 WebDriver。上层仍暴露一个稳定的 `browser` 工具,底层通过 agent-browser `0.33.0` 的 JSON CLI 驱动原生 Rust daemon 和 Chrome CDP。先安装 CLI 与浏览器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 推荐;npm 只负责安装预编译 CLI
|
|
||||||
npm install -g agent-browser@0.33.0
|
|
||||||
agent-browser install
|
|
||||||
|
|
||||||
# Linux 需要同时补齐系统库时
|
|
||||||
agent-browser install --with-deps
|
|
||||||
|
|
||||||
# 或直接通过 Rust 工具链安装
|
|
||||||
cargo install agent-browser --version 0.33.0 --locked
|
|
||||||
agent-browser install
|
|
||||||
|
|
||||||
# macOS 也可使用 Homebrew
|
|
||||||
brew install agent-browser
|
|
||||||
agent-browser install
|
|
||||||
```
|
|
||||||
|
|
||||||
已有 Chrome/Chromium 时可在配置中设置 `browser_executable_path`,或通过 `AGENT_BROWSER_EXECUTABLE_PATH` 指定。Docker 镜像已固定安装 agent-browser `0.33.0` 与 Debian Chromium,不包含 ChromeDriver。启用示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"browser": {
|
|
||||||
"enabled": true,
|
|
||||||
"command": "agent-browser",
|
|
||||||
"headless": true,
|
|
||||||
"browser_executable_path": null,
|
|
||||||
"max_sessions": 4,
|
|
||||||
"idle_timeout_secs": 3600,
|
|
||||||
"command_timeout_secs": 120,
|
|
||||||
"max_output_chars": 50000,
|
|
||||||
"content_boundaries": true,
|
|
||||||
"allowed_domains": [],
|
|
||||||
"allow_private_hosts": false,
|
|
||||||
"artifact_dir": "~/.picobot/media/browser",
|
|
||||||
"persistence": {
|
|
||||||
"profile_dir": "~/.picobot/browser/profiles"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
浏览器没有全局“持久模式”开关,而是按每次调用分流:不传 `persistent_id` 时使用当前 dialog 的普通临时浏览器,连续一小时没有操作后默认自动关闭;涉及长期工作、需要保持浏览器进程或保留登录和站点状态时,Agent 可以自主调用 `browser_profiles(create,label=...)` 生成 `picobot-profile-<uuid>`,并在该工作的后续每个 `browser` action 中持续传入同一个 ID。持久浏览器禁用 daemon 空闲自动关闭,只会在显式 `browser(close)` 或 Profile 删除时关闭。也可以用 `set_label` 随时修改语义化标签。PicoBot 不设置默认 ID,也不会按 dialog 自动选择持久 Profile。同一 ID 跨 dialog 共享 agent-browser session 和串行锁,不同 ID 使用各自的 session、锁与 Chrome Profile,因而可以并发操作。Cookie、localStorage、IndexedDB、Service Worker、缓存和标签随各自目录持久化。
|
|
||||||
|
|
||||||
`browser_profiles` 支持 `create`、`set_label`、`list`、`delete`;`list` 返回 ID、标签、目录和 active 状态,浏览器仍始终用不可变 ID 选择,重命名标签不会破坏现有调用。Profile 根目录位于 `~/.picobot` 内,现有 Docker `picobot_data` 卷会一并持久化。agent-browser 无法同时保证 Profile 复用与 `allowed_domains` 域名隔离;配置非空白名单后,普通临时浏览器仍可用,持久身份的创建和使用会被拒绝,health 会给出可选能力警告。
|
|
||||||
|
|
||||||
缺少 agent-browser 或 Chrome 不阻止 Gateway 启动,但实际调用会失败并给出安装提示,`picobot health` 也会提前报告。修改后建议先运行 health,再启动或重载 Gateway。旧的 `webdriver_url`、`chrome_path` 配置已删除,出现这两个字段时配置校验会明确失败。实际使用仍由 Agent 调用 `browser`:`open` → `snapshot` 获取 `@e1` 等引用 → `click` / `fill` / `type` → 页面变化后重新 `snapshot`。截图保存到受控产物目录,通过统一工具输出管线交给多模态模型,并默认附到本轮最终回复给用户查看,不再生成 Base64 工具文本;仅需模型内部检查时可显式设置 `present_to_user=false`。
|
|
||||||
|
|
||||||
详细开发分层、进程协议、并发/安全边界和故障语义见 [agent-browser 集成设计](docs/AGENT_BROWSER_INTEGRATION.md)。
|
|
||||||
|
|
||||||
## WebSocket API
|
## WebSocket API
|
||||||
|
|
||||||
Gateway 暴露:
|
Gateway 暴露:
|
||||||
@ -587,6 +474,7 @@ docs/ 面向维护者和 Agent 的架构与开发文档
|
|||||||
| `reqwest` | LLM 和 HTTP 客户端 |
|
| `reqwest` | LLM 和 HTTP 客户端 |
|
||||||
| `ratatui`, `crossterm`, `termimad` | 终端 UI |
|
| `ratatui`, `crossterm`, `termimad` | 终端 UI |
|
||||||
| `rmcp` | MCP 客户端 |
|
| `rmcp` | MCP 客户端 |
|
||||||
|
| `fantoccini` | 可选浏览器自动化 |
|
||||||
| `cron`, `chrono-tz` | 定时任务 |
|
| `cron`, `chrono-tz` | 定时任务 |
|
||||||
| `jieba-rs` | 中文记忆检索分词 |
|
| `jieba-rs` | 中文记忆检索分词 |
|
||||||
| `zstd`, `tar` | 内置 Skill 打包和释放 |
|
| `zstd`, `tar` | 内置 Skill 打包和释放 |
|
||||||
|
|||||||
41
build.rs
41
build.rs
@ -13,24 +13,6 @@ fn main() {
|
|||||||
let skills_out_dir = Path::new(&out_dir).join("skills");
|
let skills_out_dir = Path::new(&out_dir).join("skills");
|
||||||
fs::create_dir_all(&skills_out_dir).unwrap();
|
fs::create_dir_all(&skills_out_dir).unwrap();
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=resources/agents");
|
|
||||||
let agents_dir = Path::new("resources/agents");
|
|
||||||
let agents_out_dir = Path::new(&out_dir).join("agents");
|
|
||||||
fs::create_dir_all(&agents_out_dir).unwrap();
|
|
||||||
let mut agents = Vec::new();
|
|
||||||
if let Ok(entries) = fs::read_dir(agents_dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let agent_name = path.file_stem().unwrap().to_str().unwrap().to_string();
|
|
||||||
fs::copy(&path, agents_out_dir.join(format!("{agent_name}.md"))).unwrap();
|
|
||||||
agents.push(agent_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
agents.sort();
|
|
||||||
|
|
||||||
let mut skills = Vec::new();
|
let mut skills = Vec::new();
|
||||||
|
|
||||||
if let Ok(entries) = fs::read_dir(skills_dir) {
|
if let Ok(entries) = fs::read_dir(skills_dir) {
|
||||||
@ -76,29 +58,6 @@ pub static EMBEDDED_SKILLS: &[EmbeddedSkill] = &[
|
|||||||
let generated_path = Path::new(&out_dir).join("embedded_skills.rs");
|
let generated_path = Path::new(&out_dir).join("embedded_skills.rs");
|
||||||
let mut f = fs::File::create(&generated_path).unwrap();
|
let mut f = fs::File::create(&generated_path).unwrap();
|
||||||
f.write_all(code.as_bytes()).unwrap();
|
f.write_all(code.as_bytes()).unwrap();
|
||||||
|
|
||||||
let mut agent_code = String::from(
|
|
||||||
r#"pub struct EmbeddedAgent {
|
|
||||||
pub name: &'static str,
|
|
||||||
pub content: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub static EMBEDDED_AGENTS: &[EmbeddedAgent] = &[
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
for name in &agents {
|
|
||||||
let file_path = agents_out_dir
|
|
||||||
.join(format!("{name}.md"))
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
agent_code.push_str(&format!(
|
|
||||||
" EmbeddedAgent {{ name: \"{name}\", content: include_str!(\"{file_path}\") }},\n",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
agent_code.push_str("];\n");
|
|
||||||
let agent_path = Path::new(&out_dir).join("embedded_agents.rs");
|
|
||||||
let mut f = fs::File::create(&agent_path).unwrap();
|
|
||||||
f.write_all(agent_code.as_bytes()).unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_webui(out_dir: &Path) {
|
fn build_webui(out_dir: &Path) {
|
||||||
|
|||||||
22
config.json
22
config.json
@ -12,7 +12,6 @@
|
|||||||
"model_id": "qwen-plus",
|
"model_id": "qwen-plus",
|
||||||
"temperature": 0.0,
|
"temperature": 0.0,
|
||||||
"max_tokens": 100,
|
"max_tokens": 100,
|
||||||
"token_limit": 128000,
|
|
||||||
"input_type": ["text"]
|
"input_type": ["text"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -20,28 +19,13 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"provider": "aliyun",
|
"provider": "aliyun",
|
||||||
"model": "qwen-plus",
|
"model": "qwen-plus",
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20,
|
||||||
|
"token_limit": 128000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agent_orchestration": {
|
|
||||||
"definitions_dir": "agents",
|
|
||||||
"max_tree_depth": 4,
|
|
||||||
"max_runs_per_tree": 16,
|
|
||||||
"max_concurrent_runs": 6,
|
|
||||||
"max_concurrent_runs_per_session": 4,
|
|
||||||
"max_concurrent_provider_steps": 8,
|
|
||||||
"max_concurrent_provider_steps_per_session": 4,
|
|
||||||
"max_concurrent_tool_steps": 16,
|
|
||||||
"max_concurrent_tool_steps_per_session": 8,
|
|
||||||
"max_pending_inbox_events_per_session": 128,
|
|
||||||
"inbox_event_ttl_hours": 168,
|
|
||||||
"max_inbox_delivery_attempts": 8,
|
|
||||||
"max_user_turn_burst_before_inbox": 4,
|
|
||||||
"max_inbox_wait_secs": 30
|
|
||||||
},
|
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 19877,
|
"port": 19876,
|
||||||
"require_pairing": true
|
"require_pairing": true
|
||||||
},
|
},
|
||||||
"channels": {},
|
"channels": {},
|
||||||
|
|||||||
@ -3,7 +3,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: picobot:1.4.0
|
image: picobot:1.3.0
|
||||||
container_name: picobot-test
|
container_name: picobot-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -1,210 +0,0 @@
|
|||||||
# agent-browser 集成设计
|
|
||||||
|
|
||||||
本文描述 PicoBot 1.4.0 的浏览器工具实现。目标是在保持模型侧稳定 `browser` 协议的同时,用 agent-browser 完全替代 Fantoccini、ChromeDriver 和 WebDriver,并让临时会话、单用户多持久 Profile、管理操作、产物和健康检查拥有明确边界。
|
|
||||||
|
|
||||||
## 1. 选择与边界
|
|
||||||
|
|
||||||
PicoBot 使用 agent-browser CLI 的 `--json` 协议,不直接链接其内部 crate,也不把 agent-browser MCP Server 原样暴露给模型。
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- agent-browser 是原生 Rust CLI + daemon,daemon 通过 Chrome CDP 驱动浏览器;CLI 进程很短,浏览器状态跨命令保存在 daemon 中。
|
|
||||||
- CLI 是项目的稳定公开边界,PicoBot 不需要依赖 agent-browser 的内部 Rust 模块布局。
|
|
||||||
- PicoBot 包装层可统一管理个人浏览器身份、限制并发和输出、校验 URL、控制 Profile/截图目录,并把图片接入统一 `ToolOutput` 后处理管线。
|
|
||||||
- 直接暴露 MCP 会让 session ID、文件路径、输出规模和安全策略落到模型参数中,也难以自动绑定当前 PicoBot dialog。
|
|
||||||
|
|
||||||
这不是把浏览器逻辑重新实现一遍。元素定位、accessibility snapshot、页面交互、Chrome 启动、CDP 通信和 daemon 生命周期均由 agent-browser 负责;PicoBot 只负责编排和边界控制。
|
|
||||||
|
|
||||||
## 2. 分层
|
|
||||||
|
|
||||||
```text
|
|
||||||
AgentLoop
|
|
||||||
│ ToolExecutionContext(session_id, turn_id)
|
|
||||||
▼
|
|
||||||
BrowserTool / BrowserProfilesTool 浏览与持久 Profile 管理 schema
|
|
||||||
▼
|
|
||||||
BrowserManager 临时 dialog session / 共享持久 Profile
|
|
||||||
├─ security URL、DNS、私网与 allowlist 前置校验
|
|
||||||
├─ action browser action → CLI argv
|
|
||||||
└─ AgentBrowserRunner timeout、env、--json、错误与输出解析
|
|
||||||
▼
|
|
||||||
agent-browser CLI → Rust daemon → Chrome/Chromium CDP
|
|
||||||
```
|
|
||||||
|
|
||||||
源文件:
|
|
||||||
|
|
||||||
- `src/tools/browser/mod.rs`:`browser`、`browser_profiles` schema 与入口。
|
|
||||||
- `src/tools/browser/action.rs`:严格参数解析和 argv 映射。
|
|
||||||
- `src/tools/browser/manager.rs`:临时会话表、按 ID 管理的持久 Profile、并发、回收和截图媒体。
|
|
||||||
- `src/tools/browser/runner.rs`:无 Shell 的子进程调用、硬超时、JSON/错误解析。
|
|
||||||
- `src/tools/browser/security.rs`:导航策略。
|
|
||||||
- `src/tools/traits.rs`:向有状态工具提供 `ToolExecutionContext`;其他工具沿用默认实现。
|
|
||||||
|
|
||||||
## 3. 会话、持久 ID 与并发
|
|
||||||
|
|
||||||
BrowserManager 按每次调用是否携带 `persistent_id` 分流,两种浏览器可在同一个 Gateway 中同时使用:
|
|
||||||
|
|
||||||
- 省略 `persistent_id` 时使用普通临时浏览器。`SessionManager` 传入完整 PicoBot session ID,BrowserManager 第一次看到该 ID 时生成随机、不透明的 `picobot-<uuid>` agent-browser session。同一 dialog 串行、不同 dialog 可并发;临时 daemon 连续 `idle_timeout_secs`(默认 3600 秒)没有操作后自动退出,Manager 在容量检查时惰性回收空闲条目,数量受 `max_sessions` 限制。
|
|
||||||
- 需要长期保留登录或站点状态时,Agent 可以自主调用 `browser_profiles(action=create,label=...)` 创建持久身份,并在后续相关的每个 `browser` action 中显式传入返回的 `persistent_id`。
|
|
||||||
|
|
||||||
- 用户可以拥有多个 `picobot-profile-<32 hex>` ID。`browser_profiles(action=create,label=...)` 创建 `persistence.profile_dir/<id>` 专用目录和可选语义标签;标签可通过 `set_label` 重命名,ID 保持不变。
|
|
||||||
- PicoBot 不保存默认 ID,也不按 dialog 自动选择或绑定持久 Profile。没有 ID 的调用始终回到该 dialog 的临时浏览器,不会隐式选中任何持久身份。
|
|
||||||
- 每个 ID 分别映射 agent-browser `--session`、Chrome `--profile` 路径和 mutex。同一 ID 可从不同 dialog、子 Agent 或 Scheduler 使用并保持串行;不同 ID 的浏览器状态和锁相互独立,可以并发。
|
|
||||||
- 持久 daemon 禁用空闲自动退出,适合需要长时间等待的浏览器作业。ID 与 Profile 目录跨 Gateway 重启、配置重载和显式浏览器关闭保持不变;Cookie、localStorage、IndexedDB、Service Worker 和缓存由各 Chrome Profile 自身保存。
|
|
||||||
- `close` 关闭显式选择的浏览器进程但保留 ID、标签和 Profile;下一次使用该 ID 时从相同目录重新打开。
|
|
||||||
- `browser_profiles(action=list)` 返回每个合法 ID 的标签、完整目录和当前 Manager 是否 active。
|
|
||||||
- `browser_profiles(action=set_label,id=...,label=...)` 写入语义标签并同步当前活动实例;标签去除首尾空白,限制为 1–80 个非控制字符。
|
|
||||||
- `browser_profiles(action=delete,id=...)` 只接受 `create/list` 返回的完整格式 ID。删除时持有管理锁、等待该 ID 的活动 action、尝试关闭其浏览器,再递归删除对应目录。
|
|
||||||
|
|
||||||
Profile 根目录和每个生成目录在 Unix 上收敛为 `0700`,目录内 `.picobot-label` 标签文件为 `0600`。列表忽略格式非法的目录和符号链接,选择、改标签和删除拒绝路径穿越、符号链接及非目录目标。Gateway 配置重载会构造新的 ToolRegistry/BrowserManager;旧运行代按现有 drain 规则退出。临时 agent-browser daemon 的空闲退出时间通过 `AGENT_BROWSER_IDLE_TIMEOUT_MS` 设置为配置值,持久 daemon 则固定设置为 `0`(禁用)。
|
|
||||||
|
|
||||||
## 4. Action 映射
|
|
||||||
|
|
||||||
| PicoBot action | agent-browser 命令 |
|
|
||||||
|---|---|
|
|
||||||
| `open` | `open <url>` |
|
|
||||||
| `snapshot` | `snapshot --interactive --compact [--depth N]` |
|
|
||||||
| `click` / `fill` / `type` | 同名命令;无 selector 的 type 使用 `keyboard type` |
|
|
||||||
| `get_text` / `get_title` / `get_url` | `get text/title/url` |
|
|
||||||
| `focus` / `wait` / `press` / `hover` / `scroll` | 对应原生命令 |
|
|
||||||
| `click_at` | `mouse move` + `mouse down left` + `mouse up left` |
|
|
||||||
| `screenshot` | `screenshot <controlled-path> [--full] [--annotate]` |
|
|
||||||
| `close` | `close` |
|
|
||||||
|
|
||||||
每次调用都使用 argv 数组直接启动进程,不经过 Shell。`fill` / `type` 的内容不会写入 PicoBot 日志;日志只记录 action 名、是否绑定 session 和所选持久 ID。
|
|
||||||
|
|
||||||
Runner 固定传入 `--session`、`--json` 和明确的 headed 状态;携带持久 ID 的调用额外传入由 Manager 生成的 `--profile <controlled-path>`。Runner 还设置:
|
|
||||||
|
|
||||||
- `AGENT_BROWSER_EXECUTABLE_PATH`(配置后)
|
|
||||||
- `AGENT_BROWSER_CONTENT_BOUNDARIES`
|
|
||||||
- `AGENT_BROWSER_MAX_OUTPUT`
|
|
||||||
- `AGENT_BROWSER_ALLOWED_DOMAINS`(非空时)
|
|
||||||
- `AGENT_BROWSER_IDLE_TIMEOUT_MS`(临时会话使用配置值;持久会话固定为 `0`)
|
|
||||||
|
|
||||||
非零退出码、JSON 中 `success=false`、无效 JSON和超时都转换为工具失败。stdout/stderr 在返回模型前有长度上限;页面类结果保留 agent-browser `_boundary` 元数据。
|
|
||||||
|
|
||||||
## 5. 截图与媒体
|
|
||||||
|
|
||||||
截图绝不返回 Base64。调用方可省略 `path` 自动生成文件名,也可提供单个 `.png` 文件名;绝对路径、目录分隔、`.` 和 `..` 均拒绝。实际文件始终位于 `browser.artifact_dir`。
|
|
||||||
|
|
||||||
命令成功后 PicoBot 再验证文件存在、是普通文件且非空,然后返回:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ToolOutput {
|
|
||||||
result: ToolResult { output: "Screenshot saved: ..." },
|
|
||||||
artifacts: [ToolArtifact {
|
|
||||||
media_ref: MediaRef { media_type: "image", path: "..." },
|
|
||||||
audience: ModelAndUser
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
统一处理器会把截图同时交给下一轮多模态 Provider,并附到本 Turn 最终 assistant 回复供用户查看。`present_to_user` 默认为 `true`;显式设为 `false` 时 audience 改为 `Model`,适用于无需展示的内部视觉检查。历史中仍只保存短路径清单,不产生 Base64 上下文膨胀。
|
|
||||||
|
|
||||||
## 6. 安全模型
|
|
||||||
|
|
||||||
默认策略:
|
|
||||||
|
|
||||||
- 只允许 `http://` 和 `https://`,拒绝 URL userinfo。
|
|
||||||
- `allow_private_hosts=false` 时拒绝 localhost、`.local`、回环、私网、link-local、未指定和组播地址;域名会先解析 DNS,任一结果为私网即拒绝。
|
|
||||||
- `allowed_domains` 非空时 PicoBot 先校验首个 URL,agent-browser 再对导航、重定向、子资源、WebSocket、EventSource、sendBeacon 和受支持 Chromium 的 WebRTC 实施域名边界。
|
|
||||||
- 默认开启 content boundaries,并把页面文本限制为 50,000 字符。
|
|
||||||
- 包装层没有 `eval`、上传、下载、cookie/storage 写入或任意 agent-browser 命令透传,模型只能使用 allowlisted action。
|
|
||||||
- 截图有单独的产物目录,不能用来覆盖任意文件。
|
|
||||||
- `browser_profiles` 只允许创建受控 ID,或按格式严格的 ID 设置受限标签、列出和删除 `persistence.profile_dir` 的直接子目录;模型不能提交任意 Profile 路径。
|
|
||||||
|
|
||||||
`allowed_domains=[]` 表示不启用 agent-browser 域名过滤,适合通用浏览;这不是 OS 网络沙箱。需要强隔离时,应同时设置明确域名表和容器/主机 egress 策略。允许私网浏览是显式配置,适合本地应用测试,但会扩大 SSRF 风险。
|
|
||||||
|
|
||||||
agent-browser 0.33.0 明确拒绝在 `allowed_domains` 启用时复用 Chrome Profile,因为无法保证页面脚本执行前完整安装同等域名约束。PicoBot 因此允许受域名限制的普通临时浏览器继续工作,但会拒绝创建或使用持久 Profile,并在 health 中给出可选能力警告;列表、改标签和删除仍可用于管理已有目录。Profile 包含可直接代表用户身份的登录信息,目录必须视作敏感凭据,不得提交版本控制或跨用户共享。
|
|
||||||
|
|
||||||
## 7. 安装
|
|
||||||
|
|
||||||
验证版本为 `0.33.0`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g agent-browser@0.33.0
|
|
||||||
agent-browser install
|
|
||||||
```
|
|
||||||
|
|
||||||
Linux 自动补系统依赖:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
agent-browser install --with-deps
|
|
||||||
```
|
|
||||||
|
|
||||||
不使用 npm 时:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo install agent-browser --version 0.33.0 --locked
|
|
||||||
agent-browser install
|
|
||||||
```
|
|
||||||
|
|
||||||
macOS 也可执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install agent-browser
|
|
||||||
agent-browser install
|
|
||||||
```
|
|
||||||
|
|
||||||
`agent-browser install` 下载 Chrome for Testing。已有浏览器时设置:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"browser_executable_path": "/usr/bin/chromium"
|
|
||||||
```
|
|
||||||
|
|
||||||
或设置环境变量 `AGENT_BROWSER_EXECUTABLE_PATH`。agent-browser 的 daemon 与 CDP 路径不需要 Node.js;npm 安装方式只需要 npm 用来放置预编译 CLI。Dockerfile 安装 Debian Chromium、`agent-browser@0.33.0` 并设置 executable path。
|
|
||||||
|
|
||||||
## 8. 使用
|
|
||||||
|
|
||||||
1. 浏览器工具默认启用;从旧配置删除 `webdriver_url`、`chrome_path`,仅在需要改变持久目录位置时配置 `persistence.profile_dir`。没有持久化模式开关。缺少依赖不会阻止 Gateway 启动,只会让 health 和实际浏览器调用失败。
|
|
||||||
2. 运行 `picobot health`;应看到 agent-browser CLI 版本、浏览器安装、隔离的 offline headless 启动和环境检查通过。
|
|
||||||
3. 启动或重载 Gateway。
|
|
||||||
4. 对 Agent 说“使用浏览器打开 …”。模型的推荐动作序列是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
browser(open, url)
|
|
||||||
browser(snapshot, interactive_only=true, compact=true)
|
|
||||||
browser(click/fill/type, selector=@eN)
|
|
||||||
browser(snapshot) # 页面改变后刷新 refs
|
|
||||||
browser(screenshot, annotate=true) # 需要视觉上下文时
|
|
||||||
browser(close)
|
|
||||||
```
|
|
||||||
|
|
||||||
agent-browser 的 `@e` 引用属于当前页面快照。导航、弹窗或 DOM 大幅变化后必须重新 snapshot,不能长期缓存旧引用。
|
|
||||||
|
|
||||||
持久 Profile 管理:
|
|
||||||
|
|
||||||
```text
|
|
||||||
browser_profiles(create, label="工作账号") # 返回 persistent ID
|
|
||||||
browser_profiles(list)
|
|
||||||
browser_profiles(set_label, id=picobot-profile-..., label="个人账号")
|
|
||||||
browser(open, url, persistent_id=picobot-profile-...)
|
|
||||||
browser(snapshot, persistent_id=picobot-profile-...)
|
|
||||||
browser_profiles(delete, id=picobot-profile-...)
|
|
||||||
```
|
|
||||||
|
|
||||||
同一个持久操作链必须持续传入同一 `persistent_id`;一旦省略,调用会明确转到当前 dialog 的普通临时浏览器。不同对话可以同时操作不同 ID。标签用于识别,不能代替 ID 选择浏览器;删除会清除该 ID 的全部浏览器数据、标签和登录态,调用前必须有用户要求并使用 `create/list` 返回的精确 ID。
|
|
||||||
|
|
||||||
## 9. Health 三入口
|
|
||||||
|
|
||||||
`src/health.rs` 的 `HealthService` 是唯一检查实现:
|
|
||||||
|
|
||||||
- `picobot health [--json]`:本机运维入口;核心/配置必需项失败时退出 `1`。
|
|
||||||
- `/health`:当前 Gateway 配置的聊天入口。
|
|
||||||
- `health` Tool:Agent 可调用的只读入口,支持 `json=true`。
|
|
||||||
|
|
||||||
检查项包括 workspace、Bash、内容/文件搜索后端、可选 systemctl、配置中的 stdio MCP 命令,以及浏览器启用时的持久 Profile 可用性、agent-browser 版本、显式浏览器路径和完整的 offline doctor。Health 为 doctor 创建临时 socket 目录并使用专用 namespace,不读取或清理活动 daemon socket;不使用会跳过真实启动测试的 `--quick`。结构化结果分别展示浏览器安装、headless launch 和其余环境诊断。检查不创建或删除 PicoBot 持久 Profile、不安装软件、不执行 `doctor --fix`、不访问 Provider API,也不输出配置密钥。
|
|
||||||
|
|
||||||
## 10. 迁移和故障处理
|
|
||||||
|
|
||||||
- 配置使用 `deny_unknown_fields`;遗留 WebDriver 字段会在加载时失败,而不是被静默忽略。
|
|
||||||
- `failed to start 'agent-browser'`:CLI 不在 PATH,或 `browser.command` 错误;运行 health。
|
|
||||||
- doctor 失败:运行 `agent-browser doctor` 查看完整诊断,再安装浏览器/系统库。
|
|
||||||
- session limit:仅临时模式适用;关闭不再使用的 dialog 浏览器,或调整 `max_sessions`。
|
|
||||||
- persistence policy:非空 `allowed_domains` 下普通临时浏览器仍可用,但创建或使用持久 Profile 会失败;根据需求选择个人登录态复用或严格域名隔离。
|
|
||||||
- Profile 删除失败:先确认没有外部 Chrome 使用该目录,再用 `browser_profiles(list)` 核对精确 ID 后重试;不要手工扩大删除路径。
|
|
||||||
- domain blocked:补充站点和必要 CDN 域名;不要用空白 allowlist 绕过生产隔离策略。
|
|
||||||
- command timeout:确认页面/浏览器未卡死,再按部署风险调整 `command_timeout_secs`。
|
|
||||||
- 截图不存在:视为工具失败,不构造失效 MediaRef。
|
|
||||||
|
|
||||||
Fantoccini crate、旧 `src/tools/browser.rs` WebDriver 实现、ChromeDriver Docker 包和相关配置已全部删除。Cargo 不链接 agent-browser;它是由 health 管理的外部运行依赖。
|
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
|
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
|
||||||
|
|
||||||
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。已实施的 checkpoint 上下文压缩、pi 风格 reserve 阈值、统一编排和 overflow 失败语义见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。具名子 Agent、委托图、后台收件箱、结果传递机制与 `queue`/`steer` 信号的设计见 [SUB_AGENT_DESIGN.md](SUB_AGENT_DESIGN.md)。已实施的统一 Scheduled Run、结构化终结协议、中央投递和 v11 数据库迁移见 [SCHEDULED_RUN_DESIGN.md](SCHEDULED_RUN_DESIGN.md)。
|
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。
|
||||||
|
|
||||||
## 1. 设计目标
|
## 1. 设计目标
|
||||||
|
|
||||||
@ -18,14 +18,13 @@ PicoBot 是一个单进程、异步、可扩展的个人 AI 助手运行时。
|
|||||||
|
|
||||||
## 2. 运行模式与进程边界
|
## 2. 运行模式与进程边界
|
||||||
|
|
||||||
PicoBot 只有一个二进制,提供四种运行模式:
|
PicoBot 只有一个二进制,提供三种运行模式:
|
||||||
|
|
||||||
| 模式 | 入口 | 职责 |
|
| 模式 | 入口 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、提供嵌入式 WebUI,运行渠道、会话、调度器和后台任务 |
|
| Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、提供嵌入式 WebUI,运行渠道、会话、调度器和后台任务 |
|
||||||
| CLI client | `cargo run -- chat` | 运行 Ratatui UI,通过 WebSocket 使用 Gateway,不持有业务状态 |
|
| CLI client | `cargo run -- chat` | 运行 Ratatui UI,通过 WebSocket 使用 Gateway,不持有业务状态 |
|
||||||
| One-shot client | `cargo run -- run "prompt"` | 使用独立临时 chat scope 通过 WebSocket 提交一条消息,等待 Turn 终态,输出结果后退出 |
|
| One-shot client | `cargo run -- run "prompt"` | 使用独立临时 chat scope 通过 WebSocket 提交一条消息,等待 Turn 终态,输出结果后退出 |
|
||||||
| Health diagnostic | `cargo run -- health [--json]` | 只读检查核心、配置相关和可选外部依赖,不启动 Gateway 或连接 Provider |
|
|
||||||
|
|
||||||
Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。
|
Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。
|
||||||
|
|
||||||
@ -74,11 +73,10 @@ flowchart LR
|
|||||||
| `agent` | 单次无状态模型/工具循环、上下文压缩、子 Agent、Turn 语义事件 | 持有 dialog 生命周期 |
|
| `agent` | 单次无状态模型/工具循环、上下文压缩、子 Agent、Turn 语义事件 | 持有 dialog 生命周期 |
|
||||||
| `providers` | 把统一请求映射为原生模型流,并归一化正文、reasoning、工具和 usage | Session、Bus 或 Channel 感知 |
|
| `providers` | 把统一请求映射为原生模型流,并归一化正文、reasoning、工具和 usage | Session、Bus 或 Channel 感知 |
|
||||||
| `delivery` | 活动 Turn 快照投影、latest-wins 节流、终态重试和 TurnSink 生命周期 | Provider 协议、会话历史、平台 API 细节 |
|
| `delivery` | 活动 Turn 快照投影、latest-wins 节流、终态重试和 TurnSink 生命周期 | Provider 协议、会话历史、平台 API 细节 |
|
||||||
| `tools` / `mcp` | 工具定义、注册和执行适配;MCP 工具的本地执行属性声明 | 隐式修改会话路由 |
|
| `tools` / `mcp` | 工具定义、注册和执行适配 | 隐式修改会话路由 |
|
||||||
| `health` | 聚合只读依赖检查,供 CLI、Tool 与 slash command 复用 | 安装、修复或连接 Provider |
|
|
||||||
| `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
|
| `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
|
||||||
| `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 |
|
| `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 |
|
||||||
| `scheduler` | 原子领取 occurrence、运行隔离的 Root/命名 Agent、提交结构化结果并 drain 持久化投递 outbox | 解析模型自然语言、绕过 Bus 直接调用 Channel |
|
| `scheduler` | 领取到期任务、执行普通/巡检 Agent、应用投递策略、原子记录结果 | 复用聊天会话历史、直接感知 Channel |
|
||||||
| `work` | session 级单 active plan、并行子项状态机、版本和变更事件 | 执行模型调用、持有 Channel/WebSocket |
|
| `work` | session 级单 active plan、并行子项状态机、版本和变更事件 | 执行模型调用、持有 Channel/WebSocket |
|
||||||
| `task_supervisor` | 后台任务注册、取消、限时回收 | 业务级重试和结果语义 |
|
| `task_supervisor` | 后台任务注册、取消、限时回收 | 业务级重试和结果语义 |
|
||||||
|
|
||||||
@ -107,16 +105,11 @@ sequenceDiagram
|
|||||||
C->>B: publish InboundMessage
|
C->>B: publish InboundMessage
|
||||||
B->>G: consume inbound
|
B->>G: consume inbound
|
||||||
G->>S: handle_message
|
G->>S: handle_message
|
||||||
S->>W: try_send AgentTask (idle or /queue)
|
S->>W: try_send AgentTask
|
||||||
S-->>G: AgentProcessing
|
S-->>G: AgentProcessing
|
||||||
W->>T: start Turn
|
W->>T: start Turn
|
||||||
W->>L: subscribe latest snapshots
|
W->>L: subscribe latest snapshots
|
||||||
W->>A: process_streaming(history)
|
W->>A: process_streaming(history)
|
||||||
C->>B: ordinary input during active Turn
|
|
||||||
B->>G: consume inbound
|
|
||||||
G->>S: handle_message
|
|
||||||
S->>A: bounded steering mailbox
|
|
||||||
A->>A: drain after tool batch / before final
|
|
||||||
A-->>T: reasoning/text/tool events
|
A-->>T: reasoning/text/tool events
|
||||||
T-->>L: complete TurnSnapshot
|
T-->>L: complete TurnSnapshot
|
||||||
L->>C: TurnSink update (best effort)
|
L->>C: TurnSink update (best effort)
|
||||||
@ -132,14 +125,11 @@ sequenceDiagram
|
|||||||
|
|
||||||
关键语义:
|
关键语义:
|
||||||
|
|
||||||
- Gateway inbound router 按 `(channel, chat_id)` 使用容量 32 的短生命周期 lane 保持入口顺序,不同聊天可并发路由;没有活动 Turn 时,普通消息进入对应 session worker 后立即返回 `AgentProcessing`。
|
- Gateway inbound router 按 `(channel, chat_id)` 使用容量 32 的短生命周期 lane 保持入口顺序,不同聊天可并发路由;普通消息进入对应 session worker 后立即返回 `AgentProcessing`。
|
||||||
- `/stop` 绕过同聊天的 inbound lane,直接使正在运行的 worker/Turn 失效;其他 slash command 仍在聊天 lane 内有序执行。
|
- `/stop` 绕过同聊天的 inbound lane,直接使正在运行的 worker/Turn 失效;其他 slash command 仍在聊天 lane 内有序执行。
|
||||||
- 活动 Turn 存在时,普通消息默认作为 steering 进入本 Turn 的有界 mailbox;`/queue <message>` 明确进入下一 Turn。AgentLoop 只在完整工具批次结束后、或准备接受无工具最终回复时排空 mailbox,并把输入作为真实、可持久化的 `role=user` 消息加入下一次模型请求。
|
- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
|
||||||
- Steering mailbox 最多容纳 32 条、合计 64 KiB 文本与元数据。mailbox 已关闭或满时,输入可靠回退到 session 队列;若 session 队列也满则明确拒绝。Session 在入站时分配单调序号,Turn 结束时未消费的 steering 由 worker 本地恢复队列接管,并与 `/queue` 输入按该序号合并选择,不能丢失或互相超越。
|
|
||||||
- 每个 session 有一条容量为 32 的普通队列,同一 session 仍只运行一个 Turn,不同 session 的 worker 可并发执行。
|
|
||||||
- 队列满时明确拒绝新消息,不允许无界积压。
|
- 队列满时明确拒绝新消息,不允许无界积压。
|
||||||
- Slash command 通常不进入 Agent 队列,由 `SessionManager` 直接执行;`/queue` 是显式排队输入,`/stop` 是显式中断并清空当前 mailbox 与队列。
|
- Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。
|
||||||
- WebSocket `user_input.client_message_id` 只用于让 `turn_committed` 以同一消息 ID 替换 WebUI 的乐观用户气泡;它不改变入站顺序或 steering/queue 决策。
|
|
||||||
- `InboundMessage` 只保存规范化输入:`sender_id`、`received_at`、媒体和一个 `ChannelContext`。核心只解释其中的 `reply_to`,其语义是本轮出站应回复的当前入站消息;被用户引用的父消息只用于补充模型上下文。reaction/message ID、话题 root/thread 等平台字段作为 `private` 不透明传到对应 Turn/普通回复,不能散落为核心层 magic key。持久化的用户消息保留真实接收时间和 `UserInput` 来源,客户端历史投影不暴露来源中的平台用户 ID。
|
- `InboundMessage` 只保存规范化输入:`sender_id`、`received_at`、媒体和一个 `ChannelContext`。核心只解释其中的 `reply_to`,其语义是本轮出站应回复的当前入站消息;被用户引用的父消息只用于补充模型上下文。reaction/message ID、话题 root/thread 等平台字段作为 `private` 不透明传到对应 Turn/普通回复,不能散落为核心层 magic key。持久化的用户消息保留真实接收时间和 `UserInput` 来源,客户端历史投影不暴露来源中的平台用户 ID。
|
||||||
- Session 为每个 Agent 请求创建一个 `TurnController`。Provider 向 AgentLoop 发 delta,AgentLoop 发结构化 TurnEvent,只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。
|
- Session 为每个 Agent 请求创建一个 `TurnController`。Provider 向 AgentLoop 发 delta,AgentLoop 发结构化 TurnEvent,只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。
|
||||||
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins;慢客户端或慢渠道跳过中间状态,不反压 Provider。终态明确编码在快照中,不依赖 sender 关闭。
|
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins;慢客户端或慢渠道跳过中间状态,不反压 Provider。终态明确编码在快照中,不依赖 sender 关闭。
|
||||||
@ -171,18 +161,6 @@ sequenceDiagram
|
|||||||
|
|
||||||
不要把“已进入 Bus”误认为“外部渠道已收到”。需要确认语义时必须使用 `deliver_outbound`。
|
不要把“已进入 Bus”误认为“外部渠道已收到”。需要确认语义时必须使用 `deliver_outbound`。
|
||||||
|
|
||||||
### Scheduled Run
|
|
||||||
|
|
||||||
Scheduler 不复用聊天历史,也不根据模型正文猜测是否通知。一次到期状态在同一 SQLite 事务中取得 Job 租约、插入 `job_runs(status=claimed)`、快照 Agent/投递目标/策略,并提前推进 recurring `next_run_at`;`At` 在 claim 时立即禁用。事件循环以独立有界 JoinSet 执行 Run 和 drain delivery,长任务不阻塞其他领取或通知。
|
|
||||||
|
|
||||||
每次 Run 通过 `AgentCoordinator` 建立顶层 `agent_runs` 审计记录并执行隔离的 Root 或命名 Agent。Scheduled origin 贯穿所有后代,但只有顶层获得 exactly-once `complete_scheduled_run` sink;后代不继承 sink。Scheduled Agent 不获得 `send_message`、cron/config 管理或 `emit_signal`,不创建 Inbox completion slot,任何 background 委托都收敛为 foreground。普通最终文本不代表成功;没有提交 `ok/alert/failed/refused` 之一即 fail-closed。
|
|
||||||
|
|
||||||
顶层 AgentRun 终态、JobRun 的 lifecycle/outcome/message/diagnostic、Job 最近摘要、初始 delivery status 和租约释放在一个事务中提交。`always` 投递所有 outcome,`on_alert` 只抑制结构化 `ok`,`never` 始终只记录。`job_runs` 同时作为轻量 outbox:`pending → delivering → delivered/failed`,瞬态错误最多进行三次持久化尝试;OutboundDispatcher 返回清洗后的类型化回执,Scheduler 不解析错误字符串。
|
|
||||||
|
|
||||||
首次投递把目标 dialog 固定到 `target_session_id`,并先用稳定消息 ID `scheduled:<job_run_id>` 幂等写入本地历史,再调用 `MessageBus::deliver_outbound`。发送成功但 ack 提交前崩溃允许带稳定 metadata 的重复通知,不能为避免重复而丢失告警。启动恢复把遗留 claimed/running JobRun 原子收敛为 `unknown+unknown`、关联非终态 AgentRun 收敛为 interrupted,并按 claim-time 策略决定是否进入 outbox;已推进的 occurrence 不自动重跑。完整状态矩阵、v11 schema 和迁移规则见 [SCHEDULED_RUN_DESIGN.md](SCHEDULED_RUN_DESIGN.md)。
|
|
||||||
|
|
||||||
按需 Health 检查只读查询任务引用、投递积压、最近失败/超时/unknown、`never+unknown`、Every 周期被执行时长覆盖以及不可计算的 next run;它不执行任务、不连接 Provider,也不修改数据。
|
|
||||||
|
|
||||||
### Control 消息
|
### Control 消息
|
||||||
|
|
||||||
WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway control router 以最多 64 个并发受监督任务调用 `SessionManager`,再将 `SessionEvent` 回传给发起者;慢 control 不阻塞其他聊天的 inbound 路由。Bus 只承载消息,不解释操作。
|
WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway control router 以最多 64 个并发受监督任务调用 `SessionManager`,再将 `SessionEvent` 回传给发起者;慢 control 不阻塞其他聊天的 inbound 路由。Bus 只承载消息,不解释操作。
|
||||||
@ -212,26 +190,25 @@ Session ID 格式为:
|
|||||||
4. 慢操作开始前记录 `state_version`,提交前重新验证,防止旧快照覆盖 `/clear`、`/delete` 等并发修改。
|
4. 慢操作开始前记录 `state_version`,提交前重新验证,防止旧快照覆盖 `/clear`、`/delete` 等并发修改。
|
||||||
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
|
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
|
||||||
6. 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
|
6. 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
|
||||||
7. Steering 的接收、最终边界关闭和 `/stop` 必须通过同一个 mailbox 状态串行化;每条输入只能落入当前 Turn 或下一 Turn 之一。
|
|
||||||
|
|
||||||
当前 WebUI/TUI Turn 通过 `send_message(files=...)` 向自身 session 投递文件时,文件先进入 task-local Turn delivery 暂存区,成功结束后附加到最终 assistant 消息,与工具链一起原子提交;因此持久化和刷新后的顺序都是工具调用/结果在前、携带附件的最终回复在后,也不会生成带 `[message from ...]` 的自投递气泡。其他同 Turn 自投递仍是受控例外:只有 task-local Turn ID 仍匹配该 session 的 active Turn,写入才允许不递增 `state_version`。跨 Turn、跨 session 以及无法证明所有权的写入仍必须递增版本。Provider 回放历史附件时,只有 user 输入和当前工具结果可生成模型原生媒体块;assistant/system 附件只回放文本清单,避免把图片放到供应商不接受的角色。
|
当前 WebUI/TUI Turn 通过 `send_message(files=...)` 向自身 session 投递文件时,文件先进入 task-local Turn delivery 暂存区,成功结束后附加到最终 assistant 消息,与工具链一起原子提交;因此持久化和刷新后的顺序都是工具调用/结果在前、携带附件的最终回复在后,也不会生成带 `[message from ...]` 的自投递气泡。其他同 Turn 自投递仍是受控例外:只有 task-local Turn ID 仍匹配该 session 的 active Turn,写入才允许不递增 `state_version`。跨 Turn、跨 session 以及无法证明所有权的写入仍必须递增版本。Provider 回放历史附件时,只有 user 输入和当前工具结果可生成模型原生媒体块;assistant/system 附件只回放文本清单,避免把图片放到供应商不接受的角色。
|
||||||
|
|
||||||
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、可选的 active plan 摘要,以及由活动 `ContextCheckpoint` 投影出的会话历史。`messages` 始终是 append-only 原始日志;每个 Session 最多有一个活动 checkpoint,模型历史确定为“一条累计摘要 + `seq >= first_retained_seq` 的原始尾部”,Timeline 只是 checkpoint 提交后的 best-effort 检索副本,恢复流程不读取 Timeline、也不调用 Provider。所选 Model 的可选 `token_limit` 定义上下文硬上限,缺失时固定回退 128000;Agent 的可选 `token_limit` 只允许收紧该上限,有效窗口为 `min(agent_token_limit, model_token_limit_or_128K)`,Agent 不能扩大模型窗口。主 Agent Profile、具名 Agent 的内联 Provider/Model 和 `llm_profile` 引用必须使用同一规则,definition 上的显式值也只能收紧已解析 Profile。`session::turn_input` 只在 Session 锁外并行读取 Knowledge 和 active plan;完整请求草稿随后使用同一个 reserve 预算评估,自动触发公式唯一为 `context_tokens > context_window - effective_reserve`。手动、自动和首次 context-overflow 共用唯一的 `compact_session_context` 编排与 checkpoint CAS 提交路径;candidate 自带快照 generation,提交成功后必须从当前 raw log 重新投影,不能返回摘要前的旧尾部向量。摘要请求输入预算由有效 `token_limit` 扣除动态摘要输出、固定提示词和安全余量得到;超大压缩源只在 request-local 副本中保留已有 checkpoint 和最新材料、对单条内容做确定性 head/tail 截取,不能用固定 32K 上限拒绝压缩,也不能改写 durable raw log。每个 Turn 最多进行一次语义摘要;换模后的发送前预检若已硬超限,可在摘要失败时直接生成明确标记的确定性降级 checkpoint,避免先发送必然失败的普通请求;首次 Provider 请求 overflow 最多正式重试一次。AgentLoop 把 Provider overflow 转成类型化错误:工具尚未执行时交回 Session;工具已执行后只在同一个 AgentLoop 中删除旧完整 Turn 的请求副本并重试当前 Provider step 一次,保留本 Turn tool call/result,绝不从 durable history 重启并重复副作用工具。首次请求与重试必须复用同一个 runtime assembly,不能复制系统提示、丢失 mailbox steering 或把压缩投影写回原始历史。Provider prompt usage 仅在 provider/model/checkpoint generation/raw seq 和完整请求摘要都匹配时复用,否则完整保守估算;不再按消息数外推。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。完整压缩设计见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。
|
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。
|
||||||
|
|
||||||
当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。具名子 Agent 基础已接入:候选运行代从受信任配置目录严格加载不可变 `AgentCatalog`,Definition 固定 Provider/Model(内联或 `llm_profile`)、工具/Skill allowlist、委托边和执行限制,工具集完全由定义文件决定;单个定义校验失败(坏 YAML、未知 provider/profile/model/tool/skill、或显式委托到缺失目标)仅停用该定义并记入 `load_errors`(`GET /api/agents` 返回),不会阻塞启动或热重载,配置与目录信任级错误仍然致命;`delegate` 使用 `foreground/background` 两个 canonical 生命周期词,批量并发与生命周期正交。具名 foreground 支持批量并发、显式 `AgentExecutionContext`、祖先环路和委托边校验;Root 对具名 Agent 的 background(单任务或批量,批量并发、每个 run 独立 completion 事件)走 durable run/inbox + continuation 投递,空闲时完成即返回。内置 general-purpose 定义随二进制释放,WebUI「子 Agent」页可增删改与启停定义。旧匿名 general 兼容路径已移除。自动标题属于非关键派生工作:Turn 持久化完成后由 `TaskSupervisor` 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
|
当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。后台子 Agent 的 `TaskNotification` 表达跨 Turn 的任务完成,仍由独立的受监督消费者投递。自动标题属于非关键派生工作:Turn 持久化完成后由 `TaskSupervisor` 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
|
||||||
|
|
||||||
每个 session 最多有一个 active plan,但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 的工具集由其定义文件决定,能否继续委托由其 `delegates` 白名单决定。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
|
每个 session 最多有一个 active plan,但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 不获得 `todo` 或 `delegate`。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
|
||||||
|
|
||||||
## 6. 持久化
|
## 6. 持久化
|
||||||
|
|
||||||
`Storage` 使用 SQLx + SQLite,默认数据库为 `{config_dir}/data/picobot.db`(`config_dir` 默认 `~/.picobot`),与 workspace 相互独立。连接启用:
|
`Storage` 使用 SQLx + SQLite,默认数据库为 `{workspace_dir}/picobot.db`。连接启用:
|
||||||
|
|
||||||
- WAL journal mode。
|
- WAL journal mode。
|
||||||
- foreign keys。
|
- foreign keys。
|
||||||
- 5 秒 busy timeout。
|
- 5 秒 busy timeout。
|
||||||
- schema version 迁移。
|
- schema version 迁移。
|
||||||
|
|
||||||
持久化范围包括 sessions、messages、context checkpoints、session turn usage、memories、task plans/items、scheduled jobs、job runs、agent run/inbox/session state。checkpoint 插入、活动指针切换和 `context_generation` 递增在同一事务中完成;`/clear` 在删除消息的事务内使活动 checkpoint 失效,旧 checkpoint 行仅作为审计记录保留。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status;私有 Provider 状态不进入 WebSocket/Channel,且只允许回放给同一 Provider。成功 Turn 的 Provider usage 与消息批次在同一事务中写入 `session_turn_usage`,以 `turn_id` 幂等累计会话输入、输出、缓存输入和请求数;升级前的历史没有可归属 usage,统计起点必须显式呈现。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence;客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要,不保存正文、reasoning 或签名 payload。修改 schema 时应:
|
持久化范围包括 sessions、messages、memories、task plans/items、scheduled jobs、job runs 和 background tasks。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status;私有 Provider 状态不进入 WebSocket/Channel,且只允许回放给同一 Provider。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence;客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要,不保存正文、reasoning 或签名 payload。修改 schema 时应:
|
||||||
|
|
||||||
1. 更新集中式 schema/迁移逻辑。
|
1. 更新集中式 schema/迁移逻辑。
|
||||||
2. 保留已有数据库的升级路径。
|
2. 保留已有数据库的升级路径。
|
||||||
@ -248,7 +225,7 @@ SessionManager 负责组装会话上下文:系统提示、Skills、召回的 K
|
|||||||
|
|
||||||
## 7. 后台任务与生命周期
|
## 7. 后台任务与生命周期
|
||||||
|
|
||||||
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。inbound/control routers、inbound lanes、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、自动标题和子 Agent 后台任务都应通过它注册。
|
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。inbound/control routers、inbound lanes、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、后台任务通知消费者、自动标题和子 Agent 后台任务都应通过它注册。
|
||||||
|
|
||||||
两种注册方式:
|
两种注册方式:
|
||||||
|
|
||||||
@ -268,24 +245,17 @@ Turn delivery 使用 `spawn_graceful`。全局取消发生时先停止读取快
|
|||||||
|
|
||||||
### WebUI 与管理 API
|
### WebUI 与管理 API
|
||||||
|
|
||||||
Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。视觉层通过 `webui/src/styles.css` 中的本地 Fluent 2 语义令牌实现浅色/深色表面、六套品牌色、状态色、层级和控件状态;页面组件必须复用语义别名,不能把独立硬编码调色板或外部 Fluent 运行库引入发布产物。明暗模式和品牌色只保存在浏览器 `localStorage`,`theme-init.js` 必须在 Svelte 挂载前恢复 `data-theme` 与 `data-accent`,防止首屏颜色闪烁;这些外观选项不属于 Gateway 配置,也不跨设备同步。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;活动状态栏通过结构化 `session_stats` 展示当前 session 的已提交 Turn 用量与上下文占用,累计量来自 Provider usage,窗口占用明确区分精确匹配的 Provider 实测与完整字符估算;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。
|
Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。
|
||||||
|
|
||||||
WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。
|
WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。
|
||||||
|
|
||||||
所有工具调用统一归一化为 `ToolOutput`,并由 `AgentLoop` 中唯一的 `ToolOutputProcessor` 后处理。普通文本工具仍实现 `ToolResult`,默认转换会将其包装为无产物的 `ToolOutput`;产物工具返回带 `ToolArtifact` 的输出,并用 `Model`、`User` 或 `ModelAndUser` 声明受众。处理器只发布成功工具的产物,去重后分别形成下一轮模型媒体和最终用户回复附件。工具只负责经过自身路径策略校验后声明产物与意图,不感知当前模型、Provider、Session 或 Channel。`AgentLoop` 仅将最新连续工具结果批次的模型媒体交给 `MediaHandlerRegistry`,紧随工具结果的 steering 不会使该批次媒体失去可见性,而旧工具媒体只回放文本和路径,避免历史 Base64 膨胀;用户媒体累积到本 Turn 最终 assistant 消息,随工具链原子持久化,并由 committed-history 或普通出站路径呈现。OpenAI-compatible Provider 保持 `tool` 结果为文本,并在完整工具批次后构造仅存在于请求内的临时多模态 `user` 消息,同时保持后续 user steering 的顺序;Anthropic Provider 将同批媒体放入对应 `tool_result.content`,并将紧随的 user steering 合并进 API 所需的同一 `role=user` 内容数组,持久化消息仍彼此独立。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。
|
工具默认通过 `ToolResult` 返回文本;需要把图片等产物交给模型时,通过 `Tool::execute_with_media` 返回文本和结构化 `MediaRef`。工具只负责经过自身路径策略校验后声明媒体,不感知当前模型或 Provider。`AgentLoop` 仅将最新连续工具结果批次的媒体交给 `MediaHandlerRegistry`,旧工具媒体只回放文本和路径,避免历史 Base64 膨胀。OpenAI-compatible Provider 保持 `tool` 结果为文本,并在完整工具批次后构造仅存在于请求内的临时多模态 `user` 消息;Anthropic Provider 将媒体放入对应 `tool_result.content`。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。
|
||||||
|
|
||||||
MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>` 命名空间,避免与内置工具混淆;`tool_settings` 仍按 MCP 原始 `<tool-name>` 键入。MCP 协议不提供 PicoBot 可依赖的副作用或并发契约,因此每个 `mcp.servers[].tool_settings.<tool-name>` 可在受信任本地配置中声明 `read_only` 与 `exclusive`。未声明的 MCP 工具保守地按“可能有副作用、顺序执行”处理。`concurrency_safe` 不保存为独立状态,而是严格由 `read_only && !exclusive` 推导;工具批次只有全部工具满足该条件才允许并发执行。WebUI 的 MCP 工具展开项提供这两个声明的复选框,并将推导结果显示为“可并发”;属性编辑先保留在页面草稿中,只有选择“保存并应用”才原子写入配置并触发一次热重载,离开 MCP 标签或刷新页面会丢弃草稿。
|
|
||||||
|
|
||||||
`browser` 是有状态工具适配器:`BrowserTool` 保持模型侧 action schema,`BrowserManager` 按每次调用是否带 `persistent_id` 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session,并用每 session mutex 保证同一页面串行、不同 dialog 并发;临时 daemon 按 `browser.idle_timeout_secs`(默认一小时)自动退出,Manager 在容量检查时惰性回收对应空闲条目。长期工作需要保持浏览器进程或保留登录和站点状态时,Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex,持久 daemon 的空闲超时固定为禁用;同一 ID 跨 dialog 共享且串行,不同 ID 相互独立并可并发,Gateway 重启或显式关闭浏览器后仍可继续使用原 Profile;没有全局持久化开关、默认 ID 或按 dialog 隐式选择。`browser_profiles` 在受控根目录下创建、设置语义化标签、列出或删除格式合法的 ID;标签只负责识别,选择仍使用不可变 ID,删除活动 ID 时先等待其 action 并关闭浏览器。`AgentBrowserRunner` 以 argv 和 `--json` 调用外部原生 CLI,设置硬超时、输出/content boundaries/domain allowlist,并在持久调用中传入受控 `--profile` 路径,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。持久 Profile 与 `allowed_domains` 因上游安全边界互斥;设置域名限制时临时浏览器仍可用,持久调用会被拒绝。截图只能写入配置的 artifact directory,并作为 `ModelAndUser` 产物返回,默认附到最终用户回复;仅当调用显式设置 `present_to_user=false` 时才作为模型内部观察。完整边界见 [AGENT_BROWSER_INTEGRATION.md](AGENT_BROWSER_INTEGRATION.md)。
|
|
||||||
|
|
||||||
`HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具、`/health` 斜杠命令和 WebUI“配置 → 健康检查”必须复用它;受鉴权的 `GET /api/health` 按需返回 `HealthReport`,公开 `GET /health` 仍只承担轻量在线与版本探测,避免页面常驻轮询反复执行外部诊断命令。检查可探测命令、版本和配置路径;`fd` 与 Debian/Ubuntu 的同程序命令名 `fdfind` 都是首选后端,传统 `find` 才是降级回退。浏览器检查使用临时 socket 目录和专用 namespace 运行完整的 agent-browser offline doctor,将浏览器安装、真实 headless 启动和其余环境问题拆分报告,既不接触活动 daemon socket,也不跳过 launch probe。Health 不能安装/修复软件、连接模型 API 或泄漏配置秘密。
|
|
||||||
|
|
||||||
`AuthManager` 默认保护所有管理 API 和 `/ws`。静态资源、`/health`、`/api/auth/status` 与 `/api/auth/pair` 保持公开,使未配对浏览器只能加载配对界面。`picobot pair` 使用权限为 `0600` 的本机管理密钥调用仅接受真实回环连接的 `/api/auth/code`;反向代理即使从回环连接也无法在没有该密钥时签发代码。本机 `picobot run` 可用同一个管理密钥直接认证 `/ws`,但中间件必须同时验证请求路径严格等于 `/ws` 且 `ConnectInfo` 中的真实 TCP 对端为回环地址;这一身份不能访问管理 API。远程 `run` 与 TUI 一样使用已配对的 Bearer token。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict Cookie,CLI 使用 Bearer token;服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。
|
`AuthManager` 默认保护所有管理 API 和 `/ws`。静态资源、`/health`、`/api/auth/status` 与 `/api/auth/pair` 保持公开,使未配对浏览器只能加载配对界面。`picobot pair` 使用权限为 `0600` 的本机管理密钥调用仅接受真实回环连接的 `/api/auth/code`;反向代理即使从回环连接也无法在没有该密钥时签发代码。本机 `picobot run` 可用同一个管理密钥直接认证 `/ws`,但中间件必须同时验证请求路径严格等于 `/ws` 且 `ConnectInfo` 中的真实 TCP 对端为回环地址;这一身份不能访问管理 API。远程 `run` 与 TUI 一样使用已配对的 Bearer token。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict Cookie,CLI 使用 Bearer token;服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。
|
||||||
|
|
||||||
同源 `/api/*` 管理接口只提供显式白名单能力:
|
同源 `/api/*` 管理接口只提供显式白名单能力:
|
||||||
|
|
||||||
- `GET /api/health` 返回当前运行代 `HealthService` 的完整只读报告;它只在用户进入健康检查页或手动刷新时运行,不属于 Gateway 在线探测轮询。
|
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。`POST /api/config/reload` 通过同一重载控制器校验并切换 Gateway 运行代,`GET /api/config/reload/status` 查询 generation、相位与最近错误。
|
||||||
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。配置加载器在不修改原始文件的前提下,从请求内副本移除可恢复的未知字段、类型/枚举错误和失效的非核心命名条目,生成有效 `Config`、RFC 6901 诊断路径和原始文件 SHA-256 revision;数组元素另外保留原始索引映射,避免恢复过程中索引移动导致清理错位。`models.*` 的 `flatten` Provider 扩展参数以及 MCP 的 `env`/`headers`/`tool_settings` 动态键属于显式扩展面,不作为未知项。JSON 损坏、不可用的 `default` Agent Provider/Model 链路和候选运行代无法安全构造仍是致命错误。普通 `PUT /api/config` 保持严格,不允许写入会被忽略的新配置;`POST /api/config/cleanup-invalid` 只按同一加载器报告的路径清理,并在 revision 不匹配时返回冲突。`POST /api/config/reload` 通过同一重载控制器校验并切换 Gateway 运行代,`GET /api/config/reload/status` 查询 generation、相位与最近错误。
|
|
||||||
- `USER.md`、`AGENTS.md` 只允许固定文件名,不接受任意路径。
|
- `USER.md`、`AGENTS.md` 只允许固定文件名,不接受任意路径。
|
||||||
- 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为 `~/.picobot/logs`。
|
- 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为 `~/.picobot/logs`。
|
||||||
- 任务与记忆读取复用 Storage API,不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。
|
- 任务与记忆读取复用 Storage API,不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。
|
||||||
@ -298,7 +268,7 @@ MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>`
|
|||||||
|
|
||||||
### 启动
|
### 启动
|
||||||
|
|
||||||
1. 解析配置路径,加载配置目录 `.env`,据此定位 workspace,再加载 workspace `.env`;既有进程环境保持最高优先级,合并后通过统一容错加载器重新解析配置。可恢复项只影响有效运行时投影并产生诊断,原始 `config.json` 保持不变。
|
1. 解析配置路径,加载配置目录 `.env`,据此定位 workspace,再加载 workspace `.env`;既有进程环境保持最高优先级,合并后重新解析配置。
|
||||||
2. 初始化日志,创建并切换到 workspace,初始化 WebUI 配对存储与本机管理密钥。
|
2. 初始化日志,创建并切换到 workspace,初始化 WebUI 配对存储与本机管理密钥。
|
||||||
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
||||||
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
||||||
@ -334,9 +304,6 @@ MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>`
|
|||||||
3. 明确工具需要“workspace 默认目录”还是“不可逃逸的硬边界”;后者必须显式校验 canonical path,不能只依赖 cwd。
|
3. 明确工具需要“workspace 默认目录”还是“不可逃逸的硬边界”;后者必须显式校验 canonical path,不能只依赖 cwd。
|
||||||
4. 网络工具必须保留 SSRF/私网地址校验。
|
4. 网络工具必须保留 SSRF/私网地址校验。
|
||||||
5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。
|
5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。
|
||||||
6. 需要跨调用保存外部状态时,实现 `execute_with_context` 并按 session 隔离;不得让模型控制底层全局 session ID。
|
|
||||||
|
|
||||||
没有模型可调用的前台 `sleep`/等待工具:Agent 等待异步工作时,应结束当前 Turn 让排队完成/信号开启续接 Turn,或轮询状态工具。Turn 进入 `Cancelled` 时仍必须把 `Running` 的工具块同步归约为 `Cancelled`。需要跨重启的可靠延迟必须使用 Scheduler/后台任务。
|
|
||||||
|
|
||||||
### 新增 Provider
|
### 新增 Provider
|
||||||
|
|
||||||
|
|||||||
@ -150,8 +150,8 @@ sequenceDiagram
|
|||||||
准备阶段在旧运行代继续提供服务时执行:
|
准备阶段在旧运行代继续提供服务时执行:
|
||||||
|
|
||||||
1. `load_candidate()` 重新读取当前 Gateway 启动时确定的配置文件。
|
1. `load_candidate()` 重新读取当前 Gateway 启动时确定的配置文件。
|
||||||
2. 使用启动环境快照和启动 cwd 解析 `.env`、占位符与相对 workspace;统一配置加载器在请求内副本上忽略可恢复的未知字段、类型错误和失效非核心条目,并保留原始 JSON Pointer 诊断,不改写磁盘文件。
|
2. 使用启动环境快照和启动 cwd 解析 `.env`、占位符与相对 workspace。
|
||||||
3. 校验 default agent 能解析为完整 `LLMProviderConfig`;核心链路不可恢复时仍拒绝候选。
|
3. 校验 default agent 能解析为完整 `LLMProviderConfig`。
|
||||||
4. 若飞书启用,校验 `app_id` 和 `app_secret` 非空。
|
4. 若飞书启用,校验 `app_id` 和 `app_secret` 非空。
|
||||||
5. 比较不可热变更字段。
|
5. 比较不可热变更字段。
|
||||||
6. 调用 `GatewayState::from_config()` 构造候选运行代。
|
6. 调用 `GatewayState::from_config()` 构造候选运行代。
|
||||||
@ -271,7 +271,7 @@ Gateway 在首次调用 `Config::load_from()` 前保存:
|
|||||||
| 失败阶段 | 行为 |
|
| 失败阶段 | 行为 |
|
||||||
|----------|------|
|
|----------|------|
|
||||||
| 已有 pending 重载/控制器关闭 | 分别返回 409/503,不读取配置 |
|
| 已有 pending 重载/控制器关闭 | 分别返回 409/503,不读取配置 |
|
||||||
| JSON、`.env`、占位符或默认 Agent 校验失败 | 返回错误,旧运行代保持不变;可恢复的历史字段问题只产生诊断,不进入此失败分支 |
|
| JSON、`.env`、占位符或默认 Agent 校验失败 | 返回错误,旧运行代保持不变 |
|
||||||
| 不可热变更字段发生变化 | 返回 restart-required 错误,旧运行代保持不变 |
|
| 不可热变更字段发生变化 | 返回 restart-required 错误,旧运行代保持不变 |
|
||||||
| `GatewayState::from_config()` 构造失败 | 返回错误;候选被丢弃,其 TaskSupervisor 随对象释放取消;旧请求处理运行代保持不变 |
|
| `GatewayState::from_config()` 构造失败 | 返回错误;候选被丢弃,其 TaskSupervisor 随对象释放取消;旧请求处理运行代保持不变 |
|
||||||
| 单个 MCP Server 连接或工具发现失败 | 记录 MCP 失败状态,候选继续构造且不注册该 Server 的工具;这不视为整体 reload 失败 |
|
| 单个 MCP Server 连接或工具发现失败 | 记录 MCP 失败状态,候选继续构造且不注册该 Server 的工具;这不视为整体 reload 失败 |
|
||||||
@ -282,7 +282,7 @@ Gateway 在首次调用 `Config::load_from()` 前保存:
|
|||||||
|
|
||||||
准备阶段成功后才向调用者返回 accepted。激活阶段仍可能遇到运行时错误,因此调用者不应把 accepted 当作健康检查;可使用 `GET /api/config/reload/status` 等待相同 generation 进入 `active`,并结合 `/health` 与客户端重连确认。
|
准备阶段成功后才向调用者返回 accepted。激活阶段仍可能遇到运行时错误,因此调用者不应把 accepted 当作健康检查;可使用 `GET /api/config/reload/status` 等待相同 generation 进入 `active`,并结合 `/health` 与客户端重连确认。
|
||||||
|
|
||||||
WebUI `PUT /api/config` 只负责严格校验、原子写文件、恢复被掩码的 secret 并返回 `restart_required: true`;它不会允许新提交内容依赖容错忽略,也不会隐式触发重载。`GET /api/config` 返回原始文件 SHA-256 revision 和可恢复诊断;`POST /api/config/cleanup-invalid` 必须提交相同 revision,后端才会在共享配置写锁下原子删除已诊断路径,revision 变化返回 409。显式的 `POST /api/config/reload` 将文件写入与运行代切换解耦,使用户可以批量编辑或清理后主动决定生效时机。
|
WebUI `PUT /api/config` 只负责原子写文件、恢复被掩码的 secret 并返回 `restart_required: true`;它不会隐式触发重载。显式的 `POST /api/config/reload` 将文件写入与运行代切换解耦,使用户可以批量编辑后主动决定生效时机。
|
||||||
|
|
||||||
## 10. 并发与生命周期不变量
|
## 10. 并发与生命周期不变量
|
||||||
|
|
||||||
@ -320,10 +320,9 @@ WebUI `PUT /api/config` 只负责严格校验、原子写文件、恢复被掩
|
|||||||
当前回归测试覆盖:
|
当前回归测试覆盖:
|
||||||
|
|
||||||
- 候选配置允许 Provider/模型等运行时字段变化。
|
- 候选配置允许 Provider/模型等运行时字段变化。
|
||||||
- 可恢复未知字段、类型错误和失效非核心条目不阻止候选,并保留原始数组索引诊断;严格 WebUI 写入拒绝同样内容,清理只删除诊断路径。
|
|
||||||
- 相对 `workspace_dir` 按启动 cwd 正确解析。
|
- 相对 `workspace_dir` 按启动 cwd 正确解析。
|
||||||
- workspace 变化被拒绝且返回明确错误。
|
- workspace 变化被拒绝且返回明确错误。
|
||||||
- `None` 与显式指向同一有效数据库路径(默认 `{config_dir}/data/picobot.db`)时允许重载。
|
- `None` 与 `./picobot.db` 指向同一有效数据库路径时允许重载。
|
||||||
- admission 关闭后拒绝新工作,并等待现有 activity guard 释放。
|
- admission 关闭后拒绝新工作,并等待现有 activity guard 释放。
|
||||||
- command output 在 dispatcher 明确确认投递前不会释放处理任务。
|
- command output 在 dispatcher 明确确认投递前不会释放处理任务。
|
||||||
- 真实子进程 Gateway 可完成 generation 2 切换;无效候选返回 400 且旧代 `/health` 继续可用。
|
- 真实子进程 Gateway 可完成 generation 2 切换;无效候选返回 400 且旧代 `/health` 继续可用。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
编写日期:2026-06-17
|
编写日期:2026-06-17
|
||||||
|
|
||||||
> 历史说明:本文的 `ContextCompressor`、Timeline 回填和时间戳边界描述是 1.19.0 之前的基线。当前上下文恢复由单一活动 checkpoint 和 durable `seq` 边界驱动,Timeline 只是提交后的派生检索记录;以 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md) 和 [ARCHITECTURE.md](ARCHITECTURE.md) 为准。
|
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
||||||
PicoBot 当前已经具备最基础的记忆能力:
|
PicoBot 当前已经具备最基础的记忆能力:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,279 +0,0 @@
|
|||||||
# 子 Agent 设计
|
|
||||||
|
|
||||||
本文说明 PicoBot 具名子 Agent(named Agent)的运行时设计,重点是**结果如何从子 Agent 传回主 Agent**,以及实现过程中踩过的坑。文中描述以当前代码为准(`src/agent/`、`src/storage/agent_run.rs`、`src/storage/agent_inbox.rs`、`src/tools/delegate.rs` 等)。
|
|
||||||
|
|
||||||
## 1. 概述与设计目标
|
|
||||||
|
|
||||||
子 Agent 让主 Agent 把一个独立、可验收的子任务交给一个**具名角色**去执行,角色有独立的 Provider/模型、工具集、系统提示词和执行预算。核心目标:
|
|
||||||
|
|
||||||
- **一切可审计**:每次委托(run)先落库再执行,终态、结果、工具调用数、迭代数都持久化,WebUI 可查。
|
|
||||||
- **结果不丢**:后台任务的完成结果即使进程崩溃、收件箱打满、唤醒丢失也能最终送达主 Agent。
|
|
||||||
- **fail-closed**:定义文件、工具集、委托边、信号契约任一处非法都拒绝加载或拒绝执行,绝不悄悄放宽权限。
|
|
||||||
- **有界**:树深度、树内 run 数、并发、信号频率、结果长度全部有上限,模型只能收窄不能扩张。
|
|
||||||
|
|
||||||
## 2. 概念模型
|
|
||||||
|
|
||||||
### 2.1 Agent 定义(Markdown)
|
|
||||||
|
|
||||||
每个子 Agent 是 `definitions_dir`(默认 `agents/`)下的一个 `*.md` 文件,文件名必须等于 `id`。前面是 YAML frontmatter,后面是角色正文(role body):
|
|
||||||
|
|
||||||
```md
|
|
||||||
---
|
|
||||||
id: researcher
|
|
||||||
description: Research primary sources
|
|
||||||
llm_profile: research # 引用 config.json 顶层 agents 的 key
|
|
||||||
# 或者内联指定(WebUI 首选):
|
|
||||||
# provider: openai
|
|
||||||
# model: gpt-4.1
|
|
||||||
tools: [file_read, file_search, web_fetch]
|
|
||||||
delegates: [coder] # 下一级委托目标;缺省=general-purpose,[]=不可,["*"]=任意
|
|
||||||
skills: [summarize] # get_skill 的作用域
|
|
||||||
limits:
|
|
||||||
timeout_secs: 900
|
|
||||||
max_iterations: 24
|
|
||||||
max_result_chars: 16000
|
|
||||||
signal: # 可选:启用 emit_signal
|
|
||||||
delivery: queue # queue | steer
|
|
||||||
---
|
|
||||||
# Role
|
|
||||||
只返回有证据支撑的结论。
|
|
||||||
```
|
|
||||||
|
|
||||||
关键校验(`src/agent/definition.rs`):
|
|
||||||
|
|
||||||
- `deny_unknown_fields`:frontmatter 出现未知字段直接拒绝。
|
|
||||||
- `enabled`(默认 `true`):单个定义的开关;`enabled: false` 的定义保留在磁盘供管理 UI 查看,但不进入活动 catalog。
|
|
||||||
- `id` 必须匹配文件名、小写字母开头、长度 ≤64,且保留 `root/main/default/general`。
|
|
||||||
- `llm_profile` 或内联 `provider`+`model` 二选一必填;内联的 provider 和 model 必须成对出现。
|
|
||||||
- 文件必须是非符号链接的普通文件,≤256KB;role body 非空且 ≤64K 字符。
|
|
||||||
- 计算 `definition_hash`(canonical frontmatter + role body 的 SHA256),随 run 持久化,用于识别运行代内定义是否变更。
|
|
||||||
|
|
||||||
内置 `general-purpose` 定义在首次启动释放到 `~/.picobot/agents/`,作为 `delegates` 缺省时的默认委托目标。
|
|
||||||
|
|
||||||
### 2.2 Catalog 与委托图
|
|
||||||
|
|
||||||
`AgentCatalog` 每个运行代不可变。加载时把定义解析成 `AgentDefinition`(含解析后的 provider config),并校验:
|
|
||||||
|
|
||||||
- Provider profile 存在、工具名/Skill 名在注册表里、`delegates` 列表里显式列出的目标存在(`*` 与缺省不校验)。
|
|
||||||
- 任一无效 → 整代拒绝启动/热重载。
|
|
||||||
|
|
||||||
委托规则:
|
|
||||||
|
|
||||||
- **主 Agent(ROOT)**:可委托给任意具名子 Agent(`root_can_delegate` 只判断目标是否在 catalog 里)。
|
|
||||||
- **子 Agent 的下一级**:由定义里的 `delegates` 决定,语义如下:
|
|
||||||
|
|
||||||
| `delegates` | 含义 |
|
|
||||||
|-------------|------|
|
|
||||||
| 缺省(不写该字段) | 仅可委托内置 `general-purpose` |
|
|
||||||
| `[]` | 不可继续委托 |
|
|
||||||
| `["*"]` | 可委托任意子代理(除自己) |
|
|
||||||
| `["a", "b"]` | 按列表指定 |
|
|
||||||
|
|
||||||
self 与祖先链上的 Agent 在 `resolve_agent` 时永远被拒绝(循环检测)。`can_delegate(caller, target)` / `root_can_delegate(target)` / `delegate_targets(caller)` 是这套语义的唯一实现点。
|
|
||||||
|
|
||||||
### 2.3 Run 与执行上下文
|
|
||||||
|
|
||||||
一次委托 = 一个 run,持久化在 `agent_runs`。执行上下文 `AgentExecutionContext`(`src/agent/run.rs`)携带:
|
|
||||||
|
|
||||||
| 字段 | 含义 |
|
|
||||||
|------|------|
|
|
||||||
| `root_session_id` | 整棵委托树所属的会话 |
|
|
||||||
| `run_id` / `execution_id` | run ID 与「执行尝试」ID;首次两者相同,`execution_id` 用于条件状态转换,迟到的旧执行写不进状态 |
|
|
||||||
| `parent_run_id` / `ancestry` | 父 run 与祖先链(用于循环检测、授权) |
|
|
||||||
| `depth` | 委托深度(≥1) |
|
|
||||||
| `budget` | 剩余 run 数与剩余深度 |
|
|
||||||
| `tree_runs` | 整棵树的共享原子计数,强制 `max_runs_per_tree` |
|
|
||||||
| `signal_contract` | 信号契约(`None` 表示该 run 不能发信号) |
|
|
||||||
| `cancellation` | CancellationToken(父取消会向子级联) |
|
|
||||||
|
|
||||||
`child()` 构造子上下文:深度 +1、预算 -1、`parent_run_id` 设为父 run、`ancestry` 追加目标,并**共享** `tree_runs`。
|
|
||||||
|
|
||||||
## 3. 执行模型
|
|
||||||
|
|
||||||
### 3.1 foreground(同步等待)
|
|
||||||
|
|
||||||
`delegate` 工具 `mode=foreground` 时,调用方(主 Agent 或某个子 Agent)阻塞等待结果:
|
|
||||||
|
|
||||||
1. 先解析所有 target(任何非法请求在写库之前失败,不留孤儿行)。
|
|
||||||
2. 一次性持久化所有 run(`accept_agent_runs`,status=queued)。
|
|
||||||
3. 若调用方是具名 Agent,把父 run 置为 `waiting_children`(等待期间不占 step permit)。
|
|
||||||
4. 并发执行(`join_all`),结果**保持请求顺序**返回。
|
|
||||||
5. 每个 run 各自 commit terminal;父 run 恢复 `running`。
|
|
||||||
|
|
||||||
结果直接作为工具返回值回到模型,同时完整结果持久化到 `agent_runs.result`。
|
|
||||||
|
|
||||||
### 3.2 background(异步 + 收件箱)
|
|
||||||
|
|
||||||
`mode=background` 时,`delegate` 只做「接纳」就立即返回 run ID;真正执行在后台 runner 里,结果通过 durable inbox 送达。这是结果传递机制最复杂的部分,见第 4 节。
|
|
||||||
|
|
||||||
只有 ROOT 能发起 background;子 Agent 发起的 background、以及 background 里再 background 都不开放。
|
|
||||||
|
|
||||||
## 4. 结果传递机制(重点)
|
|
||||||
|
|
||||||
foreground 的结果是「调用即返回」,没有跨 Turn 的传递问题。**真正需要设计的是 background 的结果如何可靠地回到主 Agent**——因为 background runner 跑在后台,主 Agent 可能正在忙别的 Turn,甚至已经结束上一个 Turn。
|
|
||||||
|
|
||||||
核心思路:**结果不是直接通知 Channel,而是落进一个持久化收件箱,由主 Agent 的「续接 Turn」(continuation Turn)读取并汇入会话**。
|
|
||||||
|
|
||||||
```
|
|
||||||
background runner
|
|
||||||
└─ terminal commit(原子事务)
|
|
||||||
├─ agent_runs → 终态(execution_id + generation 条件)
|
|
||||||
├─ plan item 完成(若有)
|
|
||||||
└─ 预留槽 → agent_inbox_events 完成事件(completion)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
session 收件箱 worker(queue lane)
|
|
||||||
│ claim(pending→leased)
|
|
||||||
▼
|
|
||||||
continuation Turn(hidden 触发 + 只读工具集)
|
|
||||||
│ commit_continuation_turn(原子)
|
|
||||||
▼
|
|
||||||
可见的 assistant 结果 + 事件 consumed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.1 完成槽预留(保证不丢)
|
|
||||||
|
|
||||||
接纳 background 批次时,先对每个 run 预留一个完成槽:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
UPDATE agent_session_state
|
|
||||||
SET reserved_completion_slots = reserved_completion_slots + ?,
|
|
||||||
revision = revision + 1, updated_at = ?
|
|
||||||
WHERE root_session_id = ?
|
|
||||||
AND pending_event_count + reserved_completion_slots + ? <= ?
|
|
||||||
RETURNING revision;
|
|
||||||
```
|
|
||||||
|
|
||||||
- 这是**条件更新**:只有 `pending + reserved + 新增 ≤ 上限` 时才成功,避免并发 `COUNT(*)` 漂移。
|
|
||||||
- 预留成功后才持久化 run;预留失败则整批拒绝。
|
|
||||||
- 意义:background 的完成事件**永远占得住位置**,不会因为收件箱被 signal 打满而丢失。signal 只能在「未预留」的容量里插入(见 4.4)。
|
|
||||||
|
|
||||||
### 4.2 终态提交(单写者)
|
|
||||||
|
|
||||||
`commit_agent_terminal`(`src/storage/agent_run.rs`)在一个事务里完成:
|
|
||||||
|
|
||||||
1. `UPDATE agent_runs SET status=终态, result=?, error=?, usage...`,条件是 `WHERE id=? AND execution_id=? AND runtime_generation=? AND status IN ('queued','running','waiting_children')`。**命中 0 行 = 迟到的旧结果,直接丢弃(返回 `None`)**。
|
|
||||||
2. 若 run 绑定了 plan item,用同一个 `execution_id` 条件完成该子项。
|
|
||||||
3. 若 `completion_slot_reserved`(background),把预留槽**转换成**一条 completion 事件写入 `agent_inbox_events`(status=completed/failed/timed_out/cancelled/interrupted,携带 result/error/signal_ids)。
|
|
||||||
|
|
||||||
三步同一事务提交:要么全部生效,要么全部回滚,**内存与数据库永不分叉**。
|
|
||||||
|
|
||||||
### 4.3 收件箱事件状态机
|
|
||||||
|
|
||||||
`agent_inbox_events` 里每条事件(signal 或 completion)走:
|
|
||||||
|
|
||||||
```
|
|
||||||
pending ──claim──▶ leased ──admit(steer)──▶ admitted ──▶ consumed
|
|
||||||
▲ │ │
|
|
||||||
└──release(backoff)◀──────────────────────────┘
|
|
||||||
pending/leased/admitted ──supersede──▶ superseded(显式取消)
|
|
||||||
pending/leased/admitted ──dead_letter──▶ dead_letter(归档/删除/超限)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **claim**:`pending → leased`,带 `lease_token` + `lease_until` + `attempt_count+1`。claim 条件 `status='pending'`,天然防双租。
|
|
||||||
- **admit**(仅 steer):`leased → admitted`,绑定 `admitted_turn_id`。
|
|
||||||
- **release**:`leased/admitted → pending`,带重试 `next_attempt_at`。lease token 防止别的 worker 已消费后又被释放。
|
|
||||||
- **consume**:在续接 Turn 提交事务里原子完成。
|
|
||||||
- **supersede**:显式取消 run 时,把其未消费 signal 置为 superseded(completion 永不 supersede)。
|
|
||||||
- **dead_letter**:会话归档/删除、或投递超过 `max_inbox_delivery_attempts` 时;最多发一次有界 system fallback 提示。
|
|
||||||
|
|
||||||
### 4.4 两条投递 lane:queue 与 steer
|
|
||||||
|
|
||||||
事件按 `delivery` 分两种语义(`SignalDelivery`,定义在 `signal.delivery`):
|
|
||||||
|
|
||||||
| lane | 语义 | 到达方式 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `queue` | 排队到**下一个** Turn | 收件箱 worker 在调度边界把事件变成续接 Turn |
|
|
||||||
| `steer` | 注入**当前活动** Turn 的安全边界 | 两阶段准入:claim → 预留 mailbox 槽 → DB admit(turn_id) |
|
|
||||||
|
|
||||||
**steer 两阶段准入**(任何一步失败都必须无损回退):
|
|
||||||
|
|
||||||
1. claim(pending→leased,拿到 lease token)。
|
|
||||||
2. 在 TurnMailbox 的 agent lane 预留一个槽(容量独立于 user lane)。
|
|
||||||
3. `admit_inbox_event`(leased→admitted,绑 turn_id)。
|
|
||||||
4. 同一 Turn/代激活。
|
|
||||||
|
|
||||||
失败路径:claim 失败 → 释放 lease 并 wake queue lane;mailbox 满 → 释放 lease 回 pending,等 queue lane 以 continuation 送达。**steer 可靠退化为 queue**:当活动 Turn 关闭时,已 admit 的 steer 事件按 lease token 释放回 pending,绝不静默丢弃。
|
|
||||||
|
|
||||||
### 4.5 续接 Turn(continuation Turn)
|
|
||||||
|
|
||||||
queue lane 的 worker claim 一批事件后,把它们合成为一条**隐藏的触发消息**(`build_continuation_trigger`):completion 事件渲染为「后台任务完成(Agent、状态、Run ID、任务、结果/错误)」,signal 渲染为「后台信号(级别、摘要)」。
|
|
||||||
|
|
||||||
续接 Turn 的特殊性:
|
|
||||||
|
|
||||||
- 触发消息 `client_visibility=hidden`、`turn_origin=agent_continuation`——**不进客户端历史、不进 Channel 投递、只供模型回放**。
|
|
||||||
- 工具集受限为只读:`file_read/file_search/content_search/web_fetch/calculator/agent_task`。续接 Turn 不能写文件、发消息、再委托、调度。
|
|
||||||
- `commit_continuation_turn` 在**一个事务**里写 hidden trigger + 可见 assistant/tool 消息 + usage + 事件 consume + session 计数,客户端永远不会看到「半成品续接」。
|
|
||||||
|
|
||||||
`requires_continuation=false` 的完成事件(如 `/stop` 产生的 cancel 完成)直接写成 consumed,不触发续接。
|
|
||||||
|
|
||||||
### 4.6 唤醒与公平调度
|
|
||||||
|
|
||||||
事件 commit 成功后,Coordinator 通过 `AgentInboxNotifier` 做一次**尽力而为**的 wake(弱引用、late-bound,避免与 SessionManager 形成强引用环)。**wake 丢失不是错误**:durable inbox 是唯一事实源,worker 有周期性重新 claim 的兜底。
|
|
||||||
|
|
||||||
公平调度:空闲(无用户积压)时 due 事件立即 claim(完成即返回);忙碌时,连续处理 `max_user_turn_burst_before_inbox` 个用户 Turn 后,或最老 pending 事件等待超过 `max_inbox_wait_secs`,下一个调度项必须是一批 inbox 事件。当前活动 Turn 从不被 queue 事件抢占。
|
|
||||||
|
|
||||||
### 4.7 emit_signal(信号)
|
|
||||||
|
|
||||||
只在定义声明 `signal` 块时,run 才会被注入 `emit_signal` 工具。契约字段(总量、单条字节、最小间隔、burst、severity allowlist、dedupe 冷却窗、JSON 深度)全部由工具与 Coordinator 强制,模型只提供 key/severity/summary/details/dedupe_key。
|
|
||||||
|
|
||||||
- 结构校验(severity 是否在 allowlist、summary/key 长度、payload 大小与深度)在工具内做,不依赖模型自觉。
|
|
||||||
- 频率限制是每 run 内存态(工具实例为单个 run 的 registry 创建)。
|
|
||||||
- Coordinator `emit_signal` 再校验:run 存在、`execution_id` 匹配、非终态;`insert_agent_signal` 在 `pending + reserved + 1 ≤ 上限` 下条件插入,并做冷却窗 dedupe(`run_id + event_type + event_key` 唯一)。
|
|
||||||
- 信号 ID 记入 `emitted_signals`,最终写进该 run 的 completion 事件 payload,供主 Agent 交叉核对。
|
|
||||||
|
|
||||||
## 5. 持久化模型
|
|
||||||
|
|
||||||
三张 agent 表(schema v8):
|
|
||||||
|
|
||||||
- **`agent_runs`**:每次委托一行。含 run id、root session、父子、caller 身份(`caller_agent_id`/`caller_scope_id`)、agent/definition 快照(`definition_hash`)、provider/model、mode、depth、task/context、budget、signal 契约快照、status(queued/running/waiting_children/终态)、result/error、usage、`execution_id`、`completion_slot_reserved`、时间线、revision。`execution_id` 唯一索引。
|
|
||||||
- **`agent_inbox_events`**:收件箱。`run_id`(NOT NULL,FK)、event_type(signal/completion)、event_key(去重键)、delivery(queue/steer)、requires_continuation、severity、payload、status、attempt/lease、`UNIQUE(run_id, event_type, event_key)`。
|
|
||||||
- **`agent_session_state`**:每根会话一行,权威容量计数(`pending_event_count` + `reserved_completion_slots` + 单调 `revision`)。所有容量增减都是条件 UPDATE。
|
|
||||||
|
|
||||||
结果不复制大文本:完整结果在 `agent_runs.result`,inbox payload 只放有界摘要/元数据。
|
|
||||||
|
|
||||||
## 6. 取消与恢复
|
|
||||||
|
|
||||||
- **取消 run**(`cancel_run`):先按树位置授权、确认非终态,然后 `cancel_agent_run_with_completion`(写终态 + 若预留槽则转换 completion 事件),取消 CancellationToken,并 supersede 未消费 signal。`suppress_continuation=true` 时 completion 写成 consumed(`/stop`/归档后不再续接)。
|
|
||||||
- **取消会话**(`cancel_session`):取消该会话所有非终态 run,完成事件写 consumed。
|
|
||||||
- **启动恢复**(`recover_agent_state`):旧运行代的 queued/running/waiting_children → interrupted(background 转换 failure completion);过期 lease → pending 带 backoff、超限 → dead_letter;按行重算容量计数,差异修复并告警。
|
|
||||||
|
|
||||||
## 7. 授权
|
|
||||||
|
|
||||||
run ID 不是凭证。ROOT 可访问本会话所有 run;具名 Agent 只能访问自己的 run 及其**后代**(沿 `parent_run_id` 向上走到自己)。其他会话一律拒绝读取/取消。
|
|
||||||
|
|
||||||
## 8. 易出错点总结
|
|
||||||
|
|
||||||
实现过程中反复踩坑的地方,按重要程度排序:
|
|
||||||
|
|
||||||
1. **execution_id 条件更新**。终态、running、信号写入都必须带 `execution_id`(和 generation)条件,命中 0 行 = 迟到旧结果,静默丢弃。否则一个超时后被重试的旧 runner 可能覆盖新终态。
|
|
||||||
2. **收件箱容量 = pending + reserved,且必须条件 UPDATE**。signal 不能挤掉 background 的完成预留;用无锁 `COUNT(*)` 推断会并发漂移,必须在同一写事务里 `UPDATE ... WHERE pending+reserved+n ≤ limit RETURNING`。
|
|
||||||
3. **完成槽预留 → 完成事件转换必须在终态提交的同一事务里**。一旦分开,崩溃就会留下「已预留但永远不产出 completion」的槽。
|
|
||||||
4. **wake 是尽力而为,不是正确性来源**。任何依赖「wake 一定到达」的逻辑都会在丢 wake 时漏投。事实源是 durable inbox,wake 只加速,周期性重新 claim 兜底。
|
|
||||||
5. **steer 两阶段准入的无损性**。claim → mailbox 预留 → DB admit 任何一步失败都要释放 lease 并 wake queue lane;Turn 关闭时已 admit 的 steer 要按 lease token 放回 pending(退化为 queue)。绝不静默丢弃。
|
|
||||||
6. **输入归属互斥**(steer / 下一 Turn FIFO / `/stop`)。一条输入要么属于当前活动 Turn,要么进下一 Turn FIFO,`/stop` 两者都丢弃;三者必须无损且互斥。
|
|
||||||
7. **委托循环、预算、深度、树 run 上限要在解析阶段就拦下**。`ancestry` 判环、`budget` 判耗尽、`reserve_tree_run` 用共享原子计数强制 `max_runs_per_tree`。
|
|
||||||
8. **fail-closed 顺序:先解析所有 target 再写库**。否则批量里一个非法 target 会留下前几个 run 的孤儿行。
|
|
||||||
9. **spawn 失败的补偿**。TaskSupervisor 拒绝 spawn(如关机)时,要取消该 run 并释放其完成槽,否则槽永远占着。
|
|
||||||
10. **子 Agent 管理器对 Coordinator 用 `Weak`**。Coordinator 拥有 manager,manager 若强引用 coordinator 会成环。
|
|
||||||
11. **结果两段式:模型看到截断、库存全量**。`max_result_chars` 截断返回给模型的内容并提示「用 agent_task get_result 查全量」;`full_content` 原样持久化。截断要按 `floor_char_boundary`,否则 UTF-8 边界 panic。
|
|
||||||
12. **MIN 聚合无行时返回 NULL 被解成 0**。曾导致 worker 空转;`oldest_pending_due`/`next_pending_due_at` 用 `Option<Option<i64>>` 显式区分「无行」与「值为 0」。
|
|
||||||
13. **claim 的确定性与防双租**。claim 按 `created_at, id` 排序保证确定性;lease token 让 release 只作用于本 worker 租下的事件,防止把别人已消费的又放回 pending。
|
|
||||||
14. **idempotency_key 的部分唯一索引**。`UNIQUE(root_session_id, caller_scope_id, idempotency_key) WHERE idempotency_key IS NOT NULL`——`NULL` 不参与去重,否则 SQLite 里所有 NULL 会互相冲突。
|
|
||||||
15. **signal 冷却窗去重键要含时间窗**(`event_key = signal:{key}:{now/cooldown}`)。否则「窗口内去重、窗口外再发」无法表达。
|
|
||||||
16. **父 run 的 `waiting_children` 状态必须对称恢复**。父等待时释放 step permit,子结束后要 `restore_agent_run_running`,否则父 run 卡在 waiting 状态。
|
|
||||||
|
|
||||||
## 9. 配置项
|
|
||||||
|
|
||||||
`agent_orchestration`(`src/config/mod.rs`):
|
|
||||||
|
|
||||||
| 键 | 默认 | 含义 |
|
|
||||||
|----|------|------|
|
|
||||||
| `definitions_dir` | `agents` | 定义目录(相对 config.json 所在目录) |
|
|
||||||
| `max_tree_depth` | `4` | 委托树最大深度 |
|
|
||||||
| `max_runs_per_tree` | `16` | 一棵树内最大 run 数 |
|
|
||||||
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | `6` / `4` | 全局/每会话并发 run 上限 |
|
|
||||||
| `max_pending_inbox_events_per_session` | `128` | 每会话 pending + reserved 上限 |
|
|
||||||
| `max_inbox_delivery_attempts` | `8` | 投递尝试上限(超过进 dead-letter) |
|
|
||||||
| `max_user_turn_burst_before_inbox` | `4` | 公平调度:连续处理多少个用户 Turn 后必须清 inbox |
|
|
||||||
| `max_inbox_wait_secs` | `30` | 最老事件等待上限(秒) |
|
|
||||||
@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
**验证约定:** Rust 改动 → 定向测试 + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings` + `cargo build`;前端 → `cd webui && npm run check && npm run build`。前端无单测框架,勿虚构。
|
**验证约定:** Rust 改动 → 定向测试 + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings` + `cargo build`;前端 → `cd webui && npm run check && npm run build`。前端无单测框架,勿虚构。
|
||||||
|
|
||||||
## Implementation Progress (2026-07-26)
|
## Implementation Progress (2026-07-24)
|
||||||
|
|
||||||
**Branch:** `feat/webui-p1`
|
**Branch:** `feat/webui-p1`
|
||||||
|
|
||||||
@ -30,17 +30,16 @@
|
|||||||
- Task 1.3 AgentLoop instrumentation — `5d0cf5b`
|
- Task 1.3 AgentLoop instrumentation — `5d0cf5b`
|
||||||
- Task 2.1 runtime introspection — `aa989cb`
|
- Task 2.1 runtime introspection — `aa989cb`
|
||||||
- Task 2.2 protected `GET /api/status` — `b14edc4`, with review fixes in `87cf500`
|
- Task 2.2 protected `GET /api/status` — `b14edc4`, with review fixes in `87cf500`
|
||||||
- Task 2.3 protected `GET /api/tools` — `119df57`
|
|
||||||
- Task 2.4 protected `GET /api/skills` + SkillsLoader accessor — `5e2771c`
|
|
||||||
- Task 3.1 Sparkline/CapacityMeter/MetricTile components — `6e719e7`
|
|
||||||
- Task 3.2 OverviewPage runtime dashboard — `0b02fd5`
|
|
||||||
- Task 3.3 ToolsPage tools & skills browser — `266fb8c`
|
|
||||||
- Task 3.4 App.svelte wiring — `4621210`
|
|
||||||
- Task 4.1 P1 release — `7676502`
|
|
||||||
|
|
||||||
**Final verification baseline:** `cargo test --lib` 350 passed; `cargo clippy --all-targets --all-features -- -D warnings` clean; `cargo build` passed; `npm run check` 0 errors/warnings; `npm run build` passed.
|
**Current verification baseline:** `cargo test --lib` 349 passed; `cargo clippy --all-targets --all-features -- -D warnings` and `cargo build` passed after Task 2.2.
|
||||||
|
|
||||||
**Status:** P1 COMPLETE. Version bumped to 1.5.0.
|
**Resume at:** Task 2.3, protected `GET /api/tools`. No Task 2.3 code has been started. Continue with subagent-driven development and run separate spec-compliance and code-quality reviews before marking it complete.
|
||||||
|
|
||||||
|
**Restore on another computer:**
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git switch --track origin/feat/webui-p1
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,413 +0,0 @@
|
|||||||
# P2 日志与数据 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 为 PicoBot WebUI 增加实时日志流(tracing 广播层 + `/ws/logs`)、记忆写入端点(PUT/DELETE)、以及日志页/记忆页/任务页的前端重构。
|
|
||||||
|
|
||||||
**Architecture:** 新增 tracing 广播层(自定义 `Layer` impl,格式化后发送到 `tokio::sync::broadcast`,容量 1024,慢客户端丢旧不反压)。全局 `OnceLock<broadcast::Sender<LogEvent>>` 在 `init_logging()` 时初始化。`/ws/logs` 独立 WebSocket 端点复用现有 `require_auth` 中间件,连接后订阅广播、按 level/search 过滤推送。记忆写入复用已有 `Storage::upsert_memory`/`delete_memory`。前端三页重构为 Svelte 5 runes 组件。
|
|
||||||
|
|
||||||
**Tech Stack:** Rust(tracing-subscriber Layer trait、tokio broadcast、Axum WebSocket)、Svelte 5(runes)、bits-ui。
|
|
||||||
|
|
||||||
**关键设计决策(务必遵守):**
|
|
||||||
1. **广播层全局**:`src/logging.rs` 增加 `static LOG_TX: OnceLock<broadcast::Sender<LogEvent>>` + `pub fn log_sender() -> Option<broadcast::Sender<LogEvent>>`。`init_logging()` 初始化 broadcast channel 并挂载自定义 Layer。无订阅者时 send 为 no-op。
|
|
||||||
2. **LogEvent 结构**:`#[derive(Clone)] pub struct LogEvent { pub ts: String, pub level: String, pub target: String, pub message: String }`。ts 为 RFC 3339。level 为 "TRACE"/"DEBUG"/"INFO"/"WARN"/"ERROR"。
|
|
||||||
3. **`/ws/logs` 独立于 `/ws`**:不与聊天 WS 复用连接。路由注册在 protected router 内(`require_auth`),handler 签名与 `ws_handler` 类似但更简单(无 session 注册)。查询参数 `level`(可选,最低级别过滤)和 `search`(可选,关键字)。
|
|
||||||
4. **记忆 PUT 语义**:`PUT /api/memories/{key}` body `{content: string, importance?: f64}`。若 key 已存在则更新 content/importance/updated_at;若不存在则 404(不创建新条目——创建由 agent 内部完成)。需先查询 key 是否存在。
|
|
||||||
5. **记忆 DELETE 语义**:`DELETE /api/memories/{key}` 删除后返回 `{"deleted": true}`。key 不存在也返回 200(幂等)。
|
|
||||||
6. **GET /api/logs 保留**:文件尾读取不变,用于进入页面时拉历史与重连对齐。
|
|
||||||
7. **前端日志页**:进入时先 `GET /api/logs` 拉历史尾 → 建立 `/ws/logs` 接管实时;断线重连重新拉尾对齐。level 过滤(全部/INF/WRN/ERR)、关键字搜索、暂停滚动、下载。
|
|
||||||
8. **前端记忆页**:行内编辑(textarea + importance 滑块 + 保存/取消)、删除确认分级(Knowledge 普通 / Timeline 强警告)、搜索 + 分类筛选 + 分页加载。
|
|
||||||
9. **前端任务页**:定时任务增加 cron 表达式显示、下次运行倒计时、最近运行状态点(绿/琥珀/红);后台任务增加脉冲动画。
|
|
||||||
|
|
||||||
**参考:** 规格 `docs/superpowers/specs/2026-07-23-webui-refactor-design.md` §6.4/§6.5/§6.6/§7.4/§7.5;P1 计划 `docs/superpowers/plans/2026-07-24-p1-observability.md`(前端组件/页面模式)。
|
|
||||||
|
|
||||||
**验证约定:** Rust 改动 → 定向测试 + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings` + `cargo build`;前端 → `cd webui && npm run check && npm run build`。前端无单测框架,勿虚构。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 1: 后端 — tracing 广播 + /ws/logs
|
|
||||||
|
|
||||||
### Task 1.1: LogEvent + 广播 Layer + 全局访问器
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/logging.rs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 定义 LogEvent 与全局 Sender**
|
|
||||||
|
|
||||||
在 `src/logging.rs` 顶部增加:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use tracing::field::Visit;
|
|
||||||
use tracing_subscriber::layer::Context;
|
|
||||||
use tracing_subscriber::Layer;
|
|
||||||
|
|
||||||
const LOG_BROADCAST_CAP: usize = 1024;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct LogEvent {
|
|
||||||
pub ts: String,
|
|
||||||
pub level: String,
|
|
||||||
pub target: String,
|
|
||||||
pub message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
static LOG_TX: OnceLock<broadcast::Sender<LogEvent>> = OnceLock::new();
|
|
||||||
|
|
||||||
pub fn log_sender() -> Option<broadcast::Sender<LogEvent>> {
|
|
||||||
LOG_TX.get().cloned()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 实现自定义 Layer**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
struct BroadcastLayer;
|
|
||||||
|
|
||||||
struct MessageVisitor(String);
|
|
||||||
|
|
||||||
impl Visit for MessageVisitor {
|
|
||||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
|
||||||
if field.name() == "message" {
|
|
||||||
self.0 = format!("{:?}", value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
|
||||||
if field.name() == "message" {
|
|
||||||
self.0 = value.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S: tracing::Subscriber> Layer<S> for BroadcastLayer {
|
|
||||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
|
||||||
let Some(tx) = LOG_TX.get() else { return };
|
|
||||||
if tx.receiver_count() == 0 { return; }
|
|
||||||
let mut visitor = MessageVisitor(String::new());
|
|
||||||
event.record(&mut visitor);
|
|
||||||
let metadata = event.metadata();
|
|
||||||
let _ = tx.send(LogEvent {
|
|
||||||
ts: chrono::Local::now().to_rfc3339(),
|
|
||||||
level: metadata.level().to_string(),
|
|
||||||
target: metadata.target().to_string(),
|
|
||||||
message: visitor.0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
注意:需确认 `chrono` 是否已在依赖中。若无需添加 `chrono = "0.4"` 到 Cargo.toml。或者用 `time` crate(tracing-subscriber 的 `local-time` feature 已引入 `time`)。优先用 `time::OffsetDateTime::now_local()` 格式化为 RFC 3339,避免新增依赖。
|
|
||||||
|
|
||||||
- [ ] **Step 3: 修改 init_logging() 挂载广播层**
|
|
||||||
|
|
||||||
在 `init_logging()` 中,`tracing_subscriber::registry()` 链增加 `.with(BroadcastLayer)`,并在函数开头初始化 broadcast:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub fn init_logging() {
|
|
||||||
let (tx, _rx) = broadcast::channel(LOG_BROADCAST_CAP);
|
|
||||||
let _ = LOG_TX.set(tx);
|
|
||||||
// ... 现有代码 ...
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(env_filter)
|
|
||||||
.with(console_layer)
|
|
||||||
.with(file_layer)
|
|
||||||
.with(BroadcastLayer)
|
|
||||||
.init();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: 验证** — `cargo build` + `cargo clippy --all-targets --all-features -- -D warnings`
|
|
||||||
- [ ] **Step 5: Commit** — `git add src/logging.rs Cargo.toml && git commit -m "feat(logging): tracing broadcast layer for real-time log streaming"`
|
|
||||||
|
|
||||||
### Task 1.2: /ws/logs WebSocket 端点
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/gateway/ws.rs`(或新建 `src/gateway/ws_logs.rs`)
|
|
||||||
- Modify: `src/gateway/mod.rs`(路由注册)
|
|
||||||
|
|
||||||
- [ ] **Step 1: 实现 ws_logs handler**
|
|
||||||
|
|
||||||
在 `src/gateway/ws.rs` 底部(或新文件)增加:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
pub struct WsLogsQuery {
|
|
||||||
level: Option<String>,
|
|
||||||
search: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ws_logs_handler(
|
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
Query(query): Query<WsLogsQuery>,
|
|
||||||
Extension(_identity): Extension<super::auth::AuthIdentity>,
|
|
||||||
) -> Response {
|
|
||||||
ws.on_upgrade(|socket| async move {
|
|
||||||
handle_logs_socket(socket, query).await;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_logs_socket(ws: WebSocket, query: WsLogsQuery) {
|
|
||||||
let Some(tx) = crate::logging::log_sender() else { return };
|
|
||||||
let mut rx = tx.subscribe();
|
|
||||||
let (mut ws_sender, mut ws_receiver) = ws.split();
|
|
||||||
|
|
||||||
let min_level = query.level.as_deref().map(parse_min_level).unwrap_or(0);
|
|
||||||
let search = query.search.filter(|s| !s.is_empty()).map(|s| s.to_ascii_lowercase());
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
result = rx.recv() => {
|
|
||||||
match result {
|
|
||||||
Ok(event) => {
|
|
||||||
if level_rank(&event.level) < min_level { continue; }
|
|
||||||
if let Some(needle) = &search {
|
|
||||||
if !event.message.to_ascii_lowercase().contains(needle)
|
|
||||||
&& !event.target.to_ascii_lowercase().contains(needle) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let json = serde_json::json!({
|
|
||||||
"ts": event.ts,
|
|
||||||
"level": event.level,
|
|
||||||
"target": event.target,
|
|
||||||
"message": event.message,
|
|
||||||
});
|
|
||||||
if ws_sender.send(WsMessage::Text(json.to_string().into())).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
|
||||||
Err(broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg = ws_receiver.next() => {
|
|
||||||
match msg {
|
|
||||||
Some(Ok(WsMessage::Close(_))) | None => break,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_min_level(level: &str) -> u8 {
|
|
||||||
match level.to_ascii_uppercase().as_str() {
|
|
||||||
"DEBUG" => 1,
|
|
||||||
"INFO" => 2,
|
|
||||||
"WARN" => 3,
|
|
||||||
"ERROR" => 4,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn level_rank(level: &str) -> u8 {
|
|
||||||
match level.as_str() {
|
|
||||||
"TRACE" => 0,
|
|
||||||
"DEBUG" => 1,
|
|
||||||
"INFO" => 2,
|
|
||||||
"WARN" => 3,
|
|
||||||
"ERROR" => 4,
|
|
||||||
_ => 2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 注册路由** — 在 `src/gateway/mod.rs` 的 protected router(`route_layer(require_auth)` 之内)加:
|
|
||||||
```rust
|
|
||||||
.route("/ws/logs", routing::get(ws::ws_logs_handler))
|
|
||||||
```
|
|
||||||
放在 `/ws` 路由附近。
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证** — `cargo build` + `cargo clippy --all-targets --all-features -- -D warnings`
|
|
||||||
- [ ] **Step 4: Commit** — `git add src/gateway && git commit -m "feat(gateway): /ws/logs real-time log streaming endpoint"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 2: 后端 — 记忆写入端点
|
|
||||||
|
|
||||||
### Task 2.1: PUT /api/memories/{key} + DELETE /api/memories/{key}
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/gateway/http.rs`
|
|
||||||
- Modify: `src/gateway/mod.rs`(路由)
|
|
||||||
|
|
||||||
- [ ] **Step 1: PUT handler**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct PutMemoryBody {
|
|
||||||
content: String,
|
|
||||||
importance: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn put_memory(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path(key): Path<String>,
|
|
||||||
Json(body): Json<PutMemoryBody>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let existing = state.storage
|
|
||||||
.list_memories(None, None, 1)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
// 需要按 key 查询——检查是否有 get_memory_by_key 方法
|
|
||||||
// 若无,用 search 或直接 SQL 查询
|
|
||||||
// 简化:直接用 upsert,但需先确认 key 存在
|
|
||||||
// 实际实现:查询 SELECT * FROM memories WHERE key = ?
|
|
||||||
// 若不存在返回 404
|
|
||||||
// 若存在,更新 content/importance/updated_at
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:当前 Storage 无 `get_memory_by_key` 方法。需在 `src/storage/memory.rs` 增加:
|
|
||||||
```rust
|
|
||||||
pub async fn get_memory_by_key(&self, key: &str) -> Result<Option<MemoryEntry>, StorageError> {
|
|
||||||
let row = sqlx::query(
|
|
||||||
"SELECT id, key, content, category, importance, session_id, created_at, updated_at FROM memories WHERE key = ?"
|
|
||||||
)
|
|
||||||
.bind(key)
|
|
||||||
.fetch_optional(self.pool())
|
|
||||||
.await?;
|
|
||||||
match row {
|
|
||||||
Some(row) => Ok(Some(parse_memory_row(&row)?)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
PUT handler 完整逻辑:
|
|
||||||
1. `get_memory_by_key(&key)` → None → 404
|
|
||||||
2. 存在 → 构造更新后的 MemoryEntry(保留 id/category/session_id/created_at,更新 content/importance/updated_at)
|
|
||||||
3. `upsert_memory(&entry)` → 200 `{"updated": true, "key": key}`
|
|
||||||
|
|
||||||
- [ ] **Step 2: DELETE handler**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub async fn delete_memory(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path(key): Path<String>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
state.storage.delete_memory(&key).await.map_err(ApiError::internal)?;
|
|
||||||
Ok(Json(json!({ "deleted": true })))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 注册路由** — protected router 加:
|
|
||||||
```rust
|
|
||||||
.route("/api/memories/{key}", routing::put(http::put_memory).delete(http::delete_memory))
|
|
||||||
```
|
|
||||||
注意:现有 `GET /api/memories`(无 path param)不受影响。
|
|
||||||
|
|
||||||
- [ ] **Step 4: 验证** — `cargo build` + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings`
|
|
||||||
- [ ] **Step 5: Commit** — `git add src/storage/memory.rs src/gateway && git commit -m "feat(gateway): PUT/DELETE /api/memories/{key} write endpoints"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 3: 前端 — 日志页重构
|
|
||||||
|
|
||||||
### Task 3.1: LogsPage 实时流式重构
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `webui/src/pages/LogsPage.svelte`
|
|
||||||
|
|
||||||
布局(规格 §6.4):
|
|
||||||
- 工具栏:level 过滤 chips(全部/INF/WRN/ERR)、关键字搜索、暂停滚动按钮、下载按钮。
|
|
||||||
- 连接状态条:「实时推送中 · N 行/分」或「已断开 — 重连中」。
|
|
||||||
- 日志行:时间戳 + level 着色(INF=signal, WRN=accent, ERR=danger, DBG=muted)+ target + 消息。
|
|
||||||
- 自动跟随尾部(除非暂停)。
|
|
||||||
- 进入页面:`GET /api/logs?lines=200` 拉历史尾 → 建立 `/ws/logs` WebSocket 接管实时。
|
|
||||||
- 断线重连:重新拉尾对齐。
|
|
||||||
- 下载:将当前 lines 导出为 .log 文件(Blob + URL.createObjectURL)。
|
|
||||||
|
|
||||||
数据流:
|
|
||||||
- `onMount`:先 `api("/api/logs?lines=200")` 填充历史 → 建立 WS `new WebSocket(\`${wsBase}/ws/logs?level=...\`)`。
|
|
||||||
- WS `onmessage`:解析 JSON `{ts, level, target, message}`,追加到 `lines` 数组(上限 2000 行,超出 shift)。
|
|
||||||
- WS `onclose`:设 disconnected 状态,3s 后重连(重新拉尾 + 重建 WS)。
|
|
||||||
- level/search 过滤:前端 `$derived` 过滤已存储行(WS 查询参数在连接时固定;切换 filter 需重建 WS 或纯前端过滤——**选择纯前端过滤**,WS 不带 filter 参数,简化重连逻辑)。
|
|
||||||
- 暂停:`paused` 状态,暂停时不自动滚动到底部,新行仍追加。
|
|
||||||
|
|
||||||
- [ ] **Step 1: 重写 LogsPage.svelte**(上述布局与数据流)。
|
|
||||||
- [ ] **Step 2: 验证** — `cd webui && npm run check && npm run build`。
|
|
||||||
- [ ] **Step 3: Commit** — `git add webui/src/pages/LogsPage.svelte && git commit -m "feat(webui): real-time streaming logs page"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 4: 前端 — 记忆页重构
|
|
||||||
|
|
||||||
### Task 4.1: MemoryPage 可编辑/可删除重构
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `webui/src/pages/MemoryPage.svelte`
|
|
||||||
|
|
||||||
布局(规格 §6.5):
|
|
||||||
- 统计条:总量 / Knowledge / Timeline。
|
|
||||||
- 搜索框 + 分类筛选(全部/Knowledge/Timeline)+ 排序(最近更新)。
|
|
||||||
- 记忆卡片列表:key(mono accent)、content、category badge、importance、updated_at、session_id。
|
|
||||||
- 行内编辑:点击「编辑」→ content 变 textarea + importance range input + 保存/取消按钮。保存调 `PUT /api/memories/{key}`。
|
|
||||||
- 删除:点击「删除」→ 确认对话框。Knowledge 普通确认;Timeline 强警告(红色面板 + 说明文字 + 按钮「我了解,确认删除」)。调 `DELETE /api/memories/{key}`。
|
|
||||||
- 分页:limit=100,底部「加载更多」按钮(offset 通过增大 limit 实现——现有 API 不支持 offset,用 limit 递增近似)。
|
|
||||||
|
|
||||||
- [ ] **Step 1: 重写 MemoryPage.svelte**(上述布局)。
|
|
||||||
- [ ] **Step 2: 验证** — `cd webui && npm run check && npm run build`。
|
|
||||||
- [ ] **Step 3: Commit** — `git add webui/src/pages/MemoryPage.svelte && git commit -m "feat(webui): editable memory page with delete confirmation"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 5: 前端 — 任务页重构
|
|
||||||
|
|
||||||
### Task 5.1: TasksPage 增强
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `webui/src/pages/TasksPage.svelte`
|
|
||||||
|
|
||||||
布局(规格 §6.6):
|
|
||||||
- 定时任务卡片增强:
|
|
||||||
- 显示 cron 表达式(mono)。
|
|
||||||
- 下次运行倒计时(「3 分钟后」格式,每 30s 刷新)。
|
|
||||||
- 最近运行状态点(最多 10 个圆点:绿=completed / 琥珀=timeout / 红=error)。
|
|
||||||
- 可展开运行记录(已有,保留)。
|
|
||||||
- 后台任务增强:
|
|
||||||
- 运行中状态用脉冲动画(`.pulse` class)。
|
|
||||||
- 显示耗时(created_at 到现在的差值,或 duration 字段)。
|
|
||||||
|
|
||||||
- [ ] **Step 1: 增强 TasksPage.svelte**(上述改动,保留现有结构)。
|
|
||||||
- [ ] **Step 2: 验证** — `cd webui && npm run check && npm run build`。
|
|
||||||
- [ ] **Step 3: Commit** — `git add webui/src/pages/TasksPage.svelte && git commit -m "feat(webui): enhanced tasks page with countdown and status dots"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 6: 收尾
|
|
||||||
|
|
||||||
### Task 6.1: P2 收尾验证 + 版本号
|
|
||||||
|
|
||||||
- [ ] **Step 1: 全量验证**
|
|
||||||
- `cd webui && npm run check && npm run build`
|
|
||||||
- `cargo build`
|
|
||||||
- `cargo test --lib`
|
|
||||||
- `cargo clippy --all-targets --all-features -- -D warnings`
|
|
||||||
Expected: 全部通过。
|
|
||||||
- [ ] **Step 2: 版本号** — `Cargo.toml` 与 `webui/package.json` minor bump(1.5.0 → 1.6.0)。
|
|
||||||
- [ ] **Step 3: Commit** — `git add -A && git commit -m "chore(release): P2 logs and data"`(仅暂存版本号文件 + 可能的文档;确认无构建产物)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P2 完成标志
|
|
||||||
|
|
||||||
- `/ws/logs` 实时推送日志帧(ts/level/target/message),设备鉴权保护,慢客户端丢旧不阻塞。
|
|
||||||
- `GET /api/logs` 保留,用于历史拉取与重连对齐。
|
|
||||||
- `PUT /api/memories/{key}` 更新 content/importance,key 不存在返回 404。
|
|
||||||
- `DELETE /api/memories/{key}` 幂等删除。
|
|
||||||
- 日志页:实时流 + level 过滤 + 搜索 + 暂停 + 下载 + 断线重连。
|
|
||||||
- 记忆页:行内编辑 + 分级删除确认 + 搜索 + 分类筛选。
|
|
||||||
- 任务页:cron 显示 + 倒计时 + 运行状态点 + 脉冲动画。
|
|
||||||
- `npm run check`、`npm run build`、`cargo build`、`cargo test --lib`、`cargo clippy -- -D warnings` 全绿。
|
|
||||||
|
|
||||||
## 风险与开放项
|
|
||||||
|
|
||||||
- **chrono vs time**:tracing-subscriber 的 `local-time` feature 已引入 `time` crate。优先用 `time::OffsetDateTime::now_local()` 避免新增 chrono 依赖。若 `now_local()` 在多线程环境报错(`time` crate 的已知限制),回退到 `std::time::SystemTime` + 手动格式化或用 `chrono`。
|
|
||||||
- **广播层性能**:`receiver_count() == 0` 短路确保无订阅者时零开销。有订阅者时每条日志一次 format + send,对高频 DEBUG 日志可能有微量开销,但 EnvFilter 默认 INFO 已过滤大部分。
|
|
||||||
- **WS 重连对齐**:前端断线重连时重新拉 `GET /api/logs` 尾部,可能丢失断线期间的少量日志(broadcast 不持久化)。可接受——日志页为运维辅助,非审计。
|
|
||||||
- **记忆分页**:现有 API 无 offset/cursor,用 limit 递增近似。大量记忆(>1000)时性能可接受(SQLite 单表 LIMIT 查询)。
|
|
||||||
- **记忆 PUT 不创建**:设计决策——新记忆只由 agent 内部创建,WebUI 仅编辑已有条目。若需创建能力,后续 P3 可加 POST。
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
# P3 配置 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan.
|
|
||||||
|
|
||||||
**Goal:** 重构 WebUI 配置页(SettingsPage),增加实时 JSON 校验、配置大纲、重载状态卡、保存并热重载、放弃修改、未保存提示。
|
|
||||||
|
|
||||||
**Architecture:** 纯前端重构。所有后端端点已存在(GET/PUT /api/config、GET/PUT /api/profiles/{name}、POST /api/config/reload、GET /api/config/reload/status)。SettingsPage.svelte 重写为带配置大纲侧栏 + 重载状态卡 + 三操作按钮的布局。
|
|
||||||
|
|
||||||
**Tech Stack:** Svelte 5(runes)、bits-ui Tabs。
|
|
||||||
|
|
||||||
**关键设计决策:**
|
|
||||||
1. **布局**:左侧编辑区(现有 editor-card)+ 右侧大纲/状态侧栏(仅 config.json 标签显示)。
|
|
||||||
2. **JSON 校验**:`$derived` 实时 parse content,显示错误信息或有效状态。
|
|
||||||
3. **配置大纲**:从 parsed config 提取顶层 key 列表,标注含密钥的 section(providers)和需重启的字段(gateway.host/port/workspace)。
|
|
||||||
4. **重载状态卡**:onMount 起每 3s 轮询 `GET /api/config/reload/status`,显示 generation、phase badge、last_error。
|
|
||||||
5. **操作按钮**:保存(PUT /api/config)、保存并热重载(PUT 后 POST /api/config/reload)、放弃修改(重新 load)。
|
|
||||||
6. **未保存提示**:比较当前 content 与上次加载的 content,不同则显示修改标记。
|
|
||||||
7. **USER.md / AGENTS.md 标签**:保持简单 textarea + 保存,无大纲/重载。
|
|
||||||
|
|
||||||
**验证约定:** `cd webui && npm run check && npm run build` + `cargo build`(验证 OUT_DIR 嵌入)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: SettingsPage 重构
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `webui/src/pages/SettingsPage.svelte`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 重写 SettingsPage.svelte**
|
|
||||||
|
|
||||||
布局:
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ [config.json] [USER.md] [AGENTS.md] [重载状态卡] │
|
|
||||||
├───────────────────────────────────┬─────────────────┤
|
|
||||||
│ editor-head: title + path │ 配置大纲 │
|
|
||||||
│ + 未保存标记 │ (仅 config) │
|
|
||||||
│ ───────────────────────────── │ │
|
|
||||||
│ textarea (JSON/Markdown) │ gateway ⚠重启 │
|
|
||||||
│ │ providers 🔑 │
|
|
||||||
│ │ agent │
|
|
||||||
│ │ channels │
|
|
||||||
│ │ memory │
|
|
||||||
│ │ scheduler │
|
|
||||||
│ ───────────────────────────── │ │
|
|
||||||
│ [保存] [保存并热重载] [放弃修改] │ │
|
|
||||||
│ notice / validation error │ │
|
|
||||||
└───────────────────────────────────┴─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
功能:
|
|
||||||
- JSON 实时校验(config 标签):parse 失败显示红色错误行
|
|
||||||
- 未保存修改:dirty 标记(对比 original content)
|
|
||||||
- 保存:PUT /api/config 或 PUT /api/profiles/{name}
|
|
||||||
- 保存并热重载:保存成功后 POST /api/config/reload,显示结果
|
|
||||||
- 放弃修改:恢复为上次加载的 content
|
|
||||||
- 重载状态卡:generation、phase(badge 着色)、last_error
|
|
||||||
- 配置大纲:顶层 key 列表 + 标注
|
|
||||||
|
|
||||||
- [ ] **Step 2: 验证** — `cd webui && npm run check && npm run build`
|
|
||||||
- [ ] **Step 3: Commit** — `git add webui/src/pages/SettingsPage.svelte && git commit -m "feat(webui): enhanced settings page with outline, reload status, hot reload"`
|
|
||||||
|
|
||||||
## Task 2: 收尾
|
|
||||||
|
|
||||||
- [ ] **Step 1: 全量验证** — `cargo build` + `cd webui && npm run check && npm run build`
|
|
||||||
- [ ] **Step 2: 版本号** — 1.6.0 → 1.7.0
|
|
||||||
- [ ] **Step 3: Commit** — `git add Cargo.toml webui/package.json && git commit -m "chore(release): P3 configuration"`
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
# Sleep Tool Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add a model-callable `sleep` tool that asynchronously waits for 0~86400 whole seconds and terminates cleanly when its Turn is cancelled.
|
|
||||||
|
|
||||||
**Architecture:** A focused, stateless `SleepTool` validates its single argument and waits on one bounded Tokio timer. The existing default registry exposes it to agents, dropping the surrounding execution future cancels the timer, and terminal Turn reduction marks active tool blocks cancelled.
|
|
||||||
|
|
||||||
**Tech Stack:** Rust 2024, Tokio timers and paused-time tests, existing `Tool`/`ToolResult` interfaces, Serde JSON.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 1: Tool And Registration
|
|
||||||
|
|
||||||
### Task 1: Implement And Register `SleepTool`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/tools/sleep.rs`
|
|
||||||
- Modify: `src/tools/mod.rs:1-52`
|
|
||||||
- Modify: `src/tools/mod.rs:74-90`
|
|
||||||
- Test: `src/tools/sleep.rs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Declare the module and write failing metadata tests**
|
|
||||||
|
|
||||||
Add `pub mod sleep;` and `pub use sleep::SleepTool;` to `src/tools/mod.rs`. Create `src/tools/sleep.rs` with a test module that imports `super::*`, `crate::tools::Tool`, `serde_json::json`, and `std::time::Duration`. Add a synchronous test asserting the name is `sleep`, the schema requires `seconds`, and its property type is `integer` with minimum `0`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Write failing validation tests**
|
|
||||||
|
|
||||||
Add a zero-second async test asserting exact successful output `Slept for 0 second(s).`. Add a table-driven async test for `{}`, negative, fractional, string, large float, and `86401`; assert each result is unsuccessful with empty output and a populated error. Add a parser boundary test:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn accepts_24_hour_boundary() {
|
|
||||||
assert_eq!(parse_seconds(&json!({"seconds": 86_400})), Ok(86_400));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write failing timer and cancellation tests**
|
|
||||||
|
|
||||||
Add the following paused-clock elapsed test. Yield after every `advance` so expired timers are polled deterministically:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn waits_for_requested_seconds() {
|
|
||||||
let handle = tokio::spawn(async { SleepTool::new().execute(json!({"seconds": 2})).await });
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
tokio::time::advance(Duration::from_secs(1)).await;
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert!(!handle.is_finished());
|
|
||||||
tokio::time::advance(Duration::from_secs(1)).await;
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert!(handle.await.unwrap().unwrap().success);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a 24-hour boundary test that advances to one second before the deadline, asserts the handle is unfinished, advances the final second, and asserts success.
|
|
||||||
|
|
||||||
Add the cancellation test, which yields before aborting to ensure the timer has been registered:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn cancellation_drops_an_active_sleep() {
|
|
||||||
let handle = tokio::spawn(async {
|
|
||||||
SleepTool::new()
|
|
||||||
.execute(json!({"seconds": MAX_SLEEP_SECONDS}))
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert!(!handle.is_finished());
|
|
||||||
handle.abort();
|
|
||||||
assert!(handle.await.unwrap_err().is_cancelled());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the focused test target and confirm RED**
|
|
||||||
|
|
||||||
Run: `cargo test --lib tools::sleep::tests`
|
|
||||||
|
|
||||||
Expected: compilation fails because `SleepTool`, `parse_seconds`, and `MAX_SLEEP_SECONDS` are not defined.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Implement the minimal tool**
|
|
||||||
|
|
||||||
Implement `src/tools/sleep.rs` with this shape:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use super::traits::{Tool, ToolResult};
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
const MAX_SLEEP_SECONDS: u64 = 86_400;
|
|
||||||
|
|
||||||
pub struct SleepTool;
|
|
||||||
|
|
||||||
impl SleepTool {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SleepTool {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
|
|
||||||
let seconds = args.get("seconds")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.ok_or_else(|| "seconds must be a non-negative integer".to_string())?;
|
|
||||||
if seconds > MAX_SLEEP_SECONDS {
|
|
||||||
return Err("seconds must not exceed 86400 (24 hours)".to_string());
|
|
||||||
}
|
|
||||||
Ok(seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for SleepTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"sleep"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Pause the current agent execution for a specified number of whole seconds."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"seconds": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0,
|
|
||||||
"maximum": MAX_SLEEP_SECONDS,
|
|
||||||
"description": "Number of whole seconds to wait, up to 24 hours."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["seconds"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
||||||
let seconds = match parse_seconds(&args) {
|
|
||||||
Ok(seconds) => seconds,
|
|
||||||
Err(error) => {
|
|
||||||
return Ok(ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: String::new(),
|
|
||||||
error: Some(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_secs(seconds)).await;
|
|
||||||
|
|
||||||
Ok(ToolResult {
|
|
||||||
success: true,
|
|
||||||
output: format!("Slept for {seconds} second(s)."),
|
|
||||||
error: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep the default `read_only`, `concurrency_safe`, and `exclusive` methods unchanged so a batch containing `sleep` executes sequentially.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Register the tool**
|
|
||||||
|
|
||||||
In `create_default_tools`, add:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
registry.register(SleepTool::new());
|
|
||||||
```
|
|
||||||
|
|
||||||
Place it with the other stateless core tools, immediately after `CalculatorTool`.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run focused tests and confirm GREEN**
|
|
||||||
|
|
||||||
Run: `cargo test --lib tools::sleep::tests`
|
|
||||||
|
|
||||||
Expected: all sleep tests pass, including paused-time and cancellation cases.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Verify the complete Rust change**
|
|
||||||
|
|
||||||
Run: `cargo test --lib`
|
|
||||||
|
|
||||||
Expected: all library tests pass.
|
|
||||||
|
|
||||||
Run: `cargo clippy --all-targets --all-features -- -D warnings`
|
|
||||||
|
|
||||||
Expected: exits successfully with no warnings.
|
|
||||||
|
|
||||||
Run: `cargo build`
|
|
||||||
|
|
||||||
Expected: debug build succeeds, including embedded WebUI build handling.
|
|
||||||
|
|
||||||
- [ ] **Step 9: Inspect the final diff**
|
|
||||||
|
|
||||||
Run: `git diff --check && git status --short && git diff -- src/tools/sleep.rs src/tools/mod.rs docs/superpowers/specs/2026-07-28-sleep-tool-design.md docs/superpowers/plans/2026-07-28-sleep-tool.md`
|
|
||||||
|
|
||||||
Expected: no whitespace errors; only the intended sleep implementation, Turn cancellation handling, public documentation, tests, and patch-version files are changed. Do not commit unless the user explicitly requests it.
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# Sleep Tool Design
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Add a model-callable `sleep` tool that pauses the current agent tool call for a requested number of whole seconds. This first version only waits in process and does not schedule durable or background work.
|
|
||||||
|
|
||||||
## Interface
|
|
||||||
|
|
||||||
- Tool name: `sleep`
|
|
||||||
- Arguments: an object with one required `seconds` field
|
|
||||||
- `seconds` must be an integer from `0` through `86400` inclusive
|
|
||||||
- The maximum foreground wait is 24 hours
|
|
||||||
- `0` is valid and completes immediately
|
|
||||||
- Unknown object fields are ignored consistently with existing native tools
|
|
||||||
|
|
||||||
Invalid, missing, negative, fractional, or values above `86400` return an ordinary unsuccessful `ToolResult`. A successful call returns `Slept for N second(s).`, with the requested duration substituted for `N`.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Create a stateless `SleepTool` in `src/tools/sleep.rs`. Its `Tool::execute` implementation validates `seconds`, waits on one bounded Tokio timer, and returns success after the full duration. The asynchronous timer does not block the Gateway runtime.
|
|
||||||
|
|
||||||
Export the type from `src/tools/mod.rs` and register it in `create_default_tools`, making it available to the root agent and to constrained tool registries unless those registries explicitly filter it by name.
|
|
||||||
|
|
||||||
The tool does not persist state, create a background task, or send messages. It retains the `Tool` trait's default non-concurrency-safe classification, so a model response containing `sleep` and other calls executes that batch sequentially. `/stop`, supervisor shutdown, scheduler timeout, and sub-agent timeout cancel work by dropping the surrounding agent execution future; dropping that future also drops the current Tokio sleep timer. When a Turn is cancelled, every still-running tool block is normalized to `ToolStatus::Cancelled` before publishing the terminal snapshot.
|
|
||||||
|
|
||||||
The 24-hour cap bounds foreground resource retention. Longer or restart-durable waits must use Scheduler or background work rather than holding an interactive session worker.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Unit tests cover the tool metadata and schema, immediate success for zero seconds, elapsed-time behavior using Tokio's paused clock, rejection of missing, negative, fractional, string, and over-24-hour values, acceptance of the 24-hour boundary, cancellation of an active sleeping task, and Turn cancellation normalization.
|
|
||||||
|
|
||||||
Run the targeted tests, `cargo test --lib`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo build`.
|
|
||||||
|
|
||||||
## Out Of Scope
|
|
||||||
|
|
||||||
- Slash commands or direct user invocation
|
|
||||||
- Durable sleeps that survive process restart
|
|
||||||
- Delayed or scheduled message delivery
|
|
||||||
- A configurable duration limit
|
|
||||||
|
|
||||||
This change increments only the product patch version.
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
# Sub-Agent Activity WebUI Design
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Restructure the WebUI around sub-agent activity while moving sub-agent *definition* management into the settings page. Concretely:
|
|
||||||
|
|
||||||
1. Move sub-agent definition management (CRUD, enable/disable, role prompt) out of the `agents` navigation item into a new "子代理" tab on the settings page, and enlarge the role-prompt editing area.
|
|
||||||
2. Turn the `agents` navigation item into an activity monitor that shows running sub-agents and historical run results.
|
|
||||||
3. Add a read-only, chat-like detail view for a single sub-agent run, including a full, incrementally streamed message transcript.
|
|
||||||
4. Reduce the `tasks` page to scheduled-task information only; move the background-task listing into the sub-agent activity page.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- No changes to sub-agent orchestration semantics (delegation, budgets, signals, run admission).
|
|
||||||
- No WebUI routing framework; the detail view is an in-page sub-view.
|
|
||||||
- No persistence of the main agent's chat history changes; the transcript work is scoped to sub-agent runs.
|
|
||||||
|
|
||||||
## Backend
|
|
||||||
|
|
||||||
### Schema (v9)
|
|
||||||
|
|
||||||
Add a new `agent_run_messages` table for incrementally streamed sub-agent transcripts:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS agent_run_messages (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
run_id TEXT NOT NULL,
|
|
||||||
seq INTEGER NOT NULL,
|
|
||||||
role TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
reasoning_content TEXT,
|
|
||||||
tool_call_id TEXT,
|
|
||||||
tool_name TEXT,
|
|
||||||
tool_calls_json TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agent_run_messages_run_seq ON agent_run_messages(run_id, seq);
|
|
||||||
```
|
|
||||||
|
|
||||||
- Bump `SCHEMA_VERSION` from `8` to `9` in `src/storage/mod.rs`.
|
|
||||||
- Add the table DDL to `src/storage/agent_run.rs::AGENT_SCHEMA_STATEMENTS`.
|
|
||||||
- **Version-gate the existing agent-table drops.** `migrate_schema` currently drops `agent_runs`/`agent_inbox_events`/`agent_run_groups` unconditionally whenever `current < SCHEMA_VERSION`; on a v8→v9 upgrade this would destroy run history. Gate those drops (and the `background_tasks` drop) on `current < 8` — the batch-group removal is a pre-v8 concern — so a v8→v9 upgrade only adds the new table and preserves existing runs. When the drops do run (pre-v8), drop `agent_run_messages` before `agent_runs` so foreign-key enforcement never blocks the implicit row delete.
|
|
||||||
- Update the v8 schema test that hardcodes `assert_eq!(version, 8)` (`fresh_database_creates_schema_v8_agent_tables`) to v9 and rename it accordingly.
|
|
||||||
|
|
||||||
We deliberately do **not** add a transcript column to `agent_runs`. Incremental append rows in `agent_run_messages` are the single source of truth for transcripts.
|
|
||||||
|
|
||||||
### Incremental capture
|
|
||||||
|
|
||||||
- Add an optional sink to `AgentLoop`:
|
|
||||||
- field `transcript_sink: Option<tokio::sync::mpsc::UnboundedSender<ChatMessage>>`
|
|
||||||
- builder `with_transcript_sink(sender) -> Self`
|
|
||||||
- at every site where a message is appended to `emitted_messages` (assistant messages and tool result messages), forward a clone to the sink. Convert `append_steering_messages` from an associated function to a method so it can forward too (sub-agents never use steering, but the sink must be consistent for future use).
|
|
||||||
- `src/agent/sub_agent.rs::execute_resolved`:
|
|
||||||
- create an `mpsc::unbounded_channel`
|
|
||||||
- pass the sender via `build_sub_agent_with_provider` → `with_transcript_sink`
|
|
||||||
- spawn a writer task that owns the receiver and a `seq` counter; for each message it strips `provider_state` (set to `None`) and does `INSERT INTO agent_run_messages`
|
|
||||||
- restructure so the sender is dropped and the writer task is awaited in **every** exit path, including the `tokio::select!` cancellation arm that currently returns early without `process_with_context` returning; only after the writer drains does the terminal commit run
|
|
||||||
- the writer uses `self.storage` (the manager already holds `Option<Arc<Storage>>`); if storage is absent, the writer becomes a no-op
|
|
||||||
|
|
||||||
The task prompt is not duplicated into the table; the detail view renders `run.task` as the leading user bubble.
|
|
||||||
|
|
||||||
### Storage API
|
|
||||||
|
|
||||||
Add to `src/storage/agent_run.rs`:
|
|
||||||
|
|
||||||
- `append_agent_run_message(run_id, seq, &message, now) -> Result<()>` — single-row insert.
|
|
||||||
- `list_agent_run_messages(run_id, limit) -> Result<Vec<AgentRunMessageRecord>>` — ordered by `seq`. The transcript is naturally bounded: one run emits at most a handful of messages per tool iteration and iterations are capped by the definition's `limits.max_iterations` (default 99); default `limit` of `10_000` is a generous ceiling, not a pagination contract. `get_agent_run` uses this same default limit when loading the transcript.
|
|
||||||
|
|
||||||
Two distinct types to avoid a storage/protocol collision:
|
|
||||||
|
|
||||||
- `AgentRunMessageRecord` (storage, in `src/storage/agent_run.rs`): carries the raw columns — `id, run_id, seq, role, content, reasoning_content, tool_call_id, tool_name, tool_calls_json, created_at`.
|
|
||||||
- `AgentTranscriptMessage` (protocol, in `src/protocol.rs`): the serialized shape — same fields except `tool_calls_json` is parsed into `Vec<providers::ToolCall>`; a `From<AgentRunMessageRecord>` impl performs the parse.
|
|
||||||
|
|
||||||
### HTTP API
|
|
||||||
|
|
||||||
- `GET /api/agent-runs/{id}` currently returns `{ "run": AgentRunView }`. Extend the response to `{ "run": ..., "session_id": ..., "transcript": [ ... ] }` where:
|
|
||||||
- `session_id` is `run.root_session_id` (the `AgentRunView` deliberately omits it, so it is added at this endpoint's response level)
|
|
||||||
- `transcript` is the ordered list of `AgentTranscriptMessage` rows (exposing `reasoning_content` but never `provider_state`)
|
|
||||||
- `GET /api/agent-runs/{id}/events` is unchanged (signals/completions).
|
|
||||||
- `GET /api/tasks` is unchanged and already lists all runs in any status; the activity page consumes it.
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
### Settings page (`SettingsPage.svelte`)
|
|
||||||
|
|
||||||
- Add a "子代理定义" tab to the existing vertical `Tabs` (named to disambiguate from the "子代理" activity nav item). Move the definition list and the editor modal from `AgentsPage.svelte` here verbatim, then:
|
|
||||||
- enlarge the role-prompt `textarea` (`min-height` ~360px, full-width, monospace)
|
|
||||||
- widen the editor modal (`min(920px, 94vw)`) and put the role-prompt field on its own row
|
|
||||||
- keep the existing API calls (`/api/agents`, `/api/agents/options`, POST/DELETE) unchanged
|
|
||||||
|
|
||||||
### Sub-agent activity page (`AgentsPage.svelte`, rewritten)
|
|
||||||
|
|
||||||
- List view:
|
|
||||||
- "活动中" section: runs in `queued`/`running`/`waiting_children` status, with a pulse indicator, `agent_id`, prompt excerpt, `mode`/`depth`, and elapsed time.
|
|
||||||
- "历史活动" section: terminal runs (`completed`/`failed`/`timed_out`/`cancelled`/`interrupted`), newest first, with `StatusBadge`, `agent_id`, prompt excerpt, `tool_calls_count`/`iterations`, timestamps, and a "详情" action.
|
|
||||||
- Poll `GET /api/tasks?limit=200` every 5s while mounted.
|
|
||||||
- Detail view (in-page sub-view, back button, read-only):
|
|
||||||
- metadata header: `agent_id`, `StatusBadge`, `provider/model`, `mode`, `depth`, `tool_calls_count`, `iterations`, `session_id`, `started_at`/`finished_at`, duration, `parent_run_id` if present
|
|
||||||
- leading "task" bubble from `run.task`
|
|
||||||
- transcript messages rendered like the chat page: `Markdown` for content, collapsible reasoning block, `ToolCallCard` for tool calls. Pair each assistant message's `tool_calls` with its `tool`-role result by `tool_call_id` (mirroring `ChatPage`'s `toolResult()`), so calls and results remain independently collapsible.
|
|
||||||
- signal cards from `GET /api/agent-runs/{id}/events` (same rendering as the chat page's agent-event cards)
|
|
||||||
- final `error` card on failure
|
|
||||||
- while `status` is non-terminal, poll `GET /api/agent-runs/{id}` + `/events` every 2s to stream the growing transcript; stop polling on terminal status
|
|
||||||
- No composer, no input, no interactive actions other than navigation/collapse.
|
|
||||||
|
|
||||||
### Tasks page (`TasksPage.svelte`)
|
|
||||||
|
|
||||||
- Remove the "后台任务" tab and the `Tabs` wrapper; keep only the scheduled-job list (jobs + runs dots), which already comes from `/api/jobs` and `/api/jobs/{id}/runs`.
|
|
||||||
|
|
||||||
### Navigation (`App.svelte`)
|
|
||||||
|
|
||||||
- Update the `agents` page description to reflect activity monitoring ("查看活动中的子代理与历史运行"); keep the nav label "子代理".
|
|
||||||
- Update the `tasks` page description to scheduled tasks only ("管理定时任务").
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- Missing/empty transcript: detail view renders the task bubble + metadata + error (or a "无转录" placeholder for failed runs).
|
|
||||||
- Storage write failures in the transcript writer are logged and the run still commits its terminal status; a broken transcript never fails the run.
|
|
||||||
- API auth/5xx reuse the existing `api()` error handling and `notify` toast path.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Rust: v9 migration from a v8 database **preserves existing `agent_runs` rows** (version-gated drops), and the pre-v8 legacy rebuild path still works; `append_agent_run_message` + `list_agent_run_messages` round-trip with `seq` ordering; `provider_state` is stripped from persisted transcript rows; sink drains on success, timeout, and cancellation before terminal commit (including the early-return cancellation arm); `get_agent_run` returns the transcript and `session_id`; `AgentRunView` list responses remain transcript-free; the v8 hardcoded schema-version assertion is updated to v9.
|
|
||||||
- Run `cargo test --lib` and `cargo clippy --all-targets --all-features -- -D warnings`.
|
|
||||||
- Frontend: `cd webui && npm run check && npm run build`, then `cargo build` to verify the `OUT_DIR` embedding path. No external runtime dependency; the browser stays on `/ws` and the existing HTTP endpoints.
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
id: general-purpose
|
|
||||||
description: 通用目的子代理,处理主 Agent 委托的独立子任务
|
|
||||||
llm_profile: default
|
|
||||||
tools:
|
|
||||||
- bash
|
|
||||||
- file_read
|
|
||||||
- file_search
|
|
||||||
- content_search
|
|
||||||
- web_fetch
|
|
||||||
- calculator
|
|
||||||
---
|
|
||||||
|
|
||||||
# General Purpose Agent
|
|
||||||
|
|
||||||
You are the **general-purpose** sub-agent of PicoBot, a general-purpose assistant.
|
|
||||||
|
|
||||||
## Role
|
|
||||||
- Handle any independent subtask delegated by the main agent.
|
|
||||||
- Work autonomously and report back concrete results.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
- Follow the task description and context provided by the delegator.
|
|
||||||
- Use the tools you need; prefer read-only operations unless the task requires changes.
|
|
||||||
- Be accurate: do not fabricate tool results or guesses; report failures honestly.
|
|
||||||
- If information is missing, ask or state the gap rather than inventing it.
|
|
||||||
- You do not hold the main session's conversation history; rely on the context given in your task.
|
|
||||||
- Do not delegate further to other agents unless explicitly instructed.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
- Deliver the final result in a clear, structured, self-contained format.
|
|
||||||
- Keep it concise unless the task requires detail.
|
|
||||||
- If the task cannot be completed, explain why and what is blocking it.
|
|
||||||
@ -13,8 +13,8 @@ PicoBot 是一个基于 Rust 的个人 AI 助手运行时,包含本地 Gateway
|
|||||||
|
|
||||||
| 文件 | 内容 |
|
| 文件 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `references/config.md` | 配置字段详解:providers、models、agents、agent_orchestration、gateway、client、channels、memory、mcp、browser |
|
| `references/config.md` | 配置字段详解:providers、models、agents、gateway、client、channels、memory、mcp、browser |
|
||||||
| `references/db-schema.md` | 数据库表结构与运行约束:sessions、messages、memories、task plans/items、scheduled_jobs、job_runs、llm_calls、agent run/inbox/state |
|
| `references/db-schema.md` | 数据库表结构与运行约束:sessions、messages、memories、scheduled_jobs、job_runs、llm_calls、background_tasks |
|
||||||
| `references/architecture.md` | 核心架构:消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent |
|
| `references/architecture.md` | 核心架构:消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent |
|
||||||
| `references/faq.md` | 常见问题:模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
|
| `references/faq.md` | 常见问题:模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
|
||||||
| `references/commands.md` | 常用命令:编译、启动网关、Docker/WebUI 设备配对、启动客户端、运行测试 |
|
| `references/commands.md` | 常用命令:编译、启动网关、Docker/WebUI 设备配对、启动客户端、运行测试 |
|
||||||
|
|||||||
@ -23,8 +23,7 @@
|
|||||||
"qwen-plus": {
|
"qwen-plus": {
|
||||||
"model_id": "qwen-plus",
|
"model_id": "qwen-plus",
|
||||||
"temperature": 0.0,
|
"temperature": 0.0,
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192
|
||||||
"token_limit": 128000
|
|
||||||
},
|
},
|
||||||
"gpt-4o": {
|
"gpt-4o": {
|
||||||
"model_id": "gpt-4o",
|
"model_id": "gpt-4o",
|
||||||
@ -43,24 +42,14 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"provider": "aliyun",
|
"provider": "aliyun",
|
||||||
"model": "qwen-plus",
|
"model": "qwen-plus",
|
||||||
"max_tool_iterations": 99
|
"max_tool_iterations": 99,
|
||||||
|
"token_limit": 128000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"context_compaction": {
|
|
||||||
"enabled": true,
|
|
||||||
"reserve_tokens": 16384,
|
|
||||||
"keep_recent_tokens": 20000
|
|
||||||
},
|
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 19876,
|
"port": 19876,
|
||||||
"require_pairing": true,
|
"require_pairing": true
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"poll_interval_secs": 60,
|
|
||||||
"max_concurrent": 1,
|
|
||||||
"execution_timeout_secs": 900
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"gateway_url": "ws://127.0.0.1:19876/ws"
|
"gateway_url": "ws://127.0.0.1:19876/ws"
|
||||||
@ -87,10 +76,6 @@
|
|||||||
"consolidation_provider": null,
|
"consolidation_provider": null,
|
||||||
"consolidation_model": null,
|
"consolidation_model": null,
|
||||||
"recall_limit": 5,
|
"recall_limit": 5,
|
||||||
"recall_min_relevance": 0.25,
|
|
||||||
"recall_min_score": 0.25,
|
|
||||||
"recall_recency_half_life_days": 30,
|
|
||||||
"recall_timeout_ms": 1000,
|
|
||||||
"idle_consolidation_minutes": 10,
|
"idle_consolidation_minutes": 10,
|
||||||
"timeline_retention_days": 90,
|
"timeline_retention_days": 90,
|
||||||
"max_failures_before_degrade": 3
|
"max_failures_before_degrade": 3
|
||||||
@ -100,21 +85,10 @@
|
|||||||
"tool_timeout_secs": 180
|
"tool_timeout_secs": 180
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"command": "agent-browser",
|
"webdriver_url": "http://127.0.0.1:9515",
|
||||||
"headless": true,
|
"headless": true,
|
||||||
"browser_executable_path": null,
|
"chrome_path": null
|
||||||
"max_sessions": 4,
|
|
||||||
"idle_timeout_secs": 3600,
|
|
||||||
"command_timeout_secs": 120,
|
|
||||||
"max_output_chars": 50000,
|
|
||||||
"content_boundaries": true,
|
|
||||||
"allowed_domains": [],
|
|
||||||
"allow_private_hosts": false,
|
|
||||||
"artifact_dir": "~/.picobot/media/browser",
|
|
||||||
"persistence": {
|
|
||||||
"profile_dir": "~/.picobot/browser/profiles"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"workspace_dir": "~/.picobot/workspace"
|
"workspace_dir": "~/.picobot/workspace"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ Channel → MessageBus.inbound → Gateway processor → SessionManager → per-
|
|||||||
AgentLoop → TurnEvent → TurnController → latest TurnSnapshot → DeliveryCoordinator → TurnSink → Channel
|
AgentLoop → TurnEvent → TurnController → latest TurnSnapshot → DeliveryCoordinator → TurnSink → Channel
|
||||||
|
|
||||||
WebSocket/Channel → MessageBus.control → Gateway processor → SessionManager (dialog 操作)
|
WebSocket/Channel → MessageBus.control → Gateway processor → SessionManager (dialog 操作)
|
||||||
Scheduler → occurrence/JobRun claim → AgentCoordinator → isolated Scheduled Agent → complete_scheduled_run → JobRun outbox → SessionManager/MessageBus
|
Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
||||||
```
|
```
|
||||||
|
|
||||||
## 模块职责
|
## 模块职责
|
||||||
@ -32,7 +32,6 @@ Scheduler → occurrence/JobRun claim → AgentCoordinator → isolated Schedule
|
|||||||
| `observability` | Observer 模式,agent/工具遥测事件 |
|
| `observability` | Observer 模式,agent/工具遥测事件 |
|
||||||
| `protocol` | WebSocket 协议消息定义 |
|
| `protocol` | WebSocket 协议消息定义 |
|
||||||
| `config` | 配置加载、环境变量替换、路径解析 |
|
| `config` | 配置加载、环境变量替换、路径解析 |
|
||||||
| `health` | CLI、工具、斜杠命令和 WebUI 共用的只读运行依赖检查 |
|
|
||||||
| `memory` | 长期记忆存储与检索 |
|
| `memory` | 长期记忆存储与检索 |
|
||||||
| `mcp` | MCP(Model Context Protocol)工具集成 |
|
| `mcp` | MCP(Model Context Protocol)工具集成 |
|
||||||
| `task_supervisor` | Gateway 后台任务注册、取消、限时等待和强制回收 |
|
| `task_supervisor` | Gateway 后台任务注册、取消、限时等待和强制回收 |
|
||||||
@ -42,31 +41,28 @@ Scheduler → occurrence/JobRun claim → AgentCoordinator → isolated Schedule
|
|||||||
|
|
||||||
- Channels 通过 MessageBus 发布入站消息,通过 OutboundDispatcher 或每 Turn 一个的 TurnSink 接收出站写入,不感知 session 或 LLM
|
- Channels 通过 MessageBus 发布入站消息,通过 OutboundDispatcher 或每 Turn 一个的 TurnSink 接收出站写入,不感知 session 或 LLM
|
||||||
- MessageBus 本体持有三条有界队列;出站路由、顺序和重试由 `OutboundDispatcher` 负责
|
- MessageBus 本体持有三条有界队列;出站路由、顺序和重试由 `OutboundDispatcher` 负责
|
||||||
- SessionManager 拥有 session 状态、dialog 路由、上下文构建、每 session worker 和活动 Turn 的 steering mailbox,并通过 worker 创建 AgentLoop
|
- SessionManager 拥有 session 状态、dialog 路由、上下文构建和每 session worker,并通过 worker 创建 AgentLoop
|
||||||
- TurnController 是活动 Turn 状态的唯一 owner;Session 在消息原子提交成功后才发布 Completed
|
- TurnController 是活动 Turn 状态的唯一 owner;Session 在消息原子提交成功后才发布 Completed
|
||||||
- AgentLoop 跨轮无状态,接收已准备的 history,并在安全模型边界排空本 Turn steering 后调用 LLM、执行工具并返回一次结果
|
- AgentLoop 跨轮无状态,接收已准备的 history 调用 LLM、执行工具并返回一次结果
|
||||||
- Providers 是纯 HTTP 流客户端,无 bus/session/channel 感知;签名 reasoning 状态只回放给匹配 Provider,不下发客户端或 Channel
|
- Providers 是纯 HTTP 流客户端,无 bus/session/channel 感知;签名 reasoning 状态只回放给匹配 Provider,不下发客户端或 Channel
|
||||||
- DeliveryCoordinator 只投影完整快照,不修改会话历史;慢消费者跳过中间 revision,终态显式、有界投递
|
- DeliveryCoordinator 只投影完整快照,不修改会话历史;慢消费者跳过中间 revision,终态显式、有界投递
|
||||||
- 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内
|
- 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内
|
||||||
- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext`
|
- Tools 接收原始参数,返回字符串结果
|
||||||
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
|
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
|
||||||
- 具名子 Agent 从运行代不可变 `AgentCatalog` 加载,Definition 固定 Provider/Model、工具/Skill allowlist、委托边与限制;工具集完全由定义文件的 `tools` 列表决定(管理员显式授权),`delegate`/`emit_signal`/`get_skill`/`agent_task` 为运行时注入不可静态声明。单个定义校验失败(坏 YAML、未知 provider/profile/model/tool/skill、或显式委托到缺失目标)仅停用该定义并记入 `load_errors`(`GET /api/agents` 返回),不会阻塞启动或热重载;配置与目录信任级错误仍然致命。支持单个/批量 foreground 和显式父子授权;Root 对具名 Agent 的 background(单任务或批量)走 durable run/inbox + continuation 投递,每个 run 独立完成、空闲时完成即返回。内置 general-purpose 定义随二进制释放到 `~/.picobot/agents/`,WebUI「子 Agent」页可增删改与启停定义
|
- 子 Agent 由 `delegate` 工具创建,复用 provider 配置和按需过滤后的工具集;后台任务结果通过 MessageBus 发回原会话
|
||||||
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
|
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
|
||||||
- WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
|
- WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
|
||||||
- WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
- WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
||||||
- WebUI 通过 `get_session_stats`/`session_stats` 显示当前会话累计输入输出 Token 和上下文窗口占用;`/info [--json]` 读取同一份 SessionStats
|
|
||||||
|
|
||||||
## 关键约束
|
## 关键约束
|
||||||
|
|
||||||
- Gateway 启动时切换到 workspace 目录
|
- Gateway 启动时切换到 workspace 目录
|
||||||
- SQLite 数据在 `{config_dir}/data/picobot.db`(`config_dir` 默认 `~/.picobot`),与 workspace 相互独立
|
- SQLite 数据在 `{workspace}/picobot.db`
|
||||||
- ChannelManager 持有 MessageBus 和所有 channel
|
- ChannelManager 持有 MessageBus 和所有 channel
|
||||||
- OutboundDispatcher 通过 ChannelManager 路由出站消息
|
- OutboundDispatcher 通过 ChannelManager 路由出站消息
|
||||||
- 配置目录 `.env` 与 workspace `.env` 仅在单线程启动阶段分层加载,并使用 `unsafe { env::set_var(...) }` 写入进程环境;优先级为既有进程环境 > workspace > 配置目录
|
- 配置目录 `.env` 与 workspace `.env` 仅在单线程启动阶段分层加载,并使用 `unsafe { env::set_var(...) }` 写入进程环境;优先级为既有进程环境 > workspace > 配置目录
|
||||||
- `browser` 工具默认启用,只有 `browser.enabled=false` 时不注册;缺少 CLI/Chrome 不阻止 Gateway 启动,但 health 和实际调用会给出安装错误。每次调用按参数分流:不传 `persistent_id` 时按 dialog 使用普通临时浏览器,默认空闲一小时后自动关闭;长期工作时 Agent 可自主创建持久身份,并在后续相关 action 中持续传入同一个 ID,持久 daemon 禁用空闲自动关闭。同一 ID 跨 dialog 共享 session/锁,不同 ID 相互独立并可并发。`browser_profiles` 只在受控根目录中创建、设置语义标签、列出或删除合法 ID;没有全局持久化开关、默认 ID,也不自动按 dialog 建立或选择持久 Profile,不依赖 Fantoccini/ChromeDriver/WebDriver
|
- `browser` 工具只有在 `browser.enabled=true` 时注册,依赖 Chrome/Chromium 与 WebDriver
|
||||||
- 所有工具调用统一包装为 `ToolOutput` 并经过公共处理器;产物按模型/用户受众分流。浏览器截图默认同时供模型查看并附到最终回复,`file_read` 图片默认仅供模型理解
|
- 同一 session 的普通消息串行处理,不同 session 可并发;session 队列容量为 32,满时明确拒绝
|
||||||
- 同一 session 只运行一个 Turn;活动 Turn 期间普通输入默认 steering,`/queue` 明确等待下一 Turn,不同 session 可并发
|
|
||||||
- steering mailbox 容量为 32 条/64 KiB,满或关闭时可靠回退到容量 32 的 session 队列;两者都无法接收时明确拒绝
|
|
||||||
- 出站消息按 `(channel, chat_id)` 分 lane 保序;lane 容量为 64,慢目标不阻塞其他目标
|
- 出站消息按 `(channel, chat_id)` 分 lane 保序;lane 容量为 64,慢目标不阻塞其他目标
|
||||||
- 活动 Turn 与普通出站消息共享 `(channel, chat_id)` 写锁;禁止把 token delta 放入 MessageBus
|
- 活动 Turn 与普通出站消息共享 `(channel, chat_id)` 写锁;禁止把 token delta 放入 MessageBus
|
||||||
- `cli_chat` 向 TUI/WebUI 发送统一 `turn_updated` 完整快照;飞书默认 FinalOnly,开启 `live_updates` 后编辑同一卡片
|
- `cli_chat` 向 TUI/WebUI 发送统一 `turn_updated` 完整快照;飞书默认 FinalOnly,开启 `live_updates` 后编辑同一卡片
|
||||||
@ -77,7 +73,11 @@ Scheduler → occurrence/JobRun claim → AgentCoordinator → isolated Schedule
|
|||||||
|
|
||||||
## 上下文压缩
|
## 上下文压缩
|
||||||
|
|
||||||
`messages` 是 append-only 原始日志,压缩不会改写消息、工具结果、ID 或 seq。每个 session 最多有一个活动 `ContextCheckpoint`;Provider 历史确定为“一条累计摘要 + `seq >= first_retained_seq` 的原始尾部”。Model `token_limit` 是上下文硬上限,缺失时默认 128K;可选 Agent `token_limit` 只能收紧它,有效窗口取二者最小值。自动压缩使用 `context_tokens > context_window - effective_reserve`,默认 reserve 16,384、近期原样保留 20,000 tokens;小窗口会自适应缩小两者。摘要输入预算由有效窗口扣除摘要输出、提示词和安全余量得到;超大历史生成 checkpoint 加最新材料优先的有界 request-local 转录,不受固定 32K 上限约束。`/compact`、自动入口和首次 overflow 复用同一压缩编排和 checkpoint 原子提交路径,每次最多一次摘要调用。真实 Provider overflow 或换模后发送前已检测到的硬超限,能在摘要不可用时生成明确标记的确定性降级 checkpoint,并且正式 Provider 请求只重试一次。工具已经执行后若发生 overflow,只在同一个 AgentLoop 中保留本 Turn 工具链、裁掉旧完整 Turn 的请求副本并重试当前模型步骤一次,不从 durable history 重跑工具。
|
当上下文接近 token 限制时触发:
|
||||||
|
|
||||||
|
1. **快速裁剪**:合并连续同角色消息,截断工具输出
|
||||||
|
2. **硬截断**:移除过老消息
|
||||||
|
3. 压缩后保留用户消息确保结构完整
|
||||||
|
|
||||||
## Skill 系统
|
## Skill 系统
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ create → 存入 Storage → 载入 memory → 设为当前 dialog
|
|||||||
|
|
||||||
### 消息处理与并发
|
### 消息处理与并发
|
||||||
|
|
||||||
没有活动 Turn 时,普通消息先 `try_send` 到该 session 的有界 worker 队列,Gateway 主 processor 随即返回 `AgentProcessing`。活动 Turn 期间普通消息默认进入有界 steering mailbox,并在完整工具批次结束后或无工具最终回复边界作为真实 `role=user` 消息注入下一次模型调用;`/queue <message>` 绕过 mailbox,明确进入下一 Turn。`/stop` 直接取消当前 Turn 并清空 mailbox 与普通队列,不会排在长模型调用后。
|
普通消息先 `try_send` 到该 session 的有界 worker 队列,Gateway 主 processor 随即返回 `AgentProcessing`。Slash command 直接执行,不进入此队列,因此 `/stop` 不会排在长模型调用后。
|
||||||
|
|
||||||
Worker 的处理原则:
|
Worker 的处理原则:
|
||||||
|
|
||||||
@ -144,8 +144,7 @@ Worker 的处理原则:
|
|||||||
2. 释放锁后执行消息持久化、记忆召回、上下文压缩、LLM 和工具等慢操作。
|
2. 释放锁后执行消息持久化、记忆召回、上下文压缩、LLM 和工具等慢操作。
|
||||||
3. 提交由旧快照产生的结果前重新验证 generation/version,防止 `/stop`、`/clear` 或 `/delete` 后写回陈旧状态。
|
3. 提交由旧快照产生的结果前重新验证 generation/version,防止 `/stop`、`/clear` 或 `/delete` 后写回陈旧状态。
|
||||||
4. Session 持久化由独立 `persistence_lock` 串行化;批量消息使用原子写入,失败时精确回滚内存后缀。
|
4. Session 持久化由独立 `persistence_lock` 串行化;批量消息使用原子写入,失败时精确回滚内存后缀。
|
||||||
5. 首次请求上下文溢出且尚未执行工具时,按 Provider 返回的真实限制提交 checkpoint 并正式重试一次;工具执行后的溢出只能在原 AgentLoop 内保留当前工具链进行一次请求级恢复。
|
5. 上下文溢出时按 Provider 返回的真实限制重新压缩并重试。
|
||||||
6. mailbox 的接收与关闭原子互斥;所有输入在 Session 锁内取得单调序号,未消费 steering 与普通队列按该序号恢复,不能丢失或互相超越。
|
|
||||||
|
|
||||||
WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投递附件时,附件暂存到 task-local Turn delivery,成功结束后并入最终 assistant 消息,因此工具链始终排在附件回复之前且不会出现自引用来源前缀。其他自投递要求 task-local Turn ID 与 session 的 active Turn 匹配;历史中的 assistant/system 附件只作为文本清单提供给模型,原生媒体块仅用于 user 输入和当前工具结果。
|
WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投递附件时,附件暂存到 task-local Turn delivery,成功结束后并入最终 assistant 消息,因此工具链始终排在附件回复之前且不会出现自引用来源前缀。其他自投递要求 task-local Turn ID 与 session 的 active Turn 匹配;历史中的 assistant/system 附件只作为文本清单提供给模型,原生媒体块仅用于 user 输入和当前工具结果。
|
||||||
|
|
||||||
@ -158,9 +157,8 @@ WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投
|
|||||||
### 会话恢复
|
### 会话恢复
|
||||||
|
|
||||||
从 Storage 恢复 session 时:
|
从 Storage 恢复 session 时:
|
||||||
- 加载全部原始消息及 Session 的活动 checkpoint
|
- 若 `last_compressed_message_at` 存在:先加载近 3 条 Timeline 记忆作为 `[Previous Context]`,再加载压缩标记后的原始消息
|
||||||
- 有 checkpoint 时确定性投影累计摘要和 `first_retained_seq` 之后的原始尾部;没有 checkpoint 时投影全部原始消息
|
- 若无压缩记录:正常加载全部消息
|
||||||
- Timeline 和 `last_compressed_message_at` 不参与恢复边界判断,恢复过程不调用 Provider
|
|
||||||
- 自动修复断链的工具调用(gateway 崩溃中途重启导致)
|
- 自动修复断链的工具调用(gateway 崩溃中途重启导致)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -214,9 +212,13 @@ WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投
|
|||||||
|
|
||||||
### 上下文压缩与 Timeline
|
### 上下文压缩与 Timeline
|
||||||
|
|
||||||
自动压缩在完整请求占用满足 `context_tokens > context_window - effective_reserve` 时触发。压缩器从尾部按完整 Turn 尽量保留近期历史,用一次 LLM 调用生成累计摘要,并以 `first_retained_seq` 记录精确尾部边界。摘要请求按当前模型窗口动态限制输入;若待压缩源更大,只在请求副本中保留已有 checkpoint、最新消息和确定性 head/tail 摘录,原始内容仍完整持久化。checkpoint 与 Session 活动指针在同一 SQLite 事务中提交;提交失败继续使用旧投影。原始消息与工具结果始终保留,旧内容只是不再进入 Provider context。
|
LLM 对话上下文接近 token 限制 (默认 128K × 70%) 时自动触发压缩:
|
||||||
|
|
||||||
语义 checkpoint 提交后会 best-effort 写入一条 **Timeline**(importance 0.3)供主动检索,但 Timeline 不是恢复权威。真实 context overflow,或换模/改配置后在发送前已检测到的硬超限,能使用无语义 breadcrumb 降级,且只重试一次正式 Provider 请求;低于硬窗口的普通自动压缩和 `/compact` 失败时不会裁掉历史。
|
1. **快速裁剪**:工具输出 ≥ 2000 字符时截断
|
||||||
|
2. **LLM 摘要**:最多 3 轮,每轮找连续用户消息对,将中间的 assistant/tool 消息压缩为摘要 → 摘要作为 **Timeline 记忆** 持久化(importance 0.3)
|
||||||
|
3. **硬截断**:若仍超 90%,只保留前 N + 后 N 条消息
|
||||||
|
|
||||||
|
压缩后 `last_compressed_message_at` 标记边界,后续恢复时从标记点加载原始消息,以 Timeline 提供更早的上下文。
|
||||||
|
|
||||||
### 关键集成点
|
### 关键集成点
|
||||||
|
|
||||||
@ -224,11 +226,11 @@ WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 每次消息处理 | `memory_manager.recall()` 提取 Knowledge 上下文 |
|
| 每次消息处理 | `memory_manager.recall()` 提取 Knowledge 上下文 |
|
||||||
| 系统提示构建 | `MemorySection` 渲染记忆工具指南;匹配的 Knowledge 附加到本轮 user message |
|
| 系统提示构建 | `MemorySection` 渲染记忆工具指南;匹配的 Knowledge 附加到本轮 user message |
|
||||||
| 有活动 checkpoint 时 | 累计摘要和精确 raw tail 组成 Provider 历史 |
|
| 有压缩历史时 | `HistorySection` 提示 LLM 使用 `timeline_recall` |
|
||||||
| 语义 checkpoint 提交后 | 摘要 best-effort 存储为 Timeline 记忆 |
|
| 压缩完成后 | 摘要自动存储为 Timeline 记忆 |
|
||||||
| 会话恢复 | 从 checkpoint 与原始 seq 确定性重建,不读取 Timeline |
|
| 会话恢复 | 加载最近 Timeline 和压缩边界后的原始消息 |
|
||||||
|
|
||||||
`memory.recall_limit` 及新增的 `recall_min_relevance`、`recall_min_score`、`recall_recency_half_life_days`、`recall_timeout_ms` 已生效:每轮自动召回用 jieba 分词 + FTS5 检索候选,按「词项相关度 0.5 + 重要度 0.3 + 时效 0.2」加权并通过相关性/综合分双门槛过滤,搜索受硬超时保护。`idle_consolidation_minutes`、`timeline_retention_days` 和 `max_failures_before_degrade` 仍只是配置解析:自动 idle consolidation、Timeline 清理和失败降级循环尚未接入运行循环。不要把“配置可解析”误认为“行为已生效”。
|
`memory.recall_limit`、`idle_consolidation_minutes`、`timeline_retention_days` 和 `max_failures_before_degrade` 当前会被配置解析;其中每轮 Knowledge 召回在 worker 中仍固定为 5,其余自动维护策略尚未接入运行循环。不要把“配置可解析”误认为“行为已生效”。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -251,66 +253,13 @@ Gateway 初始化时读取 `config.mcp.servers`:
|
|||||||
|
|
||||||
| 模式 | 行为 |
|
| 模式 | 行为 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `foreground` | 当前轮等待一个或多个子 Agent;批量任务并发执行并按请求顺序聚合,全部持久化到 `agent_runs` |
|
| `inline` | 当前轮阻塞等待子 Agent 返回 |
|
||||||
| `background` | 异步执行并立即返回 run ID;仅限 Root 对具名 Agent 的单任务,结果经 durable inbox 由主 Agent 的 continuation Turn 汇总 |
|
| `background` | 后台运行,完成后通过原 channel/chat 通知 |
|
||||||
|
| `parallel` | 多个子 Agent 并发执行并聚合结果 |
|
||||||
|
|
||||||
### 具名 Agent Definition(身份设定)
|
默认工具集是只读工具:`file_read`、`file_search`、`content_search`、`web_fetch`、`http_request`、`calculator`。调用时可通过 `allowed_tools` 显式放开其他工具。后台任务会写入 `background_tasks` 表,默认 24 小时后清理。
|
||||||
|
|
||||||
启用 `agent_orchestration` 后,每个具名子 Agent 是一个 Markdown 文件:`<配置目录>/agents/<id>.md`(默认 `~/.picobot/agents/`)。frontmatter 只保存非秘密引用与限制(API key/base URL 仍在 `config.json`/`.env`):
|
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册,受 `gateway.max_concurrent_background_tasks` 限制;Gateway 关停时先收到取消信号,再在总宽限期内清理。
|
||||||
|
|
||||||
```md
|
|
||||||
---
|
|
||||||
id: researcher
|
|
||||||
description: 搜索、阅读并整理技术资料
|
|
||||||
llm_profile: research-sonnet
|
|
||||||
|
|
||||||
tools:
|
|
||||||
- file_read
|
|
||||||
- file_search
|
|
||||||
- content_search
|
|
||||||
- web_fetch
|
|
||||||
|
|
||||||
delegates:
|
|
||||||
- reviewer
|
|
||||||
|
|
||||||
skills:
|
|
||||||
- technical-research
|
|
||||||
|
|
||||||
limits:
|
|
||||||
timeout_secs: 900
|
|
||||||
max_iterations: 24
|
|
||||||
max_children: 4
|
|
||||||
max_depth: 3
|
|
||||||
|
|
||||||
signal:
|
|
||||||
delivery: steer
|
|
||||||
severity_allowlist: [info, warning, critical]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Role
|
|
||||||
|
|
||||||
你是一名严谨的研究 Agent。只返回与任务有关的结论、证据和不确定性。
|
|
||||||
```
|
|
||||||
|
|
||||||
- `id`:`[a-z][a-z0-9_-]{0,63}`,文件名必须与 id 一致;`root`/`main`/`default`/`general` 为保留名。重复 ID、大小写折叠冲突、越界 symlink 或引用错误(未知 Provider profile、未注册/不可委托工具、未知 skill 或 delegate 目标)会拒绝整个候选运行代,绝不静默裁剪。
|
|
||||||
- `llm_profile`:引用 `config.json` 中 `agents` key,Definition 绑定 Provider 与模型,运行中不热切换。
|
|
||||||
- `tools`/`skills`:`tools` 直接指定该 Agent 可用的全部普通工具;`skills` 声明要求工具集含 `get_skill`,且只注入该 allowlist。运行时注入工具(`delegate`/`emit_signal`/`agent_task`)不能写进 `tools`。
|
|
||||||
- `delegates`:出边白名单,运行时还校验 ancestry 重复、`max_tree_depth` 与树级 `max_runs_per_tree` 预算。
|
|
||||||
- `signal`:可选信号契约。带该块的 run 才获得 `emit_signal` 工具(fail-closed);`delivery: steer` 使信号在活动 Turn 的安全边界注入主 Agent,`queue` 走 continuation。
|
|
||||||
- 角色正文(`---` 之后)即 `role_prompt`,与 frontmatter 一起做 SHA-256 `definition_hash` 快照。
|
|
||||||
|
|
||||||
### 执行与投递
|
|
||||||
|
|
||||||
- 每次具名委托先持久化 run(含 execution_id、budget、Definition 快照),`allowed_tools` 只能收窄、不能扩权;子 Agent 输出视为不可信数据。
|
|
||||||
- foreground 父 run 等待子 run 时进入 `waiting_children` 且不占 provider/tool step permit;run quota 只约束 background 接纳,嵌套 foreground 并发上限为 1 时不死锁。
|
|
||||||
- background 接纳时预留 completion slot(容量条件更新),runner 持有 run quota permit 与 activity guard 直到 terminal commit;完成后 completion 事件落 `agent_inbox_events`,Session worker 按 `max_user_turn_burst_before_inbox`/`max_inbox_wait_secs` 公平调度,以 hidden trigger + 只读工具集的 continuation Turn 让主 Agent 汇总结果,不再直接发 Channel 通知。失败按 lease token 释放重试,超 `max_inbox_delivery_attempts` 进 dead-letter,重启经 activation recovery 收敛。
|
|
||||||
- `agent_task`(get/list/get_result/cancel)查询与控制 run;`agent_task.cancel` 会把该 run 未消费的普通信号标记 superseded。`/stop` 取消活动 run 但保留已存在的 pending 事件;archive/delete 取消 run 并将未消费事件 dead-letter。
|
|
||||||
- 后台 run 内可调用 `emit_signal`(key/severity/summary/details/dedupe_key),总数、速率、burst、severity allowlist、载荷大小/深度与冷却窗去重均由契约强制;steer 信号经两阶段 admission(claim → mailbox 预留 → admit(turn_id) → 激活)注入当前 Turn,`/stop` 时按 token 条件释放回 pending,绝不静默丢弃。
|
|
||||||
- 每个 run 的完成事件 payload 携带该 run 已发出的 signal IDs,主 Agent 可识别重复报告。
|
|
||||||
|
|
||||||
旧匿名 general 兼容路径已移除:委托必须指定具名 `target`。
|
|
||||||
|
|
||||||
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册;Gateway 关停时先收到取消信号,再在总宽限期内清理。
|
|
||||||
|
|
||||||
## Session Todo 计划
|
## Session Todo 计划
|
||||||
|
|
||||||
@ -343,10 +292,8 @@ Gateway 关停顺序:
|
|||||||
| `/rename <title>` | 重命名当前对话 |
|
| `/rename <title>` | 重命名当前对话 |
|
||||||
| `/delete` | 删除当前对话 |
|
| `/delete` | 删除当前对话 |
|
||||||
| `/compact` | 手动触发上下文压缩 |
|
| `/compact` | 手动触发上下文压缩 |
|
||||||
| `/info [--json]` | 显示当前对话、累计 Token 与上下文窗口信息;可选 JSON 输出 |
|
| `/info` | 显示当前对话信息 |
|
||||||
| `/dump` | 保存当前对话为 markdown |
|
| `/dump` | 保存当前对话为 markdown |
|
||||||
| `/?`, `/help` | 显示帮助 |
|
| `/?`, `/help` | 显示帮助 |
|
||||||
| `/mcp` | 显示 MCP 状态 |
|
| `/mcp` | 显示 MCP 状态 |
|
||||||
| `/health` | 检查 PicoBot 运行依赖 |
|
|
||||||
| `/queue <message>` | 等当前 Turn 完成后作为下一 Turn 处理 |
|
|
||||||
| `/stop` | 停止当前任务并清空消息队列 |
|
| `/stop` | 停止当前任务并清空消息队列 |
|
||||||
|
|||||||
@ -7,10 +7,6 @@ cargo build
|
|||||||
# 启动网关 (默认 127.0.0.1:19876)
|
# 启动网关 (默认 127.0.0.1:19876)
|
||||||
cargo run -- gateway
|
cargo run -- gateway
|
||||||
|
|
||||||
# 检查核心、配置相关和可选运行依赖;结构化输出加 --json
|
|
||||||
picobot health
|
|
||||||
picobot health --json
|
|
||||||
|
|
||||||
# 覆盖监听地址和端口
|
# 覆盖监听地址和端口
|
||||||
cargo run -- gateway --host 0.0.0.0 --port 19876
|
cargo run -- gateway --host 0.0.0.0 --port 19876
|
||||||
|
|
||||||
@ -46,11 +42,6 @@ cargo build
|
|||||||
cargo run -- chat --pair-code <CODE>
|
cargo run -- chat --pair-code <CODE>
|
||||||
cargo run -- chat
|
cargo run -- chat
|
||||||
|
|
||||||
# 浏览器工具依赖(PicoBot 验证版本)
|
|
||||||
npm install -g agent-browser@0.33.0
|
|
||||||
agent-browser install
|
|
||||||
# Linux 缺少浏览器系统库时改用:agent-browser install --with-deps
|
|
||||||
|
|
||||||
# 安装并启动 Linux systemd 用户服务
|
# 安装并启动 Linux systemd 用户服务
|
||||||
picobot service install
|
picobot service install
|
||||||
picobot service start
|
picobot service start
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`。
|
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`。
|
||||||
占位符 `<VAR_NAME>` 从启动环境替换。PicoBot 依次加载 `config.json` 同目录的 `.env`、`workspace_dir/.env`,最后保留启动进程已有环境变量作为最高优先级;workspace 层覆盖配置目录层。合并值也会进入进程环境,供 MCP 和工具子进程继承。workspace `.env` 不能修改用于定位自身的 `workspace_dir`。
|
占位符 `<VAR_NAME>` 从启动环境替换。PicoBot 依次加载 `config.json` 同目录的 `.env`、`workspace_dir/.env`,最后保留启动进程已有环境变量作为最高优先级;workspace 层覆盖配置目录层。合并值也会进入进程环境,供 MCP 和工具子进程继承。workspace `.env` 不能修改用于定位自身的 `workspace_dir`。
|
||||||
|
|
||||||
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。Gateway 会忽略可恢复的历史未知字段、类型不匹配字段和失效的非核心命名条目,在配置页按 JSON Pointer 显示诊断,但不会自动改写原始文件;“一键清除”由后端校验文件 revision 后原子删除已诊断项,普通保存仍严格拒绝无效配置。JSON 损坏、不可用的 `default` Agent 链路和无法安全构造运行代的错误不会被忽略。运行配置保存或清理后可执行 `picobot reload`、发送 `/reload`,或由根交互 Agent 在用户明确要求时调用 `reload_config` 工具。Gateway 会先校验候选配置,停止接收新工作并等待交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换;失败时继续使用旧配置。`GET /api/config/reload/status` 可查询 generation、相位与最近错误。`gateway.host`、`gateway.port`、`workspace_dir` 和 `gateway.session_db_path` 的有效路径必须通过完整重启变更。`USER.md` 与 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后可执行 `picobot reload`、发送 `/reload`,或由根交互 Agent 在用户明确要求时调用 `reload_config` 工具。Gateway 会先校验候选配置,停止接收新工作并等待交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换;失败时继续使用旧配置。`GET /api/config/reload/status` 可查询 generation、相位与最近错误。`gateway.host`、`gateway.port`、`workspace_dir` 和 `gateway.session_db_path` 的有效路径必须通过完整重启变更。`USER.md` 与 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
||||||
|
|
||||||
## config.json 结构
|
## config.json 结构
|
||||||
|
|
||||||
@ -11,9 +11,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
{
|
{
|
||||||
"providers": {}, // LLM 提供商配置
|
"providers": {}, // LLM 提供商配置
|
||||||
"models": {}, // 模型配置
|
"models": {}, // 模型配置
|
||||||
"agents": {}, // Provider/Model profile
|
"agents": {}, // agent 配置
|
||||||
"context_compaction": {}, // 上下文 reserve 预算与近期保留量
|
|
||||||
"agent_orchestration": {}, // 具名子 Agent Definition 与编排上限
|
|
||||||
"gateway": {}, // 网关配置
|
"gateway": {}, // 网关配置
|
||||||
"client": {}, // 客户端配置
|
"client": {}, // 客户端配置
|
||||||
"channels": {}, // 渠道配置
|
"channels": {}, // 渠道配置
|
||||||
@ -42,7 +40,6 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
| `model_id` | 模型标识名称 |
|
| `model_id` | 模型标识名称 |
|
||||||
| `temperature` | 采样温度,可选 |
|
| `temperature` | 采样温度,可选 |
|
||||||
| `max_tokens` | 最大输出 token 数,可选 |
|
| `max_tokens` | 最大输出 token 数,可选 |
|
||||||
| `token_limit` | 模型上下文窗口硬上限,可选;未配置时默认为 128000,Agent 只能进一步收紧 |
|
|
||||||
| `input_type` | 模型支持的输入类型,如 `["text"]` 或 `["text", "image"]`,默认 `["text"]`. 纯内部使用,不会传递给 LLM API |
|
| `input_type` | 模型支持的输入类型,如 `["text"]` 或 `["text", "image"]`,默认 `["text"]`. 纯内部使用,不会传递给 LLM API |
|
||||||
|
|
||||||
## agents 字段
|
## agents 字段
|
||||||
@ -52,37 +49,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
| `provider` | string | - | 提供商名称(对应 providers key) |
|
| `provider` | string | - | 提供商名称(对应 providers key) |
|
||||||
| `model` | string | - | 模型名称(对应 models key) |
|
| `model` | string | - | 模型名称(对应 models key) |
|
||||||
| `max_tool_iterations` | int | 99 | 最大工具调用轮数 |
|
| `max_tool_iterations` | int | 99 | 最大工具调用轮数 |
|
||||||
| `token_limit` | int | 使用模型上限 | 可选的 Agent 上限;有效窗口取 Agent 与模型(模型未配置时为 128000)的最小值 |
|
| `token_limit` | int | 128000 | 上下文 token 限制 |
|
||||||
|
|
||||||
## context_compaction 字段
|
|
||||||
|
|
||||||
自动压缩只使用 reserve 公式 `context_tokens > context_window - effective_reserve`。小窗口下 `effective_reserve = min(reserve_tokens, context_window / 2)`,近期原样保留量最多为有效阈值的一半。
|
|
||||||
|
|
||||||
| 字段 | 默认 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `enabled` | true | 只控制 Turn 前自动压缩;不禁用 `/compact` 或 overflow 恢复 |
|
|
||||||
| `reserve_tokens` | 16384 | 为输出、工具迭代和估算误差预留的输入窗口 |
|
|
||||||
| `keep_recent_tokens` | 20000 | checkpoint 后尽量原样保留的近期历史 token |
|
|
||||||
|
|
||||||
## agent_orchestration 字段
|
|
||||||
|
|
||||||
子 Agent 编排是 PicoBot 的内在机制,始终启用、不可关闭;该配置块只控制定义目录与各类上限。`definitions_dir` 相对 `config.json` 所在目录解析,且不得通过绝对路径或 symlink 逃逸该受信任配置目录。Gateway 启动和热重载会严格校验全部 Markdown Definition;任一无效 Provider profile、工具、Skill 或委托目标会拒绝整个候选运行代。
|
|
||||||
|
|
||||||
| 字段 | 默认 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `definitions_dir` | agents | 第一层 `*.md` Definition 目录 |
|
|
||||||
| `max_tree_depth` | 4 | Root-relative 委托深度硬上限 |
|
|
||||||
| `max_runs_per_tree` | 16 | 单任务树 run 预算(树级原子计数强制) |
|
|
||||||
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | 6 / 4 | background run 接纳配额(global→session 顺序获取,runner 持有至 terminal commit;foreground 不占) |
|
|
||||||
| `max_concurrent_provider_steps` / `..._per_session` | 8 / 4 | Provider step 上限(global→session) |
|
|
||||||
| `max_concurrent_tool_steps` / `..._per_session` | 16 / 8 | 普通工具 step 上限(global→session) |
|
|
||||||
| `max_pending_inbox_events_per_session` | 128 | durable inbox 容量(条件更新,预留槽不可被信号挤占) |
|
|
||||||
| `inbox_event_ttl_hours` | 168 | inbox 事件 TTL(已预留配置;TTL 清理尚未实现) |
|
|
||||||
| `max_inbox_delivery_attempts` | 8 | inbox 最大投递次数,超限 dead-letter |
|
|
||||||
| `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值 |
|
|
||||||
| `max_inbox_wait_secs` | 30 | inbox 最大等待阈值 |
|
|
||||||
|
|
||||||
已实现:具名 foreground 与 background(Root 单任务或批量)、内联 `provider`/`model` 或 `llm_profile`、工具集由定义文件 `tools` 决定、批量并发、父子委托边校验、durable inbox continuation(空闲时完成即返回)、`emit_signal`(queue/steer)、run quota 与 step gate、内置 general-purpose 定义与 WebUI「子 Agent」管理页。未开放:子 Agent 发起的 background、`idempotency_key` 工具入口。旧匿名 general 兼容路径已移除,委托必须指定具名 `target`。
|
|
||||||
|
|
||||||
## gateway 字段
|
## gateway 字段
|
||||||
|
|
||||||
@ -92,8 +59,9 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
| `port` | int | 19876 | 监听端口 |
|
| `port` | int | 19876 | 监听端口 |
|
||||||
| `require_pairing` | bool | true | 是否要求 WebUI 与 CLI 设备先使用一次性代码配对 |
|
| `require_pairing` | bool | true | 是否要求 WebUI 与 CLI 设备先使用一次性代码配对 |
|
||||||
| `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 |
|
| `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 |
|
||||||
| `session_db_path` | string | - | SQLite 数据库路径,默认在配置目录 `data/` 下 |
|
| `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 |
|
||||||
| `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 |
|
| `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 |
|
||||||
|
| `max_concurrent_background_tasks` | int | 10 | delegate 后台子任务最大并发数 |
|
||||||
| `scheduler` | object | - | 调度器配置 |
|
| `scheduler` | object | - | 调度器配置 |
|
||||||
|
|
||||||
### gateway.scheduler 字段
|
### gateway.scheduler 字段
|
||||||
@ -102,8 +70,8 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `enabled` | bool | true | 是否启动调度器并注册 cron 工具 |
|
| `enabled` | bool | true | 是否启动调度器并注册 cron 工具 |
|
||||||
| `poll_interval_secs` | int | 60 | 检查到期任务的轮询间隔 |
|
| `poll_interval_secs` | int | 60 | 检查到期任务的轮询间隔 |
|
||||||
| `max_concurrent` | int | 1 | 同时执行的 Scheduled Run 上限,运行时限制在 1–256;投递使用独立有界并发 |
|
| `max_concurrent` | int | 1 | 每批到期任务的最大并发数,运行时限制在 1–256 |
|
||||||
| `execution_timeout_secs` | int | 900 | 单个定时任务 Agent 执行的硬超时;Job 执行租约额外覆盖关停宽限,投递由持久化 outbox 独立恢复 |
|
| `execution_timeout_secs` | int | 900 | 单个定时任务 Agent 执行的硬超时;租约会覆盖执行和托管投递等待 |
|
||||||
|
|
||||||
## memory 字段
|
## memory 字段
|
||||||
|
|
||||||
@ -111,16 +79,12 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `consolidation_provider` | string | 主 Agent provider | 当前记录在 MemoryManager 中供后续归并使用;压缩摘要仍使用 Session provider |
|
| `consolidation_provider` | string | 主 Agent provider | 当前记录在 MemoryManager 中供后续归并使用;压缩摘要仍使用 Session provider |
|
||||||
| `consolidation_model` | string | 主 Agent model | 当前记录在 MemoryManager 中供后续归并使用;压缩摘要仍使用 Session model |
|
| `consolidation_model` | string | 主 Agent model | 当前记录在 MemoryManager 中供后续归并使用;压缩摘要仍使用 Session model |
|
||||||
| `recall_limit` | int | 5 | 每轮自动注入上下文的知识记忆条数上限 |
|
| `recall_limit` | int | 5 | 预期的每轮知识召回上限;当前 worker 固定使用 5 |
|
||||||
| `recall_min_relevance` | float | 0.25 | 自动召回的相关性硬门槛(命中词项占比低于此值则丢弃) |
|
|
||||||
| `recall_min_score` | float | 0.25 | 自动召回的综合分门槛(相关度+重要度+时效加权) |
|
|
||||||
| `recall_recency_half_life_days` | int | 30 | 自动召回时效衰减的半衰期(天) |
|
|
||||||
| `recall_timeout_ms` | int | 1000 | 自动召回搜索的硬超时;超时本轮不注入记忆 |
|
|
||||||
| `idle_consolidation_minutes` | int | 10 | 预留的空闲归并阈值;当前无对应循环 |
|
| `idle_consolidation_minutes` | int | 10 | 预留的空闲归并阈值;当前无对应循环 |
|
||||||
| `timeline_retention_days` | int | 90 | 默认日常维护巡检删除超过该期限的 Timeline;Knowledge 不受影响 |
|
| `timeline_retention_days` | int | 90 | 默认日常维护巡检删除超过该期限的 Timeline;Knowledge 不受影响 |
|
||||||
| `max_failures_before_degrade` | int | 3 | 预留的归并失败阈值;当前无失败降级循环 |
|
| `max_failures_before_degrade` | int | 3 | 预留的归并失败阈值;当前无失败降级循环 |
|
||||||
|
|
||||||
自动召回每轮用当前用户输入做关键词检索(jieba 分词 + FTS5),按「词项相关度 0.5 + 重要度 0.3 + 时效 0.2」加权,通过相关性/综合分双门槛后才注入;搜索有硬超时保证不拖慢 Turn。Timeline 不自动召回,需显式 `timeline_recall`。idle consolidation 和失败降级循环尚未接入。Timeline 清理由默认启用的 `picobot-routine-maintenance` Scheduled Run 执行;该任务使用 `never` 策略,结构化结果只进入运行审计和 Health。
|
注意:当前 worker 的 Knowledge 召回数量仍固定为 5;idle consolidation 和失败降级循环尚未接入。Timeline 清理由默认启用的 `picobot-routine-maintenance` 定时巡检执行。
|
||||||
|
|
||||||
## channels.feishu 字段
|
## channels.feishu 字段
|
||||||
|
|
||||||
@ -155,7 +119,6 @@ MCP 服务器单条配置:
|
|||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `name` | 服务器名称 |
|
| `name` | 服务器名称 |
|
||||||
| `enabled` | 是否启用,默认 true;关闭后启动/重载时不连接该服务器 |
|
|
||||||
| `transport` | 传输方式: `stdio`、`sse`、`streamable-http` |
|
| `transport` | 传输方式: `stdio`、`sse`、`streamable-http` |
|
||||||
| `command` | 启动命令(stdio 模式) |
|
| `command` | 启动命令(stdio 模式) |
|
||||||
| `args` | 命令参数 |
|
| `args` | 命令参数 |
|
||||||
@ -163,54 +126,14 @@ MCP 服务器单条配置:
|
|||||||
| `url` | URL(sse / streamable-http 模式) |
|
| `url` | URL(sse / streamable-http 模式) |
|
||||||
| `headers` | HTTP 传输额外请求头 |
|
| `headers` | HTTP 传输额外请求头 |
|
||||||
| `tool_timeout_secs` | 单独的超时设置 |
|
| `tool_timeout_secs` | 单独的超时设置 |
|
||||||
| `tool_settings` | 按 MCP 原始工具名索引的本地执行属性声明;每项可设 `read_only` 与 `exclusive`,均默认 false |
|
|
||||||
|
|
||||||
`tool_settings` 示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tool_settings": {
|
|
||||||
"read_file": { "read_only": true },
|
|
||||||
"refresh_index": { "exclusive": true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`可并发` 不需要也不能单独配置:它始终等于 `read_only && !exclusive`。未声明的 MCP 工具按可能有副作用且顺序执行处理。WebUI「工具 → MCP」展开服务器后可编辑这些复选框;编辑先暂存,可跨多个工具一次“保存并应用”并热重载,离开 MCP 标签或刷新页面会丢弃未保存草稿。
|
|
||||||
|
|
||||||
## browser 字段
|
## browser 字段
|
||||||
|
|
||||||
浏览器工具默认开启并注册 `browser` 与 `browser_profiles` 工具。缺少外部依赖不会阻止 Gateway 启动,但实际调用会返回安装错误,`picobot health` 会提前判定。上层由 PicoBot 管理浏览器生命周期与媒体,底层调用 agent-browser JSON CLI;不再依赖 Fantoccini、ChromeDriver 或 WebDriver。
|
浏览器工具默认关闭,开启后注册 `browser` 工具。依赖 Chrome/Chromium 与 chromedriver/WebDriver。
|
||||||
|
|
||||||
| 字段 | 类型 | 默认 | 说明 |
|
| 字段 | 类型 | 默认 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `enabled` | bool | true | 是否启用浏览器工具;关闭后不注册 `browser` |
|
| `enabled` | bool | false | 是否启用浏览器工具 |
|
||||||
| `command` | string | agent-browser | CLI 名称或绝对路径 |
|
| `webdriver_url` | string | http://127.0.0.1:9515 | WebDriver 服务地址 |
|
||||||
| `headless` | bool | true | 是否无头运行 |
|
| `headless` | bool | true | 是否无头运行 |
|
||||||
| `browser_executable_path` | string | - | 自定义 Chrome/Chromium 可执行文件路径 |
|
| `chrome_path` | string | - | 自定义 Chrome/Chromium 路径 |
|
||||||
| `max_sessions` | int | 4 | 同时保留的普通 dialog 临时浏览器会话上限;持久身份不计入 |
|
|
||||||
| `idle_timeout_secs` | int | 3600 | 大于零;普通 dialog 临时浏览器的空闲退出及 Manager 回收时间;持久浏览器不使用该超时 |
|
|
||||||
| `command_timeout_secs` | int | 120 | 单次 CLI 调用硬超时 |
|
|
||||||
| `max_output_chars` | int | 50000 | 页面来源文本输出上限 |
|
|
||||||
| `content_boundaries` | bool | true | 启用 agent-browser 不可信页面边界元数据 |
|
|
||||||
| `allowed_domains` | []string | [] | 可选域名白名单;空数组表示不启用域名限制 |
|
|
||||||
| `allow_private_hosts` | bool | false | 是否允许回环、私网和本地域名 |
|
|
||||||
| `artifact_dir` | string | ~/.picobot/media/browser | 截图产物目录 |
|
|
||||||
| `persistence.profile_dir` | string | ~/.picobot/browser/profiles | 持久 ID、语义标签和 Profile 目录的受控根目录 |
|
|
||||||
|
|
||||||
持久化不是配置模式。`browser` 调用省略 `persistent_id` 时使用当前 dialog 的普通临时浏览器,并在达到 `idle_timeout_secs` 后自动退出;长期工作需要保持浏览器进程或保留登录和站点状态时,Agent 可自主调用 `browser_profiles(create,label=...)` 生成 `picobot-profile-<uuid>`,对应数据位于 `profile_dir/<id>`,然后在该工作的后续每个 action 中持续传入同一个 ID。持久浏览器禁用 daemon 空闲自动退出,只通过显式 `browser(close)` 或 Profile 删除关闭。没有默认 ID,也不会按 dialog 自动选择持久身份。同一 ID 跨 dialog 共享底层 session 与串行锁,不同 ID 各自独立并可并发。`browser_profiles` 还支持 `set_label`、`list` 和使用精确 ID 的 `delete`;标签可重命名,但选择浏览器始终使用不可变 ID。
|
|
||||||
|
|
||||||
agent-browser 0.33.0 不允许持久 Profile 与 `allowed_domains` 同时使用。配置域名限制后普通临时浏览器仍可用,创建或使用持久身份会被拒绝,health 会提示该可选能力受限。Profile 包含登录凭据,应把目录视为敏感数据,不得提交到版本控制、跨用户共享或放在不受信任的网络文件系统中。
|
|
||||||
|
|
||||||
旧字段 `webdriver_url`、`chrome_path` 不再接受。推荐安装 `agent-browser@0.33.0` 后运行 `agent-browser install`;Linux 可运行 `agent-browser install --with-deps`。使用前用 `picobot health` 检查 CLI、浏览器安装和真实 headless 启动环境。
|
|
||||||
|
|
||||||
### 浏览器依赖故障处置
|
|
||||||
|
|
||||||
| health / 工具错误 | 处置 |
|
|
||||||
|---|---|
|
|
||||||
| `agent-browser` 未找到 | 运行 `npm install -g agent-browser@0.33.0`,或 `cargo install agent-browser --version 0.33.0 --locked` |
|
|
||||||
| CLI 已安装但找不到 Chrome/Chromium | 运行 `agent-browser install`;已有浏览器则设置 `browser_executable_path` 或 `AGENT_BROWSER_EXECUTABLE_PATH` |
|
|
||||||
| Linux 缺少共享库/系统包 | 运行 `agent-browser install --with-deps`,然后再运行 `agent-browser doctor` |
|
|
||||||
| 安装状态不明确 | 先运行 `picobot health` 获取 PicoBot 视角的结果,再运行 `agent-browser doctor` 查看完整上游诊断 |
|
|
||||||
|
|
||||||
不得在 Agent 工具调用中自动安装或执行 `doctor --fix`;安装会修改系统且可能需要管理员权限,应把命令报告给用户,由用户确认后执行。临时不需要浏览器时可设置 `browser.enabled=false`,此时 health 不要求 agent-browser/Chrome。
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# PicoBot 数据库表结构
|
# PicoBot 数据库表结构
|
||||||
|
|
||||||
数据库为 SQLite,默认位于配置目录(`~/.picobot`)`data/` 下的 `picobot.db`,与 workspace 相互独立。
|
数据库为 SQLite,默认位于 workspace 下的 `picobot.db`。
|
||||||
|
|
||||||
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout,连接池最多 8 个连接。当前 `PRAGMA user_version=11`;启动时会在单个事务内迁移旧库,遇到比程序更新的 schema version 会拒绝启动。
|
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout,连接池最多 8 个连接。当前 `PRAGMA user_version=4`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
|
||||||
|
|
||||||
## sessions 表
|
## sessions 表
|
||||||
|
|
||||||
@ -22,13 +22,7 @@
|
|||||||
| `archived_at` | INTEGER | 归档时间(Unix 毫秒),NULL 表示未归档 |
|
| `archived_at` | INTEGER | 归档时间(Unix 毫秒),NULL 表示未归档 |
|
||||||
| `deleted_at` | INTEGER | 软删除时间戳 |
|
| `deleted_at` | INTEGER | 软删除时间戳 |
|
||||||
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
|
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
|
||||||
| `last_compressed_message_at` | INTEGER | 最近 checkpoint 时间戳(兼容/诊断字段,不作为恢复边界) |
|
| `last_compressed_message_at` | INTEGER | 上次上下文压缩边界时间戳 |
|
||||||
| `active_context_checkpoint_id` | TEXT | 当前 Provider 历史投影使用的 checkpoint ID;NULL 表示使用全部原始消息 |
|
|
||||||
| `context_generation` | INTEGER | checkpoint CAS 提交代;历史清空/改写时递增并清除活动指针 |
|
|
||||||
| `delivery_context` | TEXT | 渠道声明的可跨 Turn 复用投递上下文 JSON(如飞书 thread/root 身份);一次性 reply/reaction ID 永不写入 |
|
|
||||||
| `delivery_context_updated_at` | INTEGER | delivery_context 最后更新时间 |
|
|
||||||
|
|
||||||
`session_turn_usage` 以 `turn_id` 幂等保存已提交 Turn 的 Provider usage,包括累计输入、输出、缓存输入、请求数和最后一次请求的 prompt tokens。它与 Turn 消息批次在同一事务中提交,供 WebUI 状态栏和 `/info` 使用;升级前历史无法可靠回填,因此统计起点以首条 usage 记录为准。
|
|
||||||
|
|
||||||
`(channel, chat_id, dialog_id)` 唯一。普通列表排除 `deleted_at`;是否包含归档记录由查询参数决定。
|
`(channel, chat_id, dialog_id)` 唯一。普通列表排除 `deleted_at`;是否包含归档记录由查询参数决定。
|
||||||
|
|
||||||
@ -52,98 +46,29 @@
|
|||||||
| `turn_id` | TEXT | 产生该消息的活动 Turn ID |
|
| `turn_id` | TEXT | 产生该消息的活动 Turn ID |
|
||||||
| `iteration` | INTEGER | Agent 工具循环中的迭代序号 |
|
| `iteration` | INTEGER | Agent 工具循环中的迭代序号 |
|
||||||
| `completion_status` | TEXT | `completed` / `cancelled` / `interrupted`,旧数据默认 completed |
|
| `completion_status` | TEXT | `completed` / `cancelled` / `interrupted`,旧数据默认 completed |
|
||||||
| `client_visibility` | TEXT | `visible` / `hidden`,默认 visible;hidden 只供模型回放(continuation 内部触发),客户端历史/投影/投递一律过滤 |
|
|
||||||
| `turn_origin` | TEXT | `user` / `agent_continuation` / `scheduled`,默认 user;客户端据此渲染"后台结果处理"标签而不创建用户气泡 |
|
|
||||||
|
|
||||||
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。物理删除 session 会通过外键级联删除 messages;普通对话删除使用 `deleted_at` 软删除,因此保留关联行。索引 `(session_id, client_visibility, seq)` 支撑按可见性分层查询。
|
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。
|
||||||
|
|
||||||
## context_checkpoints 表(schema v10)
|
## background_tasks 表
|
||||||
|
|
||||||
checkpoint 只保存累计摘要和精确 raw-tail 边界,不复制或删除原始消息。每个 Session 的 `active_context_checkpoint_id` 最多指向其中一行;历史行保留用于审计。
|
delegate 后台子任务表。`session_id` 不使用数据库外键,因为 session 使用软删除,关联关系由应用层维护。
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `id` | checkpoint ID,主键 |
|
|
||||||
| `session_id` / `generation` | 所属 Session 与单调提交代;组合唯一 |
|
|
||||||
| `parent_checkpoint_id` | 上一个累计 checkpoint(审计链) |
|
|
||||||
| `summary` | 不含 Provider 私有 reasoning 的累计摘要 |
|
|
||||||
| `first_retained_seq` | Provider 原样保留尾部的第一条 durable seq |
|
|
||||||
| `source_max_seq` | 生成候选时快照的最大 seq |
|
|
||||||
| `trigger_reason` | manual / auto / overflow |
|
|
||||||
| `provider_kind` / `model` | 生成摘要的 Provider/model |
|
|
||||||
| `tokens_before` / `tokens_after` | 候选验证和诊断数据 |
|
|
||||||
| `degraded` | 是否为 Provider overflow 或发送前硬超限的确定性无语义降级 |
|
|
||||||
| `created_at` | 创建时间 |
|
|
||||||
|
|
||||||
checkpoint 插入、Session 活动指针更新与 `context_generation` 递增在同一事务内完成。`/clear` 原子删除 messages 并使活动 checkpoint 失效;只有物理删除 Session 才会通过外键级联删除全部 checkpoint,普通 `/delete` 软删除会保留 checkpoint 行。
|
|
||||||
|
|
||||||
## agent_runs 表(schema v8,Agent 编排)
|
|
||||||
|
|
||||||
每次具名委托(foreground 与 background 一致)先落库再执行;`execution_id` 条件更新保证迟到结果丢弃。批量委托只是多个 run 的集合,不再存在组头(schema v7 的 `agent_run_groups` 表已删除)。
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `id` | TEXT PK | run ID |
|
| `id` | TEXT PK | 后台任务 ID |
|
||||||
| `root_session_id` | TEXT | 根会话 |
|
| `session_id` | TEXT | 所属会话 |
|
||||||
| `parent_run_id` | TEXT FK | 父 run(RESTRICT),NULL 表示 Root 直接委托 |
|
| `channel` | TEXT | 回传渠道 |
|
||||||
| `caller_agent_id` / `caller_scope_id` | TEXT | 调用方身份;Root 的 caller_scope_id 固定 `"ROOT"` |
|
| `chat_id` | TEXT | 回传目标对话 |
|
||||||
| `agent_id` / `definition_hash` / `provider_profile` | TEXT | Definition 快照(绑定运行代,运行中不热切换) |
|
| `prompt` | TEXT | 子任务提示 |
|
||||||
| `provider_name` / `model_id` | TEXT | Provider 与模型 |
|
| `allowed_tools` | TEXT | 允许工具 JSON |
|
||||||
| `mode` | TEXT | foreground / background |
|
| `status` | TEXT | pending / running / completed / failed / cancelled |
|
||||||
| `depth` | INTEGER | 委托深度(>=1) |
|
| `result` | TEXT | 执行结果 |
|
||||||
| `plan_item_id` | TEXT | 绑定计划子项(接纳时原子领取) |
|
| `error` | TEXT | 错误信息 |
|
||||||
| `execution_id` | TEXT | 执行尝试 ID,唯一索引 |
|
| `tool_calls_count` | INTEGER | 工具调用次数 |
|
||||||
| `task` / `context_json` | TEXT | 任务与调用方上下文 |
|
| `iterations` | INTEGER | Agent 迭代次数 |
|
||||||
| `budget_json` | TEXT | 树级剩余预算 |
|
| `started_at` | INTEGER | 开始时间 |
|
||||||
| `signal_contract_json` / `signal_delivery` | TEXT | Definition 信号契约快照与投递 lane(queue/steer) |
|
| `finished_at` | INTEGER | 结束时间 |
|
||||||
| `status` | TEXT | queued / running / waiting_children / completed / failed / timed_out / cancelled / interrupted |
|
| `created_at` | INTEGER | 创建时间 |
|
||||||
| `result` / `error` | TEXT | 终态完整结果/错误(get_result 与 tool 结果同源) |
|
|
||||||
| `prompt_tokens` / `completion_tokens` / `cost` | INTEGER/REAL | Provider usage |
|
|
||||||
| `tool_calls_count` / `iterations` | INTEGER | 执行统计 |
|
|
||||||
| `runtime_generation` / `attempt` | INTEGER | 运行代与重试次数 |
|
|
||||||
| `completion_slot_reserved` | INTEGER | background 完成槽预留 |
|
|
||||||
| `deadline_at` / `started_at` / `finished_at` / `created_at` / `updated_at` | INTEGER | 时间线 |
|
|
||||||
| `revision` | INTEGER | 客户端投影修订号 |
|
|
||||||
|
|
||||||
索引:`execution_id` 唯一、`(root_session_id, caller_scope_id, idempotency_key)` 部分唯一、`(root_session_id, created_at DESC)`、`(parent_run_id, created_at)`、`(runtime_generation, status, deadline_at)`(恢复扫描)。
|
|
||||||
|
|
||||||
## agent_session_state 表(schema v8,Agent 编排)
|
|
||||||
|
|
||||||
每根会话一行,inbox 容量与客户端 revision 的权威计数:
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `root_session_id` | TEXT PK |
|
|
||||||
| `revision` | 单调客户端投影修订号 |
|
|
||||||
| `pending_event_count` | 未消费事件数(容量条件更新) |
|
|
||||||
| `reserved_completion_slots` | 已接纳 background run 预留的完成槽 |
|
|
||||||
| `updated_at` | 最后更新时间 |
|
|
||||||
|
|
||||||
容量判断在同一写事务内做条件 `UPDATE`(`pending + reserved + 新增 <= 上限`),杜绝并发 `COUNT(*)` 漂移。
|
|
||||||
|
|
||||||
## agent_inbox_events 表(schema v8,Agent 编排)
|
|
||||||
|
|
||||||
background 完成/信号投递的唯一事实源:`pending → leased → admitted → consumed`,失败按 token 释放回 pending,超限进 dead-letter,崩溃靠 lease 过期恢复。
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `id` | TEXT PK |
|
|
||||||
| `root_session_id` | TEXT | 根会话 |
|
|
||||||
| `run_id` | TEXT FK NOT NULL(RESTRICT) |
|
|
||||||
| `event_type` | signal / completion |
|
|
||||||
| `event_key` | 去重键(signal 含冷却窗口 id) |
|
|
||||||
| `delivery` | queue / steer |
|
|
||||||
| `requires_continuation` | 是否反向启动 continuation Turn(cancel 产物为 false) |
|
|
||||||
| `severity` / `payload_json` | 信号级别与结构化载荷(completion 含 signal_ids) |
|
|
||||||
| `status` | pending / leased / admitted / consumed / superseded / dead_letter |
|
|
||||||
| `attempt_count` / `lease_token` / `lease_until` / `next_attempt_at` | 投递尝试与租约 |
|
|
||||||
| `admitted_turn_id` | steer 事件接纳的 Turn(/stop 按此条件释放) |
|
|
||||||
| `last_error` | 最近失败原因 |
|
|
||||||
| `revision` | 投影修订号 |
|
|
||||||
| `updated_at` | 最后更新时间 |
|
|
||||||
| `created_at` / `consumed_at` / `superseded_at` / `dead_lettered_at` / `fallback_notified_at` / `fallback_suppressed_reason` | 状态时间线 |
|
|
||||||
|
|
||||||
`(run_id, event_type, event_key)` 唯一(signal 冷却窗去重)。消费/死信会同步递减 `agent_session_state.pending_event_count`。
|
|
||||||
|
|
||||||
## task_plans / task_items 表
|
## task_plans / task_items 表
|
||||||
|
|
||||||
@ -176,21 +101,22 @@ background 完成/信号投递的唯一事实源:`pending → leased → admit
|
|||||||
| `name` | TEXT | 任务名称 |
|
| `name` | TEXT | 任务名称 |
|
||||||
| `schedule` | TEXT | 调度规则 JSON(at/every/cron) |
|
| `schedule` | TEXT | 调度规则 JSON(at/every/cron) |
|
||||||
| `prompt` | TEXT | 任务提示词 |
|
| `prompt` | TEXT | 任务提示词 |
|
||||||
| `agent_id` | TEXT | 可选命名 Agent;NULL 表示 Root |
|
| `channel` | TEXT | 执行渠道 |
|
||||||
| `channel` | TEXT | 目标渠道 |
|
|
||||||
| `chat_id` | TEXT | 目标对话 |
|
| `chat_id` | TEXT | 目标对话 |
|
||||||
| `delivery_policy` | TEXT | `always` / `on_alert` / `never` |
|
| `model` | TEXT | 可选模型标记;当前会存储/展示,但 Scheduler 执行仍使用默认 Agent 模型 |
|
||||||
| `enabled` | INTEGER | 是否启用 (1/0) |
|
| `enabled` | INTEGER | 是否启用 (1/0) |
|
||||||
|
| `delete_after_run` | INTEGER | 执行后自动删除 (1/0) |
|
||||||
| `next_run_at` | INTEGER | 下次执行时间 |
|
| `next_run_at` | INTEGER | 下次执行时间 |
|
||||||
| `last_run_at` | INTEGER | 上次执行时间 |
|
| `last_run_at` | INTEGER | 上次执行时间 |
|
||||||
| `last_outcome` | TEXT | 最近结构化结果:ok/alert/failed/refused/unknown |
|
| `last_status` | TEXT | 上次执行状态 |
|
||||||
|
| `last_error` | TEXT | 上次错误信息 |
|
||||||
| `locked_at` | INTEGER | 本次领取时间 |
|
| `locked_at` | INTEGER | 本次领取时间 |
|
||||||
| `lock_owner` | TEXT | 本次 occurrence 的唯一 owner token |
|
| `lock_owner` | TEXT | 领取任务的 Scheduler owner UUID |
|
||||||
| `lease_until` | INTEGER | 租约到期时间 |
|
| `lease_until` | INTEGER | 租约到期时间;进程崩溃后允许其他实例重新领取 |
|
||||||
| `created_at` | INTEGER | 创建时间(Unix 毫秒) |
|
| `created_at` | INTEGER | 创建时间(Unix 毫秒) |
|
||||||
| `updated_at` | INTEGER | 更新时间(Unix 毫秒) |
|
| `updated_at` | INTEGER | 更新时间(Unix 毫秒) |
|
||||||
|
|
||||||
Scheduler 在领取事务中插入 JobRun、快照执行/投递字段并推进下次时间。`At` 在领取时立即禁用;执行失败或崩溃不重放同一个 occurrence。
|
Scheduler 使用原子 `UPDATE ... RETURNING` 领取到期任务。任务结果、下次运行时间和租约释放在同一事务中提交,并校验 owner,防止过期 worker 覆盖已恢复的任务。
|
||||||
|
|
||||||
## job_runs 表
|
## job_runs 表
|
||||||
|
|
||||||
@ -198,26 +124,12 @@ Scheduler 在领取事务中插入 JobRun、快照执行/投递字段并推进
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `id` | INTEGER PK | 自增 ID |
|
| `id` | INTEGER PK | 自增 ID |
|
||||||
| `job_id` | TEXT FK | 关联任务,外键关联 scheduled_jobs(id) |
|
| `job_id` | TEXT FK | 关联任务,外键关联 scheduled_jobs(id) |
|
||||||
| `scheduled_for` | INTEGER | 本 occurrence 原计划时间 |
|
|
||||||
| `agent_run_id` | TEXT FK | 顶层 AgentRun 审计记录 |
|
|
||||||
| `agent_id` | TEXT | claim-time Agent 快照 |
|
|
||||||
| `delivery_policy` | TEXT | claim-time 投递策略快照 |
|
|
||||||
| `target_channel` / `target_chat_id` | TEXT | claim-time 目标快照 |
|
|
||||||
| `target_session_id` | TEXT | 首次投递时固定的目标 dialog |
|
|
||||||
| `started_at` | INTEGER | 开始时间 |
|
| `started_at` | INTEGER | 开始时间 |
|
||||||
| `finished_at` | INTEGER | 结束时间 |
|
| `finished_at` | INTEGER | 结束时间 |
|
||||||
| `status` | TEXT | claimed/running/completed/failed/timed_out/cancelled/interrupted/unknown |
|
| `status` | TEXT | 执行状态 |
|
||||||
| `outcome` | TEXT | ok/alert/failed/refused/unknown;与 status 有联合约束 |
|
| `output` | TEXT | 执行输出 |
|
||||||
| `message` | TEXT | 面向用户的结构化结果 |
|
| `error` | TEXT | 错误信息 |
|
||||||
| `diagnostic` | TEXT | 有界内部诊断 |
|
|
||||||
| `duration_ms` | INTEGER | 耗时(毫秒) |
|
| `duration_ms` | INTEGER | 耗时(毫秒) |
|
||||||
| `delivery_status` | TEXT | awaiting_result/not_requested/suppressed/pending/delivering/delivered/failed |
|
|
||||||
| `delivery_attempts` | INTEGER | 持久化投递尝试次数,最多 3 次 |
|
|
||||||
| `delivery_next_attempt_at` | INTEGER | 瞬态失败后的退避时间 |
|
|
||||||
| `delivery_lease_owner` / `delivery_lease_until` | TEXT / INTEGER | outbox 领取租约 |
|
|
||||||
| `delivery_error` | TEXT | 清洗后的投递失败摘要 |
|
|
||||||
|
|
||||||
JobRun 是执行结果和投递状态的唯一权威。顶层 AgentRun 与 JobRun 终态、Job 最近摘要和租约释放原子提交;启动恢复将遗留运行归为 `unknown`,不会自动重跑。
|
|
||||||
|
|
||||||
## llm_calls 表
|
## llm_calls 表
|
||||||
|
|
||||||
|
|||||||
@ -34,11 +34,7 @@ docker compose exec picobot picobot pair --gateway-url http://127.0.0.1:19876
|
|||||||
|
|
||||||
## Q: 数据库文件在哪里?
|
## Q: 数据库文件在哪里?
|
||||||
|
|
||||||
默认 `{config_dir}/data/picobot.db`,`config_dir` 默认 `~/.picobot`,与 workspace 相互独立。
|
默认 `{workspace}/picobot.db`,workspace 默认 `~/.picobot/workspace/`。
|
||||||
|
|
||||||
## Q: 如何禁用某个 skill 或 MCP 服务器?
|
|
||||||
|
|
||||||
Skill 安装后默认启用,可在 WebUI「工具 → Skills」页用开关禁用;禁用状态记录在 `~/.picobot/skills_state.json`,被禁用的 skill 不再进入提示词、列表和 `get_skill`。MCP 服务器在配置 `mcp.servers[].enabled`(默认 true)中控制,可在 WebUI「工具 → MCP」页开关;关闭后下次启动/重载时不连接该服务器。
|
|
||||||
|
|
||||||
## Q: 如何查看历史会话?
|
## Q: 如何查看历史会话?
|
||||||
|
|
||||||
@ -50,7 +46,7 @@ Skill 安装后默认启用,可在 WebUI「工具 → Skills」页用开关禁
|
|||||||
|
|
||||||
## Q: 上下文压缩是什么意思?
|
## Q: 上下文压缩是什么意思?
|
||||||
|
|
||||||
对话接近模型 token 限制时,PicoBot 用一份累计 checkpoint 摘要替代 Provider 上下文中的旧前缀,并原样保留近期消息尾部。原始消息和工具结果仍永久保存在聊天历史/SQLite 中,只是不再永久占用模型上下文;语义摘要还可通过 `timeline_recall` 检索。Model 的 `token_limit` 是窗口硬上限,未配置时为 128K;Agent 的可选 `token_limit` 只能收紧它,两者都有时取最小值。自动阈值采用窗口减 reserve 的机制,摘要输入按有效窗口动态限制而非固定 32K;换成更小模型后若历史已经超限,会在首次普通模型请求前压缩或明确降级。`/compact` 可在阈值前手动强制执行。
|
对话历史过长超出模型 token 限制时,系统自动精简历史消息。压缩后旧消息可通过 `timeline_recall` 工具检索。
|
||||||
|
|
||||||
## Q: 如何修改 gateway 监听端口?
|
## Q: 如何修改 gateway 监听端口?
|
||||||
|
|
||||||
@ -64,7 +60,7 @@ LLM 调用记录存储在 `llm_calls` 表中。可通过 SQLite 客户端直接
|
|||||||
|
|
||||||
## Q: 为什么修改了某些 memory 配置却没有看到行为变化?
|
## Q: 为什么修改了某些 memory 配置却没有看到行为变化?
|
||||||
|
|
||||||
当前 `recall_limit` 以及新增的 `recall_min_relevance`、`recall_min_score`、`recall_recency_half_life_days`、`recall_timeout_ms` 均已生效,控制每轮自动知识召回的相关性门槛、加权与超时。`idle_consolidation_minutes`、`timeline_retention_days` 和 `max_failures_before_degrade` 仍只是配置解析:自动 idle consolidation、Timeline 清理(由维护任务执行)和失败降级循环尚未接入。以当前代码行为为准。
|
当前 `recall_limit`、`idle_consolidation_minutes`、`timeline_retention_days` 和 `max_failures_before_degrade` 都能被配置解析,但每轮 Knowledge 召回仍固定为 5,自动 idle consolidation、Timeline 清理和失败降级循环尚未接入。以当前代码行为为准。
|
||||||
|
|
||||||
## Q: Gateway 为什么无法立即退出?
|
## Q: Gateway 为什么无法立即退出?
|
||||||
|
|
||||||
|
|||||||
@ -50,15 +50,14 @@
|
|||||||
|
|
||||||
## Cron 定时任务工具
|
## Cron 定时任务工具
|
||||||
|
|
||||||
Cron 不是一个带 `action` 的统一工具,而是七个独立工具;仅在 `gateway.scheduler.enabled=true` 时注册。
|
Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅在 `gateway.scheduler.enabled=true` 时注册。
|
||||||
|
|
||||||
| 工具 | 主要参数 | 说明 |
|
| 工具 | 主要参数 | 说明 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| `cron_add` | `schedule`, `prompt`, `channel`, `chat_id`; 可选 `name`, `agent_id`, `delivery_policy` | 创建任务 |
|
| `cron_add` | `schedule`, `prompt`, `channel`, `chat_id`; 可选 `name`, `model` | 创建任务 |
|
||||||
| `cron_list` | 可选 `status=all|enabled|disabled` | 列出任务 |
|
| `cron_list` | 可选 `status=all|enabled|disabled` | 列出任务 |
|
||||||
| `cron_runs` | `job_id`; 可选 `run_id`, `limit` | 查询结构化运行和投递记录,包括静默结果 |
|
| `cron_update` | `job_id`; 可选 `prompt`, `schedule`, `channel`, `chat_id`, `model` | 更新指定字段 |
|
||||||
| `cron_update` | `job_id`; 可选 `name`, `prompt`, `schedule`, `channel`, `chat_id`, `agent_id`, `delivery_policy` | 更新指定字段;`agent_id:null` 切回 Root |
|
| `cron_remove` | `job_id` | 永久删除任务和关联 job runs |
|
||||||
| `cron_remove` | `job_id` | 无活动 Run 或 pending delivery 时永久删除任务 |
|
|
||||||
| `cron_enable` | `job_id` | 启用并重新计算下次运行时间 |
|
| `cron_enable` | `job_id` | 启用并重新计算下次运行时间 |
|
||||||
| `cron_disable` | `job_id` | 禁用但保留任务 |
|
| `cron_disable` | `job_id` | 禁用但保留任务 |
|
||||||
|
|
||||||
@ -70,9 +69,7 @@ Cron 不是一个带 `action` 的统一工具,而是七个独立工具;仅
|
|||||||
{"type":"cron","expr":"0 0 9 * * *","tz":"Asia/Shanghai"}
|
{"type":"cron","expr":"0 0 9 * * *","tz":"Asia/Shanghai"}
|
||||||
```
|
```
|
||||||
|
|
||||||
时间戳和间隔单位为毫秒;Cron 表达式为 6 段(秒、分、时、日、月、周)。过去时间的 At 不能创建、更新或直接重新启用。定时 Agent 不复用聊天历史,`prompt` 必须包含完整上下文;`agent_id` 省略时使用 Root,否则使用当前 AgentCatalog 中的命名 Agent。
|
时间戳和间隔单位为毫秒;Cron 表达式为 6 段(秒、分、时、日、月、周)。定时 Agent 不复用聊天历史,`prompt` 必须包含完整上下文。`kind` 可为 `task` 或 `monitor`;`delivery_policy` 可为 `always`、`on_alert` 或 `never`。托管任务由 Scheduler 投递,巡检返回 `NO_REPLY[INFO]` 时静默,`NO_REPLY[FAIL]`/`NO_REPLY[REFUSE]` 仍视为需关注结果。升级前创建的任务保留 Agent 直接调用 `send_message` 的兼容行为。`model` 当前会持久化和展示,但执行仍使用默认 Agent Provider/Model,不能依赖它实现模型覆盖。
|
||||||
|
|
||||||
每次运行必须恰好一次调用 `complete_scheduled_run(outcome,message)`,outcome 只能是 `ok`、`alert`、`failed`、`refused`。普通最终文本不会被解释为结果,缺少结构化终结会 fail-closed。投递完全由 Scheduler 决定:`always` 投递所有结果,`on_alert` 只抑制 `ok`,`never` 只保留记录。Scheduled Agent 不能自行发送最终通知;子 Agent 委托会同步完成,也不会产生后台 Inbox/Signal。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -140,36 +137,27 @@ Cron 不是一个带 `action` 的统一工具,而是七个独立工具;仅
|
|||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
| 参数 | 必填 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `target` | 具名 Agent 必填 | 目标 Agent ID;主 Agent 可委托给任意具名子 Agent,子 Agent 按自身 Definition 的 `delegates` 白名单决定 |
|
| `action` | 是 | `run`, `check_task`, `cancel_task`, `list_tasks` |
|
||||||
| `task` | 单任务必填 | 明确、独立、可验收的子任务 |
|
| `prompt` | run 必填 | 子任务描述 |
|
||||||
| `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 |
|
| `mode` | 否 | `inline`, `background`, `parallel`,默认 `inline` |
|
||||||
| `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode |
|
| `allowed_tools` | 否 | 子 Agent 可用工具列表;默认只读工具集 |
|
||||||
| `tasks` | 批量必填 | 子任务数组;foreground 并发执行、结果保持请求顺序 |
|
| `max_iterations` | 否 | 最大迭代次数,默认 99 |
|
||||||
| `allowed_tools` | 否 | 只能收窄具名 Definition 的工具集,不能扩权 |
|
| `timeout_secs` | 否 | 超时秒数,默认 3600 |
|
||||||
| `plan_item_id` | 否 | 绑定当前计划子项;批量数组中的每项可分别绑定 |
|
| `tasks` | parallel 必填 | 并行子任务数组 |
|
||||||
|
| `task_id` | 查询/取消必填 | 后台任务 ID |
|
||||||
|
| `plan_item_id` | 否 | 将 inline/background 子 Agent 绑定到当前计划子项;parallel 数组中的每项也可分别绑定 |
|
||||||
|
|
||||||
子 Agent 的工具集完全由其定义文件(`~/.picobot/agents/*.md`)的 `tools` 列表决定,可直接内联 `provider`/`model` 指定模型。具名 background(`target` + `mode=background`,单任务或 `tasks[]` 批量)经 durable run/inbox 接纳:每个 run 先落 `agent_runs` 并预留 inbox completion slot,完成后由主 Agent 的 continuation Turn 处理结果,不再直接发 Channel 通知;批量并发执行、每个 run 独立返回(无用户输入积压时完成即返回)。子 Agent 发起的 background 尚未开放。后台运行可以在任务中调用 `emit_signal` 发送结构化内部信号(队列或 steer 投递),Steer 信号会在当前 Turn 的安全边界注入主 Agent;`agent_task.cancel` 会把该 run 未消费的普通信号标记 superseded。
|
默认只读工具集:`file_read`、`file_search`、`content_search`、`web_fetch`、`http_request`、`calculator`。
|
||||||
|
|
||||||
## agent_task — 具名 Agent Run 查询与控制
|
|
||||||
|
|
||||||
仅在 `agent_orchestration` 启用时注册。查询或控制已持久化的具名 foreground run;授权来自调用者身份(ROOT 限本 session,具名 Agent 限自身子树),run ID 本身不是凭证。
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `action` | 是 | `get` 查询单个 run;`list` 列出 session 的 run;`get_result` 读取终态完整结果;`cancel` 取消未终态 run |
|
|
||||||
| `run_id` | get/get_result/cancel 必填 | 目标 run ID |
|
|
||||||
| `cursor_created_at` / `cursor_id` | 否 | list 分页游标,必须成对出现 |
|
|
||||||
| `limit` | 否 | list 上限,默认 20 |
|
|
||||||
|
|
||||||
## todo — Session 任务计划
|
## todo — Session 任务计划
|
||||||
|
|
||||||
仅用于明确的复杂、多轮或并行任务。`create` 创建当前 session 唯一的 active plan;`view` 查看;`append` 增加子项;`update` 修改子项状态;`close` 完成或取消计划。普通闲聊和单步操作不应创建计划。子 Agent 始终不能使用 `todo`;只有 Definition 声明委托边的具名 Agent 会获得运行时注入的 `delegate`,且只能 foreground 委托允许目标。
|
仅用于明确的复杂、多轮或并行任务。`create` 创建当前 session 唯一的 active plan;`view` 查看;`append` 增加子项;`update` 修改子项状态;`close` 完成或取消计划。普通闲聊和单步操作不应创建计划。子 Agent 始终被过滤掉 `todo` 和 `delegate`,计划结构只由主 Agent 管理。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## browser — 浏览器自动化
|
## browser — 浏览器自动化
|
||||||
|
|
||||||
默认注册;设置 `browser.enabled=false` 后不注册。PicoBot 上层包装统一 action 和结构化媒体,底层逐次调用 agent-browser `--json`。调用时不传 `persistent_id`,每个 dialog 使用各自的普通临时 session,默认空闲一小时后自动关闭;长期工作需要保持浏览器进程或保留登录和站点状态时,Agent 可自主用 `browser_profiles(create,label=...)` 创建身份,并在后续相关 action 中持续传入同一个 ID。持久 session 不会因空闲自动关闭。PicoBot 没有全局持久化开关或默认持久 ID,也不按 dialog 自动选择持久身份;同一 ID 跨 dialog 共享 session 和串行锁,不同 ID 使用独立 session/锁并可并发。CLI daemon 通过 Chrome CDP 工作,不使用 Fantoccini、ChromeDriver 或 WebDriver。
|
仅在 `browser.enabled=true` 时注册。底层使用 WebDriver/Chrome。
|
||||||
|
|
||||||
| action | 说明 |
|
| action | 说明 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
@ -178,31 +166,15 @@ Cron 不是一个带 `action` 的统一工具,而是七个独立工具;仅
|
|||||||
| `click`, `click_at` | 点击元素或坐标 |
|
| `click`, `click_at` | 点击元素或坐标 |
|
||||||
| `fill`, `type`, `press` | 输入文本或按键 |
|
| `fill`, `type`, `press` | 输入文本或按键 |
|
||||||
| `get_text`, `get_title`, `get_url` | 读取页面信息 |
|
| `get_text`, `get_title`, `get_url` | 读取页面信息 |
|
||||||
| `screenshot` | 保存到 `browser.artifact_dir`,交给模型并默认附到最终用户回复;支持 `full_page`、`annotate`,可用 `present_to_user=false` 仅供模型检查 |
|
| `screenshot` | 截图,可写入文件或返回 base64 |
|
||||||
| `focus`, `hover`, `scroll`, `wait` | 常见交互和等待 |
|
| `focus`, `hover`, `scroll`, `wait` | 常见交互和等待 |
|
||||||
| `close` | 关闭浏览器会话 |
|
| `close` | 关闭浏览器会话 |
|
||||||
|
|
||||||
典型流程:`open` → `snapshot` 获取 `@e` 引用 → 交互 → 页面变化后重新 `snapshot`。`path` 只接受 `.png` 文件名,不能逃逸产物目录。`open` 默认拒绝非 HTTP(S)、userinfo、回环、私网、本地域名及 DNS 解析到私网的地址;配置 `allowed_domains` 后,agent-browser 同时限制导航、子资源、WebSocket、EventSource 与 WebRTC。页面输出是不可信内容,默认开启 content boundary 元数据和 50,000 字符上限。
|
|
||||||
|
|
||||||
## browser_profiles — 持久浏览器身份管理
|
|
||||||
|
|
||||||
与 `browser` 一同注册。`create` 生成新的持久 ID 和 Profile 目录,并可接受 1–80 字符的语义标签;`set_label` 按精确 ID 重命名标签;`list` 返回每个合法 Profile 的 ID、标签、目录和 active 状态;`delete` 必须传入 `create/list` 返回的精确 ID,并删除该 ID 的完整 Chrome Profile、标签与登录态。删除活动 Profile 时会等待其操作完成并尝试关闭浏览器。标签只用于识别,不能代替 ID 选择;该工具不能接收任意目录。非空 `browser.allowed_domains` 下普通临时浏览器仍可用,但持久身份不能创建或使用。
|
|
||||||
|
|
||||||
依赖缺失时必须把错误和处置命令返回给用户,不能声称已浏览,也不能在工具内部静默安装:CLI 不存在时安装 `agent-browser@0.33.0`;Chrome 不存在时运行 `agent-browser install`;Linux 共享库不完整时运行 `agent-browser install --with-deps`。用 `picobot health` 复查,再用 `agent-browser doctor` 获取详细上游诊断。用户明确不需要浏览器时才建议 `browser.enabled=false`。
|
|
||||||
|
|
||||||
## health — 依赖检查
|
|
||||||
|
|
||||||
无参数时返回可读报告;`json=true` 返回结构化报告。核心必需项、当前配置启用后必需的依赖、可选功能分别标记。`fd` 与 Debian/Ubuntu 的 `fdfind` 是同一个首选文件搜索程序;只有传统 `find` 会产生降级警告。浏览器启用时会检查 CLI、可执行路径,并通过隔离的完整 offline doctor 分别验证浏览器安装、真实 headless 启动和环境。该工具只读,与 CLI `picobot health [--json]`、`/health` 斜杠命令以及 WebUI“配置 → 健康检查”复用同一个 `HealthService`。
|
|
||||||
|
|
||||||
## reload_config — 重载配置
|
|
||||||
|
|
||||||
重新读取并校验 PicoBot 配置,然后让 Gateway 优雅切换到新配置。无参数,仅 Root 交互 Agent 可用;仅在用户明确要求重新加载配置时调用,会先验证再切换,失败则保留旧运行代。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MCP 工具
|
## MCP 工具
|
||||||
|
|
||||||
如果 `config.mcp.servers` 配置了 MCP 服务器,Gateway 启动时会连接服务器、发现工具,并把 MCP 工具包装后注册到 ToolRegistry。注册名固定为 `mcp_<服务器名>_<工具名>`,例如 `mcp_filesystem_read_file`;配置 `tool_settings` 仍使用 MCP 原始工具名 `read_file`。使用 `/mcp` 查看当前连接状态和工具列表。MCP 协议不声明副作用或并发安全性;可在服务器配置的 `tool_settings` 中为各工具设置本地受信任的 `read_only`、`exclusive` 属性。未设置的工具顺序执行;“可并发”由 `read_only && !exclusive` 自动推导。
|
如果 `config.mcp.servers` 配置了 MCP 服务器,Gateway 启动时会连接服务器、发现工具,并把 MCP 工具包装后注册到 ToolRegistry。使用 `/mcp` 查看当前连接状态和工具列表。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -224,20 +196,19 @@ Cron 不是一个带 `action` 的统一工具,而是七个独立工具;仅
|
|||||||
|
|
||||||
## calculator — 计算器
|
## calculator — 计算器
|
||||||
|
|
||||||
数学表达式计算和统计函数。用 `function` 指定要执行的计算。
|
数学表达式计算和统计函数。
|
||||||
|
|
||||||
| function | 相关参数 | 说明 |
|
| action | 说明 |
|
||||||
|----------|----------|------|
|
|--------|------|
|
||||||
| `evaluate` | `expression` | 计算表达式 |
|
| `evaluate` | 计算表达式 |
|
||||||
| `sum` / `count` / `range` | `values` | 求和 / 计数 / 极差 |
|
| `sum` | 求和 |
|
||||||
| `average` | `values` | 平均值 |
|
| `average` | 平均值 |
|
||||||
| `median` | `values` | 中位数 |
|
| `median` | 中位数 |
|
||||||
| `mode` | `values` | 众数 |
|
| `mode` | 众数 |
|
||||||
| `stdev` / `variance` | `values` | 标准差 / 方差 |
|
| `stdev` / `variance` | 标准差/方差 |
|
||||||
| `min` / `max` | `values` | 最小值 / 最大值 |
|
| `min` / `max` | 最小值/最大值 |
|
||||||
| `log` | `x`, 可选 `base` | 对数(base 默认 10) |
|
| `log` | 对数 |
|
||||||
| `factorial` | `x` | 阶乘 |
|
| `factorial` | 阶乘 |
|
||||||
| `round` | `x`, `decimals` | 四舍五入 |
|
| `round` | 四舍五入 |
|
||||||
| `percentage_change` | `a`(旧值), `b`(新值) | 变化百分比 |
|
| `percentage_change` | 变化百分比 |
|
||||||
| `percentile` | `values`, `p` | 百分位数(p 0–100) |
|
| `percentile` | 百分位数 |
|
||||||
| `clamp` | `x`, `min_val`, `max_val` | 夹取到区间 |
|
|
||||||
|
|||||||
@ -23,8 +23,7 @@
|
|||||||
"qwen-plus": {
|
"qwen-plus": {
|
||||||
"model_id": "qwen-plus",
|
"model_id": "qwen-plus",
|
||||||
"temperature": 0.0,
|
"temperature": 0.0,
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192
|
||||||
"token_limit": 128000
|
|
||||||
},
|
},
|
||||||
"gpt-4o": {
|
"gpt-4o": {
|
||||||
"model_id": "gpt-4o",
|
"model_id": "gpt-4o",
|
||||||
@ -43,30 +42,10 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"provider": "aliyun",
|
"provider": "aliyun",
|
||||||
"model": "qwen-plus",
|
"model": "qwen-plus",
|
||||||
"max_tool_iterations": 99
|
"max_tool_iterations": 99,
|
||||||
|
"token_limit": 128000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"context_compaction": {
|
|
||||||
"enabled": true,
|
|
||||||
"reserve_tokens": 16384,
|
|
||||||
"keep_recent_tokens": 20000
|
|
||||||
},
|
|
||||||
"agent_orchestration": {
|
|
||||||
"definitions_dir": "agents",
|
|
||||||
"max_tree_depth": 4,
|
|
||||||
"max_runs_per_tree": 16,
|
|
||||||
"max_concurrent_runs": 6,
|
|
||||||
"max_concurrent_runs_per_session": 4,
|
|
||||||
"max_concurrent_provider_steps": 8,
|
|
||||||
"max_concurrent_provider_steps_per_session": 4,
|
|
||||||
"max_concurrent_tool_steps": 16,
|
|
||||||
"max_concurrent_tool_steps_per_session": 8,
|
|
||||||
"max_pending_inbox_events_per_session": 128,
|
|
||||||
"inbox_event_ttl_hours": 168,
|
|
||||||
"max_inbox_delivery_attempts": 8,
|
|
||||||
"max_user_turn_burst_before_inbox": 4,
|
|
||||||
"max_inbox_wait_secs": 30
|
|
||||||
},
|
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 19876,
|
"port": 19876,
|
||||||
@ -78,12 +57,6 @@
|
|||||||
"max_files_per_message": 8,
|
"max_files_per_message": 8,
|
||||||
"max_message_bytes": 67108864,
|
"max_message_bytes": 67108864,
|
||||||
"pending_ttl_seconds": 3600
|
"pending_ttl_seconds": 3600
|
||||||
},
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"poll_interval_secs": 60,
|
|
||||||
"max_concurrent": 1,
|
|
||||||
"execution_timeout_secs": 900
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
@ -111,10 +84,6 @@
|
|||||||
"consolidation_provider": null,
|
"consolidation_provider": null,
|
||||||
"consolidation_model": null,
|
"consolidation_model": null,
|
||||||
"recall_limit": 5,
|
"recall_limit": 5,
|
||||||
"recall_min_relevance": 0.25,
|
|
||||||
"recall_min_score": 0.25,
|
|
||||||
"recall_recency_half_life_days": 30,
|
|
||||||
"recall_timeout_ms": 1000,
|
|
||||||
"idle_consolidation_minutes": 10,
|
"idle_consolidation_minutes": 10,
|
||||||
"timeline_retention_days": 90,
|
"timeline_retention_days": 90,
|
||||||
"max_failures_before_degrade": 3
|
"max_failures_before_degrade": 3
|
||||||
@ -124,21 +93,10 @@
|
|||||||
"tool_timeout_secs": 180
|
"tool_timeout_secs": 180
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"command": "agent-browser",
|
"webdriver_url": "http://127.0.0.1:9515",
|
||||||
"headless": true,
|
"headless": true,
|
||||||
"browser_executable_path": null,
|
"chrome_path": null
|
||||||
"max_sessions": 4,
|
|
||||||
"idle_timeout_secs": 3600,
|
|
||||||
"command_timeout_secs": 120,
|
|
||||||
"max_output_chars": 50000,
|
|
||||||
"content_boundaries": true,
|
|
||||||
"allowed_domains": [],
|
|
||||||
"allow_private_hosts": false,
|
|
||||||
"artifact_dir": "~/.picobot/media/browser",
|
|
||||||
"persistence": {
|
|
||||||
"profile_dir": "~/.picobot/browser/profiles"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"workspace_dir": "~/.picobot/workspace"
|
"workspace_dir": "~/.picobot/workspace"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,55 +0,0 @@
|
|||||||
//! Built-in Agent definitions, released to the user config directory on
|
|
||||||
//! first run just like built-in skills. A released definition is a regular
|
|
||||||
//! user-editable file afterwards; the installer never overwrites it.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use crate::config::LLMProviderConfig;
|
|
||||||
|
|
||||||
mod embedded {
|
|
||||||
include!(concat!(env!("OUT_DIR"), "/embedded_agents.rs"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Install built-in Agent definitions into `<config_dir>/agents/`. Files
|
|
||||||
/// that already exist (user-modified or user-created) are left untouched.
|
|
||||||
pub fn install_builtin_agents(
|
|
||||||
config_dir: &Path,
|
|
||||||
profiles: &std::collections::HashMap<String, LLMProviderConfig>,
|
|
||||||
) {
|
|
||||||
let agents_dir = config_dir.join("agents");
|
|
||||||
if let Err(error) = std::fs::create_dir_all(&agents_dir) {
|
|
||||||
tracing::warn!(dir = %agents_dir.display(), error = %error, "Failed to create agents directory");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for agent in embedded::EMBEDDED_AGENTS {
|
|
||||||
let path = agents_dir.join(format!("{}.md", agent.name));
|
|
||||||
if path.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Err(error) = std::fs::write(&path, agent.content) {
|
|
||||||
tracing::warn!(name = agent.name, error = %error, "Failed to install built-in Agent definition");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let profile = match profiles.get("default").cloned() {
|
|
||||||
Some(profile) => profile,
|
|
||||||
None => {
|
|
||||||
tracing::warn!(
|
|
||||||
name = agent.name,
|
|
||||||
"Skipping built-in Agent validation: no 'default' provider profile configured"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Validate the released file immediately so a future catalog load
|
|
||||||
// cannot fail on a broken built-in. Validation failure keeps the
|
|
||||||
// file (the user can edit it) but logs loudly.
|
|
||||||
match super::definition::parse_definition(&path, std::sync::Arc::new(profile)) {
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!(name = agent.name, dir = %path.display(), "Installed built-in Agent definition");
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!(name = agent.name, error = %error, "Installed built-in Agent definition failed validation");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1097
src/agent/catalog.rs
1097
src/agent/catalog.rs
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1042
src/agent/context_compressor.rs
Normal file
1042
src/agent/context_compressor.rs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,671 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::config::LLMProviderConfig;
|
|
||||||
|
|
||||||
pub const MAX_DEFINITION_FILE_BYTES: u64 = 256 * 1024;
|
|
||||||
const MAX_DESCRIPTION_CHARS: usize = 4_096;
|
|
||||||
const MAX_ROLE_CHARS: usize = 65_536;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(default, deny_unknown_fields)]
|
|
||||||
pub struct AgentLimits {
|
|
||||||
pub timeout_secs: u64,
|
|
||||||
pub max_iterations: usize,
|
|
||||||
pub max_children: usize,
|
|
||||||
pub max_depth: u16,
|
|
||||||
pub max_concurrent_runs: usize,
|
|
||||||
pub max_concurrent_provider_steps: usize,
|
|
||||||
pub max_concurrent_tool_steps: usize,
|
|
||||||
pub max_result_chars: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AgentLimits {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
timeout_secs: 900,
|
|
||||||
max_iterations: 24,
|
|
||||||
max_children: 4,
|
|
||||||
max_depth: 3,
|
|
||||||
max_concurrent_runs: 2,
|
|
||||||
max_concurrent_provider_steps: 1,
|
|
||||||
max_concurrent_tool_steps: 4,
|
|
||||||
max_result_chars: 16_000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signal delivery lane for background Agent events.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SignalDelivery {
|
|
||||||
#[default]
|
|
||||||
Queue,
|
|
||||||
Steer,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SignalDelivery {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Queue => "queue",
|
|
||||||
Self::Steer => "steer",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Durable emit_signal contract. An Agent only sees the `emit_signal` tool
|
|
||||||
/// when this block is present; every limit below is enforced by the tool and
|
|
||||||
/// the Coordinator, not by the model.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(default, deny_unknown_fields)]
|
|
||||||
pub struct SignalContract {
|
|
||||||
pub delivery: SignalDelivery,
|
|
||||||
/// Total number of signals one run may emit.
|
|
||||||
pub max_total: u32,
|
|
||||||
/// Upper bound for the serialized `details` JSON payload of one signal.
|
|
||||||
pub max_details_bytes: usize,
|
|
||||||
/// Minimum wall time between two signals of the same run.
|
|
||||||
pub min_interval_ms: u64,
|
|
||||||
/// Maximum signals emitted within `burst_window_ms`.
|
|
||||||
pub max_burst: u32,
|
|
||||||
pub burst_window_ms: u64,
|
|
||||||
/// Allowlisted severities; anything else is rejected.
|
|
||||||
pub severity_allowlist: Vec<String>,
|
|
||||||
/// Dedupe key cooldown window; repeated keys inside the window collapse
|
|
||||||
/// to the same event, keys outside it emit again.
|
|
||||||
pub dedupe_cooldown_ms: u64,
|
|
||||||
/// Maximum JSON nesting depth of `details`.
|
|
||||||
pub max_payload_depth: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SignalContract {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
delivery: SignalDelivery::Queue,
|
|
||||||
max_total: 64,
|
|
||||||
max_details_bytes: 8 * 1024,
|
|
||||||
min_interval_ms: 500,
|
|
||||||
max_burst: 5,
|
|
||||||
burst_window_ms: 10_000,
|
|
||||||
severity_allowlist: vec![
|
|
||||||
"info".to_string(),
|
|
||||||
"warning".to_string(),
|
|
||||||
"critical".to_string(),
|
|
||||||
],
|
|
||||||
dedupe_cooldown_ms: 60_000,
|
|
||||||
max_payload_depth: 16,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SignalContract {
|
|
||||||
fn validate(&self) -> Result<(), AgentDefinitionError> {
|
|
||||||
if self.max_total == 0 || self.max_total > 1024 {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"signal.max_total must be between 1 and 1024".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.max_details_bytes == 0 || self.max_details_bytes > 64 * 1024 {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"signal.max_details_bytes must be between 1 and 65536".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.max_burst == 0 || self.max_burst > self.max_total {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"signal.max_burst must be between 1 and max_total".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.max_payload_depth == 0 || self.max_payload_depth > 64 {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"signal.max_payload_depth must be between 1 and 64".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.severity_allowlist.is_empty() || self.severity_allowlist.len() > 16 {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"signal.severity_allowlist must contain 1..=16 severities".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
if let Some(duplicate) = self
|
|
||||||
.severity_allowlist
|
|
||||||
.iter()
|
|
||||||
.find(|severity| !seen.insert(severity.as_str()))
|
|
||||||
{
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"duplicate signal severity '{duplicate}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if self
|
|
||||||
.severity_allowlist
|
|
||||||
.iter()
|
|
||||||
.any(|severity| severity.trim().is_empty() || severity.len() > 64)
|
|
||||||
{
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"signal severities must be non-empty and at most 64 characters".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentLimits {
|
|
||||||
fn validate(&self) -> Result<(), AgentDefinitionError> {
|
|
||||||
if self.timeout_secs == 0 || self.timeout_secs > 86_400 {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"limits.timeout_secs must be between 1 and 86400".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.max_iterations == 0 || self.max_iterations > 256 {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"limits.max_iterations must be between 1 and 256".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.max_children == 0 || self.max_children > 128 {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"limits.max_children must be between 1 and 128".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.max_depth == 0 || self.max_depth > 32 {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"limits.max_depth must be between 1 and 32".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for (name, value, hard_max) in [
|
|
||||||
("max_concurrent_runs", self.max_concurrent_runs, 128),
|
|
||||||
(
|
|
||||||
"max_concurrent_provider_steps",
|
|
||||||
self.max_concurrent_provider_steps,
|
|
||||||
128,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"max_concurrent_tool_steps",
|
|
||||||
self.max_concurrent_tool_steps,
|
|
||||||
512,
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
if value == 0 || value > hard_max {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"limits.{name} must be between 1 and {hard_max}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.max_result_chars == 0 || self.max_result_chars > 1_000_000 {
|
|
||||||
return Err(AgentDefinitionError::Invalid(
|
|
||||||
"limits.max_result_chars must be between 1 and 1000000".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct AgentFrontmatter {
|
|
||||||
pub id: String,
|
|
||||||
pub description: String,
|
|
||||||
/// Either this (a key in `config.json`'s `agents` map) or the inline
|
|
||||||
/// `provider` + `model` pair must be present.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub llm_profile: Option<String>,
|
|
||||||
/// Inline provider/model selection; the preferred way to author an Agent
|
|
||||||
/// from the WebUI. Overrides `llm_profile` when both are present.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub provider: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub model: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub token_limit: Option<usize>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub max_tool_iterations: Option<usize>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub tools: Vec<String>,
|
|
||||||
/// Disabled definitions stay on disk but never load into the catalog.
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub enabled: bool,
|
|
||||||
/// Which Agents this one may further delegate to. `None` (field absent)
|
|
||||||
/// defaults to the built-in `general-purpose`; `Some([])` forbids further
|
|
||||||
/// delegation; a list containing `*` allows any other Agent; an explicit
|
|
||||||
/// list allows exactly those targets.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub delegates: Option<Vec<String>>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub skills: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub limits: AgentLimits,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub signal: Option<SignalContract>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AgentDefinition {
|
|
||||||
pub id: String,
|
|
||||||
pub description: String,
|
|
||||||
pub llm_profile: Option<String>,
|
|
||||||
pub provider: Option<String>,
|
|
||||||
pub model: Option<String>,
|
|
||||||
pub provider_config: Arc<LLMProviderConfig>,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub tools: Vec<String>,
|
|
||||||
pub delegates: Option<Vec<String>>,
|
|
||||||
pub skills: Vec<String>,
|
|
||||||
pub limits: AgentLimits,
|
|
||||||
pub signal_contract: Option<SignalContract>,
|
|
||||||
pub role_prompt: String,
|
|
||||||
pub definition_hash: String,
|
|
||||||
pub source_path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum AgentDefinitionError {
|
|
||||||
#[error("failed to read Agent definition {path}: {source}")]
|
|
||||||
Io {
|
|
||||||
path: String,
|
|
||||||
#[source]
|
|
||||||
source: std::io::Error,
|
|
||||||
},
|
|
||||||
#[error("invalid Agent definition: {0}")]
|
|
||||||
Invalid(String),
|
|
||||||
#[error("invalid YAML frontmatter: {0}")]
|
|
||||||
Yaml(#[from] serde_yaml::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn parse_definition(
|
|
||||||
path: &Path,
|
|
||||||
provider_config: Arc<LLMProviderConfig>,
|
|
||||||
) -> Result<AgentDefinition, AgentDefinitionError> {
|
|
||||||
let (frontmatter, role_prompt) = read_frontmatter(path)?;
|
|
||||||
let canonical_frontmatter = serde_json::to_vec(&frontmatter)
|
|
||||||
.map_err(|error| AgentDefinitionError::Invalid(error.to_string()))?;
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(canonical_frontmatter);
|
|
||||||
hasher.update(b"\n---\n");
|
|
||||||
hasher.update(role_prompt.as_bytes());
|
|
||||||
let definition_hash = hasher
|
|
||||||
.finalize()
|
|
||||||
.iter()
|
|
||||||
.map(|byte| format!("{byte:02x}"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(AgentDefinition {
|
|
||||||
id: frontmatter.id,
|
|
||||||
description: frontmatter.description.trim().to_string(),
|
|
||||||
llm_profile: frontmatter.llm_profile,
|
|
||||||
provider: frontmatter.provider,
|
|
||||||
model: frontmatter.model,
|
|
||||||
provider_config,
|
|
||||||
enabled: frontmatter.enabled,
|
|
||||||
tools: frontmatter.tools,
|
|
||||||
delegates: frontmatter.delegates,
|
|
||||||
skills: frontmatter.skills,
|
|
||||||
limits: frontmatter.limits,
|
|
||||||
signal_contract: frontmatter.signal,
|
|
||||||
role_prompt,
|
|
||||||
definition_hash,
|
|
||||||
source_path: path.to_path_buf(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provider-agnostic view of a definition file, used by the management UI.
|
|
||||||
/// It carries the frontmatter plus the role body but no resolved
|
|
||||||
/// `provider_config`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AgentDefinitionInfo {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub frontmatter: AgentFrontmatter,
|
|
||||||
pub role_prompt: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read and validate a definition file without resolving its provider
|
|
||||||
/// config. Used by the management API to list/validate definitions; the
|
|
||||||
/// catalog performs the full provider/tool/delegate resolution on load.
|
|
||||||
pub fn parse_definition_info(path: &Path) -> Result<AgentDefinitionInfo, AgentDefinitionError> {
|
|
||||||
let (frontmatter, role_prompt) = read_frontmatter(path)?;
|
|
||||||
Ok(AgentDefinitionInfo {
|
|
||||||
frontmatter,
|
|
||||||
role_prompt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort view of a definition file for the management UI. A fully
|
|
||||||
/// valid file returns `(Some(info), None)`; a broken file returns a partial
|
|
||||||
/// `info` (id from the file stem, the role body, and whichever frontmatter
|
|
||||||
/// fields still parse) together with the parse error, so broken definitions
|
|
||||||
/// remain listed and editable instead of being silently hidden.
|
|
||||||
pub fn parse_definition_lenient(path: &Path) -> (Option<AgentDefinitionInfo>, Option<String>) {
|
|
||||||
match parse_definition_info(path) {
|
|
||||||
Ok(info) => (Some(info), None),
|
|
||||||
Err(error) => {
|
|
||||||
let stem = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|value| value.to_str())
|
|
||||||
.unwrap_or("agent")
|
|
||||||
.to_string();
|
|
||||||
let mut role_prompt = String::new();
|
|
||||||
let mut frontmatter = AgentFrontmatter {
|
|
||||||
id: stem.clone(),
|
|
||||||
description: String::new(),
|
|
||||||
llm_profile: None,
|
|
||||||
provider: None,
|
|
||||||
model: None,
|
|
||||||
token_limit: None,
|
|
||||||
max_tool_iterations: None,
|
|
||||||
tools: Vec::new(),
|
|
||||||
enabled: false,
|
|
||||||
delegates: None,
|
|
||||||
skills: Vec::new(),
|
|
||||||
limits: AgentLimits::default(),
|
|
||||||
signal: None,
|
|
||||||
};
|
|
||||||
if let Ok(content) = std::fs::read_to_string(path) {
|
|
||||||
let normalized = content.replace("\r\n", "\n");
|
|
||||||
if let Some(rest) = normalized.strip_prefix("---\n")
|
|
||||||
&& let Some((yaml, body)) = rest.split_once("\n---\n")
|
|
||||||
{
|
|
||||||
role_prompt = body.trim().to_string();
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct Lenient {
|
|
||||||
id: Option<String>,
|
|
||||||
description: Option<String>,
|
|
||||||
llm_profile: Option<String>,
|
|
||||||
provider: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
token_limit: Option<usize>,
|
|
||||||
max_tool_iterations: Option<usize>,
|
|
||||||
tools: Option<Vec<String>>,
|
|
||||||
delegates: Option<Vec<String>>,
|
|
||||||
skills: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
if let Ok(parsed) = serde_yaml::from_str::<Lenient>(yaml) {
|
|
||||||
frontmatter.id = parsed.id.unwrap_or(stem);
|
|
||||||
frontmatter.description = parsed.description.unwrap_or_default();
|
|
||||||
frontmatter.llm_profile = parsed.llm_profile;
|
|
||||||
frontmatter.provider = parsed.provider;
|
|
||||||
frontmatter.model = parsed.model;
|
|
||||||
frontmatter.token_limit = parsed.token_limit;
|
|
||||||
frontmatter.max_tool_iterations = parsed.max_tool_iterations;
|
|
||||||
frontmatter.tools = parsed.tools.unwrap_or_default();
|
|
||||||
frontmatter.delegates = parsed.delegates;
|
|
||||||
frontmatter.skills = parsed.skills.unwrap_or_default();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(
|
|
||||||
Some(AgentDefinitionInfo {
|
|
||||||
frontmatter,
|
|
||||||
role_prompt,
|
|
||||||
}),
|
|
||||||
Some(error.to_string()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize a definition back to the Markdown file format.
|
|
||||||
pub fn serialize_definition(info: &AgentDefinitionInfo) -> String {
|
|
||||||
let yaml = serde_yaml::to_string(&info.frontmatter).unwrap_or_default();
|
|
||||||
format!("---\n{yaml}---\n{}\n", info.role_prompt.trim_end())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read + validate the frontmatter and role body of a definition file,
|
|
||||||
/// shared by `parse_definition` and `parse_definition_info`.
|
|
||||||
fn read_frontmatter(path: &Path) -> Result<(AgentFrontmatter, String), AgentDefinitionError> {
|
|
||||||
let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
|
|
||||||
path: path.display().to_string(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"{} must be a regular non-symlink file",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if metadata.len() > MAX_DEFINITION_FILE_BYTES {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"{} exceeds the {} byte limit",
|
|
||||||
path.display(),
|
|
||||||
MAX_DEFINITION_FILE_BYTES
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let content = std::fs::read_to_string(path).map_err(|source| AgentDefinitionError::Io {
|
|
||||||
path: path.display().to_string(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let normalized = content.replace("\r\n", "\n");
|
|
||||||
let mut lines = normalized.lines();
|
|
||||||
if lines.next() != Some("---") {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"{} must start with a standalone --- line",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let mut yaml_lines = Vec::new();
|
|
||||||
let mut found_end = false;
|
|
||||||
for line in &mut lines {
|
|
||||||
if line == "---" {
|
|
||||||
found_end = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
yaml_lines.push(line);
|
|
||||||
}
|
|
||||||
if !found_end {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"{} has no closing frontmatter delimiter",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let role_prompt = lines.collect::<Vec<_>>().join("\n").trim().to_string();
|
|
||||||
if role_prompt.is_empty() {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"{} has an empty role body",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if role_prompt.chars().count() > MAX_ROLE_CHARS {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"{} role body exceeds {MAX_ROLE_CHARS} characters",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let frontmatter: AgentFrontmatter = serde_yaml::from_str(&yaml_lines.join("\n"))?;
|
|
||||||
validate_agent_id(&frontmatter.id)?;
|
|
||||||
if frontmatter.description.trim().is_empty()
|
|
||||||
|| frontmatter.description.chars().count() > MAX_DESCRIPTION_CHARS
|
|
||||||
{
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"Agent '{}' description must contain 1..={MAX_DESCRIPTION_CHARS} characters",
|
|
||||||
frontmatter.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let has_profile = frontmatter
|
|
||||||
.llm_profile
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|p| !p.trim().is_empty());
|
|
||||||
let has_inline = frontmatter.provider.is_some() || frontmatter.model.is_some();
|
|
||||||
if !has_profile && !has_inline {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"Agent '{}' must declare either llm_profile or provider+model",
|
|
||||||
frontmatter.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if frontmatter.provider.is_some() != frontmatter.model.is_some() {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"Agent '{}' must declare provider and model together",
|
|
||||||
frontmatter.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
frontmatter.limits.validate()?;
|
|
||||||
if let Some(signal) = frontmatter.signal.as_ref() {
|
|
||||||
signal.validate()?;
|
|
||||||
}
|
|
||||||
reject_duplicates("tools", &frontmatter.tools)?;
|
|
||||||
if let Some(delegates) = frontmatter.delegates.as_deref() {
|
|
||||||
reject_duplicates("delegates", delegates)?;
|
|
||||||
}
|
|
||||||
reject_duplicates("skills", &frontmatter.skills)?;
|
|
||||||
let file_stem = path.file_stem().and_then(|value| value.to_str());
|
|
||||||
if file_stem != Some(frontmatter.id.as_str()) {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"Agent id '{}' must match file name {}",
|
|
||||||
frontmatter.id,
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok((frontmatter, role_prompt))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_agent_id(id: &str) -> Result<(), AgentDefinitionError> {
|
|
||||||
let valid = !id.is_empty()
|
|
||||||
&& id.len() <= 64
|
|
||||||
&& id
|
|
||||||
.bytes()
|
|
||||||
.next()
|
|
||||||
.is_some_and(|value| value.is_ascii_lowercase())
|
|
||||||
&& id.bytes().all(|value| {
|
|
||||||
value.is_ascii_lowercase() || value.is_ascii_digit() || value == b'_' || value == b'-'
|
|
||||||
});
|
|
||||||
if !valid || matches!(id, "root" | "main" | "default" | "general") {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"invalid or reserved Agent id '{id}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reject_duplicates(field: &str, values: &[String]) -> Result<(), AgentDefinitionError> {
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
if let Some(value) = values.iter().find(|value| !seen.insert(value.as_str())) {
|
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
|
||||||
"duplicate {field} entry '{value}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
fn provider() -> Arc<LLMProviderConfig> {
|
|
||||||
Arc::new(LLMProviderConfig {
|
|
||||||
provider_type: "openai".to_string(),
|
|
||||||
name: "test".to_string(),
|
|
||||||
base_url: "https://example.invalid/v1".to_string(),
|
|
||||||
api_key: "test".to_string(),
|
|
||||||
extra_headers: HashMap::new(),
|
|
||||||
model_id: "test-model".to_string(),
|
|
||||||
temperature: None,
|
|
||||||
max_tokens: None,
|
|
||||||
model_extra: HashMap::new(),
|
|
||||||
max_tool_iterations: 99,
|
|
||||||
token_limit: 4096,
|
|
||||||
workspace_dir: std::env::temp_dir(),
|
|
||||||
input_types: vec!["text".to_string()],
|
|
||||||
price_input_per_million: None,
|
|
||||||
price_output_per_million: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strict_definition_parses_and_hashes_stably() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("researcher.md");
|
|
||||||
std::fs::write(
|
|
||||||
&path,
|
|
||||||
concat!(
|
|
||||||
"---\n",
|
|
||||||
"id: researcher\n",
|
|
||||||
"description: Research primary sources\n",
|
|
||||||
"llm_profile: research\n",
|
|
||||||
"tools:\n",
|
|
||||||
" - calculator\n",
|
|
||||||
"limits:\n",
|
|
||||||
" max_iterations: 12\n",
|
|
||||||
"---\n",
|
|
||||||
"# Role\n\n",
|
|
||||||
"Return evidence and uncertainty.\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let first = parse_definition(&path, provider()).unwrap();
|
|
||||||
let second = parse_definition(&path, provider()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(first.id, "researcher");
|
|
||||||
assert_eq!(first.limits.max_iterations, 12);
|
|
||||||
assert_eq!(first.definition_hash, second.definition_hash);
|
|
||||||
assert_eq!(first.definition_hash.len(), 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_frontmatter_key_is_rejected() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("researcher.md");
|
|
||||||
std::fs::write(
|
|
||||||
&path,
|
|
||||||
"---\nid: researcher\ndescription: test\nllm_profile: research\nunsafe_tools: true\n---\n# Role\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(parse_definition(&path, provider()).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reserved_and_mismatched_ids_are_rejected() {
|
|
||||||
assert!(validate_agent_id("root").is_err());
|
|
||||||
assert!(validate_agent_id("Uppercase").is_err());
|
|
||||||
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("researcher.md");
|
|
||||||
std::fs::write(
|
|
||||||
&path,
|
|
||||||
"---\nid: reviewer\ndescription: test\nllm_profile: research\n---\n# Role\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(parse_definition(&path, provider()).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delegates_round_trip_preserves_all_forms() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let path = directory.path().join("researcher.md");
|
|
||||||
for (label, delegates) in [
|
|
||||||
("unset", None),
|
|
||||||
("empty", Some(Vec::<String>::new())),
|
|
||||||
("any", Some(vec!["*".to_string()])),
|
|
||||||
(
|
|
||||||
"list",
|
|
||||||
Some(vec!["coder".to_string(), "reviewer".to_string()]),
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
let info = AgentDefinitionInfo {
|
|
||||||
frontmatter: AgentFrontmatter {
|
|
||||||
id: "researcher".to_string(),
|
|
||||||
description: "test".to_string(),
|
|
||||||
llm_profile: None,
|
|
||||||
provider: Some("openai".to_string()),
|
|
||||||
model: Some("m".to_string()),
|
|
||||||
token_limit: None,
|
|
||||||
max_tool_iterations: None,
|
|
||||||
tools: Vec::new(),
|
|
||||||
enabled: true,
|
|
||||||
delegates: delegates.clone(),
|
|
||||||
skills: Vec::new(),
|
|
||||||
limits: AgentLimits::default(),
|
|
||||||
signal: None,
|
|
||||||
},
|
|
||||||
role_prompt: "# Role\n\nwork".to_string(),
|
|
||||||
};
|
|
||||||
std::fs::write(&path, serialize_definition(&info)).unwrap();
|
|
||||||
let parsed = parse_definition_info(&path).unwrap();
|
|
||||||
assert_eq!(parsed.frontmatter.delegates, delegates, "{label}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
use std::sync::{Arc, Weak};
|
|
||||||
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
/// Step permits are held while a provider request or tool invocation is in
|
|
||||||
/// flight and released as soon as the step finishes. Session permits are
|
|
||||||
/// released before global permits (reverse acquisition order). The fields
|
|
||||||
/// are never read; ownership alone keeps the quotas reserved (RAII).
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct StepPermit {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
session: Option<OwnedSemaphorePermit>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
global: OwnedSemaphorePermit,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run quota permits cover a whole Agent Run from admission until its
|
|
||||||
/// terminal commit. Foreground delegation does not take run permits; the
|
|
||||||
/// quota gates background admission so a waiting parent can never deadlock
|
|
||||||
/// nested foreground children.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct RunPermit {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
session: Option<OwnedSemaphorePermit>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
global: OwnedSemaphorePermit,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum GateError {
|
|
||||||
#[error("execution gate wait cancelled")]
|
|
||||||
Cancelled,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct KeyedSemaphores {
|
|
||||||
permits: usize,
|
|
||||||
map: DashMap<String, Weak<Semaphore>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyedSemaphores {
|
|
||||||
fn new(permits: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
permits,
|
|
||||||
map: DashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn semaphore(self: &Arc<Self>, key: &str) -> Arc<Semaphore> {
|
|
||||||
if let Some(existing) = self.map.get(key)
|
|
||||||
&& let Some(semaphore) = existing.upgrade()
|
|
||||||
{
|
|
||||||
return semaphore;
|
|
||||||
}
|
|
||||||
let semaphore = Arc::new(Semaphore::new(self.permits));
|
|
||||||
self.map.insert(key.to_string(), Arc::downgrade(&semaphore));
|
|
||||||
// Drop registry entries whose owners are gone so long-lived gateways
|
|
||||||
// do not accumulate one semaphore per historical session.
|
|
||||||
self.map.retain(|_, weak| weak.strong_count() > 0);
|
|
||||||
semaphore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Concurrency gates shared by one runtime generation. Run quota and
|
|
||||||
/// provider/tool step permits have independent lifecycles; acquisition order
|
|
||||||
/// is always global -> session and release order is reversed.
|
|
||||||
pub struct ExecutionGate {
|
|
||||||
max_concurrent_runs: usize,
|
|
||||||
run_global: Arc<Semaphore>,
|
|
||||||
run_session: Arc<KeyedSemaphores>,
|
|
||||||
provider_global: Arc<Semaphore>,
|
|
||||||
provider_session: Arc<KeyedSemaphores>,
|
|
||||||
tool_global: Arc<Semaphore>,
|
|
||||||
tool_session: Arc<KeyedSemaphores>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for ExecutionGate {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("ExecutionGate")
|
|
||||||
.field("run_available", &self.run_global.available_permits())
|
|
||||||
.field(
|
|
||||||
"provider_available",
|
|
||||||
&self.provider_global.available_permits(),
|
|
||||||
)
|
|
||||||
.field("tool_available", &self.tool_global.available_permits())
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExecutionGate {
|
|
||||||
pub fn new(config: &crate::config::AgentOrchestrationConfig) -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
max_concurrent_runs: config.max_concurrent_runs,
|
|
||||||
run_global: Arc::new(Semaphore::new(config.max_concurrent_runs)),
|
|
||||||
run_session: Arc::new(KeyedSemaphores::new(config.max_concurrent_runs_per_session)),
|
|
||||||
provider_global: Arc::new(Semaphore::new(config.max_concurrent_provider_steps)),
|
|
||||||
provider_session: Arc::new(KeyedSemaphores::new(
|
|
||||||
config.max_concurrent_provider_steps_per_session,
|
|
||||||
)),
|
|
||||||
tool_global: Arc::new(Semaphore::new(config.max_concurrent_tool_steps)),
|
|
||||||
tool_session: Arc::new(KeyedSemaphores::new(
|
|
||||||
config.max_concurrent_tool_steps_per_session,
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unlimited gate used when orchestration is disabled; root Turns still
|
|
||||||
/// route through it so the code path stays uniform.
|
|
||||||
pub fn unbounded() -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
max_concurrent_runs: usize::MAX,
|
|
||||||
run_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
|
||||||
run_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)),
|
|
||||||
provider_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
|
||||||
provider_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)),
|
|
||||||
tool_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
|
||||||
tool_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Global run quota ceiling; background batches larger than this are
|
|
||||||
/// rejected up front instead of queueing indefinitely.
|
|
||||||
pub fn max_concurrent_runs(&self) -> usize {
|
|
||||||
self.max_concurrent_runs
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn acquire_run(
|
|
||||||
self: &Arc<Self>,
|
|
||||||
session_id: &str,
|
|
||||||
cancellation: &CancellationToken,
|
|
||||||
) -> Result<RunPermit, GateError> {
|
|
||||||
let global = acquire_owned(self.run_global.clone(), cancellation).await?;
|
|
||||||
let session = acquire_keyed(&self.run_session, session_id, cancellation).await?;
|
|
||||||
Ok(RunPermit {
|
|
||||||
session: Some(session),
|
|
||||||
global,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn acquire_provider(
|
|
||||||
self: &Arc<Self>,
|
|
||||||
session_id: Option<&str>,
|
|
||||||
cancellation: &CancellationToken,
|
|
||||||
) -> Result<StepPermit, GateError> {
|
|
||||||
let global = acquire_owned(self.provider_global.clone(), cancellation).await?;
|
|
||||||
let session = match session_id {
|
|
||||||
Some(session_id) => {
|
|
||||||
Some(acquire_keyed(&self.provider_session, session_id, cancellation).await?)
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
Ok(StepPermit { session, global })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn acquire_tool(
|
|
||||||
self: &Arc<Self>,
|
|
||||||
session_id: Option<&str>,
|
|
||||||
cancellation: &CancellationToken,
|
|
||||||
) -> Result<StepPermit, GateError> {
|
|
||||||
let global = acquire_owned(self.tool_global.clone(), cancellation).await?;
|
|
||||||
let session = match session_id {
|
|
||||||
Some(session_id) => {
|
|
||||||
Some(acquire_keyed(&self.tool_session, session_id, cancellation).await?)
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
Ok(StepPermit { session, global })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn available_provider_permits(&self) -> usize {
|
|
||||||
self.provider_global.available_permits()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn available_tool_permits(&self) -> usize {
|
|
||||||
self.tool_global.available_permits()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn acquire_owned(
|
|
||||||
semaphore: Arc<Semaphore>,
|
|
||||||
cancellation: &CancellationToken,
|
|
||||||
) -> Result<OwnedSemaphorePermit, GateError> {
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = cancellation.cancelled() => Err(GateError::Cancelled),
|
|
||||||
result = semaphore.acquire_owned() => {
|
|
||||||
result.map_err(|_| GateError::Cancelled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn acquire_keyed(
|
|
||||||
keyed: &Arc<KeyedSemaphores>,
|
|
||||||
key: &str,
|
|
||||||
cancellation: &CancellationToken,
|
|
||||||
) -> Result<OwnedSemaphorePermit, GateError> {
|
|
||||||
acquire_owned(keyed.semaphore(key), cancellation).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test helper: wait until a predicate holds or fail after the timeout.
|
|
||||||
#[cfg(test)]
|
|
||||||
async fn eventually<F: Fn() -> bool>(predicate: F) {
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
||||||
while std::time::Instant::now() < deadline {
|
|
||||||
if predicate() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
panic!("condition did not hold within timeout");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::config::AgentOrchestrationConfig;
|
|
||||||
|
|
||||||
/// Global caps stay larger than session caps so a session-level waiter
|
|
||||||
/// can never exhaust the global permits while blocked (acquisition order
|
|
||||||
/// is global -> session by design).
|
|
||||||
fn gate(provider_session: usize, tool_session: usize) -> Arc<ExecutionGate> {
|
|
||||||
let config = AgentOrchestrationConfig {
|
|
||||||
max_concurrent_runs: 8,
|
|
||||||
max_concurrent_runs_per_session: 1,
|
|
||||||
max_concurrent_provider_steps: 8,
|
|
||||||
max_concurrent_provider_steps_per_session: provider_session,
|
|
||||||
max_concurrent_tool_steps: 8,
|
|
||||||
max_concurrent_tool_steps_per_session: tool_session,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
ExecutionGate::new(&config)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn provider_permits_are_released_after_drop() {
|
|
||||||
let gate = gate(1, 1);
|
|
||||||
let token = CancellationToken::new();
|
|
||||||
let permit = gate.acquire_provider(None, &token).await.unwrap();
|
|
||||||
assert_eq!(gate.available_provider_permits(), 7);
|
|
||||||
drop(permit);
|
|
||||||
assert_eq!(gate.available_provider_permits(), 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn session_quota_blocks_second_session_step_until_release() {
|
|
||||||
let gate = gate(1, 8);
|
|
||||||
let token = CancellationToken::new();
|
|
||||||
let first = gate
|
|
||||||
.acquire_provider(Some("cli:a:d1"), &token)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// Global capacity remains, but the same session is capped at one.
|
|
||||||
let blocked = tokio::spawn({
|
|
||||||
let gate = gate.clone();
|
|
||||||
let token = token.clone();
|
|
||||||
async move { gate.acquire_provider(Some("cli:a:d1"), &token).await }
|
|
||||||
});
|
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
||||||
assert!(!blocked.is_finished());
|
|
||||||
// A different session is unaffected.
|
|
||||||
let other = gate
|
|
||||||
.acquire_provider(Some("cli:b:d2"), &token)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
drop(other);
|
|
||||||
drop(first);
|
|
||||||
let second = blocked.await.unwrap().unwrap();
|
|
||||||
drop(second);
|
|
||||||
assert_eq!(gate.available_provider_permits(), 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cancellation_aborts_permit_wait() {
|
|
||||||
let gate = gate(8, 1);
|
|
||||||
let token = CancellationToken::new();
|
|
||||||
let held = gate.acquire_tool(Some("cli:a:d1"), &token).await.unwrap();
|
|
||||||
|
|
||||||
let waiter_token = CancellationToken::new();
|
|
||||||
let waiter = tokio::spawn({
|
|
||||||
let gate = gate.clone();
|
|
||||||
let waiter_token = waiter_token.clone();
|
|
||||||
async move { gate.acquire_tool(Some("cli:a:d1"), &waiter_token).await }
|
|
||||||
});
|
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
||||||
waiter_token.cancel();
|
|
||||||
let error = waiter.await.unwrap().unwrap_err();
|
|
||||||
assert!(matches!(error, GateError::Cancelled));
|
|
||||||
drop(held);
|
|
||||||
assert_eq!(gate.available_tool_permits(), 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn run_quota_is_scoped_per_session() {
|
|
||||||
let gate = gate(8, 8);
|
|
||||||
let token = CancellationToken::new();
|
|
||||||
let held = gate.acquire_run("cli:a:d1", &token).await.unwrap();
|
|
||||||
|
|
||||||
let blocked = tokio::spawn({
|
|
||||||
let gate = gate.clone();
|
|
||||||
let token = token.clone();
|
|
||||||
async move { gate.acquire_run("cli:a:d1", &token).await }
|
|
||||||
});
|
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
||||||
assert!(!blocked.is_finished());
|
|
||||||
|
|
||||||
let other = gate.acquire_run("cli:b:d2", &token).await.unwrap();
|
|
||||||
drop(other);
|
|
||||||
drop(held);
|
|
||||||
blocked.await.unwrap().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn keyed_semaphores_are_reclaimed_when_idle() {
|
|
||||||
let keyed = Arc::new(KeyedSemaphores::new(1));
|
|
||||||
let semaphore = keyed.semaphore("cli:a:d1");
|
|
||||||
assert_eq!(keyed.map.len(), 1);
|
|
||||||
drop(semaphore);
|
|
||||||
keyed.semaphore("cli:b:d2");
|
|
||||||
assert_eq!(keyed.map.len(), 1, "idle session entry must be reclaimed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn waiting_parent_holds_no_step_permits() {
|
|
||||||
// With a session cap of 1, a parent that merely waits for its child
|
|
||||||
// must leave the session's single provider permit free; otherwise
|
|
||||||
// nested foreground delegation would deadlock.
|
|
||||||
let gate = gate(1, 1);
|
|
||||||
let token = CancellationToken::new();
|
|
||||||
let parent_step = gate
|
|
||||||
.acquire_provider(Some("cli:a:d1"), &token)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
drop(parent_step);
|
|
||||||
// Parent is now in the waiting_children state: no permits held.
|
|
||||||
eventually(|| gate.available_provider_permits() == 8).await;
|
|
||||||
let child = gate
|
|
||||||
.acquire_provider(Some("cli:a:d1"), &token)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
drop(child);
|
|
||||||
assert_eq!(gate.available_provider_permits(), 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
use std::sync::{Arc, RwLock, Weak};
|
|
||||||
|
|
||||||
/// Wake contract implemented by the SessionManager. The notifier holds only
|
|
||||||
/// a weak reference so the coordinator can be dropped independently and no
|
|
||||||
/// release cycle is formed.
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
pub trait AgentInboxWakeTarget: Send + Sync {
|
|
||||||
async fn wake_agent_inbox(&self, session_id: &str, revision: i64);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Late-bound wake bus between the AgentCoordinator and Session workers.
|
|
||||||
/// Event commits never wait for a wake; the durable inbox is the source of
|
|
||||||
/// truth and a lost wake only delays the next claim until the periodic
|
|
||||||
/// re-check.
|
|
||||||
pub struct AgentInboxNotifier {
|
|
||||||
target: RwLock<Option<Weak<dyn AgentInboxWakeTarget + Send + Sync>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AgentInboxNotifier {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
target: RwLock::new(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentInboxNotifier {
|
|
||||||
pub fn new() -> Arc<Self> {
|
|
||||||
Arc::new(Self::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bind(&self, target: Weak<dyn AgentInboxWakeTarget + Send + Sync>) {
|
|
||||||
*self
|
|
||||||
.target
|
|
||||||
.write()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort wake. A missing or dead target is not an error; the
|
|
||||||
/// periodic scanner will claim the event later.
|
|
||||||
pub async fn notify(&self, session_id: &str, revision: i64) {
|
|
||||||
let target = self
|
|
||||||
.target
|
|
||||||
.read()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|weak| weak.upgrade());
|
|
||||||
if let Some(target) = target {
|
|
||||||
target.wake_agent_inbox(session_id, revision).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +1,15 @@
|
|||||||
pub mod agent_loop;
|
pub mod agent_loop;
|
||||||
pub mod builtin;
|
pub mod context_compressor;
|
||||||
pub mod catalog;
|
|
||||||
pub mod context_compaction;
|
|
||||||
pub mod coordinator;
|
|
||||||
pub mod definition;
|
|
||||||
pub mod gate;
|
|
||||||
pub mod inbox;
|
|
||||||
pub mod media_handler;
|
pub mod media_handler;
|
||||||
pub mod projection;
|
|
||||||
pub mod run;
|
|
||||||
pub mod steering;
|
|
||||||
pub mod sub_agent;
|
pub mod sub_agent;
|
||||||
pub mod system_prompt;
|
pub mod system_prompt;
|
||||||
pub mod turn_event;
|
pub mod turn_event;
|
||||||
|
|
||||||
pub use agent_loop::{AgentError, AgentLoop, AgentProcessResult};
|
pub use agent_loop::{AgentError, AgentLoop, AgentProcessResult};
|
||||||
pub use catalog::{AgentCatalog, AgentCatalogError, CatalogEntryError};
|
pub use context_compressor::{ContextCompressor, estimate_tokens};
|
||||||
pub use context_compaction::{
|
|
||||||
CompactionCandidate, CompactionReason, ContextBudget, ContextBudgetParts, ContextCompactor,
|
|
||||||
ContextRequestKey, ContextUsageTracker, PreviousCheckpoint, SequencedMessage,
|
|
||||||
context_request_digest, estimate_tokens,
|
|
||||||
};
|
|
||||||
pub use coordinator::{AgentCoordinator, CoordinatorError, ScheduledAgentExecution};
|
|
||||||
pub use definition::{AgentDefinition, AgentLimits};
|
|
||||||
pub use gate::ExecutionGate;
|
|
||||||
pub use inbox::{AgentInboxNotifier, AgentInboxWakeTarget};
|
|
||||||
pub use projection::AgentProjectionHub;
|
|
||||||
pub use run::{AgentBudget, AgentExecutionContext};
|
|
||||||
pub use steering::{SteeringDrain, SteeringPushError, TurnInput, TurnInputSource, TurnMailbox};
|
|
||||||
pub use sub_agent::{
|
pub use sub_agent::{
|
||||||
ExecutionMode, SubAgentConfig, SubAgentError, SubAgentManager, SubAgentResult, TaskStatus,
|
DelegateContext, ExecutionMode, SubAgentConfig, SubAgentError, SubAgentManager, SubAgentResult,
|
||||||
|
TaskNotification, TaskStatus,
|
||||||
};
|
};
|
||||||
pub use system_prompt::{
|
pub use system_prompt::{
|
||||||
PromptContext, PromptSection, SystemPromptBuilder, build_sub_agent_system_prompt,
|
PromptContext, PromptSection, SystemPromptBuilder, build_sub_agent_system_prompt,
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
//! Broadcast projection for durable Agent run/event updates.
|
|
||||||
//!
|
|
||||||
//! The hub follows the WorkManager plan-change broadcast pattern: the
|
|
||||||
//! Coordinator publishes a bounded view after each commit; the gateway
|
|
||||||
//! relays it to WebSocket clients. A lost broadcast is never fatal — the
|
|
||||||
//! client recalibrates with `GetAgentRuns`, whose `revision` comes from
|
|
||||||
//! `agent_session_state`.
|
|
||||||
|
|
||||||
use crate::protocol::{AgentEventView, AgentRunView};
|
|
||||||
|
|
||||||
/// One projected change for a root session. Exactly one of `run`/`event` is
|
|
||||||
/// present.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AgentProjection {
|
|
||||||
pub session_id: String,
|
|
||||||
pub revision: i64,
|
|
||||||
pub run: Option<AgentRunView>,
|
|
||||||
pub event: Option<AgentEventView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AgentProjectionHub {
|
|
||||||
tx: tokio::sync::broadcast::Sender<AgentProjection>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AgentProjectionHub {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentProjectionHub {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let (tx, _) = tokio::sync::broadcast::channel(256);
|
|
||||||
Self { tx }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<AgentProjection> {
|
|
||||||
self.tx.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Publish a run update. Best-effort: relays lag and drop the event,
|
|
||||||
/// clients recalibrate with a full query.
|
|
||||||
pub fn publish(&self, projection: AgentProjection) {
|
|
||||||
let _ = self.tx.send(projection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn publish_reaches_subscribers_and_drop_is_nonfatal() {
|
|
||||||
let hub = AgentProjectionHub::new();
|
|
||||||
let mut rx = hub.subscribe();
|
|
||||||
hub.publish(AgentProjection {
|
|
||||||
session_id: "cli:test:d".to_string(),
|
|
||||||
revision: 1,
|
|
||||||
run: None,
|
|
||||||
event: None,
|
|
||||||
});
|
|
||||||
let received = rx.try_recv().unwrap();
|
|
||||||
assert_eq!(received.session_id, "cli:test:d");
|
|
||||||
// No subscribers after the first is dropped; publish must not panic.
|
|
||||||
drop(rx);
|
|
||||||
hub.publish(AgentProjection {
|
|
||||||
session_id: "cli:test:d".to_string(),
|
|
||||||
revision: 2,
|
|
||||||
run: None,
|
|
||||||
event: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
149
src/agent/run.rs
149
src/agent/run.rs
@ -1,149 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AgentBudget {
|
|
||||||
pub remaining_runs: usize,
|
|
||||||
pub remaining_depth: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A signal this run emitted, preserved so the terminal completion payload
|
|
||||||
/// can reference the signal IDs for cross-checking.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct EmittedSignal {
|
|
||||||
pub signal_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AgentExecutionContext {
|
|
||||||
pub root_session_id: String,
|
|
||||||
pub root_turn_id: Option<String>,
|
|
||||||
pub run_id: String,
|
|
||||||
/// Execution attempt identifier owning conditional state transitions in
|
|
||||||
/// Storage. Equal to `run_id` for the first attempt.
|
|
||||||
pub execution_id: String,
|
|
||||||
pub parent_run_id: Option<String>,
|
|
||||||
pub caller_agent_id: String,
|
|
||||||
pub current_agent_id: String,
|
|
||||||
/// Agent IDs already present in this execution chain, including current.
|
|
||||||
pub ancestry: Vec<String>,
|
|
||||||
pub depth: u16,
|
|
||||||
pub plan_item_id: Option<String>,
|
|
||||||
pub cancellation: CancellationToken,
|
|
||||||
pub budget: AgentBudget,
|
|
||||||
/// Shared admission counter across the whole delegation tree, including
|
|
||||||
/// the root run that owns this context. Durable run accounting replaces
|
|
||||||
/// it once the Coordinator persists runs; until then it is the only
|
|
||||||
/// tree-wide enforcement of `max_runs_per_tree`.
|
|
||||||
pub tree_runs: Arc<AtomicUsize>,
|
|
||||||
/// Durable emit_signal contract; `None` means this run has no signal
|
|
||||||
/// capability and must not see the `emit_signal` tool.
|
|
||||||
pub signal_contract: Option<Arc<crate::agent::definition::SignalContract>>,
|
|
||||||
/// Signals accepted by this run so far, in emit order.
|
|
||||||
pub emitted_signals: Arc<Mutex<Vec<EmittedSignal>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentExecutionContext {
|
|
||||||
pub fn child(
|
|
||||||
parent: &Arc<Self>,
|
|
||||||
run_id: String,
|
|
||||||
target: String,
|
|
||||||
plan_item_id: Option<String>,
|
|
||||||
cancellation: CancellationToken,
|
|
||||||
) -> Self {
|
|
||||||
let mut ancestry = parent.ancestry.clone();
|
|
||||||
ancestry.push(target.clone());
|
|
||||||
Self {
|
|
||||||
root_session_id: parent.root_session_id.clone(),
|
|
||||||
root_turn_id: parent.root_turn_id.clone(),
|
|
||||||
run_id: run_id.clone(),
|
|
||||||
execution_id: run_id,
|
|
||||||
parent_run_id: Some(parent.run_id.clone()),
|
|
||||||
caller_agent_id: parent.current_agent_id.clone(),
|
|
||||||
current_agent_id: target,
|
|
||||||
ancestry,
|
|
||||||
depth: parent.depth.saturating_add(1),
|
|
||||||
plan_item_id,
|
|
||||||
cancellation,
|
|
||||||
budget: AgentBudget {
|
|
||||||
remaining_runs: parent.budget.remaining_runs.saturating_sub(1),
|
|
||||||
remaining_depth: parent.budget.remaining_depth.saturating_sub(1),
|
|
||||||
},
|
|
||||||
tree_runs: parent.tree_runs.clone(),
|
|
||||||
signal_contract: parent.signal_contract.clone(),
|
|
||||||
emitted_signals: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reserve one tree-run slot for a child that has already passed the
|
|
||||||
/// caller's budget checks. Returns the total number of runs in the tree
|
|
||||||
/// after reservation, or `None` (with the counter restored) when the
|
|
||||||
/// reservation would exceed `max_runs_per_tree`.
|
|
||||||
pub fn reserve_tree_run(&self, max_runs_per_tree: usize) -> Option<usize> {
|
|
||||||
let previous = self.tree_runs.fetch_add(1, Ordering::SeqCst);
|
|
||||||
let total = previous.saturating_add(1);
|
|
||||||
if total > max_runs_per_tree {
|
|
||||||
self.tree_runs.fetch_sub(1, Ordering::SeqCst);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remaining tree capacity for batch admission checks.
|
|
||||||
pub fn remaining_tree_runs(&self, max_runs_per_tree: usize) -> usize {
|
|
||||||
max_runs_per_tree.saturating_sub(self.tree_runs.load(Ordering::SeqCst))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn root_context() -> AgentExecutionContext {
|
|
||||||
AgentExecutionContext {
|
|
||||||
root_session_id: "cli:test:dialog".to_string(),
|
|
||||||
root_turn_id: None,
|
|
||||||
run_id: "run-root".to_string(),
|
|
||||||
execution_id: "run-root".to_string(),
|
|
||||||
parent_run_id: None,
|
|
||||||
caller_agent_id: "ROOT".to_string(),
|
|
||||||
current_agent_id: "researcher".to_string(),
|
|
||||||
ancestry: vec!["researcher".to_string()],
|
|
||||||
depth: 1,
|
|
||||||
plan_item_id: None,
|
|
||||||
cancellation: CancellationToken::new(),
|
|
||||||
budget: AgentBudget {
|
|
||||||
remaining_runs: 15,
|
|
||||||
remaining_depth: 3,
|
|
||||||
},
|
|
||||||
tree_runs: Arc::new(AtomicUsize::new(1)),
|
|
||||||
signal_contract: None,
|
|
||||||
emitted_signals: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_run_reservation_is_shared_and_bounded() {
|
|
||||||
let parent = Arc::new(root_context());
|
|
||||||
assert_eq!(parent.remaining_tree_runs(3), 2);
|
|
||||||
|
|
||||||
assert_eq!(parent.reserve_tree_run(3), Some(2));
|
|
||||||
assert_eq!(parent.reserve_tree_run(3), Some(3));
|
|
||||||
assert_eq!(parent.reserve_tree_run(3), None);
|
|
||||||
assert_eq!(parent.tree_runs.load(Ordering::SeqCst), 3);
|
|
||||||
assert_eq!(parent.remaining_tree_runs(3), 0);
|
|
||||||
|
|
||||||
let child = AgentExecutionContext::child(
|
|
||||||
&parent,
|
|
||||||
"run-child".to_string(),
|
|
||||||
"reviewer".to_string(),
|
|
||||||
None,
|
|
||||||
CancellationToken::new(),
|
|
||||||
);
|
|
||||||
assert!(Arc::ptr_eq(&parent.tree_runs, &child.tree_runs));
|
|
||||||
assert_eq!(child.remaining_tree_runs(3), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,814 +0,0 @@
|
|||||||
//! Bounded, session-owned mailbox for same-turn steering input.
|
|
||||||
//!
|
|
||||||
//! A mailbox is intentionally separate from the session work queue. The
|
|
||||||
//! gateway can accept a normal user message or a durable Agent steer event
|
|
||||||
//! while a turn is running and place it here;
|
|
||||||
//! [`AgentLoop`](super::AgentLoop) drains it only at safe model boundaries
|
|
||||||
//! (after a complete tool batch, or before deciding that a response is
|
|
||||||
//! final). The state transition performed by
|
|
||||||
//! [`TurnMailbox::drain_or_close`] is atomic with respect to producers,
|
|
||||||
//! which means an input is either accepted by the active turn or rejected so
|
|
||||||
//! the caller can put it on the next-turn queue -- never both and never
|
|
||||||
//! neither.
|
|
||||||
//!
|
|
||||||
//! Durable steer events use a two-phase admission: the session first
|
|
||||||
//! reserves an agent-lane entry, persists the `leased → admitted` transition
|
|
||||||
//! (with the turn id), and only then activates the reservation into the
|
|
||||||
//! drainable queue. Reservations and drained-but-uncommitted entries carry
|
|
||||||
//! their storage lease token so an abandoned turn can return every admitted
|
|
||||||
//! event to `pending` instead of losing it.
|
|
||||||
|
|
||||||
use crate::bus::{ChatMessage, MediaRef, MessageSource};
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
/// Default maximum number of user steering messages accepted by one active
|
|
||||||
/// turn.
|
|
||||||
pub const DEFAULT_MAX_USER_STEERING_MESSAGES: usize = 32;
|
|
||||||
/// Default aggregate UTF-8 byte budget for pending user steering messages.
|
|
||||||
pub const DEFAULT_MAX_USER_STEERING_BYTES: usize = 64 * 1024;
|
|
||||||
/// Default maximum number of Agent steer events accepted by one active turn.
|
|
||||||
pub const DEFAULT_MAX_AGENT_STEERING_MESSAGES: usize = 8;
|
|
||||||
/// Default aggregate UTF-8 byte budget for pending Agent steer events.
|
|
||||||
pub const DEFAULT_MAX_AGENT_STEERING_BYTES: usize = 32 * 1024;
|
|
||||||
|
|
||||||
/// Origin of one mailbox input.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum TurnInputSource {
|
|
||||||
User,
|
|
||||||
AgentSignal { run_id: String, agent_id: String },
|
|
||||||
AgentCompletion { run_id: String, agent_id: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TurnInputSource {
|
|
||||||
pub fn is_agent(&self) -> bool {
|
|
||||||
!matches!(self, Self::User)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How the input reached the mailbox. Queue inputs belong to the next Turn;
|
|
||||||
/// only Steer entries are drained by the active Turn.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum InputDelivery {
|
|
||||||
Queue,
|
|
||||||
Steer,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One steering input retained by the active Turn.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TurnInput {
|
|
||||||
pub id: String,
|
|
||||||
pub sequence: u64,
|
|
||||||
pub source: TurnInputSource,
|
|
||||||
pub delivery: InputDelivery,
|
|
||||||
pub content: String,
|
|
||||||
pub media_refs: Vec<MediaRef>,
|
|
||||||
/// Durable inbox event id; `Some` only for Agent steer events.
|
|
||||||
pub durable_event_id: Option<String>,
|
|
||||||
pub received_at: i64,
|
|
||||||
/// Channel attribution for user inputs, preserved through the turn.
|
|
||||||
pub message_source: Option<MessageSource>,
|
|
||||||
/// Storage lease token of a durable Agent event; the mailbox keeps it so
|
|
||||||
/// an abandoned turn can release the event back to `pending`.
|
|
||||||
pub(crate) lease_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TurnInput {
|
|
||||||
pub fn user(
|
|
||||||
id: impl Into<String>,
|
|
||||||
content: impl Into<String>,
|
|
||||||
media_refs: Vec<MediaRef>,
|
|
||||||
message_source: Option<MessageSource>,
|
|
||||||
received_at: i64,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id: id.into(),
|
|
||||||
sequence: 0,
|
|
||||||
source: TurnInputSource::User,
|
|
||||||
delivery: InputDelivery::Steer,
|
|
||||||
content: content.into(),
|
|
||||||
media_refs,
|
|
||||||
durable_event_id: None,
|
|
||||||
received_at,
|
|
||||||
message_source,
|
|
||||||
lease_token: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Project this input into a provider-compatible user message. Agent
|
|
||||||
/// inputs stay hidden from client history (the Signal UI comes from the
|
|
||||||
/// durable event projection) but keep their typed source for rendering
|
|
||||||
/// and cancellation recovery.
|
|
||||||
pub fn into_chat_message(self, turn_id: String, iteration: u32) -> ChatMessage {
|
|
||||||
let (client_visibility, source) = match &self.source {
|
|
||||||
TurnInputSource::User => {
|
|
||||||
let visibility = crate::bus::ClientVisibility::Visible;
|
|
||||||
let source = self.message_source.clone();
|
|
||||||
(visibility, source)
|
|
||||||
}
|
|
||||||
TurnInputSource::AgentSignal { run_id, agent_id } => {
|
|
||||||
let source = MessageSource {
|
|
||||||
kind: crate::bus::SourceKind::AgentSignal,
|
|
||||||
from_channel: None,
|
|
||||||
from_session: None,
|
|
||||||
from_user_id: None,
|
|
||||||
system_name: None,
|
|
||||||
task_id: self.durable_event_id.clone(),
|
|
||||||
from_run_id: Some(run_id.clone()),
|
|
||||||
from_agent_id: Some(agent_id.clone()),
|
|
||||||
};
|
|
||||||
(crate::bus::ClientVisibility::Hidden, Some(source))
|
|
||||||
}
|
|
||||||
TurnInputSource::AgentCompletion { run_id, agent_id } => {
|
|
||||||
let source = MessageSource {
|
|
||||||
kind: crate::bus::SourceKind::AgentCompletion,
|
|
||||||
from_channel: None,
|
|
||||||
from_session: None,
|
|
||||||
from_user_id: None,
|
|
||||||
system_name: None,
|
|
||||||
task_id: self.durable_event_id.clone(),
|
|
||||||
from_run_id: Some(run_id.clone()),
|
|
||||||
from_agent_id: Some(agent_id.clone()),
|
|
||||||
};
|
|
||||||
(crate::bus::ClientVisibility::Hidden, Some(source))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut message = ChatMessage::user(self.content);
|
|
||||||
message.id = self.id;
|
|
||||||
message.turn_id = Some(turn_id);
|
|
||||||
message.iteration = Some(iteration);
|
|
||||||
message.media_refs = self.media_refs;
|
|
||||||
message.timestamp = self.received_at;
|
|
||||||
message.client_visibility = client_visibility;
|
|
||||||
message.source = source;
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum MailboxPhase {
|
|
||||||
Accepting,
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct MailboxState {
|
|
||||||
phase: MailboxPhase,
|
|
||||||
pending: VecDeque<TurnInput>,
|
|
||||||
reserved: VecDeque<TurnInput>,
|
|
||||||
/// Drained at a safe boundary but not yet committed to durable history.
|
|
||||||
/// Keeping their count/size reserved prevents concurrent producers from
|
|
||||||
/// filling the capacity that an error retry may need to restore.
|
|
||||||
in_flight: VecDeque<TurnInput>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MailboxState {
|
|
||||||
fn user_lane_used(&self) -> (usize, usize) {
|
|
||||||
let mut count = 0usize;
|
|
||||||
let mut bytes = 0usize;
|
|
||||||
for input in self.pending.iter().chain(self.in_flight.iter()) {
|
|
||||||
if !input.source.is_agent() {
|
|
||||||
count = count.saturating_add(1);
|
|
||||||
bytes = bytes.saturating_add(input_size_bytes(input));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(count, bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent_lane_used(&self) -> (usize, usize) {
|
|
||||||
let mut count = 0usize;
|
|
||||||
let mut bytes = 0usize;
|
|
||||||
for input in self
|
|
||||||
.pending
|
|
||||||
.iter()
|
|
||||||
.chain(self.in_flight.iter())
|
|
||||||
.chain(self.reserved.iter())
|
|
||||||
{
|
|
||||||
if input.source.is_agent() {
|
|
||||||
count = count.saturating_add(1);
|
|
||||||
bytes = bytes.saturating_add(input_size_bytes(input));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(count, bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error returned when the active turn cannot accept a steering input.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum SteeringPushError {
|
|
||||||
/// The turn has reached a terminal boundary. Route the input to the
|
|
||||||
/// session's ordinary queue (or, for durable events, keep it pending).
|
|
||||||
Closed,
|
|
||||||
/// The mailbox is accepting input, but its bounded lane capacity is
|
|
||||||
/// exhausted. Route the input to the ordinary queue.
|
|
||||||
Full,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of the atomic final-response boundary operation.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum SteeringDrain {
|
|
||||||
/// One or more inputs were accepted and removed from the mailbox. The
|
|
||||||
/// mailbox remains open for a subsequent safe boundary.
|
|
||||||
Messages(Vec<TurnInput>),
|
|
||||||
/// No pending input existed. The mailbox is now closed; later producers
|
|
||||||
/// receive [`SteeringPushError::Closed`].
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What an abandoned turn returns for requeue/release handling.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct MailboxTake {
|
|
||||||
/// Pending non-durable user inputs.
|
|
||||||
pub user_inputs: Vec<TurnInput>,
|
|
||||||
/// Durable Agent entries activated and drained or still pending:
|
|
||||||
/// `(event_id, lease_token)` of `admitted` events.
|
|
||||||
pub admitted_leases: Vec<(String, String)>,
|
|
||||||
/// Reserved-but-not-activated Agent entries: `(event_id, lease_token)`
|
|
||||||
/// of `leased` (or admitted) events.
|
|
||||||
pub reserved_leases: Vec<(String, String)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MailboxTake {
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.user_inputs.is_empty()
|
|
||||||
&& self.admitted_leases.is_empty()
|
|
||||||
&& self.reserved_leases.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.user_inputs.len() + self.admitted_leases.len() + self.reserved_leases.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared state for same-turn steering during one active AgentLoop
|
|
||||||
/// execution.
|
|
||||||
///
|
|
||||||
/// Cloning a mailbox is cheap and shares the same mutex-protected state. In
|
|
||||||
/// practice the session stores an `Arc<TurnMailbox>` in its active-turn
|
|
||||||
/// handle and gives another clone to [`AgentTurnContext`](super::AgentTurnContext).
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TurnMailbox {
|
|
||||||
state: Arc<Mutex<MailboxState>>,
|
|
||||||
max_user_messages: usize,
|
|
||||||
max_user_bytes: usize,
|
|
||||||
max_agent_messages: usize,
|
|
||||||
max_agent_bytes: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for TurnMailbox {
|
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
let state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
formatter
|
|
||||||
.debug_struct("TurnMailbox")
|
|
||||||
.field("phase", &state.phase)
|
|
||||||
.field("pending", &state.pending.len())
|
|
||||||
.field("reserved", &state.reserved.len())
|
|
||||||
.field("in_flight", &state.in_flight.len())
|
|
||||||
.field("max_user_messages", &self.max_user_messages)
|
|
||||||
.field("max_agent_messages", &self.max_agent_messages)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TurnMailbox {
|
|
||||||
/// Construct a mailbox using the product defaults (32 user/8 agent).
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::with_limits(
|
|
||||||
DEFAULT_MAX_USER_STEERING_MESSAGES,
|
|
||||||
DEFAULT_MAX_USER_STEERING_BYTES,
|
|
||||||
DEFAULT_MAX_AGENT_STEERING_MESSAGES,
|
|
||||||
DEFAULT_MAX_AGENT_STEERING_BYTES,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construct a mailbox with explicit bounded capacities. Zero limits
|
|
||||||
/// are allowed and make every push return [`SteeringPushError::Full`].
|
|
||||||
pub fn with_limits(
|
|
||||||
max_user_messages: usize,
|
|
||||||
max_user_bytes: usize,
|
|
||||||
max_agent_messages: usize,
|
|
||||||
max_agent_bytes: usize,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
state: Arc::new(Mutex::new(MailboxState {
|
|
||||||
phase: MailboxPhase::Accepting,
|
|
||||||
pending: VecDeque::new(),
|
|
||||||
reserved: VecDeque::new(),
|
|
||||||
in_flight: VecDeque::new(),
|
|
||||||
})),
|
|
||||||
max_user_messages,
|
|
||||||
max_user_bytes,
|
|
||||||
max_agent_messages,
|
|
||||||
max_agent_bytes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return an `Arc` suitable for storing in Session and AgentTurnContext.
|
|
||||||
pub fn new_shared() -> Arc<Self> {
|
|
||||||
Arc::new(Self::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to accept one real user steering input.
|
|
||||||
///
|
|
||||||
/// This operation and the final close operation use the same mutex. A
|
|
||||||
/// producer racing with `drain_or_close` therefore receives a
|
|
||||||
/// deterministic result and can route a rejected input to the ordinary
|
|
||||||
/// queue.
|
|
||||||
pub fn try_push_user(&self, input: TurnInput) -> Result<(), SteeringPushError> {
|
|
||||||
debug_assert!(!input.source.is_agent());
|
|
||||||
let input_bytes = input_size_bytes(&input);
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
if state.phase == MailboxPhase::Closed {
|
|
||||||
return Err(SteeringPushError::Closed);
|
|
||||||
}
|
|
||||||
let (count, bytes) = state.user_lane_used();
|
|
||||||
if count >= self.max_user_messages
|
|
||||||
|| bytes.saturating_add(input_bytes) > self.max_user_bytes
|
|
||||||
{
|
|
||||||
return Err(SteeringPushError::Full);
|
|
||||||
}
|
|
||||||
state.pending.push_back(input);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reserve an Agent steer event for the active Turn (two-phase admission,
|
|
||||||
/// step 2). The entry is not drainable until
|
|
||||||
/// [`activate_reserved`](Self::activate_reserved) succeeds for the same
|
|
||||||
/// Turn. `lease_token` is the storage token of the `leased` event.
|
|
||||||
pub fn try_reserve_steer(
|
|
||||||
&self,
|
|
||||||
input: TurnInput,
|
|
||||||
lease_token: String,
|
|
||||||
) -> Result<(), SteeringPushError> {
|
|
||||||
debug_assert!(input.source.is_agent());
|
|
||||||
let input_bytes = input_size_bytes(&input);
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
if state.phase == MailboxPhase::Closed {
|
|
||||||
return Err(SteeringPushError::Closed);
|
|
||||||
}
|
|
||||||
let (count, bytes) = state.agent_lane_used();
|
|
||||||
if count >= self.max_agent_messages
|
|
||||||
|| bytes.saturating_add(input_bytes) > self.max_agent_bytes
|
|
||||||
{
|
|
||||||
return Err(SteeringPushError::Full);
|
|
||||||
}
|
|
||||||
let mut input = input;
|
|
||||||
input.lease_token = Some(lease_token);
|
|
||||||
state.reserved.push_back(input);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Activate all reservations into the drainable queue. Returns the
|
|
||||||
/// number activated. Callers invoke this only after the durable
|
|
||||||
/// `leased → admitted` transition succeeded for the same Turn.
|
|
||||||
pub fn activate_reserved(&self) -> usize {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
let count = state.reserved.len();
|
|
||||||
let reserved: Vec<_> = state.reserved.drain(..).collect();
|
|
||||||
state.pending.extend(reserved);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove all reservations without activating them. Returns
|
|
||||||
/// `(event_id, lease_token)` pairs so the caller can release the
|
|
||||||
/// still-leased events back to `pending`.
|
|
||||||
pub fn cancel_reserved(&self) -> Vec<(String, String)> {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
state
|
|
||||||
.reserved
|
|
||||||
.drain(..)
|
|
||||||
.filter_map(|input| {
|
|
||||||
let token = input.lease_token.clone()?;
|
|
||||||
Some((input.durable_event_id.unwrap_or_default(), token))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain currently pending inputs while leaving the mailbox open.
|
|
||||||
///
|
|
||||||
/// This is used after a complete tool-call batch. It intentionally does
|
|
||||||
/// not close the mailbox: another input may steer a later iteration.
|
|
||||||
pub fn drain(&self) -> Vec<TurnInput> {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
drain_pending_locked(&mut state)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically drain pending inputs, or close the mailbox if it is empty.
|
|
||||||
pub fn drain_or_close(&self) -> SteeringDrain {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
if state.pending.is_empty() {
|
|
||||||
state.phase = MailboxPhase::Closed;
|
|
||||||
SteeringDrain::Closed
|
|
||||||
} else {
|
|
||||||
SteeringDrain::Messages(drain_pending_locked(&mut state))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close acceptance without dropping pending messages. Session uses
|
|
||||||
/// [`take_pending`](Self::take_pending) after AgentLoop returns to move
|
|
||||||
/// those messages to the ordinary next-turn queue (for example when the
|
|
||||||
/// maximum iteration budget is exhausted).
|
|
||||||
pub fn close(&self) {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
state.phase = MailboxPhase::Closed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close acceptance and collect everything for requeue/release handling.
|
|
||||||
/// Any in-flight batch is returned through `admitted_leases`; `/stop`
|
|
||||||
/// uses this method to preserve its queue-clearing semantics for user
|
|
||||||
/// input while still releasing durable events back to `pending`.
|
|
||||||
pub fn close_and_take_pending(&self) -> MailboxTake {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
state.phase = MailboxPhase::Closed;
|
|
||||||
take_all_locked(&mut state)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take pending inputs without changing whether producers may still push.
|
|
||||||
///
|
|
||||||
/// Normally used after `close()`; keeping this method explicit makes it
|
|
||||||
/// possible for Session to transfer accepted-but-unprocessed input to its
|
|
||||||
/// FIFO queue without opening a race with a new turn.
|
|
||||||
pub fn take_pending(&self) -> MailboxTake {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
take_all_locked(&mut state)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore all inputs drained by AgentLoop since the last commit. This
|
|
||||||
/// is useful when the AgentLoop completed but Session's durable write
|
|
||||||
/// then failed: the next retry/queue operation can replay the exact
|
|
||||||
/// accepted inputs instead of silently losing them.
|
|
||||||
pub fn restore_drained(&self) {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
restore_in_flight_locked(&mut state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore inputs drained by AgentLoop when a provider/tool error makes
|
|
||||||
/// the current invocation retry from persisted history. The inputs are
|
|
||||||
/// prepended in their original order and their reserved capacity is
|
|
||||||
/// released. `drain()`/`drain_or_close()` reserve capacity while a batch
|
|
||||||
/// is in-flight, so this operation cannot overflow a bounded mailbox due
|
|
||||||
/// to a racing producer.
|
|
||||||
pub fn restore_front(&self, inputs: Vec<TurnInput>) {
|
|
||||||
if inputs.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
for _ in 0..inputs.len() {
|
|
||||||
state.in_flight.pop_back();
|
|
||||||
}
|
|
||||||
for input in inputs.into_iter().rev() {
|
|
||||||
state.pending.push_front(input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark all previously drained inputs as durably committed. Session
|
|
||||||
/// calls this only after the complete Turn persistence transaction
|
|
||||||
/// succeeds. It is a no-op when no steering batch was consumed.
|
|
||||||
pub fn commit_drained(&self) {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
state.in_flight.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Durable event ids currently drained into this Turn but not yet
|
|
||||||
/// committed. Session consumes them atomically with the Turn commit.
|
|
||||||
pub fn durable_in_flight_ids(&self) -> Vec<String> {
|
|
||||||
let state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
state
|
|
||||||
.in_flight
|
|
||||||
.iter()
|
|
||||||
.filter_map(|input| input.durable_event_id.clone())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_closed(&self) -> bool {
|
|
||||||
let state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
state.phase == MailboxPhase::Closed
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
let state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
state.pending.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.len() == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn max_user_messages(&self) -> usize {
|
|
||||||
self.max_user_messages
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn max_agent_messages(&self) -> usize {
|
|
||||||
self.max_agent_messages
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Currently pending user steer entries (wake-state hint).
|
|
||||||
pub fn user_pending_count(&self) -> usize {
|
|
||||||
let state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
state
|
|
||||||
.pending
|
|
||||||
.iter()
|
|
||||||
.filter(|input| !input.source.is_agent())
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Currently pending agent steer entries, including reservations
|
|
||||||
/// (wake-state hint).
|
|
||||||
pub fn agent_pending_count(&self) -> usize {
|
|
||||||
let state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
state
|
|
||||||
.pending
|
|
||||||
.iter()
|
|
||||||
.chain(state.reserved.iter())
|
|
||||||
.filter(|input| input.source.is_agent())
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TurnMailbox {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Approximate the bounded payload size without serializing the complete
|
|
||||||
/// input. Content, media paths/types and source fields are all untrusted
|
|
||||||
/// input; counting their UTF-8 bytes gives a conservative enough guard while
|
|
||||||
/// retaining the original input losslessly.
|
|
||||||
fn input_size_bytes(input: &TurnInput) -> usize {
|
|
||||||
let mut bytes = input.id.len()
|
|
||||||
+ input.content.len()
|
|
||||||
+ input.durable_event_id.as_deref().map_or(0, str::len)
|
|
||||||
+ input.lease_token.as_deref().map_or(0, str::len);
|
|
||||||
for media in &input.media_refs {
|
|
||||||
bytes = bytes.saturating_add(media.path.len() + media.media_type.len());
|
|
||||||
}
|
|
||||||
if let Some(source) = input.message_source.as_ref() {
|
|
||||||
bytes = bytes
|
|
||||||
.saturating_add(source.from_channel.as_deref().map_or(0, str::len))
|
|
||||||
.saturating_add(source.from_user_id.as_deref().map_or(0, str::len));
|
|
||||||
}
|
|
||||||
bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_pending_locked(state: &mut MailboxState) -> Vec<TurnInput> {
|
|
||||||
let inputs: Vec<_> = state.pending.drain(..).collect();
|
|
||||||
state.in_flight.extend(inputs.iter().cloned());
|
|
||||||
inputs
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take_all_locked(state: &mut MailboxState) -> MailboxTake {
|
|
||||||
let mut take = MailboxTake::default();
|
|
||||||
for input in state.pending.drain(..).chain(state.in_flight.drain(..)) {
|
|
||||||
if input.source.is_agent() {
|
|
||||||
if let Some(token) = input.lease_token.clone()
|
|
||||||
&& let Some(event_id) = input.durable_event_id.clone()
|
|
||||||
{
|
|
||||||
take.admitted_leases.push((event_id, token));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
take.user_inputs.push(input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for input in state.reserved.drain(..) {
|
|
||||||
if let Some(token) = input.lease_token.clone()
|
|
||||||
&& let Some(event_id) = input.durable_event_id.clone()
|
|
||||||
{
|
|
||||||
take.reserved_leases.push((event_id, token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
take
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restore_in_flight_locked(state: &mut MailboxState) {
|
|
||||||
if state.in_flight.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let inputs: Vec<_> = state.in_flight.drain(..).collect();
|
|
||||||
for input in inputs.into_iter().rev() {
|
|
||||||
state.pending.push_front(input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
fn user_input(id: &str, content: &str) -> TurnInput {
|
|
||||||
TurnInput::user(id, content, Vec::new(), None, 1_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent_signal_input(id: &str, event_id: &str) -> TurnInput {
|
|
||||||
TurnInput {
|
|
||||||
id: id.to_string(),
|
|
||||||
sequence: 0,
|
|
||||||
source: TurnInputSource::AgentSignal {
|
|
||||||
run_id: "run-1".to_string(),
|
|
||||||
agent_id: "researcher".to_string(),
|
|
||||||
},
|
|
||||||
delivery: InputDelivery::Steer,
|
|
||||||
content: "signal summary".to_string(),
|
|
||||||
media_refs: Vec::new(),
|
|
||||||
durable_event_id: Some(event_id.to_string()),
|
|
||||||
received_at: 1_000,
|
|
||||||
message_source: None,
|
|
||||||
lease_token: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_fifo_inputs_and_clone_shares_state() {
|
|
||||||
let mailbox = TurnMailbox::with_limits(2, 100, 8, 100);
|
|
||||||
let clone = mailbox.clone();
|
|
||||||
mailbox.try_push_user(user_input("a", "one")).unwrap();
|
|
||||||
clone.try_push_user(user_input("b", "two")).unwrap();
|
|
||||||
assert_eq!(mailbox.len(), 2);
|
|
||||||
let inputs = mailbox.drain();
|
|
||||||
assert_eq!(inputs.len(), 2);
|
|
||||||
assert!(!mailbox.is_closed());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_and_agent_lanes_have_independent_capacity() {
|
|
||||||
let mailbox = TurnMailbox::with_limits(1, 100_000, 1, 100_000);
|
|
||||||
mailbox.try_push_user(user_input("a", "one")).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
mailbox.try_push_user(user_input("b", "two")),
|
|
||||||
Err(SteeringPushError::Full)
|
|
||||||
));
|
|
||||||
mailbox
|
|
||||||
.try_reserve_steer(agent_signal_input("s1", "evt-1"), "token-1".to_string())
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
mailbox.try_reserve_steer(agent_signal_input("s2", "evt-2"), "token-2".to_string()),
|
|
||||||
Err(SteeringPushError::Full)
|
|
||||||
));
|
|
||||||
assert_eq!(mailbox.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reserved_entries_are_not_drainable_until_activation() {
|
|
||||||
let mailbox = TurnMailbox::new();
|
|
||||||
mailbox
|
|
||||||
.try_reserve_steer(agent_signal_input("s1", "evt-1"), "token-1".to_string())
|
|
||||||
.unwrap();
|
|
||||||
assert!(mailbox.drain().is_empty());
|
|
||||||
assert_eq!(mailbox.activate_reserved(), 1);
|
|
||||||
assert_eq!(mailbox.drain().len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cancel_reserved_returns_leases() {
|
|
||||||
let mailbox = TurnMailbox::new();
|
|
||||||
mailbox
|
|
||||||
.try_reserve_steer(agent_signal_input("s1", "evt-1"), "token-1".to_string())
|
|
||||||
.unwrap();
|
|
||||||
let leases = mailbox.cancel_reserved();
|
|
||||||
assert_eq!(leases, vec![("evt-1".to_string(), "token-1".to_string())]);
|
|
||||||
assert!(mailbox.drain().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_take_releases_admitted_and_reserved_durable_events() {
|
|
||||||
let mailbox = TurnMailbox::new();
|
|
||||||
mailbox
|
|
||||||
.try_reserve_steer(agent_signal_input("s1", "evt-1"), "token-1".to_string())
|
|
||||||
.unwrap();
|
|
||||||
mailbox.activate_reserved();
|
|
||||||
mailbox
|
|
||||||
.try_reserve_steer(agent_signal_input("s2", "evt-2"), "token-2".to_string())
|
|
||||||
.unwrap();
|
|
||||||
let drained = mailbox.drain();
|
|
||||||
assert_eq!(drained.len(), 1);
|
|
||||||
mailbox.try_push_user(user_input("u", "hello")).unwrap();
|
|
||||||
|
|
||||||
let take = mailbox.close_and_take_pending();
|
|
||||||
// evt-1 was drained (admitted), evt-2 still reserved (leased), the
|
|
||||||
// user input is returned separately.
|
|
||||||
assert_eq!(take.user_inputs.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
take.admitted_leases,
|
|
||||||
vec![("evt-1".to_string(), "token-1".to_string())]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
take.reserved_leases,
|
|
||||||
vec![("evt-2".to_string(), "token-2".to_string())]
|
|
||||||
);
|
|
||||||
assert!(mailbox.try_push_user(user_input("late", "x")).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drained_capacity_is_reserved_until_commit_or_restore() {
|
|
||||||
let mailbox = TurnMailbox::with_limits(1, 100_000, 8, 100_000);
|
|
||||||
mailbox.try_push_user(user_input("a", "first")).unwrap();
|
|
||||||
let drained = mailbox.drain();
|
|
||||||
assert_eq!(drained.len(), 1);
|
|
||||||
assert!(matches!(
|
|
||||||
mailbox.try_push_user(user_input("b", "second")),
|
|
||||||
Err(SteeringPushError::Full)
|
|
||||||
));
|
|
||||||
mailbox.restore_front(drained);
|
|
||||||
let take = mailbox.take_pending();
|
|
||||||
assert_eq!(take.user_inputs[0].content, "first");
|
|
||||||
|
|
||||||
mailbox.try_push_user(user_input("c", "committed")).unwrap();
|
|
||||||
let _ = mailbox.drain();
|
|
||||||
mailbox.commit_drained();
|
|
||||||
mailbox
|
|
||||||
.try_push_user(user_input("d", "after commit"))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let drained = mailbox.drain();
|
|
||||||
assert_eq!(drained[0].content, "after commit");
|
|
||||||
mailbox.restore_drained();
|
|
||||||
let take = mailbox.take_pending();
|
|
||||||
assert_eq!(take.user_inputs[0].content, "after commit");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drain_or_close_is_atomic_and_preserves_close_race_semantics() {
|
|
||||||
let mailbox = Arc::new(TurnMailbox::new());
|
|
||||||
let producer = mailbox.clone();
|
|
||||||
let close_result = thread::spawn(move || producer.drain_or_close())
|
|
||||||
.join()
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(close_result, SteeringDrain::Closed));
|
|
||||||
assert!(matches!(
|
|
||||||
mailbox.try_push_user(user_input("late", "x")),
|
|
||||||
Err(SteeringPushError::Closed)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn turn_input_projects_to_hidden_agent_message() {
|
|
||||||
let input = agent_signal_input("id", "evt-1");
|
|
||||||
let message = input.into_chat_message("turn-1".to_string(), 2);
|
|
||||||
assert_eq!(message.role, "user");
|
|
||||||
assert_eq!(message.turn_id.as_deref(), Some("turn-1"));
|
|
||||||
assert_eq!(message.iteration, Some(2));
|
|
||||||
assert_eq!(
|
|
||||||
message.client_visibility,
|
|
||||||
crate::bus::ClientVisibility::Hidden
|
|
||||||
);
|
|
||||||
let source = message.source.unwrap();
|
|
||||||
assert!(matches!(source.kind, crate::bus::SourceKind::AgentSignal));
|
|
||||||
assert_eq!(source.from_run_id.as_deref(), Some("run-1"));
|
|
||||||
assert_eq!(source.task_id.as_deref(), Some("evt-1"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -57,6 +57,7 @@ impl SystemPromptBuilder {
|
|||||||
task: &str,
|
task: &str,
|
||||||
timeout: &str,
|
timeout: &str,
|
||||||
skills_prompt: Option<String>,
|
skills_prompt: Option<String>,
|
||||||
|
http_get_only: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut sections: Vec<Box<dyn PromptSection>> = vec![
|
let mut sections: Vec<Box<dyn PromptSection>> = vec![
|
||||||
Box::new(SubAgentIdentitySection {
|
Box::new(SubAgentIdentitySection {
|
||||||
@ -65,7 +66,7 @@ impl SystemPromptBuilder {
|
|||||||
}),
|
}),
|
||||||
Box::new(ToolHonestySection),
|
Box::new(ToolHonestySection),
|
||||||
Box::new(SafetySection),
|
Box::new(SafetySection),
|
||||||
Box::new(SubAgentToolsSection),
|
Box::new(SubAgentToolsSection { http_get_only }),
|
||||||
Box::new(WorkspaceSection),
|
Box::new(WorkspaceSection),
|
||||||
];
|
];
|
||||||
if let Some(sp) = skills_prompt {
|
if let Some(sp) = skills_prompt {
|
||||||
@ -347,10 +348,10 @@ impl PromptSection for DelegationSection {
|
|||||||
fn build(&self, _ctx: &PromptContext<'_>) -> String {
|
fn build(&self, _ctx: &PromptContext<'_>) -> String {
|
||||||
"## 子 Agent 委托原则\n\n\
|
"## 子 Agent 委托原则\n\n\
|
||||||
- 只有当任务可以拆成独立子任务时才委托。\n\
|
- 只有当任务可以拆成独立子任务时才委托。\n\
|
||||||
- 子 Agent 的工具集由其定义文件(agents/*.md 的 tools 列表)决定,不要重复说明它已有哪些工具。\n\
|
- 子 Agent 只拿完成任务所需的最小工具集。\n\
|
||||||
- 子 Agent 能否继续委托由它的 delegates 白名单决定,你不需要、也无法给它额外授权。\n\
|
- 永远不要把 delegate 工具再分给子 Agent。\n\
|
||||||
- 子任务 prompt 要直接写清目标、输出格式和限制。\n\
|
- 子任务 prompt 要直接写清目标、输出格式和限制。\n\
|
||||||
- 并行任务彼此不能依赖;后台等待用 background(单任务或 tasks 批量,每个 run 独立返回)。"
|
- 并行任务彼此不能依赖,长期任务用 background。"
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -379,7 +380,7 @@ impl PromptSection for SubAgentIdentitySection {
|
|||||||
## 规则\n\
|
## 规则\n\
|
||||||
- 只专注于这个任务,不要扩展到无关话题\n\
|
- 只专注于这个任务,不要扩展到无关话题\n\
|
||||||
- 只在必要时使用工具\n\
|
- 只在必要时使用工具\n\
|
||||||
- 只有运行时明确提供 delegate 工具时才可继续委托,并遵守已配置的目标白名单\n\
|
- 不要使用 delegate 工具\n\
|
||||||
- 无法完成时,直接说明原因\n\
|
- 无法完成时,直接说明原因\n\
|
||||||
- 只返回最终结果,不要描述过程\n\
|
- 只返回最终结果,不要描述过程\n\
|
||||||
- 超时:{},接近时限时返回部分结果",
|
- 超时:{},接近时限时返回部分结果",
|
||||||
@ -389,7 +390,9 @@ impl PromptSection for SubAgentIdentitySection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sub-agent available tools description.
|
/// Sub-agent available tools description.
|
||||||
pub struct SubAgentToolsSection;
|
pub struct SubAgentToolsSection {
|
||||||
|
pub http_get_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl PromptSection for SubAgentToolsSection {
|
impl PromptSection for SubAgentToolsSection {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
@ -399,6 +402,11 @@ impl PromptSection for SubAgentToolsSection {
|
|||||||
fn build(&self, ctx: &PromptContext<'_>) -> String {
|
fn build(&self, ctx: &PromptContext<'_>) -> String {
|
||||||
let mut s = String::from("## 可用工具\n\n");
|
let mut s = String::from("## 可用工具\n\n");
|
||||||
s.push_str(&ctx.tools.describe_for_prompt());
|
s.push_str(&ctx.tools.describe_for_prompt());
|
||||||
|
if self.http_get_only {
|
||||||
|
s.push_str(
|
||||||
|
"\n\n**注意**:使用 http_request 时只允许 GET 方法,禁止 POST、PUT、DELETE 等。",
|
||||||
|
);
|
||||||
|
}
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -506,13 +514,15 @@ pub fn build_sub_agent_system_prompt(
|
|||||||
workspace_dir: &Path,
|
workspace_dir: &Path,
|
||||||
model_name: &str,
|
model_name: &str,
|
||||||
skills_prompt: Option<String>,
|
skills_prompt: Option<String>,
|
||||||
|
http_get_only: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
let ctx = PromptContext {
|
let ctx = PromptContext {
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
model_name,
|
model_name,
|
||||||
tools,
|
tools,
|
||||||
};
|
};
|
||||||
SystemPromptBuilder::with_sub_agent_defaults(task, timeout_human, skills_prompt).build(&ctx)
|
SystemPromptBuilder::with_sub_agent_defaults(task, timeout_human, skills_prompt, http_get_only)
|
||||||
|
.build(&ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -2,7 +2,6 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::agent::steering::TurnMailbox;
|
|
||||||
use crate::providers::ToolCall;
|
use crate::providers::ToolCall;
|
||||||
|
|
||||||
/// Presentation facts emitted while AgentLoop processes one model turn.
|
/// Presentation facts emitted while AgentLoop processes one model turn.
|
||||||
@ -59,9 +58,6 @@ pub struct AgentTurnContext {
|
|||||||
pub turn_id: String,
|
pub turn_id: String,
|
||||||
pub message_id: String,
|
pub message_id: String,
|
||||||
pub emitter: TurnEmitter,
|
pub emitter: TurnEmitter,
|
||||||
/// Same-turn user input accepted while this turn is active. Session owns
|
|
||||||
/// the mailbox lifecycle; AgentLoop only drains it at safe boundaries.
|
|
||||||
pub steering: Option<Arc<TurnMailbox>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentTurnContext {
|
impl AgentTurnContext {
|
||||||
@ -74,36 +70,8 @@ impl AgentTurnContext {
|
|||||||
turn_id: turn_id.into(),
|
turn_id: turn_id.into(),
|
||||||
message_id: message_id.into(),
|
message_id: message_id.into(),
|
||||||
emitter,
|
emitter,
|
||||||
steering: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct a streaming context with a shared steering mailbox.
|
|
||||||
pub fn new_with_steering(
|
|
||||||
turn_id: impl Into<String>,
|
|
||||||
message_id: impl Into<String>,
|
|
||||||
emitter: TurnEmitter,
|
|
||||||
steering: Arc<TurnMailbox>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
turn_id: turn_id.into(),
|
|
||||||
message_id: message_id.into(),
|
|
||||||
emitter,
|
|
||||||
steering: Some(steering),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach a mailbox to an existing context. This builder keeps the old
|
|
||||||
/// `AgentTurnContext::new` call sites source-compatible.
|
|
||||||
pub fn with_steering(mut self, steering: Arc<TurnMailbox>) -> Self {
|
|
||||||
self.steering = Some(steering);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a clone of the shared mailbox, if steering is enabled.
|
|
||||||
pub fn steering(&self) -> Option<Arc<TurnMailbox>> {
|
|
||||||
self.steering.clone()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TurnEmitter {
|
impl TurnEmitter {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::bus::{DeliveryReceipt, MessageBus, OutboundMessage};
|
use crate::bus::{MessageBus, OutboundMessage};
|
||||||
use crate::channels::ChannelManager;
|
use crate::channels::ChannelManager;
|
||||||
use crate::channels::base::{Channel, ChannelError};
|
use crate::channels::base::{Channel, ChannelError};
|
||||||
use crate::delivery::ConversationWriteLocks;
|
use crate::delivery::ConversationWriteLocks;
|
||||||
@ -64,14 +64,12 @@ impl OutboundDispatcher {
|
|||||||
if sender.as_ref().is_none_or(mpsc::Sender::is_closed) {
|
if sender.as_ref().is_none_or(mpsc::Sender::is_closed) {
|
||||||
let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else {
|
let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else {
|
||||||
tracing::warn!(channel = %msg.channel, "No channel found for message");
|
tracing::warn!(channel = %msg.channel, "No channel found for message");
|
||||||
msg.complete_delivery(DeliveryReceipt::PermanentFailure {
|
msg.complete_delivery(Err(format!("channel not found: {}", msg.channel)));
|
||||||
summary: format!("channel not found: {}", msg.channel),
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY);
|
let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY);
|
||||||
if !self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone()) {
|
if !self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone()) {
|
||||||
msg.complete_delivery(DeliveryReceipt::DispatcherClosed);
|
msg.complete_delivery(Err("dispatcher is shutting down".to_string()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
lanes.insert(lane_key.clone(), new_sender.clone());
|
lanes.insert(lane_key.clone(), new_sender.clone());
|
||||||
@ -91,9 +89,7 @@ impl OutboundDispatcher {
|
|||||||
capacity = LANE_CAPACITY,
|
capacity = LANE_CAPACITY,
|
||||||
"Outbound lane full; rejecting message instead of blocking other destinations"
|
"Outbound lane full; rejecting message instead of blocking other destinations"
|
||||||
);
|
);
|
||||||
msg.complete_delivery(DeliveryReceipt::TransientFailure {
|
msg.complete_delivery(Err("outbound lane is full".to_string()));
|
||||||
summary: "outbound lane is full".to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Err(mpsc::error::TrySendError::Closed(msg)) => {
|
Err(mpsc::error::TrySendError::Closed(msg)) => {
|
||||||
// The lane may have expired between the closed check and
|
// The lane may have expired between the closed check and
|
||||||
@ -101,15 +97,13 @@ impl OutboundDispatcher {
|
|||||||
lanes.remove(&lane_key);
|
lanes.remove(&lane_key);
|
||||||
let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else {
|
let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else {
|
||||||
tracing::warn!(channel = %msg.channel, "No channel found for message");
|
tracing::warn!(channel = %msg.channel, "No channel found for message");
|
||||||
msg.complete_delivery(DeliveryReceipt::PermanentFailure {
|
msg.complete_delivery(Err(format!("channel not found: {}", msg.channel)));
|
||||||
summary: format!("channel not found: {}", msg.channel),
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY);
|
let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY);
|
||||||
if !self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone())
|
if !self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone())
|
||||||
{
|
{
|
||||||
msg.complete_delivery(DeliveryReceipt::DispatcherClosed);
|
msg.complete_delivery(Err("dispatcher is shutting down".to_string()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match new_sender.try_send(msg) {
|
match new_sender.try_send(msg) {
|
||||||
@ -117,9 +111,9 @@ impl OutboundDispatcher {
|
|||||||
lanes.insert(lane_key, new_sender);
|
lanes.insert(lane_key, new_sender);
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
error
|
error.into_inner().complete_delivery(Err(
|
||||||
.into_inner()
|
"outbound lane could not be restarted during shutdown".to_string(),
|
||||||
.complete_delivery(DeliveryReceipt::DispatcherClosed);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -149,15 +143,15 @@ impl OutboundDispatcher {
|
|||||||
Ok(None) | Err(_) => break,
|
Ok(None) | Err(_) => break,
|
||||||
};
|
};
|
||||||
let result = Self::send_with_retry(&*channel, &msg, &target_lock).await;
|
let result = Self::send_with_retry(&*channel, &msg, &target_lock).await;
|
||||||
if result != DeliveryReceipt::Delivered {
|
if let Err(error) = &result {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
chat_id = %chat_id,
|
chat_id = %chat_id,
|
||||||
result = ?result,
|
error = %error,
|
||||||
"Failed to send message after retries"
|
"Failed to send message after retries"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
msg.complete_delivery(result);
|
msg.complete_delivery(result.map_err(|error| error.to_string()));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -167,28 +161,26 @@ impl OutboundDispatcher {
|
|||||||
channel: &dyn Channel,
|
channel: &dyn Channel,
|
||||||
msg: &OutboundMessage,
|
msg: &OutboundMessage,
|
||||||
target_lock: &tokio::sync::Mutex<()>,
|
target_lock: &tokio::sync::Mutex<()>,
|
||||||
) -> DeliveryReceipt {
|
) -> Result<(), ChannelError> {
|
||||||
let _guard = target_lock.lock().await;
|
let _guard = target_lock.lock().await;
|
||||||
const DELAYS: &[u64] = &[1, 2, 4];
|
const DELAYS: &[u64] = &[1, 2, 4];
|
||||||
|
|
||||||
for (attempt, &delay) in DELAYS.iter().enumerate() {
|
for (attempt, &delay) in DELAYS.iter().enumerate() {
|
||||||
let result = tokio::time::timeout(SEND_TIMEOUT, channel.send(msg.clone())).await;
|
let result = tokio::time::timeout(SEND_TIMEOUT, channel.send(msg.clone())).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(())) => return DeliveryReceipt::Delivered,
|
Ok(Ok(())) => return Ok(()),
|
||||||
Ok(Err(error)) if attempt < DELAYS.len() - 1 && error.is_transient() => {
|
Ok(Err(error)) if attempt < DELAYS.len() - 1 && error.is_transient() => {
|
||||||
tracing::warn!(
|
tracing::warn!(attempt = attempt + 1, delay, error = %error, "Send failed, retrying");
|
||||||
attempt = attempt + 1,
|
|
||||||
delay,
|
|
||||||
error_class = channel_error_class(&error),
|
|
||||||
"Send failed, retrying"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(Err(error)) => return receipt_from_channel_error(error),
|
Ok(Err(error)) => return Err(error),
|
||||||
Err(_) if attempt < DELAYS.len() - 1 => {
|
Err(_) if attempt < DELAYS.len() - 1 => {
|
||||||
tracing::warn!(attempt = attempt + 1, delay, "Send timed out, retrying");
|
tracing::warn!(attempt = attempt + 1, delay, "Send timed out, retrying");
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return DeliveryReceipt::TimedOut;
|
return Err(ChannelError::Other(format!(
|
||||||
|
"send timed out after {} seconds",
|
||||||
|
SEND_TIMEOUT.as_secs()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||||
@ -197,36 +189,6 @@ impl OutboundDispatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn channel_error_class(error: &ChannelError) -> &'static str {
|
|
||||||
match error {
|
|
||||||
ChannelError::ConnectionError(_) => "connection",
|
|
||||||
ChannelError::SendError(_) => "send",
|
|
||||||
ChannelError::BusError(_) => "bus",
|
|
||||||
ChannelError::ConfigError(_) => "config",
|
|
||||||
ChannelError::Other(_) => "other",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn receipt_from_channel_error(error: ChannelError) -> DeliveryReceipt {
|
|
||||||
match error {
|
|
||||||
ChannelError::ConnectionError(_) => DeliveryReceipt::TransientFailure {
|
|
||||||
summary: "channel connection failed after retries".to_string(),
|
|
||||||
},
|
|
||||||
ChannelError::SendError(_) => DeliveryReceipt::TransientFailure {
|
|
||||||
summary: "channel send failed after retries".to_string(),
|
|
||||||
},
|
|
||||||
ChannelError::BusError(_) => DeliveryReceipt::TransientFailure {
|
|
||||||
summary: "channel bus was unavailable".to_string(),
|
|
||||||
},
|
|
||||||
ChannelError::ConfigError(_) => DeliveryReceipt::PermanentFailure {
|
|
||||||
summary: "channel configuration rejected delivery".to_string(),
|
|
||||||
},
|
|
||||||
ChannelError::Other(_) => DeliveryReceipt::PermanentFailure {
|
|
||||||
summary: "channel rejected delivery".to_string(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrements the active-lane counter exactly once when a lane task ends,
|
/// Decrements the active-lane counter exactly once when a lane task ends,
|
||||||
/// whether it exits normally, is cancelled, or is aborted.
|
/// whether it exits normally, is cancelled, or is aborted.
|
||||||
struct LaneGuard {
|
struct LaneGuard {
|
||||||
@ -386,7 +348,7 @@ mod tests {
|
|||||||
message.channel = "missing".to_string();
|
message.channel = "missing".to_string();
|
||||||
let error = bus.deliver_outbound(message).await.unwrap_err();
|
let error = bus.deliver_outbound(message).await.unwrap_err();
|
||||||
|
|
||||||
assert!(matches!(error, crate::bus::BusError::DeliveryPermanent(_)));
|
assert!(matches!(error, crate::bus::BusError::DeliveryFailed(_)));
|
||||||
task.abort();
|
task.abort();
|
||||||
supervisor.shutdown(Duration::from_secs(1)).await;
|
supervisor.shutdown(Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
@ -473,40 +435,18 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let target_lock = tokio::sync::Mutex::new(());
|
let target_lock = tokio::sync::Mutex::new(());
|
||||||
let receipt = OutboundDispatcher::send_with_retry(
|
let error = OutboundDispatcher::send_with_retry(
|
||||||
&channel,
|
&channel,
|
||||||
&outbound("invalid", "message"),
|
&outbound("invalid", "message"),
|
||||||
&target_lock,
|
&target_lock,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(matches!(receipt, DeliveryReceipt::PermanentFailure { .. }));
|
assert!(matches!(error, ChannelError::Other(_)));
|
||||||
assert_eq!(channel.attempts.load(Ordering::SeqCst), 1);
|
assert_eq!(channel.attempts.load(Ordering::SeqCst), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn channel_errors_map_to_typed_sanitized_receipts() {
|
|
||||||
for error in [
|
|
||||||
ChannelError::ConnectionError("https://secret.example/?token=x".to_string()),
|
|
||||||
ChannelError::SendError("private response body".to_string()),
|
|
||||||
ChannelError::BusError("private queue detail".to_string()),
|
|
||||||
] {
|
|
||||||
let receipt = receipt_from_channel_error(error);
|
|
||||||
assert!(matches!(receipt, DeliveryReceipt::TransientFailure { .. }));
|
|
||||||
assert!(!format!("{receipt:?}").contains("private"));
|
|
||||||
assert!(!format!("{receipt:?}").contains("secret"));
|
|
||||||
}
|
|
||||||
for error in [
|
|
||||||
ChannelError::ConfigError("api_key=x".to_string()),
|
|
||||||
ChannelError::Other("private platform payload".to_string()),
|
|
||||||
] {
|
|
||||||
let receipt = receipt_from_channel_error(error);
|
|
||||||
assert!(matches!(receipt, DeliveryReceipt::PermanentFailure { .. }));
|
|
||||||
assert!(!format!("{receipt:?}").contains("private"));
|
|
||||||
assert!(!format!("{receipt:?}").contains("api_key"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn shared_write_lock_orders_dispatcher_with_live_turn_writes() {
|
async fn shared_write_lock_orders_dispatcher_with_live_turn_writes() {
|
||||||
let channel = RecordingChannel {
|
let channel = RecordingChannel {
|
||||||
@ -528,7 +468,7 @@ mod tests {
|
|||||||
assert!(channel.sent.lock().await.is_empty());
|
assert!(channel.sent.lock().await.is_empty());
|
||||||
|
|
||||||
drop(live_write);
|
drop(live_write);
|
||||||
assert_eq!(send.await, DeliveryReceipt::Delivered);
|
send.await.unwrap();
|
||||||
assert_eq!(channel.sent.lock().await.as_slice(), &["after-live-update"]);
|
assert_eq!(channel.sent.lock().await.as_slice(), &["after-live-update"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,58 +119,12 @@ impl MediaItem {
|
|||||||
media_type: self.media_type.clone(),
|
media_type: self.media_type.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_media_ref(media_ref: &MediaRef) -> Self {
|
|
||||||
Self::new(media_ref.path.clone(), media_ref.media_type.clone())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ChatMessage - Used by AgentLoop for LLM conversation history
|
// ChatMessage - Used by AgentLoop for LLM conversation history
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Whether a message may be surfaced to clients. Hidden messages exist only
|
|
||||||
/// for model replay (internal triggers) and must never appear in history,
|
|
||||||
/// projections or delivery.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ClientVisibility {
|
|
||||||
#[default]
|
|
||||||
Visible,
|
|
||||||
Hidden,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientVisibility {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Visible => "visible",
|
|
||||||
Self::Hidden => "hidden",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where a message Turn originated. Persisted alongside the message so
|
|
||||||
/// clients can render agent-driven continuation output without treating it as
|
|
||||||
/// a user bubble.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TurnOrigin {
|
|
||||||
#[default]
|
|
||||||
User,
|
|
||||||
AgentContinuation,
|
|
||||||
Scheduled,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TurnOrigin {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::User => "user",
|
|
||||||
Self::AgentContinuation => "agent_continuation",
|
|
||||||
Self::Scheduled => "scheduled",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ChatMessage {
|
pub struct ChatMessage {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -186,10 +140,6 @@ pub struct ChatMessage {
|
|||||||
pub iteration: Option<u32>,
|
pub iteration: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub completion_status: CompletionStatus,
|
pub completion_status: CompletionStatus,
|
||||||
#[serde(default)]
|
|
||||||
pub client_visibility: ClientVisibility,
|
|
||||||
#[serde(default)]
|
|
||||||
pub turn_origin: TurnOrigin,
|
|
||||||
pub media_refs: Vec<MediaRef>,
|
pub media_refs: Vec<MediaRef>,
|
||||||
pub timestamp: i64,
|
pub timestamp: i64,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@ -212,12 +162,6 @@ pub enum SourceKind {
|
|||||||
CrossChannel,
|
CrossChannel,
|
||||||
#[serde(rename = "external_trigger")]
|
#[serde(rename = "external_trigger")]
|
||||||
ExternalTrigger,
|
ExternalTrigger,
|
||||||
/// A durable signal emitted by a background Agent via `emit_signal`.
|
|
||||||
#[serde(rename = "agent_signal")]
|
|
||||||
AgentSignal,
|
|
||||||
/// A durable background run completion outcome.
|
|
||||||
#[serde(rename = "agent_result")]
|
|
||||||
AgentCompletion,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -228,12 +172,6 @@ pub struct MessageSource {
|
|||||||
pub from_user_id: Option<String>,
|
pub from_user_id: Option<String>,
|
||||||
pub system_name: Option<String>,
|
pub system_name: Option<String>,
|
||||||
pub task_id: Option<String>,
|
pub task_id: Option<String>,
|
||||||
/// Durable Agent run identity for `agent_signal`/`agent_result` sources.
|
|
||||||
#[serde(default)]
|
|
||||||
pub from_run_id: Option<String>,
|
|
||||||
/// Agent definition id for `agent_signal`/`agent_result` sources.
|
|
||||||
#[serde(default)]
|
|
||||||
pub from_agent_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatMessage {
|
impl ChatMessage {
|
||||||
@ -253,8 +191,6 @@ impl ChatMessage {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
source: None,
|
source: None,
|
||||||
client_visibility: ClientVisibility::Visible,
|
|
||||||
turn_origin: TurnOrigin::User,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,8 +210,6 @@ impl ChatMessage {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
source: None,
|
source: None,
|
||||||
client_visibility: ClientVisibility::Visible,
|
|
||||||
turn_origin: TurnOrigin::User,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -295,8 +229,6 @@ impl ChatMessage {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
source: None,
|
source: None,
|
||||||
client_visibility: ClientVisibility::Visible,
|
|
||||||
turn_origin: TurnOrigin::User,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,8 +251,6 @@ impl ChatMessage {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: Some(tool_calls),
|
tool_calls: Some(tool_calls),
|
||||||
source: None,
|
source: None,
|
||||||
client_visibility: ClientVisibility::Visible,
|
|
||||||
turn_origin: TurnOrigin::User,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,8 +270,6 @@ impl ChatMessage {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
source: Some(source),
|
source: Some(source),
|
||||||
client_visibility: ClientVisibility::Visible,
|
|
||||||
turn_origin: TurnOrigin::User,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,8 +289,6 @@ impl ChatMessage {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
source: None,
|
source: None,
|
||||||
client_visibility: ClientVisibility::Visible,
|
|
||||||
turn_origin: TurnOrigin::User,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -395,8 +321,6 @@ impl ChatMessage {
|
|||||||
tool_name: Some(tool_name.into()),
|
tool_name: Some(tool_name.into()),
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
source: None,
|
source: None,
|
||||||
client_visibility: ClientVisibility::Visible,
|
|
||||||
turn_origin: TurnOrigin::User,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -416,8 +340,6 @@ impl ChatMessage {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
source: Some(source),
|
source: Some(source),
|
||||||
client_visibility: ClientVisibility::Visible,
|
|
||||||
turn_origin: TurnOrigin::User,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -445,15 +367,11 @@ mod conversation_message_tests {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Opaque channel-owned context that may be carried to the corresponding reply.
|
/// Opaque channel-owned context that may be carried to the corresponding reply.
|
||||||
/// Core routing understands `reply_to`; all other platform data remains
|
/// Core routing understands `reply_to`; all other platform data remains private.
|
||||||
/// private. `durable_private` holds only values the channel declares safe to
|
|
||||||
/// reuse across turns (thread/root identity); one-shot message/reaction ids
|
|
||||||
/// belong in `private`.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ChannelContext {
|
pub struct ChannelContext {
|
||||||
pub reply_to: Option<String>,
|
pub reply_to: Option<String>,
|
||||||
pub private: HashMap<String, String>,
|
pub private: HashMap<String, String>,
|
||||||
pub durable_private: HashMap<String, String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public, durable projection of a newly committed conversation message.
|
/// Public, durable projection of a newly committed conversation message.
|
||||||
@ -471,7 +389,6 @@ pub struct CommittedMessage {
|
|||||||
pub tool_call_id: Option<String>,
|
pub tool_call_id: Option<String>,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
pub tool_calls: Option<Vec<ToolCall>>,
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
pub turn_origin: TurnOrigin,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -487,10 +404,6 @@ pub struct InboundMessage {
|
|||||||
pub channel: String,
|
pub channel: String,
|
||||||
pub sender_id: String,
|
pub sender_id: String,
|
||||||
pub chat_id: String,
|
pub chat_id: String,
|
||||||
/// Client-provided id for optimistic UI reconciliation. Channel-owned
|
|
||||||
/// inputs that do not expose a client id leave this unset; the session
|
|
||||||
/// layer may generate a durable id when it accepts the message.
|
|
||||||
pub client_message_id: Option<String>,
|
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub received_at: i64,
|
pub received_at: i64,
|
||||||
pub media: Vec<MediaItem>,
|
pub media: Vec<MediaItem>,
|
||||||
@ -509,26 +422,17 @@ pub struct OutboundMessage {
|
|||||||
pub reply_to: Option<String>,
|
pub reply_to: Option<String>,
|
||||||
pub media: Vec<MediaItem>,
|
pub media: Vec<MediaItem>,
|
||||||
pub metadata: HashMap<String, String>,
|
pub metadata: HashMap<String, String>,
|
||||||
pub(crate) delivery: Option<tokio::sync::watch::Sender<Option<DeliveryReceipt>>>,
|
pub(crate) delivery: Option<tokio::sync::watch::Sender<Option<Result<(), String>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OutboundMessage {
|
impl OutboundMessage {
|
||||||
pub(crate) fn complete_delivery(&self, result: DeliveryReceipt) {
|
pub(crate) fn complete_delivery(&self, result: Result<(), String>) {
|
||||||
if let Some(delivery) = &self.delivery {
|
if let Some(delivery) = &self.delivery {
|
||||||
delivery.send_replace(Some(result));
|
delivery.send_replace(Some(result));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum DeliveryReceipt {
|
|
||||||
Delivered,
|
|
||||||
TransientFailure { summary: String },
|
|
||||||
PermanentFailure { summary: String },
|
|
||||||
TimedOut,
|
|
||||||
DispatcherClosed,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ControlMessage - Message for control channel (session management)
|
// ControlMessage - Message for control channel (session management)
|
||||||
// Uses SessionCommand from session module
|
// Uses SessionCommand from session module
|
||||||
|
|||||||
@ -3,9 +3,9 @@ pub mod message;
|
|||||||
|
|
||||||
pub use dispatcher::OutboundDispatcher;
|
pub use dispatcher::OutboundDispatcher;
|
||||||
pub use message::{
|
pub use message::{
|
||||||
ChannelContext, ChatMessage, ClientVisibility, CommittedMessage, CommittedTurnDelta,
|
ChannelContext, ChatMessage, CommittedMessage, CommittedTurnDelta, CompletionStatus,
|
||||||
CompletionStatus, ContentBlock, ControlMessage, DeliveryReceipt, InboundMessage, MediaItem,
|
ContentBlock, ControlMessage, InboundMessage, MediaItem, MediaRef, MessageSource,
|
||||||
MediaRef, MessageSource, OutboundMessage, ProviderReasoningState, SourceKind, TurnOrigin,
|
OutboundMessage, ProviderReasoningState, SourceKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -80,17 +80,7 @@ impl MessageBus {
|
|||||||
loop {
|
loop {
|
||||||
delivery_rx.changed().await.map_err(|_| BusError::Closed)?;
|
delivery_rx.changed().await.map_err(|_| BusError::Closed)?;
|
||||||
if let Some(result) = delivery_rx.borrow().clone() {
|
if let Some(result) = delivery_rx.borrow().clone() {
|
||||||
return match result {
|
return result.map_err(BusError::DeliveryFailed);
|
||||||
DeliveryReceipt::Delivered => Ok(()),
|
|
||||||
DeliveryReceipt::TransientFailure { summary } => {
|
|
||||||
Err(BusError::DeliveryTransient(summary))
|
|
||||||
}
|
|
||||||
DeliveryReceipt::PermanentFailure { summary } => {
|
|
||||||
Err(BusError::DeliveryPermanent(summary))
|
|
||||||
}
|
|
||||||
DeliveryReceipt::TimedOut => Err(BusError::DeliveryTimedOut),
|
|
||||||
DeliveryReceipt::DispatcherClosed => Err(BusError::Closed),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -148,8 +138,7 @@ pub struct QueueDepths {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BusError {
|
pub enum BusError {
|
||||||
Closed,
|
Closed,
|
||||||
DeliveryTransient(String),
|
DeliveryFailed(String),
|
||||||
DeliveryPermanent(String),
|
|
||||||
DeliveryTimedOut,
|
DeliveryTimedOut,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,12 +146,7 @@ impl std::fmt::Display for BusError {
|
|||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
BusError::Closed => write!(f, "Bus channel closed"),
|
BusError::Closed => write!(f, "Bus channel closed"),
|
||||||
BusError::DeliveryTransient(error) => {
|
BusError::DeliveryFailed(error) => write!(f, "Outbound delivery failed: {error}"),
|
||||||
write!(f, "Transient outbound delivery failure: {error}")
|
|
||||||
}
|
|
||||||
BusError::DeliveryPermanent(error) => {
|
|
||||||
write!(f, "Permanent outbound delivery failure: {error}")
|
|
||||||
}
|
|
||||||
BusError::DeliveryTimedOut => write!(f, "Outbound delivery confirmation timed out"),
|
BusError::DeliveryTimedOut => write!(f, "Outbound delivery confirmation timed out"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,13 +100,6 @@ pub trait Channel: Send + Sync + 'static {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `commit_turn` presents media references from the committed
|
|
||||||
/// assistant message to the user. Channels that return false receive a
|
|
||||||
/// separate media-only outbound delivery after the durable commit.
|
|
||||||
fn commit_turn_presents_media(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message to the channel (called by OutboundDispatcher)
|
/// Send a message to the channel (called by OutboundDispatcher)
|
||||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError>;
|
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError>;
|
||||||
|
|
||||||
|
|||||||
@ -132,40 +132,6 @@ impl CliChatChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a durable Agent run/event projection update to the owning
|
|
||||||
/// WebSocket client. Clients recalibrate with `GetAgentRuns` when a
|
|
||||||
/// broadcast is lagged or lost.
|
|
||||||
pub async fn publish_agent_projection(
|
|
||||||
&self,
|
|
||||||
projection: crate::agent::projection::AgentProjection,
|
|
||||||
) {
|
|
||||||
let Some(session_id) = UnifiedSessionId::parse(&projection.session_id) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if session_id.channel != "cli_chat" {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let client = self.clients.lock().await.get(&session_id.chat_id).cloned();
|
|
||||||
if let Some(client) = client {
|
|
||||||
let message = if let Some(run) = projection.run {
|
|
||||||
WsOutbound::AgentRunUpdated {
|
|
||||||
session_id: projection.session_id,
|
|
||||||
revision: projection.revision,
|
|
||||||
run,
|
|
||||||
}
|
|
||||||
} else if let Some(event) = projection.event {
|
|
||||||
WsOutbound::AgentEventUpdated {
|
|
||||||
session_id: projection.session_id,
|
|
||||||
revision: projection.revision,
|
|
||||||
event,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let _ = client.sender.send(message).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an inbound message from a client
|
/// Handle an inbound message from a client
|
||||||
pub(crate) async fn handle_inbound(&self, client: Arc<Client>, raw_msg: &str) {
|
pub(crate) async fn handle_inbound(&self, client: Arc<Client>, raw_msg: &str) {
|
||||||
match parse_inbound(raw_msg) {
|
match parse_inbound(raw_msg) {
|
||||||
@ -213,7 +179,6 @@ impl CliChatChannel {
|
|||||||
WsInbound::UserInput {
|
WsInbound::UserInput {
|
||||||
content,
|
content,
|
||||||
upload_ids,
|
upload_ids,
|
||||||
client_message_id,
|
|
||||||
chat_id,
|
chat_id,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
@ -222,15 +187,8 @@ impl CliChatChannel {
|
|||||||
if content.trim().is_empty() && upload_ids.is_empty() {
|
if content.trim().is_empty() && upload_ids.is_empty() {
|
||||||
return Err(ChannelError::Other("Message is empty".to_string()));
|
return Err(ChannelError::Other("Message is empty".to_string()));
|
||||||
}
|
}
|
||||||
// `/queue` is deliberately allowed to carry attachments: it
|
|
||||||
// is a message-routing directive whose payload remains a
|
|
||||||
// normal user input. Other slash commands still reject
|
|
||||||
// attachments because their handlers do not consume media.
|
|
||||||
let slash_allows_attachments = crate::channels::parse_slash_command(&content)
|
|
||||||
.is_some_and(|(name, _)| name.eq_ignore_ascii_case("queue"));
|
|
||||||
if !upload_ids.is_empty()
|
if !upload_ids.is_empty()
|
||||||
&& crate::channels::parse_slash_command(&content).is_some()
|
&& crate::channels::parse_slash_command(&content).is_some()
|
||||||
&& !slash_allows_attachments
|
|
||||||
{
|
{
|
||||||
return Err(ChannelError::Other(
|
return Err(ChannelError::Other(
|
||||||
"Attachments cannot be sent with slash commands".to_string(),
|
"Attachments cannot be sent with slash commands".to_string(),
|
||||||
@ -242,18 +200,6 @@ impl CliChatChannel {
|
|||||||
"Chat does not belong to this client".to_string(),
|
"Chat does not belong to this client".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let client_message_id =
|
|
||||||
client_message_id.and_then(|raw| match uuid::Uuid::parse_str(&raw) {
|
|
||||||
Ok(id) => Some(id.to_string()),
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!(
|
|
||||||
client_message_id = %raw,
|
|
||||||
error = %error,
|
|
||||||
"Ignoring invalid client message id"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let uploads = self
|
let uploads = self
|
||||||
.uploads
|
.uploads
|
||||||
.take_many(&client.chat_id, &upload_ids)
|
.take_many(&client.chat_id, &upload_ids)
|
||||||
@ -264,7 +210,6 @@ impl CliChatChannel {
|
|||||||
channel: self.name().to_string(),
|
channel: self.name().to_string(),
|
||||||
sender_id: "cli".to_string(),
|
sender_id: "cli".to_string(),
|
||||||
chat_id: target_chat_id,
|
chat_id: target_chat_id,
|
||||||
client_message_id,
|
|
||||||
content,
|
content,
|
||||||
received_at: crate::bus::message::current_timestamp(),
|
received_at: crate::bus::message::current_timestamp(),
|
||||||
media,
|
media,
|
||||||
@ -530,96 +475,6 @@ impl CliChatChannel {
|
|||||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WsInbound::GetSessionStats { session_id } => {
|
|
||||||
let unified_id = Self::parse_client_session(&client, &session_id)?;
|
|
||||||
let (reply_tx, mut reply_rx) = mpsc::channel(1);
|
|
||||||
bus.publish_control(ControlMessage {
|
|
||||||
op: SessionCommand::GetSessionStats {
|
|
||||||
session_id: unified_id,
|
|
||||||
},
|
|
||||||
reply_tx,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
match reply_rx.recv().await {
|
|
||||||
Some(Ok(SessionEvent::SessionStats { stats })) => {
|
|
||||||
let _ = client.sender.send(WsOutbound::SessionStats { stats }).await;
|
|
||||||
}
|
|
||||||
Some(Ok(_)) => {}
|
|
||||||
Some(Err(error)) => return Err(error),
|
|
||||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::GetAgentRuns {
|
|
||||||
session_id,
|
|
||||||
cursor,
|
|
||||||
limit,
|
|
||||||
} => {
|
|
||||||
let unified_id = Self::parse_client_session(&client, &session_id)?;
|
|
||||||
let (reply_tx, mut reply_rx) = mpsc::channel(1);
|
|
||||||
bus.publish_control(ControlMessage {
|
|
||||||
op: SessionCommand::GetAgentRuns {
|
|
||||||
session_id: unified_id,
|
|
||||||
cursor,
|
|
||||||
limit: limit.unwrap_or(100).clamp(1, 200),
|
|
||||||
},
|
|
||||||
reply_tx,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
match reply_rx.recv().await {
|
|
||||||
Some(Ok(SessionEvent::AgentRuns {
|
|
||||||
session_id,
|
|
||||||
revision,
|
|
||||||
runs,
|
|
||||||
next_cursor,
|
|
||||||
})) => {
|
|
||||||
let _ = client
|
|
||||||
.sender
|
|
||||||
.send(WsOutbound::SessionAgentRuns {
|
|
||||||
session_id: session_id.to_string(),
|
|
||||||
revision,
|
|
||||||
runs,
|
|
||||||
next_cursor,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Some(Ok(_)) => {}
|
|
||||||
Some(Err(error)) => return Err(error),
|
|
||||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::GetAgentRun { session_id, run_id } => {
|
|
||||||
let unified_id = Self::parse_client_session(&client, &session_id)?;
|
|
||||||
let (reply_tx, mut reply_rx) = mpsc::channel(1);
|
|
||||||
bus.publish_control(ControlMessage {
|
|
||||||
op: SessionCommand::GetAgentRun {
|
|
||||||
session_id: unified_id,
|
|
||||||
run_id,
|
|
||||||
},
|
|
||||||
reply_tx,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
match reply_rx.recv().await {
|
|
||||||
Some(Ok(SessionEvent::AgentRun {
|
|
||||||
session_id,
|
|
||||||
revision,
|
|
||||||
run,
|
|
||||||
})) => {
|
|
||||||
let _ = client
|
|
||||||
.sender
|
|
||||||
.send(WsOutbound::AgentRunUpdated {
|
|
||||||
session_id: session_id.to_string(),
|
|
||||||
revision,
|
|
||||||
run: run.ok_or_else(|| {
|
|
||||||
ChannelError::Other("run not found".to_string())
|
|
||||||
})?,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Some(Ok(_)) => {}
|
|
||||||
Some(Err(error)) => return Err(error),
|
|
||||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::RenameSession { session_id, title } => {
|
WsInbound::RenameSession { session_id, title } => {
|
||||||
let target = session_id
|
let target = session_id
|
||||||
.or(current_session_guard.clone())
|
.or(current_session_guard.clone())
|
||||||
@ -1031,10 +886,6 @@ impl Channel for CliChatChannel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn commit_turn_presents_media(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
||||||
let client = self.clients.lock().await.get(&msg.chat_id).cloned();
|
let client = self.clients.lock().await.get(&msg.chat_id).cloned();
|
||||||
let Some(client) = client else {
|
let Some(client) = client else {
|
||||||
@ -1185,9 +1036,8 @@ mod tests {
|
|||||||
.handle_ws_inbound(
|
.handle_ws_inbound(
|
||||||
client,
|
client,
|
||||||
WsInbound::UserInput {
|
WsInbound::UserInput {
|
||||||
content: "/queue 处理附件".into(),
|
content: "处理附件".into(),
|
||||||
upload_ids: vec!["upload-1".into()],
|
upload_ids: vec!["upload-1".into()],
|
||||||
client_message_id: Some("550e8400-e29b-41d4-a716-446655440000".into()),
|
|
||||||
channel: None,
|
channel: None,
|
||||||
chat_id: None,
|
chat_id: None,
|
||||||
sender_id: None,
|
sender_id: None,
|
||||||
@ -1200,42 +1050,6 @@ mod tests {
|
|||||||
assert_eq!(inbound.media.len(), 1);
|
assert_eq!(inbound.media.len(), 1);
|
||||||
assert_eq!(inbound.media[0].path, "/tmp/report.pdf");
|
assert_eq!(inbound.media[0].path, "/tmp/report.pdf");
|
||||||
assert_eq!(inbound.media[0].media_type, "file");
|
assert_eq!(inbound.media[0].media_type, "file");
|
||||||
assert_eq!(
|
|
||||||
inbound.client_message_id.as_deref(),
|
|
||||||
Some("550e8400-e29b-41d4-a716-446655440000")
|
|
||||||
);
|
|
||||||
assert_eq!(inbound.content, "/queue 处理附件");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn invalid_client_message_id_is_ignored_at_channel_boundary() {
|
|
||||||
let channel = CliChatChannel::new();
|
|
||||||
let bus = MessageBus::new(4);
|
|
||||||
channel.start(bus.clone()).await.unwrap();
|
|
||||||
let (sender, _receiver) = mpsc::channel(1);
|
|
||||||
let client = Arc::new(Client {
|
|
||||||
sender,
|
|
||||||
chat_id: "client".into(),
|
|
||||||
current_session_id: Mutex::new(None),
|
|
||||||
});
|
|
||||||
|
|
||||||
channel
|
|
||||||
.handle_ws_inbound(
|
|
||||||
client,
|
|
||||||
WsInbound::UserInput {
|
|
||||||
content: "hello".into(),
|
|
||||||
upload_ids: Vec::new(),
|
|
||||||
client_message_id: Some("not-a-uuid".into()),
|
|
||||||
channel: None,
|
|
||||||
chat_id: None,
|
|
||||||
sender_id: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let inbound = bus.consume_inbound().await.unwrap();
|
|
||||||
assert_eq!(inbound.client_message_id, None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -1360,7 +1174,6 @@ mod tests {
|
|||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
turn_origin: crate::bus::TurnOrigin::User,
|
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -788,7 +788,7 @@ impl FeishuChannel {
|
|||||||
let result: UploadResp = serde_json::from_str(&body_text).map_err(|e| {
|
let result: UploadResp = serde_json::from_str(&body_text).map_err(|e| {
|
||||||
ChannelError::Other(format!(
|
ChannelError::Other(format!(
|
||||||
"Parse upload response error: {} | body: {}",
|
"Parse upload response error: {} | body: {}",
|
||||||
e, body_text
|
e, &body_text
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@ -876,7 +876,7 @@ impl FeishuChannel {
|
|||||||
let result: UploadResp = serde_json::from_str(&body_text).map_err(|e| {
|
let result: UploadResp = serde_json::from_str(&body_text).map_err(|e| {
|
||||||
ChannelError::Other(format!(
|
ChannelError::Other(format!(
|
||||||
"Parse upload response error: {} | body: {}",
|
"Parse upload response error: {} | body: {}",
|
||||||
e, body_text
|
e, &body_text
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@ -1415,28 +1415,16 @@ impl FeishuChannel {
|
|||||||
private_context.insert("feishu.reaction_id".to_string(), reaction_id);
|
private_context.insert("feishu.reaction_id".to_string(), reaction_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Durable context only carries values safe to reuse across Turns:
|
|
||||||
// thread/root identity. Message and reaction ids are one-shot and
|
|
||||||
// stay in `private`/`reply_to`.
|
|
||||||
let mut durable_context = HashMap::new();
|
|
||||||
for key in ["feishu.chat_type", "feishu.thread_id", "feishu.root_id"] {
|
|
||||||
if let Some(value) = private_context.get(key) {
|
|
||||||
durable_context.insert(key.to_string(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = crate::bus::InboundMessage {
|
let msg = crate::bus::InboundMessage {
|
||||||
channel: "feishu".to_string(),
|
channel: "feishu".to_string(),
|
||||||
sender_id: parsed.open_id.clone(),
|
sender_id: parsed.open_id.clone(),
|
||||||
chat_id: parsed.chat_id.clone(),
|
chat_id: parsed.chat_id.clone(),
|
||||||
client_message_id: None,
|
|
||||||
content: parsed.content,
|
content: parsed.content,
|
||||||
received_at: crate::bus::message::current_timestamp(),
|
received_at: crate::bus::message::current_timestamp(),
|
||||||
media: parsed.media,
|
media: parsed.media,
|
||||||
channel_context: crate::bus::ChannelContext {
|
channel_context: crate::bus::ChannelContext {
|
||||||
reply_to: Some(message_id),
|
reply_to: Some(message_id),
|
||||||
private: private_context,
|
private: private_context,
|
||||||
durable_private: durable_context,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if let Err(error) = self.handle_and_publish(bus, &msg).await {
|
if let Err(error) = self.handle_and_publish(bus, &msg).await {
|
||||||
@ -2623,7 +2611,6 @@ fn render_feishu_turn(snapshot: &TurnSnapshot) -> String {
|
|||||||
let status = match status {
|
let status = match status {
|
||||||
ToolStatus::Running => "执行中",
|
ToolStatus::Running => "执行中",
|
||||||
ToolStatus::Completed => "已完成",
|
ToolStatus::Completed => "已完成",
|
||||||
ToolStatus::Cancelled => "已停止",
|
|
||||||
ToolStatus::Failed => "失败",
|
ToolStatus::Failed => "失败",
|
||||||
};
|
};
|
||||||
let mut section = format!("> 🔧 **{name}** · {status}");
|
let mut section = format!("> 🔧 **{name}** · {status}");
|
||||||
|
|||||||
@ -380,12 +380,7 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
|
|||||||
} => app.set_history(&session_id, messages),
|
} => app.set_history(&session_id, messages),
|
||||||
// The first Todo UI is WebUI-only. CLI keeps receiving ordinary task
|
// The first Todo UI is WebUI-only. CLI keeps receiving ordinary task
|
||||||
// notifications and may inspect plans through /todo.
|
// notifications and may inspect plans through /todo.
|
||||||
WsOutbound::SessionPlan { .. }
|
WsOutbound::SessionPlan { .. } | WsOutbound::PlanUpdated { .. } => {}
|
||||||
| WsOutbound::SessionStats { .. }
|
|
||||||
| WsOutbound::PlanUpdated { .. }
|
|
||||||
| WsOutbound::SessionAgentRuns { .. }
|
|
||||||
| WsOutbound::AgentRunUpdated { .. }
|
|
||||||
| WsOutbound::AgentEventUpdated { .. } => {}
|
|
||||||
WsOutbound::SessionRenamed { session_id, title } => {
|
WsOutbound::SessionRenamed { session_id, title } => {
|
||||||
if let Some(session) = app
|
if let Some(session) = app
|
||||||
.sessions
|
.sessions
|
||||||
|
|||||||
@ -114,7 +114,6 @@ pub async fn run_once(
|
|||||||
let input = WsInbound::UserInput {
|
let input = WsInbound::UserInput {
|
||||||
content: prompt,
|
content: prompt,
|
||||||
upload_ids: Vec::new(),
|
upload_ids: Vec::new(),
|
||||||
client_message_id: None,
|
|
||||||
channel: None,
|
channel: None,
|
||||||
chat_id: None,
|
chat_id: None,
|
||||||
sender_id: None,
|
sender_id: None,
|
||||||
@ -194,7 +193,6 @@ where
|
|||||||
let stop = WsInbound::UserInput {
|
let stop = WsInbound::UserInput {
|
||||||
content: "/stop".to_string(),
|
content: "/stop".to_string(),
|
||||||
upload_ids: Vec::new(),
|
upload_ids: Vec::new(),
|
||||||
client_message_id: None,
|
|
||||||
channel: None,
|
channel: None,
|
||||||
chat_id: None,
|
chat_id: None,
|
||||||
sender_id: None,
|
sender_id: None,
|
||||||
@ -318,7 +316,6 @@ fn tool_status_name(status: ToolStatus) -> &'static str {
|
|||||||
match status {
|
match status {
|
||||||
ToolStatus::Running => "running",
|
ToolStatus::Running => "running",
|
||||||
ToolStatus::Completed => "completed",
|
ToolStatus::Completed => "completed",
|
||||||
ToolStatus::Cancelled => "cancelled",
|
|
||||||
ToolStatus::Failed => "failed",
|
ToolStatus::Failed => "failed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -552,7 +552,6 @@ mod tests {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
turn_origin: crate::bus::TurnOrigin::User,
|
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
assert!(app.active_turn.is_none());
|
assert!(app.active_turn.is_none());
|
||||||
@ -579,7 +578,6 @@ mod tests {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
turn_origin: crate::bus::TurnOrigin::User,
|
|
||||||
}],
|
}],
|
||||||
));
|
));
|
||||||
assert!(app.active_turn.is_none());
|
assert!(app.active_turn.is_none());
|
||||||
@ -606,7 +604,6 @@ mod tests {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
turn_origin: crate::bus::TurnOrigin::User,
|
|
||||||
}],
|
}],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@ -89,7 +89,6 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
let status = match status {
|
let status = match status {
|
||||||
ToolStatus::Running => "执行中",
|
ToolStatus::Running => "执行中",
|
||||||
ToolStatus::Completed => "已完成",
|
ToolStatus::Completed => "已完成",
|
||||||
ToolStatus::Cancelled => "已停止",
|
|
||||||
ToolStatus::Failed => "失败",
|
ToolStatus::Failed => "失败",
|
||||||
};
|
};
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
|
|||||||
@ -206,7 +206,6 @@ async fn handle_input_key(app: &mut App, key: KeyEvent) {
|
|||||||
WsInbound::UserInput {
|
WsInbound::UserInput {
|
||||||
content: input,
|
content: input,
|
||||||
upload_ids,
|
upload_ids,
|
||||||
client_message_id: None,
|
|
||||||
channel: None,
|
channel: None,
|
||||||
// Session routing is owned by the server. A full session
|
// Session routing is owned by the server. A full session
|
||||||
// id is not a chat id and must never be sent here.
|
// id is not a chat id and must never be sent here.
|
||||||
|
|||||||
1115
src/config/mod.rs
1115
src/config/mod.rs
File diff suppressed because it is too large
Load Diff
@ -84,18 +84,16 @@ impl TurnDeliveryService {
|
|||||||
&self,
|
&self,
|
||||||
target: &TurnTarget,
|
target: &TurnTarget,
|
||||||
delta: CommittedTurnDelta,
|
delta: CommittedTurnDelta,
|
||||||
) -> Result<bool, DeliveryError> {
|
) -> Result<(), DeliveryError> {
|
||||||
let channel = self
|
let channel = self
|
||||||
.channels
|
.channels
|
||||||
.get_channel(&target.channel)
|
.get_channel(&target.channel)
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| DeliveryError::ChannelNotFound(target.channel.clone()))?;
|
.ok_or_else(|| DeliveryError::ChannelNotFound(target.channel.clone()))?;
|
||||||
let presents_media = channel.commit_turn_presents_media();
|
|
||||||
channel
|
channel
|
||||||
.commit_turn(target, delta)
|
.commit_turn(target, delta)
|
||||||
.await
|
.await
|
||||||
.map_err(DeliveryError::FinalFailed)?;
|
.map_err(DeliveryError::FinalFailed)
|
||||||
Ok(presents_media)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use super::GatewayState;
|
use super::GatewayState;
|
||||||
use crate::config::{Config, ConfigDiagnostic, cleanup_invalid_config, source_revision};
|
use crate::config::Config;
|
||||||
use crate::memory::MemoryCategory;
|
use crate::memory::MemoryCategory;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
@ -44,12 +44,6 @@ pub async fn health() -> Json<HealthResponse> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn health_report(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
) -> Json<crate::health::HealthReport> {
|
|
||||||
Json(state.health.check().await)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn static_response(content_type: &'static str, content: &'static str) -> Response {
|
fn static_response(content_type: &'static str, content: &'static str) -> Response {
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.header(header::CONTENT_TYPE, content_type)
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
@ -93,22 +87,20 @@ pub async fn webui_theme_init() -> Response {
|
|||||||
|
|
||||||
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
|
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
|
||||||
(
|
(
|
||||||
"space-grotesk.woff2",
|
"space-grotesk-500.woff2",
|
||||||
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk.woff2")),
|
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-500.woff2")),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"space-grotesk-700.woff2",
|
||||||
|
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-700.woff2")),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"jetbrains-mono-400.woff2",
|
"jetbrains-mono-400.woff2",
|
||||||
include_bytes!(concat!(
|
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-400.woff2")),
|
||||||
env!("OUT_DIR"),
|
|
||||||
"/webui/fonts/jetbrains-mono-400.woff2"
|
|
||||||
)),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"jetbrains-mono-700.woff2",
|
"jetbrains-mono-700.woff2",
|
||||||
include_bytes!(concat!(
|
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-700.woff2")),
|
||||||
env!("OUT_DIR"),
|
|
||||||
"/webui/fonts/jetbrains-mono-700.woff2"
|
|
||||||
)),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -413,16 +405,9 @@ impl IntoResponse for ApiError {
|
|||||||
pub struct ConfigResponse {
|
pub struct ConfigResponse {
|
||||||
config: Value,
|
config: Value,
|
||||||
path: String,
|
path: String,
|
||||||
revision: String,
|
|
||||||
diagnostics: Vec<ConfigDiagnostic>,
|
|
||||||
restart_required: bool,
|
restart_required: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct CleanupInvalidConfigRequest {
|
|
||||||
revision: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ReloadResponse {
|
pub struct ReloadResponse {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
@ -456,32 +441,14 @@ pub async fn reload_status(
|
|||||||
pub async fn get_config(
|
pub async fn get_config(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||||
let _write_guard = state.config_write_lock.lock().await;
|
|
||||||
let raw = tokio::fs::read_to_string(&state.config_path)
|
let raw = tokio::fs::read_to_string(&state.config_path)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::internal)?;
|
.map_err(ApiError::internal)?;
|
||||||
let revision = source_revision(&raw);
|
|
||||||
let path = state.config_path.clone();
|
|
||||||
let context = state.config_load_context.clone();
|
|
||||||
let inspected = tokio::task::spawn_blocking(move || {
|
|
||||||
Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?
|
|
||||||
.map_err(ApiError::bad_request)?;
|
|
||||||
if inspected.source_revision != revision {
|
|
||||||
return Err(ApiError::conflict(
|
|
||||||
"config.json changed while it was being inspected; retry",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
|
let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
|
||||||
redact_secrets(&mut value);
|
redact_secrets(&mut value);
|
||||||
Ok(Json(ConfigResponse {
|
Ok(Json(ConfigResponse {
|
||||||
config: value,
|
config: value,
|
||||||
path: state.config_path.display().to_string(),
|
path: state.config_path.display().to_string(),
|
||||||
revision,
|
|
||||||
diagnostics: inspected.diagnostics,
|
|
||||||
restart_required: false,
|
restart_required: false,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -490,7 +457,6 @@ pub async fn put_config(
|
|||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Json(mut incoming): Json<Value>,
|
Json(mut incoming): Json<Value>,
|
||||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||||
let _write_guard = state.config_write_lock.lock().await;
|
|
||||||
if incoming.get("config").is_some() {
|
if incoming.get("config").is_some() {
|
||||||
incoming = incoming
|
incoming = incoming
|
||||||
.get_mut("config")
|
.get_mut("config")
|
||||||
@ -509,7 +475,7 @@ pub async fn put_config(
|
|||||||
.map_err(ApiError::internal)?;
|
.map_err(ApiError::internal)?;
|
||||||
let current: Value = serde_json::from_str(¤t_raw).map_err(ApiError::internal)?;
|
let current: Value = serde_json::from_str(¤t_raw).map_err(ApiError::internal)?;
|
||||||
restore_redacted_secrets(&mut incoming, ¤t);
|
restore_redacted_secrets(&mut incoming, ¤t);
|
||||||
let parsed = Config::from_value_strict(incoming.clone())
|
let parsed: Config = serde_json::from_value(incoming.clone())
|
||||||
.map_err(|error| ApiError::bad_request(format!("invalid config: {error}")))?;
|
.map_err(|error| ApiError::bad_request(format!("invalid config: {error}")))?;
|
||||||
parsed
|
parsed
|
||||||
.get_provider_config("default")
|
.get_provider_config("default")
|
||||||
@ -524,78 +490,10 @@ pub async fn put_config(
|
|||||||
Ok(Json(ConfigResponse {
|
Ok(Json(ConfigResponse {
|
||||||
config: response,
|
config: response,
|
||||||
path: state.config_path.display().to_string(),
|
path: state.config_path.display().to_string(),
|
||||||
revision: source_revision(&pretty),
|
|
||||||
diagnostics: Vec::new(),
|
|
||||||
restart_required: true,
|
restart_required: true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cleanup_invalid_config_entries(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Json(request): Json<CleanupInvalidConfigRequest>,
|
|
||||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
|
||||||
let _write_guard = state.config_write_lock.lock().await;
|
|
||||||
let raw = tokio::fs::read_to_string(&state.config_path)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
let current_revision = source_revision(&raw);
|
|
||||||
if request.revision != current_revision {
|
|
||||||
return Err(ApiError::conflict(
|
|
||||||
"config.json changed after it was displayed; reload the page before cleaning",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = state.config_path.clone();
|
|
||||||
let context = state.config_load_context.clone();
|
|
||||||
let inspected = tokio::task::spawn_blocking(move || {
|
|
||||||
Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?
|
|
||||||
.map_err(ApiError::bad_request)?;
|
|
||||||
if inspected.source_revision != current_revision {
|
|
||||||
return Err(ApiError::conflict(
|
|
||||||
"config.json changed while it was being inspected; retry",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
|
|
||||||
let removed = cleanup_invalid_config(&mut value, &inspected.diagnostics);
|
|
||||||
let pretty = serde_json::to_string_pretty(&value).map_err(ApiError::internal)? + "\n";
|
|
||||||
if removed > 0 {
|
|
||||||
atomic_write(&state.config_path, pretty.as_bytes()).await?;
|
|
||||||
tracing::info!(
|
|
||||||
path = %state.config_path.display(),
|
|
||||||
removed,
|
|
||||||
"Invalid configuration entries removed from WebUI"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let post_cleanup = if removed > 0 {
|
|
||||||
let path = state.config_path.clone();
|
|
||||||
let context = state.config_load_context.clone();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?
|
|
||||||
.map_err(ApiError::bad_request)?
|
|
||||||
} else {
|
|
||||||
inspected
|
|
||||||
};
|
|
||||||
let mut response = value;
|
|
||||||
redact_secrets(&mut response);
|
|
||||||
Ok(Json(ConfigResponse {
|
|
||||||
config: response,
|
|
||||||
path: state.config_path.display().to_string(),
|
|
||||||
revision: post_cleanup.source_revision,
|
|
||||||
diagnostics: post_cleanup.diagnostics,
|
|
||||||
restart_required: removed > 0,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_secret_key(key: &str) -> bool {
|
fn is_secret_key(key: &str) -> bool {
|
||||||
let key = key.to_ascii_lowercase();
|
let key = key.to_ascii_lowercase();
|
||||||
key.contains("api_key")
|
key.contains("api_key")
|
||||||
@ -799,25 +697,14 @@ pub struct LimitQuery {
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct AgentRunsQuery {
|
|
||||||
session_id: String,
|
|
||||||
cursor: Option<String>,
|
|
||||||
limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scheduler_snapshot(jobs: &[crate::storage::ScheduledJob]) -> Value {
|
fn scheduler_snapshot(jobs: &[crate::storage::ScheduledJob]) -> Value {
|
||||||
let enabled = jobs.iter().filter(|job| job.enabled).count();
|
let enabled = jobs.iter().filter(|job| job.enabled).count();
|
||||||
let failed_jobs = jobs
|
let failed_jobs = jobs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|job| {
|
.filter(|job| {
|
||||||
matches!(
|
matches!(
|
||||||
job.last_outcome,
|
job.last_status.as_deref(),
|
||||||
Some(
|
Some("error" | "timeout" | "delivery_error")
|
||||||
crate::storage::ScheduledOutcomeKind::Failed
|
|
||||||
| crate::storage::ScheduledOutcomeKind::Refused
|
|
||||||
| crate::storage::ScheduledOutcomeKind::Unknown
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.count();
|
.count();
|
||||||
@ -895,20 +782,8 @@ pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<V
|
|||||||
.map(|status| {
|
.map(|status| {
|
||||||
json!({
|
json!({
|
||||||
"name": status.name,
|
"name": status.name,
|
||||||
"transport": status.transport,
|
|
||||||
"connected": status.connected,
|
"connected": status.connected,
|
||||||
"error": status.error,
|
"tools": status.tools.len(),
|
||||||
"tools": status
|
|
||||||
.tools
|
|
||||||
.iter()
|
|
||||||
.map(|tool| json!({
|
|
||||||
"name": tool.name,
|
|
||||||
"description": tool.description,
|
|
||||||
"read_only": tool.read_only,
|
|
||||||
"exclusive": tool.exclusive,
|
|
||||||
"concurrency_safe": tool.concurrency_safe,
|
|
||||||
}))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@ -945,505 +820,19 @@ pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<V
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
|
|
||||||
let metrics = crate::observability::metrics::global_metrics();
|
|
||||||
let registry = state.session_manager.tools();
|
|
||||||
let mut entries = registry.iter();
|
|
||||||
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
||||||
let tools: Vec<Value> = entries
|
|
||||||
.into_iter()
|
|
||||||
.map(|(name, tool)| {
|
|
||||||
let source = if crate::mcp::is_mcp_tool_name(&name) {
|
|
||||||
"mcp"
|
|
||||||
} else {
|
|
||||||
"builtin"
|
|
||||||
};
|
|
||||||
json!({
|
|
||||||
"name": name,
|
|
||||||
"description": tool.description(),
|
|
||||||
"parameters_schema": tool.parameters_schema(),
|
|
||||||
"source": source,
|
|
||||||
"read_only": tool.read_only(),
|
|
||||||
"exclusive": tool.exclusive(),
|
|
||||||
"concurrency_safe": tool.concurrency_safe(),
|
|
||||||
"call_count": metrics.tool_call_count(&name),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(Json(json!({ "tools": tools })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List Agent definition files (enabled and disabled) from the resolved
|
|
||||||
/// definitions directory. Broken definitions are listed too (with their parse
|
|
||||||
/// error) and every entry is annotated with any load error from the active
|
|
||||||
/// catalog generation, so a definition that failed validation can be fixed and
|
|
||||||
/// re-enabled from the UI instead of silently disappearing.
|
|
||||||
pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
|
|
||||||
let load_errors: std::collections::HashMap<&str, &crate::agent::CatalogEntryError> = state
|
|
||||||
.agent_catalog
|
|
||||||
.load_errors()
|
|
||||||
.iter()
|
|
||||||
.map(|error| (error.id.as_str(), error))
|
|
||||||
.collect();
|
|
||||||
let mut agents = Vec::new();
|
|
||||||
let entries = match std::fs::read_dir(&state.agents_dir) {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(_error) => {
|
|
||||||
return Ok(Json(json!({ "agents": [] })));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (info, parse_error) = crate::agent::definition::parse_definition_lenient(&path);
|
|
||||||
let Some(info) = info else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let fm = &info.frontmatter;
|
|
||||||
let load_error = load_errors
|
|
||||||
.get(fm.id.as_str())
|
|
||||||
.map(|error| error.reason.clone());
|
|
||||||
let disabled = load_error.is_some() || parse_error.is_some();
|
|
||||||
agents.push(json!({
|
|
||||||
"id": fm.id,
|
|
||||||
"description": fm.description,
|
|
||||||
"enabled": !disabled && fm.enabled,
|
|
||||||
"llm_profile": fm.llm_profile,
|
|
||||||
"provider": fm.provider,
|
|
||||||
"model": fm.model,
|
|
||||||
"token_limit": fm.token_limit,
|
|
||||||
"max_tool_iterations": fm.max_tool_iterations,
|
|
||||||
"tools": fm.tools,
|
|
||||||
"delegates": fm.delegates,
|
|
||||||
"skills": fm.skills,
|
|
||||||
"limits": fm.limits,
|
|
||||||
"signal": fm.signal,
|
|
||||||
"role_prompt": info.role_prompt,
|
|
||||||
"load_error": load_error,
|
|
||||||
"parse_error": parse_error,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
agents.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
|
|
||||||
Ok(Json(json!({ "agents": agents })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create or update a definition file under the definitions directory.
|
|
||||||
pub async fn put_agent(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let info = agent_info_from_json(&body)?;
|
|
||||||
let fm = &info.frontmatter;
|
|
||||||
|
|
||||||
// Validate referenced provider/model/tools/skills so a broken file is
|
|
||||||
// rejected at the API boundary instead of breaking the next reload.
|
|
||||||
if let Some(provider) = fm.provider.as_deref()
|
|
||||||
&& !state.config.providers.contains_key(provider)
|
|
||||||
{
|
|
||||||
return Err(ApiError::bad_request(format!(
|
|
||||||
"unknown provider '{provider}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if let Some(model) = fm.model.as_deref()
|
|
||||||
&& !state.config.models.contains_key(model)
|
|
||||||
{
|
|
||||||
return Err(ApiError::bad_request(format!("unknown model '{model}'")));
|
|
||||||
}
|
|
||||||
let registry = state.session_manager.tools();
|
|
||||||
for tool in &fm.tools {
|
|
||||||
let Some(registered) = registry.get(tool) else {
|
|
||||||
return Err(ApiError::bad_request(format!("unknown tool '{tool}'")));
|
|
||||||
};
|
|
||||||
if registered.runtime_injected() && tool != "get_skill" {
|
|
||||||
return Err(ApiError::bad_request(format!(
|
|
||||||
"tool '{tool}' is runtime-injected and cannot be declared"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let loaded_skills: std::collections::HashSet<String> = state
|
|
||||||
.session_manager
|
|
||||||
.skills_loader()
|
|
||||||
.list_skills()
|
|
||||||
.into_iter()
|
|
||||||
.map(|(name, _)| name)
|
|
||||||
.collect();
|
|
||||||
for skill in &fm.skills {
|
|
||||||
if !loaded_skills.contains(skill) {
|
|
||||||
return Err(ApiError::bad_request(format!("unknown skill '{skill}'")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !fm.skills.is_empty() && !fm.tools.iter().any(|t| t == "get_skill") {
|
|
||||||
return Err(ApiError::bad_request(
|
|
||||||
"skills require the get_skill tool in the definition",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = crate::agent::definition::serialize_definition(&info);
|
|
||||||
tokio::fs::create_dir_all(&state.agents_dir)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
let path = state.agents_dir.join(format!("{}.md", fm.id));
|
|
||||||
tokio::fs::write(&path, content)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
Ok(Json(json!({ "id": fm.id, "saved": true })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a definition file.
|
|
||||||
pub async fn delete_agent(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
crate::agent::definition::validate_agent_id(&id)
|
|
||||||
.map_err(|error| ApiError::bad_request(error.to_string()))?;
|
|
||||||
let path = state.agents_dir.join(format!("{id}.md"));
|
|
||||||
match tokio::fs::remove_file(&path).await {
|
|
||||||
Ok(()) => Ok(Json(json!({ "deleted": id }))),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
||||||
Err(ApiError::not_found(format!("agent {id} not found")))
|
|
||||||
}
|
|
||||||
Err(error) => Err(ApiError::internal(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Available providers/models/tools/skills for the editor UI.
|
|
||||||
pub async fn get_agent_options(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let providers: Vec<String> = state.config.providers.keys().cloned().collect();
|
|
||||||
let models: Vec<Value> = state
|
|
||||||
.config
|
|
||||||
.models
|
|
||||||
.iter()
|
|
||||||
.map(|(name, model)| {
|
|
||||||
json!({
|
|
||||||
"name": name,
|
|
||||||
"model_id": model.model_id,
|
|
||||||
"token_limit": model.token_limit,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let registry = state.session_manager.tools();
|
|
||||||
let mut tools: Vec<Value> = registry
|
|
||||||
.iter()
|
|
||||||
.into_iter()
|
|
||||||
// get_skill is the one runtime-injected tool that may be declared in
|
|
||||||
// a definition's `tools` list (it turns on the scoped skill wrapper),
|
|
||||||
// so it must be offered in the editor.
|
|
||||||
.filter(|(name, tool)| {
|
|
||||||
(!tool.runtime_injected() || name == "get_skill") && !crate::mcp::is_mcp_tool_name(name)
|
|
||||||
})
|
|
||||||
.map(|(name, tool)| json!({ "name": name, "description": tool.description() }))
|
|
||||||
.collect();
|
|
||||||
tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
|
||||||
let skills: Vec<String> = state
|
|
||||||
.session_manager
|
|
||||||
.skills_loader()
|
|
||||||
.list_skills()
|
|
||||||
.into_iter()
|
|
||||||
.map(|(name, _)| name)
|
|
||||||
.collect();
|
|
||||||
Ok(Json(json!({
|
|
||||||
"providers": providers,
|
|
||||||
"models": models,
|
|
||||||
"tools": tools,
|
|
||||||
"skills": skills,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent_info_from_json(
|
|
||||||
body: &Value,
|
|
||||||
) -> Result<crate::agent::definition::AgentDefinitionInfo, ApiError> {
|
|
||||||
use crate::agent::definition::{AgentDefinitionInfo, AgentFrontmatter, AgentLimits};
|
|
||||||
let id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| ApiError::bad_request("missing required field: id"))?
|
|
||||||
.to_string();
|
|
||||||
crate::agent::definition::validate_agent_id(&id)
|
|
||||||
.map_err(|error| ApiError::bad_request(error.to_string()))?;
|
|
||||||
let description = body
|
|
||||||
.get("description")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
let role_prompt = body
|
|
||||||
.get("role_prompt")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
if role_prompt.trim().is_empty() {
|
|
||||||
return Err(ApiError::bad_request("role_prompt must not be empty"));
|
|
||||||
}
|
|
||||||
let provider = body
|
|
||||||
.get("provider")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string);
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string);
|
|
||||||
let llm_profile = body
|
|
||||||
.get("llm_profile")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string);
|
|
||||||
if provider.is_none() && model.is_none() && llm_profile.as_deref().is_none_or(str::is_empty) {
|
|
||||||
return Err(ApiError::bad_request(
|
|
||||||
"either provider+model or llm_profile is required",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if provider.is_some() != model.is_some() {
|
|
||||||
return Err(ApiError::bad_request(
|
|
||||||
"provider and model must be set together",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let info = AgentDefinitionInfo {
|
|
||||||
frontmatter: AgentFrontmatter {
|
|
||||||
id,
|
|
||||||
description,
|
|
||||||
llm_profile: llm_profile.filter(|v| !v.is_empty()),
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
token_limit: body
|
|
||||||
.get("token_limit")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|v| v as usize),
|
|
||||||
max_tool_iterations: body
|
|
||||||
.get("max_tool_iterations")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|v| v as usize),
|
|
||||||
enabled: body.get("enabled").and_then(Value::as_bool).unwrap_or(true),
|
|
||||||
tools: string_array(body, "tools"),
|
|
||||||
delegates: body
|
|
||||||
.get("delegates")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|items| {
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.filter_map(Value::as_str)
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect()
|
|
||||||
}),
|
|
||||||
skills: string_array(body, "skills"),
|
|
||||||
limits: body
|
|
||||||
.get("limits")
|
|
||||||
.and_then(|v| serde_json::from_value::<AgentLimits>(v.clone()).ok())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
signal: body
|
|
||||||
.get("signal")
|
|
||||||
.and_then(|v| serde_json::from_value(v.clone()).ok()),
|
|
||||||
},
|
|
||||||
role_prompt,
|
|
||||||
};
|
|
||||||
Ok(info)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn string_array(body: &Value, key: &str) -> Vec<String> {
|
|
||||||
body.get(key)
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|items| {
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.filter_map(Value::as_str)
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
|
|
||||||
let loader = state.session_manager.skills_loader();
|
|
||||||
let skills: Vec<Value> = loader
|
|
||||||
.get_loaded_skills()
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
json!({
|
|
||||||
"name": s.name,
|
|
||||||
"description": s.description,
|
|
||||||
"always": s.always,
|
|
||||||
"enabled": loader.is_enabled(&s.name),
|
|
||||||
"source": loader.source_of(s.path.as_deref()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(Json(json!({ "skills": skills })))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct SkillEnableUpdate {
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable a skill. The change is persisted to the skills state
|
|
||||||
/// file and takes effect immediately (disabled skills are excluded from
|
|
||||||
/// prompts, listings, and `get_skill`).
|
|
||||||
pub async fn put_skill_enabled(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path(name): Path<String>,
|
|
||||||
Json(update): Json<SkillEnableUpdate>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let loader = state.session_manager.skills_loader();
|
|
||||||
let known = loader.get_loaded_skills().iter().any(|s| s.name == name);
|
|
||||||
if !known {
|
|
||||||
return Err(ApiError::not_found(format!("skill {name} not found")));
|
|
||||||
}
|
|
||||||
loader
|
|
||||||
.set_enabled(&name, update.enabled)
|
|
||||||
.map_err(ApiError::bad_request)?;
|
|
||||||
let skills: Vec<Value> = loader
|
|
||||||
.get_loaded_skills()
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
json!({
|
|
||||||
"name": s.name,
|
|
||||||
"description": s.description,
|
|
||||||
"always": s.always,
|
|
||||||
"enabled": loader.is_enabled(&s.name),
|
|
||||||
"source": loader.source_of(s.path.as_deref()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(Json(json!({ "skills": skills })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_tasks(
|
pub async fn get_tasks(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Query(query): Query<LimitQuery>,
|
Query(query): Query<LimitQuery>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let limit = query.limit.unwrap_or(100).clamp(1, 500);
|
let limit = query.limit.unwrap_or(100).clamp(1, 500);
|
||||||
let runs = state
|
let tasks = state
|
||||||
.storage
|
.storage
|
||||||
.list_all_agent_runs(None, limit as i64)
|
.list_recent_background_tasks(limit)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::internal)?;
|
.map_err(ApiError::internal)?;
|
||||||
let tasks: Vec<Value> = runs
|
|
||||||
.into_iter()
|
|
||||||
.map(|run| {
|
|
||||||
json!({
|
|
||||||
"source": "agent_run",
|
|
||||||
"id": run.id,
|
|
||||||
"parent_run_id": run.parent_run_id,
|
|
||||||
"session_id": run.root_session_id,
|
|
||||||
"agent_id": run.agent_id,
|
|
||||||
"mode": run.mode.as_str(),
|
|
||||||
"depth": run.depth,
|
|
||||||
"prompt": run.task,
|
|
||||||
"status": run.status.as_str(),
|
|
||||||
"result": run.result,
|
|
||||||
"error": run.error,
|
|
||||||
"tool_calls_count": run.tool_calls_count,
|
|
||||||
"iterations": run.iterations,
|
|
||||||
"started_at": run.started_at,
|
|
||||||
"finished_at": run.finished_at,
|
|
||||||
"created_at": run.created_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(Json(json!({ "tasks": tasks })))
|
Ok(Json(json!({ "tasks": tasks })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session-scoped durable run listing with `(created_at,id)` cursor paging.
|
|
||||||
pub async fn get_agent_runs(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Query(query): Query<AgentRunsQuery>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let Some(coordinator) = state.session_manager.agent_coordinator() else {
|
|
||||||
return Ok(Json(json!({
|
|
||||||
"revision": 0,
|
|
||||||
"runs": [],
|
|
||||||
"next_cursor": Value::Null,
|
|
||||||
})));
|
|
||||||
};
|
|
||||||
let cursor = query.cursor.as_deref().and_then(|cursor| {
|
|
||||||
let (created_at, id) = cursor.split_once(':')?;
|
|
||||||
Some((created_at.parse::<i64>().ok()?, id.to_string()))
|
|
||||||
});
|
|
||||||
let limit = query.limit.unwrap_or(100).clamp(1, 200) as i64;
|
|
||||||
let (revision, runs, next_cursor) = coordinator
|
|
||||||
.list_runs_for_session(&query.session_id, cursor, limit)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
Ok(Json(json!({
|
|
||||||
"revision": revision,
|
|
||||||
"runs": runs,
|
|
||||||
"next_cursor": next_cursor,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_agent_run(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let Some(_coordinator) = state.session_manager.agent_coordinator() else {
|
|
||||||
return Err(ApiError::not_found("run not found".to_string()));
|
|
||||||
};
|
|
||||||
let run = state
|
|
||||||
.storage
|
|
||||||
.get_agent_run(&id)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
let Some(run) = run else {
|
|
||||||
return Err(ApiError::not_found(format!("run {id} not found")));
|
|
||||||
};
|
|
||||||
let session_id = run.root_session_id.clone();
|
|
||||||
let transcript = state
|
|
||||||
.storage
|
|
||||||
.list_agent_run_messages(&id, 10_000)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?
|
|
||||||
.into_iter()
|
|
||||||
.map(crate::protocol::AgentTranscriptMessage::from)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Ok(Json(json!({
|
|
||||||
"run": crate::protocol::AgentRunView::from_record(&run, 100_000),
|
|
||||||
"session_id": session_id,
|
|
||||||
"transcript": transcript,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_agent_run_events(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
Query(query): Query<LimitQuery>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let Some(coordinator) = state.session_manager.agent_coordinator() else {
|
|
||||||
return Ok(Json(json!({ "events": [] })));
|
|
||||||
};
|
|
||||||
let limit = query.limit.unwrap_or(100).clamp(1, 200) as i64;
|
|
||||||
let events = coordinator
|
|
||||||
.list_run_events(&id, limit)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?
|
|
||||||
.iter()
|
|
||||||
.map(crate::protocol::AgentEventView::from_record)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Ok(Json(json!({ "events": events })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cancel_agent_run(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let Some(coordinator) = state.session_manager.agent_coordinator() else {
|
|
||||||
return Err(ApiError::not_found("run not found".to_string()));
|
|
||||||
};
|
|
||||||
let Some(run) = state
|
|
||||||
.storage
|
|
||||||
.get_agent_run(&id)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?
|
|
||||||
else {
|
|
||||||
return Err(ApiError::not_found(format!("run {id} not found")));
|
|
||||||
};
|
|
||||||
let cancelled = coordinator
|
|
||||||
.cancel_run_for_session(&run.root_session_id, &id, "cancelled from management UI")
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
Ok(Json(json!({ "cancelled": cancelled, "run_id": id })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_jobs(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
|
pub async fn get_jobs(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
|
||||||
let jobs = state
|
let jobs = state
|
||||||
.storage
|
.storage
|
||||||
@ -1469,26 +858,6 @@ pub async fn get_job_runs(
|
|||||||
.list_scheduled_job_runs(&id, limit)
|
.list_scheduled_job_runs(&id, limit)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::internal)?;
|
.map_err(ApiError::internal)?;
|
||||||
let runs = runs
|
|
||||||
.into_iter()
|
|
||||||
.map(|run| {
|
|
||||||
json!({
|
|
||||||
"id": run.id,
|
|
||||||
"job_id": run.job_id,
|
|
||||||
"scheduled_for": run.scheduled_for,
|
|
||||||
"started_at": run.started_at,
|
|
||||||
"finished_at": run.finished_at,
|
|
||||||
"status": run.status,
|
|
||||||
"outcome": run.outcome,
|
|
||||||
"message": run.message,
|
|
||||||
"diagnostic": run.diagnostic,
|
|
||||||
"duration_ms": run.duration_ms,
|
|
||||||
"delivery_status": run.delivery_status,
|
|
||||||
"delivery_attempts": run.delivery_attempts,
|
|
||||||
"delivery_error": run.delivery_error,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Ok(Json(json!({ "runs": runs })))
|
Ok(Json(json!({ "runs": runs })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1537,49 +906,6 @@ pub async fn get_memories(
|
|||||||
Ok(Json(json!({ "memories": memories })))
|
Ok(Json(json!({ "memories": memories })))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct PutMemoryBody {
|
|
||||||
content: String,
|
|
||||||
importance: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn put_memory(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path(key): Path<String>,
|
|
||||||
Json(body): Json<PutMemoryBody>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let existing = state
|
|
||||||
.storage
|
|
||||||
.get_memory_by_key(&key)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?
|
|
||||||
.ok_or_else(|| ApiError::not_found("memory not found"))?;
|
|
||||||
let mut updated = existing;
|
|
||||||
updated.content = body.content;
|
|
||||||
if let Some(importance) = body.importance {
|
|
||||||
updated.importance = importance.clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
updated.updated_at = chrono::Utc::now().to_rfc3339();
|
|
||||||
state
|
|
||||||
.storage
|
|
||||||
.upsert_memory(&updated)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
Ok(Json(json!({ "updated": true, "key": key })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_memory(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path(key): Path<String>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
state
|
|
||||||
.storage
|
|
||||||
.delete_memory(&key)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
Ok(Json(json!({ "deleted": true })))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -1588,57 +914,37 @@ mod tests {
|
|||||||
id: &str,
|
id: &str,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
next_run_at: i64,
|
next_run_at: i64,
|
||||||
last_outcome: Option<crate::storage::ScheduledOutcomeKind>,
|
last_status: Option<&str>,
|
||||||
) -> crate::storage::ScheduledJob {
|
) -> crate::storage::ScheduledJob {
|
||||||
crate::storage::ScheduledJob {
|
crate::storage::ScheduledJob {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name: id.to_string(),
|
name: id.to_string(),
|
||||||
schedule: crate::scheduler::Schedule::Every { every_ms: 60_000 },
|
schedule: crate::scheduler::Schedule::Every { every_ms: 60_000 },
|
||||||
prompt: String::new(),
|
prompt: String::new(),
|
||||||
agent_id: None,
|
|
||||||
channel: "cli_chat".to_string(),
|
channel: "cli_chat".to_string(),
|
||||||
chat_id: "test".to_string(),
|
chat_id: "test".to_string(),
|
||||||
|
model: None,
|
||||||
|
job_kind: crate::storage::JobKind::Task,
|
||||||
delivery_policy: crate::storage::DeliveryPolicy::Never,
|
delivery_policy: crate::storage::DeliveryPolicy::Never,
|
||||||
enabled,
|
enabled,
|
||||||
|
delete_after_run: false,
|
||||||
next_run_at,
|
next_run_at,
|
||||||
last_run_at: None,
|
last_run_at: None,
|
||||||
last_outcome,
|
last_status: last_status.map(str::to_string),
|
||||||
|
last_error: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
updated_at: 0,
|
updated_at: 0,
|
||||||
locked_at: None,
|
|
||||||
lock_owner: None,
|
|
||||||
lease_until: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scheduler_snapshot_classifies_failures_and_next_enabled_run() {
|
fn scheduler_snapshot_classifies_failures_and_next_enabled_run() {
|
||||||
let jobs = vec![
|
let jobs = vec![
|
||||||
scheduled_job(
|
scheduled_job("healthy", true, 300, Some("ok")),
|
||||||
"healthy",
|
scheduled_job("error", true, 200, Some("error")),
|
||||||
true,
|
scheduled_job("timeout", false, 100, Some("timeout")),
|
||||||
300,
|
scheduled_job("delivery", true, 400, Some("delivery_error")),
|
||||||
Some(crate::storage::ScheduledOutcomeKind::Ok),
|
scheduled_job("other", false, 50, Some("cancelled")),
|
||||||
),
|
|
||||||
scheduled_job(
|
|
||||||
"error",
|
|
||||||
true,
|
|
||||||
200,
|
|
||||||
Some(crate::storage::ScheduledOutcomeKind::Failed),
|
|
||||||
),
|
|
||||||
scheduled_job(
|
|
||||||
"refused",
|
|
||||||
false,
|
|
||||||
100,
|
|
||||||
Some(crate::storage::ScheduledOutcomeKind::Refused),
|
|
||||||
),
|
|
||||||
scheduled_job(
|
|
||||||
"unknown",
|
|
||||||
true,
|
|
||||||
400,
|
|
||||||
Some(crate::storage::ScheduledOutcomeKind::Unknown),
|
|
||||||
),
|
|
||||||
scheduled_job("other", false, 50, None),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -13,13 +13,13 @@ use tokio::net::TcpListener;
|
|||||||
|
|
||||||
use crate::bus::{MessageBus, OutboundDispatcher};
|
use crate::bus::{MessageBus, OutboundDispatcher};
|
||||||
use crate::channels::{ChannelManager, CliChatChannel};
|
use crate::channels::{ChannelManager, CliChatChannel};
|
||||||
use crate::config::{Config, ConfigLoadContext, ensure_workspace_dir, expand_path};
|
use crate::config::{Config, ensure_workspace_dir, expand_path};
|
||||||
use crate::delivery::{ConversationWriteLocks, DeliveryCoordinator, TurnDeliveryService};
|
use crate::delivery::{ConversationWriteLocks, DeliveryCoordinator, TurnDeliveryService};
|
||||||
use crate::logging;
|
use crate::logging;
|
||||||
use crate::mcp;
|
use crate::mcp;
|
||||||
use crate::memory::MemoryManager;
|
use crate::memory::MemoryManager;
|
||||||
use crate::scheduler::Scheduler;
|
use crate::scheduler::Scheduler;
|
||||||
use crate::session::{AgentCatalogPreparation, SessionManager, SessionManagerServices};
|
use crate::session::{SessionManager, SessionManagerServices};
|
||||||
use crate::task_supervisor::TaskSupervisor;
|
use crate::task_supervisor::TaskSupervisor;
|
||||||
|
|
||||||
/// Process boot clock. A process-level static so uptime survives config reload,
|
/// Process boot clock. A process-level static so uptime survives config reload,
|
||||||
@ -37,10 +37,7 @@ pub fn process_uptime_secs() -> u64 {
|
|||||||
pub struct GatewayState {
|
pub struct GatewayState {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub config_path: std::path::PathBuf,
|
pub config_path: std::path::PathBuf,
|
||||||
pub(crate) config_load_context: Arc<ConfigLoadContext>,
|
|
||||||
pub(crate) config_write_lock: Arc<tokio::sync::Mutex<()>>,
|
|
||||||
pub workspace_dir: std::path::PathBuf,
|
pub workspace_dir: std::path::PathBuf,
|
||||||
pub(crate) health: Arc<crate::health::HealthService>,
|
|
||||||
pub session_manager: Arc<SessionManager>,
|
pub session_manager: Arc<SessionManager>,
|
||||||
pub channel_manager: ChannelManager,
|
pub channel_manager: ChannelManager,
|
||||||
pub storage: Arc<crate::storage::Storage>,
|
pub storage: Arc<crate::storage::Storage>,
|
||||||
@ -55,9 +52,6 @@ pub struct GatewayState {
|
|||||||
pub outbound_lanes: Arc<AtomicUsize>,
|
pub outbound_lanes: Arc<AtomicUsize>,
|
||||||
pub(crate) reload: reload::ReloadHandle,
|
pub(crate) reload: reload::ReloadHandle,
|
||||||
pub(crate) admission: reload::RuntimeAdmission,
|
pub(crate) admission: reload::RuntimeAdmission,
|
||||||
pub agent_catalog: Arc<crate::agent::AgentCatalog>,
|
|
||||||
/// Directory holding Agent definition files (resolved definitions_dir).
|
|
||||||
pub agents_dir: std::path::PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayState {
|
impl GatewayState {
|
||||||
@ -65,16 +59,12 @@ impl GatewayState {
|
|||||||
/// when the state is owned by [`run`], which owns the generation loop.
|
/// when the state is owned by [`run`], which owns the generation loop.
|
||||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let config_path = crate::config::resolve_default_config_path();
|
let config_path = crate::config::resolve_default_config_path();
|
||||||
let config_load_context = Arc::new(Config::load_context());
|
let config = Config::load_from(&config_path)?;
|
||||||
let config = Config::load_for_startup(&config_path, &config_load_context)?;
|
|
||||||
Self::from_config(
|
Self::from_config(
|
||||||
config,
|
config,
|
||||||
config_path,
|
config_path,
|
||||||
config_load_context,
|
|
||||||
Arc::new(tokio::sync::Mutex::new(())),
|
|
||||||
reload::ReloadHandle::unavailable(),
|
reload::ReloadHandle::unavailable(),
|
||||||
true,
|
true,
|
||||||
1,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -82,11 +72,8 @@ impl GatewayState {
|
|||||||
async fn from_config(
|
async fn from_config(
|
||||||
config: Config,
|
config: Config,
|
||||||
config_path: std::path::PathBuf,
|
config_path: std::path::PathBuf,
|
||||||
config_load_context: Arc<ConfigLoadContext>,
|
|
||||||
config_write_lock: Arc<tokio::sync::Mutex<()>>,
|
|
||||||
reload: reload::ReloadHandle,
|
reload: reload::ReloadHandle,
|
||||||
initialize_process: bool,
|
initialize_process: bool,
|
||||||
runtime_generation: u64,
|
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let task_supervisor = TaskSupervisor::new();
|
let task_supervisor = TaskSupervisor::new();
|
||||||
let admission = reload::RuntimeAdmission::open();
|
let admission = reload::RuntimeAdmission::open();
|
||||||
@ -131,17 +118,8 @@ impl GatewayState {
|
|||||||
let db_path = if let Some(ref path) = config.gateway.session_db_path {
|
let db_path = if let Some(ref path) = config.gateway.session_db_path {
|
||||||
std::path::PathBuf::from(path)
|
std::path::PathBuf::from(path)
|
||||||
} else {
|
} else {
|
||||||
crate::config::get_default_db_path()
|
workspace_path.join("picobot.db")
|
||||||
};
|
};
|
||||||
if let Some(parent) = db_path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"Failed to create database directory {}: {}",
|
|
||||||
parent.display(),
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
let storage = Arc::new(
|
let storage = Arc::new(
|
||||||
crate::storage::Storage::new(&db_path)
|
crate::storage::Storage::new(&db_path)
|
||||||
.await
|
.await
|
||||||
@ -156,16 +134,11 @@ impl GatewayState {
|
|||||||
let consolidation_model = config
|
let consolidation_model = config
|
||||||
.memory
|
.memory
|
||||||
.resolve_consolidation_model(&provider_config.model_id);
|
.resolve_consolidation_model(&provider_config.model_id);
|
||||||
let memory_manager = Arc::new(
|
let memory_manager = Arc::new(MemoryManager::new(
|
||||||
MemoryManager::new(
|
|
||||||
storage.clone(),
|
storage.clone(),
|
||||||
consolidation_provider,
|
consolidation_provider,
|
||||||
consolidation_model,
|
consolidation_model,
|
||||||
)
|
));
|
||||||
.with_recall(crate::memory::recall::RecallConfig::from_memory_config(
|
|
||||||
&config.memory,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
consolidation_provider = %memory_manager.consolidation_provider,
|
consolidation_provider = %memory_manager.consolidation_provider,
|
||||||
consolidation_model = %memory_manager.consolidation_model,
|
consolidation_model = %memory_manager.consolidation_model,
|
||||||
@ -183,7 +156,6 @@ impl GatewayState {
|
|||||||
.init(&config, workspace_path.clone())
|
.init(&config, workspace_path.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to init channels: {}", e))?;
|
.map_err(|e| format!("Failed to init channels: {}", e))?;
|
||||||
let available_channels = channel_manager.list_channel_names().await;
|
|
||||||
let turn_delivery = TurnDeliveryService::new(
|
let turn_delivery = TurnDeliveryService::new(
|
||||||
delivery_coordinator.clone(),
|
delivery_coordinator.clone(),
|
||||||
channel_manager.clone(),
|
channel_manager.clone(),
|
||||||
@ -195,50 +167,10 @@ impl GatewayState {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let health = Arc::new(
|
|
||||||
crate::health::HealthService::new(config.clone())
|
|
||||||
.with_scheduler_runtime(storage.clone(), available_channels.clone()),
|
|
||||||
);
|
|
||||||
let provider_profiles: std::collections::HashMap<String, _> = config
|
|
||||||
.agents
|
|
||||||
.keys()
|
|
||||||
.filter_map(|name| {
|
|
||||||
config
|
|
||||||
.get_provider_config(name)
|
|
||||||
.ok()
|
|
||||||
.map(|profile| (name.clone(), profile))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let config_dir = config_path
|
|
||||||
.parent()
|
|
||||||
.unwrap_or_else(|| std::path::Path::new("."))
|
|
||||||
.to_path_buf();
|
|
||||||
|
|
||||||
// Resolve the Agent definitions directory exactly like the catalog
|
|
||||||
// does (relative paths stay inside the trusted config dir).
|
|
||||||
let agents_dir = {
|
|
||||||
let configured =
|
|
||||||
crate::config::expand_path(&config.agent_orchestration.definitions_dir);
|
|
||||||
if configured.is_absolute() {
|
|
||||||
configured
|
|
||||||
} else {
|
|
||||||
config_dir.join(configured)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create SessionManager with bus injection
|
// Create SessionManager with bus injection
|
||||||
let session_manager = SessionManager::new(
|
let session_manager = SessionManager::new(
|
||||||
provider_config.clone(),
|
provider_config.clone(),
|
||||||
config.context_compaction.clone(),
|
|
||||||
AgentCatalogPreparation {
|
|
||||||
provider_profiles,
|
|
||||||
providers: config.providers.clone(),
|
|
||||||
models: config.models.clone(),
|
|
||||||
workspace_dir: crate::config::expand_path(&config.workspace_dir),
|
|
||||||
config: config.agent_orchestration.clone(),
|
|
||||||
config_dir,
|
|
||||||
runtime_generation,
|
|
||||||
},
|
|
||||||
storage.clone(),
|
storage.clone(),
|
||||||
SessionManagerServices::new(
|
SessionManagerServices::new(
|
||||||
bus.clone(),
|
bus.clone(),
|
||||||
@ -249,14 +181,12 @@ impl GatewayState {
|
|||||||
)
|
)
|
||||||
.with_admission(admission.clone()),
|
.with_admission(admission.clone()),
|
||||||
browser_config,
|
browser_config,
|
||||||
health.clone(),
|
config.gateway.max_concurrent_background_tasks,
|
||||||
)?;
|
)?;
|
||||||
let session_manager = Arc::new(session_manager);
|
let session_manager = Arc::new(session_manager);
|
||||||
session_manager.bind_inbox_wake();
|
|
||||||
let agent_catalog = session_manager.agent_catalog();
|
|
||||||
health.bind_agent_catalog(agent_catalog.clone());
|
|
||||||
|
|
||||||
// Register send_message tool with available channel names
|
// Register send_message tool with available channel names
|
||||||
|
let available_channels = channel_manager.list_channel_names().await;
|
||||||
let valid_channels = available_channels.clone();
|
let valid_channels = available_channels.clone();
|
||||||
session_manager.register_outbound_tool(available_channels);
|
session_manager.register_outbound_tool(available_channels);
|
||||||
|
|
||||||
@ -286,15 +216,11 @@ impl GatewayState {
|
|||||||
.tools()
|
.tools()
|
||||||
.register(crate::tools::cron::CronAddTool::new(
|
.register(crate::tools::cron::CronAddTool::new(
|
||||||
storage.clone(),
|
storage.clone(),
|
||||||
valid_channels.clone(),
|
valid_channels,
|
||||||
agent_catalog.clone(),
|
|
||||||
));
|
));
|
||||||
session_manager
|
session_manager
|
||||||
.tools()
|
.tools()
|
||||||
.register(crate::tools::cron::CronListTool::new(storage.clone()));
|
.register(crate::tools::cron::CronListTool::new(storage.clone()));
|
||||||
session_manager
|
|
||||||
.tools()
|
|
||||||
.register(crate::tools::cron::CronRunsTool::new(storage.clone()));
|
|
||||||
session_manager
|
session_manager
|
||||||
.tools()
|
.tools()
|
||||||
.register(crate::tools::cron::CronRemoveTool::new(storage.clone()));
|
.register(crate::tools::cron::CronRemoveTool::new(storage.clone()));
|
||||||
@ -306,21 +232,14 @@ impl GatewayState {
|
|||||||
.register(crate::tools::cron::CronDisableTool::new(storage.clone()));
|
.register(crate::tools::cron::CronDisableTool::new(storage.clone()));
|
||||||
session_manager
|
session_manager
|
||||||
.tools()
|
.tools()
|
||||||
.register(crate::tools::cron::CronUpdateTool::new(
|
.register(crate::tools::cron::CronUpdateTool::new(storage.clone()));
|
||||||
storage.clone(),
|
|
||||||
valid_channels,
|
|
||||||
agent_catalog.clone(),
|
|
||||||
));
|
|
||||||
tracing::info!("Cron tools registered");
|
tracing::info!("Cron tools registered");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
config_path,
|
config_path,
|
||||||
config_load_context,
|
|
||||||
config_write_lock,
|
|
||||||
workspace_dir: workspace_path,
|
workspace_dir: workspace_path,
|
||||||
health,
|
|
||||||
session_manager: session_manager.clone(),
|
session_manager: session_manager.clone(),
|
||||||
channel_manager,
|
channel_manager,
|
||||||
storage,
|
storage,
|
||||||
@ -333,8 +252,6 @@ impl GatewayState {
|
|||||||
outbound_lanes: Arc::new(AtomicUsize::new(0)),
|
outbound_lanes: Arc::new(AtomicUsize::new(0)),
|
||||||
reload,
|
reload,
|
||||||
admission,
|
admission,
|
||||||
agent_catalog,
|
|
||||||
agents_dir,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -349,50 +266,7 @@ impl GatewayState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start the message processing loops
|
/// Start the message processing loops
|
||||||
pub async fn start_message_processing(&self) -> Result<(), String> {
|
pub async fn start_message_processing(&self) {
|
||||||
match self
|
|
||||||
.storage
|
|
||||||
.recover_scheduled_runs(chrono::Utc::now().timestamp_millis())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(recovered) if recovered > 0 => {
|
|
||||||
tracing::warn!(
|
|
||||||
recovered,
|
|
||||||
"Scheduled runs recovered as unknown on activation"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(error) => {
|
|
||||||
return Err(format!(
|
|
||||||
"Scheduled run recovery failed on activation: {error}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Recover durable Agent state for this runtime generation: interrupt
|
|
||||||
// runs of older generations, expire stale inbox leases and reconcile
|
|
||||||
// capacity rows. Runs never recover while the generation is still a candidate.
|
|
||||||
if let Some(coordinator) = self.session_manager.agent_coordinator() {
|
|
||||||
match coordinator.recover_on_activation().await {
|
|
||||||
Ok(report) => {
|
|
||||||
if report.interrupted_runs > 0 || report.leases_expired > 0 {
|
|
||||||
tracing::warn!(
|
|
||||||
interrupted = report.interrupted_runs,
|
|
||||||
completion_events = report.completion_events_generated,
|
|
||||||
leases_expired = report.leases_expired,
|
|
||||||
dead_lettered = report.dead_lettered,
|
|
||||||
sessions_reconciled = report.sessions_reconciled,
|
|
||||||
"Agent state recovered on activation"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
return Err(format!(
|
|
||||||
"Agent state recovery failed on activation: {error}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCP connections have external/process-wide side effects. Activate
|
// MCP connections have external/process-wide side effects. Activate
|
||||||
// them only after this generation becomes current, never while it is
|
// them only after this generation becomes current, never while it is
|
||||||
// merely a reload candidate.
|
// merely a reload candidate.
|
||||||
@ -404,7 +278,6 @@ impl GatewayState {
|
|||||||
tool_info.description,
|
tool_info.description,
|
||||||
tool_info.schema,
|
tool_info.schema,
|
||||||
tool_info.connection,
|
tool_info.connection,
|
||||||
tool_info.settings,
|
|
||||||
);
|
);
|
||||||
self.session_manager.tools().register(wrapper);
|
self.session_manager.tools().register(wrapper);
|
||||||
}
|
}
|
||||||
@ -445,23 +318,6 @@ impl GatewayState {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Relay durable Agent run/event projections. A lagged or lost
|
|
||||||
// broadcast is non-fatal: the client recalibrates with GetAgentRuns.
|
|
||||||
let mut agent_projection_events = self.session_manager.projection_hub().subscribe();
|
|
||||||
let cli_chat = self.cli_chat_channel();
|
|
||||||
self.task_supervisor
|
|
||||||
.spawn("agent-projection-events", async move {
|
|
||||||
loop {
|
|
||||||
match agent_projection_events.recv().await {
|
|
||||||
Ok(event) => cli_chat.publish_agent_projection(event).await,
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
|
||||||
tracing::warn!(skipped, "Agent projection relay lagged");
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router::spawn_message_routers(
|
router::spawn_message_routers(
|
||||||
bus.clone(),
|
bus.clone(),
|
||||||
session_manager,
|
session_manager,
|
||||||
@ -498,7 +354,6 @@ impl GatewayState {
|
|||||||
});
|
});
|
||||||
tracing::info!("Scheduler background task spawned");
|
tracing::info!("Scheduler background task spawned");
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -508,41 +363,21 @@ pub async fn run(
|
|||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
STARTED.get_or_init(std::time::Instant::now);
|
STARTED.get_or_init(std::time::Instant::now);
|
||||||
let config_path = crate::config::resolve_default_config_path();
|
let config_path = crate::config::resolve_default_config_path();
|
||||||
let config_load_context = Arc::new(Config::load_context());
|
let startup_process_env = Config::startup_process_env();
|
||||||
let config = Config::load_for_startup(&config_path, &config_load_context)?;
|
let startup_cwd = std::env::current_dir()?;
|
||||||
|
let config = Config::load_from(&config_path)?;
|
||||||
|
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
logging::init_logging();
|
logging::init_logging();
|
||||||
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
|
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
|
||||||
if !config.diagnostics.is_empty() {
|
|
||||||
let paths = config
|
|
||||||
.diagnostics
|
|
||||||
.iter()
|
|
||||||
.take(8)
|
|
||||||
.map(|diagnostic| diagnostic.path.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
tracing::warn!(
|
|
||||||
count = config.diagnostics.len(),
|
|
||||||
paths,
|
|
||||||
"Gateway started with recoverable configuration entries ignored"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut reload_controller = reload::ReloadController::new(
|
let mut reload_controller = reload::ReloadController::new(startup_process_env, startup_cwd);
|
||||||
config_load_context.process_env.clone(),
|
|
||||||
config_load_context.startup_cwd.clone(),
|
|
||||||
);
|
|
||||||
let config_write_lock = Arc::new(tokio::sync::Mutex::new(()));
|
|
||||||
let mut state = Arc::new(
|
let mut state = Arc::new(
|
||||||
GatewayState::from_config(
|
GatewayState::from_config(
|
||||||
config,
|
config,
|
||||||
config_path.clone(),
|
config_path.clone(),
|
||||||
config_load_context.clone(),
|
|
||||||
config_write_lock.clone(),
|
|
||||||
reload_controller.handle.clone(),
|
reload_controller.handle.clone(),
|
||||||
true,
|
true,
|
||||||
1,
|
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
@ -563,7 +398,7 @@ pub async fn run(
|
|||||||
reload_controller.set_failed(current_generation, error.to_string());
|
reload_controller.set_failed(current_generation, error.to_string());
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
state.start_message_processing().await?;
|
state.start_message_processing().await;
|
||||||
reload_controller.set_phase(current_generation, reload::ReloadPhase::Active);
|
reload_controller.set_phase(current_generation, reload::ReloadPhase::Active);
|
||||||
let app = build_router(state.clone());
|
let app = build_router(state.clone());
|
||||||
let generation_listener = TcpListener::from_std(listener.try_clone()?)?;
|
let generation_listener = TcpListener::from_std(listener.try_clone()?)?;
|
||||||
@ -604,17 +439,13 @@ pub async fn run(
|
|||||||
};
|
};
|
||||||
let requested_generation = request.generation;
|
let requested_generation = request.generation;
|
||||||
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Preparing);
|
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Preparing);
|
||||||
let candidate_result = {
|
let candidate = match reload::load_candidate(
|
||||||
let _write_guard = state.config_write_lock.lock().await;
|
|
||||||
reload::load_candidate(
|
|
||||||
&config_path,
|
&config_path,
|
||||||
&reload_controller.startup_process_env,
|
&reload_controller.startup_process_env,
|
||||||
&reload_controller.startup_cwd,
|
&reload_controller.startup_cwd,
|
||||||
&state.config,
|
&state.config,
|
||||||
&state.workspace_dir,
|
&state.workspace_dir,
|
||||||
)
|
) {
|
||||||
};
|
|
||||||
let candidate = match candidate_result {
|
|
||||||
Ok(candidate) => candidate,
|
Ok(candidate) => candidate,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
reload_controller.set_failed(requested_generation, error.to_string());
|
reload_controller.set_failed(requested_generation, error.to_string());
|
||||||
@ -625,11 +456,8 @@ pub async fn run(
|
|||||||
let preparation = GatewayState::from_config(
|
let preparation = GatewayState::from_config(
|
||||||
candidate,
|
candidate,
|
||||||
config_path.clone(),
|
config_path.clone(),
|
||||||
config_load_context.clone(),
|
|
||||||
config_write_lock.clone(),
|
|
||||||
reload_controller.handle.clone(),
|
reload_controller.handle.clone(),
|
||||||
false,
|
false,
|
||||||
requested_generation,
|
|
||||||
);
|
);
|
||||||
tokio::pin!(preparation);
|
tokio::pin!(preparation);
|
||||||
let prepared = match tokio::select! {
|
let prepared = match tokio::select! {
|
||||||
@ -749,15 +577,11 @@ pub async fn run(
|
|||||||
|
|
||||||
fn build_router(state: Arc<GatewayState>) -> Router {
|
fn build_router(state: Arc<GatewayState>) -> Router {
|
||||||
let protected = Router::new()
|
let protected = Router::new()
|
||||||
.route("/api/health", routing::get(http::health_report))
|
.route("/api/health", routing::get(http::health))
|
||||||
.route(
|
.route(
|
||||||
"/api/config",
|
"/api/config",
|
||||||
routing::get(http::get_config).put(http::put_config),
|
routing::get(http::get_config).put(http::put_config),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/api/config/cleanup-invalid",
|
|
||||||
routing::post(http::cleanup_invalid_config_entries),
|
|
||||||
)
|
|
||||||
.route("/api/config/reload", routing::post(http::reload_config))
|
.route("/api/config/reload", routing::post(http::reload_config))
|
||||||
.route(
|
.route(
|
||||||
"/api/config/reload/status",
|
"/api/config/reload/status",
|
||||||
@ -770,31 +594,9 @@ fn build_router(state: Arc<GatewayState>) -> Router {
|
|||||||
.route("/api/logs", routing::get(http::get_logs))
|
.route("/api/logs", routing::get(http::get_logs))
|
||||||
.route("/api/tasks", routing::get(http::get_tasks))
|
.route("/api/tasks", routing::get(http::get_tasks))
|
||||||
.route("/api/status", routing::get(http::get_status))
|
.route("/api/status", routing::get(http::get_status))
|
||||||
.route("/api/tools", routing::get(http::get_tools))
|
|
||||||
.route("/api/skills", routing::get(http::get_skills))
|
|
||||||
.route("/api/skills/{name}", routing::put(http::put_skill_enabled))
|
|
||||||
.route("/api/jobs", routing::get(http::get_jobs))
|
.route("/api/jobs", routing::get(http::get_jobs))
|
||||||
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
|
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
|
||||||
.route(
|
|
||||||
"/api/agents",
|
|
||||||
routing::get(http::list_agents).post(http::put_agent),
|
|
||||||
)
|
|
||||||
.route("/api/agents/options", routing::get(http::get_agent_options))
|
|
||||||
.route("/api/agents/{id}", routing::delete(http::delete_agent))
|
|
||||||
.route("/api/agent-runs", routing::get(http::get_agent_runs))
|
|
||||||
.route(
|
|
||||||
"/api/agent-runs/{id}",
|
|
||||||
routing::get(http::get_agent_run).post(http::cancel_agent_run),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/agent-runs/{id}/events",
|
|
||||||
routing::get(http::get_agent_run_events),
|
|
||||||
)
|
|
||||||
.route("/api/memories", routing::get(http::get_memories))
|
.route("/api/memories", routing::get(http::get_memories))
|
||||||
.route(
|
|
||||||
"/api/memories/{key}",
|
|
||||||
routing::put(http::put_memory).delete(http::delete_memory),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/chat/{client_id}/uploads",
|
"/api/chat/{client_id}/uploads",
|
||||||
routing::post(http::upload_file).layer(axum::extract::DefaultBodyLimit::disable()),
|
routing::post(http::upload_file).layer(axum::extract::DefaultBodyLimit::disable()),
|
||||||
@ -804,7 +606,6 @@ fn build_router(state: Arc<GatewayState>) -> Router {
|
|||||||
routing::get(http::download_attachment),
|
routing::get(http::download_attachment),
|
||||||
)
|
)
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
.route("/ws/logs", routing::get(ws::ws_logs_handler))
|
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.auth.clone(),
|
state.auth.clone(),
|
||||||
auth::require_auth,
|
auth::require_auth,
|
||||||
|
|||||||
@ -319,7 +319,7 @@ fn effective_db_path(config: &Config, workspace: &Path) -> PathBuf {
|
|||||||
.session_db_path
|
.session_db_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(crate::config::expand_path)
|
.map(crate::config::expand_path)
|
||||||
.unwrap_or_else(crate::config::get_default_db_path);
|
.unwrap_or_else(|| workspace.join("picobot.db"));
|
||||||
let path = if path.is_relative() {
|
let path = if path.is_relative() {
|
||||||
workspace.join(path)
|
workspace.join(path)
|
||||||
} else {
|
} else {
|
||||||
@ -395,22 +395,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_database_path_equivalence_is_reloadable() {
|
fn equivalent_default_database_paths_are_reloadable() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let workspace = temp.path().join("workspace");
|
let workspace = temp.path().join("workspace");
|
||||||
std::fs::create_dir_all(&workspace).unwrap();
|
std::fs::create_dir_all(&workspace).unwrap();
|
||||||
|
std::fs::write(workspace.join("picobot.db"), []).unwrap();
|
||||||
let config_path = temp.path().join("config.json");
|
let config_path = temp.path().join("config.json");
|
||||||
let current: Config = serde_json::from_str(&config_json(&workspace, "old-model")).unwrap();
|
let current: Config = serde_json::from_str(&config_json(&workspace, "old-model")).unwrap();
|
||||||
|
|
||||||
// The implicit default (no session_db_path) is the config-dir data
|
|
||||||
// path; an explicit path resolving to the same file stays reloadable.
|
|
||||||
let default_db = crate::config::get_default_db_path();
|
|
||||||
let mut candidate: serde_json::Value =
|
let mut candidate: serde_json::Value =
|
||||||
serde_json::from_str(&config_json(&workspace, "new-model")).unwrap();
|
serde_json::from_str(&config_json(&workspace, "new-model")).unwrap();
|
||||||
candidate["gateway"] = serde_json::json!({
|
candidate["gateway"] = serde_json::json!({ "session_db_path": "./picobot.db" });
|
||||||
"session_db_path": default_db.to_string_lossy()
|
|
||||||
});
|
|
||||||
std::fs::write(&config_path, serde_json::to_vec(&candidate).unwrap()).unwrap();
|
std::fs::write(&config_path, serde_json::to_vec(&candidate).unwrap()).unwrap();
|
||||||
|
|
||||||
load_candidate(
|
load_candidate(
|
||||||
&config_path,
|
&config_path,
|
||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
@ -419,28 +415,6 @@ mod tests {
|
|||||||
&workspace,
|
&workspace,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// A different explicit path (e.g. the old workspace location) must be
|
|
||||||
// rejected as restart-only to protect the shared database identity.
|
|
||||||
let mut candidate: serde_json::Value =
|
|
||||||
serde_json::from_str(&config_json(&workspace, "new-model")).unwrap();
|
|
||||||
candidate["gateway"] = serde_json::json!({
|
|
||||||
"session_db_path": workspace.join("picobot.db").to_string_lossy()
|
|
||||||
});
|
|
||||||
std::fs::write(&config_path, serde_json::to_vec(&candidate).unwrap()).unwrap();
|
|
||||||
let error = load_candidate(
|
|
||||||
&config_path,
|
|
||||||
&HashMap::new(),
|
|
||||||
temp.path(),
|
|
||||||
¤t,
|
|
||||||
&workspace,
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(
|
|
||||||
error
|
|
||||||
.to_string()
|
|
||||||
.contains("session_db_path cannot be reloaded")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -325,23 +325,6 @@ async fn handle_control_message(session_manager: &SessionManager, message: Contr
|
|||||||
.await
|
.await
|
||||||
.map(|plan| SessionEvent::TaskPlan { session_id, plan })
|
.map(|plan| SessionEvent::TaskPlan { session_id, plan })
|
||||||
.map_err(|error| ChannelError::Other(error.to_string())),
|
.map_err(|error| ChannelError::Other(error.to_string())),
|
||||||
GetSessionStats { session_id } => session_manager
|
|
||||||
.get_session_stats(&session_id)
|
|
||||||
.await
|
|
||||||
.map(|stats| SessionEvent::SessionStats { stats })
|
|
||||||
.map_err(|error| ChannelError::Other(error.to_string())),
|
|
||||||
GetAgentRuns {
|
|
||||||
session_id,
|
|
||||||
cursor,
|
|
||||||
limit,
|
|
||||||
} => session_manager
|
|
||||||
.get_agent_runs(&session_id, cursor, limit)
|
|
||||||
.await
|
|
||||||
.map_err(|error| ChannelError::Other(error.to_string())),
|
|
||||||
GetAgentRun { session_id, run_id } => session_manager
|
|
||||||
.get_agent_run(&session_id, &run_id)
|
|
||||||
.await
|
|
||||||
.map_err(|error| ChannelError::Other(error.to_string())),
|
|
||||||
RenameDialog { session_id, title } => session_manager
|
RenameDialog { session_id, title } => session_manager
|
||||||
.rename_dialog(&session_id, &title)
|
.rename_dialog(&session_id, &title)
|
||||||
.await
|
.await
|
||||||
@ -432,14 +415,12 @@ mod tests {
|
|||||||
channel: "test".to_string(),
|
channel: "test".to_string(),
|
||||||
sender_id: "user".to_string(),
|
sender_id: "user".to_string(),
|
||||||
chat_id: "chat".to_string(),
|
chat_id: "chat".to_string(),
|
||||||
client_message_id: None,
|
|
||||||
content: "hello".to_string(),
|
content: "hello".to_string(),
|
||||||
received_at: 123,
|
received_at: 123,
|
||||||
media: vec![],
|
media: vec![],
|
||||||
channel_context: ChannelContext {
|
channel_context: ChannelContext {
|
||||||
reply_to: Some("parent".to_string()),
|
reply_to: Some("parent".to_string()),
|
||||||
private: HashMap::from([("opaque".to_string(), "value".to_string())]),
|
private: HashMap::from([("opaque".to_string(), "value".to_string())]),
|
||||||
durable_private: HashMap::new(),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -459,7 +440,7 @@ mod tests {
|
|||||||
Some("command")
|
Some("command")
|
||||||
);
|
);
|
||||||
assert!(!publish_task.is_finished());
|
assert!(!publish_task.is_finished());
|
||||||
output.complete_delivery(crate::bus::DeliveryReceipt::Delivered);
|
output.complete_delivery(Ok(()));
|
||||||
publish_task.await.unwrap();
|
publish_task.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -151,98 +151,6 @@ impl Drop for ConnectionGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
pub struct WsLogsQuery {
|
|
||||||
level: Option<String>,
|
|
||||||
search: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ws_logs_handler(
|
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
Query(query): Query<WsLogsQuery>,
|
|
||||||
Extension(_identity): Extension<super::auth::AuthIdentity>,
|
|
||||||
) -> Response {
|
|
||||||
ws.on_upgrade(|socket| async move {
|
|
||||||
handle_logs_socket(socket, query).await;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_logs_socket(ws: WebSocket, query: WsLogsQuery) {
|
|
||||||
let Some(tx) = crate::logging::log_sender() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut rx = tx.subscribe();
|
|
||||||
let (mut ws_sender, mut ws_receiver) = ws.split();
|
|
||||||
|
|
||||||
let min_level = query.level.as_deref().map(parse_min_level).unwrap_or(0);
|
|
||||||
let search = query
|
|
||||||
.search
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| s.to_ascii_lowercase());
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
result = rx.recv() => {
|
|
||||||
match result {
|
|
||||||
Ok(event) => {
|
|
||||||
if level_rank(&event.level) < min_level {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(needle) = &search
|
|
||||||
&& !event.message.to_ascii_lowercase().contains(needle)
|
|
||||||
&& !event.target.to_ascii_lowercase().contains(needle)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let json = serde_json::json!({
|
|
||||||
"ts": event.ts,
|
|
||||||
"level": event.level,
|
|
||||||
"target": event.target,
|
|
||||||
"message": event.message,
|
|
||||||
});
|
|
||||||
if ws_sender
|
|
||||||
.send(WsMessage::Text(json.to_string().into()))
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg = ws_receiver.next() => {
|
|
||||||
match msg {
|
|
||||||
Some(Ok(WsMessage::Close(_))) | None => break,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_min_level(level: &str) -> u8 {
|
|
||||||
match level.to_ascii_uppercase().as_str() {
|
|
||||||
"DEBUG" => 1,
|
|
||||||
"INFO" => 2,
|
|
||||||
"WARN" => 3,
|
|
||||||
"ERROR" => 4,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn level_rank(level: &str) -> u8 {
|
|
||||||
match level {
|
|
||||||
"TRACE" => 0,
|
|
||||||
"DEBUG" => 1,
|
|
||||||
"INFO" => 2,
|
|
||||||
"WARN" => 3,
|
|
||||||
"ERROR" => 4,
|
|
||||||
_ => 2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
1103
src/health.rs
1103
src/health.rs
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,6 @@ pub mod client;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod delivery;
|
pub mod delivery;
|
||||||
pub mod gateway;
|
pub mod gateway;
|
||||||
pub mod health;
|
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
|||||||
@ -1,93 +1,27 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use tracing::field::{Field, Visit};
|
|
||||||
use tracing::{Event, Subscriber};
|
|
||||||
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
||||||
use tracing_subscriber::Layer;
|
|
||||||
use tracing_subscriber::layer::Context;
|
|
||||||
use tracing_subscriber::registry::LookupSpan;
|
|
||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
EnvFilter, fmt, fmt::time::LocalTime, layer::SubscriberExt, util::SubscriberInitExt,
|
EnvFilter, fmt, fmt::time::LocalTime, layer::SubscriberExt, util::SubscriberInitExt,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
/// Get the default log directory path: ~/.picobot/logs
|
||||||
pub struct LogEvent {
|
|
||||||
pub ts: String,
|
|
||||||
pub level: String,
|
|
||||||
pub target: String,
|
|
||||||
pub message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
static LOG_TX: OnceLock<broadcast::Sender<LogEvent>> = OnceLock::new();
|
|
||||||
|
|
||||||
pub fn log_sender() -> Option<broadcast::Sender<LogEvent>> {
|
|
||||||
LOG_TX.get().cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MessageVisitor(String);
|
|
||||||
|
|
||||||
impl Visit for MessageVisitor {
|
|
||||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
|
||||||
if field.name() == "message" {
|
|
||||||
self.0 = format!("{value:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_str(&mut self, field: &Field, value: &str) {
|
|
||||||
if field.name() == "message" {
|
|
||||||
self.0 = value.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BroadcastLayer;
|
|
||||||
|
|
||||||
impl<S> Layer<S> for BroadcastLayer
|
|
||||||
where
|
|
||||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
|
||||||
{
|
|
||||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
|
||||||
let Some(tx) = LOG_TX.get() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if tx.receiver_count() == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut visitor = MessageVisitor(String::new());
|
|
||||||
event.record(&mut visitor);
|
|
||||||
|
|
||||||
let ts = time::OffsetDateTime::now_local()
|
|
||||||
.unwrap_or_else(|_| time::OffsetDateTime::now_utc())
|
|
||||||
.format(&time::format_description::well_known::Rfc3339)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let _ = tx.send(LogEvent {
|
|
||||||
ts,
|
|
||||||
level: event.metadata().level().to_string(),
|
|
||||||
target: event.metadata().target().to_string(),
|
|
||||||
message: visitor.0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_default_log_dir() -> PathBuf {
|
pub fn get_default_log_dir() -> PathBuf {
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
home.join(".picobot").join("logs")
|
home.join(".picobot").join("logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the default config file path: ~/.picobot/config.json
|
||||||
pub fn get_default_config_path() -> PathBuf {
|
pub fn get_default_config_path() -> PathBuf {
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
home.join(".picobot").join("config.json")
|
home.join(".picobot").join("config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialize logging with file appender
|
||||||
|
/// Logs are written to ~/.picobot/logs/ with daily rotation
|
||||||
pub fn init_logging() {
|
pub fn init_logging() {
|
||||||
let (tx, _) = broadcast::channel::<LogEvent>(1024);
|
|
||||||
let _ = LOG_TX.set(tx);
|
|
||||||
|
|
||||||
let log_dir = get_default_log_dir();
|
let log_dir = get_default_log_dir();
|
||||||
|
|
||||||
|
// Create log directory if it doesn't exist
|
||||||
if !log_dir.exists()
|
if !log_dir.exists()
|
||||||
&& let Err(e) = std::fs::create_dir_all(&log_dir)
|
&& let Err(e) = std::fs::create_dir_all(&log_dir)
|
||||||
{
|
{
|
||||||
@ -98,8 +32,10 @@ pub fn init_logging() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create file appender with daily rotation
|
||||||
let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log");
|
let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log");
|
||||||
|
|
||||||
|
// Build subscriber with both console and file output
|
||||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
|
||||||
let file_layer = fmt::layer()
|
let file_layer = fmt::layer()
|
||||||
@ -119,7 +55,6 @@ pub fn init_logging() {
|
|||||||
.with(env_filter)
|
.with(env_filter)
|
||||||
.with(console_layer)
|
.with(console_layer)
|
||||||
.with(file_layer)
|
.with(file_layer)
|
||||||
.with(BroadcastLayer)
|
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
|
tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
|
||||||
|
|||||||
20
src/main.rs
20
src/main.rs
@ -62,12 +62,6 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
gateway_url: Option<String>,
|
gateway_url: Option<String>,
|
||||||
},
|
},
|
||||||
/// Check PicoBot runtime and configured external dependencies
|
|
||||||
Health {
|
|
||||||
/// Print the structured report as JSON
|
|
||||||
#[arg(long)]
|
|
||||||
json: bool,
|
|
||||||
},
|
|
||||||
/// Generate a one-time browser pairing code from the local gateway
|
/// Generate a one-time browser pairing code from the local gateway
|
||||||
Pair {
|
Pair {
|
||||||
/// Gateway WebSocket or HTTP URL
|
/// Gateway WebSocket or HTTP URL
|
||||||
@ -142,20 +136,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string());
|
.unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string());
|
||||||
println!("{}", picobot::client::reload_gateway(&url).await?);
|
println!("{}", picobot::client::reload_gateway(&url).await?);
|
||||||
}
|
}
|
||||||
Command::Health { json } => {
|
|
||||||
let report = match picobot::config::Config::load_default() {
|
|
||||||
Ok(config) => picobot::health::HealthService::new(config).check().await,
|
|
||||||
Err(error) => picobot::health::HealthReport::configuration_error(error.to_string()),
|
|
||||||
};
|
|
||||||
if json {
|
|
||||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
||||||
} else {
|
|
||||||
println!("{}", report.render_text());
|
|
||||||
}
|
|
||||||
if !report.is_usable() {
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Command::Pair {
|
Command::Pair {
|
||||||
gateway_url,
|
gateway_url,
|
||||||
revoke_all,
|
revoke_all,
|
||||||
|
|||||||
@ -5,38 +5,22 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use http::{HeaderName, HeaderValue};
|
use http::{HeaderName, HeaderValue};
|
||||||
use rmcp::model::{CallToolRequestParams, ContentBlock};
|
use rmcp::model::{CallToolRequestParams, RawContent};
|
||||||
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
||||||
use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
|
use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
|
||||||
use rmcp::{Peer, RoleClient, ServiceExt};
|
use rmcp::{Peer, RoleClient, ServiceExt};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::config::{McpConfig, McpServerConfig, McpToolSettings, McpTransport};
|
use crate::config::{McpConfig, McpServerConfig, McpTransport};
|
||||||
use crate::tools::ToolResult;
|
use crate::tools::ToolResult;
|
||||||
|
|
||||||
pub use tool_wrapper::McpToolWrapper;
|
pub use tool_wrapper::McpToolWrapper;
|
||||||
|
|
||||||
/// Reserved prefix for tool names registered from MCP servers.
|
|
||||||
pub const MCP_TOOL_PREFIX: &str = "mcp_";
|
|
||||||
|
|
||||||
/// Build the public ToolRegistry name for an MCP-discovered tool.
|
|
||||||
pub fn qualified_tool_name(server_name: &str, tool_name: &str) -> String {
|
|
||||||
format!("{MCP_TOOL_PREFIX}{server_name}_{tool_name}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a ToolRegistry name belongs to the MCP namespace.
|
|
||||||
pub fn is_mcp_tool_name(name: &str) -> bool {
|
|
||||||
name.starts_with(MCP_TOOL_PREFIX)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Status of a single MCP tool.
|
/// Status of a single MCP tool.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct McpToolStatus {
|
pub struct McpToolStatus {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub read_only: bool,
|
|
||||||
pub exclusive: bool,
|
|
||||||
pub concurrency_safe: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status of a single MCP server.
|
/// Status of a single MCP server.
|
||||||
@ -101,14 +85,14 @@ impl McpConnection {
|
|||||||
fn extract_text(result: &rmcp::model::CallToolResult) -> String {
|
fn extract_text(result: &rmcp::model::CallToolResult) -> String {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
for content in &result.content {
|
for content in &result.content {
|
||||||
match content {
|
match &**content {
|
||||||
ContentBlock::Text(text) => {
|
RawContent::Text(text) => {
|
||||||
parts.push(text.text.clone());
|
parts.push(text.text.clone());
|
||||||
}
|
}
|
||||||
ContentBlock::Image(image) => {
|
RawContent::Image(image) => {
|
||||||
parts.push(format!("[image: {}]", image.mime_type,));
|
parts.push(format!("[image: {}]", image.mime_type,));
|
||||||
}
|
}
|
||||||
ContentBlock::Resource(resource) => match &resource.resource {
|
RawContent::Resource(resource) => match &resource.resource {
|
||||||
rmcp::model::ResourceContents::TextResourceContents { text, .. } => {
|
rmcp::model::ResourceContents::TextResourceContents { text, .. } => {
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
"[resource text: {}]",
|
"[resource text: {}]",
|
||||||
@ -118,7 +102,6 @@ fn extract_text(result: &rmcp::model::CallToolResult) -> String {
|
|||||||
rmcp::model::ResourceContents::BlobResourceContents { uri, .. } => {
|
rmcp::model::ResourceContents::BlobResourceContents { uri, .. } => {
|
||||||
parts.push(format!("[resource blob: {}]", uri));
|
parts.push(format!("[resource blob: {}]", uri));
|
||||||
}
|
}
|
||||||
_ => parts.push("[unsupported resource]".to_string()),
|
|
||||||
},
|
},
|
||||||
_ => {
|
_ => {
|
||||||
parts.push("[unsupported content]".to_string());
|
parts.push("[unsupported content]".to_string());
|
||||||
@ -138,7 +121,6 @@ pub struct ToolInfo {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub schema: serde_json::Value,
|
pub schema: serde_json::Value,
|
||||||
pub connection: Arc<McpConnection>,
|
pub connection: Arc<McpConnection>,
|
||||||
pub settings: McpToolSettings,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
|
pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
|
||||||
@ -146,14 +128,6 @@ pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
|
|||||||
let mut server_statuses = Vec::new();
|
let mut server_statuses = Vec::new();
|
||||||
|
|
||||||
for server_config in &config.servers {
|
for server_config in &config.servers {
|
||||||
if !server_config.enabled {
|
|
||||||
tracing::info!(
|
|
||||||
server = %server_config.name,
|
|
||||||
"MCP server disabled by config, skipping"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let transport_str = match server_config.transport {
|
let transport_str = match server_config.transport {
|
||||||
McpTransport::Stdio => "stdio",
|
McpTransport::Stdio => "stdio",
|
||||||
McpTransport::Sse => "sse",
|
McpTransport::Sse => "sse",
|
||||||
@ -172,15 +146,9 @@ pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
|
|||||||
);
|
);
|
||||||
let tool_statuses: Vec<McpToolStatus> = server_tools
|
let tool_statuses: Vec<McpToolStatus> = server_tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, desc, _)| {
|
.map(|(name, desc, _)| McpToolStatus {
|
||||||
let settings = server_config.tool_settings_for(name);
|
|
||||||
McpToolStatus {
|
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
description: desc.clone(),
|
description: desc.clone(),
|
||||||
read_only: settings.read_only,
|
|
||||||
exclusive: settings.exclusive,
|
|
||||||
concurrency_safe: settings.concurrency_safe(),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
server_statuses.push(McpServerStatus {
|
server_statuses.push(McpServerStatus {
|
||||||
@ -191,14 +159,12 @@ pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
|
|||||||
tools: tool_statuses,
|
tools: tool_statuses,
|
||||||
});
|
});
|
||||||
for (orig_name, desc, schema) in server_tools {
|
for (orig_name, desc, schema) in server_tools {
|
||||||
let settings = server_config.tool_settings_for(&orig_name);
|
|
||||||
tools.push(ToolInfo {
|
tools.push(ToolInfo {
|
||||||
server_name: server_config.name.clone(),
|
server_name: server_config.name.clone(),
|
||||||
tool_name: orig_name,
|
tool_name: orig_name,
|
||||||
description: desc,
|
description: desc,
|
||||||
schema,
|
schema,
|
||||||
connection: connection.clone(),
|
connection: connection.clone(),
|
||||||
settings,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -326,18 +292,3 @@ async fn list_tools(
|
|||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{is_mcp_tool_name, qualified_tool_name};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn qualified_tool_names_use_the_mcp_namespace() {
|
|
||||||
assert_eq!(
|
|
||||||
qualified_tool_name("filesystem", "read_file"),
|
|
||||||
"mcp_filesystem_read_file"
|
|
||||||
);
|
|
||||||
assert!(is_mcp_tool_name("mcp_filesystem_read_file"));
|
|
||||||
assert!(!is_mcp_tool_name("file_read"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,10 +2,9 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::config::McpToolSettings;
|
|
||||||
use crate::tools::{Tool, ToolResult};
|
use crate::tools::{Tool, ToolResult};
|
||||||
|
|
||||||
use super::{McpConnection, qualified_tool_name};
|
use super::McpConnection;
|
||||||
|
|
||||||
pub struct McpToolWrapper {
|
pub struct McpToolWrapper {
|
||||||
full_name: String,
|
full_name: String,
|
||||||
@ -13,7 +12,6 @@ pub struct McpToolWrapper {
|
|||||||
parameters_schema: serde_json::Value,
|
parameters_schema: serde_json::Value,
|
||||||
original_tool_name: String,
|
original_tool_name: String,
|
||||||
connection: Arc<McpConnection>,
|
connection: Arc<McpConnection>,
|
||||||
settings: McpToolSettings,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpToolWrapper {
|
impl McpToolWrapper {
|
||||||
@ -23,15 +21,13 @@ impl McpToolWrapper {
|
|||||||
description: String,
|
description: String,
|
||||||
parameters_schema: serde_json::Value,
|
parameters_schema: serde_json::Value,
|
||||||
connection: Arc<McpConnection>,
|
connection: Arc<McpConnection>,
|
||||||
settings: McpToolSettings,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
full_name: qualified_tool_name(server_name, &original_tool_name),
|
full_name: format!("{}__{}", server_name, original_tool_name),
|
||||||
description,
|
description,
|
||||||
parameters_schema,
|
parameters_schema,
|
||||||
original_tool_name,
|
original_tool_name,
|
||||||
connection,
|
connection,
|
||||||
settings,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -50,14 +46,6 @@ impl Tool for McpToolWrapper {
|
|||||||
self.parameters_schema.clone()
|
self.parameters_schema.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_only(&self) -> bool {
|
|
||||||
self.settings.read_only
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exclusive(&self) -> bool {
|
|
||||||
self.settings.exclusive
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||||
self.connection
|
self.connection
|
||||||
.call_tool(&self.original_tool_name, args)
|
.call_tool(&self.original_tool_name, args)
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
pub mod recall;
|
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -7,8 +6,6 @@ use uuid::Uuid;
|
|||||||
use crate::storage::Storage;
|
use crate::storage::Storage;
|
||||||
pub use types::{ConsolidationFact, ConsolidationResult, MemoryCategory, MemoryEntry};
|
pub use types::{ConsolidationFact, ConsolidationResult, MemoryCategory, MemoryEntry};
|
||||||
|
|
||||||
use recall::RecallConfig;
|
|
||||||
|
|
||||||
/// MemoryManager provides high-level memory operations.
|
/// MemoryManager provides high-level memory operations.
|
||||||
/// Wraps the Storage SQLite layer with semantic methods.
|
/// Wraps the Storage SQLite layer with semantic methods.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -16,7 +13,6 @@ pub struct MemoryManager {
|
|||||||
storage: Arc<Storage>,
|
storage: Arc<Storage>,
|
||||||
pub consolidation_provider: String,
|
pub consolidation_provider: String,
|
||||||
pub consolidation_model: String,
|
pub consolidation_model: String,
|
||||||
recall: RecallConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryManager {
|
impl MemoryManager {
|
||||||
@ -29,57 +25,9 @@ impl MemoryManager {
|
|||||||
storage,
|
storage,
|
||||||
consolidation_provider,
|
consolidation_provider,
|
||||||
consolidation_model,
|
consolidation_model,
|
||||||
recall: RecallConfig::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Override the automatic-recall knobs (derived from `config.memory`).
|
|
||||||
pub fn with_recall(mut self, config: RecallConfig) -> Self {
|
|
||||||
self.recall = config;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Automatic per-turn recall for context injection.
|
|
||||||
///
|
|
||||||
/// Returns up to `recall.limit` Knowledge entries that clear both the
|
|
||||||
/// relevance and combined-score gates. The search is bounded by a hard
|
|
||||||
/// timeout: on expiry, error, or an empty normalized query it returns an
|
|
||||||
/// empty vector so the turn is never delayed by memory lookup.
|
|
||||||
pub async fn recall_for_context(&self, query: &str) -> Vec<MemoryEntry> {
|
|
||||||
let terms = recall::normalize_query(query);
|
|
||||||
if terms.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let candidate_limit = (self.recall.limit * recall::CANDIDATE_FACTOR)
|
|
||||||
.min(recall::MAX_CANDIDATES)
|
|
||||||
.max(self.recall.limit);
|
|
||||||
let candidates = tokio::time::timeout(
|
|
||||||
self.recall.timeout,
|
|
||||||
self.storage.search_memories_by_terms(
|
|
||||||
&terms,
|
|
||||||
Some(&MemoryCategory::Knowledge),
|
|
||||||
None,
|
|
||||||
candidate_limit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let candidates = match candidates {
|
|
||||||
Ok(Ok(entries)) => entries,
|
|
||||||
Ok(Err(error)) => {
|
|
||||||
tracing::debug!(error = %error, "memory recall failed; skipping injection");
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
tracing::debug!("memory recall timed out; skipping injection");
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
||||||
let mut ranked = recall::rank_and_gate(candidates, &terms, &self.recall, now_ms);
|
|
||||||
ranked.truncate(self.recall.limit);
|
|
||||||
ranked
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store or update a memory entry. Generates timestamp and UUID.
|
/// Store or update a memory entry. Generates timestamp and UUID.
|
||||||
pub async fn store(
|
pub async fn store(
|
||||||
&self,
|
&self,
|
||||||
@ -140,10 +88,7 @@ impl MemoryManager {
|
|||||||
|
|
||||||
/// Check if the memory system has any entries (for testing/health check).
|
/// Check if the memory system has any entries (for testing/health check).
|
||||||
pub async fn is_empty(&self) -> Result<bool, crate::storage::StorageError> {
|
pub async fn is_empty(&self) -> Result<bool, crate::storage::StorageError> {
|
||||||
self.storage
|
self.recall("*", 1, None, None).await.map(|r| r.is_empty())
|
||||||
.list_memories(None, None, 1)
|
|
||||||
.await
|
|
||||||
.map(|entries| entries.is_empty())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,65 +257,4 @@ mod tests {
|
|||||||
assert_eq!(scoped[0].key, "tl_a");
|
assert_eq!(scoped[0].key, "tl_a");
|
||||||
assert_eq!(scoped[0].session_id.as_deref(), Some("chan:chat:dialog_a"));
|
assert_eq!(scoped[0].session_id.as_deref(), Some("chan:chat:dialog_a"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn recall_for_context_returns_empty_for_stopword_only_query() {
|
|
||||||
let (mm, _dir) = setup_memory_manager().await;
|
|
||||||
mm.store(
|
|
||||||
"user_pref",
|
|
||||||
"user prefers python",
|
|
||||||
MemoryCategory::Knowledge,
|
|
||||||
None,
|
|
||||||
Some(0.9),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// A query made entirely of stopwords must not surface unrelated memories.
|
|
||||||
assert!(mm.recall_for_context("你好吗请吧").await.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn recall_for_context_recalls_relevant_and_gates_irrelevant() {
|
|
||||||
let (mm, _dir) = setup_memory_manager().await;
|
|
||||||
mm.store(
|
|
||||||
"user_pref",
|
|
||||||
"user prefers python for scripting",
|
|
||||||
MemoryCategory::Knowledge,
|
|
||||||
None,
|
|
||||||
Some(0.9),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
mm.store(
|
|
||||||
"favorite_color",
|
|
||||||
"user favorite color is blue",
|
|
||||||
MemoryCategory::Knowledge,
|
|
||||||
None,
|
|
||||||
Some(0.9),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let hits = mm.recall_for_context("帮我写一个 python 脚本").await;
|
|
||||||
assert_eq!(hits.len(), 1);
|
|
||||||
assert_eq!(hits[0].key, "user_pref");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn recall_for_context_respects_limit() {
|
|
||||||
let (mm, _dir) = setup_memory_manager().await;
|
|
||||||
for i in 0..8 {
|
|
||||||
mm.store(
|
|
||||||
&format!("pref_{i}"),
|
|
||||||
format!("user preference about python number {i}").as_str(),
|
|
||||||
MemoryCategory::Knowledge,
|
|
||||||
None,
|
|
||||||
Some(0.9),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let hits = mm.recall_for_context("python preference").await;
|
|
||||||
assert!(hits.len() <= 5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,242 +0,0 @@
|
|||||||
//! Deterministic, relevance-gated memory recall for per-turn context injection.
|
|
||||||
//!
|
|
||||||
//! This is the *automatic* recall path (as opposed to the user-facing
|
|
||||||
//! `memory_recall` tool, which performs an unfiltered search). It tokenizes
|
|
||||||
//! and normalizes the query, retrieves a small candidate set through FTS5,
|
|
||||||
//! then ranks and gates candidates by lexical relevance, importance, and
|
|
||||||
//! recency. Everything here is bounded and synchronous so a turn is never
|
|
||||||
//! delayed by a slow search.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use jieba_rs::Jieba;
|
|
||||||
|
|
||||||
use super::MemoryEntry;
|
|
||||||
|
|
||||||
/// Weights for the combined score (sum to 1.0 for a clean 0..1 range).
|
|
||||||
const WEIGHT_RELEVANCE: f64 = 0.5;
|
|
||||||
const WEIGHT_IMPORTANCE: f64 = 0.3;
|
|
||||||
const WEIGHT_RECENCY: f64 = 0.2;
|
|
||||||
|
|
||||||
/// Candidate set is `limit * CANDIDATE_FACTOR`, capped by `MAX_CANDIDATES`.
|
|
||||||
pub(crate) const CANDIDATE_FACTOR: usize = 4;
|
|
||||||
pub(crate) const MAX_CANDIDATES: usize = 50;
|
|
||||||
|
|
||||||
/// Hard bounds on the query to keep tokenization and the FTS5 MATCH cheap.
|
|
||||||
const MAX_QUERY_CHARS: usize = 512;
|
|
||||||
const MAX_TERMS: usize = 12;
|
|
||||||
|
|
||||||
const STOPWORDS: &[&str] = &[
|
|
||||||
"的", "了", "是", "在", "我", "你", "他", "她", "它", "们", "吗", "呢", "啊", "吧", "这", "那",
|
|
||||||
"什么", "怎么", "为什么", "帮我", "一下", "请", "要", "想", "能", "可以", "这个", "那个", "今天",
|
|
||||||
"现在", "以及", "还是", "因为", "所以", "如果", "但是", "就", "都", "也", "还", "很", "被", "把",
|
|
||||||
"和", "与", "或", "有", "没有", "一个", "一种", "一些", "哪些", "如何",
|
|
||||||
"a", "an", "the", "to", "of", "for", "and", "or", "what", "how", "please", "can", "do", "is",
|
|
||||||
"are", "my", "you", "i", "we", "this", "that", "me", "it", "on", "in", "at", "with", "about",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Runtime knobs for automatic recall, derived from `config.memory`.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RecallConfig {
|
|
||||||
pub limit: usize,
|
|
||||||
pub min_relevance: f64,
|
|
||||||
pub min_score: f64,
|
|
||||||
pub recency_half_life_days: f64,
|
|
||||||
pub timeout: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RecallConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
limit: 5,
|
|
||||||
min_relevance: 0.25,
|
|
||||||
min_score: 0.25,
|
|
||||||
recency_half_life_days: 30.0,
|
|
||||||
timeout: Duration::from_millis(1000),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RecallConfig {
|
|
||||||
pub fn from_memory_config(memory: &crate::config::MemoryConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
limit: memory.recall_limit,
|
|
||||||
min_relevance: memory.recall_min_relevance,
|
|
||||||
min_score: memory.recall_min_score,
|
|
||||||
recency_half_life_days: memory.recall_recency_half_life_days as f64,
|
|
||||||
timeout: Duration::from_millis(memory.recall_timeout_ms),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn jieba() -> &'static Jieba {
|
|
||||||
static INSTANCE: OnceLock<Jieba> = OnceLock::new();
|
|
||||||
INSTANCE.get_or_init(Jieba::new)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tokenize with jieba and drop tokens that are too short to be meaningful.
|
|
||||||
pub(crate) fn tokenize(query: &str) -> Vec<String> {
|
|
||||||
jieba()
|
|
||||||
.cut(query, true)
|
|
||||||
.into_iter()
|
|
||||||
.map(|token| token.word)
|
|
||||||
.filter(|word| word.len() > 1 || word.bytes().any(|b| b > 127))
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalize a user query into deduplicated, stopword-free search terms.
|
|
||||||
pub(crate) fn normalize_query(query: &str) -> Vec<String> {
|
|
||||||
let bounded: String = if query.chars().count() > MAX_QUERY_CHARS {
|
|
||||||
query.chars().take(MAX_QUERY_CHARS).collect()
|
|
||||||
} else {
|
|
||||||
query.to_string()
|
|
||||||
};
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
let mut terms: Vec<String> = Vec::new();
|
|
||||||
for term in tokenize(&bounded) {
|
|
||||||
let lower = term.to_lowercase();
|
|
||||||
if STOPWORDS.contains(&lower.as_str()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if seen.insert(lower) {
|
|
||||||
terms.push(term);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
terms.truncate(MAX_TERMS);
|
|
||||||
terms
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fraction of distinct query terms present in the entry's key or content.
|
|
||||||
fn relevance(entry: &MemoryEntry, terms: &[String]) -> f64 {
|
|
||||||
let key = entry.key.to_lowercase();
|
|
||||||
let content = entry.content.to_lowercase();
|
|
||||||
let matched = terms
|
|
||||||
.iter()
|
|
||||||
.filter(|term| {
|
|
||||||
let term = term.to_lowercase();
|
|
||||||
key.contains(&term) || content.contains(&term)
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
matched as f64 / terms.len() as f64
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exponential recency decay in [0,1]; 1.0 for just-updated, 0.0 for very old.
|
|
||||||
fn recency(updated_at: &str, half_life_days: f64, now_ms: i64) -> f64 {
|
|
||||||
let Ok(dt) = chrono::DateTime::parse_from_rfc3339(updated_at) else {
|
|
||||||
return 0.0;
|
|
||||||
};
|
|
||||||
let half_life = half_life_days.max(1.0);
|
|
||||||
let age_ms = (now_ms - dt.timestamp_millis()).max(0);
|
|
||||||
let age_days = age_ms as f64 / 86_400_000.0;
|
|
||||||
(-age_days / half_life).exp()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rank candidates by combined score and drop those below the gates.
|
|
||||||
pub(crate) fn rank_and_gate(
|
|
||||||
candidates: Vec<MemoryEntry>,
|
|
||||||
terms: &[String],
|
|
||||||
config: &RecallConfig,
|
|
||||||
now_ms: i64,
|
|
||||||
) -> Vec<MemoryEntry> {
|
|
||||||
let mut scored: Vec<(f64, f64, MemoryEntry)> = Vec::with_capacity(candidates.len());
|
|
||||||
for entry in candidates {
|
|
||||||
let rel = relevance(&entry, terms);
|
|
||||||
if rel < config.min_relevance {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let importance = entry.importance.clamp(0.0, 1.0);
|
|
||||||
let rec = recency(&entry.updated_at, config.recency_half_life_days, now_ms);
|
|
||||||
let score = WEIGHT_RELEVANCE * rel + WEIGHT_IMPORTANCE * importance + WEIGHT_RECENCY * rec;
|
|
||||||
if score < config.min_score {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
scored.push((score, importance, entry));
|
|
||||||
}
|
|
||||||
scored.sort_by(|a, b| {
|
|
||||||
b.0.partial_cmp(&a.0)
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
.then_with(|| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
|
|
||||||
});
|
|
||||||
scored.into_iter().map(|(_, _, entry)| entry).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::memory::MemoryCategory;
|
|
||||||
|
|
||||||
fn entry(key: &str, content: &str, importance: f64, updated_at: &str) -> MemoryEntry {
|
|
||||||
MemoryEntry {
|
|
||||||
id: format!("id-{key}"),
|
|
||||||
key: key.to_string(),
|
|
||||||
content: content.to_string(),
|
|
||||||
category: MemoryCategory::Knowledge,
|
|
||||||
importance,
|
|
||||||
session_id: None,
|
|
||||||
created_at: updated_at.to_string(),
|
|
||||||
updated_at: updated_at.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_query_removes_stopwords_and_deduplicates() {
|
|
||||||
let terms = normalize_query("请帮我写一个 Python 脚本,用 python 处理");
|
|
||||||
assert!(!terms.contains(&"请".to_string()));
|
|
||||||
assert!(!terms.contains(&"一个".to_string()));
|
|
||||||
// "python" appears twice but is deduplicated (case-insensitive).
|
|
||||||
assert_eq!(terms.iter().filter(|t| t.to_lowercase() == "python").count(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_query_returns_empty_for_all_stopwords() {
|
|
||||||
assert!(normalize_query("呢吗啊吧").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relevance_counts_distinct_term_matches() {
|
|
||||||
let e = entry("py", "the user prefers python", 0.5, "2024-01-01T00:00:00Z");
|
|
||||||
let terms = vec!["python".to_string(), "rust".to_string()];
|
|
||||||
assert!((relevance(&e, &terms) - 0.5).abs() < 1e-9);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gate_drops_low_relevance() {
|
|
||||||
let e = entry("unrelated", "something else entirely", 1.0, "2024-01-01T00:00:00Z");
|
|
||||||
let terms = vec!["python".to_string(), "script".to_string()];
|
|
||||||
let cfg = RecallConfig::default();
|
|
||||||
let out = rank_and_gate(vec![e], &terms, &cfg, 1_704_067_200_000);
|
|
||||||
assert!(out.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gate_keeps_high_relevance_and_sorts_by_score() {
|
|
||||||
let strong = entry("strong", "python script", 0.5, "2024-01-01T00:00:00Z");
|
|
||||||
let weak = entry("weak", "a python note", 0.9, "2024-01-01T00:00:00Z");
|
|
||||||
let terms = vec!["python".to_string(), "script".to_string()];
|
|
||||||
let cfg = RecallConfig::default();
|
|
||||||
let out = rank_and_gate(vec![weak, strong], &terms, &cfg, 1_704_067_200_000);
|
|
||||||
assert_eq!(out.len(), 2);
|
|
||||||
assert_eq!(out[0].key, "strong");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn importance_breaks_relevance_ties() {
|
|
||||||
let a = entry("a", "python script", 0.2, "2024-01-01T00:00:00Z");
|
|
||||||
let b = entry("b", "python script", 0.9, "2024-01-01T00:00:00Z");
|
|
||||||
let terms = vec!["python".to_string(), "script".to_string()];
|
|
||||||
let cfg = RecallConfig::default();
|
|
||||||
let out = rank_and_gate(vec![a, b], &terms, &cfg, 1_704_067_200_000);
|
|
||||||
assert_eq!(out[0].key, "b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recency_decays_with_age() {
|
|
||||||
let recent = recency("2024-01-10T00:00:00Z", 30.0, 1_704_067_200_000);
|
|
||||||
let old = recency("2023-01-10T00:00:00Z", 30.0, 1_704_067_200_000);
|
|
||||||
assert!(recent > old);
|
|
||||||
assert!((0.0..=1.0).contains(&recent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -76,15 +76,11 @@ impl Metrics {
|
|||||||
pub fn record_turn(&self, usage: Option<&Usage>, latency_ms: u64) {
|
pub fn record_turn(&self, usage: Option<&Usage>, latency_ms: u64) {
|
||||||
self.turns.fetch_add(1, Relaxed);
|
self.turns.fetch_add(1, Relaxed);
|
||||||
if let Some(u) = usage {
|
if let Some(u) = usage {
|
||||||
self.tokens_in
|
self.tokens_in.fetch_add(u64::from(u.prompt_tokens), Relaxed);
|
||||||
.fetch_add(u64::from(u.prompt_tokens), Relaxed);
|
|
||||||
self.tokens_out
|
self.tokens_out
|
||||||
.fetch_add(u64::from(u.completion_tokens), Relaxed);
|
.fetch_add(u64::from(u.completion_tokens), Relaxed);
|
||||||
}
|
}
|
||||||
let mut q = self
|
let mut q = self.turn_latencies.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
.turn_latencies
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
|
||||||
q.push_back(latency_ms);
|
q.push_back(latency_ms);
|
||||||
while q.len() > WINDOW {
|
while q.len() > WINDOW {
|
||||||
q.pop_front();
|
q.pop_front();
|
||||||
@ -147,10 +143,7 @@ impl Metrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn snapshot(&self) -> MetricsSnapshot {
|
pub fn snapshot(&self) -> MetricsSnapshot {
|
||||||
let latencies = self
|
let latencies = self.turn_latencies.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
.turn_latencies
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
|
||||||
let p95 = percentile_95(&latencies);
|
let p95 = percentile_95(&latencies);
|
||||||
let providers = self.providers.lock().unwrap_or_else(|e| e.into_inner());
|
let providers = self.providers.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let mut cost = 0.0;
|
let mut cost = 0.0;
|
||||||
|
|||||||
@ -62,9 +62,7 @@ pub struct ToolExecutionOutcome {
|
|||||||
/// How long the tool took to execute.
|
/// How long the tool took to execute.
|
||||||
pub duration: Duration,
|
pub duration: Duration,
|
||||||
/// Structured media returned by the tool for the next model iteration.
|
/// Structured media returned by the tool for the next model iteration.
|
||||||
pub model_media_refs: Vec<MediaRef>,
|
pub media_refs: Vec<MediaRef>,
|
||||||
/// Structured media that should be attached to the final user reply.
|
|
||||||
pub reply_media_refs: Vec<MediaRef>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolExecutionOutcome {
|
impl ToolExecutionOutcome {
|
||||||
@ -75,24 +73,18 @@ impl ToolExecutionOutcome {
|
|||||||
success: true,
|
success: true,
|
||||||
error_reason: None,
|
error_reason: None,
|
||||||
duration: Duration::ZERO,
|
duration: Duration::ZERO,
|
||||||
model_media_refs: Vec::new(),
|
media_refs: Vec::new(),
|
||||||
reply_media_refs: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a successful outcome carrying processed structured artifacts.
|
/// Create a successful outcome carrying structured media artifacts.
|
||||||
pub fn success_with_output(
|
pub fn success_with_media(output: String, media_refs: Vec<MediaRef>) -> Self {
|
||||||
output: String,
|
|
||||||
model_media_refs: Vec<MediaRef>,
|
|
||||||
reply_media_refs: Vec<MediaRef>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
output,
|
output,
|
||||||
success: true,
|
success: true,
|
||||||
error_reason: None,
|
error_reason: None,
|
||||||
duration: Duration::ZERO,
|
duration: Duration::ZERO,
|
||||||
model_media_refs,
|
media_refs,
|
||||||
reply_media_refs,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,8 +95,7 @@ impl ToolExecutionOutcome {
|
|||||||
success: false,
|
success: false,
|
||||||
error_reason,
|
error_reason,
|
||||||
duration: Duration::ZERO,
|
duration: Duration::ZERO,
|
||||||
model_media_refs: Vec::new(),
|
media_refs: Vec::new(),
|
||||||
reply_media_refs: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
371
src/protocol.rs
371
src/protocol.rs
@ -37,173 +37,6 @@ pub struct MessageAttachment {
|
|||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded client projection of a durable Agent run. Sensitive internals
|
|
||||||
/// (budget, signal contract, delivery context, provider state) never leave
|
|
||||||
/// the gateway; the full result is available through the HTTP API.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AgentRunView {
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub parent_run_id: Option<String>,
|
|
||||||
pub agent_id: String,
|
|
||||||
pub provider_name: String,
|
|
||||||
pub model_id: String,
|
|
||||||
pub mode: String,
|
|
||||||
pub depth: u16,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub plan_item_id: Option<String>,
|
|
||||||
pub status: String,
|
|
||||||
/// Task prompt (bounded for client delivery).
|
|
||||||
pub task: String,
|
|
||||||
/// Bounded result excerpt; full content is loaded on demand.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub result: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub error: Option<String>,
|
|
||||||
pub tool_calls_count: i64,
|
|
||||||
pub iterations: i64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub deadline_at: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub started_at: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub finished_at: Option<i64>,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bounded client projection of a durable inbox event.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AgentEventView {
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub run_id: Option<String>,
|
|
||||||
pub event_type: String,
|
|
||||||
pub delivery: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub severity: Option<String>,
|
|
||||||
/// Structured payload, bounded; signal summaries and completion status
|
|
||||||
/// only. The client must treat the payload as untrusted data.
|
|
||||||
pub payload_json: String,
|
|
||||||
pub status: String,
|
|
||||||
pub attempt_count: i64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub last_error: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub consumed_at: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub superseded_at: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dead_lettered_at: Option<i64>,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialized transcript message for a single Agent run, exposed through the
|
|
||||||
/// HTTP detail endpoint. `reasoning_content` is client-visible here but
|
|
||||||
/// `provider_state` is never included.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AgentTranscriptMessage {
|
|
||||||
pub id: String,
|
|
||||||
pub run_id: String,
|
|
||||||
pub seq: i64,
|
|
||||||
pub role: String,
|
|
||||||
pub content: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reasoning_content: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub tool_call_id: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub tool_name: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub tool_calls: Option<Vec<crate::providers::ToolCall>>,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<crate::storage::agent_run::AgentRunMessageRecord> for AgentTranscriptMessage {
|
|
||||||
fn from(record: crate::storage::agent_run::AgentRunMessageRecord) -> Self {
|
|
||||||
let tool_calls = record
|
|
||||||
.tool_calls_json
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|json| serde_json::from_str(json).ok());
|
|
||||||
Self {
|
|
||||||
id: record.id,
|
|
||||||
run_id: record.run_id,
|
|
||||||
seq: record.seq,
|
|
||||||
role: record.role,
|
|
||||||
content: record.content,
|
|
||||||
reasoning_content: record.reasoning_content,
|
|
||||||
tool_call_id: record.tool_call_id,
|
|
||||||
tool_name: record.tool_name,
|
|
||||||
tool_calls,
|
|
||||||
created_at: record.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentRunView {
|
|
||||||
pub fn from_record(
|
|
||||||
record: &crate::storage::agent_run::AgentRunRecord,
|
|
||||||
max_result_chars: usize,
|
|
||||||
) -> Self {
|
|
||||||
let task = truncate(&record.task, 2_000);
|
|
||||||
let result = record
|
|
||||||
.result
|
|
||||||
.as_deref()
|
|
||||||
.map(|result| truncate(result, max_result_chars));
|
|
||||||
Self {
|
|
||||||
id: record.id.clone(),
|
|
||||||
parent_run_id: record.parent_run_id.clone(),
|
|
||||||
agent_id: record.agent_id.clone(),
|
|
||||||
provider_name: record.provider_name.clone(),
|
|
||||||
model_id: record.model_id.clone(),
|
|
||||||
mode: record.mode.as_str().to_string(),
|
|
||||||
depth: u16::try_from(record.depth).unwrap_or(u16::MAX),
|
|
||||||
plan_item_id: record.plan_item_id.clone(),
|
|
||||||
status: record.status.as_str().to_string(),
|
|
||||||
task,
|
|
||||||
result,
|
|
||||||
error: record.error.clone(),
|
|
||||||
tool_calls_count: record.tool_calls_count,
|
|
||||||
iterations: record.iterations,
|
|
||||||
deadline_at: Some(record.deadline_at),
|
|
||||||
started_at: record.started_at,
|
|
||||||
finished_at: record.finished_at,
|
|
||||||
created_at: record.created_at,
|
|
||||||
updated_at: record.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentEventView {
|
|
||||||
pub fn from_record(record: &crate::storage::agent_inbox::AgentInboxEventRecord) -> Self {
|
|
||||||
Self {
|
|
||||||
id: record.id.clone(),
|
|
||||||
run_id: record.run_id.clone(),
|
|
||||||
event_type: record.event_type.as_str().to_string(),
|
|
||||||
delivery: record.delivery.as_str().to_string(),
|
|
||||||
severity: record.severity.clone(),
|
|
||||||
payload_json: record.payload_json.clone(),
|
|
||||||
status: record.status.as_str().to_string(),
|
|
||||||
attempt_count: record.attempt_count,
|
|
||||||
last_error: record.last_error.clone(),
|
|
||||||
consumed_at: record.consumed_at,
|
|
||||||
superseded_at: record.superseded_at,
|
|
||||||
dead_lettered_at: record.dead_lettered_at,
|
|
||||||
created_at: record.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate(value: &str, max: usize) -> String {
|
|
||||||
if value.chars().count() <= max {
|
|
||||||
value.to_string()
|
|
||||||
} else {
|
|
||||||
let mut truncated: String = value.chars().take(max).collect();
|
|
||||||
truncated.push('…');
|
|
||||||
truncated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MessageAttachment {
|
impl MessageAttachment {
|
||||||
pub fn from_media_ref(index: usize, media_ref: &crate::bus::MediaRef) -> Self {
|
pub fn from_media_ref(index: usize, media_ref: &crate::bus::MediaRef) -> Self {
|
||||||
let name = std::path::Path::new(&media_ref.path)
|
let name = std::path::Path::new(&media_ref.path)
|
||||||
@ -242,8 +75,6 @@ pub struct HistoryMessage {
|
|||||||
pub tool_calls: Option<Vec<crate::providers::ToolCall>>,
|
pub tool_calls: Option<Vec<crate::providers::ToolCall>>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub attachments: Vec<MessageAttachment>,
|
pub attachments: Vec<MessageAttachment>,
|
||||||
#[serde(default)]
|
|
||||||
pub turn_origin: crate::bus::TurnOrigin,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<crate::bus::CommittedMessage> for HistoryMessage {
|
impl From<crate::bus::CommittedMessage> for HistoryMessage {
|
||||||
@ -266,7 +97,6 @@ impl From<crate::bus::CommittedMessage> for HistoryMessage {
|
|||||||
tool_name: message.tool_name,
|
tool_name: message.tool_name,
|
||||||
tool_calls: message.tool_calls,
|
tool_calls: message.tool_calls,
|
||||||
attachments,
|
attachments,
|
||||||
turn_origin: message.turn_origin,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -296,7 +126,6 @@ impl HistoryMessage {
|
|||||||
.tool_calls
|
.tool_calls
|
||||||
.and_then(|calls| serde_json::from_str(&calls).ok()),
|
.and_then(|calls| serde_json::from_str(&calls).ok()),
|
||||||
attachments,
|
attachments,
|
||||||
turn_origin: message.turn_origin,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -309,10 +138,6 @@ pub enum WsInbound {
|
|||||||
content: String,
|
content: String,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
upload_ids: Vec<String>,
|
upload_ids: Vec<String>,
|
||||||
/// Stable id generated by the client for optimistic-message
|
|
||||||
/// reconciliation. It is optional for older clients and channels.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
client_message_id: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
channel: Option<String>,
|
channel: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@ -347,8 +172,6 @@ pub enum WsInbound {
|
|||||||
},
|
},
|
||||||
#[serde(rename = "get_session_plan")]
|
#[serde(rename = "get_session_plan")]
|
||||||
GetSessionPlan { session_id: String },
|
GetSessionPlan { session_id: String },
|
||||||
#[serde(rename = "get_session_stats")]
|
|
||||||
GetSessionStats { session_id: String },
|
|
||||||
#[serde(rename = "rename_session")]
|
#[serde(rename = "rename_session")]
|
||||||
RenameSession {
|
RenameSession {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@ -367,16 +190,6 @@ pub enum WsInbound {
|
|||||||
},
|
},
|
||||||
#[serde(rename = "get_slash_commands")]
|
#[serde(rename = "get_slash_commands")]
|
||||||
GetSlashCommands,
|
GetSlashCommands,
|
||||||
#[serde(rename = "get_agent_runs")]
|
|
||||||
GetAgentRuns {
|
|
||||||
session_id: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
cursor: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
limit: Option<u32>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "get_agent_run")]
|
|
||||||
GetAgentRun { session_id: String, run_id: String },
|
|
||||||
#[serde(rename = "ping")]
|
#[serde(rename = "ping")]
|
||||||
Ping,
|
Ping,
|
||||||
}
|
}
|
||||||
@ -436,8 +249,6 @@ pub enum WsOutbound {
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
plan: Option<crate::work::TaskPlan>,
|
plan: Option<crate::work::TaskPlan>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "session_stats")]
|
|
||||||
SessionStats { stats: crate::session::SessionStats },
|
|
||||||
#[serde(rename = "plan_updated")]
|
#[serde(rename = "plan_updated")]
|
||||||
PlanUpdated {
|
PlanUpdated {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@ -455,26 +266,6 @@ pub enum WsOutbound {
|
|||||||
HistoryCleared { session_id: String },
|
HistoryCleared { session_id: String },
|
||||||
#[serde(rename = "slash_commands_list")]
|
#[serde(rename = "slash_commands_list")]
|
||||||
SlashCommandsList { commands: Vec<SlashCommandInfo> },
|
SlashCommandsList { commands: Vec<SlashCommandInfo> },
|
||||||
#[serde(rename = "session_agent_runs")]
|
|
||||||
SessionAgentRuns {
|
|
||||||
session_id: String,
|
|
||||||
revision: i64,
|
|
||||||
runs: Vec<AgentRunView>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
next_cursor: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "agent_run_updated")]
|
|
||||||
AgentRunUpdated {
|
|
||||||
session_id: String,
|
|
||||||
revision: i64,
|
|
||||||
run: AgentRunView,
|
|
||||||
},
|
|
||||||
#[serde(rename = "agent_event_updated")]
|
|
||||||
AgentEventUpdated {
|
|
||||||
session_id: String,
|
|
||||||
revision: i64,
|
|
||||||
event: AgentEventView,
|
|
||||||
},
|
|
||||||
#[serde(rename = "pong")]
|
#[serde(rename = "pong")]
|
||||||
Pong,
|
Pong,
|
||||||
#[serde(rename = "command_executed")]
|
#[serde(rename = "command_executed")]
|
||||||
@ -544,7 +335,6 @@ mod tests {
|
|||||||
tool_name: None,
|
tool_name: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
turn_origin: crate::bus::TurnOrigin::User,
|
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let value = serde_json::to_value(frame).unwrap();
|
let value = serde_json::to_value(frame).unwrap();
|
||||||
@ -554,36 +344,6 @@ mod tests {
|
|||||||
assert_eq!(value["messages"][0]["id"], "message");
|
assert_eq!(value["messages"][0]["id"], "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_input_preserves_optional_client_message_id() {
|
|
||||||
let inbound = parse_inbound(
|
|
||||||
r#"{"type":"user_input","content":"hello","client_message_id":"client-1"}"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
match inbound {
|
|
||||||
WsInbound::UserInput {
|
|
||||||
client_message_id,
|
|
||||||
upload_ids,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
assert_eq!(client_message_id.as_deref(), Some("client-1"));
|
|
||||||
assert!(upload_ids.is_empty());
|
|
||||||
}
|
|
||||||
other => panic!("unexpected frame: {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let serialized = serialize_inbound(&WsInbound::UserInput {
|
|
||||||
content: "hello".to_string(),
|
|
||||||
upload_ids: Vec::new(),
|
|
||||||
client_message_id: Some("client-1".to_string()),
|
|
||||||
channel: None,
|
|
||||||
chat_id: None,
|
|
||||||
sender_id: None,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert!(serialized.contains(r#""client_message_id":"client-1""#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn history_defaults_new_reasoning_fields_for_old_frames() {
|
fn history_defaults_new_reasoning_fields_for_old_frames() {
|
||||||
let message: HistoryMessage = serde_json::from_value(serde_json::json!({
|
let message: HistoryMessage = serde_json::from_value(serde_json::json!({
|
||||||
@ -602,135 +362,4 @@ mod tests {
|
|||||||
crate::bus::CompletionStatus::Completed
|
crate::bus::CompletionStatus::Completed
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_stats_request_and_response_use_structured_frames() {
|
|
||||||
let inbound =
|
|
||||||
parse_inbound(r#"{"type":"get_session_stats","session_id":"cli_chat:client:dialog"}"#)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(inbound, WsInbound::GetSessionStats { .. }));
|
|
||||||
|
|
||||||
let stats = crate::session::SessionStats {
|
|
||||||
session_id: "cli_chat:client:dialog".into(),
|
|
||||||
title: "stats".into(),
|
|
||||||
provider: "provider".into(),
|
|
||||||
model: "model".into(),
|
|
||||||
user_message_count: 1,
|
|
||||||
history_message_count: 2,
|
|
||||||
lifetime_usage: crate::session::LifetimeUsage {
|
|
||||||
input_tokens: 100,
|
|
||||||
output_tokens: 20,
|
|
||||||
total_tokens: 120,
|
|
||||||
cached_input_tokens: Some(40),
|
|
||||||
request_count: 1,
|
|
||||||
turn_count: 1,
|
|
||||||
tracked_since: Some(1),
|
|
||||||
},
|
|
||||||
context: crate::session::ContextUsage {
|
|
||||||
configured_window_tokens: 128_000,
|
|
||||||
effective_window_tokens: 128_000,
|
|
||||||
configured_reserve_tokens: 16_384,
|
|
||||||
reserve_tokens: 16_384,
|
|
||||||
configured_keep_recent_tokens: 20_000,
|
|
||||||
effective_keep_recent_tokens: 20_000,
|
|
||||||
used_tokens: 100,
|
|
||||||
remaining_tokens: 127_900,
|
|
||||||
compression_threshold_tokens: 111_616,
|
|
||||||
source: crate::session::ContextUsageSource::Observed,
|
|
||||||
last_observed_prompt_tokens: Some(90),
|
|
||||||
observed_at: Some(1),
|
|
||||||
active_checkpoint_id: None,
|
|
||||||
checkpoint_generation: 0,
|
|
||||||
checkpoint_tokens_before: None,
|
|
||||||
checkpoint_tokens_after: None,
|
|
||||||
checkpoint_degraded: false,
|
|
||||||
},
|
|
||||||
created_at: 1,
|
|
||||||
last_active_at: 2,
|
|
||||||
updated_at: 3,
|
|
||||||
};
|
|
||||||
let value = serde_json::to_value(WsOutbound::SessionStats { stats }).unwrap();
|
|
||||||
assert_eq!(value["type"], "session_stats");
|
|
||||||
assert_eq!(value["stats"]["context"]["source"], "observed");
|
|
||||||
assert_eq!(value["stats"]["lifetime_usage"]["input_tokens"], 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agent_run_and_event_views_serialize_without_sensitive_fields() {
|
|
||||||
let run = crate::storage::agent_run::AgentRunRecord {
|
|
||||||
id: "run-1".to_string(),
|
|
||||||
root_session_id: "cli:test:d1".to_string(),
|
|
||||||
root_turn_id: None,
|
|
||||||
parent_run_id: None,
|
|
||||||
caller_agent_id: "ROOT".to_string(),
|
|
||||||
caller_scope_id: "turn-1".to_string(),
|
|
||||||
idempotency_key: None,
|
|
||||||
agent_id: "researcher".to_string(),
|
|
||||||
definition_hash: "hash".to_string(),
|
|
||||||
provider_profile: "research".to_string(),
|
|
||||||
provider_name: "openai-test".to_string(),
|
|
||||||
model_id: "model-x".to_string(),
|
|
||||||
mode: crate::storage::agent_run::AgentRunMode::Background,
|
|
||||||
depth: 1,
|
|
||||||
plan_item_id: None,
|
|
||||||
execution_id: "run-1".to_string(),
|
|
||||||
task: "analyze the logs".to_string(),
|
|
||||||
context_json: Some("sensitive caller context".to_string()),
|
|
||||||
budget_json: r#"{"remaining_runs":3}"#.to_string(),
|
|
||||||
signal_contract_json: Some("secret contract".to_string()),
|
|
||||||
signal_delivery: None,
|
|
||||||
status: crate::storage::agent_run::AgentRunStatus::Completed,
|
|
||||||
result: Some("r".repeat(10_000)),
|
|
||||||
error: None,
|
|
||||||
prompt_tokens: None,
|
|
||||||
completion_tokens: None,
|
|
||||||
cost: None,
|
|
||||||
tool_calls_count: 3,
|
|
||||||
iterations: 2,
|
|
||||||
runtime_generation: 1,
|
|
||||||
attempt: 1,
|
|
||||||
completion_slot_reserved: false,
|
|
||||||
deadline_at: 1000,
|
|
||||||
revision: 7,
|
|
||||||
started_at: Some(10),
|
|
||||||
finished_at: Some(20),
|
|
||||||
created_at: 5,
|
|
||||||
updated_at: 20,
|
|
||||||
};
|
|
||||||
let view = AgentRunView::from_record(&run, 2_000);
|
|
||||||
let value = serde_json::to_value(&view).unwrap();
|
|
||||||
assert_eq!(value["id"], "run-1");
|
|
||||||
assert_eq!(value["status"], "completed");
|
|
||||||
assert!(!value.as_object().unwrap().contains_key("context_json"));
|
|
||||||
assert!(!value.as_object().unwrap().contains_key("budget_json"));
|
|
||||||
assert!(
|
|
||||||
!value
|
|
||||||
.as_object()
|
|
||||||
.unwrap()
|
|
||||||
.contains_key("signal_contract_json")
|
|
||||||
);
|
|
||||||
assert!(!value.as_object().unwrap().contains_key("execution_id"));
|
|
||||||
// The result is bounded for client delivery.
|
|
||||||
assert!(value["result"].as_str().unwrap().chars().count() <= 2_001);
|
|
||||||
|
|
||||||
let message = WsOutbound::AgentRunUpdated {
|
|
||||||
session_id: "cli:test:d1".to_string(),
|
|
||||||
revision: 7,
|
|
||||||
run: view,
|
|
||||||
};
|
|
||||||
let value = serde_json::to_value(message).unwrap();
|
|
||||||
assert_eq!(value["type"], "agent_run_updated");
|
|
||||||
assert_eq!(value["revision"], 7);
|
|
||||||
|
|
||||||
let inbound: WsInbound =
|
|
||||||
parse_inbound(r#"{"type":"get_agent_runs","session_id":"cli:test:d1","limit":10}"#)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
inbound,
|
|
||||||
WsInbound::GetAgentRuns {
|
|
||||||
limit: Some(10),
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,58 +148,21 @@ struct AnthropicMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn convert_messages(messages: &[Message]) -> Vec<AnthropicMessage> {
|
fn convert_messages(messages: &[Message]) -> Vec<AnthropicMessage> {
|
||||||
let mut converted = Vec::with_capacity(messages.len());
|
messages
|
||||||
let mut index = 0;
|
.iter()
|
||||||
|
.map(|message| {
|
||||||
while index < messages.len() {
|
let role = if message.role == "tool" {
|
||||||
let message = &messages[index];
|
"user".to_string()
|
||||||
|
} else {
|
||||||
// Anthropic requires all tool results for one assistant tool-use turn
|
message.role.clone()
|
||||||
// to be carried in a single `role: user` content array. Steering is
|
};
|
||||||
// represented as a normal user message in PicoBot history, so merge
|
let content = if let Some(ref tool_call_id) = message.tool_call_id {
|
||||||
// any immediately-following user messages into that same array at
|
vec![serde_json::json!({
|
||||||
// the provider boundary. Durable messages remain independent.
|
|
||||||
if message.role == "tool" && message.tool_call_id.is_some() {
|
|
||||||
let mut content = Vec::new();
|
|
||||||
while index < messages.len()
|
|
||||||
&& messages[index].role == "tool"
|
|
||||||
&& messages[index].tool_call_id.is_some()
|
|
||||||
{
|
|
||||||
let tool = &messages[index];
|
|
||||||
let tool_call_id = tool
|
|
||||||
.tool_call_id
|
|
||||||
.as_deref()
|
|
||||||
.expect("tool_call_id checked above");
|
|
||||||
content.push(serde_json::json!({
|
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": tool_call_id,
|
"tool_use_id": tool_call_id,
|
||||||
"content": convert_content_blocks(&tool.content, false),
|
"content": convert_content_blocks(&message.content, false),
|
||||||
}));
|
})]
|
||||||
index += 1;
|
} else if let Some(native) = native_anthropic_content(message) {
|
||||||
}
|
|
||||||
|
|
||||||
// One turn may receive more than one steering message before the
|
|
||||||
// next model request. Keep their order while emitting one native
|
|
||||||
// Anthropic user message alongside the tool_result blocks.
|
|
||||||
while index < messages.len() && messages[index].role == "user" {
|
|
||||||
let steering = &messages[index];
|
|
||||||
if let Some(native) = native_anthropic_content(steering) {
|
|
||||||
content.extend(native);
|
|
||||||
} else {
|
|
||||||
content.extend(convert_content_blocks(&steering.content, false));
|
|
||||||
}
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
converted.push(AnthropicMessage {
|
|
||||||
role: "user".to_string(),
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let role = message.role.clone();
|
|
||||||
let content = if let Some(native) = native_anthropic_content(message) {
|
|
||||||
native
|
native
|
||||||
} else {
|
} else {
|
||||||
let mut blocks = convert_content_blocks(&message.content, message.role == "system");
|
let mut blocks = convert_content_blocks(&message.content, message.role == "system");
|
||||||
@ -219,11 +182,9 @@ fn convert_messages(messages: &[Message]) -> Vec<AnthropicMessage> {
|
|||||||
}
|
}
|
||||||
blocks
|
blocks
|
||||||
};
|
};
|
||||||
converted.push(AnthropicMessage { role, content });
|
AnthropicMessage { role, content }
|
||||||
index += 1;
|
})
|
||||||
}
|
.collect()
|
||||||
|
|
||||||
converted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn native_anthropic_content(message: &Message) -> Option<Vec<Value>> {
|
fn native_anthropic_content(message: &Message) -> Option<Vec<Value>> {
|
||||||
@ -729,35 +690,6 @@ mod tests {
|
|||||||
assert_eq!(result["content"][1]["source"]["data"], "AAAA");
|
assert_eq!(result["content"][1]["source"]["data"], "AAAA");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tool_results_and_following_steering_share_one_user_content_array() {
|
|
||||||
let messages = vec![
|
|
||||||
Message::tool("call_1", "lookup", "first result"),
|
|
||||||
Message::tool("call_2", "lookup", "second result"),
|
|
||||||
Message::user("用户补充指令"),
|
|
||||||
Message::user("再补充一条"),
|
|
||||||
Message::assistant("最终回答"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let converted = convert_messages(&messages);
|
|
||||||
|
|
||||||
assert_eq!(converted.len(), 2);
|
|
||||||
assert_eq!(converted[0].role, "user");
|
|
||||||
assert_eq!(converted[0].content.len(), 4);
|
|
||||||
assert_eq!(converted[0].content[0]["type"], "tool_result");
|
|
||||||
assert_eq!(converted[0].content[0]["tool_use_id"], "call_1");
|
|
||||||
assert_eq!(converted[0].content[1]["tool_use_id"], "call_2");
|
|
||||||
assert_eq!(
|
|
||||||
converted[0].content[2],
|
|
||||||
json!({"type": "text", "text": "用户补充指令"})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted[0].content[3],
|
|
||||||
json!({"type": "text", "text": "再补充一条"})
|
|
||||||
);
|
|
||||||
assert_eq!(converted[1].role, "assistant");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn native_stream_decodes_thinking_signature_tools_usage_and_replay_state() {
|
fn native_stream_decodes_thinking_signature_tools_usage_and_replay_state() {
|
||||||
let events = [
|
let events = [
|
||||||
|
|||||||
@ -761,46 +761,6 @@ mod tests {
|
|||||||
assert_eq!(converted[1]["content"], "second image");
|
assert_eq!(converted[1]["content"], "second image");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assistant_tools_precede_all_tool_results_and_following_steering() {
|
|
||||||
let messages = vec![
|
|
||||||
Message {
|
|
||||||
role: "assistant".to_string(),
|
|
||||||
content: vec![ContentBlock::text("calling tools")],
|
|
||||||
reasoning_content: None,
|
|
||||||
provider_state: None,
|
|
||||||
tool_call_id: None,
|
|
||||||
name: None,
|
|
||||||
tool_calls: Some(vec![
|
|
||||||
ToolCall {
|
|
||||||
id: "call_1".to_string(),
|
|
||||||
name: "lookup".to_string(),
|
|
||||||
arguments: json!({"q": "one"}),
|
|
||||||
},
|
|
||||||
ToolCall {
|
|
||||||
id: "call_2".to_string(),
|
|
||||||
name: "lookup".to_string(),
|
|
||||||
arguments: json!({"q": "two"}),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
Message::tool("call_1", "lookup", "result"),
|
|
||||||
Message::tool("call_2", "lookup", "second result"),
|
|
||||||
Message::user("用户补充指令"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let converted = convert_messages(&messages);
|
|
||||||
|
|
||||||
assert_eq!(converted.len(), 4);
|
|
||||||
assert_eq!(converted[0]["role"], "assistant");
|
|
||||||
assert_eq!(converted[1]["role"], "tool");
|
|
||||||
assert_eq!(converted[1]["tool_call_id"], "call_1");
|
|
||||||
assert_eq!(converted[2]["role"], "tool");
|
|
||||||
assert_eq!(converted[2]["tool_call_id"], "call_2");
|
|
||||||
assert_eq!(converted[3]["role"], "user");
|
|
||||||
assert_eq!(converted[3]["content"], "用户补充指令");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assistant_images_are_never_serialized_as_native_content_parts() {
|
fn assistant_images_are_never_serialized_as_native_content_parts() {
|
||||||
let converted = convert_messages(&[Message {
|
let converted = convert_messages(&[Message {
|
||||||
|
|||||||
@ -46,7 +46,10 @@ impl SseFramer {
|
|||||||
|
|
||||||
fn drain_frames(&mut self, finish: bool) -> Result<Vec<String>, std::string::FromUtf8Error> {
|
fn drain_frames(&mut self, finish: bool) -> Result<Vec<String>, std::string::FromUtf8Error> {
|
||||||
let mut frames = Vec::new();
|
let mut frames = Vec::new();
|
||||||
while let Some((position, delimiter_len)) = find_sse_delimiter(&self.buffer) {
|
loop {
|
||||||
|
let Some((position, delimiter_len)) = find_sse_delimiter(&self.buffer) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
let frame = self.buffer.drain(..position).collect::<Vec<_>>();
|
let frame = self.buffer.drain(..position).collect::<Vec<_>>();
|
||||||
self.buffer.drain(..delimiter_len);
|
self.buffer.drain(..delimiter_len);
|
||||||
if let Some(data) = sse_data(frame)? {
|
if let Some(data) = sse_data(frame)? {
|
||||||
|
|||||||
@ -3,41 +3,81 @@ pub mod types;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use tokio::task::JoinSet;
|
use futures_util::stream::{self, StreamExt};
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
|
||||||
use crate::config::SchedulerConfig;
|
use crate::config::SchedulerConfig;
|
||||||
use crate::session::{ScheduledDeliveryError, SessionManager};
|
use crate::session::SessionManager;
|
||||||
use crate::storage::{
|
use crate::session::session::HandleResult;
|
||||||
ClaimedScheduledRun, JobRun, ScheduledOutcomeKind, ScheduledRunCompletion, ScheduledRunStatus,
|
use crate::storage::ScheduledJob;
|
||||||
Storage,
|
use crate::storage::Storage;
|
||||||
};
|
use crate::storage::{DeliveryPolicy, JobKind, JobRun};
|
||||||
|
|
||||||
pub use types::Schedule;
|
pub use types::Schedule;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum ScheduledDisposition {
|
||||||
|
Content(String),
|
||||||
|
Quiet(String),
|
||||||
|
ReportedFailure(String),
|
||||||
|
Refused(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scheduled_disposition(output: &str) -> ScheduledDisposition {
|
||||||
|
let trimmed = output.trim();
|
||||||
|
if trimmed.eq_ignore_ascii_case("NO_REPLY") {
|
||||||
|
return ScheduledDisposition::Quiet(String::new());
|
||||||
|
}
|
||||||
|
let upper = trimmed.to_ascii_uppercase();
|
||||||
|
for (prefix, kind) in [
|
||||||
|
("NO_REPLY[INFO]", "info"),
|
||||||
|
("NO_REPLY[FAIL]", "fail"),
|
||||||
|
("NO_REPLY[REFUSE]", "refuse"),
|
||||||
|
] {
|
||||||
|
if upper.starts_with(prefix) {
|
||||||
|
let suffix = &trimmed[prefix.len()..];
|
||||||
|
if !suffix.is_empty() && !suffix.trim_start().starts_with(':') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let reason = suffix.trim().trim_start_matches(':').trim().to_string();
|
||||||
|
return match kind {
|
||||||
|
"info" => ScheduledDisposition::Quiet(reason),
|
||||||
|
"fail" => ScheduledDisposition::ReportedFailure(reason),
|
||||||
|
_ => ScheduledDisposition::Refused(reason),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
ScheduledDisposition::ReportedFailure("scheduled agent returned empty output".into())
|
||||||
|
} else {
|
||||||
|
ScheduledDisposition::Content(trimmed.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute the next execution time (Unix ms) for a schedule, given `from` (Unix ms).
|
/// Compute the next execution time (Unix ms) for a schedule, given `from` (Unix ms).
|
||||||
/// Returns `None` if no next time can be determined (e.g. an invalid cron expression).
|
/// Returns `None` if no next time can be determined (e.g., invalid cron expression).
|
||||||
pub fn next_run_for_schedule(schedule: &Schedule, from: i64) -> Option<i64> {
|
pub fn next_run_for_schedule(schedule: &Schedule, from: i64) -> Option<i64> {
|
||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
match schedule {
|
match schedule {
|
||||||
Schedule::At { at } => Some(*at),
|
Schedule::At { at } => Some(*at),
|
||||||
Schedule::Every { every_ms } => Some(from.saturating_add(i64::try_from(*every_ms).ok()?)),
|
Schedule::Every { every_ms } => Some(from + *every_ms as i64),
|
||||||
Schedule::Cron { expr, tz } => {
|
Schedule::Cron { expr, tz } => {
|
||||||
let cron_schedule = cron::Schedule::from_str(expr.as_str()).ok()?;
|
let cron_schedule = cron::Schedule::from_str(expr.as_str()).ok()?;
|
||||||
let from_secs = from / 1000;
|
let from_secs = from / 1000;
|
||||||
let from_nanos = ((from % 1000) * 1_000_000) as u32;
|
let from_nanos = ((from % 1000) * 1_000_000) as u32;
|
||||||
let from_dt = Utc.timestamp_opt(from_secs, from_nanos).single()?;
|
let from_dt = Utc.timestamp_opt(from_secs, from_nanos).single()?;
|
||||||
|
|
||||||
let next_utc = if let Some(tz_str) = tz {
|
let next_utc = if let Some(tz_str) = tz {
|
||||||
let tz: chrono_tz::Tz = tz_str.parse().ok()?;
|
let tz: chrono_tz::Tz = tz_str.parse().ok()?;
|
||||||
cron_schedule
|
let from_local = from_dt.with_timezone(&tz);
|
||||||
.after(&from_dt.with_timezone(&tz))
|
let next_local = cron_schedule.after(&from_local).next()?;
|
||||||
.next()?
|
next_local.with_timezone(&Utc)
|
||||||
.with_timezone(&Utc)
|
|
||||||
} else {
|
} else {
|
||||||
cron_schedule.after(&from_dt).next()?
|
cron_schedule.after(&from_dt).next()?
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(next_utc.timestamp_millis())
|
Some(next_utc.timestamp_millis())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -50,6 +90,8 @@ fn now_ms() -> i64 {
|
|||||||
.as_millis() as i64
|
.as_millis() as i64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The scheduler runs as a background tokio task, periodically checking for due jobs
|
||||||
|
/// and executing them via `SessionManager::handle_cron_message`.
|
||||||
pub struct Scheduler {
|
pub struct Scheduler {
|
||||||
storage: Arc<Storage>,
|
storage: Arc<Storage>,
|
||||||
session_manager: Arc<SessionManager>,
|
session_manager: Arc<SessionManager>,
|
||||||
@ -87,16 +129,15 @@ impl Scheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-blocking event loop. Execution and delivery use separate bounded
|
/// Claim due jobs with a durable lease, then execute the claimed batch with
|
||||||
/// JoinSets so one long Agent run cannot delay other claims or outbox work.
|
/// bounded concurrency.
|
||||||
pub async fn run(self: Arc<Self>) {
|
pub async fn run(self: Arc<Self>) {
|
||||||
let poll_duration = time::Duration::from_secs(self.config.poll_interval_secs.max(1));
|
let poll_duration = time::Duration::from_secs(self.config.poll_interval_secs.max(1));
|
||||||
let mut interval = time::interval(poll_duration);
|
let mut interval = time::interval(poll_duration);
|
||||||
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
|
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
|
||||||
|
// Keep accidental configuration values from claiming an unbounded
|
||||||
|
// batch and overwhelming the runtime or SQLite parameter conversion.
|
||||||
let max_concurrent = self.config.max_concurrent.clamp(1, 256);
|
let max_concurrent = self.config.max_concurrent.clamp(1, 256);
|
||||||
let max_delivery = max_concurrent.clamp(1, 16);
|
|
||||||
let mut runs = JoinSet::new();
|
|
||||||
let mut deliveries = JoinSet::new();
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
poll_interval_secs = self.config.poll_interval_secs,
|
poll_interval_secs = self.config.poll_interval_secs,
|
||||||
@ -106,306 +147,272 @@ impl Scheduler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
interval.tick().await;
|
||||||
_ = interval.tick() => {}
|
|
||||||
Some(result) = runs.join_next(), if !runs.is_empty() => {
|
|
||||||
if let Err(error) = result {
|
|
||||||
tracing::error!(error = %error, "scheduled run task panicked");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(result) = deliveries.join_next(), if !deliveries.is_empty() => {
|
|
||||||
if let Err(error) = result {
|
|
||||||
tracing::error!(error = %error, "scheduled delivery task panicked");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while let Some(result) = runs.try_join_next() {
|
|
||||||
if let Err(error) = result {
|
|
||||||
tracing::error!(error = %error, "scheduled run task panicked");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while let Some(result) = deliveries.try_join_next() {
|
|
||||||
if let Err(error) = result {
|
|
||||||
tracing::error!(error = %error, "scheduled delivery task panicked");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !self.admission.is_accepting() {
|
if !self.admission.is_accepting() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let delivery_slots = max_delivery.saturating_sub(deliveries.len());
|
|
||||||
if delivery_slots > 0 {
|
|
||||||
let lease_until = now.saturating_add(180_000);
|
|
||||||
let delivery_owner = format!("{}:delivery:{}", self.owner, uuid::Uuid::new_v4());
|
|
||||||
match self
|
|
||||||
.storage
|
|
||||||
.claim_scheduled_deliveries(now, lease_until, &delivery_owner, delivery_slots)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(claimed) => {
|
|
||||||
for run in claimed {
|
|
||||||
let scheduler = self.clone();
|
|
||||||
deliveries.spawn(async move {
|
|
||||||
scheduler.deliver_claimed_run(run).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
tracing::error!(error = %error, "scheduler: failed to claim deliveries");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let run_slots = max_concurrent.saturating_sub(runs.len());
|
|
||||||
if run_slots == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let lease_ms = self
|
let lease_ms = self
|
||||||
.config
|
.config
|
||||||
.execution_timeout_secs
|
.execution_timeout_secs
|
||||||
.saturating_add(150)
|
.saturating_add(150)
|
||||||
.saturating_mul(1000)
|
.saturating_mul(1000)
|
||||||
.min(i64::MAX as u64) as i64;
|
.min(i64::MAX as u64) as i64;
|
||||||
let run_owner = format!("{}:run:{}", self.owner, uuid::Uuid::new_v4());
|
let lease_until = now.saturating_add(lease_ms);
|
||||||
match self
|
let jobs = match self
|
||||||
.storage
|
.storage
|
||||||
.claim_due_scheduled_runs(now, now.saturating_add(lease_ms), &run_owner, run_slots)
|
.claim_due_scheduled_jobs(now, lease_until, &self.owner, max_concurrent)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(claimed) => {
|
Ok(jobs) => jobs,
|
||||||
for run in claimed {
|
|
||||||
let scheduler = self.clone();
|
|
||||||
runs.spawn(async move {
|
|
||||||
scheduler.execute_claimed_run(run).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(error = %error, "scheduler: failed to claim due runs");
|
tracing::error!(error = %error, "scheduler: failed to claim due jobs");
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if jobs.is_empty() {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
tracing::info!(count = jobs.len(), "scheduler: claimed due jobs");
|
||||||
|
|
||||||
|
stream::iter(jobs)
|
||||||
|
.for_each_concurrent(max_concurrent, |job| {
|
||||||
|
let scheduler = self.clone();
|
||||||
|
async move { scheduler.execute_claimed_job(job).await }
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_claimed_run(self: Arc<Self>, claimed: ClaimedScheduledRun) {
|
async fn execute_claimed_job(self: Arc<Self>, job: ScheduledJob) {
|
||||||
let start = Instant::now();
|
let Some(_activity) = self.admission.try_enter() else {
|
||||||
let job = &claimed.job;
|
if let Err(error) = self
|
||||||
let (completion, agent_execution) = if let Some(_activity) = self.admission.try_enter() {
|
.storage
|
||||||
match self.session_manager.agent_coordinator() {
|
.release_scheduled_job_lease(&job.id, &self.owner)
|
||||||
Some(coordinator) => match coordinator
|
|
||||||
.execute_scheduled(
|
|
||||||
claimed.run_id,
|
|
||||||
&claimed.owner,
|
|
||||||
&job.id,
|
|
||||||
&job.name,
|
|
||||||
job.agent_id.as_deref(),
|
|
||||||
&job.prompt,
|
|
||||||
self.config.execution_timeout_secs.max(1),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(execution) => {
|
tracing::error!(job_id = %job.id, error = %error, "scheduler: failed to release job claimed during reload drain");
|
||||||
if execution.status == ScheduledRunStatus::Completed
|
|
||||||
&& let Some(outcome) = execution.outcome.clone()
|
|
||||||
{
|
|
||||||
(
|
|
||||||
ScheduledRunCompletion {
|
|
||||||
status: ScheduledRunStatus::Completed,
|
|
||||||
outcome: outcome.kind,
|
|
||||||
message: outcome.message,
|
|
||||||
diagnostic: execution.error.clone(),
|
|
||||||
duration_ms: start.elapsed().as_millis() as i64,
|
|
||||||
},
|
|
||||||
Some(execution),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let diagnostic = execution.error.clone().unwrap_or_else(|| {
|
|
||||||
"scheduled Agent returned ordinary text without calling complete_scheduled_run"
|
|
||||||
.to_string()
|
|
||||||
});
|
|
||||||
let status = match execution.status {
|
|
||||||
ScheduledRunStatus::TimedOut => ScheduledRunStatus::TimedOut,
|
|
||||||
ScheduledRunStatus::Interrupted | ScheduledRunStatus::Cancelled => {
|
|
||||||
ScheduledRunStatus::Interrupted
|
|
||||||
}
|
}
|
||||||
_ => ScheduledRunStatus::Failed,
|
|
||||||
};
|
|
||||||
(
|
|
||||||
ScheduledRunCompletion {
|
|
||||||
status,
|
|
||||||
outcome: ScheduledOutcomeKind::Failed,
|
|
||||||
message: if status == ScheduledRunStatus::TimedOut {
|
|
||||||
format!("定时任务「{}」执行超时。", job.name)
|
|
||||||
} else if status == ScheduledRunStatus::Interrupted {
|
|
||||||
format!("定时任务「{}」在系统关停时被中断。", job.name)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"定时任务「{}」未可靠完成:Agent 未提交结构化运行结果。",
|
|
||||||
job.name
|
|
||||||
)
|
|
||||||
},
|
|
||||||
diagnostic: Some(diagnostic),
|
|
||||||
duration_ms: start.elapsed().as_millis() as i64,
|
|
||||||
},
|
|
||||||
Some(execution),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => (
|
|
||||||
ScheduledRunCompletion {
|
|
||||||
status: ScheduledRunStatus::Failed,
|
|
||||||
outcome: ScheduledOutcomeKind::Failed,
|
|
||||||
message: job.agent_id.as_deref().map_or_else(
|
|
||||||
|| format!("定时任务「{}」未能启动 Root Agent 执行。", job.name),
|
|
||||||
|agent_id| {
|
|
||||||
format!(
|
|
||||||
"定时任务「{}」无法使用 Agent「{}」执行。请恢复该 Agent 定义,或更新任务的 agent_id。",
|
|
||||||
job.name, agent_id
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
diagnostic: Some(error.to_string()),
|
|
||||||
duration_ms: start.elapsed().as_millis() as i64,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
None => (
|
|
||||||
ScheduledRunCompletion {
|
|
||||||
status: ScheduledRunStatus::Failed,
|
|
||||||
outcome: ScheduledOutcomeKind::Failed,
|
|
||||||
message: format!("定时任务「{}」未能启动执行。", job.name),
|
|
||||||
diagnostic: Some("AgentCoordinator is unavailable".to_string()),
|
|
||||||
duration_ms: start.elapsed().as_millis() as i64,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
ScheduledRunCompletion {
|
|
||||||
status: ScheduledRunStatus::Interrupted,
|
|
||||||
outcome: ScheduledOutcomeKind::Failed,
|
|
||||||
message: format!("定时任务「{}」因 Gateway 重载而中断。", job.name),
|
|
||||||
diagnostic: Some("runtime admission closed".to_string()),
|
|
||||||
duration_ms: start.elapsed().as_millis() as i64,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let finished_at = now_ms();
|
|
||||||
let mut commit_attempt = 0_u64;
|
|
||||||
let commit = loop {
|
|
||||||
let result = match agent_execution.as_ref() {
|
|
||||||
Some(execution) => {
|
|
||||||
self.storage
|
|
||||||
.finish_scheduled_run_with_agent(
|
|
||||||
claimed.run_id,
|
|
||||||
&claimed.owner,
|
|
||||||
&completion,
|
|
||||||
&execution.agent_run_id,
|
|
||||||
&execution.agent_run_id,
|
|
||||||
execution.runtime_generation,
|
|
||||||
&execution.agent_terminal,
|
|
||||||
finished_at,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
self.storage
|
|
||||||
.finish_scheduled_run(
|
|
||||||
claimed.run_id,
|
|
||||||
&claimed.owner,
|
|
||||||
&completion,
|
|
||||||
finished_at,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Err(error) if error.is_transient() && commit_attempt < 2 => {
|
|
||||||
commit_attempt += 1;
|
|
||||||
tracing::warn!(
|
|
||||||
job_id = %job.id,
|
|
||||||
run_id = claimed.run_id,
|
|
||||||
attempt = commit_attempt + 1,
|
|
||||||
error = %error,
|
|
||||||
"scheduler: retrying transient run completion commit"
|
|
||||||
);
|
|
||||||
time::sleep(time::Duration::from_millis(50 * commit_attempt)).await;
|
|
||||||
}
|
|
||||||
result => break result,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match commit {
|
|
||||||
Ok(true) => tracing::info!(
|
|
||||||
job_id = %job.id,
|
|
||||||
run_id = claimed.run_id,
|
|
||||||
status = completion.status.as_str(),
|
|
||||||
outcome = completion.outcome.as_str(),
|
|
||||||
duration_ms = completion.duration_ms,
|
|
||||||
"scheduler: run completed"
|
|
||||||
),
|
|
||||||
Ok(false) => tracing::warn!(
|
|
||||||
job_id = %job.id,
|
|
||||||
run_id = claimed.run_id,
|
|
||||||
"scheduler: late run result discarded"
|
|
||||||
),
|
|
||||||
Err(error) => tracing::error!(
|
|
||||||
job_id = %job.id,
|
|
||||||
run_id = claimed.run_id,
|
|
||||||
error = %error,
|
|
||||||
"scheduler: failed to commit run completion"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn deliver_claimed_run(self: Arc<Self>, run: JobRun) {
|
|
||||||
let Some(delivery_owner) = run.delivery_lease_owner.clone() else {
|
|
||||||
tracing::error!(
|
|
||||||
run_id = run.id,
|
|
||||||
"scheduler: claimed delivery has no lease owner"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let result = self
|
let start = Instant::now();
|
||||||
.session_manager
|
let started_at = now_ms();
|
||||||
.deliver_scheduled_run(&run, &delivery_owner)
|
tracing::info!(job_id = %job.id, job_name = %job.name, "scheduler: executing claimed job");
|
||||||
.await;
|
|
||||||
let (delivered, permanent, error) = match result {
|
let managed = job.delivery_policy != DeliveryPolicy::Direct;
|
||||||
Ok(()) => (true, false, None),
|
let execution = async {
|
||||||
Err(ScheduledDeliveryError::Transient(error)) => {
|
if managed {
|
||||||
(false, false, Some(sanitize_error(&error)))
|
self.session_manager
|
||||||
}
|
.handle_managed_scheduled_message(
|
||||||
Err(ScheduledDeliveryError::Permanent(error)) => {
|
&job.prompt,
|
||||||
(false, true, Some(sanitize_error(&error)))
|
&job.id,
|
||||||
|
&job.name,
|
||||||
|
job.job_kind == JobKind::Monitor,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(HandleResult::AgentResponse)
|
||||||
|
} else {
|
||||||
|
self.session_manager
|
||||||
|
.handle_cron_message(
|
||||||
|
&job.channel,
|
||||||
|
&job.chat_id,
|
||||||
|
&job.prompt,
|
||||||
|
&job.id,
|
||||||
|
&job.name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(commit_error) = self
|
let result = time::timeout(
|
||||||
.storage
|
time::Duration::from_secs(self.config.execution_timeout_secs.max(1)),
|
||||||
.complete_scheduled_delivery(
|
execution,
|
||||||
run.id,
|
)
|
||||||
&delivery_owner,
|
.await;
|
||||||
delivered,
|
let finished_at = now_ms();
|
||||||
permanent,
|
let duration_ms = start.elapsed().as_millis() as i64;
|
||||||
error.as_deref(),
|
|
||||||
now_ms(),
|
let (mut status, output, error, result_kind, mut delivery_status, mut delivery_error) =
|
||||||
|
match result {
|
||||||
|
Ok(Ok(
|
||||||
|
HandleResult::AgentResponse(output) | HandleResult::CommandOutput(output),
|
||||||
|
)) => {
|
||||||
|
let output = if output.len() > 8000 {
|
||||||
|
format!(
|
||||||
|
"{}...[truncated]",
|
||||||
|
&output[..output.ceil_char_boundary(8000)]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
output
|
||||||
|
};
|
||||||
|
if !managed {
|
||||||
|
(
|
||||||
|
"ok".into(),
|
||||||
|
Some(output),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("direct".into()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let disposition = parse_scheduled_disposition(&output);
|
||||||
|
let (kind, content, alert) = match &disposition {
|
||||||
|
ScheduledDisposition::Content(value) => {
|
||||||
|
("content", Some(value.as_str()), true)
|
||||||
|
}
|
||||||
|
ScheduledDisposition::Quiet(_) => ("quiet", None, false),
|
||||||
|
ScheduledDisposition::ReportedFailure(value) => {
|
||||||
|
("reported_failure", Some(value.as_str()), true)
|
||||||
|
}
|
||||||
|
ScheduledDisposition::Refused(value) => {
|
||||||
|
("refused", Some(value.as_str()), true)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let should_deliver = match job.delivery_policy {
|
||||||
|
DeliveryPolicy::Always => true,
|
||||||
|
DeliveryPolicy::OnAlert => alert,
|
||||||
|
DeliveryPolicy::Never => false,
|
||||||
|
DeliveryPolicy::Direct => false,
|
||||||
|
};
|
||||||
|
if should_deliver {
|
||||||
|
let message = content.unwrap_or("巡检完成,未发现需要关注的问题。");
|
||||||
|
match self
|
||||||
|
.session_manager
|
||||||
|
.deliver_scheduled_message(
|
||||||
|
&job.channel,
|
||||||
|
&job.chat_id,
|
||||||
|
&job.id,
|
||||||
|
&job.name,
|
||||||
|
message,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(
|
Ok(()) => (
|
||||||
run_id = run.id,
|
"ok".into(),
|
||||||
error = %commit_error,
|
Some(output),
|
||||||
"scheduler: failed to commit delivery receipt"
|
None,
|
||||||
);
|
Some(kind.into()),
|
||||||
|
Some("delivered".into()),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
Err(delivery_error) => (
|
||||||
|
"delivery_error".into(),
|
||||||
|
Some(output),
|
||||||
|
None,
|
||||||
|
Some(kind.into()),
|
||||||
|
Some("failed".into()),
|
||||||
|
Some(delivery_error),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let delivery = if job.delivery_policy == DeliveryPolicy::Never {
|
||||||
|
"skipped"
|
||||||
|
} else {
|
||||||
|
"suppressed"
|
||||||
|
};
|
||||||
|
(
|
||||||
|
"ok".into(),
|
||||||
|
Some(output),
|
||||||
|
None,
|
||||||
|
Some(kind.into()),
|
||||||
|
Some(delivery.into()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Ok(HandleResult::AgentProcessing)) => (
|
||||||
|
"error".to_string(),
|
||||||
|
None,
|
||||||
|
Some("cron execution returned asynchronous processing".to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
Ok(Err(error)) => (
|
||||||
|
"error".to_string(),
|
||||||
|
None,
|
||||||
|
Some(error.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
Err(_) => (
|
||||||
|
"timeout".to_string(),
|
||||||
|
None,
|
||||||
|
Some(format!(
|
||||||
|
"execution exceeded {} seconds",
|
||||||
|
self.config.execution_timeout_secs.max(1)
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if managed
|
||||||
|
&& delivery_status.is_none()
|
||||||
|
&& job.delivery_policy != DeliveryPolicy::Never
|
||||||
|
&& let Some(message) = error.as_deref()
|
||||||
|
{
|
||||||
|
let notice = format!("定时任务「{}」执行失败:{}", job.name, message);
|
||||||
|
match self
|
||||||
|
.session_manager
|
||||||
|
.deliver_scheduled_message(&job.channel, &job.chat_id, &job.id, &job.name, ¬ice)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => delivery_status = Some("delivered".into()),
|
||||||
|
Err(error) => {
|
||||||
|
status = "delivery_error".into();
|
||||||
|
delivery_status = Some("failed".into());
|
||||||
|
delivery_error = Some(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_error(error: &str) -> String {
|
let (next_run_at, disable, delete) = match &job.schedule {
|
||||||
error.chars().take(1_024).collect()
|
Schedule::At { .. } => (None, !job.delete_after_run, job.delete_after_run),
|
||||||
|
Schedule::Every { .. } | Schedule::Cron { .. } => {
|
||||||
|
match next_run_for_schedule(&job.schedule, finished_at) {
|
||||||
|
Some(next) => (Some(next), false, false),
|
||||||
|
None => (None, true, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let run = JobRun {
|
||||||
|
id: 0,
|
||||||
|
job_id: job.id.clone(),
|
||||||
|
started_at,
|
||||||
|
finished_at,
|
||||||
|
status,
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
duration_ms,
|
||||||
|
result_kind,
|
||||||
|
delivery_status,
|
||||||
|
delivery_error,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = self
|
||||||
|
.storage
|
||||||
|
.complete_scheduled_job(&run, &self.owner, next_run_at, disable, delete)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(job_id = %job.id, error = %error, "scheduler: failed to commit job completion");
|
||||||
|
let _ = self
|
||||||
|
.storage
|
||||||
|
.release_scheduled_job_lease(&job.id, &self.owner)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
job_id = %job.id,
|
||||||
|
status = %run.status,
|
||||||
|
duration_ms,
|
||||||
|
"scheduler: job completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -413,33 +420,102 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_run_for_every_uses_claim_time() {
|
fn test_next_run_at_schedule() {
|
||||||
assert_eq!(
|
let now = 1000000;
|
||||||
next_run_for_schedule(&Schedule::Every { every_ms: 5_000 }, 1_000),
|
let next = next_run_for_schedule(&Schedule::At { at: 2000000 }, now);
|
||||||
Some(6_000)
|
assert_eq!(next, Some(2000000));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_run_for_at_keeps_absolute_timestamp() {
|
fn test_next_run_every_schedule() {
|
||||||
assert_eq!(
|
let now = 1000000;
|
||||||
next_run_for_schedule(&Schedule::At { at: 2_000 }, 1_000),
|
let next = next_run_for_schedule(&Schedule::Every { every_ms: 5000 }, now);
|
||||||
Some(2_000)
|
assert_eq!(next, Some(1005000));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cron_timezone_uses_from_argument() {
|
fn test_next_run_cron_every_minute() {
|
||||||
|
let expr = "0 * * * * *".to_string();
|
||||||
|
let schedule = Schedule::Cron { expr, tz: None };
|
||||||
|
let now = 1000000;
|
||||||
|
let next = next_run_for_schedule(&schedule, now);
|
||||||
|
assert!(next.is_some());
|
||||||
|
assert!(next.unwrap() > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_next_run_cron_every_day_at_9am() {
|
||||||
|
let expr = "0 0 9 * * *".to_string();
|
||||||
|
let schedule = Schedule::Cron { expr, tz: None };
|
||||||
|
let now = 1000000;
|
||||||
|
let next = next_run_for_schedule(&schedule, now);
|
||||||
|
assert!(next.is_some());
|
||||||
|
let next_ms = next.unwrap();
|
||||||
|
assert!(next_ms > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_next_run_cron_uses_from_argument() {
|
||||||
|
let expr = "0 * * * * *".to_string();
|
||||||
|
let schedule = Schedule::Cron { expr, tz: None };
|
||||||
|
let from = chrono::DateTime::parse_from_rfc3339("2026-06-16T12:34:20Z")
|
||||||
|
.unwrap()
|
||||||
|
.timestamp_millis();
|
||||||
|
|
||||||
|
let next = next_run_for_schedule(&schedule, from).unwrap();
|
||||||
|
let expected = chrono::DateTime::parse_from_rfc3339("2026-06-16T12:35:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.timestamp_millis();
|
||||||
|
assert_eq!(next, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduled_disposition_is_fail_safe() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_scheduled_disposition("NO_REPLY"),
|
||||||
|
ScheduledDisposition::Quiet(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_scheduled_disposition("NO_REPLY[INFO]: healthy"),
|
||||||
|
ScheduledDisposition::Quiet(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_scheduled_disposition("NO_REPLY[FAIL]: timeout"),
|
||||||
|
ScheduledDisposition::ReportedFailure(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_scheduled_disposition("NO_REPLY[REFUSE]: denied"),
|
||||||
|
ScheduledDisposition::Refused(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_scheduled_disposition("text mentioning NO_REPLY"),
|
||||||
|
ScheduledDisposition::Content(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_scheduled_disposition("NO_REPLY[INFO] but this is content"),
|
||||||
|
ScheduledDisposition::Content(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_scheduled_disposition(""),
|
||||||
|
ScheduledDisposition::ReportedFailure(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_next_run_cron_timezone_uses_from_argument() {
|
||||||
|
let expr = "0 0 9 * * *".to_string();
|
||||||
let schedule = Schedule::Cron {
|
let schedule = Schedule::Cron {
|
||||||
expr: "0 0 9 * * *".to_string(),
|
expr,
|
||||||
tz: Some("Asia/Shanghai".to_string()),
|
tz: Some("Asia/Shanghai".to_string()),
|
||||||
};
|
};
|
||||||
let from = chrono::DateTime::parse_from_rfc3339("2026-06-16T00:30:00Z")
|
let from = chrono::DateTime::parse_from_rfc3339("2026-06-16T00:30:00Z")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.timestamp_millis();
|
.timestamp_millis();
|
||||||
|
|
||||||
|
let next = next_run_for_schedule(&schedule, from).unwrap();
|
||||||
let expected = chrono::DateTime::parse_from_rfc3339("2026-06-16T01:00:00Z")
|
let expected = chrono::DateTime::parse_from_rfc3339("2026-06-16T01:00:00Z")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.timestamp_millis();
|
.timestamp_millis();
|
||||||
assert_eq!(next_run_for_schedule(&schedule, from), Some(expected));
|
assert_eq!(next, expected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,19 +28,6 @@ pub enum SessionCommand {
|
|||||||
},
|
},
|
||||||
/// Load the active task plan for a dialog.
|
/// Load the active task plan for a dialog.
|
||||||
GetTaskPlan { session_id: UnifiedSessionId },
|
GetTaskPlan { session_id: UnifiedSessionId },
|
||||||
/// Load token totals and context-window state for a dialog.
|
|
||||||
GetSessionStats { session_id: UnifiedSessionId },
|
|
||||||
/// Load the durable Agent run projection for a dialog.
|
|
||||||
GetAgentRuns {
|
|
||||||
session_id: UnifiedSessionId,
|
|
||||||
cursor: Option<String>,
|
|
||||||
limit: u32,
|
|
||||||
},
|
|
||||||
/// Load one durable Agent run projection.
|
|
||||||
GetAgentRun {
|
|
||||||
session_id: UnifiedSessionId,
|
|
||||||
run_id: String,
|
|
||||||
},
|
|
||||||
/// Get the current dialog for a chat
|
/// Get the current dialog for a chat
|
||||||
GetCurrentDialog { channel: String, chat_id: String },
|
GetCurrentDialog { channel: String, chat_id: String },
|
||||||
/// Rename a dialog
|
/// Rename a dialog
|
||||||
|
|||||||
@ -41,21 +41,6 @@ pub enum SessionEvent {
|
|||||||
session_id: UnifiedSessionId,
|
session_id: UnifiedSessionId,
|
||||||
plan: Option<crate::work::TaskPlan>,
|
plan: Option<crate::work::TaskPlan>,
|
||||||
},
|
},
|
||||||
/// Provider usage totals and current context-window state.
|
|
||||||
SessionStats { stats: crate::session::SessionStats },
|
|
||||||
/// Durable Agent run projection page for a dialog.
|
|
||||||
AgentRuns {
|
|
||||||
session_id: UnifiedSessionId,
|
|
||||||
revision: i64,
|
|
||||||
runs: Vec<crate::protocol::AgentRunView>,
|
|
||||||
next_cursor: Option<String>,
|
|
||||||
},
|
|
||||||
/// One durable Agent run projection.
|
|
||||||
AgentRun {
|
|
||||||
session_id: UnifiedSessionId,
|
|
||||||
revision: i64,
|
|
||||||
run: Option<crate::protocol::AgentRunView>,
|
|
||||||
},
|
|
||||||
/// Dialog renamed
|
/// Dialog renamed
|
||||||
DialogRenamed {
|
DialogRenamed {
|
||||||
session_id: UnifiedSessionId,
|
session_id: UnifiedSessionId,
|
||||||
|
|||||||
@ -4,9 +4,7 @@ use crate::bus::{ChatMessage, MediaItem, MessageSource, OutboundMessage, SourceK
|
|||||||
use crate::session::UnifiedSessionId;
|
use crate::session::UnifiedSessionId;
|
||||||
use crate::tools::{OutboundDelivery, OutboundMessenger};
|
use crate::tools::{OutboundDelivery, OutboundMessenger};
|
||||||
|
|
||||||
use super::persistence::{
|
use super::persistence::{append_active_turn_message, append_persisted_messages};
|
||||||
append_active_turn_message, append_persisted_message_if_absent, append_persisted_messages,
|
|
||||||
};
|
|
||||||
use super::session::{
|
use super::session::{
|
||||||
CURRENT_SOURCE_SESSION, CURRENT_TURN_DELIVERIES, CURRENT_TURN_ID, PendingTurnDelivery,
|
CURRENT_SOURCE_SESSION, CURRENT_TURN_DELIVERIES, CURRENT_TURN_ID, PendingTurnDelivery,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
@ -120,106 +118,33 @@ impl OutboundMessenger for SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
pub async fn deliver_scheduled_run(
|
pub async fn deliver_scheduled_message(
|
||||||
&self,
|
&self,
|
||||||
run: &crate::storage::JobRun,
|
channel: &str,
|
||||||
delivery_owner: &str,
|
chat_id: &str,
|
||||||
) -> Result<(), ScheduledDeliveryError> {
|
job_id: &str,
|
||||||
let content = run
|
job_name: &str,
|
||||||
.message
|
content: &str,
|
||||||
.as_deref()
|
) -> Result<(), String> {
|
||||||
.unwrap_or("定时任务已结束,但没有生成可投递的结果。请在任务运行记录中查看诊断信息。");
|
<Self as OutboundMessenger>::send_message(
|
||||||
let target_sid = if let Some(session_id) = run.target_session_id.as_deref() {
|
self,
|
||||||
UnifiedSessionId::parse(session_id).ok_or_else(|| {
|
channel,
|
||||||
ScheduledDeliveryError::Permanent("stored target session is invalid".to_string())
|
chat_id,
|
||||||
})?
|
None,
|
||||||
} else {
|
content,
|
||||||
let resolved = self
|
MessageSource {
|
||||||
.resolve_dialog_id(&run.target_channel, &run.target_chat_id)
|
|
||||||
.await
|
|
||||||
.map_err(|error| ScheduledDeliveryError::Transient(error.to_string()))?;
|
|
||||||
let fixed = self
|
|
||||||
.storage
|
|
||||||
.set_scheduled_delivery_target_session(
|
|
||||||
run.id,
|
|
||||||
delivery_owner,
|
|
||||||
&resolved.to_string(),
|
|
||||||
chrono::Utc::now().timestamp_millis(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| ScheduledDeliveryError::Transient(error.to_string()))?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ScheduledDeliveryError::Transient(
|
|
||||||
"scheduled delivery lost its claim before fixing target session"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
UnifiedSessionId::parse(&fixed).ok_or_else(|| {
|
|
||||||
ScheduledDeliveryError::Permanent("fixed target session is invalid".to_string())
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
if target_sid.channel != run.target_channel || target_sid.chat_id != run.target_chat_id {
|
|
||||||
return Err(ScheduledDeliveryError::Permanent(
|
|
||||||
"fixed target session does not belong to the scheduled destination".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let session = self
|
|
||||||
.get_or_activate_session(&target_sid)
|
|
||||||
.await
|
|
||||||
.map_err(|error| ScheduledDeliveryError::Transient(error.to_string()))?;
|
|
||||||
let source = MessageSource {
|
|
||||||
kind: SourceKind::ExternalTrigger,
|
kind: SourceKind::ExternalTrigger,
|
||||||
from_channel: Some("scheduler".to_string()),
|
from_channel: Some("scheduler".to_string()),
|
||||||
from_session: Some(format!("scheduled-run:{}", run.id)),
|
from_session: Some(format!("cron:{job_id}")),
|
||||||
from_user_id: None,
|
from_user_id: None,
|
||||||
system_name: Some("scheduled task".to_string()),
|
system_name: Some(job_name.to_string()),
|
||||||
task_id: Some(run.job_id.clone()),
|
task_id: Some(job_id.to_string()),
|
||||||
from_run_id: run.agent_run_id.clone(),
|
},
|
||||||
from_agent_id: run.agent_id.clone(),
|
Vec::new(),
|
||||||
};
|
)
|
||||||
let mut message = outbound_history_message(content, source, &[]);
|
|
||||||
message.id = format!("scheduled:{}", run.id);
|
|
||||||
let message_id = message.id.clone();
|
|
||||||
append_persisted_message_if_absent(&session, message)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ScheduledDeliveryError::Transient(error.to_string()))?;
|
.map(|_| ())
|
||||||
|
|
||||||
let metadata = HashMap::from([
|
|
||||||
("_session_id".to_string(), target_sid.to_string()),
|
|
||||||
("_message_id".to_string(), message_id),
|
|
||||||
("scheduled_delivery_id".to_string(), run.id.to_string()),
|
|
||||||
]);
|
|
||||||
self.bus
|
|
||||||
.deliver_outbound(OutboundMessage {
|
|
||||||
channel: run.target_channel.clone(),
|
|
||||||
chat_id: run.target_chat_id.clone(),
|
|
||||||
content: content.to_string(),
|
|
||||||
reply_to: None,
|
|
||||||
media: Vec::new(),
|
|
||||||
metadata,
|
|
||||||
delivery: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|error| match error {
|
|
||||||
crate::bus::BusError::Closed
|
|
||||||
| crate::bus::BusError::DeliveryTimedOut
|
|
||||||
| crate::bus::BusError::DeliveryTransient(_) => {
|
|
||||||
ScheduledDeliveryError::Transient(error.to_string())
|
|
||||||
}
|
}
|
||||||
crate::bus::BusError::DeliveryPermanent(summary) => {
|
|
||||||
ScheduledDeliveryError::Permanent(summary)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ScheduledDeliveryError {
|
|
||||||
#[error("transient scheduled delivery failure: {0}")]
|
|
||||||
Transient(String),
|
|
||||||
#[error("permanent scheduled delivery failure: {0}")]
|
|
||||||
Permanent(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn outbound_history_message(
|
fn outbound_history_message(
|
||||||
@ -246,8 +171,6 @@ mod tests {
|
|||||||
from_user_id: None,
|
from_user_id: None,
|
||||||
system_name: None,
|
system_name: None,
|
||||||
task_id: None,
|
task_id: None,
|
||||||
from_run_id: None,
|
|
||||||
from_agent_id: None,
|
|
||||||
};
|
};
|
||||||
let media = vec![MediaItem::new("/tmp/report.pdf", "file")];
|
let media = vec![MediaItem::new("/tmp/report.pdf", "file")];
|
||||||
|
|
||||||
|
|||||||
@ -8,19 +8,13 @@ mod turn_input;
|
|||||||
#[allow(clippy::module_inception)]
|
#[allow(clippy::module_inception)]
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod session_id;
|
pub mod session_id;
|
||||||
pub mod stats;
|
|
||||||
pub mod turn;
|
pub mod turn;
|
||||||
|
|
||||||
pub use commands::SessionCommand;
|
pub use commands::SessionCommand;
|
||||||
pub use error::SessionError;
|
pub use error::SessionError;
|
||||||
pub use events::{DialogInfo, SessionEvent};
|
pub use events::{DialogInfo, SessionEvent};
|
||||||
pub use messenger::ScheduledDeliveryError;
|
pub use session::{SLASH_COMMANDS, Session, SessionManager, SessionManagerServices, SlashCommand};
|
||||||
pub use session::{
|
|
||||||
AgentCatalogPreparation, SLASH_COMMANDS, Session, SessionManager, SessionManagerServices,
|
|
||||||
SlashCommand,
|
|
||||||
};
|
|
||||||
pub use session_id::UnifiedSessionId;
|
pub use session_id::UnifiedSessionId;
|
||||||
pub use stats::{ContextUsage, ContextUsageSource, LifetimeUsage, SessionStats};
|
|
||||||
pub use turn::{
|
pub use turn::{
|
||||||
BlockId, ToolStatus, TurnBlock, TurnController, TurnId, TurnPhase, TurnSnapshot, TurnState,
|
BlockId, ToolStatus, TurnBlock, TurnController, TurnId, TurnPhase, TurnSnapshot, TurnState,
|
||||||
TurnStatus,
|
TurnStatus,
|
||||||
|
|||||||
@ -10,8 +10,6 @@ use crate::{providers::Usage, session::TurnController};
|
|||||||
|
|
||||||
async fn persist_added_messages(
|
async fn persist_added_messages(
|
||||||
snapshots: Vec<Option<MessagePersistSnapshot>>,
|
snapshots: Vec<Option<MessagePersistSnapshot>>,
|
||||||
usage: Option<&crate::storage::TurnUsageRecord>,
|
|
||||||
steer: Option<&crate::storage::agent_inbox::SteerConsumption>,
|
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let mut storage = None;
|
let mut storage = None;
|
||||||
let mut session_id = None;
|
let mut session_id = None;
|
||||||
@ -37,26 +35,10 @@ async fn persist_added_messages(
|
|||||||
else {
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if let Some(steer) = steer {
|
|
||||||
storage
|
|
||||||
.persist_turn_with_steer_with_retry(
|
|
||||||
&session_id,
|
|
||||||
&messages,
|
|
||||||
&final_meta,
|
|
||||||
usage.unwrap(),
|
|
||||||
steer,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
} else if let Some(usage) = usage {
|
|
||||||
storage
|
|
||||||
.persist_turn_batch_with_retry(&session_id, &messages, &final_meta, usage)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
storage
|
storage
|
||||||
.persist_message_batch_with_retry(&session_id, &messages, &final_meta)
|
.persist_message_batch_with_retry(&session_id, &messages, &final_meta)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn append_persisted_messages(
|
pub(super) async fn append_persisted_messages(
|
||||||
session: &Arc<Mutex<Session>>,
|
session: &Arc<Mutex<Session>>,
|
||||||
@ -87,8 +69,6 @@ pub(super) async fn append_active_turn_message(
|
|||||||
session,
|
session,
|
||||||
vec![message],
|
vec![message],
|
||||||
VersionPolicy::PreserveForOwnedTurn(turn_id),
|
VersionPolicy::PreserveForOwnedTurn(turn_id),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
@ -98,76 +78,13 @@ pub(super) async fn append_persisted_messages_with_meta(
|
|||||||
session: &Arc<Mutex<Session>>,
|
session: &Arc<Mutex<Session>>,
|
||||||
messages: Vec<ChatMessage>,
|
messages: Vec<ChatMessage>,
|
||||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
||||||
append_persisted_messages_inner(session, messages, VersionPolicy::Advance, None, None).await
|
append_persisted_messages_inner(session, messages, VersionPolicy::Advance).await
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn append_persisted_message_if_absent(
|
|
||||||
session: &Arc<Mutex<Session>>,
|
|
||||||
message: ChatMessage,
|
|
||||||
) -> Result<bool, StorageError> {
|
|
||||||
let persistence_lock = { session.lock().await.persistence_lock.clone() };
|
|
||||||
let _persistence_guard = persistence_lock.lock().await;
|
|
||||||
let message_id = message.id.clone();
|
|
||||||
let snapshot = {
|
|
||||||
let guard = session.lock().await;
|
|
||||||
if guard.contains_message_id(&message_id) {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
guard.prepare_message_persist_snapshot(&message)
|
|
||||||
};
|
|
||||||
let Some((storage, session_id, persisted, meta)) = snapshot else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let inserted = storage
|
|
||||||
.persist_message_if_absent(&session_id, &persisted, &meta)
|
|
||||||
.await?;
|
|
||||||
if !inserted {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let mut guard = session.lock().await;
|
|
||||||
if guard.contains_message_id(&message_id) {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
if !guard.apply_prepared_message_in_memory(message, persisted.seq, persisted.created_at, true) {
|
|
||||||
return Err(StorageError::Conflict(format!(
|
|
||||||
"session changed while committing idempotent message {message_id}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn append_persisted_turn_messages(
|
|
||||||
session: &Arc<Mutex<Session>>,
|
|
||||||
messages: Vec<ChatMessage>,
|
|
||||||
usage: crate::storage::TurnUsageRecord,
|
|
||||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
|
||||||
append_persisted_messages_inner(session, messages, VersionPolicy::Advance, Some(usage), None)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist a Turn and consume its admitted steer events atomically.
|
|
||||||
pub(super) async fn append_persisted_turn_messages_with_steer(
|
|
||||||
session: &Arc<Mutex<Session>>,
|
|
||||||
messages: Vec<ChatMessage>,
|
|
||||||
usage: crate::storage::TurnUsageRecord,
|
|
||||||
steer: crate::storage::agent_inbox::SteerConsumption,
|
|
||||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
|
||||||
append_persisted_messages_inner(
|
|
||||||
session,
|
|
||||||
messages,
|
|
||||||
VersionPolicy::Advance,
|
|
||||||
Some(usage),
|
|
||||||
Some(steer),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn append_persisted_messages_inner(
|
async fn append_persisted_messages_inner(
|
||||||
session: &Arc<Mutex<Session>>,
|
session: &Arc<Mutex<Session>>,
|
||||||
messages: Vec<ChatMessage>,
|
messages: Vec<ChatMessage>,
|
||||||
version_policy: VersionPolicy,
|
version_policy: VersionPolicy,
|
||||||
usage: Option<crate::storage::TurnUsageRecord>,
|
|
||||||
steer: Option<crate::storage::agent_inbox::SteerConsumption>,
|
|
||||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@ -196,7 +113,7 @@ async fn append_persisted_messages_inner(
|
|||||||
.map(|(_, _, message, _)| message.clone())
|
.map(|(_, _, message, _)| message.clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if let Err(error) = persist_added_messages(snapshots, usage.as_ref(), steer.as_ref()).await {
|
if let Err(error) = persist_added_messages(snapshots).await {
|
||||||
session
|
session
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
@ -282,7 +199,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn side_effect_messages_apply_the_expected_session_version_once() {
|
async fn active_turn_side_effect_does_not_invalidate_its_session_version() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let storage = Arc::new(
|
let storage = Arc::new(
|
||||||
crate::storage::Storage::new(&dir.path().join("memory.db"))
|
crate::storage::Storage::new(&dir.path().join("memory.db"))
|
||||||
@ -290,7 +207,7 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
let memory_manager = Arc::new(MemoryManager::new(
|
let memory_manager = Arc::new(MemoryManager::new(
|
||||||
storage.clone(),
|
storage,
|
||||||
"test".to_string(),
|
"test".to_string(),
|
||||||
"test".to_string(),
|
"test".to_string(),
|
||||||
));
|
));
|
||||||
@ -311,38 +228,15 @@ mod tests {
|
|||||||
price_input_per_million: None,
|
price_input_per_million: None,
|
||||||
price_output_per_million: None,
|
price_output_per_million: None,
|
||||||
};
|
};
|
||||||
let unified_id = crate::session::UnifiedSessionId::new("cli_chat", "chat", "dialog");
|
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
|
||||||
storage
|
|
||||||
.upsert_session(&crate::storage::session::SessionMeta {
|
|
||||||
id: unified_id.to_string(),
|
|
||||||
channel: "cli_chat".to_string(),
|
|
||||||
chat_id: "chat".to_string(),
|
|
||||||
dialog_id: "dialog".to_string(),
|
|
||||||
title: "test".to_string(),
|
|
||||||
created_at: now,
|
|
||||||
last_active_at: now,
|
|
||||||
message_count: 0,
|
|
||||||
routing_info: None,
|
|
||||||
archived_at: None,
|
|
||||||
deleted_at: None,
|
|
||||||
last_consolidated_at: None,
|
|
||||||
last_compressed_message_at: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let session = Arc::new(Mutex::new(
|
let session = Arc::new(Mutex::new(
|
||||||
Session::new(
|
Session::new(
|
||||||
unified_id,
|
crate::session::UnifiedSessionId::new("cli_chat", "chat", "dialog"),
|
||||||
config,
|
config,
|
||||||
Arc::new(ToolRegistry::new()),
|
Arc::new(ToolRegistry::new()),
|
||||||
Some(storage.clone()),
|
None,
|
||||||
String::new(),
|
String::new(),
|
||||||
"test".to_string(),
|
"test".to_string(),
|
||||||
super::super::session::SessionContextServices {
|
|
||||||
memory_manager,
|
memory_manager,
|
||||||
compaction_config: crate::config::ContextCompactionConfig::default(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@ -375,37 +269,5 @@ mod tests {
|
|||||||
let guard = session.lock().await;
|
let guard = session.lock().await;
|
||||||
assert_eq!(guard.state_version_for_test(), base_version + 1);
|
assert_eq!(guard.state_version_for_test(), base_version + 1);
|
||||||
assert_eq!(guard.get_history().len(), 2);
|
assert_eq!(guard.get_history().len(), 2);
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
let idempotent_base_version = session.lock().await.state_version_for_test();
|
|
||||||
let mut scheduled = ChatMessage::assistant("scheduled result");
|
|
||||||
scheduled.id = "scheduled:42".to_string();
|
|
||||||
assert!(
|
|
||||||
append_persisted_message_if_absent(&session, scheduled.clone())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!append_persisted_message_if_absent(&session, scheduled)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
let guard = session.lock().await;
|
|
||||||
assert_eq!(guard.state_version_for_test(), idempotent_base_version + 1);
|
|
||||||
assert_eq!(
|
|
||||||
guard
|
|
||||||
.get_history()
|
|
||||||
.iter()
|
|
||||||
.filter(|message| message.id == "scheduled:42")
|
|
||||||
.count(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
drop(guard);
|
|
||||||
let persisted: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE id = ?")
|
|
||||||
.bind("scheduled:42")
|
|
||||||
.fetch_one(storage.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(persisted, 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,182 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct SessionStats {
|
|
||||||
pub session_id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub provider: String,
|
|
||||||
pub model: String,
|
|
||||||
pub user_message_count: u64,
|
|
||||||
pub history_message_count: u64,
|
|
||||||
pub lifetime_usage: LifetimeUsage,
|
|
||||||
pub context: ContextUsage,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub last_active_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct LifetimeUsage {
|
|
||||||
pub input_tokens: u64,
|
|
||||||
pub output_tokens: u64,
|
|
||||||
pub total_tokens: u64,
|
|
||||||
pub cached_input_tokens: Option<u64>,
|
|
||||||
pub request_count: u64,
|
|
||||||
pub turn_count: u64,
|
|
||||||
pub tracked_since: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct ContextUsage {
|
|
||||||
pub configured_window_tokens: u64,
|
|
||||||
pub effective_window_tokens: u64,
|
|
||||||
pub configured_reserve_tokens: u64,
|
|
||||||
pub reserve_tokens: u64,
|
|
||||||
pub configured_keep_recent_tokens: u64,
|
|
||||||
pub effective_keep_recent_tokens: u64,
|
|
||||||
pub used_tokens: u64,
|
|
||||||
pub remaining_tokens: u64,
|
|
||||||
pub compression_threshold_tokens: u64,
|
|
||||||
pub source: ContextUsageSource,
|
|
||||||
pub last_observed_prompt_tokens: Option<u64>,
|
|
||||||
pub observed_at: Option<i64>,
|
|
||||||
pub active_checkpoint_id: Option<String>,
|
|
||||||
pub checkpoint_generation: u64,
|
|
||||||
pub checkpoint_tokens_before: Option<u64>,
|
|
||||||
pub checkpoint_tokens_after: Option<u64>,
|
|
||||||
pub checkpoint_degraded: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ContextUsageSource {
|
|
||||||
Observed,
|
|
||||||
Estimated,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ContextUsageSource {
|
|
||||||
pub fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Observed => "Provider 实测",
|
|
||||||
Self::Estimated => "字符估算",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionStats {
|
|
||||||
pub fn render_text(&self) -> String {
|
|
||||||
let percent = if self.context.effective_window_tokens == 0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
self.context.used_tokens as f64 / self.context.effective_window_tokens as f64 * 100.0
|
|
||||||
};
|
|
||||||
let created_at = format_timestamp(self.created_at);
|
|
||||||
let last_active_at = format_timestamp(self.last_active_at);
|
|
||||||
let tracked_since = self
|
|
||||||
.lifetime_usage
|
|
||||||
.tracked_since
|
|
||||||
.map(format_timestamp)
|
|
||||||
.unwrap_or_else(|| "尚无已完成模型请求".to_string());
|
|
||||||
let cached = self
|
|
||||||
.lifetime_usage
|
|
||||||
.cached_input_tokens
|
|
||||||
.map(format_tokens)
|
|
||||||
.unwrap_or_else(|| "—".to_string());
|
|
||||||
let observed = self
|
|
||||||
.context
|
|
||||||
.last_observed_prompt_tokens
|
|
||||||
.map(format_tokens)
|
|
||||||
.unwrap_or_else(|| "—".to_string());
|
|
||||||
let checkpoint = self
|
|
||||||
.context
|
|
||||||
.active_checkpoint_id
|
|
||||||
.as_deref()
|
|
||||||
.map(|id| {
|
|
||||||
let before = self
|
|
||||||
.context
|
|
||||||
.checkpoint_tokens_before
|
|
||||||
.map(format_tokens)
|
|
||||||
.unwrap_or_else(|| "—".to_string());
|
|
||||||
let after = self
|
|
||||||
.context
|
|
||||||
.checkpoint_tokens_after
|
|
||||||
.map(format_tokens)
|
|
||||||
.unwrap_or_else(|| "—".to_string());
|
|
||||||
let degraded = if self.context.checkpoint_degraded {
|
|
||||||
",overflow 降级"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
"{}(generation {},{} → {}{})",
|
|
||||||
id, self.context.checkpoint_generation, before, after, degraded
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "—".to_string());
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"会话\n 标题 {}\n ID {}\n 模型 {} / {}\n 消息 {} 条用户消息,{} 条历史消息\n 创建 {}\n 最后活跃 {}\n\nToken 用量 · 已提交 Turns\n 输入 {}\n 输出 {}\n 合计 {}\n 缓存输入 {}\n 请求 {}\n Turns {}\n 统计起点 {}\n\n上下文窗口 · {}\n 占用 {} / {}({:.1}%)\n 剩余 {}\n 预留 {}(配置 {})\n 近期保留 {}(配置 {})\n 自动压缩阈值 {}\n 最近实测 {}\n Checkpoint {}",
|
|
||||||
self.title,
|
|
||||||
self.session_id,
|
|
||||||
self.provider,
|
|
||||||
self.model,
|
|
||||||
self.user_message_count,
|
|
||||||
self.history_message_count,
|
|
||||||
created_at,
|
|
||||||
last_active_at,
|
|
||||||
format_tokens(self.lifetime_usage.input_tokens),
|
|
||||||
format_tokens(self.lifetime_usage.output_tokens),
|
|
||||||
format_tokens(self.lifetime_usage.total_tokens),
|
|
||||||
cached,
|
|
||||||
format_tokens(self.lifetime_usage.request_count),
|
|
||||||
format_tokens(self.lifetime_usage.turn_count),
|
|
||||||
tracked_since,
|
|
||||||
self.context.source.label(),
|
|
||||||
format_tokens(self.context.used_tokens),
|
|
||||||
format_tokens(self.context.effective_window_tokens),
|
|
||||||
percent,
|
|
||||||
format_tokens(self.context.remaining_tokens),
|
|
||||||
format_tokens(self.context.reserve_tokens),
|
|
||||||
format_tokens(self.context.configured_reserve_tokens),
|
|
||||||
format_tokens(self.context.effective_keep_recent_tokens),
|
|
||||||
format_tokens(self.context.configured_keep_recent_tokens),
|
|
||||||
format_tokens(self.context.compression_threshold_tokens),
|
|
||||||
observed,
|
|
||||||
checkpoint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_timestamp(value: i64) -> String {
|
|
||||||
chrono::DateTime::from_timestamp_millis(value)
|
|
||||||
.map(|timestamp| {
|
|
||||||
timestamp
|
|
||||||
.with_timezone(&chrono::Local)
|
|
||||||
.format("%Y-%m-%d %H:%M:%S")
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "—".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_tokens(value: u64) -> String {
|
|
||||||
let digits = value.to_string();
|
|
||||||
let mut formatted = String::with_capacity(digits.len() + digits.len() / 3);
|
|
||||||
for (index, ch) in digits.chars().enumerate() {
|
|
||||||
if index > 0 && (digits.len() - index).is_multiple_of(3) {
|
|
||||||
formatted.push(',');
|
|
||||||
}
|
|
||||||
formatted.push(ch);
|
|
||||||
}
|
|
||||||
formatted
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn formats_large_token_counts() {
|
|
||||||
assert_eq!(format_tokens(1_234_567), "1,234,567");
|
|
||||||
assert_eq!(format_tokens(12), "12");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -56,7 +56,6 @@ pub enum TurnPhase {
|
|||||||
pub enum ToolStatus {
|
pub enum ToolStatus {
|
||||||
Running,
|
Running,
|
||||||
Completed,
|
Completed,
|
||||||
Cancelled,
|
|
||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,18 +233,6 @@ impl TurnControllerInner {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.text_segment_open = false;
|
self.text_segment_open = false;
|
||||||
if status == TurnStatus::Cancelled {
|
|
||||||
for block in &mut self.state.blocks {
|
|
||||||
if let TurnBlock::Tool {
|
|
||||||
status: tool_status,
|
|
||||||
..
|
|
||||||
} = block
|
|
||||||
&& *tool_status == ToolStatus::Running
|
|
||||||
{
|
|
||||||
*tool_status = ToolStatus::Cancelled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.state.status = status;
|
self.state.status = status;
|
||||||
self.state.phase = TurnPhase::Finalizing;
|
self.state.phase = TurnPhase::Finalizing;
|
||||||
self.state.usage = usage;
|
self.state.usage = usage;
|
||||||
@ -593,34 +580,6 @@ mod tests {
|
|||||||
assert_eq!(snapshot.revision, 1);
|
assert_eq!(snapshot.revision, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cancelling_turn_marks_running_tools_cancelled() {
|
|
||||||
let (controller, emitter, _receiver) = start();
|
|
||||||
emitter
|
|
||||||
.emit(TurnEvent::ToolStarted {
|
|
||||||
iteration: 0,
|
|
||||||
call: ToolCall {
|
|
||||||
id: "long-call".into(),
|
|
||||||
name: "long_tool".into(),
|
|
||||||
arguments: serde_json::json!({"seconds": 60}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(controller.cancel(Some("stopped by user".into())));
|
|
||||||
|
|
||||||
let snapshot = controller.snapshot();
|
|
||||||
assert_eq!(snapshot.status, TurnStatus::Cancelled);
|
|
||||||
assert!(matches!(
|
|
||||||
&snapshot.blocks[0],
|
|
||||||
TurnBlock::Tool {
|
|
||||||
id,
|
|
||||||
status: ToolStatus::Cancelled,
|
|
||||||
..
|
|
||||||
} if id == "long-call"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn failure_is_published_as_structured_terminal_state() {
|
fn failure_is_published_as_structured_terminal_state() {
|
||||||
let (controller, _emitter, _) = start();
|
let (controller, _emitter, _) = start();
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::agent::ContextCompressor;
|
||||||
use crate::agent::system_prompt::build_runtime_context;
|
use crate::agent::system_prompt::build_runtime_context;
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
use crate::memory::MemoryManager;
|
use crate::memory::{MemoryCategory, MemoryManager};
|
||||||
use crate::work::WorkManager;
|
use crate::work::WorkManager;
|
||||||
|
|
||||||
/// Immutable context used to assemble provider input for both the initial call
|
/// Immutable context used to assemble provider input for both the initial call
|
||||||
@ -10,8 +11,6 @@ use crate::work::WorkManager;
|
|||||||
pub(super) struct TurnRuntimeContext {
|
pub(super) struct TurnRuntimeContext {
|
||||||
system_prompt: String,
|
system_prompt: String,
|
||||||
runtime_context: String,
|
runtime_context: String,
|
||||||
memory_tokens: usize,
|
|
||||||
active_plan_tokens: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TurnRuntimeContext {
|
impl TurnRuntimeContext {
|
||||||
@ -26,36 +25,44 @@ impl TurnRuntimeContext {
|
|||||||
}
|
}
|
||||||
history
|
history
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn budget_hints(&self) -> (usize, usize) {
|
|
||||||
(self.memory_tokens, self.active_plan_tokens)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds runtime-only context outside the Session lock. Durable history
|
pub(super) struct PreparedTurnInput {
|
||||||
/// projection and compaction are orchestrated by SessionManager after these
|
pub(super) messages: Vec<ChatMessage>,
|
||||||
/// variable-size sources are known, so the budget covers the complete request.
|
pub(super) runtime: TurnRuntimeContext,
|
||||||
pub(super) async fn prepare_turn_runtime(
|
pub(super) created_timelines: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the complete cross-turn provider input outside the Session lock.
|
||||||
|
/// Independent context sources and compression are fetched concurrently.
|
||||||
|
pub(super) async fn prepare_turn_input(
|
||||||
memory_manager: Arc<MemoryManager>,
|
memory_manager: Arc<MemoryManager>,
|
||||||
work_manager: Arc<WorkManager>,
|
work_manager: Arc<WorkManager>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
query: &str,
|
query: &str,
|
||||||
system_prompt: String,
|
system_prompt: String,
|
||||||
) -> TurnRuntimeContext {
|
compressor: &mut ContextCompressor,
|
||||||
let memory_future = memory_manager.recall_for_context(query);
|
history: Vec<ChatMessage>,
|
||||||
|
) -> PreparedTurnInput {
|
||||||
|
let memory_future = memory_manager.recall(query, 5, Some(MemoryCategory::Knowledge), None);
|
||||||
let work_future = work_manager.active_plan(session_id);
|
let work_future = work_manager.active_plan(session_id);
|
||||||
let (memory_entries, work_result) = tokio::join!(memory_future, work_future);
|
let compression_future = compressor.compress_if_needed(history.clone());
|
||||||
|
let (memory_result, work_result, compression_result) =
|
||||||
|
tokio::join!(memory_future, work_future, compression_future);
|
||||||
|
|
||||||
let memory_context = if memory_entries.is_empty() {
|
let memory_context = match memory_result {
|
||||||
None
|
Ok(entries) if !entries.is_empty() => Some(
|
||||||
} else {
|
entries
|
||||||
Some(
|
|
||||||
memory_entries
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| format!("- {}: {}", entry.key, entry.content))
|
.map(|entry| format!("- {}: {}", entry.key, entry.content))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
)
|
),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(error = %error, "Failed to fetch memory context");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
};
|
};
|
||||||
let work_context = match work_result {
|
let work_context = match work_result {
|
||||||
Ok(Some(plan)) => Some(plan.compact_context()),
|
Ok(Some(plan)) => Some(plan.compact_context()),
|
||||||
@ -65,21 +72,29 @@ pub(super) async fn prepare_turn_runtime(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
TurnRuntimeContext {
|
let compression = match compression_result {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(error = %error, "Context compression failed while preparing turn input");
|
||||||
|
crate::agent::context_compressor::CompressionResult {
|
||||||
|
history,
|
||||||
|
created_timelines: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let runtime = TurnRuntimeContext {
|
||||||
system_prompt,
|
system_prompt,
|
||||||
memory_tokens: memory_context
|
|
||||||
.as_deref()
|
|
||||||
.map(crate::agent::context_compaction::estimate_text_tokens)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
active_plan_tokens: work_context
|
|
||||||
.as_deref()
|
|
||||||
.map(crate::agent::context_compaction::estimate_text_tokens)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
runtime_context: build_runtime_context(
|
runtime_context: build_runtime_context(
|
||||||
Some(session_id),
|
Some(session_id),
|
||||||
memory_context.as_deref(),
|
memory_context.as_deref(),
|
||||||
work_context.as_deref(),
|
work_context.as_deref(),
|
||||||
),
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
PreparedTurnInput {
|
||||||
|
messages: runtime.assemble(compression.history),
|
||||||
|
runtime,
|
||||||
|
created_timelines: compression.created_timelines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,8 +118,6 @@ mod tests {
|
|||||||
let runtime = TurnRuntimeContext {
|
let runtime = TurnRuntimeContext {
|
||||||
system_prompt: "system".to_string(),
|
system_prompt: "system".to_string(),
|
||||||
runtime_context: "runtime".to_string(),
|
runtime_context: "runtime".to_string(),
|
||||||
memory_tokens: 0,
|
|
||||||
active_plan_tokens: 0,
|
|
||||||
};
|
};
|
||||||
let messages = runtime.assemble(vec![
|
let messages = runtime.assemble(vec![
|
||||||
ChatMessage::user("old"),
|
ChatMessage::user("old"),
|
||||||
@ -122,8 +135,6 @@ mod tests {
|
|||||||
let runtime = TurnRuntimeContext {
|
let runtime = TurnRuntimeContext {
|
||||||
system_prompt: "system".to_string(),
|
system_prompt: "system".to_string(),
|
||||||
runtime_context: "runtime".to_string(),
|
runtime_context: "runtime".to_string(),
|
||||||
memory_tokens: 0,
|
|
||||||
active_plan_tokens: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let first = runtime.assemble(vec![ChatMessage::user("question")]);
|
let first = runtime.assemble(vec![ChatMessage::user("question")]);
|
||||||
|
|||||||
@ -4,7 +4,6 @@ mod embedded {
|
|||||||
include!(concat!(env!("OUT_DIR"), "/embedded_skills.rs"));
|
include!(concat!(env!("OUT_DIR"), "/embedded_skills.rs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
@ -29,13 +28,9 @@ struct SkillMarkdownMeta {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct SkillsState {
|
struct SkillsState {
|
||||||
loaded_skills: Vec<Skill>,
|
loaded_skills: Vec<Skill>,
|
||||||
/// Skill names explicitly disabled by the user; everything else is
|
|
||||||
/// enabled by default.
|
|
||||||
disabled_skills: HashSet<String>,
|
|
||||||
last_picobot_mtime: Option<SystemTime>,
|
last_picobot_mtime: Option<SystemTime>,
|
||||||
last_agent_mtime: Option<SystemTime>,
|
last_agent_mtime: Option<SystemTime>,
|
||||||
last_workspace_mtime: Option<SystemTime>,
|
last_workspace_mtime: Option<SystemTime>,
|
||||||
last_state_mtime: Option<SystemTime>,
|
|
||||||
last_load_time: SystemTime,
|
last_load_time: SystemTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,11 +38,9 @@ impl Default for SkillsState {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
loaded_skills: Vec::new(),
|
loaded_skills: Vec::new(),
|
||||||
disabled_skills: HashSet::new(),
|
|
||||||
last_picobot_mtime: None,
|
last_picobot_mtime: None,
|
||||||
last_agent_mtime: None,
|
last_agent_mtime: None,
|
||||||
last_workspace_mtime: None,
|
last_workspace_mtime: None,
|
||||||
last_state_mtime: None,
|
|
||||||
last_load_time: SystemTime::now(),
|
last_load_time: SystemTime::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,9 +52,6 @@ pub struct SkillsLoader {
|
|||||||
picobot_skills_dir: PathBuf,
|
picobot_skills_dir: PathBuf,
|
||||||
agent_skills_dir: PathBuf,
|
agent_skills_dir: PathBuf,
|
||||||
workspace_skills_dir: Option<PathBuf>,
|
workspace_skills_dir: Option<PathBuf>,
|
||||||
/// Path of the JSON state file recording user-disabled skills
|
|
||||||
/// (`<config_dir>/skills_state.json`).
|
|
||||||
state_path: PathBuf,
|
|
||||||
state: Arc<Mutex<SkillsState>>,
|
state: Arc<Mutex<SkillsState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,10 +60,6 @@ impl SkillsLoader {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
let picobot_skills_dir = home.join(".picobot/skills");
|
let picobot_skills_dir = home.join(".picobot/skills");
|
||||||
let state_path = picobot_skills_dir
|
|
||||||
.parent()
|
|
||||||
.unwrap_or(home.as_path())
|
|
||||||
.join("skills_state.json");
|
|
||||||
|
|
||||||
builtin::install_builtin_skills(&picobot_skills_dir);
|
builtin::install_builtin_skills(&picobot_skills_dir);
|
||||||
|
|
||||||
@ -81,22 +67,16 @@ impl SkillsLoader {
|
|||||||
picobot_skills_dir,
|
picobot_skills_dir,
|
||||||
agent_skills_dir: home.join(".agents/skills"),
|
agent_skills_dir: home.join(".agents/skills"),
|
||||||
workspace_skills_dir: None,
|
workspace_skills_dir: None,
|
||||||
state_path,
|
|
||||||
state: Arc::new(Mutex::new(SkillsState::default())),
|
state: Arc::new(Mutex::new(SkillsState::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn new_for_testing(picobot_dir: PathBuf, agent_dir: PathBuf) -> Self {
|
pub(crate) fn new_for_testing(picobot_dir: PathBuf, agent_dir: PathBuf) -> Self {
|
||||||
let state_path = picobot_dir
|
|
||||||
.parent()
|
|
||||||
.unwrap_or(picobot_dir.as_path())
|
|
||||||
.join("skills_state.json");
|
|
||||||
Self {
|
Self {
|
||||||
picobot_skills_dir: picobot_dir,
|
picobot_skills_dir: picobot_dir,
|
||||||
agent_skills_dir: agent_dir,
|
agent_skills_dir: agent_dir,
|
||||||
workspace_skills_dir: None,
|
workspace_skills_dir: None,
|
||||||
state_path,
|
|
||||||
state: Arc::new(Mutex::new(SkillsState::default())),
|
state: Arc::new(Mutex::new(SkillsState::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,9 +89,7 @@ impl SkillsLoader {
|
|||||||
/// Load all skills from all directories and record modification times.
|
/// Load all skills from all directories and record modification times.
|
||||||
/// Priority: workspace > ~/.picobot/skills > ~/.agents/skills.
|
/// Priority: workspace > ~/.picobot/skills > ~/.agents/skills.
|
||||||
/// Same-name skills from higher-priority directories replace lower-priority ones.
|
/// Same-name skills from higher-priority directories replace lower-priority ones.
|
||||||
/// User-disabled skills (see `set_enabled`) are excluded from the result.
|
|
||||||
pub fn load_skills(&self) {
|
pub fn load_skills(&self) {
|
||||||
self.load_state();
|
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.loaded_skills.clear();
|
state.loaded_skills.clear();
|
||||||
|
|
||||||
@ -192,11 +170,6 @@ impl SkillsLoader {
|
|||||||
|
|
||||||
state.last_load_time = SystemTime::now();
|
state.last_load_time = SystemTime::now();
|
||||||
|
|
||||||
let disabled_skills = state.disabled_skills.clone();
|
|
||||||
state
|
|
||||||
.loaded_skills
|
|
||||||
.retain(|skill| !disabled_skills.contains(&skill.name));
|
|
||||||
|
|
||||||
if state.loaded_skills.is_empty() {
|
if state.loaded_skills.is_empty() {
|
||||||
tracing::debug!("No skills found in any skills directory");
|
tracing::debug!("No skills found in any skills directory");
|
||||||
} else {
|
} else {
|
||||||
@ -236,9 +209,7 @@ impl SkillsLoader {
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
let state_changed = Self::get_file_mtime(&self.state_path) != state.last_state_mtime;
|
picobot_changed || agent_changed || workspace_changed
|
||||||
|
|
||||||
picobot_changed || agent_changed || workspace_changed || state_changed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload skills if changes are detected
|
/// Reload skills if changes are detected
|
||||||
@ -277,95 +248,6 @@ impl SkillsLoader {
|
|||||||
max_mtime
|
max_mtime
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the modification time of a single file (missing file -> None).
|
|
||||||
fn get_file_mtime(path: &Path) -> Option<SystemTime> {
|
|
||||||
std::fs::metadata(path)
|
|
||||||
.and_then(|metadata| metadata.modified())
|
|
||||||
.ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the disabled-skills state file into `state.disabled_skills`.
|
|
||||||
/// A missing or malformed file resets to "everything enabled".
|
|
||||||
fn load_state(&self) {
|
|
||||||
let mut state = self.state.lock().unwrap();
|
|
||||||
match std::fs::read_to_string(&self.state_path) {
|
|
||||||
Ok(content) => {
|
|
||||||
let mut disabled = HashSet::new();
|
|
||||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content)
|
|
||||||
&& let Some(items) = value.get("disabled").and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
disabled = items
|
|
||||||
.iter()
|
|
||||||
.filter_map(serde_json::Value::as_str)
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
state.disabled_skills = disabled;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
state.disabled_skills.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.last_state_mtime = Self::get_file_mtime(&self.state_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the disabled-skills state atomically.
|
|
||||||
fn save_state(&self) -> Result<(), String> {
|
|
||||||
let mut disabled: Vec<String> = {
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
state.disabled_skills.iter().cloned().collect()
|
|
||||||
};
|
|
||||||
disabled.sort();
|
|
||||||
let content = serde_json::json!({ "disabled": disabled }).to_string();
|
|
||||||
|
|
||||||
let parent = self.state_path.parent().unwrap_or_else(|| Path::new("."));
|
|
||||||
std::fs::create_dir_all(parent).map_err(|e| format!("create state dir: {e}"))?;
|
|
||||||
let temp = parent.join(".skills_state.json.tmp");
|
|
||||||
std::fs::write(&temp, &content).map_err(|e| format!("write state: {e}"))?;
|
|
||||||
std::fs::rename(&temp, &self.state_path).map_err(|e| format!("rename state: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a skill is enabled. Skills are enabled by default; only names
|
|
||||||
/// explicitly disabled by the user return false.
|
|
||||||
pub fn is_enabled(&self, name: &str) -> bool {
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
!state.disabled_skills.contains(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable a skill and persist the change. Disabled skills are
|
|
||||||
/// excluded from prompts, listings, and `get_skill`.
|
|
||||||
pub fn set_enabled(&self, name: &str, enabled: bool) -> Result<(), String> {
|
|
||||||
{
|
|
||||||
let mut state = self.state.lock().unwrap();
|
|
||||||
if enabled {
|
|
||||||
state.disabled_skills.remove(name);
|
|
||||||
} else {
|
|
||||||
state.disabled_skills.insert(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.save_state()?;
|
|
||||||
self.load_skills();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn source_of(&self, path: Option<&Path>) -> &'static str {
|
|
||||||
let Some(path) = path else {
|
|
||||||
return "unknown";
|
|
||||||
};
|
|
||||||
if let Some(ws) = &self.workspace_skills_dir
|
|
||||||
&& path.starts_with(ws)
|
|
||||||
{
|
|
||||||
return "workspace";
|
|
||||||
}
|
|
||||||
if path.starts_with(&self.picobot_skills_dir) {
|
|
||||||
return "picobot";
|
|
||||||
}
|
|
||||||
if path.starts_with(&self.agent_skills_dir) {
|
|
||||||
return "agent";
|
|
||||||
}
|
|
||||||
"other"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a copy of loaded skills (checks for changes first)
|
/// Get a copy of loaded skills (checks for changes first)
|
||||||
pub fn get_loaded_skills(&self) -> Vec<Skill> {
|
pub fn get_loaded_skills(&self) -> Vec<Skill> {
|
||||||
self.reload_if_changed();
|
self.reload_if_changed();
|
||||||
@ -468,29 +350,6 @@ impl SkillsLoader {
|
|||||||
prompt
|
prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the minimal prompt exposed to a named Agent. The Agent can only
|
|
||||||
/// discover the allowlisted skills through its scoped get_skill tool.
|
|
||||||
pub fn build_scoped_skills_prompt(&self, allowed: &[String]) -> String {
|
|
||||||
if allowed.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
let allowed: std::collections::HashSet<_> = allowed.iter().map(String::as_str).collect();
|
|
||||||
let skills: Vec<_> = self
|
|
||||||
.get_loaded_skills()
|
|
||||||
.into_iter()
|
|
||||||
.filter(|skill| allowed.contains(skill.name.as_str()))
|
|
||||||
.collect();
|
|
||||||
if skills.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
let mut prompt = String::from("## 可用 Skills\n\n");
|
|
||||||
for skill in skills {
|
|
||||||
prompt.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
|
|
||||||
}
|
|
||||||
prompt.push_str("\n需要详细说明时,使用 `get_skill` 读取上述 allowlist 中的 skill。");
|
|
||||||
prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sort_skills(mut skills: Vec<Skill>) -> Vec<Skill> {
|
fn sort_skills(mut skills: Vec<Skill>) -> Vec<Skill> {
|
||||||
skills.sort_by(|a, b| {
|
skills.sort_by(|a, b| {
|
||||||
b.always
|
b.always
|
||||||
@ -727,69 +586,4 @@ This is the content.
|
|||||||
let beta_pos = prompt.find("**beta**").unwrap();
|
let beta_pos = prompt.find("**beta**").unwrap();
|
||||||
assert!(alpha_pos < beta_pos);
|
assert!(alpha_pos < beta_pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_source_of() {
|
|
||||||
let mut loader = SkillsLoader::new_for_testing(
|
|
||||||
PathBuf::from("/home/user/.picobot/skills"),
|
|
||||||
PathBuf::from("/home/user/.agents/skills"),
|
|
||||||
);
|
|
||||||
loader.set_workspace_skills_dir(PathBuf::from("/workspace"));
|
|
||||||
|
|
||||||
assert_eq!(loader.source_of(None), "unknown");
|
|
||||||
assert_eq!(
|
|
||||||
loader.source_of(Some(Path::new("/workspace/skills/my-skill"))),
|
|
||||||
"workspace"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
loader.source_of(Some(Path::new("/home/user/.picobot/skills/foo"))),
|
|
||||||
"picobot"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
loader.source_of(Some(Path::new("/home/user/.agents/skills/bar"))),
|
|
||||||
"agent"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
loader.source_of(Some(Path::new("/opt/other/skill"))),
|
|
||||||
"other"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_set_enabled_persists_and_filters() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let picobot_dir = temp.path().join("picobot");
|
|
||||||
let agent_dir = temp.path().join("agents");
|
|
||||||
for name in ["alpha", "beta"] {
|
|
||||||
let skill_dir = picobot_dir.join(name);
|
|
||||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
skill_dir.join("SKILL.md"),
|
|
||||||
format!("---\nname: {name}\ndescription: {name}\n---\ncontent"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let loader = SkillsLoader::new_for_testing(picobot_dir.clone(), agent_dir.clone());
|
|
||||||
loader.load_skills();
|
|
||||||
assert_eq!(loader.get_loaded_skills().len(), 2);
|
|
||||||
assert!(loader.is_enabled("beta"));
|
|
||||||
|
|
||||||
loader.set_enabled("beta", false).unwrap();
|
|
||||||
let loaded = loader.get_loaded_skills();
|
|
||||||
assert_eq!(loaded.len(), 1);
|
|
||||||
assert_eq!(loaded[0].name, "alpha");
|
|
||||||
assert!(!loader.is_enabled("beta"));
|
|
||||||
assert!(loader.is_enabled("alpha"));
|
|
||||||
|
|
||||||
// State survives a fresh loader instance reading the same state file.
|
|
||||||
let loader2 = SkillsLoader::new_for_testing(picobot_dir, agent_dir);
|
|
||||||
loader2.load_skills();
|
|
||||||
assert_eq!(loader2.get_loaded_skills().len(), 1);
|
|
||||||
assert!(!loader2.is_enabled("beta"));
|
|
||||||
|
|
||||||
loader2.set_enabled("beta", true).unwrap();
|
|
||||||
assert_eq!(loader2.get_loaded_skills().len(), 2);
|
|
||||||
assert!(loader2.is_enabled("beta"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
29
src/storage/background_task.rs
Normal file
29
src/storage/background_task.rs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BackgroundTask {
|
||||||
|
pub id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub channel: String,
|
||||||
|
pub chat_id: String,
|
||||||
|
pub prompt: String,
|
||||||
|
pub allowed_tools: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub result: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub tool_calls_count: i64,
|
||||||
|
pub iterations: i64,
|
||||||
|
pub started_at: Option<i64>,
|
||||||
|
pub finished_at: Option<i64>,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct BackgroundTaskUpdate<'a> {
|
||||||
|
pub status: &'a str,
|
||||||
|
pub result: Option<&'a str>,
|
||||||
|
pub error: Option<&'a str>,
|
||||||
|
pub started_at: Option<i64>,
|
||||||
|
pub finished_at: Option<i64>,
|
||||||
|
pub tool_calls_count: Option<i64>,
|
||||||
|
pub iterations: Option<i64>,
|
||||||
|
}
|
||||||
@ -1,325 +0,0 @@
|
|||||||
use sqlx::Row;
|
|
||||||
|
|
||||||
use super::{Storage, StorageError};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ContextCheckpoint {
|
|
||||||
pub id: String,
|
|
||||||
pub session_id: String,
|
|
||||||
pub generation: i64,
|
|
||||||
pub parent_checkpoint_id: Option<String>,
|
|
||||||
pub summary: String,
|
|
||||||
pub first_retained_seq: i64,
|
|
||||||
pub source_max_seq: i64,
|
|
||||||
pub trigger_reason: String,
|
|
||||||
pub provider_kind: String,
|
|
||||||
pub model: String,
|
|
||||||
pub tokens_before: i64,
|
|
||||||
pub tokens_after: i64,
|
|
||||||
pub degraded: bool,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct NewContextCheckpoint {
|
|
||||||
pub id: String,
|
|
||||||
pub parent_checkpoint_id: Option<String>,
|
|
||||||
pub summary: String,
|
|
||||||
pub first_retained_seq: i64,
|
|
||||||
pub source_max_seq: i64,
|
|
||||||
pub trigger_reason: String,
|
|
||||||
pub provider_kind: String,
|
|
||||||
pub model: String,
|
|
||||||
pub tokens_before: i64,
|
|
||||||
pub tokens_after: i64,
|
|
||||||
pub degraded: bool,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ContextCheckpointState {
|
|
||||||
pub generation: i64,
|
|
||||||
pub active_checkpoint_id: Option<String>,
|
|
||||||
pub checkpoint: Option<ContextCheckpoint>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Storage {
|
|
||||||
pub async fn load_context_checkpoint_state(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
) -> Result<ContextCheckpointState, StorageError> {
|
|
||||||
let session = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT s.context_generation,
|
|
||||||
s.active_context_checkpoint_id,
|
|
||||||
c.id AS checkpoint_id,
|
|
||||||
c.session_id AS checkpoint_session_id,
|
|
||||||
c.generation AS checkpoint_generation,
|
|
||||||
c.parent_checkpoint_id,
|
|
||||||
c.summary,
|
|
||||||
c.first_retained_seq,
|
|
||||||
c.source_max_seq,
|
|
||||||
c.trigger_reason,
|
|
||||||
c.provider_kind,
|
|
||||||
c.model,
|
|
||||||
c.tokens_before,
|
|
||||||
c.tokens_after,
|
|
||||||
c.degraded,
|
|
||||||
c.created_at
|
|
||||||
FROM sessions s
|
|
||||||
LEFT JOIN context_checkpoints c
|
|
||||||
ON c.id = s.active_context_checkpoint_id
|
|
||||||
AND c.session_id = s.id
|
|
||||||
WHERE s.id = ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(session_id)
|
|
||||||
.fetch_optional(self.pool())
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| StorageError::NotFound(session_id.to_string()))?;
|
|
||||||
|
|
||||||
let generation = session.get("context_generation");
|
|
||||||
let active_checkpoint_id: Option<String> = session.get("active_context_checkpoint_id");
|
|
||||||
let checkpoint_id: Option<String> = session.get("checkpoint_id");
|
|
||||||
let checkpoint = checkpoint_id.map(|id| ContextCheckpoint {
|
|
||||||
id,
|
|
||||||
session_id: session.get("checkpoint_session_id"),
|
|
||||||
generation: session.get("checkpoint_generation"),
|
|
||||||
parent_checkpoint_id: session.get("parent_checkpoint_id"),
|
|
||||||
summary: session.get("summary"),
|
|
||||||
first_retained_seq: session.get("first_retained_seq"),
|
|
||||||
source_max_seq: session.get("source_max_seq"),
|
|
||||||
trigger_reason: session.get("trigger_reason"),
|
|
||||||
provider_kind: session.get("provider_kind"),
|
|
||||||
model: session.get("model"),
|
|
||||||
tokens_before: session.get("tokens_before"),
|
|
||||||
tokens_after: session.get("tokens_after"),
|
|
||||||
degraded: session.get("degraded"),
|
|
||||||
created_at: session.get("created_at"),
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(ContextCheckpointState {
|
|
||||||
generation,
|
|
||||||
active_checkpoint_id,
|
|
||||||
checkpoint,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn commit_context_checkpoint(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
expected_generation: i64,
|
|
||||||
checkpoint: &NewContextCheckpoint,
|
|
||||||
) -> Result<ContextCheckpoint, StorageError> {
|
|
||||||
if checkpoint.summary.trim().is_empty() {
|
|
||||||
return Err(StorageError::Serialization(
|
|
||||||
"context checkpoint summary cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if checkpoint.first_retained_seq < 1
|
|
||||||
|| checkpoint.source_max_seq < checkpoint.first_retained_seq
|
|
||||||
{
|
|
||||||
return Err(StorageError::Serialization(
|
|
||||||
"context checkpoint sequence boundary is invalid".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let generation = expected_generation.saturating_add(1);
|
|
||||||
let mut tx = self.pool().begin().await?;
|
|
||||||
let updated = sqlx::query(
|
|
||||||
r#"
|
|
||||||
UPDATE sessions
|
|
||||||
SET active_context_checkpoint_id = ?,
|
|
||||||
context_generation = context_generation + 1,
|
|
||||||
last_compressed_message_at = ?
|
|
||||||
WHERE id = ? AND context_generation = ? AND deleted_at IS NULL
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&checkpoint.id)
|
|
||||||
.bind(checkpoint.created_at)
|
|
||||||
.bind(session_id)
|
|
||||||
.bind(expected_generation)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
if updated.rows_affected() != 1 {
|
|
||||||
tx.rollback().await?;
|
|
||||||
return Err(StorageError::Conflict(format!(
|
|
||||||
"stale context checkpoint generation for session {session_id}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO context_checkpoints (
|
|
||||||
id, session_id, generation, parent_checkpoint_id, summary,
|
|
||||||
first_retained_seq, source_max_seq, trigger_reason,
|
|
||||||
provider_kind, model, tokens_before, tokens_after,
|
|
||||||
degraded, created_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&checkpoint.id)
|
|
||||||
.bind(session_id)
|
|
||||||
.bind(generation)
|
|
||||||
.bind(&checkpoint.parent_checkpoint_id)
|
|
||||||
.bind(&checkpoint.summary)
|
|
||||||
.bind(checkpoint.first_retained_seq)
|
|
||||||
.bind(checkpoint.source_max_seq)
|
|
||||||
.bind(&checkpoint.trigger_reason)
|
|
||||||
.bind(&checkpoint.provider_kind)
|
|
||||||
.bind(&checkpoint.model)
|
|
||||||
.bind(checkpoint.tokens_before)
|
|
||||||
.bind(checkpoint.tokens_after)
|
|
||||||
.bind(checkpoint.degraded)
|
|
||||||
.bind(checkpoint.created_at)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(ContextCheckpoint {
|
|
||||||
id: checkpoint.id.clone(),
|
|
||||||
session_id: session_id.to_string(),
|
|
||||||
generation,
|
|
||||||
parent_checkpoint_id: checkpoint.parent_checkpoint_id.clone(),
|
|
||||||
summary: checkpoint.summary.clone(),
|
|
||||||
first_retained_seq: checkpoint.first_retained_seq,
|
|
||||||
source_max_seq: checkpoint.source_max_seq,
|
|
||||||
trigger_reason: checkpoint.trigger_reason.clone(),
|
|
||||||
provider_kind: checkpoint.provider_kind.clone(),
|
|
||||||
model: checkpoint.model.clone(),
|
|
||||||
tokens_before: checkpoint.tokens_before,
|
|
||||||
tokens_after: checkpoint.tokens_after,
|
|
||||||
degraded: checkpoint.degraded,
|
|
||||||
created_at: checkpoint.created_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn invalidate_context_checkpoint(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
) -> Result<(), StorageError> {
|
|
||||||
let updated = sqlx::query(
|
|
||||||
r#"
|
|
||||||
UPDATE sessions
|
|
||||||
SET active_context_checkpoint_id = NULL,
|
|
||||||
context_generation = context_generation + 1,
|
|
||||||
last_compressed_message_at = NULL
|
|
||||||
WHERE id = ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(session_id)
|
|
||||||
.execute(self.pool())
|
|
||||||
.await?;
|
|
||||||
if updated.rows_affected() != 1 {
|
|
||||||
return Err(StorageError::NotFound(session_id.to_string()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
async fn test_storage() -> (Storage, tempfile::TempDir) {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let storage = Storage::new(&directory.path().join("checkpoint.db"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO sessions (
|
|
||||||
id, channel, chat_id, dialog_id, title, created_at, last_active_at
|
|
||||||
) VALUES ('session', 'cli', 'chat', 'dialog', 'checkpoint', 1, 1)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(storage.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
for seq in 1..=4 {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO messages (id, session_id, seq, role, content, created_at) VALUES (?, 'session', ?, 'user', ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(format!("message-{seq}"))
|
|
||||||
.bind(seq)
|
|
||||||
.bind(format!("message {seq}"))
|
|
||||||
.bind(seq)
|
|
||||||
.execute(storage.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
(storage, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn checkpoint(id: &str) -> NewContextCheckpoint {
|
|
||||||
NewContextCheckpoint {
|
|
||||||
id: id.to_string(),
|
|
||||||
parent_checkpoint_id: None,
|
|
||||||
summary: "durable summary".to_string(),
|
|
||||||
first_retained_seq: 2,
|
|
||||||
source_max_seq: 4,
|
|
||||||
trigger_reason: "manual".to_string(),
|
|
||||||
provider_kind: "test".to_string(),
|
|
||||||
model: "test-model".to_string(),
|
|
||||||
tokens_before: 100,
|
|
||||||
tokens_after: 25,
|
|
||||||
degraded: false,
|
|
||||||
created_at: 10,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn checkpoint_commit_is_cas_guarded_and_clear_invalidates_projection() {
|
|
||||||
let (storage, _directory) = test_storage().await;
|
|
||||||
let committed = storage
|
|
||||||
.commit_context_checkpoint("session", 0, &checkpoint("cp-1"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(committed.generation, 1);
|
|
||||||
|
|
||||||
let state = storage
|
|
||||||
.load_context_checkpoint_state("session")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(state.generation, 1);
|
|
||||||
assert_eq!(state.active_checkpoint_id.as_deref(), Some("cp-1"));
|
|
||||||
assert_eq!(state.checkpoint, Some(committed));
|
|
||||||
let raw_count: i64 =
|
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE session_id = 'session'")
|
|
||||||
.fetch_one(storage.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(raw_count, 4);
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO messages (id, session_id, seq, role, content, created_at) VALUES ('message-5', 'session', 5, 'assistant', 'tail', 5)",
|
|
||||||
)
|
|
||||||
.execute(storage.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(storage.get_max_message_seq("session").await.unwrap(), 5);
|
|
||||||
|
|
||||||
let stale = storage
|
|
||||||
.commit_context_checkpoint("session", 0, &checkpoint("cp-stale"))
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(stale, StorageError::Conflict(_)));
|
|
||||||
|
|
||||||
storage.clear_messages("session").await.unwrap();
|
|
||||||
let cleared = storage
|
|
||||||
.load_context_checkpoint_state("session")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(cleared.generation, 2);
|
|
||||||
assert!(cleared.active_checkpoint_id.is_none());
|
|
||||||
assert!(cleared.checkpoint.is_none());
|
|
||||||
|
|
||||||
let retained_audit_rows: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM context_checkpoints WHERE session_id = 'session'",
|
|
||||||
)
|
|
||||||
.fetch_one(storage.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(retained_audit_rows, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +1,17 @@
|
|||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use jieba_rs::Jieba;
|
||||||
|
|
||||||
use crate::memory::{MemoryCategory, MemoryEntry};
|
use crate::memory::{MemoryCategory, MemoryEntry};
|
||||||
|
|
||||||
use super::StorageError;
|
use super::StorageError;
|
||||||
|
|
||||||
|
fn jieba() -> &'static Jieba {
|
||||||
|
static INSTANCE: OnceLock<Jieba> = OnceLock::new();
|
||||||
|
INSTANCE.get_or_init(Jieba::new)
|
||||||
|
}
|
||||||
|
|
||||||
impl super::Storage {
|
impl super::Storage {
|
||||||
/// List recent memories without requiring a full-text query.
|
/// List recent memories without requiring a full-text query.
|
||||||
pub async fn list_memories(
|
pub async fn list_memories(
|
||||||
@ -34,17 +42,6 @@ impl super::Storage {
|
|||||||
parse_memory_rows(&rows)
|
parse_memory_rows(&rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_memory_by_key(&self, key: &str) -> Result<Option<MemoryEntry>, StorageError> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT id, key, content, category, importance, session_id, created_at, updated_at FROM memories WHERE key = ?",
|
|
||||||
)
|
|
||||||
.bind(key)
|
|
||||||
.fetch_all(self.pool())
|
|
||||||
.await?;
|
|
||||||
let mut entries = parse_memory_rows(&rows)?;
|
|
||||||
Ok(entries.pop())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store or update a memory entry (upsert by key).
|
/// Store or update a memory entry (upsert by key).
|
||||||
pub async fn upsert_memory(&self, entry: &MemoryEntry) -> Result<(), StorageError> {
|
pub async fn upsert_memory(&self, entry: &MemoryEntry) -> Result<(), StorageError> {
|
||||||
let category_str = entry.category.as_str();
|
let category_str = entry.category.as_str();
|
||||||
@ -91,28 +88,12 @@ impl super::Storage {
|
|||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<MemoryEntry>, StorageError> {
|
) -> Result<Vec<MemoryEntry>, StorageError> {
|
||||||
let terms = crate::memory::recall::tokenize(query);
|
// Build FTS5 query: segment with jieba, wrap each term in quotes, join with OR
|
||||||
self.search_memories_by_terms(&terms, category, session_id, limit)
|
let fts_query = jieba()
|
||||||
.await
|
.cut(query, true)
|
||||||
}
|
.into_iter()
|
||||||
|
.filter(|w| w.len() > 1 || w.bytes().any(|b| b > 127))
|
||||||
/// Search memories using pre-tokenized terms (FTS5 with LIKE fallback).
|
.map(|w| format!("\"{}\"", w.replace('"', "")))
|
||||||
/// An empty term list returns no results without issuing a query.
|
|
||||||
pub async fn search_memories_by_terms(
|
|
||||||
&self,
|
|
||||||
terms: &[String],
|
|
||||||
category: Option<&MemoryCategory>,
|
|
||||||
session_id: Option<&str>,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<MemoryEntry>, StorageError> {
|
|
||||||
if terms.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build FTS5 query: wrap each term in quotes, join with OR.
|
|
||||||
let fts_query = terms
|
|
||||||
.iter()
|
|
||||||
.map(|word| format!("\"{}\"", word.replace('"', "")))
|
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" OR ");
|
.join(" OR ");
|
||||||
|
|
||||||
@ -143,6 +124,14 @@ impl super::Storage {
|
|||||||
|
|
||||||
// Fallback to term-based LIKE query if FTS5 returned nothing
|
// Fallback to term-based LIKE query if FTS5 returned nothing
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
|
let terms: Vec<String> = jieba()
|
||||||
|
.cut(query, true)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|w| w.len() > 1 || w.bytes().any(|b| b > 127))
|
||||||
|
.map(|w| w.replace(['%', '_'], ""))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !terms.is_empty() {
|
||||||
let like_clauses = terms
|
let like_clauses = terms
|
||||||
.iter()
|
.iter()
|
||||||
.map(|_| "(key LIKE ? OR content LIKE ?)")
|
.map(|_| "(key LIKE ? OR content LIKE ?)")
|
||||||
@ -163,10 +152,9 @@ impl super::Storage {
|
|||||||
like_clauses
|
like_clauses
|
||||||
);
|
);
|
||||||
|
|
||||||
// The only interpolated fragment is a generated sequence of bind placeholders.
|
let mut query_builder = sqlx::query(&sql);
|
||||||
let mut query_builder = sqlx::query(sqlx::AssertSqlSafe(sql));
|
for term in &terms {
|
||||||
for term in terms {
|
let pattern = format!("%{}%", term);
|
||||||
let pattern = format!("%{}%", term.replace(['%', '_'], ""));
|
|
||||||
query_builder = query_builder.bind(pattern.clone()).bind(pattern);
|
query_builder = query_builder.bind(pattern.clone()).bind(pattern);
|
||||||
}
|
}
|
||||||
query_builder = query_builder
|
query_builder = query_builder
|
||||||
@ -179,6 +167,7 @@ impl super::Storage {
|
|||||||
let rows = query_builder.fetch_all(self.pool()).await?;
|
let rows = query_builder.fetch_all(self.pool()).await?;
|
||||||
entries = parse_memory_rows(&rows)?;
|
entries = parse_memory_rows(&rows)?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
@ -202,7 +191,12 @@ impl super::Storage {
|
|||||||
.to_rfc3339();
|
.to_rfc3339();
|
||||||
|
|
||||||
let rows = if let Some(q) = query {
|
let rows = if let Some(q) = query {
|
||||||
let terms: Vec<String> = crate::memory::recall::tokenize(q);
|
let terms: Vec<String> = jieba()
|
||||||
|
.cut(q, true)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|w| w.len() > 1 || w.bytes().any(|b| b > 127))
|
||||||
|
.map(|w| w.replace(['%', '_'], ""))
|
||||||
|
.collect();
|
||||||
|
|
||||||
if terms.is_empty() {
|
if terms.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@ -229,10 +223,9 @@ impl super::Storage {
|
|||||||
like_clauses
|
like_clauses
|
||||||
);
|
);
|
||||||
|
|
||||||
// The only interpolated fragment is a generated sequence of bind placeholders.
|
let mut query_builder = sqlx::query(&sql);
|
||||||
let mut query_builder = sqlx::query(sqlx::AssertSqlSafe(sql));
|
|
||||||
for term in &terms {
|
for term in &terms {
|
||||||
let pattern = format!("%{}%", term.replace(['%', '_'], ""));
|
let pattern = format!("%{}%", term);
|
||||||
query_builder = query_builder.bind(pattern.clone()).bind(pattern);
|
query_builder = query_builder.bind(pattern.clone()).bind(pattern);
|
||||||
}
|
}
|
||||||
query_builder = query_builder
|
query_builder = query_builder
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::bus::{ClientVisibility, CompletionStatus, TurnOrigin};
|
use crate::bus::CompletionStatus;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MessageMeta {
|
pub struct MessageMeta {
|
||||||
@ -14,8 +14,6 @@ pub struct MessageMeta {
|
|||||||
pub turn_id: Option<String>,
|
pub turn_id: Option<String>,
|
||||||
pub iteration: Option<i64>,
|
pub iteration: Option<i64>,
|
||||||
pub completion_status: CompletionStatus,
|
pub completion_status: CompletionStatus,
|
||||||
pub client_visibility: ClientVisibility,
|
|
||||||
pub turn_origin: TurnOrigin,
|
|
||||||
pub media_refs: Option<String>,
|
pub media_refs: Option<String>,
|
||||||
pub tool_call_id: Option<String>,
|
pub tool_call_id: Option<String>,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
|
|||||||
1547
src/storage/mod.rs
1547
src/storage/mod.rs
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,28 +0,0 @@
|
|||||||
use crate::providers::Usage;
|
|
||||||
|
|
||||||
/// Provider-reported usage committed with one durable assistant Turn.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TurnUsageRecord {
|
|
||||||
pub session_id: String,
|
|
||||||
pub turn_id: String,
|
|
||||||
pub provider: String,
|
|
||||||
pub model: String,
|
|
||||||
pub usage: Usage,
|
|
||||||
/// Prompt usage from the final provider request in the Turn. Unlike
|
|
||||||
/// `usage.prompt_tokens`, this is not accumulated across tool iterations.
|
|
||||||
pub last_prompt_tokens: u32,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
||||||
pub struct SessionUsageTotals {
|
|
||||||
pub prompt_tokens: u64,
|
|
||||||
pub completion_tokens: u64,
|
|
||||||
pub total_tokens: u64,
|
|
||||||
pub cached_input_tokens: Option<u64>,
|
|
||||||
pub request_count: u64,
|
|
||||||
pub turn_count: u64,
|
|
||||||
pub tracked_since: Option<i64>,
|
|
||||||
pub last_prompt_tokens: Option<u64>,
|
|
||||||
pub last_observed_at: Option<i64>,
|
|
||||||
}
|
|
||||||
@ -1,250 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
|
|
||||||
use crate::agent::AgentCoordinator;
|
|
||||||
use crate::storage::agent_run::AgentRunRecord;
|
|
||||||
use crate::tools::traits::{Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
|
||||||
|
|
||||||
const RESULT_PREVIEW_CHARS: usize = 2_000;
|
|
||||||
|
|
||||||
/// Scoped inspection and control of durable Agent runs. Authorization is
|
|
||||||
/// derived from the caller's ToolExecutionContext (session for ROOT, tree
|
|
||||||
/// position for named Agents); run IDs are never credentials.
|
|
||||||
pub struct AgentTaskTool {
|
|
||||||
coordinator: Arc<AgentCoordinator>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentTaskTool {
|
|
||||||
pub fn new(coordinator: Arc<AgentCoordinator>) -> Self {
|
|
||||||
Self { coordinator }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for AgentTaskTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"agent_task"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Inspect or control delegated Agent runs: get reads one run, list shows the session's runs, get_result returns the full terminal result, cancel stops a non-terminal run."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_injected(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["get", "list", "get_result", "cancel"],
|
|
||||||
"description": "Operation to perform on Agent runs"
|
|
||||||
},
|
|
||||||
"run_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Target run identifier for get/get_result/cancel"
|
|
||||||
},
|
|
||||||
"cursor_created_at": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Pagination cursor: created_at of the last run seen"
|
|
||||||
},
|
|
||||||
"cursor_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Pagination cursor: id of the last run seen"
|
|
||||||
},
|
|
||||||
"limit": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 100,
|
|
||||||
"description": "Maximum number of runs to list (default 20)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["action"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_only(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
|
||||||
self.execute_with_context(&ToolExecutionContext::default(), args)
|
|
||||||
.await
|
|
||||||
.map(|output| output.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_with_context(
|
|
||||||
&self,
|
|
||||||
context: &ToolExecutionContext,
|
|
||||||
args: Value,
|
|
||||||
) -> anyhow::Result<ToolOutput> {
|
|
||||||
let action = args
|
|
||||||
.get("action")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let result = match action {
|
|
||||||
"get" => self.handle_get(context, &args).await,
|
|
||||||
"list" => self.handle_list(context, &args).await,
|
|
||||||
"get_result" => self.handle_get_result(context, &args).await,
|
|
||||||
"cancel" => self.handle_cancel(context, &args).await,
|
|
||||||
other => Ok(ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: String::new(),
|
|
||||||
error: Some(format!(
|
|
||||||
"unknown agent_task action '{other}'; supported: get, list, get_result, cancel"
|
|
||||||
)),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
Ok(result?.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentTaskTool {
|
|
||||||
fn run_id<'a>(&self, args: &'a Value) -> anyhow::Result<&'a str> {
|
|
||||||
args.get("run_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: run_id"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_get(
|
|
||||||
&self,
|
|
||||||
context: &ToolExecutionContext,
|
|
||||||
args: &Value,
|
|
||||||
) -> anyhow::Result<ToolResult> {
|
|
||||||
let run_id = self.run_id(args)?;
|
|
||||||
match self.coordinator.get_run(context, run_id).await {
|
|
||||||
Ok(Some(run)) => Ok(success(run_projection(&run, true))),
|
|
||||||
Ok(None) => Ok(failure(format!("run not found: {run_id}"))),
|
|
||||||
Err(error) => Ok(failure(error.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_list(
|
|
||||||
&self,
|
|
||||||
context: &ToolExecutionContext,
|
|
||||||
args: &Value,
|
|
||||||
) -> anyhow::Result<ToolResult> {
|
|
||||||
let cursor = match (
|
|
||||||
args.get("cursor_created_at").and_then(Value::as_i64),
|
|
||||||
args.get("cursor_id").and_then(Value::as_str),
|
|
||||||
) {
|
|
||||||
(Some(created_at), Some(id)) => Some((created_at, id.to_string())),
|
|
||||||
(None, None) => None,
|
|
||||||
_ => {
|
|
||||||
return Ok(failure(
|
|
||||||
"cursor requires both cursor_created_at and cursor_id",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let limit = args.get("limit").and_then(Value::as_i64).unwrap_or(20);
|
|
||||||
match self.coordinator.list_runs(context, cursor, limit).await {
|
|
||||||
Ok(runs) => {
|
|
||||||
let payload: Vec<Value> =
|
|
||||||
runs.iter().map(|run| run_projection(run, false)).collect();
|
|
||||||
Ok(success(json!({ "runs": payload })))
|
|
||||||
}
|
|
||||||
Err(error) => Ok(failure(error.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_get_result(
|
|
||||||
&self,
|
|
||||||
context: &ToolExecutionContext,
|
|
||||||
args: &Value,
|
|
||||||
) -> anyhow::Result<ToolResult> {
|
|
||||||
let run_id = self.run_id(args)?;
|
|
||||||
match self.coordinator.get_result(context, run_id).await {
|
|
||||||
Ok(Some(run)) => Ok(success(json!({
|
|
||||||
"run_id": run.id,
|
|
||||||
"status": run.status.as_str(),
|
|
||||||
"result": run.result,
|
|
||||||
"error": run.error,
|
|
||||||
"tool_calls": run.tool_calls_count,
|
|
||||||
"iterations": run.iterations,
|
|
||||||
"finished_at": run.finished_at
|
|
||||||
}))),
|
|
||||||
Ok(None) => Ok(failure(format!(
|
|
||||||
"run {run_id} is not terminal or does not exist"
|
|
||||||
))),
|
|
||||||
Err(error) => Ok(failure(error.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_cancel(
|
|
||||||
&self,
|
|
||||||
context: &ToolExecutionContext,
|
|
||||||
args: &Value,
|
|
||||||
) -> anyhow::Result<ToolResult> {
|
|
||||||
let run_id = self.run_id(args)?;
|
|
||||||
match self
|
|
||||||
.coordinator
|
|
||||||
.cancel_run(context, run_id, "cancelled via agent_task")
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(true) => Ok(success(json!({ "run_id": run_id, "status": "cancelled" }))),
|
|
||||||
Ok(false) => Ok(failure(format!(
|
|
||||||
"cannot cancel run {run_id}; it is terminal or does not exist"
|
|
||||||
))),
|
|
||||||
Err(error) => Ok(failure(error.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_projection(run: &AgentRunRecord, include_result_preview: bool) -> Value {
|
|
||||||
let mut value = json!({
|
|
||||||
"run_id": run.id,
|
|
||||||
"agent_id": run.agent_id,
|
|
||||||
"status": run.status.as_str(),
|
|
||||||
"mode": run.mode.as_str(),
|
|
||||||
"depth": run.depth,
|
|
||||||
"parent_run_id": run.parent_run_id,
|
|
||||||
"provider_profile": run.provider_profile,
|
|
||||||
"model_id": run.model_id,
|
|
||||||
"tool_calls": run.tool_calls_count,
|
|
||||||
"iterations": run.iterations,
|
|
||||||
"created_at": run.created_at,
|
|
||||||
"started_at": run.started_at,
|
|
||||||
"finished_at": run.finished_at,
|
|
||||||
"error": run.error,
|
|
||||||
});
|
|
||||||
if include_result_preview {
|
|
||||||
value["result_preview"] = json!(run.result.as_deref().map(preview));
|
|
||||||
value["result_truncated"] = json!(
|
|
||||||
run.result
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|result| result.chars().count() > RESULT_PREVIEW_CHARS)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
value
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preview(value: &str) -> String {
|
|
||||||
if value.chars().count() <= RESULT_PREVIEW_CHARS {
|
|
||||||
value.to_string()
|
|
||||||
} else {
|
|
||||||
let cut = value.floor_char_boundary(RESULT_PREVIEW_CHARS);
|
|
||||||
format!("{}...", &value[..cut])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn success(value: Value) -> ToolResult {
|
|
||||||
ToolResult {
|
|
||||||
success: true,
|
|
||||||
output: value.to_string(),
|
|
||||||
error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn failure(error: impl Into<String>) -> ToolResult {
|
|
||||||
ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: String::new(),
|
|
||||||
error: Some(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1267
src/tools/browser.rs
Normal file
1267
src/tools/browser.rs
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user