mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Auth mode: when a tool requires an API key, the thread enters a special mode where the next user message is routed directly to the credential store, bypassing logs, turns, history, and compaction entirely. This prevents tokens from leaking into debug output or persistent storage. Fix MCP auth: auth_mcp now actually uses the token parameter (was ignored as _token) and falls back to manual token entry when OAuth and DCR are both unsupported. Parallel loading: WASM tools, WASM channels, and MCP servers now load concurrently at startup. Within each loader, individual items also load in parallel (join_all for WASM, JoinSet for MCP servers). Co-Authored-By: Claude Opus 4.6 <[email protected]>
597 lines
19 KiB
Rust
597 lines
19 KiB
Rust
//! Session and thread model for turn-based agent interactions.
|
|
//!
|
|
//! A Session contains one or more Threads. Each Thread represents a
|
|
//! conversation/interaction sequence with the agent. Threads contain
|
|
//! Turns, which are request/response pairs.
|
|
//!
|
|
//! This model supports:
|
|
//! - Undo: Roll back to a previous turn
|
|
//! - Interrupt: Cancel the current turn mid-execution
|
|
//! - Compaction: Summarize old turns to save context
|
|
//! - Resume: Continue from a saved checkpoint
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::llm::ChatMessage;
|
|
|
|
/// A session containing one or more threads.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Session {
|
|
/// Unique session ID.
|
|
pub id: Uuid,
|
|
/// User ID that owns this session.
|
|
pub user_id: String,
|
|
/// Active thread ID.
|
|
pub active_thread: Option<Uuid>,
|
|
/// All threads in this session.
|
|
pub threads: HashMap<Uuid, Thread>,
|
|
/// When the session was created.
|
|
pub created_at: DateTime<Utc>,
|
|
/// When the session was last active.
|
|
pub last_active_at: DateTime<Utc>,
|
|
/// Session metadata.
|
|
pub metadata: serde_json::Value,
|
|
/// Tools that have been auto-approved for this session ("always approve").
|
|
#[serde(default)]
|
|
pub auto_approved_tools: HashSet<String>,
|
|
}
|
|
|
|
impl Session {
|
|
/// Create a new session.
|
|
pub fn new(user_id: impl Into<String>) -> Self {
|
|
let now = Utc::now();
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
user_id: user_id.into(),
|
|
active_thread: None,
|
|
threads: HashMap::new(),
|
|
created_at: now,
|
|
last_active_at: now,
|
|
metadata: serde_json::Value::Null,
|
|
auto_approved_tools: HashSet::new(),
|
|
}
|
|
}
|
|
|
|
/// Check if a tool has been auto-approved for this session.
|
|
pub fn is_tool_auto_approved(&self, tool_name: &str) -> bool {
|
|
self.auto_approved_tools.contains(tool_name)
|
|
}
|
|
|
|
/// Add a tool to the auto-approved set.
|
|
pub fn auto_approve_tool(&mut self, tool_name: impl Into<String>) {
|
|
self.auto_approved_tools.insert(tool_name.into());
|
|
}
|
|
|
|
/// Create a new thread in this session.
|
|
pub fn create_thread(&mut self) -> &mut Thread {
|
|
let thread = Thread::new(self.id);
|
|
let thread_id = thread.id;
|
|
self.threads.insert(thread_id, thread);
|
|
self.active_thread = Some(thread_id);
|
|
self.last_active_at = Utc::now();
|
|
self.threads.get_mut(&thread_id).expect("just inserted")
|
|
}
|
|
|
|
/// Get the active thread.
|
|
pub fn active_thread(&self) -> Option<&Thread> {
|
|
self.active_thread.and_then(|id| self.threads.get(&id))
|
|
}
|
|
|
|
/// Get the active thread mutably.
|
|
pub fn active_thread_mut(&mut self) -> Option<&mut Thread> {
|
|
self.active_thread.and_then(|id| self.threads.get_mut(&id))
|
|
}
|
|
|
|
/// Get or create the active thread.
|
|
pub fn get_or_create_thread(&mut self) -> &mut Thread {
|
|
if self.active_thread.is_none() {
|
|
self.create_thread();
|
|
}
|
|
self.active_thread_mut().expect("just created")
|
|
}
|
|
|
|
/// Switch to a different thread.
|
|
pub fn switch_thread(&mut self, thread_id: Uuid) -> bool {
|
|
if self.threads.contains_key(&thread_id) {
|
|
self.active_thread = Some(thread_id);
|
|
self.last_active_at = Utc::now();
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// State of a thread.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ThreadState {
|
|
/// Thread is idle, waiting for input.
|
|
Idle,
|
|
/// Thread is processing a turn.
|
|
Processing,
|
|
/// Thread is waiting for user approval.
|
|
AwaitingApproval,
|
|
/// Thread has completed (no more turns expected).
|
|
Completed,
|
|
/// Thread was interrupted.
|
|
Interrupted,
|
|
}
|
|
|
|
/// Pending auth token request.
|
|
///
|
|
/// When `tool_auth` returns `awaiting_token`, the thread enters auth mode.
|
|
/// The next user message is intercepted before entering the normal pipeline
|
|
/// (no logging, no turn creation, no history) and routed directly to the
|
|
/// credential store.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PendingAuth {
|
|
/// Extension name to authenticate.
|
|
pub extension_name: String,
|
|
}
|
|
|
|
/// Pending tool approval request stored on a thread.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PendingApproval {
|
|
/// Unique request ID.
|
|
pub request_id: Uuid,
|
|
/// Tool name requiring approval.
|
|
pub tool_name: String,
|
|
/// Tool parameters.
|
|
pub parameters: serde_json::Value,
|
|
/// Description of what the tool will do.
|
|
pub description: String,
|
|
/// Tool call ID from LLM (for proper context continuation).
|
|
pub tool_call_id: String,
|
|
/// Context messages at the time of the request (to resume from).
|
|
pub context_messages: Vec<ChatMessage>,
|
|
}
|
|
|
|
/// A conversation thread within a session.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Thread {
|
|
/// Unique thread ID.
|
|
pub id: Uuid,
|
|
/// Parent session ID.
|
|
pub session_id: Uuid,
|
|
/// Current state.
|
|
pub state: ThreadState,
|
|
/// Turns in this thread.
|
|
pub turns: Vec<Turn>,
|
|
/// When the thread was created.
|
|
pub created_at: DateTime<Utc>,
|
|
/// When the thread was last updated.
|
|
pub updated_at: DateTime<Utc>,
|
|
/// Thread metadata (e.g., title, tags).
|
|
pub metadata: serde_json::Value,
|
|
/// Pending approval request (when state is AwaitingApproval).
|
|
#[serde(default)]
|
|
pub pending_approval: Option<PendingApproval>,
|
|
/// Pending auth token request (thread is in auth mode).
|
|
#[serde(default)]
|
|
pub pending_auth: Option<PendingAuth>,
|
|
}
|
|
|
|
impl Thread {
|
|
/// Create a new thread.
|
|
pub fn new(session_id: Uuid) -> Self {
|
|
let now = Utc::now();
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
session_id,
|
|
state: ThreadState::Idle,
|
|
turns: Vec::new(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
metadata: serde_json::Value::Null,
|
|
pending_approval: None,
|
|
pending_auth: None,
|
|
}
|
|
}
|
|
|
|
/// Get the current turn number (1-indexed for display).
|
|
pub fn turn_number(&self) -> usize {
|
|
self.turns.len() + 1
|
|
}
|
|
|
|
/// Get the last turn.
|
|
pub fn last_turn(&self) -> Option<&Turn> {
|
|
self.turns.last()
|
|
}
|
|
|
|
/// Get the last turn mutably.
|
|
pub fn last_turn_mut(&mut self) -> Option<&mut Turn> {
|
|
self.turns.last_mut()
|
|
}
|
|
|
|
/// Start a new turn with user input.
|
|
pub fn start_turn(&mut self, user_input: impl Into<String>) -> &mut Turn {
|
|
let turn_number = self.turns.len();
|
|
let turn = Turn::new(turn_number, user_input);
|
|
self.turns.push(turn);
|
|
self.state = ThreadState::Processing;
|
|
self.updated_at = Utc::now();
|
|
self.turns.last_mut().expect("just pushed")
|
|
}
|
|
|
|
/// Complete the current turn with a response.
|
|
pub fn complete_turn(&mut self, response: impl Into<String>) {
|
|
if let Some(turn) = self.turns.last_mut() {
|
|
turn.complete(response);
|
|
}
|
|
self.state = ThreadState::Idle;
|
|
self.updated_at = Utc::now();
|
|
}
|
|
|
|
/// Fail the current turn with an error.
|
|
pub fn fail_turn(&mut self, error: impl Into<String>) {
|
|
if let Some(turn) = self.turns.last_mut() {
|
|
turn.fail(error);
|
|
}
|
|
self.state = ThreadState::Idle;
|
|
self.updated_at = Utc::now();
|
|
}
|
|
|
|
/// Mark the thread as awaiting approval with pending request details.
|
|
pub fn await_approval(&mut self, pending: PendingApproval) {
|
|
self.state = ThreadState::AwaitingApproval;
|
|
self.pending_approval = Some(pending);
|
|
self.updated_at = Utc::now();
|
|
}
|
|
|
|
/// Take the pending approval (clearing it from the thread).
|
|
pub fn take_pending_approval(&mut self) -> Option<PendingApproval> {
|
|
self.pending_approval.take()
|
|
}
|
|
|
|
/// Clear pending approval and return to idle state.
|
|
pub fn clear_pending_approval(&mut self) {
|
|
self.pending_approval = None;
|
|
self.state = ThreadState::Idle;
|
|
self.updated_at = Utc::now();
|
|
}
|
|
|
|
/// Enter auth mode: next user message will be routed directly to
|
|
/// the credential store, bypassing the normal pipeline entirely.
|
|
pub fn enter_auth_mode(&mut self, extension_name: String) {
|
|
self.pending_auth = Some(PendingAuth { extension_name });
|
|
self.updated_at = Utc::now();
|
|
}
|
|
|
|
/// Take the pending auth (clearing auth mode).
|
|
pub fn take_pending_auth(&mut self) -> Option<PendingAuth> {
|
|
self.pending_auth.take()
|
|
}
|
|
|
|
/// Interrupt the current turn.
|
|
pub fn interrupt(&mut self) {
|
|
if let Some(turn) = self.turns.last_mut() {
|
|
turn.interrupt();
|
|
}
|
|
self.state = ThreadState::Interrupted;
|
|
self.updated_at = Utc::now();
|
|
}
|
|
|
|
/// Resume after interruption.
|
|
pub fn resume(&mut self) {
|
|
if self.state == ThreadState::Interrupted {
|
|
self.state = ThreadState::Idle;
|
|
self.updated_at = Utc::now();
|
|
}
|
|
}
|
|
|
|
/// Get all messages for context building.
|
|
pub fn messages(&self) -> Vec<ChatMessage> {
|
|
let mut messages = Vec::new();
|
|
for turn in &self.turns {
|
|
messages.push(ChatMessage::user(&turn.user_input));
|
|
if let Some(ref response) = turn.response {
|
|
messages.push(ChatMessage::assistant(response));
|
|
}
|
|
}
|
|
messages
|
|
}
|
|
|
|
/// Truncate turns to a specific count (keeping most recent).
|
|
pub fn truncate_turns(&mut self, keep: usize) {
|
|
if self.turns.len() > keep {
|
|
let drain_count = self.turns.len() - keep;
|
|
self.turns.drain(0..drain_count);
|
|
// Re-number remaining turns
|
|
for (i, turn) in self.turns.iter_mut().enumerate() {
|
|
turn.turn_number = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Restore thread state from a checkpoint's messages.
|
|
///
|
|
/// Clears existing turns and rebuilds from message pairs.
|
|
/// Messages should alternate: user, assistant, user, assistant...
|
|
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
|
|
self.turns.clear();
|
|
self.state = ThreadState::Idle;
|
|
|
|
// Messages alternate: user, assistant, user, assistant...
|
|
let mut iter = messages.into_iter().peekable();
|
|
let mut turn_number = 0;
|
|
|
|
while let Some(msg) = iter.next() {
|
|
if msg.role == crate::llm::Role::User {
|
|
let mut turn = Turn::new(turn_number, &msg.content);
|
|
|
|
// Check if next is assistant response
|
|
if let Some(next) = iter.peek() {
|
|
if next.role == crate::llm::Role::Assistant {
|
|
let response = iter.next().expect("peeked");
|
|
turn.complete(&response.content);
|
|
}
|
|
}
|
|
|
|
self.turns.push(turn);
|
|
turn_number += 1;
|
|
}
|
|
}
|
|
|
|
self.updated_at = Utc::now();
|
|
}
|
|
}
|
|
|
|
/// State of a turn.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum TurnState {
|
|
/// Turn is being processed.
|
|
Processing,
|
|
/// Turn completed successfully.
|
|
Completed,
|
|
/// Turn failed with an error.
|
|
Failed,
|
|
/// Turn was interrupted.
|
|
Interrupted,
|
|
}
|
|
|
|
/// A single turn (request/response pair) in a thread.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Turn {
|
|
/// Turn number (0-indexed).
|
|
pub turn_number: usize,
|
|
/// User input that started this turn.
|
|
pub user_input: String,
|
|
/// Agent response (if completed).
|
|
pub response: Option<String>,
|
|
/// Tool calls made during this turn.
|
|
pub tool_calls: Vec<TurnToolCall>,
|
|
/// Turn state.
|
|
pub state: TurnState,
|
|
/// When the turn started.
|
|
pub started_at: DateTime<Utc>,
|
|
/// When the turn completed.
|
|
pub completed_at: Option<DateTime<Utc>>,
|
|
/// Error message (if failed).
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl Turn {
|
|
/// Create a new turn.
|
|
pub fn new(turn_number: usize, user_input: impl Into<String>) -> Self {
|
|
Self {
|
|
turn_number,
|
|
user_input: user_input.into(),
|
|
response: None,
|
|
tool_calls: Vec::new(),
|
|
state: TurnState::Processing,
|
|
started_at: Utc::now(),
|
|
completed_at: None,
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
/// Complete this turn.
|
|
pub fn complete(&mut self, response: impl Into<String>) {
|
|
self.response = Some(response.into());
|
|
self.state = TurnState::Completed;
|
|
self.completed_at = Some(Utc::now());
|
|
}
|
|
|
|
/// Fail this turn.
|
|
pub fn fail(&mut self, error: impl Into<String>) {
|
|
self.error = Some(error.into());
|
|
self.state = TurnState::Failed;
|
|
self.completed_at = Some(Utc::now());
|
|
}
|
|
|
|
/// Interrupt this turn.
|
|
pub fn interrupt(&mut self) {
|
|
self.state = TurnState::Interrupted;
|
|
self.completed_at = Some(Utc::now());
|
|
}
|
|
|
|
/// Record a tool call.
|
|
pub fn record_tool_call(&mut self, name: impl Into<String>, params: serde_json::Value) {
|
|
self.tool_calls.push(TurnToolCall {
|
|
name: name.into(),
|
|
parameters: params,
|
|
result: None,
|
|
error: None,
|
|
});
|
|
}
|
|
|
|
/// Record tool call result.
|
|
pub fn record_tool_result(&mut self, result: serde_json::Value) {
|
|
if let Some(call) = self.tool_calls.last_mut() {
|
|
call.result = Some(result);
|
|
}
|
|
}
|
|
|
|
/// Record tool call error.
|
|
pub fn record_tool_error(&mut self, error: impl Into<String>) {
|
|
if let Some(call) = self.tool_calls.last_mut() {
|
|
call.error = Some(error.into());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record of a tool call made during a turn.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TurnToolCall {
|
|
/// Tool name.
|
|
pub name: String,
|
|
/// Parameters passed to the tool.
|
|
pub parameters: serde_json::Value,
|
|
/// Result from the tool (if successful).
|
|
pub result: Option<serde_json::Value>,
|
|
/// Error from the tool (if failed).
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_session_creation() {
|
|
let mut session = Session::new("user-123");
|
|
assert!(session.active_thread.is_none());
|
|
|
|
session.create_thread();
|
|
assert!(session.active_thread.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_turns() {
|
|
let mut thread = Thread::new(Uuid::new_v4());
|
|
|
|
thread.start_turn("Hello");
|
|
assert_eq!(thread.state, ThreadState::Processing);
|
|
assert_eq!(thread.turns.len(), 1);
|
|
|
|
thread.complete_turn("Hi there!");
|
|
assert_eq!(thread.state, ThreadState::Idle);
|
|
assert_eq!(thread.turns[0].response, Some("Hi there!".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_messages() {
|
|
let mut thread = Thread::new(Uuid::new_v4());
|
|
|
|
thread.start_turn("First message");
|
|
thread.complete_turn("First response");
|
|
thread.start_turn("Second message");
|
|
thread.complete_turn("Second response");
|
|
|
|
let messages = thread.messages();
|
|
assert_eq!(messages.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_turn_tool_calls() {
|
|
let mut turn = Turn::new(0, "Test input");
|
|
turn.record_tool_call("echo", serde_json::json!({"message": "test"}));
|
|
turn.record_tool_result(serde_json::json!("test"));
|
|
|
|
assert_eq!(turn.tool_calls.len(), 1);
|
|
assert!(turn.tool_calls[0].result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_restore_from_messages() {
|
|
let mut thread = Thread::new(Uuid::new_v4());
|
|
|
|
// First add some turns
|
|
thread.start_turn("Original message");
|
|
thread.complete_turn("Original response");
|
|
|
|
// Now restore from different messages
|
|
let messages = vec![
|
|
ChatMessage::user("Hello"),
|
|
ChatMessage::assistant("Hi there!"),
|
|
ChatMessage::user("How are you?"),
|
|
ChatMessage::assistant("I'm good!"),
|
|
];
|
|
|
|
thread.restore_from_messages(messages);
|
|
|
|
assert_eq!(thread.turns.len(), 2);
|
|
assert_eq!(thread.turns[0].user_input, "Hello");
|
|
assert_eq!(thread.turns[0].response, Some("Hi there!".to_string()));
|
|
assert_eq!(thread.turns[1].user_input, "How are you?");
|
|
assert_eq!(thread.turns[1].response, Some("I'm good!".to_string()));
|
|
assert_eq!(thread.state, ThreadState::Idle);
|
|
}
|
|
|
|
#[test]
|
|
fn test_restore_from_messages_incomplete_turn() {
|
|
let mut thread = Thread::new(Uuid::new_v4());
|
|
|
|
// Messages with incomplete last turn (no assistant response)
|
|
let messages = vec![
|
|
ChatMessage::user("Hello"),
|
|
ChatMessage::assistant("Hi there!"),
|
|
ChatMessage::user("How are you?"),
|
|
];
|
|
|
|
thread.restore_from_messages(messages);
|
|
|
|
assert_eq!(thread.turns.len(), 2);
|
|
assert_eq!(thread.turns[1].user_input, "How are you?");
|
|
assert!(thread.turns[1].response.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_enter_auth_mode() {
|
|
let mut thread = Thread::new(Uuid::new_v4());
|
|
assert!(thread.pending_auth.is_none());
|
|
|
|
thread.enter_auth_mode("telegram".to_string());
|
|
assert!(thread.pending_auth.is_some());
|
|
assert_eq!(
|
|
thread.pending_auth.as_ref().unwrap().extension_name,
|
|
"telegram"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_take_pending_auth() {
|
|
let mut thread = Thread::new(Uuid::new_v4());
|
|
thread.enter_auth_mode("notion".to_string());
|
|
|
|
let pending = thread.take_pending_auth();
|
|
assert!(pending.is_some());
|
|
assert_eq!(pending.unwrap().extension_name, "notion");
|
|
|
|
// Should be cleared after take
|
|
assert!(thread.pending_auth.is_none());
|
|
assert!(thread.take_pending_auth().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pending_auth_serialization() {
|
|
let mut thread = Thread::new(Uuid::new_v4());
|
|
thread.enter_auth_mode("openai".to_string());
|
|
|
|
let json = serde_json::to_string(&thread).expect("should serialize");
|
|
assert!(json.contains("pending_auth"));
|
|
assert!(json.contains("openai"));
|
|
|
|
let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
|
|
assert!(restored.pending_auth.is_some());
|
|
assert_eq!(restored.pending_auth.unwrap().extension_name, "openai");
|
|
}
|
|
|
|
#[test]
|
|
fn test_pending_auth_default_none() {
|
|
// Deserialization of old data without pending_auth should default to None
|
|
let mut thread = Thread::new(Uuid::new_v4());
|
|
thread.pending_auth = None;
|
|
let json = serde_json::to_string(&thread).expect("serialize");
|
|
|
|
// Remove the pending_auth field to simulate old data
|
|
let json = json.replace(",\"pending_auth\":null", "");
|
|
let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
|
|
assert!(restored.pending_auth.is_none());
|
|
}
|
|
}
|