Skip to main content

rig_ecs/
tools.rs

1//! The tool registry and tool calls. A tool is an entity holding its
2//! definition and its default [`Footprint`], and, for an
3//! ordinary tool, its effect handler; plugins add tools with
4//! [`AppToolsExt::add_tool`]. A tool call is an effect entity with a
5//! [`ToolRequest`]. An ordinary tool's handler answers it; an open tool's
6//! call triggers [`ToolCalled`] and stays open until any system decides it
7//! ([`Decision`]).
8
9use bevy_app::App;
10use bevy_ecs::prelude::*;
11use bevy_ecs::system::{IntoObserverSystem, SystemParam};
12use bevy_log::warn;
13use bevy_reflect::prelude::*;
14use rig_core::completion::ToolDefinition;
15use rig_core::effect::{Answered, EffectId, EffectKind};
16use rig_core::error::{ErrorKind, ErrorReport};
17use rig_core::message::{ToolCall, ToolName};
18use rig_core::redact;
19use rig_core::serve::ErasedHandler;
20use rig_core::serve::adapters::ToolAdapter;
21use rig_core::tool::feedback::arguments_refusal;
22use rig_core::tool::{Scope, Tool, ToolErrorKind, Touches, args_schema, tool_definition};
23use schemars::JsonSchema;
24use serde::de::DeserializeOwned;
25use serde::{Deserialize, Serialize};
26use std::borrow::Cow;
27use std::sync::Arc;
28
29use super::agent::{AgentId, ToolAccess};
30use super::effects::Handler;
31use super::stage::{
32    Admitted, Decision, Effect, ForAgent, Outcome, Settled, Started, SupersededBy, ToolCalls,
33};
34
35/// What the model is told about a tool. A call naming an argument its
36/// parameters do not declare is refused before the tool runs.
37#[derive(Component, Reflect, Clone, Serialize, Deserialize)]
38#[reflect(opaque, Component, Clone, Serialize, Deserialize)]
39#[require(Footprint)]
40#[serde(transparent)]
41pub struct ToolDef(pub Arc<ToolDefinition>);
42
43/// How a tool's calls run, on the tool's entity.
44#[derive(Component, Clone)]
45pub(crate) enum Serves {
46    /// An ordinary tool: its effect handler runs each call.
47    Handler(Handler),
48    /// An open tool: starts its calls ([`Open`]).
49    Open(Open),
50}
51
52/// Starts the call `call` of the open tool `tool`, read from the call's
53/// own components: its arguments parsed into the tool's arguments type, it
54/// is [`Started`] and its [`ToolCalled`] triggered. Why it cannot start,
55/// such as arguments that do not fit, and it is not started.
56pub(crate) type Open = fn(&mut World, Entity, Entity, Started) -> Result<(), ErrorReport>;
57
58/// [`Open`] for the arguments type `A`. A call gone, or no tool call any
59/// more, is left alone.
60fn open<A: DeserializeOwned + Send + Sync + 'static>(
61    world: &mut World,
62    tool: Entity,
63    call: Entity,
64    started: Started,
65) -> Result<(), ErrorReport> {
66    let refused = |kind, why| ErrorReport::new(ErrorKind::Tool(kind), why);
67    let Ok(entity) = world.get_entity(call) else {
68        return Ok(());
69    };
70    let (Some(ToolRequest { call: tool_call }), Some(effect)) =
71        (entity.get::<ToolRequest>(), entity.get::<Effect>())
72    else {
73        return Ok(());
74    };
75    let agent = entity.get::<ForAgent>().map(|agent| agent.0);
76    let caller = agent.and_then(|agent| world.get::<AgentId>(agent));
77    let (Some(agent), Some(caller)) = (agent, caller) else {
78        let why = "a tool call needs an agent".to_owned();
79        return Err(refused(ToolErrorKind::NotFound, why));
80    };
81    let args = A::deserialize(&tool_call.function.arguments).map_err(|error| {
82        let why = format!(
83            "The arguments do not fit: {error}. Nothing ran; fix the call and send it again."
84        );
85        refused(ToolErrorKind::InvalidArgs, why)
86    })?;
87    let called = ToolCalled {
88        entity: tool,
89        call,
90        agent,
91        agent_id: caller.clone(),
92        effect: effect.id(),
93        parent: effect.parent,
94        tool_call: tool_call.clone(),
95        args,
96    };
97    world.entity_mut(call).insert(started);
98    world.trigger(called);
99    Ok(())
100}
101
102/// A tool call: an effect entity, spawned by the model's reply or a plugin as
103/// `(ToolRequest { call }, ForAgent(agent))`, often [`CallOf`] a turn. Its
104/// [`Outcome`] is its result.
105///
106/// When admitted it gets its tool's [`CallHandler`] unless it has one;
107/// Prepare may change the call or the handler, once. Each time Gate opens the
108/// kernel gives it the [`Footprint`] of the call as it stands, so a held call
109/// edited meanwhile is gated again. After the answers from a record and
110/// before every gate, the kernel refuses a call no record decided whose tool
111/// is unregistered or not the agent's to use, or whose arguments its tool
112/// does not declare; no gate sees it and no later answer replaces it. Start
113/// runs the handler, or opens the call for an open tool.
114///
115/// [`CallOf`]: super::agent::CallOf
116#[derive(Component, Clone, Debug)]
117#[require(Effect)]
118pub struct ToolRequest {
119    /// The call as the model sent it.
120    pub call: ToolCall,
121}
122
123/// The handler that answers a tool call, on the call: its tool's, which a
124/// Prepare system may wrap (rig-core's `ErasedHandler::layered`) or
125/// replace. A call without one is opened for its open tool.
126#[derive(Component, Clone)]
127pub struct CallHandler(pub Handler);
128
129/// A call of an open tool started, its arguments parsed into `A`: triggered
130/// on the tool's entity, so the observer [`AppToolsExt::add_open_tool`]
131/// registered handles it. The call stays open until its
132/// [`Decision`] is decided, such as with
133/// `Outcome::from(ToolResult::success("Done.".into()))`; despawning it, as
134/// a [`StopTurn`](super::agent::StopTurn) does, cancels it.
135#[derive(EntityEvent, Clone)]
136pub struct ToolCalled<A: Send + Sync + 'static> {
137    /// The tool.
138    pub entity: Entity,
139    /// The call.
140    pub call: Entity,
141    /// The calling agent.
142    pub agent: Entity,
143    /// The calling agent's id.
144    pub agent_id: AgentId,
145    /// The call's effect, which work done for it is recorded under.
146    pub effect: EffectId,
147    /// The effect that asked for the call, such as the model call whose
148    /// reply it is.
149    pub parent: Option<EffectId>,
150    /// The call as the model sent it.
151    pub tool_call: ToolCall,
152    /// The call's arguments.
153    pub args: A,
154}
155
156/// What a tool call may read and change ([`Touches`]), so the calls of one
157/// reply that touch different things run side by side. On a tool's entity,
158/// what its calls touch when they cannot say more; on a call, what it
159/// touches as Gate sees it, computed by the kernel when Gate opens: what
160/// its handler says of its final arguments, else its tool's, its paths
161/// [`normalized`](Touches::normalized), unless a plugin [`DeclaredFootprint`] it on
162/// the call. A call waits
163/// while an earlier call of its reply that conflicts with it is not
164/// settled ([`Waiting`](super::stage::Waiting)).
165#[derive(Component, Reflect, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
166#[reflect(opaque, Component, Clone, Debug, Default, PartialEq)]
167#[serde(transparent)]
168pub struct Footprint(pub Touches);
169
170impl Default for Footprint {
171    /// May change anything, right for any tool that does not say.
172    fn default() -> Self {
173        Self(Touches::Writes(Scope::Anything))
174    }
175}
176
177/// A [`Footprint`] that stands. On a tool registered with a
178/// [`ToolOptions::footprint`]: that footprint is each of its calls',
179/// whatever its handler says. On a call, inserted with its `Footprint` in
180/// Prepare: the kernel keeps that footprint (made
181/// [`normalized`](Touches::normalized)) instead of asking the call's
182/// handler, so a plugin that knows what a call touches, such as a shell
183/// command that only reads, lets the reply's other calls run beside it.
184#[derive(Component, Clone, Copy, Debug, Default)]
185pub struct DeclaredFootprint;
186
187/// How a tool is registered with [`AppToolsExt::add_tool_with`].
188#[derive(Clone, Debug, Default)]
189pub struct ToolOptions {
190    /// What every call of it touches, whatever its handler says: a local
191    /// declaration, which stands, such as one that trusts a remote tool to
192    /// only read. `None` asks each call's handler
193    /// (rig-core's `Tool::footprint`), and for a call it cannot say, such
194    /// as an open tool's, takes it from the tool's annotations: those of a
195    /// typed tool ([`AppToolsExt::add_tool_with`]) as its code declares
196    /// them (`Touches::from_annotations`), any other's as hints that never
197    /// make a call read only (`Touches::from_hints`).
198    pub footprint: Option<Touches>,
199}
200
201/// Registers tools on an [`App`].
202pub trait AppToolsExt {
203    /// Makes `tool` available to every agent whose [`ToolAccess`] allows its
204    /// name. A name already registered is refused with a warning; to replace
205    /// another plugin's tool, insert Bevy's `Disabled` on its entity first, in
206    /// `Plugin::finish`.
207    ///
208    /// Tool futures run on Bevy's async compute pool, a few threads every
209    /// agent's calls share, so a tool that blocks (`std::fs`,
210    /// `std::process::Command`, a long computation) runs that work on a thread
211    /// of its own, such as with rig-tools' `blocking`.
212    fn add_tool<T: Tool + 'static>(&mut self, tool: T) -> &mut Self {
213        self.add_tool_with(tool, ToolOptions::default())
214    }
215
216    /// [`add_tool`](Self::add_tool), with what its calls touch.
217    fn add_tool_with<T: Tool + 'static>(&mut self, tool: T, options: ToolOptions) -> &mut Self;
218
219    /// Make the open tool `name` available, its parameters derived from
220    /// `A` ([`args_schema`]): each call whose arguments parse into `A`
221    /// triggers [`ToolCalled<A>`] on the tool's entity, where `observer`
222    /// watches, and ends when the call's
223    /// [`Decision`] is decided, at once or much
224    /// later; any other is refused.
225    /// Its calls are recorded like any other.
226    fn add_open_tool<A, M>(
227        &mut self,
228        name: &str,
229        description: &str,
230        options: ToolOptions,
231        observer: impl IntoObserverSystem<ToolCalled<A>, M>,
232    ) -> &mut Self
233    where
234        A: DeserializeOwned + JsonSchema + Send + Sync + 'static;
235}
236
237impl AppToolsExt for App {
238    fn add_tool_with<T: Tool + 'static>(&mut self, tool: T, options: ToolOptions) -> &mut Self {
239        let definition = tool_definition(&tool);
240        let declared = Touches::from_annotations(&definition.annotations);
241        let handler = Handler(ErasedHandler::new(ToolAdapter::new(tool)));
242        let serves = Serves::Handler(handler);
243        register_tool(self.world_mut(), definition, serves, options, declared);
244        self
245    }
246
247    fn add_open_tool<A, M>(
248        &mut self,
249        name: &str,
250        description: &str,
251        options: ToolOptions,
252        observer: impl IntoObserverSystem<ToolCalled<A>, M>,
253    ) -> &mut Self
254    where
255        A: DeserializeOwned + JsonSchema + Send + Sync + 'static,
256    {
257        let world = self.world_mut();
258        let Ok(tool_name) = ToolName::new(name) else {
259            warn!("tool not registered: `{name}` is not a valid tool name");
260            return self;
261        };
262        let definition = ToolDefinition::new(tool_name, description, args_schema::<A>());
263        let serves = Serves::Open(open::<A>);
264        let changes = Touches::Writes(Scope::Anything);
265        if let Some(tool) = register_tool(world, definition, serves, options, changes) {
266            world.entity_mut(tool).observe(observer);
267        }
268        self
269    }
270}
271
272/// Registers tools while the app runs, such as those an MCP server lists
273/// once it answers, from a system:
274/// `commands.queue(move |world: &mut World| { world.spawn_tool(..); })`.
275/// Each request reads the registered tools again, so the next one offers
276/// it; despawning its entity removes it. A tool list that changes misses
277/// the provider's prompt cache once.
278pub trait WorldToolsExt {
279    /// Make the tool `definition` describes available as
280    /// [`AppToolsExt::add_tool_with`] does, its calls answered by `handler`
281    /// on the one dispatch path (a rig-core `Serve` of the tool family,
282    /// such as a `ToolAdapter`), its footprint the one `options` names, else
283    /// its handler's for each call, else what its annotations hint
284    /// (`Touches::from_hints`): they may be a remote server's, so a tool
285    /// that says it only reads is never run again after a restart. Its
286    /// entity, or `None`, with a warning, when its name is taken.
287    fn spawn_tool(
288        &mut self,
289        definition: ToolDefinition,
290        handler: ErasedHandler,
291        options: ToolOptions,
292    ) -> Option<Entity>;
293}
294
295impl WorldToolsExt for World {
296    fn spawn_tool(
297        &mut self,
298        definition: ToolDefinition,
299        handler: ErasedHandler,
300        options: ToolOptions,
301    ) -> Option<Entity> {
302        let serves = Serves::Handler(Handler(handler));
303        let hinted = Touches::from_hints(&definition.annotations);
304        register_tool(self, definition, serves, options, hinted)
305    }
306}
307
308/// Spawns the entity of the tool `definition` names, whose calls run as
309/// `serves` says, touching what `options` declares, else `default`, and
310/// returns it; `None`, with a warning, when the name is taken.
311fn register_tool(
312    world: &mut World,
313    definition: ToolDefinition,
314    serves: Serves,
315    options: ToolOptions,
316    default: Touches,
317) -> Option<Entity> {
318    let name = definition.name.as_str().to_owned();
319    if world
320        .query::<&ToolDef>()
321        .iter(world)
322        .any(|def| def.0.name == definition.name)
323    {
324        warn!("tool not registered: `{name}` exists; insert `Disabled` on it to replace it");
325        return None;
326    }
327    let declared = options.footprint.is_some();
328    let footprint = options.footprint.unwrap_or(default);
329    let mut entity = world.spawn((
330        Name::new(format!("tool:{name}")),
331        ToolDef(definition.into()),
332        Footprint(footprint),
333        serves,
334    ));
335    if declared {
336        entity.insert(DeclaredFootprint);
337    }
338    Some(entity.id())
339}
340
341/// What `call` touches, run by `handler`, of the tool `tool` (its default
342/// footprint, and whether that was declared): the declared one, else what
343/// the handler says of the call, else the tool's default, made
344/// [`normalized`](Touches::normalized) so no `..` climbs past a guard. A
345/// call of a tool that is not registered may change anything.
346pub(crate) fn footprint_of(
347    call: &ToolCall,
348    handler: Option<&Handler>,
349    tool: Option<(&Footprint, bool)>,
350) -> Footprint {
351    let Footprint(touches) = match tool {
352        Some((footprint, true)) => footprint.clone(),
353        _ => handler
354            .and_then(|handler| handler.0.footprint(&EffectKind::from(call)))
355            .map(Footprint)
356            .or_else(|| tool.map(|(footprint, _)| footprint.clone()))
357            .unwrap_or_default(),
358    };
359    Footprint(touches.normalized())
360}
361
362/// Completes each tool call about to be admitted with its tool's
363/// [`CallHandler`], and a [`Name`], where it has none of its own.
364pub(crate) fn complete_calls(
365    calls: Query<(Entity, &ToolRequest), (Without<Admitted>, Without<Settled>)>,
366    tools: Query<(&ToolDef, &Serves)>,
367    mut commands: Commands,
368) {
369    for (entity, ToolRequest { call }) in &calls {
370        let name = call.function.name.as_str();
371        let tool = tools.iter().find(|(def, _)| def.0.name.as_str() == name);
372        let mut completed = commands.entity(entity);
373        completed.try_insert_if_new(Name::new(format!("tool call {name}")));
374        if let Some((_, Serves::Handler(handler))) = tool {
375            completed.try_insert_if_new(CallHandler(handler.clone()));
376        }
377    }
378}
379
380/// Gives each admitted tool call that has not started the [`Footprint`]
381/// of the call as it stands, when Gate opens: after every Prepare edit of
382/// its arguments or handler; one a plugin [`DeclaredFootprint`] on the call stands.
383pub(crate) fn compute_footprints(
384    calls: Query<
385        (
386            Entity,
387            &ToolRequest,
388            Option<&CallHandler>,
389            Option<&Footprint>,
390            Has<DeclaredFootprint>,
391        ),
392        (With<Admitted>, Without<Started>, Without<Settled>),
393    >,
394    tools: Query<(&ToolDef, &Footprint, Has<DeclaredFootprint>)>,
395    mut commands: Commands,
396) {
397    for (entity, ToolRequest { call }, handler, had, declared) in &calls {
398        let footprint = match had {
399            Some(Footprint(touches)) if declared => Footprint(touches.clone().normalized()),
400            _ => {
401                let name = call.function.name.as_str();
402                let tool = tools
403                    .iter()
404                    .find(|(def, ..)| def.0.name.as_str() == name)
405                    .map(|(_, footprint, declared)| (footprint, declared));
406                footprint_of(call, handler.map(|handler| &handler.0), tool)
407            }
408        };
409        if had != Some(&footprint) {
410            commands.entity(entity).try_insert(footprint);
411        }
412    }
413}
414
415/// How Start starts a tool call.
416pub(crate) enum ToolStart {
417    /// The kernel runs `kind` through the handler.
418    Run(Handler, EffectKind),
419    /// The open call is started by its tool, the entity.
420    Open(Open, Entity),
421    /// Refused before it started.
422    Refused(ErrorReport),
423}
424
425/// What starting a tool call reads: the registered tools and the agents'
426/// access to them.
427#[derive(SystemParam)]
428pub(crate) struct ToolStarts<'w, 's> {
429    tools: Query<'w, 's, (Entity, &'static ToolDef, &'static Serves)>,
430    agents: Query<'w, 's, Option<&'static ToolAccess>, With<AgentId>>,
431}
432
433impl ToolStarts<'_, '_> {
434    /// The tool that runs `call` for `agent`, or why the kernel refuses
435    /// it: no agent, no tool of its name is registered, the agent may not
436    /// use it (a refusal, `Denied`), or its arguments do not fit the tool's
437    /// parameters.
438    fn tool(
439        &self,
440        call: &ToolCall,
441        agent: Option<Entity>,
442    ) -> Result<(Entity, &Serves), ErrorReport> {
443        let name = call.function.name.as_str();
444        let refused = |kind, why| ErrorReport::new(ErrorKind::Tool(kind), why);
445        let Some(access) = agent.and_then(|agent| self.agents.get(agent).ok()) else {
446            let why = "a tool call needs an agent".to_owned();
447            return Err(refused(ToolErrorKind::NotFound, why));
448        };
449        let missing = format!("no tool named `{name}` is available");
450        let Some((tool, def, serves)) = self
451            .tools
452            .iter()
453            .find(|(_, def, _)| def.0.name.as_str() == name)
454        else {
455            return Err(refused(ToolErrorKind::NotFound, missing));
456        };
457        if !access.is_some_and(|access| access.allows(name)) {
458            // A refusal, recorded as one; the model reads what it reads for
459            // a tool that does not exist.
460            return Err(ErrorReport::new(ErrorKind::Denied, missing).refused());
461        }
462        if let Some(why) = arguments_refusal(&def.0.parameters, call) {
463            return Err(refused(ToolErrorKind::InvalidArgs, why));
464        }
465        Ok((tool, serves))
466    }
467
468    /// How `call`, with `handler`, starts for `agent`: run through
469    /// `handler`, or opened for an open tool; refused as
470    /// [`refuse_calls`] refuses it, should it have changed since.
471    pub(crate) fn start(
472        &self,
473        call: &ToolCall,
474        handler: Option<&CallHandler>,
475        agent: Option<Entity>,
476    ) -> ToolStart {
477        match (self.tool(call, agent), handler) {
478            (Err(report), _) => ToolStart::Refused(report),
479            (Ok(_), Some(CallHandler(handler))) | (Ok((_, Serves::Handler(handler))), None) => {
480                ToolStart::Run(handler.clone(), EffectKind::from(call))
481            }
482            (Ok((tool, Serves::Open(open))), None) => ToolStart::Open(*open, tool),
483        }
484    }
485}
486
487/// Answers each admitted tool call that has not started, held or not,
488/// that the kernel refuses ([`ToolStarts`]), unless it is decided: after
489/// the answers from a record and before every gate, so no gate sees it,
490/// and one after the gates reads its [`Outcome`]. Run on every pass, so
491/// what changed since, such as the agent's [`ToolAccess`], counts.
492pub(crate) fn refuse_calls(
493    calls: Query<
494        (Entity, &ToolRequest, &Decision, Option<&ForAgent>),
495        (
496            With<Admitted>,
497            Without<Started>,
498            Without<Outcome>,
499            Without<Settled>,
500            Without<SupersededBy>,
501        ),
502    >,
503    tools: ToolStarts,
504    mut commands: Commands,
505) {
506    for (entity, ToolRequest { call }, decision, agent) in &calls {
507        if decision.is_decided() {
508            continue;
509        }
510        if let Err(report) = tools.tool(call, agent.map(|agent| agent.0)) {
511            commands
512                .entity(entity)
513                .try_insert_if_new(Outcome(Err(report)));
514        }
515    }
516}
517
518/// Redacts every secret in each tool call's answer, such as a key `env`
519/// printed ([`redact::tool_result`]), as Commit opens, after every
520/// reviewer and before the answer is committed: no key a tool returns
521/// reaches the model, the conversation, the transcript or a log. Only an answer changed since the
522/// last pass is searched, and only one holding a secret is copied.
523pub(crate) fn redact_results(
524    mut calls: Query<&mut Outcome, (ToolCalls, Changed<Outcome>, Without<Settled>)>,
525) {
526    for mut outcome in &mut calls {
527        let redacted = match &outcome.0 {
528            Ok(Answered::ToolResult { result }) => {
529                redact::tool_result(result).map(|result| Ok(Answered::ToolResult { result }))
530            }
531            Err(report) => match redact::text(&report.message) {
532                Cow::Owned(message) => Some(Err(ErrorReport {
533                    message,
534                    ..report.clone()
535                })),
536                Cow::Borrowed(_) => None,
537            },
538            Ok(_) => None,
539        };
540        if let Some(redacted) = redacted {
541            outcome.0 = redacted;
542        }
543    }
544}