lc_core/tools/base.rs
1// src/core/tools/base.rs
2//! Tool base traits.
3//!
4//! Python's BaseTool uses a simplified run(input: str) -> str interface.
5
6use crate::runnables::{LcelError, Runnable, RunnableConfig};
7use async_trait::async_trait;
8use schemars::JsonSchema;
9use serde::{de::DeserializeOwned, Serialize};
10use serde_json::Value;
11use std::sync::Arc;
12
13/// Base tool trait (object-safe version).
14///
15/// This is the base interface for tool registries and Agents.
16/// Uses string input/output to simplify LLM calls.
17///
18/// All tools must implement this interface to be used by Agents.
19#[async_trait]
20pub trait BaseTool: Send + Sync {
21 /// Returns the tool name.
22 ///
23 /// Name should be unique and clearly express the tool's purpose.
24 fn name(&self) -> &str;
25
26 /// Returns the tool description.
27 ///
28 /// Description should detail the tool's purpose, input format, and output format.
29 fn description(&self) -> &str;
30
31 /// Execute the tool (string version).
32 ///
33 /// This is the primary interface called by Agents.
34 /// Input is typically a JSON string, output is the execution result.
35 ///
36 /// # Arguments
37 /// * `input` - Tool input (typically JSON-formatted string).
38 ///
39 /// # Returns
40 /// String representation of execution result.
41 async fn run(&self, input: String) -> Result<String, ToolError>;
42
43 /// Returns the input JSON Schema.
44 ///
45 /// Used to describe the tool's input format to the LLM.
46 fn args_schema(&self) -> Option<Value> {
47 None
48 }
49
50 /// Whether to return result directly to user.
51 ///
52 /// If true, tool output is returned directly to user, not passed to Agent.
53 fn return_direct(&self) -> bool {
54 false
55 }
56
57 /// Declared Rule-of-Two risk profile (A2, v0.22.1).
58 ///
59 /// Defaults to an all-false profile, so existing tools are never intercepted. Tools that
60 /// combine an untrusted input source, sensitive access, and world-state change should
61 /// override this so the agent can refuse the call before executing it (see
62 /// [`ToolRiskProfile::count_armed`]).
63 fn risk(&self) -> ToolRiskProfile {
64 ToolRiskProfile::empty()
65 }
66
67 /// Handle execution error.
68 ///
69 /// Returns a friendly error message when tool execution fails.
70 async fn handle_error(&self, error: ToolError) -> String {
71 format!("Tool '{}' execution failed: {}", self.name(), error)
72 }
73}
74
75/// Generic tool trait (type-safe version).
76///
77/// For scenarios requiring type-safe input/output.
78/// Tools implementing this trait can be automatically wrapped as BaseTool.
79#[async_trait]
80pub trait Tool: Send + Sync {
81 /// Input type (must support deserialization and JSON Schema).
82 type Input: DeserializeOwned + JsonSchema + Send + Sync + 'static;
83
84 /// Output type (must support serialization).
85 type Output: Serialize + Send + Sync;
86
87 /// Execute the tool.
88 ///
89 /// # Arguments
90 /// * `input` - Tool input.
91 ///
92 /// # Returns
93 /// Tool output.
94 async fn invoke(&self, input: Self::Input) -> Result<Self::Output, ToolError>;
95
96 /// Returns the input JSON Schema.
97 fn args_schema(&self) -> Option<Value> {
98 use schemars::schema_for;
99 serde_json::to_value(schema_for!(Self::Input)).ok()
100 }
101}
102
103/// Tool error type.
104#[derive(Debug, thiserror::Error)]
105#[non_exhaustive]
106pub enum ToolError {
107 /// Input validation error.
108 #[error("Invalid input: {0}")]
109 InvalidInput(String),
110
111 /// Execution error.
112 #[error("Execution failed: {0}")]
113 ExecutionFailed(String),
114
115 /// Timeout.
116 #[error("Timeout: {0} seconds")]
117 Timeout(u64),
118
119 /// Tool not found.
120 #[error("Tool not found: {0}")]
121 ToolNotFound(String),
122
123 /// MCP transport-layer error (adapted via `MCPToolAdapter`), preserving code/message/data (P1-6).
124 ///
125 /// Not silently downgraded to `ExecutionFailed`: the caller can distinguish connection drop /
126 /// method-not-found / argument errors by `code`.
127 #[error("MCP error [{code}]: {message}")]
128 McpError {
129 /// MCP error code
130 code: i32,
131 /// MCP error message
132 message: String,
133 /// Additional error data (optional)
134 data: Option<Value>,
135 },
136
137 /// Framework-level control abort (0.20.0 S3.1).
138 ///
139 /// Distinct from [`ExecutionFailed`](ToolError::ExecutionFailed): this is the framework
140 /// **refusing to perform** the tool call for control-flow reasons (e.g. the handoff
141 /// cycle / depth guard in `lc-agents`), not the tool running and failing. Callers must
142 /// propagate it **hard** — the agent cannot recover by re-planning, and softening it to
143 /// an observation would defeat the guard it exists to enforce.
144 #[error("Control abort: {0}")]
145 ControlAbort(String),
146
147 /// Authorization denied (0.22.4 A16): the framework refused to dispatch the
148 /// tool call **before it ran** — the target server declares no unattended-execution
149 /// policy (the MCP fail-closed default), its sandbox rejected the arguments, or its
150 /// runtime approval gate denied this specific call.
151 ///
152 /// Distinct from [`ExecutionFailed`](ToolError::ExecutionFailed): the tool never
153 /// executed, so (unlike [`ControlAbort`](ToolError::ControlAbort)) feeding the reason
154 /// back as an observation lets the agent re-plan or ask for approval.
155 #[error("Permission denied: {0}")]
156 PermissionDenied(String),
157}
158
159use super::{ToolDefinition, ToolRiskProfile};
160
161/// Converts BaseTool to ToolDefinition (for function calling).
162///
163/// # Arguments
164/// * `tool` - Tool implementing BaseTool trait.
165///
166/// # Returns
167/// ToolDefinition for bind_tools().
168///
169/// # Example
170/// ```ignore
171/// use langchainrust::{Calculator, BaseTool, to_tool_definition};
172/// use std::sync::Arc;
173///
174/// let calculator = Calculator::new();
175/// let tool_def = to_tool_definition(&calculator);
176/// ```
177pub fn to_tool_definition(tool: &dyn BaseTool) -> ToolDefinition {
178 ToolDefinition::new(tool.name(), tool.description()).with_parameters(
179 tool.args_schema()
180 .unwrap_or(serde_json::json!({"type": "object"})),
181 )
182}
183
184// Runnable form: lets a tool enter an LCEL chain, so `tool.pipe(...)` works.
185// Receives a String (usually JSON input), delegates to `run`; errors go into `LcelError::Tool` via `From<ToolError>`.
186#[async_trait]
187impl Runnable<String, String> for Arc<dyn BaseTool> {
188 type Error = LcelError;
189
190 async fn invoke(
191 &self,
192 input: String,
193 _config: Option<RunnableConfig>,
194 ) -> Result<String, LcelError> {
195 BaseTool::run(&**self, input).await.map_err(LcelError::from)
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use std::sync::Arc;
203
204 /// Simple echo tool: returns `echo: {input}`.
205 struct EchoTool;
206
207 #[async_trait]
208 impl BaseTool for EchoTool {
209 fn name(&self) -> &str {
210 "echo"
211 }
212 fn description(&self) -> &str {
213 "回显输入"
214 }
215 async fn run(&self, input: String) -> Result<String, ToolError> {
216 Ok(format!("echo: {input}"))
217 }
218 }
219
220 #[tokio::test]
221 async fn arc_tool_is_runnable() {
222 let tool: Arc<dyn BaseTool> = Arc::new(EchoTool);
223 let result = tool.invoke("hi".to_string(), None).await.unwrap();
224 assert_eq!(result, "echo: hi");
225 }
226
227 #[tokio::test]
228 async fn arc_tool_pipes() {
229 use crate::runnables::{RunnableExt, RunnableLambda};
230
231 let tool: Arc<dyn BaseTool> = Arc::new(EchoTool);
232 let chain = tool.pipe(RunnableLambda::new_sync(|s: String| s.to_uppercase()));
233 let result = chain.invoke("hi".to_string(), None).await.unwrap();
234 assert_eq!(result, "ECHO: HI");
235 }
236
237 #[tokio::test]
238 async fn arc_tool_error_maps_to_lcel() {
239 struct FailingTool;
240 #[async_trait]
241 impl BaseTool for FailingTool {
242 fn name(&self) -> &str {
243 "fail"
244 }
245 fn description(&self) -> &str {
246 "总是失败"
247 }
248 async fn run(&self, _input: String) -> Result<String, ToolError> {
249 Err(ToolError::ExecutionFailed("boom".to_string()))
250 }
251 }
252
253 let tool: Arc<dyn BaseTool> = Arc::new(FailingTool);
254 let err = tool.invoke("x".to_string(), None).await.unwrap_err();
255 assert!(matches!(err, LcelError::Tool(ref msg) if msg.contains("boom")));
256 }
257}