Files
optimclaw/src/agent/agent_loop.rs
T
pikaxingeandGitHub 2094d6e30d fix(agent): block thread_id-based context pollution across users (#760)
* fix(agent): prevent forged thread UUID context/write contamination

* fix(agent): close thread_id race and reject forged UUID hydration

* fix(ci): satisfy clippy and fmt checks after rebase
2026-03-11 16:52:31 -07:00

1034 lines
41 KiB
Rust

//! Main agent loop.
//!
//! Contains the `Agent` struct, `AgentDeps`, and the core event loop (`run`).
//! The heavy lifting is delegated to sibling modules:
//!
//! - `dispatcher` - Tool dispatch (agentic loop, tool execution)
//! - `commands` - System commands and job handlers
//! - `thread_ops` - Thread/session operations (user input, undo, approval, persistence)
use std::sync::Arc;
use futures::StreamExt;
use crate::agent::context_monitor::ContextMonitor;
use crate::agent::heartbeat::spawn_heartbeat;
use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
use crate::agent::session_manager::SessionManager;
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler};
use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse, StatusUpdate};
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig};
use crate::context::ContextManager;
use crate::db::Database;
use crate::error::Error;
use crate::extensions::ExtensionManager;
use crate::hooks::HookRegistry;
use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::skills::SkillRegistry;
use crate::tools::ToolRegistry;
use crate::workspace::Workspace;
/// Collapse a tool output string into a single-line preview for display.
pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String {
let collapsed: String = output
.chars()
.take(max_chars + 50)
.map(|c| if c == '\n' { ' ' } else { c })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
// char_indices gives us byte offsets at char boundaries, so the slice is always valid UTF-8.
if collapsed.chars().count() > max_chars {
let byte_offset = collapsed
.char_indices()
.nth(max_chars)
.map(|(i, _)| i)
.unwrap_or(collapsed.len());
format!("{}...", &collapsed[..byte_offset])
} else {
collapsed
}
}
/// Core dependencies for the agent.
///
/// Bundles the shared components to reduce argument count.
pub struct AgentDeps {
pub store: Option<Arc<dyn Database>>,
pub llm: Arc<dyn LlmProvider>,
/// Cheap/fast LLM for lightweight tasks (heartbeat, routing, evaluation).
/// Falls back to the main `llm` if None.
pub cheap_llm: Option<Arc<dyn LlmProvider>>,
pub safety: Arc<SafetyLayer>,
pub tools: Arc<ToolRegistry>,
pub workspace: Option<Arc<Workspace>>,
pub extension_manager: Option<Arc<ExtensionManager>>,
pub skill_registry: Option<Arc<std::sync::RwLock<SkillRegistry>>>,
pub skill_catalog: Option<Arc<crate::skills::catalog::SkillCatalog>>,
pub skills_config: SkillsConfig,
pub hooks: Arc<HookRegistry>,
/// Cost enforcement guardrails (daily budget, hourly rate limits).
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
/// SSE broadcast sender for live job event streaming to the web gateway.
pub sse_tx: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
/// HTTP interceptor for trace recording/replay.
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
/// Audio transcription middleware for voice messages.
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
}
/// The main agent that coordinates all components.
pub struct Agent {
pub(super) config: AgentConfig,
pub(super) deps: AgentDeps,
pub(super) channels: Arc<ChannelManager>,
pub(super) context_manager: Arc<ContextManager>,
pub(super) scheduler: Arc<Scheduler>,
pub(super) router: Router,
pub(super) session_manager: Arc<SessionManager>,
pub(super) context_monitor: ContextMonitor,
pub(super) heartbeat_config: Option<HeartbeatConfig>,
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
pub(super) routine_config: Option<RoutineConfig>,
/// Optional slot to expose the routine engine to the gateway for manual triggering.
pub(super) routine_engine_slot:
Option<Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>>,
}
impl Agent {
/// Create a new agent.
///
/// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing
/// with external components (job tools, web gateway). Creates new ones if not provided.
#[allow(clippy::too_many_arguments)]
pub fn new(
config: AgentConfig,
deps: AgentDeps,
channels: Arc<ChannelManager>,
heartbeat_config: Option<HeartbeatConfig>,
hygiene_config: Option<crate::config::HygieneConfig>,
routine_config: Option<RoutineConfig>,
context_manager: Option<Arc<ContextManager>>,
session_manager: Option<Arc<SessionManager>>,
) -> Self {
let context_manager = context_manager
.unwrap_or_else(|| Arc::new(ContextManager::new(config.max_parallel_jobs)));
let session_manager = session_manager.unwrap_or_else(|| Arc::new(SessionManager::new()));
let mut scheduler = Scheduler::new(
config.clone(),
context_manager.clone(),
deps.llm.clone(),
deps.safety.clone(),
deps.tools.clone(),
deps.store.clone(),
deps.hooks.clone(),
);
if let Some(ref tx) = deps.sse_tx {
scheduler.set_sse_sender(tx.clone());
}
if let Some(ref interceptor) = deps.http_interceptor {
scheduler.set_http_interceptor(Arc::clone(interceptor));
}
let scheduler = Arc::new(scheduler);
Self {
config,
deps,
channels,
context_manager,
scheduler,
router: Router::new(),
session_manager,
context_monitor: ContextMonitor::new(),
heartbeat_config,
hygiene_config,
routine_config,
routine_engine_slot: None,
}
}
/// Set the routine engine slot for exposing the engine to the gateway.
pub fn set_routine_engine_slot(
&mut self,
slot: Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
) {
self.routine_engine_slot = Some(slot);
}
// Convenience accessors
/// Get the scheduler (for external wiring, e.g. CreateJobTool).
pub fn scheduler(&self) -> Arc<Scheduler> {
Arc::clone(&self.scheduler)
}
pub(super) fn store(&self) -> Option<&Arc<dyn Database>> {
self.deps.store.as_ref()
}
pub(super) fn llm(&self) -> &Arc<dyn LlmProvider> {
&self.deps.llm
}
/// Get the cheap/fast LLM provider, falling back to the main one.
pub(super) fn cheap_llm(&self) -> &Arc<dyn LlmProvider> {
self.deps.cheap_llm.as_ref().unwrap_or(&self.deps.llm)
}
pub(super) fn safety(&self) -> &Arc<SafetyLayer> {
&self.deps.safety
}
pub(super) fn tools(&self) -> &Arc<ToolRegistry> {
&self.deps.tools
}
pub(super) fn workspace(&self) -> Option<&Arc<Workspace>> {
self.deps.workspace.as_ref()
}
pub(super) fn hooks(&self) -> &Arc<HookRegistry> {
&self.deps.hooks
}
pub(super) fn cost_guard(&self) -> &Arc<crate::agent::cost_guard::CostGuard> {
&self.deps.cost_guard
}
pub(super) fn skill_registry(&self) -> Option<&Arc<std::sync::RwLock<SkillRegistry>>> {
self.deps.skill_registry.as_ref()
}
pub(super) fn skill_catalog(&self) -> Option<&Arc<crate::skills::catalog::SkillCatalog>> {
self.deps.skill_catalog.as_ref()
}
/// Select active skills for a message using deterministic prefiltering.
pub(super) fn select_active_skills(
&self,
message_content: &str,
) -> Vec<crate::skills::LoadedSkill> {
if let Some(registry) = self.skill_registry() {
let guard = match registry.read() {
Ok(g) => g,
Err(e) => {
tracing::error!("Skill registry lock poisoned: {}", e);
return vec![];
}
};
let available = guard.skills();
let skills_cfg = &self.deps.skills_config;
let selected = crate::skills::prefilter_skills(
message_content,
available,
skills_cfg.max_active_skills,
skills_cfg.max_context_tokens,
);
if !selected.is_empty() {
tracing::debug!(
"Selected {} skill(s) for message: {}",
selected.len(),
selected
.iter()
.map(|s| s.name())
.collect::<Vec<_>>()
.join(", ")
);
}
selected.into_iter().cloned().collect()
} else {
vec![]
}
}
/// Run the agent main loop.
pub async fn run(self) -> Result<(), Error> {
// Start channels
let mut message_stream = self.channels.start_all().await?;
// Start self-repair task with notification forwarding
let repair = Arc::new(DefaultSelfRepair::new(
self.context_manager.clone(),
self.config.stuck_threshold,
self.config.max_repair_attempts,
));
let repair_interval = self.config.repair_check_interval;
let repair_channels = self.channels.clone();
let repair_handle = tokio::spawn(async move {
loop {
tokio::time::sleep(repair_interval).await;
// Check stuck jobs
let stuck_jobs = repair.detect_stuck_jobs().await;
for job in stuck_jobs {
tracing::info!("Attempting to repair stuck job {}", job.job_id);
let result = repair.repair_stuck_job(&job).await;
let notification = match &result {
Ok(RepairResult::Success { message }) => {
tracing::info!("Repair succeeded: {}", message);
Some(format!(
"Job {} was stuck for {}s, recovery succeeded: {}",
job.job_id,
job.stuck_duration.as_secs(),
message
))
}
Ok(RepairResult::Failed { message }) => {
tracing::error!("Repair failed: {}", message);
Some(format!(
"Job {} was stuck for {}s, recovery failed permanently: {}",
job.job_id,
job.stuck_duration.as_secs(),
message
))
}
Ok(RepairResult::ManualRequired { message }) => {
tracing::warn!("Manual intervention needed: {}", message);
Some(format!(
"Job {} needs manual intervention: {}",
job.job_id, message
))
}
Ok(RepairResult::Retry { message }) => {
tracing::warn!("Repair needs retry: {}", message);
None // Don't spam the user on retries
}
Err(e) => {
tracing::error!("Repair error: {}", e);
None
}
};
if let Some(msg) = notification {
let response = OutgoingResponse::text(format!("Self-Repair: {}", msg));
let _ = repair_channels.broadcast_all("default", response).await;
}
}
// Check broken tools
let broken_tools = repair.detect_broken_tools().await;
for tool in broken_tools {
tracing::info!("Attempting to repair broken tool: {}", tool.name);
match repair.repair_broken_tool(&tool).await {
Ok(RepairResult::Success { message }) => {
let response = OutgoingResponse::text(format!(
"Self-Repair: Tool '{}' repaired: {}",
tool.name, message
));
let _ = repair_channels.broadcast_all("default", response).await;
}
Ok(result) => {
tracing::info!("Tool repair result: {:?}", result);
}
Err(e) => {
tracing::error!("Tool repair error: {}", e);
}
}
}
}
});
// Spawn session pruning task
let session_mgr = self.session_manager.clone();
let session_idle_timeout = self.config.session_idle_timeout;
let pruning_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(600)); // Every 10 min
interval.tick().await; // Skip immediate first tick
loop {
interval.tick().await;
session_mgr.prune_stale_sessions(session_idle_timeout).await;
}
});
// Spawn heartbeat if enabled
let heartbeat_handle = if let Some(ref hb_config) = self.heartbeat_config {
if hb_config.enabled {
if let Some(workspace) = self.workspace() {
let mut config = AgentHeartbeatConfig::default()
.with_interval(std::time::Duration::from_secs(hb_config.interval_secs));
config.quiet_hours_start = hb_config.quiet_hours_start;
config.quiet_hours_end = hb_config.quiet_hours_end;
config.timezone = hb_config
.timezone
.clone()
.or_else(|| Some(self.config.default_timezone.clone()));
if let (Some(user), Some(channel)) =
(&hb_config.notify_user, &hb_config.notify_channel)
{
config = config.with_notify(user, channel);
}
// Set up notification channel
let (notify_tx, mut notify_rx) =
tokio::sync::mpsc::channel::<OutgoingResponse>(16);
// Spawn notification forwarder that routes through channel manager
let notify_channel = hb_config.notify_channel.clone();
let notify_user = hb_config.notify_user.clone();
let channels = self.channels.clone();
tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await {
let user = notify_user.as_deref().unwrap_or("default");
// Try the configured channel first, fall back to
// broadcasting on all channels.
let targeted_ok = if let Some(ref channel) = notify_channel {
channels
.broadcast(channel, user, response.clone())
.await
.is_ok()
} else {
false
};
if !targeted_ok {
let results = channels.broadcast_all(user, response).await;
for (ch, result) in results {
if let Err(e) = result {
tracing::warn!(
"Failed to broadcast heartbeat to {}: {}",
ch,
e
);
}
}
}
}
});
let hygiene = self
.hygiene_config
.as_ref()
.map(|h| h.to_workspace_config())
.unwrap_or_default();
Some(spawn_heartbeat(
config,
hygiene,
workspace.clone(),
self.cheap_llm().clone(),
Some(notify_tx),
self.store().map(Arc::clone),
))
} else {
tracing::warn!("Heartbeat enabled but no workspace available");
None
}
} else {
None
}
} else {
None
};
// Spawn routine engine if enabled
let routine_handle = if let Some(ref rt_config) = self.routine_config {
if rt_config.enabled {
if let (Some(store), Some(workspace)) = (self.store(), self.workspace()) {
// Set up notification channel (same pattern as heartbeat)
let (notify_tx, mut notify_rx) =
tokio::sync::mpsc::channel::<OutgoingResponse>(32);
let engine = Arc::new(RoutineEngine::new(
rt_config.clone(),
Arc::clone(store),
self.llm().clone(),
Arc::clone(workspace),
notify_tx,
Some(self.scheduler.clone()),
self.tools().clone(),
self.safety().clone(),
));
// Register routine tools
self.deps
.tools
.register_routine_tools(Arc::clone(store), Arc::clone(&engine));
// Load initial event cache
engine.refresh_event_cache().await;
// Spawn notification forwarder (mirrors heartbeat pattern)
let channels = self.channels.clone();
tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await {
let user = response
.metadata
.get("notify_user")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string();
let notify_channel = response
.metadata
.get("notify_channel")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Try the configured channel first, fall back to
// broadcasting on all channels.
let targeted_ok = if let Some(ref channel) = notify_channel {
channels
.broadcast(channel, &user, response.clone())
.await
.is_ok()
} else {
false
};
if !targeted_ok {
let results = channels.broadcast_all(&user, response).await;
for (ch, result) in results {
if let Err(e) = result {
tracing::warn!(
"Failed to broadcast routine notification to {}: {}",
ch,
e
);
}
}
}
}
});
// Spawn cron ticker
let cron_interval =
std::time::Duration::from_secs(rt_config.cron_check_interval_secs);
let cron_handle = spawn_cron_ticker(Arc::clone(&engine), cron_interval);
// Store engine reference for event trigger checking
// Safety: we're in run() which takes self, no other reference exists
let engine_ref = Arc::clone(&engine);
// SAFETY: self is consumed by run(), we can smuggle the engine in
// via a local to use in the message loop below.
// Expose engine to gateway for manual triggering
if let Some(ref slot) = self.routine_engine_slot {
*slot.write().await = Some(Arc::clone(&engine));
}
tracing::debug!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
rt_config.cron_check_interval_secs,
rt_config.max_concurrent_routines
);
Some((cron_handle, engine_ref))
} else {
tracing::warn!("Routines enabled but store/workspace not available");
None
}
} else {
None
}
} else {
None
};
// Extract engine ref for use in message loop
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
loop {
let message = tokio::select! {
biased;
_ = tokio::signal::ctrl_c() => {
tracing::debug!("Ctrl+C received, shutting down...");
break;
}
msg = message_stream.next() => {
match msg {
Some(m) => m,
None => {
tracing::debug!("All channel streams ended, shutting down...");
break;
}
}
}
};
// Apply transcription middleware to audio attachments
let mut message = message;
if let Some(ref transcription) = self.deps.transcription {
transcription.process(&mut message).await;
}
// Apply document extraction middleware to document attachments
if let Some(ref doc_extraction) = self.deps.document_extraction {
doc_extraction.process(&mut message).await;
}
// Store successfully extracted document text in workspace for indexing
self.store_extracted_documents(&message).await;
match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
let event = crate::hooks::HookEvent::Outbound {
user_id: message.user_id.clone(),
channel: message.channel.clone(),
content: response.clone(),
thread_id: message.thread_id.clone(),
};
match self.hooks().run(&event).await {
Err(err) => {
tracing::warn!("BeforeOutbound hook blocked response: {}", err);
}
Ok(crate::hooks::HookOutcome::Continue {
modified: Some(new_content),
}) => {
if let Err(e) = self
.channels
.respond(&message, OutgoingResponse::text(new_content))
.await
{
tracing::error!(
channel = %message.channel,
error = %e,
"Failed to send response to channel"
);
}
}
_ => {
if let Err(e) = self
.channels
.respond(&message, OutgoingResponse::text(response))
.await
{
tracing::error!(
channel = %message.channel,
error = %e,
"Failed to send response to channel"
);
}
}
}
}
Ok(Some(empty)) => {
// Empty response, nothing to send (e.g. approval handled via send_status)
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
empty_len = empty.len(),
"Suppressed empty response (not sent to channel)"
);
}
Ok(None) => {
// Shutdown signal received (/quit, /exit, /shutdown)
tracing::debug!("Shutdown command received, exiting...");
break;
}
Err(e) => {
tracing::error!("Error handling message: {}", e);
if let Err(send_err) = self
.channels
.respond(&message, OutgoingResponse::text(format!("Error: {}", e)))
.await
{
tracing::error!(
channel = %message.channel,
error = %send_err,
"Failed to send error response to channel"
);
}
}
}
// Check event triggers (cheap in-memory regex, fires async if matched)
if let Some(ref engine) = routine_engine_for_loop {
let fired = engine.check_event_triggers(&message).await;
if fired > 0 {
tracing::debug!("Fired {} event-triggered routines", fired);
}
}
}
// Cleanup
tracing::debug!("Agent shutting down...");
repair_handle.abort();
pruning_handle.abort();
if let Some(handle) = heartbeat_handle {
handle.abort();
}
if let Some((cron_handle, _)) = routine_handle {
cron_handle.abort();
}
self.scheduler.stop_all().await;
self.channels.shutdown_all().await?;
Ok(())
}
/// Store extracted document text in workspace memory for future search/recall.
async fn store_extracted_documents(&self, message: &IncomingMessage) {
let workspace = match self.workspace() {
Some(ws) => ws,
None => return,
};
for attachment in &message.attachments {
if attachment.kind != crate::channels::AttachmentKind::Document {
continue;
}
let text = match &attachment.extracted_text {
Some(t) if !t.starts_with('[') => t, // skip error messages like "[Failed to..."
_ => continue,
};
// Sanitize filename: strip path separators to prevent directory traversal
let raw_name = attachment.filename.as_deref().unwrap_or("unnamed_document");
let filename: String = raw_name
.chars()
.map(|c| {
if c == '/' || c == '\\' || c == '\0' {
'_'
} else {
c
}
})
.collect();
let filename = filename.trim_start_matches('.');
let filename = if filename.is_empty() {
"unnamed_document"
} else {
filename
};
let date = chrono::Utc::now().format("%Y-%m-%d");
let path = format!("documents/{date}/{filename}");
let header = format!(
"# {filename}\n\n\
> Uploaded by **{}** via **{}** on {date}\n\
> MIME: {} | Size: {} bytes\n\n---\n\n",
message.user_id,
message.channel,
attachment.mime_type,
attachment.size_bytes.unwrap_or(0),
);
let content = format!("{header}{text}");
match workspace.write(&path, &content).await {
Ok(_) => {
tracing::info!(
path = %path,
text_len = text.len(),
"Stored extracted document in workspace memory"
);
}
Err(e) => {
tracing::warn!(
path = %path,
error = %e,
"Failed to store extracted document in workspace"
);
}
}
}
}
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
// Log at info level only for tracking without exposing PII (user_id can be a phone number)
tracing::info!(message_id = %message.id, "Processing message");
// Log sensitive details at debug level for troubleshooting
tracing::debug!(
message_id = %message.id,
user_id = %message.user_id,
channel = %message.channel,
thread_id = ?message.thread_id,
"Message details"
);
// Set message tool context for this turn (current channel and target)
// For Signal, use signal_target from metadata (group:ID or phone number),
// otherwise fall back to user_id
let target = message
.metadata
.get("signal_target")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| message.user_id.clone());
self.tools()
.set_message_tool_context(Some(message.channel.clone()), Some(target))
.await;
// Parse submission type first
let mut submission = SubmissionParser::parse(&message.content);
tracing::trace!(
"[agent_loop] Parsed submission: {:?}",
std::any::type_name_of_val(&submission)
);
// Hook: BeforeInbound — allow hooks to modify or reject user input
if let Submission::UserInput { ref content } = submission {
let event = crate::hooks::HookEvent::Inbound {
user_id: message.user_id.clone(),
channel: message.channel.clone(),
content: content.clone(),
thread_id: message.thread_id.clone(),
};
match self.hooks().run(&event).await {
Err(crate::hooks::HookError::Rejected { reason }) => {
return Ok(Some(format!("[Message rejected: {}]", reason)));
}
Err(err) => {
return Ok(Some(format!("[Message blocked by hook policy: {}]", err)));
}
Ok(crate::hooks::HookOutcome::Continue {
modified: Some(new_content),
}) => {
submission = Submission::UserInput {
content: new_content,
};
}
_ => {} // Continue, fail-open errors already logged in registry
}
}
// Hydrate thread from DB if it's a historical thread not in memory
if let Some(ref external_thread_id) = message.thread_id {
tracing::trace!(
message_id = %message.id,
thread_id = %external_thread_id,
"Hydrating thread from DB"
);
if let Some(rejection) = self.maybe_hydrate_thread(message, external_thread_id).await {
return Ok(Some(format!("Error: {}", rejection)));
}
}
// Resolve session and thread
tracing::debug!(
message_id = %message.id,
"Resolving session and thread"
);
let (session, thread_id) = self
.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.thread_id.as_deref(),
)
.await;
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"Resolved session and thread"
);
// Auth mode interception: if the thread is awaiting a token, route
// the message directly to the credential store. Nothing touches
// logs, turns, history, or compaction.
let pending_auth = {
let sess = session.lock().await;
sess.threads
.get(&thread_id)
.and_then(|t| t.pending_auth.clone())
};
if let Some(pending) = pending_auth {
match &submission {
Submission::UserInput { content } => {
return self
.process_auth_token(message, &pending, content, session, thread_id)
.await;
}
_ => {
// Any control submission (interrupt, undo, etc.) cancels auth mode
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.pending_auth = None;
}
// Fall through to normal handling
}
}
}
tracing::trace!(
"Received message from {} on {} ({} chars)",
message.user_id,
message.channel,
message.content.len()
);
// Process based on submission type
let result = match submission {
Submission::UserInput { content } => {
self.process_user_input(message, session, thread_id, &content)
.await
}
Submission::SystemCommand { command, args } => {
tracing::debug!(
"[agent_loop] SystemCommand: command={}, channel={}",
command,
message.channel
);
// Authorization checks (including restart channel check) are enforced in handle_system_command
self.handle_system_command(&command, &args, &message.channel)
.await
}
Submission::Undo => self.process_undo(session, thread_id).await,
Submission::Redo => self.process_redo(session, thread_id).await,
Submission::Interrupt => self.process_interrupt(session, thread_id).await,
Submission::Compact => self.process_compact(session, thread_id).await,
Submission::Clear => self.process_clear(session, thread_id).await,
Submission::NewThread => self.process_new_thread(message).await,
Submission::Heartbeat => self.process_heartbeat().await,
Submission::Summarize => self.process_summarize(session, thread_id).await,
Submission::Suggest => self.process_suggest(session, thread_id).await,
Submission::JobStatus { job_id } => {
self.process_job_status(&message.user_id, job_id.as_deref())
.await
}
Submission::JobCancel { job_id } => {
self.process_job_cancel(&message.user_id, &job_id).await
}
Submission::Quit => return Ok(None),
Submission::SwitchThread { thread_id: target } => {
self.process_switch_thread(message, target).await
}
Submission::Resume { checkpoint_id } => {
self.process_resume(session, thread_id, checkpoint_id).await
}
Submission::ExecApproval {
request_id,
approved,
always,
} => {
self.process_approval(
message,
session,
thread_id,
Some(request_id),
approved,
always,
)
.await
}
Submission::ApprovalResponse { approved, always } => {
self.process_approval(message, session, thread_id, None, approved, always)
.await
}
};
// Convert SubmissionResult to response string
match result? {
SubmissionResult::Response { content } => {
// Suppress silent replies (e.g. from group chat "nothing to say" responses)
if crate::llm::is_silent_reply(&content) {
tracing::debug!("Suppressing silent reply token");
Ok(None)
} else {
Ok(Some(content))
}
}
SubmissionResult::Ok { message } => Ok(message),
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
SubmissionResult::Interrupted => Ok(Some("Interrupted.".into())),
SubmissionResult::NeedApproval {
request_id,
tool_name,
description,
parameters,
} => {
// Each channel renders the approval prompt via send_status.
// Web gateway shows an inline card, REPL prints a formatted prompt, etc.
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::ApprovalNeeded {
request_id: request_id.to_string(),
tool_name,
description,
parameters,
},
&message.metadata,
)
.await;
// Empty string signals the caller to skip respond() (no duplicate text)
Ok(Some(String::new()))
}
}
}
}
#[cfg(test)]
mod tests {
use super::truncate_for_preview;
#[test]
fn test_truncate_short_input() {
assert_eq!(truncate_for_preview("hello", 10), "hello");
}
#[test]
fn test_truncate_empty_input() {
assert_eq!(truncate_for_preview("", 10), "");
}
#[test]
fn test_truncate_exact_length() {
assert_eq!(truncate_for_preview("hello", 5), "hello");
}
#[test]
fn test_truncate_over_limit() {
let result = truncate_for_preview("hello world, this is long", 10);
assert!(result.ends_with("..."));
// "hello worl" = 10 chars + "..."
assert_eq!(result, "hello worl...");
}
#[test]
fn test_truncate_collapses_newlines() {
let result = truncate_for_preview("line1\nline2\nline3", 100);
assert!(!result.contains('\n'));
assert_eq!(result, "line1 line2 line3");
}
#[test]
fn test_truncate_collapses_whitespace() {
let result = truncate_for_preview("hello world", 100);
assert_eq!(result, "hello world");
}
#[test]
fn test_truncate_multibyte_utf8() {
// Each emoji is 4 bytes. Truncating at char boundary must not panic.
let input = "😀😁😂🤣😃😄😅😆😉😊";
let result = truncate_for_preview(input, 5);
assert!(result.ends_with("..."));
// First 5 chars = 5 emoji
assert_eq!(result, "😀😁😂🤣😃...");
}
#[test]
fn test_truncate_cjk_characters() {
// CJK chars are 3 bytes each in UTF-8.
let input = "你好世界测试数据很长的字符串";
let result = truncate_for_preview(input, 4);
assert_eq!(result, "你好世界...");
}
#[test]
fn test_truncate_mixed_multibyte_and_ascii() {
let input = "hello 世界 foo";
let result = truncate_for_preview(input, 8);
// 'h','e','l','l','o',' ','世','界' = 8 chars
assert_eq!(result, "hello 世界...");
}
}