1use crate::RiskLevel;
2use crate::tool::{
3 SideEffectLevel, Tool, ToolContext, ToolError, ToolPolicy, ToolResult, ToolSchema,
4};
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7
8use super::git::{GitOperation, GitStatusRequest};
9use super::payload::{
10 ApplyPatchPayload, CommandPayload, GitPayload, ListFilesPayload, ReadFilePayload, SearchPayload,
11};
12
13#[derive(Debug, Clone, Copy, Default)]
15pub struct ReadFileTool;
16
17#[crate::async_trait]
18impl Tool for ReadFileTool {
19 fn schema(&self) -> ToolSchema {
20 ToolSchema::new(
21 "read_file",
22 "Read a file from the governed workspace",
23 json!({
24 "type": "object",
25 "properties": {
26 "path": { "type": "string", "description": "Root-relative workspace path" },
27 "max_bytes": { "type": "integer", "minimum": 1 }
28 },
29 "required": ["path"]
30 }),
31 )
32 .with_policy(ToolPolicy {
33 side_effects: SideEffectLevel::ReadOnly,
34 risk: RiskLevel::Low,
35 ..ToolPolicy::default()
36 })
37 }
38
39 async fn call(
40 &self,
41 arguments: serde_json::Value,
42 context: ToolContext<'_>,
43 ) -> Result<ToolResult, ToolError> {
44 effect_from_payload::<ReadFilePayload>(arguments, context, ReadFilePayload::into_effect)
45 }
46}
47
48#[derive(Debug, Clone, Copy, Default)]
50pub struct ListFilesTool;
51
52#[crate::async_trait]
53impl Tool for ListFilesTool {
54 fn schema(&self) -> ToolSchema {
55 ToolSchema::new(
56 "list_files",
57 "List files from the governed workspace",
58 json!({
59 "type": "object",
60 "properties": {
61 "path": { "type": "string", "description": "Root-relative workspace path" },
62 "recursive": { "type": "boolean" },
63 "max_entries": { "type": "integer", "minimum": 1 },
64 "include_hidden": { "type": "boolean" },
65 "respect_gitignore": { "type": "boolean" }
66 },
67 "required": ["path", "recursive"]
68 }),
69 )
70 .with_policy(ToolPolicy {
71 side_effects: SideEffectLevel::ReadOnly,
72 risk: RiskLevel::Low,
73 ..ToolPolicy::default()
74 })
75 }
76
77 async fn call(
78 &self,
79 arguments: serde_json::Value,
80 context: ToolContext<'_>,
81 ) -> Result<ToolResult, ToolError> {
82 effect_from_payload::<ListFilesPayload>(arguments, context, ListFilesPayload::into_effect)
83 }
84}
85
86#[derive(Debug, Clone, Copy, Default)]
88pub struct SearchRepoTool;
89
90#[crate::async_trait]
91impl Tool for SearchRepoTool {
92 fn schema(&self) -> ToolSchema {
93 ToolSchema::new(
94 "search_repo",
95 "Search text in the governed workspace",
96 json!({
97 "type": "object",
98 "properties": {
99 "query": { "type": "string" },
100 "paths": {
101 "type": "array",
102 "items": { "type": "string" }
103 },
104 "max_matches": { "type": "integer", "minimum": 1 },
105 "context_lines": { "type": "integer", "minimum": 0 }
106 },
107 "required": ["query"]
108 }),
109 )
110 .with_policy(ToolPolicy {
111 side_effects: SideEffectLevel::ReadOnly,
112 risk: RiskLevel::Low,
113 ..ToolPolicy::default()
114 })
115 }
116
117 async fn call(
118 &self,
119 arguments: serde_json::Value,
120 context: ToolContext<'_>,
121 ) -> Result<ToolResult, ToolError> {
122 effect_from_payload::<SearchPayload>(arguments, context, SearchPayload::into_effect)
123 }
124}
125
126#[derive(Debug, Clone, Copy, Default)]
128pub struct ApplyPatchTool;
129
130#[crate::async_trait]
131impl Tool for ApplyPatchTool {
132 fn schema(&self) -> ToolSchema {
133 ToolSchema::new(
134 "apply_patch",
135 "Apply a structured patch through the governed workspace",
136 json!({
137 "type": "object",
138 "properties": {
139 "patch": { "type": "object" },
140 "expected_versions": { "type": "array" },
141 "dry_run": { "type": "boolean" }
142 },
143 "required": ["patch", "dry_run"]
144 }),
145 )
146 .with_policy(ToolPolicy {
147 side_effects: SideEffectLevel::Write,
148 risk: RiskLevel::Medium,
149 requires_confirmation: true,
150 ..ToolPolicy::default()
151 })
152 }
153
154 async fn call(
155 &self,
156 arguments: serde_json::Value,
157 context: ToolContext<'_>,
158 ) -> Result<ToolResult, ToolError> {
159 effect_from_payload::<ApplyPatchPayload>(arguments, context, ApplyPatchPayload::into_effect)
160 }
161}
162
163#[derive(Debug, Clone, Copy, Default)]
165pub struct RunCommandTool;
166
167#[crate::async_trait]
168impl Tool for RunCommandTool {
169 fn schema(&self) -> ToolSchema {
170 ToolSchema::new(
171 "run_command",
172 "Run an explicit argv command through the governed command executor",
173 json!({
174 "type": "object",
175 "properties": {
176 "request": { "type": "object" }
177 },
178 "required": ["request"]
179 }),
180 )
181 .with_policy(ToolPolicy {
182 side_effects: SideEffectLevel::External,
183 risk: RiskLevel::Medium,
184 requires_confirmation: true,
185 ..ToolPolicy::default()
186 })
187 }
188
189 async fn call(
190 &self,
191 arguments: serde_json::Value,
192 context: ToolContext<'_>,
193 ) -> Result<ToolResult, ToolError> {
194 effect_from_payload::<CommandPayload>(arguments, context, CommandPayload::into_effect)
195 }
196}
197
198#[derive(Debug, Clone, Copy, Default)]
200pub struct GitStatusTool;
201
202#[crate::async_trait]
203impl Tool for GitStatusTool {
204 fn schema(&self) -> ToolSchema {
205 ToolSchema::new(
206 "git_status",
207 "Inspect git status without mutating git state",
208 json!({
209 "type": "object",
210 "properties": {
211 "include_branch": { "type": "boolean" }
212 }
213 }),
214 )
215 .with_policy(ToolPolicy {
216 side_effects: SideEffectLevel::ReadOnly,
217 risk: RiskLevel::Low,
218 ..ToolPolicy::default()
219 })
220 }
221
222 async fn call(
223 &self,
224 arguments: serde_json::Value,
225 context: ToolContext<'_>,
226 ) -> Result<ToolResult, ToolError> {
227 let args: GitStatusArgs = serde_json::from_value(arguments).map_err(ToolError::from)?;
228 let effect = GitPayload {
229 operation: GitOperation::Status(GitStatusRequest {
230 include_branch: args.include_branch.unwrap_or(true),
231 }),
232 }
233 .into_effect()
234 .map_err(|error| ToolError::InvalidArguments(error.to_string()))?
235 .with_source(context.tool_call_id, context.tool_name);
236 Ok(ToolResult::Effect(effect))
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241struct GitStatusArgs {
242 include_branch: Option<bool>,
243}
244
245fn effect_from_payload<P>(
246 arguments: serde_json::Value,
247 context: ToolContext<'_>,
248 into_effect: impl FnOnce(P) -> Result<crate::EffectRequest, super::CodingError>,
249) -> Result<ToolResult, ToolError>
250where
251 P: for<'de> Deserialize<'de>,
252{
253 let payload: P = serde_json::from_value(arguments).map_err(ToolError::from)?;
254 let effect = into_effect(payload)
255 .map_err(|error| ToolError::InvalidArguments(error.to_string()))?
256 .with_source(context.tool_call_id, context.tool_name);
257 Ok(ToolResult::Effect(effect))
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::{RunContext, SharedState};
264
265 #[tokio::test]
266 async fn read_file_tool_returns_effect() {
267 let run = RunContext::new("tool");
268 let state = SharedState::new();
269 let context = ToolContext::new(&run, &state, "call-1", "read_file");
270 let result = ReadFileTool
271 .call(json!({"path": "Cargo.toml", "max_bytes": 10}), context)
272 .await
273 .unwrap();
274 let ToolResult::Effect(effect) = result else {
275 panic!("expected effect");
276 };
277 assert_eq!(effect.source.tool_call_id.as_deref(), Some("call-1"));
278 assert_eq!(effect.source.tool_name.as_deref(), Some("read_file"));
279 }
280
281 #[tokio::test]
282 async fn read_file_tool_rejects_invalid_json_arguments() {
283 let run = RunContext::new("tool");
284 let state = SharedState::new();
285 let context = ToolContext::new(&run, &state, "call-1", "read_file");
286 let err = ReadFileTool
287 .call(json!({"path": "../secret"}), context)
288 .await
289 .unwrap_err();
290 assert!(matches!(err, ToolError::InvalidArguments(_)));
291 }
292}