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}