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