Skip to main content

agent_base/engine/
pipeline.rs

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/// Pure execution pipeline — cares about *how* to safely execute a tool.
12///
13/// Responsibilities: policy hooks, timeout, output truncation.
14/// Does NOT do: tool lookup, event emission, user-event forwarding.
15#[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/// Default pipeline: ToolPolicy hooks + timeout + output truncation.
26#[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        // 1. before_call hook
60        if let Some(policy) = &self.tool_policy {
61            policy.before_call(tool.name(), args, ctx)?;
62        }
63
64        // 2. Execute with optional timeout
65        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        // 3. Output truncation
90        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                    // Use floor_char_boundary to avoid panicking on multi-byte
98                    // UTF-8 characters (e.g. CJK, emoji) where `keep` falls
99                    // in the middle of a character.
100                    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        // 4. after_call hook
122        if let Some(policy) = &self.tool_policy {
123            policy.after_call(tool.name(), args, &output, ctx)?;
124        }
125
126        Ok(output)
127    }
128}
129
130/// Event-emitting decorator — wraps any pipeline to emit ToolCallStarted/Finished
131/// events on the internal [`EventBus`] and forward [`UserEvent`]s from tools.
132pub(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    // ── Test helpers ──
183
184    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    // ── DefaultPipeline tests ──
301
302    #[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        // after_call should NOT be called
334        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); // 50ms timeout
340        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)); // max 10 chars
360        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        // CJK chars are 3 bytes each. With max_chars=20, keep=6 bytes,
389        // which falls inside a 3-byte CJK char. floor_char_boundary
390        // should round down to the nearest char boundary.
391        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        // Must not panic — that's the main assertion
404    }
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()); // timeout output is short
415    }
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    // ── EventEmittingPipeline tests ──
427
428    #[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}