1use std::collections::{BTreeMap, BTreeSet};
31use std::path::{Path, PathBuf};
32use std::sync::Mutex;
33use std::time::Instant;
34
35use agentkit_core::{MetadataMap, SessionId, ToolOutput, ToolResultPart};
36use agentkit_tools_core::{
37 FileSystemPermissionRequest, PermissionCode, PermissionDenial, PermissionRequest, Tool,
38 ToolAnnotations, ToolContext, ToolError, ToolName, ToolRegistry, ToolRequest, ToolResources,
39 ToolResult, ToolSpec,
40};
41use async_trait::async_trait;
42use futures_lite::StreamExt;
43use serde::Deserialize;
44use serde_json::{Value, json};
45use thiserror::Error;
46
47pub fn registry() -> ToolRegistry {
63 ToolRegistry::new()
64 .with(ReadFileTool::default())
65 .with(WriteFileTool::default())
66 .with(ReplaceInFileTool::default())
67 .with(MoveTool::default())
68 .with(DeleteTool::default())
69 .with(ListDirectoryTool::default())
70 .with(CreateDirectoryTool::default())
71}
72
73#[derive(Debug, Error)]
80pub enum FileSystemToolError {
81 #[error("path {0} is not valid UTF-8")]
83 InvalidUtf8Path(PathBuf),
84 #[error("invalid line range: from={from:?} to={to:?}")]
86 InvalidLineRange {
87 from: Option<usize>,
89 to: Option<usize>,
91 },
92}
93
94#[derive(Clone, Debug, Default)]
109pub struct FileSystemToolPolicy {
110 require_read_before_write: bool,
111}
112
113impl FileSystemToolPolicy {
114 pub fn new() -> Self {
116 Self::default()
117 }
118
119 pub fn require_read_before_write(mut self, value: bool) -> Self {
124 self.require_read_before_write = value;
125 self
126 }
127}
128
129#[derive(Default)]
130struct SessionAccessState {
131 inspected_paths: BTreeSet<PathBuf>,
132}
133
134#[derive(Default)]
157pub struct FileSystemToolResources {
158 policy: FileSystemToolPolicy,
159 sessions: Mutex<BTreeMap<SessionId, SessionAccessState>>,
160}
161
162impl FileSystemToolResources {
163 pub fn new() -> Self {
165 Self::default()
166 }
167
168 pub fn with_policy(mut self, policy: FileSystemToolPolicy) -> Self {
170 self.policy = policy;
171 self
172 }
173
174 pub fn record_read(&self, session_id: &SessionId, path: &Path) {
179 self.record_inspected_path(session_id, path);
180 }
181
182 pub fn record_list(&self, session_id: &SessionId, path: &Path) {
187 self.record_inspected_path(session_id, path);
188 }
189
190 pub fn record_written(&self, session_id: &SessionId, path: &Path) {
195 self.record_inspected_path(session_id, path);
196 }
197
198 pub fn record_moved(&self, session_id: &SessionId, from: &Path, to: &Path) {
203 let mut sessions = self.sessions.lock().unwrap_or_else(|err| err.into_inner());
204 let state = sessions.entry(session_id.clone()).or_default();
205 state.inspected_paths.remove(from);
206 state.inspected_paths.insert(to.to_path_buf());
207 }
208
209 fn ensure_mutation_allowed(
210 &self,
211 session_id: Option<&SessionId>,
212 action: &'static str,
213 path: &Path,
214 target_exists: bool,
215 ) -> Result<(), ToolError> {
216 if !self.policy.require_read_before_write || !target_exists {
217 return Ok(());
218 }
219
220 let Some(session_id) = session_id else {
221 return Err(read_before_write_denial(action, path));
222 };
223
224 let sessions = self.sessions.lock().unwrap_or_else(|err| err.into_inner());
225 let Some(state) = sessions.get(session_id) else {
226 return Err(read_before_write_denial(action, path));
227 };
228
229 if state
230 .inspected_paths
231 .iter()
232 .any(|inspected| path == inspected || path.starts_with(inspected))
233 {
234 Ok(())
235 } else {
236 Err(read_before_write_denial(action, path))
237 }
238 }
239
240 fn record_inspected_path(&self, session_id: &SessionId, path: &Path) {
241 self.sessions
242 .lock()
243 .unwrap_or_else(|err| err.into_inner())
244 .entry(session_id.clone())
245 .or_default()
246 .inspected_paths
247 .insert(path.to_path_buf());
248 }
249}
250
251impl ToolResources for FileSystemToolResources {
252 fn as_any(&self) -> &dyn std::any::Any {
253 self
254 }
255}
256
257#[derive(Clone, Debug)]
274pub struct ReadFileTool {
275 spec: ToolSpec,
276}
277
278impl Default for ReadFileTool {
279 fn default() -> Self {
280 Self {
281 spec: ToolSpec {
282 name: ToolName::new("fs.read_file"),
283 description: "Read a UTF-8 text file from disk, optionally limited to a 1-based inclusive line range."
284 .into(),
285 input_schema: json!({
286 "type": "object",
287 "properties": {
288 "path": { "type": "string" },
289 "from": { "type": "integer", "minimum": 1 },
290 "to": { "type": "integer", "minimum": 1 }
291 },
292 "required": ["path"],
293 "additionalProperties": false
294 }),
295 annotations: ToolAnnotations {
296 read_only_hint: true,
297 idempotent_hint: true,
298 ..ToolAnnotations::default()
299 },
300 metadata: MetadataMap::new(),
301 },
302 }
303 }
304}
305
306#[derive(Deserialize)]
307struct ReadFileInput {
308 path: PathBuf,
309 from: Option<usize>,
310 to: Option<usize>,
311}
312
313#[async_trait]
314impl Tool for ReadFileTool {
315 fn spec(&self) -> &ToolSpec {
316 &self.spec
317 }
318
319 fn proposed_requests(
320 &self,
321 request: &ToolRequest,
322 ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
323 let input: ReadFileInput = parse_input(&request.input)?;
324 Ok(vec![Box::new(FileSystemPermissionRequest::Read {
325 path: input.path,
326 metadata: request.metadata.clone(),
327 })])
328 }
329
330 async fn invoke(
331 &self,
332 request: ToolRequest,
333 ctx: &mut ToolContext<'_>,
334 ) -> Result<ToolResult, ToolError> {
335 let started = Instant::now();
336 let input: ReadFileInput = parse_input(&request.input)?;
337 validate_line_range(input.from, input.to)?;
338
339 let contents = async_fs::read_to_string(&input.path)
340 .await
341 .map_err(|error| ToolError::ExecutionFailed(format!("failed to read file: {error}")))?;
342 let sliced = slice_lines(&contents, input.from, input.to)?;
343
344 if let (Some(session_id), Some(resources)) = (
345 ctx.capability.session_id,
346 file_system_resources(ctx.resources),
347 ) {
348 resources.record_read(session_id, &input.path);
349 }
350
351 Ok(ToolResult {
352 result: ToolResultPart {
353 call_id: request.call_id,
354 output: ToolOutput::Text(sliced),
355 is_error: false,
356 metadata: MetadataMap::new(),
357 },
358 duration: Some(started.elapsed()),
359 metadata: MetadataMap::new(),
360 })
361 }
362}
363
364#[derive(Clone, Debug)]
382pub struct WriteFileTool {
383 spec: ToolSpec,
384}
385
386impl Default for WriteFileTool {
387 fn default() -> Self {
388 Self {
389 spec: ToolSpec {
390 name: ToolName::new("fs.write_file"),
391 description: "Write UTF-8 text to a file, creating parent directories if needed."
392 .into(),
393 input_schema: json!({
394 "type": "object",
395 "properties": {
396 "path": { "type": "string" },
397 "contents": { "type": "string" },
398 "create_parents": { "type": "boolean", "default": true }
399 },
400 "required": ["path", "contents"],
401 "additionalProperties": false
402 }),
403 annotations: ToolAnnotations {
404 destructive_hint: true,
405 idempotent_hint: false,
406 ..ToolAnnotations::default()
407 },
408 metadata: MetadataMap::new(),
409 },
410 }
411 }
412}
413
414#[derive(Deserialize)]
415struct WriteFileInput {
416 path: PathBuf,
417 contents: String,
418 #[serde(default = "default_true")]
419 create_parents: bool,
420}
421
422#[async_trait]
423impl Tool for WriteFileTool {
424 fn spec(&self) -> &ToolSpec {
425 &self.spec
426 }
427
428 fn proposed_requests(
429 &self,
430 request: &ToolRequest,
431 ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
432 let input: WriteFileInput = parse_input(&request.input)?;
433 Ok(vec![Box::new(FileSystemPermissionRequest::Write {
434 path: input.path,
435 metadata: request.metadata.clone(),
436 })])
437 }
438
439 async fn invoke(
440 &self,
441 request: ToolRequest,
442 ctx: &mut ToolContext<'_>,
443 ) -> Result<ToolResult, ToolError> {
444 let started = Instant::now();
445 let input: WriteFileInput = parse_input(&request.input)?;
446 let existed = path_exists(&input.path).await?;
447 enforce_mutation_policy(ctx, "write", &input.path, existed)?;
448
449 if input.create_parents
450 && let Some(parent) = input.path.parent()
451 {
452 async_fs::create_dir_all(parent).await.map_err(|error| {
453 ToolError::ExecutionFailed(format!(
454 "failed to create parent directories for {}: {error}",
455 input.path.display()
456 ))
457 })?;
458 }
459
460 async_fs::write(&input.path, input.contents.as_bytes())
461 .await
462 .map_err(|error| {
463 ToolError::ExecutionFailed(format!("failed to write file: {error}"))
464 })?;
465
466 if let (Some(session_id), Some(resources)) = (
467 ctx.capability.session_id,
468 file_system_resources(ctx.resources),
469 ) {
470 resources.record_written(session_id, &input.path);
471 }
472
473 Ok(ToolResult {
474 result: ToolResultPart {
475 call_id: request.call_id,
476 output: ToolOutput::Structured(json!({
477 "path": input.path.display().to_string(),
478 "bytes_written": input.contents.len(),
479 "created": !existed,
480 })),
481 is_error: false,
482 metadata: MetadataMap::new(),
483 },
484 duration: Some(started.elapsed()),
485 metadata: MetadataMap::new(),
486 })
487 }
488}
489
490#[derive(Clone, Debug)]
507pub struct ReplaceInFileTool {
508 spec: ToolSpec,
509}
510
511impl Default for ReplaceInFileTool {
512 fn default() -> Self {
513 Self {
514 spec: ToolSpec {
515 name: ToolName::new("fs.replace_in_file"),
516 description:
517 "Replace exact text in a UTF-8 file. Fails if the search text is not found."
518 .into(),
519 input_schema: json!({
520 "type": "object",
521 "properties": {
522 "path": { "type": "string" },
523 "find": { "type": "string" },
524 "replace": { "type": "string" },
525 "replace_all": { "type": "boolean", "default": false }
526 },
527 "required": ["path", "find", "replace"],
528 "additionalProperties": false
529 }),
530 annotations: ToolAnnotations {
531 destructive_hint: true,
532 idempotent_hint: false,
533 ..ToolAnnotations::default()
534 },
535 metadata: MetadataMap::new(),
536 },
537 }
538 }
539}
540
541#[derive(Deserialize)]
542struct ReplaceInFileInput {
543 path: PathBuf,
544 find: String,
545 replace: String,
546 #[serde(default)]
547 replace_all: bool,
548}
549
550#[async_trait]
551impl Tool for ReplaceInFileTool {
552 fn spec(&self) -> &ToolSpec {
553 &self.spec
554 }
555
556 fn proposed_requests(
557 &self,
558 request: &ToolRequest,
559 ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
560 let input: ReplaceInFileInput = parse_input(&request.input)?;
561 Ok(vec![Box::new(FileSystemPermissionRequest::Edit {
562 path: input.path,
563 metadata: request.metadata.clone(),
564 })])
565 }
566
567 async fn invoke(
568 &self,
569 request: ToolRequest,
570 ctx: &mut ToolContext<'_>,
571 ) -> Result<ToolResult, ToolError> {
572 let started = Instant::now();
573 let input: ReplaceInFileInput = parse_input(&request.input)?;
574 enforce_mutation_policy(ctx, "edit", &input.path, true)?;
575
576 let contents = async_fs::read_to_string(&input.path)
577 .await
578 .map_err(|error| ToolError::ExecutionFailed(format!("failed to read file: {error}")))?;
579
580 let replacement_count = contents.matches(&input.find).count();
581 if replacement_count == 0 {
582 return Err(ToolError::ExecutionFailed(format!(
583 "search text not found in {}",
584 input.path.display()
585 )));
586 }
587
588 let updated = if input.replace_all {
589 contents.replace(&input.find, &input.replace)
590 } else {
591 contents.replacen(&input.find, &input.replace, 1)
592 };
593 let applied = if input.replace_all {
594 replacement_count
595 } else {
596 1
597 };
598
599 async_fs::write(&input.path, updated.as_bytes())
600 .await
601 .map_err(|error| {
602 ToolError::ExecutionFailed(format!("failed to write file: {error}"))
603 })?;
604
605 if let (Some(session_id), Some(resources)) = (
606 ctx.capability.session_id,
607 file_system_resources(ctx.resources),
608 ) {
609 resources.record_written(session_id, &input.path);
610 }
611
612 Ok(ToolResult {
613 result: ToolResultPart {
614 call_id: request.call_id,
615 output: ToolOutput::Structured(json!({
616 "path": input.path.display().to_string(),
617 "replacements": applied,
618 })),
619 is_error: false,
620 metadata: MetadataMap::new(),
621 },
622 duration: Some(started.elapsed()),
623 metadata: MetadataMap::new(),
624 })
625 }
626}
627
628#[derive(Clone, Debug)]
646pub struct MoveTool {
647 spec: ToolSpec,
648}
649
650impl Default for MoveTool {
651 fn default() -> Self {
652 Self {
653 spec: ToolSpec {
654 name: ToolName::new("fs.move"),
655 description: "Move or rename a file or directory.".into(),
656 input_schema: json!({
657 "type": "object",
658 "properties": {
659 "from": { "type": "string" },
660 "to": { "type": "string" },
661 "create_parents": { "type": "boolean", "default": true },
662 "overwrite": { "type": "boolean", "default": false }
663 },
664 "required": ["from", "to"],
665 "additionalProperties": false
666 }),
667 annotations: ToolAnnotations {
668 destructive_hint: true,
669 idempotent_hint: false,
670 ..ToolAnnotations::default()
671 },
672 metadata: MetadataMap::new(),
673 },
674 }
675 }
676}
677
678#[derive(Deserialize)]
679struct MoveInput {
680 from: PathBuf,
681 to: PathBuf,
682 #[serde(default = "default_true")]
683 create_parents: bool,
684 #[serde(default)]
685 overwrite: bool,
686}
687
688#[async_trait]
689impl Tool for MoveTool {
690 fn spec(&self) -> &ToolSpec {
691 &self.spec
692 }
693
694 fn proposed_requests(
695 &self,
696 request: &ToolRequest,
697 ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
698 let input: MoveInput = parse_input(&request.input)?;
699 Ok(vec![Box::new(FileSystemPermissionRequest::Move {
700 from: input.from,
701 to: input.to,
702 metadata: request.metadata.clone(),
703 })])
704 }
705
706 async fn invoke(
707 &self,
708 request: ToolRequest,
709 ctx: &mut ToolContext<'_>,
710 ) -> Result<ToolResult, ToolError> {
711 let started = Instant::now();
712 let input: MoveInput = parse_input(&request.input)?;
713 enforce_mutation_policy(ctx, "move", &input.from, true)?;
714
715 if path_exists(&input.to).await? {
716 if input.overwrite {
717 remove_path(&input.to, true, true).await?;
718 } else {
719 return Err(ToolError::ExecutionFailed(format!(
720 "destination {} already exists",
721 input.to.display()
722 )));
723 }
724 }
725
726 if input.create_parents
727 && let Some(parent) = input.to.parent()
728 {
729 async_fs::create_dir_all(parent).await.map_err(|error| {
730 ToolError::ExecutionFailed(format!(
731 "failed to create parent directories for {}: {error}",
732 input.to.display()
733 ))
734 })?;
735 }
736
737 async_fs::rename(&input.from, &input.to)
738 .await
739 .map_err(|error| {
740 ToolError::ExecutionFailed(format!(
741 "failed to move {} to {}: {error}",
742 input.from.display(),
743 input.to.display()
744 ))
745 })?;
746
747 if let (Some(session_id), Some(resources)) = (
748 ctx.capability.session_id,
749 file_system_resources(ctx.resources),
750 ) {
751 resources.record_moved(session_id, &input.from, &input.to);
752 }
753
754 Ok(ToolResult {
755 result: ToolResultPart {
756 call_id: request.call_id,
757 output: ToolOutput::Structured(json!({
758 "from": input.from.display().to_string(),
759 "to": input.to.display().to_string(),
760 "moved": true,
761 })),
762 is_error: false,
763 metadata: MetadataMap::new(),
764 },
765 duration: Some(started.elapsed()),
766 metadata: MetadataMap::new(),
767 })
768 }
769}
770
771#[derive(Clone, Debug)]
788pub struct DeleteTool {
789 spec: ToolSpec,
790}
791
792impl Default for DeleteTool {
793 fn default() -> Self {
794 Self {
795 spec: ToolSpec {
796 name: ToolName::new("fs.delete"),
797 description: "Delete a file or directory.".into(),
798 input_schema: json!({
799 "type": "object",
800 "properties": {
801 "path": { "type": "string" },
802 "recursive": { "type": "boolean", "default": false },
803 "missing_ok": { "type": "boolean", "default": false }
804 },
805 "required": ["path"],
806 "additionalProperties": false
807 }),
808 annotations: ToolAnnotations {
809 destructive_hint: true,
810 idempotent_hint: false,
811 ..ToolAnnotations::default()
812 },
813 metadata: MetadataMap::new(),
814 },
815 }
816 }
817}
818
819#[derive(Deserialize)]
820struct DeleteInput {
821 path: PathBuf,
822 #[serde(default)]
823 recursive: bool,
824 #[serde(default)]
825 missing_ok: bool,
826}
827
828#[async_trait]
829impl Tool for DeleteTool {
830 fn spec(&self) -> &ToolSpec {
831 &self.spec
832 }
833
834 fn proposed_requests(
835 &self,
836 request: &ToolRequest,
837 ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
838 let input: DeleteInput = parse_input(&request.input)?;
839 Ok(vec![Box::new(FileSystemPermissionRequest::Delete {
840 path: input.path,
841 metadata: request.metadata.clone(),
842 })])
843 }
844
845 async fn invoke(
846 &self,
847 request: ToolRequest,
848 ctx: &mut ToolContext<'_>,
849 ) -> Result<ToolResult, ToolError> {
850 let started = Instant::now();
851 let input: DeleteInput = parse_input(&request.input)?;
852 let existed = path_exists(&input.path).await?;
853 if !existed && input.missing_ok {
854 return Ok(ToolResult {
855 result: ToolResultPart {
856 call_id: request.call_id,
857 output: ToolOutput::Structured(json!({
858 "path": input.path.display().to_string(),
859 "deleted": false,
860 "missing": true,
861 })),
862 is_error: false,
863 metadata: MetadataMap::new(),
864 },
865 duration: Some(started.elapsed()),
866 metadata: MetadataMap::new(),
867 });
868 }
869
870 enforce_mutation_policy(ctx, "delete", &input.path, existed)?;
871 remove_path(&input.path, input.recursive, false).await?;
872
873 Ok(ToolResult {
874 result: ToolResultPart {
875 call_id: request.call_id,
876 output: ToolOutput::Structured(json!({
877 "path": input.path.display().to_string(),
878 "deleted": true,
879 })),
880 is_error: false,
881 metadata: MetadataMap::new(),
882 },
883 duration: Some(started.elapsed()),
884 metadata: MetadataMap::new(),
885 })
886 }
887}
888
889#[derive(Clone, Debug)]
907pub struct ListDirectoryTool {
908 spec: ToolSpec,
909}
910
911impl Default for ListDirectoryTool {
912 fn default() -> Self {
913 Self {
914 spec: ToolSpec {
915 name: ToolName::new("fs.list_directory"),
916 description: "List the entries in a directory.".into(),
917 input_schema: json!({
918 "type": "object",
919 "properties": {
920 "path": { "type": "string" }
921 },
922 "required": ["path"],
923 "additionalProperties": false
924 }),
925 annotations: ToolAnnotations {
926 read_only_hint: true,
927 idempotent_hint: true,
928 ..ToolAnnotations::default()
929 },
930 metadata: MetadataMap::new(),
931 },
932 }
933 }
934}
935
936#[derive(Deserialize)]
937struct ListDirectoryInput {
938 path: PathBuf,
939}
940
941#[async_trait]
942impl Tool for ListDirectoryTool {
943 fn spec(&self) -> &ToolSpec {
944 &self.spec
945 }
946
947 fn proposed_requests(
948 &self,
949 request: &ToolRequest,
950 ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
951 let input: ListDirectoryInput = parse_input(&request.input)?;
952 Ok(vec![Box::new(FileSystemPermissionRequest::List {
953 path: input.path,
954 metadata: request.metadata.clone(),
955 })])
956 }
957
958 async fn invoke(
959 &self,
960 request: ToolRequest,
961 ctx: &mut ToolContext<'_>,
962 ) -> Result<ToolResult, ToolError> {
963 let started = Instant::now();
964 let input: ListDirectoryInput = parse_input(&request.input)?;
965 let mut entries = Vec::new();
966 let mut dir = async_fs::read_dir(&input.path).await.map_err(|error| {
967 ToolError::ExecutionFailed(format!(
968 "failed to list directory {}: {error}",
969 input.path.display()
970 ))
971 })?;
972
973 while let Some(entry) = dir.next().await {
974 let entry = entry.map_err(|error| {
975 ToolError::ExecutionFailed(format!(
976 "failed to read directory entry in {}: {error}",
977 input.path.display()
978 ))
979 })?;
980 let file_type = entry.file_type().await.map_err(|error| {
981 ToolError::ExecutionFailed(format!(
982 "failed to inspect directory entry in {}: {error}",
983 input.path.display()
984 ))
985 })?;
986 entries.push(json!({
987 "name": path_name(entry.path())?,
988 "path": entry.path().display().to_string(),
989 "kind": file_kind_label(&file_type),
990 }));
991 }
992
993 if let (Some(session_id), Some(resources)) = (
994 ctx.capability.session_id,
995 file_system_resources(ctx.resources),
996 ) {
997 resources.record_list(session_id, &input.path);
998 }
999
1000 Ok(ToolResult {
1001 result: ToolResultPart {
1002 call_id: request.call_id,
1003 output: ToolOutput::Structured(Value::Array(entries)),
1004 is_error: false,
1005 metadata: MetadataMap::new(),
1006 },
1007 duration: Some(started.elapsed()),
1008 metadata: MetadataMap::new(),
1009 })
1010 }
1011}
1012
1013#[derive(Clone, Debug)]
1030pub struct CreateDirectoryTool {
1031 spec: ToolSpec,
1032}
1033
1034impl Default for CreateDirectoryTool {
1035 fn default() -> Self {
1036 Self {
1037 spec: ToolSpec {
1038 name: ToolName::new("fs.create_directory"),
1039 description: "Create a directory and any missing parent directories.".into(),
1040 input_schema: json!({
1041 "type": "object",
1042 "properties": {
1043 "path": { "type": "string" }
1044 },
1045 "required": ["path"],
1046 "additionalProperties": false
1047 }),
1048 annotations: ToolAnnotations {
1049 destructive_hint: true,
1050 idempotent_hint: true,
1051 ..ToolAnnotations::default()
1052 },
1053 metadata: MetadataMap::new(),
1054 },
1055 }
1056 }
1057}
1058
1059#[derive(Deserialize)]
1060struct CreateDirectoryInput {
1061 path: PathBuf,
1062}
1063
1064#[async_trait]
1065impl Tool for CreateDirectoryTool {
1066 fn spec(&self) -> &ToolSpec {
1067 &self.spec
1068 }
1069
1070 fn proposed_requests(
1071 &self,
1072 request: &ToolRequest,
1073 ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
1074 let input: CreateDirectoryInput = parse_input(&request.input)?;
1075 Ok(vec![Box::new(FileSystemPermissionRequest::CreateDir {
1076 path: input.path,
1077 metadata: request.metadata.clone(),
1078 })])
1079 }
1080
1081 async fn invoke(
1082 &self,
1083 request: ToolRequest,
1084 _ctx: &mut ToolContext<'_>,
1085 ) -> Result<ToolResult, ToolError> {
1086 let started = Instant::now();
1087 let input: CreateDirectoryInput = parse_input(&request.input)?;
1088 async_fs::create_dir_all(&input.path)
1089 .await
1090 .map_err(|error| {
1091 ToolError::ExecutionFailed(format!(
1092 "failed to create directory {}: {error}",
1093 input.path.display()
1094 ))
1095 })?;
1096
1097 Ok(ToolResult {
1098 result: ToolResultPart {
1099 call_id: request.call_id,
1100 output: ToolOutput::Structured(json!({
1101 "path": input.path.display().to_string(),
1102 "created": true,
1103 })),
1104 is_error: false,
1105 metadata: MetadataMap::new(),
1106 },
1107 duration: Some(started.elapsed()),
1108 metadata: MetadataMap::new(),
1109 })
1110 }
1111}
1112
1113fn parse_input<T>(value: &Value) -> Result<T, ToolError>
1114where
1115 T: for<'de> Deserialize<'de>,
1116{
1117 serde_json::from_value(value.clone())
1118 .map_err(|error| ToolError::InvalidInput(format!("invalid tool input: {error}")))
1119}
1120
1121fn default_true() -> bool {
1122 true
1123}
1124
1125fn validate_line_range(from: Option<usize>, to: Option<usize>) -> Result<(), ToolError> {
1126 if matches!((from, to), (Some(start), Some(end)) if end < start) {
1127 return Err(ToolError::InvalidInput(
1128 FileSystemToolError::InvalidLineRange { from, to }.to_string(),
1129 ));
1130 }
1131 Ok(())
1132}
1133
1134fn slice_lines(
1135 contents: &str,
1136 from: Option<usize>,
1137 to: Option<usize>,
1138) -> Result<String, ToolError> {
1139 validate_line_range(from, to)?;
1140 if from.is_none() && to.is_none() {
1141 return Ok(contents.to_string());
1142 }
1143
1144 let start = from.unwrap_or(1);
1145 let end = to.unwrap_or(usize::MAX);
1146 let selected = contents
1147 .lines()
1148 .enumerate()
1149 .filter_map(|(index, line)| {
1150 let line_number = index + 1;
1151 (line_number >= start && line_number <= end).then_some(line)
1152 })
1153 .collect::<Vec<_>>();
1154
1155 Ok(selected.join("\n"))
1156}
1157
1158fn file_system_resources(resources: &dyn ToolResources) -> Option<&FileSystemToolResources> {
1159 resources.as_any().downcast_ref::<FileSystemToolResources>()
1160}
1161
1162fn enforce_mutation_policy(
1163 ctx: &ToolContext<'_>,
1164 action: &'static str,
1165 path: &Path,
1166 target_exists: bool,
1167) -> Result<(), ToolError> {
1168 let Some(resources) = file_system_resources(ctx.resources) else {
1169 return Ok(());
1170 };
1171
1172 resources.ensure_mutation_allowed(ctx.capability.session_id, action, path, target_exists)
1173}
1174
1175fn read_before_write_denial(action: &'static str, path: &Path) -> ToolError {
1176 ToolError::PermissionDenied(PermissionDenial {
1177 code: PermissionCode::CustomPolicyDenied,
1178 message: format!(
1179 "filesystem policy requires reading {} before attempting to {} it",
1180 path.display(),
1181 action
1182 ),
1183 metadata: MetadataMap::new(),
1184 })
1185}
1186
1187async fn path_exists(path: &Path) -> Result<bool, ToolError> {
1188 Ok(async_fs::metadata(path).await.is_ok())
1189}
1190
1191async fn remove_path(path: &Path, recursive: bool, overwrite: bool) -> Result<(), ToolError> {
1192 let metadata = async_fs::metadata(path).await.map_err(|error| {
1193 ToolError::ExecutionFailed(format!(
1194 "failed to inspect {} before deletion: {error}",
1195 path.display()
1196 ))
1197 })?;
1198
1199 if metadata.is_dir() {
1200 if recursive || overwrite {
1201 async_fs::remove_dir_all(path).await.map_err(|error| {
1202 ToolError::ExecutionFailed(format!(
1203 "failed to remove directory {}: {error}",
1204 path.display()
1205 ))
1206 })?;
1207 } else {
1208 async_fs::remove_dir(path).await.map_err(|error| {
1209 ToolError::ExecutionFailed(format!(
1210 "failed to remove directory {}: {error}",
1211 path.display()
1212 ))
1213 })?;
1214 }
1215 } else {
1216 async_fs::remove_file(path).await.map_err(|error| {
1217 ToolError::ExecutionFailed(format!("failed to remove file {}: {error}", path.display()))
1218 })?;
1219 }
1220
1221 Ok(())
1222}
1223
1224fn path_name(path: impl AsRef<Path>) -> Result<String, ToolError> {
1225 let path = path.as_ref();
1226 let name = path.file_name().ok_or_else(|| {
1227 ToolError::ExecutionFailed(format!("path {} has no file name", path.display()))
1228 })?;
1229
1230 name.to_str().map(|value| value.to_string()).ok_or_else(|| {
1231 ToolError::ExecutionFailed(
1232 FileSystemToolError::InvalidUtf8Path(path.to_path_buf()).to_string(),
1233 )
1234 })
1235}
1236
1237fn file_kind_label(file_type: &std::fs::FileType) -> &'static str {
1238 if file_type.is_dir() {
1239 "directory"
1240 } else if file_type.is_symlink() {
1241 "symlink"
1242 } else {
1243 "file"
1244 }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use std::time::{SystemTime, UNIX_EPOCH};
1250
1251 use agentkit_capabilities::CapabilityContext;
1252 use agentkit_core::{SessionId, ToolCallId, TurnId};
1253 use agentkit_tools_core::{
1254 BasicToolExecutor, PermissionChecker, PermissionDecision, ToolExecutionOutcome,
1255 ToolExecutor,
1256 };
1257
1258 use super::*;
1259
1260 struct AllowAll;
1261
1262 impl PermissionChecker for AllowAll {
1263 fn evaluate(&self, _request: &dyn PermissionRequest) -> PermissionDecision {
1264 PermissionDecision::Allow
1265 }
1266 }
1267
1268 fn temp_dir(name: &str) -> PathBuf {
1269 let nanos = SystemTime::now()
1270 .duration_since(UNIX_EPOCH)
1271 .expect("time moved backwards")
1272 .as_nanos();
1273 std::env::temp_dir().join(format!("agentkit-{name}-{nanos}"))
1274 }
1275
1276 fn tool_context<'a>(
1277 session_id: &'a SessionId,
1278 turn_id: &'a TurnId,
1279 metadata: &'a MetadataMap,
1280 resources: &'a dyn ToolResources,
1281 ) -> ToolContext<'a> {
1282 ToolContext {
1283 capability: CapabilityContext {
1284 session_id: Some(session_id),
1285 turn_id: Some(turn_id),
1286 metadata,
1287 },
1288 permissions: &AllowAll,
1289 resources,
1290 cancellation: None,
1291 }
1292 }
1293
1294 fn request(
1295 tool_name: &str,
1296 input: Value,
1297 session_id: &SessionId,
1298 turn_id: &TurnId,
1299 ) -> ToolRequest {
1300 ToolRequest {
1301 call_id: ToolCallId::new(format!("call-{tool_name}")),
1302 tool_name: ToolName::new(tool_name),
1303 input,
1304 session_id: session_id.clone(),
1305 turn_id: turn_id.clone(),
1306 metadata: MetadataMap::new(),
1307 }
1308 }
1309
1310 #[test]
1311 fn registry_exposes_expected_tools() {
1312 let specs = registry().specs();
1313 let names: Vec<_> = specs.into_iter().map(|spec| spec.name.0).collect();
1314 assert!(names.contains(&"fs.read_file".into()));
1315 assert!(names.contains(&"fs.write_file".into()));
1316 assert!(names.contains(&"fs.replace_in_file".into()));
1317 assert!(names.contains(&"fs.move".into()));
1318 assert!(names.contains(&"fs.delete".into()));
1319 assert!(names.contains(&"fs.list_directory".into()));
1320 assert!(names.contains(&"fs.create_directory".into()));
1321 }
1322
1323 #[tokio::test]
1324 async fn write_then_ranged_read_roundtrip() {
1325 let root = temp_dir("fs");
1326 async_fs::create_dir_all(&root).await.unwrap();
1327 let target = root.join("note.txt");
1328 let session_id = SessionId::new("session-1");
1329 let turn_id = TurnId::new("turn-1");
1330
1331 let executor = BasicToolExecutor::new(registry());
1332 let metadata = MetadataMap::new();
1333 let mut ctx = tool_context(&session_id, &turn_id, &metadata, &());
1334
1335 let write = executor
1336 .execute(
1337 request(
1338 "fs.write_file",
1339 json!({
1340 "path": target.display().to_string(),
1341 "contents": "alpha\nbeta\ngamma"
1342 }),
1343 &session_id,
1344 &turn_id,
1345 ),
1346 &mut ctx,
1347 )
1348 .await;
1349 assert!(matches!(write, ToolExecutionOutcome::Completed(_)));
1350
1351 let read = executor
1352 .execute(
1353 request(
1354 "fs.read_file",
1355 json!({
1356 "path": target.display().to_string(),
1357 "from": 2,
1358 "to": 3
1359 }),
1360 &session_id,
1361 &turn_id,
1362 ),
1363 &mut ctx,
1364 )
1365 .await;
1366
1367 match read {
1368 ToolExecutionOutcome::Completed(result) => {
1369 assert_eq!(result.result.output, ToolOutput::Text("beta\ngamma".into()));
1370 }
1371 other => panic!("unexpected outcome: {other:?}"),
1372 }
1373
1374 let _ = async_fs::remove_dir_all(root).await;
1375 }
1376
1377 #[tokio::test]
1378 async fn replace_move_and_delete_work() {
1379 let root = temp_dir("fs-edit");
1380 async_fs::create_dir_all(&root).await.unwrap();
1381 let source = root.join("source.txt");
1382 let destination = root.join("archive").join("renamed.txt");
1383 async_fs::write(&source, "hello world").await.unwrap();
1384
1385 let resources = FileSystemToolResources::new()
1386 .with_policy(FileSystemToolPolicy::new().require_read_before_write(true));
1387 let session_id = SessionId::new("session-2");
1388 let turn_id = TurnId::new("turn-2");
1389 let metadata = MetadataMap::new();
1390 let executor = BasicToolExecutor::new(registry());
1391 let mut ctx = tool_context(&session_id, &turn_id, &metadata, &resources);
1392
1393 let denied_edit = executor
1394 .execute(
1395 request(
1396 "fs.replace_in_file",
1397 json!({
1398 "path": source.display().to_string(),
1399 "find": "world",
1400 "replace": "agentkit"
1401 }),
1402 &session_id,
1403 &turn_id,
1404 ),
1405 &mut ctx,
1406 )
1407 .await;
1408 assert!(matches!(
1409 denied_edit,
1410 ToolExecutionOutcome::Failed(ToolError::PermissionDenied(_))
1411 ));
1412
1413 let read = executor
1414 .execute(
1415 request(
1416 "fs.read_file",
1417 json!({
1418 "path": source.display().to_string()
1419 }),
1420 &session_id,
1421 &turn_id,
1422 ),
1423 &mut ctx,
1424 )
1425 .await;
1426 assert!(matches!(read, ToolExecutionOutcome::Completed(_)));
1427
1428 let replace = executor
1429 .execute(
1430 request(
1431 "fs.replace_in_file",
1432 json!({
1433 "path": source.display().to_string(),
1434 "find": "world",
1435 "replace": "agentkit"
1436 }),
1437 &session_id,
1438 &turn_id,
1439 ),
1440 &mut ctx,
1441 )
1442 .await;
1443 assert!(matches!(replace, ToolExecutionOutcome::Completed(_)));
1444
1445 let move_result = executor
1446 .execute(
1447 request(
1448 "fs.move",
1449 json!({
1450 "from": source.display().to_string(),
1451 "to": destination.display().to_string()
1452 }),
1453 &session_id,
1454 &turn_id,
1455 ),
1456 &mut ctx,
1457 )
1458 .await;
1459 assert!(matches!(move_result, ToolExecutionOutcome::Completed(_)));
1460
1461 let read_moved = async_fs::read_to_string(&destination).await.unwrap();
1462 assert_eq!(read_moved, "hello agentkit");
1463
1464 let delete = executor
1465 .execute(
1466 request(
1467 "fs.delete",
1468 json!({
1469 "path": destination.display().to_string()
1470 }),
1471 &session_id,
1472 &turn_id,
1473 ),
1474 &mut ctx,
1475 )
1476 .await;
1477 assert!(matches!(delete, ToolExecutionOutcome::Completed(_)));
1478 assert!(!path_exists(&destination).await.unwrap());
1479
1480 let _ = async_fs::remove_dir_all(root).await;
1481 }
1482}