Skip to main content

molo_coding/coding/
tools.rs

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/// Model-visible adapter that requests a governed workspace file read.
14#[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/// Model-visible adapter that requests a governed workspace listing.
49#[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/// Model-visible adapter that requests governed repository search.
87#[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/// Model-visible adapter that requests a governed structured patch.
127#[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/// Model-visible adapter that requests governed command execution.
164#[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/// Model-visible adapter that requests read-only git status.
199#[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}