Skip to main content

leviath_runtime/embed/
tool_service.rs

1//! The batteries-included [`ToolService`] for embedded worlds: the built-in
2//! file/shell tools over each agent's workdir, with the `ask_user_*` /
3//! `present_for_review` / `edit_document` interaction tools routed through an
4//! [`InteractionHub`] so the host application answers them (surfaced as
5//! [`Interaction`](crate::host::WorldEvent::Interaction) events).
6//!
7//! Deliberately smaller than the daemon's tool service: no MCP tools, no Rhai
8//! script tools, no sandboxes, no per-tool approval policy - the embedder is
9//! code, not an unattended model, and can install its own [`ToolService`] for
10//! anything richer.
11
12use 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
24/// One registered agent's tool state: its confined built-in tools and its
25/// hub-backed interaction channel.
26struct AgentTools {
27    tools: BuiltinTools,
28    backend: HubInteractionBackend,
29    /// The agent's current stage name, stamped into interaction requests so
30    /// the host knows which stage is asking. Updated by `sync_stage`.
31    stage_name: Mutex<String>,
32}
33
34/// The default tool service for embedded worlds. See the module docs for what
35/// it does and does not provide.
36pub struct BasicToolService {
37    hub: InteractionHub,
38    agents: Mutex<HashMap<Entity, Arc<AgentTools>>>,
39}
40
41impl BasicToolService {
42    /// A service whose interaction tools ask through `hub`.
43    pub fn new(hub: InteractionHub) -> Self {
44        Self {
45            hub,
46            agents: Mutex::new(HashMap::new()),
47        }
48    }
49
50    /// The tool definitions this service can execute for an agent working in
51    /// `workdir` - the set stage resolution filters `available_tools` against.
52    pub fn tool_defs(workdir: &Path) -> Vec<leviath_providers::Tool> {
53        BuiltinTools::new(ToolContext::new(workdir.to_path_buf())).tool_defs()
54    }
55
56    /// Register `entity`'s tool state: built-ins confined to `workdir`, and
57    /// interactions attributed to `agent_id`. The embed spawner calls this for
58    /// every agent it creates; a host spawning agents directly on the world
59    /// does the same.
60    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    /// Drop `entity`'s tool state. Called by the reaper when a terminal agent
73    /// is unloaded, so the map stays bounded by the set of live agents.
74    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                    // Never registered (an agent spawned around the embed
100                    // spawner): answer every call rather than dropping the
101                    // batch, which would strand the agent.
102                    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                    // Interaction tools block on the hub (the host answers);
116                    // everything else runs on the built-ins.
117                    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        // The request lands on the hub, attributed to the agent + stage.
252        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        // Answering unblocks the tool call, which carries the answer back.
264        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"); // nothing to update, no panic
273    }
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}