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}