Skip to main content

tinyagents/harness/tool/
types.rs

1//! Tool layer types used by the harness.
2//!
3//! These types define the call boundary every harness capability shares —
4//! including sub-agents exposed as tools (see
5//! [`crate::harness::subagent::SubAgentTool`]), which is how the recursive
6//! architecture turns "agents calling agents" into ordinary tool calls.
7//!
8//! Here a [`ToolCall`] carries a required `id` so results can be correlated
9//! back to the originating call, matching provider tool-call semantics.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::Result;
19use crate::harness::context::RunContext;
20use crate::harness::events::EventSink;
21use crate::harness::ids::{RunId, ThreadId};
22use crate::harness::tool::{context_detail_from_args, humanize_tool_name};
23
24/// The model-visible syntax a tool declaration prefers.
25///
26/// Tool execution remains provider-neutral: after parsing, the harness invokes
27/// tools with [`ToolCall::arguments`] as JSON so local schema validation,
28/// middleware, tracing, and replay use one stable representation. This format
29/// tells prompt renderers and provider adapters how a tool should be exposed to
30/// a model when the provider does not force a native tool-calling shape.
31#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case", tag = "type")]
33pub enum ToolFormat {
34    /// Native JSON/function-call style. This is the default and maps directly to
35    /// providers such as OpenAI Chat Completions.
36    #[default]
37    Json,
38    /// XML tag style, for example
39    /// `<search><query>rust</query></search>`.
40    Xml,
41    /// Parametric p-type style: a compact ordered-parameter call syntax such as
42    /// `search("rust", 5)`.
43    PType {
44        /// Ordered parameter names used by compact renderers. The names should
45        /// correspond to fields in [`ToolSchema::parameters`].
46        parameters: Vec<String>,
47    },
48}
49
50/// A model-visible declaration of a tool: its name, description,
51/// JSON-schema-compatible parameter shape, and preferred tool-call format.
52#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
53pub struct ToolSchema {
54    /// Canonical tool name (ASCII `snake_case` by convention).
55    pub name: String,
56    /// Human/model readable description of what the tool does.
57    pub description: String,
58    /// JSON Schema describing the model-visible input arguments.
59    pub parameters: Value,
60    /// Preferred model-visible tool-call format.
61    #[serde(default, skip_serializing_if = "ToolFormat::is_json")]
62    pub format: ToolFormat,
63}
64
65/// A request from the model to invoke a tool.
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67pub struct ToolCall {
68    /// Provider-assigned call id, required for result correlation.
69    pub id: String,
70    /// Name of the tool to invoke.
71    pub name: String,
72    /// Arguments supplied by the model, as raw JSON.
73    ///
74    /// When [`Self::invalid`] is set, this preserves the raw (unparseable)
75    /// arguments string the model emitted (as a JSON string value) instead of a
76    /// parsed object.
77    #[serde(default)]
78    pub arguments: Value,
79    /// Set when the model emitted arguments that could not be parsed as JSON.
80    ///
81    /// Small local models (Ollama, LM Studio, llama.cpp, vLLM) occasionally emit
82    /// malformed argument JSON. Rather than fail the whole model call, the
83    /// provider surfaces the call with this error message and the raw arguments
84    /// preserved in [`Self::arguments`]; the agent loop feeds the message back to
85    /// the model as an error tool result so it can retry (mirroring LangChain's
86    /// `invalid_tool_calls` and the AI SDK's invalid dynamic tool parts). `None`
87    /// on a well-formed call.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub invalid: Option<String>,
90}
91
92/// The outcome of executing a [`ToolCall`].
93#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
94pub struct ToolResult {
95    /// Id of the [`ToolCall`] this result answers.
96    pub call_id: String,
97    /// Name of the tool that produced the result.
98    pub name: String,
99    /// Model-facing textual content.
100    pub content: String,
101    /// Optional structured value for application code.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub raw: Option<Value>,
104    /// Error message when the tool failed; `None` on success.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub error: Option<String>,
107    /// Wall-clock execution time in milliseconds.
108    #[serde(default)]
109    pub elapsed_ms: u64,
110}
111
112/// Live run context visible to a tool invoked by an agent loop.
113///
114/// The legacy [`Tool::call`] entry point remains available for direct calls and
115/// tests. The agent loop uses [`Tool::call_with_context`] so recursive tools
116/// such as sub-agents can inherit caller lineage while still isolating child
117/// threads.
118#[derive(Clone)]
119pub struct ToolExecutionContext {
120    /// Run that invoked the tool.
121    pub run_id: RunId,
122    /// Caller thread id, when the parent run is threaded.
123    pub thread_id: Option<ThreadId>,
124    /// Caller recursion depth.
125    pub depth: usize,
126    /// Maximum output tokens requested for each model turn in the caller's run.
127    pub max_turn_output_tokens: Option<u32>,
128    /// Shared event sink for nested run observability.
129    pub events: EventSink,
130    /// Whether the caller run is being driven through the streaming loop path.
131    /// A sub-agent tool uses this to run its child in the matching mode so the
132    /// child's deltas propagate onto the shared [`EventSink`].
133    pub streaming: bool,
134    /// The isolated workspace/sandbox the tool may operate in, when the run was
135    /// configured with a
136    /// [`WorkspaceIsolation`][crate::harness::workspace::WorkspaceIsolation]
137    /// provider. A tool discovers its allowed root here instead of an
138    /// application global; `None` means no workspace policy is in effect.
139    pub workspace: Option<crate::harness::workspace::WorkspaceDescriptor>,
140}
141
142impl ToolExecutionContext {
143    /// Captures the non-generic tool-visible parts of a live [`RunContext`].
144    pub fn from_run_context<Ctx>(ctx: &RunContext<Ctx>) -> Self {
145        Self {
146            run_id: ctx.config.run_id.clone(),
147            thread_id: ctx.config.thread_id.clone(),
148            depth: ctx.config.depth,
149            max_turn_output_tokens: ctx.config.max_turn_output_tokens,
150            events: ctx.events.clone(),
151            streaming: ctx.streaming,
152            workspace: ctx.workspace.clone(),
153        }
154    }
155
156    /// Attaches an isolated workspace descriptor the tool may operate in.
157    pub fn with_workspace(
158        mut self,
159        workspace: crate::harness::workspace::WorkspaceDescriptor,
160    ) -> Self {
161        self.workspace = Some(workspace);
162        self
163    }
164}
165
166/// How strictly a tool must be sandboxed when it executes.
167#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum SandboxMode {
170    /// Inherit whatever the run's execution environment provides (the default).
171    #[default]
172    Inherit,
173    /// The tool is safe to run without any sandbox.
174    Disabled,
175    /// The tool must run inside an isolated execution environment; policy
176    /// enforcement fails closed if no sandbox is available.
177    Required,
178}
179
180/// How a tool is allowed to reach the caller's workspace / filesystem root.
181#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum WorkspaceAccess {
184    /// The tool needs no filesystem/workspace access (the safe default).
185    #[default]
186    None,
187    /// The tool may only touch explicitly declared
188    /// [`ToolAccess::trusted_roots`].
189    Scoped,
190    /// The tool may touch any path the process can reach.
191    Any,
192}
193
194/// Declared side effects a tool may cause.
195///
196/// Used by policy enforcement (see
197/// [`ToolPolicyMiddleware`][crate::harness::middleware::ToolPolicyMiddleware]) to
198/// decide whether a tool may be exposed to the model or executed under a given
199/// run's constraints. Every flag defaults to `false`, matching a pure,
200/// side-effect-free tool.
201#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
202pub struct ToolSideEffects {
203    /// The tool reads state but never mutates anything observable.
204    pub read_only: bool,
205    /// The tool creates, modifies, or deletes files.
206    pub writes_files: bool,
207    /// The tool performs network I/O.
208    pub network: bool,
209    /// The tool installs packages or otherwise mutates the toolchain.
210    pub installs_dependencies: bool,
211    /// The tool can perform irreversible / destructive actions.
212    pub destructive: bool,
213    /// The tool calls an external third-party service.
214    pub external_service: bool,
215    /// The tool can move money or incur a charge.
216    pub payment: bool,
217}
218
219/// How the harness should bound a single tool invocation in wall-clock time.
220///
221/// Most tools should inherit the run's global tool timeout. Long-running
222/// scripting or build tools can opt out with [`ToolTimeout::Unbounded`] when the
223/// caller did not supply a deadline, and can return [`ToolTimeout::Millis`] for
224/// an explicit per-call budget.
225#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case", tag = "mode", content = "timeout_ms")]
227pub enum ToolTimeout {
228    /// Use the run/global timeout policy.
229    #[default]
230    Inherit,
231    /// Run without a harness-imposed wall-clock deadline.
232    Unbounded,
233    /// Enforce this exact deadline in milliseconds.
234    Millis(u64),
235}
236
237/// Human-facing presentation metadata for a tool invocation.
238///
239/// This metadata is never sent to the model as part of [`ToolSchema`]. It is
240/// intended for timelines, audit logs, and application UIs that need compact
241/// labels such as `Read(src/lib.rs)` instead of raw machine names.
242#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
243pub struct ToolDisplay {
244    /// Short verb phrase or title-cased label shown for the call.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub label: Option<String>,
247    /// Optional static detail. Dynamic details usually come from call args via
248    /// [`Tool::display_detail`].
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub detail: Option<String>,
251}
252
253/// Runtime requirements a tool declares for safe execution.
254#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
255pub struct ToolRuntime {
256    /// Suggested per-call wall-clock timeout in milliseconds.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub timeout_ms: Option<u64>,
259    /// Invocation timeout behavior when a simple numeric timeout is not enough.
260    #[serde(default, skip_serializing_if = "ToolTimeout::is_inherit")]
261    pub timeout: ToolTimeout,
262    /// Maximum automatic retries permitted for this tool.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub max_retries: Option<u32>,
265    /// Whether repeating the call with identical arguments is safe.
266    pub idempotent: bool,
267    /// Whether the tool honors cooperative cancellation.
268    pub cancelable: bool,
269    /// How strictly the tool must be sandboxed.
270    pub sandbox: SandboxMode,
271    /// Maximum result payload the harness should accept, in bytes.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub max_result_bytes: Option<usize>,
274    /// Whether the tool can emit [`ToolDelta`] streaming fragments.
275    pub streaming: bool,
276}
277
278/// Access requirements a tool declares before it can be exposed or run.
279#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
280pub struct ToolAccess {
281    /// Workspace/filesystem reach the tool needs.
282    pub workspace: WorkspaceAccess,
283    /// Filesystem roots the tool is allowed to touch (for
284    /// [`WorkspaceAccess::Scoped`]).
285    #[serde(default, skip_serializing_if = "Vec::is_empty")]
286    pub trusted_roots: Vec<String>,
287    /// Named credentials the tool requires to be present.
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub credentials: Vec<String>,
290    /// Whether an explicit human approval is required before each call.
291    pub approval_required: bool,
292    /// Whether the tool is safe to run in a background/non-interactive run.
293    pub background_safe: bool,
294}
295
296/// SDK-owned safety and runtime metadata attached to a [`Tool`].
297///
298/// A tool advertises its policy through [`Tool::policy`]. The default is
299/// **unclassified** (`classified == false`): policy enforcement can be
300/// configured to fail closed on unclassified tools so that adding a new tool
301/// without declaring its safety profile does not silently widen the attack
302/// surface. The plain [`ToolSchema`] remains the model-visible projection;
303/// `ToolPolicy` is the audit/enforcement projection and is fully serializable
304/// for registry introspection.
305#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
306pub struct ToolPolicy {
307    /// Whether the tool author has explicitly classified this policy. A default
308    /// (`false`) marks the tool as *unclassified*, which strict enforcement
309    /// treats as untrusted.
310    pub classified: bool,
311    /// Declared side effects.
312    pub side_effects: ToolSideEffects,
313    /// Declared runtime requirements.
314    pub runtime: ToolRuntime,
315    /// Declared access requirements.
316    pub access: ToolAccess,
317    /// Human-facing presentation metadata.
318    #[serde(default, skip_serializing_if = "ToolDisplay::is_empty")]
319    pub display: ToolDisplay,
320}
321
322/// An incremental progress update emitted while a tool runs (streaming).
323#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
324pub struct ToolDelta {
325    /// Id of the [`ToolCall`] this delta belongs to.
326    pub call_id: String,
327    /// Incremental content fragment.
328    pub content: String,
329    /// The tool's name, when known. Providers that surface it on the first
330    /// (call-opening) delta populate this so consumers can label a tool call as
331    /// soon as it begins — before its arguments have streamed. Subsequent
332    /// argument fragments leave it `None`; [`StreamAccumulator`] remembers the
333    /// first non-empty name per `call_id` and stamps it onto the reconstructed
334    /// [`ToolCall`].
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub tool_name: Option<String>,
337}
338
339/// A tool the harness can invoke during an agent loop.
340///
341/// Generic over the application `State` so tools can read shared context
342/// without exposing it to model-visible schemas.
343#[async_trait]
344pub trait Tool<State: Send + Sync>: Send + Sync {
345    /// Canonical tool name.
346    fn name(&self) -> &str;
347
348    /// Human/model readable description.
349    fn description(&self) -> &str;
350
351    /// Returns the model-visible schema for this tool.
352    fn schema(&self) -> ToolSchema;
353
354    /// Returns the SDK-owned safety/runtime policy for this tool.
355    ///
356    /// The default is an *unclassified* [`ToolPolicy`]; tools that touch the
357    /// filesystem, network, money, or otherwise carry risk should override this
358    /// so policy enforcement can make fail-closed decisions. Enforcement lives in
359    /// [`ToolPolicyMiddleware`][crate::harness::middleware::ToolPolicyMiddleware].
360    fn policy(&self) -> ToolPolicy {
361        ToolPolicy::default()
362    }
363
364    /// Returns the human-facing label for this specific call.
365    ///
366    /// The default prefers [`ToolPolicy::display`] and otherwise derives a
367    /// compact title-cased label from [`Self::name`]. Applications can use this
368    /// for timelines and audit logs without exposing presentation text to model
369    /// tool declarations.
370    fn display_label(&self, _call: &ToolCall) -> Option<String> {
371        self.policy()
372            .display
373            .label
374            .or_else(|| Some(humanize_tool_name(self.name())))
375    }
376
377    /// Returns the human-facing detail for this specific call.
378    ///
379    /// The default prefers a static [`ToolPolicy::display`] detail and
380    /// otherwise extracts the most relevant common argument from the call.
381    fn display_detail(&self, call: &ToolCall) -> Option<String> {
382        self.policy()
383            .display
384            .detail
385            .or_else(|| context_detail_from_args(&call.arguments))
386    }
387
388    /// Returns the invocation timeout behavior for this specific call.
389    ///
390    /// The default reads [`ToolPolicy::runtime`]. Static `timeout_ms` values are
391    /// promoted to [`ToolTimeout::Millis`] for callers that consume the richer
392    /// timeout vocabulary; tools with argument-dependent deadlines can override
393    /// this method.
394    fn timeout_policy(&self, _call: &ToolCall) -> ToolTimeout {
395        let runtime = self.policy().runtime;
396        match (runtime.timeout, runtime.timeout_ms) {
397            (ToolTimeout::Inherit, Some(timeout_ms)) => ToolTimeout::Millis(timeout_ms),
398            (timeout, _) => timeout,
399        }
400    }
401
402    /// Executes the tool against application state and a validated call.
403    async fn call(&self, state: &State, call: ToolCall) -> Result<ToolResult>;
404
405    /// Executes the tool with access to caller run context.
406    ///
407    /// Implementors that do not need caller lineage can rely on the default,
408    /// which delegates to [`Self::call`].
409    async fn call_with_context(
410        &self,
411        state: &State,
412        call: ToolCall,
413        context: ToolExecutionContext,
414    ) -> Result<ToolResult> {
415        let _ = context;
416        self.call(state, call).await
417    }
418}
419
420/// A name-keyed registry of tools available to the harness.
421pub struct ToolRegistry<State> {
422    pub(crate) tools: HashMap<String, Arc<dyn Tool<State>>>,
423}