Skip to main content

koda_core/
session.rs

1//! KodaSession — per-conversation state.
2//!
3//! Holds mutable, per-turn state: database handle, session ID,
4//! provider instance, approval mode, and cancellation token.
5//! Instantiable N times for parallel sub-agents or cowork mode.
6//!
7//! ## Architecture
8//!
9//! ```text
10//! KodaAgent (shared, immutable)
11//!   ├─ tools, system prompt, project root
12//!   └─ shared via Arc across sessions
13//!
14//! KodaSession (per-conversation, mutable)
15//!   ├─ database handle (SQLite)
16//!   ├─ session_id (UUID)
17//!   ├─ provider instance
18//!   ├─ trust mode (plan/safe/auto)
19//!   └─ cancellation token
20//! ```
21//!
22//! This split allows the same agent to power multiple concurrent sessions
23//! (e.g., main REPL + background sub-agents) without shared mutable state.
24
25use crate::agent::KodaAgent;
26use crate::config::KodaConfig;
27use crate::db::Database;
28use crate::engine::{EngineCommand, EngineSink};
29use crate::file_tracker::FileTracker;
30use crate::inference::InferenceContext;
31use crate::providers::{self, ImageData, LlmProvider};
32use crate::trust::TrustMode;
33
34use anyhow::Result;
35use std::sync::Arc;
36use tokio::sync::mpsc;
37use tokio_util::sync::CancellationToken;
38
39/// A single conversation session with its own state.
40///
41/// Each session has its own provider, trust mode, and cancel token.
42/// Multiple sessions can share the same `Arc<KodaAgent>`.
43pub struct KodaSession {
44    /// Unique session identifier.
45    pub id: String,
46    /// Shared agent configuration (tools, system prompt).
47    pub agent: Arc<KodaAgent>,
48    /// Database handle for message persistence.
49    pub db: Database,
50    /// LLM provider for this session.
51    pub provider: Box<dyn LlmProvider>,
52    /// Current trust mode (Plan / Safe / Auto).
53    pub mode: TrustMode,
54    /// Cancellation token for graceful shutdown.
55    pub cancel: CancellationToken,
56    /// File lifecycle tracker — tracks files created by Koda (#465).
57    pub file_tracker: FileTracker,
58    /// Whether the session title has already been set (first-message guard).
59    pub title_set: bool,
60}
61
62impl KodaSession {
63    /// Create a new session from an agent, config, and database.
64    pub async fn new(
65        id: String,
66        agent: Arc<KodaAgent>,
67        db: Database,
68        config: &KodaConfig,
69        mode: TrustMode,
70    ) -> Self {
71        let provider = providers::create_provider(config);
72        // Wire db+session into ToolRegistry for RecallContext
73        agent.tools.set_session(Arc::new(db.clone()), id.clone());
74
75        // Start MCP servers from DB config (#662)
76        // TODO(#662 Phase 2): Move MCP manager to app-level ownership so
77        // servers are shared across sessions and not duplicated on resume.
78        match crate::mcp::McpManager::start_from_db(&db).await {
79            Ok(manager) => {
80                if !manager.is_empty() {
81                    let mgr = Arc::new(tokio::sync::RwLock::new(manager));
82                    agent.tools.set_mcp_manager(mgr);
83                }
84            }
85            Err(e) => {
86                tracing::warn!(error = %e, "failed to start MCP servers (non-fatal)");
87            }
88        }
89        let file_tracker = FileTracker::new(&id, db.clone()).await;
90        Self {
91            id,
92            agent,
93            db,
94            provider,
95            mode,
96            cancel: CancellationToken::new(),
97            file_tracker,
98            title_set: false,
99        }
100    }
101
102    /// Run one inference turn: prompt → streaming → tool execution → response.
103    ///
104    /// Emits `TurnStart` and `TurnEnd` lifecycle events. The loop-cap prompt is handled via `EngineEvent::LoopCapReached` / `EngineCommand::LoopDecision`
105    /// through the `cmd_rx` channel.
106    pub async fn run_turn(
107        &mut self,
108        config: &KodaConfig,
109        pending_images: Option<Vec<ImageData>>,
110        sink: &dyn EngineSink,
111        cmd_rx: &mut mpsc::Receiver<EngineCommand>,
112    ) -> Result<()> {
113        let turn_id = uuid::Uuid::new_v4().to_string();
114        sink.emit(crate::engine::EngineEvent::TurnStart {
115            turn_id: turn_id.clone(),
116        });
117
118        let result = crate::inference::inference_loop(InferenceContext {
119            project_root: &self.agent.project_root,
120            config,
121            db: &self.db,
122            session_id: &self.id,
123            system_prompt: &self.agent.system_prompt,
124            provider: self.provider.as_ref(),
125            tools: &self.agent.tools,
126            tool_defs: &self.agent.tool_defs,
127            pending_images,
128            mode: self.mode,
129            sink,
130            cancel: self.cancel.clone(),
131            cmd_rx,
132            file_tracker: &mut self.file_tracker,
133        })
134        .await;
135
136        let reason = match &result {
137            Ok(()) if self.cancel.is_cancelled() => crate::engine::event::TurnEndReason::Cancelled,
138            Ok(()) => crate::engine::event::TurnEndReason::Complete,
139            Err(e) => crate::engine::event::TurnEndReason::Error {
140                message: e.to_string(),
141            },
142        };
143        sink.emit(crate::engine::EngineEvent::TurnEnd { turn_id, reason });
144
145        result
146    }
147
148    /// Replace the provider (e.g., after switching models or providers).
149    pub fn update_provider(&mut self, config: &KodaConfig) {
150        self.provider = providers::create_provider(config);
151    }
152}