Skip to main content

apollo/tools/
config_tool.rs

1//! ConfigTool — read and patch the agent's config file from within a conversation.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::Value;
8
9use super::traits::*;
10
11pub struct ConfigTool {
12    config_path: PathBuf,
13}
14
15impl ConfigTool {
16    pub fn new(config_path: PathBuf) -> Self {
17        Self { config_path }
18    }
19}
20
21#[derive(Deserialize)]
22struct ConfigArgs {
23    /// Action: "read", "get", or "set"
24    action: String,
25    /// JSON pointer path for get/set (e.g. "/agent/max_rounds")
26    #[serde(default)]
27    path: String,
28    /// New value for "set" (any JSON value)
29    value: Option<Value>,
30}
31
32#[async_trait]
33impl Tool for ConfigTool {
34    fn name(&self) -> &str {
35        "config"
36    }
37
38    fn spec(&self) -> ToolSpec {
39        ToolSpec {
40            name: "config".to_string(),
41            description: "Read or update the agent's configuration file. \
42                Use 'read' to show the full config, 'get' to retrieve a specific field \
43                (JSON pointer, e.g. /agent/max_rounds), and 'set' to update a field."
44                .to_string(),
45            parameters: serde_json::json!({
46                "type": "object",
47                "properties": {
48                    "action": {
49                        "type": "string",
50                        "enum": ["read", "get", "set"],
51                        "description": "read = full config, get = read field, set = write field"
52                    },
53                    "path": {
54                        "type": "string",
55                        "description": "JSON pointer path (e.g. /agent/max_rounds). Required for get/set."
56                    },
57                    "value": {
58                        "description": "New value for set action (any JSON type)"
59                    }
60                },
61                "required": ["action"]
62            }),
63        }
64    }
65
66    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
67        let args: ConfigArgs = serde_json::from_str(arguments)?;
68
69        let raw = tokio::fs::read_to_string(&self.config_path)
70            .await
71            .unwrap_or_else(|_| "{}".to_string());
72
73        match args.action.as_str() {
74            "read" => Ok(ToolResult::success(raw)),
75
76            "get" => {
77                if args.path.is_empty() {
78                    return Ok(ToolResult::error("path is required for get"));
79                }
80                let mut config: Value = serde_json::from_str(&raw)?;
81                match config.pointer_mut(&args.path) {
82                    Some(val) => Ok(ToolResult::success(serde_json::to_string_pretty(val)?)),
83                    None => Ok(ToolResult::error(format!("Path '{}' not found", args.path))),
84                }
85            }
86
87            "set" => {
88                if args.path.is_empty() {
89                    return Ok(ToolResult::error("path is required for set"));
90                }
91                let new_value = match args.value {
92                    Some(v) => v,
93                    None => return Ok(ToolResult::error("value is required for set")),
94                };
95
96                let mut config: Value = serde_json::from_str(&raw)?;
97
98                // Walk the pointer and set the value
99                let parts: Vec<&str> = args.path.trim_start_matches('/').split('/').collect();
100
101                if parts.is_empty() || parts[0].is_empty() {
102                    return Ok(ToolResult::error("invalid path"));
103                }
104
105                let mut current = &mut config;
106                for (i, part) in parts.iter().enumerate() {
107                    if i == parts.len() - 1 {
108                        if let Some(obj) = current.as_object_mut() {
109                            obj.insert(part.to_string(), new_value.clone());
110                        } else {
111                            return Ok(ToolResult::error("parent is not an object"));
112                        }
113                    } else {
114                        current = current
115                            .as_object_mut()
116                            .and_then(|o| o.get_mut(*part))
117                            .ok_or_else(|| anyhow::anyhow!("path segment '{}' not found", part))?;
118                    }
119                }
120
121                let updated = serde_json::to_string_pretty(&config)?;
122                tokio::fs::write(&self.config_path, &updated).await?;
123                Ok(ToolResult::success(format!(
124                    "Set {} = {}",
125                    args.path,
126                    serde_json::to_string(&new_value)?
127                )))
128            }
129
130            other => Ok(ToolResult::error(format!("Unknown action: {}", other))),
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use tempfile::TempDir;
139
140    #[tokio::test]
141    async fn read_missing_returns_empty_object() {
142        let dir = TempDir::new().unwrap();
143        let tool = ConfigTool::new(dir.path().join("config.json"));
144        let r = tool.execute(r#"{"action":"read"}"#).await.unwrap();
145        assert!(!r.is_error);
146        assert_eq!(r.output.trim(), "{}");
147    }
148
149    #[tokio::test]
150    async fn set_and_get() {
151        let dir = TempDir::new().unwrap();
152        let path = dir.path().join("config.json");
153        tokio::fs::write(&path, r#"{"agent":{"max_rounds":50}}"#)
154            .await
155            .unwrap();
156        let tool = ConfigTool::new(path);
157
158        let s = tool
159            .execute(r#"{"action":"set","path":"/agent/max_rounds","value":99}"#)
160            .await
161            .unwrap();
162        assert!(!s.is_error);
163
164        let g = tool
165            .execute(r#"{"action":"get","path":"/agent/max_rounds"}"#)
166            .await
167            .unwrap();
168        assert!(g.output.contains("99"));
169    }
170}