feat(users): UserManager with per-user SQLCipher, and extract skald-core crate
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.
## UserManager + per-user encryption (§9/§11)
New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.
New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).
- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
<0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
`create_owner_tables` (one owner's content, identical in every file). No FK in
the owner bucket may reach the registry — enforced by a standalone test.
Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
so a crash leaves an orphan file, never a user without a database. `open_db`
never creates: a missing file is an error, not a silent empty database.
Not consumed yet: no login, call sites still use the shared system.db pool.
## Extract crates/skald-core
The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:
- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
(sibling of `http_router`), so the core no longer downcasts to
`MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
teardown-and-respawn; the core defaults to the supervisor exit code. The core
loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
only emits tracing events.
All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use tracing::{error, info, trace, warn};
|
||||
|
||||
use crate::approval::ApprovalManager;
|
||||
use crate::run_context::RunContext;
|
||||
use crate::tools::tool_names as tn;
|
||||
use crate::chat_event_bus::{ChatEvent, ChatEventBus, ChatEventRole};
|
||||
use crate::clarification::ClarificationManager;
|
||||
use crate::compactor::ContextCompactor;
|
||||
use crate::config::DatetimeConfig;
|
||||
use crate::db::{chat_history, chat_sessions_stack};
|
||||
use crate::events::ServerEvent;
|
||||
use core_api::message_meta::MessageMetadata;
|
||||
use crate::llm::LlmManager;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::image_generate::ImageGeneratorManager;
|
||||
use crate::memory::MemoryManager;
|
||||
use crate::tool_discovery::ToolDiscovery;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
mod approval;
|
||||
mod agent_dispatch;
|
||||
mod config;
|
||||
mod dispatch;
|
||||
mod emitter;
|
||||
mod gate;
|
||||
mod interface_tools;
|
||||
mod llm_call;
|
||||
mod llm_loop;
|
||||
pub mod message_builder;
|
||||
mod messages;
|
||||
mod outcome;
|
||||
mod resume;
|
||||
|
||||
use emitter::TurnEmitter;
|
||||
|
||||
pub use interface_tools::{InterfaceTool, ToolFuture};
|
||||
|
||||
pub const DEFAULT_MAX_TOOL_ROUNDS: usize = 20;
|
||||
|
||||
/// Default maximum number of synchronous sub-agents dispatched concurrently when
|
||||
/// the LLM emits a homogeneous batch of sub-agent calls in a single response.
|
||||
/// Bounds fan-out so a large batch does not trigger provider rate-limit storms.
|
||||
pub const DEFAULT_MAX_PARALLEL_SUBAGENTS: usize = 4;
|
||||
|
||||
pub(super) const MAX_AGENT_DEPTH: i64 = 5;
|
||||
|
||||
/// A queued user message to be appended to history mid-turn (drained from the
|
||||
/// source inbox at a round boundary).
|
||||
pub struct PendingMsg {
|
||||
pub content: String,
|
||||
pub metadata: Option<MessageMetadata>,
|
||||
}
|
||||
|
||||
/// Source of queued user input for the in-flight turn. Implemented by `ChatHub`
|
||||
/// over a source's inbox; it lets `run_agent_turn` pull newly-queued user
|
||||
/// messages at each round boundary and inject them live into the running turn.
|
||||
///
|
||||
/// Passed as `Some` only for the root interactive turn. Sub-agents, resume, and
|
||||
/// non-interactive runners (cron, TIC) pass `None` — they never inject.
|
||||
#[async_trait]
|
||||
pub trait PendingUserInput: Send + Sync {
|
||||
/// Drains the leading run of queued non-synthetic user messages, one entry
|
||||
/// each. Returns empty when there is nothing to inject.
|
||||
async fn drain_user(&self) -> Vec<PendingMsg>;
|
||||
}
|
||||
|
||||
/// Control-flow signals returned as `anyhow::Error` by internal dispatch methods.
|
||||
/// Using a typed enum instead of two separate sentinel structs allows a single
|
||||
/// `downcast_ref` in `llm_loop` instead of two separate type checks.
|
||||
#[derive(Debug)]
|
||||
pub(super) enum AgentFlowSignal {
|
||||
/// The WS disconnected while `dispatch_ask_user_clarification` was blocking.
|
||||
/// The tool stays `'pending'` in DB so `resume_pending_tools` can re-ask on reconnect.
|
||||
QuestionChannelClosed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentFlowSignal {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::QuestionChannelClosed => write!(f, "question channel closed (WS disconnected)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AgentFlowSignal {}
|
||||
|
||||
pub(super) enum TurnOutcome {
|
||||
Final {
|
||||
content: String,
|
||||
message_id: i64,
|
||||
input_tokens: Option<u32>,
|
||||
output_tokens: Option<u32>,
|
||||
truncated: bool,
|
||||
/// All tool calls executed during this turn, across all rounds.
|
||||
tool_calls: Vec<crate::chat_event_bus::ToolCallEvent>,
|
||||
},
|
||||
Cancelled,
|
||||
Exhausted,
|
||||
}
|
||||
|
||||
/// Truncate `s` to at most `max_chars` characters, appending `…` when it was
|
||||
/// longer. Char-boundary safe: a raw `&s[..n]` byte slice panics when byte `n`
|
||||
/// lands inside a multi-byte UTF-8 character (e.g. an em-dash or emoji straddling
|
||||
/// the cut point), which is exactly how a well-formed sub-agent result once
|
||||
/// unwound a whole turn. Used for every event/log preview.
|
||||
pub(super) fn preview_truncate(s: &str, max_chars: usize) -> String {
|
||||
match s.char_indices().nth(max_chars) {
|
||||
Some((byte_idx, _)) => format!("{}…", &s[..byte_idx]),
|
||||
None => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_scratchpad_tool_def() -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tn::UPDATE_SCRATCHPAD,
|
||||
"description": "Write or update a key-value note in the session scratchpad. \
|
||||
Notes are shared by all agents in this chat session and automatically \
|
||||
injected into every agent's context. Not persisted across sessions. \
|
||||
Use it for temporary discoveries: architecture notes, path lookups, \
|
||||
decisions that other agents in this session need to know about.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string", "description": "Short identifier for this note (e.g. 'db_url', 'main_struct')." },
|
||||
"value": { "type": "string", "description": "Content of the note." }
|
||||
},
|
||||
"required": ["key", "value"]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Tool definition for `write_todos` — a private, per-turn task list the agent
|
||||
/// uses to plan and track its own progress.
|
||||
///
|
||||
/// Unlike `update_scratchpad` (a shared blackboard injected into every agent in
|
||||
/// the session), `write_todos` is **stateless**: the list lives only in this
|
||||
/// agent's own tool-result history. Because conversation history is per-stack,
|
||||
/// it is never visible to sub-agents or to the caller — no DB storage needed.
|
||||
/// The agent re-sends the whole list (TodoWrite-style) on every update.
|
||||
pub(super) fn write_todos_tool_def() -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tn::WRITE_TODOS,
|
||||
"description": "Record and update your task list for the current turn, to plan multi-step \
|
||||
work and track progress. Re-send the ENTIRE list on every call (including \
|
||||
already-completed items with their new status) — this replaces the previous \
|
||||
list. Keep exactly one item `in_progress` at a time. This list is PRIVATE \
|
||||
to you: it is not shared with sub-agents you dispatch, nor returned to your \
|
||||
caller (use `update_scratchpad` instead for notes other agents must see).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"description": "The full, ordered task list. Re-send it entirely on every update.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": { "type": "string", "description": "Short description of the task." },
|
||||
"status": { "type": "string", "enum": ["pending", "in_progress", "completed"], "description": "Current status of this task." }
|
||||
},
|
||||
"required": ["content", "status"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["todos"]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Tool definition that lets a sub-agent (depth > 0) dispatch a further
|
||||
/// synchronous sub-agent. The call is intercepted in `run_agent_turn` and routed
|
||||
/// to `dispatch_sub_agent` (the InterfaceTool handler is never reached), so only
|
||||
/// the definition is needed here. `agent_id` is required because
|
||||
/// `dispatch_sub_agent` rejects calls without it.
|
||||
fn execute_subtask_tool_def() -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tn::EXECUTE_SUBTASK,
|
||||
"description": "Delegate work to another agent and get its result. Runs the \
|
||||
named agent synchronously with the given prompt and blocks until \
|
||||
it finishes, returning its final answer as the tool result. Use \
|
||||
`list_agents` first to see which agents are available.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": { "type": "string", "description": "Id of the agent to run (see `list_agents`)." },
|
||||
"title": { "type": "string", "description": "Short name for this sub-task." },
|
||||
"description": { "type": "string", "description": "What this sub-task does." },
|
||||
"prompt": { "type": "string", "description": "Prompt sent to the agent." }
|
||||
},
|
||||
"required": ["agent_id", "prompt"]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ask_user_clarification_tool_def() -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tn::ASK_USER_CLARIFICATION,
|
||||
"description": "Pause execution and ask the user a clarification question. \
|
||||
Use when requirements are ambiguous, a dependency is missing, \
|
||||
or a decision requires user input before continuing. \
|
||||
The user's answer is returned as the tool result.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string", "description": "Short label shown in the inbox card (e.g. 'Missing API key')." },
|
||||
"question": { "type": "string", "description": "Full question text." },
|
||||
"suggested_answers": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional list of suggested answers shown as chips. The user can pick one or type freely."
|
||||
}
|
||||
},
|
||||
"required": ["title", "question"]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
pub enum ApprovalDecision {
|
||||
Approved,
|
||||
Rejected { note: String },
|
||||
}
|
||||
|
||||
impl ApprovalDecision {
|
||||
/// Canonical tool-result text shown to the LLM for a human rejection,
|
||||
/// given the raw user-supplied note (which may be empty). This is the
|
||||
/// single source of truth: every reject path passes the raw note and lets
|
||||
/// this build the message, so the wording stays consistent and the note
|
||||
/// carries the user's justification verbatim — no surface-specific prefixes.
|
||||
pub fn rejection_message(note: &str) -> String {
|
||||
let note = note.trim();
|
||||
if note.is_empty() {
|
||||
"User rejected this tool call.".to_string()
|
||||
} else {
|
||||
format!("User rejected this tool call. Reason: {note}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChatSessionHandler {
|
||||
pub session_id: i64,
|
||||
pub(super) db: Arc<SqlitePool>,
|
||||
pub(super) llm_manager: Arc<LlmManager>,
|
||||
pub(super) max_history_messages: usize,
|
||||
pub(super) max_tool_rounds: usize,
|
||||
/// Max synchronous sub-agents dispatched concurrently for a homogeneous batch
|
||||
/// of sub-agent calls in a single LLM response (`1` = sequential).
|
||||
pub(super) max_parallel_subagents: usize,
|
||||
/// If `Some(n)`, tool results from previous turns that exceed `n` characters
|
||||
/// are replaced with a placeholder when building the LLM context.
|
||||
/// The database always retains the original content.
|
||||
pub(super) max_tool_result_chars: Option<usize>,
|
||||
pub(super) datetime_config: DatetimeConfig,
|
||||
pub(super) agent_id: String,
|
||||
/// Source of the session: "web", "telegram", "cron", etc.
|
||||
pub(super) source: String,
|
||||
/// True when a real user is actively participating (web, telegram).
|
||||
pub(super) is_interactive: bool,
|
||||
/// True for short-lived automated sessions (cron, tic).
|
||||
pub(super) is_ephemeral: bool,
|
||||
pub(super) tools: Arc<ToolRegistry>,
|
||||
pub(super) mcp: Arc<McpManager>,
|
||||
/// Records tools offered to the LLM each round so the Security-groups UI can
|
||||
/// list/gate dynamically-injected tools (interface/plugin/provider tools).
|
||||
pub(super) tool_discovery: Arc<ToolDiscovery>,
|
||||
pub(super) approval: Arc<ApprovalManager>,
|
||||
pub(super) clarification: Arc<ClarificationManager>,
|
||||
pub(super) event_bus: Arc<ChatEventBus>,
|
||||
/// Human-readable label injected by background runners (e.g. "CronJob: Daily Digest").
|
||||
pub(super) context_label: std::sync::RwLock<Option<String>>,
|
||||
pub(super) memory_manager: Arc<MemoryManager>,
|
||||
pub(super) image_generator_manager: Arc<ImageGeneratorManager>,
|
||||
/// Prevents concurrent handle_message calls on the same session.
|
||||
pub(super) processing: Mutex<()>,
|
||||
/// Cancellation scope for the in-flight turn. A fresh token is minted per
|
||||
/// user message (`handle_message`) and per resume (`resume_turn`), then a
|
||||
/// clone is threaded by value through the whole (possibly recursive) call
|
||||
/// tree. `cancel()` cancels whatever token is currently stored, which the
|
||||
/// running chain observes because it holds its own clone of that same token.
|
||||
/// Replacing the field only affects the *next* turn — that is what makes a
|
||||
/// stop sticky across sub-agent recursion (it is never reset mid-turn).
|
||||
pub(super) current_cancel: std::sync::Mutex<CancellationToken>,
|
||||
/// When true, any tool call that would require human approval is automatically
|
||||
/// denied instead of blocking. Used by TicManager and other headless runners
|
||||
/// that cannot process approval requests.
|
||||
pub(super) auto_deny_approvals: AtomicBool,
|
||||
/// Tool-call ids the user already approved via a resolve endpoint after a restart
|
||||
/// (no live oneshot to unblock). The next resume's approval gate skips re-gating
|
||||
/// these so a post-restart approve dispatches the tool without a second prompt.
|
||||
pub(super) pre_approved: std::sync::Mutex<std::collections::HashSet<i64>>,
|
||||
/// Context compactor, shared across all sessions. `None` when compaction
|
||||
/// is disabled (no `compaction` section in config).
|
||||
pub(super) compactor: Option<Arc<ContextCompactor>>,
|
||||
/// Input token count from the most recently completed turn, stored
|
||||
/// atomically so the next `handle_message` call can decide whether to
|
||||
/// compact before processing the new message. Zero means unknown
|
||||
/// (provider did not report usage on the first turn).
|
||||
pub(super) last_input_tokens: AtomicU32,
|
||||
/// Active RunContext for this session. `None` means the "default" group is used implicitly.
|
||||
pub(super) run_context: tokio::sync::RwLock<Option<RunContext>>,
|
||||
/// When set, scratchpad reads/writes use this session_id instead of `self.session_id`.
|
||||
/// Used by async sub-tasks to share the parent's scratchpad.
|
||||
pub(super) scratchpad_session_id: std::sync::OnceLock<i64>,
|
||||
}
|
||||
|
||||
impl ChatSessionHandler {
|
||||
pub fn new(
|
||||
session_id: i64,
|
||||
db: Arc<SqlitePool>,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
max_history_messages: usize,
|
||||
max_tool_rounds: usize,
|
||||
max_parallel_subagents: usize,
|
||||
max_tool_result_chars: Option<usize>,
|
||||
datetime_config: DatetimeConfig,
|
||||
agent_id: String,
|
||||
source: String,
|
||||
is_interactive: bool,
|
||||
is_ephemeral: bool,
|
||||
tools: Arc<ToolRegistry>,
|
||||
mcp: Arc<McpManager>,
|
||||
approval: Arc<ApprovalManager>,
|
||||
clarification: Arc<ClarificationManager>,
|
||||
event_bus: Arc<ChatEventBus>,
|
||||
memory_manager: Arc<MemoryManager>,
|
||||
image_generator_manager: Arc<ImageGeneratorManager>,
|
||||
compactor: Option<Arc<ContextCompactor>>,
|
||||
run_context: Option<RunContext>,
|
||||
tool_discovery: Arc<ToolDiscovery>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
db,
|
||||
llm_manager,
|
||||
max_history_messages,
|
||||
max_tool_rounds,
|
||||
max_parallel_subagents,
|
||||
max_tool_result_chars,
|
||||
datetime_config,
|
||||
agent_id,
|
||||
source,
|
||||
is_interactive,
|
||||
is_ephemeral,
|
||||
tools,
|
||||
mcp,
|
||||
tool_discovery,
|
||||
approval,
|
||||
clarification,
|
||||
event_bus,
|
||||
memory_manager,
|
||||
image_generator_manager,
|
||||
compactor,
|
||||
context_label: std::sync::RwLock::new(None),
|
||||
processing: Mutex::new(()),
|
||||
current_cancel: std::sync::Mutex::new(CancellationToken::new()),
|
||||
auto_deny_approvals: AtomicBool::new(false),
|
||||
pre_approved: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||
last_input_tokens: AtomicU32::new(0),
|
||||
run_context: tokio::sync::RwLock::new(run_context),
|
||||
scratchpad_session_id: std::sync::OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the human-readable context label for this session (e.g. "CronJob: Daily Digest").
|
||||
/// Called by background runners after the handler is created.
|
||||
pub fn set_context_label(&self, label: impl Into<String>) {
|
||||
if let Ok(mut g) = self.context_label.write() {
|
||||
*g = Some(label.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the session used for scratchpad reads/writes.
|
||||
/// Called by the cron runner for async tasks so they share the parent's scratchpad.
|
||||
pub fn set_scratchpad_session_id(&self, id: i64) {
|
||||
let _ = self.scratchpad_session_id.set(id);
|
||||
}
|
||||
|
||||
/// Returns the session_id to use for scratchpad operations.
|
||||
pub(super) fn scratchpad_sid(&self) -> i64 {
|
||||
*self.scratchpad_session_id.get().unwrap_or(&self.session_id)
|
||||
}
|
||||
|
||||
/// Updates the active RunContext for this session at runtime.
|
||||
pub async fn set_run_context(&self, ctx: Option<RunContext>) {
|
||||
*self.run_context.write().await = ctx;
|
||||
}
|
||||
|
||||
/// Returns the serialised JSON blob of the active RunContext (for storing on child tasks).
|
||||
pub async fn run_context_json(&self) -> Option<String> {
|
||||
self.run_context.read().await.as_ref().map(|rc| rc.to_db())
|
||||
}
|
||||
|
||||
/// Returns the active tool_permission_groups id for approval checks.
|
||||
pub(super) async fn tool_group_id(&self) -> Option<String> {
|
||||
self.run_context.read().await.as_ref().and_then(|rc| rc.tool_group_id().map(str::to_owned))
|
||||
}
|
||||
|
||||
/// Cancels the in-flight turn. The running call tree holds its own clone of
|
||||
/// the same token, so it stops at the next round boundary, on the in-flight
|
||||
/// LLM call, and on cancellable tools (e.g. `execute_cmd`). Sticky across
|
||||
/// sub-agent recursion: the token is never reset mid-turn.
|
||||
pub fn cancel(&self) {
|
||||
self.current_cancel.lock().unwrap().cancel();
|
||||
}
|
||||
|
||||
/// True if a turn is currently in flight (the `processing` mutex is held for
|
||||
/// the whole duration of `handle_message` / `resume_turn`). Used to tell a
|
||||
/// freshly (re)connected client to show the STOP button.
|
||||
pub fn is_processing(&self) -> bool {
|
||||
self.processing.try_lock().is_err()
|
||||
}
|
||||
|
||||
/// When set, any tool call that would require human approval is automatically
|
||||
/// denied instead of blocking indefinitely.
|
||||
pub fn set_auto_deny_approvals(&self) {
|
||||
self.auto_deny_approvals.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records that the user already approved this tool_call via a resolve endpoint
|
||||
/// after a restart (no live oneshot to unblock). The next resume's approval gate
|
||||
/// consumes this and skips re-gating, so the tool dispatches without re-prompting.
|
||||
pub fn mark_pre_approved(&self, tool_call_id: i64) {
|
||||
self.pre_approved.lock().unwrap().insert(tool_call_id);
|
||||
}
|
||||
|
||||
/// Cancels all pending approvals for this session in the ApprovalManager.
|
||||
/// Called when the WS connection is lost mid-approval so the waiting future unblocks.
|
||||
pub async fn cancel_pending_approvals(&self) {
|
||||
self.approval.cancel_for_session(self.session_id).await;
|
||||
}
|
||||
|
||||
/// Resolves a pending `ask_user_clarification` call with the user's answer.
|
||||
pub async fn resolve_question(&self, request_id: i64, answer: String) {
|
||||
if !self.clarification.resolve(request_id, answer).await {
|
||||
warn!(session_id = self.session_id, request_id, "resolve_question: request_id not found in ClarificationManager");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels all pending clarification requests for this session (WS disconnected).
|
||||
/// The blocked `rx.await` in dispatch_ask_user_clarification returns Err → TurnOutcome::Cancelled,
|
||||
/// leaving the tool as 'pending' so resume_pending_tools re-dispatches on reconnect.
|
||||
pub async fn cancel_pending_questions(&self) {
|
||||
self.clarification.cancel_for_session(self.session_id).await;
|
||||
}
|
||||
|
||||
/// Force compaction of the current stack's conversation history.
|
||||
/// Bypasses the token threshold check; still respects the ephemeral guard.
|
||||
/// Returns `true` if a new summary was written, `false` if skipped.
|
||||
pub async fn force_compact(&self) -> anyhow::Result<bool> {
|
||||
let pool = &self.db;
|
||||
let stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
|
||||
Some(s) => s,
|
||||
None => return Ok(false),
|
||||
};
|
||||
match self.compactor {
|
||||
Some(ref compactor) => {
|
||||
compactor.force_compact(pool, self.session_id, stack.id, self.is_ephemeral).await
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes a user message end-to-end:
|
||||
/// saves it, runs the tool-calling loop, saves the final response,
|
||||
/// sends a Done event. Only one call can run at a time per session.
|
||||
pub async fn handle_message(
|
||||
&self,
|
||||
content: &str,
|
||||
client_name: Option<String>,
|
||||
extra_system_context: Option<String>,
|
||||
// Per-turn dynamic system suffix injected AFTER conversation history.
|
||||
// Merged with the Honcho memory context (which also lives at position 5).
|
||||
// Use for per-turn framing that must not pollute the cacheable static prefix
|
||||
// (e.g. notification behavioural instructions from ChatHub).
|
||||
extra_system_dynamic_override: Option<String>,
|
||||
tail_reminder: Option<String>,
|
||||
interface_tools: Vec<InterfaceTool>,
|
||||
system_substitutions: HashMap<String, String>,
|
||||
tx: mpsc::Sender<ServerEvent>,
|
||||
// True for system-generated messages injected as user turns
|
||||
// (TicManager ticks, notification briefings from ChatHub).
|
||||
is_synthetic: bool,
|
||||
// Structured metadata persisted on the user turn (e.g. file attachments).
|
||||
// The MessageBuilder derives the LLM-facing block; the UI renders chips.
|
||||
metadata: Option<MessageMetadata>,
|
||||
// Queued user input for this source. When `Some`, `run_agent_turn` drains
|
||||
// it at each round boundary and injects newly-arrived user messages into
|
||||
// the running turn. `None` for sub-agents / resume / non-interactive runners.
|
||||
pending_input: Option<Arc<dyn PendingUserInput>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let _guard = self.processing.lock().await;
|
||||
// Fresh cancellation scope for this user message. Stored so `cancel()`
|
||||
// can reach it, and cloned-by-value into the call tree so a /stop during
|
||||
// the turn is sticky across sub-agent recursion (never reset mid-turn).
|
||||
let token = CancellationToken::new();
|
||||
*self.current_cancel.lock().unwrap() = token.clone();
|
||||
let pool = &self.db;
|
||||
let em = TurnEmitter::new(&tx);
|
||||
|
||||
// Retrieve memory context (Honcho or other backend) for this turn.
|
||||
// Kept SEPARATE from extra_system_context (the static part) so it can be
|
||||
// injected as a dynamic tail system message after the conversation history
|
||||
// rather than embedded in the cacheable static prefix. This allows
|
||||
// providers with prefix caching (e.g. Alibaba/DeepSeek via OpenRouter)
|
||||
// to cache the stable system prompt across turns even though Honcho
|
||||
// memories change on every call.
|
||||
let honcho_dynamic = match self.memory_manager.query_context(self.session_id, content).await {
|
||||
Some(mem_ctx) => {
|
||||
trace!(
|
||||
session_id = self.session_id,
|
||||
chars = mem_ctx.len(),
|
||||
"handle_message: memory context retrieved (will be injected as dynamic tail)"
|
||||
);
|
||||
Some(mem_ctx)
|
||||
}
|
||||
None => {
|
||||
trace!(
|
||||
session_id = self.session_id,
|
||||
"handle_message: no memory context returned (cold start, unavailable, or nothing to say)"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Merge Honcho memories with any per-turn override from the caller.
|
||||
// The override goes last so it sits closest to the generation point (recency bias).
|
||||
// extra_system_context (passed by the caller) is the STATIC part:
|
||||
// interface-specific formatting rules (e.g. Telegram HTML format),
|
||||
// never changes turn-to-turn, safe to include in the cached prefix.
|
||||
let extra_system_dynamic = match (honcho_dynamic, extra_system_dynamic_override) {
|
||||
(Some(honcho), Some(override_)) => Some(format!("{honcho}\n\n{override_}")),
|
||||
(Some(honcho), None) => Some(honcho),
|
||||
(None, Some(override_)) => Some(override_),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
let mut config = self.build_agent_config(
|
||||
client_name, extra_system_context, extra_system_dynamic, interface_tools, system_substitutions,
|
||||
).await?;
|
||||
config.tail_reminder = tail_reminder;
|
||||
|
||||
let stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
chat_sessions_stack::create(pool, self.session_id, "main", None, 0, None).await?
|
||||
}
|
||||
};
|
||||
|
||||
info!(session_id = self.session_id, stack_id = stack.id, client = %config.client_name, "handle_message start");
|
||||
|
||||
// ── Context compaction (Opzione C: at the start of the next turn) ────
|
||||
// Check whether the previous turn's input token count exceeded the
|
||||
// threshold. If so, summarise the old history before processing the
|
||||
// new message. This keeps latency transparent to the user — the wait
|
||||
// happens here, before the LLM loop, and is not a separate turn.
|
||||
if let Some(ref compactor) = self.compactor {
|
||||
let last_tokens = self.last_input_tokens.load(Ordering::Relaxed);
|
||||
match compactor.try_compact(pool, self.session_id, stack.id, last_tokens, self.is_ephemeral).await {
|
||||
Ok(true) => info!(session_id = self.session_id, stack_id = stack.id, "handle_message: context compacted"),
|
||||
Ok(false) => {}
|
||||
Err(e) => warn!(session_id = self.session_id, error = %e, "handle_message: compaction failed (non-fatal), continuing"),
|
||||
}
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// If the previous turn was cancelled before the LLM responded, the history ends on a
|
||||
// User message with no following assistant. This breaks the user→assistant alternation
|
||||
// required by strict APIs (e.g. OpenRouter). Mark the orphaned message as failed so
|
||||
// for_stack() excludes it from the context we send to the LLM.
|
||||
let prior = chat_history::for_stack(pool, stack.id).await?;
|
||||
if let Some(last) = prior.last() {
|
||||
if matches!(last.role, chat_history::Role::User | chat_history::Role::Agent) {
|
||||
warn!(session_id = self.session_id, message_id = last.id, "orphaned user message (cancelled turn) — marking failed");
|
||||
chat_history::mark_failed(pool, last.id).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let user_content = content.to_string(); // save before TurnOutcome::Final shadows `content`
|
||||
let user_message_id = chat_history::append_with_metadata(pool, stack.id, &chat_history::Role::User, content, is_synthetic, None, metadata.as_ref()).await?;
|
||||
|
||||
// Telnet-style echo: the bubble appears only once the message is persisted.
|
||||
// Synthetic turns (TIC/notification) never produce a user bubble.
|
||||
if !is_synthetic {
|
||||
let attachments = metadata.as_ref().map(|m| m.attachments.clone()).unwrap_or_default();
|
||||
// A custom slash command persists its expanded template (for LLM replay)
|
||||
// but the bubble must show the typed command — emit `display` when present.
|
||||
let echo = metadata.as_ref()
|
||||
.and_then(|m| m.command.as_ref())
|
||||
.map(|c| c.display.clone())
|
||||
.unwrap_or_else(|| user_content.clone());
|
||||
em.user_message(user_message_id, echo, attachments).await;
|
||||
}
|
||||
|
||||
// Resume any tool calls left pending from a previous interrupted session.
|
||||
// They are re-gated (rules may have changed) and executed before the LLM runs.
|
||||
self.resume_pending_tools(stack.id, &config, &token, &tx).await?;
|
||||
|
||||
let outcome = self.run_agent_turn(stack.id, &config, &token, &tx, pending_input.as_ref()).await?;
|
||||
|
||||
match outcome {
|
||||
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, truncated, tool_calls } => {
|
||||
// Persist token count so the *next* handle_message call knows
|
||||
// whether to compact before running the LLM loop.
|
||||
if let Some(t) = input_tokens {
|
||||
self.last_input_tokens.store(t, Ordering::Relaxed);
|
||||
}
|
||||
info!(session_id = self.session_id, stack_id = stack.id, ?input_tokens, ?output_tokens, "handle_message done");
|
||||
if truncated {
|
||||
warn!(session_id = self.session_id, ?output_tokens, "response truncated (max_tokens)");
|
||||
em.truncated(output_tokens).await;
|
||||
}
|
||||
em.done(message_id, stack.id, content.clone(), input_tokens, output_tokens).await;
|
||||
|
||||
// Publish both messages to the event bus now that both are in the DB.
|
||||
let now = chrono::Utc::now();
|
||||
self.event_bus.user_message(ChatEvent {
|
||||
session_id: self.session_id,
|
||||
stack_id: stack.id,
|
||||
message_id: user_message_id,
|
||||
role: ChatEventRole::User,
|
||||
content: user_content,
|
||||
is_synthetic,
|
||||
is_interactive: self.is_interactive,
|
||||
is_ephemeral: self.is_ephemeral,
|
||||
tool_calls: vec![],
|
||||
created_at: now,
|
||||
});
|
||||
self.event_bus.assistant_response(ChatEvent {
|
||||
session_id: self.session_id,
|
||||
stack_id: stack.id,
|
||||
message_id,
|
||||
role: ChatEventRole::Assistant,
|
||||
content,
|
||||
is_synthetic: false,
|
||||
is_interactive: self.is_interactive,
|
||||
is_ephemeral: self.is_ephemeral,
|
||||
tool_calls,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
TurnOutcome::Cancelled => {
|
||||
info!(session_id = self.session_id, "handle_message cancelled by user");
|
||||
em.error("Cancelled by user.".to_string()).await;
|
||||
Err(anyhow::anyhow!("Turn cancelled by user"))
|
||||
}
|
||||
TurnOutcome::Exhausted => {
|
||||
error!(session_id = self.session_id, max_rounds = self.max_tool_rounds, "tool-call loop exhausted without final answer");
|
||||
em.error(format!("Exceeded {} tool-call rounds without a final answer.", self.max_tool_rounds)).await;
|
||||
Err(anyhow::anyhow!("tool-call loop exhausted after {} rounds without a final answer", self.max_tool_rounds))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user