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            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    // ── DefaultPipeline tests ──
230
231    #[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        // after_call should NOT be called
271        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); // 50ms timeout
280        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)); // max 10 chars
300        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        // CJK chars are 3 bytes each. The size check counts chars, not bytes,
326        // so a long Chinese string must still be rejected without panicking.
327        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        // 17 CJK chars = 51 bytes. With a 20-char limit, byte-counting would
344        // wrongly reject (51 > 20), but char-counting must accept (17 <= 20).
345        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        // timeout output is short, so it does not trip the size limit
365        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}