1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use serde_json::Value;
6
7use crate::engine::EventBus;
8use crate::tool::{Tool, ToolContext, ToolControlFlow, ToolOutput, ToolPolicy, TruncationInfo};
9use crate::types::{AgentResult, RuntimeEvent};
10
11#[async_trait]
16pub trait ToolExecutionPipeline: Send + Sync {
17 async fn execute(
18 &self,
19 tool: &dyn Tool,
20 args: &Value,
21 ctx: &ToolContext,
22 ) -> AgentResult<ToolOutput>;
23}
24
25#[derive(Clone)]
27pub struct DefaultPipeline {
28 tool_policy: Option<Arc<dyn ToolPolicy>>,
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 tool_timeout_ms,
42 max_output_chars,
43 }
44 }
45
46 pub fn policy(&self) -> Option<Arc<dyn ToolPolicy>> {
47 self.tool_policy.clone()
48 }
49}
50
51#[async_trait]
52impl ToolExecutionPipeline for DefaultPipeline {
53 async fn execute(
54 &self,
55 tool: &dyn Tool,
56 args: &Value,
57 ctx: &ToolContext,
58 ) -> AgentResult<ToolOutput> {
59 if let Some(policy) = &self.tool_policy {
61 policy.before_call(tool.name(), args, ctx)?;
62 }
63
64 let result = if let Some(timeout_ms) = self.tool_timeout_ms {
66 match tokio::time::timeout(Duration::from_millis(timeout_ms), tool.call(args, ctx))
67 .await
68 {
69 Ok(result) => result,
70 Err(_) => {
71 tracing::warn!(
72 tool = tool.name(),
73 timeout_ms = timeout_ms,
74 "tool execution timed out"
75 );
76 return Ok(ToolOutput {
77 summary: "[Tool Timeout]".to_string(),
78 control_flow: ToolControlFlow::Continue,
79 ..Default::default()
80 });
81 }
82 }
83 } else {
84 tool.call(args, ctx).await
85 };
86
87 let mut output = result?;
88
89 if let Some(max_chars) = self.max_output_chars {
91 if output.summary.len() > max_chars {
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
121 if let Some(policy) = &self.tool_policy {
123 policy.after_call(tool.name(), args, &output, ctx)?;
124 }
125
126 Ok(output)
127 }
128}
129
130pub(crate) struct EventEmittingPipeline<P: ToolExecutionPipeline> {
133 inner: P,
134 event_bus: EventBus,
135}
136
137impl<P: ToolExecutionPipeline> EventEmittingPipeline<P> {
138 pub fn new(inner: P, event_bus: EventBus) -> Self {
139 Self { inner, event_bus }
140 }
141}
142
143#[async_trait]
144impl<P: ToolExecutionPipeline + Send + Sync> ToolExecutionPipeline for EventEmittingPipeline<P> {
145 async fn execute(
146 &self,
147 tool: &dyn Tool,
148 args: &Value,
149 ctx: &ToolContext,
150 ) -> AgentResult<ToolOutput> {
151 self.event_bus.emit(RuntimeEvent::ToolCallStarted {
152 session_id: ctx.session_id.clone(),
153 tool_name: tool.name().to_string(),
154 args_json: args.to_string(),
155 });
156
157 let result = self.inner.execute(tool, args, ctx).await;
158
159 let summary = match &result {
160 Ok(output) => output.summary.clone(),
161 Err(e) => e.to_string(),
162 };
163 self.event_bus.emit(RuntimeEvent::ToolCallFinished {
164 session_id: ctx.session_id.clone(),
165 tool_name: tool.name().to_string(),
166 summary,
167 });
168
169 result
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use serde_json::json;
177 use std::sync::atomic::{AtomicU32, Ordering};
178
179 use crate::tool::ToolRegistry;
180 use crate::types::{AgentError, Language, SessionId};
181
182 use tokio::sync::mpsc;
185
186 fn test_ctx() -> ToolContext {
187 let (tx, _rx) = mpsc::unbounded_channel();
188 ToolContext {
189 session_id: SessionId::new(1),
190 user_event_tx: tx,
191 llm_client: None,
192 session_store: None,
193 language: Language::En,
194 cancel_token: tokio_util::sync::CancellationToken::new(),
195 }
196 }
197
198 struct EchoTool;
199 #[async_trait]
200 impl Tool for EchoTool {
201 fn name(&self) -> &'static str {
202 "echo"
203 }
204 fn definition(&self) -> Value {
205 json!({})
206 }
207 async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
208 Ok(ToolOutput {
209 summary: args
210 .get("msg")
211 .and_then(|v| v.as_str())
212 .unwrap_or("ok")
213 .to_string(),
214 ..Default::default()
215 })
216 }
217 }
218
219 struct SlowTool;
220 #[async_trait]
221 impl Tool for SlowTool {
222 fn name(&self) -> &'static str {
223 "slow"
224 }
225 fn definition(&self) -> Value {
226 json!({})
227 }
228 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
229 tokio::time::sleep(Duration::from_secs(10)).await;
230 Ok(ToolOutput {
231 summary: "done".to_string(),
232 ..Default::default()
233 })
234 }
235 }
236
237 struct FailingTool;
238 #[async_trait]
239 impl Tool for FailingTool {
240 fn name(&self) -> &'static str {
241 "fail"
242 }
243 fn definition(&self) -> Value {
244 json!({})
245 }
246 async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
247 Err(AgentError::tool_not_found("intentional"))
248 }
249 }
250
251 struct TrackingPolicy {
252 before_count: AtomicU32,
253 after_count: AtomicU32,
254 fail_before: bool,
255 }
256 impl TrackingPolicy {
257 fn new() -> Self {
258 Self {
259 before_count: AtomicU32::new(0),
260 after_count: AtomicU32::new(0),
261 fail_before: false,
262 }
263 }
264 fn fail_before_call() -> Self {
265 Self {
266 before_count: AtomicU32::new(0),
267 after_count: AtomicU32::new(0),
268 fail_before: true,
269 }
270 }
271 }
272 #[async_trait]
273 impl ToolPolicy for TrackingPolicy {
274 async fn evaluate_approval(
275 &self,
276 _: &str,
277 _: &Value,
278 ) -> Option<crate::types::ApprovalRequest> {
279 None
280 }
281 fn before_call(&self, _name: &str, _args: &Value, _ctx: &ToolContext) -> AgentResult<()> {
282 self.before_count.fetch_add(1, Ordering::SeqCst);
283 if self.fail_before {
284 return Err(AgentError::internal("before_call denied"));
285 }
286 Ok(())
287 }
288 fn after_call(
289 &self,
290 _name: &str,
291 _args: &Value,
292 _output: &ToolOutput,
293 _ctx: &ToolContext,
294 ) -> AgentResult<()> {
295 self.after_count.fetch_add(1, Ordering::SeqCst);
296 Ok(())
297 }
298 }
299
300 #[tokio::test]
303 async fn basic_execution() {
304 let pipeline = DefaultPipeline::new(None, None, None);
305 let output = pipeline
306 .execute(&EchoTool, &json!({"msg": "hello"}), &test_ctx())
307 .await
308 .unwrap();
309 assert_eq!(output.summary, "hello");
310 }
311
312 #[tokio::test]
313 async fn policy_before_and_after_called() {
314 let policy = Arc::new(TrackingPolicy::new());
315 let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
316
317 pipeline
318 .execute(&EchoTool, &json!({}), &test_ctx())
319 .await
320 .unwrap();
321
322 assert_eq!(policy.before_count.load(Ordering::SeqCst), 1);
323 assert_eq!(policy.after_count.load(Ordering::SeqCst), 1);
324 }
325
326 #[tokio::test]
327 async fn policy_before_call_aborts() {
328 let policy = Arc::new(TrackingPolicy::fail_before_call());
329 let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
330
331 let result = pipeline.execute(&EchoTool, &json!({}), &test_ctx()).await;
332 assert!(result.is_err());
333 assert_eq!(policy.after_count.load(Ordering::SeqCst), 0);
335 }
336
337 #[tokio::test]
338 async fn timeout_fires() {
339 let pipeline = DefaultPipeline::new(None, Some(50), None); let output = pipeline
341 .execute(&SlowTool, &json!({}), &test_ctx())
342 .await
343 .unwrap();
344 assert_eq!(output.summary, "[Tool Timeout]");
345 }
346
347 #[tokio::test]
348 async fn no_timeout_when_tool_fast() {
349 let pipeline = DefaultPipeline::new(None, Some(5000), None);
350 let output = pipeline
351 .execute(&EchoTool, &json!({"msg": "fast"}), &test_ctx())
352 .await
353 .unwrap();
354 assert_eq!(output.summary, "fast");
355 }
356
357 #[tokio::test]
358 async fn truncation_applies() {
359 let pipeline = DefaultPipeline::new(None, None, Some(10)); let output = pipeline
361 .execute(
362 &EchoTool,
363 &json!({"msg": "this is a very long message"}),
364 &test_ctx(),
365 )
366 .await
367 .unwrap();
368 assert!(output.summary.len() <= 10);
369 assert!(output.truncation.is_some());
370 let t = output.truncation.unwrap();
371 assert_eq!(t.original_summary_len, 27);
372 assert_eq!(t.max_allowed_chars, 10);
373 }
374
375 #[tokio::test]
376 async fn no_truncation_when_short() {
377 let pipeline = DefaultPipeline::new(None, None, Some(100));
378 let output = pipeline
379 .execute(&EchoTool, &json!({"msg": "short"}), &test_ctx())
380 .await
381 .unwrap();
382 assert_eq!(output.summary, "short");
383 assert!(output.truncation.is_none());
384 }
385
386 #[tokio::test]
387 async fn truncation_cjk_no_panic() {
388 let pipeline = DefaultPipeline::new(None, None, Some(20));
392 let output = pipeline
393 .execute(
394 &EchoTool,
395 &json!({"msg": "这是一个很长的中文消息,用于测试多字节字符的截断处理"}),
396 &test_ctx(),
397 )
398 .await
399 .unwrap();
400 assert!(output.summary.len() <= 20);
401 assert!(output.summary.ends_with("...(truncated)") || output.summary.len() <= 20);
402 assert!(output.truncation.is_some());
403 }
405
406 #[tokio::test]
407 async fn timeout_plus_truncation() {
408 let pipeline = DefaultPipeline::new(None, Some(50), Some(100));
409 let output = pipeline
410 .execute(&SlowTool, &json!({}), &test_ctx())
411 .await
412 .unwrap();
413 assert_eq!(output.summary, "[Tool Timeout]");
414 assert!(output.truncation.is_none()); }
416
417 #[tokio::test]
418 async fn tool_error_propagates() {
419 let pipeline = DefaultPipeline::new(None, None, None);
420 let result = pipeline
421 .execute(&FailingTool, &json!({}), &test_ctx())
422 .await;
423 assert!(result.is_err());
424 }
425
426 #[tokio::test]
429 async fn emits_start_and_finish_events() {
430 let inner = DefaultPipeline::new(None, None, None);
431 let event_bus = EventBus::new(64);
432 let mut rx = event_bus.subscribe();
433 let pipeline = EventEmittingPipeline::new(inner, event_bus);
434
435 let _ = pipeline
436 .execute(&EchoTool, &json!({"msg": "test"}), &test_ctx())
437 .await;
438
439 let mut events = Vec::new();
440 while let Ok(event) = rx.try_recv() {
441 events.push(event);
442 }
443 assert_eq!(events.len(), 2);
444
445 match &events[0] {
446 RuntimeEvent::ToolCallStarted { tool_name, .. } => assert_eq!(tool_name, "echo"),
447 _ => panic!("expected ToolCallStarted"),
448 }
449 match &events[1] {
450 RuntimeEvent::ToolCallFinished {
451 tool_name, summary, ..
452 } => {
453 assert_eq!(tool_name, "echo");
454 assert_eq!(summary, "test");
455 }
456 _ => panic!("expected ToolCallFinished"),
457 }
458 }
459
460 #[tokio::test]
461 async fn emits_finish_with_error_on_failure() {
462 let inner = DefaultPipeline::new(None, None, None);
463 let event_bus = EventBus::new(64);
464 let mut rx = event_bus.subscribe();
465 let pipeline = EventEmittingPipeline::new(inner, event_bus);
466
467 let _ = pipeline
468 .execute(&FailingTool, &json!({}), &test_ctx())
469 .await;
470
471 let mut events = Vec::new();
472 while let Ok(event) = rx.try_recv() {
473 events.push(event);
474 }
475 assert_eq!(events.len(), 2);
476
477 match &events[1] {
478 RuntimeEvent::ToolCallFinished { summary, .. } => {
479 assert!(summary.contains("intentional"));
480 }
481 _ => panic!("expected ToolCallFinished"),
482 }
483 }
484
485 #[tokio::test]
486 async fn event_emitting_delegates_to_inner() {
487 let policy = Arc::new(TrackingPolicy::new());
488 let inner = DefaultPipeline::new(Some(policy.clone()), None, None);
489 let event_bus = EventBus::new(64);
490 let pipeline = EventEmittingPipeline::new(inner, event_bus);
491
492 let output = pipeline
493 .execute(&EchoTool, &json!({"msg": "delegated"}), &test_ctx())
494 .await
495 .unwrap();
496 assert_eq!(output.summary, "delegated");
497 assert_eq!(policy.before_count.load(Ordering::SeqCst), 1);
498 assert_eq!(policy.after_count.load(Ordering::SeqCst), 1);
499 }
500}