1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use serde_json::Value;
6
7use crate::tool::{Content, Tool, ToolContext, ToolPolicy, content_text};
8use crate::types::{AgentError, AgentResult, DEFAULT_TOOL_TIMEOUT_MS};
9
10#[async_trait]
15pub trait ToolExecutionPipeline: Send + Sync {
16 async fn execute(
17 &self,
18 tool: &dyn Tool,
19 args: &Value,
20 ctx: &ToolContext,
21 ) -> AgentResult<Vec<Content>>;
22}
23
24#[derive(Clone)]
26pub struct DefaultPipeline {
27 tool_policy: Option<Arc<dyn ToolPolicy>>,
28 default_tool_timeout_ms: u64,
29 tool_timeout_ms: Option<u64>,
30 max_output_chars: Option<usize>,
31}
32
33impl DefaultPipeline {
34 pub fn new(
35 tool_policy: Option<Arc<dyn ToolPolicy>>,
36 tool_timeout_ms: Option<u64>,
37 max_output_chars: Option<usize>,
38 ) -> Self {
39 Self {
40 tool_policy,
41 default_tool_timeout_ms: DEFAULT_TOOL_TIMEOUT_MS,
42 tool_timeout_ms,
43 max_output_chars,
44 }
45 }
46
47 pub fn with_default_timeout(mut self, timeout_ms: u64) -> Self {
48 self.default_tool_timeout_ms = timeout_ms;
49 self
50 }
51
52 pub fn policy(&self) -> Option<Arc<dyn ToolPolicy>> {
53 self.tool_policy.clone()
54 }
55}
56
57#[async_trait]
58impl ToolExecutionPipeline for DefaultPipeline {
59 async fn execute(
60 &self,
61 tool: &dyn Tool,
62 args: &Value,
63 ctx: &ToolContext,
64 ) -> AgentResult<Vec<Content>> {
65 if let Some(policy) = &self.tool_policy {
67 policy.before_call(tool.name(), args, ctx)?;
68 }
69
70 let timeout_ms = tool
72 .timeout_ms()
73 .or(self.tool_timeout_ms)
74 .unwrap_or(self.default_tool_timeout_ms);
75 let output =
76 match tokio::time::timeout(Duration::from_millis(timeout_ms), tool.call(args, ctx))
77 .await
78 {
79 Ok(result) => result?,
80 Err(_) => {
81 tracing::warn!(
82 tool = tool.name(),
83 timeout_ms = timeout_ms,
84 "tool execution timed out"
85 );
86 return Ok(vec![Content::text("[Tool Timeout]")]);
87 }
88 };
89
90 if let Some(max_chars) = self.max_output_chars {
94 let text_len = content_text(&output).chars().count();
95 if text_len > max_chars {
96 return Err(AgentError::ToolOutputTooLarge {
97 name: tool.name().to_string(),
98 max_chars,
99 });
100 }
101 }
102
103 if let Some(policy) = &self.tool_policy {
105 policy.after_call(tool.name(), args, &output, ctx)?;
106 }
107
108 Ok(output)
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use serde_json::json;
116
117 use crate::types::{ApprovalRequest, Language, SessionId};
118
119 use tokio::sync::mpsc;
122
123 fn test_ctx() -> ToolContext {
124 let (tx, _rx) = mpsc::unbounded_channel();
125 ToolContext {
126 session_id: SessionId::new(1),
127 user_event_tx: tx,
128 llm_client: None,
129 session_store: None,
130 language: Language::En,
131 cancel_token: tokio_util::sync::CancellationToken::new(),
132 max_output_chars: None,
133 event_bus: crate::engine::EventBus::new(1),
134 }
135 }
136
137 struct EchoTool;
138 #[async_trait]
139 impl Tool for EchoTool {
140 fn name(&self) -> &'static str {
141 "echo"
142 }
143 fn description(&self) -> &'static str {
144 "echo"
145 }
146 fn schema(&self) -> Value {
147 json!({})
148 }
149 async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
150 Ok(vec![Content::text(
151 args.get("msg").and_then(|v| v.as_str()).unwrap_or("ok"),
152 )])
153 }
154 }
155
156 struct SlowTool;
157 #[async_trait]
158 impl Tool for SlowTool {
159 fn name(&self) -> &'static str {
160 "slow"
161 }
162 fn description(&self) -> &'static str {
163 "slow"
164 }
165 fn schema(&self) -> Value {
166 json!({})
167 }
168 fn timeout_ms(&self) -> Option<u64> {
169 Some(50) }
171 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
172 tokio::time::sleep(Duration::from_secs(10)).await;
173 Ok(vec![Content::text("done")])
174 }
175 }
176
177 struct FailingTool;
178 #[async_trait]
179 impl Tool for FailingTool {
180 fn name(&self) -> &'static str {
181 "fail"
182 }
183 fn description(&self) -> &'static str {
184 "fail"
185 }
186 fn schema(&self) -> Value {
187 json!({})
188 }
189 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
190 Err(AgentError::tool_not_found("intentional"))
191 }
192 }
193
194 struct TrackingPolicy {
195 before_count: std::sync::atomic::AtomicU32,
196 after_count: std::sync::atomic::AtomicU32,
197 fail_before: bool,
198 }
199 impl TrackingPolicy {
200 fn new() -> Self {
201 Self {
202 before_count: std::sync::atomic::AtomicU32::new(0),
203 after_count: std::sync::atomic::AtomicU32::new(0),
204 fail_before: false,
205 }
206 }
207 fn fail_before_call() -> Self {
208 Self {
209 before_count: std::sync::atomic::AtomicU32::new(0),
210 after_count: std::sync::atomic::AtomicU32::new(0),
211 fail_before: true,
212 }
213 }
214 }
215 #[async_trait]
216 impl ToolPolicy for TrackingPolicy {
217 async fn evaluate_approval(&self, _: &str, _: &Value) -> Option<ApprovalRequest> {
218 None
219 }
220 fn before_call(&self, _name: &str, _args: &Value, _ctx: &ToolContext) -> AgentResult<()> {
221 self.before_count
222 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
223 if self.fail_before {
224 return Err(AgentError::internal("before_call denied"));
225 }
226 Ok(())
227 }
228 fn after_call(
229 &self,
230 _name: &str,
231 _args: &Value,
232 _output: &[Content],
233 _ctx: &ToolContext,
234 ) -> AgentResult<()> {
235 self.after_count
236 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
237 Ok(())
238 }
239 }
240
241 #[tokio::test]
244 async fn basic_execution() {
245 let pipeline = DefaultPipeline::new(None, None, None);
246 let output = pipeline
247 .execute(&EchoTool, &json!({"msg": "hello"}), &test_ctx())
248 .await
249 .unwrap();
250 assert_eq!(content_text(&output), "hello");
251 }
252
253 #[tokio::test]
254 async fn policy_before_and_after_called() {
255 let policy = Arc::new(TrackingPolicy::new());
256 let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
257
258 pipeline
259 .execute(&EchoTool, &json!({}), &test_ctx())
260 .await
261 .unwrap();
262
263 assert_eq!(
264 policy
265 .before_count
266 .load(std::sync::atomic::Ordering::SeqCst),
267 1
268 );
269 assert_eq!(
270 policy.after_count.load(std::sync::atomic::Ordering::SeqCst),
271 1
272 );
273 }
274
275 #[tokio::test]
276 async fn policy_before_call_aborts() {
277 let policy = Arc::new(TrackingPolicy::fail_before_call());
278 let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
279
280 let result = pipeline.execute(&EchoTool, &json!({}), &test_ctx()).await;
281 assert!(result.is_err());
282 assert_eq!(
284 policy.after_count.load(std::sync::atomic::Ordering::SeqCst),
285 0
286 );
287 }
288
289 #[tokio::test]
290 async fn timeout_fires() {
291 let pipeline = DefaultPipeline::new(None, Some(50), None); let output = pipeline
293 .execute(&SlowTool, &json!({}), &test_ctx())
294 .await
295 .unwrap();
296 assert_eq!(content_text(&output), "[Tool Timeout]");
297 }
298
299 #[tokio::test]
300 async fn no_timeout_when_tool_fast() {
301 let pipeline = DefaultPipeline::new(None, Some(5000), None);
302 let output = pipeline
303 .execute(&EchoTool, &json!({"msg": "fast"}), &test_ctx())
304 .await
305 .unwrap();
306 assert_eq!(content_text(&output), "fast");
307 }
308
309 #[tokio::test]
310 async fn output_over_limit_is_rejected() {
311 let pipeline = DefaultPipeline::new(None, None, Some(10)); let result = pipeline
313 .execute(
314 &EchoTool,
315 &json!({"msg": "this is a very long message"}),
316 &test_ctx(),
317 )
318 .await;
319 assert!(matches!(
320 result,
321 Err(AgentError::ToolOutputTooLarge { max_chars: 10, .. })
322 ));
323 }
324
325 #[tokio::test]
326 async fn no_rejection_when_short() {
327 let pipeline = DefaultPipeline::new(None, None, Some(100));
328 let output = pipeline
329 .execute(&EchoTool, &json!({"msg": "short"}), &test_ctx())
330 .await
331 .unwrap();
332 assert_eq!(content_text(&output), "short");
333 }
334
335 #[tokio::test]
336 async fn output_over_limit_cjk_rejected() {
337 let pipeline = DefaultPipeline::new(None, None, Some(20));
340 let result = pipeline
341 .execute(
342 &EchoTool,
343 &json!({"msg": "这是一个很长的中文消息,用于测试多字节字符的超限处理"}),
344 &test_ctx(),
345 )
346 .await;
347 assert!(matches!(
348 result,
349 Err(AgentError::ToolOutputTooLarge { max_chars: 20, .. })
350 ));
351 }
352
353 #[tokio::test]
354 async fn cjk_within_char_limit_is_not_rejected() {
355 let pipeline = DefaultPipeline::new(None, None, Some(20));
358 let output = pipeline
359 .execute(
360 &EchoTool,
361 &json!({"msg": "这是一个用于验证字符计数的中文消息"}),
362 &test_ctx(),
363 )
364 .await
365 .unwrap();
366 assert_eq!(content_text(&output).chars().count(), 17);
367 }
368
369 #[tokio::test]
370 async fn timeout_plus_limit() {
371 let pipeline = DefaultPipeline::new(None, Some(50), Some(100));
372 let output = pipeline
373 .execute(&SlowTool, &json!({}), &test_ctx())
374 .await
375 .unwrap();
376 assert_eq!(content_text(&output), "[Tool Timeout]");
378 }
379
380 #[tokio::test]
381 async fn tool_error_propagates() {
382 let pipeline = DefaultPipeline::new(None, None, None);
383 let result = pipeline
384 .execute(&FailingTool, &json!({}), &test_ctx())
385 .await;
386 assert!(result.is_err());
387 }
388}