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::bg_agent::{self, BgAgentRegistry};
27use crate::config::KodaConfig;
28use crate::db::Database;
29use crate::engine::{EngineCommand, EngineSink};
30use crate::file_tracker::FileTracker;
31use crate::inference::InferenceContext;
32use crate::providers::{self, ImageData, LlmProvider};
33use crate::sub_agent_cache::SubAgentCache;
34use crate::trust::TrustMode;
35
36use anyhow::Result;
37use koda_sandbox::{BuiltInProxy, BuiltInSocks5Proxy, DEFAULT_DEV_ALLOWLIST, Filter, ProxyHandle};
38use std::sync::{Arc, OnceLock};
39use tokio::sync::mpsc;
40use tokio_util::sync::CancellationToken;
41
42/// Cached parse of [`DEFAULT_DEV_ALLOWLIST`].
43///
44/// **#1022 B22**: pre-fix, `Filter::new(DEFAULT_DEV_ALLOWLIST).expect(…)`
45/// was called on every session creation. The args are static, the
46/// result is identical, and parsing the regex set isn't free. More
47/// importantly: a bad pattern in `DEFAULT_DEV_ALLOWLIST` would panic
48/// at *every* session creation in production rather than failing
49/// once at startup. The CI test
50/// `koda_sandbox::filter::tests::default_allowlist_parses` already
51/// guards the static, so the `expect` is sound — but hoisting into
52/// a `OnceLock` makes the contract structural: parse once, panic
53/// once if it ever does, clone-cheap (`Filter` is
54/// `#[derive(Clone)]` and holds a `Vec<Pattern>`) for each session.
55static DEV_ALLOWLIST_FILTER: OnceLock<Filter> = OnceLock::new();
56
57/// Get (or initialize) the cached default-allowlist filter.
58fn dev_allowlist_filter() -> &'static Filter {
59 DEV_ALLOWLIST_FILTER
60 .get_or_init(|| Filter::new(DEFAULT_DEV_ALLOWLIST).expect("default allowlist must parse"))
61}
62
63/// A single conversation session with its own state.
64///
65/// Each session has its own provider, trust mode, and cancel token.
66/// Multiple sessions can share the same `Arc<KodaAgent>`.
67pub struct KodaSession {
68 /// Unique session identifier.
69 pub id: String,
70 /// Shared agent configuration (tools, system prompt).
71 pub agent: Arc<KodaAgent>,
72 /// Database handle for message persistence.
73 pub db: Database,
74 /// LLM provider for this session.
75 pub provider: Box<dyn LlmProvider>,
76 /// Current trust mode (Plan / Safe / Auto).
77 pub mode: TrustMode,
78 /// Cancellation token for graceful shutdown.
79 pub cancel: CancellationToken,
80 /// File lifecycle tracker — tracks files created by Koda (#465).
81 pub file_tracker: FileTracker,
82 /// Whether the session title has already been set (first-message guard).
83 pub title_set: bool,
84 /// Per-session HTTP CONNECT proxy (Phase 3b of #934).
85 ///
86 /// Spawned unconditionally in [`Self::new`] with the hardcoded
87 /// [`koda_sandbox::DEFAULT_DEV_ALLOWLIST`] — koda is config-free,
88 /// so there's no "opt in" toggle and no user-tunable allowlist
89 /// (yet; future work: DB-backed slash command for per-project
90 /// extensions). Always-on means every Bash invocation routes
91 /// through this proxy and unknown hostnames get a 403 at the CONNECT
92 /// layer.
93 ///
94 /// `Option` rather than bare [`ProxyHandle`] because spawn can fail
95 /// (ephemeral-port exhaustion, broken loopback, runtime shutdown).
96 /// Fail-open: on spawn failure we log + continue with `None`,
97 /// matching the contract of [`koda_sandbox::ExternalProxy::spawn`].
98 /// A broken proxy must never break a session — the kernel sandbox
99 /// remains the authoritative network boundary anyway.
100 ///
101 /// Held for the session's lifetime; `Drop` aborts the proxy task
102 /// and closes the listener — no manual teardown needed.
103 pub proxy: Option<ProxyHandle>,
104
105 /// Per-session SOCKS5 proxy (Phase 3d.1 of #934). Sibling of
106 /// [`Self::proxy`] for raw-TCP clients (git over ssh, gRPC) that
107 /// don't honor `HTTPS_PROXY`. Same fail-open contract: spawn
108 /// failure logs a warning and the field stays `None`. Uses the
109 /// same hostname allowlist as the HTTP proxy by construction —
110 /// see [`koda_sandbox::BuiltInSocks5Proxy`].
111 pub socks5_proxy: Option<ProxyHandle>,
112
113 /// Background sub-agent registry (#1022 B12).
114 ///
115 /// Lives on the session, not on `inference_loop`, so background
116 /// agents survive across turns. The previous design constructed
117 /// the registry locally inside `inference_loop`; when the loop
118 /// returned (final text, error, hard-stop) the `Arc` dropped and
119 /// every still-pending bg task was aborted via
120 /// [`tokio_util::task::AbortOnDropHandle`] — silently discarding
121 /// any not-yet-completed result. With single-iteration responses
122 /// (`InvokeAgent { background: true }` followed by final text in
123 /// the same turn) this lost the bg result every time.
124 ///
125 /// Owning here means: bg tasks keep running between turns, and the
126 /// next turn's first iteration drains anything that completed
127 /// during the idle gap. Registry abort still happens at
128 /// `Drop` — i.e. when the session itself is dropped — which is
129 /// what users actually mean by "stop".
130 ///
131 /// Wrapped in `Arc` because tool dispatch needs to hand the same
132 /// registry into the recursive `execute_sub_agent` call (so
133 /// nested `InvokeAgent { background: true }` registers in the
134 /// caller-visible slot, not a fresh per-call one).
135 pub bg_agents: Arc<BgAgentRegistry>,
136
137 /// Cross-turn sub-agent result cache (#1022 B12).
138 ///
139 /// Same lifetime motivation as [`Self::bg_agents`]: was previously
140 /// re-created per `inference_loop` invocation, which threw away
141 /// every cache entry on each turn boundary and made the cache
142 /// useless for the natural "ask, follow up, ask again" flow.
143 /// Living on the session means the second turn can hit results
144 /// computed in the first.
145 ///
146 /// Invalidation still happens on every mutating tool call via
147 /// `crate::tool_dispatch::execute_one_tool` — generation bump,
148 /// cached entries with stale generations are treated as misses.
149 /// Cross-turn doesn't change that contract; it just extends the
150 /// window in which a still-fresh entry can be reused.
151 pub sub_agent_cache: SubAgentCache,
152}
153
154impl KodaSession {
155 /// Create a new session from an agent, config, and database.
156 pub async fn new(
157 id: String,
158 agent: Arc<KodaAgent>,
159 db: Database,
160 config: &KodaConfig,
161 mode: TrustMode,
162 ) -> Self {
163 let provider = providers::create_provider(config);
164 // Wire db+session into ToolRegistry for RecallContext
165 agent.tools.set_session(Arc::new(db.clone()), id.clone());
166
167 // Start MCP servers from DB config (#662).
168 //
169 // Per-session ownership is intentional, not pending refactor (see #959).
170 // Codex (closest peer agent) chose the same shape: per-session
171 // `McpConnectionManager` in `SessionServices`, not app-level. App-level
172 // ownership would complicate config-change semantics and lifecycle
173 // management for an unmeasured startup-cost optimization. Reopen #959
174 // if a real bug surfaces (e.g. multi-session resume becomes slow with
175 // many configured servers).
176 match crate::mcp::McpManager::start_from_db(&db).await {
177 Ok(manager) => {
178 if !manager.is_empty() {
179 let mgr = Arc::new(tokio::sync::RwLock::new(manager));
180 agent.tools.set_mcp_manager(mgr);
181 }
182 }
183 Err(e) => {
184 tracing::warn!(error = %e, "failed to start MCP servers (non-fatal)");
185 }
186 }
187 let file_tracker = FileTracker::new(&id, db.clone()).await;
188
189 // Spawn the per-session HTTP CONNECT proxy with the default dev
190 // allowlist. Fail-open: on spawn failure, log + run unfiltered.
191 // Always-on — koda is config-free, there's no "disable" knob.
192 // **#1022 B22**: parse-once via `OnceLock` instead of
193 // re-parsing the static allowlist on every session creation.
194 // See `dev_allowlist_filter()` above for the rationale.
195 let filter = dev_allowlist_filter().clone();
196 let proxy = match BuiltInProxy::new(filter.clone()).spawn().await {
197 Ok(handle) => {
198 agent.tools.set_proxy_port(Some(handle.port));
199 tracing::debug!(
200 "session {id} egress proxy listening on 127.0.0.1:{}",
201 handle.port
202 );
203 Some(handle)
204 }
205 Err(e) => {
206 tracing::warn!(error = %e, "egress proxy spawn failed; running unfiltered");
207 None
208 }
209 };
210
211 // 3d.2: spin up the SOCKS5 sibling using the same allowlist.
212 // Same fail-open contract as the HTTP proxy — raw-TCP clients
213 // will fall through to whatever they'd do without ALL_PROXY
214 // (i.e. dial direct, get caught by kernel-enforced egress where
215 // present, or actually escape on platforms where it isn't).
216 let socks5_proxy = match BuiltInSocks5Proxy::new(filter).spawn().await {
217 Ok(handle) => {
218 agent.tools.set_socks5_port(Some(handle.port));
219 tracing::debug!(
220 "session {id} socks5 proxy listening on 127.0.0.1:{}",
221 handle.port
222 );
223 Some(handle)
224 }
225 Err(e) => {
226 tracing::warn!(error = %e, "socks5 proxy spawn failed; raw-TCP clients unfiltered");
227 None
228 }
229 };
230
231 Self {
232 id,
233 agent,
234 db,
235 provider,
236 mode,
237 cancel: CancellationToken::new(),
238 file_tracker,
239 title_set: false,
240 proxy,
241 socks5_proxy,
242 // #1022 B12: registry + cache live on the session so bg
243 // agents survive across turns and the cache yields
244 // cross-turn hits.
245 bg_agents: bg_agent::new_shared(),
246 sub_agent_cache: SubAgentCache::new(),
247 }
248 }
249
250 /// Run one inference turn: prompt → streaming → tool execution → response.
251 ///
252 /// Emits `TurnStart` and `TurnEnd` lifecycle events. The loop-cap prompt is handled via `EngineEvent::LoopCapReached` / `EngineCommand::LoopDecision`
253 /// through the `cmd_rx` channel.
254 pub async fn run_turn(
255 &mut self,
256 config: &KodaConfig,
257 pending_images: Option<Vec<ImageData>>,
258 sink: &dyn EngineSink,
259 cmd_rx: &mut mpsc::Receiver<EngineCommand>,
260 ) -> Result<()> {
261 let turn_id = uuid::Uuid::new_v4().to_string();
262 sink.emit(crate::engine::EngineEvent::TurnStart {
263 turn_id: turn_id.clone(),
264 });
265
266 // Compose the per-turn system prompt: static `agent.system_prompt`
267 // plus a dynamically-rendered MCP server-instructions section. We
268 // do this per-turn (not at agent build time) because MCP servers
269 // attach inside `KodaSession::new`, AFTER the static prompt is
270 // built and the agent is wrapped in `Arc`. Composing here picks up
271 // both the initial-connect case and any mid-session `/mcp add`
272 // hot-reloads automatically (#922).
273 let mcp_section = if let Some(mgr) = self.agent.tools.mcp_manager() {
274 // Bind the Arc to extend its lifetime past the read guard
275 // (try_read() returns a guard that borrows the lock).
276 match mgr.try_read() {
277 Ok(guard) => {
278 crate::prompt::render_mcp_instructions_section(&guard.server_instructions())
279 }
280 Err(_) => String::new(), // manager momentarily locked; skip this turn
281 }
282 } else {
283 String::new()
284 };
285 let system_prompt = if mcp_section.is_empty() {
286 self.agent.system_prompt.clone()
287 } else {
288 format!("{}{mcp_section}", self.agent.system_prompt)
289 };
290
291 let result = crate::inference::inference_loop(InferenceContext {
292 project_root: &self.agent.project_root,
293 config,
294 db: &self.db,
295 session_id: &self.id,
296 system_prompt: &system_prompt,
297 provider: self.provider.as_ref(),
298 tools: &self.agent.tools,
299 tool_defs: &self.agent.tool_defs,
300 pending_images,
301 mode: self.mode,
302 sink,
303 cancel: self.cancel.clone(),
304 cmd_rx,
305 file_tracker: &mut self.file_tracker,
306 bg_agents: &self.bg_agents,
307 sub_agent_cache: &self.sub_agent_cache,
308 })
309 .await;
310
311 let reason = match &result {
312 Ok(()) if self.cancel.is_cancelled() => crate::engine::event::TurnEndReason::Cancelled,
313 Ok(()) => crate::engine::event::TurnEndReason::Complete,
314 Err(e) => crate::engine::event::TurnEndReason::Error {
315 message: e.to_string(),
316 },
317 };
318 sink.emit(crate::engine::EngineEvent::TurnEnd { turn_id, reason });
319
320 result
321 }
322
323 /// Replace the provider (e.g., after switching models or providers).
324 pub fn update_provider(&mut self, config: &KodaConfig) {
325 self.provider = providers::create_provider(config);
326 }
327}
328
329#[cfg(test)]
330mod b22_tests {
331 //! **#1022 B22** regression tests.
332 //!
333 //! These pin the OnceLock semantics: parse exactly once, return
334 //! the same instance across calls, and stay valid across
335 //! threads. Without these, a future "helpful" refactor that
336 //! moves the cache to a thread-local or a `RwLock<Option<...>>`
337 //! would silently re-introduce the per-session reparse cost
338 //! (or, worse, the per-session panic path).
339 use super::dev_allowlist_filter;
340 use koda_sandbox::DEFAULT_DEV_ALLOWLIST;
341
342 #[test]
343 fn dev_allowlist_filter_is_singleton() {
344 let a = dev_allowlist_filter();
345 let b = dev_allowlist_filter();
346 // Same `&'static` reference \u2014 not just equal contents.
347 // OnceLock guarantees this; if someone refactors to a Box
348 // and clones, this fails fast.
349 assert!(
350 std::ptr::eq(a, b),
351 "dev_allowlist_filter must return the same instance across calls"
352 );
353 }
354
355 #[test]
356 fn dev_allowlist_filter_matches_static_size() {
357 let f = dev_allowlist_filter();
358 // Sanity: every pattern in the static parsed and made it
359 // into the filter. If a future patch silently drops
360 // patterns (e.g. a filter with a max-size cap), this catches it.
361 assert_eq!(f.len(), DEFAULT_DEV_ALLOWLIST.len());
362 }
363
364 #[test]
365 fn dev_allowlist_filter_is_send_sync() {
366 // `OnceLock<Filter>` requires `Filter: Send + Sync` to give
367 // out `&'static Filter` across threads. Pin it.
368 fn assert_send_sync<T: Send + Sync>() {}
369 assert_send_sync::<koda_sandbox::Filter>();
370 }
371}