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