Skip to main content

defect_agent/
session.rs

1//! Session — state container and lifecycle interface for a single conversation.
2//!
3//! ## Abstraction layers
4//!
5//! - [`AgentCore`]: process-level "agent instance", holds the built-in tool set and
6//!   global configuration;
7//!   it is the root object assembled by `defect-cli` and injected into
8//!   `defect-acp::serve`
9//! - [`Session`]: lifecycle unit for a single conversation; holds history, per-session
10//!   tool
11//!   table (including MCP), cancel token, and event stream
12//! - [`History`]: wrapper around message history, with hooks reserved for compression,
13//!   token counting, and resume
14//!
15//! All three are **exposed as traits**; concrete implementations live in the `session/`
16//! submodule within this crate
17//! and at the assembly point in `defect-cli`; `defect-acp` interacts with them only
18//! through the traits.
19
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use agent_client_protocol_schema::{ContentBlock, McpServer, SessionId, StopReason, ToolCallId};
24use futures::future::BoxFuture;
25
26use crate::error::BoxError;
27use crate::event::{AgentEvent, PermissionResolution};
28use crate::fs::FsBackend;
29use crate::llm::{
30    Message, ModelCandidate, ModelInfo, ProviderError, ProviderInfo, ReasoningEffort,
31};
32use crate::shell::ShellBackend;
33use crate::tool::{Tool, ToolSchema};
34
35mod background;
36mod capabilities;
37mod context;
38mod default;
39mod events;
40mod goal;
41mod history;
42mod permissions;
43mod prompt;
44mod tool_registry;
45mod turn;
46
47pub use background::{
48    BackgroundOutcome, BackgroundProgressConfig, BackgroundResult, BackgroundTasks, BlockKind,
49    ProgressBlock, TaskHandle, TaskSnapshot, TaskStatus, format_background_outcome,
50};
51pub use capabilities::{
52    ResolvedSessionCapabilities, SessionCapabilitiesConfig, WebSearchCapabilityConfig,
53    WebSearchCapabilityMode,
54};
55pub use context::{Frontend, RunningContext};
56pub use default::{DefaultAgentCore, DefaultAgentCoreBuilder, DefaultSession, new_session_id};
57pub use events::EventEmitter;
58pub use goal::GoalState;
59pub use history::VecHistory;
60pub use permissions::PermissionGate;
61pub use prompt::{load_project_prompt, resolve_system_prompt};
62pub use tool_registry::{
63    AllowlistMatch, CompositeRegistry, StaticToolRegistry, StaticToolRegistryBuilder,
64    filter_registry_by_allowlist, match_tool_allowlist,
65};
66/// Re-exported for reuse within the crate: the `spawn_agent` sub-agent tool needs a
67/// `RequestAuditTracker` instance when constructing a nested [`TurnRunner`]. This type is
68/// not public (it exposes internal diagnostic state), but `crate::tool::spawn_agent` in
69/// the same crate must be able to call `new()`.
70pub(crate) use turn::RequestAuditTracker;
71pub use turn::{
72    BasePromptConfig, CompactionSlot, PromptConfig, TurnConfig, TurnRequestLimit, TurnRunner,
73};
74
75/// Process-level agent root object.
76///
77/// `defect-cli` constructs a concrete implementation at startup (holding the LLM provider
78/// registry, built-in tool set, and configuration) and injects an `Arc<dyn AgentCore>`
79/// into `defect-acp::serve`.
80///
81/// Rationale for extracting a trait:
82/// - Allows injecting a mock in tests without spinning up a real LLM.
83/// - If an "embedded agent" (library mode called by a host application) emerges in the
84///   future, a second concrete implementation can be added without touching the ACP
85///   bridge code.
86pub trait AgentCore: Send + Sync {
87    /// Creates a new session.
88    ///
89    /// `id` is generated and passed in by the caller (the `defect-acp` `session/new`
90    /// handler) — the filesystem backend already needs a `SessionId` when constructed
91    /// outside of [`AgentCore::create_session`] (see the ACP filesystem delegation
92    /// contract). Concrete implementations treat it as the authoritative external id and
93    /// return [`AgentError::DuplicateSessionId`] on duplicates.
94    ///
95    /// `mcp_servers` is the per-session MCP server list from the `session/new` request;
96    /// the concrete implementation spawns subprocesses or establishes SSE connections
97    /// during initialization, wrapping each MCP tool as a [`Tool`] and adding it to the
98    /// session's tool table.
99    ///
100    /// `fs` is the session-level filesystem backend — `defect-acp` selects
101    /// `LocalFsBackend` or `AcpFsBackend` at assembly time based on the client's
102    /// [`FileSystemCapabilities`]. The session holds an `Arc` to it, and all filesystem
103    /// tool calls go through it.
104    ///
105    /// `shell` is the session-level shell backend — `defect-acp` selects
106    /// `LocalShellBackend` or `AcpShellBackend` at assembly time based on the client's
107    /// [`ClientCapabilities::terminal`]. The session holds an `Arc` to it, and all `bash`
108    /// tool calls go through it.
109    ///
110    /// `frontend` indicates how the agent is being accessed ([`Frontend::Acp`] carries
111    /// the fs/shell delegation state negotiated during the ACP handshake) and is used to
112    /// inject the `# Environment` section of the system prompt.
113    ///
114    /// # Errors
115    ///
116    /// MCP startup failure, missing cwd, duplicate id, etc.
117    ///
118    /// [`FileSystemCapabilities`]: agent_client_protocol_schema::FileSystemCapabilities
119    /// [`ClientCapabilities::terminal`]: agent_client_protocol_schema::ClientCapabilities
120    fn create_session(
121        &self,
122        id: SessionId,
123        cwd: PathBuf,
124        mcp_servers: Vec<McpServer>,
125        fs: Arc<dyn FsBackend>,
126        shell: Arc<dyn ShellBackend>,
127        frontend: Frontend,
128    ) -> BoxFuture<'_, Result<Arc<dyn Session>, AgentError>>;
129
130    /// Restore an existing session from persistent state.
131    ///
132    /// `frontend` works the same as in [`AgentCore::create_session`] — the restored
133    /// session also uses it to inject runtime environment information.
134    ///
135    /// # Errors
136    ///
137    /// The session does not exist, the persisted data is corrupted, the restored `cwd` is
138    /// unavailable, etc.
139    fn load_session(
140        &self,
141        id: SessionId,
142        fs: Arc<dyn FsBackend>,
143        shell: Arc<dyn ShellBackend>,
144        frontend: Frontend,
145    ) -> BoxFuture<'_, Result<Arc<dyn Session>, AgentError>>;
146
147    /// Look up an existing session by id.
148    fn session(&self, id: &SessionId) -> Option<Arc<dyn Session>>;
149}
150
151/// Abstraction for restoring a session from persistent storage.
152///
153/// Concrete implementations typically come from `defect-storage`.
154pub trait SessionLoader: Send + Sync {
155    /// Read back the state needed for recovery by session id.
156    ///
157    /// # Errors
158    ///
159    /// The session does not exist, the storage is corrupted, or replay fails.
160    fn load_session(&self, id: SessionId) -> BoxFuture<'_, Result<LoadedSession, BoxError>>;
161}
162
163/// Abstraction for building an additional tool registry for a single session.
164///
165/// A typical implementation comes from `defect-mcp`: it connects to the list of MCP
166/// servers provided by `session/new` or `session/load`, and wraps the remote tools into a
167/// [`ToolRegistry`].
168pub trait SessionToolFactory: Send + Sync {
169    /// Build a session-level tool registry for the current session.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the external tool source fails to initialize, the remote
174    /// inventory cannot be fetched, or the configuration is unsupported.
175    fn build_registry(
176        &self,
177        cwd: PathBuf,
178        mcp_servers: Vec<McpServer>,
179    ) -> BoxFuture<'_, Result<Arc<dyn ToolRegistry>, BoxError>>;
180}
181
182/// Observer for when `AgentCore::create_session` succeeds.
183///
184/// Typical uses:
185/// - Start `defect-storage` event subscription persistence
186/// - Attach per-session sidecar consumers for tracing / metrics
187pub trait SessionObserver: Send + Sync {
188    /// Called after the session is successfully created.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if initializing the side‑channel consumer fails, preventing the
193    /// session from becoming externally visible.
194    fn on_session_created(
195        &self,
196        session: Arc<dyn Session>,
197        info: SessionCreateInfo,
198    ) -> Result<(), BoxError>;
199}
200
201/// A public description of an optional permission mode. Used by `defect-acp` to construct
202/// an ACP `SessionMode`.
203///
204/// It is a "policy-free" projection of [`crate::policy::PolicyMode`] — exposing only the
205/// id/display fields without leaking the internal decision engine.
206#[derive(Debug, Clone)]
207pub struct ModeDescriptor {
208    pub id: String,
209    pub name: String,
210    pub description: Option<String>,
211}
212
213/// Model selection key: a `(provider vendor, model id)` pair.
214///
215/// The same model id can be declared by multiple providers (multiple gateways with the
216/// same model), so selection must include both the provider vendor and the model id.
217/// `provider` refers to [`ProviderInfo::vendor`].
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct ModelSelection {
220    pub provider: String,
221    pub model: String,
222}
223
224/// A single session.
225///
226/// All methods are trait-object-friendly (`&self` + `BoxFuture`). The `Arc<dyn Session>`
227/// is shared between `defect-acp` and the main loop.
228pub trait Session: Send + Sync {
229    fn id(&self) -> &SessionId;
230
231    /// Provider metadata used by the current session.
232    fn provider_info(&self) -> ProviderInfo;
233
234    /// The model ID used by the current session.
235    fn current_model(&self) -> String;
236
237    /// List the model candidates available from the current provider for this session.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`ProviderError`] if the provider fails to fetch the model list.
242    fn list_models(&self) -> BoxFuture<'_, Result<Vec<ModelInfo>, ProviderError>>;
243
244    /// List the (provider, model) candidate pairs visible to the session. Under a
245    /// multi-provider setup, the same session may switch models across providers, so ACP
246    /// rendering needs to annotate each candidate with its provider.
247    ///
248    /// # Errors
249    ///
250    /// Same as [`Self::list_models`]: returns [`ProviderError`] if fetching the provider
251    /// list fails.
252    fn list_candidates(&self) -> BoxFuture<'_, Result<Vec<ModelCandidate>, ProviderError>>;
253
254    /// Switches the model for the current session.
255    ///
256    /// The selection key is a `(provider vendor, model)` pair — the same model id may be
257    /// advertised by multiple providers (multiple gateways for the same model), so the
258    /// provider must be explicitly specified. The currently in-progress turn retains its
259    /// original selection; subsequent turns use the new selection.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`ProviderError`] when the provider fails to fetch its model list, or when
264    /// the requested `(provider, model)` pair does not exist.
265    fn set_model(&self, selection: ModelSelection) -> BoxFuture<'_, Result<(), ProviderError>>;
266
267    /// The current active permission mode ID. Returns `None` if no mode catalog is
268    /// loaded.
269    ///
270    /// Maps to ACP `SessionModeState::current_mode_id`.
271    fn current_mode(&self) -> Option<String>;
272
273    /// The list of permission modes available to this session, in assembly order. Returns
274    /// an empty list when no mode directory is mounted. Maps to ACP
275    /// `SessionModeState::available_modes`.
276    fn available_modes(&self) -> Vec<ModeDescriptor>;
277
278    /// Switch the current permission mode. The change takes effect on subsequent turns;
279    /// the in-flight turn retains its original policy (same semantics as
280    /// [`Self::set_model`] — the policy is snapshotted when `run_turn` starts).
281    ///
282    /// # Errors
283    ///
284    /// Returns [`AgentError::ModeNotFound`] if `mode_id` does not match any available
285    /// mode, or if the session has no mode directory installed.
286    fn set_mode(&self, mode_id: String) -> Result<(), AgentError>;
287
288    /// The current `reasoning_effort` level (`None` = unset, falling back to the provider
289    /// default). Maps to the current value of the ACP thought-level configuration item.
290    fn current_reasoning_effort(&self) -> Option<ReasoningEffort>;
291
292    /// Sets the `reasoning_effort` level. `None` clears the override (falls back to the
293    /// provider default). Takes effect on subsequent turns. Providers that do not support
294    /// this concept ignore it when assembling requests.
295    fn set_reasoning_effort(&self, effort: Option<ReasoningEffort>);
296
297    /// Subscribe to the event stream. Three independent consumers (acp / storage /
298    /// tracing) each call this once without interfering with each other — internally uses
299    /// mpsc with fan-out so that slow consumers only experience backpressure without
300    /// dropping events.
301    fn subscribe(&self) -> EventStream;
302
303    /// A read-only snapshot of the current history, used to replay the transcript to the
304    /// client after a session load.
305    fn history_snapshot(&self) -> Vec<Message>;
306
307    /// Starts a turn.
308    ///
309    /// The returned future resolves when the turn ends:
310    /// - `Ok(StopReason)` – normal termination (including Cancelled); drives the ACP
311    ///   `PromptResponse`
312    /// - `Err(TurnError)` – fatal error (auth expiry, model unavailable, etc.);
313    ///   drives the ACP JSON-RPC `Error` response
314    ///
315    /// [`AgentEvent`]s produced during the turn are pushed via [`Session::subscribe`],
316    /// **not** through this future. The `TurnEnded` event is still emitted on the event
317    /// stream (for storage / tracing), but the ACP bridge uses this future's outcome.
318    ///
319    /// Only one turn may be in progress per session at a time; concurrent calls return
320    /// [`TurnError::TurnInProgress`].
321    fn run_turn(&self, prompt: Vec<ContentBlock>) -> BoxFuture<'_, Result<StopReason, TurnError>>;
322
323    /// Cancels the current turn. Idempotent: no-op if no turn is in progress.
324    fn cancel_turn(&self);
325
326    /// Writes back the client response to the ACP reverse request
327    /// `session/request_permission` to the main loop.
328    fn resolve_permission(&self, id: ToolCallId, outcome: PermissionResolution);
329
330    /// Current context usage. Read-only and cheap; backs the `/context` slash command.
331    fn context_status(&self) -> ContextStatus;
332
333    /// Synchronously compact the session history now (out-of-band `/compact` command),
334    /// reusing the same boundary selection + summarization as the turn loop's hard
335    /// watermark.
336    ///
337    /// Returns `Ok(Some(report))` when a compaction ran, `Ok(None)` when there was no safe
338    /// boundary to summarize (e.g. a single short turn — nothing to do).
339    ///
340    /// # Errors
341    ///
342    /// Returns [`TurnError::TurnInProgress`] if a turn is currently running: compaction
343    /// rewrites history and would race the in-flight turn, so the caller must `/cancel` or
344    /// wait first.
345    fn compact_now(&self) -> BoxFuture<'_, Result<Option<CompactionReport>, TurnError>>;
346}
347
348/// Event stream. Type-erased to support trait object return.
349pub type EventStream = futures::stream::BoxStream<'static, AgentEvent>;
350
351/// Stable information provided to [`SessionObserver`] after successful creation.
352#[derive(Debug, Clone)]
353pub struct SessionCreateInfo {
354    pub id: SessionId,
355    pub cwd: PathBuf,
356    pub mcp_servers: Vec<McpServer>,
357}
358
359/// Minimal session data restored from persistent storage.
360#[derive(Debug, Clone)]
361pub struct LoadedSession {
362    pub info: SessionCreateInfo,
363    pub history: Vec<Message>,
364}
365
366/// Abstraction over message history — pure storage + token accounting.
367///
368/// Compaction is **not** handled here: summarization requires calling the LLM, which the
369/// storage abstraction cannot reach.
370/// Compaction is orchestrated in the turn main loop (`session/turn/compact.rs`) — it
371/// reads [`History::snapshot`], calls the LLM for a summary, then writes back the
372/// computed new message list via [`History::replace`]. This trait is only responsible
373/// for: appending, snapshotting, wholesale replacement, and providing the main loop with
374/// an estimate of "how many tokens the current history is worth."
375///
376/// Token estimation strategy (see [`VecHistory`]): use the **actual input token** count
377/// reported by the last LLM call as a baseline, then add a **character-heuristic**
378/// increment for messages appended after that baseline; when no real baseline is
379/// available, fall back to a pure character-heuristic estimate for the entire history.
380/// The turn main loop compares this estimate against the compaction threshold.
381pub trait History: Send + Sync {
382    /// Appends a message.
383    fn append(&self, msg: Message);
384
385    /// A snapshot of the current history, to be fed into the next LLM call.
386    fn snapshot(&self) -> Vec<Message>;
387
388    /// Replace the entire message list after compression. The turn main loop calls this
389    /// to write back the new list consisting of a summary plus the retained tail. The
390    /// implementation should also reset the token estimation baseline, since the old
391    /// actual token counts no longer apply to the new list.
392    fn replace(&self, messages: Vec<Message>);
393
394    /// Prefix splice: replaces the first `drop_count` messages in the **current** list
395    /// with the single `summary` message, preserving everything after them. Returns the
396    /// actual number of messages dropped (`drop_count` is clamped to the current length).
397    ///
398    /// This is the primitive for **background compression** write-back: a background task
399    /// computes `drop_count` (= the prefix length to summarize) and `summary` from a
400    /// snapshot taken at some point, but while the summarization LLM call is in flight,
401    /// the foreground turn may still be `append`ing to the **tail**. Writing back with
402    /// `replace(entire list)` would discard any tail messages added during that time.
403    /// `splice_prefix` only touches the first `drop_count` messages of the **current**
404    /// list, preserving everything from `drop_count..` onward (including tail messages
405    /// added in the meantime), so the write-back is correct.
406    ///
407    /// **Concurrency invariant** (must be maintained): `drop_count` is computed from an
408    /// old snapshot and remains valid for the **current** list provided that during the
409    /// flight only tail appends (`append`) and in-place content replacements
410    /// (micro-compression `replace` with same-length rebuild) occur — no insertion or
411    /// deletion of middle messages. The only operation that removes middle messages is
412    /// compression itself, and compression runs **solo** (at most one in flight at a
413    /// time), so the invariant holds.
414    ///
415    /// Like [`Self::replace`], resets the token estimation baseline after write-back (the
416    /// true token count of the new prefix is unknown).
417    fn splice_prefix(&self, drop_count: usize, summary: Message) -> usize;
418
419    /// Number of messages currently held. Used to record a rollback boundary before a turn
420    /// appends its prompt, so [`Self::truncate`] can undo it if the turn fails permanently.
421    fn len(&self) -> usize;
422
423    /// Returns whether the history holds no messages.
424    fn is_empty(&self) -> bool {
425        self.len() == 0
426    }
427
428    /// Truncates the message list to at most `len` messages, dropping any tail beyond it.
429    /// A no-op when `len >= current length`.
430    ///
431    /// Used to roll back a permanently-failed turn: the user prompt (and any hook feedback)
432    /// appended at the start of the turn must not linger in history once the turn errors
433    /// out, otherwise it would be replayed on reload and re-sent to the model on the next
434    /// request. Like [`Self::replace`], resets the token estimation baseline since the
435    /// dropped messages may have contributed to the delta estimate.
436    fn truncate(&self, len: usize);
437
438    /// Records the actual input token count from the last LLM call
439    /// (`input + cache_read + cache_creation`). Serves as the precise baseline for
440    /// [`Self::token_estimate`]; subsequent [`Self::append`] messages are accumulated
441    /// incrementally using a character heuristic.
442    fn record_input_tokens(&self, tokens: u64);
443
444    /// Estimates the token count for the current history. `None` indicates the history is
445    /// empty or no estimate is available.
446    fn token_estimate(&self) -> Option<u64>;
447}
448
449/// Compaction report. The token counts before and after compaction are wrapped into
450/// [`AgentEvent::ContextCompressed`] by the main loop.
451#[derive(Debug, Clone, Copy)]
452pub struct CompactionReport {
453    pub tokens_before: u64,
454    pub tokens_after: u64,
455}
456
457/// Snapshot of the session's context usage, returned by [`Session::context_status`].
458/// Powers the `/context` slash command (and any client-side context gauge).
459#[derive(Debug, Clone, Copy)]
460pub struct ContextStatus {
461    /// Estimated tokens currently held in history. `None` when no estimate is available
462    /// yet (e.g. an empty session before the first request).
463    pub used_tokens: Option<u64>,
464    /// The model's context window in tokens, if the provider exposes it.
465    pub context_window: Option<u64>,
466    /// Fraction of the window in use (`used / window`), only when both are known.
467    pub ratio: Option<f64>,
468}
469
470/// Process-level agent error.
471#[non_exhaustive]
472#[derive(Debug, thiserror::Error)]
473pub enum AgentError {
474    #[error("invalid working directory: {0}")]
475    InvalidCwd(PathBuf),
476
477    /// MCP server failed to start (stdio process could not be launched / SSE connection
478    /// could not be established).
479    #[error("mcp startup failed for {server}: {source}")]
480    McpStartup {
481        server: String,
482        #[source]
483        source: BoxError,
484    },
485
486    /// The caller-provided [`SessionId`] already exists in the session table.
487    /// A monotonic + timestamp ID generator should theoretically never collide; this is a
488    /// safety net.
489    #[error("session id already in use: {0}")]
490    DuplicateSessionId(SessionId),
491
492    #[error("session observer failed: {0}")]
493    Observer(#[source] BoxError),
494
495    #[error("session not found in storage: {0}")]
496    SessionNotFound(SessionId),
497
498    /// The `mode_id` received by `set_mode` is not in the session's mode directory (or
499    /// the directory is not mounted).
500    #[error("permission mode not found: {0}")]
501    ModeNotFound(String),
502
503    #[error("session restore failed: {0}")]
504    Restore(#[source] BoxError),
505
506    /// Session capability adjudication failed during startup. See [`SessionInitError`].
507    #[error(transparent)]
508    Init(#[from] SessionInitError),
509
510    #[error(transparent)]
511    Other(#[from] BoxError),
512}
513
514/// A one-time adjudication failure during session startup.
515///
516/// See capabilities design.
517/// The session is refused when `capabilities.<name>.mode = "delegate"` but the current
518/// provider's
519/// [`crate::llm::LlmProvider::hosted_capabilities`] does not support that capability.
520#[non_exhaustive]
521#[derive(Debug)]
522pub enum SessionInitError {
523    /// The user explicitly chose `Delegate`, but the provider does not support the
524    /// corresponding hosted capability.
525    CapabilityUnsatisfied {
526        /// The name of the problematic capability (e.g. `"web_search"`).
527        capability: &'static str,
528        /// The name of the provider bound to the current session.
529        provider: String,
530    },
531}
532
533impl std::fmt::Display for SessionInitError {
534    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535        match self {
536            Self::CapabilityUnsatisfied {
537                capability,
538                provider,
539            } => {
540                writeln!(
541                    f,
542                    "{capability} capability is unsatisfied: provider `{provider}` does not support hosted {capability}."
543                )?;
544                writeln!(f)?;
545                writeln!(f, "To fix this, choose one of:")?;
546                writeln!(f, "  1. Disable hosted {capability} for this provider:")?;
547                writeln!(f, "       [providers.{provider}.capabilities.{capability}]")?;
548                writeln!(f, "       mode = \"disabled\"")?;
549                writeln!(
550                    f,
551                    "  2. Change global default to `disabled` and only delegate where supported:"
552                )?;
553                writeln!(f, "       [capabilities.{capability}]")?;
554                writeln!(f, "       mode = \"disabled\"")?;
555                writeln!(
556                    f,
557                    "       [providers.<hosted-supported>.capabilities.{capability}]"
558                )?;
559                write!(f, "       mode = \"delegate\"")
560            }
561        }
562    }
563}
564
565impl std::error::Error for SessionInitError {}
566
567/// Reasons why a turn fails.
568///
569/// Rule of thumb: **only include errors that make the turn unable to continue**. Internal
570/// tool failures within a turn, single LLM retry failures, etc. belong in [`AgentEvent`]
571/// and the historical state machine instead.
572#[non_exhaustive]
573#[derive(Debug, thiserror::Error)]
574pub enum TurnError {
575    /// A turn is already in progress for this session.
576    #[error("turn already in progress for this session")]
577    TurnInProgress,
578
579    /// Provider error that still fails after retries are exhausted.
580    #[error(transparent)]
581    Provider(#[from] ProviderError),
582
583    /// Internal invariant broken (should be a bug).
584    #[error("internal turn error: {0}")]
585    Internal(#[source] BoxError),
586}
587
588/// Abstraction for a tool registry.
589///
590/// Both the process-level registry (owned by [`AgentCore`], for built-in tools) and the
591/// session-level registry (owned by [`Session`], for MCP tools) share the same shape; the
592/// turn main loop looks up tools through the composite registry exposed by [`Session`].
593pub trait ToolRegistry: Send + Sync {
594    /// Return the schemas of all tools in the registry, used to populate the `tools`
595    /// field of an LLM request.
596    fn schemas(&self) -> Vec<ToolSchema>;
597
598    /// Looks up a tool by name.
599    fn get(&self, name: &str) -> Option<Arc<dyn Tool>>;
600}