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