Skip to main content

funera_orchestrate/
event.rs

1use std::sync::Arc;
2
3use serde_json::Value as JsonValue;
4
5use funera_core::chat::message::{MsgVariant, Role, TextMessage};
6#[cfg(feature = "tool")]
7use funera_core::chat::message::{ToolRequestMessage, ToolResponseMessage};
8use funera_core::event_bus::env_state_bus::EnvStateEvent;
9use funera_core::event_bus::react_bus::ReactEvent;
10use funera_core::event_bus::token_bus::TokenEvent;
11use funera_core::middleware::MiddlewareEvent;
12#[cfg(feature = "tool")]
13use funera_core::re_act::tool::ToolType;
14
15#[derive(Debug, Clone)]
16pub enum AgentEvent {
17    Text(String),
18    Reasoning(String),
19    ToolCallRequest {
20        index: usize,
21        call_id: Arc<str>,
22        name: String,
23        args: JsonValue,
24    },
25    ToolCallResult {
26        call_id: Arc<str>,
27        name: String,
28        result: Result<String, String>,
29    },
30    TurnStart,
31    TurnEnd {
32        finish_reason: Option<String>,
33    },
34    Error(String),
35    ToolApprovalRequired {
36        call_id: Arc<str>,
37        tool_name: String,
38        reason: String,
39    },
40    Done,
41}
42
43impl MiddlewareEvent for AgentEvent {
44    type Error = String;
45
46    fn assistant_text(content: String, _reasoning: Option<String>) -> Self {
47        AgentEvent::Text(content)
48    }
49
50    fn tool_call_request(call_id: Arc<str>, name: String, args: JsonValue) -> Self {
51        AgentEvent::ToolCallRequest {
52            index: 0,
53            call_id,
54            name,
55            args,
56        }
57    }
58
59    fn tool_response(call_id: Arc<str>, name: String, result: Result<String, String>) -> Self {
60        AgentEvent::ToolCallResult {
61            call_id,
62            name,
63            result,
64        }
65    }
66
67    fn turn_start() -> Self {
68        AgentEvent::TurnStart
69    }
70
71    fn turn_end(finish_reason: Option<String>) -> Self {
72        AgentEvent::TurnEnd { finish_reason }
73    }
74
75    fn done() -> Self {
76        AgentEvent::Done
77    }
78
79    fn into_session_message(self) -> Option<(Role, MsgVariant)> {
80        match self {
81            AgentEvent::Text(text) => Some((
82                Role::Assistant,
83                MsgVariant::Text(TextMessage {
84                    text: text.into(),
85                    reasoning_content: None,
86                }),
87            )),
88            #[cfg(feature = "tool")]
89            AgentEvent::ToolCallRequest {
90                call_id,
91                name,
92                args,
93                ..
94            } => Some((
95                Role::Assistant,
96                MsgVariant::ToolRequest(ToolRequestMessage {
97                    tool_call_id: call_id,
98                    tool_type: ToolType::Function,
99                    function_name: name.into(),
100                    function_args: args,
101                    reasoning_content: None,
102                }),
103            )),
104            #[cfg(feature = "tool")]
105            AgentEvent::ToolCallResult {
106                call_id,
107                name: _,
108                result,
109            } => Some((
110                Role::Tool,
111                MsgVariant::ToolResponse(ToolResponseMessage {
112                    tool_call_id: call_id,
113                    result: result
114                        .unwrap_or_else(|e| format!("tool call failed: {e}"))
115                        .into(),
116                }),
117            )),
118            _ => None,
119        }
120    }
121}
122
123/// Wraps a raw underlying event from the core event buses.
124///
125/// Returned by [`Agent::subscribe_raw_events`](crate::Agent::subscribe_raw_events).
126#[derive(Debug, Clone)]
127pub enum RawAgentEvent {
128    Token(TokenEvent),
129    React(ReactEvent),
130    EnvState(EnvStateEvent),
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn agent_event_text() {
139        let e = AgentEvent::Text("hello".into());
140        assert!(matches!(e, AgentEvent::Text(t) if t == "hello"));
141    }
142
143    #[test]
144    fn agent_event_tool_call_start() {
145        let e = AgentEvent::ToolCallRequest {
146            index: 0,
147            call_id: "call_abc".into(),
148            name: "test".into(),
149            args: serde_json::json!({"x": 1}),
150        };
151        assert!(matches!(e, AgentEvent::ToolCallRequest { name, .. } if name == "test"));
152    }
153
154    #[test]
155    fn agent_event_tool_call_result_ok() {
156        let e = AgentEvent::ToolCallResult {
157            call_id: "call_abc".into(),
158            name: "test".into(),
159            result: Ok("done".into()),
160        };
161        assert!(matches!(e, AgentEvent::ToolCallResult { .. }));
162    }
163
164    #[test]
165    fn agent_event_tool_call_result_err() {
166        let e = AgentEvent::ToolCallResult {
167            call_id: "call_abc".into(),
168            name: "test".into(),
169            result: Err("fail".into()),
170        };
171        assert!(matches!(
172            e,
173            AgentEvent::ToolCallResult { result: Err(_), .. }
174        ));
175    }
176
177    #[cfg(feature = "tool")]
178    #[test]
179    fn tool_call_result_error_preserved_in_session_message() {
180        // When a tool call fails, the error must appear in the
181        // session message so the LLM sees the rejection reason.
182        let event = AgentEvent::ToolCallResult {
183            call_id: "call_1".into(),
184            name: "shell".into(),
185            result: Err("tool call rejected by user".into()),
186        };
187        let (role, variant) = event.into_session_message().unwrap();
188        assert_eq!(role.to_string(), "tool");
189        match variant {
190            MsgVariant::ToolResponse(msg) => {
191                assert!(
192                    msg.result.contains("tool call failed:"),
193                    "result should contain error prefix; got: {}",
194                    msg.result
195                );
196                assert!(
197                    msg.result.contains("tool call rejected by user"),
198                    "result should contain original error; got: {}",
199                    msg.result
200                );
201            }
202            other => panic!("expected ToolResponse, got {other:?}"),
203        }
204    }
205
206    #[cfg(feature = "tool")]
207    #[test]
208    fn tool_call_result_success_not_modified() {
209        // A successful tool result should pass through unchanged.
210        let event = AgentEvent::ToolCallResult {
211            call_id: "call_2".into(),
212            name: "read".into(),
213            result: Ok("file contents".into()),
214        };
215        let (_role, variant) = event.into_session_message().unwrap();
216        match variant {
217            MsgVariant::ToolResponse(msg) => {
218                assert_eq!(&*msg.result, "file contents");
219            }
220            other => panic!("expected ToolResponse, got {other:?}"),
221        }
222    }
223
224    #[test]
225    fn agent_event_clone() {
226        let e = AgentEvent::Text("hi".into());
227        let cloned = e.clone();
228        assert!(matches!(cloned, AgentEvent::Text(t) if t == "hi"));
229    }
230
231    #[test]
232    fn agent_event_turn_end() {
233        let e = AgentEvent::TurnEnd {
234            finish_reason: Some("Stop".into()),
235        };
236        assert!(matches!(e, AgentEvent::TurnEnd { finish_reason: Some(ref r) } if r == "Stop"));
237    }
238
239    #[test]
240    fn middleware_event_into_session() {
241        let event: AgentEvent = MiddlewareEvent::assistant_text("hi".into(), None);
242        let (role, variant) = event.into_session_message().unwrap();
243        assert_eq!(role.to_string(), "assistant");
244        assert!(matches!(variant, MsgVariant::Text(_)));
245    }
246
247    #[test]
248    fn raw_token_wraps_text() {
249        let raw = RawAgentEvent::Token(TokenEvent::Text("hello".into()));
250        assert!(matches!(raw, RawAgentEvent::Token(TokenEvent::Text(t)) if t == "hello"));
251    }
252
253    #[test]
254    fn raw_react_wraps_turn_start() {
255        let raw = RawAgentEvent::React(ReactEvent::TurnStart);
256        assert!(matches!(raw, RawAgentEvent::React(ReactEvent::TurnStart)));
257    }
258
259    #[test]
260    fn raw_env_state_wraps_session_start() {
261        let raw = RawAgentEvent::EnvState(EnvStateEvent::SessionStart);
262        assert!(matches!(
263            raw,
264            RawAgentEvent::EnvState(EnvStateEvent::SessionStart)
265        ));
266    }
267
268    #[test]
269    fn raw_clone() {
270        let raw = RawAgentEvent::Token(TokenEvent::Text("x".into()));
271        let cloned = raw.clone();
272        assert!(matches!(cloned, RawAgentEvent::Token(TokenEvent::Text(t)) if t == "x"));
273    }
274
275    #[test]
276    fn raw_debug() {
277        let raw = RawAgentEvent::Token(TokenEvent::Text("x".into()));
278        let _ = format!("{raw:?}");
279    }
280}