Skip to main content

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    ///
98    /// J9:序列化失败不再 `.ok()` 占位空 `None`——`Self::Input` 必须 derive
99    /// `JsonSchema`(否则 `schema_for!` 本身是编译错误),schema 自描述必可序列化,
100    /// 真失败是内部错误,直接 panic 报出。
101    fn args_schema(&self) -> Option<Value> {
102        use schemars::schema_for;
103        Some(
104            serde_json::to_value(schema_for!(Self::Input))
105                .expect("[lc-core] BaseTool::args_schema: Input schema failed to serialize"),
106        )
107    }
108}
109
110/// Tool error type.
111#[derive(Debug, thiserror::Error)]
112#[non_exhaustive]
113pub enum ToolError {
114    /// Input validation error.
115    #[error("Invalid input: {0}")]
116    InvalidInput(String),
117
118    /// Execution error.
119    #[error("Execution failed: {0}")]
120    ExecutionFailed(String),
121
122    /// Timeout.
123    #[error("Timeout: {0} seconds")]
124    Timeout(u64),
125
126    /// Tool not found.
127    #[error("Tool not found: {0}")]
128    ToolNotFound(String),
129
130    /// MCP transport-layer error (adapted via `MCPToolAdapter`), preserving code/message/data (P1-6).
131    ///
132    /// Not silently downgraded to `ExecutionFailed`: the caller can distinguish connection drop /
133    /// method-not-found / argument errors by `code`.
134    #[error("MCP error [{code}]: {message}")]
135    McpError {
136        /// MCP error code
137        code: i32,
138        /// MCP error message
139        message: String,
140        /// Additional error data (optional)
141        data: Option<Value>,
142    },
143
144    /// Framework-level control abort (0.20.0 S3.1).
145    ///
146    /// Distinct from [`ExecutionFailed`](ToolError::ExecutionFailed): this is the framework
147    /// **refusing to perform** the tool call for control-flow reasons (e.g. the handoff
148    /// cycle / depth guard in `lc-agents`), not the tool running and failing. Callers must
149    /// propagate it **hard** — the agent cannot recover by re-planning, and softening it to
150    /// an observation would defeat the guard it exists to enforce.
151    #[error("Control abort: {0}")]
152    ControlAbort(String),
153
154    /// Authorization denied (0.22.4 A16): the framework refused to dispatch the
155    /// tool call **before it ran** — the target server declares no unattended-execution
156    /// policy (the MCP fail-closed default), its sandbox rejected the arguments, or its
157    /// runtime approval gate denied this specific call.
158    ///
159    /// Distinct from [`ExecutionFailed`](ToolError::ExecutionFailed): the tool never
160    /// executed, so (unlike [`ControlAbort`](ToolError::ControlAbort)) feeding the reason
161    /// back as an observation lets the agent re-plan or ask for approval.
162    #[error("Permission denied: {0}")]
163    PermissionDenied(String),
164}
165
166use super::{ToolDefinition, ToolRiskProfile};
167
168/// Converts BaseTool to ToolDefinition (for function calling).
169///
170/// # Arguments
171/// * `tool` - Tool implementing BaseTool trait.
172///
173/// # Returns
174/// ToolDefinition for bind_tools().
175///
176/// # Example
177/// ```ignore
178/// use langchainrust::{Calculator, BaseTool, to_tool_definition};
179/// use std::sync::Arc;
180///
181/// let calculator = Calculator::new();
182/// let tool_def = to_tool_definition(&calculator);
183/// ```
184pub fn to_tool_definition(tool: &dyn BaseTool) -> ToolDefinition {
185    ToolDefinition::new(tool.name(), tool.description()).with_parameters(
186        tool.args_schema()
187            .unwrap_or(serde_json::json!({"type": "object"})),
188    )
189}
190
191// Runnable form: lets a tool enter an LCEL chain, so `tool.pipe(...)` works.
192// Receives a String (usually JSON input), delegates to `run`; errors go into `LcelError::Tool` via `From<ToolError>`.
193#[async_trait]
194impl Runnable<String, String> for Arc<dyn BaseTool> {
195    type Error = LcelError;
196
197    async fn invoke(
198        &self,
199        input: String,
200        _config: Option<RunnableConfig>,
201    ) -> Result<String, LcelError> {
202        BaseTool::run(&**self, input).await.map_err(LcelError::from)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::sync::Arc;
210
211    /// Simple echo tool: returns `echo: {input}`.
212    struct EchoTool;
213
214    #[async_trait]
215    impl BaseTool for EchoTool {
216        fn name(&self) -> &str {
217            "echo"
218        }
219        fn description(&self) -> &str {
220            "回显输入"
221        }
222        async fn run(&self, input: String) -> Result<String, ToolError> {
223            Ok(format!("echo: {input}"))
224        }
225    }
226
227    #[tokio::test]
228    async fn arc_tool_is_runnable() {
229        let tool: Arc<dyn BaseTool> = Arc::new(EchoTool);
230        let result = tool.invoke("hi".to_string(), None).await.unwrap();
231        assert_eq!(result, "echo: hi");
232    }
233
234    #[tokio::test]
235    async fn arc_tool_pipes() {
236        use crate::runnables::{RunnableExt, RunnableLambda};
237
238        let tool: Arc<dyn BaseTool> = Arc::new(EchoTool);
239        let chain = tool.pipe(RunnableLambda::new_sync(|s: String| s.to_uppercase()));
240        let result = chain.invoke("hi".to_string(), None).await.unwrap();
241        assert_eq!(result, "ECHO: HI");
242    }
243
244    #[tokio::test]
245    async fn arc_tool_error_maps_to_lcel() {
246        struct FailingTool;
247        #[async_trait]
248        impl BaseTool for FailingTool {
249            fn name(&self) -> &str {
250                "fail"
251            }
252            fn description(&self) -> &str {
253                "总是失败"
254            }
255            async fn run(&self, _input: String) -> Result<String, ToolError> {
256                Err(ToolError::ExecutionFailed("boom".to_string()))
257            }
258        }
259
260        let tool: Arc<dyn BaseTool> = Arc::new(FailingTool);
261        let err = tool.invoke("x".to_string(), None).await.unwrap_err();
262        assert!(matches!(err, LcelError::Tool(ref msg) if msg.contains("boom")));
263    }
264}