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