mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
467 lines
14 KiB
Rust
467 lines
14 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;
|
|
|
|
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,
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
pub fn await_approval(&mut self) {
|
|
self.state = ThreadState::AwaitingApproval;
|
|
self.updated_at = Utc::now();
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|