Skip to main content

ingot_runtime/
tools.rs

1//! Tool hosting and approval.
2//!
3//! The runtime never executes a tool itself. It resolves the call, re-checks the
4//! artifact's policy, and hands the invocation to a [`ToolHost`] the operator
5//! supplied. The default host denies everything, so a tool runs only because
6//! somebody chose to provide it.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use serde_json::Value;
12
13/// One resolved tool call.
14#[derive(Debug, Clone, PartialEq)]
15pub struct ToolInvocation {
16    /// The IR node that made the call, for cassette matching and error
17    /// messages. The same role `CompletionRequest::node` plays for a model call.
18    pub node: String,
19    /// The agent making the call.
20    ///
21    /// A host that bounds what a tool can reach needs this: two agents in one
22    /// program deliberately hold different policies, and a bound wide enough
23    /// for both would hand each of them the other's grants.
24    pub agent: String,
25    /// Transport-qualified reference, e.g. `mcp:web.search`.
26    pub reference: String,
27    /// Bare tool name, e.g. `web.search`.
28    pub name: String,
29    pub transport: String,
30    /// Arguments in the callee's declaration order.
31    pub arguments: BTreeMap<String, Value>,
32    /// Effects the artifact declares for this tool.
33    pub effects: Vec<String>,
34    /// The Ingot type the tool is declared to return.
35    pub result_type: String,
36}
37
38#[derive(Debug)]
39pub enum ToolError {
40    /// The host does not provide this tool.
41    NotAvailable(String),
42    /// The tool ran and failed.
43    Failed(String),
44    /// The tool returned something that is not its declared type.
45    InvalidResult(String),
46}
47
48impl fmt::Display for ToolError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            ToolError::NotAvailable(name) => write!(
52                f,
53                "no host provides the tool `{name}`; \
54                 the artifact requires it, so the run cannot continue"
55            ),
56            ToolError::Failed(message) => write!(f, "the tool failed: {message}"),
57            ToolError::InvalidResult(message) => {
58                write!(f, "the tool returned an unexpected value: {message}")
59            }
60        }
61    }
62}
63
64impl std::error::Error for ToolError {}
65
66/// Something that can execute a tool call.
67pub trait ToolHost {
68    fn name(&self) -> &str;
69
70    /// Whether this host can serve `tool`, checked before the call is attempted
71    /// so that a missing tool is reported before any effect happens.
72    fn provides(&self, tool: &str) -> bool;
73
74    fn call(&mut self, invocation: &ToolInvocation) -> Result<Value, ToolError>;
75}
76
77/// Lets a boxed host be used wherever a host is expected — including as the
78/// inner host of a [`crate::RecordingTools`], which is how the CLI wraps a
79/// recorder around a host it chose at runtime. The same courtesy
80/// [`crate::ModelProvider`] already gets.
81impl<H: ToolHost + ?Sized> ToolHost for Box<H> {
82    fn name(&self) -> &str {
83        (**self).name()
84    }
85
86    fn provides(&self, tool: &str) -> bool {
87        (**self).provides(tool)
88    }
89
90    fn call(&mut self, invocation: &ToolInvocation) -> Result<Value, ToolError> {
91        (**self).call(invocation)
92    }
93}
94
95/// Refuses every tool. The default, so nothing runs by accident.
96pub struct DenyAllTools;
97
98impl ToolHost for DenyAllTools {
99    fn name(&self) -> &str {
100        "deny-all"
101    }
102
103    fn provides(&self, _tool: &str) -> bool {
104        false
105    }
106
107    fn call(&mut self, invocation: &ToolInvocation) -> Result<Value, ToolError> {
108        Err(ToolError::NotAvailable(invocation.name.clone()))
109    }
110}
111
112/// Serves tools from an in-process table. Test scaffolding, and the basis for
113/// the MCP host that replaces it.
114#[derive(Default)]
115pub struct StaticToolHost {
116    #[allow(clippy::type_complexity)]
117    handlers: BTreeMap<String, Box<dyn FnMut(&ToolInvocation) -> Result<Value, ToolError>>>,
118}
119
120impl StaticToolHost {
121    pub fn new() -> StaticToolHost {
122        StaticToolHost::default()
123    }
124
125    pub fn with(
126        mut self,
127        name: impl Into<String>,
128        handler: impl FnMut(&ToolInvocation) -> Result<Value, ToolError> + 'static,
129    ) -> StaticToolHost {
130        self.handlers.insert(name.into(), Box::new(handler));
131        self
132    }
133}
134
135impl ToolHost for StaticToolHost {
136    fn name(&self) -> &str {
137        "static"
138    }
139
140    fn provides(&self, tool: &str) -> bool {
141        self.handlers.contains_key(tool)
142    }
143
144    fn call(&mut self, invocation: &ToolInvocation) -> Result<Value, ToolError> {
145        match self.handlers.get_mut(&invocation.name) {
146            Some(handler) => handler(invocation),
147            None => Err(ToolError::NotAvailable(invocation.name.clone())),
148        }
149    }
150}
151
152/// How this run reaches a person, if it can.
153///
154/// One channel for both of the things a run can want from a human: a yes or no
155/// on an effect the policy gates, and the answer to a question the program
156/// wrote. They were designed together because they want the same channel, and
157/// building it twice would have given two. See
158/// [RFC-0020](../../../rfcs/0020-a-person-in-the-loop.md).
159pub enum HumanChannel {
160    /// Ask whoever is on the other end.
161    Ask(Box<dyn Interlocutor>),
162    /// Approve without asking. Requires an explicit opt-in from the operator,
163    /// because the artifact asked for a human.
164    ///
165    /// **Approves a gate and cannot answer a question.** There is no default
166    /// answer to *which framing should the report take*, and inventing one would
167    /// put a value into the flow and the recording that nobody chose. The
168    /// asymmetry is the difference between a decision with a known safe side and
169    /// one without.
170    AssumeYes,
171    /// Refuse every gate and every question. The safe default for unattended
172    /// runs.
173    Deny,
174}
175
176impl fmt::Debug for HumanChannel {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        match self {
179            HumanChannel::Ask(_) => f.write_str("Ask(..)"),
180            HumanChannel::AssumeYes => f.write_str("AssumeYes"),
181            HumanChannel::Deny => f.write_str("Deny"),
182        }
183    }
184}
185
186/// A person, from the run's side of the channel.
187pub trait Interlocutor {
188    /// Whether a gated action may proceed.
189    fn approve(&mut self, request: &ApprovalRequest) -> bool;
190
191    /// Put a question to a person and return what they said.
192    ///
193    /// Fallible where [`Interlocutor::approve`] is not, and the difference is
194    /// real rather than stylistic: a gate that cannot reach anybody has a safe
195    /// answer, and a question does not. Refusing to guess is the only thing
196    /// left, so this reports why instead.
197    fn consult(&mut self, request: &ConsultRequest) -> Result<String, ConsultError>;
198}
199
200/// A question the program wrote, on its way to a person.
201#[derive(Debug, Clone, PartialEq)]
202pub struct ConsultRequest {
203    pub node: String,
204    /// Which consultation this is within the run, counting from zero.
205    ///
206    /// The same number the cassette matches by, so a recorded event stream and a
207    /// recording line up without either having to carry the other's identifier.
208    pub index: usize,
209    pub question: String,
210    /// What a person may answer, when the program limited it. Empty means free
211    /// text.
212    pub choices: Vec<String>,
213    /// What the run wants the person to see first, named as the source named it.
214    ///
215    /// The same shape a model call carries, and for the same reason: the surface
216    /// showing it decides how to render, because a terminal and a page do not
217    /// render the same way.
218    pub context: Vec<(String, Value)>,
219}
220
221/// Why a question could not be answered.
222#[derive(Debug, Clone, PartialEq)]
223pub enum ConsultError {
224    /// There is nobody to ask.
225    NoChannel(String),
226    /// Somebody was asked and the answer did not arrive.
227    Failed(String),
228    /// An answer arrived that was not one of the choices offered.
229    NotAChoice {
230        answer: String,
231        choices: Vec<String>,
232    },
233}
234
235impl fmt::Display for ConsultError {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        match self {
238            ConsultError::NoChannel(reason) => write!(f, "there is nobody to ask: {reason}"),
239            ConsultError::Failed(reason) => write!(f, "the question was not answered: {reason}"),
240            ConsultError::NotAChoice { answer, choices } => write!(
241                f,
242                "`{answer}` is not one of the choices offered ({})",
243                choices.join(", ")
244            ),
245        }
246    }
247}
248
249impl std::error::Error for ConsultError {}
250
251#[derive(Debug, Clone, PartialEq)]
252pub struct ApprovalRequest {
253    pub node: String,
254    pub effects: Vec<String>,
255    /// The label the compiler attached, naming what is about to happen.
256    pub reason: String,
257}
258
259/// Answers from a fixed list, then denies. Test scaffolding.
260pub struct ScriptedApprovals {
261    answers: Vec<bool>,
262    position: usize,
263}
264
265impl ScriptedApprovals {
266    pub fn new(answers: Vec<bool>) -> ScriptedApprovals {
267        ScriptedApprovals {
268            answers,
269            position: 0,
270        }
271    }
272}
273
274impl Interlocutor for ScriptedApprovals {
275    fn approve(&mut self, _request: &ApprovalRequest) -> bool {
276        let answer = self.answers.get(self.position).copied().unwrap_or(false);
277        self.position += 1;
278        answer
279    }
280
281    fn consult(&mut self, request: &ConsultRequest) -> Result<String, ConsultError> {
282        Err(ConsultError::NoChannel(format!(
283            "scripted approvals answer gates and not questions (node `{}`)",
284            request.node
285        )))
286    }
287}
288
289/// Answers questions from a fixed list, then refuses. Test scaffolding.
290pub struct ScriptedAnswers {
291    answers: Vec<String>,
292    position: usize,
293}
294
295impl ScriptedAnswers {
296    pub fn new(answers: Vec<impl Into<String>>) -> ScriptedAnswers {
297        ScriptedAnswers {
298            answers: answers.into_iter().map(Into::into).collect(),
299            position: 0,
300        }
301    }
302}
303
304impl Interlocutor for ScriptedAnswers {
305    /// Approves, so a test can put a gate and a question in one flow without
306    /// needing two channels.
307    fn approve(&mut self, _request: &ApprovalRequest) -> bool {
308        true
309    }
310
311    fn consult(&mut self, request: &ConsultRequest) -> Result<String, ConsultError> {
312        let Some(answer) = self.answers.get(self.position).cloned() else {
313            return Err(ConsultError::NoChannel(format!(
314                "the script has {} answer(s) and node `{}` asked for another",
315                self.answers.len(),
316                request.node
317            )));
318        };
319        self.position += 1;
320        Ok(answer)
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use serde_json::json;
328
329    fn invocation(name: &str) -> ToolInvocation {
330        ToolInvocation {
331            node: "n0".to_string(),
332            agent: "test.Agent".to_string(),
333            reference: format!("mcp:{name}"),
334            name: name.to_string(),
335            transport: "mcp".to_string(),
336            arguments: BTreeMap::new(),
337            effects: vec!["network".to_string()],
338            result_type: "json".to_string(),
339        }
340    }
341
342    #[test]
343    fn the_default_host_denies_everything() {
344        let mut host = DenyAllTools;
345        assert!(!host.provides("web.search"));
346        let error = host.call(&invocation("web.search")).unwrap_err();
347        assert!(error.to_string().contains("no host provides"), "{error}");
348    }
349
350    #[test]
351    fn a_static_host_serves_registered_tools() {
352        let mut host = StaticToolHost::new().with("web.search", |_| Ok(json!(["a", "b"])));
353        assert!(host.provides("web.search"));
354        assert!(!host.provides("files.write"));
355        assert_eq!(
356            host.call(&invocation("web.search")).unwrap(),
357            json!(["a", "b"])
358        );
359    }
360
361    #[test]
362    fn an_unregistered_tool_is_reported_by_name() {
363        let mut host = StaticToolHost::new().with("web.search", |_| Ok(json!(null)));
364        let error = host.call(&invocation("files.write")).unwrap_err();
365        assert!(error.to_string().contains("files.write"), "{error}");
366    }
367
368    #[test]
369    fn scripted_approvals_deny_once_exhausted() {
370        let mut handler = ScriptedApprovals::new(vec![true]);
371        let request = ApprovalRequest {
372            node: "n0".into(),
373            effects: vec!["external_write".into()],
374            reason: "test".into(),
375        };
376        assert!(handler.approve(&request));
377        assert!(
378            !handler.approve(&request),
379            "an exhausted script must not keep approving"
380        );
381    }
382}