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, ToolDecision, 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 let effective_args: std::borrow::Cow<'_, Value> = if let Some(policy) = &self.tool_policy {
67 match policy.before_call(tool.name(), args, ctx)? {
68 ToolDecision::Proceed => std::borrow::Cow::Borrowed(args),
69 ToolDecision::Block(msg) => {
70 return Err(AgentError::ToolBlocked(msg));
71 }
72 ToolDecision::Modify(modified) => std::borrow::Cow::Owned(modified),
73 }
74 } else {
75 std::borrow::Cow::Borrowed(args)
76 };
77 let args = &*effective_args;
78
79 let timeout_ms = tool
81 .timeout_ms()
82 .or(self.tool_timeout_ms)
83 .unwrap_or(self.default_tool_timeout_ms);
84 let output =
85 match tokio::time::timeout(Duration::from_millis(timeout_ms), tool.call(args, ctx))
86 .await
87 {
88 Ok(result) => result?,
89 Err(_) => {
90 tracing::warn!(
91 tool = tool.name(),
92 timeout_ms = timeout_ms,
93 "tool execution timed out"
94 );
95 return Ok(vec![Content::text("[Tool Timeout]")]);
96 }
97 };
98
99 if let Some(max_chars) = self.max_output_chars {
103 let text_len = content_text(&output).chars().count();
104 if text_len > max_chars {
105 return Err(AgentError::ToolOutputTooLarge {
106 name: tool.name().to_string(),
107 max_chars,
108 });
109 }
110 }
111
112 if let Some(policy) = &self.tool_policy {
114 policy.after_call(tool.name(), args, &output, ctx)?;
115 }
116
117 Ok(output)
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use serde_json::json;
125
126 use crate::types::{ApprovalRequest, Language, SessionId};
127
128 use tokio::sync::mpsc;
131
132 fn test_ctx() -> ToolContext {
133 let (tx, _rx) = mpsc::unbounded_channel();
134 ToolContext {
135 session_id: SessionId::new(1),
136 user_event_tx: tx,
137 llm_client: None,
138 session_store: None,
139 language: Language::En,
140 cancel_token: tokio_util::sync::CancellationToken::new(),
141 max_output_chars: None,
142 event_bus: crate::engine::EventBus::new(1),
143 }
144 }
145
146 struct EchoTool;
147 #[async_trait]
148 impl Tool for EchoTool {
149 fn name(&self) -> &'static str {
150 "echo"
151 }
152 fn description(&self) -> &'static str {
153 "echo"
154 }
155 fn schema(&self) -> Value {
156 json!({})
157 }
158 async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
159 Ok(vec![Content::text(
160 args.get("msg").and_then(|v| v.as_str()).unwrap_or("ok"),
161 )])
162 }
163 }
164
165 struct SlowTool;
166 #[async_trait]
167 impl Tool for SlowTool {
168 fn name(&self) -> &'static str {
169 "slow"
170 }
171 fn description(&self) -> &'static str {
172 "slow"
173 }
174 fn schema(&self) -> Value {
175 json!({})
176 }
177 fn timeout_ms(&self) -> Option<u64> {
178 Some(50) }
180 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
181 tokio::time::sleep(Duration::from_secs(10)).await;
182 Ok(vec![Content::text("done")])
183 }
184 }
185
186 struct FailingTool;
187 #[async_trait]
188 impl Tool for FailingTool {
189 fn name(&self) -> &'static str {
190 "fail"
191 }
192 fn description(&self) -> &'static str {
193 "fail"
194 }
195 fn schema(&self) -> Value {
196 json!({})
197 }
198 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
199 Err(AgentError::tool_not_found("intentional"))
200 }
201 }
202
203 struct TrackingPolicy {
204 before_count: std::sync::atomic::AtomicU32,
205 after_count: std::sync::atomic::AtomicU32,
206 fail_before: bool,
207 }
208 impl TrackingPolicy {
209 fn new() -> Self {
210 Self {
211 before_count: std::sync::atomic::AtomicU32::new(0),
212 after_count: std::sync::atomic::AtomicU32::new(0),
213 fail_before: false,
214 }
215 }
216 fn fail_before_call() -> Self {
217 Self {
218 before_count: std::sync::atomic::AtomicU32::new(0),
219 after_count: std::sync::atomic::AtomicU32::new(0),
220 fail_before: true,
221 }
222 }
223 }
224 #[async_trait]
225 impl ToolPolicy for TrackingPolicy {
226 async fn evaluate_approval(&self, _: &str, _: &Value) -> Option<ApprovalRequest> {
227 None
228 }
229 fn before_call(
230 &self,
231 _name: &str,
232 _args: &Value,
233 _ctx: &ToolContext,
234 ) -> AgentResult<ToolDecision> {
235 self.before_count
236 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
237 if self.fail_before {
238 return Err(AgentError::ToolBlocked("before_call denied".into()));
239 }
240 Ok(ToolDecision::Proceed)
241 }
242 fn after_call(
243 &self,
244 _name: &str,
245 _args: &Value,
246 _output: &[Content],
247 _ctx: &ToolContext,
248 ) -> AgentResult<()> {
249 self.after_count
250 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
251 Ok(())
252 }
253 }
254
255 #[tokio::test]
258 async fn basic_execution() {
259 let pipeline = DefaultPipeline::new(None, None, None);
260 let output = pipeline
261 .execute(&EchoTool, &json!({"msg": "hello"}), &test_ctx())
262 .await
263 .unwrap();
264 assert_eq!(content_text(&output), "hello");
265 }
266
267 #[tokio::test]
268 async fn policy_before_and_after_called() {
269 let policy = Arc::new(TrackingPolicy::new());
270 let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
271
272 pipeline
273 .execute(&EchoTool, &json!({}), &test_ctx())
274 .await
275 .unwrap();
276
277 assert_eq!(
278 policy
279 .before_count
280 .load(std::sync::atomic::Ordering::SeqCst),
281 1
282 );
283 assert_eq!(
284 policy.after_count.load(std::sync::atomic::Ordering::SeqCst),
285 1
286 );
287 }
288
289 #[tokio::test]
290 async fn policy_before_call_aborts() {
291 let policy = Arc::new(TrackingPolicy::fail_before_call());
292 let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
293
294 let result = pipeline.execute(&EchoTool, &json!({}), &test_ctx()).await;
295 assert!(result.is_err());
296 assert_eq!(
298 policy.after_count.load(std::sync::atomic::Ordering::SeqCst),
299 0
300 );
301 }
302
303 #[tokio::test]
304 async fn timeout_fires() {
305 let pipeline = DefaultPipeline::new(None, Some(50), None); let output = pipeline
307 .execute(&SlowTool, &json!({}), &test_ctx())
308 .await
309 .unwrap();
310 assert_eq!(content_text(&output), "[Tool Timeout]");
311 }
312
313 #[tokio::test]
314 async fn no_timeout_when_tool_fast() {
315 let pipeline = DefaultPipeline::new(None, Some(5000), None);
316 let output = pipeline
317 .execute(&EchoTool, &json!({"msg": "fast"}), &test_ctx())
318 .await
319 .unwrap();
320 assert_eq!(content_text(&output), "fast");
321 }
322
323 #[tokio::test]
324 async fn output_over_limit_is_rejected() {
325 let pipeline = DefaultPipeline::new(None, None, Some(10)); let result = pipeline
327 .execute(
328 &EchoTool,
329 &json!({"msg": "this is a very long message"}),
330 &test_ctx(),
331 )
332 .await;
333 assert!(matches!(
334 result,
335 Err(AgentError::ToolOutputTooLarge { max_chars: 10, .. })
336 ));
337 }
338
339 #[tokio::test]
340 async fn no_rejection_when_short() {
341 let pipeline = DefaultPipeline::new(None, None, Some(100));
342 let output = pipeline
343 .execute(&EchoTool, &json!({"msg": "short"}), &test_ctx())
344 .await
345 .unwrap();
346 assert_eq!(content_text(&output), "short");
347 }
348
349 #[tokio::test]
350 async fn output_over_limit_cjk_rejected() {
351 let pipeline = DefaultPipeline::new(None, None, Some(20));
354 let result = pipeline
355 .execute(
356 &EchoTool,
357 &json!({"msg": "这是一个很长的中文消息,用于测试多字节字符的超限处理"}),
358 &test_ctx(),
359 )
360 .await;
361 assert!(matches!(
362 result,
363 Err(AgentError::ToolOutputTooLarge { max_chars: 20, .. })
364 ));
365 }
366
367 #[tokio::test]
368 async fn cjk_within_char_limit_is_not_rejected() {
369 let pipeline = DefaultPipeline::new(None, None, Some(20));
372 let output = pipeline
373 .execute(
374 &EchoTool,
375 &json!({"msg": "这是一个用于验证字符计数的中文消息"}),
376 &test_ctx(),
377 )
378 .await
379 .unwrap();
380 assert_eq!(content_text(&output).chars().count(), 17);
381 }
382
383 #[tokio::test]
384 async fn timeout_plus_limit() {
385 let pipeline = DefaultPipeline::new(None, Some(50), Some(100));
386 let output = pipeline
387 .execute(&SlowTool, &json!({}), &test_ctx())
388 .await
389 .unwrap();
390 assert_eq!(content_text(&output), "[Tool Timeout]");
392 }
393
394 #[tokio::test]
395 async fn tool_error_propagates() {
396 let pipeline = DefaultPipeline::new(None, None, None);
397 let result = pipeline
398 .execute(&FailingTool, &json!({}), &test_ctx())
399 .await;
400 assert!(result.is_err());
401 }
402
403 struct ModifyPolicy;
406 #[async_trait]
407 impl ToolPolicy for ModifyPolicy {
408 async fn evaluate_approval(&self, _: &str, _: &Value) -> Option<ApprovalRequest> {
409 None
410 }
411 fn before_call(
412 &self,
413 _name: &str,
414 _args: &Value,
415 _ctx: &ToolContext,
416 ) -> AgentResult<ToolDecision> {
417 Ok(ToolDecision::Modify(json!({"msg": "modified"})))
419 }
420 }
421
422 struct BlockPolicy;
423 #[async_trait]
424 impl ToolPolicy for BlockPolicy {
425 async fn evaluate_approval(&self, _: &str, _: &Value) -> Option<ApprovalRequest> {
426 None
427 }
428 fn before_call(
429 &self,
430 name: &str,
431 _args: &Value,
432 _ctx: &ToolContext,
433 ) -> AgentResult<ToolDecision> {
434 Ok(ToolDecision::Block(format!("{name} is blocked")))
435 }
436 }
437
438 #[tokio::test]
439 async fn modify_decision_replaces_args() {
440 let pipeline = DefaultPipeline::new(Some(Arc::new(ModifyPolicy)), None, None);
441 let output = pipeline
442 .execute(&EchoTool, &json!({"msg": "original"}), &test_ctx())
443 .await
444 .unwrap();
445 assert_eq!(content_text(&output), "modified");
447 }
448
449 #[tokio::test]
450 async fn block_decision_returns_error() {
451 let pipeline = DefaultPipeline::new(Some(Arc::new(BlockPolicy)), None, None);
452 let result = pipeline
453 .execute(&EchoTool, &json!({"msg": "hello"}), &test_ctx())
454 .await;
455 assert!(result.is_err());
456 let err = result.unwrap_err();
457 assert!(
458 err.to_string().contains("is blocked"),
459 "expected block message, got: {err}"
460 );
461 }
462}