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::{Tool, ToolContext, ToolControlFlow, ToolOutput, ToolPolicy, TruncationInfo};
8use crate::types::AgentResult;
9
10/// Pure execution pipeline — cares about *how* to safely execute a tool.
11///
12/// Responsibilities: policy hooks, timeout, output truncation.
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<ToolOutput>;
22}
23
24/// Default pipeline: ToolPolicy hooks + timeout + output truncation.
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<ToolOutput> {
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 result = 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(ToolOutput {
76                        summary: "[Tool Timeout]".to_string(),
77                        control_flow: ToolControlFlow::Continue,
78                        ..Default::default()
79                    });
80                }
81            }
82        } else {
83            tool.call(args, ctx).await
84        };
85
86        let mut output = result?;
87
88        // 3. Output truncation
89        if let Some(max_chars) = self.max_output_chars
90            && output.summary.len() > max_chars
91        {
92            let original_summary_len = output.summary.len();
93            let original_raw_len = output.raw.as_ref().map(|v| v.to_string().len());
94            let suffix = "...(truncated)";
95            let keep = max_chars.saturating_sub(suffix.len());
96            if keep > 0 {
97                // Use floor_char_boundary to avoid panicking on multi-byte
98                // UTF-8 characters (e.g. CJK, emoji) where `keep` falls
99                // in the middle of a character.
100                let truncate_at = output.summary.floor_char_boundary(keep);
101                output.summary.truncate(truncate_at);
102                output.summary.push_str(suffix);
103            } else {
104                output.summary = suffix[..max_chars].to_string();
105            }
106            output.truncation = Some(TruncationInfo {
107                original_summary_len,
108                original_raw_len,
109                max_allowed_chars: max_chars,
110            });
111            tracing::debug!(
112                tool = tool.name(),
113                original_summary_len = original_summary_len,
114                original_raw_len = original_raw_len,
115                max_allowed_chars = max_chars,
116                "tool output truncated"
117            );
118        }
119
120        // 4. after_call hook
121        if let Some(policy) = &self.tool_policy {
122            policy.after_call(tool.name(), args, &output, ctx)?;
123        }
124
125        Ok(output)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use serde_json::json;
133    use std::sync::atomic::{AtomicU32, Ordering};
134
135    use crate::types::{AgentError, Language, SessionId};
136
137    // ── Test helpers ──
138
139    use tokio::sync::mpsc;
140
141    fn test_ctx() -> ToolContext {
142        let (tx, _rx) = mpsc::unbounded_channel();
143        ToolContext {
144            session_id: SessionId::new(1),
145            user_event_tx: tx,
146            llm_client: None,
147            session_store: None,
148            language: Language::En,
149            cancel_token: tokio_util::sync::CancellationToken::new(),
150        }
151    }
152
153    struct EchoTool;
154    #[async_trait]
155    impl Tool for EchoTool {
156        fn name(&self) -> &'static str {
157            "echo"
158        }
159        fn definition(&self) -> Value {
160            json!({})
161        }
162        async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
163            Ok(ToolOutput {
164                summary: args
165                    .get("msg")
166                    .and_then(|v| v.as_str())
167                    .unwrap_or("ok")
168                    .to_string(),
169                ..Default::default()
170            })
171        }
172    }
173
174    struct SlowTool;
175    #[async_trait]
176    impl Tool for SlowTool {
177        fn name(&self) -> &'static str {
178            "slow"
179        }
180        fn definition(&self) -> Value {
181            json!({})
182        }
183        async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
184            tokio::time::sleep(Duration::from_secs(10)).await;
185            Ok(ToolOutput {
186                summary: "done".to_string(),
187                ..Default::default()
188            })
189        }
190    }
191
192    struct FailingTool;
193    #[async_trait]
194    impl Tool for FailingTool {
195        fn name(&self) -> &'static str {
196            "fail"
197        }
198        fn definition(&self) -> Value {
199            json!({})
200        }
201        async fn call(&self, _args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
202            Err(AgentError::tool_not_found("intentional"))
203        }
204    }
205
206    struct TrackingPolicy {
207        before_count: AtomicU32,
208        after_count: AtomicU32,
209        fail_before: bool,
210    }
211    impl TrackingPolicy {
212        fn new() -> Self {
213            Self {
214                before_count: AtomicU32::new(0),
215                after_count: AtomicU32::new(0),
216                fail_before: false,
217            }
218        }
219        fn fail_before_call() -> Self {
220            Self {
221                before_count: AtomicU32::new(0),
222                after_count: AtomicU32::new(0),
223                fail_before: true,
224            }
225        }
226    }
227    #[async_trait]
228    impl ToolPolicy for TrackingPolicy {
229        async fn evaluate_approval(
230            &self,
231            _: &str,
232            _: &Value,
233        ) -> Option<crate::types::ApprovalRequest> {
234            None
235        }
236        fn before_call(&self, _name: &str, _args: &Value, _ctx: &ToolContext) -> AgentResult<()> {
237            self.before_count.fetch_add(1, Ordering::SeqCst);
238            if self.fail_before {
239                return Err(AgentError::internal("before_call denied"));
240            }
241            Ok(())
242        }
243        fn after_call(
244            &self,
245            _name: &str,
246            _args: &Value,
247            _output: &ToolOutput,
248            _ctx: &ToolContext,
249        ) -> AgentResult<()> {
250            self.after_count.fetch_add(1, 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!(output.summary, "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!(policy.before_count.load(Ordering::SeqCst), 1);
278        assert_eq!(policy.after_count.load(Ordering::SeqCst), 1);
279    }
280
281    #[tokio::test]
282    async fn policy_before_call_aborts() {
283        let policy = Arc::new(TrackingPolicy::fail_before_call());
284        let pipeline = DefaultPipeline::new(Some(policy.clone()), None, None);
285
286        let result = pipeline.execute(&EchoTool, &json!({}), &test_ctx()).await;
287        assert!(result.is_err());
288        // after_call should NOT be called
289        assert_eq!(policy.after_count.load(Ordering::SeqCst), 0);
290    }
291
292    #[tokio::test]
293    async fn timeout_fires() {
294        let pipeline = DefaultPipeline::new(None, Some(50), None); // 50ms timeout
295        let output = pipeline
296            .execute(&SlowTool, &json!({}), &test_ctx())
297            .await
298            .unwrap();
299        assert_eq!(output.summary, "[Tool Timeout]");
300    }
301
302    #[tokio::test]
303    async fn no_timeout_when_tool_fast() {
304        let pipeline = DefaultPipeline::new(None, Some(5000), None);
305        let output = pipeline
306            .execute(&EchoTool, &json!({"msg": "fast"}), &test_ctx())
307            .await
308            .unwrap();
309        assert_eq!(output.summary, "fast");
310    }
311
312    #[tokio::test]
313    async fn truncation_applies() {
314        let pipeline = DefaultPipeline::new(None, None, Some(10)); // max 10 chars
315        let output = pipeline
316            .execute(
317                &EchoTool,
318                &json!({"msg": "this is a very long message"}),
319                &test_ctx(),
320            )
321            .await
322            .unwrap();
323        assert!(output.summary.len() <= 10);
324        assert!(output.truncation.is_some());
325        let t = output.truncation.unwrap();
326        assert_eq!(t.original_summary_len, 27);
327        assert_eq!(t.max_allowed_chars, 10);
328    }
329
330    #[tokio::test]
331    async fn no_truncation_when_short() {
332        let pipeline = DefaultPipeline::new(None, None, Some(100));
333        let output = pipeline
334            .execute(&EchoTool, &json!({"msg": "short"}), &test_ctx())
335            .await
336            .unwrap();
337        assert_eq!(output.summary, "short");
338        assert!(output.truncation.is_none());
339    }
340
341    #[tokio::test]
342    async fn truncation_cjk_no_panic() {
343        // CJK chars are 3 bytes each. With max_chars=20, keep=6 bytes,
344        // which falls inside a 3-byte CJK char. floor_char_boundary
345        // should round down to the nearest char boundary.
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!(output.summary.len() <= 20);
356        assert!(output.summary.ends_with("...(truncated)") || output.summary.len() <= 20);
357        assert!(output.truncation.is_some());
358        // Must not panic — that's the main assertion
359    }
360
361    #[tokio::test]
362    async fn timeout_plus_truncation() {
363        let pipeline = DefaultPipeline::new(None, Some(50), Some(100));
364        let output = pipeline
365            .execute(&SlowTool, &json!({}), &test_ctx())
366            .await
367            .unwrap();
368        assert_eq!(output.summary, "[Tool Timeout]");
369        assert!(output.truncation.is_none()); // timeout output is short
370    }
371
372    #[tokio::test]
373    async fn tool_error_propagates() {
374        let pipeline = DefaultPipeline::new(None, None, None);
375        let result = pipeline
376            .execute(&FailingTool, &json!({}), &test_ctx())
377            .await;
378        assert!(result.is_err());
379    }
380}