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