Skip to main content

agent_runtime_definition_builder/
workspace.rs

1use 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
10/// Standard Workspace Tool composition for an Agent Definition.
11///
12/// `read_only` is suitable for Skill + Workspace agents. `read_write` adds one
13/// bounded write Tool, and `with_command_execution` explicitly opts into the
14/// higher-risk process capability. Caller capability filtering remains
15/// authoritative at execution time.
16pub 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}