Nightly Build / build (push) Successful in 6m49s
The session handler is now a thin shell: three entry points in kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call) and the ChatSessionHandler. Everything that shaped a Value — projection, recovery, compaction mechanics, the LLM loop, message building — lives in agent-loop or behind a loop_adapters trait. agent-loop: - projection/ (mod + media): stored history -> wire messages, the one place provider divergence lives; well-formedness contract, DTL injections (append-only), media parts. LinearAssembler is now a Projection + ProjectionHooks config, not its own implementation - recovery.rs: reap interrupted batches -> resolve the deepest frame's non-terminal calls (Running by policy + RestartHint, AwaitingHuman re-asked) -> un-wedge finished children -> cascade up, every frame on its own agent (B3) - compaction.rs: split point (never assistant+tool group), transcript, SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row - manager: resolve_pending (gate skipped, real ToolContext, then continue incl. sub-agent); start_loop used by recovery; LiveInput - delegate: AsyncExecutor + StoreSink for mode:async (durable cron row, result delivered back into the parent conversation) - kernel/context/store: support the above (TurnScope via Extensions, frame lookups, aligned result-text semantics) skald-core: - loop_adapters: UserLoopRuntime (D12 - one LoopManager per user), TurnScope (per-turn state in the Extensions type-map; no scope is denied), projection_cfg/media_source/tool_digest (Skald's projection knobs without owning projection code), async_task (CronExecutor + DurableSink) - session/handler: stripped to mod.rs + kernel_turn.rs + config.rs + interface_tools.rs + media.rs; deleted agent_dispatch, approval, dispatch, emitter, gate, llm_call, llm_loop, message_builder, messages, outcome, resume - compactor.rs: policy only (threshold, model pick, CompactionEvent); mechanics are the crate's CLAUDE.md updated (recovery, compaction, sub-agents, approval gate, projection sections now describe the crate-owned flow).
219 lines
10 KiB
Rust
219 lines
10 KiB
Rust
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::{Attachment, MessageMetadata};
|
|
|
|
// ── SendMessageOptions ────────────────────────────────────────────────────────
|
|
|
|
/// Optional parameters for a [`ChatHubApi::send_message`] call.
|
|
#[derive(Default)]
|
|
pub struct SendMessageOptions {
|
|
/// Agent to use for this source's session. When unset, falls back to the
|
|
/// hub's owner-resolved default entry agent (the caller's role `attrs.chat_agent`,
|
|
/// else `DEFAULT_CHAT_AGENT`). 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 projection and the 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<()>;
|
|
|
|
/// Persist an uploaded file for `source_id` into the owner's
|
|
/// `~/uploads/{session}/` and return its [`Attachment`] (home-relative agent
|
|
/// path). Channel adapters (e.g. the Telegram plugin) call this instead of
|
|
/// writing files themselves, so the core owns *where* uploads land and every
|
|
/// surface produces a path the agent can actually reach. The recognized
|
|
/// magic-byte MIME wins over the caller-claimed `client_mime`.
|
|
async fn save_upload(
|
|
&self,
|
|
source_id: &str,
|
|
file_name: &str,
|
|
client_mime: Option<String>,
|
|
bytes: &[u8],
|
|
) -> anyhow::Result<Attachment>;
|
|
|
|
/// 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(", ")
|
|
)),
|
|
}
|
|
}
|