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