Skip to main content

rhema_core/
tool.rs

1//! The capability seam: one primitive, not a registry.
2//!
3//! A [`ToolResolver`] decides which tools the model sees this step
4//! ([`ToolResolver::specs`]) and turns a model's call into an in-band
5//! [`ToolResult`]. Errors never abort a step — they become `is_error` results
6//! the model can react to; an unknown tool name is the resolver's error
7//! result, not a panic. Each tool also declares its [`ReplaySafety`]:
8//! whether a committed call that lost its result to a crash may be
9//! re-executed on resume or must be reported interrupted.
10
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::sync::atomic::AtomicBool;
14
15use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17
18/// One tool invocation requested by the model.
19#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
20pub struct ToolCall {
21    /// Model-chosen correlation id; the result must carry it back.
22    pub id: String,
23    /// Name of the tool to invoke. Must be one of the offered specs.
24    pub name: String,
25    /// Arguments as JSON, validated against the spec's `parameters` schema.
26    pub args: serde_json::Value,
27    /// Opaque provider metadata that must round-trip with this call through
28    /// the session history. Stateful providers mark their function calls
29    /// (e.g. Gemini's thought signatures) and reject replays that lose the
30    /// marker; the provider stores it here on the way in and reads it back
31    /// on the way out. Crate 1 never interprets it — the provider owns the
32    /// shape.
33    #[serde(default)]
34    pub provider_data: serde_json::Value,
35}
36
37/// The outcome of resolving one [`ToolCall`], appended to the session
38/// history as a `Tool`-role message.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct ToolResult {
41    /// The [`ToolCall::id`] this result answers.
42    pub id: String,
43    /// Result payload as text (JSON-serialized data, or an error message).
44    pub content: String,
45    /// Whether the call failed; the model reacts to this in-band.
46    pub is_error: bool,
47    /// The process exit code of command-like tools; `None` for everything
48    /// else. UI-facing metadata — the model reads the status from
49    /// `content`.
50    #[serde(default)]
51    pub code: Option<i32>,
52    /// Images the result carries beside its text, base64-encoded file
53    /// bytes. Empty for most tools; the shell tool fills it from the
54    /// images a command staged for viewing (rhema-agent-tools' `vision`
55    /// module). Every provider maps the field onto its wire — APIs whose
56    /// tool results carry text only state the withholding in the text.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub images: Vec<crate::Image>,
59}
60
61impl ToolResult {
62    /// A successful result for `id`.
63    pub fn success(id: impl Into<String>, content: impl Into<String>) -> Self {
64        Self {
65            id: id.into(),
66            content: content.into(),
67            is_error: false,
68            code: None,
69            images: Vec::new(),
70        }
71    }
72
73    /// A failed result for `id`; `content` should say what went wrong.
74    pub fn failure(id: impl Into<String>, content: impl Into<String>) -> Self {
75        Self {
76            id: id.into(),
77            content: content.into(),
78            is_error: true,
79            code: None,
80            images: Vec::new(),
81        }
82    }
83
84    /// Attaches the process exit code, for command results.
85    pub fn with_code(mut self, code: i32) -> Self {
86        self.code = Some(code);
87        self
88    }
89
90    /// Attaches images, replacing any already present — the staging
91    /// side's builder. An empty `images` changes nothing.
92    pub fn with_images(mut self, images: Vec<crate::Image>) -> Self {
93        self.images = images;
94        self
95    }
96}
97
98/// Per-run tool execution environment, created by the driver so that
99/// permissions, cwd, and cancellation are fixed before the step starts.
100#[derive(Debug, Clone)]
101pub struct ToolContext {
102    /// Working directory tool executions run in.
103    pub cwd: PathBuf,
104    /// Driver-chosen identifier of the current run, for tracing.
105    pub run_id: String,
106    /// Set to `true` by the driver to ask in-flight tools to stop; polling
107    /// the flag is the resolver's responsibility.
108    pub cancelled: Arc<AtomicBool>,
109}
110
111impl Default for ToolContext {
112    fn default() -> Self {
113        Self {
114            cwd: PathBuf::from("."),
115            run_id: String::new(),
116            cancelled: Arc::new(AtomicBool::new(false)),
117        }
118    }
119}
120
121/// Whether re-running a tool after an interruption can repeat an external
122/// side effect. Declared per tool via [`ToolResolver::replay_safety`] and
123/// consulted when a session resumes with a committed call whose result
124/// never landed: a `Safe` call may be re-executed to finish the round, an
125/// `Unsafe` one is reported interrupted so the model can verify state
126/// before retrying.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
128pub enum ReplaySafety {
129    /// Re-running may repeat an external effect — a post, a merge, a
130    /// delete. The default: an unclassified tool is treated as
131    /// side-effecting.
132    #[default]
133    Unsafe,
134    /// Re-running is safe — read-only queries, pure computation. A resume
135    /// may re-execute the call without asking the model again.
136    Safe,
137}
138
139/// Turns a model's tool calls into results.
140#[async_trait]
141pub trait ToolResolver: Send + Sync {
142    /// The tools offered to the model this step.
143    fn specs(&self) -> Vec<crate::request::ToolSpec>;
144
145    /// The replay safety of one offered tool (see [`ReplaySafety`]),
146    /// consulted when a crash left a committed call without its result.
147    /// Defaults to [`ReplaySafety::Unsafe`]: a resolver that does not
148    /// classify its tools is treated as entirely side-effecting.
149    fn replay_safety(&self, _name: &str) -> ReplaySafety {
150        ReplaySafety::Unsafe
151    }
152
153    /// Turn a model's call into a result. Errors become in-band results:
154    /// implementations must not panic and must not abort on unknown names
155    /// or bad arguments — return [`ToolResult::failure`] instead.
156    async fn resolve(&self, call: ToolCall, ctx: &ToolContext) -> ToolResult;
157
158    /// Resolve with progress: an implementation running something long —
159    /// a shell command — reports human-facing output chunks (complete
160    /// lines) as they land, so a sink can render them live; the full
161    /// text still arrives in the returned result. The default ignores
162    /// progress and resolves as usual.
163    async fn resolve_streaming(
164        &self,
165        call: ToolCall,
166        ctx: &ToolContext,
167        progress: &mut dyn ToolProgress,
168    ) -> ToolResult {
169        let _ = progress;
170        self.resolve(call, ctx).await
171    }
172}
173
174/// The live status of a streaming tool call's inner work: what that work
175/// is doing now, the context its current model call carries, and what
176/// its model calls have spent, when the tool knows. Status, not
177/// output — a sink shows it beside the call, and it never lands in the
178/// call's output or the session history.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct ToolStatus {
181    /// The phase the work is in, in the few words a status line reads
182    /// ("thinking", "running").
183    pub phase: String,
184    /// The work's current model call's usage — prompt plus reply in
185    /// flight — when the tool runs model calls of its own. The figure a
186    /// context-window gauge sizes against [`ToolStatus::model`]'s
187    /// window: each step re-sends the whole prompt, so this is the only
188    /// input count that reads as "how full is the context now".
189    pub context: Option<crate::output::Usage>,
190    /// The work's cumulative token spend across every model call so
191    /// far, when the tool runs model calls of its own. The figure cost
192    /// prices — every re-sent prompt is billed again, so the spend
193    /// legitimately grows past the context window.
194    pub spent: Option<crate::output::Usage>,
195    /// The model the work runs on — the catalog id a UI prices and sizes
196    /// it by — when the tool knows it. It may differ from the caller's
197    /// model.
198    pub model: Option<String>,
199}
200
201/// The live channel a streaming resolver reports through. Output is
202/// human-facing text owned by the call; status is the resolver's own
203/// state, for a sink that has a place to show it.
204pub trait ToolProgress: Send {
205    /// A human-facing output chunk (a complete line), rendered live; the
206    /// full text still arrives in the returned result.
207    fn output(&mut self, chunk: &str);
208    /// The work's current status, replacing any earlier one. The default
209    /// drops it: a sink with nowhere to show status loses nothing.
210    fn status(&mut self, status: ToolStatus) {
211        let _ = status;
212    }
213}
214
215/// A [`ToolProgress`] that keeps only the output chunks: the adapter for
216/// callers — drivers, tests — that have no status surface.
217pub struct OutputOnly<F: FnMut(&str) + Send>(
218    /// The receiver of the output chunks.
219    pub F,
220);
221
222impl<F: FnMut(&str) + Send> std::fmt::Debug for OutputOnly<F> {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        // The receiver is a closure: it prints as a placeholder.
225        f.debug_tuple("OutputOnly").field(&"<closure>").finish()
226    }
227}
228
229impl<F: FnMut(&str) + Send> ToolProgress for OutputOnly<F> {
230    fn output(&mut self, chunk: &str) {
231        (self.0)(chunk);
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn a_result_without_a_code_field_loads_from_an_old_history() {
241        let raw = r#"{"id":"c1","content":"ok","is_error":false}"#;
242        let result: ToolResult = serde_json::from_str(raw).unwrap();
243        assert_eq!(result.code, None);
244        assert_eq!(result.images, Vec::new());
245        assert_eq!(result.with_code(3).code, Some(3));
246    }
247
248    #[test]
249    fn images_round_trip_and_absent_images_stay_out_of_the_json() {
250        let result = ToolResult::success("c1", "here").with_images(vec![crate::Image::new(
251            crate::ImageMediaType::Png,
252            "aGVsbG8=",
253        )]);
254        let text = serde_json::to_string(&result).unwrap();
255        assert!(text.contains("aGVsbG8="), "{text}");
256        let back: ToolResult = serde_json::from_str(&text).unwrap();
257        assert_eq!(back, result);
258
259        // No images: the field vanishes, so old histories stay byte-stable.
260        let bare = serde_json::to_value(ToolResult::success("c1", "here")).unwrap();
261        assert!(bare.get("images").is_none(), "{bare}");
262    }
263}