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, Mutex, 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, json};
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/// Metadata key used by tool specs to advertise their preferred output
163/// overflow behaviour. Hosts can respect this through
164/// [`ConfigurableToolOutputTruncationStrategy`], while still overriding
165/// individual tools in executor configuration.
166pub const TOOL_OUTPUT_LIMIT_METADATA_KEY: &str = "agentkit.tool_output_limit";
167
168/// What the executor should do when a tool result exceeds its configured
169/// model-facing byte budget.
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum ToolOutputOverflowAction {
173    /// Return an execution failure instead of placing an oversized result in
174    /// the transcript. Use this for readback tools where silent truncation
175    /// would reintroduce an unbounded loop.
176    Fail,
177    /// Clip the output inline with an explicit truncation marker.
178    InlineClip,
179    /// Store the full output in the configured tool-result artifact store and
180    /// return a small pointer envelope that can be read back with
181    /// `tool_result_read`.
182    StoreForReadback,
183}
184
185/// Per-tool output limit configuration.
186#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
187pub struct ToolOutputLimit {
188    /// Maximum model-facing bytes allowed for this tool result.
189    pub max_bytes: usize,
190    /// Overflow behaviour once `max_bytes` is exceeded.
191    pub action: ToolOutputOverflowAction,
192}
193
194impl ToolOutputLimit {
195    /// Fail execution if output exceeds `max_bytes`.
196    pub fn fail(max_bytes: usize) -> Self {
197        Self {
198            max_bytes,
199            action: ToolOutputOverflowAction::Fail,
200        }
201    }
202
203    /// Clip output inline if it exceeds `max_bytes`.
204    pub fn inline_clip(max_bytes: usize) -> Self {
205        Self {
206            max_bytes,
207            action: ToolOutputOverflowAction::InlineClip,
208        }
209    }
210
211    /// Store oversized output in the configured artifact store for bounded
212    /// readback.
213    pub fn store_for_readback(max_bytes: usize) -> Self {
214        Self {
215            max_bytes,
216            action: ToolOutputOverflowAction::StoreForReadback,
217        }
218    }
219
220    fn to_metadata_value(&self) -> Value {
221        serde_json::to_value(self).expect("ToolOutputLimit serializes")
222    }
223
224    fn from_metadata(metadata: &MetadataMap) -> Option<Self> {
225        metadata
226            .get(TOOL_OUTPUT_LIMIT_METADATA_KEY)
227            .and_then(|value| serde_json::from_value(value.clone()).ok())
228    }
229}
230
231/// Declarative specification of a tool's identity, schema, and behavioural hints.
232///
233/// Every [`Tool`] implementation exposes a `ToolSpec` that the framework uses to
234/// advertise the tool to an LLM, validate inputs, and drive permission checks.
235///
236/// # Example
237///
238/// ```rust
239/// use agentkit_tools_core::{ToolAnnotations, ToolName, ToolSpec};
240/// use serde_json::json;
241///
242/// let spec = ToolSpec::new(
243///     ToolName::new("grep_search"),
244///     "Search files by regex pattern",
245///     json!({
246///         "type": "object",
247///         "properties": {
248///             "pattern": { "type": "string" },
249///             "path": { "type": "string" }
250///         },
251///         "required": ["pattern"]
252///     }),
253/// )
254/// .with_annotations(ToolAnnotations::read_only());
255/// ```
256#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
257pub struct ToolSpec {
258    /// Machine-readable name used to route tool calls.
259    pub name: ToolName,
260    /// Human-readable description sent to the LLM so it knows when to use this tool.
261    pub description: String,
262    /// JSON Schema describing the expected input object.
263    pub input_schema: Value,
264    /// JSON Schema describing the shape this tool returns.
265    ///
266    /// Provider APIs (Anthropic, OpenAI, Gemini) don't carry an output schema
267    /// in their tool declarations, so this is **not** surfaced verbatim to the
268    /// model. Hosts and composing tools may render it into the description, or
269    /// use it for validation. `ComposeTool::wrap` (in `agentkit-tool-compose`)
270    /// renders wrapped child tools' output schemas into the compose tool
271    /// description; the Lua `tools()` helper also exposes current specs so
272    /// composed scripts can target the correct return shape on the first try.
273    #[serde(skip_serializing_if = "Option::is_none", default)]
274    pub output_schema: Option<Value>,
275    /// Advisory behavioural hints (read-only, destructive, etc.).
276    pub annotations: ToolAnnotations,
277    /// Arbitrary key-value pairs for framework extensions.
278    pub metadata: MetadataMap,
279}
280
281/// A change notification for a dynamic tool catalog.
282///
283/// Dynamic executors, such as MCP-backed executors, use this to tell the
284/// agent loop that the model should see a refreshed tool list on the next
285/// provider request.
286#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
287pub struct ToolCatalogEvent {
288    /// Stable source identifier for the catalog that changed.
289    pub source: String,
290    /// Tool names that became available.
291    pub added: Vec<String>,
292    /// Tool names that are no longer available.
293    pub removed: Vec<String>,
294    /// Tool names whose schema, description, or metadata changed.
295    pub changed: Vec<String>,
296}
297
298impl ToolCatalogEvent {
299    /// Builds a catalog event with empty change sets.
300    pub fn new(source: impl Into<String>) -> Self {
301        Self {
302            source: source.into(),
303            added: Vec::new(),
304            removed: Vec::new(),
305            changed: Vec::new(),
306        }
307    }
308
309    /// Applies `f` to every tool name in `added`, `removed`, and `changed`.
310    pub fn for_each_name_mut(&mut self, mut f: impl FnMut(&mut String)) {
311        for vec in [&mut self.added, &mut self.removed, &mut self.changed] {
312            for name in vec.iter_mut() {
313                f(name);
314            }
315        }
316    }
317
318    /// Retains only tool names that pass `predicate` in `added`, `removed`,
319    /// and `changed`.
320    pub fn retain_names(&mut self, mut predicate: impl FnMut(&str) -> bool) {
321        self.added.retain(|n| predicate(n));
322        self.removed.retain(|n| predicate(n));
323        self.changed.retain(|n| predicate(n));
324    }
325}
326
327impl ToolSpec {
328    /// Builds a tool spec with default annotations and empty metadata.
329    pub fn new(
330        name: impl Into<ToolName>,
331        description: impl Into<String>,
332        input_schema: Value,
333    ) -> Self {
334        Self {
335            name: name.into(),
336            description: description.into(),
337            input_schema,
338            output_schema: None,
339            annotations: ToolAnnotations::default(),
340            metadata: MetadataMap::new(),
341        }
342    }
343
344    /// Declares the JSON shape this tool returns. See
345    /// [`output_schema`](Self::output_schema) for distribution semantics.
346    pub fn with_output_schema(mut self, schema: Value) -> Self {
347        self.output_schema = Some(schema);
348        self
349    }
350
351    /// Replaces the tool annotations.
352    pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self {
353        self.annotations = annotations;
354        self
355    }
356
357    /// Replaces the tool metadata.
358    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
359        self.metadata = metadata;
360        self
361    }
362
363    /// Advertises this tool's preferred output overflow behaviour.
364    ///
365    /// This is advisory metadata: hosts opt into it by configuring an output
366    /// truncation strategy that reads tool metadata. Executor-level per-tool
367    /// overrides still take precedence.
368    pub fn with_output_limit(mut self, limit: ToolOutputLimit) -> Self {
369        self.metadata.insert(
370            TOOL_OUTPUT_LIMIT_METADATA_KEY.to_string(),
371            limit.to_metadata_value(),
372        );
373        self
374    }
375}
376
377/// An incoming request to execute a tool.
378///
379/// Created by the agent loop when the model emits a tool-call. The
380/// [`BasicToolExecutor`] uses `tool_name` to look up the [`Tool`] in the
381/// registry and forwards this request to [`Tool::invoke`].
382#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
383pub struct ToolRequest {
384    /// Provider-assigned identifier for this specific call.
385    pub call_id: ToolCallId,
386    /// Name of the tool to invoke (must match a registered [`ToolName`]).
387    pub tool_name: ToolName,
388    /// JSON input parsed from the model's tool-call arguments.
389    pub input: Value,
390    /// Session that owns this call.
391    pub session_id: SessionId,
392    /// Turn within the session that triggered this call.
393    pub turn_id: TurnId,
394    /// Arbitrary key-value pairs for framework extensions.
395    pub metadata: MetadataMap,
396}
397
398impl ToolRequest {
399    /// Builds a tool request with empty metadata.
400    pub fn new(
401        call_id: impl Into<ToolCallId>,
402        tool_name: impl Into<ToolName>,
403        input: Value,
404        session_id: impl Into<SessionId>,
405        turn_id: impl Into<TurnId>,
406    ) -> Self {
407        Self {
408            call_id: call_id.into(),
409            tool_name: tool_name.into(),
410            input,
411            session_id: session_id.into(),
412            turn_id: turn_id.into(),
413            metadata: MetadataMap::new(),
414        }
415    }
416
417    /// Replaces the request metadata.
418    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
419        self.metadata = metadata;
420        self
421    }
422}
423
424/// The output produced by a successful tool invocation.
425///
426/// Returned from [`Tool::invoke`] and wrapped by [`ToolExecutionOutcome::Completed`]
427/// after the executor finishes permission checks and execution.
428#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
429pub struct ToolResult {
430    /// The content payload sent back to the model.
431    pub result: ToolResultPart,
432    /// Wall-clock time the tool took to run, if measured.
433    pub duration: Option<Duration>,
434    /// Arbitrary key-value pairs for framework extensions.
435    pub metadata: MetadataMap,
436}
437
438impl ToolResult {
439    /// Builds a tool result with no duration and empty metadata.
440    pub fn new(result: ToolResultPart) -> Self {
441        Self {
442            result,
443            duration: None,
444            metadata: MetadataMap::new(),
445        }
446    }
447
448    /// Sets the measured duration.
449    pub fn with_duration(mut self, duration: Duration) -> Self {
450        self.duration = Some(duration);
451        self
452    }
453
454    /// Replaces the result metadata.
455    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
456        self.metadata = metadata;
457        self
458    }
459}
460
461/// Trait for dependency injection into tool implementations.
462///
463/// Tools that need access to shared state (database handles, HTTP clients,
464/// configuration, etc.) can downcast the `&dyn ToolResources` provided in
465/// [`ToolContext`] to a concrete type.
466///
467/// The unit type `()` implements `ToolResources` and serves as the default
468/// when no shared resources are needed.
469///
470/// # Example
471///
472/// ```rust
473/// use std::any::Any;
474/// use agentkit_tools_core::ToolResources;
475///
476/// struct AppResources {
477///     project_root: std::path::PathBuf,
478/// }
479///
480/// impl ToolResources for AppResources {
481///     fn as_any(&self) -> &dyn Any {
482///         self
483///     }
484/// }
485/// ```
486pub trait ToolResources: Send + Sync {
487    /// Returns a reference to `self` as [`Any`] so callers can downcast to
488    /// the concrete resource type.
489    fn as_any(&self) -> &dyn Any;
490}
491
492impl ToolResources for () {
493    fn as_any(&self) -> &dyn Any {
494        self
495    }
496}
497
498/// Runtime context passed to every [`Tool::invoke`] call.
499///
500/// Provides the tool with access to session/turn metadata, the active
501/// permission checker, shared resources, and a cancellation signal so the
502/// tool can abort long-running work when a turn is cancelled.
503pub struct ToolContext<'a> {
504    /// Capability-layer context carrying session and turn identifiers.
505    pub capability: CapabilityContext<'a>,
506    /// The active permission checker for sub-operations the tool may perform.
507    pub permissions: &'a dyn PermissionChecker,
508    /// Shared resources (e.g. database handles, config) injected by the host.
509    pub resources: &'a dyn ToolResources,
510    /// Signal that the current turn has been cancelled by the user.
511    pub cancellation: Option<TurnCancellation>,
512    /// Optional scope that lets advanced tools invoke other tools through the
513    /// same executor, permissions, resources, and cancellation path.
514    pub execution_scope: Option<ToolExecutionScope>,
515    /// Approval request currently being resumed, if this invocation is the
516    /// result of a host approval.
517    pub approved_request: Option<ApprovalRequest>,
518}
519
520/// Owned scope for nested tool execution.
521///
522/// This is intentionally executor-centric: tools that compose other tools
523/// must still go through the normal [`ToolExecutor`] path so lookup,
524/// permissions, approval interrupts, and output truncation remain centralized.
525#[derive(Clone)]
526pub struct ToolExecutionScope {
527    pub executor: Arc<dyn ToolExecutor>,
528    pub session_id: SessionId,
529    pub turn_id: TurnId,
530    pub permissions: Arc<dyn PermissionChecker>,
531    pub resources: Arc<dyn ToolResources>,
532    pub cancellation: Option<TurnCancellation>,
533}
534
535impl ToolExecutionScope {
536    /// Creates an owned tool context for a nested tool call.
537    pub fn nested_context(&self, metadata: MetadataMap) -> OwnedToolContext {
538        OwnedToolContext {
539            session_id: self.session_id.clone(),
540            turn_id: self.turn_id.clone(),
541            metadata,
542            permissions: self.permissions.clone(),
543            resources: self.resources.clone(),
544            cancellation: self.cancellation.clone(),
545            execution_scope: Some(self.clone()),
546            approved_request: None,
547        }
548    }
549
550    /// Invokes a nested tool through the same executor and execution context.
551    pub async fn execute_child(&self, request: ToolRequest) -> ToolExecutionOutcome {
552        let ctx = self.nested_context(request.metadata.clone());
553        self.executor.execute_owned(request, ctx).await
554    }
555
556    /// Invokes a nested tool after approval through the same executor and
557    /// execution context.
558    pub async fn execute_approved_child(
559        &self,
560        request: ToolRequest,
561        approval: &ApprovalRequest,
562    ) -> ToolExecutionOutcome {
563        let ctx = self.nested_context(request.metadata.clone());
564        self.executor
565            .execute_approved_owned(request, approval, ctx)
566            .await
567    }
568}
569
570/// Owned execution context that can outlive a single stack frame.
571///
572/// This is useful for schedulers or task managers that need to move a tool
573/// execution onto another task while still constructing the borrowed
574/// [`ToolContext`] expected by existing tool implementations.
575#[derive(Clone)]
576pub struct OwnedToolContext {
577    /// Session identifier for the invocation.
578    pub session_id: SessionId,
579    /// Turn identifier for the invocation.
580    pub turn_id: TurnId,
581    /// Arbitrary invocation metadata.
582    pub metadata: MetadataMap,
583    /// Shared permission checker.
584    pub permissions: Arc<dyn PermissionChecker>,
585    /// Shared resources injected by the host.
586    pub resources: Arc<dyn ToolResources>,
587    /// Cooperative cancellation signal for the invocation.
588    pub cancellation: Option<TurnCancellation>,
589    /// Optional owned scope for nested tool execution.
590    pub execution_scope: Option<ToolExecutionScope>,
591    /// Approval request currently being resumed, if any.
592    pub approved_request: Option<ApprovalRequest>,
593}
594
595impl OwnedToolContext {
596    /// Creates a borrowed [`ToolContext`] view over this owned context.
597    pub fn borrowed(&self) -> ToolContext<'_> {
598        ToolContext {
599            capability: CapabilityContext {
600                session_id: Some(&self.session_id),
601                turn_id: Some(&self.turn_id),
602                metadata: &self.metadata,
603            },
604            permissions: self.permissions.as_ref(),
605            resources: self.resources.as_ref(),
606            cancellation: self.cancellation.clone(),
607            execution_scope: self.execution_scope.clone(),
608            approved_request: self.approved_request.clone(),
609        }
610    }
611}
612
613/// Context passed to a tool-output truncation strategy after a tool invocation
614/// succeeds and before the result is appended to the transcript.
615#[derive(Clone, Debug)]
616pub struct ToolOutputTruncationContext {
617    pub tool_name: ToolName,
618    pub call_id: ToolCallId,
619    pub session_id: SessionId,
620    pub turn_id: TurnId,
621    pub tool_spec: ToolSpec,
622}
623
624impl From<(&ToolRequest, ToolSpec)> for ToolOutputTruncationContext {
625    fn from((request, tool_spec): (&ToolRequest, ToolSpec)) -> Self {
626        Self {
627            tool_name: request.tool_name.clone(),
628            call_id: request.call_id.clone(),
629            session_id: request.session_id.clone(),
630            turn_id: request.turn_id.clone(),
631            tool_spec,
632        }
633    }
634}
635
636/// Strategy hook for enforcing model-facing tool-output budgets.
637///
638/// This runs centrally in [`BasicToolExecutor`], so it applies uniformly to
639/// native tools, filesystem tools, MCP tools, and any custom tool source.
640#[async_trait]
641pub trait ToolOutputTruncationStrategy: Send + Sync {
642    async fn apply(
643        &self,
644        ctx: ToolOutputTruncationContext,
645        output: ToolOutput,
646    ) -> Result<ToolOutput, ToolError>;
647}
648
649/// Identifier returned when oversized tool output is stored out-of-band.
650#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
651pub struct ToolOutputArtifactId(pub String);
652
653impl fmt::Display for ToolOutputArtifactId {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        self.0.fmt(f)
656    }
657}
658
659/// Stored representation of an oversized tool result.
660#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
661pub struct ToolOutputArtifact {
662    pub id: ToolOutputArtifactId,
663    pub tool_name: ToolName,
664    pub call_id: ToolCallId,
665    pub session_id: SessionId,
666    pub turn_id: TurnId,
667    pub original_bytes: usize,
668    pub body: String,
669}
670
671/// Bounded UTF-8 slice of a stored tool-result artifact.
672#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
673pub struct ToolOutputArtifactSlice {
674    pub id: ToolOutputArtifactId,
675    pub offset: usize,
676    pub next_offset: usize,
677    pub original_bytes: usize,
678    pub eof: bool,
679    pub content: String,
680}
681
682#[async_trait]
683pub trait ToolOutputArtifactStore: Send + Sync {
684    async fn put(
685        &self,
686        ctx: &ToolOutputTruncationContext,
687        body: String,
688        original_bytes: usize,
689    ) -> Result<ToolOutputArtifact, ToolError>;
690
691    async fn read(
692        &self,
693        id: &ToolOutputArtifactId,
694        offset: usize,
695        max_bytes: usize,
696    ) -> Result<ToolOutputArtifactSlice, ToolError>;
697}
698
699/// Process-local artifact store for oversized tool results.
700#[derive(Debug, Default)]
701pub struct InMemoryToolOutputArtifactStore {
702    next_id: AtomicU64,
703    artifacts: Mutex<BTreeMap<ToolOutputArtifactId, ToolOutputArtifact>>,
704}
705
706impl InMemoryToolOutputArtifactStore {
707    pub fn new() -> Self {
708        Self::default()
709    }
710}
711
712#[async_trait]
713impl ToolOutputArtifactStore for InMemoryToolOutputArtifactStore {
714    async fn put(
715        &self,
716        ctx: &ToolOutputTruncationContext,
717        body: String,
718        original_bytes: usize,
719    ) -> Result<ToolOutputArtifact, ToolError> {
720        let n = self.next_id.fetch_add(1, Ordering::Relaxed);
721        let id = ToolOutputArtifactId(format!(
722            "{}:{}:{}",
723            sanitize_artifact_id_component(ctx.session_id.0.as_str()),
724            sanitize_artifact_id_component(ctx.call_id.0.as_str()),
725            n
726        ));
727        let artifact = ToolOutputArtifact {
728            id: id.clone(),
729            tool_name: ctx.tool_name.clone(),
730            call_id: ctx.call_id.clone(),
731            session_id: ctx.session_id.clone(),
732            turn_id: ctx.turn_id.clone(),
733            original_bytes,
734            body,
735        };
736        self.artifacts
737            .lock()
738            .unwrap_or_else(|e| e.into_inner())
739            .insert(id, artifact.clone());
740        Ok(artifact)
741    }
742
743    async fn read(
744        &self,
745        id: &ToolOutputArtifactId,
746        offset: usize,
747        max_bytes: usize,
748    ) -> Result<ToolOutputArtifactSlice, ToolError> {
749        let artifact = self
750            .artifacts
751            .lock()
752            .unwrap_or_else(|e| e.into_inner())
753            .get(id)
754            .cloned()
755            .ok_or_else(|| {
756                ToolError::InvalidInput(format!("unknown tool result artifact: {id}"))
757            })?;
758        let body = artifact.body;
759        if offset > body.len() || !body.is_char_boundary(offset) {
760            return Err(ToolError::InvalidInput(format!(
761                "offset {offset} is not a UTF-8 boundary in tool result artifact {id}"
762            )));
763        }
764        let requested_end = offset.saturating_add(max_bytes).min(body.len());
765        let end = body.floor_char_boundary(requested_end);
766        Ok(ToolOutputArtifactSlice {
767            id: id.clone(),
768            offset,
769            next_offset: end,
770            original_bytes: artifact.original_bytes,
771            eof: end == body.len(),
772            content: body[offset..end].to_string(),
773        })
774    }
775}
776
777fn sanitize_artifact_id_component(s: &str) -> String {
778    let cleaned: String = s
779        .chars()
780        .map(|c| {
781            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
782                c
783            } else {
784                '_'
785            }
786        })
787        .take(64)
788        .collect();
789    if cleaned.is_empty() {
790        "_".to_string()
791    } else {
792        cleaned
793    }
794}
795
796/// Configurable truncation strategy with executor-level defaults, per-tool
797/// overrides, and optional tool-metadata defaults.
798pub struct ConfigurableToolOutputTruncationStrategy {
799    default_limit: Option<ToolOutputLimit>,
800    per_tool_limits: BTreeMap<ToolName, ToolOutputLimit>,
801    use_tool_metadata: bool,
802    store: Arc<dyn ToolOutputArtifactStore>,
803}
804
805impl ConfigurableToolOutputTruncationStrategy {
806    pub fn new(store: Arc<dyn ToolOutputArtifactStore>) -> Self {
807        Self {
808            default_limit: None,
809            per_tool_limits: BTreeMap::new(),
810            use_tool_metadata: true,
811            store,
812        }
813    }
814
815    pub fn with_default_limit(mut self, limit: ToolOutputLimit) -> Self {
816        self.default_limit = Some(limit);
817        self
818    }
819
820    pub fn with_tool_limit(
821        mut self,
822        tool_name: impl Into<ToolName>,
823        limit: ToolOutputLimit,
824    ) -> Self {
825        self.per_tool_limits.insert(tool_name.into(), limit);
826        self
827    }
828
829    pub fn use_tool_metadata(mut self, value: bool) -> Self {
830        self.use_tool_metadata = value;
831        self
832    }
833
834    fn limit_for(&self, ctx: &ToolOutputTruncationContext) -> Option<ToolOutputLimit> {
835        self.per_tool_limits
836            .get(&ctx.tool_name)
837            .cloned()
838            .or_else(|| {
839                self.use_tool_metadata
840                    .then(|| ToolOutputLimit::from_metadata(&ctx.tool_spec.metadata))
841                    .flatten()
842            })
843            .or_else(|| self.default_limit.clone())
844    }
845}
846
847#[async_trait]
848impl ToolOutputTruncationStrategy for ConfigurableToolOutputTruncationStrategy {
849    async fn apply(
850        &self,
851        ctx: ToolOutputTruncationContext,
852        output: ToolOutput,
853    ) -> Result<ToolOutput, ToolError> {
854        let Some(limit) = self.limit_for(&ctx) else {
855            return Ok(output);
856        };
857        let model_bytes = tool_output_model_bytes(&output);
858        if model_bytes <= limit.max_bytes {
859            return Ok(output);
860        }
861
862        match limit.action {
863            ToolOutputOverflowAction::Fail => Err(ToolError::ExecutionFailed(format!(
864                "tool {} produced {model_bytes} bytes, exceeding configured limit of {} bytes",
865                ctx.tool_name, limit.max_bytes
866            ))),
867            ToolOutputOverflowAction::InlineClip => Ok(clip_tool_output_inline(
868                output,
869                limit.max_bytes,
870                model_bytes,
871            )),
872            ToolOutputOverflowAction::StoreForReadback => {
873                let body = tool_output_readback_body(&output);
874                let artifact = self.store.put(&ctx, body, model_bytes).await?;
875                Ok(fit_structured_tool_output(
876                    json!({
877                        "truncated": true,
878                        "tool_result_id": artifact.id.0,
879                        "read_tool": TOOL_RESULT_READ_TOOL_NAME,
880                        "read_args": {
881                            "id": artifact.id.0,
882                            "offset": 0,
883                            "limit": limit.max_bytes
884                        },
885                        "original_bytes": artifact.original_bytes,
886                    }),
887                    limit.max_bytes,
888                ))
889            }
890        }
891    }
892}
893
894fn tool_output_model_bytes(output: &ToolOutput) -> usize {
895    match output {
896        ToolOutput::Text(s) => s.len(),
897        other => serde_json::to_string(other)
898            .map(|s| s.len())
899            .unwrap_or(usize::MAX),
900    }
901}
902
903fn tool_output_readback_body(output: &ToolOutput) -> String {
904    match output {
905        ToolOutput::Text(s) => s.clone(),
906        ToolOutput::Structured(value) => {
907            serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
908        }
909        ToolOutput::Parts(parts) => serde_json::to_string_pretty(parts).unwrap_or_default(),
910        ToolOutput::Files(files) => serde_json::to_string_pretty(files).unwrap_or_default(),
911    }
912}
913
914fn clip_tool_output_inline(
915    output: ToolOutput,
916    max_bytes: usize,
917    original_bytes: usize,
918) -> ToolOutput {
919    match output {
920        ToolOutput::Text(s) => {
921            ToolOutput::Text(clip_string_with_marker(&s, max_bytes, original_bytes))
922        }
923        other => {
924            let body = tool_output_readback_body(&other);
925            fit_structured_tool_output(
926                json!({
927                    "truncated": true,
928                    "original_bytes": original_bytes,
929                    "content": body,
930                }),
931                max_bytes,
932            )
933        }
934    }
935}
936
937fn clip_string_with_marker(s: &str, max_bytes: usize, original_bytes: usize) -> String {
938    let marker = format!("\n[tool output truncated: original_bytes={original_bytes}]");
939    if marker.len() >= max_bytes {
940        let cut = marker.floor_char_boundary(max_bytes.min(marker.len()));
941        return marker[..cut].to_string();
942    }
943    let keep_bytes = max_bytes.saturating_sub(marker.len());
944    let cut = s.floor_char_boundary(keep_bytes.min(s.len()));
945    format!("{}{}", &s[..cut], marker)
946}
947
948fn fit_structured_tool_output(mut value: Value, max_bytes: usize) -> ToolOutput {
949    loop {
950        let output = ToolOutput::Structured(value.clone());
951        if tool_output_model_bytes(&output) <= max_bytes {
952            return output;
953        }
954
955        let Some(Value::String(content)) = value.get_mut("content") else {
956            return ToolOutput::Structured(json!({
957                "truncated": true,
958                "error": "tool output metadata exceeded configured max_bytes"
959            }));
960        };
961        if content.is_empty() {
962            return ToolOutput::Structured(json!({
963                "truncated": true,
964                "error": "tool output metadata exceeded configured max_bytes"
965            }));
966        }
967
968        let current_len = content.len();
969        let shrink_by = tool_output_model_bytes(&output)
970            .saturating_sub(max_bytes)
971            .saturating_add(32)
972            .min(current_len);
973        let new_len = content.floor_char_boundary(current_len - shrink_by);
974        content.truncate(new_len);
975    }
976}
977
978pub const TOOL_RESULT_READ_TOOL_NAME: &str = "tool_result_read";
979const TOOL_RESULT_READ_OUTPUT_ENVELOPE_BYTES: usize = 4096;
980const TOOL_RESULT_READ_JSON_ESCAPE_BYTES_PER_INPUT_BYTE: usize = 6;
981
982/// Read back a bounded slice from an oversized tool result stored by
983/// [`ConfigurableToolOutputTruncationStrategy`].
984#[derive(Clone)]
985pub struct ToolResultReadTool {
986    spec: ToolSpec,
987    store: Arc<dyn ToolOutputArtifactStore>,
988    max_read_bytes: usize,
989}
990
991impl ToolResultReadTool {
992    pub fn new(store: Arc<dyn ToolOutputArtifactStore>, max_read_bytes: usize) -> Self {
993        Self {
994            spec: ToolSpec::new(
995                TOOL_RESULT_READ_TOOL_NAME,
996                "Read a bounded UTF-8 byte slice from a stored oversized tool result.",
997                json!({
998                    "type": "object",
999                    "properties": {
1000                        "id": { "type": "string" },
1001                        "offset": { "type": "integer", "minimum": 0 },
1002                        "limit": { "type": "integer", "minimum": 1 }
1003                    },
1004                    "required": ["id", "offset", "limit"],
1005                    "additionalProperties": false
1006                }),
1007            )
1008            .with_annotations(ToolAnnotations {
1009                read_only_hint: true,
1010                idempotent_hint: true,
1011                ..ToolAnnotations::default()
1012            })
1013            .with_output_limit(ToolOutputLimit::fail(
1014                max_read_bytes
1015                    .saturating_mul(TOOL_RESULT_READ_JSON_ESCAPE_BYTES_PER_INPUT_BYTE)
1016                    .saturating_add(TOOL_RESULT_READ_OUTPUT_ENVELOPE_BYTES),
1017            )),
1018            store,
1019            max_read_bytes,
1020        }
1021    }
1022}
1023
1024#[derive(Deserialize)]
1025struct ToolResultReadInput {
1026    id: String,
1027    offset: usize,
1028    limit: usize,
1029}
1030
1031#[async_trait]
1032impl Tool for ToolResultReadTool {
1033    fn spec(&self) -> &ToolSpec {
1034        &self.spec
1035    }
1036
1037    async fn invoke(
1038        &self,
1039        request: ToolRequest,
1040        _ctx: &mut ToolContext<'_>,
1041    ) -> Result<ToolResult, ToolError> {
1042        let input: ToolResultReadInput = serde_json::from_value(request.input.clone())
1043            .map_err(|error| ToolError::InvalidInput(format!("invalid tool input: {error}")))?;
1044        if input.limit == 0 {
1045            return Err(ToolError::InvalidInput(
1046                "limit must be greater than 0".to_string(),
1047            ));
1048        }
1049        if input.limit > self.max_read_bytes {
1050            return Err(ToolError::InvalidInput(format!(
1051                "limit {} exceeds maximum read size of {} bytes",
1052                input.limit, self.max_read_bytes
1053            )));
1054        }
1055        let slice = self
1056            .store
1057            .read(&ToolOutputArtifactId(input.id), input.offset, input.limit)
1058            .await?;
1059        Ok(ToolResult::new(ToolResultPart::success(
1060            request.call_id,
1061            ToolOutput::Structured(json!({
1062                "id": slice.id.0,
1063                "offset": slice.offset,
1064                "next_offset": slice.next_offset,
1065                "original_bytes": slice.original_bytes,
1066                "eof": slice.eof,
1067                "content": slice.content,
1068            })),
1069        )))
1070    }
1071}
1072
1073/// Convenience registry for safe tool-output readback.
1074pub fn tool_result_readback_registry(
1075    store: Arc<dyn ToolOutputArtifactStore>,
1076    max_read_bytes: usize,
1077) -> ToolRegistry {
1078    ToolRegistry::new().with(ToolResultReadTool::new(store, max_read_bytes))
1079}
1080
1081/// A description of an operation that requires permission before it can proceed.
1082///
1083/// Tool implementations return `PermissionRequest` objects from
1084/// [`Tool::proposed_requests`] so the executor can evaluate them against the
1085/// active [`PermissionChecker`] before invoking the tool.
1086///
1087/// Built-in implementations include [`ShellPermissionRequest`],
1088/// [`FileSystemPermissionRequest`], and [`McpPermissionRequest`].
1089///
1090/// # Implementing a custom request
1091///
1092/// ```rust
1093/// use std::any::Any;
1094/// use agentkit_core::MetadataMap;
1095/// use agentkit_tools_core::PermissionRequest;
1096///
1097/// struct NetworkPermissionRequest {
1098///     url: String,
1099///     metadata: MetadataMap,
1100/// }
1101///
1102/// impl PermissionRequest for NetworkPermissionRequest {
1103///     fn kind(&self) -> &'static str { "network.http" }
1104///     fn summary(&self) -> String { format!("HTTP request to {}", self.url) }
1105///     fn metadata(&self) -> &MetadataMap { &self.metadata }
1106///     fn as_any(&self) -> &dyn Any { self }
1107/// }
1108/// ```
1109pub trait PermissionRequest: Send + Sync {
1110    /// A dot-separated category string (e.g. `"filesystem.write"`, `"shell.command"`).
1111    fn kind(&self) -> &'static str;
1112    /// Human-readable one-line description of what is being requested.
1113    fn summary(&self) -> String;
1114    /// Arbitrary metadata attached to this request.
1115    fn metadata(&self) -> &MetadataMap;
1116    /// Returns `self` as [`Any`] so policies can downcast to the concrete type.
1117    fn as_any(&self) -> &dyn Any;
1118}
1119
1120/// Machine-readable code indicating why a permission was denied.
1121///
1122/// Returned inside a [`PermissionDenial`] so callers can programmatically
1123/// react to specific denial categories.
1124#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1125pub enum PermissionCode {
1126    /// A filesystem path is outside the allowed set.
1127    PathNotAllowed,
1128    /// A shell command or executable is not permitted.
1129    CommandNotAllowed,
1130    /// A network operation is not permitted.
1131    NetworkNotAllowed,
1132    /// An MCP server is not in the trusted set.
1133    ServerNotTrusted,
1134    /// An MCP auth scope is not in the allowed set.
1135    AuthScopeNotAllowed,
1136    /// A custom permission policy explicitly denied the request.
1137    CustomPolicyDenied,
1138    /// No policy recognised the request kind.
1139    UnknownRequest,
1140}
1141
1142/// Structured denial produced when a [`PermissionChecker`] rejects an operation.
1143///
1144/// Contains a machine-readable [`PermissionCode`] and a human-readable
1145/// message suitable for logging or displaying to the user.
1146#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1147pub struct PermissionDenial {
1148    /// Machine-readable denial category.
1149    pub code: PermissionCode,
1150    /// Human-readable explanation of why the operation was denied.
1151    pub message: String,
1152    /// Arbitrary metadata carried from the original request.
1153    pub metadata: MetadataMap,
1154}
1155
1156/// Why a permission policy is requesting human approval before proceeding.
1157///
1158/// Used inside [`ApprovalRequest`] so the UI layer can display context-appropriate
1159/// prompts to the user.
1160#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1161pub enum ApprovalReason {
1162    /// The active policy always requires confirmation for this kind of operation.
1163    PolicyRequiresConfirmation,
1164    /// The operation was flagged as higher risk than usual.
1165    EscalatedRisk,
1166    /// The target (server, path, etc.) was not recognised by any policy.
1167    UnknownTarget,
1168    /// The operation targets a filesystem path that is not in the allowed set.
1169    SensitivePath,
1170    /// The shell command is not in the pre-approved allow-list.
1171    SensitiveCommand,
1172    /// The MCP server is not in the trusted set.
1173    SensitiveServer,
1174    /// The MCP auth scope is not in the pre-approved set.
1175    SensitiveAuthScope,
1176}
1177
1178/// A request sent to the host when a tool execution needs human approval.
1179///
1180/// The agent loop surfaces this to the user. Once the user responds, the
1181/// loop can re-submit the tool call via [`ToolExecutor::execute_approved`].
1182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1183pub struct ApprovalRequest {
1184    /// Runtime task identifier associated with this approval request, if any.
1185    pub task_id: Option<TaskId>,
1186    /// The originating tool call id when this approval was raised from a
1187    /// tool invocation. Hosts can use this to resolve specific approvals.
1188    pub call_id: Option<ToolCallId>,
1189    /// Stable identifier so the executor can match the approval to its request.
1190    pub id: ApprovalId,
1191    /// The [`PermissionRequest::kind`] string that triggered the approval flow.
1192    pub request_kind: String,
1193    /// Why approval is needed.
1194    pub reason: ApprovalReason,
1195    /// Human-readable summary shown to the user.
1196    pub summary: String,
1197    /// Arbitrary metadata carried from the original permission request.
1198    pub metadata: MetadataMap,
1199}
1200
1201impl ApprovalRequest {
1202    /// Builds an approval request with no task or call id.
1203    pub fn new(
1204        id: impl Into<ApprovalId>,
1205        request_kind: impl Into<String>,
1206        reason: ApprovalReason,
1207        summary: impl Into<String>,
1208    ) -> Self {
1209        Self {
1210            task_id: None,
1211            call_id: None,
1212            id: id.into(),
1213            request_kind: request_kind.into(),
1214            reason,
1215            summary: summary.into(),
1216            metadata: MetadataMap::new(),
1217        }
1218    }
1219
1220    /// Sets the associated task id.
1221    pub fn with_task_id(mut self, task_id: impl Into<TaskId>) -> Self {
1222        self.task_id = Some(task_id.into());
1223        self
1224    }
1225
1226    /// Sets the associated tool call id.
1227    pub fn with_call_id(mut self, call_id: impl Into<ToolCallId>) -> Self {
1228        self.call_id = Some(call_id.into());
1229        self
1230    }
1231
1232    /// Replaces the approval metadata.
1233    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
1234        self.metadata = metadata;
1235        self
1236    }
1237}
1238
1239/// The user's response to an [`ApprovalRequest`].
1240#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1241pub enum ApprovalDecision {
1242    /// The user approved the operation.
1243    Approve,
1244    /// The user denied the operation, optionally with a reason.
1245    Deny {
1246        /// Optional human-readable explanation for the denial.
1247        reason: Option<String>,
1248    },
1249}
1250
1251/// A tool execution was paused because it needs external input.
1252///
1253/// The agent loop should handle the interruption (show a prompt, etc.) and
1254/// then re-submit the tool call. Source-specific interruptions (e.g. MCP
1255/// auth challenges) do not surface here — they are resolved by responders
1256/// registered with the source.
1257#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1258pub enum ToolInterruption {
1259    /// The operation requires human approval before it can proceed.
1260    ApprovalRequired(ApprovalRequest),
1261}
1262
1263/// The verdict from a [`PermissionChecker`] for a single [`PermissionRequest`].
1264#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1265pub enum PermissionDecision {
1266    /// The operation is allowed to proceed.
1267    Allow,
1268    /// The operation is denied.
1269    Deny(PermissionDenial),
1270    /// The operation may proceed only after the user approves.
1271    RequireApproval(ApprovalRequest),
1272}
1273
1274/// Evaluates a [`PermissionRequest`] and returns a final [`PermissionDecision`].
1275///
1276/// The [`BasicToolExecutor`] calls `evaluate` for every permission request
1277/// returned by [`Tool::proposed_requests`] before invoking the tool. If any
1278/// request is denied, execution is aborted; if any request requires approval,
1279/// the executor returns a [`ToolInterruption`].
1280///
1281/// For composing multiple policies, see [`CompositePermissionChecker`].
1282///
1283/// # Example
1284///
1285/// ```rust
1286/// use agentkit_tools_core::{PermissionChecker, PermissionDecision, PermissionRequest};
1287///
1288/// /// A checker that allows every operation unconditionally.
1289/// struct AllowAll;
1290///
1291/// impl PermissionChecker for AllowAll {
1292///     fn evaluate(&self, _request: &dyn PermissionRequest) -> PermissionDecision {
1293///         PermissionDecision::Allow
1294///     }
1295/// }
1296/// ```
1297pub trait PermissionChecker: Send + Sync {
1298    /// Evaluate a single permission request and return the decision.
1299    fn evaluate(&self, request: &dyn PermissionRequest) -> PermissionDecision;
1300}
1301
1302/// A [`PermissionChecker`] that unconditionally allows every operation.
1303///
1304/// Useful in tests, examples, and embedding scenarios where the host has
1305/// already gated tool access elsewhere.
1306#[derive(Copy, Clone, Debug, Default)]
1307pub struct AllowAllPermissions;
1308
1309impl PermissionChecker for AllowAllPermissions {
1310    fn evaluate(&self, _request: &dyn PermissionRequest) -> PermissionDecision {
1311        PermissionDecision::Allow
1312    }
1313}
1314
1315/// The result of a single [`PermissionPolicy`] evaluation.
1316///
1317/// Unlike [`PermissionDecision`], a policy can return [`PolicyMatch::NoOpinion`]
1318/// to indicate it has nothing to say about this request kind, letting other
1319/// policies in the [`CompositePermissionChecker`] chain decide.
1320#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1321pub enum PolicyMatch {
1322    /// This policy does not apply to the given request kind.
1323    NoOpinion,
1324    /// This policy explicitly allows the operation.
1325    Allow,
1326    /// This policy explicitly denies the operation.
1327    Deny(PermissionDenial),
1328    /// This policy requires user approval before the operation can proceed.
1329    RequireApproval(ApprovalRequest),
1330}
1331
1332/// A single, focused permission rule that contributes to a composite decision.
1333///
1334/// Policies are combined inside a [`CompositePermissionChecker`]. Each policy
1335/// inspects the request and either returns a definitive answer or
1336/// [`PolicyMatch::NoOpinion`] to defer.
1337///
1338/// Built-in policies: [`PathPolicy`], [`CommandPolicy`], [`McpServerPolicy`],
1339/// [`CustomKindPolicy`].
1340pub trait PermissionPolicy: Send + Sync {
1341    /// Evaluate the request and return a match or [`PolicyMatch::NoOpinion`].
1342    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch;
1343}
1344
1345/// Chains multiple [`PermissionPolicy`] implementations into a single [`PermissionChecker`].
1346///
1347/// Policies are evaluated in registration order. The first `Deny` short-circuits
1348/// immediately. If any policy returns `RequireApproval`, that is used unless a
1349/// later policy denies. If at least one policy returns `Allow` and none deny or
1350/// require approval, the result is `Allow`. Otherwise the `fallback` decision
1351/// is returned.
1352///
1353/// # Example
1354///
1355/// ```rust
1356/// use agentkit_tools_core::{
1357///     CommandPolicy, CompositePermissionChecker, PathPolicy, PermissionDecision,
1358/// };
1359///
1360/// let checker = CompositePermissionChecker::new(PermissionDecision::Allow)
1361///     .with_policy(PathPolicy::new().allow_root("/workspace"))
1362///     .with_policy(CommandPolicy::new().allow_executable("git"));
1363/// ```
1364pub struct CompositePermissionChecker {
1365    policies: Vec<Box<dyn PermissionPolicy>>,
1366    fallback: PermissionDecision,
1367}
1368
1369impl CompositePermissionChecker {
1370    /// Creates a new composite checker with the given fallback decision.
1371    ///
1372    /// The fallback is used when no policy has an opinion about a request.
1373    ///
1374    /// # Arguments
1375    ///
1376    /// * `fallback` - Decision returned when every policy returns [`PolicyMatch::NoOpinion`].
1377    pub fn new(fallback: PermissionDecision) -> Self {
1378        Self {
1379            policies: Vec::new(),
1380            fallback,
1381        }
1382    }
1383
1384    /// Appends a policy to the evaluation chain and returns `self` for chaining.
1385    pub fn with_policy(mut self, policy: impl PermissionPolicy + 'static) -> Self {
1386        self.policies.push(Box::new(policy));
1387        self
1388    }
1389}
1390
1391impl PermissionChecker for CompositePermissionChecker {
1392    fn evaluate(&self, request: &dyn PermissionRequest) -> PermissionDecision {
1393        let mut saw_allow = false;
1394        let mut approval = None;
1395
1396        for policy in &self.policies {
1397            match policy.evaluate(request) {
1398                PolicyMatch::NoOpinion => {}
1399                PolicyMatch::Allow => saw_allow = true,
1400                PolicyMatch::Deny(denial) => return PermissionDecision::Deny(denial),
1401                PolicyMatch::RequireApproval(req) => approval = Some(req),
1402            }
1403        }
1404
1405        if let Some(req) = approval {
1406            PermissionDecision::RequireApproval(req)
1407        } else if saw_allow {
1408            PermissionDecision::Allow
1409        } else {
1410            self.fallback.clone()
1411        }
1412    }
1413}
1414
1415/// Permission request for executing a shell command.
1416///
1417/// Evaluated by [`CommandPolicy`] to decide whether the executable, arguments,
1418/// working directory, and environment variables are acceptable.
1419#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1420pub struct ShellPermissionRequest {
1421    /// The executable name or path (e.g. `"git"`, `"/usr/bin/curl"`).
1422    pub executable: String,
1423    /// Command-line arguments passed to the executable.
1424    pub argv: Vec<String>,
1425    /// Working directory for the command, if specified.
1426    pub cwd: Option<PathBuf>,
1427    /// Names of environment variables the command will receive.
1428    pub env_keys: Vec<String>,
1429    /// Arbitrary metadata for policy extensions.
1430    pub metadata: MetadataMap,
1431}
1432
1433impl PermissionRequest for ShellPermissionRequest {
1434    fn kind(&self) -> &'static str {
1435        "shell.command"
1436    }
1437
1438    fn summary(&self) -> String {
1439        if self.argv.is_empty() {
1440            self.executable.clone()
1441        } else {
1442            format!("{} {}", self.executable, self.argv.join(" "))
1443        }
1444    }
1445
1446    fn metadata(&self) -> &MetadataMap {
1447        &self.metadata
1448    }
1449
1450    fn as_any(&self) -> &dyn Any {
1451        self
1452    }
1453}
1454
1455/// Permission request for a filesystem operation.
1456///
1457/// Evaluated by [`PathPolicy`] to decide whether the target path(s) fall
1458/// within allowed or protected directory roots.
1459#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1460pub enum FileSystemPermissionRequest {
1461    /// Read a file's contents.
1462    Read {
1463        path: PathBuf,
1464        metadata: MetadataMap,
1465    },
1466    /// Write (create or overwrite) a file.
1467    Write {
1468        path: PathBuf,
1469        metadata: MetadataMap,
1470    },
1471    /// Edit (modify in place) an existing file.
1472    Edit {
1473        path: PathBuf,
1474        metadata: MetadataMap,
1475    },
1476    /// Delete a file or directory.
1477    Delete {
1478        path: PathBuf,
1479        metadata: MetadataMap,
1480    },
1481    /// Move or rename a file.
1482    Move {
1483        from: PathBuf,
1484        to: PathBuf,
1485        metadata: MetadataMap,
1486    },
1487    /// List directory contents.
1488    List {
1489        path: PathBuf,
1490        metadata: MetadataMap,
1491    },
1492    /// Create a directory (including parents).
1493    CreateDir {
1494        path: PathBuf,
1495        metadata: MetadataMap,
1496    },
1497}
1498
1499impl FileSystemPermissionRequest {
1500    fn metadata_map(&self) -> &MetadataMap {
1501        match self {
1502            Self::Read { metadata, .. }
1503            | Self::Write { metadata, .. }
1504            | Self::Edit { metadata, .. }
1505            | Self::Delete { metadata, .. }
1506            | Self::Move { metadata, .. }
1507            | Self::List { metadata, .. }
1508            | Self::CreateDir { metadata, .. } => metadata,
1509        }
1510    }
1511}
1512
1513impl PermissionRequest for FileSystemPermissionRequest {
1514    fn kind(&self) -> &'static str {
1515        match self {
1516            Self::Read { .. } => "filesystem.read",
1517            Self::Write { .. } => "filesystem.write",
1518            Self::Edit { .. } => "filesystem.edit",
1519            Self::Delete { .. } => "filesystem.delete",
1520            Self::Move { .. } => "filesystem.move",
1521            Self::List { .. } => "filesystem.list",
1522            Self::CreateDir { .. } => "filesystem.mkdir",
1523        }
1524    }
1525
1526    fn summary(&self) -> String {
1527        match self {
1528            Self::Read { path, .. } => format!("Read {}", path.display()),
1529            Self::Write { path, .. } => format!("Write {}", path.display()),
1530            Self::Edit { path, .. } => format!("Edit {}", path.display()),
1531            Self::Delete { path, .. } => format!("Delete {}", path.display()),
1532            Self::Move { from, to, .. } => {
1533                format!("Move {} to {}", from.display(), to.display())
1534            }
1535            Self::List { path, .. } => format!("List {}", path.display()),
1536            Self::CreateDir { path, .. } => format!("Create directory {}", path.display()),
1537        }
1538    }
1539
1540    fn metadata(&self) -> &MetadataMap {
1541        self.metadata_map()
1542    }
1543
1544    fn as_any(&self) -> &dyn Any {
1545        self
1546    }
1547}
1548
1549/// Permission request for an MCP (Model Context Protocol) operation.
1550///
1551/// Evaluated by [`McpServerPolicy`] to decide whether the target server is
1552/// trusted and the requested auth scopes are allowed.
1553#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1554pub enum McpPermissionRequest {
1555    /// Connect to an MCP server.
1556    Connect {
1557        server_id: String,
1558        metadata: MetadataMap,
1559    },
1560    /// Invoke a tool exposed by an MCP server.
1561    InvokeTool {
1562        server_id: String,
1563        tool_name: String,
1564        metadata: MetadataMap,
1565    },
1566    /// Read a resource from an MCP server.
1567    ReadResource {
1568        server_id: String,
1569        resource_id: String,
1570        metadata: MetadataMap,
1571    },
1572    /// Fetch a prompt template from an MCP server.
1573    FetchPrompt {
1574        server_id: String,
1575        prompt_id: String,
1576        metadata: MetadataMap,
1577    },
1578    /// Request an auth scope on an MCP server.
1579    UseAuthScope {
1580        server_id: String,
1581        scope: String,
1582        metadata: MetadataMap,
1583    },
1584}
1585
1586impl McpPermissionRequest {
1587    fn metadata_map(&self) -> &MetadataMap {
1588        match self {
1589            Self::Connect { metadata, .. }
1590            | Self::InvokeTool { metadata, .. }
1591            | Self::ReadResource { metadata, .. }
1592            | Self::FetchPrompt { metadata, .. }
1593            | Self::UseAuthScope { metadata, .. } => metadata,
1594        }
1595    }
1596}
1597
1598impl PermissionRequest for McpPermissionRequest {
1599    fn kind(&self) -> &'static str {
1600        match self {
1601            Self::Connect { .. } => "mcp.connect",
1602            Self::InvokeTool { .. } => "mcp.invoke_tool",
1603            Self::ReadResource { .. } => "mcp.read_resource",
1604            Self::FetchPrompt { .. } => "mcp.fetch_prompt",
1605            Self::UseAuthScope { .. } => "mcp.use_auth_scope",
1606        }
1607    }
1608
1609    fn summary(&self) -> String {
1610        match self {
1611            Self::Connect { server_id, .. } => format!("Connect MCP server {server_id}"),
1612            Self::InvokeTool {
1613                server_id,
1614                tool_name,
1615                ..
1616            } => format!("Invoke MCP tool {server_id}.{tool_name}"),
1617            Self::ReadResource {
1618                server_id,
1619                resource_id,
1620                ..
1621            } => format!("Read MCP resource {server_id}:{resource_id}"),
1622            Self::FetchPrompt {
1623                server_id,
1624                prompt_id,
1625                ..
1626            } => format!("Fetch MCP prompt {server_id}:{prompt_id}"),
1627            Self::UseAuthScope {
1628                server_id, scope, ..
1629            } => format!("Use MCP auth scope {server_id}:{scope}"),
1630        }
1631    }
1632
1633    fn metadata(&self) -> &MetadataMap {
1634        self.metadata_map()
1635    }
1636
1637    fn as_any(&self) -> &dyn Any {
1638        self
1639    }
1640}
1641
1642/// A [`PermissionPolicy`] that matches requests whose [`PermissionRequest::kind`]
1643/// starts with `"custom."` and allows or denies them by name.
1644///
1645/// Use this to govern application-defined permission categories without
1646/// writing a full policy implementation.
1647///
1648/// # Example
1649///
1650/// ```rust
1651/// use agentkit_tools_core::CustomKindPolicy;
1652///
1653/// let policy = CustomKindPolicy::new(true)
1654///     .allow_kind("custom.analytics")
1655///     .deny_kind("custom.billing");
1656/// ```
1657pub struct CustomKindPolicy {
1658    allowed_kinds: BTreeSet<String>,
1659    denied_kinds: BTreeSet<String>,
1660    require_approval_by_default: bool,
1661}
1662
1663impl CustomKindPolicy {
1664    /// Creates a new policy.
1665    ///
1666    /// # Arguments
1667    ///
1668    /// * `require_approval_by_default` - When `true`, unrecognised `custom.*`
1669    ///   kinds require approval instead of returning [`PolicyMatch::NoOpinion`].
1670    pub fn new(require_approval_by_default: bool) -> Self {
1671        Self {
1672            allowed_kinds: BTreeSet::new(),
1673            denied_kinds: BTreeSet::new(),
1674            require_approval_by_default,
1675        }
1676    }
1677
1678    /// Adds a kind string to the allow-list.
1679    pub fn allow_kind(mut self, kind: impl Into<String>) -> Self {
1680        self.allowed_kinds.insert(kind.into());
1681        self
1682    }
1683
1684    /// Adds a kind string to the deny-list.
1685    pub fn deny_kind(mut self, kind: impl Into<String>) -> Self {
1686        self.denied_kinds.insert(kind.into());
1687        self
1688    }
1689}
1690
1691impl PermissionPolicy for CustomKindPolicy {
1692    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
1693        let kind = request.kind();
1694        if !kind.starts_with("custom.") {
1695            return PolicyMatch::NoOpinion;
1696        }
1697        if self.denied_kinds.contains(kind) {
1698            return PolicyMatch::Deny(PermissionDenial {
1699                code: PermissionCode::CustomPolicyDenied,
1700                message: format!("custom permission kind {kind} is denied"),
1701                metadata: request.metadata().clone(),
1702            });
1703        }
1704        if self.allowed_kinds.contains(kind) {
1705            return PolicyMatch::Allow;
1706        }
1707        if self.require_approval_by_default {
1708            PolicyMatch::RequireApproval(ApprovalRequest {
1709                task_id: None,
1710                call_id: None,
1711                id: ApprovalId::new(format!("approval:{kind}")),
1712                request_kind: kind.to_string(),
1713                reason: ApprovalReason::PolicyRequiresConfirmation,
1714                summary: request.summary(),
1715                metadata: request.metadata().clone(),
1716            })
1717        } else {
1718            PolicyMatch::NoOpinion
1719        }
1720    }
1721}
1722
1723/// A [`PermissionPolicy`] that governs [`FileSystemPermissionRequest`]s by
1724/// checking whether target paths fall within allowed or protected directory trees.
1725///
1726/// Protected roots take priority: any path under a protected root is denied
1727/// immediately. Paths under an allowed root are permitted. Paths outside both
1728/// sets either require approval or are denied, depending on
1729/// `require_approval_outside_allowed`.
1730///
1731/// # Example
1732///
1733/// ```rust
1734/// use agentkit_tools_core::PathPolicy;
1735///
1736/// let policy = PathPolicy::new()
1737///     .allow_root("/workspace/project")
1738///     .read_only_root("/workspace/project/vendor")
1739///     .protect_root("/workspace/project/.env")
1740///     .require_approval_outside_allowed(true);
1741/// ```
1742pub struct PathPolicy {
1743    allowed_roots: Vec<CanonicalRoot>,
1744    read_only_roots: Vec<CanonicalRoot>,
1745    protected_roots: Vec<CanonicalRoot>,
1746    require_approval_outside_allowed: bool,
1747}
1748
1749impl PathPolicy {
1750    /// Creates a new path policy with no roots and approval required for
1751    /// paths outside allowed roots.
1752    pub fn new() -> Self {
1753        Self {
1754            allowed_roots: Vec::new(),
1755            read_only_roots: Vec::new(),
1756            protected_roots: Vec::new(),
1757            require_approval_outside_allowed: true,
1758        }
1759    }
1760
1761    /// Adds a directory tree that filesystem operations are allowed to target.
1762    pub fn allow_root(mut self, root: impl Into<PathBuf>) -> Self {
1763        self.allowed_roots.push(CanonicalRoot::new(root.into()));
1764        self
1765    }
1766
1767    /// Adds a directory tree that may be read or listed but not mutated.
1768    pub fn read_only_root(mut self, root: impl Into<PathBuf>) -> Self {
1769        self.read_only_roots.push(CanonicalRoot::new(root.into()));
1770        self
1771    }
1772
1773    /// Adds a directory tree that filesystem operations are never allowed to target.
1774    pub fn protect_root(mut self, root: impl Into<PathBuf>) -> Self {
1775        self.protected_roots.push(CanonicalRoot::new(root.into()));
1776        self
1777    }
1778
1779    /// When `true` (the default), paths outside allowed roots trigger an
1780    /// approval request instead of an outright denial.
1781    pub fn require_approval_outside_allowed(mut self, value: bool) -> Self {
1782        self.require_approval_outside_allowed = value;
1783        self
1784    }
1785}
1786
1787impl Default for PathPolicy {
1788    fn default() -> Self {
1789        Self::new()
1790    }
1791}
1792
1793/// Resolves `path` for symlink-safe containment checks; falls back to the
1794/// lexically-absolute path so policy decisions stay deterministic when no
1795/// component on disk yet exists.
1796fn resolve_canonical(path: &Path) -> PathBuf {
1797    let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
1798    canonicalize_with_partial_fallback(&abs).unwrap_or(abs)
1799}
1800
1801fn canonicalize_with_partial_fallback(abs: &Path) -> Option<PathBuf> {
1802    if let Ok(canonical) = std::fs::canonicalize(abs) {
1803        return Some(canonical);
1804    }
1805    let mut tail: Vec<std::ffi::OsString> = Vec::new();
1806    let mut current = abs.to_path_buf();
1807    loop {
1808        let name = current.file_name().map(|n| n.to_os_string())?;
1809        tail.push(name);
1810        if !current.pop() {
1811            return None;
1812        }
1813        if let Ok(canonical) = std::fs::canonicalize(&current) {
1814            let mut out = canonical;
1815            for seg in tail.iter().rev() {
1816                out.push(seg);
1817            }
1818            return Some(out);
1819        }
1820    }
1821}
1822
1823/// A configured root with a lazily-cached canonical form.
1824///
1825/// Roots can be registered before they exist on disk; we only memoise once
1826/// `fs::canonicalize` succeeds, so symlink changes to not-yet-existent
1827/// components are still picked up on later evaluations.
1828struct CanonicalRoot {
1829    lexical: PathBuf,
1830    canonical: OnceLock<PathBuf>,
1831}
1832
1833impl CanonicalRoot {
1834    fn new(lexical: PathBuf) -> Self {
1835        Self {
1836            lexical,
1837            canonical: OnceLock::new(),
1838        }
1839    }
1840
1841    fn resolve(&self) -> std::borrow::Cow<'_, Path> {
1842        if let Some(canonical) = self.canonical.get() {
1843            return std::borrow::Cow::Borrowed(canonical);
1844        }
1845        let abs = std::path::absolute(&self.lexical).unwrap_or_else(|_| self.lexical.clone());
1846        if let Ok(canonical) = std::fs::canonicalize(&abs) {
1847            let _ = self.canonical.set(canonical);
1848            return std::borrow::Cow::Borrowed(self.canonical.get().unwrap());
1849        }
1850        std::borrow::Cow::Owned(canonicalize_with_partial_fallback(&abs).unwrap_or(abs))
1851    }
1852}
1853
1854impl PermissionPolicy for PathPolicy {
1855    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
1856        let Some(fs) = request
1857            .as_any()
1858            .downcast_ref::<FileSystemPermissionRequest>()
1859        else {
1860            return PolicyMatch::NoOpinion;
1861        };
1862
1863        let raw_paths: Vec<&Path> = match fs {
1864            FileSystemPermissionRequest::Move { from, to, .. } => {
1865                vec![from.as_path(), to.as_path()]
1866            }
1867            FileSystemPermissionRequest::Read { path, .. }
1868            | FileSystemPermissionRequest::Write { path, .. }
1869            | FileSystemPermissionRequest::Edit { path, .. }
1870            | FileSystemPermissionRequest::Delete { path, .. }
1871            | FileSystemPermissionRequest::List { path, .. }
1872            | FileSystemPermissionRequest::CreateDir { path, .. } => vec![path.as_path()],
1873        };
1874
1875        let candidate_paths: Vec<PathBuf> =
1876            raw_paths.iter().map(|p| resolve_canonical(p)).collect();
1877
1878        let mutates = matches!(
1879            fs,
1880            FileSystemPermissionRequest::Write { .. }
1881                | FileSystemPermissionRequest::Edit { .. }
1882                | FileSystemPermissionRequest::Delete { .. }
1883                | FileSystemPermissionRequest::Move { .. }
1884                | FileSystemPermissionRequest::CreateDir { .. }
1885        );
1886
1887        if candidate_paths.iter().any(|path| {
1888            self.protected_roots
1889                .iter()
1890                .any(|root| path.starts_with(root.resolve().as_ref()))
1891        }) {
1892            return PolicyMatch::Deny(PermissionDenial {
1893                code: PermissionCode::PathNotAllowed,
1894                message: format!("path access denied for {}", fs.summary()),
1895                metadata: fs.metadata().clone(),
1896            });
1897        }
1898
1899        if mutates
1900            && candidate_paths.iter().any(|path| {
1901                self.read_only_roots
1902                    .iter()
1903                    .any(|root| path.starts_with(root.resolve().as_ref()))
1904            })
1905        {
1906            return PolicyMatch::Deny(PermissionDenial {
1907                code: PermissionCode::PathNotAllowed,
1908                message: format!("path is read-only for {}", fs.summary()),
1909                metadata: fs.metadata().clone(),
1910            });
1911        }
1912
1913        if self.allowed_roots.is_empty() {
1914            return PolicyMatch::NoOpinion;
1915        }
1916
1917        let all_allowed = candidate_paths.iter().all(|path| {
1918            self.allowed_roots
1919                .iter()
1920                .any(|root| path.starts_with(root.resolve().as_ref()))
1921        });
1922
1923        if all_allowed {
1924            PolicyMatch::Allow
1925        } else if self.require_approval_outside_allowed {
1926            PolicyMatch::RequireApproval(ApprovalRequest {
1927                task_id: None,
1928                call_id: None,
1929                id: ApprovalId::new(format!("approval:{}", fs.kind())),
1930                request_kind: fs.kind().to_string(),
1931                reason: ApprovalReason::SensitivePath,
1932                summary: fs.summary(),
1933                metadata: fs.metadata().clone(),
1934            })
1935        } else {
1936            PolicyMatch::Deny(PermissionDenial {
1937                code: PermissionCode::PathNotAllowed,
1938                message: format!("path outside allowed roots for {}", fs.summary()),
1939                metadata: fs.metadata().clone(),
1940            })
1941        }
1942    }
1943}
1944
1945/// A [`PermissionPolicy`] that governs [`ShellPermissionRequest`]s by checking
1946/// the executable name, working directory, and environment variables.
1947///
1948/// Denied executables and env keys are rejected immediately. Allowed
1949/// executables pass. Unknown executables either require approval or are
1950/// denied, depending on `require_approval_for_unknown`.
1951///
1952/// # Example
1953///
1954/// ```rust
1955/// use agentkit_tools_core::CommandPolicy;
1956///
1957/// let policy = CommandPolicy::new()
1958///     .allow_executable("git")
1959///     .allow_executable("cargo")
1960///     .deny_executable("rm")
1961///     .deny_env_key("AWS_SECRET_ACCESS_KEY")
1962///     .allow_cwd("/workspace")
1963///     .require_approval_for_unknown(true);
1964/// ```
1965pub struct CommandPolicy {
1966    allowed_executables: BTreeSet<String>,
1967    denied_executables: BTreeSet<String>,
1968    allowed_cwds: Vec<PathBuf>,
1969    denied_env_keys: BTreeSet<String>,
1970    require_approval_for_unknown: bool,
1971}
1972
1973impl CommandPolicy {
1974    /// Creates a new command policy with no rules and approval required
1975    /// for unknown executables.
1976    pub fn new() -> Self {
1977        Self {
1978            allowed_executables: BTreeSet::new(),
1979            denied_executables: BTreeSet::new(),
1980            allowed_cwds: Vec::new(),
1981            denied_env_keys: BTreeSet::new(),
1982            require_approval_for_unknown: true,
1983        }
1984    }
1985
1986    /// Adds an executable name to the allow-list.
1987    pub fn allow_executable(mut self, executable: impl Into<String>) -> Self {
1988        self.allowed_executables.insert(executable.into());
1989        self
1990    }
1991
1992    /// Adds an executable name to the deny-list.
1993    pub fn deny_executable(mut self, executable: impl Into<String>) -> Self {
1994        self.denied_executables.insert(executable.into());
1995        self
1996    }
1997
1998    /// Adds a directory root that commands are allowed to run in.
1999    pub fn allow_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
2000        self.allowed_cwds.push(cwd.into());
2001        self
2002    }
2003
2004    /// Adds an environment variable name to the deny-list.
2005    pub fn deny_env_key(mut self, key: impl Into<String>) -> Self {
2006        self.denied_env_keys.insert(key.into());
2007        self
2008    }
2009
2010    /// When `true` (the default), executables not in the allow-list trigger
2011    /// an approval request instead of an outright denial.
2012    pub fn require_approval_for_unknown(mut self, value: bool) -> Self {
2013        self.require_approval_for_unknown = value;
2014        self
2015    }
2016}
2017
2018impl Default for CommandPolicy {
2019    fn default() -> Self {
2020        Self::new()
2021    }
2022}
2023
2024impl PermissionPolicy for CommandPolicy {
2025    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
2026        let Some(shell) = request.as_any().downcast_ref::<ShellPermissionRequest>() else {
2027            return PolicyMatch::NoOpinion;
2028        };
2029
2030        if self.denied_executables.contains(&shell.executable)
2031            || shell
2032                .env_keys
2033                .iter()
2034                .any(|key| self.denied_env_keys.contains(key))
2035        {
2036            return PolicyMatch::Deny(PermissionDenial {
2037                code: PermissionCode::CommandNotAllowed,
2038                message: format!("command denied for {}", shell.summary()),
2039                metadata: shell.metadata().clone(),
2040            });
2041        }
2042
2043        if let Some(cwd) = &shell.cwd
2044            && !self.allowed_cwds.is_empty()
2045            && !self.allowed_cwds.iter().any(|root| cwd.starts_with(root))
2046        {
2047            return PolicyMatch::RequireApproval(ApprovalRequest {
2048                task_id: None,
2049                call_id: None,
2050                id: ApprovalId::new("approval:shell.cwd"),
2051                request_kind: shell.kind().to_string(),
2052                reason: ApprovalReason::SensitiveCommand,
2053                summary: shell.summary(),
2054                metadata: shell.metadata().clone(),
2055            });
2056        }
2057
2058        if self.allowed_executables.is_empty()
2059            || self.allowed_executables.contains(&shell.executable)
2060        {
2061            PolicyMatch::Allow
2062        } else if self.require_approval_for_unknown {
2063            PolicyMatch::RequireApproval(ApprovalRequest {
2064                task_id: None,
2065                call_id: None,
2066                id: ApprovalId::new("approval:shell.command"),
2067                request_kind: shell.kind().to_string(),
2068                reason: ApprovalReason::SensitiveCommand,
2069                summary: shell.summary(),
2070                metadata: shell.metadata().clone(),
2071            })
2072        } else {
2073            PolicyMatch::Deny(PermissionDenial {
2074                code: PermissionCode::CommandNotAllowed,
2075                message: format!("executable {} is not allowed", shell.executable),
2076                metadata: shell.metadata().clone(),
2077            })
2078        }
2079    }
2080}
2081
2082/// A [`PermissionPolicy`] that governs [`McpPermissionRequest`]s by checking
2083/// whether the target server is trusted and the requested auth scopes are
2084/// in the allow-list.
2085///
2086/// # Example
2087///
2088/// ```rust
2089/// use agentkit_tools_core::McpServerPolicy;
2090///
2091/// let policy = McpServerPolicy::new()
2092///     .trust_server("github-mcp")
2093///     .allow_auth_scope("repo:read");
2094/// ```
2095pub struct McpServerPolicy {
2096    trusted_servers: BTreeSet<String>,
2097    allowed_auth_scopes: BTreeSet<String>,
2098    require_approval_for_untrusted: bool,
2099}
2100
2101impl McpServerPolicy {
2102    /// Creates a new MCP server policy with approval required for untrusted
2103    /// servers.
2104    pub fn new() -> Self {
2105        Self {
2106            trusted_servers: BTreeSet::new(),
2107            allowed_auth_scopes: BTreeSet::new(),
2108            require_approval_for_untrusted: true,
2109        }
2110    }
2111
2112    /// Marks a server as trusted so operations targeting it are allowed.
2113    pub fn trust_server(mut self, server_id: impl Into<String>) -> Self {
2114        self.trusted_servers.insert(server_id.into());
2115        self
2116    }
2117
2118    /// Adds an auth scope to the allow-list.
2119    pub fn allow_auth_scope(mut self, scope: impl Into<String>) -> Self {
2120        self.allowed_auth_scopes.insert(scope.into());
2121        self
2122    }
2123}
2124
2125impl Default for McpServerPolicy {
2126    fn default() -> Self {
2127        Self::new()
2128    }
2129}
2130
2131impl PermissionPolicy for McpServerPolicy {
2132    fn evaluate(&self, request: &dyn PermissionRequest) -> PolicyMatch {
2133        let Some(mcp) = request.as_any().downcast_ref::<McpPermissionRequest>() else {
2134            return PolicyMatch::NoOpinion;
2135        };
2136
2137        let server_id = match mcp {
2138            McpPermissionRequest::Connect { server_id, .. }
2139            | McpPermissionRequest::InvokeTool { server_id, .. }
2140            | McpPermissionRequest::ReadResource { server_id, .. }
2141            | McpPermissionRequest::FetchPrompt { server_id, .. }
2142            | McpPermissionRequest::UseAuthScope { server_id, .. } => server_id,
2143        };
2144
2145        if !self.trusted_servers.is_empty() && !self.trusted_servers.contains(server_id) {
2146            return if self.require_approval_for_untrusted {
2147                PolicyMatch::RequireApproval(ApprovalRequest {
2148                    task_id: None,
2149                    call_id: None,
2150                    id: ApprovalId::new(format!("approval:mcp:{server_id}")),
2151                    request_kind: mcp.kind().to_string(),
2152                    reason: ApprovalReason::SensitiveServer,
2153                    summary: mcp.summary(),
2154                    metadata: mcp.metadata().clone(),
2155                })
2156            } else {
2157                PolicyMatch::Deny(PermissionDenial {
2158                    code: PermissionCode::ServerNotTrusted,
2159                    message: format!("MCP server {server_id} is not trusted"),
2160                    metadata: mcp.metadata().clone(),
2161                })
2162            };
2163        }
2164
2165        if let McpPermissionRequest::UseAuthScope { scope, .. } = mcp
2166            && !self.allowed_auth_scopes.is_empty()
2167            && !self.allowed_auth_scopes.contains(scope)
2168        {
2169            return PolicyMatch::Deny(PermissionDenial {
2170                code: PermissionCode::AuthScopeNotAllowed,
2171                message: format!("MCP auth scope {scope} is not allowed"),
2172                metadata: mcp.metadata().clone(),
2173            });
2174        }
2175
2176        PolicyMatch::Allow
2177    }
2178}
2179
2180/// The central abstraction for an executable tool in an agentkit agent.
2181///
2182/// Implement this trait to define a tool that an LLM can call. Each tool
2183/// provides a [`ToolSpec`] describing its name, schema, and hints, optional
2184/// permission requests via [`proposed_requests`](Tool::proposed_requests),
2185/// and the actual execution logic in [`invoke`](Tool::invoke).
2186///
2187/// # Example
2188///
2189/// ```rust
2190/// use agentkit_core::{MetadataMap, ToolOutput, ToolResultPart};
2191/// use agentkit_tools_core::{
2192///     Tool, ToolContext, ToolError, ToolName, ToolRequest, ToolResult, ToolSpec,
2193/// };
2194/// use async_trait::async_trait;
2195/// use serde_json::json;
2196///
2197/// struct TimeTool {
2198///     spec: ToolSpec,
2199/// }
2200///
2201/// impl TimeTool {
2202///     fn new() -> Self {
2203///         Self {
2204///             spec: ToolSpec::new(
2205///                 ToolName::new("current_time"),
2206///                 "Returns the current UTC time",
2207///                 json!({ "type": "object" }),
2208///             ),
2209///         }
2210///     }
2211/// }
2212///
2213/// #[async_trait]
2214/// impl Tool for TimeTool {
2215///     fn spec(&self) -> &ToolSpec {
2216///         &self.spec
2217///     }
2218///
2219///     async fn invoke(
2220///         &self,
2221///         request: ToolRequest,
2222///         _ctx: &mut ToolContext<'_>,
2223///     ) -> Result<ToolResult, ToolError> {
2224///         Ok(ToolResult::new(ToolResultPart::success(
2225///             request.call_id,
2226///             ToolOutput::text("2026-03-22T12:00:00Z"),
2227///         )))
2228///     }
2229/// }
2230/// ```
2231#[async_trait]
2232pub trait Tool: Send + Sync {
2233    /// Returns the static specification for this tool.
2234    fn spec(&self) -> &ToolSpec;
2235
2236    /// Returns the current specification for this tool, if it should be
2237    /// advertised right now.
2238    ///
2239    /// Most tools are static and can rely on the default implementation,
2240    /// which clones [`spec`](Self::spec). Override this when the description
2241    /// or input schema should reflect runtime state, or when the tool should
2242    /// be temporarily hidden from the model.
2243    fn current_spec(&self) -> Option<ToolSpec> {
2244        Some(self.spec().clone())
2245    }
2246
2247    /// Returns permission requests the executor should evaluate before calling
2248    /// [`invoke`](Tool::invoke).
2249    ///
2250    /// The default implementation returns an empty list (no permissions needed).
2251    /// Override this to declare filesystem, shell, or custom permission
2252    /// requirements based on the incoming request.
2253    ///
2254    /// # Errors
2255    ///
2256    /// Return [`ToolError::InvalidInput`] if the request input is malformed
2257    /// and permission requests cannot be constructed.
2258    fn proposed_requests(
2259        &self,
2260        _request: &ToolRequest,
2261    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
2262        Ok(Vec::new())
2263    }
2264
2265    /// Executes the tool and returns a result or error.
2266    ///
2267    /// # Errors
2268    ///
2269    /// Return an appropriate [`ToolError`] variant on failure. Source-specific
2270    /// concerns such as MCP authentication are resolved internally by the
2271    /// source (via host-supplied responders) and are not surfaced as tool
2272    /// errors.
2273    async fn invoke(
2274        &self,
2275        request: ToolRequest,
2276        ctx: &mut ToolContext<'_>,
2277    ) -> Result<ToolResult, ToolError>;
2278
2279    /// Executes the tool and may return an interruption directly.
2280    ///
2281    /// Most tools should implement only [`invoke`](Self::invoke). Advanced
2282    /// tools that compose other tools can override this method to propagate
2283    /// nested approval interrupts back to the loop.
2284    async fn invoke_outcome(
2285        &self,
2286        request: ToolRequest,
2287        ctx: &mut ToolContext<'_>,
2288    ) -> ToolExecutionOutcome {
2289        match self.invoke(request, ctx).await {
2290            Ok(result) => ToolExecutionOutcome::Completed(result),
2291            Err(error) => ToolExecutionOutcome::Failed(error),
2292        }
2293    }
2294}
2295
2296/// A name-keyed collection of [`Tool`] implementations.
2297///
2298/// The registry owns `Arc`-wrapped tools and is passed to a
2299/// [`BasicToolExecutor`] (or consumed by [`ToolCapabilityProvider`]) so the
2300/// agent loop can look up tools by name at execution time.
2301///
2302/// # Example
2303///
2304/// ```rust
2305/// use agentkit_tools_core::ToolRegistry;
2306/// # use agentkit_tools_core::{Tool, ToolContext, ToolError, ToolName, ToolRequest, ToolResult, ToolSpec};
2307/// # use async_trait::async_trait;
2308/// # use serde_json::json;
2309/// # struct NoopTool(ToolSpec);
2310/// # #[async_trait]
2311/// # impl Tool for NoopTool {
2312/// #     fn spec(&self) -> &ToolSpec { &self.0 }
2313/// #     async fn invoke(&self, _r: ToolRequest, _c: &mut ToolContext<'_>) -> Result<ToolResult, ToolError> { todo!() }
2314/// # }
2315///
2316/// let registry = ToolRegistry::new()
2317///     .with(NoopTool(ToolSpec::new(
2318///         ToolName::new("noop"),
2319///         "Does nothing",
2320///         json!({"type": "object"}),
2321///     )));
2322///
2323/// assert!(registry.get(&ToolName::new("noop")).is_some());
2324/// assert_eq!(registry.specs().len(), 1);
2325/// ```
2326#[derive(Clone, Default)]
2327pub struct ToolRegistry {
2328    tools: BTreeMap<ToolName, Arc<dyn Tool>>,
2329}
2330
2331impl ToolRegistry {
2332    /// Creates an empty registry.
2333    pub fn new() -> Self {
2334        Self::default()
2335    }
2336
2337    /// Registers a tool by value and returns `&mut self` for imperative chaining.
2338    pub fn register<T>(&mut self, tool: T) -> &mut Self
2339    where
2340        T: Tool + 'static,
2341    {
2342        self.tools.insert(tool.spec().name.clone(), Arc::new(tool));
2343        self
2344    }
2345
2346    /// Registers a tool by value and returns `self` for builder-style chaining.
2347    pub fn with<T>(mut self, tool: T) -> Self
2348    where
2349        T: Tool + 'static,
2350    {
2351        self.register(tool);
2352        self
2353    }
2354
2355    /// Registers a pre-wrapped `Arc<dyn Tool>`.
2356    pub fn register_arc(&mut self, tool: Arc<dyn Tool>) -> &mut Self {
2357        self.tools.insert(tool.spec().name.clone(), tool);
2358        self
2359    }
2360
2361    /// Looks up a tool by name, returning `None` if not registered.
2362    pub fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2363        self.tools.get(name).cloned()
2364    }
2365
2366    /// Returns all registered tools as a `Vec`.
2367    pub fn tools(&self) -> Vec<Arc<dyn Tool>> {
2368        self.tools.values().cloned().collect()
2369    }
2370
2371    /// Merges all tools from another registry into this one, consuming it.
2372    ///
2373    /// Supports builder-style chaining:
2374    ///
2375    /// ```ignore
2376    /// let registry = agentkit_tool_fs::registry()
2377    ///     .merge(agentkit_tool_shell::registry());
2378    /// ```
2379    pub fn merge(mut self, other: Self) -> Self {
2380        self.tools.extend(other.tools);
2381        self
2382    }
2383
2384    /// Returns the [`ToolSpec`] for every registered tool.
2385    pub fn specs(&self) -> Vec<ToolSpec> {
2386        self.tools
2387            .values()
2388            .filter_map(|tool| tool.current_spec())
2389            .collect()
2390    }
2391}
2392
2393/// Read-side contract for a federated tool catalog.
2394///
2395/// A [`BasicToolExecutor`] composes one or more `ToolSource`s — typically a
2396/// frozen [`ToolRegistry`] of native tools alongside one or more
2397/// [`CatalogReader`]s owned by subsystems (MCP server manager, skill watcher,
2398/// plugin loader). Each source manages its own lifecycle and concurrency
2399/// story; the executor only reads.
2400pub trait ToolSource: Send + Sync {
2401    /// Returns the current spec for every tool in this source.
2402    fn specs(&self) -> Vec<ToolSpec>;
2403
2404    /// Looks up a tool by name, returning `None` if not present.
2405    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>>;
2406
2407    /// Drains pending catalog change events. Static sources return an empty
2408    /// list; dynamic sources surface added/removed/changed batches that the
2409    /// loop forwards to the model on the next turn.
2410    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2411        Vec::new()
2412    }
2413
2414    /// Wraps this source so every advertised tool name is prefixed with
2415    /// `<prefix>_`. Useful for mounting the same source under multiple
2416    /// namespaces, or for avoiding collisions between MCP catalogs.
2417    ///
2418    /// Lookups strip the prefix before delegating, and the wrapped tool's
2419    /// `spec()` reports the public (prefixed) name so the model and the
2420    /// tool see consistent names.
2421    ///
2422    /// To wrap an `Arc<dyn ToolSource>` instead, use [`Prefixed::new`].
2423    fn prefixed(self, prefix: impl Into<String>) -> Prefixed<Self>
2424    where
2425        Self: Sized,
2426    {
2427        Prefixed::new(self, prefix)
2428    }
2429
2430    /// Wraps this source so only tools whose name passes `predicate` are
2431    /// advertised and resolvable. Tools rejected by the predicate are
2432    /// invisible to the model and return `None` on lookup.
2433    ///
2434    /// To wrap an `Arc<dyn ToolSource>` instead, use [`Filtered::new`].
2435    fn filtered<F>(self, predicate: F) -> Filtered<Self, F>
2436    where
2437        Self: Sized,
2438        F: Fn(&ToolName) -> bool + Send + Sync + 'static,
2439    {
2440        Filtered::new(self, predicate)
2441    }
2442
2443    /// Wraps this source with a name remapping. Each `(original, new)` pair
2444    /// in `mapping` causes the tool to be advertised as `new` and resolved
2445    /// from `new` back to `original` on lookup. Tools not in the mapping
2446    /// pass through unchanged.
2447    ///
2448    /// To wrap an `Arc<dyn ToolSource>` instead, use [`Renamed::new`].
2449    fn renamed<I>(self, mapping: I) -> Renamed<Self>
2450    where
2451        Self: Sized,
2452        I: IntoIterator<Item = (ToolName, ToolName)>,
2453    {
2454        Renamed::new(self, mapping)
2455    }
2456}
2457
2458impl ToolSource for ToolRegistry {
2459    fn specs(&self) -> Vec<ToolSpec> {
2460        ToolRegistry::specs(self)
2461    }
2462
2463    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2464        ToolRegistry::get(self, name)
2465    }
2466}
2467
2468impl<S> ToolSource for Arc<S>
2469where
2470    S: ToolSource + ?Sized,
2471{
2472    fn specs(&self) -> Vec<ToolSpec> {
2473        (**self).specs()
2474    }
2475
2476    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2477        (**self).get(name)
2478    }
2479
2480    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2481        (**self).drain_catalog_events()
2482    }
2483}
2484
2485/// A [`ToolSource`] wrapper that prefixes every advertised tool name with
2486/// `<prefix>_`. Constructed via [`ToolSource::prefixed`] or directly.
2487pub struct Prefixed<S> {
2488    inner: S,
2489    prefix: String,
2490}
2491
2492impl<S> Prefixed<S> {
2493    /// Creates a new prefixed wrapper.
2494    pub fn new(inner: S, prefix: impl Into<String>) -> Self {
2495        Self {
2496            inner,
2497            prefix: prefix.into(),
2498        }
2499    }
2500
2501    fn rewrite(&self, name: &str) -> String {
2502        format!("{}_{}", self.prefix, name)
2503    }
2504
2505    fn strip<'a>(&self, name: &'a str) -> Option<&'a str> {
2506        name.strip_prefix(self.prefix.as_str())
2507            .and_then(|rest| rest.strip_prefix('_'))
2508    }
2509}
2510
2511impl<S> ToolSource for Prefixed<S>
2512where
2513    S: ToolSource,
2514{
2515    fn specs(&self) -> Vec<ToolSpec> {
2516        self.inner
2517            .specs()
2518            .into_iter()
2519            .map(|mut spec| {
2520                spec.name = ToolName::new(self.rewrite(spec.name.0.as_str()));
2521                spec
2522            })
2523            .collect()
2524    }
2525
2526    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2527        let original = self.strip(name.0.as_str())?;
2528        let inner_name = ToolName::new(original);
2529        let inner_tool = self.inner.get(&inner_name)?;
2530        let mut public_spec = inner_tool.spec().clone();
2531        public_spec.name = name.clone();
2532        Some(Arc::new(RewrittenTool {
2533            inner: inner_tool,
2534            inner_name,
2535            public_spec,
2536        }))
2537    }
2538
2539    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2540        self.inner
2541            .drain_catalog_events()
2542            .into_iter()
2543            .map(|mut event| {
2544                event.for_each_name_mut(|name| *name = self.rewrite(name.as_str()));
2545                event
2546            })
2547            .collect()
2548    }
2549}
2550
2551/// A [`ToolSource`] wrapper that hides tools rejected by `predicate`.
2552/// Constructed via [`ToolSource::filtered`] or directly.
2553pub struct Filtered<S, F> {
2554    inner: S,
2555    predicate: F,
2556}
2557
2558impl<S, F> Filtered<S, F> {
2559    /// Creates a new filtered wrapper.
2560    pub fn new(inner: S, predicate: F) -> Self {
2561        Self { inner, predicate }
2562    }
2563}
2564
2565impl<S, F> ToolSource for Filtered<S, F>
2566where
2567    S: ToolSource,
2568    F: Fn(&ToolName) -> bool + Send + Sync + 'static,
2569{
2570    fn specs(&self) -> Vec<ToolSpec> {
2571        self.inner
2572            .specs()
2573            .into_iter()
2574            .filter(|spec| (self.predicate)(&spec.name))
2575            .collect()
2576    }
2577
2578    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2579        if !(self.predicate)(name) {
2580            return None;
2581        }
2582        self.inner.get(name)
2583    }
2584
2585    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2586        self.inner
2587            .drain_catalog_events()
2588            .into_iter()
2589            .map(|mut event| {
2590                event.retain_names(|n| (self.predicate)(&ToolName::new(n)));
2591                event
2592            })
2593            .collect()
2594    }
2595}
2596
2597/// A [`ToolSource`] wrapper that renames specific tools. Tools whose
2598/// original name appears in the forward mapping are advertised under the
2599/// new name and resolved from the new name back to the original.
2600/// Unmapped names pass through unchanged.
2601///
2602/// Constructed via [`ToolSource::renamed`] or directly.
2603pub struct Renamed<S> {
2604    inner: S,
2605    forward: BTreeMap<ToolName, ToolName>,
2606    backward: BTreeMap<ToolName, ToolName>,
2607}
2608
2609impl<S> Renamed<S> {
2610    /// Creates a new renaming wrapper from a `(original, new)` mapping.
2611    pub fn new<I>(inner: S, mapping: I) -> Self
2612    where
2613        I: IntoIterator<Item = (ToolName, ToolName)>,
2614    {
2615        let forward: BTreeMap<ToolName, ToolName> = mapping.into_iter().collect();
2616        let backward = forward
2617            .iter()
2618            .map(|(k, v)| (v.clone(), k.clone()))
2619            .collect();
2620        Self {
2621            inner,
2622            forward,
2623            backward,
2624        }
2625    }
2626}
2627
2628impl<S> ToolSource for Renamed<S>
2629where
2630    S: ToolSource,
2631{
2632    fn specs(&self) -> Vec<ToolSpec> {
2633        self.inner
2634            .specs()
2635            .into_iter()
2636            .map(|mut spec| {
2637                if let Some(new_name) = self.forward.get(&spec.name) {
2638                    spec.name = new_name.clone();
2639                }
2640                spec
2641            })
2642            .collect()
2643    }
2644
2645    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
2646        if let Some(original) = self.backward.get(name) {
2647            let inner_tool = self.inner.get(original)?;
2648            let mut public_spec = inner_tool.spec().clone();
2649            public_spec.name = name.clone();
2650            Some(Arc::new(RewrittenTool {
2651                inner: inner_tool,
2652                inner_name: original.clone(),
2653                public_spec,
2654            }))
2655        } else if self.forward.contains_key(name) {
2656            // Original name of a remapped tool — hidden under its new name.
2657            None
2658        } else {
2659            self.inner.get(name)
2660        }
2661    }
2662
2663    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
2664        self.inner
2665            .drain_catalog_events()
2666            .into_iter()
2667            .map(|mut event| {
2668                event.for_each_name_mut(|name| {
2669                    if let Some(new) = self.forward.get(&ToolName::new(name.as_str())) {
2670                        *name = new.0.clone();
2671                    }
2672                });
2673                event
2674            })
2675            .collect()
2676    }
2677}
2678
2679/// Builds a JSON Schema [`Value`] for the given input type. Requires the
2680/// `schemars` feature.
2681///
2682/// This is the bridge between Rust types and the
2683/// [`ToolSpec::input_schema`] field — instead of hand-writing JSON Schema,
2684/// derive [`schemars::JsonSchema`] on your input struct and call this.
2685///
2686/// # Example
2687///
2688/// ```rust,ignore
2689/// use agentkit_tools_core::schema_for;
2690/// use schemars::JsonSchema;
2691///
2692/// #[derive(JsonSchema)]
2693/// struct WeatherInput {
2694///     /// City name to look up.
2695///     location: String,
2696///     /// Use celsius (default false).
2697///     #[serde(default)]
2698///     celsius: bool,
2699/// }
2700///
2701/// let schema = schema_for::<WeatherInput>();
2702/// assert!(schema.is_object());
2703/// ```
2704#[cfg(feature = "schemars")]
2705pub fn schema_for<T: schemars::JsonSchema>() -> Value {
2706    let schema = schemars::schema_for!(T);
2707    serde_json::to_value(schema)
2708        .expect("schemars produces valid JSON; this conversion is infallible")
2709}
2710
2711/// Builds a [`ToolSpec`] from `T`'s derived JSON Schema. Requires the
2712/// `schemars` feature. The generated schema is exactly what
2713/// [`schema_for::<T>`] produces; this helper just wraps it with a name and
2714/// description.
2715///
2716/// # Example
2717///
2718/// ```rust,ignore
2719/// use agentkit_tools_core::tool_spec_for;
2720/// use schemars::JsonSchema;
2721///
2722/// #[derive(JsonSchema)]
2723/// struct WeatherInput { location: String }
2724///
2725/// let spec = tool_spec_for::<WeatherInput>("get_weather", "Fetch current weather");
2726/// assert_eq!(spec.name.0, "get_weather");
2727/// ```
2728#[cfg(feature = "schemars")]
2729pub fn tool_spec_for<T: schemars::JsonSchema>(
2730    name: impl Into<ToolName>,
2731    description: impl Into<String>,
2732) -> ToolSpec {
2733    ToolSpec::new(name, description, schema_for::<T>())
2734}
2735
2736/// A [`Tool`] wrapper used by [`Prefixed`] and [`Renamed`] to bridge between
2737/// the public (rewritten) tool name and the inner tool's own name. The
2738/// wrapper reports the public spec but rewrites `request.tool_name` back to
2739/// the inner name before delegating to the wrapped tool, so tools that
2740/// inspect their own name (e.g. for logging or routing) see the original.
2741struct RewrittenTool {
2742    inner: Arc<dyn Tool>,
2743    inner_name: ToolName,
2744    public_spec: ToolSpec,
2745}
2746
2747#[async_trait]
2748impl Tool for RewrittenTool {
2749    fn spec(&self) -> &ToolSpec {
2750        &self.public_spec
2751    }
2752
2753    fn current_spec(&self) -> Option<ToolSpec> {
2754        let inner_current = self.inner.current_spec()?;
2755        Some(ToolSpec {
2756            name: self.public_spec.name.clone(),
2757            description: inner_current.description,
2758            input_schema: inner_current.input_schema,
2759            output_schema: inner_current.output_schema,
2760            annotations: inner_current.annotations,
2761            metadata: inner_current.metadata,
2762        })
2763    }
2764
2765    fn proposed_requests(
2766        &self,
2767        request: &ToolRequest,
2768    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
2769        let mut inner_request = request.clone();
2770        inner_request.tool_name = self.inner_name.clone();
2771        self.inner.proposed_requests(&inner_request)
2772    }
2773
2774    async fn invoke(
2775        &self,
2776        mut request: ToolRequest,
2777        ctx: &mut ToolContext<'_>,
2778    ) -> Result<ToolResult, ToolError> {
2779        request.tool_name = self.inner_name.clone();
2780        self.inner.invoke(request, ctx).await
2781    }
2782
2783    async fn invoke_outcome(
2784        &self,
2785        mut request: ToolRequest,
2786        ctx: &mut ToolContext<'_>,
2787    ) -> ToolExecutionOutcome {
2788        request.tool_name = self.inner_name.clone();
2789        self.inner.invoke_outcome(request, ctx).await
2790    }
2791}
2792
2793/// Catalog storage with poison-recovery encoded in the type. The wrapped
2794/// `RwLock` is private; only [`read`](Self::read) and [`write`](Self::write)
2795/// are exposed, both infallible. Recovery is safe because every callsite
2796/// honors the invariant below.
2797///
2798/// **Invariant for callers:** write critical sections must compute all
2799/// derived state — diffs, comparisons, anything that may run user code
2800/// (`Tool` impls in particular) — BEFORE mutating the map. If a panic fires
2801/// between two mutations in the same critical section, recovery would hand
2802/// the next caller a partially-updated map. The current callsites all hold
2803/// this: `upsert`/`remove` perform a single op with no user code;
2804/// `replace_all` completes its diff (which calls `Tool::current_spec`)
2805/// before the swap.
2806///
2807/// The `catalog_recovers_from_panicked_writer` test exercises the recovery
2808/// path; if you change a write critical section, re-check that it still
2809/// computes-then-mutates.
2810struct ToolMap {
2811    inner: std::sync::RwLock<BTreeMap<ToolName, Arc<dyn Tool>>>,
2812}
2813
2814impl ToolMap {
2815    fn new() -> Self {
2816        Self {
2817            inner: std::sync::RwLock::new(BTreeMap::new()),
2818        }
2819    }
2820
2821    fn read(&self) -> std::sync::RwLockReadGuard<'_, BTreeMap<ToolName, Arc<dyn Tool>>> {
2822        self.inner.read().unwrap_or_else(|e| e.into_inner())
2823    }
2824
2825    fn write(&self) -> std::sync::RwLockWriteGuard<'_, BTreeMap<ToolName, Arc<dyn Tool>>> {
2826        self.inner.write().unwrap_or_else(|e| e.into_inner())
2827    }
2828}
2829
2830/// Shared inner state of a dynamic catalog. Held by both [`CatalogWriter`]
2831/// (mutates) and [`CatalogReader`] (reads), behind `Arc`s that hosts never see.
2832struct DynamicCatalogInner {
2833    source_id: String,
2834    tools: ToolMap,
2835    events_tx: tokio::sync::broadcast::Sender<ToolCatalogEvent>,
2836}
2837
2838/// Constructs a fresh dynamic tool catalog as a writer/reader pair.
2839///
2840/// The writer mutates the catalog; the reader implements [`ToolSource`] and
2841/// is what gets handed to an `Agent`. Both sides share storage internally —
2842/// callers see only sized, owned values. Modeled on
2843/// `tokio::sync::watch::channel`.
2844///
2845/// `source_id` appears as the `source` field on every emitted
2846/// [`ToolCatalogEvent`].
2847///
2848/// ```
2849/// use agentkit_tools_core::dynamic_catalog;
2850///
2851/// let (writer, reader) = dynamic_catalog("plugins");
2852/// assert_eq!(writer.source_id(), "plugins");
2853/// assert_eq!(reader.source_id(), "plugins");
2854/// ```
2855pub fn dynamic_catalog(source_id: impl Into<String>) -> (CatalogWriter, CatalogReader) {
2856    let (events_tx, events_rx) = tokio::sync::broadcast::channel(128);
2857    let inner = Arc::new(DynamicCatalogInner {
2858        source_id: source_id.into(),
2859        tools: ToolMap::new(),
2860        events_tx,
2861    });
2862    (
2863        CatalogWriter {
2864            inner: Arc::clone(&inner),
2865        },
2866        CatalogReader {
2867            inner,
2868            events_rx: std::sync::Mutex::new(events_rx),
2869        },
2870    )
2871}
2872
2873/// Mutating side of a dynamic tool catalog. Owned by subsystems that
2874/// discover or refresh tools at runtime (MCP server manager, skill watcher,
2875/// plugin loader). Each [`upsert`](Self::upsert), [`remove`](Self::remove),
2876/// or [`replace_all`](Self::replace_all) emits a [`ToolCatalogEvent`] that
2877/// every [`CatalogReader`] minted from the same [`dynamic_catalog`] call
2878/// (or its clones) observes via [`ToolSource::drain_catalog_events`].
2879pub struct CatalogWriter {
2880    inner: Arc<DynamicCatalogInner>,
2881}
2882
2883impl CatalogWriter {
2884    /// Stable source identifier appearing on emitted catalog events.
2885    pub fn source_id(&self) -> &str {
2886        &self.inner.source_id
2887    }
2888
2889    /// Mints an additional [`CatalogReader`] over the same shared state.
2890    /// The new reader subscribes from now forward — it does not see events
2891    /// emitted before this call.
2892    pub fn reader(&self) -> CatalogReader {
2893        CatalogReader {
2894            inner: Arc::clone(&self.inner),
2895            events_rx: std::sync::Mutex::new(self.inner.events_tx.subscribe()),
2896        }
2897    }
2898
2899    /// Inserts or replaces a tool. Emits a single-entry catalog event with
2900    /// the tool's name in `added` (new) or `changed` (replaced).
2901    pub fn upsert(&self, tool: Arc<dyn Tool>) {
2902        let name = tool.spec().name.clone();
2903        let mut guard = self.inner.tools.write();
2904        let existed = guard.insert(name.clone(), tool).is_some();
2905        drop(guard);
2906        let mut event = ToolCatalogEvent::new(self.inner.source_id.clone());
2907        if existed {
2908            event.changed.push(name.0);
2909        } else {
2910            event.added.push(name.0);
2911        }
2912        let _ = self.inner.events_tx.send(event);
2913    }
2914
2915    /// Removes a tool by name. Emits a catalog event with the name in
2916    /// `removed` if it existed.
2917    pub fn remove(&self, name: &ToolName) -> bool {
2918        let mut guard = self.inner.tools.write();
2919        let removed = guard.remove(name).is_some();
2920        drop(guard);
2921        if removed {
2922            let mut event = ToolCatalogEvent::new(self.inner.source_id.clone());
2923            event.removed.push(name.0.clone());
2924            let _ = self.inner.events_tx.send(event);
2925        }
2926        removed
2927    }
2928
2929    /// Atomically replaces the entire tool set. Emits one catalog event
2930    /// describing the diff against the previous contents.
2931    pub fn replace_all(&self, tools: impl IntoIterator<Item = Arc<dyn Tool>>) {
2932        let new_map: BTreeMap<ToolName, Arc<dyn Tool>> = tools
2933            .into_iter()
2934            .map(|tool| (tool.spec().name.clone(), tool))
2935            .collect();
2936
2937        let mut guard = self.inner.tools.write();
2938        let mut event = ToolCatalogEvent::new(self.inner.source_id.clone());
2939
2940        for (name, new_tool) in new_map.iter() {
2941            match guard.get(name) {
2942                None => event.added.push(name.0.clone()),
2943                Some(existing)
2944                    if !Arc::ptr_eq(existing, new_tool)
2945                        && existing.current_spec() != new_tool.current_spec() =>
2946                {
2947                    event.changed.push(name.0.clone());
2948                }
2949                Some(_) => {}
2950            }
2951        }
2952        for name in guard.keys() {
2953            if !new_map.contains_key(name) {
2954                event.removed.push(name.0.clone());
2955            }
2956        }
2957
2958        *guard = new_map;
2959        drop(guard);
2960
2961        if !event.added.is_empty() || !event.removed.is_empty() || !event.changed.is_empty() {
2962            let _ = self.inner.events_tx.send(event);
2963        }
2964    }
2965
2966    /// Subscribes a fresh broadcast receiver. Lower-level than
2967    /// [`CatalogReader`] — for hosts that consume catalog events directly
2968    /// rather than through the loop.
2969    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ToolCatalogEvent> {
2970        self.inner.events_tx.subscribe()
2971    }
2972}
2973
2974/// Read side of a dynamic tool catalog. Implements [`ToolSource`] and is the
2975/// value handed to [`agentkit_loop::AgentBuilder::tools`]. Cloning subscribes
2976/// a fresh broadcast receiver, so independent observers don't compete for
2977/// catalog events.
2978pub struct CatalogReader {
2979    inner: Arc<DynamicCatalogInner>,
2980    events_rx: std::sync::Mutex<tokio::sync::broadcast::Receiver<ToolCatalogEvent>>,
2981}
2982
2983impl CatalogReader {
2984    /// Stable source identifier appearing on emitted catalog events.
2985    pub fn source_id(&self) -> &str {
2986        &self.inner.source_id
2987    }
2988
2989    /// Subscribes a fresh broadcast receiver — equivalent to
2990    /// [`CatalogWriter::subscribe`].
2991    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ToolCatalogEvent> {
2992        self.inner.events_tx.subscribe()
2993    }
2994}
2995
2996impl Clone for CatalogReader {
2997    fn clone(&self) -> Self {
2998        Self {
2999            inner: Arc::clone(&self.inner),
3000            events_rx: std::sync::Mutex::new(self.inner.events_tx.subscribe()),
3001        }
3002    }
3003}
3004
3005impl ToolSource for CatalogReader {
3006    fn specs(&self) -> Vec<ToolSpec> {
3007        self.inner
3008            .tools
3009            .read()
3010            .values()
3011            .filter_map(|tool| tool.current_spec())
3012            .collect()
3013    }
3014
3015    fn get(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
3016        self.inner.tools.read().get(name).cloned()
3017    }
3018
3019    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
3020        // try_recv on a broadcast::Receiver has no panic source, so the only
3021        // way this Mutex poisons is if a panic somehow originates outside the
3022        // try_recv loop while held — recover defensively, the receiver state
3023        // is independent of this lock.
3024        let mut rx = self.events_rx.lock().unwrap_or_else(|e| e.into_inner());
3025        let mut out = Vec::new();
3026        loop {
3027            match rx.try_recv() {
3028                Ok(event) => out.push(event),
3029                Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
3030                Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
3031                Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
3032            }
3033        }
3034        out
3035    }
3036}
3037
3038impl ToolSpec {
3039    /// Converts this spec into an [`InvocableSpec`] for use with the
3040    /// capability layer.
3041    pub fn as_invocable_spec(&self) -> InvocableSpec {
3042        InvocableSpec::new(
3043            CapabilityName::new(self.name.0.clone()),
3044            self.description.clone(),
3045            self.input_schema.clone(),
3046        )
3047        .with_metadata(self.metadata.clone())
3048    }
3049}
3050
3051/// Wraps a [`Tool`] as an [`Invocable`] so it can be surfaced through the
3052/// agentkit capability layer.
3053///
3054/// Created automatically by [`ToolCapabilityProvider::from_registry`]; you
3055/// rarely need to construct one yourself.
3056pub struct ToolInvocableAdapter {
3057    spec: InvocableSpec,
3058    tool: Arc<dyn Tool>,
3059    permissions: Arc<dyn PermissionChecker>,
3060    resources: Arc<dyn ToolResources>,
3061    next_call_id: AtomicU64,
3062}
3063
3064impl ToolInvocableAdapter {
3065    /// Creates a new adapter that wraps `tool` with the given permission
3066    /// checker and shared resources.
3067    pub fn new(
3068        tool: Arc<dyn Tool>,
3069        permissions: Arc<dyn PermissionChecker>,
3070        resources: Arc<dyn ToolResources>,
3071    ) -> Option<Self> {
3072        let spec = tool.current_spec()?.as_invocable_spec();
3073        Some(Self {
3074            spec,
3075            tool,
3076            permissions,
3077            resources,
3078            next_call_id: AtomicU64::new(1),
3079        })
3080    }
3081}
3082
3083#[async_trait]
3084impl Invocable for ToolInvocableAdapter {
3085    fn spec(&self) -> &InvocableSpec {
3086        &self.spec
3087    }
3088
3089    async fn invoke(
3090        &self,
3091        request: InvocableRequest,
3092        ctx: &mut CapabilityContext<'_>,
3093    ) -> Result<InvocableResult, CapabilityError> {
3094        let tool_request = ToolRequest {
3095            call_id: ToolCallId::new(format!(
3096                "tool-call-{}",
3097                self.next_call_id.fetch_add(1, Ordering::Relaxed)
3098            )),
3099            tool_name: self.tool.spec().name.clone(),
3100            input: request.input,
3101            session_id: ctx
3102                .session_id
3103                .cloned()
3104                .unwrap_or_else(|| SessionId::new("capability-session")),
3105            turn_id: ctx
3106                .turn_id
3107                .cloned()
3108                .unwrap_or_else(|| TurnId::new("capability-turn")),
3109            metadata: request.metadata,
3110        };
3111
3112        for permission_request in self
3113            .tool
3114            .proposed_requests(&tool_request)
3115            .map_err(|error| CapabilityError::InvalidInput(error.to_string()))?
3116        {
3117            match self.permissions.evaluate(permission_request.as_ref()) {
3118                PermissionDecision::Allow => {}
3119                PermissionDecision::Deny(denial) => {
3120                    return Err(CapabilityError::ExecutionFailed(format!(
3121                        "tool permission denied: {denial:?}"
3122                    )));
3123                }
3124                PermissionDecision::RequireApproval(req) => {
3125                    return Err(CapabilityError::Unavailable(format!(
3126                        "tool invocation requires approval: {}",
3127                        req.summary
3128                    )));
3129                }
3130            }
3131        }
3132
3133        let mut tool_ctx = ToolContext {
3134            capability: CapabilityContext {
3135                session_id: ctx.session_id,
3136                turn_id: ctx.turn_id,
3137                metadata: ctx.metadata,
3138            },
3139            permissions: self.permissions.as_ref(),
3140            resources: self.resources.as_ref(),
3141            cancellation: None,
3142            execution_scope: None,
3143            approved_request: None,
3144        };
3145
3146        let result = self
3147            .tool
3148            .invoke(tool_request, &mut tool_ctx)
3149            .await
3150            .map_err(|error| CapabilityError::ExecutionFailed(error.to_string()))?;
3151
3152        Ok(InvocableResult {
3153            output: match result.result.output {
3154                ToolOutput::Text(text) => InvocableOutput::Text(text),
3155                ToolOutput::Structured(value) => InvocableOutput::Structured(value),
3156                ToolOutput::Parts(parts) => InvocableOutput::Items(vec![Item {
3157                    id: None,
3158                    kind: ItemKind::Tool,
3159                    parts,
3160                    metadata: MetadataMap::new(),
3161                    usage: None,
3162                    finish_reason: None,
3163                    created_at: None,
3164                }]),
3165                ToolOutput::Files(files) => {
3166                    let parts = files.into_iter().map(Part::File).collect();
3167                    InvocableOutput::Items(vec![Item {
3168                        id: None,
3169                        kind: ItemKind::Tool,
3170                        parts,
3171                        metadata: MetadataMap::new(),
3172                        usage: None,
3173                        finish_reason: None,
3174                        created_at: None,
3175                    }])
3176                }
3177            },
3178            metadata: result.metadata,
3179        })
3180    }
3181}
3182
3183/// A [`CapabilityProvider`] that exposes every tool in a [`ToolRegistry`]
3184/// as an [`Invocable`] in the agentkit capability layer.
3185///
3186/// This is the bridge between the tool subsystem and the generic capability
3187/// API that the agent loop consumes.
3188pub struct ToolCapabilityProvider {
3189    invocables: Vec<Arc<dyn Invocable>>,
3190}
3191
3192impl ToolCapabilityProvider {
3193    /// Builds a provider from all tools in `registry`, sharing the given
3194    /// permission checker and resources across every adapter.
3195    pub fn from_registry(
3196        registry: &ToolRegistry,
3197        permissions: Arc<dyn PermissionChecker>,
3198        resources: Arc<dyn ToolResources>,
3199    ) -> Self {
3200        let invocables = registry
3201            .tools()
3202            .into_iter()
3203            .filter_map(|tool| {
3204                ToolInvocableAdapter::new(tool, permissions.clone(), resources.clone())
3205                    .map(|adapter| Arc::new(adapter) as Arc<dyn Invocable>)
3206            })
3207            .collect();
3208
3209        Self { invocables }
3210    }
3211}
3212
3213impl CapabilityProvider for ToolCapabilityProvider {
3214    fn invocables(&self) -> Vec<Arc<dyn Invocable>> {
3215        self.invocables.clone()
3216    }
3217
3218    fn resources(&self) -> Vec<Arc<dyn ResourceProvider>> {
3219        Vec::new()
3220    }
3221
3222    fn prompts(&self) -> Vec<Arc<dyn PromptProvider>> {
3223        Vec::new()
3224    }
3225}
3226
3227/// The three-way result of a [`ToolExecutor::execute`] call.
3228///
3229/// Unlike a simple `Result`, this type distinguishes between a successful
3230/// completion, an interruption requiring user input (approval or auth), and
3231/// an outright failure.
3232#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
3233pub enum ToolExecutionOutcome {
3234    /// The tool ran to completion and produced a result.
3235    Completed(ToolResult),
3236    /// The tool was interrupted and needs user input before it can continue.
3237    Interrupted(ToolInterruption),
3238    /// Execution failed before the tool body was invoked.
3239    FailedBeforeInvocation(ToolError),
3240    /// The tool failed with an error.
3241    Failed(ToolError),
3242}
3243
3244/// Trait for executing tool calls with permission checking and interruption
3245/// handling.
3246///
3247/// The agent loop calls [`execute`](ToolExecutor::execute) for every tool
3248/// call the model emits. If execution returns
3249/// [`ToolExecutionOutcome::Interrupted`], the loop collects user input and
3250/// retries with [`execute_approved`](ToolExecutor::execute_approved).
3251#[async_trait]
3252pub trait ToolExecutor: Send + Sync {
3253    /// Returns the current specification for every available tool.
3254    fn specs(&self) -> Vec<ToolSpec>;
3255
3256    /// Drains any pending dynamic catalog events.
3257    ///
3258    /// Static executors return an empty list. Dynamic executors should use
3259    /// interior mutability to return each catalog event once.
3260    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
3261        Vec::new()
3262    }
3263
3264    /// Looks up the tool, evaluates permissions, and invokes it.
3265    async fn execute(
3266        &self,
3267        request: ToolRequest,
3268        ctx: &mut ToolContext<'_>,
3269    ) -> ToolExecutionOutcome;
3270
3271    /// Looks up the tool, evaluates permissions, and invokes it using an
3272    /// owned execution context.
3273    async fn execute_owned(
3274        &self,
3275        request: ToolRequest,
3276        ctx: OwnedToolContext,
3277    ) -> ToolExecutionOutcome {
3278        let mut borrowed = ctx.borrowed();
3279        self.execute(request, &mut borrowed).await
3280    }
3281
3282    /// Re-executes a tool call that was previously interrupted for approval.
3283    ///
3284    /// The default implementation ignores `approved_request` and delegates
3285    /// to [`execute`](ToolExecutor::execute). [`BasicToolExecutor`]
3286    /// overrides this to skip the approval gate for the matching request.
3287    async fn execute_approved(
3288        &self,
3289        request: ToolRequest,
3290        approved_request: &ApprovalRequest,
3291        ctx: &mut ToolContext<'_>,
3292    ) -> ToolExecutionOutcome {
3293        let _ = approved_request;
3294        self.execute(request, ctx).await
3295    }
3296
3297    /// Re-executes a tool call that was previously interrupted for approval
3298    /// using an owned execution context.
3299    async fn execute_approved_owned(
3300        &self,
3301        request: ToolRequest,
3302        approved_request: &ApprovalRequest,
3303        mut ctx: OwnedToolContext,
3304    ) -> ToolExecutionOutcome {
3305        ctx.approved_request = Some(approved_request.clone());
3306        let mut borrowed = ctx.borrowed();
3307        self.execute_approved(request, approved_request, &mut borrowed)
3308            .await
3309    }
3310}
3311
3312#[async_trait]
3313impl<T> ToolExecutor for Arc<T>
3314where
3315    T: ToolExecutor + ?Sized,
3316{
3317    fn specs(&self) -> Vec<ToolSpec> {
3318        (**self).specs()
3319    }
3320
3321    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
3322        (**self).drain_catalog_events()
3323    }
3324
3325    async fn execute(
3326        &self,
3327        request: ToolRequest,
3328        ctx: &mut ToolContext<'_>,
3329    ) -> ToolExecutionOutcome {
3330        (**self).execute(request, ctx).await
3331    }
3332
3333    async fn execute_approved(
3334        &self,
3335        request: ToolRequest,
3336        approved_request: &ApprovalRequest,
3337        ctx: &mut ToolContext<'_>,
3338    ) -> ToolExecutionOutcome {
3339        (**self)
3340            .execute_approved(request, approved_request, ctx)
3341            .await
3342    }
3343}
3344
3345/// Policy applied when the same tool name appears in more than one
3346/// [`ToolSource`] of a [`BasicToolExecutor`].
3347#[derive(Clone, Debug, Default, PartialEq, Eq)]
3348pub enum CollisionPolicy {
3349    /// First source wins (in iteration order). Subsequent definitions of
3350    /// the same name are ignored.
3351    #[default]
3352    FirstWins,
3353    /// Later sources overwrite earlier ones at lookup time.
3354    LastWins,
3355}
3356
3357/// The default [`ToolExecutor`] that walks one or more [`ToolSource`]s,
3358/// checks permissions via [`Tool::proposed_requests`], and invokes the tool.
3359///
3360/// Compose static native tools (a frozen [`ToolRegistry`]) alongside
3361/// dynamic sources (a [`CatalogReader`] minted by [`dynamic_catalog`] and
3362/// owned by an MCP manager, skill watcher, plugin loader, etc.) without
3363/// merging into a single mutable registry.
3364///
3365/// # Example
3366///
3367/// ```rust,no_run
3368/// use std::sync::Arc;
3369/// use agentkit_tools_core::{BasicToolExecutor, ToolRegistry, ToolSource};
3370///
3371/// let static_registry: Arc<dyn ToolSource> = Arc::new(ToolRegistry::new());
3372/// let executor = BasicToolExecutor::new([static_registry]);
3373/// // Pass `executor` to the agent loop.
3374/// ```
3375pub struct BasicToolExecutor {
3376    sources: Vec<Arc<dyn ToolSource>>,
3377    collision: CollisionPolicy,
3378    output_truncation: Option<Arc<dyn ToolOutputTruncationStrategy>>,
3379}
3380
3381impl BasicToolExecutor {
3382    /// Creates an executor that walks `sources` in order on every lookup.
3383    pub fn new(sources: impl IntoIterator<Item = Arc<dyn ToolSource>>) -> Self {
3384        Self {
3385            sources: sources.into_iter().collect(),
3386            collision: CollisionPolicy::default(),
3387            output_truncation: None,
3388        }
3389    }
3390
3391    /// Back-compat shorthand: wrap a single [`ToolRegistry`] as the only source.
3392    pub fn from_registry(registry: ToolRegistry) -> Self {
3393        Self::new([Arc::new(registry) as Arc<dyn ToolSource>])
3394    }
3395
3396    /// Sets the collision policy applied when the same tool name appears in
3397    /// multiple sources.
3398    pub fn with_collision_policy(mut self, policy: CollisionPolicy) -> Self {
3399        self.collision = policy;
3400        self
3401    }
3402
3403    /// Installs a central tool-output truncation strategy. The strategy runs
3404    /// after every successful tool invocation and before the result is returned
3405    /// to the agent loop.
3406    pub fn with_output_truncation_strategy(
3407        mut self,
3408        strategy: impl ToolOutputTruncationStrategy + 'static,
3409    ) -> Self {
3410        self.output_truncation = Some(Arc::new(strategy));
3411        self
3412    }
3413
3414    /// Installs a pre-wrapped central tool-output truncation strategy.
3415    pub fn with_output_truncation_strategy_arc(
3416        mut self,
3417        strategy: Arc<dyn ToolOutputTruncationStrategy>,
3418    ) -> Self {
3419        self.output_truncation = Some(strategy);
3420        self
3421    }
3422
3423    /// Returns the [`ToolSpec`] for every tool across all sources, deduped
3424    /// by [`CollisionPolicy`].
3425    pub fn specs(&self) -> Vec<ToolSpec> {
3426        let mut seen = BTreeSet::new();
3427        let mut out = Vec::new();
3428        let iter: Box<dyn Iterator<Item = &Arc<dyn ToolSource>>> = match self.collision {
3429            CollisionPolicy::FirstWins => Box::new(self.sources.iter()),
3430            CollisionPolicy::LastWins => Box::new(self.sources.iter().rev()),
3431        };
3432        for source in iter {
3433            for spec in source.specs() {
3434                if seen.insert(spec.name.clone()) {
3435                    out.push(spec);
3436                }
3437            }
3438        }
3439        out
3440    }
3441
3442    fn lookup(&self, name: &ToolName) -> Option<Arc<dyn Tool>> {
3443        match self.collision {
3444            CollisionPolicy::FirstWins => self.sources.iter().find_map(|s| s.get(name)),
3445            CollisionPolicy::LastWins => self.sources.iter().rev().find_map(|s| s.get(name)),
3446        }
3447    }
3448
3449    async fn execute_inner(
3450        &self,
3451        request: ToolRequest,
3452        approved_request_id: Option<&ApprovalId>,
3453        ctx: &mut ToolContext<'_>,
3454    ) -> ToolExecutionOutcome {
3455        let Some(tool) = self.lookup(&request.tool_name) else {
3456            return ToolExecutionOutcome::FailedBeforeInvocation(ToolError::NotFound(
3457                request.tool_name,
3458            ));
3459        };
3460
3461        match tool.proposed_requests(&request) {
3462            Ok(requests) => {
3463                for permission_request in requests {
3464                    match ctx.permissions.evaluate(permission_request.as_ref()) {
3465                        PermissionDecision::Allow => {}
3466                        PermissionDecision::Deny(denial) => {
3467                            return ToolExecutionOutcome::FailedBeforeInvocation(
3468                                ToolError::PermissionDenied(denial),
3469                            );
3470                        }
3471                        PermissionDecision::RequireApproval(mut req) => {
3472                            req.call_id = Some(request.call_id.clone());
3473                            if approved_request_id != Some(&req.id) {
3474                                return ToolExecutionOutcome::Interrupted(
3475                                    ToolInterruption::ApprovalRequired(req),
3476                                );
3477                            }
3478                        }
3479                    }
3480                }
3481            }
3482            Err(error) => return ToolExecutionOutcome::FailedBeforeInvocation(error),
3483        }
3484
3485        let truncation_ctx = ToolOutputTruncationContext::from((&request, tool.spec().clone()));
3486        match tool.invoke_outcome(request, ctx).await {
3487            ToolExecutionOutcome::Completed(mut result) => {
3488                if let Some(strategy) = &self.output_truncation {
3489                    match strategy.apply(truncation_ctx, result.result.output).await {
3490                        Ok(output) => {
3491                            result.result.output = output;
3492                        }
3493                        Err(error) => return ToolExecutionOutcome::Failed(error),
3494                    }
3495                }
3496                ToolExecutionOutcome::Completed(result)
3497            }
3498            other => other,
3499        }
3500    }
3501}
3502
3503#[async_trait]
3504impl ToolExecutor for BasicToolExecutor {
3505    fn specs(&self) -> Vec<ToolSpec> {
3506        BasicToolExecutor::specs(self)
3507    }
3508
3509    fn drain_catalog_events(&self) -> Vec<ToolCatalogEvent> {
3510        self.sources
3511            .iter()
3512            .flat_map(|s| s.drain_catalog_events())
3513            .collect()
3514    }
3515
3516    async fn execute(
3517        &self,
3518        request: ToolRequest,
3519        ctx: &mut ToolContext<'_>,
3520    ) -> ToolExecutionOutcome {
3521        self.execute_inner(request, None, ctx).await
3522    }
3523
3524    async fn execute_approved(
3525        &self,
3526        request: ToolRequest,
3527        approved_request: &ApprovalRequest,
3528        ctx: &mut ToolContext<'_>,
3529    ) -> ToolExecutionOutcome {
3530        let previous = ctx.approved_request.replace(approved_request.clone());
3531        let outcome = self
3532            .execute_inner(request, Some(&approved_request.id), ctx)
3533            .await;
3534        ctx.approved_request = previous;
3535        outcome
3536    }
3537}
3538
3539/// Errors that can occur during tool lookup, permission checking, or execution.
3540///
3541/// Returned from [`Tool::invoke`] and also used internally by
3542/// [`BasicToolExecutor`] to represent lookup and permission failures.
3543#[derive(Debug, Error, Clone, PartialEq, Serialize, Deserialize)]
3544pub enum ToolError {
3545    /// No tool with the given name exists in the registry.
3546    #[error("tool not found: {0}")]
3547    NotFound(ToolName),
3548    /// The input JSON did not match the tool's expected schema.
3549    #[error("invalid tool input: {0}")]
3550    InvalidInput(String),
3551    /// A permission policy denied the operation.
3552    #[error("tool permission denied: {0:?}")]
3553    PermissionDenied(PermissionDenial),
3554    /// The tool ran but encountered a runtime error.
3555    #[error("tool execution failed: {0}")]
3556    ExecutionFailed(String),
3557    /// The tool is temporarily unavailable.
3558    #[error("tool unavailable: {0}")]
3559    Unavailable(String),
3560    /// The turn was cancelled while the tool was running.
3561    #[error("tool execution cancelled")]
3562    Cancelled,
3563    /// An unexpected internal error.
3564    #[error("internal tool error: {0}")]
3565    Internal(String),
3566}
3567
3568impl ToolError {
3569    /// Convenience constructor for the [`PermissionDenied`](ToolError::PermissionDenied) variant.
3570    pub fn permission_denied(denial: PermissionDenial) -> Self {
3571        Self::PermissionDenied(denial)
3572    }
3573}
3574
3575impl From<PermissionDenial> for ToolError {
3576    fn from(value: PermissionDenial) -> Self {
3577        Self::permission_denied(value)
3578    }
3579}
3580
3581#[cfg(test)]
3582mod tests {
3583    use super::*;
3584    use async_trait::async_trait;
3585    use serde_json::json;
3586
3587    #[test]
3588    fn command_policy_can_deny_unknown_executables_without_approval() {
3589        let policy = CommandPolicy::new()
3590            .allow_executable("pwd")
3591            .require_approval_for_unknown(false);
3592        let request = ShellPermissionRequest {
3593            executable: "rm".into(),
3594            argv: vec!["-rf".into(), "/tmp/demo".into()],
3595            cwd: None,
3596            env_keys: Vec::new(),
3597            metadata: MetadataMap::new(),
3598        };
3599
3600        match policy.evaluate(&request) {
3601            PolicyMatch::Deny(denial) => {
3602                assert_eq!(denial.code, PermissionCode::CommandNotAllowed);
3603            }
3604            other => panic!("unexpected policy match: {other:?}"),
3605        }
3606    }
3607
3608    #[test]
3609    fn path_policy_allows_reads_under_read_only_roots() {
3610        let policy = PathPolicy::new().read_only_root("/workspace/vendor");
3611        let request = FileSystemPermissionRequest::Read {
3612            path: PathBuf::from("/workspace/vendor/lib.rs"),
3613            metadata: MetadataMap::new(),
3614        };
3615
3616        match policy.evaluate(&request) {
3617            PolicyMatch::NoOpinion | PolicyMatch::Allow => {}
3618            other => panic!("unexpected policy match: {other:?}"),
3619        }
3620    }
3621
3622    #[test]
3623    fn path_policy_denies_mutations_under_read_only_roots() {
3624        let policy = PathPolicy::new().read_only_root("/workspace/vendor");
3625        let request = FileSystemPermissionRequest::Edit {
3626            path: PathBuf::from("/workspace/vendor/lib.rs"),
3627            metadata: MetadataMap::new(),
3628        };
3629
3630        match policy.evaluate(&request) {
3631            PolicyMatch::Deny(denial) => {
3632                assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3633                assert!(denial.message.contains("read-only"));
3634            }
3635            other => panic!("unexpected policy match: {other:?}"),
3636        }
3637    }
3638
3639    #[test]
3640    fn path_policy_denies_moves_into_read_only_roots() {
3641        let policy = PathPolicy::new().read_only_root("/workspace/vendor");
3642        let request = FileSystemPermissionRequest::Move {
3643            from: PathBuf::from("/workspace/src/lib.rs"),
3644            to: PathBuf::from("/workspace/vendor/lib.rs"),
3645            metadata: MetadataMap::new(),
3646        };
3647
3648        match policy.evaluate(&request) {
3649            PolicyMatch::Deny(denial) => {
3650                assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3651                assert!(denial.message.contains("read-only"));
3652            }
3653            other => panic!("unexpected policy match: {other:?}"),
3654        }
3655    }
3656
3657    #[cfg(unix)]
3658    struct SymlinkTmpDir(PathBuf);
3659
3660    #[cfg(unix)]
3661    impl SymlinkTmpDir {
3662        fn new(label: &str) -> Self {
3663            use std::time::{SystemTime, UNIX_EPOCH};
3664            let nanos = SystemTime::now()
3665                .duration_since(UNIX_EPOCH)
3666                .unwrap()
3667                .as_nanos();
3668            let dir = std::env::temp_dir().join(format!(
3669                "agentkit-pathpolicy-{}-{}-{}",
3670                label,
3671                std::process::id(),
3672                nanos
3673            ));
3674            std::fs::create_dir_all(&dir).unwrap();
3675            // Canonicalise so callers compare against the resolved tmp path
3676            // (macOS `/tmp` is a symlink to `/private/tmp`, etc.).
3677            Self(std::fs::canonicalize(&dir).unwrap())
3678        }
3679
3680        fn path(&self) -> &Path {
3681            &self.0
3682        }
3683    }
3684
3685    #[cfg(unix)]
3686    impl Drop for SymlinkTmpDir {
3687        fn drop(&mut self) {
3688            let _ = std::fs::remove_dir_all(&self.0);
3689        }
3690    }
3691
3692    #[cfg(unix)]
3693    fn assert_path_denied(
3694        policy: &PathPolicy,
3695        request: FileSystemPermissionRequest,
3696    ) -> PermissionDenial {
3697        match policy.evaluate(&request) {
3698            PolicyMatch::Deny(denial) => denial,
3699            other => panic!("expected deny, got: {other:?}"),
3700        }
3701    }
3702
3703    #[cfg(unix)]
3704    #[test]
3705    fn path_policy_blocks_symlink_escape_from_allowed_root() {
3706        let tmp = SymlinkTmpDir::new("allow-escape");
3707        let allowed = tmp.path().join("workspace");
3708        let outside = tmp.path().join("outside");
3709        std::fs::create_dir_all(&allowed).unwrap();
3710        std::fs::create_dir_all(&outside).unwrap();
3711        let secret = outside.join("secret.txt");
3712        std::fs::write(&secret, b"top-secret").unwrap();
3713        let escape = allowed.join("leak");
3714        std::os::unix::fs::symlink(&secret, &escape).unwrap();
3715
3716        let policy = PathPolicy::new()
3717            .allow_root(&allowed)
3718            .require_approval_outside_allowed(false);
3719        let denial = assert_path_denied(
3720            &policy,
3721            FileSystemPermissionRequest::Read {
3722                path: escape,
3723                metadata: MetadataMap::new(),
3724            },
3725        );
3726        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3727    }
3728
3729    #[cfg(unix)]
3730    #[test]
3731    fn path_policy_blocks_symlink_into_protected_root() {
3732        let tmp = SymlinkTmpDir::new("protect-bypass");
3733        let workspace = tmp.path().join("workspace");
3734        std::fs::create_dir_all(&workspace).unwrap();
3735        let secret = workspace.join(".env");
3736        std::fs::write(&secret, b"API_KEY=xxx").unwrap();
3737        let alias = workspace.join("config");
3738        std::os::unix::fs::symlink(&secret, &alias).unwrap();
3739
3740        let policy = PathPolicy::new()
3741            .allow_root(&workspace)
3742            .protect_root(&secret);
3743        let denial = assert_path_denied(
3744            &policy,
3745            FileSystemPermissionRequest::Read {
3746                path: alias,
3747                metadata: MetadataMap::new(),
3748            },
3749        );
3750        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3751        assert!(denial.message.contains("denied"));
3752    }
3753
3754    #[cfg(unix)]
3755    #[test]
3756    fn path_policy_blocks_symlink_write_into_read_only_root() {
3757        let tmp = SymlinkTmpDir::new("readonly-bypass");
3758        let workspace = tmp.path().join("workspace");
3759        let vendor = workspace.join("vendor");
3760        std::fs::create_dir_all(&vendor).unwrap();
3761        let target = vendor.join("lib.rs");
3762        std::fs::write(&target, b"// vendored").unwrap();
3763        let writable_alias = workspace.join("writable");
3764        std::os::unix::fs::symlink(&target, &writable_alias).unwrap();
3765
3766        let policy = PathPolicy::new()
3767            .allow_root(&workspace)
3768            .read_only_root(&vendor);
3769        let denial = assert_path_denied(
3770            &policy,
3771            FileSystemPermissionRequest::Edit {
3772                path: writable_alias,
3773                metadata: MetadataMap::new(),
3774            },
3775        );
3776        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3777        assert!(denial.message.contains("read-only"));
3778    }
3779
3780    #[cfg(unix)]
3781    #[test]
3782    fn path_policy_resolves_symlink_parent_for_nonexistent_leaf() {
3783        let tmp = SymlinkTmpDir::new("create-escape");
3784        let allowed = tmp.path().join("workspace");
3785        let outside = tmp.path().join("outside");
3786        std::fs::create_dir_all(&allowed).unwrap();
3787        std::fs::create_dir_all(&outside).unwrap();
3788        let escape_dir = allowed.join("escape");
3789        std::os::unix::fs::symlink(&outside, &escape_dir).unwrap();
3790        let new_file = escape_dir.join("new.txt");
3791
3792        let policy = PathPolicy::new()
3793            .allow_root(&allowed)
3794            .require_approval_outside_allowed(false);
3795        let denial = assert_path_denied(
3796            &policy,
3797            FileSystemPermissionRequest::Write {
3798                path: new_file,
3799                metadata: MetadataMap::new(),
3800            },
3801        );
3802        assert_eq!(denial.code, PermissionCode::PathNotAllowed);
3803    }
3804
3805    #[derive(Clone)]
3806    struct HiddenTool {
3807        spec: ToolSpec,
3808    }
3809
3810    impl HiddenTool {
3811        fn new() -> Self {
3812            Self {
3813                spec: ToolSpec {
3814                    name: ToolName::new("hidden"),
3815                    description: "hidden".into(),
3816                    input_schema: json!({"type": "object"}),
3817                    output_schema: None,
3818                    annotations: ToolAnnotations::default(),
3819                    metadata: MetadataMap::new(),
3820                },
3821            }
3822        }
3823    }
3824
3825    #[async_trait]
3826    impl Tool for HiddenTool {
3827        fn spec(&self) -> &ToolSpec {
3828            &self.spec
3829        }
3830
3831        fn current_spec(&self) -> Option<ToolSpec> {
3832            None
3833        }
3834
3835        async fn invoke(
3836            &self,
3837            request: ToolRequest,
3838            _ctx: &mut ToolContext<'_>,
3839        ) -> Result<ToolResult, ToolError> {
3840            Ok(ToolResult {
3841                result: ToolResultPart {
3842                    call_id: request.call_id,
3843                    output: ToolOutput::Text("hidden".into()),
3844                    is_error: false,
3845                    metadata: MetadataMap::new(),
3846                },
3847                duration: None,
3848                metadata: MetadataMap::new(),
3849            })
3850        }
3851    }
3852
3853    #[test]
3854    fn hidden_tools_are_omitted_from_specs_and_capabilities() {
3855        let registry = ToolRegistry::new().with(HiddenTool::new());
3856
3857        assert!(registry.specs().is_empty());
3858
3859        let provider = ToolCapabilityProvider::from_registry(
3860            &registry,
3861            Arc::new(AllowAllPermissionChecker),
3862            Arc::new(()),
3863        );
3864        assert!(provider.invocables().is_empty());
3865    }
3866
3867    struct AllowAllPermissionChecker;
3868
3869    impl PermissionChecker for AllowAllPermissionChecker {
3870        fn evaluate(&self, _request: &dyn PermissionRequest) -> PermissionDecision {
3871            PermissionDecision::Allow
3872        }
3873    }
3874
3875    /// Tool whose `current_spec()` panics — used to exercise the
3876    /// catalog's poison-recovery guarantee.
3877    #[derive(Clone)]
3878    struct PanickingSpecTool {
3879        spec: ToolSpec,
3880    }
3881
3882    impl PanickingSpecTool {
3883        fn new(name: &str) -> Self {
3884            Self {
3885                spec: ToolSpec {
3886                    name: ToolName::new(name),
3887                    description: "panics on current_spec".into(),
3888                    input_schema: json!({"type": "object"}),
3889                    output_schema: None,
3890                    annotations: ToolAnnotations::default(),
3891                    metadata: MetadataMap::new(),
3892                },
3893            }
3894        }
3895    }
3896
3897    #[async_trait]
3898    impl Tool for PanickingSpecTool {
3899        fn spec(&self) -> &ToolSpec {
3900            &self.spec
3901        }
3902
3903        fn current_spec(&self) -> Option<ToolSpec> {
3904            panic!("PanickingSpecTool::current_spec");
3905        }
3906
3907        async fn invoke(
3908            &self,
3909            request: ToolRequest,
3910            _ctx: &mut ToolContext<'_>,
3911        ) -> Result<ToolResult, ToolError> {
3912            Ok(ToolResult {
3913                result: ToolResultPart {
3914                    call_id: request.call_id,
3915                    output: ToolOutput::Text("never".into()),
3916                    is_error: false,
3917                    metadata: MetadataMap::new(),
3918                },
3919                duration: None,
3920                metadata: MetadataMap::new(),
3921            })
3922        }
3923    }
3924
3925    /// If a tool's `current_spec()` panics during `replace_all`'s diff phase,
3926    /// the inner `RwLock` would normally poison and brick the catalog forever.
3927    /// `ToolMap` recovers from poison; this test pins the behavior so a future
3928    /// patch can't accidentally reintroduce the brick.
3929    ///
3930    /// The recovery is only safe because `replace_all` computes the diff
3931    /// (running user code) BEFORE swapping the map. If you change a write
3932    /// critical section to mutate before/between user-code calls, this test
3933    /// will still pass — but the catalog WILL be in a half-mutated state
3934    /// after a panic. Re-read `ToolMap`'s invariant before changing.
3935    #[test]
3936    fn catalog_recovers_from_panicked_writer() {
3937        let (writer, reader) = dynamic_catalog("test");
3938
3939        // Pre-seed with a panicker so the next `replace_all` enters the
3940        // diff branch that calls `existing.current_spec()`. `upsert`
3941        // itself never calls `current_spec`, so this insertion is safe.
3942        writer.upsert(Arc::new(PanickingSpecTool::new("boom")));
3943        let _ = reader.drain_catalog_events();
3944
3945        // `replace_all` with a different Arc of the same name forces the
3946        // diff to call `existing.current_spec()` → panics. The swap has
3947        // NOT happened yet at this point (the diff runs before the
3948        // `*guard = new_map`), so the catalog state is still consistent.
3949        let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3950            writer.replace_all(vec![
3951                Arc::new(PanickingSpecTool::new("boom")) as Arc<dyn Tool>
3952            ]);
3953        }));
3954        assert!(
3955            panic_result.is_err(),
3956            "PanickingSpecTool::current_spec must propagate"
3957        );
3958
3959        // Without recovery, every subsequent lock acquisition would panic
3960        // with "dynamic catalog poisoned". `get` doesn't call `current_spec`,
3961        // so it's a clean probe of whether the lock recovered.
3962        assert!(
3963            reader.get(&ToolName::new("boom")).is_some(),
3964            "catalog still readable after poisoning panic"
3965        );
3966
3967        // Writes also recover. Remove the panicker so subsequent `specs()`
3968        // calls don't re-trigger its panic.
3969        assert!(writer.remove(&ToolName::new("boom")));
3970
3971        // Add a well-behaved tool and round-trip through both sides.
3972        // (HiddenTool::current_spec returns None, so it's intentionally
3973        // filtered out of specs() — probe via get() instead.)
3974        writer.upsert(Arc::new(HiddenTool::new()));
3975        assert!(
3976            reader.get(&ToolName::new("hidden")).is_some(),
3977            "catalog usable for further writes + reads"
3978        );
3979    }
3980
3981    #[derive(Clone)]
3982    struct EchoTool {
3983        spec: ToolSpec,
3984    }
3985
3986    impl EchoTool {
3987        fn new(name: &str) -> Self {
3988            Self {
3989                spec: ToolSpec {
3990                    name: ToolName::new(name),
3991                    description: format!("echo {name}"),
3992                    input_schema: json!({"type": "object"}),
3993                    output_schema: None,
3994                    annotations: ToolAnnotations::default(),
3995                    metadata: MetadataMap::new(),
3996                },
3997            }
3998        }
3999    }
4000
4001    #[async_trait]
4002    impl Tool for EchoTool {
4003        fn spec(&self) -> &ToolSpec {
4004            &self.spec
4005        }
4006
4007        async fn invoke(
4008            &self,
4009            request: ToolRequest,
4010            _ctx: &mut ToolContext<'_>,
4011        ) -> Result<ToolResult, ToolError> {
4012            Ok(ToolResult::new(ToolResultPart::success(
4013                request.call_id,
4014                ToolOutput::text(request.tool_name.0.clone()),
4015            )))
4016        }
4017    }
4018
4019    fn registry_with(names: &[&str]) -> ToolRegistry {
4020        names.iter().fold(ToolRegistry::new(), |reg, name| {
4021            reg.with(EchoTool::new(name))
4022        })
4023    }
4024
4025    #[test]
4026    fn prefixed_rewrites_specs_and_resolves_lookups() {
4027        let source = registry_with(&["get_temp", "get_humidity"]).prefixed("weather");
4028        let names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
4029        assert_eq!(names, vec!["weather_get_humidity", "weather_get_temp"]);
4030
4031        assert!(source.get(&ToolName::new("weather_get_temp")).is_some());
4032        assert!(
4033            source.get(&ToolName::new("get_temp")).is_none(),
4034            "original name must not resolve when prefixed"
4035        );
4036        assert!(source.get(&ToolName::new("unknown")).is_none());
4037    }
4038
4039    #[tokio::test]
4040    async fn prefixed_invoke_sees_inner_name_on_request() {
4041        let source = registry_with(&["get_temp"]).prefixed("weather");
4042        let tool = source.get(&ToolName::new("weather_get_temp")).unwrap();
4043
4044        // The wrapper must report the public name on its spec...
4045        assert_eq!(tool.spec().name.0, "weather_get_temp");
4046
4047        // ...but the inner tool must see its own name in the request.
4048        let owned = OwnedToolContext {
4049            session_id: SessionId::new("s"),
4050            turn_id: TurnId::new("t"),
4051            metadata: MetadataMap::new(),
4052            permissions: Arc::new(AllowAllPermissions),
4053            resources: Arc::new(()),
4054            cancellation: None,
4055            execution_scope: None,
4056            approved_request: None,
4057        };
4058        let mut ctx = owned.borrowed();
4059        let request = ToolRequest {
4060            call_id: ToolCallId::new("c"),
4061            tool_name: ToolName::new("weather_get_temp"),
4062            input: json!({}),
4063            session_id: SessionId::new("s"),
4064            turn_id: TurnId::new("t"),
4065            metadata: MetadataMap::new(),
4066        };
4067        let result = tool.invoke(request, &mut ctx).await.unwrap();
4068        match result.result.output {
4069            ToolOutput::Text(text) => assert_eq!(text, "get_temp"),
4070            other => panic!("unexpected output: {other:?}"),
4071        }
4072    }
4073
4074    #[derive(Clone)]
4075    struct StaticOutputTool {
4076        spec: ToolSpec,
4077        output: ToolOutput,
4078    }
4079
4080    impl StaticOutputTool {
4081        fn new(name: &str, output: ToolOutput) -> Self {
4082            Self {
4083                spec: ToolSpec::new(name, format!("static {name}"), json!({"type": "object"})),
4084                output,
4085            }
4086        }
4087
4088        fn with_output_limit(mut self, limit: ToolOutputLimit) -> Self {
4089            self.spec = self.spec.with_output_limit(limit);
4090            self
4091        }
4092    }
4093
4094    #[async_trait]
4095    impl Tool for StaticOutputTool {
4096        fn spec(&self) -> &ToolSpec {
4097            &self.spec
4098        }
4099
4100        async fn invoke(
4101            &self,
4102            request: ToolRequest,
4103            _ctx: &mut ToolContext<'_>,
4104        ) -> Result<ToolResult, ToolError> {
4105            Ok(ToolResult::new(ToolResultPart::success(
4106                request.call_id,
4107                self.output.clone(),
4108            )))
4109        }
4110    }
4111
4112    struct ApprovedContextTool {
4113        spec: ToolSpec,
4114    }
4115
4116    impl ApprovedContextTool {
4117        fn new() -> Self {
4118            Self {
4119                spec: ToolSpec::new(
4120                    "approved_context",
4121                    "approved context",
4122                    json!({"type": "object"}),
4123                ),
4124            }
4125        }
4126    }
4127
4128    #[async_trait]
4129    impl Tool for ApprovedContextTool {
4130        fn spec(&self) -> &ToolSpec {
4131            &self.spec
4132        }
4133
4134        async fn invoke(
4135            &self,
4136            request: ToolRequest,
4137            ctx: &mut ToolContext<'_>,
4138        ) -> Result<ToolResult, ToolError> {
4139            Ok(ToolResult::new(ToolResultPart::success(
4140                request.call_id,
4141                ToolOutput::structured(json!({
4142                    "approved": ctx.approved_request.is_some()
4143                })),
4144            )))
4145        }
4146    }
4147
4148    struct ScopeChildTool {
4149        spec: ToolSpec,
4150    }
4151
4152    impl ScopeChildTool {
4153        fn new() -> Self {
4154            Self {
4155                spec: ToolSpec::new("scope_child", "scope child", json!({"type": "object"})),
4156            }
4157        }
4158    }
4159
4160    #[async_trait]
4161    impl Tool for ScopeChildTool {
4162        fn spec(&self) -> &ToolSpec {
4163            &self.spec
4164        }
4165
4166        async fn invoke(
4167            &self,
4168            request: ToolRequest,
4169            _ctx: &mut ToolContext<'_>,
4170        ) -> Result<ToolResult, ToolError> {
4171            Ok(ToolResult::new(ToolResultPart::success(
4172                request.call_id,
4173                ToolOutput::structured(json!({ "child": request.input })),
4174            )))
4175        }
4176    }
4177
4178    struct ScopeParentTool {
4179        spec: ToolSpec,
4180    }
4181
4182    impl ScopeParentTool {
4183        fn new() -> Self {
4184            Self {
4185                spec: ToolSpec::new("scope_parent", "scope parent", json!({"type": "object"})),
4186            }
4187        }
4188    }
4189
4190    #[async_trait]
4191    impl Tool for ScopeParentTool {
4192        fn spec(&self) -> &ToolSpec {
4193            &self.spec
4194        }
4195
4196        async fn invoke(
4197            &self,
4198            request: ToolRequest,
4199            _ctx: &mut ToolContext<'_>,
4200        ) -> Result<ToolResult, ToolError> {
4201            Ok(ToolResult::new(ToolResultPart::success(
4202                request.call_id,
4203                ToolOutput::text("unused"),
4204            )))
4205        }
4206
4207        async fn invoke_outcome(
4208            &self,
4209            request: ToolRequest,
4210            ctx: &mut ToolContext<'_>,
4211        ) -> ToolExecutionOutcome {
4212            let Some(scope) = ctx.execution_scope.clone() else {
4213                return ToolExecutionOutcome::Failed(ToolError::Internal(
4214                    "missing execution scope".into(),
4215                ));
4216            };
4217            let child = ToolRequest::new(
4218                "child-call",
4219                "scope_child",
4220                request.input.clone(),
4221                request.session_id.clone(),
4222                request.turn_id.clone(),
4223            );
4224            match scope.execute_child(child).await {
4225                ToolExecutionOutcome::Completed(child_result) => {
4226                    ToolExecutionOutcome::Completed(ToolResult::new(ToolResultPart::success(
4227                        request.call_id,
4228                        child_result.result.output,
4229                    )))
4230                }
4231                other => other,
4232            }
4233        }
4234    }
4235
4236    fn test_context() -> OwnedToolContext {
4237        OwnedToolContext {
4238            session_id: SessionId::new("s"),
4239            turn_id: TurnId::new("t"),
4240            metadata: MetadataMap::new(),
4241            permissions: Arc::new(AllowAllPermissions),
4242            resources: Arc::new(()),
4243            cancellation: None,
4244            execution_scope: None,
4245            approved_request: None,
4246        }
4247    }
4248
4249    fn test_context_with_scope(executor: Arc<dyn ToolExecutor>) -> OwnedToolContext {
4250        let session_id = SessionId::new("s");
4251        let turn_id = TurnId::new("t");
4252        let metadata = MetadataMap::new();
4253        let permissions: Arc<dyn PermissionChecker> = Arc::new(AllowAllPermissions);
4254        let resources: Arc<dyn ToolResources> = Arc::new(());
4255        let scope = ToolExecutionScope {
4256            executor,
4257            session_id: session_id.clone(),
4258            turn_id: turn_id.clone(),
4259            permissions: permissions.clone(),
4260            resources: resources.clone(),
4261            cancellation: None,
4262        };
4263        OwnedToolContext {
4264            session_id,
4265            turn_id,
4266            metadata,
4267            permissions,
4268            resources,
4269            cancellation: None,
4270            execution_scope: Some(scope),
4271            approved_request: None,
4272        }
4273    }
4274
4275    #[tokio::test]
4276    async fn default_invoke_outcome_wraps_invoke_success() {
4277        let executor = BasicToolExecutor::from_registry(ToolRegistry::new().with(
4278            StaticOutputTool::new("plain", ToolOutput::structured(json!({"ok": true}))),
4279        ));
4280        let outcome = executor
4281            .execute_owned(
4282                ToolRequest::new("call", "plain", json!({}), "s", "t"),
4283                test_context(),
4284            )
4285            .await;
4286
4287        let ToolExecutionOutcome::Completed(result) = outcome else {
4288            panic!("expected completed outcome, got {outcome:?}");
4289        };
4290        assert_eq!(
4291            result.result.output,
4292            ToolOutput::structured(json!({"ok": true}))
4293        );
4294    }
4295
4296    #[tokio::test]
4297    async fn execute_approved_passes_approval_context_to_tool() {
4298        let executor =
4299            BasicToolExecutor::from_registry(ToolRegistry::new().with(ApprovedContextTool::new()));
4300        let approval = ApprovalRequest {
4301            task_id: None,
4302            call_id: Some(ToolCallId::new("call")),
4303            id: ApprovalId::new("approval"),
4304            request_kind: "test.approval".into(),
4305            reason: ApprovalReason::PolicyRequiresConfirmation,
4306            summary: "approve".into(),
4307            metadata: MetadataMap::new(),
4308        };
4309        let outcome = executor
4310            .execute_approved_owned(
4311                ToolRequest::new("call", "approved_context", json!({}), "s", "t"),
4312                &approval,
4313                test_context(),
4314            )
4315            .await;
4316
4317        let ToolExecutionOutcome::Completed(result) = outcome else {
4318            panic!("expected completed outcome, got {outcome:?}");
4319        };
4320        assert_eq!(
4321            result.result.output,
4322            ToolOutput::structured(json!({"approved": true}))
4323        );
4324    }
4325
4326    #[tokio::test]
4327    async fn execution_scope_invokes_child_through_executor() {
4328        let executor: Arc<dyn ToolExecutor> = Arc::new(BasicToolExecutor::from_registry(
4329            ToolRegistry::new()
4330                .with(ScopeParentTool::new())
4331                .with(ScopeChildTool::new()),
4332        ));
4333        let outcome = executor
4334            .execute_owned(
4335                ToolRequest::new("parent-call", "scope_parent", json!({"value": 3}), "s", "t"),
4336                test_context_with_scope(executor.clone()),
4337            )
4338            .await;
4339
4340        let ToolExecutionOutcome::Completed(result) = outcome else {
4341            panic!("expected completed outcome, got {outcome:?}");
4342        };
4343        assert_eq!(
4344            result.result.output,
4345            ToolOutput::structured(json!({ "child": { "value": 3 } }))
4346        );
4347    }
4348
4349    #[tokio::test]
4350    async fn executor_stores_oversized_output_using_tool_metadata_limit() {
4351        let store = Arc::new(InMemoryToolOutputArtifactStore::new());
4352        let strategy = ConfigurableToolOutputTruncationStrategy::new(store.clone());
4353        let tool = StaticOutputTool::new("big", ToolOutput::text("x".repeat(500)))
4354            .with_output_limit(ToolOutputLimit::store_for_readback(300));
4355        let executor = BasicToolExecutor::from_registry(ToolRegistry::new().with(tool))
4356            .with_output_truncation_strategy(strategy);
4357
4358        let outcome = executor
4359            .execute_owned(
4360                ToolRequest::new(
4361                    "call",
4362                    "big",
4363                    json!({}),
4364                    SessionId::new("s"),
4365                    TurnId::new("t"),
4366                ),
4367                test_context(),
4368            )
4369            .await;
4370
4371        let ToolExecutionOutcome::Completed(result) = outcome else {
4372            panic!("expected completed outcome, got {outcome:?}");
4373        };
4374        let ToolOutput::Structured(envelope) = result.result.output else {
4375            panic!("expected truncation envelope");
4376        };
4377        assert_eq!(envelope["truncated"], true);
4378        assert_eq!(envelope["read_tool"], TOOL_RESULT_READ_TOOL_NAME);
4379        let id = envelope["tool_result_id"].as_str().expect("tool_result_id");
4380
4381        let slice = store
4382            .read(&ToolOutputArtifactId(id.to_string()), 0, 50)
4383            .await
4384            .expect("read artifact");
4385        assert_eq!(slice.content, "x".repeat(50));
4386        assert_eq!(slice.next_offset, 50);
4387        assert!(!slice.eof);
4388    }
4389
4390    #[tokio::test]
4391    async fn tool_result_read_enforces_explicit_max_read_size() {
4392        let store = Arc::new(InMemoryToolOutputArtifactStore::new());
4393        let spec = ToolSpec::new("big", "big output", json!({"type": "object"}));
4394        let request = ToolRequest::new(
4395            "call",
4396            "big",
4397            json!({}),
4398            SessionId::new("s"),
4399            TurnId::new("t"),
4400        );
4401        let ctx = ToolOutputTruncationContext::from((&request, spec));
4402        let artifact = store
4403            .put(&ctx, "abcdef".to_string(), 6)
4404            .await
4405            .expect("store artifact");
4406        let tool = ToolResultReadTool::new(store, 4);
4407        let owned_ctx = test_context();
4408        let mut tool_ctx = owned_ctx.borrowed();
4409
4410        let err = tool
4411            .invoke(
4412                ToolRequest::new(
4413                    "read-call",
4414                    TOOL_RESULT_READ_TOOL_NAME,
4415                    json!({"id": artifact.id.0, "offset": 0, "limit": 5}),
4416                    SessionId::new("s"),
4417                    TurnId::new("t"),
4418                ),
4419                &mut tool_ctx,
4420            )
4421            .await
4422            .expect_err("read past max must fail");
4423        match err {
4424            ToolError::InvalidInput(message) => assert!(message.contains("exceeds maximum")),
4425            other => panic!("expected InvalidInput, got {other:?}"),
4426        }
4427    }
4428
4429    #[tokio::test]
4430    async fn tool_result_read_rejects_zero_limit() {
4431        let store = Arc::new(InMemoryToolOutputArtifactStore::new());
4432        let spec = ToolSpec::new("big", "big output", json!({"type": "object"}));
4433        let request = ToolRequest::new(
4434            "call",
4435            "big",
4436            json!({}),
4437            SessionId::new("s"),
4438            TurnId::new("t"),
4439        );
4440        let ctx = ToolOutputTruncationContext::from((&request, spec));
4441        let artifact = store
4442            .put(&ctx, "abcdef".to_string(), 6)
4443            .await
4444            .expect("store artifact");
4445        let tool = ToolResultReadTool::new(store, 4);
4446        let owned_ctx = test_context();
4447        let mut tool_ctx = owned_ctx.borrowed();
4448
4449        let err = tool
4450            .invoke(
4451                ToolRequest::new(
4452                    "read-call",
4453                    TOOL_RESULT_READ_TOOL_NAME,
4454                    json!({"id": artifact.id.0, "offset": 0, "limit": 0}),
4455                    SessionId::new("s"),
4456                    TurnId::new("t"),
4457                ),
4458                &mut tool_ctx,
4459            )
4460            .await
4461            .expect_err("zero limit must fail");
4462        match err {
4463            ToolError::InvalidInput(message) => assert!(message.contains("greater than 0")),
4464            other => panic!("expected InvalidInput, got {other:?}"),
4465        }
4466    }
4467
4468    #[tokio::test]
4469    async fn tool_result_read_executor_allows_full_content_limit_with_envelope() {
4470        let store = Arc::new(InMemoryToolOutputArtifactStore::new());
4471        let spec = ToolSpec::new("big", "big output", json!({"type": "object"}));
4472        let request = ToolRequest::new(
4473            "call",
4474            "big",
4475            json!({}),
4476            SessionId::new("s"),
4477            TurnId::new("t"),
4478        );
4479        let ctx = ToolOutputTruncationContext::from((&request, spec));
4480        let artifact = store
4481            .put(&ctx, "abcd".to_string(), 4)
4482            .await
4483            .expect("store artifact");
4484        let executor = BasicToolExecutor::from_registry(
4485            ToolRegistry::new().with(ToolResultReadTool::new(store.clone(), 4)),
4486        )
4487        .with_output_truncation_strategy(ConfigurableToolOutputTruncationStrategy::new(store));
4488
4489        let outcome = executor
4490            .execute_owned(
4491                ToolRequest::new(
4492                    "read-call",
4493                    TOOL_RESULT_READ_TOOL_NAME,
4494                    json!({"id": artifact.id.0, "offset": 0, "limit": 4}),
4495                    SessionId::new("s"),
4496                    TurnId::new("t"),
4497                ),
4498                test_context(),
4499            )
4500            .await;
4501
4502        let ToolExecutionOutcome::Completed(result) = outcome else {
4503            panic!("expected completed outcome, got {outcome:?}");
4504        };
4505        let ToolOutput::Structured(output) = result.result.output else {
4506            panic!("expected structured readback output");
4507        };
4508        assert_eq!(output["content"], "abcd");
4509        assert_eq!(output["eof"], true);
4510    }
4511
4512    #[tokio::test]
4513    async fn tool_result_read_executor_allows_json_escaped_full_content_limit() {
4514        let store = Arc::new(InMemoryToolOutputArtifactStore::new());
4515        let spec = ToolSpec::new("big", "big output", json!({"type": "object"}));
4516        let request = ToolRequest::new(
4517            "call",
4518            "big",
4519            json!({}),
4520            SessionId::new("s"),
4521            TurnId::new("t"),
4522        );
4523        let ctx = ToolOutputTruncationContext::from((&request, spec));
4524        let content = "\0".repeat(4);
4525        let artifact = store
4526            .put(&ctx, content.clone(), content.len())
4527            .await
4528            .expect("store artifact");
4529        let executor = BasicToolExecutor::from_registry(
4530            ToolRegistry::new().with(ToolResultReadTool::new(store.clone(), 4)),
4531        )
4532        .with_output_truncation_strategy(ConfigurableToolOutputTruncationStrategy::new(store));
4533
4534        let outcome = executor
4535            .execute_owned(
4536                ToolRequest::new(
4537                    "read-call",
4538                    TOOL_RESULT_READ_TOOL_NAME,
4539                    json!({"id": artifact.id.0, "offset": 0, "limit": 4}),
4540                    SessionId::new("s"),
4541                    TurnId::new("t"),
4542                ),
4543                test_context(),
4544            )
4545            .await;
4546
4547        let ToolExecutionOutcome::Completed(result) = outcome else {
4548            panic!("expected completed outcome, got {outcome:?}");
4549        };
4550        let ToolOutput::Structured(output) = result.result.output else {
4551            panic!("expected structured readback output");
4552        };
4553        assert_eq!(output["content"], content);
4554        assert_eq!(output["eof"], true);
4555    }
4556
4557    #[test]
4558    fn inline_clip_respects_limit_when_marker_exceeds_budget() {
4559        let clipped = clip_string_with_marker("abcdef", 8, 1000);
4560
4561        assert!(clipped.len() <= 8);
4562        assert!(clipped.is_char_boundary(clipped.len()));
4563    }
4564
4565    #[test]
4566    fn filtered_hides_tools_rejected_by_predicate() {
4567        let source = registry_with(&["safe", "danger_drop", "danger_delete"])
4568            .filtered(|name| !name.0.starts_with("danger_"));
4569        let names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
4570        assert_eq!(names, vec!["safe"]);
4571
4572        assert!(source.get(&ToolName::new("safe")).is_some());
4573        assert!(source.get(&ToolName::new("danger_drop")).is_none());
4574    }
4575
4576    #[test]
4577    fn renamed_remaps_specs_and_lookups() {
4578        let source = registry_with(&["legacy_name", "passthrough"])
4579            .renamed([(ToolName::new("legacy_name"), ToolName::new("modern_name"))]);
4580        let mut names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
4581        names.sort();
4582        assert_eq!(names, vec!["modern_name", "passthrough"]);
4583
4584        assert!(source.get(&ToolName::new("modern_name")).is_some());
4585        assert!(
4586            source.get(&ToolName::new("legacy_name")).is_none(),
4587            "original name is hidden after renaming"
4588        );
4589        assert!(source.get(&ToolName::new("passthrough")).is_some());
4590    }
4591
4592    #[cfg(feature = "schemars")]
4593    mod schemars_helpers {
4594        use super::*;
4595        use schemars::JsonSchema;
4596        use serde::Deserialize;
4597
4598        #[derive(JsonSchema, Deserialize)]
4599        #[allow(dead_code)]
4600        struct WeatherInput {
4601            /// City name to look up.
4602            location: String,
4603            /// Use celsius (default false).
4604            #[serde(default)]
4605            celsius: bool,
4606        }
4607
4608        #[test]
4609        fn schema_for_emits_object_schema_with_typed_fields() {
4610            let schema = schema_for::<WeatherInput>();
4611            let obj = schema.as_object().expect("schema is a JSON object");
4612            assert_eq!(
4613                obj.get("type").and_then(|v| v.as_str()),
4614                Some("object"),
4615                "root type should be object"
4616            );
4617            let properties = obj
4618                .get("properties")
4619                .and_then(|v| v.as_object())
4620                .expect("properties block");
4621            assert!(properties.contains_key("location"));
4622            assert!(properties.contains_key("celsius"));
4623        }
4624
4625        #[test]
4626        fn tool_spec_for_carries_schema_name_and_description() {
4627            let spec = tool_spec_for::<WeatherInput>("get_weather", "Fetch current weather");
4628            assert_eq!(spec.name.0, "get_weather");
4629            assert_eq!(spec.description, "Fetch current weather");
4630            assert!(spec.input_schema.is_object());
4631        }
4632    }
4633
4634    #[test]
4635    fn transforms_compose_via_chained_methods() {
4636        let source = registry_with(&["read_file", "write_file", "delete_file"])
4637            .filtered(|name| name.0 != "delete_file")
4638            .prefixed("fs");
4639        let mut names: Vec<_> = source.specs().into_iter().map(|s| s.name.0).collect();
4640        names.sort();
4641        assert_eq!(names, vec!["fs_read_file", "fs_write_file"]);
4642    }
4643}