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    ///
591    /// In `--goal` mode the `goal_done` tool is force-kept regardless of the allowlist: it
592    /// is the only way the agent can signal the goal is reached and let the loop exit, so a
593    /// profile that omits it must not silently strip it.
594    fn apply_tool_allow(
595        &self,
596        pool: Arc<dyn ToolRegistry>,
597    ) -> Result<Arc<dyn ToolRegistry>, AgentError> {
598        let Some(allow) = &self.tool_allow else {
599            return Ok(pool);
600        };
601        let filtered =
602            crate::session::filter_registry_by_allowlist(&pool, allow).map_err(|pattern| {
603                AgentError::Other(BoxError::new(io::Error::other(format!(
604                    "profile allows tool pattern `{pattern}` matching nothing in the built-in or MCP tool pool"
605                ))))
606            })?;
607        if self.goal.is_some()
608            && filtered.get(crate::tool::GOAL_DONE_TOOL_NAME).is_none()
609            && let Some(goal_done) = pool.get(crate::tool::GOAL_DONE_TOOL_NAME)
610        {
611            let mut builder = StaticToolRegistry::builder();
612            builder = builder.insert(goal_done);
613            let overlay = Arc::new(builder.build()) as Arc<dyn ToolRegistry>;
614            return Ok(Arc::new(CompositeRegistry::new(overlay, filtered)));
615        }
616        Ok(filtered)
617    }
618
619    /// Look up the entry in the registry for the current [`TurnConfig::model`] and
620    /// resolve it into `(provider, hosted_capabilities)`. Shared by `create_session` /
621    /// `load_session`.
622    ///
623    /// The configured model must have an entry in the registry —
624    /// [`ProviderRegistry::new`] already validated the default model during CLI assembly,
625    /// so the only way to reach this error is builder misuse (registry and turn config
626    /// are inconsistent).
627    fn resolve_initial_provider(&self) -> Result<SessionProviderState, AgentError> {
628        let (vendor, model) = {
629            let cfg = self
630                .config
631                .read()
632                .expect("DefaultAgentCore config rwlock poisoned");
633            (cfg.provider.clone(), cfg.model.clone())
634        };
635        let entry = self.registry.entry_for(&vendor, &model).ok_or_else(|| {
636            AgentError::Other(BoxError::new(io::Error::other(format!(
637                "default model `{model}` is not declared by provider `{vendor}` in the registry"
638            ))))
639        })?;
640        let provider = entry.provider().clone();
641        let resolved = ResolvedSessionCapabilities::resolve(
642            entry.capabilities(),
643            provider.hosted_capabilities(),
644            &provider.info().vendor,
645        )?;
646        Ok(SessionProviderState {
647            provider,
648            hosted_capabilities: resolved.hosted,
649        })
650    }
651
652    /// Derive the initial `(active policy, mode catalog)` for a new session.
653    ///
654    /// When a [`ModeCatalog`] is configured: each session holds its own clone of the
655    /// catalog (so `current` can be switched independently), and the active policy is the
656    /// policy of the catalog's current mode. When not configured: the active policy is
657    /// the process-level `policy`, and there is no catalog (mode switching is
658    /// unavailable).
659    fn session_policy_state(&self) -> (RwLock<Arc<dyn SandboxPolicy>>, Option<Mutex<ModeCatalog>>) {
660        match &self.modes {
661            Some(catalog) => {
662                let catalog = catalog.clone();
663                let active = catalog.current_policy();
664                (RwLock::new(active), Some(Mutex::new(catalog)))
665            }
666            None => (RwLock::new(self.policy.clone()), None),
667        }
668    }
669}
670
671/// The currently selected real provider for the session, together with the parsed hosted
672/// capabilities of that provider.
673///
674/// Atomically replaced by `set_model` when switching providers.
675struct SessionProviderState {
676    provider: Arc<dyn LlmProvider>,
677    hosted_capabilities: HostedCapabilities,
678}
679
680pub struct DefaultSession {
681    id: SessionId,
682    cwd: PathBuf,
683    /// Use `Arc` instead of `Box` because the background compaction task
684    /// ([`CompactionSlot`](crate::session::CompactionSlot)) needs to hold it with a
685    /// `'static` lifetime across turns, requiring shared reference counting.
686    history: Arc<dyn History>,
687    tools: Arc<dyn ToolRegistry>,
688    /// Global provider directory. The session shares the same `Arc<ProviderRegistry>`
689    /// held by [`DefaultAgentCore`] — it is used to resolve candidates and owner
690    /// providers for `list_models` / `set_model`.
691    registry: Arc<ProviderRegistry>,
692    /// Current selected (provider, hosted_capabilities) state. `set_model` replaces the
693    /// entire pair when switching providers, ensuring `(provider, hosted_capabilities)`
694    /// is always consistent — there is no intermediate state where the provider has
695    /// changed but capabilities have not.
696    provider_state: RwLock<SessionProviderState>,
697    /// The currently active decision policy. Atomically replaced on `set_mode` (lock
698    /// order: after `modes`).
699    /// Uses `RwLock<Arc<_>>` rather than a bare `Arc` because the per-session permission
700    /// mode must be switchable at runtime; `run_turn` snapshots the policy via
701    /// `.read().clone()` at the start of each turn, so in-flight turns are unaffected by
702    /// subsequent switches (same semantics as `set_model`).
703    policy: RwLock<Arc<dyn SandboxPolicy>>,
704    /// Permission mode catalog. When `Some`, enables `session/set_mode` and ACP
705    /// `SessionModeState`; when `None`, `policy` is fixed and cannot be switched. Uses
706    /// `std::sync::Mutex` held only briefly, never across an await.
707    modes: Option<Mutex<ModeCatalog>>,
708    events: Arc<EventEmitter>,
709    permissions: Arc<PermissionGate>,
710    /// Single-turn mutex + cancel channel. `Some(token)` means a turn is running; `None`
711    /// means idle. The `std::sync::Mutex` is held briefly and never across an await.
712    turn_state: Mutex<TurnSlot>,
713    /// Session-level background task table (landing point for `run_in_background`). Holds
714    /// the task's `JoinHandle` to keep it alive past the originating turn; its internal
715    /// cancel token is independent of the turn's child token. `run_turn` clones it
716    /// through `TurnRunner` → `ToolContext` for injection into tools.
717    background: crate::session::BackgroundTasks,
718    /// Shared state for the `--goal` goal-driven loop. When `Some`, this session runs in
719    /// goal mode; the top-level turn injects it into tools via
720    /// [`crate::tool::ToolContext::goal`], and the `goal-gate` hook uses it to keep the
721    /// turn alive or let it proceed. Cloned from [`DefaultAgentCore::goal`]. `None` =
722    /// non-goal mode.
723    goal: Option<Arc<crate::session::GoalState>>,
724    /// Session-level single-flight compaction slot. When the soft watermark is exceeded,
725    /// the turn main loop asynchronously triggers one summary compaction without blocking
726    /// the current turn. See `session/turn/compaction_slot.rs`.
727    compaction_slot: crate::session::CompactionSlot,
728    /// Notifies when a turn slot is released. `TurnGuard::drop` calls `notify_one` — the
729    /// session driver waits on this after hitting `TurnInProgress`, so it can start an
730    /// autonomous turn continuation once the current turn ends (liveness guarantee for
731    /// autonomous continuation).
732    turn_freed: Arc<tokio::sync::Notify>,
733    /// Session-level cancellation token; cancelled when the session terminates, causing
734    /// the driver loop to exit. Also the source (same token) for cancellation tokens of
735    /// tasks inside `background`.
736    session_cancel: CancellationToken,
737    config: RwLock<TurnConfig>,
738    /// Session-level filesystem backend. Injected by [`AgentCore::create_session`];
739    /// `TurnRunner` borrows a `&dyn FsBackend` into [`crate::tool::ToolContext`] for
740    /// tools.
741    fs: Arc<dyn FsBackend>,
742    /// Session-level shell backend. Injected by [`AgentCore::create_session`] alongside
743    /// `fs`; the `bash` tool accesses it via [`crate::tool::ToolContext`].
744    shell: Arc<dyn ShellBackend>,
745    /// How the agent is accessed. Injected by [`AgentCore::create_session`] /
746    /// `load_session`, assembled into [`RunningContext`] during turn setup, and rendered
747    /// into the `# Environment` section of the system prompt.
748    frontend: Frontend,
749    /// HTTP fetch backend, shared across sessions in this core and held/cloned by
750    /// [`DefaultAgentCore`]. The `fetch` tool accesses it via
751    /// [`crate::tool::ToolContext`].
752    http: Arc<dyn HttpClient>,
753    /// Hook engine, shared across sessions in this core. When `run_turn` assembles
754    /// [`TurnRunner`], it borrows `&dyn HookEngine` to the main loop.
755    hook_engine: Arc<dyn HookEngine>,
756    /// Content appended by the `after_session_enter` hook during session startup (e.g.,
757    /// skill L1 manifest / always-on skill body). Populated by
758    /// [`AgentCore::create_session`] / `load_session` after the hook runs; on each turn,
759    /// when assembling the system prompt, [`merge_session_overlay`] merges it with the
760    /// explicit `config.system_prompt`, and
761    /// [`crate::session::prompt::resolve_system_prompt`] places it into the "Session
762    /// Instructions" section via hooks.
763    session_start_append: Vec<agent_client_protocol_schema::ContentBlock>,
764    /// Adjacent-request stability diagnostic. Emits a tracing record for every request
765    /// actually sent to the provider, helping to identify the source of cache misses.
766    request_audit: RequestAuditTracker,
767}
768
769impl DefaultSession {
770    fn current_provider(&self) -> Arc<dyn LlmProvider> {
771        self.provider_state
772            .read()
773            .expect("DefaultSession provider_state rwlock poisoned")
774            .provider
775            .clone()
776    }
777
778    fn current_hosted(&self) -> HostedCapabilities {
779        self.provider_state
780            .read()
781            .expect("DefaultSession provider_state rwlock poisoned")
782            .hosted_capabilities
783    }
784
785    /// Core execution of a turn, shared by user-initiated turns and automatic
786    /// continuation turns.
787    ///
788    /// `prompt` is either external input (user turn) or empty (automatic continuation
789    /// turn). In both cases, any completed background results are prepended as **prefix
790    /// blocks** to the prompt. An empty prompt with no background results does not start
791    /// a turn (returns `EndTurn` to avoid a no-op turn). Turn slot mutual exclusion is
792    /// still enforced at the top of this function.
793    async fn run_turn_core(
794        &self,
795        prompt: Vec<ContentBlock>,
796        ingest_source: crate::hooks::step::IngestSource,
797    ) -> Result<StopReason, TurnError> {
798        let span = tracing::info_span!(
799            "turn",
800            session_id = %short_id(self.id.0.as_ref()),
801            model = %self.current_model(),
802        );
803        async move {
804            let cancel = {
805                let mut slot = self
806                    .turn_state
807                    .lock()
808                    .expect("DefaultSession turn_state mutex poisoned");
809                if slot.cancel.is_some() {
810                    return Err(TurnError::TurnInProgress);
811                }
812                let cancel = CancellationToken::new();
813                slot.cancel = Some(cancel.clone());
814                cancel
815            };
816
817            // RAII guard: releases the slot and wakes the driver on any exit path
818            // (including panic inside an await).
819            let _guard = TurnGuard {
820                state: &self.turn_state,
821                freed: &self.turn_freed,
822            };
823
824            // Prepend completed background-task results as prefix blocks for the current
825            // prompt.
826            // Background task reflow.
827            let prompt = {
828                let outcomes = self.background.drain_completed();
829                if outcomes.is_empty() {
830                    prompt
831                } else {
832                    let mut blocks: Vec<ContentBlock> = outcomes
833                        .iter()
834                        .map(|o| {
835                            ContentBlock::from(
836                                crate::session::format_background_outcome(o).as_str(),
837                            )
838                        })
839                        .collect();
840                    blocks.extend(prompt);
841                    blocks
842                }
843            };
844
845            // Empty prompt (autonomous turn with no background results to consume) — skip
846            // the turn to avoid spinning.
847            if prompt.is_empty() {
848                return Ok(StopReason::EndTurn);
849            }
850
851            let config = self
852                .config
853                .read()
854                .expect("DefaultSession config rwlock poisoned")
855                .clone();
856            // Snapshot (provider, hosted) once at turn start — concurrent set_model
857            // requests within the same turn still use the chosen provider; changes take
858            // effect on the next turn.
859            let provider = self.current_provider();
860            let hosted = self.current_hosted();
861            let running_ctx = RunningContext::new(self.frontend, &self.cwd);
862            // Merge session-scoped injection (the `additional_context` from the
863            // `after_session_enter` hook, e.g. skill L1 manifest / always-on skill body)
864            // with the explicit `system_prompt` into a single "Session Instructions"
865            // overlay — both originate from the same source and target the same location,
866            // so no extra parameter is needed.
867            let session_overlay =
868                merge_session_overlay(config.system_prompt.as_deref(), &self.session_start_append);
869            let system_prompt = resolve_system_prompt(
870                &running_ctx,
871                &provider.info().vendor,
872                &config.model,
873                &config.base_prompt,
874                &config.prompt,
875                session_overlay.as_deref(),
876            )
877            .map_err(|err| TurnError::Internal(BoxError::new(err)))?;
878            // Snapshot the active policy for this turn: an in-progress turn uses a fixed
879            // policy, so
880            // `session/set_mode` changes only affect subsequent turns (same semantics as
881            // `set_model`).
882            // Use an owned `Arc` rather than a borrow — it flows with `ToolContext` into
883            // `spawn_agent`,
884            // ensuring child agents capture the parent's actual policy at this moment,
885            // not a stale process-level default.
886            let policy = self
887                .policy
888                .read()
889                .expect("DefaultSession policy rwlock poisoned")
890                .clone();
891            let runner = TurnRunner {
892                history: self.history.as_ref(),
893                tools: self.tools.as_ref(),
894                // Owned clone of the composite (built-in + MCP) for injection into
895                // ToolContext → spawn_agent, so subagent profiles can allow `mcp__*`.
896                session_tools: Some(self.tools.clone()),
897                provider: provider.as_ref(),
898                policy,
899                events: self.events.clone(),
900                permissions: self.permissions.as_ref(),
901                cancel,
902                config: &config,
903                // Owned clone for injection into ToolContext → spawn_agent, so a child
904                // agent inherits the parent's turn settings instead of bare defaults.
905                config_arc: Some(Arc::new(config.clone())),
906                system_prompt: system_prompt.map(Arc::from),
907                cwd: &self.cwd,
908                fs: self.fs.clone(),
909                shell: self.shell.clone(),
910                http: self.http.clone(),
911                hosted_capabilities: hosted,
912                hooks: self.hook_engine.as_ref(),
913                session_id: &self.id,
914                request_audit: &self.request_audit,
915                // Inject the session-level background task handle into the top-level
916                // turn, enabling the tool's `run_in_background` capability. Nested
917                // sub-agent turns do not receive this injection (see `spawn_agent`).
918                background: Some(self.background.clone()),
919                // Top-level turn injects the goal-loop state (`Some` under `--goal`
920                // mode); the `goal_done` tool and `goal-gate` hook use it to drive
921                // multi-turn autonomous loops. `None` in non-goal mode.
922                goal: self.goal.clone(),
923                // Inject the compaction slot, history Arc, and provider Arc into the
924                // top-level turn so that summary compaction can be triggered
925                // asynchronously when the soft watermark is exceeded. Sub-agent turns
926                // pass `None` for all of these (see `spawn_agent`).
927                compaction_slot: Some(self.compaction_slot.clone()),
928                history_arc: Some(self.history.clone()),
929                provider_arc: Some(provider.clone()),
930                session_cancel: Some(self.session_cancel.clone()),
931                ingest_source,
932            };
933
934            runner.run(prompt).await
935        }
936        .instrument(span)
937        .await
938    }
939
940    /// Session driver loop (autonomous turn continuation): a long-lived task that starts
941    /// an autonomous turn when a background task completes, consuming its results.
942    /// Spawned during `create_session` / `load_session`.
943    ///
944    /// Holds `Weak<Self>` instead of `Arc`: the driver must not keep the session alive
945    /// indefinitely. Each iteration first calls `upgrade` — when all external strong
946    /// references (the `AgentCore.sessions` DashMap) are gone, `upgrade` fails and the
947    /// driver exits. `session_cancel` is the explicit exit signal (process shutdown /
948    /// future session eviction).
949    ///
950    /// Two waiting paths:
951    /// - `background.wait_for_completion()`: a task completed → prepare to start an
952    ///   autonomous turn;
953    /// - `session_cancel.cancelled()`: session terminated → exit the loop.
954    ///
955    /// If a `TurnInProgress` is encountered before starting a turn (a user turn is
956    /// running), wait for `turn_freed` and retry — this is exactly where user input and
957    /// background results contend for the same turn slot: if the user turn arrives first,
958    /// it runs, and the background result either hitches a ride (via `run_turn_core`'s
959    /// drain) or waits for it to finish before starting its own turn.
960    async fn drive(weak: std::sync::Weak<Self>) {
961        loop {
962            let Some(this) = weak.upgrade() else { break };
963            if this.session_cancel.is_cancelled() {
964                break;
965            }
966            // First take the notified() future, then check the queue — avoid missing
967            // completion notifications that arrive between the two steps.
968            let completion = this.background.wait_for_completion();
969            if this.background.has_completed() {
970                this.run_autonomous_turn_with_retry().await;
971                continue;
972            }
973            tokio::select! {
974                () = completion => {
975                    this.run_autonomous_turn_with_retry().await;
976                }
977                () = this.session_cancel.cancelled() => break,
978            }
979        }
980    }
981
982    /// Run an autonomous turn; if the turn slot is occupied (a user turn is running),
983    /// wait for it to be released and retry, up to the point where the result is
984    /// consumed. Abort when `session_cancel` fires.
985    async fn run_autonomous_turn_with_retry(self: &Arc<Self>) {
986        loop {
987            if self.session_cancel.is_cancelled() {
988                return;
989            }
990            match self
991                .run_turn_core(Vec::new(), crate::hooks::step::IngestSource::Background)
992                .await
993            {
994                Err(TurnError::TurnInProgress) => {
995                    // A user turn is in progress. Wait for it to finish — its
996                    // `run_turn_core` will drain our background results (piggybacking),
997                    // so `has_completed` will be empty here and we exit naturally.
998                    tokio::select! {
999                        () = self.turn_freed.notified() => {}
1000                        () = self.session_cancel.cancelled() => return,
1001                    }
1002                    if !self.background.has_completed() {
1003                        // Consumed by an in-flight user turn (piggybacking) — no need to
1004                        // start an autonomous turn.
1005                        return;
1006                    }
1007                }
1008                _ => return,
1009            }
1010        }
1011    }
1012}
1013
1014impl Drop for DefaultSession {
1015    fn drop(&mut self) {
1016        // On session drop, cancel `session_cancel`: this kills all in-flight background
1017        // tasks and wakes the driver loop's `session_cancel.cancelled()` branch so it
1018        // exits (the driver holds a `Weak`, which will now fail to upgrade).
1019        self.session_cancel.cancel();
1020    }
1021}
1022
1023#[derive(Default)]
1024struct TurnSlot {
1025    cancel: Option<CancellationToken>,
1026}
1027
1028/// A guard that occupies a turn slot on construction and releases it on drop.
1029struct TurnGuard<'a> {
1030    state: &'a Mutex<TurnSlot>,
1031    /// Notifies the session driver when the turn is released (liveness guarantee for
1032    /// proactive turn renewal).
1033    freed: &'a tokio::sync::Notify,
1034}
1035
1036impl<'a> Drop for TurnGuard<'a> {
1037    fn drop(&mut self) {
1038        if let Ok(mut slot) = self.state.lock() {
1039            slot.cancel = None;
1040        }
1041        // Turn slot is now empty; wake the driver that may be waiting to start its own
1042        // turn.
1043        self.freed.notify_one();
1044    }
1045}
1046
1047impl Session for DefaultSession {
1048    fn id(&self) -> &SessionId {
1049        &self.id
1050    }
1051
1052    fn provider_info(&self) -> ProviderInfo {
1053        self.current_provider().info()
1054    }
1055
1056    fn current_model(&self) -> String {
1057        self.config
1058            .read()
1059            .expect("DefaultSession config rwlock poisoned")
1060            .model
1061            .clone()
1062    }
1063
1064    fn list_models(&self) -> BoxFuture<'_, Result<Vec<ModelInfo>, ProviderError>> {
1065        Box::pin(async move {
1066            // Under multi-provider assembly, the candidate set comes from the registry —
1067            // each entry already carries its own model list (populated during CLI
1068            // assembly). It is then filtered against the session's `allowed_models`
1069            // allowlist. The registry makes no network requests, so this path always
1070            // succeeds.
1071            let allowed_models = self
1072                .config
1073                .read()
1074                .expect("DefaultSession config rwlock poisoned")
1075                .allowed_models
1076                .clone();
1077            let candidates = self.registry.list_candidates();
1078            let mut models: Vec<ModelInfo> = candidates
1079                .into_iter()
1080                .map(|candidate| {
1081                    decorate_with_provider_display(candidate.model, &candidate.provider)
1082                })
1083                .collect();
1084            models = filter_allowed_models(models, allowed_models.as_deref());
1085            Ok(models)
1086        })
1087    }
1088
1089    fn list_candidates(&self) -> BoxFuture<'_, Result<Vec<ModelCandidate>, ProviderError>> {
1090        Box::pin(async move {
1091            let allowed_models = self
1092                .config
1093                .read()
1094                .expect("DefaultSession config rwlock poisoned")
1095                .allowed_models
1096                .clone();
1097            let candidates = self.registry.list_candidates();
1098            let candidates: Vec<ModelCandidate> = match allowed_models {
1099                Some(allowed) => candidates
1100                    .into_iter()
1101                    .filter(|c| allowed.iter().any(|id| id == &c.model.id))
1102                    .collect(),
1103                None => candidates,
1104            };
1105            Ok(candidates)
1106        })
1107    }
1108
1109    fn set_model(&self, selection: ModelSelection) -> BoxFuture<'_, Result<(), ProviderError>> {
1110        Box::pin(async move {
1111            let ModelSelection { provider, model } = selection;
1112            let allowed_models = self
1113                .config
1114                .read()
1115                .expect("DefaultSession config rwlock poisoned")
1116                .allowed_models
1117                .clone();
1118            // Note: `allowed_models` is a flat list of model IDs without a vendor
1119            // dimension — models with the same name are allowed or denied uniformly
1120            // across all providers. Pairing is deferred to future work.
1121            if let Some(allowed_models) = allowed_models.as_ref()
1122                && !allowed_models.iter().any(|allowed| allowed == &model)
1123            {
1124                return Err(ProviderError::new(ProviderErrorKind::ModelNotFound {
1125                    model,
1126                }));
1127            }
1128
1129            let Some(entry) = self.registry.entry_for(&provider, &model) else {
1130                return Err(ProviderError::new(ProviderErrorKind::ModelNotFound {
1131                    model,
1132                }));
1133            };
1134
1135            // When switching providers, re-resolve hosted capabilities: each entry
1136            // carries its own [`SessionCapabilitiesConfig`], which is cross-referenced
1137            // with the provider's hosted_capabilities. If the delegate is not supported
1138            // by the provider, a `ProviderError` is returned — preserving the stable
1139            // failure semantics of `set_model`.
1140            let new_provider = entry.provider().clone();
1141            let resolved = ResolvedSessionCapabilities::resolve(
1142                entry.capabilities(),
1143                new_provider.hosted_capabilities(),
1144                &new_provider.info().vendor,
1145            )
1146            .map_err(|err| {
1147                ProviderError::new(ProviderErrorKind::Other(BoxError::new(io::Error::other(
1148                    err.to_string(),
1149                ))))
1150            })?;
1151
1152            // Lock order: `provider_state` before `config`, matching the snapshot path in
1153            // `run_turn`.
1154            // The window where both write locks are held is very short (just a few
1155            // assignments) and will not block the main loop.
1156            {
1157                let mut state = self
1158                    .provider_state
1159                    .write()
1160                    .expect("DefaultSession provider_state rwlock poisoned");
1161                state.provider = new_provider;
1162                state.hosted_capabilities = resolved.hosted;
1163            }
1164            let mut config = self
1165                .config
1166                .write()
1167                .expect("DefaultSession config rwlock poisoned");
1168            config.provider = provider;
1169            config.model = model;
1170            Ok(())
1171        })
1172    }
1173
1174    fn current_mode(&self) -> Option<String> {
1175        self.modes.as_ref().map(|m| {
1176            m.lock()
1177                .expect("DefaultSession modes mutex poisoned")
1178                .current_id()
1179                .to_string()
1180        })
1181    }
1182
1183    fn available_modes(&self) -> Vec<crate::session::ModeDescriptor> {
1184        let Some(modes) = self.modes.as_ref() else {
1185            return Vec::new();
1186        };
1187        modes
1188            .lock()
1189            .expect("DefaultSession modes mutex poisoned")
1190            .modes()
1191            .iter()
1192            .map(|m| crate::session::ModeDescriptor {
1193                id: m.id.clone(),
1194                name: m.name.clone(),
1195                description: m.description.clone(),
1196            })
1197            .collect()
1198    }
1199
1200    fn set_mode(&self, mode_id: String) -> Result<(), AgentError> {
1201        let Some(modes) = self.modes.as_ref() else {
1202            return Err(AgentError::ModeNotFound(mode_id));
1203        };
1204        // Lock order: `modes` before `policy` (no overlap with `run_turn`'s read path —
1205        // `run_turn` only reads `policy`). Both locks are held briefly and never across
1206        // an `.await`.
1207        let mut catalog = modes.lock().expect("DefaultSession modes mutex poisoned");
1208        if !catalog.set_current(&mode_id) {
1209            return Err(AgentError::ModeNotFound(mode_id));
1210        }
1211        let active = catalog.current_policy();
1212        *self
1213            .policy
1214            .write()
1215            .expect("DefaultSession policy rwlock poisoned") = active;
1216        Ok(())
1217    }
1218
1219    fn current_reasoning_effort(&self) -> Option<ReasoningEffort> {
1220        self.config
1221            .read()
1222            .expect("DefaultSession config rwlock poisoned")
1223            .sampling
1224            .reasoning_effort
1225    }
1226
1227    fn set_reasoning_effort(&self, effort: Option<ReasoningEffort>) {
1228        self.config
1229            .write()
1230            .expect("DefaultSession config rwlock poisoned")
1231            .sampling
1232            .reasoning_effort = effort;
1233    }
1234
1235    fn subscribe(&self) -> EventStream {
1236        self.events.subscribe()
1237    }
1238
1239    fn history_snapshot(&self) -> Vec<Message> {
1240        self.history.snapshot()
1241    }
1242
1243    fn run_turn(&self, prompt: Vec<ContentBlock>) -> BoxFuture<'_, Result<StopReason, TurnError>> {
1244        // User-driven turn: piggybacks completed background results as a prefix to the
1245        // prompt.
1246        // (Passive backflow, complementary to active continuation — active continuation
1247        // handles idle state, piggybacking handles "user happened to speak up".)
1248        Box::pin(self.run_turn_core(prompt, crate::hooks::step::IngestSource::User))
1249    }
1250
1251    fn cancel_turn(&self) {
1252        let token = {
1253            let slot = self
1254                .turn_state
1255                .lock()
1256                .expect("DefaultSession turn_state mutex poisoned");
1257            slot.cancel.clone()
1258        };
1259        if let Some(token) = token {
1260            token.cancel();
1261        }
1262        // No turn running → no-op (idempotent)
1263    }
1264
1265    fn resolve_permission(&self, id: ToolCallId, outcome: PermissionResolution) {
1266        self.permissions.resolve(&id, outcome);
1267    }
1268
1269    fn context_status(&self) -> ContextStatus {
1270        let used_tokens = self.history.token_estimate();
1271        let context_window = {
1272            let model = self
1273                .config
1274                .read()
1275                .expect("DefaultSession config rwlock poisoned")
1276                .model
1277                .clone();
1278            self.current_provider()
1279                .model_info(&model)
1280                .and_then(|m| m.context_window)
1281        };
1282        let ratio = match (used_tokens, context_window) {
1283            (Some(used), Some(window)) if window > 0 => Some(used as f64 / window as f64),
1284            _ => None,
1285        };
1286        ContextStatus {
1287            used_tokens,
1288            context_window,
1289            ratio,
1290        }
1291    }
1292
1293    fn compact_now(&self) -> BoxFuture<'_, Result<Option<CompactionReport>, TurnError>> {
1294        Box::pin(async move {
1295            // A turn rewrites history concurrently with compaction; refuse rather than
1296            // race. The caller should `/cancel` or wait. (Held briefly, never across await.)
1297            {
1298                let slot = self
1299                    .turn_state
1300                    .lock()
1301                    .expect("DefaultSession turn_state mutex poisoned");
1302                if slot.cancel.is_some() {
1303                    return Err(TurnError::TurnInProgress);
1304                }
1305            }
1306
1307            let (model, sampling) = {
1308                let config = self
1309                    .config
1310                    .read()
1311                    .expect("DefaultSession config rwlock poisoned");
1312                (config.model.clone(), config.sampling.clone())
1313            };
1314            let ctx = CompactionCtx {
1315                provider: self.current_provider(),
1316                model,
1317                sampling,
1318                tools: self.tools.schemas(),
1319                cancel: self.session_cancel.clone(),
1320            };
1321
1322            // Force a compaction regardless of watermark: use the current estimate as the
1323            // threshold so boundary selection keeps a sensible tail. If history is empty /
1324            // has no estimate, there is nothing to compact.
1325            let Some(threshold) = self.history.token_estimate() else {
1326                return Ok(None);
1327            };
1328            let report = run_sync_compaction(self.history.as_ref(), &ctx, threshold).await;
1329            if let Some(report) = report {
1330                self.events
1331                    .emit(AgentEvent::ContextCompressed {
1332                        tokens_before: report.tokens_before,
1333                        tokens_after: report.tokens_after,
1334                    })
1335                    .await;
1336            }
1337            Ok(report)
1338        })
1339    }
1340}
1341
1342fn filter_allowed_models(
1343    available_models: Vec<ModelInfo>,
1344    allowed_models: Option<&[String]>,
1345) -> Vec<ModelInfo> {
1346    let Some(allowed_models) = allowed_models else {
1347        return available_models;
1348    };
1349
1350    available_models
1351        .into_iter()
1352        .filter(|model| allowed_models.iter().any(|allowed| allowed == &model.id))
1353        .collect()
1354}
1355
1356/// Prepend the provider name to the model's `display_name` so that ACP clients can
1357/// distinguish the same model ID served by different providers — the only reliable
1358/// disambiguation when multiple gateways expose the same model (e.g. "OpenAI: gpt-4o" vs
1359/// "gw-b: gpt-4o").
1360fn decorate_with_provider_display(mut model: ModelInfo, provider: &ProviderInfo) -> ModelInfo {
1361    let name = model
1362        .display_name
1363        .clone()
1364        .unwrap_or_else(|| model.id.clone());
1365    model.display_name = Some(format!("{}: {name}", provider.display_name));
1366    model
1367}
1368
1369/// Generates a session ID as a random UUID v4.
1370///
1371/// The `defect-acp` `session/new` handler needs a [`SessionId`] (to construct an
1372/// `AcpFsBackend`) before calling [`AgentCore::create_session`]; this function is
1373/// public so that both acp and tests can produce IDs in a consistent format.
1374///
1375/// Using a globally unique UUID instead of an in-process counter plus timestamp
1376/// avoids collisions across process restarts and concurrent instances, and allows
1377/// downstream consumers (storage on-disk directories, observability trace
1378/// correlation) to use it as a stable primary key.
1379pub fn new_session_id() -> String {
1380    uuid::Uuid::new_v4().to_string()
1381}
1382
1383/// Short session ID for tracing spans: takes the first 12 characters. Diagnostic use
1384/// only.
1385fn short_id(s: &str) -> &str {
1386    match s.char_indices().nth(12) {
1387        Some((idx, _)) => &s[..idx],
1388        None => s,
1389    }
1390}
1391
1392/// Merge the explicit `config.system_prompt` with the text-only content blocks from the
1393/// session-start hook's `append` into a single overlay string for the `session_overlay`
1394/// parameter of [`resolve_system_prompt`]. Returns `None` when both are empty (no empty
1395/// segment injected); when both are present they are separated by `\n\n`.
1396fn merge_session_overlay(system_prompt: Option<&str>, append: &[ContentBlock]) -> Option<String> {
1397    let appended: String = append
1398        .iter()
1399        .filter_map(|b| match b {
1400            ContentBlock::Text(t) => Some(t.text.as_str()),
1401            _ => None,
1402        })
1403        .collect::<Vec<_>>()
1404        .join("\n\n");
1405    match (system_prompt, appended.is_empty()) {
1406        (Some(sp), true) => Some(sp.to_owned()),
1407        (Some(sp), false) => Some(format!("{sp}\n\n{appended}")),
1408        (None, false) => Some(appended),
1409        (None, true) => None,
1410    }
1411}