Skip to main content

oino_agent/
tool.rs

1//! Tool contract (`AgentTool`, `AgentToolResult`).
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use futures::future::BoxFuture;
7use oino_ai::{ConstrainedSampling, JsonObject, JsonValue, ToolDecl, Usage, UserContent};
8use serde::{Deserialize, Serialize};
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11
12/// How a tool may be scheduled relative to other calls of the same assistant message
13///.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum ExecutionMode {
17    /// Runs concurrently with other calls of the batch.
18    #[default]
19    Parallel,
20    /// Forces the whole batch to run sequentially.
21    Sequential,
22}
23
24/// Whether a recorded call may be replayed.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Replay {
28    /// Never replay.
29    Never,
30    /// Replay is side-effect free.
31    Safe,
32}
33
34/// What the loop needs to know about a tool; core adds prompt and exposure metadata.
35#[derive(Clone, Debug, PartialEq)]
36pub struct ToolDefinitionMeta {
37    /// Model-facing name (unique per context).
38    pub name: String,
39    /// Human label for frontends.
40    pub label: String,
41    /// Model-facing description.
42    pub description: String,
43    /// JSON Schema of the arguments.
44    pub parameters: JsonValue,
45    /// JSON Schema of `structuredContent`.
46    pub output_schema: Option<JsonValue>,
47    /// Scheduling inside a tool batch.
48    pub execution_mode: ExecutionMode,
49    /// Replay policy.
50    pub replay: Option<Replay>,
51    /// Constrained-sampling preference sent with the declaration.
52    pub constrained_sampling: Option<ConstrainedSampling>,
53}
54
55impl ToolDefinitionMeta {
56    /// A parallel tool with no output schema, replay policy or sampling preference.
57    #[must_use]
58    pub fn new(
59        name: impl Into<String>,
60        label: impl Into<String>,
61        description: impl Into<String>,
62        parameters: JsonValue,
63    ) -> Self {
64        Self {
65            name: name.into(),
66            label: label.into(),
67            description: description.into(),
68            parameters,
69            output_schema: None,
70            execution_mode: ExecutionMode::Parallel,
71            replay: None,
72            constrained_sampling: None,
73        }
74    }
75
76    /// The model-visible declaration recorded in system messages.
77    #[must_use]
78    pub fn declaration(&self) -> ToolDecl {
79        ToolDecl {
80            name: self.name.clone(),
81            description: self.description.clone(),
82            parameters: self.parameters.clone(),
83            constrained_sampling: self.constrained_sampling.clone(),
84        }
85    }
86}
87
88/// Result of a tool execution. Also used for partial updates.
89#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct AgentToolResult {
92    /// Text and image blocks sent to the model.
93    #[serde(default)]
94    pub content: Vec<UserContent>,
95    /// Tool-specific details for frontends (not sent to the model).
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub details: Option<JsonValue>,
98    /// Machine-readable result matching the tool's output schema.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub structured_content: Option<JsonValue>,
101    /// Usage of nested model calls made by the tool.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub usage: Option<Usage>,
104    /// The tool reports failure without raising an error.
105    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
106    pub is_error: bool,
107    /// Ask the loop to stop after this batch (honoured only when every result asks).
108    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
109    pub terminate: bool,
110}
111
112impl AgentToolResult {
113    /// A text-only result with no details.
114    #[must_use]
115    pub fn text(text: impl Into<String>) -> Self {
116        Self {
117            content: vec![UserContent::text(text)],
118            ..Self::default()
119        }
120    }
121
122    /// `{content:[{type:"text",text}], details:{}}`.
123    #[must_use]
124    pub fn error(message: impl Into<String>) -> Self {
125        Self {
126            content: vec![UserContent::text(message)],
127            details: Some(JsonValue::Object(JsonObject::new())),
128            ..Self::default()
129        }
130    }
131}
132
133/// Partial results streamed while a tool runs.
134pub type ToolUpdate = AgentToolResult;
135
136/// Failure raised by a tool; the loop turns it into an error result with this text.
137#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
138#[error("{0}")]
139pub struct ToolError(pub String);
140
141impl ToolError {
142    /// Error with the given message.
143    #[must_use]
144    pub fn new(message: impl Into<String>) -> Self {
145        Self(message.into())
146    }
147}
148
149impl From<String> for ToolError {
150    fn from(message: String) -> Self {
151        Self(message)
152    }
153}
154
155impl From<&str> for ToolError {
156    fn from(message: &str) -> Self {
157        Self(message.to_owned())
158    }
159}
160
161/// One validated call handed to [`AgentTool::execute`].
162#[derive(Clone, Debug, PartialEq)]
163pub struct ToolInvocation {
164    /// Provider tool-call id (nested calls use `<parent>/<n>`).
165    pub tool_call_id: String,
166    /// Validated (and coerced) arguments.
167    pub args: JsonObject,
168    /// Calling tool for nested calls.
169    pub parent_tool_call_id: Option<String>,
170}
171
172/// Sink for partial results. Cheap to clone; updates sent after the call
173/// settled are dropped.
174#[derive(Clone, Debug)]
175pub struct UpdateSink {
176    tx: Option<mpsc::UnboundedSender<ToolUpdate>>,
177    open: Arc<AtomicBool>,
178}
179
180impl UpdateSink {
181    /// A sink that discards every update.
182    #[must_use]
183    pub fn discard() -> Self {
184        Self {
185            tx: None,
186            open: Arc::new(AtomicBool::new(false)),
187        }
188    }
189
190    /// A sink feeding `tx`, plus the switch that closes it.
191    pub(crate) fn channel() -> (Self, mpsc::UnboundedReceiver<ToolUpdate>, Arc<AtomicBool>) {
192        let (tx, rx) = mpsc::unbounded_channel();
193        let open = Arc::new(AtomicBool::new(true));
194        (
195            Self {
196                tx: Some(tx),
197                open: Arc::clone(&open),
198            },
199            rx,
200            open,
201        )
202    }
203
204    /// Publishes a partial result.
205    pub fn send(&self, update: ToolUpdate) {
206        if !self.open.load(Ordering::Acquire) {
207            return;
208        }
209        if let Some(tx) = &self.tx {
210            // A closed receiver means the call settled; dropping the update is expected.
211            let _ = tx.send(update);
212        }
213    }
214
215    /// Whether updates are still accepted.
216    #[must_use]
217    pub fn is_open(&self) -> bool {
218        self.open.load(Ordering::Acquire)
219    }
220}
221
222/// An executable tool.
223pub trait AgentTool: Send + Sync {
224    /// Static metadata.
225    fn meta(&self) -> &ToolDefinitionMeta;
226
227    /// Normalises raw arguments before validation.
228    fn prepare_arguments(&self, raw: JsonObject) -> JsonObject {
229        raw
230    }
231
232    /// Runs the call. `Err` becomes an error result with the error text.
233    fn execute(
234        &self,
235        call: ToolInvocation,
236        updates: UpdateSink,
237        cancel: CancellationToken,
238    ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>>;
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use serde_json::json;
245
246    #[test]
247    fn error_result_matches_pi_shape() {
248        let value = serde_json::to_value(AgentToolResult::error("boom")).unwrap();
249        assert_eq!(
250            value,
251            json!({"content":[{"type":"text","text":"boom"}],"details":{}})
252        );
253    }
254
255    #[test]
256    fn closed_sink_drops_updates() {
257        let (sink, mut rx, open) = UpdateSink::channel();
258        sink.send(AgentToolResult::text("a"));
259        open.store(false, Ordering::Release);
260        sink.send(AgentToolResult::text("b"));
261        assert_eq!(rx.try_recv().ok(), Some(AgentToolResult::text("a")));
262        assert!(rx.try_recv().is_err());
263    }
264}