First Version
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "core-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["sync", "macros"] }
|
||||
tokio-util = { version = "0.7" }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
async-trait = "0.1"
|
||||
anyhow = "1"
|
||||
axum = { version = "0.8", default-features = false }
|
||||
# SqlitePool exposed to plugins via PluginContext (plugin.md §12.1). Version
|
||||
# pinned to match the rest of the workspace.
|
||||
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
|
||||
@@ -0,0 +1,22 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Minimal approval API exposed to plugins.
|
||||
///
|
||||
/// Plugins receive `Arc<dyn ApprovalApi>` via `PluginContext` and use it to
|
||||
/// resolve pending tool-call approvals without depending on the main crate's
|
||||
/// `ApprovalManager` directly.
|
||||
#[async_trait]
|
||||
pub trait ApprovalApi: Send + Sync {
|
||||
/// Approve a pending tool-call request.
|
||||
async fn approve(&self, request_id: i64);
|
||||
|
||||
/// Reject a pending tool-call request with an optional note.
|
||||
async fn reject(&self, request_id: i64, note: String);
|
||||
|
||||
/// Approve + register a session bypass so future tool calls of the same
|
||||
/// category/MCP-server are skipped automatically.
|
||||
///
|
||||
/// - `bypass_secs = Some(n)`: bypass lasts `n` seconds (e.g. 900 = 15 min)
|
||||
/// - `bypass_secs = None`: bypass lasts until the session ends
|
||||
async fn approve_with_bypass(&self, request_id: i64, bypass_secs: Option<u64>);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! In-process event bus for chat turns and system events.
|
||||
//!
|
||||
//! Every completed chat turn (user message + assistant response) is published
|
||||
//! here **after** both messages have been persisted to the DB successfully.
|
||||
//! Context compaction events are published when the compactor completes a
|
||||
//! summarisation cycle.
|
||||
//!
|
||||
//! Subscribers receive a clone of each [`BusEvent`] via a
|
||||
//! `tokio::sync::broadcast` channel.
|
||||
//!
|
||||
//! # Current consumers
|
||||
//! - `honcho` plugin: processes `UserMessage` and `AssistantResponse` variants.
|
||||
//!
|
||||
//! # Usage
|
||||
//! ```rust,ignore
|
||||
//! // Producer (ChatSessionHandler):
|
||||
//! bus.user_message(ChatEvent { ... });
|
||||
//! bus.assistant_response(ChatEvent { ... });
|
||||
//!
|
||||
//! // Producer (ContextCompactor):
|
||||
//! bus.compaction_done(CompactionEvent { ... });
|
||||
//!
|
||||
//! // Consumer (spawn once, keep running):
|
||||
//! let mut rx = bus.subscribe();
|
||||
//! tokio::spawn(async move {
|
||||
//! loop {
|
||||
//! match rx.recv().await {
|
||||
//! Ok(BusEvent::UserMessage(e)) => { /* ... */ }
|
||||
//! Ok(BusEvent::AssistantResponse(e)) => { /* ... */ }
|
||||
//! Ok(BusEvent::CompactionDone(e)) => { /* ... */ }
|
||||
//! Err(RecvError::Lagged(n)) => warn!("lagged by {n}"),
|
||||
//! Err(RecvError::Closed) => break,
|
||||
//! }
|
||||
//! }
|
||||
//! });
|
||||
//! ```
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
/// Default channel capacity. At 256 events the bus can absorb a burst of 128
|
||||
/// turns before a slow consumer starts lagging.
|
||||
const DEFAULT_CAPACITY: usize = 256;
|
||||
|
||||
// ── Sub-event types ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatEventRole {
|
||||
User,
|
||||
Assistant,
|
||||
/// Sub-agent invocation (role = "agent" in DB).
|
||||
Agent,
|
||||
}
|
||||
|
||||
/// Per-tool-call detail attached to an assistant `ChatEvent`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCallEvent {
|
||||
pub name: String,
|
||||
pub arguments: Option<String>,
|
||||
pub result: Option<String>,
|
||||
/// "done" | "failed"
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// A single message in a completed chat turn.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatEvent {
|
||||
pub session_id: i64,
|
||||
pub stack_id: i64,
|
||||
/// `chat_history.id` for this message.
|
||||
pub message_id: i64,
|
||||
pub role: ChatEventRole,
|
||||
pub content: String,
|
||||
/// True for system-generated messages that look like user turns
|
||||
/// (TicManager ticks, notification briefings).
|
||||
pub is_synthetic: bool,
|
||||
/// True when a real user is actively participating in the session
|
||||
/// (web, telegram). False for automated sessions (cron, tic).
|
||||
pub is_interactive: bool,
|
||||
/// True for short-lived task sessions (cron, tic) that have no
|
||||
/// long-term conversational value (e.g. skip Honcho memory sink).
|
||||
pub is_ephemeral: bool,
|
||||
/// Non-empty only for assistant messages that triggered tool calls.
|
||||
pub tool_calls: Vec<ToolCallEvent>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Emitted after the compactor successfully persists a new summary.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompactionEvent {
|
||||
pub session_id: i64,
|
||||
pub stack_id: i64,
|
||||
/// `chat_summaries.id` of the newly created summary row.
|
||||
pub summary_id: i64,
|
||||
/// All `chat_history` rows with `id <= covers_up_to_message_id` are now
|
||||
/// covered by the summary and will no longer be sent to the LLM raw.
|
||||
pub covers_up_to_message_id: i64,
|
||||
/// Input token count of the turn that triggered this compaction.
|
||||
pub triggered_by_tokens: u32,
|
||||
}
|
||||
|
||||
// ── Top-level bus event ───────────────────────────────────────────────────────
|
||||
|
||||
/// All events that flow through the [`ChatEventBus`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BusEvent {
|
||||
/// A user (or synthetic) message was saved after a completed turn.
|
||||
UserMessage(ChatEvent),
|
||||
/// The assistant's final response was saved after a completed turn.
|
||||
AssistantResponse(ChatEvent),
|
||||
/// The context compactor created a new summary for a stack.
|
||||
CompactionDone(CompactionEvent),
|
||||
}
|
||||
|
||||
// ── Bus ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct ChatEventBus {
|
||||
tx: broadcast::Sender<BusEvent>,
|
||||
}
|
||||
|
||||
impl ChatEventBus {
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(DEFAULT_CAPACITY)
|
||||
}
|
||||
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// Publish a user message event. No-op if there are no active subscribers.
|
||||
pub fn user_message(&self, event: ChatEvent) {
|
||||
let _ = self.tx.send(BusEvent::UserMessage(event));
|
||||
}
|
||||
|
||||
/// Publish an assistant response event. No-op if there are no active subscribers.
|
||||
pub fn assistant_response(&self, event: ChatEvent) {
|
||||
let _ = self.tx.send(BusEvent::AssistantResponse(event));
|
||||
}
|
||||
|
||||
/// Publish a compaction-done event. No-op if there are no active subscribers.
|
||||
pub fn compaction_done(&self, event: CompactionEvent) {
|
||||
let _ = self.tx.send(BusEvent::CompactionDone(event));
|
||||
}
|
||||
|
||||
/// Returns a new receiver. Each subscriber gets every future event
|
||||
/// independently. If the subscriber falls behind by more than the channel
|
||||
/// capacity it will receive `RecvError::Lagged(n)` — handle gracefully.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<BusEvent> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChatEventBus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::events::GlobalEvent;
|
||||
use crate::interface_tool::InterfaceTool;
|
||||
use crate::message_meta::MessageMetadata;
|
||||
|
||||
// ── SendMessageOptions ────────────────────────────────────────────────────────
|
||||
|
||||
/// Optional parameters for a [`ChatHubApi::send_message`] call.
|
||||
#[derive(Default)]
|
||||
pub struct SendMessageOptions {
|
||||
/// Agent to use for this source's session. Defaults to `"main"` if not set.
|
||||
/// Only takes effect when a new session is created — ignored for existing sessions.
|
||||
pub agent_id: Option<String>,
|
||||
/// Named substitutions applied to the agent's system prompt.
|
||||
/// Each entry replaces the sentinel `__KEY__` in the loaded prompt text.
|
||||
/// Matches `<!-- KEY -->` placeholders that `agents::resolve_includes` converts to sentinels.
|
||||
pub system_substitutions: HashMap<String, String>,
|
||||
pub client_name: Option<String>,
|
||||
/// Extra text prepended to the agent's system prompt for this turn only.
|
||||
/// STATIC: safe to cache — use for interface-specific formatting rules that
|
||||
/// never change turn-to-turn (e.g. Telegram HTML mode).
|
||||
pub extra_system_context: Option<String>,
|
||||
/// Extra system message injected AFTER the conversation history for this turn only.
|
||||
/// DYNAMIC: not cached — use for per-turn context (e.g. notification framing).
|
||||
pub extra_system_dynamic: Option<String>,
|
||||
/// Short reminder injected near the tail of the message list to prevent drift.
|
||||
pub tail_reminder: Option<String>,
|
||||
pub interface_tools: Vec<InterfaceTool>,
|
||||
/// True for system-generated messages injected as user turns (notification briefings).
|
||||
pub is_synthetic: bool,
|
||||
/// Opaque structured metadata persisted on the user turn (e.g. file attachments).
|
||||
/// ChatHub forwards it verbatim; the MessageBuilder/UI derive their own views.
|
||||
pub metadata: Option<MessageMetadata>,
|
||||
}
|
||||
|
||||
// ── ChatHubApi ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Abstraction over [`ChatHub`](crate) that plugins and external crates depend on.
|
||||
///
|
||||
/// Implementing this trait for `ChatHub` in the main crate is the only coupling
|
||||
/// point needed: plugins can accept `Arc<dyn ChatHubApi>` and stay independent.
|
||||
#[async_trait]
|
||||
pub trait ChatHubApi: Send + Sync {
|
||||
/// Register a source. No-op for duplicate registrations.
|
||||
async fn register(&self, source_id: &str);
|
||||
|
||||
/// Send a user message for a source, running a full LLM turn.
|
||||
/// Creates a session lazily if none exists yet.
|
||||
async fn send_message(
|
||||
&self,
|
||||
source_id: &str,
|
||||
prompt: &str,
|
||||
opts: SendMessageOptions,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Create a new session for the source, discarding the previous one.
|
||||
async fn clear(&self, source_id: &str) -> anyhow::Result<i64>;
|
||||
|
||||
/// Subscribe to the global event bus.
|
||||
/// Filtering by source is the caller's responsibility.
|
||||
fn events(&self, source_id: &str) -> broadcast::Receiver<GlobalEvent>;
|
||||
|
||||
/// Set which source is the "home" for background agent notifications.
|
||||
async fn set_home(&self, source_id: &str) -> anyhow::Result<()>;
|
||||
|
||||
/// Returns token usage `(input, output)` for the last message in the source's session.
|
||||
async fn context_info(
|
||||
&self,
|
||||
source_id: &str,
|
||||
) -> anyhow::Result<(Option<i64>, Option<i64>)>;
|
||||
|
||||
/// Total spend (USD) of the source's active session, including synchronous
|
||||
/// sub-agent frames and excluding asynchronous tasks (which run in their own
|
||||
/// session). `None` when no provider reported a cost.
|
||||
async fn cost_info(&self, source_id: &str) -> anyhow::Result<Option<f64>>;
|
||||
|
||||
/// Force compaction of the source's active session history.
|
||||
/// Returns `true` if compaction occurred.
|
||||
async fn force_compact(&self, source_id: &str) -> anyhow::Result<bool>;
|
||||
|
||||
/// Resume any interrupted turn for a source's active session.
|
||||
async fn resume(&self, source_id: &str) -> anyhow::Result<()>;
|
||||
|
||||
/// Approve a pending tool-call approval request.
|
||||
async fn approve(&self, request_id: i64);
|
||||
|
||||
/// Reject a pending tool-call approval request.
|
||||
async fn reject(&self, request_id: i64, note: String);
|
||||
|
||||
/// Resolve a pending `ask_user_clarification` question.
|
||||
/// Collapses `session_handler(source_id).resolve_question(...)` into a single
|
||||
/// hub-level call so callers never need to know about `ChatSessionHandler`.
|
||||
async fn resolve_question(&self, source_id: &str, request_id: i64, answer: String);
|
||||
|
||||
/// Cancel the active LLM turn for a source, clearing any pending approvals
|
||||
/// and clarification questions. No-op if no session is active.
|
||||
async fn cancel(&self, source_id: &str);
|
||||
|
||||
/// Revoke all session-scoped MCP grants for a source's active session.
|
||||
/// The next LLM turn will start with no MCP tools activated.
|
||||
async fn reset_mcp(&self, source_id: &str) -> anyhow::Result<()>;
|
||||
|
||||
/// Returns `(models, default)` where `models` is the ordered list of usable
|
||||
/// LLM client names (first entry is always `"auto"`) and `default` is the
|
||||
/// configured default client name. Used by `/models` and the web selector.
|
||||
async fn list_clients(&self) -> (Vec<String>, String);
|
||||
|
||||
/// Returns the client name pinned for the source, or `None` when unset
|
||||
/// (the caller should fall back to AUTO resolution).
|
||||
async fn get_selected_client(&self, source_id: &str) -> Option<String>;
|
||||
|
||||
/// Pin a client name for the source and broadcast `ClientSelected` to every
|
||||
/// client of the source. `client` must be a `list_clients()` entry (e.g.
|
||||
/// `"auto"` or a model name); the caller is responsible for validation.
|
||||
async fn set_selected_client(&self, source_id: &str, client: String);
|
||||
|
||||
/// Clear any pinned client for the source (revert to AUTO) and broadcast
|
||||
/// `ClientSelected { client: "auto" }`.
|
||||
async fn clear_selected_client(&self, source_id: &str);
|
||||
|
||||
/// Snapshot of the model list with the per-source current selection marked.
|
||||
/// Returns `(index, name, is_current)` tuples — call sites format them as
|
||||
/// HTML or Markdown without re-querying the LLM manager.
|
||||
async fn list_clients_marked(
|
||||
&self,
|
||||
source_id: &str,
|
||||
) -> Vec<(usize, String, bool)>;
|
||||
|
||||
/// Apply a `/model {arg}` command: resolve the argument, mutate the
|
||||
/// per-source pinned client (broadcasting `ClientSelected`), return a
|
||||
/// structured outcome the caller can format for its medium (HTML/Markdown).
|
||||
async fn apply_model_command(
|
||||
&self,
|
||||
source_id: &str,
|
||||
arg: &str,
|
||||
) -> ModelCommandOutcome;
|
||||
}
|
||||
|
||||
// ── Model command helpers (shared business logic) ────────────────────────────
|
||||
|
||||
/// Outcome of [`ChatHubApi::apply_model_command`]. The caller formats each
|
||||
/// variant for its medium (Telegram HTML, web Markdown, …).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModelCommandOutcome {
|
||||
/// A model was pinned. The backend has already broadcast `ClientSelected`.
|
||||
Set(String),
|
||||
/// The pin was cleared (back to AUTO). The backend has already broadcast
|
||||
/// `ClientSelected { client: "auto" }`.
|
||||
Cleared,
|
||||
/// The argument was empty, out of range, or ambiguous. Carries a
|
||||
/// user-facing message (no formatting — the caller wraps it as needed).
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Resolve a `/model` (or analogous — e.g. a future `/reasoning`) argument
|
||||
/// against an ordered list.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(Some(client))` for a unique match (caller pins it)
|
||||
/// - `Ok(None)` for `auto` / index 0 (caller clears the pin)
|
||||
/// - `Err(user_facing_message)` when the input is empty / out of range /
|
||||
/// ambiguous
|
||||
///
|
||||
/// Accepts (in order):
|
||||
/// 1. `auto` (case-insensitive) — or numeric `0` which is conventionally the
|
||||
/// "auto" slot in `client_names()`
|
||||
/// 2. Numeric index `N` → exact lookup
|
||||
/// 3. Exact case-insensitive name match
|
||||
/// 4. Substring match (case-insensitive) — must be unique
|
||||
pub fn resolve_list_arg(models: &[String], arg: &str) -> Result<Option<String>, String> {
|
||||
let arg = arg.trim();
|
||||
if arg.is_empty() {
|
||||
return Err("Usage: /model N or /model name or /model auto".to_string());
|
||||
}
|
||||
if arg.eq_ignore_ascii_case("auto") {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(n) = arg.parse::<usize>() {
|
||||
return match models.get(n) {
|
||||
Some(m) if m == "auto" => Ok(None),
|
||||
Some(m) => Ok(Some(m.clone())),
|
||||
None => Err(format!("Index {n} out of range. Use /models to see the list.")),
|
||||
};
|
||||
}
|
||||
if let Some(m) = models.iter().find(|m| m.eq_ignore_ascii_case(arg)) {
|
||||
return Ok(if m == "auto" { None } else { Some(m.clone()) });
|
||||
}
|
||||
let lower = arg.to_ascii_lowercase();
|
||||
let hits: Vec<&String> = models.iter().filter(|m| m.to_ascii_lowercase().contains(&lower)).collect();
|
||||
match hits.len() {
|
||||
1 => Ok(if hits[0] == "auto" { None } else { Some(hits[0].clone()) }),
|
||||
0 => Err(format!("No model matches '{arg}'. Use /models to see the list.")),
|
||||
_ => Err(format!(
|
||||
"Multiple models match '{arg}': {}. Be more specific.",
|
||||
hits.iter().map(|h| h.as_str()).collect::<Vec<_>>().join(", ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A single message in a conversation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Message {
|
||||
pub role: Role,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { role: Role::System, content: content.into() }
|
||||
}
|
||||
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: Role::User, content: content.into() }
|
||||
}
|
||||
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: Role::Assistant, content: content.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for a single chat completion request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatOptions {
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
/// Session/stack IDs for request logging. Set by the LLM loop; ignored by
|
||||
/// providers — only the logging wrapper reads them.
|
||||
pub session_id: Option<i64>,
|
||||
pub stack_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Raw HTTP metadata captured during a provider call.
|
||||
/// Sensitive header values (api_key) are redacted before storage.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LlmRawMeta {
|
||||
pub request_headers: Option<Value>,
|
||||
pub request_body: Option<Value>,
|
||||
pub response_headers: Option<Value>,
|
||||
pub response_body: Option<Value>,
|
||||
}
|
||||
|
||||
/// The response from a chat completion (text only).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatResponse {
|
||||
pub content: String,
|
||||
pub input_tokens: Option<u32>,
|
||||
pub output_tokens: Option<u32>,
|
||||
/// True when the model stopped due to hitting the token limit.
|
||||
pub truncated: bool,
|
||||
/// Chain-of-thought produced by reasoning models (e.g. DeepSeek thinking mode).
|
||||
/// Must be echoed back in the assistant message on subsequent turns.
|
||||
pub reasoning_content: Option<String>,
|
||||
/// Tokens served from the provider's prompt cache (Anthropic: cache_read_input_tokens,
|
||||
/// OpenAI: prompt_tokens_details.cached_tokens). None when the provider does not
|
||||
/// report cache metrics.
|
||||
pub cache_read_tokens: Option<u32>,
|
||||
/// Tokens written into the provider's prompt cache (Anthropic only:
|
||||
/// cache_creation_input_tokens). None for providers that do not expose this.
|
||||
pub cache_creation_tokens: Option<u32>,
|
||||
/// Cost of the request in USD, when the provider reports it (OpenRouter
|
||||
/// returns it under `usage.cost`). None for providers that do not bill
|
||||
/// per-request or do not expose the figure.
|
||||
pub cost: Option<f64>,
|
||||
}
|
||||
|
||||
/// A single tool call requested by the LLM.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// Result of one LLM turn when tools are available.
|
||||
#[derive(Debug)]
|
||||
pub enum LlmTurn {
|
||||
Message(ChatResponse),
|
||||
ToolCalls {
|
||||
content: String,
|
||||
calls: Vec<ToolCall>,
|
||||
input_tokens: Option<u32>,
|
||||
output_tokens: Option<u32>,
|
||||
reasoning_content: Option<String>,
|
||||
cache_read_tokens: Option<u32>,
|
||||
cache_creation_tokens: Option<u32>,
|
||||
cost: Option<f64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Stateless LLM client. Implementations hold only connection config (base URL,
|
||||
/// API key). No memory, no database, no session state.
|
||||
#[async_trait]
|
||||
pub trait ChatbotClient: Send + Sync {
|
||||
async fn chat(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
options: &ChatOptions,
|
||||
) -> anyhow::Result<ChatResponse>;
|
||||
|
||||
/// Extracts the request cost in USD from a provider's raw JSON response,
|
||||
/// when the provider reports it. OpenRouter (and other OpenAI-compatible
|
||||
/// gateways) return it under `usage.cost`; the default reads that path and
|
||||
/// yields None when absent. Providers with a different shape override this.
|
||||
fn extract_cost(&self, response: &Value) -> Option<f64> {
|
||||
response["usage"]["cost"].as_f64()
|
||||
}
|
||||
|
||||
/// Chat with tool support. Default implementation ignores tools and falls
|
||||
/// back to `chat()`.
|
||||
async fn chat_with_tools(
|
||||
&self,
|
||||
messages: &[Value],
|
||||
tools: &[Value],
|
||||
options: &ChatOptions,
|
||||
) -> anyhow::Result<LlmTurn> {
|
||||
let simple: Vec<Message> = messages
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let role = m["role"].as_str()?;
|
||||
let content = m["content"].as_str().unwrap_or("").to_string();
|
||||
match role {
|
||||
"system" => Some(Message::system(content)),
|
||||
"user" => Some(Message::user(content)),
|
||||
"assistant" => Some(Message::assistant(content)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let _ = tools;
|
||||
let resp = self.chat(&simple, options).await?;
|
||||
Ok(LlmTurn::Message(resp))
|
||||
}
|
||||
|
||||
/// Like `chat_with_tools` but also returns raw HTTP metadata for logging.
|
||||
/// Providers that make real HTTP calls should override this.
|
||||
async fn chat_with_tools_raw(
|
||||
&self,
|
||||
messages: &[Value],
|
||||
tools: &[Value],
|
||||
options: &ChatOptions,
|
||||
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
|
||||
self.chat_with_tools(messages, tools, options).await.map(|t| (t, None))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Custom slash-command abstraction shared between the main crate and plugins.
|
||||
//!
|
||||
//! `LlmCommandManager` (main crate) implements [`CommandApi`]; plugins (e.g. the
|
||||
//! Telegram bot) accept `Arc<dyn CommandApi>` and stay decoupled from the manager's
|
||||
//! concrete type and from the file-system discovery logic. Commands live in
|
||||
//! `commands/<name>/` and are read at request time (see `src/core/command/mod.rs`).
|
||||
//!
|
||||
//! This module is intentionally synchronous: command discovery is trivial file I/O
|
||||
//! over tiny files with no DB/network dependency, so it does not need the
|
||||
//! `async_trait` ceremony of the other plugin API traits.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One enabled custom command — the listing DTO (no template body). Returned by
|
||||
/// [`CommandApi::list_enabled`] for `/help` and the composer autocomplete.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandInfo {
|
||||
/// Canonical command name, without the leading `/` (e.g. `review`).
|
||||
pub name: String,
|
||||
/// One-line description shown in `/help` and the autocomplete dropdown.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// A resolved command ready for template expansion. Returned by
|
||||
/// [`CommandApi::resolve`]. `name` is canonical (lowercase, no `/`); `template` is
|
||||
/// the raw `COMMAND.md` body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedCommand {
|
||||
pub name: String,
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
/// Expand a command template by substituting the user's arguments. Replaces
|
||||
/// `{{args}}` / `{{prompt}}` with `args`; if neither placeholder is present, the
|
||||
/// arguments are appended after a `---` separator (opencode-like default). An empty
|
||||
/// `args` with no placeholder leaves the template verbatim.
|
||||
pub fn expand_template(template: &str, args: &str) -> String {
|
||||
if template.contains("{{args}}") || template.contains("{{prompt}}") {
|
||||
template.replace("{{args}}", args).replace("{{prompt}}", args)
|
||||
} else if args.is_empty() {
|
||||
template.to_string()
|
||||
} else {
|
||||
format!("{template}\n\n---\n\n{args}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstraction over the command manager that plugins depend on. The main crate's
|
||||
/// `LlmCommandManager` is the only implementation.
|
||||
pub trait CommandApi: Send + Sync {
|
||||
/// Every enabled, non-reserved command (metadata only — no template body),
|
||||
/// sorted by name. Tolerant of a missing `commands/` directory (returns empty).
|
||||
fn list_enabled(&self) -> Vec<CommandInfo>;
|
||||
|
||||
/// Resolve a command by name (case-insensitive), loading its template body.
|
||||
/// Returns `None` when the command does not exist, is disabled, or is reserved
|
||||
/// (collides with a hard-coded system command).
|
||||
fn resolve(&self, name: &str) -> Option<ResolvedCommand>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PropertyType {
|
||||
String,
|
||||
Int,
|
||||
Bool,
|
||||
SecurityGroup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigProperty {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub property_type: PropertyType,
|
||||
/// Value used when the key is absent from the DB config table.
|
||||
pub default_value: Option<String>,
|
||||
}
|
||||
|
||||
/// A named group of related [`ConfigProperty`] items, shown as a distinct
|
||||
/// section in the Config UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigSet {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub properties: Vec<ConfigProperty>,
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::message_meta::Attachment;
|
||||
|
||||
// ── Client → Server ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClientMessage {
|
||||
pub content: String,
|
||||
/// Files attached to this message (uploaded beforehand via `POST /api/{source}/uploads`).
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
/// Typed data push from remote clients (iOS app, etc.).
|
||||
/// Sent over the existing WebSocket as `{"type":"data","stream":"...","payload":{...}}`.
|
||||
#[derive(Deserialize)]
|
||||
pub struct InboundDataMessage {
|
||||
pub stream: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
// ── Global event envelope ─────────────────────────────────────────────────────
|
||||
|
||||
/// Envelope that wraps every event on the global broadcast bus.
|
||||
/// `source` is `None` for system/background events (cron, tic, plugins).
|
||||
#[derive(Clone)]
|
||||
pub struct GlobalEvent {
|
||||
pub source: Option<String>,
|
||||
pub session_id: Option<i64>,
|
||||
pub event: ServerEvent,
|
||||
}
|
||||
|
||||
// ── Server → Client ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerEvent {
|
||||
/// A tool call was started. DB status: running.
|
||||
ToolStart {
|
||||
tool_call_id: i64,
|
||||
message_id: i64,
|
||||
name: String,
|
||||
arguments: Value,
|
||||
/// Concise human-readable label (≤60 chars): tool + primary argument.
|
||||
label_short: String,
|
||||
/// Verbose human-readable label (≤120 chars): tool + all meaningful arguments.
|
||||
label_full: String,
|
||||
/// Path to a single viewable file this call targets, if any. The
|
||||
/// frontend renders it as a clickable link to the file viewer.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
},
|
||||
/// A tool call completed successfully. DB status: done.
|
||||
ToolDone {
|
||||
tool_call_id: i64,
|
||||
result: String,
|
||||
/// Result type tag: `"string"` (plain text) or `"json"` (structured
|
||||
/// payload, e.g. MCP `structuredContent`). The frontend uses it to render
|
||||
/// typed results instead of a raw text blob. Always populated by the
|
||||
/// server; the frontend treats an absent/unknown value as plain text, so
|
||||
/// older clients degrade gracefully.
|
||||
result_type: String,
|
||||
},
|
||||
/// A tool call failed. DB status: error.
|
||||
ToolError {
|
||||
tool_call_id: i64,
|
||||
error: String,
|
||||
},
|
||||
/// A tool call was stopped by the user via `/stop`. DB status: cancelled.
|
||||
/// Distinct from `ToolError`: a cancellation is deliberate, not a failure.
|
||||
ToolCancelled {
|
||||
tool_call_id: i64,
|
||||
},
|
||||
/// A tool call was denied by an approval policy or a human. DB status: rejected.
|
||||
/// Distinct from `ToolError`: a denial is a policy decision, not a failure.
|
||||
ToolRejected {
|
||||
tool_call_id: i64,
|
||||
reason: String,
|
||||
},
|
||||
/// A sub-agent stack frame was opened.
|
||||
AgentStart {
|
||||
stack_id: i64,
|
||||
parent_tool_call_id: i64,
|
||||
agent_id: String,
|
||||
parent_agent_id: String,
|
||||
depth: i64,
|
||||
/// The prompt sent to the sub-agent (truncated to 500 chars by the sender).
|
||||
prompt_preview: String,
|
||||
},
|
||||
/// A sub-agent stack frame was closed.
|
||||
AgentDone {
|
||||
stack_id: i64,
|
||||
agent_id: String,
|
||||
parent_agent_id: String,
|
||||
/// The sub-agent's final response (truncated to 500 chars by the sender).
|
||||
result_preview: String,
|
||||
},
|
||||
/// The assistant response is complete.
|
||||
Done {
|
||||
message_id: i64,
|
||||
stack_id: i64,
|
||||
content: String,
|
||||
input_tokens: Option<u32>,
|
||||
output_tokens: Option<u32>,
|
||||
},
|
||||
/// A fatal error occurred processing the request.
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
/// The LLM was cut off by the token limit (finish_reason="length").
|
||||
Truncated {
|
||||
output_tokens: Option<u32>,
|
||||
},
|
||||
/// The LLM produced text alongside tool calls (reasoning before acting).
|
||||
Thinking {
|
||||
message_id: i64,
|
||||
content: String,
|
||||
input_tokens: Option<u32>,
|
||||
output_tokens: Option<u32>,
|
||||
},
|
||||
/// A write operation requires user approval before executing (shows a diff).
|
||||
PendingWrite {
|
||||
request_id: i64,
|
||||
tool_call_id: i64,
|
||||
path: String,
|
||||
old_content: Option<String>,
|
||||
new_content: String,
|
||||
},
|
||||
/// A non-file tool call requires user approval before executing.
|
||||
/// Used for MCP tools, execute_cmd, restart, and any other tool
|
||||
/// that the ApprovalManager flags as `Require`.
|
||||
ApprovalRequired {
|
||||
request_id: i64,
|
||||
tool_call_id: i64,
|
||||
tool_name: String,
|
||||
arguments: Value,
|
||||
},
|
||||
/// A sub-agent needs clarification from the user before continuing.
|
||||
AgentQuestion {
|
||||
request_id: i64,
|
||||
tool_call_id: i64,
|
||||
title: String,
|
||||
question: String,
|
||||
suggested_answers: Vec<String>,
|
||||
},
|
||||
/// A book file was written by a tool; the frontend should reload if it has it open.
|
||||
FileChanged {
|
||||
path: String,
|
||||
},
|
||||
/// Ask the frontend to open a file for the user. Behaves like
|
||||
/// `window.openFile(path)`: navigates to the file viewer page for markdown /
|
||||
/// text / images, or opens an HTML file in a new browser tab. Emitted by
|
||||
/// the future `show_file_to_user` interface tool (not wired yet).
|
||||
OpenFile {
|
||||
path: String,
|
||||
},
|
||||
/// The active LLM model failed and the system switched to a fallback automatically.
|
||||
ModelFallback {
|
||||
from: String,
|
||||
to: String,
|
||||
reason: String,
|
||||
},
|
||||
/// All LLM fallback attempts were exhausted; the turn could not complete.
|
||||
LlmFailed {
|
||||
tried: Vec<String>,
|
||||
last_error: String,
|
||||
},
|
||||
/// A new approval entered the Inbox. Emitted on the global bus when the
|
||||
/// `ApprovalManager` registers a pending request, so bus subscribers (e.g.
|
||||
/// the mobile-connector plugin) can re-snapshot the Inbox. Distinct from
|
||||
/// `ApprovalRequired`, which is the per-session WS event carrying full args
|
||||
/// for the active client.
|
||||
ApprovalRequested {
|
||||
request_id: i64,
|
||||
tool_call_id: i64,
|
||||
tool_name: String,
|
||||
},
|
||||
/// A pending approval or pending-write was resolved (approved or rejected).
|
||||
/// Emitted on the global bus so all clients (e.g. Telegram) can update their UI.
|
||||
ApprovalResolved {
|
||||
request_id: i64,
|
||||
tool_call_id: i64,
|
||||
approved: bool,
|
||||
},
|
||||
/// A new clarification entered the Inbox. Emitted on the global bus when the
|
||||
/// `ClarificationManager` registers a pending question. Distinct from
|
||||
/// `AgentQuestion`, which is the per-session WS event for the active client.
|
||||
ClarificationRequested {
|
||||
request_id: i64,
|
||||
title: String,
|
||||
},
|
||||
/// A pending clarification was resolved (answered). Emitted on the global bus
|
||||
/// so all clients can update their Inbox view.
|
||||
ClarificationResolved {
|
||||
request_id: i64,
|
||||
},
|
||||
/// A server-initiated MCP elicitation entered the Inbox (e.g. an MCP server
|
||||
/// asking for a sudo password mid tool-call). Carries only `request_id` +
|
||||
/// `title` — never the requested value.
|
||||
ElicitationRequested {
|
||||
request_id: i64,
|
||||
title: String,
|
||||
},
|
||||
/// A pending elicitation was resolved (accepted / declined / cancelled).
|
||||
/// Emitted on the global bus so all clients can update their Inbox view.
|
||||
ElicitationResolved {
|
||||
request_id: i64,
|
||||
},
|
||||
/// The active session for a source was replaced (e.g. /new, /clear).
|
||||
NewSession {
|
||||
session_id: i64,
|
||||
},
|
||||
/// A user message was persisted to history; broadcast so every client (the
|
||||
/// sender included) renders the bubble. Emitted at save time — when the row
|
||||
/// is appended, either at turn start or at a round boundary for messages
|
||||
/// injected mid-turn — so the bubble appears exactly where the agent saw it.
|
||||
/// Clients render purely from this echo (no optimistic local rendering).
|
||||
UserMessage {
|
||||
/// Id of the `chat_history` row just written.
|
||||
message_id: i64,
|
||||
content: String,
|
||||
/// Files attached to the message; lets secondary clients render chips live.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<Attachment>,
|
||||
},
|
||||
/// Sent to a client right after it (re)connects, reporting whether a turn is
|
||||
/// currently in flight for its session. Lets a reloaded page restore the
|
||||
/// SEND→STOP button state instead of assuming idle.
|
||||
TurnRunning {
|
||||
running: bool,
|
||||
},
|
||||
/// The selected LLM client (model) for a source changed. Broadcast to every
|
||||
/// client of the source so dropdowns/selects stay in sync. `client` is a
|
||||
/// `client_names()` entry (typically `"auto"` or a model name). Driven by
|
||||
/// `ChatHub::set_selected_client` — the backend is the single source of truth.
|
||||
ClientSelected {
|
||||
client: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ServerEvent {
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string(self).expect("ServerEvent serialization failed")
|
||||
}
|
||||
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ToolStart { .. } => "tool_start",
|
||||
Self::ToolDone { .. } => "tool_done",
|
||||
Self::ToolError { .. } => "tool_error",
|
||||
Self::ToolCancelled { .. } => "tool_cancelled",
|
||||
Self::ToolRejected { .. } => "tool_rejected",
|
||||
Self::AgentStart { .. } => "agent_start",
|
||||
Self::AgentDone { .. } => "agent_done",
|
||||
Self::Done { .. } => "done",
|
||||
Self::Error { .. } => "error",
|
||||
Self::Thinking { .. } => "thinking",
|
||||
Self::PendingWrite { .. } => "pending_write",
|
||||
Self::ApprovalRequired { .. } => "approval_required",
|
||||
Self::AgentQuestion { .. } => "agent_question",
|
||||
Self::FileChanged { .. } => "file_changed",
|
||||
Self::OpenFile { .. } => "open_file",
|
||||
Self::Truncated { .. } => "truncated",
|
||||
Self::ModelFallback { .. } => "model_fallback",
|
||||
Self::LlmFailed { .. } => "llm_failed",
|
||||
Self::ApprovalRequested { .. } => "approval_requested",
|
||||
Self::ApprovalResolved { .. } => "approval_resolved",
|
||||
Self::ClarificationRequested { .. } => "clarification_requested",
|
||||
Self::ClarificationResolved { .. } => "clarification_resolved",
|
||||
Self::ElicitationRequested { .. } => "elicitation_requested",
|
||||
Self::ElicitationResolved { .. } => "elicitation_resolved",
|
||||
Self::NewSession { .. } => "new_session",
|
||||
Self::UserMessage { .. } => "user_message",
|
||||
Self::TurnRunning { .. } => "turn_running",
|
||||
Self::ClientSelected { .. } => "client_selected",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
// ── Record types (DB ↔ manager) ───────────────────────────────────────────────
|
||||
|
||||
/// Full model record, mirroring one row in `image_generate_models`.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ImageGenerateModelRecord {
|
||||
pub id: i64,
|
||||
pub provider_id: i64,
|
||||
pub model_id: String,
|
||||
/// Display alias (also used as the generator `id()`).
|
||||
pub name: String,
|
||||
/// Lower number = tried first by `get()`.
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
/// Implemented by any provider that can generate an image from a text prompt.
|
||||
/// Returns raw image bytes (PNG expected).
|
||||
#[async_trait]
|
||||
pub trait ImageGenerate: Send + Sync {
|
||||
/// A stable, unique identifier for this provider (e.g. `"comfyui-portrait"`).
|
||||
fn id(&self) -> &str;
|
||||
/// Human-readable display name.
|
||||
fn name(&self) -> &str;
|
||||
/// Human-readable description shown to the LLM in `image_generate_providers_list`.
|
||||
/// Should mention format, style, default dimensions, and ideal use cases.
|
||||
fn description(&self) -> Option<&str> { None }
|
||||
/// JSON Schema for the `extra_params` argument accepted by this provider.
|
||||
/// Returned as-is in `image_generate_providers_list` so the LLM knows what to pass.
|
||||
fn extra_params_schema(&self) -> Option<Value> { None }
|
||||
/// Generate an image. `extra_params` is the provider-specific JSON object
|
||||
/// passed by the LLM (validated against `extra_params_schema`).
|
||||
async fn generate(&self, prompt: &str, extra_params: Option<&Value>) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// Write-side of the image generator manager: register and remove ephemeral providers.
|
||||
///
|
||||
/// Implemented by `ImageGeneratorManager` in the main crate. Plugins that provide
|
||||
/// their own image generation (e.g. a ComfyUI plugin) use this to register providers
|
||||
/// at start and unregister them at stop.
|
||||
#[async_trait]
|
||||
pub trait ImageGenerateRegistry: Send + Sync {
|
||||
async fn register(&self, provider: Arc<dyn ImageGenerate>);
|
||||
async fn unregister(&self, id: &str);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Inbox API exposed to plugins.
|
||||
//!
|
||||
//! The Skald `Inbox` façade (src/core/inbox.rs) wraps the `ApprovalManager` and
|
||||
//! `ClarificationManager`. Plugins receive `Arc<dyn InboxApi>` via `PluginContext`
|
||||
//! and use it to read pending items and resolve them, without depending on the
|
||||
//! main crate. `request_id` is the integer rowid used both for resolution and
|
||||
//! for idempotency (plugin.md §2): `approve`/`reject`/`answer` on an already
|
||||
//! resolved id are no-ops.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// One pending approval, surfaced to plugins (mirrors the main crate's
|
||||
/// `PendingApprovalInfo`, trimmed to the fields a plugin needs for the
|
||||
/// `inbox_update` payload — see payloads.md §3.1).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InboxApprovalItem {
|
||||
pub request_id: i64,
|
||||
pub tool_name: String,
|
||||
/// Human-readable one-line label for the tool call, from `Tool::describe(Short)`.
|
||||
/// Used for the card / push notification text.
|
||||
pub summary: String,
|
||||
/// Raw tool arguments (untruncated). Source of truth for the detail dialog so
|
||||
/// the user sees exactly what is being approved — critical for `execute_cmd`.
|
||||
pub arguments: Value,
|
||||
pub agent_id: String,
|
||||
pub source: String,
|
||||
pub context_label: Option<String>,
|
||||
/// ISO-8601 timestamp string (UTC).
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// One pending clarification, surfaced to plugins (mirrors the main crate's
|
||||
/// `PendingClarificationInfo`).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InboxClarificationItem {
|
||||
pub request_id: i64,
|
||||
pub agent_id: String,
|
||||
pub source: String,
|
||||
pub context_label: Option<String>,
|
||||
pub title: String,
|
||||
pub question: String,
|
||||
pub suggested_answers: Vec<String>,
|
||||
/// ISO-8601 timestamp string (UTC).
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// One pending elicitation, surfaced to plugins (mirrors the main crate's
|
||||
/// `PendingElicitationInfo`). Holds only prompt metadata — never the value.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InboxElicitationItem {
|
||||
pub request_id: i64,
|
||||
pub server_name: String,
|
||||
pub message: String,
|
||||
pub field_name: Option<String>,
|
||||
pub sensitive: bool,
|
||||
pub is_confirmation: bool,
|
||||
/// ISO-8601 timestamp string (UTC).
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// A snapshot of all pending Inbox items.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InboxSnapshot {
|
||||
pub total: usize,
|
||||
pub approvals: Vec<InboxApprovalItem>,
|
||||
pub clarifications: Vec<InboxClarificationItem>,
|
||||
pub elicitations: Vec<InboxElicitationItem>,
|
||||
}
|
||||
|
||||
/// Inbox operations available to plugins.
|
||||
#[async_trait]
|
||||
pub trait InboxApi: Send + Sync {
|
||||
/// Snapshot of all currently pending approvals + clarifications.
|
||||
async fn list_pending(&self) -> InboxSnapshot;
|
||||
|
||||
/// Approve a pending tool-call request. No-op if already resolved.
|
||||
async fn approve(&self, request_id: i64);
|
||||
|
||||
/// Reject a pending tool-call request with a reason. No-op if already resolved.
|
||||
async fn reject(&self, request_id: i64, reason: String);
|
||||
|
||||
/// Answer a pending clarification. Returns `true` if a pending entry was
|
||||
/// found and resolved, `false` otherwise (idempotent).
|
||||
async fn answer(&self, request_id: i64, answer: String) -> bool;
|
||||
|
||||
/// Resolve a pending MCP elicitation. `action` is `"accept"`/`"decline"`/
|
||||
/// `"cancel"`; `content` carries the field values for `accept` (it may hold
|
||||
/// a secret — do not log it). Returns `true` if a pending entry was found.
|
||||
async fn resolve_elicitation(&self, request_id: i64, action: String, content: Option<Value>) -> bool;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Future returned by an [`InterfaceTool`] handler.
|
||||
pub type ToolFuture = Pin<Box<dyn std::future::Future<Output = anyhow::Result<String>> + Send>>;
|
||||
|
||||
/// A single LLM-callable tool injected by a specific interface (Telegram, Web, Cron, …).
|
||||
///
|
||||
/// The handler closure captures interface-specific state (e.g. `Arc<Bot>` + `ChatId`).
|
||||
pub struct InterfaceTool {
|
||||
/// OpenAI-format tool definition sent to the LLM in the tools array.
|
||||
pub definition: Value,
|
||||
/// Async handler invoked when the LLM calls this tool.
|
||||
pub handler: Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>,
|
||||
}
|
||||
|
||||
impl InterfaceTool {
|
||||
/// Returns the tool's name as declared in the definition.
|
||||
pub fn name(&self) -> &str {
|
||||
self.definition["function"]["name"].as_str().unwrap_or("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// Application name, sent as `X-Title` HTTP header to LLM/image/audio providers.
|
||||
pub const APP_NAME: &str = "Skald";
|
||||
|
||||
pub mod approval;
|
||||
pub mod bus;
|
||||
pub mod system_bus;
|
||||
pub mod chatbot;
|
||||
pub mod chat_hub;
|
||||
pub mod command;
|
||||
pub mod events;
|
||||
pub mod image_generate;
|
||||
pub mod inbox;
|
||||
pub mod interface_tool;
|
||||
pub mod location;
|
||||
pub mod memory;
|
||||
pub mod message_meta;
|
||||
pub mod plugin;
|
||||
pub mod provider;
|
||||
pub mod remote;
|
||||
pub mod tool;
|
||||
pub mod secrets;
|
||||
pub mod transcribe;
|
||||
pub mod tts;
|
||||
pub mod config_property;
|
||||
pub use config_property::{ConfigProperty, ConfigSet, PropertyType};
|
||||
@@ -0,0 +1,69 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GpsCoord {
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocationEntry {
|
||||
pub coord: GpsCoord,
|
||||
pub accuracy: Option<f64>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub is_live: bool,
|
||||
}
|
||||
|
||||
pub struct LocationManager {
|
||||
locations: RwLock<HashMap<String, LocationEntry>>,
|
||||
}
|
||||
|
||||
impl LocationManager {
|
||||
pub fn new() -> Self {
|
||||
Self { locations: RwLock::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
pub fn update(&self, source: &str, coord: GpsCoord, accuracy: Option<f64>, is_live: bool) {
|
||||
let entry = LocationEntry { coord, accuracy, updated_at: Utc::now(), is_live };
|
||||
self.locations.write().unwrap().insert(source.to_string(), entry);
|
||||
}
|
||||
|
||||
pub fn get(&self, source: &str) -> Option<LocationEntry> {
|
||||
self.locations.read().unwrap().get(source).cloned()
|
||||
}
|
||||
|
||||
pub fn latest(&self) -> Option<(String, LocationEntry)> {
|
||||
self.locations.read().unwrap()
|
||||
.iter()
|
||||
.max_by_key(|(_, e)| e.updated_at)
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<(String, LocationEntry)> {
|
||||
self.locations.read().unwrap()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocationManager {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
/// Write-only view of a location store.
|
||||
///
|
||||
/// Plugins store `Arc<dyn LocationUpdater>` so they can report GPS fixes
|
||||
/// without depending on the concrete `LocationManager` struct.
|
||||
pub trait LocationUpdater: Send + Sync {
|
||||
fn update(&self, source: &str, coord: GpsCoord, accuracy: Option<f64>, is_live: bool);
|
||||
}
|
||||
|
||||
impl LocationUpdater for LocationManager {
|
||||
fn update(&self, source: &str, coord: GpsCoord, accuracy: Option<f64>, is_live: bool) {
|
||||
LocationManager::update(self, source, coord, accuracy, is_live);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::tool::Tool;
|
||||
|
||||
/// Pluggable long-term memory backend.
|
||||
///
|
||||
/// Implementations are registered with `MemoryManager` in the main crate.
|
||||
/// At most one backend is active at a time (singleton rule enforced by the manager).
|
||||
#[async_trait]
|
||||
pub trait Memory: Send + Sync {
|
||||
/// Unique identifier for this backend (e.g. `"honcho"`).
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// Returns `true` when the backend is reachable and ready.
|
||||
fn is_available(&self) -> bool;
|
||||
|
||||
/// Retrieves context for the upcoming turn to inject into the system prompt.
|
||||
/// Returns `None` on cold start, backend down, or nothing useful available.
|
||||
async fn query_context(&self, session_id: i64, user_message: &str) -> Option<String>;
|
||||
|
||||
/// Optional LLM-callable tools exposed by this backend (e.g. `memory_query`).
|
||||
/// Called per turn — added to the live tool list and dispatched before the
|
||||
/// global tool registry.
|
||||
fn tools(&self) -> Vec<Arc<dyn Tool>> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Structured, reusable metadata attached to a `chat_history` row.
|
||||
//!
|
||||
//! Persisted as a single JSON column (`chat_history.metadata`) and intentionally
|
||||
//! generic: today it carries user file **attachments**, but new keys can be added
|
||||
//! later without a schema change. Two independent readers derive different views
|
||||
//! from the same source:
|
||||
//! - the **LLM context** builder appends [`attachments_block`] to the user turn,
|
||||
//! - the **history UI** renders the structured attachments as chips.
|
||||
//!
|
||||
//! The raw `[SYSTEM INFO]` text block is therefore never persisted — it is
|
||||
//! generated on the fly from this metadata.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One file attached by the user to a message. `path` is relative to the project
|
||||
/// root (e.g. `data/uploads/123/file.pdf`) so it is both servable under `/data/…`
|
||||
/// and resolvable by the filesystem tools.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Attachment {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
/// Best-effort MIME type (e.g. `application/pdf`); `None` if unknown.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mimetype: Option<String>,
|
||||
/// Size in bytes, when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub filesize: Option<u64>,
|
||||
}
|
||||
|
||||
/// Generic metadata bag for a chat message. Extra keys may be added over time;
|
||||
/// `#[serde(default)]` keeps deserialization tolerant of older/newer shapes.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MessageMetadata {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub attachments: Vec<Attachment>,
|
||||
/// Present when this user turn was produced by a custom slash command.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<CommandRef>,
|
||||
}
|
||||
|
||||
impl MessageMetadata {
|
||||
/// True when there is nothing worth persisting.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.attachments.is_empty() && self.command.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies a user message produced by a custom slash command. The history row's
|
||||
/// `content` holds the **expanded template** (replayed to the LLM verbatim); the UI
|
||||
/// renders `display` — the original `/command …` the user typed — instead.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommandRef {
|
||||
/// Canonical command name, without the leading `/` (e.g. `review`).
|
||||
pub name: String,
|
||||
/// The original text the user typed (e.g. `/review revisiona Cat.java`).
|
||||
pub display: String,
|
||||
}
|
||||
|
||||
/// Renders the human-readable block appended to a user turn so the LLM learns
|
||||
/// which files were attached. Returns an empty string when there are none, so
|
||||
/// callers can unconditionally concatenate it.
|
||||
///
|
||||
/// Shared by the web/mobile path and the Telegram plugin so every surface emits
|
||||
/// an identical format.
|
||||
pub fn attachments_block(attachments: &[Attachment]) -> String {
|
||||
if attachments.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let noun = if attachments.len() == 1 { "file" } else { "files" };
|
||||
let mut block = format!(
|
||||
"\n\n[SYSTEM INFO]\n{} attached {}:",
|
||||
attachments.len(),
|
||||
noun
|
||||
);
|
||||
for a in attachments {
|
||||
block.push_str(&format!("\n* {}", a.path));
|
||||
}
|
||||
block
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::approval::ApprovalApi;
|
||||
use crate::bus::ChatEventBus;
|
||||
use crate::command::CommandApi;
|
||||
use crate::system_bus::SystemEventBus;
|
||||
use crate::chat_hub::ChatHubApi;
|
||||
use crate::image_generate::ImageGenerateRegistry;
|
||||
use crate::inbox::InboxApi;
|
||||
use crate::location::LocationUpdater;
|
||||
use crate::memory::Memory;
|
||||
use crate::provider::ApiProviderRegistry;
|
||||
use crate::remote::RemoteAccess;
|
||||
use crate::secrets::SecretsApi;
|
||||
use crate::transcribe::{TranscribeProvider, TranscribeRegistry};
|
||||
use crate::tts::{TtsProvider, TtsRegistry};
|
||||
|
||||
/// Closure that builds a fresh Axum router (e.g. for the mesh-facing server).
|
||||
pub type RouterFactory = Arc<dyn Fn() -> axum::Router + Send + Sync>;
|
||||
|
||||
/// All deps a plugin may need — passed to [`Plugin::start`] and [`Plugin::reload`].
|
||||
///
|
||||
/// Fields are `Arc<dyn Trait>` sourced from `core-api`. Plugins use only the
|
||||
/// fields relevant to them; unused fields are ignored.
|
||||
/// `router_factory` and `remote_slot` are networking-specific — used only by
|
||||
/// `RemotePlugin`.
|
||||
#[derive(Clone)]
|
||||
pub struct PluginContext {
|
||||
pub chat_hub: Arc<dyn ChatHubApi>,
|
||||
/// Custom file-based slash commands (`commands/<name>/`). Read-only from the
|
||||
/// plugin side — lets the Telegram bot resolve `/command` expansions.
|
||||
pub command: Arc<dyn CommandApi>,
|
||||
pub approval: Arc<dyn ApprovalApi>,
|
||||
/// Unified Inbox façade (approvals + clarifications). See plugin.md §12.2.
|
||||
pub inbox: Arc<dyn InboxApi>,
|
||||
/// Skald's shared SQLite pool — lets plugins create/use their own tables
|
||||
/// (e.g. `relay_*`) in the main DB. See plugin.md §12.1.
|
||||
pub db: Arc<sqlx::SqlitePool>,
|
||||
pub secrets: Arc<dyn SecretsApi>,
|
||||
pub transcribe: Arc<dyn TranscribeProvider>,
|
||||
pub transcribe_registry: Arc<dyn TranscribeRegistry>,
|
||||
pub image_generate_registry: Arc<dyn ImageGenerateRegistry>,
|
||||
pub tts_registry: Arc<dyn TtsRegistry>,
|
||||
pub tts_provider: Arc<dyn TtsProvider>,
|
||||
pub api_provider_registry: Arc<dyn ApiProviderRegistry>,
|
||||
pub location: Arc<dyn LocationUpdater>,
|
||||
pub event_bus: Arc<ChatEventBus>,
|
||||
pub system_bus: Arc<SystemEventBus>,
|
||||
pub web_port: u16,
|
||||
pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
|
||||
pub router_factory: RouterFactory,
|
||||
}
|
||||
|
||||
/// Plugin lifecycle contract.
|
||||
///
|
||||
/// Each plugin implements this trait. The `PluginManager` in the main crate
|
||||
/// manages their lifecycle and passes a `PluginContext` on every start/reload.
|
||||
#[async_trait]
|
||||
pub trait Plugin: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn name(&self) -> &str;
|
||||
fn description(&self) -> &str;
|
||||
fn is_running(&self) -> bool;
|
||||
|
||||
/// JSON Schema describing the plugin's config fields.
|
||||
fn config_schema(&self) -> Value { serde_json::json!({}) }
|
||||
|
||||
/// Called whenever the enabled flag or config changes — including at startup.
|
||||
/// The plugin is responsible for diffing state and restarting only what changed.
|
||||
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()>;
|
||||
|
||||
async fn start(&self, ctx: PluginContext) -> Result<()>;
|
||||
async fn stop(&self) -> Result<()>;
|
||||
|
||||
/// Runtime state surfaced to the UI and to agents (e.g. mesh IP).
|
||||
fn runtime_status(&self) -> Option<Value> { None }
|
||||
|
||||
/// Optional Axum router contributed by the plugin. When `Some`, the main
|
||||
/// `WebFrontend` nests it under `/api/plugin/<id>/` behind Skald's normal
|
||||
/// auth (plugin.md §12.3). The router must close over the plugin's own state
|
||||
/// (it receives no `State`). Default: no routes — existing plugins are
|
||||
/// unaffected.
|
||||
fn http_router(&self) -> Option<axum::Router> { None }
|
||||
|
||||
/// Returns a [`Memory`] backend if this plugin provides one.
|
||||
fn memory(&self) -> Option<Arc<dyn Memory>> { None }
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any;
|
||||
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync>;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::chatbot::ChatbotClient;
|
||||
use crate::image_generate::{ImageGenerate, ImageGenerateModelRecord};
|
||||
use crate::tts::{TextToSpeech, TtsModelRecord, RemoteTtsModelInfo};
|
||||
use crate::transcribe::{Transcribe, TranscribeModelRecord, RemoteTranscribeModelInfo};
|
||||
|
||||
// ── LlmStrength ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LlmStrength {
|
||||
VeryLow,
|
||||
Low,
|
||||
Average,
|
||||
High,
|
||||
VeryHigh,
|
||||
}
|
||||
|
||||
// ── Provider record types (DB ↔ manager) ──────────────────────────────────────
|
||||
|
||||
/// Full provider record (includes secrets — only expose over trusted local connections).
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LlmProviderRecord {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Provider type_id string as stored in DB (e.g. "open_ai", "anthropic").
|
||||
#[serde(rename = "type")]
|
||||
pub provider: String,
|
||||
pub api_key: Option<String>,
|
||||
/// Only used by ollama and lm_studio.
|
||||
pub base_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Full model record.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LlmModelRecord {
|
||||
pub id: i64,
|
||||
pub provider_id: i64,
|
||||
pub model_id: String,
|
||||
pub name: String,
|
||||
pub strength: Option<LlmStrength>,
|
||||
pub scope: Vec<String>,
|
||||
pub is_default: bool,
|
||||
pub priority: i32,
|
||||
pub extra_params: Option<serde_json::Value>,
|
||||
pub context_length: Option<i64>,
|
||||
pub max_output_tokens: Option<i64>,
|
||||
pub knowledge_cutoff: Option<String>,
|
||||
pub capabilities: Vec<String>,
|
||||
/// Selected reasoning value (interpreted per provider via `ReasoningMode`):
|
||||
/// a JSON string for a `ValueSet` (e.g. `"high"`, `"enabled"`) or a JSON
|
||||
/// number for a `Range` (e.g. `8000`). `None` = reasoning off / unset.
|
||||
pub reasoning: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Remote model info returned by a provider's `list_llm_models()`.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct RemoteLlmModelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub context_length: Option<u64>,
|
||||
pub max_completion_tokens: Option<u64>,
|
||||
pub knowledge_cutoff: Option<String>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub vision: Option<bool>,
|
||||
pub price_input_per_million: Option<f64>,
|
||||
pub price_output_per_million: Option<f64>,
|
||||
/// Reasoning control descriptor for this model, if it supports reasoning.
|
||||
/// Filled by the API layer from `ApiProvider::reasoning_mode`; providers'
|
||||
/// `list_llm_models` may leave it `None`.
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<ReasoningMode>,
|
||||
}
|
||||
|
||||
// ── ReasoningMode ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Describes how a model exposes its "reasoning"/"thinking" knob, so the UI can
|
||||
/// render the right control and the caller knows the space of valid values.
|
||||
/// Provider-owned (each `ApiProvider` returns it per model); the actual request
|
||||
/// translation is done by `ApiProvider::reasoning_request`.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ReasoningMode {
|
||||
/// A fixed set of discrete string choices — e.g. `["low","medium","high"]`
|
||||
/// (effort levels) or `["disabled","enabled"]` (on/off toggle).
|
||||
ValueSet {
|
||||
values: Vec<String>,
|
||||
default: Option<String>,
|
||||
},
|
||||
/// A continuous numeric range — e.g. Anthropic thinking `budget_tokens`.
|
||||
Range {
|
||||
min: i64,
|
||||
max: i64,
|
||||
step: Option<i64>,
|
||||
default: Option<i64>,
|
||||
unit: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ── ServiceType ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceType {
|
||||
Llm,
|
||||
Transcribe,
|
||||
ImageGenerate,
|
||||
Tts,
|
||||
}
|
||||
|
||||
// ── UI metadata ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ProviderUiMeta {
|
||||
pub type_id: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub description: Option<&'static str>,
|
||||
pub color: &'static str,
|
||||
pub icon: &'static str,
|
||||
pub fields: &'static [ProviderField],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ProviderField {
|
||||
pub key: &'static str,
|
||||
pub label: &'static str,
|
||||
pub required: bool,
|
||||
pub secret: bool,
|
||||
}
|
||||
|
||||
// ── BuiltLlmClient ────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct BuiltLlmClient {
|
||||
pub client: Arc<dyn ChatbotClient>,
|
||||
pub prompt_cache: bool,
|
||||
}
|
||||
|
||||
// ── ApiProvider trait ─────────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
pub trait ApiProvider: Send + Sync {
|
||||
/// Short stable identifier stored in the DB (e.g. "open_ai", "anthropic").
|
||||
fn type_id(&self) -> &'static str;
|
||||
fn display_name(&self) -> &'static str;
|
||||
fn supported_types(&self) -> &'static [ServiceType];
|
||||
|
||||
async fn list_llm_models(
|
||||
&self,
|
||||
_record: &LlmProviderRecord,
|
||||
) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Reasoning control descriptor for a given model, or `None` if the model
|
||||
/// does not support reasoning. Drives the UI control (a `ValueSet` dropdown
|
||||
/// or a numeric `Range`). Provider-owned so each provider decides which of
|
||||
/// its models support reasoning and in what form.
|
||||
fn reasoning_mode(
|
||||
&self,
|
||||
_model_id: &str,
|
||||
_capabilities: &[String],
|
||||
) -> Option<ReasoningMode> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Translates a selected reasoning `value` (see `LlmModelRecord::reasoning`)
|
||||
/// into the provider-specific JSON fragment to merge into the request body
|
||||
/// (e.g. `{"reasoning":{"effort":"high"}}` or `{"thinking":{"type":"enabled"}}`).
|
||||
/// `None` = nothing to inject.
|
||||
fn reasoning_request(
|
||||
&self,
|
||||
_value: &serde_json::Value,
|
||||
) -> Option<serde_json::Value> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn llm_model_info(
|
||||
&self,
|
||||
_record: &LlmProviderRecord,
|
||||
_model_id: &str,
|
||||
) -> Result<Option<RemoteLlmModelInfo>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list_tts_models(
|
||||
&self,
|
||||
_record: &LlmProviderRecord,
|
||||
) -> Result<Option<Vec<RemoteTtsModelInfo>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list_transcribe_models(
|
||||
&self,
|
||||
_record: &LlmProviderRecord,
|
||||
) -> Result<Option<Vec<RemoteTranscribeModelInfo>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn build_llm(
|
||||
&self,
|
||||
_record: &LlmProviderRecord,
|
||||
_model: &LlmModelRecord,
|
||||
) -> Option<Result<BuiltLlmClient>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn build_tts(
|
||||
&self,
|
||||
_record: &LlmProviderRecord,
|
||||
_model: &TtsModelRecord,
|
||||
) -> Option<Result<Arc<dyn TextToSpeech>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn build_transcriber(
|
||||
&self,
|
||||
_record: &LlmProviderRecord,
|
||||
_model: &TranscribeModelRecord,
|
||||
) -> Option<Result<Arc<dyn Transcribe>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn build_image_generator(
|
||||
&self,
|
||||
_record: &LlmProviderRecord,
|
||||
_model: &ImageGenerateModelRecord,
|
||||
) -> Option<Result<Arc<dyn ImageGenerate>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn ui_meta(&self) -> ProviderUiMeta;
|
||||
}
|
||||
|
||||
// ── ApiProviderRegistry trait ─────────────────────────────────────────────────
|
||||
|
||||
/// Write-side of the provider registry: register and remove plugin-provided
|
||||
/// `ApiProvider` implementations at runtime.
|
||||
///
|
||||
/// Implemented by `ProviderRegistry` in the main crate. Plugins that supply
|
||||
/// their own API provider (e.g. `plugin-elevenlabs`) use this to register at
|
||||
/// start and unregister at stop.
|
||||
#[async_trait]
|
||||
pub trait ApiProviderRegistry: Send + Sync {
|
||||
fn register_plugin(&self, provider: Arc<dyn ApiProvider>);
|
||||
fn unregister_plugin(&self, type_id: &str);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Abstraction over a mesh/remote-connectivity provider.
|
||||
/// The core (AppState, plugins) talks only to this trait —
|
||||
/// no Tailscale-specific types leak outside the implementation.
|
||||
#[async_trait]
|
||||
pub trait RemoteAccess: Send + Sync {
|
||||
/// Short provider name used in logs and status fields (e.g. `"tailscale"`).
|
||||
fn provider_name(&self) -> &str;
|
||||
|
||||
/// IP address of this node on the mesh network.
|
||||
async fn device_ip(&self) -> Result<Ipv4Addr>;
|
||||
|
||||
/// True once the provider has joined the mesh and obtained an IP.
|
||||
fn is_connected(&self) -> bool;
|
||||
|
||||
/// Graceful shutdown — disconnect from the mesh and release resources.
|
||||
async fn shutdown(&self);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Full CRUD interface to the secrets store.
|
||||
///
|
||||
/// Implemented by `SecretsStore` in the main crate. Both plugins (via
|
||||
/// `PluginContext`) and agent tools (via `AppState`) receive an
|
||||
/// `Arc<dyn SecretsApi>` so neither needs to depend on the main crate.
|
||||
///
|
||||
/// Security notes:
|
||||
/// - Values should never appear in log output.
|
||||
/// - Keys are safe to log/list.
|
||||
/// - Storage is SQLite — same protection level as the rest of the DB.
|
||||
#[async_trait]
|
||||
pub trait SecretsApi: Send + Sync {
|
||||
/// Returns the value for `key`, or `None` if not set.
|
||||
async fn get(&self, key: &str) -> Option<String>;
|
||||
|
||||
/// Inserts or replaces the secret for `key`.
|
||||
async fn set(&self, key: &str, value: &str) -> Result<()>;
|
||||
|
||||
/// Removes the secret for `key`. No-op if not present.
|
||||
async fn delete(&self, key: &str) -> Result<()>;
|
||||
|
||||
/// Returns all stored keys (never values).
|
||||
async fn list_keys(&self) -> Vec<String>;
|
||||
}
|
||||
|
||||
/// Resolves a required secret, returning a descriptive error if absent.
|
||||
pub async fn require(secrets: &Arc<dyn SecretsApi>, key: &str) -> Result<String> {
|
||||
secrets.get(key).await.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"secret '{}' is not set — tell the agent: \"set the secret {} to <value>\"",
|
||||
key, key,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! In-process event bus for system-level lifecycle events.
|
||||
//!
|
||||
//! Distinct from [`crate::bus::ChatEventBus`] which carries chat-turn events.
|
||||
//! This bus carries infrastructure events: provider registration, plugin state
|
||||
//! changes, etc. Any component can subscribe and react without direct coupling.
|
||||
//!
|
||||
//! # Usage
|
||||
//! ```rust,ignore
|
||||
//! // Producer (e.g. a plugin):
|
||||
//! bus.send(SystemEvent::ApiProviderRegistered { type_id: "elevenlabs".into() });
|
||||
//!
|
||||
//! // Consumer (spawn once at startup):
|
||||
//! let mut rx = bus.subscribe();
|
||||
//! tokio::spawn(async move {
|
||||
//! loop {
|
||||
//! match rx.recv().await {
|
||||
//! Ok(SystemEvent::ApiProviderRegistered { type_id }) => { /* reload */ }
|
||||
//! Ok(SystemEvent::ApiProviderUnregistered { type_id }) => { /* reload */ }
|
||||
//! Err(RecvError::Lagged(n)) => warn!("system_bus lagged by {n}"),
|
||||
//! Err(RecvError::Closed) => break,
|
||||
//! }
|
||||
//! }
|
||||
//! });
|
||||
//! ```
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
const DEFAULT_CAPACITY: usize = 64;
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// All system-level events that flow through the [`SystemEventBus`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SystemEvent {
|
||||
/// A plugin registered a new `ApiProvider` (e.g. ElevenLabs on plugin start).
|
||||
ApiProviderRegistered { type_id: String },
|
||||
/// A plugin unregistered an `ApiProvider` (e.g. on plugin stop/disable).
|
||||
ApiProviderUnregistered { type_id: String },
|
||||
/// A config key was changed via the API (only fires when the value actually changes).
|
||||
ConfigKeyUpdated { key: String, old_value: Option<String>, new_value: String },
|
||||
/// A scheduled job finished (success or failure). `origin_ref` is the opaque
|
||||
/// string stored in `scheduled_jobs.origin_ref` (e.g. `"PROJECT_TASK:42"`).
|
||||
JobCompleted {
|
||||
job_id: i64,
|
||||
origin_ref: Option<String>,
|
||||
result: Option<String>,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// A session was forcibly cancelled (e.g. via the kill-task API).
|
||||
/// Subscribers should cancel any in-flight LLM turn, pending approvals,
|
||||
/// and pending clarifications for that session.
|
||||
SessionCancelled {
|
||||
session_id: i64,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Bus ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SystemEventBus {
|
||||
tx: broadcast::Sender<SystemEvent>,
|
||||
}
|
||||
|
||||
impl SystemEventBus {
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(DEFAULT_CAPACITY)
|
||||
}
|
||||
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// Publish an event. No-op if there are no active subscribers.
|
||||
pub fn send(&self, event: SystemEvent) {
|
||||
let _ = self.tx.send(event);
|
||||
}
|
||||
|
||||
/// Returns a new independent receiver. Each subscriber gets every future
|
||||
/// event independently. If the subscriber falls behind by more than the
|
||||
/// channel capacity it receives `RecvError::Lagged(n)`.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<SystemEvent> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SystemEventBus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
// ── ToolDescriptionLength ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolDescriptionLength {
|
||||
Short,
|
||||
Full,
|
||||
}
|
||||
|
||||
// ── ToolCategory ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Logical grouping for a tool.
|
||||
///
|
||||
/// Used for access-control filtering and for display/audit purposes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCategory {
|
||||
/// Read or write files on disk.
|
||||
Filesystem,
|
||||
/// Run shell commands or restart the process.
|
||||
Shell,
|
||||
/// Invoke sub-agents via call_agent.
|
||||
Subagent,
|
||||
/// Read-only discovery of system state.
|
||||
Introspection,
|
||||
/// Mutate system configuration.
|
||||
Config,
|
||||
}
|
||||
|
||||
// ── Tool trait ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single LLM-callable tool.
|
||||
pub trait Tool: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn description(&self) -> &str;
|
||||
|
||||
/// Human-readable label for this tool invocation shown in UI / notifications.
|
||||
fn describe(&self, _args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
self.name().to_string()
|
||||
}
|
||||
|
||||
/// If this invocation targets a single file the user can open in the file
|
||||
/// viewer, return its path (relative to the project root, or absolute).
|
||||
/// Tools that target a directory (list/grep) or no file at all return
|
||||
/// `None`. The frontend renders the returned path as a clickable link.
|
||||
fn target_path(&self, _args: &Value) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// JSON Schema for the `parameters` field in the OpenAI function definition.
|
||||
fn parameters_schema(&self) -> Value;
|
||||
|
||||
/// Execute the tool synchronously and return a plain-text result (or error string).
|
||||
/// Tools that require async I/O should override `execute_async` instead.
|
||||
fn execute(&self, _args: Value) -> Result<String> {
|
||||
Err(anyhow::anyhow!("tool '{}': sync execute not implemented — use execute_async", self.name()))
|
||||
}
|
||||
|
||||
/// Execute the tool asynchronously. The default wraps `execute`; async tools
|
||||
/// (e.g. image generation) override this directly to avoid `block_in_place`.
|
||||
fn execute_async<'a>(&'a self, args: Value) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
|
||||
Box::pin(async move { self.execute(args) })
|
||||
}
|
||||
|
||||
/// Execute and produce a typed [`ToolResult`]. The default bridges
|
||||
/// [`execute_async`](Self::execute_async) (plain string) into [`ToolResult::Text`],
|
||||
/// so existing tools need no changes. Override to return [`ToolResult::Json`]
|
||||
/// for tools with structured output (e.g. MCP tools exposing `structuredContent`).
|
||||
fn execute_typed<'a>(&'a self, args: Value)
|
||||
-> Pin<Box<dyn Future<Output = Result<ToolResult>> + Send + 'a>> {
|
||||
Box::pin(async move { Ok(ToolResult::Text(self.execute_async(args).await?)) })
|
||||
}
|
||||
|
||||
/// Start a single execution of this tool and return a live [`ToolExecution`]
|
||||
/// handle. This is the entry point the session driver uses: it lets the tool
|
||||
/// own its in-flight state and implement its own `stop()` (e.g. ComfyUI sends
|
||||
/// an `/interrupt`; `execute_cmd` relies on `kill_on_drop`).
|
||||
///
|
||||
/// The default wraps `execute_typed` in a [`SimpleExecution`], whose `stop()`
|
||||
/// drops the work future — already enough to make `/stop` responsive for any
|
||||
/// I/O-bound tool. Tools needing remote/child teardown override this and
|
||||
/// return their own `ToolExecution` with a bespoke `stop()`.
|
||||
fn run<'a>(&'a self, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
Box::new(SimpleExecution::new(self.execute_typed(args)))
|
||||
}
|
||||
|
||||
/// Logical category of this tool.
|
||||
fn category(&self) -> ToolCategory;
|
||||
|
||||
/// If true, this tool is only included in the tool list for sub-agents (depth > 0).
|
||||
fn sub_agents_only(&self) -> bool { false }
|
||||
|
||||
/// If true, this tool is only included in the tool list for the root agent (depth == 0).
|
||||
fn root_agent_only(&self) -> bool { false }
|
||||
|
||||
/// If true, this tool is only available to interactive sessions (web, telegram, mobile, voice).
|
||||
/// Non-interactive background sessions (cron, tic) will not receive this tool definition.
|
||||
fn interactive_only(&self) -> bool { false }
|
||||
|
||||
/// Full OpenAI-format tool definition ready to be sent to the LLM.
|
||||
fn openai_definition(&self) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name(),
|
||||
"description": self.description(),
|
||||
"parameters": self.parameters_schema(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── ToolExecutionState ────────────────────────────────────────────────────────
|
||||
|
||||
/// Lifecycle state of a single tool execution.
|
||||
///
|
||||
/// Richer than the persisted `chat_llm_tools.status` string: it distinguishes a
|
||||
/// user `/stop` (`Cancelled`) and a policy/human denial (`Rejected`) from a real
|
||||
/// tool error (`Failed`). The session driver owns the approval-phase states
|
||||
/// (`Pending`, `AwaitingApproval`, `Rejected`); a [`ToolExecution`] itself only
|
||||
/// ever reports `Running → Completed | Failed | Cancelled`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolExecutionState {
|
||||
/// Intent recorded, not yet started (transient; not persisted on its own).
|
||||
Pending,
|
||||
/// Blocked waiting for a human approval / clarification answer.
|
||||
AwaitingApproval,
|
||||
/// Actively executing.
|
||||
Running,
|
||||
/// Finished successfully.
|
||||
Completed,
|
||||
/// Finished with a tool/runtime error.
|
||||
Failed,
|
||||
/// Stopped by the user via `/stop` — not an error.
|
||||
Cancelled,
|
||||
/// Denied by an approval policy or a human — not an error.
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl ToolExecutionState {
|
||||
/// String persisted in `chat_llm_tools.status`. `AwaitingApproval` maps to the
|
||||
/// legacy `pending` value so existing resume logic keeps working; the brand-new
|
||||
/// `Pending` state is never persisted (the row is created on the first real
|
||||
/// transition) and defaults to `running` defensively.
|
||||
pub fn as_db_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "running",
|
||||
Self::AwaitingApproval => "pending",
|
||||
Self::Running => "running",
|
||||
Self::Completed => "done",
|
||||
Self::Failed => "failed",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ToolResult ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The typed result of a successful tool execution.
|
||||
///
|
||||
/// Most tools produce plain text ([`ToolResult::Text`], persisted as
|
||||
/// `result_type = "string"`). Tools with structured output — notably MCP servers
|
||||
/// returning `structuredContent` — produce [`ToolResult::Json`] (persisted as
|
||||
/// `result_type = "json"`), so the host/frontend can render the typed payload
|
||||
/// instead of a raw text blob. At the LLM wire both variants become the same
|
||||
/// `{"role":"tool","content": <string>}` bytes (see [`ToolResult::to_wire`]); the
|
||||
/// type tag only matters to the host.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ToolResult {
|
||||
/// Plain-text result. The default for every built-in tool.
|
||||
Text(String),
|
||||
/// Structured JSON result (e.g. MCP `structuredContent`).
|
||||
Json(serde_json::Value),
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Tag persisted in `chat_llm_tools.result_type` and sent over the WS as
|
||||
/// `ServerEvent::ToolDone.result_type`. Either `"string"` or `"json"`.
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Text(_) => "string",
|
||||
Self::Json(_) => "json",
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire content for the LLM tool message: text as-is, Json serialized to a
|
||||
/// compact JSON string. Both OpenAI and Anthropic encode tool results as
|
||||
/// text/JSON, so this is the canonical string form persisted in
|
||||
/// `chat_llm_tools.result` and replayed by the message builder.
|
||||
pub fn to_wire(&self) -> String {
|
||||
match self {
|
||||
Self::Text(s) => s.clone(),
|
||||
Self::Json(v) => serde_json::to_string(v).unwrap_or_else(|_| "null".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolResult {
|
||||
fn from(s: String) -> Self { Self::Text(s) }
|
||||
}
|
||||
|
||||
impl From<&str> for ToolResult {
|
||||
fn from(s: &str) -> Self { Self::Text(s.to_string()) }
|
||||
}
|
||||
|
||||
// ── ExecutionOutcome ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Terminal outcome produced by [`ToolExecution::wait`]. A running execution can
|
||||
/// only end in one of these three ways; `Rejected`/`AwaitingApproval` are decided
|
||||
/// by the approval gate *before* the work runs and never appear here.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ExecutionOutcome {
|
||||
Completed(ToolResult),
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ExecutionOutcome {
|
||||
pub fn state(&self) -> ToolExecutionState {
|
||||
match self {
|
||||
Self::Completed(_) => ToolExecutionState::Completed,
|
||||
Self::Failed(_) => ToolExecutionState::Failed,
|
||||
Self::Cancelled => ToolExecutionState::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A boxed, owned unit of asynchronous tool work producing a typed [`ToolResult`].
|
||||
/// This is what [`Tool::execute_typed`] returns; [`SimpleExecution`] wraps one.
|
||||
pub type ToolWork<'a> = Pin<Box<dyn Future<Output = Result<ToolResult>> + Send + 'a>>;
|
||||
|
||||
// ── ToolExecution ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single, live execution of a [`Tool`]. Owns its in-memory state and decides
|
||||
/// how it stops. Pure: it never touches the DB or the WebSocket — the session
|
||||
/// driver mirrors its state transitions to persistence and transport.
|
||||
///
|
||||
/// `Send + Sync` is required because the driver shares `&self` across the two
|
||||
/// concurrent branches of the cancellation race (`wait` vs `stop`).
|
||||
pub trait ToolExecution: Send + Sync {
|
||||
/// Current in-memory lifecycle state.
|
||||
fn state(&self) -> ToolExecutionState;
|
||||
|
||||
/// Drive the work to its terminal outcome. Called exactly once by the driver.
|
||||
fn wait<'a>(&'a self) -> Pin<Box<dyn Future<Output = ExecutionOutcome> + Send + 'a>>;
|
||||
|
||||
/// Tool-specific cancellation: signal the work to stop and tear down any
|
||||
/// remote/child resources. The default relies on the driver dropping the
|
||||
/// `wait` future; [`SimpleExecution`] overrides it to cancel its stop-token.
|
||||
fn stop<'a>(&'a self) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
}
|
||||
|
||||
// ── SimpleExecution ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Default [`ToolExecution`] for any tool that is a single async unit of work.
|
||||
///
|
||||
/// Holds the work future plus a stop-token; `wait` races the two, so a `stop()`
|
||||
/// (or the driver dropping `wait`) drops the work future — aborting the in-flight
|
||||
/// I/O (a `reqwest` connection, a `kill_on_drop` child, …). Enough to make
|
||||
/// `/stop` responsive for every I/O-bound tool with zero per-tool code.
|
||||
pub struct SimpleExecution<'a> {
|
||||
state: Mutex<ToolExecutionState>,
|
||||
stop: CancellationToken,
|
||||
work: tokio::sync::Mutex<Option<ToolWork<'a>>>,
|
||||
}
|
||||
|
||||
impl<'a> SimpleExecution<'a> {
|
||||
pub fn new(work: ToolWork<'a>) -> Self {
|
||||
Self {
|
||||
state: Mutex::new(ToolExecutionState::Running),
|
||||
stop: CancellationToken::new(),
|
||||
work: tokio::sync::Mutex::new(Some(work)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ToolExecution for SimpleExecution<'a> {
|
||||
fn state(&self) -> ToolExecutionState {
|
||||
*self.state.lock().unwrap()
|
||||
}
|
||||
|
||||
fn wait<'b>(&'b self) -> Pin<Box<dyn Future<Output = ExecutionOutcome> + Send + 'b>> {
|
||||
Box::pin(async move {
|
||||
let work = self.work.lock().await.take();
|
||||
let Some(work) = work else {
|
||||
// Already consumed (e.g. a second wait after cancellation).
|
||||
return ExecutionOutcome::Cancelled;
|
||||
};
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
_ = self.stop.cancelled() => ExecutionOutcome::Cancelled,
|
||||
r = work => match r {
|
||||
Ok(s) => ExecutionOutcome::Completed(s),
|
||||
Err(e) => ExecutionOutcome::Failed(e.to_string()),
|
||||
},
|
||||
};
|
||||
*self.state.lock().unwrap() = outcome.state();
|
||||
outcome
|
||||
})
|
||||
}
|
||||
|
||||
fn stop<'b>(&'b self) -> Pin<Box<dyn Future<Output = ()> + Send + 'b>> {
|
||||
Box::pin(async move { self.stop.cancel(); })
|
||||
}
|
||||
}
|
||||
|
||||
// ── drive_execution ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Run a [`ToolExecution`] to completion while honouring a cancellation token.
|
||||
///
|
||||
/// When `cancel` fires we call `exec.stop()` once (tool-specific teardown); the
|
||||
/// execution then resolves `wait` to `Cancelled`. Both methods take `&self`, so
|
||||
/// the two concurrent borrows are shared and the borrow checker is happy.
|
||||
pub async fn drive_execution(
|
||||
exec: &dyn ToolExecution,
|
||||
cancel: &CancellationToken,
|
||||
) -> ExecutionOutcome {
|
||||
let work = exec.wait();
|
||||
tokio::pin!(work);
|
||||
|
||||
let mut stopped = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
outcome = &mut work => return outcome,
|
||||
_ = cancel.cancelled(), if !stopped => {
|
||||
exec.stop().await;
|
||||
stopped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Truncates a label to `max` chars, appending `…` if cut.
|
||||
pub fn truncate_label(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let cut = max.saturating_sub(1);
|
||||
let mut end = cut;
|
||||
while !s.is_char_boundary(end) { end -= 1; }
|
||||
format!("{}…", &s[..end])
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
// ── Record types (DB ↔ manager) ───────────────────────────────────────────────
|
||||
|
||||
/// Full model record, mirroring one row in `transcribe_models`.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TranscribeModelRecord {
|
||||
pub id: i64,
|
||||
pub provider_id: i64,
|
||||
pub model_id: String,
|
||||
/// Display alias (also used as the transcriber `id()`).
|
||||
pub name: String,
|
||||
/// BCP-47 language hint, e.g. `"it"`. `None` → auto-detect.
|
||||
pub language: Option<String>,
|
||||
/// Lower number = tried first by `get()`.
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
/// Remote model info returned by a provider's `list_transcribe_models()`.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct RemoteTranscribeModelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// BCP-47 language codes supported by this model (empty = unknown).
|
||||
pub languages: Vec<String>,
|
||||
}
|
||||
|
||||
/// Implemented by any provider that can convert audio bytes to text.
|
||||
/// The `format` hint (e.g. `"ogg"`, `"mp3"`) is advisory.
|
||||
#[async_trait]
|
||||
pub trait Transcribe: Send + Sync {
|
||||
/// A stable, unique identifier for this provider (e.g. `"whisper_local"`).
|
||||
fn id(&self) -> &str;
|
||||
async fn transcribe(&self, audio: Vec<u8>, format: &str) -> anyhow::Result<String>;
|
||||
}
|
||||
|
||||
/// Resolves the currently active [`Transcribe`] provider.
|
||||
///
|
||||
/// Implemented by `TranscribeManager` in the main crate. Plugins store
|
||||
/// `Arc<dyn TranscribeProvider>` so they can resolve the active transcriber
|
||||
/// per-call without holding a reference to `AppState`.
|
||||
#[async_trait]
|
||||
pub trait TranscribeProvider: Send + Sync {
|
||||
async fn get(&self) -> Option<Arc<dyn Transcribe>>;
|
||||
}
|
||||
|
||||
/// Write-side of the transcribe manager: register and remove ephemeral providers.
|
||||
///
|
||||
/// Implemented by `TranscribeManager`. Plugins that provide their own STT
|
||||
/// (e.g. `WhisperLocalPlugin`) use this to register themselves at start and
|
||||
/// unregister at stop.
|
||||
#[async_trait]
|
||||
pub trait TranscribeRegistry: Send + Sync {
|
||||
async fn register(&self, provider: Arc<dyn Transcribe>);
|
||||
async fn unregister(&self, id: &str);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
// ── Record types (DB ↔ manager) ───────────────────────────────────────────────
|
||||
|
||||
/// Full model record, mirroring one row in `tts_models`.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TtsModelRecord {
|
||||
pub id: i64,
|
||||
pub provider_id: i64,
|
||||
pub model_id: String,
|
||||
/// Voice/speaker identifier, if the provider requires it separately from the model.
|
||||
pub voice_id: Option<String>,
|
||||
/// Display alias (also used as the synthesiser `id()`).
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Default voice instructions (tone, speed, style).
|
||||
pub instructions: Option<String>,
|
||||
/// Audio container/codec requested from the provider (`response_format`):
|
||||
/// `mp3`, `opus`, `aac`, `flac`, `wav`, `pcm`. `None` ⇒ provider default (`mp3`).
|
||||
/// Some models only accept a specific value (e.g. Gemini TTS requires `pcm`).
|
||||
pub response_format: Option<String>,
|
||||
/// Lower number = tried first by `get()`.
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
/// Remote model info returned by a provider's `list_tts_models()`.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct RemoteTtsModelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// BCP-47 language codes supported by this model (empty = unknown).
|
||||
pub languages: Vec<String>,
|
||||
/// Cost multiplier relative to the provider's base rate (1.0 = standard).
|
||||
pub cost_factor: Option<f64>,
|
||||
/// Usage instructions: supported tags, markup, etc. Shown in UI and passed
|
||||
/// to the LLM when generating text destined for this synthesiser.
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
|
||||
/// Implemented by any provider that can convert text to audio bytes.
|
||||
/// Returns raw audio bytes (MP3 expected unless the provider states otherwise).
|
||||
#[async_trait]
|
||||
pub trait TextToSpeech: Send + Sync {
|
||||
/// A stable, unique identifier for this provider (e.g. `"openai_tts_alloy"`).
|
||||
fn id(&self) -> &str;
|
||||
/// Human-readable display name.
|
||||
fn name(&self) -> &str;
|
||||
/// Human-readable description (voice style, language, ideal use cases).
|
||||
fn description(&self) -> Option<&str> { None }
|
||||
/// Default synthesis instructions: voice style, tone, speed, and any
|
||||
/// provider-specific text markup syntax (e.g. emotion tags).
|
||||
/// Surfaced to the LLM via `TtsModelInfo` so it knows how to format input text.
|
||||
/// Individual call-time instructions passed to `synthesize` take precedence.
|
||||
fn instructions(&self) -> Option<&str> { None }
|
||||
/// Audio format (container/codec) of the bytes returned by `synthesize`,
|
||||
/// e.g. `mp3`, `opus`, `wav`, `pcm`. Consumers that require a specific
|
||||
/// container (e.g. Telegram voice messages need Ogg/Opus) use this to decide
|
||||
/// whether and how to transcode. Default `"mp3"`.
|
||||
fn output_format(&self) -> &str { "mp3" }
|
||||
/// Synthesise `text` to audio bytes.
|
||||
/// `instructions` overrides the provider's default instructions for this call only.
|
||||
async fn synthesize(&self, text: &str, instructions: Option<&str>) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// Resolves the currently active [`TextToSpeech`] provider.
|
||||
///
|
||||
/// Implemented by `TtsManager` in the main crate. Plugins store
|
||||
/// `Arc<dyn TtsProvider>` to resolve the active synthesiser per-call
|
||||
/// without holding a reference to `AppState`.
|
||||
#[async_trait]
|
||||
pub trait TtsProvider: Send + Sync {
|
||||
async fn get(&self) -> Option<Arc<dyn TextToSpeech>>;
|
||||
}
|
||||
|
||||
/// Write-side of the TTS manager: register and remove ephemeral providers.
|
||||
///
|
||||
/// Implemented by `TtsManager`. Plugins that supply their own TTS engine
|
||||
/// (e.g. a local Kokoro or Piper plugin) use this to register at start
|
||||
/// and unregister at stop.
|
||||
#[async_trait]
|
||||
pub trait TtsRegistry: Send + Sync {
|
||||
async fn register(&self, provider: Arc<dyn TextToSpeech>);
|
||||
async fn unregister(&self, id: &str);
|
||||
}
|
||||
Reference in New Issue
Block a user