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