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::tool::{Content, Tool, ToolContext, ToolPolicy, content_text};
8use crate::types::{AgentError, AgentResult};
9
10/// Pure execution pipeline — cares about *how* to safely execute a tool.
11///
12/// Responsibilities: policy hooks, timeout, output size limit.
13/// Does NOT do: tool lookup, event emission, user-event forwarding.
14#[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/// Default pipeline: ToolPolicy hooks + timeout + output size limit.
25#[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        // 1. before_call hook
59        if let Some(policy) = &self.tool_policy {
60            policy.before_call(tool.name(), args, ctx)?;
61        }
62
63        // 2. Execute with optional timeout
64        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        // 3. Output size limit — reject by default rather than silently
83        // truncating (design §6.5). Tools that want to return a bounded
84        // subset should do their own explicit truncation before returning.
85        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        // 4. after_call hook
96        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    // ── Test helpers ──
112
113    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    // ── DefaultPipeline tests ──
231
232    #[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        // after_call should NOT be called
272        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); // 50ms timeout
281        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)); // max 10 chars
301        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        // CJK chars are 3 bytes each. The size check counts chars, not bytes,
327        // so a long Chinese string must still be rejected without panicking.
328        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        // 17 CJK chars = 51 bytes. With a 20-char limit, byte-counting would
345        // wrongly reject (51 > 20), but char-counting must accept (17 <= 20).
346        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        // timeout output is short, so it does not trip the size limit
366        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}