Skip to main content

agent_base/engine/
middleware.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde_json::Value;
5
6use crate::types::{AgentResult, ChatMessage, FinishReason, SessionId, UserEvent};
7
8#[derive(Clone)]
9pub struct UserMessageCtx {
10    pub session_id: SessionId,
11    pub user_input: String,
12}
13
14pub struct PreLlmCtx {
15    pub session_id: SessionId,
16    pub messages: Vec<ChatMessage>,
17    pub tools: Vec<Value>,
18    /// Unified emit function: sends [`UserEvent`] to both the renderer (real-time)
19    /// and the event bus (persistence).  Set by the react loop; middleware calls
20    /// [`emit()`](Self::emit) which delegates to this closure.
21    ///
22    /// The closure captures the renderer callback and the event bus, so middleware
23    /// never needs to know about broadcast channels or drain mechanics.
24    pub emit_fn: Option<Box<dyn Fn(UserEvent) + Send + Sync>>,
25    /// Current turn count (1-indexed).
26    pub turn_count: u32,
27    /// Maximum turns allowed for this run.
28    pub max_turns: u32,
29}
30
31impl PreLlmCtx {
32    /// Emit a [`UserEvent`] to both the renderer and the event bus.
33    ///
34    /// This is the single entry point for middleware to send events.  The event
35    /// is delivered to the renderer callback (real-time display) and to the
36    /// event bus (persistence, event_log, checkpoint) in one call.
37    ///
38    /// Does nothing if the emit function was not set (e.g. in tests).
39    /// Panics inside the emit function are caught and logged as warnings.
40    pub fn emit(&self, event: UserEvent) {
41        if let Some(ref f) = self.emit_fn
42            && let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
43                f(event);
44            }))
45        {
46            tracing::warn!("emit failed: {:?}", e);
47        }
48    }
49}
50
51#[derive(Clone)]
52pub struct PostLlmCtx {
53    pub session_id: SessionId,
54    pub full_text: String,
55    pub is_tool_call: bool,
56    pub tool_calls: Vec<(String, String, String)>,
57    pub available_tools: Vec<String>,
58    pub turn_count: u32,
59    pub total_tool_calls: usize,
60    /// Number of tool-enforcement nudges issued in the current turn.
61    /// Read from session; middleware may increment this to track nudge attempts.
62    pub nudge_count: usize,
63    /// Number of tool calls already executed in the current turn.
64    /// Used by `TurnToolLimitMiddleware` to enforce per-turn tool call limits.
65    pub turn_tool_calls: usize,
66    pub skip_push: bool,
67    pub follow_up_message: Option<String>,
68    /// Semantic finish reason from the LLM (Stop / ToolUse / Truncated / Other).
69    /// Middleware can inspect this to implement custom continuation logic
70    /// (e.g. auto-continue on truncation).
71    pub finish_reason: FinishReason,
72}
73
74#[async_trait]
75pub trait Middleware: Send + Sync {
76    async fn on_user_message(&self, _ctx: &mut UserMessageCtx) -> AgentResult<()> {
77        Ok(())
78    }
79
80    async fn on_pre_llm(&self, _ctx: &mut PreLlmCtx) -> AgentResult<()> {
81        Ok(())
82    }
83
84    async fn on_post_llm(&self, _ctx: &mut PostLlmCtx) -> AgentResult<()> {
85        Ok(())
86    }
87}
88
89pub(crate) type MiddlewareRef = Arc<dyn Middleware>;
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::types::SessionId;
95
96    #[test]
97    fn test_post_llm_ctx_new_fields_defaults() {
98        let ctx = PostLlmCtx {
99            session_id: SessionId {
100                id: 1,
101                external_id: None,
102            },
103            full_text: "test".to_string(),
104            is_tool_call: false,
105            tool_calls: vec![],
106            available_tools: vec![],
107            turn_count: 0,
108            total_tool_calls: 0,
109            nudge_count: 0,
110            turn_tool_calls: 0,
111            skip_push: false,
112            follow_up_message: None,
113            finish_reason: FinishReason::Stop,
114        };
115        assert!(ctx.available_tools.is_empty());
116        assert_eq!(ctx.turn_count, 0);
117        assert_eq!(ctx.total_tool_calls, 0);
118        assert!(!ctx.skip_push);
119        assert!(ctx.follow_up_message.is_none());
120        assert_eq!(ctx.finish_reason, FinishReason::Stop);
121    }
122
123    #[test]
124    fn test_post_llm_ctx_skip_push_follow_up_set() {
125        let ctx = PostLlmCtx {
126            session_id: SessionId {
127                id: 2,
128                external_id: None,
129            },
130            full_text: "I will execute...".to_string(),
131            is_tool_call: false,
132            tool_calls: vec![],
133            available_tools: vec!["echo".to_string()],
134            turn_count: 1,
135            total_tool_calls: 0,
136            nudge_count: 0,
137            turn_tool_calls: 0,
138            skip_push: true,
139            follow_up_message: Some("Please call tools now.".to_string()),
140            finish_reason: FinishReason::Stop,
141        };
142        assert!(ctx.skip_push);
143        assert_eq!(
144            ctx.follow_up_message,
145            Some("Please call tools now.".to_string())
146        );
147        assert_eq!(ctx.available_tools, vec!["echo".to_string()]);
148        assert_eq!(ctx.total_tool_calls, 0);
149    }
150
151    #[test]
152    fn test_post_llm_ctx_clone_preserves_new_fields() {
153        let ctx = PostLlmCtx {
154            session_id: SessionId {
155                id: 3,
156                external_id: None,
157            },
158            full_text: "hello".to_string(),
159            is_tool_call: false,
160            tool_calls: vec![],
161            available_tools: vec!["add".to_string(), "subtract".to_string()],
162            turn_count: 5,
163            total_tool_calls: 3,
164            nudge_count: 0,
165            turn_tool_calls: 2,
166            skip_push: true,
167            follow_up_message: Some("nudge".to_string()),
168            finish_reason: FinishReason::Truncated {
169                reason: Some("max_tokens".into()),
170            },
171        };
172        let cloned = ctx.clone();
173        assert_eq!(cloned.available_tools, vec!["add", "subtract"]);
174        assert_eq!(cloned.turn_count, 5);
175        assert_eq!(cloned.total_tool_calls, 3);
176        assert_eq!(cloned.turn_tool_calls, 2);
177        assert!(cloned.skip_push);
178        assert_eq!(cloned.follow_up_message, Some("nudge".to_string()));
179        assert!(cloned.finish_reason.is_truncated());
180    }
181}