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}