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    /// Keyed by raw `Entity`, deliberately.
39    ///
40    /// Two worlds mint the same entity id, so an entity-keyed map shared between
41    /// them would hand one world's tool state to the other's agent. This map is
42    /// not shared: a `BasicToolService` is built once per `AgentWorld`, and the
43    /// only writer is that world's own spawner. An [`crate::world::AgentId`] key
44    /// would carry a world the `ToolService::execute` signature has no way to
45    /// supply - it is handed a bare `Entity` by the tool lane, with no world in
46    /// reach - so it would buy nothing and cost the trait.
47    agents: Mutex<HashMap<Entity, Arc<AgentTools>>>,
48}
49
50impl BasicToolService {
51    /// A service whose interaction tools ask through `hub`.
52    pub fn new(hub: InteractionHub) -> Self {
53        Self {
54            hub,
55            agents: Mutex::new(HashMap::new()),
56        }
57    }
58
59    /// The tool definitions this service can execute for an agent working in
60    /// `workdir` - the set stage resolution filters `available_tools` against.
61    pub fn tool_defs(workdir: &Path) -> Vec<leviath_providers::Tool> {
62        BuiltinTools::new(ToolContext::new(workdir.to_path_buf())).tool_defs()
63    }
64
65    /// Register `entity`'s tool state: built-ins confined to `workdir`, and
66    /// interactions attributed to `agent_id`. The embed spawner calls this for
67    /// every agent it creates; a host spawning agents directly on the world
68    /// does the same.
69    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    /// Drop `entity`'s tool state. Called by the reaper when a terminal agent
82    /// is unloaded, so the map stays bounded by the set of live agents.
83    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                    // Never registered (an agent spawned around the embed
109                    // spawner): answer every call rather than dropping the
110                    // batch, which would strand the agent.
111                    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                    // Interaction tools block on the hub (the host answers);
125                    // everything else runs on the built-ins.
126                    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        // The request lands on the hub, attributed to the agent + stage.
261        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        // Answering unblocks the tool call, which carries the answer back.
273        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"); // nothing to update, no panic
282    }
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}