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 event_bus: crate::engine::EventBus::new(1),
125 }
126 }
127
128 struct EchoTool;
129 #[async_trait]
130 impl Tool for EchoTool {
131 fn name(&self) -> &'static str {
132 "echo"
133 }
134 fn description(&self) -> &'static str {
135 "echo"
136 }
137 fn schema(&self) -> Value {
138 json!({})
139 }
140 async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
141 Ok(vec![Content::text(
142 args.get("msg").and_then(|v| v.as_str()).unwrap_or("ok"),
143 )])
144 }
145 }
146
147 struct SlowTool;
148 #[async_trait]
149 impl Tool for SlowTool {
150 fn name(&self) -> &'static str {
151 "slow"
152 }
153 fn description(&self) -> &'static str {
154 "slow"
155 }
156 fn schema(&self) -> Value {
157 json!({})
158 }
159 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
160 tokio::time::sleep(Duration::from_secs(10)).await;
161 Ok(vec![Content::text("done")])
162 }
163 }
164
165 struct FailingTool;
166 #[async_trait]
167 impl Tool for FailingTool {
168 fn name(&self) -> &'static str {
169 "fail"
170 }
171 fn description(&self) -> &'static str {
172 "fail"
173 }
174 fn schema(&self) -> Value {
175 json!({})
176 }
177 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
178 Err(AgentError::tool_not_found("intentional"))
179 }
180 }
181
182 struct TrackingPolicy {
183 before_count: std::sync::atomic::AtomicU32,
184 after_count: std::sync::atomic::AtomicU32,
185 fail_before: bool,
186 }
187 impl TrackingPolicy {
188 fn new() -> Self {
189 Self {
190 before_count: std::sync::atomic::AtomicU32::new(0),
191 after_count: std::sync::atomic::AtomicU32::new(0),
192 fail_before: false,
193 }
194 }
195 fn fail_before_call() -> Self {
196 Self {
197 before_count: std::sync::atomic::AtomicU32::new(0),
198 after_count: std::sync::atomic::AtomicU32::new(0),
199 fail_before: true,
200 }
201 }
202 }
203 #[async_trait]
204 impl ToolPolicy for TrackingPolicy {
205 async fn evaluate_approval(&self, _: &str, _: &Value) -> Option<ApprovalRequest> {
206 None
207 }
208 fn before_call(&self, _name: &str, _args: &Value, _ctx: &ToolContext) -> AgentResult<()> {
209 self.before_count
210 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
211 if self.fail_before {
212 return Err(AgentError::internal("before_call denied"));
213 }
214 Ok(())
215 }
216 fn after_call(
217 &self,
218 _name: &str,
219 _args: &Value,
220 _output: &[Content],
221 _ctx: &ToolContext,
222 ) -> AgentResult<()> {
223 self.after_count
224 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
225 Ok(())
226 }
227 }
228
229 #[tokio::test]
232 async fn basic_execution() {
233 let pipeline = DefaultPipeline::new(None, None, None);
234 let output = pipeline
235 .execute(&EchoTool, &json!({"msg": "hello"}), &test_ctx())
236 .await
237 .unwrap();
238 assert_eq!(content_text(&output), "hello");
239 }
240
241 #[tokio::test]
242 async fn policy_before_and_after_called() {
243 let policy = Arc::new(TrackingPolicy::new());
244 let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
245
246 pipeline
247 .execute(&EchoTool, &json!({}), &test_ctx())
248 .await
249 .unwrap();
250
251 assert_eq!(
252 policy
253 .before_count
254 .load(std::sync::atomic::Ordering::SeqCst),
255 1
256 );
257 assert_eq!(
258 policy.after_count.load(std::sync::atomic::Ordering::SeqCst),
259 1
260 );
261 }
262
263 #[tokio::test]
264 async fn policy_before_call_aborts() {
265 let policy = Arc::new(TrackingPolicy::fail_before_call());
266 let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
267
268 let result = pipeline.execute(&EchoTool, &json!({}), &test_ctx()).await;
269 assert!(result.is_err());
270 assert_eq!(
272 policy.after_count.load(std::sync::atomic::Ordering::SeqCst),
273 0
274 );
275 }
276
277 #[tokio::test]
278 async fn timeout_fires() {
279 let pipeline = DefaultPipeline::new(None, Some(50), None); let output = pipeline
281 .execute(&SlowTool, &json!({}), &test_ctx())
282 .await
283 .unwrap();
284 assert_eq!(content_text(&output), "[Tool Timeout]");
285 }
286
287 #[tokio::test]
288 async fn no_timeout_when_tool_fast() {
289 let pipeline = DefaultPipeline::new(None, Some(5000), None);
290 let output = pipeline
291 .execute(&EchoTool, &json!({"msg": "fast"}), &test_ctx())
292 .await
293 .unwrap();
294 assert_eq!(content_text(&output), "fast");
295 }
296
297 #[tokio::test]
298 async fn output_over_limit_is_rejected() {
299 let pipeline = DefaultPipeline::new(None, None, Some(10)); let result = pipeline
301 .execute(
302 &EchoTool,
303 &json!({"msg": "this is a very long message"}),
304 &test_ctx(),
305 )
306 .await;
307 assert!(matches!(
308 result,
309 Err(AgentError::ToolOutputTooLarge { max_chars: 10, .. })
310 ));
311 }
312
313 #[tokio::test]
314 async fn no_rejection_when_short() {
315 let pipeline = DefaultPipeline::new(None, None, Some(100));
316 let output = pipeline
317 .execute(&EchoTool, &json!({"msg": "short"}), &test_ctx())
318 .await
319 .unwrap();
320 assert_eq!(content_text(&output), "short");
321 }
322
323 #[tokio::test]
324 async fn output_over_limit_cjk_rejected() {
325 let pipeline = DefaultPipeline::new(None, None, Some(20));
328 let result = pipeline
329 .execute(
330 &EchoTool,
331 &json!({"msg": "这是一个很长的中文消息,用于测试多字节字符的超限处理"}),
332 &test_ctx(),
333 )
334 .await;
335 assert!(matches!(
336 result,
337 Err(AgentError::ToolOutputTooLarge { max_chars: 20, .. })
338 ));
339 }
340
341 #[tokio::test]
342 async fn cjk_within_char_limit_is_not_rejected() {
343 let pipeline = DefaultPipeline::new(None, None, Some(20));
346 let output = pipeline
347 .execute(
348 &EchoTool,
349 &json!({"msg": "这是一个用于验证字符计数的中文消息"}),
350 &test_ctx(),
351 )
352 .await
353 .unwrap();
354 assert_eq!(content_text(&output).chars().count(), 17);
355 }
356
357 #[tokio::test]
358 async fn timeout_plus_limit() {
359 let pipeline = DefaultPipeline::new(None, Some(50), Some(100));
360 let output = pipeline
361 .execute(&SlowTool, &json!({}), &test_ctx())
362 .await
363 .unwrap();
364 assert_eq!(content_text(&output), "[Tool Timeout]");
366 }
367
368 #[tokio::test]
369 async fn tool_error_propagates() {
370 let pipeline = DefaultPipeline::new(None, None, None);
371 let result = pipeline
372 .execute(&FailingTool, &json!({}), &test_ctx())
373 .await;
374 assert!(result.is_err());
375 }
376}