agentyk_core/tool.rs
1//! Tools — functions the model can call during a turn.
2//!
3//! Mirrors the everruns `Tool` / `ToolDefinition` vocabulary. Tools reach the
4//! model through capabilities ([`crate::capability::Capability::tools`]) or
5//! directly via `AgentBuilder::tool` in the `agentyk` framework crate.
6
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13
14use crate::id::{SessionId, TurnId};
15
16/// What the model sees: name, description, and a JSON-schema for arguments.
17#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
18#[non_exhaustive]
19pub struct ToolDefinition {
20 /// What the model calls it. Unique within one turn's tool list.
21 pub name: String,
22 /// What it does, written for the model — this is prompt text, and the
23 /// main lever on whether the tool gets called correctly.
24 pub description: String,
25 /// JSON schema for the arguments object.
26 pub parameters: serde_json::Value,
27 /// Generic, serializable extension hatch — the tool-definition analogue
28 /// of [`crate::event::EventData::Custom`]. Host-side, everruns-flavored
29 /// metadata an adopting host owns (a risk/hint taxonomy like
30 /// `readonly`/`destructive`/`open_world`, categories, MCP annotations)
31 /// rides here rather than growing typed core fields; it is **not** sent
32 /// to the model. `Null` for the common case.
33 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
34 pub metadata: serde_json::Value,
35}
36
37impl ToolDefinition {
38 /// Describe a tool to the model.
39 pub fn new(
40 name: impl Into<String>,
41 description: impl Into<String>,
42 parameters: serde_json::Value,
43 ) -> Self {
44 Self {
45 name: name.into(),
46 description: description.into(),
47 parameters,
48 metadata: serde_json::Value::Null,
49 }
50 }
51
52 /// Attach host-side extension metadata — see [`ToolDefinition::metadata`].
53 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
54 self.metadata = metadata;
55 self
56 }
57}
58
59/// Whether a tool's definition is sent to the model up front. Mirrors
60/// everruns' `DeferrablePolicy` (what ToolSearch-style deferred-tool
61/// discovery is built on).
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum DeferrablePolicy {
64 /// Always included in the tool list sent to the model.
65 #[default]
66 Never,
67 /// Omitted from `atoms::assemble`'s tool list — a capability can still
68 /// execute it (it stays in the lookup table), it just isn't offered to
69 /// the model by default. Surfacing deferred tools on demand (a
70 /// ToolSearch-style capability) is not implemented; this is the policy
71 /// slot such a capability would read.
72 Deferred,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76/// Host-side policy about a tool — how it is offered, not what it does.
77#[non_exhaustive]
78pub struct ToolPolicy {
79 /// Whether the model is told about this tool up front.
80 pub deferrable: DeferrablePolicy,
81}
82
83impl ToolPolicy {
84 /// A policy with an explicit deferrability.
85 pub fn new(deferrable: DeferrablePolicy) -> Self {
86 Self { deferrable }
87 }
88
89 /// Hide this tool from the model's tool list while keeping it callable —
90 /// see [`DeferrablePolicy::Deferred`].
91 pub fn deferred() -> Self {
92 Self::new(DeferrablePolicy::Deferred)
93 }
94}
95
96/// The result of executing a tool.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98#[non_exhaustive]
99pub struct ToolOutput {
100 /// What the model receives back.
101 pub content: String,
102 /// Whether `content` describes a failure. A tool failure is a *result*,
103 /// not a turn failure: the model sees it and gets to react.
104 pub is_error: bool,
105 /// Structured result data for the **host**, never sent to the model —
106 /// the tool-result analogue of [`ToolDefinition::metadata`].
107 ///
108 /// A tool result is one string because that is what every provider's
109 /// wire format accepts. That is fine for the model and lossy for
110 /// everything else: a UI that wants to render a diff, a file list, an
111 /// exit code, or a preview has to re-parse prose. Put the structured
112 /// form here and the flat form in `content`, and both consumers get what
113 /// they need. It rides on `tool.completed`, so listeners see it live and
114 /// replay sees it again.
115 ///
116 /// `Null` for the common case, and omitted from serialization then, so
117 /// existing logs still deserialize.
118 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
119 pub metadata: serde_json::Value,
120 /// Extra content parts the **model** receives alongside `content` —
121 /// images a model can actually look at.
122 ///
123 /// Separate from `content` rather than replacing it, because the two have
124 /// different audiences and different guarantees. `content` is the text
125 /// every provider accepts for a tool result and the only thing a
126 /// text-only provider will ever see; `parts` is best-effort richness that
127 /// a driver maps as well as its protocol allows (see
128 /// [`crate::driver::ChatDriver`] implementations — Anthropic puts images
129 /// inside the `tool_result` block; the OpenAI Chat Completions protocol
130 /// has no such slot, so its driver relays them as a following user
131 /// message rather than dropping them). Write `content` as if `parts` will
132 /// not arrive.
133 ///
134 /// Empty for the common case, and omitted from serialization then.
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
136 pub parts: Vec<crate::message::ContentPart>,
137}
138
139impl ToolOutput {
140 /// Build an output whose error-ness is only known at runtime; prefer
141 /// [`ToolOutput::text`] or [`ToolOutput::error`] when it is known.
142 pub fn new(content: impl Into<String>, is_error: bool) -> Self {
143 Self {
144 content: content.into(),
145 is_error,
146 metadata: serde_json::Value::Null,
147 parts: Vec::new(),
148 }
149 }
150
151 /// A successful result.
152 pub fn text(content: impl Into<String>) -> Self {
153 Self::new(content, false)
154 }
155
156 /// A failure the model should see and can react to. Prefer this over
157 /// failing the turn: "file not found" is something a model can recover
158 /// from.
159 pub fn error(content: impl Into<String>) -> Self {
160 Self::new(content, true)
161 }
162
163 /// Attach structured data for the host — see [`ToolOutput::metadata`].
164 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
165 self.metadata = metadata;
166 self
167 }
168
169 /// Attach content parts for the model — see [`ToolOutput::parts`].
170 ///
171 /// ```
172 /// # use agentyk_core::{ContentPart, ImageContentPart, ToolOutput};
173 /// let shot = ToolOutput::text("captured the login page").with_parts([
174 /// ContentPart::Image(ImageContentPart::from_base64("iVBORw0…", "image/png")),
175 /// ]);
176 /// assert_eq!(shot.parts.len(), 1);
177 /// ```
178 pub fn with_parts(
179 mut self,
180 parts: impl IntoIterator<Item = crate::message::ContentPart>,
181 ) -> Self {
182 self.parts = parts.into_iter().collect();
183 self
184 }
185}
186
187/// One progress report from a tool that is still running.
188///
189/// Tools that take real time — a build, a test run, a large fetch — are
190/// otherwise silent between `tool.started` and `tool.completed`, which is the
191/// exact window a user is waiting through. A report is **ephemeral**: it
192/// reaches listeners and is never persisted, the same contract as a streaming
193/// message delta.
194#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
195#[non_exhaustive]
196pub struct ToolProgress {
197 /// One line for a human, e.g. `"compiling agentyk-core"`.
198 pub message: String,
199 /// Optional structured detail for a host that renders more than a line —
200 /// a percentage, a byte count, a chunk of streamed output. Host-defined;
201 /// core never interprets it.
202 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
203 pub detail: serde_json::Value,
204}
205
206impl ToolProgress {
207 /// A plain progress line.
208 pub fn message(message: impl Into<String>) -> Self {
209 Self {
210 message: message.into(),
211 detail: serde_json::Value::Null,
212 }
213 }
214
215 /// Attach structured detail — see [`ToolProgress::detail`].
216 pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
217 self.detail = detail;
218 self
219 }
220}
221
222/// Where a running tool's progress reports go.
223///
224/// Implemented by the host, not by tools: the in-process executor's
225/// implementation turns each report into an ephemeral `tool.progress` event
226/// for the session's listeners. A durable host might instead forward them to
227/// a queue. Tools never construct one — they call
228/// [`ToolContext::report_progress`], which does nothing when no host supplied
229/// a sink (a unit test, say).
230#[async_trait]
231pub trait ToolProgressSink: Send + Sync {
232 /// Deliver one report. Must not fail the tool: a host that cannot
233 /// deliver drops the report.
234 async fn progress(&self, progress: ToolProgress);
235}
236
237/// Execution-time context handed to a tool.
238#[derive(Clone, Default)]
239#[non_exhaustive]
240pub struct ToolContext {
241 /// The session this call belongs to — the scope for anything the tool
242 /// keeps between turns.
243 pub session_id: SessionId,
244 /// The turn this call belongs to.
245 pub turn_id: TurnId,
246 /// Host-injected services a tool can downcast by type — see
247 /// [`crate::extensions::Extensions`]. Empty unless the agent's builder
248 /// set some.
249 pub extensions: crate::extensions::Extensions,
250 /// The turn's cancellation signal.
251 ///
252 /// A host already drops a tool's future when the turn is cancelled, so a
253 /// tool needs this only to do something *better* than being dropped —
254 /// flush a partial result, remove a temp file, report what it got done.
255 /// A tool that ignores it is still cancellable.
256 ///
257 /// Middleware reaches it the same way (via
258 /// [`crate::middleware::ToolInvocation::context`]), which is how an
259 /// approval prompt stops waiting when the turn is cancelled.
260 pub cancellation: crate::cancellation::CancellationToken,
261 /// Where [`ToolContext::report_progress`] sends its reports. `None` when
262 /// no host is listening.
263 pub progress: Option<Arc<dyn ToolProgressSink>>,
264}
265
266impl std::fmt::Debug for ToolContext {
267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 f.debug_struct("ToolContext")
269 .field("session_id", &self.session_id)
270 .field("turn_id", &self.turn_id)
271 .field("cancellation", &self.cancellation)
272 .field("progress", &self.progress.is_some())
273 .finish_non_exhaustive()
274 }
275}
276
277impl ToolContext {
278 /// Context for a call in this session and turn: no extensions, no
279 /// progress sink, and a token nobody else holds (so nothing cancels it).
280 pub fn new(session_id: SessionId, turn_id: TurnId) -> Self {
281 Self {
282 session_id,
283 turn_id,
284 extensions: crate::extensions::Extensions::new(),
285 cancellation: crate::cancellation::CancellationToken::new(),
286 progress: None,
287 }
288 }
289
290 /// Make the host's injected services reachable from this call.
291 pub fn with_extensions(mut self, extensions: crate::extensions::Extensions) -> Self {
292 self.extensions = extensions;
293 self
294 }
295
296 /// Share the turn's cancellation signal with this call.
297 pub fn with_cancellation(
298 mut self,
299 cancellation: crate::cancellation::CancellationToken,
300 ) -> Self {
301 self.cancellation = cancellation;
302 self
303 }
304
305 /// Route this call's progress reports to the host.
306 pub fn with_progress(mut self, progress: Arc<dyn ToolProgressSink>) -> Self {
307 self.progress = Some(progress);
308 self
309 }
310
311 /// Report progress, if anyone is listening.
312 ///
313 /// Deliberately infallible and silent when unwired: a tool should be
314 /// able to narrate what it is doing without branching on whether a host
315 /// cares.
316 ///
317 /// ```
318 /// # use agentyk_core::{ToolContext, ToolOutput, ToolProgress};
319 /// # async fn run(context: &ToolContext) -> ToolOutput {
320 /// context.report_progress(ToolProgress::message("running tests")).await;
321 /// ToolOutput::text("ok")
322 /// # }
323 /// ```
324 pub async fn report_progress(&self, progress: ToolProgress) {
325 if let Some(sink) = &self.progress {
326 sink.progress(progress).await;
327 }
328 }
329}
330
331/// A function the model can call.
332///
333/// Tools reach the model through a [`crate::capability::Capability`], or
334/// directly via `AgentBuilder::tool`. For a closure, use [`FnTool`].
335#[async_trait]
336pub trait Tool: Send + Sync {
337 /// What the model is told about this tool.
338 fn definition(&self) -> ToolDefinition;
339
340 /// Run it.
341 ///
342 /// Returns a [`ToolOutput`] rather than a `Result` on purpose: a tool
343 /// that fails should tell the *model* so, and let it recover, instead of
344 /// failing the turn. `arguments` come from the model — validate them.
345 async fn execute(&self, arguments: serde_json::Value, context: &ToolContext) -> ToolOutput;
346
347 /// Default: always offered to the model — see [`ToolPolicy`].
348 fn policy(&self) -> ToolPolicy {
349 ToolPolicy::default()
350 }
351}
352
353type BoxedToolFuture = Pin<Box<dyn Future<Output = ToolOutput> + Send>>;
354type BoxedToolFn = Box<dyn Fn(serde_json::Value) -> BoxedToolFuture + Send + Sync>;
355
356/// A tool built from a closure — the quickest way to hand the model a
357/// function.
358///
359/// ```
360/// use agentyk_core::{FnTool, ToolOutput};
361/// use serde_json::json;
362///
363/// let add = FnTool::new(
364/// "add",
365/// "Add two numbers.",
366/// json!({
367/// "type": "object",
368/// "properties": {"a": {"type": "number"}, "b": {"type": "number"}},
369/// "required": ["a", "b"]
370/// }),
371/// |args| async move {
372/// let a = args["a"].as_f64().unwrap_or(0.0);
373/// let b = args["b"].as_f64().unwrap_or(0.0);
374/// ToolOutput::text((a + b).to_string())
375/// },
376/// );
377/// ```
378pub struct FnTool {
379 definition: ToolDefinition,
380 handler: BoxedToolFn,
381}
382
383impl FnTool {
384 /// Wrap a closure as a tool. `parameters` is the JSON schema the model
385 /// sees for the arguments.
386 pub fn new<F, Fut>(
387 name: impl Into<String>,
388 description: impl Into<String>,
389 parameters: serde_json::Value,
390 handler: F,
391 ) -> Self
392 where
393 F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static,
394 Fut: Future<Output = ToolOutput> + Send + 'static,
395 {
396 Self {
397 definition: ToolDefinition::new(name, description, parameters),
398 handler: Box::new(move |args| Box::pin(handler(args))),
399 }
400 }
401}
402
403#[async_trait]
404impl Tool for FnTool {
405 fn definition(&self) -> ToolDefinition {
406 self.definition.clone()
407 }
408
409 async fn execute(&self, arguments: serde_json::Value, _context: &ToolContext) -> ToolOutput {
410 (self.handler)(arguments).await
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use serde_json::json;
418
419 #[test]
420 fn metadata_hatch_round_trips_and_is_omitted_when_null() {
421 let plain = ToolDefinition::new("read", "Read a file.", json!({"type": "object"}));
422 let plain_json = serde_json::to_string(&plain).unwrap();
423 assert!(!plain_json.contains("metadata"));
424
425 let hinted = plain.with_metadata(json!({"hints": {"risk": "readonly"}}));
426 let back: ToolDefinition =
427 serde_json::from_str(&serde_json::to_string(&hinted).unwrap()).unwrap();
428 assert_eq!(back, hinted);
429 assert_eq!(back.metadata["hints"]["risk"], "readonly");
430 }
431}