Files
Skald-Circle/crates/core-api/src/chat_hub.rs
T
dguiducci 24ee5b89d7
Nightly Build / build (push) Successful in 6m49s
agent-loop: projection, recovery, compaction into the crate (phase 3)
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).
2026-07-26 17:09:01 +01:00

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(", ")
)),
}
}