agent_runtime_definition_builder/
workspace.rs1use std::{path::Component, sync::Arc};
2
3use async_trait::async_trait;
4use runtime_extension_api::{
5 ContextInvalidation, ExtensionError, SideEffect, Tool, ToolContext, ToolDescriptor, ToolOutcome,
6};
7use serde::Deserialize;
8use serde_json::{Value, json};
9
10pub struct WorkspaceToolSet {
17 write: bool,
18 execute: bool,
19}
20
21impl WorkspaceToolSet {
22 pub fn read_only() -> Self {
23 Self {
24 write: false,
25 execute: false,
26 }
27 }
28
29 pub fn read_write() -> Self {
30 Self {
31 write: true,
32 execute: false,
33 }
34 }
35
36 pub fn with_command_execution(mut self) -> Self {
37 self.execute = true;
38 self
39 }
40
41 pub(crate) fn into_tools(self) -> Vec<Arc<dyn Tool>> {
42 let mut tools: Vec<Arc<dyn Tool>> =
43 vec![Arc::new(ReadTool), Arc::new(ListTool), Arc::new(SearchTool)];
44 if self.write {
45 tools.push(Arc::new(WriteTool));
46 }
47 if self.execute {
48 tools.push(Arc::new(ExecuteTool));
49 }
50 tools
51 }
52}
53
54fn descriptor(
55 name: &str,
56 description: &str,
57 input_schema: Value,
58 side_effect: SideEffect,
59 capability: &str,
60) -> ToolDescriptor {
61 ToolDescriptor {
62 name: name.into(),
63 description: description.into(),
64 input_schema,
65 required_capabilities: vec![capability.into()],
66 side_effect,
67 }
68}
69
70fn parse<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T, ExtensionError> {
71 serde_json::from_value(value).map_err(|error| ExtensionError::Invalid(error.to_string()))
72}
73
74fn validate_path(path: &str, root_allowed: bool) -> Result<(), ExtensionError> {
75 if (!root_allowed && path.is_empty())
76 || std::path::Path::new(path).is_absolute()
77 || std::path::Path::new(path)
78 .components()
79 .any(|part| !matches!(part, Component::Normal(_)))
80 {
81 return Err(ExtensionError::Invalid(
82 "workspace path must be relative and must not contain traversal".into(),
83 ));
84 }
85 Ok(())
86}
87
88#[derive(Deserialize)]
89#[serde(deny_unknown_fields)]
90struct PathInput {
91 path: String,
92}
93
94struct ReadTool;
95#[async_trait]
96impl Tool for ReadTool {
97 fn descriptor(&self) -> ToolDescriptor {
98 descriptor(
99 "workspace.read",
100 "Read one UTF-8 workspace file.",
101 json!({"type":"object","properties":{"path":{"type":"string","minLength":1}},"required":["path"],"additionalProperties":false}),
102 SideEffect::ReadOnly,
103 "files:read",
104 )
105 }
106
107 async fn invoke(
108 &self,
109 context: ToolContext,
110 value: Value,
111 ) -> Result<ToolOutcome, ExtensionError> {
112 let input: PathInput = parse(value)?;
113 validate_path(&input.path, false)?;
114 Ok(ToolOutcome::success(
115 context.workspace.read_text(&input.path).await?,
116 ))
117 }
118}
119
120struct ListTool;
121#[async_trait]
122impl Tool for ListTool {
123 fn descriptor(&self) -> ToolDescriptor {
124 descriptor(
125 "workspace.list",
126 "List entries below a workspace-relative path.",
127 json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}),
128 SideEffect::ReadOnly,
129 "files:read",
130 )
131 }
132
133 async fn invoke(
134 &self,
135 context: ToolContext,
136 value: Value,
137 ) -> Result<ToolOutcome, ExtensionError> {
138 let input: PathInput = parse(value)?;
139 validate_path(&input.path, true)?;
140 let entries = context.workspace.list(&input.path).await?;
141 Ok(ToolOutcome::success(
142 entries
143 .into_iter()
144 .map(|entry| format!("{}{}", entry.path, if entry.is_dir { "/" } else { "" }))
145 .collect::<Vec<_>>()
146 .join("\n"),
147 ))
148 }
149}
150
151#[derive(Deserialize)]
152#[serde(deny_unknown_fields)]
153struct SearchInput {
154 path: String,
155 query: String,
156 #[serde(default = "default_limit")]
157 limit: usize,
158}
159
160fn default_limit() -> usize {
161 100
162}
163
164struct SearchTool;
165#[async_trait]
166impl Tool for SearchTool {
167 fn descriptor(&self) -> ToolDescriptor {
168 descriptor(
169 "workspace.search",
170 "Search workspace text with a bounded result count.",
171 json!({"type":"object","properties":{"path":{"type":"string"},"query":{"type":"string","minLength":1},"limit":{"type":"integer","minimum":1,"maximum":1000}},"required":["path","query"],"additionalProperties":false}),
172 SideEffect::ReadOnly,
173 "files:read",
174 )
175 }
176
177 async fn invoke(
178 &self,
179 context: ToolContext,
180 value: Value,
181 ) -> Result<ToolOutcome, ExtensionError> {
182 let input: SearchInput = parse(value)?;
183 validate_path(&input.path, true)?;
184 if input.query.is_empty() || !(1..=1000).contains(&input.limit) {
185 return Err(ExtensionError::Invalid("query or limit is invalid".into()));
186 }
187 Ok(ToolOutcome::success(
188 context
189 .workspace
190 .search(&input.path, &input.query, input.limit)
191 .await?
192 .join("\n"),
193 ))
194 }
195}
196
197#[derive(Deserialize)]
198#[serde(deny_unknown_fields)]
199struct WriteInput {
200 path: String,
201 content: String,
202}
203
204struct WriteTool;
205#[async_trait]
206impl Tool for WriteTool {
207 fn descriptor(&self) -> ToolDescriptor {
208 descriptor(
209 "workspace.write",
210 "Create or replace one UTF-8 workspace file.",
211 json!({"type":"object","properties":{"path":{"type":"string","minLength":1},"content":{"type":"string"}},"required":["path","content"],"additionalProperties":false}),
212 SideEffect::WorkspaceWrite,
213 "files:write",
214 )
215 }
216
217 async fn invoke(
218 &self,
219 context: ToolContext,
220 value: Value,
221 ) -> Result<ToolOutcome, ExtensionError> {
222 let input: WriteInput = parse(value)?;
223 validate_path(&input.path, false)?;
224 context
225 .workspace
226 .write_text(&input.path, &input.content)
227 .await?;
228 Ok(ToolOutcome::success(format!(
229 "wrote {} bytes to {}",
230 input.content.len(),
231 input.path
232 ))
233 .with_context_invalidation(ContextInvalidation::RUNTIME_STATE))
234 }
235}
236
237#[derive(Deserialize)]
238#[serde(deny_unknown_fields)]
239struct ExecuteInput {
240 command: String,
241 cwd: Option<String>,
242}
243
244struct ExecuteTool;
245#[async_trait]
246impl Tool for ExecuteTool {
247 fn descriptor(&self) -> ToolDescriptor {
248 descriptor(
249 "workspace.exec",
250 "Execute one command through the managed Workspace service.",
251 json!({"type":"object","properties":{"command":{"type":"string","minLength":1},"cwd":{"type":["string","null"]}},"required":["command"],"additionalProperties":false}),
252 SideEffect::Process,
253 "commands:execute",
254 )
255 }
256
257 async fn invoke(
258 &self,
259 context: ToolContext,
260 value: Value,
261 ) -> Result<ToolOutcome, ExtensionError> {
262 let input: ExecuteInput = parse(value)?;
263 if input.command.trim().is_empty() {
264 return Err(ExtensionError::Invalid("command must not be empty".into()));
265 }
266 if let Some(cwd) = &input.cwd {
267 validate_path(cwd, true)?;
268 }
269 let result = context
270 .workspace
271 .execute(&input.command, input.cwd.as_deref())
272 .await?;
273 Ok(ToolOutcome::success(format!(
274 "exit={}\nstdout:\n{}\nstderr:\n{}",
275 result.exit_code, result.stdout, result.stderr
276 ))
277 .with_context_invalidation(ContextInvalidation::RUNTIME_STATE))
278 }
279}