Skip to main content

defect_agent/session/
default.rs

1//! Default implementation of [`Session`] / [`AgentCore`].
2//!
3//! Assembly structure:
4//!
5//! ```text
6//! DefaultAgentCore
7//!   ├── Arc<dyn LlmProvider>          (injected at assembly, shared by all sessions in this core)
8//!   ├── Arc<dyn ToolRegistry>         (built-in tools, shared by all sessions in this core)
9//!   ├── TurnConfig                    (default configuration)
10//!   └── DashMap<SessionId, Arc<dyn Session>>
11//!
12//! Note: "shared" here is scoped to the **`AgentCore` instance**, not process-global.
13//! When using defect as a library, a single process can assemble multiple `AgentCore`
14//! instances, each with its own provider / tool set / configuration.
15//!
16//! DefaultSession
17//!   ├── id: SessionId
18//!   ├── cwd: PathBuf
19//!   ├── history: Box<dyn History>
20//!   ├── tools:   Arc<dyn ToolRegistry>   (CompositeRegistry: per-session + process)
21//!   ├── provider: Arc<dyn LlmProvider>
22//!   ├── events:   Arc<EventEmitter>
23//!   ├── permissions: Arc<PermissionGate>
24//!   ├── turn_lock: tokio::sync::Mutex<TurnSlot>
25//!   └── config: RwLock<TurnConfig>
26//! ```
27//!
28//! Turn mutual exclusion uses `Mutex<TurnSlot>`: `run_turn` calls `try_lock` at the
29//! outermost level, returning `TurnError::TurnInProgress` on failure. `TurnSlot`
30//! internally stores the current turn's [`CancellationToken`]; `cancel_turn` extracts
31//! it and calls `cancel()`.
32
33use std::io;
34use std::path::PathBuf;
35use std::sync::{Arc, Mutex, RwLock};
36
37use agent_client_protocol_schema::{ContentBlock, McpServer, SessionId, StopReason, ToolCallId};
38use dashmap::DashMap;
39use futures::future::BoxFuture;
40use tokio_util::sync::CancellationToken;
41use tracing::Instrument;
42
43use crate::error::BoxError;
44use crate::event::{AgentEvent, PermissionResolution};
45use crate::fs::FsBackend;
46use crate::hooks::{HookCtx, HookEngine, NoopHookEngine};
47use crate::http::{HttpClient, NoopHttpClient};
48use crate::llm::{
49    HostedCapabilities, LlmProvider, Message, ModelCandidate, ModelInfo, ProviderError,
50    ProviderErrorKind, ProviderInfo, ProviderRegistry, ReasoningEffort,
51};
52use crate::policy::{AskWritesPolicy, ModeCatalog, SandboxPolicy};
53use crate::session::capabilities::{ResolvedSessionCapabilities, SessionCapabilitiesConfig};
54use crate::session::context::{Frontend, RunningContext};
55use crate::session::events::EventEmitter;
56use crate::session::permissions::PermissionGate;
57use crate::session::prompt::resolve_system_prompt;
58use crate::session::tool_registry::{CompositeRegistry, StaticToolRegistry};
59use crate::session::turn::{
60    CompactionCtx, RequestAuditTracker, TurnConfig, TurnRunner, run_sync_compaction,
61};
62use crate::session::{
63    AgentCore, AgentError, CompactionReport, ContextStatus, EventStream, History, ModelSelection,
64    Session, SessionCreateInfo, SessionLoader, SessionObserver, SessionToolFactory, ToolRegistry,
65    TurnError, VecHistory,
66};
67use crate::shell::ShellBackend;
68
69/// Default [`AgentCore`].
70pub struct DefaultAgentCore {
71    /// Provider registry wired at assembly time. Sessions share the same `Arc`; the
72    /// active model ID is used to resolve the corresponding [`LlmProvider`] — this type
73    /// no longer "holds a single provider".
74    registry: Arc<ProviderRegistry>,
75    process_tools: Arc<dyn ToolRegistry>,
76    /// Optional tool allowlist for the top-level `--profile` mode. When `Some`, each
77    /// session's tool pool (built-in **plus** connected MCP tools) is restricted to this
78    /// set after assembly. Applied at session-creation time — not at CLI assembly — so
79    /// that `mcp__*` tools (connected per-session and absent until then) can be allowed.
80    /// `None` ⇒ no restriction (the default and non-profile path).
81    tool_allow: Option<Vec<String>>,
82    /// Default policy — used as the session's active policy **only when no mode catalog
83    /// is present (`modes` is `None`)**. When a catalog is present, it is overridden by
84    /// the catalog's current mode.
85    policy: Arc<dyn SandboxPolicy>,
86    /// Permission mode catalog template. When `Some`, each session holds a cloned copy
87    /// and can switch independently via `session/set_mode`; when `None`, falls back to
88    /// the single non-switchable `policy`.
89    modes: Option<ModeCatalog>,
90    config: RwLock<TurnConfig>,
91    loader: Option<Arc<dyn SessionLoader>>,
92    session_tools: Option<Arc<dyn SessionToolFactory>>,
93    observers: Vec<Arc<dyn SessionObserver>>,
94    /// HTTP fetch backend. Shared across all sessions in this core — HTTP has no
95    /// per-client capability negotiation, and there is no need to isolate connection
96    /// pools between sessions. Constructed once at the CLI entry point from
97    /// `HttpClientConfig` and injected; tests and the `echo` provider use
98    /// [`NoopHttpClient`].
99    http: Arc<dyn HttpClient>,
100    /// Hook engine shared by all sessions in this core — hook configuration uses global +
101    /// per-session matchers. Assembled at CLI entry; without explicit injection, uses
102    /// [`NoopHookEngine`], equivalent to "no hooks configured = main loop unchanged".
103    hook_engine: Arc<dyn HookEngine>,
104    /// Background progress view configuration. Shared across all sessions (at the same
105    /// level as process-wide tool configuration).
106    /// Passed to each session when constructing `BackgroundTasks`.
107    background_progress: crate::session::BackgroundProgressConfig,
108    /// Shared state for the `--goal` goal-driven loop. When `Some`, sessions in this core
109    /// run in goal mode; injected by CLI assembly based on `--goal`. All sessions share
110    /// the same instance (a `--goal` process typically runs only one session). `None` =
111    /// non-goal mode (default).
112    goal: Option<Arc<crate::session::GoalState>>,
113    sessions: DashMap<SessionId, Arc<dyn Session>>,
114}
115
116impl DefaultAgentCore {
117    pub fn builder() -> DefaultAgentCoreBuilder {
118        DefaultAgentCoreBuilder::default()
119    }
120}
121
122#[derive(Default)]
123pub struct DefaultAgentCoreBuilder {
124    registry: Option<Arc<ProviderRegistry>>,
125    /// Convenience entry for a single provider: set [`Self::provider`] here, and at
126    /// `build()` time it is combined with `config.model` to produce a single-entry
127    /// [`ProviderRegistry`]. This field is ignored when `registry` has been explicitly
128    /// injected.
129    single_provider: Option<Arc<dyn LlmProvider>>,
130    /// Session capabilities for the single-provider path. Ignored when `registry` is
131    /// explicitly injected, as the entry provides its own.
132    single_capabilities: SessionCapabilitiesConfig,
133    process_tools: Option<Arc<dyn ToolRegistry>>,
134    tool_allow: Option<Vec<String>>,
135    policy: Option<Arc<dyn SandboxPolicy>>,
136    modes: Option<ModeCatalog>,
137    loader: Option<Arc<dyn SessionLoader>>,
138    session_tools: Option<Arc<dyn SessionToolFactory>>,
139    observers: Vec<Arc<dyn SessionObserver>>,
140    http: Option<Arc<dyn HttpClient>>,
141    hook_engine: Option<Arc<dyn HookEngine>>,
142    config: TurnConfig,
143    /// Background task progress view configuration (ring capacity / body limit). Each
144    /// session's `BackgroundTasks` builds its progress ring from this. When unset, falls
145    /// back to
146    /// [`BackgroundProgressConfig::default`](crate::session::BackgroundProgressConfig)
147    /// (bird's-eye view, no body content).
148    background_progress: crate::session::BackgroundProgressConfig,
149    /// Shared state for the `--goal` goal-driven loop. Injected by the CLI during
150    /// assembly based on `--goal`; if unset, goal mode is disabled.
151    goal: Option<Arc<crate::session::GoalState>>,
152}
153
154impl DefaultAgentCoreBuilder {
155    /// Injects the provider registry during assembly. Used by the CLI and real startup
156    /// paths; tests and single-provider scenarios should use [`Self::provider`] for
157    /// convenience.
158    pub fn registry(mut self, registry: Arc<ProviderRegistry>) -> Self {
159        self.registry = Some(registry);
160        self
161    }
162
163    /// Convenience entry point for a single provider. Wraps it into a single-entry
164    /// [`ProviderRegistry`] at `build()` time; default model = [`TurnConfig::model`].
165    /// Mutually exclusive with [`Self::registry`]; if both are set, `registry` takes
166    /// precedence.
167    pub fn provider(mut self, provider: Arc<dyn LlmProvider>) -> Self {
168        self.single_provider = Some(provider);
169        self
170    }
171
172    /// Session capabilities for the single-provider convenience path — these are merged
173    /// into the single-entry registry automatically constructed by `build()`. For the
174    /// multi-provider path, write capabilities directly into
175    /// [`ProviderEntry`](crate::llm::ProviderEntry) instead; this field is ignored.
176    pub fn capabilities(mut self, capabilities: SessionCapabilitiesConfig) -> Self {
177        self.single_capabilities = capabilities;
178        self
179    }
180
181    /// Restrict every session's tool pool to this allowlist (top-level `--profile`). The
182    /// filter is applied at session-creation time, after built-in and MCP tools are both
183    /// in the pool, so `mcp__*` names resolve. Names absent from the assembled pool are a
184    /// hard error at session creation (fail-loud). `spawn_agent` is handled by the depth
185    /// gate and ignored here.
186    pub fn tool_allow(mut self, allow: Vec<String>) -> Self {
187        self.tool_allow = Some(allow);
188        self
189    }
190
191    pub fn process_tools(mut self, tools: Arc<dyn ToolRegistry>) -> Self {
192        self.process_tools = Some(tools);
193        self
194    }
195
196    pub fn policy(mut self, policy: Arc<dyn SandboxPolicy>) -> Self {
197        self.policy = Some(policy);
198        self
199    }
200
201    /// Inject a permission-mode catalog. When `Some`, each session exposes an ACP
202    /// `SessionModeState` and supports `session/set_mode`; the catalog's current mode
203    /// overrides [`Self::policy`] as the session's initial active policy. If not called,
204    /// the session has no mode switching and is fixed to [`Self::policy`] (or the default
205    /// [`AskWritesPolicy`]).
206    pub fn modes(mut self, modes: ModeCatalog) -> Self {
207        self.modes = Some(modes);
208        self
209    }
210
211    pub fn session_loader(mut self, loader: Arc<dyn SessionLoader>) -> Self {
212        self.loader = Some(loader);
213        self
214    }
215
216    pub fn session_tool_factory(mut self, factory: Arc<dyn SessionToolFactory>) -> Self {
217        self.session_tools = Some(factory);
218        self
219    }
220
221    pub fn observe_session(mut self, observer: Arc<dyn SessionObserver>) -> Self {
222        self.observers.push(observer);
223        self
224    }
225
226    pub fn config(mut self, config: TurnConfig) -> Self {
227        self.config = config;
228        self
229    }
230
231    /// Inject shared state for the `--goal` goal-driven loop. When `Some`, the session
232    /// runs in goal mode: the top-level turn injects it into the `goal_done` tool via
233    /// [`crate::tool::ToolContext::goal`], and the `goal-gate` hook uses it to drive
234    /// multi-turn autonomous loops. If not called, the session runs in non-goal mode (the
235    /// default).
236    pub fn goal(mut self, goal: Arc<crate::session::GoalState>) -> Self {
237        self.goal = Some(goal);
238        self
239    }
240
241    /// Sets the background task progress view configuration (progress ring capacity /
242    /// per-block body character limit). When not called, defaults to ring size 64 and
243    /// body limit 0, meaning only summary/metadata is shown and sub-turn bodies are not
244    /// populated. During CLI assembly, this is injected from the `[tools.background]`
245    /// projection.
246    pub fn background_progress(mut self, config: crate::session::BackgroundProgressConfig) -> Self {
247        self.background_progress = config;
248        self
249    }
250
251    /// Sets the HTTP fetch backend for this core. When unset, defaults to
252    /// [`NoopHttpClient`]—any `fetch` call will fail with
253    /// [`crate::http::HttpClientError::Transport`], allowing tests or `echo` assemblies
254    /// that don't need networking to skip constructing a real HTTP stack.
255    pub fn http(mut self, http: Arc<dyn HttpClient>) -> Self {
256        self.http = Some(http);
257        self
258    }
259
260    /// Sets the hook engine for this core. When unset, falls back to [`NoopHookEngine`] —
261    /// all hook calls return `Pass` directly, and the main loop behaves as if the hook
262    /// system were not introduced.
263    pub fn hook_engine(mut self, hook_engine: Arc<dyn HookEngine>) -> Self {
264        self.hook_engine = Some(hook_engine);
265        self
266    }
267
268    /// # Panics
269    /// Neither `registry` nor `provider` is set; or, in the single-provider path,
270    /// `config.model` is an empty string (the registry must have at least one default
271    /// model).
272    pub fn build(mut self) -> DefaultAgentCore {
273        let registry = self.registry.take().unwrap_or_else(|| {
274            let provider = self
275                .single_provider
276                .take()
277                .expect("DefaultAgentCore requires a provider or a registry");
278            let vendor = provider.info().vendor;
279            // Under the single-provider path, config usually lacks a selected vendor —
280            // fill in the provider's own vendor so that `resolve_initial_provider` can
281            // find the entry by the (vendor, model) pair.
282            if self.config.provider.is_empty() {
283                self.config.provider = vendor.clone();
284            }
285            let model_id = self.config.model.clone();
286            assert!(
287                !model_id.is_empty(),
288                "DefaultAgentCoreBuilder::provider() requires TurnConfig::model to be set; \
289                 use registry() for multi-provider setups"
290            );
291            // In the single-provider path, treat `TurnConfig::allowed_models` as the
292            // model candidate list — this mirrors the multi-provider assembly in the CLI:
293            // users declare candidates via `[providers.<p>.models]`, and the agent does
294            // not send a `list_models` network request to the adapter. When
295            // `allowed_models` is absent, fall back to exposing only the default model.
296            let model_ids = match self.config.allowed_models.as_ref() {
297                Some(ids) if !ids.is_empty() => ids.clone(),
298                _ => vec![model_id.clone()],
299            };
300            let mut model_infos: Vec<ModelInfo> = model_ids
301                .into_iter()
302                .map(|id| {
303                    provider.model_info(&id).unwrap_or(ModelInfo {
304                        id,
305                        display_name: None,
306                        context_window: None,
307                        max_output_tokens: None,
308                        deprecated: false,
309                        capabilities_overrides: Default::default(),
310                    })
311                })
312                .collect();
313            if !model_infos.iter().any(|m| m.id == model_id) {
314                model_infos.insert(
315                    0,
316                    ModelInfo {
317                        id: model_id.clone(),
318                        display_name: None,
319                        context_window: None,
320                        max_output_tokens: None,
321                        deprecated: false,
322                        capabilities_overrides: Default::default(),
323                    },
324                );
325            }
326            Arc::new(
327                crate::llm::ProviderRegistry::new(
328                    vec![crate::llm::ProviderEntry::new(
329                        provider,
330                        model_infos,
331                        self.single_capabilities,
332                    )],
333                    &vendor,
334                    &model_id,
335                )
336                .expect("single-entry registry must satisfy invariants"),
337            )
338        });
339
340        DefaultAgentCore {
341            registry,
342            process_tools: self
343                .process_tools
344                .unwrap_or_else(|| Arc::new(StaticToolRegistry::empty()) as Arc<dyn ToolRegistry>),
345            tool_allow: self.tool_allow,
346            policy: self
347                .policy
348                .unwrap_or_else(|| Arc::new(AskWritesPolicy::new()) as Arc<dyn SandboxPolicy>),
349            modes: self.modes,
350            loader: self.loader,
351            session_tools: self.session_tools,
352            observers: self.observers,
353            http: self
354                .http
355                .unwrap_or_else(|| Arc::new(NoopHttpClient) as Arc<dyn HttpClient>),
356            hook_engine: self
357                .hook_engine
358                .unwrap_or_else(|| Arc::new(NoopHookEngine) as Arc<dyn HookEngine>),
359            config: RwLock::new(self.config),
360            background_progress: self.background_progress,
361            goal: self.goal,
362            sessions: DashMap::new(),
363        }
364    }
365}
366
367impl AgentCore for DefaultAgentCore {
368    fn create_session(
369        &self,
370        id: SessionId,
371        cwd: PathBuf,
372        mcp_servers: Vec<McpServer>,
373        fs: Arc<dyn FsBackend>,
374        shell: Arc<dyn ShellBackend>,
375        frontend: Frontend,
376    ) -> BoxFuture<'_, Result<Arc<dyn Session>, AgentError>> {
377        Box::pin(async move {
378            if !cwd.is_absolute() || !cwd.exists() {
379                return Err(AgentError::InvalidCwd(cwd));
380            }
381            let session_cwd = cwd.clone();
382            if self.sessions.contains_key(&id) {
383                return Err(AgentError::DuplicateSessionId(id));
384            }
385
386            let initial = self.resolve_initial_provider()?;
387
388            let session_tools = match &self.session_tools {
389                Some(factory) => factory
390                    .build_registry(cwd.clone(), mcp_servers.clone())
391                    .await
392                    .map_err(|source| AgentError::McpStartup {
393                        server: "session_tools".to_string(),
394                        source,
395                    })?,
396                None => Arc::new(StaticToolRegistry::empty()) as Arc<dyn ToolRegistry>,
397            };
398            let composite: Arc<dyn ToolRegistry> = Arc::new(CompositeRegistry::new(
399                session_tools,
400                self.process_tools.clone(),
401            ));
402            // Top-level `--profile` allowlist: applied here (not at CLI assembly) so the
403            // pool already contains connected MCP tools, letting `mcp__*` names resolve.
404            // `spawn_agent` is governed by the depth gate, so it is skipped by the filter.
405            let composite = self.apply_tool_allow(composite)?;
406
407            // After the session-enter hook, absorb any injected `additional_context` as
408            // candidate system-prompt suffixes.
409            let session_start_append = {
410                let cancel = CancellationToken::new();
411                let ctx = HookCtx::new(&id, &cwd, cancel);
412                let mut step = crate::hooks::step::AfterSessionEnter {
413                    cwd: cwd.to_string_lossy().into_owned(),
414                    source: crate::hooks::step::SessionSource::New,
415                    additional_context: Vec::new(),
416                };
417                let _ = self.hook_engine.dispatch(&mut step, ctx).await;
418                step.additional_context
419            };
420
421            // Session-level cancellation token: both the driver loop exit signal and the
422            // source of background task cancellation tokens (same token).
423            let session_cancel = CancellationToken::new();
424            let (policy, modes) = self.session_policy_state();
425            let concrete = Arc::new(DefaultSession {
426                id: id.clone(),
427                cwd,
428                history: Arc::new(VecHistory::new()) as Arc<dyn History>,
429                tools: composite,
430                registry: self.registry.clone(),
431                provider_state: RwLock::new(initial),
432                policy,
433                modes,
434                events: Arc::new(EventEmitter::new()),
435                permissions: Arc::new(PermissionGate::new()),
436                turn_state: Mutex::new(TurnSlot::default()),
437                background: crate::session::BackgroundTasks::new(
438                    session_cancel.clone(),
439                    self.background_progress,
440                ),
441                goal: self.goal.clone(),
442                compaction_slot: crate::session::CompactionSlot::new(),
443                turn_freed: Arc::new(tokio::sync::Notify::new()),
444                session_cancel,
445                config: RwLock::new(
446                    self.config
447                        .read()
448                        .expect("DefaultAgentCore config rwlock poisoned")
449                        .clone(),
450                ),
451                fs,
452                shell,
453                frontend,
454                http: self.http.clone(),
455                hook_engine: self.hook_engine.clone(),
456                session_start_append,
457                request_audit: RequestAuditTracker::new(),
458            });
459            // Spawn the session driver (active keep-alive). The driver holds a `Weak`
460            // self-reference so that when all external strong references to the session
461            // are dropped, the driver's `upgrade` fails and it exits, preventing the
462            // session from living forever.
463            tokio::spawn(DefaultSession::drive(Arc::downgrade(&concrete)));
464            let session = concrete as Arc<dyn Session>;
465
466            let session_info = SessionCreateInfo {
467                id: id.clone(),
468                cwd: session_cwd,
469                mcp_servers,
470            };
471            for observer in &self.observers {
472                observer
473                    .on_session_created(session.clone(), session_info.clone())
474                    .map_err(AgentError::Observer)?;
475            }
476
477            self.sessions.insert(id, session.clone());
478            Ok(session)
479        })
480    }
481
482    fn load_session(
483        &self,
484        id: SessionId,
485        fs: Arc<dyn FsBackend>,
486        shell: Arc<dyn ShellBackend>,
487        frontend: Frontend,
488    ) -> BoxFuture<'_, Result<Arc<dyn Session>, AgentError>> {
489        Box::pin(async move {
490            if let Some(existing) = self.sessions.get(&id) {
491                return Ok(existing.value().clone());
492            }
493            let Some(loader) = &self.loader else {
494                return Err(AgentError::Restore(BoxError::new(io::Error::other(
495                    "session loader not configured",
496                ))));
497            };
498            let loaded = loader
499                .load_session(id.clone())
500                .await
501                .map_err(AgentError::Restore)?;
502            let initial = self.resolve_initial_provider()?;
503            let session_tools = match &self.session_tools {
504                Some(factory) => factory
505                    .build_registry(loaded.info.cwd.clone(), loaded.info.mcp_servers.clone())
506                    .await
507                    .map_err(AgentError::Restore)?,
508                None => Arc::new(StaticToolRegistry::empty()) as Arc<dyn ToolRegistry>,
509            };
510
511            // After session enter hook (resume path). Same as `create_session`: retrieve
512            // the injected context.
513            let session_start_append = {
514                let cancel = CancellationToken::new();
515                let ctx = HookCtx::new(&loaded.info.id, &loaded.info.cwd, cancel);
516                let mut step = crate::hooks::step::AfterSessionEnter {
517                    cwd: loaded.info.cwd.to_string_lossy().into_owned(),
518                    source: crate::hooks::step::SessionSource::Resume,
519                    additional_context: Vec::new(),
520                };
521                let _ = self.hook_engine.dispatch(&mut step, ctx).await;
522                step.additional_context
523            };
524
525            let session_cancel = CancellationToken::new();
526            let (policy, modes) = self.session_policy_state();
527            let concrete = Arc::new(DefaultSession {
528                id: loaded.info.id.clone(),
529                cwd: loaded.info.cwd.clone(),
530                history: Arc::new(VecHistory::from_messages(loaded.history)) as Arc<dyn History>,
531                tools: self.apply_tool_allow(Arc::new(CompositeRegistry::new(
532                    session_tools,
533                    self.process_tools.clone(),
534                )))?,
535                registry: self.registry.clone(),
536                provider_state: RwLock::new(initial),
537                policy,
538                modes,
539                events: Arc::new(EventEmitter::new()),
540                permissions: Arc::new(PermissionGate::new()),
541                turn_state: Mutex::new(TurnSlot::default()),
542                background: crate::session::BackgroundTasks::new(
543                    session_cancel.clone(),
544                    self.background_progress,
545                ),
546                goal: self.goal.clone(),
547                compaction_slot: crate::session::CompactionSlot::new(),
548                turn_freed: Arc::new(tokio::sync::Notify::new()),
549                session_cancel,
550                config: RwLock::new(
551                    self.config
552                        .read()
553                        .expect("DefaultAgentCore config rwlock poisoned")
554                        .clone(),
555                ),
556                fs,
557                shell,
558                frontend,
559                http: self.http.clone(),
560                hook_engine: self.hook_engine.clone(),
561                session_start_append,
562                request_audit: RequestAuditTracker::new(),
563            });
564            tokio::spawn(DefaultSession::drive(Arc::downgrade(&concrete)));
565            let session = concrete as Arc<dyn Session>;
566
567            let session_info = loaded.info;
568            for observer in &self.observers {
569                observer
570                    .on_session_created(session.clone(), session_info.clone())
571                    .map_err(AgentError::Observer)?;
572            }
573
574            self.sessions.insert(id, session.clone());
575            Ok(session)
576        })
577    }
578
579    fn session(&self, id: &SessionId) -> Option<Arc<dyn Session>> {
580        self.sessions.get(id).map(|r| r.value().clone())
581    }
582}
583
584impl DefaultAgentCore {
585    /// Apply the top-level `--profile` tool allowlist (if configured) to a session's fully
586    /// assembled tool pool. No-op when `tool_allow` is `None`. Entries are glob patterns
587    /// (see [`crate::session::match_tool_allowlist`]); a top-level profile is a leaf agent,
588    /// so a matched `spawn_agent` is dropped. A pattern matching nothing is a hard error
589    /// (fail-loud).
590    fn apply_tool_allow(
591        &self,
592        pool: Arc<dyn ToolRegistry>,
593    ) -> Result<Arc<dyn ToolRegistry>, AgentError> {
594        let Some(allow) = &self.tool_allow else {
595            return Ok(pool);
596        };
597        crate::session::filter_registry_by_allowlist(&pool, allow).map_err(|pattern| {
598            AgentError::Other(BoxError::new(io::Error::other(format!(
599                "profile allows tool pattern `{pattern}` matching nothing in the built-in or MCP tool pool"
600            ))))
601        })
602    }
603
604    /// Look up the entry in the registry for the current [`TurnConfig::model`] and
605    /// resolve it into `(provider, hosted_capabilities)`. Shared by `create_session` /
606    /// `load_session`.
607    ///
608    /// The configured model must have an entry in the registry —
609    /// [`ProviderRegistry::new`] already validated the default model during CLI assembly,
610    /// so the only way to reach this error is builder misuse (registry and turn config
611    /// are inconsistent).
612    fn resolve_initial_provider(&self) -> Result<SessionProviderState, AgentError> {
613        let (vendor, model) = {
614            let cfg = self
615                .config
616                .read()
617                .expect("DefaultAgentCore config rwlock poisoned");
618            (cfg.provider.clone(), cfg.model.clone())
619        };
620        let entry = self.registry.entry_for(&vendor, &model).ok_or_else(|| {
621            AgentError::Other(BoxError::new(io::Error::other(format!(
622                "default model `{model}` is not declared by provider `{vendor}` in the registry"
623            ))))
624        })?;
625        let provider = entry.provider().clone();
626        let resolved = ResolvedSessionCapabilities::resolve(
627            entry.capabilities(),
628            provider.hosted_capabilities(),
629            &provider.info().vendor,
630        )?;
631        Ok(SessionProviderState {
632            provider,
633            hosted_capabilities: resolved.hosted,
634        })
635    }
636
637    /// Derive the initial `(active policy, mode catalog)` for a new session.
638    ///
639    /// When a [`ModeCatalog`] is configured: each session holds its own clone of the
640    /// catalog (so `current` can be switched independently), and the active policy is the
641    /// policy of the catalog's current mode. When not configured: the active policy is
642    /// the process-level `policy`, and there is no catalog (mode switching is
643    /// unavailable).
644    fn session_policy_state(&self) -> (RwLock<Arc<dyn SandboxPolicy>>, Option<Mutex<ModeCatalog>>) {
645        match &self.modes {
646            Some(catalog) => {
647                let catalog = catalog.clone();
648                let active = catalog.current_policy();
649                (RwLock::new(active), Some(Mutex::new(catalog)))
650            }
651            None => (RwLock::new(self.policy.clone()), None),
652        }
653    }
654}
655
656/// The currently selected real provider for the session, together with the parsed hosted
657/// capabilities of that provider.
658///
659/// Atomically replaced by `set_model` when switching providers.
660struct SessionProviderState {
661    provider: Arc<dyn LlmProvider>,
662    hosted_capabilities: HostedCapabilities,
663}
664
665pub struct DefaultSession {
666    id: SessionId,
667    cwd: PathBuf,
668    /// Use `Arc` instead of `Box` because the background compaction task
669    /// ([`CompactionSlot`](crate::session::CompactionSlot)) needs to hold it with a
670    /// `'static` lifetime across turns, requiring shared reference counting.
671    history: Arc<dyn History>,
672    tools: Arc<dyn ToolRegistry>,
673    /// Global provider directory. The session shares the same `Arc<ProviderRegistry>`
674    /// held by [`DefaultAgentCore`] — it is used to resolve candidates and owner
675    /// providers for `list_models` / `set_model`.
676    registry: Arc<ProviderRegistry>,
677    /// Current selected (provider, hosted_capabilities) state. `set_model` replaces the
678    /// entire pair when switching providers, ensuring `(provider, hosted_capabilities)`
679    /// is always consistent — there is no intermediate state where the provider has
680    /// changed but capabilities have not.
681    provider_state: RwLock<SessionProviderState>,
682    /// The currently active decision policy. Atomically replaced on `set_mode` (lock
683    /// order: after `modes`).
684    /// Uses `RwLock<Arc<_>>` rather than a bare `Arc` because the per-session permission
685    /// mode must be switchable at runtime; `run_turn` snapshots the policy via
686    /// `.read().clone()` at the start of each turn, so in-flight turns are unaffected by
687    /// subsequent switches (same semantics as `set_model`).
688    policy: RwLock<Arc<dyn SandboxPolicy>>,
689    /// Permission mode catalog. When `Some`, enables `session/set_mode` and ACP
690    /// `SessionModeState`; when `None`, `policy` is fixed and cannot be switched. Uses
691    /// `std::sync::Mutex` held only briefly, never across an await.
692    modes: Option<Mutex<ModeCatalog>>,
693    events: Arc<EventEmitter>,
694    permissions: Arc<PermissionGate>,
695    /// Single-turn mutex + cancel channel. `Some(token)` means a turn is running; `None`
696    /// means idle. The `std::sync::Mutex` is held briefly and never across an await.
697    turn_state: Mutex<TurnSlot>,
698    /// Session-level background task table (landing point for `run_in_background`). Holds
699    /// the task's `JoinHandle` to keep it alive past the originating turn; its internal
700    /// cancel token is independent of the turn's child token. `run_turn` clones it
701    /// through `TurnRunner` → `ToolContext` for injection into tools.
702    background: crate::session::BackgroundTasks,
703    /// Shared state for the `--goal` goal-driven loop. When `Some`, this session runs in
704    /// goal mode; the top-level turn injects it into tools via
705    /// [`crate::tool::ToolContext::goal`], and the `goal-gate` hook uses it to keep the
706    /// turn alive or let it proceed. Cloned from [`DefaultAgentCore::goal`]. `None` =
707    /// non-goal mode.
708    goal: Option<Arc<crate::session::GoalState>>,
709    /// Session-level single-flight compaction slot. When the soft watermark is exceeded,
710    /// the turn main loop asynchronously triggers one summary compaction without blocking
711    /// the current turn. See `session/turn/compaction_slot.rs`.
712    compaction_slot: crate::session::CompactionSlot,
713    /// Notifies when a turn slot is released. `TurnGuard::drop` calls `notify_one` — the
714    /// session driver waits on this after hitting `TurnInProgress`, so it can start an
715    /// autonomous turn continuation once the current turn ends (liveness guarantee for
716    /// autonomous continuation).
717    turn_freed: Arc<tokio::sync::Notify>,
718    /// Session-level cancellation token; cancelled when the session terminates, causing
719    /// the driver loop to exit. Also the source (same token) for cancellation tokens of
720    /// tasks inside `background`.
721    session_cancel: CancellationToken,
722    config: RwLock<TurnConfig>,
723    /// Session-level filesystem backend. Injected by [`AgentCore::create_session`];
724    /// `TurnRunner` borrows a `&dyn FsBackend` into [`crate::tool::ToolContext`] for
725    /// tools.
726    fs: Arc<dyn FsBackend>,
727    /// Session-level shell backend. Injected by [`AgentCore::create_session`] alongside
728    /// `fs`; the `bash` tool accesses it via [`crate::tool::ToolContext`].
729    shell: Arc<dyn ShellBackend>,
730    /// How the agent is accessed. Injected by [`AgentCore::create_session`] /
731    /// `load_session`, assembled into [`RunningContext`] during turn setup, and rendered
732    /// into the `# Environment` section of the system prompt.
733    frontend: Frontend,
734    /// HTTP fetch backend, shared across sessions in this core and held/cloned by
735    /// [`DefaultAgentCore`]. The `fetch` tool accesses it via
736    /// [`crate::tool::ToolContext`].
737    http: Arc<dyn HttpClient>,
738    /// Hook engine, shared across sessions in this core. When `run_turn` assembles
739    /// [`TurnRunner`], it borrows `&dyn HookEngine` to the main loop.
740    hook_engine: Arc<dyn HookEngine>,
741    /// Content appended by the `after_session_enter` hook during session startup (e.g.,
742    /// skill L1 manifest / always-on skill body). Populated by
743    /// [`AgentCore::create_session`] / `load_session` after the hook runs; on each turn,
744    /// when assembling the system prompt, [`merge_session_overlay`] merges it with the
745    /// explicit `config.system_prompt`, and
746    /// [`crate::session::prompt::resolve_system_prompt`] places it into the "Session
747    /// Instructions" section via hooks.
748    session_start_append: Vec<agent_client_protocol_schema::ContentBlock>,
749    /// Adjacent-request stability diagnostic. Emits a tracing record for every request
750    /// actually sent to the provider, helping to identify the source of cache misses.
751    request_audit: RequestAuditTracker,
752}
753
754impl DefaultSession {
755    fn current_provider(&self) -> Arc<dyn LlmProvider> {
756        self.provider_state
757            .read()
758            .expect("DefaultSession provider_state rwlock poisoned")
759            .provider
760            .clone()
761    }
762
763    fn current_hosted(&self) -> HostedCapabilities {
764        self.provider_state
765            .read()
766            .expect("DefaultSession provider_state rwlock poisoned")
767            .hosted_capabilities
768    }
769
770    /// Core execution of a turn, shared by user-initiated turns and automatic
771    /// continuation turns.
772    ///
773    /// `prompt` is either external input (user turn) or empty (automatic continuation
774    /// turn). In both cases, any completed background results are prepended as **prefix
775    /// blocks** to the prompt. An empty prompt with no background results does not start
776    /// a turn (returns `EndTurn` to avoid a no-op turn). Turn slot mutual exclusion is
777    /// still enforced at the top of this function.
778    async fn run_turn_core(
779        &self,
780        prompt: Vec<ContentBlock>,
781        ingest_source: crate::hooks::step::IngestSource,
782    ) -> Result<StopReason, TurnError> {
783        let span = tracing::info_span!(
784            "turn",
785            session_id = %short_id(self.id.0.as_ref()),
786            model = %self.current_model(),
787        );
788        async move {
789            let cancel = {
790                let mut slot = self
791                    .turn_state
792                    .lock()
793                    .expect("DefaultSession turn_state mutex poisoned");
794                if slot.cancel.is_some() {
795                    return Err(TurnError::TurnInProgress);
796                }
797                let cancel = CancellationToken::new();
798                slot.cancel = Some(cancel.clone());
799                cancel
800            };
801
802            // RAII guard: releases the slot and wakes the driver on any exit path
803            // (including panic inside an await).
804            let _guard = TurnGuard {
805                state: &self.turn_state,
806                freed: &self.turn_freed,
807            };
808
809            // Prepend completed background-task results as prefix blocks for the current
810            // prompt.
811            // Background task reflow.
812            let prompt = {
813                let outcomes = self.background.drain_completed();
814                if outcomes.is_empty() {
815                    prompt
816                } else {
817                    let mut blocks: Vec<ContentBlock> = outcomes
818                        .iter()
819                        .map(|o| {
820                            ContentBlock::from(
821                                crate::session::format_background_outcome(o).as_str(),
822                            )
823                        })
824                        .collect();
825                    blocks.extend(prompt);
826                    blocks
827                }
828            };
829
830            // Empty prompt (autonomous turn with no background results to consume) — skip
831            // the turn to avoid spinning.
832            if prompt.is_empty() {
833                return Ok(StopReason::EndTurn);
834            }
835
836            let config = self
837                .config
838                .read()
839                .expect("DefaultSession config rwlock poisoned")
840                .clone();
841            // Snapshot (provider, hosted) once at turn start — concurrent set_model
842            // requests within the same turn still use the chosen provider; changes take
843            // effect on the next turn.
844            let provider = self.current_provider();
845            let hosted = self.current_hosted();
846            let running_ctx = RunningContext::new(self.frontend, &self.cwd);
847            // Merge session-scoped injection (the `additional_context` from the
848            // `after_session_enter` hook, e.g. skill L1 manifest / always-on skill body)
849            // with the explicit `system_prompt` into a single "Session Instructions"
850            // overlay — both originate from the same source and target the same location,
851            // so no extra parameter is needed.
852            let session_overlay =
853                merge_session_overlay(config.system_prompt.as_deref(), &self.session_start_append);
854            let system_prompt = resolve_system_prompt(
855                &running_ctx,
856                &provider.info().vendor,
857                &config.model,
858                &config.base_prompt,
859                &config.prompt,
860                session_overlay.as_deref(),
861            )
862            .map_err(|err| TurnError::Internal(BoxError::new(err)))?;
863            // Snapshot the active policy for this turn: an in-progress turn uses a fixed
864            // policy, so
865            // `session/set_mode` changes only affect subsequent turns (same semantics as
866            // `set_model`).
867            // Use an owned `Arc` rather than a borrow — it flows with `ToolContext` into
868            // `spawn_agent`,
869            // ensuring child agents capture the parent's actual policy at this moment,
870            // not a stale process-level default.
871            let policy = self
872                .policy
873                .read()
874                .expect("DefaultSession policy rwlock poisoned")
875                .clone();
876            let runner = TurnRunner {
877                history: self.history.as_ref(),
878                tools: self.tools.as_ref(),
879                // Owned clone of the composite (built-in + MCP) for injection into
880                // ToolContext → spawn_agent, so subagent profiles can allow `mcp__*`.
881                session_tools: Some(self.tools.clone()),
882                provider: provider.as_ref(),
883                policy,
884                events: self.events.clone(),
885                permissions: self.permissions.as_ref(),
886                cancel,
887                config: &config,
888                // Owned clone for injection into ToolContext → spawn_agent, so a child
889                // agent inherits the parent's turn settings instead of bare defaults.
890                config_arc: Some(Arc::new(config.clone())),
891                system_prompt: system_prompt.map(Arc::from),
892                cwd: &self.cwd,
893                fs: self.fs.clone(),
894                shell: self.shell.clone(),
895                http: self.http.clone(),
896                hosted_capabilities: hosted,
897                hooks: self.hook_engine.as_ref(),
898                session_id: &self.id,
899                request_audit: &self.request_audit,
900                // Inject the session-level background task handle into the top-level
901                // turn, enabling the tool's `run_in_background` capability. Nested
902                // sub-agent turns do not receive this injection (see `spawn_agent`).
903                background: Some(self.background.clone()),
904                // Top-level turn injects the goal-loop state (`Some` under `--goal`
905                // mode); the `goal_done` tool and `goal-gate` hook use it to drive
906                // multi-turn autonomous loops. `None` in non-goal mode.
907                goal: self.goal.clone(),
908                // Inject the compaction slot, history Arc, and provider Arc into the
909                // top-level turn so that summary compaction can be triggered
910                // asynchronously when the soft watermark is exceeded. Sub-agent turns
911                // pass `None` for all of these (see `spawn_agent`).
912                compaction_slot: Some(self.compaction_slot.clone()),
913                history_arc: Some(self.history.clone()),
914                provider_arc: Some(provider.clone()),
915                session_cancel: Some(self.session_cancel.clone()),
916                ingest_source,
917            };
918
919            runner.run(prompt).await
920        }
921        .instrument(span)
922        .await
923    }
924
925    /// Session driver loop (autonomous turn continuation): a long-lived task that starts
926    /// an autonomous turn when a background task completes, consuming its results.
927    /// Spawned during `create_session` / `load_session`.
928    ///
929    /// Holds `Weak<Self>` instead of `Arc`: the driver must not keep the session alive
930    /// indefinitely. Each iteration first calls `upgrade` — when all external strong
931    /// references (the `AgentCore.sessions` DashMap) are gone, `upgrade` fails and the
932    /// driver exits. `session_cancel` is the explicit exit signal (process shutdown /
933    /// future session eviction).
934    ///
935    /// Two waiting paths:
936    /// - `background.wait_for_completion()`: a task completed → prepare to start an
937    ///   autonomous turn;
938    /// - `session_cancel.cancelled()`: session terminated → exit the loop.
939    ///
940    /// If a `TurnInProgress` is encountered before starting a turn (a user turn is
941    /// running), wait for `turn_freed` and retry — this is exactly where user input and
942    /// background results contend for the same turn slot: if the user turn arrives first,
943    /// it runs, and the background result either hitches a ride (via `run_turn_core`'s
944    /// drain) or waits for it to finish before starting its own turn.
945    async fn drive(weak: std::sync::Weak<Self>) {
946        loop {
947            let Some(this) = weak.upgrade() else { break };
948            if this.session_cancel.is_cancelled() {
949                break;
950            }
951            // First take the notified() future, then check the queue — avoid missing
952            // completion notifications that arrive between the two steps.
953            let completion = this.background.wait_for_completion();
954            if this.background.has_completed() {
955                this.run_autonomous_turn_with_retry().await;
956                continue;
957            }
958            tokio::select! {
959                () = completion => {
960                    this.run_autonomous_turn_with_retry().await;
961                }
962                () = this.session_cancel.cancelled() => break,
963            }
964        }
965    }
966
967    /// Run an autonomous turn; if the turn slot is occupied (a user turn is running),
968    /// wait for it to be released and retry, up to the point where the result is
969    /// consumed. Abort when `session_cancel` fires.
970    async fn run_autonomous_turn_with_retry(self: &Arc<Self>) {
971        loop {
972            if self.session_cancel.is_cancelled() {
973                return;
974            }
975            match self
976                .run_turn_core(Vec::new(), crate::hooks::step::IngestSource::Background)
977                .await
978            {
979                Err(TurnError::TurnInProgress) => {
980                    // A user turn is in progress. Wait for it to finish — its
981                    // `run_turn_core` will drain our background results (piggybacking),
982                    // so `has_completed` will be empty here and we exit naturally.
983                    tokio::select! {
984                        () = self.turn_freed.notified() => {}
985                        () = self.session_cancel.cancelled() => return,
986                    }
987                    if !self.background.has_completed() {
988                        // Consumed by an in-flight user turn (piggybacking) — no need to
989                        // start an autonomous turn.
990                        return;
991                    }
992                }
993                _ => return,
994            }
995        }
996    }
997}
998
999impl Drop for DefaultSession {
1000    fn drop(&mut self) {
1001        // On session drop, cancel `session_cancel`: this kills all in-flight background
1002        // tasks and wakes the driver loop's `session_cancel.cancelled()` branch so it
1003        // exits (the driver holds a `Weak`, which will now fail to upgrade).
1004        self.session_cancel.cancel();
1005    }
1006}
1007
1008#[derive(Default)]
1009struct TurnSlot {
1010    cancel: Option<CancellationToken>,
1011}
1012
1013/// A guard that occupies a turn slot on construction and releases it on drop.
1014struct TurnGuard<'a> {
1015    state: &'a Mutex<TurnSlot>,
1016    /// Notifies the session driver when the turn is released (liveness guarantee for
1017    /// proactive turn renewal).
1018    freed: &'a tokio::sync::Notify,
1019}
1020
1021impl<'a> Drop for TurnGuard<'a> {
1022    fn drop(&mut self) {
1023        if let Ok(mut slot) = self.state.lock() {
1024            slot.cancel = None;
1025        }
1026        // Turn slot is now empty; wake the driver that may be waiting to start its own
1027        // turn.
1028        self.freed.notify_one();
1029    }
1030}
1031
1032impl Session for DefaultSession {
1033    fn id(&self) -> &SessionId {
1034        &self.id
1035    }
1036
1037    fn provider_info(&self) -> ProviderInfo {
1038        self.current_provider().info()
1039    }
1040
1041    fn current_model(&self) -> String {
1042        self.config
1043            .read()
1044            .expect("DefaultSession config rwlock poisoned")
1045            .model
1046            .clone()
1047    }
1048
1049    fn list_models(&self) -> BoxFuture<'_, Result<Vec<ModelInfo>, ProviderError>> {
1050        Box::pin(async move {
1051            // Under multi-provider assembly, the candidate set comes from the registry —
1052            // each entry already carries its own model list (populated during CLI
1053            // assembly). It is then filtered against the session's `allowed_models`
1054            // allowlist. The registry makes no network requests, so this path always
1055            // succeeds.
1056            let allowed_models = self
1057                .config
1058                .read()
1059                .expect("DefaultSession config rwlock poisoned")
1060                .allowed_models
1061                .clone();
1062            let candidates = self.registry.list_candidates();
1063            let mut models: Vec<ModelInfo> = candidates
1064                .into_iter()
1065                .map(|candidate| {
1066                    decorate_with_provider_display(candidate.model, &candidate.provider)
1067                })
1068                .collect();
1069            models = filter_allowed_models(models, allowed_models.as_deref());
1070            Ok(models)
1071        })
1072    }
1073
1074    fn list_candidates(&self) -> BoxFuture<'_, Result<Vec<ModelCandidate>, ProviderError>> {
1075        Box::pin(async move {
1076            let allowed_models = self
1077                .config
1078                .read()
1079                .expect("DefaultSession config rwlock poisoned")
1080                .allowed_models
1081                .clone();
1082            let candidates = self.registry.list_candidates();
1083            let candidates: Vec<ModelCandidate> = match allowed_models {
1084                Some(allowed) => candidates
1085                    .into_iter()
1086                    .filter(|c| allowed.iter().any(|id| id == &c.model.id))
1087                    .collect(),
1088                None => candidates,
1089            };
1090            Ok(candidates)
1091        })
1092    }
1093
1094    fn set_model(&self, selection: ModelSelection) -> BoxFuture<'_, Result<(), ProviderError>> {
1095        Box::pin(async move {
1096            let ModelSelection { provider, model } = selection;
1097            let allowed_models = self
1098                .config
1099                .read()
1100                .expect("DefaultSession config rwlock poisoned")
1101                .allowed_models
1102                .clone();
1103            // Note: `allowed_models` is a flat list of model IDs without a vendor
1104            // dimension — models with the same name are allowed or denied uniformly
1105            // across all providers. Pairing is deferred to future work.
1106            if let Some(allowed_models) = allowed_models.as_ref()
1107                && !allowed_models.iter().any(|allowed| allowed == &model)
1108            {
1109                return Err(ProviderError::new(ProviderErrorKind::ModelNotFound {
1110                    model,
1111                }));
1112            }
1113
1114            let Some(entry) = self.registry.entry_for(&provider, &model) else {
1115                return Err(ProviderError::new(ProviderErrorKind::ModelNotFound {
1116                    model,
1117                }));
1118            };
1119
1120            // When switching providers, re-resolve hosted capabilities: each entry
1121            // carries its own [`SessionCapabilitiesConfig`], which is cross-referenced
1122            // with the provider's hosted_capabilities. If the delegate is not supported
1123            // by the provider, a `ProviderError` is returned — preserving the stable
1124            // failure semantics of `set_model`.
1125            let new_provider = entry.provider().clone();
1126            let resolved = ResolvedSessionCapabilities::resolve(
1127                entry.capabilities(),
1128                new_provider.hosted_capabilities(),
1129                &new_provider.info().vendor,
1130            )
1131            .map_err(|err| {
1132                ProviderError::new(ProviderErrorKind::Other(BoxError::new(io::Error::other(
1133                    err.to_string(),
1134                ))))
1135            })?;
1136
1137            // Lock order: `provider_state` before `config`, matching the snapshot path in
1138            // `run_turn`.
1139            // The window where both write locks are held is very short (just a few
1140            // assignments) and will not block the main loop.
1141            {
1142                let mut state = self
1143                    .provider_state
1144                    .write()
1145                    .expect("DefaultSession provider_state rwlock poisoned");
1146                state.provider = new_provider;
1147                state.hosted_capabilities = resolved.hosted;
1148            }
1149            let mut config = self
1150                .config
1151                .write()
1152                .expect("DefaultSession config rwlock poisoned");
1153            config.provider = provider;
1154            config.model = model;
1155            Ok(())
1156        })
1157    }
1158
1159    fn current_mode(&self) -> Option<String> {
1160        self.modes.as_ref().map(|m| {
1161            m.lock()
1162                .expect("DefaultSession modes mutex poisoned")
1163                .current_id()
1164                .to_string()
1165        })
1166    }
1167
1168    fn available_modes(&self) -> Vec<crate::session::ModeDescriptor> {
1169        let Some(modes) = self.modes.as_ref() else {
1170            return Vec::new();
1171        };
1172        modes
1173            .lock()
1174            .expect("DefaultSession modes mutex poisoned")
1175            .modes()
1176            .iter()
1177            .map(|m| crate::session::ModeDescriptor {
1178                id: m.id.clone(),
1179                name: m.name.clone(),
1180                description: m.description.clone(),
1181            })
1182            .collect()
1183    }
1184
1185    fn set_mode(&self, mode_id: String) -> Result<(), AgentError> {
1186        let Some(modes) = self.modes.as_ref() else {
1187            return Err(AgentError::ModeNotFound(mode_id));
1188        };
1189        // Lock order: `modes` before `policy` (no overlap with `run_turn`'s read path —
1190        // `run_turn` only reads `policy`). Both locks are held briefly and never across
1191        // an `.await`.
1192        let mut catalog = modes.lock().expect("DefaultSession modes mutex poisoned");
1193        if !catalog.set_current(&mode_id) {
1194            return Err(AgentError::ModeNotFound(mode_id));
1195        }
1196        let active = catalog.current_policy();
1197        *self
1198            .policy
1199            .write()
1200            .expect("DefaultSession policy rwlock poisoned") = active;
1201        Ok(())
1202    }
1203
1204    fn current_reasoning_effort(&self) -> Option<ReasoningEffort> {
1205        self.config
1206            .read()
1207            .expect("DefaultSession config rwlock poisoned")
1208            .sampling
1209            .reasoning_effort
1210    }
1211
1212    fn set_reasoning_effort(&self, effort: Option<ReasoningEffort>) {
1213        self.config
1214            .write()
1215            .expect("DefaultSession config rwlock poisoned")
1216            .sampling
1217            .reasoning_effort = effort;
1218    }
1219
1220    fn subscribe(&self) -> EventStream {
1221        self.events.subscribe()
1222    }
1223
1224    fn history_snapshot(&self) -> Vec<Message> {
1225        self.history.snapshot()
1226    }
1227
1228    fn run_turn(&self, prompt: Vec<ContentBlock>) -> BoxFuture<'_, Result<StopReason, TurnError>> {
1229        // User-driven turn: piggybacks completed background results as a prefix to the
1230        // prompt.
1231        // (Passive backflow, complementary to active continuation — active continuation
1232        // handles idle state, piggybacking handles "user happened to speak up".)
1233        Box::pin(self.run_turn_core(prompt, crate::hooks::step::IngestSource::User))
1234    }
1235
1236    fn cancel_turn(&self) {
1237        let token = {
1238            let slot = self
1239                .turn_state
1240                .lock()
1241                .expect("DefaultSession turn_state mutex poisoned");
1242            slot.cancel.clone()
1243        };
1244        if let Some(token) = token {
1245            token.cancel();
1246        }
1247        // No turn running → no-op (idempotent)
1248    }
1249
1250    fn resolve_permission(&self, id: ToolCallId, outcome: PermissionResolution) {
1251        self.permissions.resolve(&id, outcome);
1252    }
1253
1254    fn context_status(&self) -> ContextStatus {
1255        let used_tokens = self.history.token_estimate();
1256        let context_window = {
1257            let model = self
1258                .config
1259                .read()
1260                .expect("DefaultSession config rwlock poisoned")
1261                .model
1262                .clone();
1263            self.current_provider()
1264                .model_info(&model)
1265                .and_then(|m| m.context_window)
1266        };
1267        let ratio = match (used_tokens, context_window) {
1268            (Some(used), Some(window)) if window > 0 => Some(used as f64 / window as f64),
1269            _ => None,
1270        };
1271        ContextStatus {
1272            used_tokens,
1273            context_window,
1274            ratio,
1275        }
1276    }
1277
1278    fn compact_now(&self) -> BoxFuture<'_, Result<Option<CompactionReport>, TurnError>> {
1279        Box::pin(async move {
1280            // A turn rewrites history concurrently with compaction; refuse rather than
1281            // race. The caller should `/cancel` or wait. (Held briefly, never across await.)
1282            {
1283                let slot = self
1284                    .turn_state
1285                    .lock()
1286                    .expect("DefaultSession turn_state mutex poisoned");
1287                if slot.cancel.is_some() {
1288                    return Err(TurnError::TurnInProgress);
1289                }
1290            }
1291
1292            let (model, sampling) = {
1293                let config = self
1294                    .config
1295                    .read()
1296                    .expect("DefaultSession config rwlock poisoned");
1297                (config.model.clone(), config.sampling.clone())
1298            };
1299            let ctx = CompactionCtx {
1300                provider: self.current_provider(),
1301                model,
1302                sampling,
1303                tools: self.tools.schemas(),
1304                cancel: self.session_cancel.clone(),
1305            };
1306
1307            // Force a compaction regardless of watermark: use the current estimate as the
1308            // threshold so boundary selection keeps a sensible tail. If history is empty /
1309            // has no estimate, there is nothing to compact.
1310            let Some(threshold) = self.history.token_estimate() else {
1311                return Ok(None);
1312            };
1313            let report = run_sync_compaction(self.history.as_ref(), &ctx, threshold).await;
1314            if let Some(report) = report {
1315                self.events
1316                    .emit(AgentEvent::ContextCompressed {
1317                        tokens_before: report.tokens_before,
1318                        tokens_after: report.tokens_after,
1319                    })
1320                    .await;
1321            }
1322            Ok(report)
1323        })
1324    }
1325}
1326
1327fn filter_allowed_models(
1328    available_models: Vec<ModelInfo>,
1329    allowed_models: Option<&[String]>,
1330) -> Vec<ModelInfo> {
1331    let Some(allowed_models) = allowed_models else {
1332        return available_models;
1333    };
1334
1335    available_models
1336        .into_iter()
1337        .filter(|model| allowed_models.iter().any(|allowed| allowed == &model.id))
1338        .collect()
1339}
1340
1341/// Prepend the provider name to the model's `display_name` so that ACP clients can
1342/// distinguish the same model ID served by different providers — the only reliable
1343/// disambiguation when multiple gateways expose the same model (e.g. "OpenAI: gpt-4o" vs
1344/// "gw-b: gpt-4o").
1345fn decorate_with_provider_display(mut model: ModelInfo, provider: &ProviderInfo) -> ModelInfo {
1346    let name = model
1347        .display_name
1348        .clone()
1349        .unwrap_or_else(|| model.id.clone());
1350    model.display_name = Some(format!("{}: {name}", provider.display_name));
1351    model
1352}
1353
1354/// Generates a session ID as a random UUID v4.
1355///
1356/// The `defect-acp` `session/new` handler needs a [`SessionId`] (to construct an
1357/// `AcpFsBackend`) before calling [`AgentCore::create_session`]; this function is
1358/// public so that both acp and tests can produce IDs in a consistent format.
1359///
1360/// Using a globally unique UUID instead of an in-process counter plus timestamp
1361/// avoids collisions across process restarts and concurrent instances, and allows
1362/// downstream consumers (storage on-disk directories, observability trace
1363/// correlation) to use it as a stable primary key.
1364pub fn new_session_id() -> String {
1365    uuid::Uuid::new_v4().to_string()
1366}
1367
1368/// Short session ID for tracing spans: takes the first 12 characters. Diagnostic use
1369/// only.
1370fn short_id(s: &str) -> &str {
1371    match s.char_indices().nth(12) {
1372        Some((idx, _)) => &s[..idx],
1373        None => s,
1374    }
1375}
1376
1377/// Merge the explicit `config.system_prompt` with the text-only content blocks from the
1378/// session-start hook's `append` into a single overlay string for the `session_overlay`
1379/// parameter of [`resolve_system_prompt`]. Returns `None` when both are empty (no empty
1380/// segment injected); when both are present they are separated by `\n\n`.
1381fn merge_session_overlay(system_prompt: Option<&str>, append: &[ContentBlock]) -> Option<String> {
1382    let appended: String = append
1383        .iter()
1384        .filter_map(|b| match b {
1385            ContentBlock::Text(t) => Some(t.text.as_str()),
1386            _ => None,
1387        })
1388        .collect::<Vec<_>>()
1389        .join("\n\n");
1390    match (system_prompt, appended.is_empty()) {
1391        (Some(sp), true) => Some(sp.to_owned()),
1392        (Some(sp), false) => Some(format!("{sp}\n\n{appended}")),
1393        (None, false) => Some(appended),
1394        (None, true) => None,
1395    }
1396}