Skip to main content

rig_harness/
per_call.rs

1//! Tools whose every call is built for where it works
2//! ([`AppPerCallExt::add_tool_per_call`]): the app's workspace, the calling
3//! agent's [`WorkDir`] or else the [`ProjectRoot`], and, for a tool that
4//! reports its output as it runs, where it reports it ([`CallPlace`]). A
5//! plugin builds such a call somewhere else, such as on an overlay of the
6//! workspace, with [`PerCallTools`].
7//!
8//! ```
9//! use rig_harness::prelude::*;
10//! use rig_harness::rig_tools::Write;
11//!
12//! let mut app = App::new();
13//! app.add_tool_per_call(Write::default(), ToolOptions::default(), |place| Write {
14//!     workspace: place.workspace,
15//!     base: Some(place.base),
16//!     confine: place.confine,
17//!     ..Write::default()
18//! });
19//! ```
20
21use std::path::PathBuf;
22use std::sync::Arc;
23
24use bevy_app::{App, Plugin};
25use bevy_ecs::prelude::*;
26use bevy_ecs::system::SystemParam;
27use rig_core::serve::ErasedHandler;
28use rig_core::serve::adapters::ToolAdapter;
29use rig_core::tool::{Touches, tool_definition};
30use rig_ecs::effects::Handler;
31use rig_ecs::prelude::{
32    Admitted, AgentPass, CallHandler, Footprint, ForAgent, PrepareSystems, Tool, ToolCalls,
33    ToolDef, ToolOptions, ToolRequest, Wake, WorldToolsExt,
34};
35use rig_tools::workspace::Workspace;
36use rig_tools::{Live, Spill};
37
38use crate::host::session::Scratch;
39use crate::progress::CallOutput;
40use crate::services::{ProjectRoot, WorkDir};
41use crate::workspace::ProjectWorkspace;
42
43/// Where one call of a tool registered with
44/// [`add_tool_per_call`](AppPerCallExt::add_tool_per_call) works.
45pub struct CallPlace {
46    /// The app's workspace ([`ProjectWorkspace`]), or the one a plugin
47    /// builds the call on with [`PerCallTools`], such as an overlay of it
48    /// for a call that is previewed (rig-basics' `Preview`).
49    pub workspace: Arc<dyn Workspace>,
50    /// The calling agent's [`WorkDir`], else the [`ProjectRoot`]: relative
51    /// paths resolve and commands start here.
52    pub base: PathBuf,
53    /// Whether `base` is a `WorkDir`, outside which the call refuses paths.
54    pub confine: bool,
55    /// Where the call reports its output as it comes, for a tool
56    /// registered with [`add_live_tool_per_call`](AppPerCallExt::add_live_tool_per_call);
57    /// read by the front as the call's [`CallOutput`]. `None` from
58    /// [`PerCallTools::place`]; the harness sets it for a call it runs.
59    pub live: Option<Live>,
60    /// Where output cut short is kept whole, if anywhere
61    /// ([`Scratch::spill`]). `None` from [`PerCallTools::place`]; the
62    /// harness sets it for a call it runs.
63    pub spill: Option<Spill>,
64}
65
66/// Registering a tool whose every call is built for its [`CallPlace`].
67pub trait AppPerCallExt {
68    /// Registers `tool` with `options`, as
69    /// [`add_tool_with`](rig_ecs::tools::AppToolsExt::add_tool_with) does, and
70    /// gives each of its calls the handler of `make(place)` in
71    /// [`PrepareSystems::Compose`], so a plugin that wraps or replaces a call's
72    /// [`CallHandler`] in [`PrepareSystems::Edit`] gets that one. A call of the
73    /// tool can be previewed (rig-basics' `Preview`), as `make`
74    /// builds it on an overlay of the workspace.
75    fn add_tool_per_call<T: Tool + 'static>(
76        &mut self,
77        tool: T,
78        options: ToolOptions,
79        make: impl Fn(CallPlace) -> T + Send + Sync + 'static,
80    ) -> &mut Self;
81
82    /// As [`add_tool_per_call`](Self::add_tool_per_call), for a tool that
83    /// reports its output as it runs: each call gets a [`CallOutput`], and
84    /// its place the [`Live`] that fills it.
85    fn add_live_tool_per_call<T: Tool + 'static>(
86        &mut self,
87        tool: T,
88        options: ToolOptions,
89        make: impl Fn(CallPlace) -> T + Send + Sync + 'static,
90    ) -> &mut Self;
91}
92
93impl AppPerCallExt for App {
94    fn add_tool_per_call<T: Tool + 'static>(
95        &mut self,
96        tool: T,
97        options: ToolOptions,
98        make: impl Fn(CallPlace) -> T + Send + Sync + 'static,
99    ) -> &mut Self {
100        add_per_call(self, tool, options, false, make)
101    }
102
103    fn add_live_tool_per_call<T: Tool + 'static>(
104        &mut self,
105        tool: T,
106        options: ToolOptions,
107        make: impl Fn(CallPlace) -> T + Send + Sync + 'static,
108    ) -> &mut Self {
109        add_per_call(self, tool, options, true, make)
110    }
111}
112
113fn add_per_call<T: Tool + 'static>(
114    app: &mut App,
115    tool: T,
116    options: ToolOptions,
117    live: bool,
118    make: impl Fn(CallPlace) -> T + Send + Sync + 'static,
119) -> &mut App {
120    if !app.is_plugin_added::<PerCallPlugin>() {
121        app.add_plugins(PerCallPlugin);
122    }
123    let definition = tool_definition(&tool);
124    // The annotations are the tool's own code's, so they give its default
125    // footprint, as `add_tool_with` takes them, not as hints.
126    let own = Touches::from_annotations(&definition.annotations);
127    let own = options.footprint.is_none().then_some(Footprint(own));
128    let handler = ErasedHandler::new(ToolAdapter::new(tool));
129    let make = move |place: CallPlace| {
130        CallHandler(Handler(ErasedHandler::new(ToolAdapter::new(make(place)))))
131    };
132    let world = app.world_mut();
133    if let Some(tool) = world.spawn_tool(definition, handler, options) {
134        let make = Box::new(make);
135        let mut tool = world.entity_mut(tool);
136        tool.insert(PerCall { live, make });
137        if let Some(own) = own {
138            tool.insert(own);
139        }
140    }
141    app
142}
143
144/// Builds the calls of the tools registered per call.
145struct PerCallPlugin;
146
147impl Plugin for PerCallPlugin {
148    fn build(&self, app: &mut App) {
149        app.init_resource::<ProjectRoot>()
150            .init_resource::<ProjectWorkspace>()
151            .add_systems(
152                AgentPass,
153                build_per_call_handlers.in_set(PrepareSystems::Compose),
154            );
155    }
156}
157
158/// On a tool's entity: how each of its calls is built for its [`CallPlace`].
159/// A replacement disables the tool's entity, and this with it.
160#[derive(Component)]
161struct PerCall {
162    /// Whether its calls report their output as they run.
163    live: bool,
164    make: Box<dyn Fn(CallPlace) -> CallHandler + Send + Sync>,
165}
166
167/// The tools registered per call, and where a call of an agent's works:
168/// for a plugin that builds a call somewhere else, as rig-basics' `Preview`
169/// builds it on an overlay of the workspace, or wraps a call where it
170/// works, as its `Checkpoint` does. The plugin inits [`ProjectRoot`], which
171/// this reads: without it the system using this fails. A tool replaced
172/// (its entity `Disabled`) is not seen.
173///
174/// ```
175/// use std::sync::Arc;
176///
177/// use bevy_ecs::system::RunSystemOnce;
178/// use rig_harness::prelude::*;
179/// use rig_harness::rig_core::effect::EffectKind;
180/// use rig_harness::rig_core::message::{ToolFunction, ToolName};
181/// use rig_harness::rig_core::serve::serve_inline;
182/// use rig_harness::rig_tools::{Write, overlay::Overlay};
183///
184/// let mut app = App::new();
185/// app.add_tool_per_call(Write::default(), ToolOptions::default(), |place| Write {
186///     workspace: place.workspace,
187///     base: Some(place.base),
188///     confine: place.confine,
189///     ..Write::default()
190/// });
191/// // It exists; nothing is written to it.
192/// let dir = std::env::current_dir()?;
193/// let agent = app.world_mut().spawn(WorkDir(dir.clone())).id();
194/// let project = app.world().resource::<ProjectWorkspace>().0.clone();
195/// let overlay = Arc::new(Overlay::new(project));
196/// let on = overlay.clone();
197/// let ran = app.world_mut().run_system_once(move |tools: PerCallTools| {
198///     let place = tools.place(Some(agent), on.clone());
199///     let (base, confine) = (place.base.clone(), place.confine);
200///     let quiet = place.live.is_none() && place.spill.is_none();
201///     let missing = tools.build("read", tools.place(None, on.clone()));
202///     (tools.build("write", place), missing, base, confine, quiet)
203/// });
204/// let (write, missing, base, confine, quiet) = ran.map_err(|failure| failure.to_string())?;
205/// assert_eq!(base, dir);
206/// assert!(confine && quiet && missing.is_none());
207/// let CallHandler(handler) = write.ok_or("write is registered per call")?;
208/// let args = serde_json::json!({ "path": "per-call-doc.txt", "content": "one\n" });
209/// let call = ToolCall::from_wire("w1", ToolFunction::new(ToolName::new("write")?, args));
210/// bevy_tasks::block_on(serve_inline(&handler.0, EffectKind::from(&call)))?;
211/// // The write went to the overlay, and not to the disk.
212/// assert_eq!(overlay.changes().len(), 1);
213/// assert!(!dir.join("per-call-doc.txt").exists());
214/// # Ok::<_, Box<dyn std::error::Error>>(())
215/// ```
216#[derive(SystemParam)]
217pub struct PerCallTools<'w, 's> {
218    tools: Query<'w, 's, (&'static ToolDef, &'static PerCall)>,
219    dirs: Query<'w, 's, &'static WorkDir>,
220    root: Res<'w, ProjectRoot>,
221}
222
223impl PerCallTools<'_, '_> {
224    /// A call of `agent`'s working in `workspace`: its base is the agent's
225    /// [`WorkDir`], which confines it, else the [`ProjectRoot`]. It has no
226    /// `live` and no `spill`, so a call built on an overlay writes nowhere
227    /// else; a call that runs for real elsewhere sets `spill` itself
228    /// ([`Scratch::spill`]). `confine` limits the file tools' paths; it is
229    /// no process sandbox.
230    pub fn place(&self, agent: Option<Entity>, workspace: Arc<dyn Workspace>) -> CallPlace {
231        let dir = agent.and_then(|agent| self.dirs.get(agent).ok());
232        CallPlace {
233            workspace,
234            base: dir.map_or(&self.root.0, |dir| &dir.0).clone(),
235            confine: dir.is_some(),
236            live: None,
237            spill: None,
238        }
239    }
240
241    /// The handler of a call of the per-call tool `name` built for
242    /// `place`; `None` when no enabled tool of that name is registered per
243    /// call. It runs nothing.
244    pub fn build(&self, name: &str, place: CallPlace) -> Option<CallHandler> {
245        self.find(name).map(|tool| (tool.make)(place))
246    }
247
248    /// The per-call tool named `name`.
249    fn find(&self, name: &str) -> Option<&PerCall> {
250        self.tools
251            .iter()
252            .find(|(tool, _)| tool.0.name == name)
253            .map(|(_, per_call)| per_call)
254    }
255}
256
257/// Gives each call of these tools admitted this pass the handler of its
258/// tool built for its [`CallPlace`], and a live call its [`CallOutput`].
259fn build_per_call_handlers(
260    mut calls: Query<
261        (Entity, &ToolRequest, Option<&ForAgent>, &mut CallHandler),
262        (ToolCalls, Added<Admitted>),
263    >,
264    tools: PerCallTools,
265    (workspace, scratch): (Res<ProjectWorkspace>, Option<Res<Scratch>>),
266    wake: Res<Wake>,
267    mut commands: Commands,
268) {
269    for (call, request, agent, mut handler) in &mut calls {
270        let Some(tool) = tools.find(request.call.function.name.as_str()) else {
271            continue;
272        };
273        let live = tool.live.then(|| {
274            let (output, live) = CallOutput::new(&wake);
275            commands.entity(call).insert(output);
276            live
277        });
278        let place = CallPlace {
279            live,
280            spill: scratch.as_ref().and_then(|scratch| scratch.spill()),
281            ..tools.place(agent.map(|agent| agent.0), workspace.0.clone())
282        };
283        *handler = (tool.make)(place);
284    }
285}
286
287#[cfg(test)]
288mod tests;