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