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::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}