1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex, PoisonError};
15
16use bevy_ecs::entity::Entity;
17use leviath_tools::{BuiltinTools, ToolContext};
18
19use crate::dynamic_interaction::dispatch_dynamic_interaction;
20use crate::interaction_hub::{HubInteractionBackend, InteractionHub};
21use crate::pipeline::{ToolProgress, ToolService};
22use crate::tool_bridge::BoxedToolExec;
23
24struct AgentTools {
27 tools: BuiltinTools,
28 backend: HubInteractionBackend,
29 stage_name: Mutex<String>,
32}
33
34pub struct BasicToolService {
37 hub: InteractionHub,
38 agents: Mutex<HashMap<Entity, Arc<AgentTools>>>,
39}
40
41impl BasicToolService {
42 pub fn new(hub: InteractionHub) -> Self {
44 Self {
45 hub,
46 agents: Mutex::new(HashMap::new()),
47 }
48 }
49
50 pub fn tool_defs(workdir: &Path) -> Vec<leviath_providers::Tool> {
53 BuiltinTools::new(ToolContext::new(workdir.to_path_buf())).tool_defs()
54 }
55
56 pub fn register(&self, entity: Entity, agent_id: &str, workdir: PathBuf) {
61 let state = AgentTools {
62 tools: BuiltinTools::new(ToolContext::new(workdir)),
63 backend: self.hub.backend_for(agent_id),
64 stage_name: Mutex::new(String::new()),
65 };
66 self.agents
67 .lock()
68 .unwrap_or_else(PoisonError::into_inner)
69 .insert(entity, Arc::new(state));
70 }
71
72 pub fn unregister(&self, entity: Entity) {
75 self.agents
76 .lock()
77 .unwrap_or_else(PoisonError::into_inner)
78 .remove(&entity);
79 }
80}
81
82impl ToolService for BasicToolService {
83 fn exec_for(
84 &self,
85 entity: Entity,
86 calls: Vec<leviath_providers::ToolCall>,
87 progress: ToolProgress,
88 ) -> BoxedToolExec {
89 let state = self
90 .agents
91 .lock()
92 .unwrap_or_else(PoisonError::into_inner)
93 .get(&entity)
94 .cloned();
95 Box::new(move || {
96 Box::pin(async move {
97 let mut results = Vec::with_capacity(calls.len());
98 let Some(state) = state else {
99 for call in calls {
103 let answer = "[error] no tool state registered for this agent";
104 progress(&call.id, answer);
105 results.push((call.id, answer.to_string()));
106 }
107 return results;
108 };
109 let stage_name = state
110 .stage_name
111 .lock()
112 .unwrap_or_else(PoisonError::into_inner)
113 .clone();
114 for call in calls {
115 let result = match dispatch_dynamic_interaction(
118 &state.backend,
119 &call.name,
120 &call.id,
121 &call.arguments,
122 &stage_name,
123 )
124 .await
125 {
126 Some(result) => result,
127 None => state.tools.execute(&call.name, call.arguments).await,
128 };
129 progress(&call.id, &result);
130 results.push((call.id, result));
131 }
132 results
133 })
134 })
135 }
136
137 fn sync_stage(&self, entity: Entity, _stage_index: usize, stage_name: &str) {
138 if let Some(state) = self
139 .agents
140 .lock()
141 .unwrap_or_else(PoisonError::into_inner)
142 .get(&entity)
143 {
144 *state
145 .stage_name
146 .lock()
147 .unwrap_or_else(PoisonError::into_inner) = stage_name.to_string();
148 }
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::pipeline::noop_progress;
156 use leviath_core::interaction::InteractionResponse;
157
158 fn call(id: &str, name: &str, args: serde_json::Value) -> leviath_providers::ToolCall {
159 leviath_providers::ToolCall {
160 id: id.to_string(),
161 name: name.to_string(),
162 arguments: args,
163 thought_signature: None,
164 }
165 }
166
167 fn entity(index: u32) -> Entity {
168 Entity::from_raw_u32(index).expect("a small literal index is always a valid entity id")
169 }
170
171 #[tokio::test]
172 async fn executes_builtin_tools_in_the_registered_workdir() {
173 let dir = tempfile::tempdir().unwrap();
174 std::fs::write(dir.path().join("hello.txt"), "hi there").unwrap();
175 let svc = BasicToolService::new(InteractionHub::new());
176 let e = entity(1);
177 svc.register(e, "agent-a", dir.path().to_path_buf());
178
179 let exec = svc.exec_for(
180 e,
181 vec![
182 call("c1", "read_file", serde_json::json!({"path": "hello.txt"})),
183 call(
184 "c2",
185 "write_file",
186 serde_json::json!({"path": "out.txt", "content": "made"}),
187 ),
188 ],
189 noop_progress(),
190 );
191 let results = exec().await;
192 assert_eq!(results[0].0, "c1");
193 assert!(results[0].1.contains("hi there"));
194 assert_eq!(results[1].0, "c2");
195 assert!(!results[1].1.starts_with("[error]"));
196 assert_eq!(
197 std::fs::read_to_string(dir.path().join("out.txt")).unwrap(),
198 "made"
199 );
200 }
201
202 #[tokio::test]
203 async fn unknown_tool_reports_an_error_result() {
204 let dir = tempfile::tempdir().unwrap();
205 let svc = BasicToolService::new(InteractionHub::new());
206 let e = entity(1);
207 svc.register(e, "agent-a", dir.path().to_path_buf());
208
209 let results = svc.exec_for(
210 e,
211 vec![call("c1", "no_such_tool", serde_json::Value::Null)],
212 noop_progress(),
213 )()
214 .await;
215 assert!(results[0].1.starts_with("[error]"));
216 }
217
218 #[tokio::test]
219 async fn unregistered_entity_answers_instead_of_stranding_the_batch() {
220 let svc = BasicToolService::new(InteractionHub::new());
221 let results = svc.exec_for(
222 entity(9),
223 vec![call("c1", "read_file", serde_json::json!({"path": "x"}))],
224 noop_progress(),
225 )()
226 .await;
227 assert_eq!(results.len(), 1);
228 assert!(results[0].1.contains("no tool state"));
229 }
230
231 #[tokio::test]
232 async fn ask_user_text_routes_through_the_hub_and_resumes_on_answer() {
233 let dir = tempfile::tempdir().unwrap();
234 let hub = InteractionHub::new();
235 let svc = BasicToolService::new(hub.clone());
236 let e = entity(1);
237 svc.register(e, "agent-a", dir.path().to_path_buf());
238 svc.sync_stage(e, 0, "plan");
239
240 let exec = svc.exec_for(
241 e,
242 vec![call(
243 "c1",
244 "ask_user_text",
245 serde_json::json!({"prompt": "Which database?"}),
246 )],
247 noop_progress(),
248 );
249 let worker = tokio::spawn(async move { exec().await });
250
251 let (agent_id, request) = loop {
253 let pending = hub.pending();
254 if let Some(p) = pending.into_iter().next() {
255 break p;
256 }
257 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
258 };
259 assert_eq!(agent_id, "agent-a");
260 assert_eq!(request.stage_name, "plan");
261 assert!(request.prompt.contains("Which database?"));
262
263 assert!(hub.answer(InteractionResponse::text(request.id.clone(), "postgres")));
265 let results = worker.await.unwrap();
266 assert!(results[0].1.contains("postgres"));
267 }
268
269 #[tokio::test]
270 async fn sync_stage_for_an_unknown_entity_is_a_no_op() {
271 let svc = BasicToolService::new(InteractionHub::new());
272 svc.sync_stage(entity(3), 0, "plan"); }
274
275 #[tokio::test]
276 async fn unregister_drops_the_agent_state() {
277 let dir = tempfile::tempdir().unwrap();
278 let svc = BasicToolService::new(InteractionHub::new());
279 let e = entity(1);
280 svc.register(e, "agent-a", dir.path().to_path_buf());
281 svc.unregister(e);
282 let results = svc.exec_for(
283 e,
284 vec![call("c1", "read_file", serde_json::json!({"path": "x"}))],
285 noop_progress(),
286 )()
287 .await;
288 assert!(results[0].1.contains("no tool state"));
289 }
290
291 #[test]
292 fn tool_defs_cover_the_builtin_set() {
293 let dir = tempfile::tempdir().unwrap();
294 let defs = BasicToolService::tool_defs(dir.path());
295 let names: Vec<&str> = defs.iter().map(|t| t.name.as_str()).collect();
296 assert!(names.contains(&"read_file"));
297 assert!(names.contains(&"shell"));
298 assert!(names.contains(&"ask_user_text"));
299 }
300}