Skip to main content

agentkit_tools_core/
lib.rs

1//! Core abstractions for defining, registering, executing, and governing
2//! tools in agentkit.
3//!
4//! This crate provides the [`Tool`] trait, [`ToolRegistry`],
5//! [`BasicToolExecutor`], and a layered permission system built on
6//! [`PermissionChecker`], [`PermissionPolicy`], and
7//! [`CompositePermissionChecker`]. Together these types let you:
8//!
9//! - **Define tools** by implementing [`Tool`] with a [`ToolSpec`] and
10//!   async `invoke` method.
11//! - **Register tools** in a [`ToolRegistry`] and hand it to an executor
12//!   or capability provider.
13//! - **Check permissions** before execution using composable policies
14//!   ([`PathPolicy`], [`CommandPolicy`], [`McpServerPolicy`],
15//!   [`CustomKindPolicy`]).
16//! - **Handle interruptions** (approval prompts) via the
17//!   [`ToolInterruption`] / [`ApprovalRequest`] types.
18//! - **Bridge to the capability layer** with [`ToolCapabilityProvider`],
19//!   which wraps every registered tool as an [`Invocable`].
20
21use std::any::Any;
22use std::collections::{BTreeMap, BTreeSet};
23use std::fmt;
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, OnceLock};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::time::Duration;
28
29use agentkit_capabilities::{
30    CapabilityContext, CapabilityError, CapabilityName, CapabilityProvider, Invocable,
31    InvocableOutput, InvocableRequest, InvocableResult, InvocableSpec, PromptProvider,
32    ResourceProvider,
33};
34use agentkit_core::{
35    ApprovalId, Item, ItemKind, MetadataMap, Part, SessionId, TaskId, ToolCallId, ToolOutput,
36    ToolResultPart, TurnCancellation, TurnId,
37};
38use async_trait::async_trait;
39use serde::{Deserialize, Serialize};
40use serde_json::Value;
41use thiserror::Error;
42
43/// Re-exports used by the `#[tool]` proc macro so generated code does not
44/// require downstream crates to add `async-trait` as a direct dependency.
45/// Not part of the public API; the path may change at any time.
46#[doc(hidden)]
47pub mod __private_async_trait {
48    pub use async_trait::async_trait;
49}
50
51/// Unique name identifying a [`Tool`] within a [`ToolRegistry`].
52///
53/// Tool names are used as registry keys and appear in [`ToolRequest`]s to
54/// route calls to the correct implementation. Names are compared in a
55/// case-sensitive, lexicographic order.
56///
57/// # Example
58///
59/// ```rust
60/// use agentkit_tools_core::ToolName;
61///
62/// let name = ToolName::new("file_read");
63/// assert_eq!(name.to_string(), "file_read");
64///
65/// // Also converts from &str:
66/// let name: ToolName = "shell_exec".into();
67/// ```
68#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69pub struct ToolName(pub String);
70
71impl ToolName {
72    /// Creates a new `ToolName` from any value that converts into a [`String`].
73    pub fn new(value: impl Into<String>) -> Self {
74        Self(value.into())
75    }
76}
77
78impl fmt::Display for ToolName {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        self.0.fmt(f)
81    }
82}
83
84impl From<&str> for ToolName {
85    fn from(value: &str) -> Self {
86        Self::new(value)
87    }
88}
89
90/// Hints that describe behavioural properties of a tool.
91///
92/// These flags are advisory — they influence UI presentation and permission
93/// policies but do not enforce behaviour at runtime. For example, a
94/// permission policy may automatically require approval for tools that
95/// set `destructive_hint` to `true`.
96#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
97pub struct ToolAnnotations {
98    /// The tool only reads data and has no side-effects.
99    pub read_only_hint: bool,
100    /// The tool may perform destructive operations (e.g. file deletion).
101    pub destructive_hint: bool,
102    /// Repeated calls with the same input produce the same effect.
103    pub idempotent_hint: bool,
104    /// The tool should prompt for user approval before execution.
105    pub needs_approval_hint: bool,
106    /// The tool can stream partial results during execution.
107    pub supports_streaming_hint: bool,
108}
109
110impl ToolAnnotations {
111    /// Builds the default advisory flags.
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Marks the tool as read-only.
117    pub fn read_only() -> Self {
118        Self::default().with_read_only(true)
119    }
120
121    /// Marks the tool as destructive.
122    pub fn destructive() -> Self {
123        Self::default().with_destructive(true)
124    }
125
126    /// Marks the tool as requiring approval.
127    pub fn needs_approval() -> Self {
128        Self::default().with_needs_approval(true)
129    }
130
131    /// Marks the tool as supporting streaming.
132    pub fn streaming() -> Self {
133        Self::default().with_supports_streaming(true)
134    }
135
136    pub fn with_read_only(mut self, read_only_hint: bool) -> Self {
137        self.read_only_hint = read_only_hint;
138        self
139    }
140
141    pub fn with_destructive(mut self, destructive_hint: bool) -> Self {
142        self.destructive_hint = destructive_hint;
143        self
144    }
145
146    pub fn with_idempotent(mut self, idempotent_hint: bool) -> Self {
147        self.idempotent_hint = idempotent_hint;
148        self
149    }
150
151    pub fn with_needs_approval(mut self, needs_approval_hint: bool) -> Self {
152        self.needs_approval_hint = needs_approval_hint;
153        self
154    }
155
156    pub fn with_supports_streaming(mut self, supports_streaming_hint: bool) -> Self {
157        self.supports_streaming_hint = supports_streaming_hint;
158        self
159    }
160}
161
162/// Declarative specification of a tool's identity, schema, and behavioural hints.
163///
164/// Every [`Tool`] implementation exposes a `ToolSpec` that the framework uses to
165/// advertise the tool to an LLM, validate inputs, and drive permission checks.
166///
167/// # Example
168///
169/// ```rust
170/// use agentkit_tools_core::{ToolAnnotations, ToolName, ToolSpec};
171/// use serde_json::json;
172///
173/// let spec = ToolSpec::new(
174///     ToolName::new("grep_search"),
175///     "Search files by regex pattern",
176///     json!({
177///         "type": "object",
178///         "properties": {
179///             "pattern": { "type": "string" },
180///             "path": { "type": "string" }
181///         },
182///         "required": ["pattern"]
183///     }),
184/// )
185/// .with_annotations(ToolAnnotations::read_only());
186/// ```
187#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
188pub struct ToolSpec {
189    /// Machine-readable name used to route tool calls.
190    pub name: ToolName,
191    /// Human-readable description sent to the LLM so it knows when to use this tool.
192    pub description: String,
193    /// JSON Schema describing the expected input object.
194    pub input_schema: Value,
195    /// Advisory behavioural hints (read-only, destructive, etc.).
196    pub annotations: ToolAnnotations,
197    /// Arbitrary key-value pairs for framework extensions.
198    pub metadata: MetadataMap,
199}
200
201/// A change notification for a dynamic tool catalog.
202///
203/// Dynamic executors, such as MCP-backed executors, use this to tell the
204/// agent loop that the model should see a refreshed tool list on the next
205/// provider request.
206#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
207pub struct ToolCatalogEvent {
208    /// Stable source identifier for the catalog that changed.
209    pub source: String,
210    /// Tool names that became available.
211    pub added: Vec<String>,
212    /// Tool names that are no longer available.
213    pub removed: Vec<String>,
214    /// Tool names whose schema, description, or metadata changed.
215    pub changed: Vec<String>,
216}
217
218impl ToolCatalogEvent {
219    /// Builds a catalog event with empty change sets.
220    pub fn new(source: impl Into<String>) -> Self {
221        Self {
222            source: source.into(),
223            added: Vec::new(),
224            removed: Vec::new(),
225            changed: Vec::new(),
226        }
227    }
228
229    /// Applies `f` to every tool name in `added`, `removed`, and `changed`.
230    pub fn for_each_name_mut(&mut self, mut f: impl FnMut(&mut String)) {
231        for vec in [&mut self.added, &mut self.removed, &mut self.changed] {
232            for name in vec.iter_mut() {
233                f(name);
234            }
235        }
236    }
237
238    /// Retains only tool names that pass `predicate` in `added`, `removed`,
239    /// and `changed`.
240    pub fn retain_names(&mut self, mut predicate: impl FnMut(&str) -> bool) {
241        self.added.retain(|n| predicate(n));
242        self.removed.retain(|n| predicate(n));
243        self.changed.retain(|n| predicate(n));
244    }
245}
246
247impl ToolSpec {
248    /// Builds a tool spec with default annotations and empty metadata.
249    pub fn new(
250        name: impl Into<ToolName>,
251        description: impl Into<String>,
252        input_schema: Value,
253    ) -> Self {
254        Self {
255            name: name.into(),
256            description: description.into(),
257            input_schema,
258            annotations: ToolAnnotations::default(),
259            metadata: MetadataMap::new(),
260        }
261    }
262
263    /// Replaces the tool annotations.
264    pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self {
265        self.annotations = annotations;
266        self
267    }
268
269    /// Replaces the tool metadata.
270    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
271        self.metadata = metadata;
272        self
273    }
274}
275
276/// An incoming request to execute a tool.
277///
278/// Created by the agent loop when the model emits a tool-call. The
279/// [`BasicToolExecutor`] uses `tool_name` to look up the [`Tool`] in the
280/// registry and forwards this request to [`Tool::invoke`].
281#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
282pub struct ToolRequest {
283    /// Provider-assigned identifier for this specific call.
284    pub call_id: ToolCallId,
285    /// Name of the tool to invoke (must match a registered [`ToolName`]).
286    pub tool_name: ToolName,
287    /// JSON input parsed from the model's tool-call arguments.
288    pub input: Value,
289    /// Session that owns this call.
290    pub session_id: SessionId,
291    /// Turn within the session that triggered this call.
292    pub turn_id: TurnId,
293    /// Arbitrary key-value pairs for framework extensions.
294    pub metadata: MetadataMap,
295}
296
297impl ToolRequest {
298    /// Builds a tool request with empty metadata.
299    pub fn new(
300        call_id: impl Into<ToolCallId>,
301        tool_name: impl Into<ToolName>,
302        input: Value,
303        session_id: impl Into<SessionId>,
304        turn_id: impl Into<TurnId>,
305    ) -> Self {
306        Self {
307            call_id: call_id.into(),
308            tool_name: tool_name.into(),
309            input,
310            session_id: session_id.into(),
311            turn_id: turn_id.into(),
312            metadata: MetadataMap::new(),
313        }
314    }
315
316    /// Replaces the request metadata.
317    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
318        self.metadata = metadata;
319        self
320    }
321}
322
323/// The output produced by a successful tool invocation.
324///
325/// Returned from [`Tool::invoke`] and wrapped by [`ToolExecutionOutcome::Completed`]
326/// after the executor finishes permission checks and execution.
327#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
328pub struct ToolResult {
329    /// The content payload sent back to the model.
330    pub result: ToolResultPart,
331    /// Wall-clock time the tool took to run, if measured.
332    pub duration: Option<Duration>,
333    /// Arbitrary key-value pairs for framework extensions.
334    pub metadata: MetadataMap,
335}
336
337impl ToolResult {
338    /// Builds a tool result with no duration and empty metadata.
339    pub fn new(result: ToolResultPart) -> Self {
340        Self {
341            result,
342            duration: None,
343            metadata: MetadataMap::new(),
344        }
345    }
346
347    /// Sets the measured duration.
348    pub fn with_duration(mut self, duration: Duration) -> Self {
349        self.duration = Some(duration);
350        self
351    }
352
353    /// Replaces the result metadata.
354    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
355        self.metadata = metadata;
356        self
357    }
358}
359
360/// Trait for dependency injection into tool implementations.
361///
362/// Tools that need access to shared state (database handles, HTTP clients,
363/// configuration, etc.) can downcast the `&dyn ToolResources` provided in
364/// [`ToolContext`] to a concrete type.
365///
366/// The unit type `()` implements `ToolResources` and serves as the default
367/// when no shared resources are needed.
368///
369/// # Example
370///
371/// ```rust
372/// use std::any::Any;
373/// use agentkit_tools_core::ToolResources;
374///
375/// struct AppResources {
376///     project_root: std::path::PathBuf,
377/// }
378///
379/// impl ToolResources for AppResources {
380///     fn as_any(&self) -> &dyn Any {
381///         self
382///     }
383/// }
384/// ```
385pub trait ToolResources: Send + Sync {
386    /// Returns a reference to `self` as [`Any`] so callers can downcast to
387    /// the concrete resource type.
388    fn as_any(&self) -> &dyn Any;
389}
390
391impl ToolResources for () {
392    fn as_any(&self) -> &dyn Any {
393        self
394    }
395}
396
397/// Runtime context passed to every [`Tool::invoke`] call.
398///
399/// Provides the tool with access to session/turn metadata, the active
400/// permission checker, shared resources, and a cancellation signal so the
401/// tool can abort long-running work when a turn is cancelled.
402pub struct ToolContext<'a> {
403    /// Capability-layer context carrying session and turn identifiers.
404    pub capability: CapabilityContext<'a>,
405    /// The active permission checker for sub-operations the tool may perform.
406    pub permissions: &'a dyn PermissionChecker,
407    /// Shared resources (e.g. database handles, config) injected by the host.
408    pub resources: &'a dyn ToolResources,
409    /// Signal that the current turn has been cancelled by the user.
410    pub cancellation: Option<TurnCancellation>,
411}
412
413/// Owned execution context that can outlive a single stack frame.
414///
415/// This is useful for schedulers or task managers that need to move a tool
416/// execution onto another task while still constructing the borrowed
417/// [`ToolContext`] expected by existing tool implementations.
418#[derive(Clone)]
419pub struct OwnedToolContext {
420    /// Session identifier for the invocation.
421    pub session_id: SessionId,
422    /// Turn identifier for the invocation.
423    pub turn_id: TurnId,
424    /// Arbitrary invocation metadata.
425    pub metadata: MetadataMap,
426    /// Shared permission checker.
427    pub permissions: Arc<dyn PermissionChecker>,
428    /// Shared resources injected by the host.
429    pub resources: Arc<dyn ToolResources>,
430    /// Cooperative cancellation signal for the invocation.
431    pub cancellation: Option<TurnCancellation>,
432}
433
434impl OwnedToolContext {
435    /// Creates a borrowed [`ToolContext`] view over this owned context.
436    pub fn borrowed(&self) -> ToolContext<'_> {
437        ToolContext {
438            capability: CapabilityContext {
439                session_id: Some(&self.session_id),
440                turn_id: Some(&self.turn_id),
441                metadata: &self.metadata,
442            },
443            permissions: self.permissions.as_ref(),
444            resources: self.resources.as_ref(),
445            cancellation: self.cancellation.clone(),
446        }
447    }
448}
449
450/// A description of an operation that requires permission before it can proceed.
451///
452/// Tool implementations return `PermissionRequest` objects from
453/// [`Tool::proposed_requests`] so the executor can evaluate them against the
454/// active [`PermissionChecker`] before invoking the tool.
455///
456/// Built-in implementations include [`ShellPermissionRequest`],
457/// [`FileSystemPermissionRequest`], and [`McpPermissionRequest`].
458///
459/// # Implementing a custom request
460///
461/// ```rust
462/// use std::any::Any;
463/// use agentkit_core::MetadataMap;
464/// use agentkit_tools_core::PermissionRequest;
465///
466/// struct NetworkPermissionRequest {
467///     url: String,
468///     metadata: MetadataMap,
469/// }
470///
471/// impl PermissionRequest for NetworkPermissionRequest {
472///     fn kind(&self) -> &'static str { "network.http" }
473///     fn summary(&self) -> String { format!("HTTP request to {}", self.url) }
474///     fn metadata(&self) -> &MetadataMap { &self.metadata }
475///     fn as_any(&self) -> &dyn Any { self }
476/// }
477/// ```
478pub trait PermissionRequest: Send + Sync {
479    /// A dot-separated category string (e.g. `"filesystem.write"`, `"shell.command"`).
480    fn kind(&self) -> &'static str;
481    /// Human-readable one-line description of what is being requested.
482    fn summary(&self) -> String;
483    /// Arbitrary metadata attached to this request.
484    fn metadata(&self) -> &MetadataMap;
485    /// Returns `self` as [`Any`] so policies can downcast to the concrete type.
486    fn as_any(&self) -> &dyn Any;
487}
488
489/// Machine-readable code indicating why a permission was denied.
490///
491/// Returned inside a [`PermissionDenial`] so callers can programmatically
492/// react to specific denial categories.
493#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
494pub enum PermissionCode {
495    /// A filesystem path is outside the allowed set.
496    PathNotAllowed,
497    /// A shell command or executable is not permitted.
498    CommandNotAllowed,
499    /// A network operation is not permitted.
500    NetworkNotAllowed,
501    /// An MCP server is not in the trusted set.
502    ServerNotTrusted,
503    /// An MCP auth scope is not in the allowed set.
504    AuthScopeNotAllowed,
505    /// A custom permission policy explicitly denied the request.
506    CustomPolicyDenied,
507    /// No policy recognised the request kind.
508    UnknownRequest,
509}
510
511/// Structured denial produced when a [`PermissionChecker`] rejects an operation.
512///
513/// Contains a machine-readable [`PermissionCode`] and a human-readable
514/// message suitable for logging or displaying to the user.
515#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
516pub struct PermissionDenial {
517    /// Machine-readable denial category.
518    pub code: PermissionCode,
519    /// Human-readable explanation of why the operation was denied.
520    pub message: String,
521    /// Arbitrary metadata carried from the original request.
522    pub metadata: MetadataMap,
523}
524
525/// Why a permission policy is requesting human approval before proceeding.
526///
527/// Used inside [`ApprovalRequest`] so the UI layer can display context-appropriate
528/// prompts to the user.
529#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
530pub enum ApprovalReason {
531    /// The active policy always requires confirmation for this kind of operation.
532    PolicyRequiresConfirmation,
533    /// The operation was flagged as higher risk than usual.
534    EscalatedRisk,
535    /// The target (server, path, etc.) was not recognised by any policy.
536    UnknownTarget,
537    /// The operation targets a filesystem path that is not in the allowed set.
538    SensitivePath,
539    /// The shell command is not in the pre-approved allow-list.
540    SensitiveCommand,
541    /// The MCP server is not in the trusted set.
542    SensitiveServer,
543    /// The MCP auth scope is not in the pre-approved set.
544    SensitiveAuthScope,
545}
546
547/// A request sent to the host when a tool execution needs human approval.
548///
549/// The agent loop surfaces this to the user. Once the user responds, the
550/// loop can re-submit the tool call via [`ToolExecutor::execute_approved`].
551#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
552pub struct ApprovalRequest {
553    /// Runtime task identifier associated with this approval request, if any.
554    pub task_id: Option<TaskId>,
555    /// The originating tool call id when this approval was raised from a
556    /// tool invocation. Hosts can use this to resolve specific approvals.
557    pub call_id: Option<ToolCallId>,
558    /// Stable identifier so the executor can match the approval to its request.
559    pub id: ApprovalId,
560    /// The [`PermissionRequest::kind`] string that triggered the approval flow.
561    pub request_kind: String,
562    /// Why approval is needed.
563    pub reason: ApprovalReason,
564    /// Human-readable summary shown to the user.
565    pub summary: String,
566    /// Arbitrary metadata carried from the original permission request.
567    pub metadata: MetadataMap,
568}
569
570impl ApprovalRequest {
571    /// Builds an approval request with no task or call id.
572    pub fn new(
573        id: impl Into<ApprovalId>,
574        request_kind: impl Into<String>,
575        reason: ApprovalReason,
576        summary: impl Into<String>,
577    ) -> Self {
578        Self {
579            task_id: None,
580            call_id: None,
581            id: id.into(),
582            request_kind: request_kind.into(),
583            reason,
584            summary: summary.into(),
585            metadata: MetadataMap::new(),
586        }
587    }
588
589    /// Sets the associated task id.
590    pub fn with_task_id(mut self, task_id: impl Into<TaskId>) -> Self {
591        self.task_id = Some(task_id.into());
592        self
593    }
594
595    /// Sets the associated tool call id.
596    pub fn with_call_id(mut self, call_id: impl Into<ToolCallId>) -> Self {
597        self.call_id = Some(call_id.into());
598        self
599    }
600
601    /// Replaces the approval metadata.
602    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
603        self.metadata = metadata;
604        self
605    }
606}
607
608/// The user's response to an [`ApprovalRequest`].
609#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
610pub enum ApprovalDecision {
611    /// The user approved the operation.
612    Approve,
613    /// The user denied the operation, optionally with a reason.
614    Deny {
615        /// Optional human-readable explanation for the denial.
616        reason: Option<String>,
617    },
618}
619
620/// A tool execution was paused because it needs external input.
621///
622/// The agent loop should handle the interruption (show a prompt, etc.) and
623/// then re-submit the tool call. Source-specific interruptions (e.g. MCP
624/// auth challenges) do not surface here — they are resolved by responders
625/// registered with the source.
626#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
627pub enum ToolInterruption {
628    /// The operation requires human approval before it can proceed.
629    ApprovalRequired(ApprovalRequest),
630}
631
632/// The verdict from a [`PermissionChecker`] for a single [`PermissionRequest`].
633#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
634pub enum PermissionDecision {
635    /// The operation is allowed to proceed.
636    Allow,
637    /// The operation is denied.
638    Deny(PermissionDenial),
639    /// The operation may proceed only after the user approves.
640    RequireApproval(ApprovalRequest),
641}
642
643/// Evaluates a [`PermissionRequest`] and returns a final [`PermissionDecision`].
644///
645/// The [`BasicToolExecutor`] calls `evaluate` for every permission request
646/// returned by [`Tool::proposed_requests`] before invoking the tool. If any
647/// request is denied, execution is aborted; if any request requires approval,
648/// the executor returns a [`ToolInterruption`].
649///
650/// For composing multiple policies, see [`CompositePermissionChecker`].
651///
652/// # Example
653///
654/// ```rust
655/// use agentkit_tools_core::{PermissionChecker, PermissionDecision, PermissionRequest};
656///
657/// /// A checker that allows every operation unconditionally.
658/// struct AllowAll;
659///
660/// impl PermissionChecker for AllowAll {
661///     fn evaluate(&self, _request: &dyn PermissionRequest) -> PermissionDecision {
662///         PermissionDecision::Allow
663///     }
664/// }
665/// ```
666pub trait PermissionChecker: Send + Sync {
667    /// Evaluate a single permission request and return the decision.
668    fn evaluate(&self, request: &dyn PermissionRequest) -> PermissionDecision;
669}
670
671/// A [`PermissionChecker`] that unconditionally allows every operation.
672///
673/// Useful in tests, examples, and embedding scenarios where the host has
674/// already gated tool access elsewhere.
675#[derive(Copy, Clone, Debug, Default)]
676pub struct AllowAllPermissions;
677
678impl PermissionChecker for AllowAllPermissions {
679    fn evaluate(&self, _request: &dyn PermissionRequest) -> PermissionDecision {
680        PermissionDecision::Allow
681    }
682}
683
684/// The result of a single [`PermissionPolicy`] evaluation.
685///
686/// Unlike [`PermissionDecision`], a policy can return [`PolicyMatch::NoOpinion`]
687/// to indicate it has nothing to say about this request kind, letting other
688/// policies in the [`CompositePermissionChecker`] chain decide.
689#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
690pub enum PolicyMatch {
691    /// This policy does not apply to the given request kind.
692    NoOpinion,
693    /// This policy explicitly allows the operation.
694    Allow,
695    /// This policy explicitly denies the operation.
696    Deny(PermissionDenial),
697    /// This policy requires user approval before the operation can proceed.
698    RequireApproval(ApprovalRequest),
699}
700
701/// A single, focused permission rule that contributes to a composite decision.
702///
703/// Policies are combined inside a [`CompositePermissionChecker`]. Each policy
704/// inspects the request and either returns a definitive answer or
705/// [`PolicyMatch::NoOpinion`] to defer.
706///
707/// Built-in policies: [`PathPolicy`], [`CommandPolicy`], [`McpServerPolicy`],
708/// [`CustomKindPolicy`].
709pub trait PermissionPolicy: Send + Sync {
710    /// Evaluate the request and return a match or [`PolicyMatch::NoOpinion`].
711    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch;
712}
713
714/// Chains multiple [`PermissionPolicy`] implementations into a single [`PermissionChecker`].
715///
716/// Policies are evaluated in registration order. The first `Deny` short-circuits
717/// immediately. If any policy returns `RequireApproval`, that is used unless a
718/// later policy denies. If at least one policy returns `Allow` and none deny or
719/// require approval, the result is `Allow`. Otherwise the `fallback` decision
720/// is returned.
721///
722/// # Example
723///
724/// ```rust
725/// use agentkit_tools_core::{
726///     CommandPolicy, CompositePermissionChecker, PathPolicy, PermissionDecision,
727/// };
728///
729/// let checker = CompositePermissionChecker::new(PermissionDecision::Allow)
730///     .with_policy(PathPolicy::new().allow_root("/workspace"))
731///     .with_policy(CommandPolicy::new().allow_executable("git"));
732/// ```
733pub struct CompositePermissionChecker {
734    policies: Vec<Box<dyn PermissionPolicy>>,
735    fallback: PermissionDecision,
736}
737
738impl CompositePermissionChecker {
739    /// Creates a new composite checker with the given fallback decision.
740    ///
741    /// The fallback is used when no policy has an opinion about a request.
742    ///
743    /// # Arguments
744    ///
745    /// * `fallback` - Decision returned when every policy returns [`PolicyMatch::NoOpinion`].
746    pub fn new(fallback: PermissionDecision) -> Self {
747        Self {
748            policies: Vec::new(),
749            fallback,
750        }
751    }
752
753    /// Appends a policy to the evaluation chain and returns `self` for chaining.
754    pub fn with_policy(mut self, policy: impl PermissionPolicy + 'static) -> Self {
755        self.policies.push(Box::new(policy));
756        self
757    }
758}
759
760impl PermissionChecker for CompositePermissionChecker {
761    fn evaluate(&self, request: &dyn PermissionRequest) -> PermissionDecision {
762        let mut saw_allow = false;
763        let mut approval = None;
764
765        for policy in &self.policies {
766            match policy.evaluate(request) {
767                PolicyMatch::NoOpinion => {}
768                PolicyMatch::Allow => saw_allow = true,
769                PolicyMatch::Deny(denial) => return PermissionDecision::Deny(denial),
770                PolicyMatch::RequireApproval(req) => approval = Some(req),
771            }
772        }
773
774        if let Some(req) = approval {
775            PermissionDecision::RequireApproval(req)
776        } else if saw_allow {
777            PermissionDecision::Allow
778        } else {
779            self.fallback.clone()
780        }
781    }
782}
783
784/// Permission request for executing a shell command.
785///
786/// Evaluated by [`CommandPolicy`] to decide whether the executable, arguments,
787/// working directory, and environment variables are acceptable.
788#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
789pub struct ShellPermissionRequest {
790    /// The executable name or path (e.g. `"git"`, `"/usr/bin/curl"`).
791    pub executable: String,
792    /// Command-line arguments passed to the executable.
793    pub argv: Vec<String>,
794    /// Working directory for the command, if specified.
795    pub cwd: Option<PathBuf>,
796    /// Names of environment variables the command will receive.
797    pub env_keys: Vec<String>,
798    /// Arbitrary metadata for policy extensions.
799    pub metadata: MetadataMap,
800}
801
802impl PermissionRequest for ShellPermissionRequest {
803    fn kind(&self) -> &'static str {
804        "shell.command"
805    }
806
807    fn summary(&self) -> String {
808        if self.argv.is_empty() {
809            self.executable.clone()
810        } else {
811            format!("{} {}", self.executable, self.argv.join(" "))
812        }
813    }
814
815    fn metadata(&self) -> &MetadataMap {
816        &self.metadata
817    }
818
819    fn as_any(&self) -> &dyn Any {
820        self
821    }
822}
823
824/// Permission request for a filesystem operation.
825///
826/// Evaluated by [`PathPolicy`] to decide whether the target path(s) fall
827/// within allowed or protected directory roots.
828#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
829pub enum FileSystemPermissionRequest {
830    /// Read a file's contents.
831    Read {
832        path: PathBuf,
833        metadata: MetadataMap,
834    },
835    /// Write (create or overwrite) a file.
836    Write {
837        path: PathBuf,
838        metadata: MetadataMap,
839    },
840    /// Edit (modify in place) an existing file.
841    Edit {
842        path: PathBuf,
843        metadata: MetadataMap,
844    },
845    /// Delete a file or directory.
846    Delete {
847        path: PathBuf,
848        metadata: MetadataMap,
849    },
850    /// Move or rename a file.
851    Move {
852        from: PathBuf,
853        to: PathBuf,
854        metadata: MetadataMap,
855    },
856    /// List directory contents.
857    List {
858        path: PathBuf,
859        metadata: MetadataMap,
860    },
861    /// Create a directory (including parents).
862    CreateDir {
863        path: PathBuf,
864        metadata: MetadataMap,
865    },
866}
867
868impl FileSystemPermissionRequest {
869    fn metadata_map(&self) -> &MetadataMap {
870        match self {
871            Self::Read { metadata, .. }
872            | Self::Write { metadata, .. }
873            | Self::Edit { metadata, .. }
874            | Self::Delete { metadata, .. }
875            | Self::Move { metadata, .. }
876            | Self::List { metadata, .. }
877            | Self::CreateDir { metadata, .. } => metadata,
878        }
879    }
880}
881
882impl PermissionRequest for FileSystemPermissionRequest {
883    fn kind(&self) -> &'static str {
884        match self {
885            Self::Read { .. } => "filesystem.read",
886            Self::Write { .. } => "filesystem.write",
887            Self::Edit { .. } => "filesystem.edit",
888            Self::Delete { .. } => "filesystem.delete",
889            Self::Move { .. } => "filesystem.move",
890            Self::List { .. } => "filesystem.list",
891            Self::CreateDir { .. } => "filesystem.mkdir",
892        }
893    }
894
895    fn summary(&self) -> String {
896        match self {
897            Self::Read { path, .. } => format!("Read {}", path.display()),
898            Self::Write { path, .. } => format!("Write {}", path.display()),
899            Self::Edit { path, .. } => format!("Edit {}", path.display()),
900            Self::Delete { path, .. } => format!("Delete {}", path.display()),
901            Self::Move { from, to, .. } => {
902                format!("Move {} to {}", from.display(), to.display())
903            }
904            Self::List { path, .. } => format!("List {}", path.display()),
905            Self::CreateDir { path, .. } => format!("Create directory {}", path.display()),
906        }
907    }
908
909    fn metadata(&self) -> &MetadataMap {
910        self.metadata_map()
911    }
912
913    fn as_any(&self) -> &dyn Any {
914        self
915    }
916}
917
918/// Permission request for an MCP (Model Context Protocol) operation.
919///
920/// Evaluated by [`McpServerPolicy`] to decide whether the target server is
921/// trusted and the requested auth scopes are allowed.
922#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
923pub enum McpPermissionRequest {
924    /// Connect to an MCP server.
925    Connect {
926        server_id: String,
927        metadata: MetadataMap,
928    },
929    /// Invoke a tool exposed by an MCP server.
930    InvokeTool {
931        server_id: String,
932        tool_name: String,
933        metadata: MetadataMap,
934    },
935    /// Read a resource from an MCP server.
936    ReadResource {
937        server_id: String,
938        resource_id: String,
939        metadata: MetadataMap,
940    },
941    /// Fetch a prompt template from an MCP server.
942    FetchPrompt {
943        server_id: String,
944        prompt_id: String,
945        metadata: MetadataMap,
946    },
947    /// Request an auth scope on an MCP server.
948    UseAuthScope {
949        server_id: String,
950        scope: String,
951        metadata: MetadataMap,
952    },
953}
954
955impl McpPermissionRequest {
956    fn metadata_map(&self) -> &MetadataMap {
957        match self {
958            Self::Connect { metadata, .. }
959            | Self::InvokeTool { metadata, .. }
960            | Self::ReadResource { metadata, .. }
961            | Self::FetchPrompt { metadata, .. }
962            | Self::UseAuthScope { metadata, .. } => metadata,
963        }
964    }
965}
966
967impl PermissionRequest for McpPermissionRequest {
968    fn kind(&self) -> &'static str {
969        match self {
970            Self::Connect { .. } => "mcp.connect",
971            Self::InvokeTool { .. } => "mcp.invoke_tool",
972            Self::ReadResource { .. } => "mcp.read_resource",
973            Self::FetchPrompt { .. } => "mcp.fetch_prompt",
974            Self::UseAuthScope { .. } => "mcp.use_auth_scope",
975        }
976    }
977
978    fn summary(&self) -> String {
979        match self {
980            Self::Connect { server_id, .. } => format!("Connect MCP server {server_id}"),
981            Self::InvokeTool {
982                server_id,
983                tool_name,
984                ..
985            } => format!("Invoke MCP tool {server_id}.{tool_name}"),
986            Self::ReadResource {
987                server_id,
988                resource_id,
989                ..
990            } => format!("Read MCP resource {server_id}:{resource_id}"),
991            Self::FetchPrompt {
992                server_id,
993                prompt_id,
994                ..
995            } => format!("Fetch MCP prompt {server_id}:{prompt_id}"),
996            Self::UseAuthScope {
997                server_id, scope, ..
998            } => format!("Use MCP auth scope {server_id}:{scope}"),
999        }
1000    }
1001
1002    fn metadata(&self) -> &MetadataMap {
1003        self.metadata_map()
1004    }
1005
1006    fn as_any(&self) -> &dyn Any {
1007        self
1008    }
1009}
1010
1011/// A [`PermissionPolicy`] that matches requests whose [`PermissionRequest::kind`]
1012/// starts with `"custom."` and allows or denies them by name.
1013///
1014/// Use this to govern application-defined permission categories without
1015/// writing a full policy implementation.
1016///
1017/// # Example
1018///
1019/// ```rust
1020/// use agentkit_tools_core::CustomKindPolicy;
1021///
1022/// let policy = CustomKindPolicy::new(true)
1023///     .allow_kind("custom.analytics")
1024///     .deny_kind("custom.billing");
1025/// ```
1026pub struct CustomKindPolicy {
1027    allowed_kinds: BTreeSet<String>,
1028    denied_kinds: BTreeSet<String>,
1029    require_approval_by_default: bool,
1030}
1031
1032impl CustomKindPolicy {
1033    /// Creates a new policy.
1034    ///
1035    /// # Arguments
1036    ///
1037    /// * `require_approval_by_default` - When `true`, unrecognised `custom.*`
1038    ///   kinds require approval instead of returning [`PolicyMatch::NoOpinion`].
1039    pub fn new(require_approval_by_default: bool) -> Self {
1040        Self {
1041            allowed_kinds: BTreeSet::new(),
1042            denied_kinds: BTreeSet::new(),
1043            require_approval_by_default,
1044        }
1045    }
1046
1047    /// Adds a kind string to the allow-list.
1048    pub fn allow_kind(mut self, kind: impl Into<String>) -> Self {
1049        self.allowed_kinds.insert(kind.into());
1050        self
1051    }
1052
1053    /// Adds a kind string to the deny-list.
1054    pub fn deny_kind(mut self, kind: impl Into<String>) -> Self {
1055        self.denied_kinds.insert(kind.into());
1056        self
1057    }
1058}
1059
1060impl PermissionPolicy for CustomKindPolicy {
1061    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
1062        let kind = request.kind();
1063        if !kind.starts_with("custom.") {
1064            return PolicyMatch::NoOpinion;
1065        }
1066        if self.denied_kinds.contains(kind) {
1067            return PolicyMatch::Deny(PermissionDenial {
1068                code: PermissionCode::CustomPolicyDenied,
1069                message: format!("custom permission kind {kind} is denied"),
1070                metadata: request.metadata().clone(),
1071            });
1072        }
1073        if self.allowed_kinds.contains(kind) {
1074            return PolicyMatch::Allow;
1075        }
1076        if self.require_approval_by_default {
1077            PolicyMatch::RequireApproval(ApprovalRequest {
1078                task_id: None,
1079                call_id: None,
1080                id: ApprovalId::new(format!("approval:{kind}")),
1081                request_kind: kind.to_string(),
1082                reason: ApprovalReason::PolicyRequiresConfirmation,
1083                summary: request.summary(),
1084                metadata: request.metadata().clone(),
1085            })
1086        } else {
1087            PolicyMatch::NoOpinion
1088        }
1089    }
1090}
1091
1092/// A [`PermissionPolicy`] that governs [`FileSystemPermissionRequest`]s by
1093/// checking whether target paths fall within allowed or protected directory trees.
1094///
1095/// Protected roots take priority: any path under a protected root is denied
1096/// immediately. Paths under an allowed root are permitted. Paths outside both
1097/// sets either require approval or are denied, depending on
1098/// `require_approval_outside_allowed`.
1099///
1100/// # Example
1101///
1102/// ```rust
1103/// use agentkit_tools_core::PathPolicy;
1104///
1105/// let policy = PathPolicy::new()
1106///     .allow_root("/workspace/project")
1107///     .read_only_root("/workspace/project/vendor")
1108///     .protect_root("/workspace/project/.env")
1109///     .require_approval_outside_allowed(true);
1110/// ```
1111pub struct PathPolicy {
1112    allowed_roots: Vec<CanonicalRoot>,
1113    read_only_roots: Vec<CanonicalRoot>,
1114    protected_roots: Vec<CanonicalRoot>,
1115    require_approval_outside_allowed: bool,
1116}
1117
1118impl PathPolicy {
1119    /// Creates a new path policy with no roots and approval required for
1120    /// paths outside allowed roots.
1121    pub fn new() -> Self {
1122        Self {
1123            allowed_roots: Vec::new(),
1124            read_only_roots: Vec::new(),
1125            protected_roots: Vec::new(),
1126            require_approval_outside_allowed: true,
1127        }
1128    }
1129
1130    /// Adds a directory tree that filesystem operations are allowed to target.
1131    pub fn allow_root(mut self, root: impl Into<PathBuf>) -> Self {
1132        self.allowed_roots.push(CanonicalRoot::new(root.into()));
1133        self
1134    }
1135
1136    /// Adds a directory tree that may be read or listed but not mutated.
1137    pub fn read_only_root(mut self, root: impl Into<PathBuf>) -> Self {
1138        self.read_only_roots.push(CanonicalRoot::new(root.into()));
1139        self
1140    }
1141
1142    /// Adds a directory tree that filesystem operations are never allowed to target.
1143    pub fn protect_root(mut self, root: impl Into<PathBuf>) -> Self {
1144        self.protected_roots.push(CanonicalRoot::new(root.into()));
1145        self
1146    }
1147
1148    /// When `true` (the default), paths outside allowed roots trigger an
1149    /// approval request instead of an outright denial.
1150    pub fn require_approval_outside_allowed(mut self, value: bool) -> Self {
1151        self.require_approval_outside_allowed = value;
1152        self
1153    }
1154}
1155
1156impl Default for PathPolicy {
1157    fn default() -> Self {
1158        Self::new()
1159    }
1160}
1161
1162/// Resolves `path` for symlink-safe containment checks; falls back to the
1163/// lexically-absolute path so policy decisions stay deterministic when no
1164/// component on disk yet exists.
1165fn resolve_canonical(path: &Path) -> PathBuf {
1166    let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
1167    canonicalize_with_partial_fallback(&abs).unwrap_or(abs)
1168}
1169
1170fn canonicalize_with_partial_fallback(abs: &Path) -> Option<PathBuf> {
1171    if let Ok(canonical) = std::fs::canonicalize(abs) {
1172        return Some(canonical);
1173    }
1174    let mut tail: Vec<std::ffi::OsString> = Vec::new();
1175    let mut current = abs.to_path_buf();
1176    loop {
1177        let name = current.file_name().map(|n| n.to_os_string())?;
1178        tail.push(name);
1179        if !current.pop() {
1180            return None;
1181        }
1182        if let Ok(canonical) = std::fs::canonicalize(&current) {
1183            let mut out = canonical;
1184            for seg in tail.iter().rev() {
1185                out.push(seg);
1186            }
1187            return Some(out);
1188        }
1189    }
1190}
1191
1192/// A configured root with a lazily-cached canonical form.
1193///
1194/// Roots can be registered before they exist on disk; we only memoise once
1195/// `fs::canonicalize` succeeds, so symlink changes to not-yet-existent
1196/// components are still picked up on later evaluations.
1197struct CanonicalRoot {
1198    lexical: PathBuf,
1199    canonical: OnceLock<PathBuf>,
1200}
1201
1202impl CanonicalRoot {
1203    fn new(lexical: PathBuf) -> Self {
1204        Self {
1205            lexical,
1206            canonical: OnceLock::new(),
1207        }
1208    }
1209
1210    fn resolve(&self) -> std::borrow::Cow<'_, Path> {
1211        if let Some(canonical) = self.canonical.get() {
1212            return std::borrow::Cow::Borrowed(canonical);
1213        }
1214        let abs =
1215            std::path::absolute(&self.lexical).unwrap_or_else(|_| self.lexical.clone());
1216        if let Ok(canonical) = std::fs::canonicalize(&abs) {
1217            let _ = self.canonical.set(canonical);
1218            return std::borrow::Cow::Borrowed(self.canonical.get().unwrap());
1219        }
1220        std::borrow::Cow::Owned(canonicalize_with_partial_fallback(&abs).unwrap_or(abs))
1221    }
1222}
1223
1224impl PermissionPolicy for PathPolicy {
1225    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
1226        let Some(fs) = request
1227            .as_any()
1228            .downcast_ref::<FileSystemPermissionRequest>()
1229        else {
1230            return PolicyMatch::NoOpinion;
1231        };
1232
1233        let raw_paths: Vec<&Path> = match fs {
1234            FileSystemPermissionRequest::Move { from, to, .. } => {
1235                vec![from.as_path(), to.as_path()]
1236            }
1237            FileSystemPermissionRequest::Read { path, .. }
1238            | FileSystemPermissionRequest::Write { path, .. }
1239            | FileSystemPermissionRequest::Edit { path, .. }
1240            | FileSystemPermissionRequest::Delete { path, .. }
1241            | FileSystemPermissionRequest::List { path, .. }
1242            | FileSystemPermissionRequest::CreateDir { path, .. } => vec![path.as_path()],
1243        };
1244
1245        let candidate_paths: Vec<PathBuf> =
1246            raw_paths.iter().map(|p| resolve_canonical(p)).collect();
1247
1248        let mutates = matches!(
1249            fs,
1250            FileSystemPermissionRequest::Write { .. }
1251                | FileSystemPermissionRequest::Edit { .. }
1252                | FileSystemPermissionRequest::Delete { .. }
1253                | FileSystemPermissionRequest::Move { .. }
1254                | FileSystemPermissionRequest::CreateDir { .. }
1255        );
1256
1257        if candidate_paths.iter().any(|path| {
1258            self.protected_roots
1259                .iter()
1260                .any(|root| path.starts_with(root.resolve().as_ref()))
1261        }) {
1262            return PolicyMatch::Deny(PermissionDenial {
1263                code: PermissionCode::PathNotAllowed,
1264                message: format!("path access denied for {}", fs.summary()),
1265                metadata: fs.metadata().clone(),
1266            });
1267        }
1268
1269        if mutates
1270            && candidate_paths.iter().any(|path| {
1271                self.read_only_roots
1272                    .iter()
1273                    .any(|root| path.starts_with(root.resolve().as_ref()))
1274            })
1275        {
1276            return PolicyMatch::Deny(PermissionDenial {
1277                code: PermissionCode::PathNotAllowed,
1278                message: format!("path is read-only for {}", fs.summary()),
1279                metadata: fs.metadata().clone(),
1280            });
1281        }
1282
1283        if self.allowed_roots.is_empty() {
1284            return PolicyMatch::NoOpinion;
1285        }
1286
1287        let all_allowed = candidate_paths.iter().all(|path| {
1288            self.allowed_roots
1289                .iter()
1290                .any(|root| path.starts_with(root.resolve().as_ref()))
1291        });
1292
1293        if all_allowed {
1294            PolicyMatch::Allow
1295        } else if self.require_approval_outside_allowed {
1296            PolicyMatch::RequireApproval(ApprovalRequest {
1297                task_id: None,
1298                call_id: None,
1299                id: ApprovalId::new(format!("approval:{}", fs.kind())),
1300                request_kind: fs.kind().to_string(),
1301                reason: ApprovalReason::SensitivePath,
1302                summary: fs.summary(),
1303                metadata: fs.metadata().clone(),
1304            })
1305        } else {
1306            PolicyMatch::Deny(PermissionDenial {
1307                code: PermissionCode::PathNotAllowed,
1308                message: format!("path outside allowed roots for {}", fs.summary()),
1309                metadata: fs.metadata().clone(),
1310            })
1311        }
1312    }
1313}
1314
1315/// A [`PermissionPolicy`] that governs [`ShellPermissionRequest`]s by checking
1316/// the executable name, working directory, and environment variables.
1317///
1318/// Denied executables and env keys are rejected immediately. Allowed
1319/// executables pass. Unknown executables either require approval or are
1320/// denied, depending on `require_approval_for_unknown`.
1321///
1322/// # Example
1323///
1324/// ```rust
1325/// use agentkit_tools_core::CommandPolicy;
1326///
1327/// let policy = CommandPolicy::new()
1328///     .allow_executable("git")
1329///     .allow_executable("cargo")
1330///     .deny_executable("rm")
1331///     .deny_env_key("AWS_SECRET_ACCESS_KEY")
1332///     .allow_cwd("/workspace")
1333///     .require_approval_for_unknown(true);
1334/// ```
1335pub struct CommandPolicy {
1336    allowed_executables: BTreeSet<String>,
1337    denied_executables: BTreeSet<String>,
1338    allowed_cwds: Vec<PathBuf>,
1339    denied_env_keys: BTreeSet<String>,
1340    require_approval_for_unknown: bool,
1341}
1342
1343impl CommandPolicy {
1344    /// Creates a new command policy with no rules and approval required
1345    /// for unknown executables.
1346    pub fn new() -> Self {
1347        Self {
1348            allowed_executables: BTreeSet::new(),
1349            denied_executables: BTreeSet::new(),
1350            allowed_cwds: Vec::new(),
1351            denied_env_keys: BTreeSet::new(),
1352            require_approval_for_unknown: true,
1353        }
1354    }
1355
1356    /// Adds an executable name to the allow-list.
1357    pub fn allow_executable(mut self, executable: impl Into<String>) -> Self {
1358        self.allowed_executables.insert(executable.into());
1359        self
1360    }
1361
1362    /// Adds an executable name to the deny-list.
1363    pub fn deny_executable(mut self, executable: impl Into<String>) -> Self {
1364        self.denied_executables.insert(executable.into());
1365        self
1366    }
1367
1368    /// Adds a directory root that commands are allowed to run in.
1369    pub fn allow_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
1370        self.allowed_cwds.push(cwd.into());
1371        self
1372    }
1373
1374    /// Adds an environment variable name to the deny-list.
1375    pub fn deny_env_key(mut self, key: impl Into<String>) -> Self {
1376        self.denied_env_keys.insert(key.into());
1377        self
1378    }
1379
1380    /// When `true` (the default), executables not in the allow-list trigger
1381    /// an approval request instead of an outright denial.
1382    pub fn require_approval_for_unknown(mut self, value: bool) -> Self {
1383        self.require_approval_for_unknown = value;
1384        self
1385    }
1386}
1387
1388impl Default for CommandPolicy {
1389    fn default() -> Self {
1390        Self::new()
1391    }
1392}
1393
1394impl PermissionPolicy for CommandPolicy {
1395    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
1396        let Some(shell) = request.as_any().downcast_ref::<ShellPermissionRequest>() else {
1397            return PolicyMatch::NoOpinion;
1398        };
1399
1400        if self.denied_executables.contains(&shell.executable)
1401            || shell
1402                .env_keys
1403                .iter()
1404                .any(|key| self.denied_env_keys.contains(key))
1405        {
1406            return PolicyMatch::Deny(PermissionDenial {
1407                code: PermissionCode::CommandNotAllowed,
1408                message: format!("command denied for {}", shell.summary()),
1409                metadata: shell.metadata().clone(),
1410            });
1411        }
1412
1413        if let Some(cwd) = &shell.cwd
1414            && !self.allowed_cwds.is_empty()
1415            && !self.allowed_cwds.iter().any(|root| cwd.starts_with(root))
1416        {
1417            return PolicyMatch::RequireApproval(ApprovalRequest {
1418                task_id: None,
1419                call_id: None,
1420                id: ApprovalId::new("approval:shell.cwd"),
1421                request_kind: shell.kind().to_string(),
1422                reason: ApprovalReason::SensitiveCommand,
1423                summary: shell.summary(),
1424                metadata: shell.metadata().clone(),
1425            });
1426        }
1427
1428        if self.allowed_executables.is_empty()
1429            || self.allowed_executables.contains(&shell.executable)
1430        {
1431            PolicyMatch::Allow
1432        } else if self.require_approval_for_unknown {
1433            PolicyMatch::RequireApproval(ApprovalRequest {
1434                task_id: None,
1435                call_id: None,
1436                id: ApprovalId::new("approval:shell.command"),
1437                request_kind: shell.kind().to_string(),
1438                reason: ApprovalReason::SensitiveCommand,
1439                summary: shell.summary(),
1440                metadata: shell.metadata().clone(),
1441            })
1442        } else {
1443            PolicyMatch::Deny(PermissionDenial {
1444                code: PermissionCode::CommandNotAllowed,
1445                message: format!("executable {} is not allowed", shell.executable),
1446                metadata: shell.metadata().clone(),
1447            })
1448        }
1449    }
1450}
1451
1452/// A [`PermissionPolicy`] that governs [`McpPermissionRequest`]s by checking
1453/// whether the target server is trusted and the requested auth scopes are
1454/// in the allow-list.
1455///
1456/// # Example
1457///
1458/// ```rust
1459/// use agentkit_tools_core::McpServerPolicy;
1460///
1461/// let policy = McpServerPolicy::new()
1462///     .trust_server("github-mcp")
1463///     .allow_auth_scope("repo:read");
1464/// ```
1465pub struct McpServerPolicy {
1466    trusted_servers: BTreeSet<String>,
1467    allowed_auth_scopes: BTreeSet<String>,
1468    require_approval_for_untrusted: bool,
1469}
1470
1471impl McpServerPolicy {
1472    /// Creates a new MCP server policy with approval required for untrusted
1473    /// servers.
1474    pub fn new() -> Self {
1475        Self {
1476            trusted_servers: BTreeSet::new(),
1477            allowed_auth_scopes: BTreeSet::new(),
1478            require_approval_for_untrusted: true,
1479        }
1480    }
1481
1482    /// Marks a server as trusted so operations targeting it are allowed.
1483    pub fn trust_server(mut self, server_id: impl Into<String>) -> Self {
1484        self.trusted_servers.insert(server_id.into());
1485        self
1486    }
1487
1488    /// Adds an auth scope to the allow-list.
1489    pub fn allow_auth_scope(mut self, scope: impl Into<String>) -> Self {
1490        self.allowed_auth_scopes.insert(scope.into());
1491        self
1492    }
1493}
1494
1495impl Default for McpServerPolicy {
1496    fn default() -> Self {
1497        Self::new()
1498    }
1499}
1500
1501impl PermissionPolicy for McpServerPolicy {
1502    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
1503        let Some(mcp) = request.as_any().downcast_ref::<McpPermissionRequest>() else {
1504            return PolicyMatch::NoOpinion;
1505        };
1506
1507        let server_id = match mcp {
1508            McpPermissionRequest::Connect { server_id, .. }
1509            | McpPermissionRequest::InvokeTool { server_id, .. }
1510            | McpPermissionRequest::ReadResource { server_id, .. }
1511            | McpPermissionRequest::FetchPrompt { server_id, .. }
1512            | McpPermissionRequest::UseAuthScope { server_id, .. } => server_id,
1513        };
1514
1515        if !self.trusted_servers.is_empty() && !self.trusted_servers.contains(server_id) {
1516            return if self.require_approval_for_untrusted {
1517                PolicyMatch::RequireApproval(ApprovalRequest {
1518                    task_id: None,
1519                    call_id: None,
1520                    id: ApprovalId::new(format!("approval:mcp:{server_id}")),
1521                    request_kind: mcp.kind().to_string(),
1522                    reason: ApprovalReason::SensitiveServer,
1523                    summary: mcp.summary(),
1524                    metadata: mcp.metadata().clone(),
1525                })
1526            } else {
1527                PolicyMatch::Deny(PermissionDenial {
1528                    code: PermissionCode::ServerNotTrusted,
1529                    message: format!("MCP server {server_id} is not trusted"),
1530                    metadata: mcp.metadata().clone(),
1531                })
1532            };
1533        }
1534
1535        if let McpPermissionRequest::UseAuthScope { scope, .. } = mcp
1536            && !self.allowed_auth_scopes.is_empty()
1537            && !self.allowed_auth_scopes.contains(scope)
1538        {
1539            return PolicyMatch::Deny(PermissionDenial {
1540                code: PermissionCode::AuthScopeNotAllowed,
1541                message: format!("MCP auth scope {scope} is not allowed"),
1542                metadata: mcp.metadata().clone(),
1543            });
1544        }
1545
1546        PolicyMatch::Allow
1547    }
1548}
1549
1550/// The central abstraction for an executable tool in an agentkit agent.
1551///
1552/// Implement this trait to define a tool that an LLM can call. Each tool
1553/// provides a [`ToolSpec`] describing its name, schema, and hints, optional
1554/// permission requests via [`proposed_requests`](Tool::proposed_requests),
1555/// and the actual execution logic in [`invoke`](Tool::invoke).
1556///
1557/// # Example
1558///
1559/// ```rust
1560/// use agentkit_core::{MetadataMap, ToolOutput, ToolResultPart};
1561/// use agentkit_tools_core::{
1562///     Tool, ToolContext, ToolError, ToolName, ToolRequest, ToolResult, ToolSpec,
1563/// };
1564/// use async_trait::async_trait;
1565/// use serde_json::json;
1566///
1567/// struct TimeTool {
1568///     spec: ToolSpec,
1569/// }
1570///
1571/// impl TimeTool {
1572///     fn new() -> Self {
1573///         Self {
1574///             spec: ToolSpec::new(
1575///                 ToolName::new("current_time"),
1576///                 "Returns the current UTC time",
1577///                 json!({ "type": "object" }),
1578///             ),
1579///         }
1580///     }
1581/// }
1582///
1583/// #[async_trait]
1584/// impl Tool for TimeTool {
1585///     fn spec(&self) -> &ToolSpec {
1586///         &self.spec
1587///     }
1588///
1589///     async fn invoke(
1590///         &self,
1591///         request: ToolRequest,
1592///         _ctx: &mut ToolContext<'_>,
1593///     ) -> Result<ToolResult, ToolError> {
1594///         Ok(ToolResult::new(ToolResultPart::success(
1595///             request.call_id,
1596///             ToolOutput::text("2026-03-22T12:00:00Z"),
1597///         )))
1598///     }
1599/// }
1600/// ```
1601#[async_trait]
1602pub trait Tool: Send + Sync {
1603    /// Returns the static specification for this tool.
1604    fn spec(&self) -> &ToolSpec;
1605
1606    /// Returns the current specification for this tool, if it should be
1607    /// advertised right now.
1608    ///
1609    /// Most tools are static and can rely on the default implementation,
1610    /// which clones [`spec`](Self::spec). Override this when the description
1611    /// or input schema should reflect runtime state, or when the tool should
1612    /// be temporarily hidden from the model.
1613    fn current_spec(&self) -> Option<ToolSpec> {
1614        Some(self.spec().clone())
1615    }
1616
1617    /// Returns permission requests the executor should evaluate before calling
1618    /// [`invoke`](Tool::invoke).
1619    ///
1620    /// The default implementation returns an empty list (no permissions needed).
1621    /// Override this to declare filesystem, shell, or custom permission
1622    /// requirements based on the incoming request.
1623    ///
1624    /// # Errors
1625    ///
1626    /// Return [`ToolError::InvalidInput`] if the request input is malformed
1627    /// and permission requests cannot be constructed.
1628    fn proposed_requests(
1629        &self,
1630        _request: &ToolRequest,
1631    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
1632        Ok(Vec::new())
1633    }
1634
1635    /// Executes the tool and returns a result or error.
1636    ///
1637    /// # Errors
1638    ///
1639    /// Return an appropriate [`ToolError`] variant on failure. Source-specific
1640    /// concerns such as MCP authentication are resolved internally by the
1641    /// source (via host-supplied responders) and are not surfaced as tool
1642    /// errors.
1643    async fn invoke(
1644        &self,
1645        request: ToolRequest,
1646        ctx: &mut ToolContext<'_>,
1647    ) -> Result<ToolResult, ToolError>;
1648}
1649
1650/// A name-keyed collection of [`Tool`] implementations.
1651///
1652/// The registry owns `Arc`-wrapped tools and is passed to a
1653/// [`BasicToolExecutor`] (or consumed by [`ToolCapabilityProvider`]) so the
1654/// agent loop can look up tools by name at execution time.
1655///
1656/// # Example
1657///
1658/// ```rust
1659/// use agentkit_tools_core::ToolRegistry;
1660/// # use agentkit_tools_core::{Tool, ToolContext, ToolError, ToolName, ToolRequest, ToolResult, ToolSpec};
1661/// # use async_trait::async_trait;
1662/// # use serde_json::json;
1663/// # struct NoopTool(ToolSpec);
1664/// # #[async_trait]
1665/// # impl Tool for NoopTool {
1666/// #     fn spec(&self) -> &ToolSpec { &self.0 }
1667/// #     async fn invoke(&self, _r: ToolRequest, _c: &mut ToolContext<'_>) -> Result<ToolResult, ToolError> { todo!() }
1668/// # }
1669///
1670/// let registry = ToolRegistry::new()
1671///     .with(NoopTool(ToolSpec::new(
1672///         ToolName::new("noop"),
1673///         "Does nothing",
1674///         json!({"type": "object"}),
1675///     )));
1676///
1677/// assert!(registry.get(&ToolName::new("noop")).is_some());
1678/// assert_eq!(registry.specs().len(), 1);
1679/// ```
1680#[derive(Clone, Default)]
1681pub struct ToolRegistry {
1682    tools: BTreeMap<ToolName, Arc<dyn Tool>>,
1683}
1684
1685impl ToolRegistry {
1686    /// Creates an empty registry.
1687    pub fn new() -> Self {
1688        Self::default()
1689    }
1690
1691    /// Registers a tool by value and returns `&mut self` for imperative chaining.
1692    pub fn register<T>(&mut self, tool: T) -> &mut Self
1693    where
1694        T: Tool + 'static,
1695    {
1696        self.tools.insert(tool.spec().name.clone(), Arc::new(tool));
1697        self
1698    }
1699
1700    /// Registers a tool by value and returns `self` for builder-style chaining.
1701    pub fn with<T>(mut self, tool: T) -> Self
1702    where
1703        T: Tool + 'static,
1704    {
1705        self.register(tool);
1706        self
1707    }
1708
1709    /// Registers a pre-wrapped `Arc<dyn Tool>`.
1710    pub fn register_arc(&mut self, tool: Arc<dyn Tool>) -> &mut Self {
1711        self.tools.insert(tool.spec().name.clone(), tool);
1712        self
1713    }
1714
1715    /// Looks up a tool by name, returning `None` if not registered.
1716    pub fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1717        self.tools.get(name).cloned()
1718    }
1719
1720    /// Returns all registered tools as a `Vec`.
1721    pub fn tools(&self) -> Vec<Arc<dyn Tool>> {
1722        self.tools.values().cloned().collect()
1723    }
1724
1725    /// Merges all tools from another registry into this one, consuming it.
1726    ///
1727    /// Supports builder-style chaining:
1728    ///
1729    /// ```ignore
1730    /// let registry = agentkit_tool_fs::registry()
1731    ///     .merge(agentkit_tool_shell::registry());
1732    /// ```
1733    pub fn merge(mut self, other: Self) -> Self {
1734        self.tools.extend(other.tools);
1735        self
1736    }
1737
1738    /// Returns the [`ToolSpec`] for every registered tool.
1739    pub fn specs(&self) -> Vec<ToolSpec> {
1740        self.tools
1741            .values()
1742            .filter_map(|tool| tool.current_spec())
1743            .collect()
1744    }
1745}
1746
1747/// Read-side contract for a federated tool catalog.
1748///
1749/// A [`BasicToolExecutor`] composes one or more `ToolSource`s — typically a
1750/// frozen [`ToolRegistry`] of native tools alongside one or more
1751/// [`CatalogReader`]s owned by subsystems (MCP server manager, skill watcher,
1752/// plugin loader). Each source manages its own lifecycle and concurrency
1753/// story; the executor only reads.
1754pub trait ToolSource: Send + Sync {
1755    /// Returns the current spec for every tool in this source.
1756    fn specs(&self) -> Vec<ToolSpec>;
1757
1758    /// Looks up a tool by name, returning `None` if not present.
1759    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>>;
1760
1761    /// Drains pending catalog change events. Static sources return an empty
1762    /// list; dynamic sources surface added/removed/changed batches that the
1763    /// loop forwards to the model on the next turn.
1764    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
1765        Vec::new()
1766    }
1767
1768    /// Wraps this source so every advertised tool name is prefixed with
1769    /// `<prefix>_`. Useful for mounting the same source under multiple
1770    /// namespaces, or for avoiding collisions between MCP catalogs.
1771    ///
1772    /// Lookups strip the prefix before delegating, and the wrapped tool's
1773    /// `spec()` reports the public (prefixed) name so the model and the
1774    /// tool see consistent names.
1775    ///
1776    /// To wrap an `Arc<dyn ToolSource>` instead, use [`Prefixed::new`].
1777    fn prefixed(self, prefix: impl Into<String>) -> Prefixed<Self>
1778    where
1779        Self: Sized,
1780    {
1781        Prefixed::new(self, prefix)
1782    }
1783
1784    /// Wraps this source so only tools whose name passes `predicate` are
1785    /// advertised and resolvable. Tools rejected by the predicate are
1786    /// invisible to the model and return `None` on lookup.
1787    ///
1788    /// To wrap an `Arc<dyn ToolSource>` instead, use [`Filtered::new`].
1789    fn filtered<F>(self, predicate: F) -> Filtered<Self, F>
1790    where
1791        Self: Sized,
1792        F: Fn(&ToolName) -> bool + Send + Sync + 'static,
1793    {
1794        Filtered::new(self, predicate)
1795    }
1796
1797    /// Wraps this source with a name remapping. Each `(original, new)` pair
1798    /// in `mapping` causes the tool to be advertised as `new` and resolved
1799    /// from `new` back to `original` on lookup. Tools not in the mapping
1800    /// pass through unchanged.
1801    ///
1802    /// To wrap an `Arc<dyn ToolSource>` instead, use [`Renamed::new`].
1803    fn renamed<I>(self, mapping: I) -> Renamed<Self>
1804    where
1805        Self: Sized,
1806        I: IntoIterator<Item = (ToolName, ToolName)>,
1807    {
1808        Renamed::new(self, mapping)
1809    }
1810}
1811
1812impl ToolSource for ToolRegistry {
1813    fn specs(&self) -> Vec<ToolSpec> {
1814        ToolRegistry::specs(self)
1815    }
1816
1817    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1818        ToolRegistry::get(self, name)
1819    }
1820}
1821
1822impl<S> ToolSource for Arc<S>
1823where
1824    S: ToolSource + ?Sized,
1825{
1826    fn specs(&self) -> Vec<ToolSpec> {
1827        (**self).specs()
1828    }
1829
1830    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1831        (**self).get(name)
1832    }
1833
1834    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
1835        (**self).drain_catalog_events()
1836    }
1837}
1838
1839/// A [`ToolSource`] wrapper that prefixes every advertised tool name with
1840/// `<prefix>_`. Constructed via [`ToolSource::prefixed`] or directly.
1841pub struct Prefixed<S> {
1842    inner: S,
1843    prefix: String,
1844}
1845
1846impl<S> Prefixed<S> {
1847    /// Creates a new prefixed wrapper.
1848    pub fn new(inner: S, prefix: impl Into<String>) -> Self {
1849        Self {
1850            inner,
1851            prefix: prefix.into(),
1852        }
1853    }
1854
1855    fn rewrite(&self, name: &str) -> String {
1856        format!("{}_{}", self.prefix, name)
1857    }
1858
1859    fn strip<'a>(&self, name: &'a str) -> Option<&'a str> {
1860        name.strip_prefix(self.prefix.as_str())
1861            .and_then(|rest| rest.strip_prefix('_'))
1862    }
1863}
1864
1865impl<S> ToolSource for Prefixed<S>
1866where
1867    S: ToolSource,
1868{
1869    fn specs(&self) -> Vec<ToolSpec> {
1870        self.inner
1871            .specs()
1872            .into_iter()
1873            .map(|mut spec| {
1874                spec.name = ToolName::new(self.rewrite(spec.name.0.as_str()));
1875                spec
1876            })
1877            .collect()
1878    }
1879
1880    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1881        let original = self.strip(name.0.as_str())?;
1882        let inner_name = ToolName::new(original);
1883        let inner_tool = self.inner.get(&inner_name)?;
1884        let mut public_spec = inner_tool.spec().clone();
1885        public_spec.name = name.clone();
1886        Some(Arc::new(RewrittenTool {
1887            inner: inner_tool,
1888            inner_name,
1889            public_spec,
1890        }))
1891    }
1892
1893    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
1894        self.inner
1895            .drain_catalog_events()
1896            .into_iter()
1897            .map(|mut event| {
1898                event.for_each_name_mut(|name| *name = self.rewrite(name.as_str()));
1899                event
1900            })
1901            .collect()
1902    }
1903}
1904
1905/// A [`ToolSource`] wrapper that hides tools rejected by `predicate`.
1906/// Constructed via [`ToolSource::filtered`] or directly.
1907pub struct Filtered<S, F> {
1908    inner: S,
1909    predicate: F,
1910}
1911
1912impl<S, F> Filtered<S, F> {
1913    /// Creates a new filtered wrapper.
1914    pub fn new(inner: S, predicate: F) -> Self {
1915        Self { inner, predicate }
1916    }
1917}
1918
1919impl<S, F> ToolSource for Filtered<S, F>
1920where
1921    S: ToolSource,
1922    F: Fn(&ToolName) -> bool + Send + Sync + 'static,
1923{
1924    fn specs(&self) -> Vec<ToolSpec> {
1925        self.inner
1926            .specs()
1927            .into_iter()
1928            .filter(|spec| (self.predicate)(&spec.name))
1929            .collect()
1930    }
1931
1932    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1933        if !(self.predicate)(name) {
1934            return None;
1935        }
1936        self.inner.get(name)
1937    }
1938
1939    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
1940        self.inner
1941            .drain_catalog_events()
1942            .into_iter()
1943            .map(|mut event| {
1944                event.retain_names(|n| (self.predicate)(&ToolName::new(n)));
1945                event
1946            })
1947            .collect()
1948    }
1949}
1950
1951/// A [`ToolSource`] wrapper that renames specific tools. Tools whose
1952/// original name appears in the forward mapping are advertised under the
1953/// new name and resolved from the new name back to the original.
1954/// Unmapped names pass through unchanged.
1955///
1956/// Constructed via [`ToolSource::renamed`] or directly.
1957pub struct Renamed<S> {
1958    inner: S,
1959    forward: BTreeMap<ToolName, ToolName>,
1960    backward: BTreeMap<ToolName, ToolName>,
1961}
1962
1963impl<S> Renamed<S> {
1964    /// Creates a new renaming wrapper from a `(original, new)` mapping.
1965    pub fn new<I>(inner: S, mapping: I) -> Self
1966    where
1967        I: IntoIterator<Item = (ToolName, ToolName)>,
1968    {
1969        let forward: BTreeMap<ToolName, ToolName> = mapping.into_iter().collect();
1970        let backward = forward
1971            .iter()
1972            .map(|(k, v)| (v.clone(), k.clone()))
1973            .collect();
1974        Self {
1975            inner,
1976            forward,
1977            backward,
1978        }
1979    }
1980}
1981
1982impl<S> ToolSource for Renamed<S>
1983where
1984    S: ToolSource,
1985{
1986    fn specs(&self) -> Vec<ToolSpec> {
1987        self.inner
1988            .specs()
1989            .into_iter()
1990            .map(|mut spec| {
1991                if let Some(new_name) = self.forward.get(&spec.name) {
1992                    spec.name = new_name.clone();
1993                }
1994                spec
1995            })
1996            .collect()
1997    }
1998
1999    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2000        if let Some(original) = self.backward.get(name) {
2001            let inner_tool = self.inner.get(original)?;
2002            let mut public_spec = inner_tool.spec().clone();
2003            public_spec.name = name.clone();
2004            Some(Arc::new(RewrittenTool {
2005                inner: inner_tool,
2006                inner_name: original.clone(),
2007                public_spec,
2008            }))
2009        } else if self.forward.contains_key(name) {
2010            // Original name of a remapped tool — hidden under its new name.
2011            None
2012        } else {
2013            self.inner.get(name)
2014        }
2015    }
2016
2017    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2018        self.inner
2019            .drain_catalog_events()
2020            .into_iter()
2021            .map(|mut event| {
2022                event.for_each_name_mut(|name| {
2023                    if let Some(new) = self.forward.get(&ToolName::new(name.as_str())) {
2024                        *name = new.0.clone();
2025                    }
2026                });
2027                event
2028            })
2029            .collect()
2030    }
2031}
2032
2033/// Builds a JSON Schema [`Value`] for the given input type. Requires the
2034/// `schemars` feature.
2035///
2036/// This is the bridge between Rust types and the
2037/// [`ToolSpec::input_schema`] field — instead of hand-writing JSON Schema,
2038/// derive [`schemars::JsonSchema`] on your input struct and call this.
2039///
2040/// # Example
2041///
2042/// ```rust,ignore
2043/// use agentkit_tools_core::schema_for;
2044/// use schemars::JsonSchema;
2045///
2046/// #[derive(JsonSchema)]
2047/// struct WeatherInput {
2048///     /// City name to look up.
2049///     location: String,
2050///     /// Use celsius (default false).
2051///     #[serde(default)]
2052///     celsius: bool,
2053/// }
2054///
2055/// let schema = schema_for::<WeatherInput>();
2056/// assert!(schema.is_object());
2057/// ```
2058#[cfg(feature = "schemars")]
2059pub fn schema_for<T: schemars::JsonSchema>() -> Value {
2060    let schema = schemars::schema_for!(T);
2061    serde_json::to_value(schema)
2062        .expect("schemars produces valid JSON; this conversion is infallible")
2063}
2064
2065/// Builds a [`ToolSpec`] from `T`'s derived JSON Schema. Requires the
2066/// `schemars` feature. The generated schema is exactly what
2067/// [`schema_for::<T>`] produces; this helper just wraps it with a name and
2068/// description.
2069///
2070/// # Example
2071///
2072/// ```rust,ignore
2073/// use agentkit_tools_core::tool_spec_for;
2074/// use schemars::JsonSchema;
2075///
2076/// #[derive(JsonSchema)]
2077/// struct WeatherInput { location: String }
2078///
2079/// let spec = tool_spec_for::<WeatherInput>("get_weather", "Fetch current weather");
2080/// assert_eq!(spec.name.0, "get_weather");
2081/// ```
2082#[cfg(feature = "schemars")]
2083pub fn tool_spec_for<T: schemars::JsonSchema>(
2084    name: impl Into<ToolName>,
2085    description: impl Into<String>,
2086) -> ToolSpec {
2087    ToolSpec::new(name, description, schema_for::<T>())
2088}
2089
2090/// A [`Tool`] wrapper used by [`Prefixed`] and [`Renamed`] to bridge between
2091/// the public (rewritten) tool name and the inner tool's own name. The
2092/// wrapper reports the public spec but rewrites `request.tool_name` back to
2093/// the inner name before delegating to the wrapped tool, so tools that
2094/// inspect their own name (e.g. for logging or routing) see the original.
2095struct RewrittenTool {
2096    inner: Arc<dyn Tool>,
2097    inner_name: ToolName,
2098    public_spec: ToolSpec,
2099}
2100
2101#[async_trait]
2102impl Tool for RewrittenTool {
2103    fn spec(&self) -> &ToolSpec {
2104        &self.public_spec
2105    }
2106
2107    fn current_spec(&self) -> Option<ToolSpec> {
2108        let inner_current = self.inner.current_spec()?;
2109        Some(ToolSpec {
2110            name: self.public_spec.name.clone(),
2111            description: inner_current.description,
2112            input_schema: inner_current.input_schema,
2113            annotations: inner_current.annotations,
2114            metadata: inner_current.metadata,
2115        })
2116    }
2117
2118    fn proposed_requests(
2119        &self,
2120        request: &ToolRequest,
2121    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
2122        let mut inner_request = request.clone();
2123        inner_request.tool_name = self.inner_name.clone();
2124        self.inner.proposed_requests(&inner_request)
2125    }
2126
2127    async fn invoke(
2128        &self,
2129        mut request: ToolRequest,
2130        ctx: &mut ToolContext<'_>,
2131    ) -> Result<ToolResult, ToolError> {
2132        request.tool_name = self.inner_name.clone();
2133        self.inner.invoke(request, ctx).await
2134    }
2135}
2136
2137/// Catalog storage with poison-recovery encoded in the type. The wrapped
2138/// `RwLock` is private; only [`read`](Self::read) and [`write`](Self::write)
2139/// are exposed, both infallible. Recovery is safe because every callsite
2140/// honors the invariant below.
2141///
2142/// **Invariant for callers:** write critical sections must compute all
2143/// derived state — diffs, comparisons, anything that may run user code
2144/// (`Tool` impls in particular) — BEFORE mutating the map. If a panic fires
2145/// between two mutations in the same critical section, recovery would hand
2146/// the next caller a partially-updated map. The current callsites all hold
2147/// this: `upsert`/`remove` perform a single op with no user code;
2148/// `replace_all` completes its diff (which calls `Tool::current_spec`)
2149/// before the swap.
2150///
2151/// The `catalog_recovers_from_panicked_writer` test exercises the recovery
2152/// path; if you change a write critical section, re-check that it still
2153/// computes-then-mutates.
2154struct ToolMap {
2155    inner: std::sync::RwLock<BTreeMap<ToolName, Arc<dyn Tool>>>,
2156}
2157
2158impl ToolMap {
2159    fn new() -> Self {
2160        Self {
2161            inner: std::sync::RwLock::new(BTreeMap::new()),
2162        }
2163    }
2164
2165    fn read(&self) -> std::sync::RwLockReadGuard<'_, BTreeMap<ToolName, Arc<dyn Tool>>> {
2166        self.inner.read().unwrap_or_else(|e| e.into_inner())
2167    }
2168
2169    fn write(&self) -> std::sync::RwLockWriteGuard<'_, BTreeMap<ToolName, Arc<dyn Tool>>> {
2170        self.inner.write().unwrap_or_else(|e| e.into_inner())
2171    }
2172}
2173
2174/// Shared inner state of a dynamic catalog. Held by both [`CatalogWriter`]
2175/// (mutates) and [`CatalogReader`] (reads), behind `Arc`s that hosts never see.
2176struct DynamicCatalogInner {
2177    source_id: String,
2178    tools: ToolMap,
2179    events_tx: tokio::sync::broadcast::Sender<ToolCatalogEvent>,
2180}
2181
2182/// Constructs a fresh dynamic tool catalog as a writer/reader pair.
2183///
2184/// The writer mutates the catalog; the reader implements [`ToolSource`] and
2185/// is what gets handed to an `Agent`. Both sides share storage internally —
2186/// callers see only sized, owned values. Modeled on
2187/// `tokio::sync::watch::channel`.
2188///
2189/// `source_id` appears as the `source` field on every emitted
2190/// [`ToolCatalogEvent`].
2191///
2192/// ```
2193/// use agentkit_tools_core::dynamic_catalog;
2194///
2195/// let (writer, reader) = dynamic_catalog("plugins");
2196/// assert_eq!(writer.source_id(), "plugins");
2197/// assert_eq!(reader.source_id(), "plugins");
2198/// ```
2199pub fn dynamic_catalog(source_id: impl Into<String>) -> (CatalogWriter, CatalogReader) {
2200    let (events_tx, events_rx) = tokio::sync::broadcast::channel(128);
2201    let inner = Arc::new(DynamicCatalogInner {
2202        source_id: source_id.into(),
2203        tools: ToolMap::new(),
2204        events_tx,
2205    });
2206    (
2207        CatalogWriter {
2208            inner: Arc::clone(&inner),
2209        },
2210        CatalogReader {
2211            inner,
2212            events_rx: std::sync::Mutex::new(events_rx),
2213        },
2214    )
2215}
2216
2217/// Mutating side of a dynamic tool catalog. Owned by subsystems that
2218/// discover or refresh tools at runtime (MCP server manager, skill watcher,
2219/// plugin loader). Each [`upsert`](Self::upsert), [`remove`](Self::remove),
2220/// or [`replace_all`](Self::replace_all) emits a [`ToolCatalogEvent`] that
2221/// every [`CatalogReader`] minted from the same [`dynamic_catalog`] call
2222/// (or its clones) observes via [`ToolSource::drain_catalog_events`].
2223pub struct CatalogWriter {
2224    inner: Arc<DynamicCatalogInner>,
2225}
2226
2227impl CatalogWriter {
2228    /// Stable source identifier appearing on emitted catalog events.
2229    pub fn source_id(&self) -> &str {
2230        &self.inner.source_id
2231    }
2232
2233    /// Mints an additional [`CatalogReader`] over the same shared state.
2234    /// The new reader subscribes from now forward — it does not see events
2235    /// emitted before this call.
2236    pub fn reader(&self) -> CatalogReader {
2237        CatalogReader {
2238            inner: Arc::clone(&self.inner),
2239            events_rx: std::sync::Mutex::new(self.inner.events_tx.subscribe()),
2240        }
2241    }
2242
2243    /// Inserts or replaces a tool. Emits a single-entry catalog event with
2244    /// the tool's name in `added` (new) or `changed` (replaced).
2245    pub fn upsert(&self, tool: Arc<dyn Tool>) {
2246        let name = tool.spec().name.clone();
2247        let mut guard = self.inner.tools.write();
2248        let existed = guard.insert(name.clone(), tool).is_some();
2249        drop(guard);
2250        let mut event = ToolCatalogEvent::new(self.inner.source_id.clone());
2251        if existed {
2252            event.changed.push(name.0);
2253        } else {
2254            event.added.push(name.0);
2255        }
2256        let _ = self.inner.events_tx.send(event);
2257    }
2258
2259    /// Removes a tool by name. Emits a catalog event with the name in
2260    /// `removed` if it existed.
2261    pub fn remove(&self, name: &ToolName) -> bool {
2262        let mut guard = self.inner.tools.write();
2263        let removed = guard.remove(name).is_some();
2264        drop(guard);
2265        if removed {
2266            let mut event = ToolCatalogEvent::new(self.inner.source_id.clone());
2267            event.removed.push(name.0.clone());
2268            let _ = self.inner.events_tx.send(event);
2269        }
2270        removed
2271    }
2272
2273    /// Atomically replaces the entire tool set. Emits one catalog event
2274    /// describing the diff against the previous contents.
2275    pub fn replace_all(&self, tools: impl IntoIterator<Item = Arc<dyn Tool>>) {
2276        let new_map: BTreeMap<ToolName, Arc<dyn Tool>> = tools
2277            .into_iter()
2278            .map(|tool| (tool.spec().name.clone(), tool))
2279            .collect();
2280
2281        let mut guard = self.inner.tools.write();
2282        let mut event = ToolCatalogEvent::new(self.inner.source_id.clone());
2283
2284        for (name, new_tool) in new_map.iter() {
2285            match guard.get(name) {
2286                None => event.added.push(name.0.clone()),
2287                Some(existing)
2288                    if !Arc::ptr_eq(existing, new_tool)
2289                        && existing.current_spec() != new_tool.current_spec() =>
2290                {
2291                    event.changed.push(name.0.clone());
2292                }
2293                Some(_) => {}
2294            }
2295        }
2296        for name in guard.keys() {
2297            if !new_map.contains_key(name) {
2298                event.removed.push(name.0.clone());
2299            }
2300        }
2301
2302        *guard = new_map;
2303        drop(guard);
2304
2305        if !event.added.is_empty() || !event.removed.is_empty() || !event.changed.is_empty() {
2306            let _ = self.inner.events_tx.send(event);
2307        }
2308    }
2309
2310    /// Subscribes a fresh broadcast receiver. Lower-level than
2311    /// [`CatalogReader`] — for hosts that consume catalog events directly
2312    /// rather than through the loop.
2313    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ToolCatalogEvent> {
2314        self.inner.events_tx.subscribe()
2315    }
2316}
2317
2318/// Read side of a dynamic tool catalog. Implements [`ToolSource`] and is the
2319/// value handed to [`agentkit_loop::AgentBuilder::tools`]. Cloning subscribes
2320/// a fresh broadcast receiver, so independent observers don't compete for
2321/// catalog events.
2322pub struct CatalogReader {
2323    inner: Arc<DynamicCatalogInner>,
2324    events_rx: std::sync::Mutex<tokio::sync::broadcast::Receiver<ToolCatalogEvent>>,
2325}
2326
2327impl CatalogReader {
2328    /// Stable source identifier appearing on emitted catalog events.
2329    pub fn source_id(&self) -> &str {
2330        &self.inner.source_id
2331    }
2332
2333    /// Subscribes a fresh broadcast receiver — equivalent to
2334    /// [`CatalogWriter::subscribe`].
2335    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ToolCatalogEvent> {
2336        self.inner.events_tx.subscribe()
2337    }
2338}
2339
2340impl Clone for CatalogReader {
2341    fn clone(&self) -> Self {
2342        Self {
2343            inner: Arc::clone(&self.inner),
2344            events_rx: std::sync::Mutex::new(self.inner.events_tx.subscribe()),
2345        }
2346    }
2347}
2348
2349impl ToolSource for CatalogReader {
2350    fn specs(&self) -> Vec<ToolSpec> {
2351        self.inner
2352            .tools
2353            .read()
2354            .values()
2355            .filter_map(|tool| tool.current_spec())
2356            .collect()
2357    }
2358
2359    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2360        self.inner.tools.read().get(name).cloned()
2361    }
2362
2363    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2364        // try_recv on a broadcast::Receiver has no panic source, so the only
2365        // way this Mutex poisons is if a panic somehow originates outside the
2366        // try_recv loop while held — recover defensively, the receiver state
2367        // is independent of this lock.
2368        let mut rx = self.events_rx.lock().unwrap_or_else(|e| e.into_inner());
2369        let mut out = Vec::new();
2370        loop {
2371            match rx.try_recv() {
2372                Ok(event) => out.push(event),
2373                Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
2374                Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
2375                Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
2376            }
2377        }
2378        out
2379    }
2380}
2381
2382impl ToolSpec {
2383    /// Converts this spec into an [`InvocableSpec`] for use with the
2384    /// capability layer.
2385    pub fn as_invocable_spec(&self) -> InvocableSpec {
2386        InvocableSpec::new(
2387            CapabilityName::new(self.name.0.clone()),
2388            self.description.clone(),
2389            self.input_schema.clone(),
2390        )
2391        .with_metadata(self.metadata.clone())
2392    }
2393}
2394
2395/// Wraps a [`Tool`] as an [`Invocable`] so it can be surfaced through the
2396/// agentkit capability layer.
2397///
2398/// Created automatically by [`ToolCapabilityProvider::from_registry`]; you
2399/// rarely need to construct one yourself.
2400pub struct ToolInvocableAdapter {
2401    spec: InvocableSpec,
2402    tool: Arc<dyn Tool>,
2403    permissions: Arc<dyn PermissionChecker>,
2404    resources: Arc<dyn ToolResources>,
2405    next_call_id: AtomicU64,
2406}
2407
2408impl ToolInvocableAdapter {
2409    /// Creates a new adapter that wraps `tool` with the given permission
2410    /// checker and shared resources.
2411    pub fn new(
2412        tool: Arc<dyn Tool>,
2413        permissions: Arc<dyn PermissionChecker>,
2414        resources: Arc<dyn ToolResources>,
2415    ) -> Option<Self> {
2416        let spec = tool.current_spec()?.as_invocable_spec();
2417        Some(Self {
2418            spec,
2419            tool,
2420            permissions,
2421            resources,
2422            next_call_id: AtomicU64::new(1),
2423        })
2424    }
2425}
2426
2427#[async_trait]
2428impl Invocable for ToolInvocableAdapter {
2429    fn spec(&self) -> &InvocableSpec {
2430        &self.spec
2431    }
2432
2433    async fn invoke(
2434        &self,
2435        request: InvocableRequest,
2436        ctx: &mut CapabilityContext<'_>,
2437    ) -> Result<InvocableResult, CapabilityError> {
2438        let tool_request = ToolRequest {
2439            call_id: ToolCallId::new(format!(
2440                "tool-call-{}",
2441                self.next_call_id.fetch_add(1, Ordering::Relaxed)
2442            )),
2443            tool_name: self.tool.spec().name.clone(),
2444            input: request.input,
2445            session_id: ctx
2446                .session_id
2447                .cloned()
2448                .unwrap_or_else(|| SessionId::new("capability-session")),
2449            turn_id: ctx
2450                .turn_id
2451                .cloned()
2452                .unwrap_or_else(|| TurnId::new("capability-turn")),
2453            metadata: request.metadata,
2454        };
2455
2456        for permission_request in self
2457            .tool
2458            .proposed_requests(&tool_request)
2459            .map_err(|error| CapabilityError::InvalidInput(error.to_string()))?
2460        {
2461            match self.permissions.evaluate(permission_request.as_ref()) {
2462                PermissionDecision::Allow => {}
2463                PermissionDecision::Deny(denial) => {
2464                    return Err(CapabilityError::ExecutionFailed(format!(
2465                        "tool permission denied: {denial:?}"
2466                    )));
2467                }
2468                PermissionDecision::RequireApproval(req) => {
2469                    return Err(CapabilityError::Unavailable(format!(
2470                        "tool invocation requires approval: {}",
2471                        req.summary
2472                    )));
2473                }
2474            }
2475        }
2476
2477        let mut tool_ctx = ToolContext {
2478            capability: CapabilityContext {
2479                session_id: ctx.session_id,
2480                turn_id: ctx.turn_id,
2481                metadata: ctx.metadata,
2482            },
2483            permissions: self.permissions.as_ref(),
2484            resources: self.resources.as_ref(),
2485            cancellation: None,
2486        };
2487
2488        let result = self
2489            .tool
2490            .invoke(tool_request, &mut tool_ctx)
2491            .await
2492            .map_err(|error| CapabilityError::ExecutionFailed(error.to_string()))?;
2493
2494        Ok(InvocableResult {
2495            output: match result.result.output {
2496                ToolOutput::Text(text) => InvocableOutput::Text(text),
2497                ToolOutput::Structured(value) => InvocableOutput::Structured(value),
2498                ToolOutput::Parts(parts) => InvocableOutput::Items(vec![Item {
2499                    id: None,
2500                    kind: ItemKind::Tool,
2501                    parts,
2502                    metadata: MetadataMap::new(),
2503                }]),
2504                ToolOutput::Files(files) => {
2505                    let parts = files.into_iter().map(Part::File).collect();
2506                    InvocableOutput::Items(vec![Item {
2507                        id: None,
2508                        kind: ItemKind::Tool,
2509                        parts,
2510                        metadata: MetadataMap::new(),
2511                    }])
2512                }
2513            },
2514            metadata: result.metadata,
2515        })
2516    }
2517}
2518
2519/// A [`CapabilityProvider`] that exposes every tool in a [`ToolRegistry`]
2520/// as an [`Invocable`] in the agentkit capability layer.
2521///
2522/// This is the bridge between the tool subsystem and the generic capability
2523/// API that the agent loop consumes.
2524pub struct ToolCapabilityProvider {
2525    invocables: Vec<Arc<dyn Invocable>>,
2526}
2527
2528impl ToolCapabilityProvider {
2529    /// Builds a provider from all tools in `registry`, sharing the given
2530    /// permission checker and resources across every adapter.
2531    pub fn from_registry(
2532        registry: &ToolRegistry,
2533        permissions: Arc<dyn PermissionChecker>,
2534        resources: Arc<dyn ToolResources>,
2535    ) -> Self {
2536        let invocables = registry
2537            .tools()
2538            .into_iter()
2539            .filter_map(|tool| {
2540                ToolInvocableAdapter::new(tool, permissions.clone(), resources.clone())
2541                    .map(|adapter| Arc::new(adapter) as Arc<dyn Invocable>)
2542            })
2543            .collect();
2544
2545        Self { invocables }
2546    }
2547}
2548
2549impl CapabilityProvider for ToolCapabilityProvider {
2550    fn invocables(&self) -> Vec<Arc<dyn Invocable>> {
2551        self.invocables.clone()
2552    }
2553
2554    fn resources(&self) -> Vec<Arc<dyn ResourceProvider>> {
2555        Vec::new()
2556    }
2557
2558    fn prompts(&self) -> Vec<Arc<dyn PromptProvider>> {
2559        Vec::new()
2560    }
2561}
2562
2563/// The three-way result of a [`ToolExecutor::execute`] call.
2564///
2565/// Unlike a simple `Result`, this type distinguishes between a successful
2566/// completion, an interruption requiring user input (approval or auth), and
2567/// an outright failure.
2568#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2569pub enum ToolExecutionOutcome {
2570    /// The tool ran to completion and produced a result.
2571    Completed(ToolResult),
2572    /// The tool was interrupted and needs user input before it can continue.
2573    Interrupted(ToolInterruption),
2574    /// The tool failed with an error.
2575    Failed(ToolError),
2576}
2577
2578/// Trait for executing tool calls with permission checking and interruption
2579/// handling.
2580///
2581/// The agent loop calls [`execute`](ToolExecutor::execute) for every tool
2582/// call the model emits. If execution returns
2583/// [`ToolExecutionOutcome::Interrupted`], the loop collects user input and
2584/// retries with [`execute_approved`](ToolExecutor::execute_approved).
2585#[async_trait]
2586pub trait ToolExecutor: Send + Sync {
2587    /// Returns the current specification for every available tool.
2588    fn specs(&self) -> Vec<ToolSpec>;
2589
2590    /// Drains any pending dynamic catalog events.
2591    ///
2592    /// Static executors return an empty list. Dynamic executors should use
2593    /// interior mutability to return each catalog event once.
2594    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2595        Vec::new()
2596    }
2597
2598    /// Looks up the tool, evaluates permissions, and invokes it.
2599    async fn execute(
2600        &self,
2601        request: ToolRequest,
2602        ctx: &mut ToolContext<'_>,
2603    ) -> ToolExecutionOutcome;
2604
2605    /// Looks up the tool, evaluates permissions, and invokes it using an
2606    /// owned execution context.
2607    async fn execute_owned(
2608        &self,
2609        request: ToolRequest,
2610        ctx: OwnedToolContext,
2611    ) -> ToolExecutionOutcome {
2612        let mut borrowed = ctx.borrowed();
2613        self.execute(request, &mut borrowed).await
2614    }
2615
2616    /// Re-executes a tool call that was previously interrupted for approval.
2617    ///
2618    /// The default implementation ignores `approved_request` and delegates
2619    /// to [`execute`](ToolExecutor::execute). [`BasicToolExecutor`]
2620    /// overrides this to skip the approval gate for the matching request.
2621    async fn execute_approved(
2622        &self,
2623        request: ToolRequest,
2624        approved_request: &ApprovalRequest,
2625        ctx: &mut ToolContext<'_>,
2626    ) -> ToolExecutionOutcome {
2627        let _ = approved_request;
2628        self.execute(request, ctx).await
2629    }
2630
2631    /// Re-executes a tool call that was previously interrupted for approval
2632    /// using an owned execution context.
2633    async fn execute_approved_owned(
2634        &self,
2635        request: ToolRequest,
2636        approved_request: &ApprovalRequest,
2637        ctx: OwnedToolContext,
2638    ) -> ToolExecutionOutcome {
2639        let mut borrowed = ctx.borrowed();
2640        self.execute_approved(request, approved_request, &mut borrowed)
2641            .await
2642    }
2643}
2644
2645#[async_trait]
2646impl<T> ToolExecutor for Arc<T>
2647where
2648    T: ToolExecutor + ?Sized,
2649{
2650    fn specs(&self) -> Vec<ToolSpec> {
2651        (**self).specs()
2652    }
2653
2654    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2655        (**self).drain_catalog_events()
2656    }
2657
2658    async fn execute(
2659        &self,
2660        request: ToolRequest,
2661        ctx: &mut ToolContext<'_>,
2662    ) -> ToolExecutionOutcome {
2663        (**self).execute(request, ctx).await
2664    }
2665
2666    async fn execute_approved(
2667        &self,
2668        request: ToolRequest,
2669        approved_request: &ApprovalRequest,
2670        ctx: &mut ToolContext<'_>,
2671    ) -> ToolExecutionOutcome {
2672        (**self)
2673            .execute_approved(request, approved_request, ctx)
2674            .await
2675    }
2676}
2677
2678/// Policy applied when the same tool name appears in more than one
2679/// [`ToolSource`] of a [`BasicToolExecutor`].
2680#[derive(Clone, Debug, Default, PartialEq, Eq)]
2681pub enum CollisionPolicy {
2682    /// First source wins (in iteration order). Subsequent definitions of
2683    /// the same name are ignored.
2684    #[default]
2685    FirstWins,
2686    /// Later sources overwrite earlier ones at lookup time.
2687    LastWins,
2688}
2689
2690/// The default [`ToolExecutor`] that walks one or more [`ToolSource`]s,
2691/// checks permissions via [`Tool::proposed_requests`], and invokes the tool.
2692///
2693/// Compose static native tools (a frozen [`ToolRegistry`]) alongside
2694/// dynamic sources (a [`CatalogReader`] minted by [`dynamic_catalog`] and
2695/// owned by an MCP manager, skill watcher, plugin loader, etc.) without
2696/// merging into a single mutable registry.
2697///
2698/// # Example
2699///
2700/// ```rust,no_run
2701/// use std::sync::Arc;
2702/// use agentkit_tools_core::{BasicToolExecutor, ToolRegistry, ToolSource};
2703///
2704/// let static_registry: Arc<dyn ToolSource> = Arc::new(ToolRegistry::new());
2705/// let executor = BasicToolExecutor::new([static_registry]);
2706/// // Pass `executor` to the agent loop.
2707/// ```
2708pub struct BasicToolExecutor {
2709    sources: Vec<Arc<dyn ToolSource>>,
2710    collision: CollisionPolicy,
2711}
2712
2713impl BasicToolExecutor {
2714    /// Creates an executor that walks `sources` in order on every lookup.
2715    pub fn new(sources: impl IntoIterator<Item = Arc<dyn ToolSource>>) -> Self {
2716        Self {
2717            sources: sources.into_iter().collect(),
2718            collision: CollisionPolicy::default(),
2719        }
2720    }
2721
2722    /// Back-compat shorthand: wrap a single [`ToolRegistry`] as the only source.
2723    pub fn from_registry(registry: ToolRegistry) -> Self {
2724        Self::new([Arc::new(registry) as Arc<dyn ToolSource>])
2725    }
2726
2727    /// Sets the collision policy applied when the same tool name appears in
2728    /// multiple sources.
2729    pub fn with_collision_policy(mut self, policy: CollisionPolicy) -> Self {
2730        self.collision = policy;
2731        self
2732    }
2733
2734    /// Returns the [`ToolSpec`] for every tool across all sources, deduped
2735    /// by [`CollisionPolicy`].
2736    pub fn specs(&self) -> Vec<ToolSpec> {
2737        let mut seen = BTreeSet::new();
2738        let mut out = Vec::new();
2739        let iter: Box<dyn Iterator<Item = &Arc<dyn ToolSource>>> = match self.collision {
2740            CollisionPolicy::FirstWins => Box::new(self.sources.iter()),
2741            CollisionPolicy::LastWins => Box::new(self.sources.iter().rev()),
2742        };
2743        for source in iter {
2744            for spec in source.specs() {
2745                if seen.insert(spec.name.clone()) {
2746                    out.push(spec);
2747                }
2748            }
2749        }
2750        out
2751    }
2752
2753    fn lookup(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2754        match self.collision {
2755            CollisionPolicy::FirstWins => self.sources.iter().find_map(|s| s.get(name)),
2756            CollisionPolicy::LastWins => self.sources.iter().rev().find_map(|s| s.get(name)),
2757        }
2758    }
2759
2760    async fn execute_inner(
2761        &self,
2762        request: ToolRequest,
2763        approved_request_id: Option<&ApprovalId>,
2764        ctx: &mut ToolContext<'_>,
2765    ) -> ToolExecutionOutcome {
2766        let Some(tool) = self.lookup(&request.tool_name) else {
2767            return ToolExecutionOutcome::Failed(ToolError::NotFound(request.tool_name));
2768        };
2769
2770        match tool.proposed_requests(&request) {
2771            Ok(requests) => {
2772                for permission_request in requests {
2773                    match ctx.permissions.evaluate(permission_request.as_ref()) {
2774                        PermissionDecision::Allow => {}
2775                        PermissionDecision::Deny(denial) => {
2776                            return ToolExecutionOutcome::Failed(ToolError::PermissionDenied(
2777                                denial,
2778                            ));
2779                        }
2780                        PermissionDecision::RequireApproval(mut req) => {
2781                            req.call_id = Some(request.call_id.clone());
2782                            if approved_request_id != Some(&req.id) {
2783                                return ToolExecutionOutcome::Interrupted(
2784                                    ToolInterruption::ApprovalRequired(req),
2785                                );
2786                            }
2787                        }
2788                    }
2789                }
2790            }
2791            Err(error) => return ToolExecutionOutcome::Failed(error),
2792        }
2793
2794        match tool.invoke(request, ctx).await {
2795            Ok(result) => ToolExecutionOutcome::Completed(result),
2796            Err(error) => ToolExecutionOutcome::Failed(error),
2797        }
2798    }
2799}
2800
2801#[async_trait]
2802impl ToolExecutor for BasicToolExecutor {
2803    fn specs(&self) -> Vec<ToolSpec> {
2804        BasicToolExecutor::specs(self)
2805    }
2806
2807    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2808        self.sources
2809            .iter()
2810            .flat_map(|s| s.drain_catalog_events())
2811            .collect()
2812    }
2813
2814    async fn execute(
2815        &self,
2816        request: ToolRequest,
2817        ctx: &mut ToolContext<'_>,
2818    ) -> ToolExecutionOutcome {
2819        self.execute_inner(request, None, ctx).await
2820    }
2821
2822    async fn execute_approved(
2823        &self,
2824        request: ToolRequest,
2825        approved_request: &ApprovalRequest,
2826        ctx: &mut ToolContext<'_>,
2827    ) -> ToolExecutionOutcome {
2828        self.execute_inner(request, Some(&approved_request.id), ctx)
2829            .await
2830    }
2831}
2832
2833/// Errors that can occur during tool lookup, permission checking, or execution.
2834///
2835/// Returned from [`Tool::invoke`] and also used internally by
2836/// [`BasicToolExecutor`] to represent lookup and permission failures.
2837#[derive(Debug, Error, Clone, PartialEq, Serialize, Deserialize)]
2838pub enum ToolError {
2839    /// No tool with the given name exists in the registry.
2840    #[error("tool not found: {0}")]
2841    NotFound(ToolName),
2842    /// The input JSON did not match the tool's expected schema.
2843    #[error("invalid tool input: {0}")]
2844    InvalidInput(String),
2845    /// A permission policy denied the operation.
2846    #[error("tool permission denied: {0:?}")]
2847    PermissionDenied(PermissionDenial),
2848    /// The tool ran but encountered a runtime error.
2849    #[error("tool execution failed: {0}")]
2850    ExecutionFailed(String),
2851    /// The tool is temporarily unavailable.
2852    #[error("tool unavailable: {0}")]
2853    Unavailable(String),
2854    /// The turn was cancelled while the tool was running.
2855    #[error("tool execution cancelled")]
2856    Cancelled,
2857    /// An unexpected internal error.
2858    #[error("internal tool error: {0}")]
2859    Internal(String),
2860}
2861
2862impl ToolError {
2863    /// Convenience constructor for the [`PermissionDenied`](ToolError::PermissionDenied) variant.
2864    pub fn permission_denied(denial: PermissionDenial) -> Self {
2865        Self::PermissionDenied(denial)
2866    }
2867}
2868
2869impl From<PermissionDenial> for ToolError {
2870    fn from(value: PermissionDenial) -> Self {
2871        Self::permission_denied(value)
2872    }
2873}
2874
2875#[cfg(test)]
2876mod tests {
2877    use super::*;
2878    use async_trait::async_trait;
2879    use serde_json::json;
2880
2881    #[test]
2882    fn command_policy_can_deny_unknown_executables_without_approval() {
2883        let policy = CommandPolicy::new()
2884            .allow_executable("pwd")
2885            .require_approval_for_unknown(false);
2886        let request = ShellPermissionRequest {
2887            executable: "rm".into(),
2888            argv: vec!["-rf".into(), "/tmp/demo".into()],
2889            cwd: None,
2890            env_keys: Vec::new(),
2891            metadata: MetadataMap::new(),
2892        };
2893
2894        match policy.evaluate(&request) {
2895            PolicyMatch::Deny(denial) => {
2896                assert_eq!(denial.code, PermissionCode::CommandNotAllowed);
2897            }
2898            other => panic!("unexpected policy match: {other:?}"),
2899        }
2900    }
2901
2902    #[test]
2903    fn path_policy_allows_reads_under_read_only_roots() {
2904        let policy = PathPolicy::new().read_only_root("/workspace/vendor");
2905        let request = FileSystemPermissionRequest::Read {
2906            path: PathBuf::from("/workspace/vendor/lib.rs"),
2907            metadata: MetadataMap::new(),
2908        };
2909
2910        match policy.evaluate(&request) {
2911            PolicyMatch::NoOpinion | PolicyMatch::Allow => {}
2912            other => panic!("unexpected policy match: {other:?}"),
2913        }
2914    }
2915
2916    #[test]
2917    fn path_policy_denies_mutations_under_read_only_roots() {
2918        let policy = PathPolicy::new().read_only_root("/workspace/vendor");
2919        let request = FileSystemPermissionRequest::Edit {
2920            path: PathBuf::from("/workspace/vendor/lib.rs"),
2921            metadata: MetadataMap::new(),
2922        };
2923
2924        match policy.evaluate(&request) {
2925            PolicyMatch::Deny(denial) => {
2926                assert_eq!(denial.code, PermissionCode::PathNotAllowed);
2927                assert!(denial.message.contains("read-only"));
2928            }
2929            other => panic!("unexpected policy match: {other:?}"),
2930        }
2931    }
2932
2933    #[test]
2934    fn path_policy_denies_moves_into_read_only_roots() {
2935        let policy = PathPolicy::new().read_only_root("/workspace/vendor");
2936        let request = FileSystemPermissionRequest::Move {
2937            from: PathBuf::from("/workspace/src/lib.rs"),
2938            to: PathBuf::from("/workspace/vendor/lib.rs"),
2939            metadata: MetadataMap::new(),
2940        };
2941
2942        match policy.evaluate(&request) {
2943            PolicyMatch::Deny(denial) => {
2944                assert_eq!(denial.code, PermissionCode::PathNotAllowed);
2945                assert!(denial.message.contains("read-only"));
2946            }
2947            other => panic!("unexpected policy match: {other:?}"),
2948        }
2949    }
2950
2951    #[cfg(unix)]
2952    struct SymlinkTmpDir(PathBuf);
2953
2954    #[cfg(unix)]
2955    impl SymlinkTmpDir {
2956        fn new(label: &str) -> Self {
2957            use std::time::{SystemTime, UNIX_EPOCH};
2958            let nanos = SystemTime::now()
2959                .duration_since(UNIX_EPOCH)
2960                .unwrap()
2961                .as_nanos();
2962            let dir = std::env::temp_dir().join(format!(
2963                "agentkit-pathpolicy-{}-{}-{}",
2964                label,
2965                std::process::id(),
2966                nanos
2967            ));
2968            std::fs::create_dir_all(&dir).unwrap();
2969            // Canonicalise so callers compare against the resolved tmp path
2970            // (macOS `/tmp` is a symlink to `/private/tmp`, etc.).
2971            Self(std::fs::canonicalize(&dir).unwrap())
2972        }
2973
2974        fn path(&self) -> &Path {
2975            &self.0
2976        }
2977    }
2978
2979    #[cfg(unix)]
2980    impl Drop for SymlinkTmpDir {
2981        fn drop(&mut self) {
2982            let _ = std::fs::remove_dir_all(&self.0);
2983        }
2984    }
2985
2986    #[cfg(unix)]
2987    fn assert_path_denied(
2988        policy: &PathPolicy,
2989        request: FileSystemPermissionRequest,
2990    ) -> PermissionDenial {
2991        match policy.evaluate(&request) {
2992            PolicyMatch::Deny(denial) => denial,
2993            other => panic!("expected deny, got: {other:?}"),
2994        }
2995    }
2996
2997    #[cfg(unix)]
2998    #[test]
2999    fn path_policy_blocks_symlink_escape_from_allowed_root() {
3000        let tmp = SymlinkTmpDir::new("allow-escape");
3001        let allowed = tmp.path().join("workspace");
3002        let outside = tmp.path().join("outside");
3003        std::fs::create_dir_all(&allowed).unwrap();
3004        std::fs::create_dir_all(&outside).unwrap();
3005        let secret = outside.join("secret.txt");
3006        std::fs::write(&secret, b"top-secret").unwrap();
3007        let escape = allowed.join("leak");
3008        std::os::unix::fs::symlink(&secret, &escape).unwrap();
3009
3010        let policy = PathPolicy::new()
3011            .allow_root(&allowed)
3012            .require_approval_outside_allowed(false);
3013        let denial = assert_path_denied(
3014            &policy,
3015            FileSystemPermissionRequest::Read {
3016                path: escape,
3017                metadata: MetadataMap::new(),
3018            },
3019        );
3020        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3021    }
3022
3023    #[cfg(unix)]
3024    #[test]
3025    fn path_policy_blocks_symlink_into_protected_root() {
3026        let tmp = SymlinkTmpDir::new("protect-bypass");
3027        let workspace = tmp.path().join("workspace");
3028        std::fs::create_dir_all(&workspace).unwrap();
3029        let secret = workspace.join(".env");
3030        std::fs::write(&secret, b"API_KEY=xxx").unwrap();
3031        let alias = workspace.join("config");
3032        std::os::unix::fs::symlink(&secret, &alias).unwrap();
3033
3034        let policy = PathPolicy::new()
3035            .allow_root(&workspace)
3036            .protect_root(&secret);
3037        let denial = assert_path_denied(
3038            &policy,
3039            FileSystemPermissionRequest::Read {
3040                path: alias,
3041                metadata: MetadataMap::new(),
3042            },
3043        );
3044        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3045        assert!(denial.message.contains("denied"));
3046    }
3047
3048    #[cfg(unix)]
3049    #[test]
3050    fn path_policy_blocks_symlink_write_into_read_only_root() {
3051        let tmp = SymlinkTmpDir::new("readonly-bypass");
3052        let workspace = tmp.path().join("workspace");
3053        let vendor = workspace.join("vendor");
3054        std::fs::create_dir_all(&vendor).unwrap();
3055        let target = vendor.join("lib.rs");
3056        std::fs::write(&target, b"// vendored").unwrap();
3057        let writable_alias = workspace.join("writable");
3058        std::os::unix::fs::symlink(&target, &writable_alias).unwrap();
3059
3060        let policy = PathPolicy::new()
3061            .allow_root(&workspace)
3062            .read_only_root(&vendor);
3063        let denial = assert_path_denied(
3064            &policy,
3065            FileSystemPermissionRequest::Edit {
3066                path: writable_alias,
3067                metadata: MetadataMap::new(),
3068            },
3069        );
3070        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3071        assert!(denial.message.contains("read-only"));
3072    }
3073
3074    #[cfg(unix)]
3075    #[test]
3076    fn path_policy_resolves_symlink_parent_for_nonexistent_leaf() {
3077        let tmp = SymlinkTmpDir::new("create-escape");
3078        let allowed = tmp.path().join("workspace");
3079        let outside = tmp.path().join("outside");
3080        std::fs::create_dir_all(&allowed).unwrap();
3081        std::fs::create_dir_all(&outside).unwrap();
3082        let escape_dir = allowed.join("escape");
3083        std::os::unix::fs::symlink(&outside, &escape_dir).unwrap();
3084        let new_file = escape_dir.join("new.txt");
3085
3086        let policy = PathPolicy::new()
3087            .allow_root(&allowed)
3088            .require_approval_outside_allowed(false);
3089        let denial = assert_path_denied(
3090            &policy,
3091            FileSystemPermissionRequest::Write {
3092                path: new_file,
3093                metadata: MetadataMap::new(),
3094            },
3095        );
3096        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3097    }
3098
3099    #[derive(Clone)]
3100    struct HiddenTool {
3101        spec: ToolSpec,
3102    }
3103
3104    impl HiddenTool {
3105        fn new() -> Self {
3106            Self {
3107                spec: ToolSpec {
3108                    name: ToolName::new("hidden"),
3109                    description: "hidden".into(),
3110                    input_schema: json!({"type": "object"}),
3111                    annotations: ToolAnnotations::default(),
3112                    metadata: MetadataMap::new(),
3113                },
3114            }
3115        }
3116    }
3117
3118    #[async_trait]
3119    impl Tool for HiddenTool {
3120        fn spec(&self) -> &ToolSpec {
3121            &self.spec
3122        }
3123
3124        fn current_spec(&self) -> Option<ToolSpec> {
3125            None
3126        }
3127
3128        async fn invoke(
3129            &self,
3130            request: ToolRequest,
3131            _ctx: &mut ToolContext<'_>,
3132        ) -> Result<ToolResult, ToolError> {
3133            Ok(ToolResult {
3134                result: ToolResultPart {
3135                    call_id: request.call_id,
3136                    output: ToolOutput::Text("hidden".into()),
3137                    is_error: false,
3138                    metadata: MetadataMap::new(),
3139                },
3140                duration: None,
3141                metadata: MetadataMap::new(),
3142            })
3143        }
3144    }
3145
3146    #[test]
3147    fn hidden_tools_are_omitted_from_specs_and_capabilities() {
3148        let registry = ToolRegistry::new().with(HiddenTool::new());
3149
3150        assert!(registry.specs().is_empty());
3151
3152        let provider = ToolCapabilityProvider::from_registry(
3153            &registry,
3154            Arc::new(AllowAllPermissionChecker),
3155            Arc::new(()),
3156        );
3157        assert!(provider.invocables().is_empty());
3158    }
3159
3160    struct AllowAllPermissionChecker;
3161
3162    impl PermissionChecker for AllowAllPermissionChecker {
3163        fn evaluate(&self, _request: &dyn PermissionRequest) -> PermissionDecision {
3164            PermissionDecision::Allow
3165        }
3166    }
3167
3168    /// Tool whose `current_spec()` panics — used to exercise the
3169    /// catalog's poison-recovery guarantee.
3170    #[derive(Clone)]
3171    struct PanickingSpecTool {
3172        spec: ToolSpec,
3173    }
3174
3175    impl PanickingSpecTool {
3176        fn new(name: &str) -> Self {
3177            Self {
3178                spec: ToolSpec {
3179                    name: ToolName::new(name),
3180                    description: "panics on current_spec".into(),
3181                    input_schema: json!({"type": "object"}),
3182                    annotations: ToolAnnotations::default(),
3183                    metadata: MetadataMap::new(),
3184                },
3185            }
3186        }
3187    }
3188
3189    #[async_trait]
3190    impl Tool for PanickingSpecTool {
3191        fn spec(&self) -> &ToolSpec {
3192            &self.spec
3193        }
3194
3195        fn current_spec(&self) -> Option<ToolSpec> {
3196            panic!("PanickingSpecTool::current_spec");
3197        }
3198
3199        async fn invoke(
3200            &self,
3201            request: ToolRequest,
3202            _ctx: &mut ToolContext<'_>,
3203        ) -> Result<ToolResult, ToolError> {
3204            Ok(ToolResult {
3205                result: ToolResultPart {
3206                    call_id: request.call_id,
3207                    output: ToolOutput::Text("never".into()),
3208                    is_error: false,
3209                    metadata: MetadataMap::new(),
3210                },
3211                duration: None,
3212                metadata: MetadataMap::new(),
3213            })
3214        }
3215    }
3216
3217    /// If a tool's `current_spec()` panics during `replace_all`'s diff phase,
3218    /// the inner `RwLock` would normally poison and brick the catalog forever.
3219    /// `ToolMap` recovers from poison; this test pins the behavior so a future
3220    /// patch can't accidentally reintroduce the brick.
3221    ///
3222    /// The recovery is only safe because `replace_all` computes the diff
3223    /// (running user code) BEFORE swapping the map. If you change a write
3224    /// critical section to mutate before/between user-code calls, this test
3225    /// will still pass — but the catalog WILL be in a half-mutated state
3226    /// after a panic. Re-read `ToolMap`'s invariant before changing.
3227    #[test]
3228    fn catalog_recovers_from_panicked_writer() {
3229        let (writer, reader) = dynamic_catalog("test");
3230
3231        // Pre-seed with a panicker so the next `replace_all` enters the
3232        // diff branch that calls `existing.current_spec()`. `upsert`
3233        // itself never calls `current_spec`, so this insertion is safe.
3234        writer.upsert(Arc::new(PanickingSpecTool::new("boom")));
3235        let _ = reader.drain_catalog_events();
3236
3237        // `replace_all` with a different Arc of the same name forces the
3238        // diff to call `existing.current_spec()` → panics. The swap has
3239        // NOT happened yet at this point (the diff runs before the
3240        // `*guard = new_map`), so the catalog state is still consistent.
3241        let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3242            writer.replace_all(vec![
3243                Arc::new(PanickingSpecTool::new("boom")) as Arc<dyn Tool>
3244            ]);
3245        }));
3246        assert!(
3247            panic_result.is_err(),
3248            "PanickingSpecTool::current_spec must propagate"
3249        );
3250
3251        // Without recovery, every subsequent lock acquisition would panic
3252        // with "dynamic catalog poisoned". `get` doesn't call `current_spec`,
3253        // so it's a clean probe of whether the lock recovered.
3254        assert!(
3255            reader.get(&ToolName::new("boom")).is_some(),
3256            "catalog still readable after poisoning panic"
3257        );
3258
3259        // Writes also recover. Remove the panicker so subsequent `specs()`
3260        // calls don't re-trigger its panic.
3261        assert!(writer.remove(&ToolName::new("boom")));
3262
3263        // Add a well-behaved tool and round-trip through both sides.
3264        // (HiddenTool::current_spec returns None, so it's intentionally
3265        // filtered out of specs() — probe via get() instead.)
3266        writer.upsert(Arc::new(HiddenTool::new()));
3267        assert!(
3268            reader.get(&ToolName::new("hidden")).is_some(),
3269            "catalog usable for further writes + reads"
3270        );
3271    }
3272
3273    #[derive(Clone)]
3274    struct EchoTool {
3275        spec: ToolSpec,
3276    }
3277
3278    impl EchoTool {
3279        fn new(name: &str) -> Self {
3280            Self {
3281                spec: ToolSpec {
3282                    name: ToolName::new(name),
3283                    description: format!("echo {name}"),
3284                    input_schema: json!({"type": "object"}),
3285                    annotations: ToolAnnotations::default(),
3286                    metadata: MetadataMap::new(),
3287                },
3288            }
3289        }
3290    }
3291
3292    #[async_trait]
3293    impl Tool for EchoTool {
3294        fn spec(&self) -> &ToolSpec {
3295            &self.spec
3296        }
3297
3298        async fn invoke(
3299            &self,
3300            request: ToolRequest,
3301            _ctx: &mut ToolContext<'_>,
3302        ) -> Result<ToolResult, ToolError> {
3303            Ok(ToolResult::new(ToolResultPart::success(
3304                request.call_id,
3305                ToolOutput::text(request.tool_name.0.clone()),
3306            )))
3307        }
3308    }
3309
3310    fn registry_with(names: &[&str]) -> ToolRegistry {
3311        names.iter().fold(ToolRegistry::new(), |reg, name| {
3312            reg.with(EchoTool::new(name))
3313        })
3314    }
3315
3316    #[test]
3317    fn prefixed_rewrites_specs_and_resolves_lookups() {
3318        let source = registry_with(&["get_temp", "get_humidity"]).prefixed("weather");
3319        let names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
3320        assert_eq!(names, vec!["weather_get_humidity", "weather_get_temp"]);
3321
3322        assert!(source.get(&ToolName::new("weather_get_temp")).is_some());
3323        assert!(
3324            source.get(&ToolName::new("get_temp")).is_none(),
3325            "original name must not resolve when prefixed"
3326        );
3327        assert!(source.get(&ToolName::new("unknown")).is_none());
3328    }
3329
3330    #[tokio::test]
3331    async fn prefixed_invoke_sees_inner_name_on_request() {
3332        let source = registry_with(&["get_temp"]).prefixed("weather");
3333        let tool = source.get(&ToolName::new("weather_get_temp")).unwrap();
3334
3335        // The wrapper must report the public name on its spec...
3336        assert_eq!(tool.spec().name.0, "weather_get_temp");
3337
3338        // ...but the inner tool must see its own name in the request.
3339        let owned = OwnedToolContext {
3340            session_id: SessionId::new("s"),
3341            turn_id: TurnId::new("t"),
3342            metadata: MetadataMap::new(),
3343            permissions: Arc::new(AllowAllPermissions),
3344            resources: Arc::new(()),
3345            cancellation: None,
3346        };
3347        let mut ctx = owned.borrowed();
3348        let request = ToolRequest {
3349            call_id: ToolCallId::new("c"),
3350            tool_name: ToolName::new("weather_get_temp"),
3351            input: json!({}),
3352            session_id: SessionId::new("s"),
3353            turn_id: TurnId::new("t"),
3354            metadata: MetadataMap::new(),
3355        };
3356        let result = tool.invoke(request, &mut ctx).await.unwrap();
3357        match result.result.output {
3358            ToolOutput::Text(text) => assert_eq!(text, "get_temp"),
3359            other => panic!("unexpected output: {other:?}"),
3360        }
3361    }
3362
3363    #[test]
3364    fn filtered_hides_tools_rejected_by_predicate() {
3365        let source = registry_with(&["safe", "danger_drop", "danger_delete"])
3366            .filtered(|name| !name.0.starts_with("danger_"));
3367        let names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
3368        assert_eq!(names, vec!["safe"]);
3369
3370        assert!(source.get(&ToolName::new("safe")).is_some());
3371        assert!(source.get(&ToolName::new("danger_drop")).is_none());
3372    }
3373
3374    #[test]
3375    fn renamed_remaps_specs_and_lookups() {
3376        let source = registry_with(&["legacy_name", "passthrough"])
3377            .renamed([(ToolName::new("legacy_name"), ToolName::new("modern_name"))]);
3378        let mut names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
3379        names.sort();
3380        assert_eq!(names, vec!["modern_name", "passthrough"]);
3381
3382        assert!(source.get(&ToolName::new("modern_name")).is_some());
3383        assert!(
3384            source.get(&ToolName::new("legacy_name")).is_none(),
3385            "original name is hidden after renaming"
3386        );
3387        assert!(source.get(&ToolName::new("passthrough")).is_some());
3388    }
3389
3390    #[cfg(feature = "schemars")]
3391    mod schemars_helpers {
3392        use super::*;
3393        use schemars::JsonSchema;
3394        use serde::Deserialize;
3395
3396        #[derive(JsonSchema, Deserialize)]
3397        #[allow(dead_code)]
3398        struct WeatherInput {
3399            /// City name to look up.
3400            location: String,
3401            /// Use celsius (default false).
3402            #[serde(default)]
3403            celsius: bool,
3404        }
3405
3406        #[test]
3407        fn schema_for_emits_object_schema_with_typed_fields() {
3408            let schema = schema_for::<WeatherInput>();
3409            let obj = schema.as_object().expect("schema is a JSON object");
3410            assert_eq!(
3411                obj.get("type").and_then(|v| v.as_str()),
3412                Some("object"),
3413                "root type should be object"
3414            );
3415            let properties = obj
3416                .get("properties")
3417                .and_then(|v| v.as_object())
3418                .expect("properties block");
3419            assert!(properties.contains_key("location"));
3420            assert!(properties.contains_key("celsius"));
3421        }
3422
3423        #[test]
3424        fn tool_spec_for_carries_schema_name_and_description() {
3425            let spec = tool_spec_for::<WeatherInput>("get_weather", "Fetch current weather");
3426            assert_eq!(spec.name.0, "get_weather");
3427            assert_eq!(spec.description, "Fetch current weather");
3428            assert!(spec.input_schema.is_object());
3429        }
3430    }
3431
3432    #[test]
3433    fn transforms_compose_via_chained_methods() {
3434        let source = registry_with(&["read_file", "write_file", "delete_file"])
3435            .filtered(|name| name.0 != "delete_file")
3436            .prefixed("fs");
3437        let mut names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
3438        names.sort();
3439        assert_eq!(names, vec!["fs_read_file", "fs_write_file"]);
3440    }
3441}