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