mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 01:19:34 +00:00
* fix(mcp): use gateway callback for MCP OAuth so auth opens in same browser When MCP OAuth is triggered from the web gateway, the auth URL was being opened via `open::that()` which launches the OS default browser instead of the browser already running the gateway UI. This changes the MCP OAuth flow to use the same gateway callback pattern as WASM extensions: in gateway mode, the auth URL is returned to the frontend via SSE and opened with `window.open()`, keeping the user in the same browser. Also adds RFC 8707 `resource` parameter support to the gateway token exchange path, scoping issued tokens to the correct MCP server. Closes #299 Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(mcp): persist DCR client_id in gateway OAuth callback for token refresh The gateway callback handler stored access and refresh tokens but not the DCR client_id. When the token expired, refresh failed with "No client ID found" because get_client_id() could not find it in secrets. Adds client_id_secret_name to PendingOAuthFlow so the gateway callback handler persists the client_id alongside the tokens, matching the behavior of the CLI flow in authorize_mcp_server(). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(mcp): return AuthRequired on 401 so activate triggers OAuth flow activate_mcp() returned ActivationFailed for all errors including 401 auth responses, so the activate handler never triggered the OAuth flow. Now 401/auth errors return AuthRequired, which the handler detects and redirects to the OAuth flow — matching the WASM extension pattern. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(mcp): fix gateway OAuth flow, approval cards, and auto-activation - Add explicit gateway_mode flag on ExtensionManager (set at startup by web gateway) so MCP OAuth returns auth URLs to the frontend instead of calling open::that() on the server machine. - Auto-activate extensions after successful OAuth callback so the UI transitions from "Activate" to "Active" without a second click. - Send ApprovalNeeded status (not generic "Awaiting approval") from thread_ops.rs for all three NeedApproval paths so the web UI shows approval cards for deferred tool calls. - Remove duplicate ApprovalNeeded send from agent_loop.rs (thread_ops.rs is now the canonical sender). - Skip approval for tool_auth in gateway mode since it only returns a URL. - Revert fragile active-server detection heuristic from system prompt. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review findings - Use Release/Acquire ordering for gateway_mode AtomicBool instead of Relaxed to ensure visibility across threads. - Report activation failure as error in OAuth callback SSE event instead of silently falling back to the success message. - Fix EnvGuard::drop to remove env var when original was unset. - Replace hardcoded /tmp/ path with std::env::temp_dir() in test helper. Co-Authored-By: Claude Opus 4.6 <[email protected]> * test(mcp): add E2E trace test for MCP extension lifecycle with mock server Add a full MCP extension lifecycle E2E test that exercises: - Turn 1: tool_search → tool_install → text (extension discovery and install) - Token injection + activate (simulating OAuth completion) - Turn 2: MCP tool calls (notion-search → notion-fetch → text) Includes a mock MCP server (tests/support/mock_mcp_server.rs) with OAuth discovery, DCR, token exchange, and JSON-RPC endpoints. The mock server validates Bearer auth and serves pre-configured tool responses. Also adds inject_registry_entry() to ExtensionManager for test use and exposes extension_manager from TestRig. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review findings (round 2) - Only fall back to manual token entry on AuthNotSupported, propagate real errors from auth_mcp_build_url() instead of masking them - Use mcp:-prefixed provider string in PendingOAuthFlow for consistency with CLI MCP auth token storage - Only persist client_id_secret_name for DCR flows (not pre-configured OAuth) - Fix gateway_callback_redirect_uri to use /oauth/callback path - Bypass exchange proxy when flow has RFC 8707 resource parameter - Remove client_id double-prefix in oauth callback handler - Remove weak tests that didn't exercise production logic - Add clarifying comments for exchange_oauth_code delegation Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: keep OAuth success independent of activation, fix wait_for_responses scoping - OAuth success is now reported accurately even when auto-activation fails (tokens are already stored, so auth succeeded) - E2E test waits for turn1_count + 1 responses to ensure turn-2 behavior is actually observed Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1869 lines
70 KiB
Rust
1869 lines
70 KiB
Rust
//! Thread and session operations for the agent.
|
|
//!
|
|
//! Extracted from `agent_loop.rs` to isolate thread management (user input
|
|
//! processing, undo/redo, approval, auth, persistence) from the core loop.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use tokio::sync::Mutex;
|
|
use tokio::task::JoinSet;
|
|
use uuid::Uuid;
|
|
|
|
use crate::agent::Agent;
|
|
use crate::agent::compaction::ContextCompactor;
|
|
use crate::agent::dispatcher::{
|
|
AgenticLoopResult, check_auth_required, execute_chat_tool_standalone, parse_auth_result,
|
|
};
|
|
use crate::agent::session::{PendingApproval, Session, ThreadState};
|
|
use crate::agent::submission::SubmissionResult;
|
|
use crate::channels::web::util::truncate_preview;
|
|
use crate::channels::{IncomingMessage, StatusUpdate};
|
|
use crate::context::JobContext;
|
|
use crate::error::Error;
|
|
use crate::llm::{ChatMessage, ToolCall};
|
|
use crate::tools::redact_params;
|
|
|
|
const FORGED_THREAD_ID_ERROR: &str = "Invalid or unauthorized thread ID.";
|
|
|
|
fn requires_preexisting_uuid_thread(channel: &str) -> bool {
|
|
// Gateway-style channels send server-issued conversation UUIDs.
|
|
// Unknown UUIDs should be rejected instead of silently creating a new thread.
|
|
matches!(channel, "gateway" | "test")
|
|
}
|
|
|
|
impl Agent {
|
|
/// Hydrate a historical thread from DB into memory if not already present.
|
|
///
|
|
/// Called before `resolve_thread` so that the session manager finds the
|
|
/// thread on lookup instead of creating a new one.
|
|
///
|
|
/// Creates an in-memory thread with the exact UUID the frontend sent,
|
|
/// even when the conversation has zero messages (e.g. a brand-new
|
|
/// assistant thread). Without this, `resolve_thread` would mint a
|
|
/// fresh UUID and all messages would land in the wrong conversation.
|
|
pub(super) async fn maybe_hydrate_thread(
|
|
&self,
|
|
message: &IncomingMessage,
|
|
external_thread_id: &str,
|
|
) -> Option<String> {
|
|
// Only hydrate UUID-shaped thread IDs (web gateway uses UUIDs)
|
|
let thread_uuid = match Uuid::parse_str(external_thread_id) {
|
|
Ok(id) => id,
|
|
Err(_) => return None,
|
|
};
|
|
|
|
// Check if already in memory
|
|
let session = self
|
|
.session_manager
|
|
.get_or_create_session(&message.user_id)
|
|
.await;
|
|
{
|
|
let sess = session.lock().await;
|
|
if sess.threads.contains_key(&thread_uuid) {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
// Load history from DB (may be empty for a newly created thread).
|
|
let mut chat_messages: Vec<ChatMessage> = Vec::new();
|
|
let msg_count;
|
|
|
|
if let Some(store) = self.store() {
|
|
// Never hydrate history from a conversation UUID that isn't owned
|
|
// by the current authenticated user.
|
|
let owned = match store
|
|
.conversation_belongs_to_user(thread_uuid, &message.user_id)
|
|
.await
|
|
{
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to verify conversation ownership for hydration {}: {}",
|
|
thread_uuid,
|
|
e
|
|
);
|
|
if requires_preexisting_uuid_thread(&message.channel) {
|
|
return Some(FORGED_THREAD_ID_ERROR.to_string());
|
|
}
|
|
return None;
|
|
}
|
|
};
|
|
if !owned {
|
|
let exists = match store.get_conversation_metadata(thread_uuid).await {
|
|
Ok(Some(_)) => true,
|
|
Ok(None) => false,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to inspect conversation metadata for hydration {}: {}",
|
|
thread_uuid,
|
|
e
|
|
);
|
|
if requires_preexisting_uuid_thread(&message.channel) {
|
|
return Some(FORGED_THREAD_ID_ERROR.to_string());
|
|
}
|
|
return None;
|
|
}
|
|
};
|
|
|
|
if requires_preexisting_uuid_thread(&message.channel) {
|
|
tracing::warn!(
|
|
user = %message.user_id,
|
|
channel = %message.channel,
|
|
thread_id = %thread_uuid,
|
|
exists,
|
|
"Rejected message for unavailable thread id"
|
|
);
|
|
return Some(FORGED_THREAD_ID_ERROR.to_string());
|
|
}
|
|
|
|
tracing::warn!(
|
|
user = %message.user_id,
|
|
thread_id = %thread_uuid,
|
|
exists,
|
|
"Skipped hydration for thread id not owned by sender"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
let db_messages = store
|
|
.list_conversation_messages(thread_uuid)
|
|
.await
|
|
.unwrap_or_default();
|
|
msg_count = db_messages.len();
|
|
chat_messages = rebuild_chat_messages_from_db(&db_messages);
|
|
} else {
|
|
msg_count = 0;
|
|
}
|
|
|
|
// Create thread with the historical ID and restore messages
|
|
let session_id = {
|
|
let sess = session.lock().await;
|
|
sess.id
|
|
};
|
|
|
|
let mut thread = crate::agent::session::Thread::with_id(thread_uuid, session_id);
|
|
if !chat_messages.is_empty() {
|
|
thread.restore_from_messages(chat_messages);
|
|
}
|
|
|
|
// Insert into session and register with session manager
|
|
{
|
|
let mut sess = session.lock().await;
|
|
sess.threads.insert(thread_uuid, thread);
|
|
sess.active_thread = Some(thread_uuid);
|
|
sess.last_active_at = chrono::Utc::now();
|
|
}
|
|
|
|
self.session_manager
|
|
.register_thread(
|
|
&message.user_id,
|
|
&message.channel,
|
|
thread_uuid,
|
|
Arc::clone(&session),
|
|
)
|
|
.await;
|
|
|
|
tracing::debug!(
|
|
"Hydrated thread {} from DB ({} messages)",
|
|
thread_uuid,
|
|
msg_count
|
|
);
|
|
|
|
None
|
|
}
|
|
|
|
pub(super) async fn process_user_input(
|
|
&self,
|
|
message: &IncomingMessage,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
content: &str,
|
|
) -> Result<SubmissionResult, Error> {
|
|
tracing::debug!(
|
|
message_id = %message.id,
|
|
thread_id = %thread_id,
|
|
content_len = content.len(),
|
|
"Processing user input"
|
|
);
|
|
|
|
// First check thread state without holding lock during I/O
|
|
let thread_state = {
|
|
let sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
thread.state
|
|
};
|
|
|
|
tracing::debug!(
|
|
message_id = %message.id,
|
|
thread_id = %thread_id,
|
|
thread_state = ?thread_state,
|
|
"Checked thread state"
|
|
);
|
|
|
|
// Check thread state
|
|
match thread_state {
|
|
ThreadState::Processing => {
|
|
tracing::warn!(
|
|
message_id = %message.id,
|
|
thread_id = %thread_id,
|
|
"Thread is processing, rejecting new input"
|
|
);
|
|
return Ok(SubmissionResult::error(
|
|
"Turn in progress. Use /interrupt to cancel.",
|
|
));
|
|
}
|
|
ThreadState::AwaitingApproval => {
|
|
tracing::warn!(
|
|
message_id = %message.id,
|
|
thread_id = %thread_id,
|
|
"Thread awaiting approval, rejecting new input"
|
|
);
|
|
return Ok(SubmissionResult::error(
|
|
"Waiting for approval. Use /interrupt to cancel.",
|
|
));
|
|
}
|
|
ThreadState::Completed => {
|
|
tracing::warn!(
|
|
message_id = %message.id,
|
|
thread_id = %thread_id,
|
|
"Thread completed, rejecting new input"
|
|
);
|
|
return Ok(SubmissionResult::error(
|
|
"Thread completed. Use /thread new.",
|
|
));
|
|
}
|
|
ThreadState::Idle | ThreadState::Interrupted => {
|
|
// Can proceed
|
|
}
|
|
}
|
|
|
|
// Safety validation for user input
|
|
let validation = self.safety().validate_input(content);
|
|
if !validation.is_valid {
|
|
let details = validation
|
|
.errors
|
|
.iter()
|
|
.map(|e| format!("{}: {}", e.field, e.message))
|
|
.collect::<Vec<_>>()
|
|
.join("; ");
|
|
return Ok(SubmissionResult::error(format!(
|
|
"Input rejected by safety validation: {}",
|
|
details
|
|
)));
|
|
}
|
|
|
|
let violations = self.safety().check_policy(content);
|
|
if violations
|
|
.iter()
|
|
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
|
|
{
|
|
return Ok(SubmissionResult::error("Input rejected by safety policy."));
|
|
}
|
|
|
|
// Scan inbound messages for secrets (API keys, tokens).
|
|
// Catching them here prevents the LLM from echoing them back, which
|
|
// would trigger the outbound leak detector and create error loops.
|
|
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
|
|
tracing::warn!(
|
|
user = %message.user_id,
|
|
channel = %message.channel,
|
|
"Inbound message blocked: contains leaked secret"
|
|
);
|
|
return Ok(SubmissionResult::error(warning));
|
|
}
|
|
|
|
// Handle explicit commands (starting with /) directly
|
|
// Everything else goes through the normal agentic loop with tools
|
|
let temp_message = IncomingMessage {
|
|
content: content.to_string(),
|
|
..message.clone()
|
|
};
|
|
|
|
if let Some(intent) = self.router.route_command(&temp_message) {
|
|
// Explicit command like /status, /job, /list - handle directly
|
|
return self.handle_job_or_command(intent, message).await;
|
|
}
|
|
|
|
// Natural language goes through the agentic loop
|
|
// Job tools (create_job, list_jobs, etc.) are in the tool registry
|
|
|
|
// Auto-compact if needed BEFORE adding new turn
|
|
{
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
|
|
let messages = thread.messages();
|
|
if let Some(strategy) = self.context_monitor.suggest_compaction(&messages) {
|
|
let pct = self.context_monitor.usage_percent(&messages);
|
|
tracing::info!("Context at {:.1}% capacity, auto-compacting", pct);
|
|
|
|
// Notify the user that compaction is happening
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::Status(format!(
|
|
"Context at {:.0}% capacity, compacting...",
|
|
pct
|
|
)),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
|
|
let compactor = ContextCompactor::new(self.llm().clone());
|
|
if let Err(e) = compactor
|
|
.compact(thread, strategy, self.workspace().map(|w| w.as_ref()))
|
|
.await
|
|
{
|
|
tracing::warn!("Auto-compaction failed: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create checkpoint before turn
|
|
let undo_mgr = self.session_manager.get_undo_manager(thread_id).await;
|
|
{
|
|
let sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
|
|
let mut mgr = undo_mgr.lock().await;
|
|
mgr.checkpoint(
|
|
thread.turn_number(),
|
|
thread.messages(),
|
|
format!("Before turn {}", thread.turn_number()),
|
|
);
|
|
}
|
|
|
|
// Augment content with attachment context (transcripts, metadata, images)
|
|
let augmented =
|
|
crate::agent::attachments::augment_with_attachments(content, &message.attachments);
|
|
let (effective_content, image_parts) = match &augmented {
|
|
Some(result) => (result.text.as_str(), result.image_parts.clone()),
|
|
None => (content, Vec::new()),
|
|
};
|
|
|
|
// Start the turn and get messages
|
|
let turn_messages = {
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
let turn = thread.start_turn(effective_content);
|
|
turn.image_content_parts = image_parts;
|
|
thread.messages()
|
|
};
|
|
|
|
// Persist user message to DB immediately so it survives crashes
|
|
tracing::debug!(
|
|
message_id = %message.id,
|
|
thread_id = %thread_id,
|
|
"Persisting user message to DB"
|
|
);
|
|
self.persist_user_message(
|
|
thread_id,
|
|
&message.channel,
|
|
&message.user_id,
|
|
effective_content,
|
|
)
|
|
.await;
|
|
|
|
tracing::debug!(
|
|
message_id = %message.id,
|
|
thread_id = %thread_id,
|
|
"User message persisted, starting agentic loop"
|
|
);
|
|
|
|
// Send thinking status
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::Thinking("Processing...".into()),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
|
|
// Run the agentic tool execution loop
|
|
let result = self
|
|
.run_agentic_loop(message, session.clone(), thread_id, turn_messages)
|
|
.await;
|
|
|
|
// Re-acquire lock and check if interrupted
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
|
|
if thread.state == ThreadState::Interrupted {
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::Status("Interrupted".into()),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
return Ok(SubmissionResult::Interrupted);
|
|
}
|
|
|
|
// Complete, fail, or request approval
|
|
match result {
|
|
Ok(AgenticLoopResult::Response(response)) => {
|
|
// Hook: TransformResponse — allow hooks to modify or reject the final response
|
|
let response = {
|
|
let event = crate::hooks::HookEvent::ResponseTransform {
|
|
user_id: message.user_id.clone(),
|
|
thread_id: thread_id.to_string(),
|
|
response: response.clone(),
|
|
};
|
|
match self.hooks().run(&event).await {
|
|
Err(crate::hooks::HookError::Rejected { reason }) => {
|
|
format!("[Response filtered: {}]", reason)
|
|
}
|
|
Err(err) => {
|
|
format!("[Response blocked by hook policy: {}]", err)
|
|
}
|
|
Ok(crate::hooks::HookOutcome::Continue {
|
|
modified: Some(new_response),
|
|
}) => new_response,
|
|
_ => response, // fail-open: use original
|
|
}
|
|
};
|
|
|
|
thread.complete_turn(&response);
|
|
let (turn_number, tool_calls) = thread
|
|
.turns
|
|
.last()
|
|
.map(|t| (t.turn_number, t.tool_calls.clone()))
|
|
.unwrap_or_default();
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::Status("Done".into()),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
|
|
// Persist tool calls then assistant response (user message already persisted at turn start)
|
|
self.persist_tool_calls(
|
|
thread_id,
|
|
&message.channel,
|
|
&message.user_id,
|
|
turn_number,
|
|
&tool_calls,
|
|
)
|
|
.await;
|
|
self.persist_assistant_response(
|
|
thread_id,
|
|
&message.channel,
|
|
&message.user_id,
|
|
&response,
|
|
)
|
|
.await;
|
|
|
|
Ok(SubmissionResult::response(response))
|
|
}
|
|
Ok(AgenticLoopResult::NeedApproval { pending }) => {
|
|
// Store pending approval in thread and update state
|
|
let request_id = pending.request_id;
|
|
let tool_name = pending.tool_name.clone();
|
|
let description = pending.description.clone();
|
|
let parameters = pending.display_parameters.clone();
|
|
thread.await_approval(pending);
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id: request_id.to_string(),
|
|
tool_name: tool_name.clone(),
|
|
description: description.clone(),
|
|
parameters: parameters.clone(),
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
Ok(SubmissionResult::NeedApproval {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
thread.fail_turn(e.to_string());
|
|
// User message already persisted at turn start; nothing else to save
|
|
Ok(SubmissionResult::error(e.to_string()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ensure a thread UUID is writable for `(channel, user_id)`.
|
|
///
|
|
/// Returns `false` for foreign/unowned conversation IDs or DB errors.
|
|
async fn ensure_writable_conversation(
|
|
&self,
|
|
store: &Arc<dyn crate::db::Database>,
|
|
thread_id: Uuid,
|
|
channel: &str,
|
|
user_id: &str,
|
|
) -> bool {
|
|
match store
|
|
.ensure_conversation(thread_id, channel, user_id, None)
|
|
.await
|
|
{
|
|
Ok(true) => true,
|
|
Ok(false) => {
|
|
tracing::warn!(
|
|
user = %user_id,
|
|
channel = %channel,
|
|
thread_id = %thread_id,
|
|
"Rejected write for unavailable thread id"
|
|
);
|
|
false
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to ensure writable conversation {}: {}",
|
|
thread_id,
|
|
e
|
|
);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Persist the user message to the DB at turn start (before the agentic loop).
|
|
///
|
|
/// This ensures the user message is durable even if the process crashes
|
|
/// mid-response. Call this right after `thread.start_turn()`.
|
|
pub(super) async fn persist_user_message(
|
|
&self,
|
|
thread_id: Uuid,
|
|
channel: &str,
|
|
user_id: &str,
|
|
user_input: &str,
|
|
) {
|
|
let store = match self.store() {
|
|
Some(s) => Arc::clone(s),
|
|
None => return,
|
|
};
|
|
|
|
if !self
|
|
.ensure_writable_conversation(&store, thread_id, channel, user_id)
|
|
.await
|
|
{
|
|
return;
|
|
}
|
|
|
|
if let Err(e) = store
|
|
.add_conversation_message(thread_id, "user", user_input)
|
|
.await
|
|
{
|
|
tracing::warn!("Failed to persist user message: {}", e);
|
|
}
|
|
}
|
|
|
|
/// Persist the assistant response to the DB after the agentic loop completes.
|
|
///
|
|
/// Re-ensures the conversation row exists so that assistant responses are
|
|
/// still persisted even if `persist_user_message` failed transiently at
|
|
/// turn start (e.g. a brief DB blip that resolved before response time).
|
|
pub(super) async fn persist_assistant_response(
|
|
&self,
|
|
thread_id: Uuid,
|
|
channel: &str,
|
|
user_id: &str,
|
|
response: &str,
|
|
) {
|
|
let store = match self.store() {
|
|
Some(s) => Arc::clone(s),
|
|
None => return,
|
|
};
|
|
|
|
if !self
|
|
.ensure_writable_conversation(&store, thread_id, channel, user_id)
|
|
.await
|
|
{
|
|
return;
|
|
}
|
|
|
|
if let Err(e) = store
|
|
.add_conversation_message(thread_id, "assistant", response)
|
|
.await
|
|
{
|
|
tracing::warn!("Failed to persist assistant message: {}", e);
|
|
}
|
|
}
|
|
|
|
/// Persist tool call summaries to the DB as a `role="tool_calls"` message.
|
|
///
|
|
/// Stored between the user and assistant messages so that
|
|
/// `build_turns_from_db_messages` can reconstruct the tool call history.
|
|
/// Content is a JSON array of tool call summaries.
|
|
pub(super) async fn persist_tool_calls(
|
|
&self,
|
|
thread_id: Uuid,
|
|
channel: &str,
|
|
user_id: &str,
|
|
turn_number: usize,
|
|
tool_calls: &[crate::agent::session::TurnToolCall],
|
|
) {
|
|
if tool_calls.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let store = match self.store() {
|
|
Some(s) => Arc::clone(s),
|
|
None => return,
|
|
};
|
|
|
|
let summaries: Vec<serde_json::Value> = tool_calls
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, tc)| {
|
|
let mut obj = serde_json::json!({
|
|
"name": tc.name,
|
|
"call_id": format!("turn{}_{}", turn_number, i),
|
|
});
|
|
if let Some(ref result) = tc.result {
|
|
let preview = match result {
|
|
serde_json::Value::String(s) => truncate_preview(s, 500),
|
|
other => truncate_preview(&other.to_string(), 500),
|
|
};
|
|
obj["result_preview"] = serde_json::Value::String(preview);
|
|
// Store full result (truncated to ~1000 chars) for LLM context rebuild
|
|
let full_result = match result {
|
|
serde_json::Value::String(s) => truncate_preview(s, 1000),
|
|
other => truncate_preview(&other.to_string(), 1000),
|
|
};
|
|
obj["result"] = serde_json::Value::String(full_result);
|
|
}
|
|
if let Some(ref error) = tc.error {
|
|
obj["error"] = serde_json::Value::String(truncate_preview(error, 200));
|
|
}
|
|
obj
|
|
})
|
|
.collect();
|
|
|
|
let content = match serde_json::to_string(&summaries) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::warn!("Failed to serialize tool calls: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if !self
|
|
.ensure_writable_conversation(&store, thread_id, channel, user_id)
|
|
.await
|
|
{
|
|
return;
|
|
}
|
|
|
|
if let Err(e) = store
|
|
.add_conversation_message(thread_id, "tool_calls", &content)
|
|
.await
|
|
{
|
|
tracing::warn!("Failed to persist tool calls: {}", e);
|
|
}
|
|
}
|
|
|
|
pub(super) async fn process_undo(
|
|
&self,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let undo_mgr = self.session_manager.get_undo_manager(thread_id).await;
|
|
let mut mgr = undo_mgr.lock().await;
|
|
|
|
if !mgr.can_undo() {
|
|
return Ok(SubmissionResult::ok_with_message("Nothing to undo."));
|
|
}
|
|
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
|
|
// Save current state to redo, get previous checkpoint
|
|
let current_messages = thread.messages();
|
|
let current_turn = thread.turn_number();
|
|
|
|
if let Some(checkpoint) = mgr.undo(current_turn, current_messages) {
|
|
// Extract values before consuming the reference
|
|
let turn_number = checkpoint.turn_number;
|
|
let messages = checkpoint.messages.clone();
|
|
let undo_count = mgr.undo_count();
|
|
// Restore thread from checkpoint
|
|
thread.restore_from_messages(messages);
|
|
Ok(SubmissionResult::ok_with_message(format!(
|
|
"Undone to turn {}. {} undo(s) remaining.",
|
|
turn_number, undo_count
|
|
)))
|
|
} else {
|
|
Ok(SubmissionResult::error("Undo failed."))
|
|
}
|
|
}
|
|
|
|
pub(super) async fn process_redo(
|
|
&self,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let undo_mgr = self.session_manager.get_undo_manager(thread_id).await;
|
|
let mut mgr = undo_mgr.lock().await;
|
|
|
|
if !mgr.can_redo() {
|
|
return Ok(SubmissionResult::ok_with_message("Nothing to redo."));
|
|
}
|
|
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
|
|
let current_messages = thread.messages();
|
|
let current_turn = thread.turn_number();
|
|
|
|
if let Some(checkpoint) = mgr.redo(current_turn, current_messages) {
|
|
thread.restore_from_messages(checkpoint.messages);
|
|
Ok(SubmissionResult::ok_with_message(format!(
|
|
"Redone to turn {}.",
|
|
checkpoint.turn_number
|
|
)))
|
|
} else {
|
|
Ok(SubmissionResult::error("Redo failed."))
|
|
}
|
|
}
|
|
|
|
pub(super) async fn process_interrupt(
|
|
&self,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
|
|
match thread.state {
|
|
ThreadState::Processing | ThreadState::AwaitingApproval => {
|
|
thread.interrupt();
|
|
Ok(SubmissionResult::ok_with_message("Interrupted."))
|
|
}
|
|
_ => Ok(SubmissionResult::ok_with_message("Nothing to interrupt.")),
|
|
}
|
|
}
|
|
|
|
pub(super) async fn process_compact(
|
|
&self,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
|
|
let messages = thread.messages();
|
|
let usage = self.context_monitor.usage_percent(&messages);
|
|
let strategy = self
|
|
.context_monitor
|
|
.suggest_compaction(&messages)
|
|
.unwrap_or(
|
|
crate::agent::context_monitor::CompactionStrategy::Summarize { keep_recent: 5 },
|
|
);
|
|
|
|
let compactor = ContextCompactor::new(self.llm().clone());
|
|
match compactor
|
|
.compact(thread, strategy, self.workspace().map(|w| w.as_ref()))
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
let mut msg = format!(
|
|
"Compacted: {} turns removed, {} → {} tokens (was {:.1}% full)",
|
|
result.turns_removed, result.tokens_before, result.tokens_after, usage
|
|
);
|
|
if result.summary_written {
|
|
msg.push_str(", summary saved to workspace");
|
|
}
|
|
Ok(SubmissionResult::ok_with_message(msg))
|
|
}
|
|
Err(e) => Ok(SubmissionResult::error(format!("Compaction failed: {}", e))),
|
|
}
|
|
}
|
|
|
|
pub(super) async fn process_clear(
|
|
&self,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
thread.turns.clear();
|
|
thread.state = ThreadState::Idle;
|
|
|
|
// Clear undo history too
|
|
let undo_mgr = self.session_manager.get_undo_manager(thread_id).await;
|
|
undo_mgr.lock().await.clear();
|
|
|
|
Ok(SubmissionResult::ok_with_message("Thread cleared."))
|
|
}
|
|
|
|
/// Process an approval or rejection of a pending tool execution.
|
|
pub(super) async fn process_approval(
|
|
&self,
|
|
message: &IncomingMessage,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
request_id: Option<Uuid>,
|
|
approved: bool,
|
|
always: bool,
|
|
) -> Result<SubmissionResult, Error> {
|
|
// Get pending approval for this thread
|
|
let pending = {
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
|
|
if thread.state != ThreadState::AwaitingApproval {
|
|
// Stale or duplicate approval (tool already executed) — silently ignore.
|
|
tracing::debug!(
|
|
%thread_id,
|
|
state = ?thread.state,
|
|
"Ignoring stale approval: thread not in AwaitingApproval state"
|
|
);
|
|
return Ok(SubmissionResult::ok_with_message(""));
|
|
}
|
|
|
|
thread.take_pending_approval()
|
|
};
|
|
|
|
let pending = match pending {
|
|
Some(p) => p,
|
|
None => {
|
|
tracing::debug!(
|
|
%thread_id,
|
|
"Ignoring stale approval: no pending approval found"
|
|
);
|
|
return Ok(SubmissionResult::ok_with_message(""));
|
|
}
|
|
};
|
|
|
|
// Verify request ID if provided
|
|
if let Some(req_id) = request_id
|
|
&& req_id != pending.request_id
|
|
{
|
|
// Put it back and return error
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
|
thread.await_approval(pending);
|
|
}
|
|
return Ok(SubmissionResult::error(
|
|
"Request ID mismatch. Use the correct request ID.",
|
|
));
|
|
}
|
|
|
|
if approved {
|
|
// If always, add to auto-approved set
|
|
if always {
|
|
let mut sess = session.lock().await;
|
|
sess.auto_approve_tool(&pending.tool_name);
|
|
tracing::info!(
|
|
"Auto-approved tool '{}' for session {}",
|
|
pending.tool_name,
|
|
sess.id
|
|
);
|
|
}
|
|
|
|
// Reset thread state to processing
|
|
{
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
|
thread.state = ThreadState::Processing;
|
|
}
|
|
}
|
|
|
|
// Execute the approved tool and continue the loop
|
|
let mut job_ctx =
|
|
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
|
|
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
|
|
// Prefer a valid timezone from the approval message, fall back to the
|
|
// resolved timezone stored when the approval was originally requested.
|
|
let tz_candidate = message
|
|
.timezone
|
|
.as_deref()
|
|
.filter(|tz| crate::timezone::parse_timezone(tz).is_some())
|
|
.or(pending.user_timezone.as_deref());
|
|
if let Some(tz) = tz_candidate {
|
|
job_ctx.user_timezone = tz.to_string();
|
|
}
|
|
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::ToolStarted {
|
|
name: pending.tool_name.clone(),
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
|
|
let tool_result = self
|
|
.execute_chat_tool(&pending.tool_name, &pending.parameters, &job_ctx)
|
|
.await;
|
|
|
|
let tool_ref = self.tools().get(&pending.tool_name).await;
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::tool_completed(
|
|
pending.tool_name.clone(),
|
|
&tool_result,
|
|
&pending.display_parameters,
|
|
tool_ref.as_deref(),
|
|
),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
|
|
if let Ok(ref output) = tool_result
|
|
&& !output.is_empty()
|
|
{
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::ToolResult {
|
|
name: pending.tool_name.clone(),
|
|
preview: output.clone(),
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// Build context including the tool result
|
|
let mut context_messages = pending.context_messages;
|
|
let deferred_tool_calls = pending.deferred_tool_calls;
|
|
|
|
// Sanitize tool result, then record the cleaned version in the
|
|
// thread. Must happen before auth intercept check which may return early.
|
|
let is_tool_error = tool_result.is_err();
|
|
let (result_content, _) = crate::tools::execute::process_tool_result(
|
|
self.safety(),
|
|
&pending.tool_name,
|
|
&pending.tool_call_id,
|
|
&tool_result,
|
|
);
|
|
|
|
// Record sanitized result in thread
|
|
{
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
|
&& let Some(turn) = thread.last_turn_mut()
|
|
{
|
|
if is_tool_error {
|
|
turn.record_tool_error(result_content.clone());
|
|
} else {
|
|
turn.record_tool_result(serde_json::json!(result_content));
|
|
}
|
|
}
|
|
}
|
|
|
|
// If tool_auth returned awaiting_token, enter auth mode and
|
|
// return instructions directly (skip agentic loop continuation).
|
|
if let Some((ext_name, instructions)) =
|
|
check_auth_required(&pending.tool_name, &tool_result)
|
|
{
|
|
self.handle_auth_intercept(
|
|
&session,
|
|
thread_id,
|
|
message,
|
|
&tool_result,
|
|
ext_name,
|
|
instructions.clone(),
|
|
)
|
|
.await;
|
|
return Ok(SubmissionResult::response(instructions));
|
|
}
|
|
|
|
context_messages.push(ChatMessage::tool_result(
|
|
&pending.tool_call_id,
|
|
&pending.tool_name,
|
|
result_content,
|
|
));
|
|
|
|
// Replay deferred tool calls from the same assistant message so
|
|
// every tool_use ID gets a matching tool_result before the next
|
|
// LLM call.
|
|
if !deferred_tool_calls.is_empty() {
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::Thinking(format!(
|
|
"Executing {} deferred tool(s)...",
|
|
deferred_tool_calls.len()
|
|
)),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// === Phase 1: Preflight (sequential) ===
|
|
// Walk deferred tools checking approval. Collect runnable
|
|
// tools; stop at the first that needs approval.
|
|
let mut runnable: Vec<crate::llm::ToolCall> = Vec::new();
|
|
let mut approval_needed: Option<(
|
|
usize,
|
|
crate::llm::ToolCall,
|
|
Arc<dyn crate::tools::Tool>,
|
|
)> = None;
|
|
|
|
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
|
|
if let Some(tool) = self.tools().get(&tc.name).await {
|
|
// Match dispatcher.rs: when auto_approve_tools is true, skip
|
|
// all approval checks (including ApprovalRequirement::Always).
|
|
let needs_approval = if self.config.auto_approve_tools {
|
|
false
|
|
} else {
|
|
use crate::tools::ApprovalRequirement;
|
|
match tool.requires_approval(&tc.arguments) {
|
|
ApprovalRequirement::Never => false,
|
|
ApprovalRequirement::UnlessAutoApproved => {
|
|
let sess = session.lock().await;
|
|
!sess.is_tool_auto_approved(&tc.name)
|
|
}
|
|
ApprovalRequirement::Always => true,
|
|
}
|
|
};
|
|
|
|
if needs_approval {
|
|
approval_needed = Some((idx, tc.clone(), tool));
|
|
break; // remaining tools stay deferred
|
|
}
|
|
}
|
|
|
|
runnable.push(tc.clone());
|
|
}
|
|
|
|
// === Phase 2: Parallel execution ===
|
|
let exec_results: Vec<(crate::llm::ToolCall, Result<String, Error>)> = if runnable.len()
|
|
<= 1
|
|
{
|
|
// Single tool (or none): execute inline
|
|
let mut results = Vec::new();
|
|
for tc in &runnable {
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::ToolStarted {
|
|
name: tc.name.clone(),
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
|
|
let result = self
|
|
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
|
|
.await;
|
|
|
|
let deferred_tool = self.tools().get(&tc.name).await;
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::tool_completed(
|
|
tc.name.clone(),
|
|
&result,
|
|
&tc.arguments,
|
|
deferred_tool.as_deref(),
|
|
),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
|
|
results.push((tc.clone(), result));
|
|
}
|
|
results
|
|
} else {
|
|
// Multiple tools: execute in parallel via JoinSet
|
|
let mut join_set = JoinSet::new();
|
|
let runnable_count = runnable.len();
|
|
|
|
for (spawn_idx, tc) in runnable.iter().enumerate() {
|
|
let tools = self.tools().clone();
|
|
let safety = self.safety().clone();
|
|
let channels = self.channels.clone();
|
|
let job_ctx = job_ctx.clone();
|
|
let tc = tc.clone();
|
|
let channel = message.channel.clone();
|
|
let metadata = message.metadata.clone();
|
|
|
|
join_set.spawn(async move {
|
|
let _ = channels
|
|
.send_status(
|
|
&channel,
|
|
StatusUpdate::ToolStarted {
|
|
name: tc.name.clone(),
|
|
},
|
|
&metadata,
|
|
)
|
|
.await;
|
|
|
|
let result = execute_chat_tool_standalone(
|
|
&tools,
|
|
&safety,
|
|
&tc.name,
|
|
&tc.arguments,
|
|
&job_ctx,
|
|
)
|
|
.await;
|
|
|
|
let par_tool = tools.get(&tc.name).await;
|
|
let _ = channels
|
|
.send_status(
|
|
&channel,
|
|
StatusUpdate::tool_completed(
|
|
tc.name.clone(),
|
|
&result,
|
|
&tc.arguments,
|
|
par_tool.as_deref(),
|
|
),
|
|
&metadata,
|
|
)
|
|
.await;
|
|
|
|
(spawn_idx, tc, result)
|
|
});
|
|
}
|
|
|
|
// Collect and reorder by original index
|
|
let mut ordered: Vec<Option<(crate::llm::ToolCall, Result<String, Error>)>> =
|
|
(0..runnable_count).map(|_| None).collect();
|
|
while let Some(join_result) = join_set.join_next().await {
|
|
match join_result {
|
|
Ok((idx, tc, result)) => {
|
|
ordered[idx] = Some((tc, result));
|
|
}
|
|
Err(e) => {
|
|
if e.is_panic() {
|
|
tracing::error!("Deferred tool execution task panicked: {}", e);
|
|
} else {
|
|
tracing::error!("Deferred tool execution task cancelled: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fill panicked slots with error results
|
|
ordered
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, opt)| {
|
|
opt.unwrap_or_else(|| {
|
|
let tc = runnable[i].clone();
|
|
let err: Error = crate::error::ToolError::ExecutionFailed {
|
|
name: tc.name.clone(),
|
|
reason: "Task failed during execution".to_string(),
|
|
}
|
|
.into();
|
|
(tc, Err(err))
|
|
})
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
// === Phase 3: Post-flight (sequential, in original order) ===
|
|
// Process all results before any conditional return so every
|
|
// tool result is recorded in the session audit trail.
|
|
let mut deferred_auth: Option<String> = None;
|
|
|
|
for (tc, deferred_result) in exec_results {
|
|
if let Ok(ref output) = deferred_result
|
|
&& !output.is_empty()
|
|
{
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::ToolResult {
|
|
name: tc.name.clone(),
|
|
preview: output.clone(),
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// Sanitize first, then record the cleaned version in thread.
|
|
// Must happen before auth detection which may set deferred_auth.
|
|
let is_deferred_error = deferred_result.is_err();
|
|
let (deferred_content, _) = crate::tools::execute::process_tool_result(
|
|
self.safety(),
|
|
&tc.name,
|
|
&tc.id,
|
|
&deferred_result,
|
|
);
|
|
|
|
// Record sanitized result in thread
|
|
{
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
|
&& let Some(turn) = thread.last_turn_mut()
|
|
{
|
|
if is_deferred_error {
|
|
turn.record_tool_error(deferred_content.clone());
|
|
} else {
|
|
turn.record_tool_result(serde_json::json!(deferred_content));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auth detection — defer return until all results are recorded
|
|
if deferred_auth.is_none()
|
|
&& let Some((ext_name, instructions)) =
|
|
check_auth_required(&tc.name, &deferred_result)
|
|
{
|
|
self.handle_auth_intercept(
|
|
&session,
|
|
thread_id,
|
|
message,
|
|
&deferred_result,
|
|
ext_name,
|
|
instructions.clone(),
|
|
)
|
|
.await;
|
|
deferred_auth = Some(instructions);
|
|
}
|
|
|
|
context_messages.push(ChatMessage::tool_result(&tc.id, &tc.name, deferred_content));
|
|
}
|
|
|
|
// Return auth response after all results are recorded
|
|
if let Some(instructions) = deferred_auth {
|
|
return Ok(SubmissionResult::response(instructions));
|
|
}
|
|
|
|
// Handle approval if a tool needed it
|
|
if let Some((approval_idx, tc, tool)) = approval_needed {
|
|
let new_pending = PendingApproval {
|
|
request_id: Uuid::new_v4(),
|
|
tool_name: tc.name.clone(),
|
|
parameters: tc.arguments.clone(),
|
|
display_parameters: redact_params(&tc.arguments, tool.sensitive_params()),
|
|
description: tool.description().to_string(),
|
|
tool_call_id: tc.id.clone(),
|
|
context_messages: context_messages.clone(),
|
|
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
|
|
// Carry forward the resolved timezone from the original pending approval
|
|
user_timezone: pending.user_timezone.clone(),
|
|
};
|
|
|
|
let request_id = new_pending.request_id;
|
|
let tool_name = new_pending.tool_name.clone();
|
|
let description = new_pending.description.clone();
|
|
let parameters = new_pending.display_parameters.clone();
|
|
|
|
{
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
|
thread.await_approval(new_pending);
|
|
}
|
|
}
|
|
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id: request_id.to_string(),
|
|
tool_name: tool_name.clone(),
|
|
description: description.clone(),
|
|
parameters: parameters.clone(),
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
|
|
return Ok(SubmissionResult::NeedApproval {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters,
|
|
});
|
|
}
|
|
|
|
// Continue the agentic loop (a tool was already executed this turn)
|
|
let result = self
|
|
.run_agentic_loop(message, session.clone(), thread_id, context_messages)
|
|
.await;
|
|
|
|
// Handle the result
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
|
|
match result {
|
|
Ok(AgenticLoopResult::Response(response)) => {
|
|
thread.complete_turn(&response);
|
|
let (turn_number, tool_calls) = thread
|
|
.turns
|
|
.last()
|
|
.map(|t| (t.turn_number, t.tool_calls.clone()))
|
|
.unwrap_or_default();
|
|
// User message already persisted at turn start; save tool calls then assistant response
|
|
self.persist_tool_calls(
|
|
thread_id,
|
|
&message.channel,
|
|
&message.user_id,
|
|
turn_number,
|
|
&tool_calls,
|
|
)
|
|
.await;
|
|
self.persist_assistant_response(
|
|
thread_id,
|
|
&message.channel,
|
|
&message.user_id,
|
|
&response,
|
|
)
|
|
.await;
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::Status("Done".into()),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
Ok(SubmissionResult::response(response))
|
|
}
|
|
Ok(AgenticLoopResult::NeedApproval {
|
|
pending: new_pending,
|
|
}) => {
|
|
let request_id = new_pending.request_id;
|
|
let tool_name = new_pending.tool_name.clone();
|
|
let description = new_pending.description.clone();
|
|
let parameters = new_pending.display_parameters.clone();
|
|
thread.await_approval(new_pending);
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id: request_id.to_string(),
|
|
tool_name: tool_name.clone(),
|
|
description: description.clone(),
|
|
parameters: parameters.clone(),
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
Ok(SubmissionResult::NeedApproval {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
thread.fail_turn(e.to_string());
|
|
// User message already persisted at turn start
|
|
Ok(SubmissionResult::error(e.to_string()))
|
|
}
|
|
}
|
|
} else {
|
|
// Rejected - complete the turn with a rejection message and persist
|
|
let rejection = format!(
|
|
"Tool '{}' was rejected. The agent will not execute this tool.\n\n\
|
|
You can continue the conversation or try a different approach.",
|
|
pending.tool_name
|
|
);
|
|
{
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
|
thread.clear_pending_approval();
|
|
thread.complete_turn(&rejection);
|
|
// User message already persisted at turn start; save rejection response
|
|
self.persist_assistant_response(
|
|
thread_id,
|
|
&message.channel,
|
|
&message.user_id,
|
|
&rejection,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::Status("Rejected".into()),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
|
|
Ok(SubmissionResult::response(rejection))
|
|
}
|
|
}
|
|
|
|
/// Handle an auth-required result from a tool execution.
|
|
///
|
|
/// Enters auth mode on the thread, completes + persists the turn,
|
|
/// and sends the AuthRequired status to the channel.
|
|
/// Returns the instructions string for the caller to wrap in a response.
|
|
async fn handle_auth_intercept(
|
|
&self,
|
|
session: &Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
message: &IncomingMessage,
|
|
tool_result: &Result<String, Error>,
|
|
ext_name: String,
|
|
instructions: String,
|
|
) {
|
|
let auth_data = parse_auth_result(tool_result);
|
|
{
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
|
thread.enter_auth_mode(ext_name.clone());
|
|
thread.complete_turn(&instructions);
|
|
// User message already persisted at turn start; save auth instructions
|
|
self.persist_assistant_response(
|
|
thread_id,
|
|
&message.channel,
|
|
&message.user_id,
|
|
&instructions,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::AuthRequired {
|
|
extension_name: ext_name,
|
|
instructions: Some(instructions.clone()),
|
|
auth_url: auth_data.auth_url,
|
|
setup_url: auth_data.setup_url,
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Handle an auth token submitted while the thread is in auth mode.
|
|
///
|
|
/// The token goes directly to the extension manager's credential store,
|
|
/// completely bypassing logging, turn creation, history, and compaction.
|
|
pub(super) async fn process_auth_token(
|
|
&self,
|
|
message: &IncomingMessage,
|
|
pending: &crate::agent::session::PendingAuth,
|
|
token: &str,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
) -> Result<Option<String>, Error> {
|
|
let token = token.trim();
|
|
|
|
// Clear auth mode regardless of outcome
|
|
{
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
|
thread.pending_auth = None;
|
|
}
|
|
}
|
|
|
|
let ext_mgr = match self.deps.extension_manager.as_ref() {
|
|
Some(mgr) => mgr,
|
|
None => return Ok(Some("Extension manager not available.".to_string())),
|
|
};
|
|
|
|
match ext_mgr
|
|
.configure_token(&pending.extension_name, token)
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
tracing::info!(
|
|
"Extension '{}' configured via auth mode: {}",
|
|
pending.extension_name,
|
|
result.message
|
|
);
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::AuthCompleted {
|
|
extension_name: pending.extension_name.clone(),
|
|
success: true,
|
|
message: result.message.clone(),
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
Ok(Some(result.message))
|
|
}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
// Token validation errors: re-enter auth mode and re-prompt
|
|
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
|
{
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
|
thread.enter_auth_mode(pending.extension_name.clone());
|
|
}
|
|
}
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::AuthRequired {
|
|
extension_name: pending.extension_name.clone(),
|
|
instructions: Some(msg.clone()),
|
|
auth_url: None,
|
|
setup_url: None,
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
return Ok(Some(msg));
|
|
}
|
|
// Infrastructure errors
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::AuthCompleted {
|
|
extension_name: pending.extension_name.clone(),
|
|
success: false,
|
|
message: msg.clone(),
|
|
},
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
Ok(Some(msg))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn process_new_thread(
|
|
&self,
|
|
message: &IncomingMessage,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let session = self
|
|
.session_manager
|
|
.get_or_create_session(&message.user_id)
|
|
.await;
|
|
let mut sess = session.lock().await;
|
|
let thread = sess.create_thread();
|
|
let thread_id = thread.id;
|
|
Ok(SubmissionResult::ok_with_message(format!(
|
|
"New thread: {}",
|
|
thread_id
|
|
)))
|
|
}
|
|
|
|
pub(super) async fn process_switch_thread(
|
|
&self,
|
|
message: &IncomingMessage,
|
|
target_thread_id: Uuid,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let session = self
|
|
.session_manager
|
|
.get_or_create_session(&message.user_id)
|
|
.await;
|
|
let mut sess = session.lock().await;
|
|
|
|
if sess.switch_thread(target_thread_id) {
|
|
Ok(SubmissionResult::ok_with_message(format!(
|
|
"Switched to thread {}",
|
|
target_thread_id
|
|
)))
|
|
} else {
|
|
Ok(SubmissionResult::error("Thread not found."))
|
|
}
|
|
}
|
|
|
|
pub(super) async fn process_resume(
|
|
&self,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
checkpoint_id: Uuid,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let undo_mgr = self.session_manager.get_undo_manager(thread_id).await;
|
|
let mut mgr = undo_mgr.lock().await;
|
|
|
|
if let Some(checkpoint) = mgr.restore(checkpoint_id) {
|
|
let mut sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get_mut(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
thread.restore_from_messages(checkpoint.messages);
|
|
Ok(SubmissionResult::ok_with_message(format!(
|
|
"Resumed from checkpoint: {}",
|
|
checkpoint.description
|
|
)))
|
|
} else {
|
|
Ok(SubmissionResult::error("Checkpoint not found."))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rebuild full LLM-compatible `ChatMessage` sequence from DB messages.
|
|
///
|
|
/// Parses `role="tool_calls"` rows to reconstruct `assistant_with_tool_calls`
|
|
/// and `tool_result` messages so that the LLM sees the complete tool execution
|
|
/// history on thread hydration. Falls back gracefully for legacy rows that
|
|
/// lack the enriched fields (`call_id`, `parameters`, `result`).
|
|
fn rebuild_chat_messages_from_db(
|
|
db_messages: &[crate::history::ConversationMessage],
|
|
) -> Vec<ChatMessage> {
|
|
let mut result = Vec::new();
|
|
|
|
for msg in db_messages {
|
|
match msg.role.as_str() {
|
|
"user" => result.push(ChatMessage::user(&msg.content)),
|
|
"assistant" => result.push(ChatMessage::assistant(&msg.content)),
|
|
"tool_calls" => {
|
|
// Try to parse the enriched JSON and rebuild tool messages.
|
|
if let Ok(calls) = serde_json::from_str::<Vec<serde_json::Value>>(&msg.content) {
|
|
if calls.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Check if this is an enriched row (has call_id) or legacy
|
|
let has_call_id = calls
|
|
.first()
|
|
.and_then(|c| c.get("call_id"))
|
|
.and_then(|v| v.as_str())
|
|
.is_some();
|
|
|
|
if has_call_id {
|
|
// Build assistant_with_tool_calls + tool_result messages
|
|
let tool_calls: Vec<ToolCall> = calls
|
|
.iter()
|
|
.map(|c| ToolCall {
|
|
id: c["call_id"].as_str().unwrap_or("call_0").to_string(),
|
|
name: c["name"].as_str().unwrap_or("unknown").to_string(),
|
|
arguments: c
|
|
.get("parameters")
|
|
.cloned()
|
|
.unwrap_or(serde_json::json!({})),
|
|
})
|
|
.collect();
|
|
|
|
// The assistant text for tool_calls is always None here;
|
|
// the final assistant response comes as a separate
|
|
// "assistant" row after this tool_calls row.
|
|
result.push(ChatMessage::assistant_with_tool_calls(None, tool_calls));
|
|
|
|
// Emit tool_result messages for each call
|
|
for c in &calls {
|
|
let call_id = c["call_id"].as_str().unwrap_or("call_0").to_string();
|
|
let name = c["name"].as_str().unwrap_or("unknown").to_string();
|
|
let content = if let Some(err) = c.get("error").and_then(|v| v.as_str())
|
|
{
|
|
format!("Error: {}", err)
|
|
} else if let Some(res) = c.get("result").and_then(|v| v.as_str()) {
|
|
res.to_string()
|
|
} else if let Some(preview) =
|
|
c.get("result_preview").and_then(|v| v.as_str())
|
|
{
|
|
preview.to_string()
|
|
} else {
|
|
"OK".to_string()
|
|
};
|
|
result.push(ChatMessage::tool_result(call_id, name, content));
|
|
}
|
|
}
|
|
// Legacy rows without call_id: skip (will appear as
|
|
// simple user/assistant pairs, same as before this fix).
|
|
}
|
|
}
|
|
_ => {} // Skip unknown roles
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_rebuild_chat_messages_user_assistant_only() {
|
|
let messages = vec![
|
|
make_db_msg("user", "Hello"),
|
|
make_db_msg("assistant", "Hi there!"),
|
|
];
|
|
let result = rebuild_chat_messages_from_db(&messages);
|
|
assert_eq!(result.len(), 2);
|
|
assert_eq!(result[0].role, crate::llm::Role::User);
|
|
assert_eq!(result[1].role, crate::llm::Role::Assistant);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rebuild_chat_messages_with_enriched_tool_calls() {
|
|
let tool_json = serde_json::json!([
|
|
{
|
|
"name": "memory_search",
|
|
"call_id": "call_0",
|
|
"parameters": {"query": "test"},
|
|
"result": "Found 3 results",
|
|
"result_preview": "Found 3 re..."
|
|
},
|
|
{
|
|
"name": "echo",
|
|
"call_id": "call_1",
|
|
"parameters": {"message": "hi"},
|
|
"error": "timeout"
|
|
}
|
|
]);
|
|
let messages = vec![
|
|
make_db_msg("user", "Search for test"),
|
|
make_db_msg("tool_calls", &tool_json.to_string()),
|
|
make_db_msg("assistant", "I found some results."),
|
|
];
|
|
let result = rebuild_chat_messages_from_db(&messages);
|
|
|
|
// user + assistant_with_tool_calls + tool_result*2 + assistant
|
|
assert_eq!(result.len(), 5);
|
|
|
|
// user
|
|
assert_eq!(result[0].role, crate::llm::Role::User);
|
|
|
|
// assistant with tool_calls
|
|
assert_eq!(result[1].role, crate::llm::Role::Assistant);
|
|
assert!(result[1].tool_calls.is_some());
|
|
let tcs = result[1].tool_calls.as_ref().unwrap();
|
|
assert_eq!(tcs.len(), 2);
|
|
assert_eq!(tcs[0].name, "memory_search");
|
|
assert_eq!(tcs[0].id, "call_0");
|
|
assert_eq!(tcs[1].name, "echo");
|
|
|
|
// tool results
|
|
assert_eq!(result[2].role, crate::llm::Role::Tool);
|
|
assert_eq!(result[2].tool_call_id, Some("call_0".to_string()));
|
|
assert!(result[2].content.contains("Found 3 results"));
|
|
|
|
assert_eq!(result[3].role, crate::llm::Role::Tool);
|
|
assert_eq!(result[3].tool_call_id, Some("call_1".to_string()));
|
|
assert!(result[3].content.contains("Error: timeout"));
|
|
|
|
// final assistant
|
|
assert_eq!(result[4].role, crate::llm::Role::Assistant);
|
|
assert_eq!(result[4].content, "I found some results.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rebuild_chat_messages_legacy_tool_calls_skipped() {
|
|
// Legacy format: no call_id field
|
|
let tool_json = serde_json::json!([
|
|
{"name": "echo", "result_preview": "hello"}
|
|
]);
|
|
let messages = vec![
|
|
make_db_msg("user", "Hi"),
|
|
make_db_msg("tool_calls", &tool_json.to_string()),
|
|
make_db_msg("assistant", "Done"),
|
|
];
|
|
let result = rebuild_chat_messages_from_db(&messages);
|
|
|
|
// Legacy rows are skipped, only user + assistant
|
|
assert_eq!(result.len(), 2);
|
|
assert_eq!(result[0].role, crate::llm::Role::User);
|
|
assert_eq!(result[1].role, crate::llm::Role::Assistant);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rebuild_chat_messages_empty() {
|
|
let result = rebuild_chat_messages_from_db(&[]);
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_rebuild_chat_messages_malformed_tool_calls_json() {
|
|
let messages = vec![
|
|
make_db_msg("user", "Hi"),
|
|
make_db_msg("tool_calls", "not valid json"),
|
|
make_db_msg("assistant", "Done"),
|
|
];
|
|
let result = rebuild_chat_messages_from_db(&messages);
|
|
// Malformed JSON is silently skipped
|
|
assert_eq!(result.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rebuild_chat_messages_multi_turn_with_tools() {
|
|
let tool_json_1 = serde_json::json!([
|
|
{"name": "search", "call_id": "call_0", "parameters": {}, "result": "found it"}
|
|
]);
|
|
let tool_json_2 = serde_json::json!([
|
|
{"name": "write", "call_id": "call_0", "parameters": {"path": "a.txt"}, "result": "ok"}
|
|
]);
|
|
let messages = vec![
|
|
make_db_msg("user", "Find X"),
|
|
make_db_msg("tool_calls", &tool_json_1.to_string()),
|
|
make_db_msg("assistant", "Found X"),
|
|
make_db_msg("user", "Write it"),
|
|
make_db_msg("tool_calls", &tool_json_2.to_string()),
|
|
make_db_msg("assistant", "Written"),
|
|
];
|
|
let result = rebuild_chat_messages_from_db(&messages);
|
|
|
|
// Turn 1: user + assistant_with_calls + tool_result + assistant = 4
|
|
// Turn 2: user + assistant_with_calls + tool_result + assistant = 4
|
|
assert_eq!(result.len(), 8);
|
|
|
|
// Verify turn boundaries
|
|
assert_eq!(result[0].content, "Find X");
|
|
assert!(result[1].tool_calls.is_some());
|
|
assert_eq!(result[2].role, crate::llm::Role::Tool);
|
|
assert_eq!(result[3].content, "Found X");
|
|
|
|
assert_eq!(result[4].content, "Write it");
|
|
assert!(result[5].tool_calls.is_some());
|
|
assert_eq!(result[6].role, crate::llm::Role::Tool);
|
|
assert_eq!(result[7].content, "Written");
|
|
}
|
|
|
|
fn make_db_msg(role: &str, content: &str) -> crate::history::ConversationMessage {
|
|
crate::history::ConversationMessage {
|
|
id: uuid::Uuid::new_v4(),
|
|
role: role.to_string(),
|
|
content: content.to_string(),
|
|
created_at: chrono::Utc::now(),
|
|
}
|
|
}
|
|
}
|