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::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, OnceLock};
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 = std::path::absolute(&self.lexical).unwrap_or_else(|_| self.lexical.clone());
1215        if let Ok(canonical) = std::fs::canonicalize(&abs) {
1216            let _ = self.canonical.set(canonical);
1217            return std::borrow::Cow::Borrowed(self.canonical.get().unwrap());
1218        }
1219        std::borrow::Cow::Owned(canonicalize_with_partial_fallback(&abs).unwrap_or(abs))
1220    }
1221}
1222
1223impl PermissionPolicy for PathPolicy {
1224    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
1225        let Some(fs) = request
1226            .as_any()
1227            .downcast_ref::<FileSystemPermissionRequest>()
1228        else {
1229            return PolicyMatch::NoOpinion;
1230        };
1231
1232        let raw_paths: Vec<&Path> = match fs {
1233            FileSystemPermissionRequest::Move { from, to, .. } => {
1234                vec![from.as_path(), to.as_path()]
1235            }
1236            FileSystemPermissionRequest::Read { path, .. }
1237            | FileSystemPermissionRequest::Write { path, .. }
1238            | FileSystemPermissionRequest::Edit { path, .. }
1239            | FileSystemPermissionRequest::Delete { path, .. }
1240            | FileSystemPermissionRequest::List { path, .. }
1241            | FileSystemPermissionRequest::CreateDir { path, .. } => vec![path.as_path()],
1242        };
1243
1244        let candidate_paths: Vec<PathBuf> =
1245            raw_paths.iter().map(|p| resolve_canonical(p)).collect();
1246
1247        let mutates = matches!(
1248            fs,
1249            FileSystemPermissionRequest::Write { .. }
1250                | FileSystemPermissionRequest::Edit { .. }
1251                | FileSystemPermissionRequest::Delete { .. }
1252                | FileSystemPermissionRequest::Move { .. }
1253                | FileSystemPermissionRequest::CreateDir { .. }
1254        );
1255
1256        if candidate_paths.iter().any(|path| {
1257            self.protected_roots
1258                .iter()
1259                .any(|root| path.starts_with(root.resolve().as_ref()))
1260        }) {
1261            return PolicyMatch::Deny(PermissionDenial {
1262                code: PermissionCode::PathNotAllowed,
1263                message: format!("path access denied for {}", fs.summary()),
1264                metadata: fs.metadata().clone(),
1265            });
1266        }
1267
1268        if mutates
1269            && candidate_paths.iter().any(|path| {
1270                self.read_only_roots
1271                    .iter()
1272                    .any(|root| path.starts_with(root.resolve().as_ref()))
1273            })
1274        {
1275            return PolicyMatch::Deny(PermissionDenial {
1276                code: PermissionCode::PathNotAllowed,
1277                message: format!("path is read-only for {}", fs.summary()),
1278                metadata: fs.metadata().clone(),
1279            });
1280        }
1281
1282        if self.allowed_roots.is_empty() {
1283            return PolicyMatch::NoOpinion;
1284        }
1285
1286        let all_allowed = candidate_paths.iter().all(|path| {
1287            self.allowed_roots
1288                .iter()
1289                .any(|root| path.starts_with(root.resolve().as_ref()))
1290        });
1291
1292        if all_allowed {
1293            PolicyMatch::Allow
1294        } else if self.require_approval_outside_allowed {
1295            PolicyMatch::RequireApproval(ApprovalRequest {
1296                task_id: None,
1297                call_id: None,
1298                id: ApprovalId::new(format!("approval:{}", fs.kind())),
1299                request_kind: fs.kind().to_string(),
1300                reason: ApprovalReason::SensitivePath,
1301                summary: fs.summary(),
1302                metadata: fs.metadata().clone(),
1303            })
1304        } else {
1305            PolicyMatch::Deny(PermissionDenial {
1306                code: PermissionCode::PathNotAllowed,
1307                message: format!("path outside allowed roots for {}", fs.summary()),
1308                metadata: fs.metadata().clone(),
1309            })
1310        }
1311    }
1312}
1313
1314/// A [`PermissionPolicy`] that governs [`ShellPermissionRequest`]s by checking
1315/// the executable name, working directory, and environment variables.
1316///
1317/// Denied executables and env keys are rejected immediately. Allowed
1318/// executables pass. Unknown executables either require approval or are
1319/// denied, depending on `require_approval_for_unknown`.
1320///
1321/// # Example
1322///
1323/// ```rust
1324/// use agentkit_tools_core::CommandPolicy;
1325///
1326/// let policy = CommandPolicy::new()
1327///     .allow_executable("git")
1328///     .allow_executable("cargo")
1329///     .deny_executable("rm")
1330///     .deny_env_key("AWS_SECRET_ACCESS_KEY")
1331///     .allow_cwd("/workspace")
1332///     .require_approval_for_unknown(true);
1333/// ```
1334pub struct CommandPolicy {
1335    allowed_executables: BTreeSet<String>,
1336    denied_executables: BTreeSet<String>,
1337    allowed_cwds: Vec<PathBuf>,
1338    denied_env_keys: BTreeSet<String>,
1339    require_approval_for_unknown: bool,
1340}
1341
1342impl CommandPolicy {
1343    /// Creates a new command policy with no rules and approval required
1344    /// for unknown executables.
1345    pub fn new() -> Self {
1346        Self {
1347            allowed_executables: BTreeSet::new(),
1348            denied_executables: BTreeSet::new(),
1349            allowed_cwds: Vec::new(),
1350            denied_env_keys: BTreeSet::new(),
1351            require_approval_for_unknown: true,
1352        }
1353    }
1354
1355    /// Adds an executable name to the allow-list.
1356    pub fn allow_executable(mut self, executable: impl Into<String>) -> Self {
1357        self.allowed_executables.insert(executable.into());
1358        self
1359    }
1360
1361    /// Adds an executable name to the deny-list.
1362    pub fn deny_executable(mut self, executable: impl Into<String>) -> Self {
1363        self.denied_executables.insert(executable.into());
1364        self
1365    }
1366
1367    /// Adds a directory root that commands are allowed to run in.
1368    pub fn allow_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
1369        self.allowed_cwds.push(cwd.into());
1370        self
1371    }
1372
1373    /// Adds an environment variable name to the deny-list.
1374    pub fn deny_env_key(mut self, key: impl Into<String>) -> Self {
1375        self.denied_env_keys.insert(key.into());
1376        self
1377    }
1378
1379    /// When `true` (the default), executables not in the allow-list trigger
1380    /// an approval request instead of an outright denial.
1381    pub fn require_approval_for_unknown(mut self, value: bool) -> Self {
1382        self.require_approval_for_unknown = value;
1383        self
1384    }
1385}
1386
1387impl Default for CommandPolicy {
1388    fn default() -> Self {
1389        Self::new()
1390    }
1391}
1392
1393impl PermissionPolicy for CommandPolicy {
1394    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
1395        let Some(shell) = request.as_any().downcast_ref::<ShellPermissionRequest>() else {
1396            return PolicyMatch::NoOpinion;
1397        };
1398
1399        if self.denied_executables.contains(&shell.executable)
1400            || shell
1401                .env_keys
1402                .iter()
1403                .any(|key| self.denied_env_keys.contains(key))
1404        {
1405            return PolicyMatch::Deny(PermissionDenial {
1406                code: PermissionCode::CommandNotAllowed,
1407                message: format!("command denied for {}", shell.summary()),
1408                metadata: shell.metadata().clone(),
1409            });
1410        }
1411
1412        if let Some(cwd) = &shell.cwd
1413            && !self.allowed_cwds.is_empty()
1414            && !self.allowed_cwds.iter().any(|root| cwd.starts_with(root))
1415        {
1416            return PolicyMatch::RequireApproval(ApprovalRequest {
1417                task_id: None,
1418                call_id: None,
1419                id: ApprovalId::new("approval:shell.cwd"),
1420                request_kind: shell.kind().to_string(),
1421                reason: ApprovalReason::SensitiveCommand,
1422                summary: shell.summary(),
1423                metadata: shell.metadata().clone(),
1424            });
1425        }
1426
1427        if self.allowed_executables.is_empty()
1428            || self.allowed_executables.contains(&shell.executable)
1429        {
1430            PolicyMatch::Allow
1431        } else if self.require_approval_for_unknown {
1432            PolicyMatch::RequireApproval(ApprovalRequest {
1433                task_id: None,
1434                call_id: None,
1435                id: ApprovalId::new("approval:shell.command"),
1436                request_kind: shell.kind().to_string(),
1437                reason: ApprovalReason::SensitiveCommand,
1438                summary: shell.summary(),
1439                metadata: shell.metadata().clone(),
1440            })
1441        } else {
1442            PolicyMatch::Deny(PermissionDenial {
1443                code: PermissionCode::CommandNotAllowed,
1444                message: format!("executable {} is not allowed", shell.executable),
1445                metadata: shell.metadata().clone(),
1446            })
1447        }
1448    }
1449}
1450
1451/// A [`PermissionPolicy`] that governs [`McpPermissionRequest`]s by checking
1452/// whether the target server is trusted and the requested auth scopes are
1453/// in the allow-list.
1454///
1455/// # Example
1456///
1457/// ```rust
1458/// use agentkit_tools_core::McpServerPolicy;
1459///
1460/// let policy = McpServerPolicy::new()
1461///     .trust_server("github-mcp")
1462///     .allow_auth_scope("repo:read");
1463/// ```
1464pub struct McpServerPolicy {
1465    trusted_servers: BTreeSet<String>,
1466    allowed_auth_scopes: BTreeSet<String>,
1467    require_approval_for_untrusted: bool,
1468}
1469
1470impl McpServerPolicy {
1471    /// Creates a new MCP server policy with approval required for untrusted
1472    /// servers.
1473    pub fn new() -> Self {
1474        Self {
1475            trusted_servers: BTreeSet::new(),
1476            allowed_auth_scopes: BTreeSet::new(),
1477            require_approval_for_untrusted: true,
1478        }
1479    }
1480
1481    /// Marks a server as trusted so operations targeting it are allowed.
1482    pub fn trust_server(mut self, server_id: impl Into<String>) -> Self {
1483        self.trusted_servers.insert(server_id.into());
1484        self
1485    }
1486
1487    /// Adds an auth scope to the allow-list.
1488    pub fn allow_auth_scope(mut self, scope: impl Into<String>) -> Self {
1489        self.allowed_auth_scopes.insert(scope.into());
1490        self
1491    }
1492}
1493
1494impl Default for McpServerPolicy {
1495    fn default() -> Self {
1496        Self::new()
1497    }
1498}
1499
1500impl PermissionPolicy for McpServerPolicy {
1501    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
1502        let Some(mcp) = request.as_any().downcast_ref::<McpPermissionRequest>() else {
1503            return PolicyMatch::NoOpinion;
1504        };
1505
1506        let server_id = match mcp {
1507            McpPermissionRequest::Connect { server_id, .. }
1508            | McpPermissionRequest::InvokeTool { server_id, .. }
1509            | McpPermissionRequest::ReadResource { server_id, .. }
1510            | McpPermissionRequest::FetchPrompt { server_id, .. }
1511            | McpPermissionRequest::UseAuthScope { server_id, .. } => server_id,
1512        };
1513
1514        if !self.trusted_servers.is_empty() && !self.trusted_servers.contains(server_id) {
1515            return if self.require_approval_for_untrusted {
1516                PolicyMatch::RequireApproval(ApprovalRequest {
1517                    task_id: None,
1518                    call_id: None,
1519                    id: ApprovalId::new(format!("approval:mcp:{server_id}")),
1520                    request_kind: mcp.kind().to_string(),
1521                    reason: ApprovalReason::SensitiveServer,
1522                    summary: mcp.summary(),
1523                    metadata: mcp.metadata().clone(),
1524                })
1525            } else {
1526                PolicyMatch::Deny(PermissionDenial {
1527                    code: PermissionCode::ServerNotTrusted,
1528                    message: format!("MCP server {server_id} is not trusted"),
1529                    metadata: mcp.metadata().clone(),
1530                })
1531            };
1532        }
1533
1534        if let McpPermissionRequest::UseAuthScope { scope, .. } = mcp
1535            && !self.allowed_auth_scopes.is_empty()
1536            && !self.allowed_auth_scopes.contains(scope)
1537        {
1538            return PolicyMatch::Deny(PermissionDenial {
1539                code: PermissionCode::AuthScopeNotAllowed,
1540                message: format!("MCP auth scope {scope} is not allowed"),
1541                metadata: mcp.metadata().clone(),
1542            });
1543        }
1544
1545        PolicyMatch::Allow
1546    }
1547}
1548
1549/// The central abstraction for an executable tool in an agentkit agent.
1550///
1551/// Implement this trait to define a tool that an LLM can call. Each tool
1552/// provides a [`ToolSpec`] describing its name, schema, and hints, optional
1553/// permission requests via [`proposed_requests`](Tool::proposed_requests),
1554/// and the actual execution logic in [`invoke`](Tool::invoke).
1555///
1556/// # Example
1557///
1558/// ```rust
1559/// use agentkit_core::{MetadataMap, ToolOutput, ToolResultPart};
1560/// use agentkit_tools_core::{
1561///     Tool, ToolContext, ToolError, ToolName, ToolRequest, ToolResult, ToolSpec,
1562/// };
1563/// use async_trait::async_trait;
1564/// use serde_json::json;
1565///
1566/// struct TimeTool {
1567///     spec: ToolSpec,
1568/// }
1569///
1570/// impl TimeTool {
1571///     fn new() -> Self {
1572///         Self {
1573///             spec: ToolSpec::new(
1574///                 ToolName::new("current_time"),
1575///                 "Returns the current UTC time",
1576///                 json!({ "type": "object" }),
1577///             ),
1578///         }
1579///     }
1580/// }
1581///
1582/// #[async_trait]
1583/// impl Tool for TimeTool {
1584///     fn spec(&self) -> &ToolSpec {
1585///         &self.spec
1586///     }
1587///
1588///     async fn invoke(
1589///         &self,
1590///         request: ToolRequest,
1591///         _ctx: &mut ToolContext<'_>,
1592///     ) -> Result<ToolResult, ToolError> {
1593///         Ok(ToolResult::new(ToolResultPart::success(
1594///             request.call_id,
1595///             ToolOutput::text("2026-03-22T12:00:00Z"),
1596///         )))
1597///     }
1598/// }
1599/// ```
1600#[async_trait]
1601pub trait Tool: Send + Sync {
1602    /// Returns the static specification for this tool.
1603    fn spec(&self) -> &ToolSpec;
1604
1605    /// Returns the current specification for this tool, if it should be
1606    /// advertised right now.
1607    ///
1608    /// Most tools are static and can rely on the default implementation,
1609    /// which clones [`spec`](Self::spec). Override this when the description
1610    /// or input schema should reflect runtime state, or when the tool should
1611    /// be temporarily hidden from the model.
1612    fn current_spec(&self) -> Option<ToolSpec> {
1613        Some(self.spec().clone())
1614    }
1615
1616    /// Returns permission requests the executor should evaluate before calling
1617    /// [`invoke`](Tool::invoke).
1618    ///
1619    /// The default implementation returns an empty list (no permissions needed).
1620    /// Override this to declare filesystem, shell, or custom permission
1621    /// requirements based on the incoming request.
1622    ///
1623    /// # Errors
1624    ///
1625    /// Return [`ToolError::InvalidInput`] if the request input is malformed
1626    /// and permission requests cannot be constructed.
1627    fn proposed_requests(
1628        &self,
1629        _request: &ToolRequest,
1630    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
1631        Ok(Vec::new())
1632    }
1633
1634    /// Executes the tool and returns a result or error.
1635    ///
1636    /// # Errors
1637    ///
1638    /// Return an appropriate [`ToolError`] variant on failure. Source-specific
1639    /// concerns such as MCP authentication are resolved internally by the
1640    /// source (via host-supplied responders) and are not surfaced as tool
1641    /// errors.
1642    async fn invoke(
1643        &self,
1644        request: ToolRequest,
1645        ctx: &mut ToolContext<'_>,
1646    ) -> Result<ToolResult, ToolError>;
1647}
1648
1649/// A name-keyed collection of [`Tool`] implementations.
1650///
1651/// The registry owns `Arc`-wrapped tools and is passed to a
1652/// [`BasicToolExecutor`] (or consumed by [`ToolCapabilityProvider`]) so the
1653/// agent loop can look up tools by name at execution time.
1654///
1655/// # Example
1656///
1657/// ```rust
1658/// use agentkit_tools_core::ToolRegistry;
1659/// # use agentkit_tools_core::{Tool, ToolContext, ToolError, ToolName, ToolRequest, ToolResult, ToolSpec};
1660/// # use async_trait::async_trait;
1661/// # use serde_json::json;
1662/// # struct NoopTool(ToolSpec);
1663/// # #[async_trait]
1664/// # impl Tool for NoopTool {
1665/// #     fn spec(&self) -> &ToolSpec { &self.0 }
1666/// #     async fn invoke(&self, _r: ToolRequest, _c: &mut ToolContext<'_>) -> Result<ToolResult, ToolError> { todo!() }
1667/// # }
1668///
1669/// let registry = ToolRegistry::new()
1670///     .with(NoopTool(ToolSpec::new(
1671///         ToolName::new("noop"),
1672///         "Does nothing",
1673///         json!({"type": "object"}),
1674///     )));
1675///
1676/// assert!(registry.get(&ToolName::new("noop")).is_some());
1677/// assert_eq!(registry.specs().len(), 1);
1678/// ```
1679#[derive(Clone, Default)]
1680pub struct ToolRegistry {
1681    tools: BTreeMap<ToolName, Arc<dyn Tool>>,
1682}
1683
1684impl ToolRegistry {
1685    /// Creates an empty registry.
1686    pub fn new() -> Self {
1687        Self::default()
1688    }
1689
1690    /// Registers a tool by value and returns `&mut self` for imperative chaining.
1691    pub fn register<T>(&mut self, tool: T) -> &mut Self
1692    where
1693        T: Tool + 'static,
1694    {
1695        self.tools.insert(tool.spec().name.clone(), Arc::new(tool));
1696        self
1697    }
1698
1699    /// Registers a tool by value and returns `self` for builder-style chaining.
1700    pub fn with<T>(mut self, tool: T) -> Self
1701    where
1702        T: Tool + 'static,
1703    {
1704        self.register(tool);
1705        self
1706    }
1707
1708    /// Registers a pre-wrapped `Arc<dyn Tool>`.
1709    pub fn register_arc(&mut self, tool: Arc<dyn Tool>) -> &mut Self {
1710        self.tools.insert(tool.spec().name.clone(), tool);
1711        self
1712    }
1713
1714    /// Looks up a tool by name, returning `None` if not registered.
1715    pub fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1716        self.tools.get(name).cloned()
1717    }
1718
1719    /// Returns all registered tools as a `Vec`.
1720    pub fn tools(&self) -> Vec<Arc<dyn Tool>> {
1721        self.tools.values().cloned().collect()
1722    }
1723
1724    /// Merges all tools from another registry into this one, consuming it.
1725    ///
1726    /// Supports builder-style chaining:
1727    ///
1728    /// ```ignore
1729    /// let registry = agentkit_tool_fs::registry()
1730    ///     .merge(agentkit_tool_shell::registry());
1731    /// ```
1732    pub fn merge(mut self, other: Self) -> Self {
1733        self.tools.extend(other.tools);
1734        self
1735    }
1736
1737    /// Returns the [`ToolSpec`] for every registered tool.
1738    pub fn specs(&self) -> Vec<ToolSpec> {
1739        self.tools
1740            .values()
1741            .filter_map(|tool| tool.current_spec())
1742            .collect()
1743    }
1744}
1745
1746/// Read-side contract for a federated tool catalog.
1747///
1748/// A [`BasicToolExecutor`] composes one or more `ToolSource`s — typically a
1749/// frozen [`ToolRegistry`] of native tools alongside one or more
1750/// [`CatalogReader`]s owned by subsystems (MCP server manager, skill watcher,
1751/// plugin loader). Each source manages its own lifecycle and concurrency
1752/// story; the executor only reads.
1753pub trait ToolSource: Send + Sync {
1754    /// Returns the current spec for every tool in this source.
1755    fn specs(&self) -> Vec<ToolSpec>;
1756
1757    /// Looks up a tool by name, returning `None` if not present.
1758    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>>;
1759
1760    /// Drains pending catalog change events. Static sources return an empty
1761    /// list; dynamic sources surface added/removed/changed batches that the
1762    /// loop forwards to the model on the next turn.
1763    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
1764        Vec::new()
1765    }
1766
1767    /// Wraps this source so every advertised tool name is prefixed with
1768    /// `<prefix>_`. Useful for mounting the same source under multiple
1769    /// namespaces, or for avoiding collisions between MCP catalogs.
1770    ///
1771    /// Lookups strip the prefix before delegating, and the wrapped tool's
1772    /// `spec()` reports the public (prefixed) name so the model and the
1773    /// tool see consistent names.
1774    ///
1775    /// To wrap an `Arc<dyn ToolSource>` instead, use [`Prefixed::new`].
1776    fn prefixed(self, prefix: impl Into<String>) -> Prefixed<Self>
1777    where
1778        Self: Sized,
1779    {
1780        Prefixed::new(self, prefix)
1781    }
1782
1783    /// Wraps this source so only tools whose name passes `predicate` are
1784    /// advertised and resolvable. Tools rejected by the predicate are
1785    /// invisible to the model and return `None` on lookup.
1786    ///
1787    /// To wrap an `Arc<dyn ToolSource>` instead, use [`Filtered::new`].
1788    fn filtered<F>(self, predicate: F) -> Filtered<Self, F>
1789    where
1790        Self: Sized,
1791        F: Fn(&ToolName) -> bool + Send + Sync + 'static,
1792    {
1793        Filtered::new(self, predicate)
1794    }
1795
1796    /// Wraps this source with a name remapping. Each `(original, new)` pair
1797    /// in `mapping` causes the tool to be advertised as `new` and resolved
1798    /// from `new` back to `original` on lookup. Tools not in the mapping
1799    /// pass through unchanged.
1800    ///
1801    /// To wrap an `Arc<dyn ToolSource>` instead, use [`Renamed::new`].
1802    fn renamed<I>(self, mapping: I) -> Renamed<Self>
1803    where
1804        Self: Sized,
1805        I: IntoIterator<Item = (ToolName, ToolName)>,
1806    {
1807        Renamed::new(self, mapping)
1808    }
1809}
1810
1811impl ToolSource for ToolRegistry {
1812    fn specs(&self) -> Vec<ToolSpec> {
1813        ToolRegistry::specs(self)
1814    }
1815
1816    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1817        ToolRegistry::get(self, name)
1818    }
1819}
1820
1821impl<S> ToolSource for Arc<S>
1822where
1823    S: ToolSource + ?Sized,
1824{
1825    fn specs(&self) -> Vec<ToolSpec> {
1826        (**self).specs()
1827    }
1828
1829    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1830        (**self).get(name)
1831    }
1832
1833    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
1834        (**self).drain_catalog_events()
1835    }
1836}
1837
1838/// A [`ToolSource`] wrapper that prefixes every advertised tool name with
1839/// `<prefix>_`. Constructed via [`ToolSource::prefixed`] or directly.
1840pub struct Prefixed<S> {
1841    inner: S,
1842    prefix: String,
1843}
1844
1845impl<S> Prefixed<S> {
1846    /// Creates a new prefixed wrapper.
1847    pub fn new(inner: S, prefix: impl Into<String>) -> Self {
1848        Self {
1849            inner,
1850            prefix: prefix.into(),
1851        }
1852    }
1853
1854    fn rewrite(&self, name: &str) -> String {
1855        format!("{}_{}", self.prefix, name)
1856    }
1857
1858    fn strip<'a>(&self, name: &'a str) -> Option<&'a str> {
1859        name.strip_prefix(self.prefix.as_str())
1860            .and_then(|rest| rest.strip_prefix('_'))
1861    }
1862}
1863
1864impl<S> ToolSource for Prefixed<S>
1865where
1866    S: ToolSource,
1867{
1868    fn specs(&self) -> Vec<ToolSpec> {
1869        self.inner
1870            .specs()
1871            .into_iter()
1872            .map(|mut spec| {
1873                spec.name = ToolName::new(self.rewrite(spec.name.0.as_str()));
1874                spec
1875            })
1876            .collect()
1877    }
1878
1879    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1880        let original = self.strip(name.0.as_str())?;
1881        let inner_name = ToolName::new(original);
1882        let inner_tool = self.inner.get(&inner_name)?;
1883        let mut public_spec = inner_tool.spec().clone();
1884        public_spec.name = name.clone();
1885        Some(Arc::new(RewrittenTool {
1886            inner: inner_tool,
1887            inner_name,
1888            public_spec,
1889        }))
1890    }
1891
1892    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
1893        self.inner
1894            .drain_catalog_events()
1895            .into_iter()
1896            .map(|mut event| {
1897                event.for_each_name_mut(|name| *name = self.rewrite(name.as_str()));
1898                event
1899            })
1900            .collect()
1901    }
1902}
1903
1904/// A [`ToolSource`] wrapper that hides tools rejected by `predicate`.
1905/// Constructed via [`ToolSource::filtered`] or directly.
1906pub struct Filtered<S, F> {
1907    inner: S,
1908    predicate: F,
1909}
1910
1911impl<S, F> Filtered<S, F> {
1912    /// Creates a new filtered wrapper.
1913    pub fn new(inner: S, predicate: F) -> Self {
1914        Self { inner, predicate }
1915    }
1916}
1917
1918impl<S, F> ToolSource for Filtered<S, F>
1919where
1920    S: ToolSource,
1921    F: Fn(&ToolName) -> bool + Send + Sync + 'static,
1922{
1923    fn specs(&self) -> Vec<ToolSpec> {
1924        self.inner
1925            .specs()
1926            .into_iter()
1927            .filter(|spec| (self.predicate)(&spec.name))
1928            .collect()
1929    }
1930
1931    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1932        if !(self.predicate)(name) {
1933            return None;
1934        }
1935        self.inner.get(name)
1936    }
1937
1938    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
1939        self.inner
1940            .drain_catalog_events()
1941            .into_iter()
1942            .map(|mut event| {
1943                event.retain_names(|n| (self.predicate)(&ToolName::new(n)));
1944                event
1945            })
1946            .collect()
1947    }
1948}
1949
1950/// A [`ToolSource`] wrapper that renames specific tools. Tools whose
1951/// original name appears in the forward mapping are advertised under the
1952/// new name and resolved from the new name back to the original.
1953/// Unmapped names pass through unchanged.
1954///
1955/// Constructed via [`ToolSource::renamed`] or directly.
1956pub struct Renamed<S> {
1957    inner: S,
1958    forward: BTreeMap<ToolName, ToolName>,
1959    backward: BTreeMap<ToolName, ToolName>,
1960}
1961
1962impl<S> Renamed<S> {
1963    /// Creates a new renaming wrapper from a `(original, new)` mapping.
1964    pub fn new<I>(inner: S, mapping: I) -> Self
1965    where
1966        I: IntoIterator<Item = (ToolName, ToolName)>,
1967    {
1968        let forward: BTreeMap<ToolName, ToolName> = mapping.into_iter().collect();
1969        let backward = forward
1970            .iter()
1971            .map(|(k, v)| (v.clone(), k.clone()))
1972            .collect();
1973        Self {
1974            inner,
1975            forward,
1976            backward,
1977        }
1978    }
1979}
1980
1981impl<S> ToolSource for Renamed<S>
1982where
1983    S: ToolSource,
1984{
1985    fn specs(&self) -> Vec<ToolSpec> {
1986        self.inner
1987            .specs()
1988            .into_iter()
1989            .map(|mut spec| {
1990                if let Some(new_name) = self.forward.get(&spec.name) {
1991                    spec.name = new_name.clone();
1992                }
1993                spec
1994            })
1995            .collect()
1996    }
1997
1998    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
1999        if let Some(original) = self.backward.get(name) {
2000            let inner_tool = self.inner.get(original)?;
2001            let mut public_spec = inner_tool.spec().clone();
2002            public_spec.name = name.clone();
2003            Some(Arc::new(RewrittenTool {
2004                inner: inner_tool,
2005                inner_name: original.clone(),
2006                public_spec,
2007            }))
2008        } else if self.forward.contains_key(name) {
2009            // Original name of a remapped tool — hidden under its new name.
2010            None
2011        } else {
2012            self.inner.get(name)
2013        }
2014    }
2015
2016    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2017        self.inner
2018            .drain_catalog_events()
2019            .into_iter()
2020            .map(|mut event| {
2021                event.for_each_name_mut(|name| {
2022                    if let Some(new) = self.forward.get(&ToolName::new(name.as_str())) {
2023                        *name = new.0.clone();
2024                    }
2025                });
2026                event
2027            })
2028            .collect()
2029    }
2030}
2031
2032/// Builds a JSON Schema [`Value`] for the given input type. Requires the
2033/// `schemars` feature.
2034///
2035/// This is the bridge between Rust types and the
2036/// [`ToolSpec::input_schema`] field — instead of hand-writing JSON Schema,
2037/// derive [`schemars::JsonSchema`] on your input struct and call this.
2038///
2039/// # Example
2040///
2041/// ```rust,ignore
2042/// use agentkit_tools_core::schema_for;
2043/// use schemars::JsonSchema;
2044///
2045/// #[derive(JsonSchema)]
2046/// struct WeatherInput {
2047///     /// City name to look up.
2048///     location: String,
2049///     /// Use celsius (default false).
2050///     #[serde(default)]
2051///     celsius: bool,
2052/// }
2053///
2054/// let schema = schema_for::<WeatherInput>();
2055/// assert!(schema.is_object());
2056/// ```
2057#[cfg(feature = "schemars")]
2058pub fn schema_for<T: schemars::JsonSchema>() -> Value {
2059    let schema = schemars::schema_for!(T);
2060    serde_json::to_value(schema)
2061        .expect("schemars produces valid JSON; this conversion is infallible")
2062}
2063
2064/// Builds a [`ToolSpec`] from `T`'s derived JSON Schema. Requires the
2065/// `schemars` feature. The generated schema is exactly what
2066/// [`schema_for::<T>`] produces; this helper just wraps it with a name and
2067/// description.
2068///
2069/// # Example
2070///
2071/// ```rust,ignore
2072/// use agentkit_tools_core::tool_spec_for;
2073/// use schemars::JsonSchema;
2074///
2075/// #[derive(JsonSchema)]
2076/// struct WeatherInput { location: String }
2077///
2078/// let spec = tool_spec_for::<WeatherInput>("get_weather", "Fetch current weather");
2079/// assert_eq!(spec.name.0, "get_weather");
2080/// ```
2081#[cfg(feature = "schemars")]
2082pub fn tool_spec_for<T: schemars::JsonSchema>(
2083    name: impl Into<ToolName>,
2084    description: impl Into<String>,
2085) -> ToolSpec {
2086    ToolSpec::new(name, description, schema_for::<T>())
2087}
2088
2089/// A [`Tool`] wrapper used by [`Prefixed`] and [`Renamed`] to bridge between
2090/// the public (rewritten) tool name and the inner tool's own name. The
2091/// wrapper reports the public spec but rewrites `request.tool_name` back to
2092/// the inner name before delegating to the wrapped tool, so tools that
2093/// inspect their own name (e.g. for logging or routing) see the original.
2094struct RewrittenTool {
2095    inner: Arc<dyn Tool>,
2096    inner_name: ToolName,
2097    public_spec: ToolSpec,
2098}
2099
2100#[async_trait]
2101impl Tool for RewrittenTool {
2102    fn spec(&self) -> &ToolSpec {
2103        &self.public_spec
2104    }
2105
2106    fn current_spec(&self) -> Option<ToolSpec> {
2107        let inner_current = self.inner.current_spec()?;
2108        Some(ToolSpec {
2109            name: self.public_spec.name.clone(),
2110            description: inner_current.description,
2111            input_schema: inner_current.input_schema,
2112            annotations: inner_current.annotations,
2113            metadata: inner_current.metadata,
2114        })
2115    }
2116
2117    fn proposed_requests(
2118        &self,
2119        request: &ToolRequest,
2120    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
2121        let mut inner_request = request.clone();
2122        inner_request.tool_name = self.inner_name.clone();
2123        self.inner.proposed_requests(&inner_request)
2124    }
2125
2126    async fn invoke(
2127        &self,
2128        mut request: ToolRequest,
2129        ctx: &mut ToolContext<'_>,
2130    ) -> Result<ToolResult, ToolError> {
2131        request.tool_name = self.inner_name.clone();
2132        self.inner.invoke(request, ctx).await
2133    }
2134}
2135
2136/// Catalog storage with poison-recovery encoded in the type. The wrapped
2137/// `RwLock` is private; only [`read`](Self::read) and [`write`](Self::write)
2138/// are exposed, both infallible. Recovery is safe because every callsite
2139/// honors the invariant below.
2140///
2141/// **Invariant for callers:** write critical sections must compute all
2142/// derived state — diffs, comparisons, anything that may run user code
2143/// (`Tool` impls in particular) — BEFORE mutating the map. If a panic fires
2144/// between two mutations in the same critical section, recovery would hand
2145/// the next caller a partially-updated map. The current callsites all hold
2146/// this: `upsert`/`remove` perform a single op with no user code;
2147/// `replace_all` completes its diff (which calls `Tool::current_spec`)
2148/// before the swap.
2149///
2150/// The `catalog_recovers_from_panicked_writer` test exercises the recovery
2151/// path; if you change a write critical section, re-check that it still
2152/// computes-then-mutates.
2153struct ToolMap {
2154    inner: std::sync::RwLock<BTreeMap<ToolName, Arc<dyn Tool>>>,
2155}
2156
2157impl ToolMap {
2158    fn new() -> Self {
2159        Self {
2160            inner: std::sync::RwLock::new(BTreeMap::new()),
2161        }
2162    }
2163
2164    fn read(&self) -> std::sync::RwLockReadGuard<'_, BTreeMap<ToolName, Arc<dyn Tool>>> {
2165        self.inner.read().unwrap_or_else(|e| e.into_inner())
2166    }
2167
2168    fn write(&self) -> std::sync::RwLockWriteGuard<'_, BTreeMap<ToolName, Arc<dyn Tool>>> {
2169        self.inner.write().unwrap_or_else(|e| e.into_inner())
2170    }
2171}
2172
2173/// Shared inner state of a dynamic catalog. Held by both [`CatalogWriter`]
2174/// (mutates) and [`CatalogReader`] (reads), behind `Arc`s that hosts never see.
2175struct DynamicCatalogInner {
2176    source_id: String,
2177    tools: ToolMap,
2178    events_tx: tokio::sync::broadcast::Sender<ToolCatalogEvent>,
2179}
2180
2181/// Constructs a fresh dynamic tool catalog as a writer/reader pair.
2182///
2183/// The writer mutates the catalog; the reader implements [`ToolSource`] and
2184/// is what gets handed to an `Agent`. Both sides share storage internally —
2185/// callers see only sized, owned values. Modeled on
2186/// `tokio::sync::watch::channel`.
2187///
2188/// `source_id` appears as the `source` field on every emitted
2189/// [`ToolCatalogEvent`].
2190///
2191/// ```
2192/// use agentkit_tools_core::dynamic_catalog;
2193///
2194/// let (writer, reader) = dynamic_catalog("plugins");
2195/// assert_eq!(writer.source_id(), "plugins");
2196/// assert_eq!(reader.source_id(), "plugins");
2197/// ```
2198pub fn dynamic_catalog(source_id: impl Into<String>) -> (CatalogWriter, CatalogReader) {
2199    let (events_tx, events_rx) = tokio::sync::broadcast::channel(128);
2200    let inner = Arc::new(DynamicCatalogInner {
2201        source_id: source_id.into(),
2202        tools: ToolMap::new(),
2203        events_tx,
2204    });
2205    (
2206        CatalogWriter {
2207            inner: Arc::clone(&inner),
2208        },
2209        CatalogReader {
2210            inner,
2211            events_rx: std::sync::Mutex::new(events_rx),
2212        },
2213    )
2214}
2215
2216/// Mutating side of a dynamic tool catalog. Owned by subsystems that
2217/// discover or refresh tools at runtime (MCP server manager, skill watcher,
2218/// plugin loader). Each [`upsert`](Self::upsert), [`remove`](Self::remove),
2219/// or [`replace_all`](Self::replace_all) emits a [`ToolCatalogEvent`] that
2220/// every [`CatalogReader`] minted from the same [`dynamic_catalog`] call
2221/// (or its clones) observes via [`ToolSource::drain_catalog_events`].
2222pub struct CatalogWriter {
2223    inner: Arc<DynamicCatalogInner>,
2224}
2225
2226impl CatalogWriter {
2227    /// Stable source identifier appearing on emitted catalog events.
2228    pub fn source_id(&self) -> &str {
2229        &self.inner.source_id
2230    }
2231
2232    /// Mints an additional [`CatalogReader`] over the same shared state.
2233    /// The new reader subscribes from now forward — it does not see events
2234    /// emitted before this call.
2235    pub fn reader(&self) -> CatalogReader {
2236        CatalogReader {
2237            inner: Arc::clone(&self.inner),
2238            events_rx: std::sync::Mutex::new(self.inner.events_tx.subscribe()),
2239        }
2240    }
2241
2242    /// Inserts or replaces a tool. Emits a single-entry catalog event with
2243    /// the tool's name in `added` (new) or `changed` (replaced).
2244    pub fn upsert(&self, tool: Arc<dyn Tool>) {
2245        let name = tool.spec().name.clone();
2246        let mut guard = self.inner.tools.write();
2247        let existed = guard.insert(name.clone(), tool).is_some();
2248        drop(guard);
2249        let mut event = ToolCatalogEvent::new(self.inner.source_id.clone());
2250        if existed {
2251            event.changed.push(name.0);
2252        } else {
2253            event.added.push(name.0);
2254        }
2255        let _ = self.inner.events_tx.send(event);
2256    }
2257
2258    /// Removes a tool by name. Emits a catalog event with the name in
2259    /// `removed` if it existed.
2260    pub fn remove(&self, name: &ToolName) -> bool {
2261        let mut guard = self.inner.tools.write();
2262        let removed = guard.remove(name).is_some();
2263        drop(guard);
2264        if removed {
2265            let mut event = ToolCatalogEvent::new(self.inner.source_id.clone());
2266            event.removed.push(name.0.clone());
2267            let _ = self.inner.events_tx.send(event);
2268        }
2269        removed
2270    }
2271
2272    /// Atomically replaces the entire tool set. Emits one catalog event
2273    /// describing the diff against the previous contents.
2274    pub fn replace_all(&self, tools: impl IntoIterator<Item = Arc<dyn Tool>>) {
2275        let new_map: BTreeMap<ToolName, Arc<dyn Tool>> = tools
2276            .into_iter()
2277            .map(|tool| (tool.spec().name.clone(), tool))
2278            .collect();
2279
2280        let mut guard = self.inner.tools.write();
2281        let mut event = ToolCatalogEvent::new(self.inner.source_id.clone());
2282
2283        for (name, new_tool) in new_map.iter() {
2284            match guard.get(name) {
2285                None => event.added.push(name.0.clone()),
2286                Some(existing)
2287                    if !Arc::ptr_eq(existing, new_tool)
2288                        && existing.current_spec() != new_tool.current_spec() =>
2289                {
2290                    event.changed.push(name.0.clone());
2291                }
2292                Some(_) => {}
2293            }
2294        }
2295        for name in guard.keys() {
2296            if !new_map.contains_key(name) {
2297                event.removed.push(name.0.clone());
2298            }
2299        }
2300
2301        *guard = new_map;
2302        drop(guard);
2303
2304        if !event.added.is_empty() || !event.removed.is_empty() || !event.changed.is_empty() {
2305            let _ = self.inner.events_tx.send(event);
2306        }
2307    }
2308
2309    /// Subscribes a fresh broadcast receiver. Lower-level than
2310    /// [`CatalogReader`] — for hosts that consume catalog events directly
2311    /// rather than through the loop.
2312    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ToolCatalogEvent> {
2313        self.inner.events_tx.subscribe()
2314    }
2315}
2316
2317/// Read side of a dynamic tool catalog. Implements [`ToolSource`] and is the
2318/// value handed to [`agentkit_loop::AgentBuilder::tools`]. Cloning subscribes
2319/// a fresh broadcast receiver, so independent observers don't compete for
2320/// catalog events.
2321pub struct CatalogReader {
2322    inner: Arc<DynamicCatalogInner>,
2323    events_rx: std::sync::Mutex<tokio::sync::broadcast::Receiver<ToolCatalogEvent>>,
2324}
2325
2326impl CatalogReader {
2327    /// Stable source identifier appearing on emitted catalog events.
2328    pub fn source_id(&self) -> &str {
2329        &self.inner.source_id
2330    }
2331
2332    /// Subscribes a fresh broadcast receiver — equivalent to
2333    /// [`CatalogWriter::subscribe`].
2334    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ToolCatalogEvent> {
2335        self.inner.events_tx.subscribe()
2336    }
2337}
2338
2339impl Clone for CatalogReader {
2340    fn clone(&self) -> Self {
2341        Self {
2342            inner: Arc::clone(&self.inner),
2343            events_rx: std::sync::Mutex::new(self.inner.events_tx.subscribe()),
2344        }
2345    }
2346}
2347
2348impl ToolSource for CatalogReader {
2349    fn specs(&self) -> Vec<ToolSpec> {
2350        self.inner
2351            .tools
2352            .read()
2353            .values()
2354            .filter_map(|tool| tool.current_spec())
2355            .collect()
2356    }
2357
2358    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2359        self.inner.tools.read().get(name).cloned()
2360    }
2361
2362    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2363        // try_recv on a broadcast::Receiver has no panic source, so the only
2364        // way this Mutex poisons is if a panic somehow originates outside the
2365        // try_recv loop while held — recover defensively, the receiver state
2366        // is independent of this lock.
2367        let mut rx = self.events_rx.lock().unwrap_or_else(|e| e.into_inner());
2368        let mut out = Vec::new();
2369        loop {
2370            match rx.try_recv() {
2371                Ok(event) => out.push(event),
2372                Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
2373                Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
2374                Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
2375            }
2376        }
2377        out
2378    }
2379}
2380
2381impl ToolSpec {
2382    /// Converts this spec into an [`InvocableSpec`] for use with the
2383    /// capability layer.
2384    pub fn as_invocable_spec(&self) -> InvocableSpec {
2385        InvocableSpec::new(
2386            CapabilityName::new(self.name.0.clone()),
2387            self.description.clone(),
2388            self.input_schema.clone(),
2389        )
2390        .with_metadata(self.metadata.clone())
2391    }
2392}
2393
2394/// Wraps a [`Tool`] as an [`Invocable`] so it can be surfaced through the
2395/// agentkit capability layer.
2396///
2397/// Created automatically by [`ToolCapabilityProvider::from_registry`]; you
2398/// rarely need to construct one yourself.
2399pub struct ToolInvocableAdapter {
2400    spec: InvocableSpec,
2401    tool: Arc<dyn Tool>,
2402    permissions: Arc<dyn PermissionChecker>,
2403    resources: Arc<dyn ToolResources>,
2404    next_call_id: AtomicU64,
2405}
2406
2407impl ToolInvocableAdapter {
2408    /// Creates a new adapter that wraps `tool` with the given permission
2409    /// checker and shared resources.
2410    pub fn new(
2411        tool: Arc<dyn Tool>,
2412        permissions: Arc<dyn PermissionChecker>,
2413        resources: Arc<dyn ToolResources>,
2414    ) -> Option<Self> {
2415        let spec = tool.current_spec()?.as_invocable_spec();
2416        Some(Self {
2417            spec,
2418            tool,
2419            permissions,
2420            resources,
2421            next_call_id: AtomicU64::new(1),
2422        })
2423    }
2424}
2425
2426#[async_trait]
2427impl Invocable for ToolInvocableAdapter {
2428    fn spec(&self) -> &InvocableSpec {
2429        &self.spec
2430    }
2431
2432    async fn invoke(
2433        &self,
2434        request: InvocableRequest,
2435        ctx: &mut CapabilityContext<'_>,
2436    ) -> Result<InvocableResult, CapabilityError> {
2437        let tool_request = ToolRequest {
2438            call_id: ToolCallId::new(format!(
2439                "tool-call-{}",
2440                self.next_call_id.fetch_add(1, Ordering::Relaxed)
2441            )),
2442            tool_name: self.tool.spec().name.clone(),
2443            input: request.input,
2444            session_id: ctx
2445                .session_id
2446                .cloned()
2447                .unwrap_or_else(|| SessionId::new("capability-session")),
2448            turn_id: ctx
2449                .turn_id
2450                .cloned()
2451                .unwrap_or_else(|| TurnId::new("capability-turn")),
2452            metadata: request.metadata,
2453        };
2454
2455        for permission_request in self
2456            .tool
2457            .proposed_requests(&tool_request)
2458            .map_err(|error| CapabilityError::InvalidInput(error.to_string()))?
2459        {
2460            match self.permissions.evaluate(permission_request.as_ref()) {
2461                PermissionDecision::Allow => {}
2462                PermissionDecision::Deny(denial) => {
2463                    return Err(CapabilityError::ExecutionFailed(format!(
2464                        "tool permission denied: {denial:?}"
2465                    )));
2466                }
2467                PermissionDecision::RequireApproval(req) => {
2468                    return Err(CapabilityError::Unavailable(format!(
2469                        "tool invocation requires approval: {}",
2470                        req.summary
2471                    )));
2472                }
2473            }
2474        }
2475
2476        let mut tool_ctx = ToolContext {
2477            capability: CapabilityContext {
2478                session_id: ctx.session_id,
2479                turn_id: ctx.turn_id,
2480                metadata: ctx.metadata,
2481            },
2482            permissions: self.permissions.as_ref(),
2483            resources: self.resources.as_ref(),
2484            cancellation: None,
2485        };
2486
2487        let result = self
2488            .tool
2489            .invoke(tool_request, &mut tool_ctx)
2490            .await
2491            .map_err(|error| CapabilityError::ExecutionFailed(error.to_string()))?;
2492
2493        Ok(InvocableResult {
2494            output: match result.result.output {
2495                ToolOutput::Text(text) => InvocableOutput::Text(text),
2496                ToolOutput::Structured(value) => InvocableOutput::Structured(value),
2497                ToolOutput::Parts(parts) => InvocableOutput::Items(vec![Item {
2498                    id: None,
2499                    kind: ItemKind::Tool,
2500                    parts,
2501                    metadata: MetadataMap::new(),
2502                    usage: None,
2503                    finish_reason: None,
2504                    created_at: None,
2505                }]),
2506                ToolOutput::Files(files) => {
2507                    let parts = files.into_iter().map(Part::File).collect();
2508                    InvocableOutput::Items(vec![Item {
2509                        id: None,
2510                        kind: ItemKind::Tool,
2511                        parts,
2512                        metadata: MetadataMap::new(),
2513                        usage: None,
2514                        finish_reason: None,
2515                        created_at: None,
2516                    }])
2517                }
2518            },
2519            metadata: result.metadata,
2520        })
2521    }
2522}
2523
2524/// A [`CapabilityProvider`] that exposes every tool in a [`ToolRegistry`]
2525/// as an [`Invocable`] in the agentkit capability layer.
2526///
2527/// This is the bridge between the tool subsystem and the generic capability
2528/// API that the agent loop consumes.
2529pub struct ToolCapabilityProvider {
2530    invocables: Vec<Arc<dyn Invocable>>,
2531}
2532
2533impl ToolCapabilityProvider {
2534    /// Builds a provider from all tools in `registry`, sharing the given
2535    /// permission checker and resources across every adapter.
2536    pub fn from_registry(
2537        registry: &ToolRegistry,
2538        permissions: Arc<dyn PermissionChecker>,
2539        resources: Arc<dyn ToolResources>,
2540    ) -> Self {
2541        let invocables = registry
2542            .tools()
2543            .into_iter()
2544            .filter_map(|tool| {
2545                ToolInvocableAdapter::new(tool, permissions.clone(), resources.clone())
2546                    .map(|adapter| Arc::new(adapter) as Arc<dyn Invocable>)
2547            })
2548            .collect();
2549
2550        Self { invocables }
2551    }
2552}
2553
2554impl CapabilityProvider for ToolCapabilityProvider {
2555    fn invocables(&self) -> Vec<Arc<dyn Invocable>> {
2556        self.invocables.clone()
2557    }
2558
2559    fn resources(&self) -> Vec<Arc<dyn ResourceProvider>> {
2560        Vec::new()
2561    }
2562
2563    fn prompts(&self) -> Vec<Arc<dyn PromptProvider>> {
2564        Vec::new()
2565    }
2566}
2567
2568/// The three-way result of a [`ToolExecutor::execute`] call.
2569///
2570/// Unlike a simple `Result`, this type distinguishes between a successful
2571/// completion, an interruption requiring user input (approval or auth), and
2572/// an outright failure.
2573#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2574pub enum ToolExecutionOutcome {
2575    /// The tool ran to completion and produced a result.
2576    Completed(ToolResult),
2577    /// The tool was interrupted and needs user input before it can continue.
2578    Interrupted(ToolInterruption),
2579    /// The tool failed with an error.
2580    Failed(ToolError),
2581}
2582
2583/// Trait for executing tool calls with permission checking and interruption
2584/// handling.
2585///
2586/// The agent loop calls [`execute`](ToolExecutor::execute) for every tool
2587/// call the model emits. If execution returns
2588/// [`ToolExecutionOutcome::Interrupted`], the loop collects user input and
2589/// retries with [`execute_approved`](ToolExecutor::execute_approved).
2590#[async_trait]
2591pub trait ToolExecutor: Send + Sync {
2592    /// Returns the current specification for every available tool.
2593    fn specs(&self) -> Vec<ToolSpec>;
2594
2595    /// Drains any pending dynamic catalog events.
2596    ///
2597    /// Static executors return an empty list. Dynamic executors should use
2598    /// interior mutability to return each catalog event once.
2599    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2600        Vec::new()
2601    }
2602
2603    /// Looks up the tool, evaluates permissions, and invokes it.
2604    async fn execute(
2605        &self,
2606        request: ToolRequest,
2607        ctx: &mut ToolContext<'_>,
2608    ) -> ToolExecutionOutcome;
2609
2610    /// Looks up the tool, evaluates permissions, and invokes it using an
2611    /// owned execution context.
2612    async fn execute_owned(
2613        &self,
2614        request: ToolRequest,
2615        ctx: OwnedToolContext,
2616    ) -> ToolExecutionOutcome {
2617        let mut borrowed = ctx.borrowed();
2618        self.execute(request, &mut borrowed).await
2619    }
2620
2621    /// Re-executes a tool call that was previously interrupted for approval.
2622    ///
2623    /// The default implementation ignores `approved_request` and delegates
2624    /// to [`execute`](ToolExecutor::execute). [`BasicToolExecutor`]
2625    /// overrides this to skip the approval gate for the matching request.
2626    async fn execute_approved(
2627        &self,
2628        request: ToolRequest,
2629        approved_request: &ApprovalRequest,
2630        ctx: &mut ToolContext<'_>,
2631    ) -> ToolExecutionOutcome {
2632        let _ = approved_request;
2633        self.execute(request, ctx).await
2634    }
2635
2636    /// Re-executes a tool call that was previously interrupted for approval
2637    /// using an owned execution context.
2638    async fn execute_approved_owned(
2639        &self,
2640        request: ToolRequest,
2641        approved_request: &ApprovalRequest,
2642        ctx: OwnedToolContext,
2643    ) -> ToolExecutionOutcome {
2644        let mut borrowed = ctx.borrowed();
2645        self.execute_approved(request, approved_request, &mut borrowed)
2646            .await
2647    }
2648}
2649
2650#[async_trait]
2651impl<T> ToolExecutor for Arc<T>
2652where
2653    T: ToolExecutor + ?Sized,
2654{
2655    fn specs(&self) -> Vec<ToolSpec> {
2656        (**self).specs()
2657    }
2658
2659    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2660        (**self).drain_catalog_events()
2661    }
2662
2663    async fn execute(
2664        &self,
2665        request: ToolRequest,
2666        ctx: &mut ToolContext<'_>,
2667    ) -> ToolExecutionOutcome {
2668        (**self).execute(request, ctx).await
2669    }
2670
2671    async fn execute_approved(
2672        &self,
2673        request: ToolRequest,
2674        approved_request: &ApprovalRequest,
2675        ctx: &mut ToolContext<'_>,
2676    ) -> ToolExecutionOutcome {
2677        (**self)
2678            .execute_approved(request, approved_request, ctx)
2679            .await
2680    }
2681}
2682
2683/// Policy applied when the same tool name appears in more than one
2684/// [`ToolSource`] of a [`BasicToolExecutor`].
2685#[derive(Clone, Debug, Default, PartialEq, Eq)]
2686pub enum CollisionPolicy {
2687    /// First source wins (in iteration order). Subsequent definitions of
2688    /// the same name are ignored.
2689    #[default]
2690    FirstWins,
2691    /// Later sources overwrite earlier ones at lookup time.
2692    LastWins,
2693}
2694
2695/// The default [`ToolExecutor`] that walks one or more [`ToolSource`]s,
2696/// checks permissions via [`Tool::proposed_requests`], and invokes the tool.
2697///
2698/// Compose static native tools (a frozen [`ToolRegistry`]) alongside
2699/// dynamic sources (a [`CatalogReader`] minted by [`dynamic_catalog`] and
2700/// owned by an MCP manager, skill watcher, plugin loader, etc.) without
2701/// merging into a single mutable registry.
2702///
2703/// # Example
2704///
2705/// ```rust,no_run
2706/// use std::sync::Arc;
2707/// use agentkit_tools_core::{BasicToolExecutor, ToolRegistry, ToolSource};
2708///
2709/// let static_registry: Arc<dyn ToolSource> = Arc::new(ToolRegistry::new());
2710/// let executor = BasicToolExecutor::new([static_registry]);
2711/// // Pass `executor` to the agent loop.
2712/// ```
2713pub struct BasicToolExecutor {
2714    sources: Vec<Arc<dyn ToolSource>>,
2715    collision: CollisionPolicy,
2716}
2717
2718impl BasicToolExecutor {
2719    /// Creates an executor that walks `sources` in order on every lookup.
2720    pub fn new(sources: impl IntoIterator<Item = Arc<dyn ToolSource>>) -> Self {
2721        Self {
2722            sources: sources.into_iter().collect(),
2723            collision: CollisionPolicy::default(),
2724        }
2725    }
2726
2727    /// Back-compat shorthand: wrap a single [`ToolRegistry`] as the only source.
2728    pub fn from_registry(registry: ToolRegistry) -> Self {
2729        Self::new([Arc::new(registry) as Arc<dyn ToolSource>])
2730    }
2731
2732    /// Sets the collision policy applied when the same tool name appears in
2733    /// multiple sources.
2734    pub fn with_collision_policy(mut self, policy: CollisionPolicy) -> Self {
2735        self.collision = policy;
2736        self
2737    }
2738
2739    /// Returns the [`ToolSpec`] for every tool across all sources, deduped
2740    /// by [`CollisionPolicy`].
2741    pub fn specs(&self) -> Vec<ToolSpec> {
2742        let mut seen = BTreeSet::new();
2743        let mut out = Vec::new();
2744        let iter: Box<dyn Iterator<Item = &Arc<dyn ToolSource>>> = match self.collision {
2745            CollisionPolicy::FirstWins => Box::new(self.sources.iter()),
2746            CollisionPolicy::LastWins => Box::new(self.sources.iter().rev()),
2747        };
2748        for source in iter {
2749            for spec in source.specs() {
2750                if seen.insert(spec.name.clone()) {
2751                    out.push(spec);
2752                }
2753            }
2754        }
2755        out
2756    }
2757
2758    fn lookup(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2759        match self.collision {
2760            CollisionPolicy::FirstWins => self.sources.iter().find_map(|s| s.get(name)),
2761            CollisionPolicy::LastWins => self.sources.iter().rev().find_map(|s| s.get(name)),
2762        }
2763    }
2764
2765    async fn execute_inner(
2766        &self,
2767        request: ToolRequest,
2768        approved_request_id: Option<&ApprovalId>,
2769        ctx: &mut ToolContext<'_>,
2770    ) -> ToolExecutionOutcome {
2771        let Some(tool) = self.lookup(&request.tool_name) else {
2772            return ToolExecutionOutcome::Failed(ToolError::NotFound(request.tool_name));
2773        };
2774
2775        match tool.proposed_requests(&request) {
2776            Ok(requests) => {
2777                for permission_request in requests {
2778                    match ctx.permissions.evaluate(permission_request.as_ref()) {
2779                        PermissionDecision::Allow => {}
2780                        PermissionDecision::Deny(denial) => {
2781                            return ToolExecutionOutcome::Failed(ToolError::PermissionDenied(
2782                                denial,
2783                            ));
2784                        }
2785                        PermissionDecision::RequireApproval(mut req) => {
2786                            req.call_id = Some(request.call_id.clone());
2787                            if approved_request_id != Some(&req.id) {
2788                                return ToolExecutionOutcome::Interrupted(
2789                                    ToolInterruption::ApprovalRequired(req),
2790                                );
2791                            }
2792                        }
2793                    }
2794                }
2795            }
2796            Err(error) => return ToolExecutionOutcome::Failed(error),
2797        }
2798
2799        match tool.invoke(request, ctx).await {
2800            Ok(result) => ToolExecutionOutcome::Completed(result),
2801            Err(error) => ToolExecutionOutcome::Failed(error),
2802        }
2803    }
2804}
2805
2806#[async_trait]
2807impl ToolExecutor for BasicToolExecutor {
2808    fn specs(&self) -> Vec<ToolSpec> {
2809        BasicToolExecutor::specs(self)
2810    }
2811
2812    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2813        self.sources
2814            .iter()
2815            .flat_map(|s| s.drain_catalog_events())
2816            .collect()
2817    }
2818
2819    async fn execute(
2820        &self,
2821        request: ToolRequest,
2822        ctx: &mut ToolContext<'_>,
2823    ) -> ToolExecutionOutcome {
2824        self.execute_inner(request, None, ctx).await
2825    }
2826
2827    async fn execute_approved(
2828        &self,
2829        request: ToolRequest,
2830        approved_request: &ApprovalRequest,
2831        ctx: &mut ToolContext<'_>,
2832    ) -> ToolExecutionOutcome {
2833        self.execute_inner(request, Some(&approved_request.id), ctx)
2834            .await
2835    }
2836}
2837
2838/// Errors that can occur during tool lookup, permission checking, or execution.
2839///
2840/// Returned from [`Tool::invoke`] and also used internally by
2841/// [`BasicToolExecutor`] to represent lookup and permission failures.
2842#[derive(Debug, Error, Clone, PartialEq, Serialize, Deserialize)]
2843pub enum ToolError {
2844    /// No tool with the given name exists in the registry.
2845    #[error("tool not found: {0}")]
2846    NotFound(ToolName),
2847    /// The input JSON did not match the tool's expected schema.
2848    #[error("invalid tool input: {0}")]
2849    InvalidInput(String),
2850    /// A permission policy denied the operation.
2851    #[error("tool permission denied: {0:?}")]
2852    PermissionDenied(PermissionDenial),
2853    /// The tool ran but encountered a runtime error.
2854    #[error("tool execution failed: {0}")]
2855    ExecutionFailed(String),
2856    /// The tool is temporarily unavailable.
2857    #[error("tool unavailable: {0}")]
2858    Unavailable(String),
2859    /// The turn was cancelled while the tool was running.
2860    #[error("tool execution cancelled")]
2861    Cancelled,
2862    /// An unexpected internal error.
2863    #[error("internal tool error: {0}")]
2864    Internal(String),
2865}
2866
2867impl ToolError {
2868    /// Convenience constructor for the [`PermissionDenied`](ToolError::PermissionDenied) variant.
2869    pub fn permission_denied(denial: PermissionDenial) -> Self {
2870        Self::PermissionDenied(denial)
2871    }
2872}
2873
2874impl From<PermissionDenial> for ToolError {
2875    fn from(value: PermissionDenial) -> Self {
2876        Self::permission_denied(value)
2877    }
2878}
2879
2880#[cfg(test)]
2881mod tests {
2882    use super::*;
2883    use async_trait::async_trait;
2884    use serde_json::json;
2885
2886    #[test]
2887    fn command_policy_can_deny_unknown_executables_without_approval() {
2888        let policy = CommandPolicy::new()
2889            .allow_executable("pwd")
2890            .require_approval_for_unknown(false);
2891        let request = ShellPermissionRequest {
2892            executable: "rm".into(),
2893            argv: vec!["-rf".into(), "/tmp/demo".into()],
2894            cwd: None,
2895            env_keys: Vec::new(),
2896            metadata: MetadataMap::new(),
2897        };
2898
2899        match policy.evaluate(&request) {
2900            PolicyMatch::Deny(denial) => {
2901                assert_eq!(denial.code, PermissionCode::CommandNotAllowed);
2902            }
2903            other => panic!("unexpected policy match: {other:?}"),
2904        }
2905    }
2906
2907    #[test]
2908    fn path_policy_allows_reads_under_read_only_roots() {
2909        let policy = PathPolicy::new().read_only_root("/workspace/vendor");
2910        let request = FileSystemPermissionRequest::Read {
2911            path: PathBuf::from("/workspace/vendor/lib.rs"),
2912            metadata: MetadataMap::new(),
2913        };
2914
2915        match policy.evaluate(&request) {
2916            PolicyMatch::NoOpinion | PolicyMatch::Allow => {}
2917            other => panic!("unexpected policy match: {other:?}"),
2918        }
2919    }
2920
2921    #[test]
2922    fn path_policy_denies_mutations_under_read_only_roots() {
2923        let policy = PathPolicy::new().read_only_root("/workspace/vendor");
2924        let request = FileSystemPermissionRequest::Edit {
2925            path: PathBuf::from("/workspace/vendor/lib.rs"),
2926            metadata: MetadataMap::new(),
2927        };
2928
2929        match policy.evaluate(&request) {
2930            PolicyMatch::Deny(denial) => {
2931                assert_eq!(denial.code, PermissionCode::PathNotAllowed);
2932                assert!(denial.message.contains("read-only"));
2933            }
2934            other => panic!("unexpected policy match: {other:?}"),
2935        }
2936    }
2937
2938    #[test]
2939    fn path_policy_denies_moves_into_read_only_roots() {
2940        let policy = PathPolicy::new().read_only_root("/workspace/vendor");
2941        let request = FileSystemPermissionRequest::Move {
2942            from: PathBuf::from("/workspace/src/lib.rs"),
2943            to: PathBuf::from("/workspace/vendor/lib.rs"),
2944            metadata: MetadataMap::new(),
2945        };
2946
2947        match policy.evaluate(&request) {
2948            PolicyMatch::Deny(denial) => {
2949                assert_eq!(denial.code, PermissionCode::PathNotAllowed);
2950                assert!(denial.message.contains("read-only"));
2951            }
2952            other => panic!("unexpected policy match: {other:?}"),
2953        }
2954    }
2955
2956    #[cfg(unix)]
2957    struct SymlinkTmpDir(PathBuf);
2958
2959    #[cfg(unix)]
2960    impl SymlinkTmpDir {
2961        fn new(label: &str) -> Self {
2962            use std::time::{SystemTime, UNIX_EPOCH};
2963            let nanos = SystemTime::now()
2964                .duration_since(UNIX_EPOCH)
2965                .unwrap()
2966                .as_nanos();
2967            let dir = std::env::temp_dir().join(format!(
2968                "agentkit-pathpolicy-{}-{}-{}",
2969                label,
2970                std::process::id(),
2971                nanos
2972            ));
2973            std::fs::create_dir_all(&dir).unwrap();
2974            // Canonicalise so callers compare against the resolved tmp path
2975            // (macOS `/tmp` is a symlink to `/private/tmp`, etc.).
2976            Self(std::fs::canonicalize(&dir).unwrap())
2977        }
2978
2979        fn path(&self) -> &Path {
2980            &self.0
2981        }
2982    }
2983
2984    #[cfg(unix)]
2985    impl Drop for SymlinkTmpDir {
2986        fn drop(&mut self) {
2987            let _ = std::fs::remove_dir_all(&self.0);
2988        }
2989    }
2990
2991    #[cfg(unix)]
2992    fn assert_path_denied(
2993        policy: &PathPolicy,
2994        request: FileSystemPermissionRequest,
2995    ) -> PermissionDenial {
2996        match policy.evaluate(&request) {
2997            PolicyMatch::Deny(denial) => denial,
2998            other => panic!("expected deny, got: {other:?}"),
2999        }
3000    }
3001
3002    #[cfg(unix)]
3003    #[test]
3004    fn path_policy_blocks_symlink_escape_from_allowed_root() {
3005        let tmp = SymlinkTmpDir::new("allow-escape");
3006        let allowed = tmp.path().join("workspace");
3007        let outside = tmp.path().join("outside");
3008        std::fs::create_dir_all(&allowed).unwrap();
3009        std::fs::create_dir_all(&outside).unwrap();
3010        let secret = outside.join("secret.txt");
3011        std::fs::write(&secret, b"top-secret").unwrap();
3012        let escape = allowed.join("leak");
3013        std::os::unix::fs::symlink(&secret, &escape).unwrap();
3014
3015        let policy = PathPolicy::new()
3016            .allow_root(&allowed)
3017            .require_approval_outside_allowed(false);
3018        let denial = assert_path_denied(
3019            &policy,
3020            FileSystemPermissionRequest::Read {
3021                path: escape,
3022                metadata: MetadataMap::new(),
3023            },
3024        );
3025        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3026    }
3027
3028    #[cfg(unix)]
3029    #[test]
3030    fn path_policy_blocks_symlink_into_protected_root() {
3031        let tmp = SymlinkTmpDir::new("protect-bypass");
3032        let workspace = tmp.path().join("workspace");
3033        std::fs::create_dir_all(&workspace).unwrap();
3034        let secret = workspace.join(".env");
3035        std::fs::write(&secret, b"API_KEY=xxx").unwrap();
3036        let alias = workspace.join("config");
3037        std::os::unix::fs::symlink(&secret, &alias).unwrap();
3038
3039        let policy = PathPolicy::new()
3040            .allow_root(&workspace)
3041            .protect_root(&secret);
3042        let denial = assert_path_denied(
3043            &policy,
3044            FileSystemPermissionRequest::Read {
3045                path: alias,
3046                metadata: MetadataMap::new(),
3047            },
3048        );
3049        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3050        assert!(denial.message.contains("denied"));
3051    }
3052
3053    #[cfg(unix)]
3054    #[test]
3055    fn path_policy_blocks_symlink_write_into_read_only_root() {
3056        let tmp = SymlinkTmpDir::new("readonly-bypass");
3057        let workspace = tmp.path().join("workspace");
3058        let vendor = workspace.join("vendor");
3059        std::fs::create_dir_all(&vendor).unwrap();
3060        let target = vendor.join("lib.rs");
3061        std::fs::write(&target, b"// vendored").unwrap();
3062        let writable_alias = workspace.join("writable");
3063        std::os::unix::fs::symlink(&target, &writable_alias).unwrap();
3064
3065        let policy = PathPolicy::new()
3066            .allow_root(&workspace)
3067            .read_only_root(&vendor);
3068        let denial = assert_path_denied(
3069            &policy,
3070            FileSystemPermissionRequest::Edit {
3071                path: writable_alias,
3072                metadata: MetadataMap::new(),
3073            },
3074        );
3075        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3076        assert!(denial.message.contains("read-only"));
3077    }
3078
3079    #[cfg(unix)]
3080    #[test]
3081    fn path_policy_resolves_symlink_parent_for_nonexistent_leaf() {
3082        let tmp = SymlinkTmpDir::new("create-escape");
3083        let allowed = tmp.path().join("workspace");
3084        let outside = tmp.path().join("outside");
3085        std::fs::create_dir_all(&allowed).unwrap();
3086        std::fs::create_dir_all(&outside).unwrap();
3087        let escape_dir = allowed.join("escape");
3088        std::os::unix::fs::symlink(&outside, &escape_dir).unwrap();
3089        let new_file = escape_dir.join("new.txt");
3090
3091        let policy = PathPolicy::new()
3092            .allow_root(&allowed)
3093            .require_approval_outside_allowed(false);
3094        let denial = assert_path_denied(
3095            &policy,
3096            FileSystemPermissionRequest::Write {
3097                path: new_file,
3098                metadata: MetadataMap::new(),
3099            },
3100        );
3101        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3102    }
3103
3104    #[derive(Clone)]
3105    struct HiddenTool {
3106        spec: ToolSpec,
3107    }
3108
3109    impl HiddenTool {
3110        fn new() -> Self {
3111            Self {
3112                spec: ToolSpec {
3113                    name: ToolName::new("hidden"),
3114                    description: "hidden".into(),
3115                    input_schema: json!({"type": "object"}),
3116                    annotations: ToolAnnotations::default(),
3117                    metadata: MetadataMap::new(),
3118                },
3119            }
3120        }
3121    }
3122
3123    #[async_trait]
3124    impl Tool for HiddenTool {
3125        fn spec(&self) -> &ToolSpec {
3126            &self.spec
3127        }
3128
3129        fn current_spec(&self) -> Option<ToolSpec> {
3130            None
3131        }
3132
3133        async fn invoke(
3134            &self,
3135            request: ToolRequest,
3136            _ctx: &mut ToolContext<'_>,
3137        ) -> Result<ToolResult, ToolError> {
3138            Ok(ToolResult {
3139                result: ToolResultPart {
3140                    call_id: request.call_id,
3141                    output: ToolOutput::Text("hidden".into()),
3142                    is_error: false,
3143                    metadata: MetadataMap::new(),
3144                },
3145                duration: None,
3146                metadata: MetadataMap::new(),
3147            })
3148        }
3149    }
3150
3151    #[test]
3152    fn hidden_tools_are_omitted_from_specs_and_capabilities() {
3153        let registry = ToolRegistry::new().with(HiddenTool::new());
3154
3155        assert!(registry.specs().is_empty());
3156
3157        let provider = ToolCapabilityProvider::from_registry(
3158            &registry,
3159            Arc::new(AllowAllPermissionChecker),
3160            Arc::new(()),
3161        );
3162        assert!(provider.invocables().is_empty());
3163    }
3164
3165    struct AllowAllPermissionChecker;
3166
3167    impl PermissionChecker for AllowAllPermissionChecker {
3168        fn evaluate(&self, _request: &dyn PermissionRequest) -> PermissionDecision {
3169            PermissionDecision::Allow
3170        }
3171    }
3172
3173    /// Tool whose `current_spec()` panics — used to exercise the
3174    /// catalog's poison-recovery guarantee.
3175    #[derive(Clone)]
3176    struct PanickingSpecTool {
3177        spec: ToolSpec,
3178    }
3179
3180    impl PanickingSpecTool {
3181        fn new(name: &str) -> Self {
3182            Self {
3183                spec: ToolSpec {
3184                    name: ToolName::new(name),
3185                    description: "panics on current_spec".into(),
3186                    input_schema: json!({"type": "object"}),
3187                    annotations: ToolAnnotations::default(),
3188                    metadata: MetadataMap::new(),
3189                },
3190            }
3191        }
3192    }
3193
3194    #[async_trait]
3195    impl Tool for PanickingSpecTool {
3196        fn spec(&self) -> &ToolSpec {
3197            &self.spec
3198        }
3199
3200        fn current_spec(&self) -> Option<ToolSpec> {
3201            panic!("PanickingSpecTool::current_spec");
3202        }
3203
3204        async fn invoke(
3205            &self,
3206            request: ToolRequest,
3207            _ctx: &mut ToolContext<'_>,
3208        ) -> Result<ToolResult, ToolError> {
3209            Ok(ToolResult {
3210                result: ToolResultPart {
3211                    call_id: request.call_id,
3212                    output: ToolOutput::Text("never".into()),
3213                    is_error: false,
3214                    metadata: MetadataMap::new(),
3215                },
3216                duration: None,
3217                metadata: MetadataMap::new(),
3218            })
3219        }
3220    }
3221
3222    /// If a tool's `current_spec()` panics during `replace_all`'s diff phase,
3223    /// the inner `RwLock` would normally poison and brick the catalog forever.
3224    /// `ToolMap` recovers from poison; this test pins the behavior so a future
3225    /// patch can't accidentally reintroduce the brick.
3226    ///
3227    /// The recovery is only safe because `replace_all` computes the diff
3228    /// (running user code) BEFORE swapping the map. If you change a write
3229    /// critical section to mutate before/between user-code calls, this test
3230    /// will still pass — but the catalog WILL be in a half-mutated state
3231    /// after a panic. Re-read `ToolMap`'s invariant before changing.
3232    #[test]
3233    fn catalog_recovers_from_panicked_writer() {
3234        let (writer, reader) = dynamic_catalog("test");
3235
3236        // Pre-seed with a panicker so the next `replace_all` enters the
3237        // diff branch that calls `existing.current_spec()`. `upsert`
3238        // itself never calls `current_spec`, so this insertion is safe.
3239        writer.upsert(Arc::new(PanickingSpecTool::new("boom")));
3240        let _ = reader.drain_catalog_events();
3241
3242        // `replace_all` with a different Arc of the same name forces the
3243        // diff to call `existing.current_spec()` → panics. The swap has
3244        // NOT happened yet at this point (the diff runs before the
3245        // `*guard = new_map`), so the catalog state is still consistent.
3246        let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3247            writer.replace_all(vec![
3248                Arc::new(PanickingSpecTool::new("boom")) as Arc<dyn Tool>
3249            ]);
3250        }));
3251        assert!(
3252            panic_result.is_err(),
3253            "PanickingSpecTool::current_spec must propagate"
3254        );
3255
3256        // Without recovery, every subsequent lock acquisition would panic
3257        // with "dynamic catalog poisoned". `get` doesn't call `current_spec`,
3258        // so it's a clean probe of whether the lock recovered.
3259        assert!(
3260            reader.get(&ToolName::new("boom")).is_some(),
3261            "catalog still readable after poisoning panic"
3262        );
3263
3264        // Writes also recover. Remove the panicker so subsequent `specs()`
3265        // calls don't re-trigger its panic.
3266        assert!(writer.remove(&ToolName::new("boom")));
3267
3268        // Add a well-behaved tool and round-trip through both sides.
3269        // (HiddenTool::current_spec returns None, so it's intentionally
3270        // filtered out of specs() — probe via get() instead.)
3271        writer.upsert(Arc::new(HiddenTool::new()));
3272        assert!(
3273            reader.get(&ToolName::new("hidden")).is_some(),
3274            "catalog usable for further writes + reads"
3275        );
3276    }
3277
3278    #[derive(Clone)]
3279    struct EchoTool {
3280        spec: ToolSpec,
3281    }
3282
3283    impl EchoTool {
3284        fn new(name: &str) -> Self {
3285            Self {
3286                spec: ToolSpec {
3287                    name: ToolName::new(name),
3288                    description: format!("echo {name}"),
3289                    input_schema: json!({"type": "object"}),
3290                    annotations: ToolAnnotations::default(),
3291                    metadata: MetadataMap::new(),
3292                },
3293            }
3294        }
3295    }
3296
3297    #[async_trait]
3298    impl Tool for EchoTool {
3299        fn spec(&self) -> &ToolSpec {
3300            &self.spec
3301        }
3302
3303        async fn invoke(
3304            &self,
3305            request: ToolRequest,
3306            _ctx: &mut ToolContext<'_>,
3307        ) -> Result<ToolResult, ToolError> {
3308            Ok(ToolResult::new(ToolResultPart::success(
3309                request.call_id,
3310                ToolOutput::text(request.tool_name.0.clone()),
3311            )))
3312        }
3313    }
3314
3315    fn registry_with(names: &[&str]) -> ToolRegistry {
3316        names.iter().fold(ToolRegistry::new(), |reg, name| {
3317            reg.with(EchoTool::new(name))
3318        })
3319    }
3320
3321    #[test]
3322    fn prefixed_rewrites_specs_and_resolves_lookups() {
3323        let source = registry_with(&["get_temp", "get_humidity"]).prefixed("weather");
3324        let names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
3325        assert_eq!(names, vec!["weather_get_humidity", "weather_get_temp"]);
3326
3327        assert!(source.get(&ToolName::new("weather_get_temp")).is_some());
3328        assert!(
3329            source.get(&ToolName::new("get_temp")).is_none(),
3330            "original name must not resolve when prefixed"
3331        );
3332        assert!(source.get(&ToolName::new("unknown")).is_none());
3333    }
3334
3335    #[tokio::test]
3336    async fn prefixed_invoke_sees_inner_name_on_request() {
3337        let source = registry_with(&["get_temp"]).prefixed("weather");
3338        let tool = source.get(&ToolName::new("weather_get_temp")).unwrap();
3339
3340        // The wrapper must report the public name on its spec...
3341        assert_eq!(tool.spec().name.0, "weather_get_temp");
3342
3343        // ...but the inner tool must see its own name in the request.
3344        let owned = OwnedToolContext {
3345            session_id: SessionId::new("s"),
3346            turn_id: TurnId::new("t"),
3347            metadata: MetadataMap::new(),
3348            permissions: Arc::new(AllowAllPermissions),
3349            resources: Arc::new(()),
3350            cancellation: None,
3351        };
3352        let mut ctx = owned.borrowed();
3353        let request = ToolRequest {
3354            call_id: ToolCallId::new("c"),
3355            tool_name: ToolName::new("weather_get_temp"),
3356            input: json!({}),
3357            session_id: SessionId::new("s"),
3358            turn_id: TurnId::new("t"),
3359            metadata: MetadataMap::new(),
3360        };
3361        let result = tool.invoke(request, &mut ctx).await.unwrap();
3362        match result.result.output {
3363            ToolOutput::Text(text) => assert_eq!(text, "get_temp"),
3364            other => panic!("unexpected output: {other:?}"),
3365        }
3366    }
3367
3368    #[test]
3369    fn filtered_hides_tools_rejected_by_predicate() {
3370        let source = registry_with(&["safe", "danger_drop", "danger_delete"])
3371            .filtered(|name| !name.0.starts_with("danger_"));
3372        let names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
3373        assert_eq!(names, vec!["safe"]);
3374
3375        assert!(source.get(&ToolName::new("safe")).is_some());
3376        assert!(source.get(&ToolName::new("danger_drop")).is_none());
3377    }
3378
3379    #[test]
3380    fn renamed_remaps_specs_and_lookups() {
3381        let source = registry_with(&["legacy_name", "passthrough"])
3382            .renamed([(ToolName::new("legacy_name"), ToolName::new("modern_name"))]);
3383        let mut names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
3384        names.sort();
3385        assert_eq!(names, vec!["modern_name", "passthrough"]);
3386
3387        assert!(source.get(&ToolName::new("modern_name")).is_some());
3388        assert!(
3389            source.get(&ToolName::new("legacy_name")).is_none(),
3390            "original name is hidden after renaming"
3391        );
3392        assert!(source.get(&ToolName::new("passthrough")).is_some());
3393    }
3394
3395    #[cfg(feature = "schemars")]
3396    mod schemars_helpers {
3397        use super::*;
3398        use schemars::JsonSchema;
3399        use serde::Deserialize;
3400
3401        #[derive(JsonSchema, Deserialize)]
3402        #[allow(dead_code)]
3403        struct WeatherInput {
3404            /// City name to look up.
3405            location: String,
3406            /// Use celsius (default false).
3407            #[serde(default)]
3408            celsius: bool,
3409        }
3410
3411        #[test]
3412        fn schema_for_emits_object_schema_with_typed_fields() {
3413            let schema = schema_for::<WeatherInput>();
3414            let obj = schema.as_object().expect("schema is a JSON object");
3415            assert_eq!(
3416                obj.get("type").and_then(|v| v.as_str()),
3417                Some("object"),
3418                "root type should be object"
3419            );
3420            let properties = obj
3421                .get("properties")
3422                .and_then(|v| v.as_object())
3423                .expect("properties block");
3424            assert!(properties.contains_key("location"));
3425            assert!(properties.contains_key("celsius"));
3426        }
3427
3428        #[test]
3429        fn tool_spec_for_carries_schema_name_and_description() {
3430            let spec = tool_spec_for::<WeatherInput>("get_weather", "Fetch current weather");
3431            assert_eq!(spec.name.0, "get_weather");
3432            assert_eq!(spec.description, "Fetch current weather");
3433            assert!(spec.input_schema.is_object());
3434        }
3435    }
3436
3437    #[test]
3438    fn transforms_compose_via_chained_methods() {
3439        let source = registry_with(&["read_file", "write_file", "delete_file"])
3440            .filtered(|name| name.0 != "delete_file")
3441            .prefixed("fs");
3442        let mut names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
3443        names.sort();
3444        assert_eq!(names, vec!["fs_read_file", "fs_write_file"]);
3445    }
3446}