apollo/tools/
config_tool.rs1use 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: String,
25 #[serde(default)]
27 path: String,
28 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 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}