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}