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, DEFAULT_TOOL_TIMEOUT_MS};
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    default_tool_timeout_ms: u64,
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            default_tool_timeout_ms: DEFAULT_TOOL_TIMEOUT_MS,
42            tool_timeout_ms,
43            max_output_chars,
44        }
45    }
46
47    pub fn with_default_timeout(mut self, timeout_ms: u64) -> Self {
48        self.default_tool_timeout_ms = timeout_ms;
49        self
50    }
51
52    pub fn policy(&self) -> Option<Arc<dyn ToolPolicy>> {
53        self.tool_policy.clone()
54    }
55}
56
57#[async_trait]
58impl ToolExecutionPipeline for DefaultPipeline {
59    async fn execute(
60        &self,
61        tool: &dyn Tool,
62        args: &Value,
63        ctx: &ToolContext,
64    ) -> AgentResult<Vec<Content>> {
65        // 1. before_call hook
66        if let Some(policy) = &self.tool_policy {
67            policy.before_call(tool.name(), args, ctx)?;
68        }
69
70        // 2. Execute with timeout: tool → global config → framework default
71        let timeout_ms = tool
72            .timeout_ms()
73            .or(self.tool_timeout_ms)
74            .unwrap_or(self.default_tool_timeout_ms);
75        let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), tool.call(args, ctx))
76            .await
77        {
78            Ok(result) => result?,
79            Err(_) => {
80                tracing::warn!(
81                    tool = tool.name(),
82                    timeout_ms = timeout_ms,
83                    "tool execution timed out"
84                );
85                return Ok(vec![Content::text("[Tool Timeout]")]);
86            }
87        };
88
89        // 3. Output size limit — reject by default rather than silently
90        // truncating (design §6.5). Tools that want to return a bounded
91        // subset should do their own explicit truncation before returning.
92        if let Some(max_chars) = self.max_output_chars {
93            let text_len = content_text(&output).chars().count();
94            if text_len > max_chars {
95                return Err(AgentError::ToolOutputTooLarge {
96                    name: tool.name().to_string(),
97                    max_chars,
98                });
99            }
100        }
101
102        // 4. after_call hook
103        if let Some(policy) = &self.tool_policy {
104            policy.after_call(tool.name(), args, &output, ctx)?;
105        }
106
107        Ok(output)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use serde_json::json;
115
116    use crate::types::{ApprovalRequest, Language, SessionId};
117
118    // ── Test helpers ──
119
120    use tokio::sync::mpsc;
121
122    fn test_ctx() -> ToolContext {
123        let (tx, _rx) = mpsc::unbounded_channel();
124        ToolContext {
125            session_id: SessionId::new(1),
126            user_event_tx: tx,
127            llm_client: None,
128            session_store: None,
129            language: Language::En,
130            cancel_token: tokio_util::sync::CancellationToken::new(),
131            max_output_chars: None,
132            event_bus: crate::engine::EventBus::new(1),
133        }
134    }
135
136    struct EchoTool;
137    #[async_trait]
138    impl Tool for EchoTool {
139        fn name(&self) -> &'static str {
140            "echo"
141        }
142        fn description(&self) -> &'static str {
143            "echo"
144        }
145        fn schema(&self) -> Value {
146            json!({})
147        }
148        async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
149            Ok(vec![Content::text(
150                args.get("msg").and_then(|v| v.as_str()).unwrap_or("ok"),
151            )])
152        }
153    }
154
155    struct SlowTool;
156    #[async_trait]
157    impl Tool for SlowTool {
158        fn name(&self) -> &'static str {
159            "slow"
160        }
161        fn description(&self) -> &'static str {
162            "slow"
163        }
164        fn schema(&self) -> Value {
165            json!({})
166        }
167        fn timeout_ms(&self) -> Option<u64> {
168            Some(50) // 50ms for testing
169        }
170        async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
171            tokio::time::sleep(Duration::from_secs(10)).await;
172            Ok(vec![Content::text("done")])
173        }
174    }
175
176    struct FailingTool;
177    #[async_trait]
178    impl Tool for FailingTool {
179        fn name(&self) -> &'static str {
180            "fail"
181        }
182        fn description(&self) -> &'static str {
183            "fail"
184        }
185        fn schema(&self) -> Value {
186            json!({})
187        }
188        async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<Vec<Content>> {
189            Err(AgentError::tool_not_found("intentional"))
190        }
191    }
192
193    struct TrackingPolicy {
194        before_count: std::sync::atomic::AtomicU32,
195        after_count: std::sync::atomic::AtomicU32,
196        fail_before: bool,
197    }
198    impl TrackingPolicy {
199        fn new() -> Self {
200            Self {
201                before_count: std::sync::atomic::AtomicU32::new(0),
202                after_count: std::sync::atomic::AtomicU32::new(0),
203                fail_before: false,
204            }
205        }
206        fn fail_before_call() -> Self {
207            Self {
208                before_count: std::sync::atomic::AtomicU32::new(0),
209                after_count: std::sync::atomic::AtomicU32::new(0),
210                fail_before: true,
211            }
212        }
213    }
214    #[async_trait]
215    impl ToolPolicy for TrackingPolicy {
216        async fn evaluate_approval(&self, _: &str, _: &Value) -> Option<ApprovalRequest> {
217            None
218        }
219        fn before_call(&self, _name: &str, _args: &Value, _ctx: &ToolContext) -> AgentResult<()> {
220            self.before_count
221                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
222            if self.fail_before {
223                return Err(AgentError::internal("before_call denied"));
224            }
225            Ok(())
226        }
227        fn after_call(
228            &self,
229            _name: &str,
230            _args: &Value,
231            _output: &[Content],
232            _ctx: &ToolContext,
233        ) -> AgentResult<()> {
234            self.after_count
235                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
236            Ok(())
237        }
238    }
239
240    // ── DefaultPipeline tests ──
241
242    #[tokio::test]
243    async fn basic_execution() {
244        let pipeline = DefaultPipeline::new(None, None, None);
245        let output = pipeline
246            .execute(&EchoTool, &json!({"msg": "hello"}), &test_ctx())
247            .await
248            .unwrap();
249        assert_eq!(content_text(&output), "hello");
250    }
251
252    #[tokio::test]
253    async fn policy_before_and_after_called() {
254        let policy = Arc::new(TrackingPolicy::new());
255        let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
256
257        pipeline
258            .execute(&EchoTool, &json!({}), &test_ctx())
259            .await
260            .unwrap();
261
262        assert_eq!(
263            policy
264                .before_count
265                .load(std::sync::atomic::Ordering::SeqCst),
266            1
267        );
268        assert_eq!(
269            policy.after_count.load(std::sync::atomic::Ordering::SeqCst),
270            1
271        );
272    }
273
274    #[tokio::test]
275    async fn policy_before_call_aborts() {
276        let policy = Arc::new(TrackingPolicy::fail_before_call());
277        let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
278
279        let result = pipeline.execute(&EchoTool, &json!({}), &test_ctx()).await;
280        assert!(result.is_err());
281        // after_call should NOT be called
282        assert_eq!(
283            policy.after_count.load(std::sync::atomic::Ordering::SeqCst),
284            0
285        );
286    }
287
288    #[tokio::test]
289    async fn timeout_fires() {
290        let pipeline = DefaultPipeline::new(None, Some(50), None); // 50ms timeout
291        let output = pipeline
292            .execute(&SlowTool, &json!({}), &test_ctx())
293            .await
294            .unwrap();
295        assert_eq!(content_text(&output), "[Tool Timeout]");
296    }
297
298    #[tokio::test]
299    async fn no_timeout_when_tool_fast() {
300        let pipeline = DefaultPipeline::new(None, Some(5000), None);
301        let output = pipeline
302            .execute(&EchoTool, &json!({"msg": "fast"}), &test_ctx())
303            .await
304            .unwrap();
305        assert_eq!(content_text(&output), "fast");
306    }
307
308    #[tokio::test]
309    async fn output_over_limit_is_rejected() {
310        let pipeline = DefaultPipeline::new(None, None, Some(10)); // max 10 chars
311        let result = pipeline
312            .execute(
313                &EchoTool,
314                &json!({"msg": "this is a very long message"}),
315                &test_ctx(),
316            )
317            .await;
318        assert!(matches!(
319            result,
320            Err(AgentError::ToolOutputTooLarge { max_chars: 10, .. })
321        ));
322    }
323
324    #[tokio::test]
325    async fn no_rejection_when_short() {
326        let pipeline = DefaultPipeline::new(None, None, Some(100));
327        let output = pipeline
328            .execute(&EchoTool, &json!({"msg": "short"}), &test_ctx())
329            .await
330            .unwrap();
331        assert_eq!(content_text(&output), "short");
332    }
333
334    #[tokio::test]
335    async fn output_over_limit_cjk_rejected() {
336        // CJK chars are 3 bytes each. The size check counts chars, not bytes,
337        // so a long Chinese string must still be rejected without panicking.
338        let pipeline = DefaultPipeline::new(None, None, Some(20));
339        let result = pipeline
340            .execute(
341                &EchoTool,
342                &json!({"msg": "这是一个很长的中文消息,用于测试多字节字符的超限处理"}),
343                &test_ctx(),
344            )
345            .await;
346        assert!(matches!(
347            result,
348            Err(AgentError::ToolOutputTooLarge { max_chars: 20, .. })
349        ));
350    }
351
352    #[tokio::test]
353    async fn cjk_within_char_limit_is_not_rejected() {
354        // 17 CJK chars = 51 bytes. With a 20-char limit, byte-counting would
355        // wrongly reject (51 > 20), but char-counting must accept (17 <= 20).
356        let pipeline = DefaultPipeline::new(None, None, Some(20));
357        let output = pipeline
358            .execute(
359                &EchoTool,
360                &json!({"msg": "这是一个用于验证字符计数的中文消息"}),
361                &test_ctx(),
362            )
363            .await
364            .unwrap();
365        assert_eq!(content_text(&output).chars().count(), 17);
366    }
367
368    #[tokio::test]
369    async fn timeout_plus_limit() {
370        let pipeline = DefaultPipeline::new(None, Some(50), Some(100));
371        let output = pipeline
372            .execute(&SlowTool, &json!({}), &test_ctx())
373            .await
374            .unwrap();
375        // timeout output is short, so it does not trip the size limit
376        assert_eq!(content_text(&output), "[Tool Timeout]");
377    }
378
379    #[tokio::test]
380    async fn tool_error_propagates() {
381        let pipeline = DefaultPipeline::new(None, None, None);
382        let result = pipeline
383            .execute(&FailingTool, &json!({}), &test_ctx())
384            .await;
385        assert!(result.is_err());
386    }
387}