Skip to main content

funera_core/
env_actor.rs

1use async_openai::config::OpenAIConfig;
2use tokio::sync::{broadcast, mpsc, oneshot};
3
4#[cfg(feature = "tool")]
5use crate::event_bus::tool_bus::ToolBus;
6#[cfg(feature = "skill")]
7use crate::re_act::skills::Skill;
8#[cfg(feature = "tool")]
9use crate::re_act::tool::Tool;
10#[cfg(feature = "tool")]
11use crate::re_act::tool_executor::ToolExecutor;
12#[cfg(feature = "security")]
13use crate::security::audit::{AuditBus, AuditEvent};
14#[cfg(feature = "security")]
15use crate::security::policy::ToolPolicy;
16#[cfg(feature = "sandbox")]
17use crate::security::sandbox::SandboxPolicy;
18
19use crate::env::{FuneraEnv, FuneraEnvWatcher};
20use crate::event_bus::env_state_bus::EnvStateEvent;
21
22// ═══════════════════════════════════════════════════════════════
23// Config structs — bundle params to keep fn arg count ≤ 7
24// ═══════════════════════════════════════════════════════════════
25
26/// Bundle of tool-system resources for the EnvActor.
27///
28/// Fields are feature-gated internally; the struct always exists so call
29/// sites can pass `Option<EnvToolConfig>` without any `#[cfg]` on the
30/// function signature.
31pub struct EnvToolConfig {
32    #[cfg(feature = "tool")]
33    pub tool_bus: ToolBus,
34    #[cfg(feature = "tool")]
35    pub exec_rx: mpsc::Receiver<crate::event_bus::tool_bus::ToolExecCommand>,
36}
37
38/// Bundle of security resources for the EnvActor.
39pub struct EnvSecurityConfig {
40    #[cfg(feature = "sandbox")]
41    pub sandbox_policy: SandboxPolicy,
42    #[cfg(feature = "security")]
43    pub tool_policy: ToolPolicy,
44    #[cfg(feature = "security")]
45    pub audit_bus: AuditBus,
46}
47
48// ═══════════════════════════════════════════════════════════════
49// ReActConfig — bundled handles the ReAct loop needs each call
50// ═══════════════════════════════════════════════════════════════
51
52pub struct ReActConfig {
53    pub env_watcher: FuneraEnvWatcher,
54    #[cfg(feature = "tool")]
55    pub tool_bus: ToolBus,
56    pub max_iterations: usize,
57    pub channel_buffer: usize,
58}
59
60// ═══════════════════════════════════════════════════════════════
61// EnvCmd — commands sent to the EnvActor via mpsc
62// ═══════════════════════════════════════════════════════════════
63
64pub enum EnvCmd {
65    // ── Mutation (fire-and-forget) ────────────────────────────
66    SetModel(String),
67    SetClient(async_openai::Client<OpenAIConfig>),
68    #[cfg(feature = "tool")]
69    AddTool(Box<dyn Tool>),
70    #[cfg(feature = "tool")]
71    RemoveTool(String),
72    #[cfg(feature = "tool")]
73    SetToolAvailability {
74        name: String,
75        available: bool,
76    },
77    #[cfg(feature = "skill")]
78    AddSkill(Skill),
79    #[cfg(feature = "skill")]
80    RemoveSkill(String),
81    #[cfg(feature = "skill")]
82    ActivateSkill {
83        name: String,
84        respond: oneshot::Sender<bool>,
85    },
86    #[cfg(feature = "skill")]
87    DeactivateSkill {
88        name: String,
89        respond: oneshot::Sender<bool>,
90    },
91    #[cfg(feature = "skill")]
92    SetSkillPrompt(String),
93
94    // ── Query (oneshot response) ──────────────────────────────
95    #[cfg(feature = "skill")]
96    GetSkillPrompt {
97        respond: oneshot::Sender<String>,
98    },
99    SubscribeEnvState {
100        respond: oneshot::Sender<broadcast::Receiver<EnvStateEvent>>,
101    },
102    GetReActConfig {
103        respond: oneshot::Sender<ReActConfig>,
104    },
105    GetModel {
106        respond: oneshot::Sender<String>,
107    },
108    #[cfg(feature = "tool")]
109    GetToolNames {
110        respond: oneshot::Sender<Vec<String>>,
111    },
112    #[cfg(feature = "sandbox")]
113    GetSandboxPolicy {
114        respond: oneshot::Sender<SandboxPolicy>,
115    },
116    #[cfg(all(feature = "tool", feature = "security"))]
117    ApproveToolCall {
118        call_id: String,
119        approved: bool,
120        respond: oneshot::Sender<Result<(), String>>,
121    },
122    #[cfg(feature = "security")]
123    SubscribeAudit {
124        respond: oneshot::Sender<broadcast::Receiver<AuditEvent>>,
125    },
126}
127
128/// Spawn a long-running EnvActor that owns all environment state.
129///
130/// The actor is the **single source of truth** for all environment
131/// configuration and mutation. It:
132/// - Owns [`FuneraEnv`] (model, client, watch senders, registries)
133/// - Spawns and manages the [`ToolExecutor`] internally
134/// - Atomically broadcasts [`EnvStateEvent`] on every mutation
135/// - Provides read-snapshot queries via oneshot channels
136///
137/// When all [`EnvCmd`] senders are dropped, the actor and its
138/// ToolExecutor exit cleanly.
139pub fn spawn_env_actor(
140    env: FuneraEnv,
141    env_watcher: FuneraEnvWatcher,
142    max_iterations: usize,
143    channel_buffer: usize,
144    tool: Option<EnvToolConfig>,
145    security: Option<EnvSecurityConfig>,
146) -> mpsc::UnboundedSender<EnvCmd> {
147    let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<EnvCmd>();
148    let (state_tx, _) = broadcast::channel::<EnvStateEvent>(32);
149
150    let mut env = env;
151
152    // ── Spawn ToolExecutor internally ─────────────────────────
153    #[cfg(feature = "tool")]
154    let tool_bus_for_react = {
155        if let Some(tc) = tool {
156            let reg = env.tool_registry.clone();
157            let tb = tc.tool_bus.clone();
158            tokio::spawn(async move {
159                ToolExecutor::new(reg, tc.exec_rx).run().await;
160            });
161            Some(tb)
162        } else {
163            None
164        }
165    };
166
167    // ── Extract security resources ────────────────────────────
168    #[cfg(feature = "sandbox")]
169    let sandbox_policy = {
170        let _s = &security;
171        _s.as_ref()
172            .map(|s| s.sandbox_policy.clone())
173            .unwrap_or_default()
174    };
175    #[cfg(feature = "security")]
176    let audit_bus = {
177        let _s = &security;
178        _s.as_ref().map(|s| s.audit_bus.clone()).unwrap_or_default()
179    };
180    #[cfg(not(any(feature = "sandbox", feature = "security")))]
181    let _ = &security;
182
183    tokio::spawn(async move {
184        // ── Broadcast initial state ─────────────────────────────
185        #[cfg(feature = "tool")]
186        if let Ok(guard) = env.tool_registry.try_read() {
187            for name in guard.get_all_tools().keys() {
188                let _ = state_tx.send(EnvStateEvent::ToolAdded(name.clone()));
189            }
190        }
191        #[cfg(feature = "skill")]
192        if let Ok(guard) = env.skill_registry.try_read() {
193            for name in guard.all_skills().keys() {
194                let _ = state_tx.send(EnvStateEvent::SkillAdded(name.clone()));
195            }
196        }
197
198        // ── Command loop ───────────────────────────────────────
199        while let Some(cmd) = cmd_rx.recv().await {
200            match cmd {
201                // ── Mutation ──────────────────────────────────
202                EnvCmd::SetModel(model) => {
203                    env.set_model(&model);
204                    let _ = state_tx.send(EnvStateEvent::LlmChanged(model));
205                }
206                EnvCmd::SetClient(client) => {
207                    env.set_client(client);
208                }
209                #[cfg(feature = "tool")]
210                EnvCmd::AddTool(tool) => {
211                    let name = tool.name().to_string();
212                    env.add_tool(tool).await;
213                    let _ = state_tx.send(EnvStateEvent::ToolAdded(name));
214                }
215                #[cfg(feature = "tool")]
216                EnvCmd::RemoveTool(name) => {
217                    env.remove_tool(&name).await;
218                    let _ = state_tx.send(EnvStateEvent::ToolRemoved(name));
219                }
220                #[cfg(feature = "tool")]
221                EnvCmd::SetToolAvailability { name, available } => {
222                    env.set_tool_availability(&name, available).await;
223                    let _ = state_tx.send(EnvStateEvent::ToolAvailability(name, available));
224                }
225                #[cfg(feature = "skill")]
226                EnvCmd::AddSkill(skill) => {
227                    let name = skill.name.clone();
228                    env.add_skill(skill).await;
229                    let _ = state_tx.send(EnvStateEvent::SkillAdded(name));
230                }
231                #[cfg(feature = "skill")]
232                EnvCmd::RemoveSkill(name) => {
233                    env.remove_skill(&name).await;
234                    let _ = state_tx.send(EnvStateEvent::SkillRemoved(name.clone()));
235                }
236                #[cfg(feature = "skill")]
237                EnvCmd::ActivateSkill { name, respond } => {
238                    let ok = env.activate_skill(&name).await;
239                    let _ = respond.send(ok);
240                    if ok {
241                        let _ = state_tx.send(EnvStateEvent::SkillActivated(name));
242                    }
243                }
244                #[cfg(feature = "skill")]
245                EnvCmd::DeactivateSkill { name, respond } => {
246                    let ok = env.deactivate_skill(&name).await;
247                    let _ = respond.send(ok);
248                    if ok {
249                        let _ = state_tx.send(EnvStateEvent::SkillDeactivated(name));
250                    }
251                }
252                #[cfg(feature = "skill")]
253                EnvCmd::SetSkillPrompt(prompt) => {
254                    env.set_skill_prompt(prompt);
255                }
256
257                // ── Query ─────────────────────────────────────
258                #[cfg(feature = "skill")]
259                EnvCmd::GetSkillPrompt { respond } => {
260                    let _ = respond.send(env.skill_prompt_now());
261                }
262                EnvCmd::SubscribeEnvState { respond } => {
263                    let _ = respond.send(state_tx.subscribe());
264                }
265                EnvCmd::GetReActConfig { respond } => {
266                    let _ = respond.send(ReActConfig {
267                        env_watcher: env_watcher.clone(),
268                        #[cfg(feature = "tool")]
269                        tool_bus: tool_bus_for_react
270                            .clone()
271                            .expect("tool_bus must be Some when tool feature is enabled"),
272                        max_iterations,
273                        channel_buffer,
274                    });
275                }
276                EnvCmd::GetModel { respond } => {
277                    let _ = respond.send(env.model().to_string());
278                }
279                #[cfg(feature = "tool")]
280                EnvCmd::GetToolNames { respond } => {
281                    let names = if let Ok(guard) = env.tool_registry.try_read() {
282                        guard.get_all_tools().keys().cloned().collect()
283                    } else {
284                        Vec::new()
285                    };
286                    let _ = respond.send(names);
287                }
288                #[cfg(feature = "sandbox")]
289                EnvCmd::GetSandboxPolicy { respond } => {
290                    let _ = respond.send(sandbox_policy.clone());
291                }
292                #[cfg(all(feature = "tool", feature = "security"))]
293                EnvCmd::ApproveToolCall {
294                    call_id,
295                    approved,
296                    respond,
297                } => {
298                    let result = env
299                        .tool_registry
300                        .read()
301                        .await
302                        .approve_tool_call(&call_id, approved);
303                    let _ = respond.send(result);
304                }
305                #[cfg(feature = "security")]
306                EnvCmd::SubscribeAudit { respond } => {
307                    let _ = respond.send(audit_bus.subscribe());
308                }
309            }
310        }
311    });
312
313    cmd_tx
314}