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