Skip to main content

fxrs_core/
tool.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use thiserror::Error;
10
11use crate::{
12    BoxFuture, PermissionRequest, ReadEvidenceStore, ScopedProjectContextProvider,
13    ToolAdvertisement, ToolCall, ToolResultStore,
14};
15
16/// Executor-neutral cooperative cancellation shared by agent and tool hosts.
17pub trait CancellationSignal: Send + Sync {
18    fn is_cancelled(&self) -> bool;
19}
20
21#[derive(Clone, Copy, Debug, Default)]
22pub struct NeverCancelled;
23
24impl CancellationSignal for NeverCancelled {
25    fn is_cancelled(&self) -> bool {
26        false
27    }
28}
29
30#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum ToolEffect {
33    Read,
34    Write,
35    Process,
36    Network,
37    UserInteraction,
38    Delegation,
39}
40
41/// Host execution isolation selected before a tool is prepared.
42///
43/// Core carries only the semantic choice. Native process adapters decide how
44/// to project `Os` (currently the macOS seatbelt profile) without pulling host
45/// details into policy or agent orchestration.
46#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
47#[serde(rename_all = "lowercase")]
48pub enum SandboxMode {
49    #[default]
50    None,
51    Os,
52}
53
54#[derive(Clone)]
55pub struct ToolContext {
56    pub workspace_root: PathBuf,
57    pub additional_roots: Vec<PathBuf>,
58    pub limits: ToolLimits,
59    pub read_evidence: Option<Arc<dyn ReadEvidenceStore>>,
60    pub tool_results: Option<Arc<dyn ToolResultStore>>,
61    pub project_context: Option<Arc<dyn ScopedProjectContextProvider>>,
62    pub cancellation: Arc<dyn CancellationSignal>,
63    pub sandbox: SandboxMode,
64}
65
66impl ToolContext {
67    pub fn new(workspace_root: PathBuf) -> Self {
68        Self {
69            workspace_root,
70            additional_roots: Vec::new(),
71            limits: ToolLimits::default(),
72            read_evidence: None,
73            tool_results: None,
74            project_context: None,
75            cancellation: Arc::new(NeverCancelled),
76            sandbox: SandboxMode::None,
77        }
78    }
79}
80
81impl fmt::Debug for ToolContext {
82    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83        formatter
84            .debug_struct("ToolContext")
85            .field("workspace_root", &self.workspace_root)
86            .field("additional_roots", &self.additional_roots)
87            .field("limits", &self.limits)
88            .field("has_read_evidence", &self.read_evidence.is_some())
89            .field("has_tool_result_store", &self.tool_results.is_some())
90            .field("has_project_context", &self.project_context.is_some())
91            .field("cancelled", &self.cancellation.is_cancelled())
92            .field("sandbox", &self.sandbox)
93            .finish()
94    }
95}
96
97#[derive(Clone, Copy, Debug)]
98pub struct ToolLimits {
99    pub max_result_bytes: usize,
100    pub max_read_file_lines: usize,
101    pub max_read_file_line_bytes: usize,
102    pub max_list_entries: usize,
103    pub command_timeout: Option<Duration>,
104}
105
106impl Default for ToolLimits {
107    fn default() -> Self {
108        Self {
109            max_result_bytes: 64 * 1024,
110            max_read_file_lines: 400,
111            max_read_file_line_bytes: 2_000,
112            max_list_entries: 100,
113            command_timeout: None,
114        }
115    }
116}
117
118#[derive(Clone, Deserialize, PartialEq, Serialize)]
119pub struct ToolOutput {
120    pub content: String,
121    #[serde(default)]
122    pub is_error: bool,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub structured: Option<Value>,
125    #[serde(default)]
126    pub original_bytes: usize,
127    #[serde(default)]
128    pub truncated: bool,
129    /// Complete transient content before projection into the model context.
130    ///
131    /// Tools that already hold a complete textual result may set this field so
132    /// the Agent can persist large output in a session-scoped sidecar. It is
133    /// deliberately excluded from serialization and must never be treated as
134    /// durable history by protocol or session adapters.
135    #[serde(skip)]
136    pub durable_content: Option<String>,
137}
138
139impl fmt::Debug for ToolOutput {
140    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141        formatter
142            .debug_struct("ToolOutput")
143            .field("content", &self.content)
144            .field("is_error", &self.is_error)
145            .field("structured", &self.structured)
146            .field("original_bytes", &self.original_bytes)
147            .field("truncated", &self.truncated)
148            .field("has_durable_content", &self.durable_content.is_some())
149            .finish()
150    }
151}
152
153#[derive(Debug, Error)]
154pub enum ToolError {
155    #[error("invalid arguments: {0}")]
156    InvalidArguments(String),
157    #[error("tool call is outside the active workspace: {0}")]
158    OutsideWorkspace(String),
159    #[error("permission denied: {0}")]
160    PermissionDenied(String),
161    #[error("tool execution failed: {0}")]
162    Execution(String),
163    #[error("tool was cancelled")]
164    Cancelled,
165}
166
167/// Host-neutral material needed to review a file mutation.
168///
169/// Keeping bytes instead of UTF-8 strings preserves the source behavior for
170/// an existing file whose contents are not valid Unicode. Presentation layers
171/// are responsible for producing a bounded textual or binary-aware diff.
172#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct FileChangeReview {
174    pub path: PathBuf,
175    pub before: Option<Vec<u8>>,
176    pub after: Vec<u8>,
177}
178
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub struct CommandReview {
181    pub command: String,
182    pub cwd: PathBuf,
183    pub shell: PathBuf,
184    pub profile: String,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
188pub enum ToolReview {
189    FileChange(FileChangeReview),
190    Command(CommandReview),
191}
192
193/// An approved action that can be consumed exactly once.
194///
195/// The consuming receiver makes replay impossible without cloning the
196/// concrete implementation's private state. That replaces the mutable
197/// commit-token bookkeeping required by the original Zig runtime.
198pub trait PreparedToolAction: Send {
199    fn commit<'a>(
200        self: Box<Self>,
201        context: &'a ToolContext,
202    ) -> BoxFuture<'a, Result<ToolOutput, ToolError>>;
203}
204
205pub struct PreparedToolCall {
206    pub tool_name: String,
207    pub permission_requests: Vec<PermissionRequest>,
208    pub irreversible: bool,
209    pub review: Option<ToolReview>,
210    action: Box<dyn PreparedToolAction>,
211}
212
213impl PreparedToolCall {
214    pub fn new(
215        tool_name: impl Into<String>,
216        permission_requests: Vec<PermissionRequest>,
217        irreversible: bool,
218        review: Option<ToolReview>,
219        action: impl PreparedToolAction + 'static,
220    ) -> Self {
221        Self {
222            tool_name: tool_name.into(),
223            permission_requests,
224            irreversible,
225            review,
226            action: Box::new(action),
227        }
228    }
229
230    pub fn commit<'a>(
231        self,
232        context: &'a ToolContext,
233    ) -> BoxFuture<'a, Result<ToolOutput, ToolError>> {
234        self.action.commit(context)
235    }
236}
237
238impl fmt::Debug for PreparedToolCall {
239    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
240        formatter
241            .debug_struct("PreparedToolCall")
242            .field("tool_name", &self.tool_name)
243            .field("permission_requests", &self.permission_requests)
244            .field("irreversible", &self.irreversible)
245            .field("review", &self.review)
246            .finish_non_exhaustive()
247    }
248}
249
250#[derive(Debug)]
251pub enum ToolPreparation {
252    Direct {
253        permission_requests: Vec<PermissionRequest>,
254        irreversible: bool,
255    },
256    Prepared(PreparedToolCall),
257}
258
259/// Runtime-extensible tool contract. Validation, effect classification, and
260/// execution stay together so registry metadata cannot drift from behavior.
261pub trait Tool: Send + Sync {
262    fn name(&self) -> &str;
263    fn description(&self) -> &str;
264    fn input_schema(&self) -> Value;
265    fn effect(&self, arguments: &Value) -> Result<ToolEffect, ToolError>;
266    fn irreversible(&self, _arguments: &Value) -> Result<bool, ToolError> {
267        Ok(false)
268    }
269
270    /// Filesystem targets used to select nested project instructions.
271    ///
272    /// This is separate from permission targets: command permission matches
273    /// human command text while its project-context target is the canonical
274    /// cwd, and network tools intentionally expose no filesystem target.
275    fn project_context_targets(
276        &self,
277        _context: &ToolContext,
278        _arguments: &Value,
279    ) -> Result<Vec<PathBuf>, ToolError> {
280        Ok(Vec::new())
281    }
282
283    fn permission_requests(
284        &self,
285        context: &ToolContext,
286        arguments: &Value,
287    ) -> Result<Vec<PermissionRequest>, ToolError> {
288        Ok(vec![PermissionRequest::new(
289            self.name(),
290            context.workspace_root.display().to_string(),
291            self.effect(arguments)?,
292        )])
293    }
294
295    /// Produces the authorization contract for a call. Most tools execute
296    /// directly after permission evaluation; TOCTOU-sensitive mutations
297    /// override this to return an owned, one-shot prepared action.
298    fn prepare(
299        &self,
300        context: &ToolContext,
301        arguments: &Value,
302    ) -> Result<ToolPreparation, ToolError> {
303        Ok(ToolPreparation::Direct {
304            permission_requests: self.permission_requests(context, arguments)?,
305            irreversible: self.irreversible(arguments)?,
306        })
307    }
308
309    fn execute<'a>(
310        &'a self,
311        context: &'a ToolContext,
312        arguments: Value,
313    ) -> BoxFuture<'a, Result<ToolOutput, ToolError>>;
314
315    fn advertisement(&self) -> ToolAdvertisement {
316        ToolAdvertisement::function(self.name(), self.description(), self.input_schema())
317    }
318}
319
320#[derive(Debug, Error, Eq, PartialEq)]
321pub enum RegistryError {
322    #[error("tool `{0}` is already registered")]
323    Duplicate(String),
324    #[error("tool `{0}` is not registered")]
325    Unknown(String),
326}
327
328#[derive(Clone, Default)]
329pub struct ToolRegistry {
330    tools: BTreeMap<String, Arc<dyn Tool>>,
331    order: Vec<String>,
332}
333
334impl ToolRegistry {
335    pub fn register<T: Tool + 'static>(&mut self, tool: T) -> Result<(), RegistryError> {
336        let name = tool.name().to_owned();
337        if self.tools.contains_key(&name) {
338            return Err(RegistryError::Duplicate(name));
339        }
340        self.tools.insert(name.clone(), Arc::new(tool));
341        self.order.push(name);
342        Ok(())
343    }
344
345    pub fn get(&self, name: &str) -> Result<Arc<dyn Tool>, RegistryError> {
346        self.tools
347            .get(name)
348            .cloned()
349            .ok_or_else(|| RegistryError::Unknown(name.to_owned()))
350    }
351
352    pub fn advertisements(&self) -> Vec<ToolAdvertisement> {
353        self.order
354            .iter()
355            .filter_map(|name| self.tools.get(name))
356            .map(|tool| tool.advertisement())
357            .collect()
358    }
359
360    pub fn validate_call(&self, call: &ToolCall) -> Result<(Arc<dyn Tool>, Value), ToolError> {
361        let tool = self
362            .get(&call.name)
363            .map_err(|error| ToolError::InvalidArguments(error.to_string()))?;
364        let arguments = call
365            .arguments()
366            .map_err(|error| ToolError::InvalidArguments(error.to_string()))?;
367        if !arguments.is_object() {
368            return Err(ToolError::InvalidArguments(
369                "tool arguments must be a JSON object".into(),
370            ));
371        }
372        Ok((tool, arguments))
373    }
374
375    pub fn len(&self) -> usize {
376        self.tools.len()
377    }
378
379    pub fn is_empty(&self) -> bool {
380        self.tools.is_empty()
381    }
382}