Skip to main content

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}