1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use serde_json::Value;
6
7use crate::tool::{Tool, ToolContext, ToolControlFlow, ToolOutput, ToolPolicy, TruncationInfo};
8use crate::types::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<ToolOutput>;
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<ToolOutput> {
58 if let Some(policy) = &self.tool_policy {
60 policy.before_call(tool.name(), args, ctx)?;
61 }
62
63 let result = 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(ToolOutput {
76 summary: "[Tool Timeout]".to_string(),
77 control_flow: ToolControlFlow::Continue,
78 ..Default::default()
79 });
80 }
81 }
82 } else {
83 tool.call(args, ctx).await
84 };
85
86 let mut output = result?;
87
88 if let Some(max_chars) = self.max_output_chars
90 && output.summary.len() > max_chars
91 {
92 let original_summary_len = output.summary.len();
93 let original_raw_len = output.raw.as_ref().map(|v| v.to_string().len());
94 let suffix = "...(truncated)";
95 let keep = max_chars.saturating_sub(suffix.len());
96 if keep > 0 {
97 let truncate_at = output.summary.floor_char_boundary(keep);
101 output.summary.truncate(truncate_at);
102 output.summary.push_str(suffix);
103 } else {
104 output.summary = suffix[..max_chars].to_string();
105 }
106 output.truncation = Some(TruncationInfo {
107 original_summary_len,
108 original_raw_len,
109 max_allowed_chars: max_chars,
110 });
111 tracing::debug!(
112 tool = tool.name(),
113 original_summary_len = original_summary_len,
114 original_raw_len = original_raw_len,
115 max_allowed_chars = max_chars,
116 "tool output truncated"
117 );
118 }
119
120 if let Some(policy) = &self.tool_policy {
122 policy.after_call(tool.name(), args, &output, ctx)?;
123 }
124
125 Ok(output)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use serde_json::json;
133 use std::sync::atomic::{AtomicU32, Ordering};
134
135 use crate::types::{AgentError, Language, SessionId};
136
137 use tokio::sync::mpsc;
140
141 fn test_ctx() -> ToolContext {
142 let (tx, _rx) = mpsc::unbounded_channel();
143 ToolContext {
144 session_id: SessionId::new(1),
145 user_event_tx: tx,
146 llm_client: None,
147 session_store: None,
148 language: Language::En,
149 cancel_token: tokio_util::sync::CancellationToken::new(),
150 }
151 }
152
153 struct EchoTool;
154 #[async_trait]
155 impl Tool for EchoTool {
156 fn name(&self) -> &'static str {
157 "echo"
158 }
159 fn definition(&self) -> Value {
160 json!({})
161 }
162 async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
163 Ok(ToolOutput {
164 summary: args
165 .get("msg")
166 .and_then(|v| v.as_str())
167 .unwrap_or("ok")
168 .to_string(),
169 ..Default::default()
170 })
171 }
172 }
173
174 struct SlowTool;
175 #[async_trait]
176 impl Tool for SlowTool {
177 fn name(&self) -> &'static str {
178 "slow"
179 }
180 fn definition(&self) -> Value {
181 json!({})
182 }
183 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
184 tokio::time::sleep(Duration::from_secs(10)).await;
185 Ok(ToolOutput {
186 summary: "done".to_string(),
187 ..Default::default()
188 })
189 }
190 }
191
192 struct FailingTool;
193 #[async_trait]
194 impl Tool for FailingTool {
195 fn name(&self) -> &'static str {
196 "fail"
197 }
198 fn definition(&self) -> Value {
199 json!({})
200 }
201 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
202 Err(AgentError::tool_not_found("intentional"))
203 }
204 }
205
206 struct TrackingPolicy {
207 before_count: AtomicU32,
208 after_count: AtomicU32,
209 fail_before: bool,
210 }
211 impl TrackingPolicy {
212 fn new() -> Self {
213 Self {
214 before_count: AtomicU32::new(0),
215 after_count: AtomicU32::new(0),
216 fail_before: false,
217 }
218 }
219 fn fail_before_call() -> Self {
220 Self {
221 before_count: AtomicU32::new(0),
222 after_count: AtomicU32::new(0),
223 fail_before: true,
224 }
225 }
226 }
227 #[async_trait]
228 impl ToolPolicy for TrackingPolicy {
229 async fn evaluate_approval(
230 &self,
231 _: &str,
232 _: &Value,
233 ) -> Option<crate::types::ApprovalRequest> {
234 None
235 }
236 fn before_call(&self, _name: &str, _args: &Value, _ctx: &ToolContext) -> AgentResult<()> {
237 self.before_count.fetch_add(1, Ordering::SeqCst);
238 if self.fail_before {
239 return Err(AgentError::internal("before_call denied"));
240 }
241 Ok(())
242 }
243 fn after_call(
244 &self,
245 _name: &str,
246 _args: &Value,
247 _output: &ToolOutput,
248 _ctx: &ToolContext,
249 ) -> AgentResult<()> {
250 self.after_count.fetch_add(1, 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!(output.summary, "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!(policy.before_count.load(Ordering::SeqCst), 1);
278 assert_eq!(policy.after_count.load(Ordering::SeqCst), 1);
279 }
280
281 #[tokio::test]
282 async fn policy_before_call_aborts() {
283 let policy = Arc::new(TrackingPolicy::fail_before_call());
284 let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
285
286 let result = pipeline.execute(&EchoTool, &json!({}), &test_ctx()).await;
287 assert!(result.is_err());
288 assert_eq!(policy.after_count.load(Ordering::SeqCst), 0);
290 }
291
292 #[tokio::test]
293 async fn timeout_fires() {
294 let pipeline = DefaultPipeline::new(None, Some(50), None); let output = pipeline
296 .execute(&SlowTool, &json!({}), &test_ctx())
297 .await
298 .unwrap();
299 assert_eq!(output.summary, "[Tool Timeout]");
300 }
301
302 #[tokio::test]
303 async fn no_timeout_when_tool_fast() {
304 let pipeline = DefaultPipeline::new(None, Some(5000), None);
305 let output = pipeline
306 .execute(&EchoTool, &json!({"msg": "fast"}), &test_ctx())
307 .await
308 .unwrap();
309 assert_eq!(output.summary, "fast");
310 }
311
312 #[tokio::test]
313 async fn truncation_applies() {
314 let pipeline = DefaultPipeline::new(None, None, Some(10)); let output = pipeline
316 .execute(
317 &EchoTool,
318 &json!({"msg": "this is a very long message"}),
319 &test_ctx(),
320 )
321 .await
322 .unwrap();
323 assert!(output.summary.len() <= 10);
324 assert!(output.truncation.is_some());
325 let t = output.truncation.unwrap();
326 assert_eq!(t.original_summary_len, 27);
327 assert_eq!(t.max_allowed_chars, 10);
328 }
329
330 #[tokio::test]
331 async fn no_truncation_when_short() {
332 let pipeline = DefaultPipeline::new(None, None, Some(100));
333 let output = pipeline
334 .execute(&EchoTool, &json!({"msg": "short"}), &test_ctx())
335 .await
336 .unwrap();
337 assert_eq!(output.summary, "short");
338 assert!(output.truncation.is_none());
339 }
340
341 #[tokio::test]
342 async fn truncation_cjk_no_panic() {
343 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!(output.summary.len() <= 20);
356 assert!(output.summary.ends_with("...(truncated)") || output.summary.len() <= 20);
357 assert!(output.truncation.is_some());
358 }
360
361 #[tokio::test]
362 async fn timeout_plus_truncation() {
363 let pipeline = DefaultPipeline::new(None, Some(50), Some(100));
364 let output = pipeline
365 .execute(&SlowTool, &json!({}), &test_ctx())
366 .await
367 .unwrap();
368 assert_eq!(output.summary, "[Tool Timeout]");
369 assert!(output.truncation.is_none()); }
371
372 #[tokio::test]
373 async fn tool_error_propagates() {
374 let pipeline = DefaultPipeline::new(None, None, None);
375 let result = pipeline
376 .execute(&FailingTool, &json!({}), &test_ctx())
377 .await;
378 assert!(result.is_err());
379 }
380}