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}
53
54impl ToolResult {
55    /// A successful result for `id`.
56    pub fn success(id: impl Into<String>, content: impl Into<String>) -> Self {
57        Self {
58            id: id.into(),
59            content: content.into(),
60            is_error: false,
61            code: None,
62        }
63    }
64
65    /// A failed result for `id`; `content` should say what went wrong.
66    pub fn failure(id: impl Into<String>, content: impl Into<String>) -> Self {
67        Self {
68            id: id.into(),
69            content: content.into(),
70            is_error: true,
71            code: None,
72        }
73    }
74
75    /// Attaches the process exit code, for command results.
76    pub fn with_code(mut self, code: i32) -> Self {
77        self.code = Some(code);
78        self
79    }
80}
81
82/// Per-run tool execution environment, created by the driver so that
83/// permissions, cwd, and cancellation are fixed before the step starts.
84#[derive(Debug, Clone)]
85pub struct ToolContext {
86    /// Working directory tool executions run in.
87    pub cwd: PathBuf,
88    /// Driver-chosen identifier of the current run, for tracing.
89    pub run_id: String,
90    /// Set to `true` by the driver to ask in-flight tools to stop; polling
91    /// the flag is the resolver's responsibility.
92    pub cancelled: Arc<AtomicBool>,
93}
94
95impl Default for ToolContext {
96    fn default() -> Self {
97        Self {
98            cwd: PathBuf::from("."),
99            run_id: String::new(),
100            cancelled: Arc::new(AtomicBool::new(false)),
101        }
102    }
103}
104
105/// Whether re-running a tool after an interruption can repeat an external
106/// side effect. Declared per tool via [`ToolResolver::replay_safety`] and
107/// consulted when a session resumes with a committed call whose result
108/// never landed: a `Safe` call may be re-executed to finish the round, an
109/// `Unsafe` one is reported interrupted so the model can verify state
110/// before retrying.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
112pub enum ReplaySafety {
113    /// Re-running may repeat an external effect — a post, a merge, a
114    /// delete. The default: an unclassified tool is treated as
115    /// side-effecting.
116    #[default]
117    Unsafe,
118    /// Re-running is safe — read-only queries, pure computation. A resume
119    /// may re-execute the call without asking the model again.
120    Safe,
121}
122
123/// Turns a model's tool calls into results.
124#[async_trait]
125pub trait ToolResolver: Send + Sync {
126    /// The tools offered to the model this step.
127    fn specs(&self) -> Vec<crate::request::ToolSpec>;
128
129    /// The replay safety of one offered tool (see [`ReplaySafety`]),
130    /// consulted when a crash left a committed call without its result.
131    /// Defaults to [`ReplaySafety::Unsafe`]: a resolver that does not
132    /// classify its tools is treated as entirely side-effecting.
133    fn replay_safety(&self, _name: &str) -> ReplaySafety {
134        ReplaySafety::Unsafe
135    }
136
137    /// Turn a model's call into a result. Errors become in-band results:
138    /// implementations must not panic and must not abort on unknown names
139    /// or bad arguments — return [`ToolResult::failure`] instead.
140    async fn resolve(&self, call: ToolCall, ctx: &ToolContext) -> ToolResult;
141
142    /// Resolve with progress: an implementation running something long —
143    /// a shell command — calls `progress` with human-facing output
144    /// chunks (complete lines) as they land, so a sink can render them
145    /// live; the full text still arrives in the returned result. The
146    /// default ignores progress and resolves as usual.
147    async fn resolve_streaming(
148        &self,
149        call: ToolCall,
150        ctx: &ToolContext,
151        progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
152    ) -> ToolResult {
153        let _ = progress;
154        self.resolve(call, ctx).await
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn a_result_without_a_code_field_loads_from_an_old_history() {
164        let raw = r#"{"id":"c1","content":"ok","is_error":false}"#;
165        let result: ToolResult = serde_json::from_str(raw).unwrap();
166        assert_eq!(result.code, None);
167        assert_eq!(result.with_code(3).code, Some(3));
168    }
169}