Skip to main content

opi_coding_agent/tool/
read.rs

1use std::future::Future;
2use std::path::PathBuf;
3use std::pin::Pin;
4
5use opi_agent::tool::{ExecutionMode, Tool, ToolError, ToolResult};
6use opi_ai::message::{OutputContent, ToolDef};
7use schemars::JsonSchema;
8use serde::Deserialize;
9use tokio_util::sync::CancellationToken;
10
11use super::PathPolicy;
12
13#[derive(Debug, Deserialize, JsonSchema)]
14pub struct ReadArgs {
15    /// Relative path within workspace to read.
16    pub path: String,
17    /// 1-based line offset (optional, defaults to 1).
18    pub offset: Option<usize>,
19    /// Maximum number of lines to read (optional).
20    pub limit: Option<usize>,
21}
22
23pub struct ReadTool {
24    workspace_root: PathBuf,
25    path_policy: PathPolicy,
26    schema: serde_json::Value,
27}
28
29impl ReadTool {
30    pub fn new(workspace_root: PathBuf) -> Self {
31        Self::new_with_policy(workspace_root, PathPolicy::WorkspaceOnly)
32    }
33
34    pub fn new_with_policy(workspace_root: PathBuf, path_policy: PathPolicy) -> Self {
35        let schema = schemars::schema_for!(ReadArgs);
36        Self {
37            workspace_root,
38            path_policy,
39            schema: serde_json::to_value(&schema).unwrap_or_default(),
40        }
41    }
42}
43
44impl Tool for ReadTool {
45    fn definition(&self) -> ToolDef {
46        ToolDef {
47            name: "read".into(),
48            description: "Read file content with optional line range.".into(),
49            input_schema: self.schema.clone(),
50        }
51    }
52
53    fn execute(
54        &self,
55        _call_id: &str,
56        arguments: serde_json::Value,
57        _signal: CancellationToken,
58        _on_update: Option<opi_agent::tool::UpdateCallback>,
59    ) -> Pin<Box<dyn Future<Output = Result<ToolResult, ToolError>> + Send>> {
60        let args: ReadArgs = match serde_json::from_value(arguments) {
61            Ok(a) => a,
62            Err(e) => {
63                return Box::pin(async move {
64                    Ok(ToolResult {
65                        content: vec![OutputContent::Text {
66                            text: format!("invalid arguments: {e}"),
67                        }],
68                        details: None,
69                        is_error: true,
70                        terminate: false,
71                    })
72                });
73            }
74        };
75        let resolved_path =
76            match super::resolve_tool_path(&self.workspace_root, &args.path, self.path_policy) {
77                Ok(p) => p,
78                Err(msg) => {
79                    return Box::pin(async move {
80                        Ok(ToolResult {
81                            content: vec![OutputContent::Text { text: msg }],
82                            details: None,
83                            is_error: true,
84                            terminate: false,
85                        })
86                    });
87                }
88            };
89        let file_path = resolved_path.path;
90        let inside_workspace = resolved_path.inside_workspace;
91        let workspace_root = self.workspace_root.clone();
92        let path_for_display = args.path.clone();
93        Box::pin(async move {
94            let content = match tokio::fs::read_to_string(&file_path).await {
95                Ok(c) => c,
96                Err(e) => {
97                    return Ok(ToolResult {
98                        content: vec![OutputContent::Text {
99                            text: format!("failed to read {}: {e}", file_path.display()),
100                        }],
101                        details: None,
102                        is_error: true,
103                        terminate: false,
104                    });
105                }
106            };
107
108            let lines: Vec<&str> = content.lines().collect();
109            let offset = args.offset.unwrap_or(1).saturating_sub(1);
110            let offset = offset.min(lines.len());
111            let selected: Vec<&str> = if let Some(limit) = args.limit {
112                lines[offset..].iter().take(limit).copied().collect()
113            } else {
114                lines[offset..].to_vec()
115            };
116
117            let output = selected.join("\n");
118            let details = serde_json::json!({
119                "workspace_root": workspace_root.to_string_lossy(),
120                "path": path_for_display,
121                "resolved_path": file_path.to_string_lossy(),
122                "inside_workspace": inside_workspace,
123            });
124
125            let text = format!("{}\n{}", file_path.display(), output);
126
127            Ok(ToolResult {
128                content: vec![OutputContent::Text { text }],
129                details: Some(details),
130                is_error: false,
131                terminate: false,
132            })
133        })
134    }
135
136    fn execution_mode(&self) -> ExecutionMode {
137        ExecutionMode::Parallel
138    }
139}