tkach/tool.rs
1use std::path::PathBuf;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use serde_json::Value;
6use tokio_util::sync::CancellationToken;
7
8use crate::error::ToolError;
9use crate::executor::ToolExecutor;
10
11/// Context passed to every tool execution.
12///
13/// Intentionally slim — holds only primitives the runtime actually owns
14/// and wants to share with tools:
15///
16/// - `working_dir`: file-system base for path resolution.
17/// - `cancel`: cooperative cancellation. Long-running tools should
18/// `tokio::select!` on `cancel.cancelled()` and return
19/// [`ToolError::Cancelled`] promptly.
20/// - `depth` / `max_depth`: current nesting level of the agent; used by
21/// `SubAgent` to prevent unbounded recursion.
22/// - `executor`: the parent agent's [`ToolExecutor`], letting tools that
23/// spawn nested work (e.g. `SubAgent`) inherit the full toolset
24/// automatically — no explicit layering required.
25pub struct ToolContext {
26 pub working_dir: PathBuf,
27 pub cancel: CancellationToken,
28 pub depth: usize,
29 pub max_depth: usize,
30 pub executor: Arc<ToolExecutor>,
31}
32
33impl ToolContext {
34 pub(crate) fn with_cancel(&self, cancel: CancellationToken) -> Self {
35 Self {
36 working_dir: self.working_dir.clone(),
37 cancel,
38 depth: self.depth,
39 max_depth: self.max_depth,
40 executor: Arc::clone(&self.executor),
41 }
42 }
43}
44
45/// Result of a tool execution.
46pub enum ToolOutput {
47 Text(String),
48 Error(String),
49}
50
51impl ToolOutput {
52 pub fn text(s: impl Into<String>) -> Self {
53 ToolOutput::Text(s.into())
54 }
55
56 pub fn error(s: impl Into<String>) -> Self {
57 ToolOutput::Error(s.into())
58 }
59
60 pub fn is_error(&self) -> bool {
61 matches!(self, ToolOutput::Error(_))
62 }
63
64 pub fn content(&self) -> &str {
65 match self {
66 ToolOutput::Text(s) | ToolOutput::Error(s) => s,
67 }
68 }
69}
70
71/// Side-effect class of a tool.
72///
73/// Used by the executor to safely parallelise consecutive read-only calls
74/// in a single batch while keeping mutating calls sequential. Mutating is
75/// the default because misclassifying a side-effectful tool as `ReadOnly`
76/// can lead to subtle ordering bugs (two "mutating" writes racing against
77/// each other); the reverse is merely a missed optimisation.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ToolClass {
80 /// No observable side effects. Safe to run concurrently with other
81 /// `ReadOnly` tools. Examples: `Read`, `Glob`, `Grep`, `WebFetch`.
82 ReadOnly,
83 /// Changes state — file system, external services, processes, or
84 /// nested agents. Must run sequentially to preserve ordering.
85 /// Examples: `Write`, `Edit`, `Bash`, `SubAgent`.
86 Mutating,
87}
88
89/// Trait for tools that the agent can use.
90///
91/// Implement this trait to create custom tools.
92///
93/// # Example
94///
95/// ```ignore
96/// use tkach::{Tool, ToolContext, ToolOutput, ToolError};
97/// use serde_json::{json, Value};
98///
99/// struct MyTool;
100///
101/// #[async_trait::async_trait]
102/// impl Tool for MyTool {
103/// fn name(&self) -> &str { "my_tool" }
104/// fn description(&self) -> &str { "Does something useful" }
105/// fn input_schema(&self) -> Value {
106/// json!({
107/// "type": "object",
108/// "properties": {
109/// "query": { "type": "string" }
110/// },
111/// "required": ["query"]
112/// })
113/// }
114/// async fn execute(&self, input: Value, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
115/// let query = input["query"].as_str().unwrap_or_default();
116/// Ok(ToolOutput::text(format!("Result for: {query}")))
117/// }
118/// }
119/// ```
120#[async_trait]
121pub trait Tool: Send + Sync {
122 /// Unique name of the tool (used by the LLM to invoke it).
123 fn name(&self) -> &str;
124
125 /// Human-readable description of what the tool does.
126 fn description(&self) -> &str;
127
128 /// JSON Schema describing the tool's input parameters.
129 fn input_schema(&self) -> Value;
130
131 /// Side-effect class. Defaults to `Mutating` — the safe choice for
132 /// tools that are not explicitly marked read-only. Override to return
133 /// [`ToolClass::ReadOnly`] only when you are certain the tool has no
134 /// observable side effects.
135 fn class(&self) -> ToolClass {
136 ToolClass::Mutating
137 }
138
139 /// Whether this tool itself drives nested executor work — i.e.
140 /// the tool's `execute` body holds the executor task open while
141 /// running another `Agent::run` underneath. The canonical example
142 /// is `SubAgent`.
143 ///
144 /// The executor uses this to admit recursive tools through the
145 /// concurrent-mutator pool *regardless of explicit promotion*,
146 /// because non-recursive admission classes (the width-1
147 /// `serial_mut` pool, in particular) are shared across the
148 /// agent tree and would deadlock when a parent's permit is
149 /// pinned during the child's nested `execute`. Routing recursive
150 /// tools through `concurrent_mut` — which the executor forks
151 /// per nesting level — keeps nested fan-out free of pool
152 /// contention while still bounding it by the
153 /// `max_concurrent_mutations` cap of the *current* level.
154 ///
155 /// Defaults to `false`. Override to `true` for tools whose
156 /// `execute` body drives nested executor work.
157 ///
158 /// When you override this, the recursive call site **must** run
159 /// against an executor obtained from
160 /// `ctx.executor.fork_for_subagent()` — not directly against
161 /// `ctx.executor`. The fork is what gives the nested level its
162 /// own `concurrent_mut` and per-tool semaphores; without it, a
163 /// parent saturating those pools would deadlock the moment any
164 /// child tried to acquire a permit from the same shared
165 /// semaphore. The canonical pattern is to construct an
166 /// `Agent::builder().executor(ctx.executor.fork_for_subagent())`
167 /// and `agent.run(...)` from inside `execute`; `SubAgent`
168 /// implements exactly this shape.
169 fn is_recursive(&self) -> bool {
170 false
171 }
172
173 /// Execute the tool with the given input.
174 async fn execute(&self, input: Value, ctx: &ToolContext) -> Result<ToolOutput, ToolError>;
175}