Skip to main content

funera_orchestrate/
runtime.rs

1use std::marker::PhantomData;
2#[cfg(feature = "skill")]
3use std::path::PathBuf;
4#[cfg(any(feature = "middleware", feature = "security", feature = "tool"))]
5use std::sync::Arc;
6
7use async_openai::config::OpenAIConfig;
8use tokio::sync::{broadcast, mpsc};
9
10#[cfg(test)]
11use funera_core::chat::session::FuneraSession;
12use funera_core::chat::session::{SessionCmd, spawn_session_actor};
13use funera_core::env::FuneraEnv;
14#[cfg(feature = "security")]
15use funera_core::env_actor::EnvSecurityConfig;
16#[cfg(feature = "tool")]
17use funera_core::env_actor::EnvToolConfig;
18use funera_core::env_actor::{EnvCmd, ReActConfig, spawn_env_actor};
19use funera_core::event_bus::env_state_bus::EnvStateEvent;
20#[cfg(feature = "tool")]
21use funera_core::event_bus::tool_bus::ToolBus;
22use funera_core::provider::ChatProvider;
23#[cfg(feature = "deepseek")]
24use funera_core::provider::deepseek::DeepSeekProvider;
25#[cfg(feature = "skill")]
26use funera_core::re_act::skills::{Skill, SkillRegistry};
27#[cfg(feature = "tool")]
28use funera_core::re_act::tool::{Tool, ToolRegistry};
29#[cfg(feature = "security")]
30use funera_core::security::audit::{AuditBus, AuditEvent};
31#[cfg(all(feature = "tool", feature = "sandbox", feature = "security"))]
32use funera_core::security::path_guard::PathGuard;
33#[cfg(feature = "security")]
34use funera_core::security::policy::ToolPolicy;
35#[cfg(feature = "security")]
36use funera_core::security::registry::ApprovalCallback;
37#[cfg(feature = "sandbox")]
38use funera_core::security::sandbox::SandboxPolicy;
39#[cfg(feature = "security")]
40use funera_core::security::secret::SecureApiKey;
41
42#[cfg(feature = "middleware")]
43use crate::event::AgentEvent;
44#[cfg(feature = "middleware")]
45use crate::middleware_bundle::MiddlewareBundle;
46#[cfg(feature = "middleware")]
47use funera_core::middleware::{ErrorsEnabled, MiddlewareChain};
48
49use crate::error::OrchestrateError;
50
51/// Builds an [`AgentRuntime`].
52///
53/// ```rust,no_run
54/// # use funera_orchestrate::{AgentRuntime, DeepSeekProvider};
55/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
56/// let runtime = AgentRuntime::<DeepSeekProvider>::builder()
57///     .api_key(std::env::var("DEEPSEEK_API_KEY")?)
58///     .model("deepseek-v4-flash")
59///     .build()?;
60/// # Ok(())
61/// # }
62/// ```
63pub struct AgentRuntimeBuilder {
64    api_key: Option<String>,
65    base_url: Option<String>,
66    client: Option<async_openai::Client<OpenAIConfig>>,
67    model: Option<String>,
68    max_iterations: usize,
69    channel_buffer: usize,
70    #[cfg(feature = "tool")]
71    tools: Vec<Arc<dyn Tool>>,
72    #[cfg(feature = "skill")]
73    skills: Vec<Skill>,
74    #[cfg(feature = "skill")]
75    skill_names_to_activate: Vec<String>,
76    #[cfg(feature = "skill")]
77    load_default_skills: bool,
78    #[cfg(feature = "sandbox")]
79    sandbox_policy: Option<SandboxPolicy>,
80    #[cfg(feature = "security")]
81    tool_policy: Option<ToolPolicy>,
82    #[cfg(feature = "security")]
83    secure_api_key: Option<SecureApiKey>,
84    #[cfg(feature = "security")]
85    approval_callback: Option<ApprovalCallback>,
86    #[cfg(feature = "security")]
87    approval_timeout: Option<std::time::Duration>,
88    #[cfg(feature = "middleware")]
89    middleware_bundle: Option<MiddlewareBundle<AgentEvent>>,
90}
91
92impl Default for AgentRuntimeBuilder {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl AgentRuntimeBuilder {
99    pub fn new() -> Self {
100        Self {
101            api_key: None,
102            base_url: None,
103            client: None,
104            model: None,
105            max_iterations: 10,
106            channel_buffer: 32,
107            #[cfg(feature = "tool")]
108            tools: Vec::new(),
109            #[cfg(feature = "skill")]
110            skills: Vec::new(),
111            #[cfg(feature = "skill")]
112            skill_names_to_activate: Vec::new(),
113            #[cfg(feature = "skill")]
114            load_default_skills: false,
115            #[cfg(feature = "sandbox")]
116            sandbox_policy: None,
117            #[cfg(feature = "security")]
118            tool_policy: None,
119            #[cfg(feature = "security")]
120            secure_api_key: None,
121            #[cfg(feature = "security")]
122            approval_callback: None,
123            #[cfg(feature = "security")]
124            approval_timeout: None,
125            #[cfg(feature = "middleware")]
126            middleware_bundle: None,
127        }
128    }
129
130    /// OpenAI API key. Falls back to `OPENAI_API_KEY` env var.
131    pub fn api_key(mut self, key: impl Into<String>) -> Self {
132        let key = key.into();
133        #[cfg(feature = "security")]
134        {
135            self.secure_api_key = Some(SecureApiKey::new(key.clone()));
136        }
137        self.api_key = Some(key);
138        self
139    }
140
141    /// Custom base URL (proxy, compatible API, etc.).
142    /// Pass e.g. `std::env::var("OPENAI_BASE_URL").ok()`.
143    pub fn base_url(mut self, url: Option<String>) -> Self {
144        if let Some(u) = url {
145            self.base_url = Some(u);
146        }
147        self
148    }
149
150    /// LLM model name. Falls back to `OPENAI_MODEL` env var, then `"gpt-4o"`.
151    pub fn model(mut self, model: impl Into<String>) -> Self {
152        self.model = Some(model.into());
153        self
154    }
155
156    /// Directly provide an OpenAI client (overrides api_key + base_url).
157    pub fn client(mut self, client: async_openai::Client<OpenAIConfig>) -> Self {
158        self.client = Some(client);
159        self
160    }
161
162    /// Maximum number of ReAct iterations per call (default 10).
163    pub fn max_iterations(mut self, n: usize) -> Self {
164        self.max_iterations = n;
165        self
166    }
167
168    /// Internal channel buffer size (default 32).
169    pub fn channel_buffer(mut self, n: usize) -> Self {
170        self.channel_buffer = n;
171        self
172    }
173
174    /// Load a skill from a SKILL.md file.
175    #[cfg(feature = "skill")]
176    pub fn with_skill_file(mut self, path: impl Into<PathBuf>) -> Self {
177        let path = path.into();
178        match Skill::from_file(&path) {
179            Ok(skill) => {
180                self.skills.push(skill);
181            }
182            Err(e) => {
183                eprintln!("warn: failed to load skill from {:?}: {}", path, e);
184            }
185        }
186        self
187    }
188
189    /// Load all SKILL.md files from a directory.
190    #[cfg(feature = "skill")]
191    pub fn with_skills_dir(mut self, path: impl Into<PathBuf>) -> Self {
192        let path = path.into();
193        match Skill::from_dir(&path) {
194            Ok(skills) => self.skills.extend(skills),
195            Err(e) => {
196                eprintln!("warn: failed to load skills from {:?}: {}", path, e);
197            }
198        }
199        self
200    }
201
202    /// Register an inline skill definition.
203    #[cfg(feature = "skill")]
204    pub fn with_skill(
205        mut self,
206        name: impl Into<String>,
207        description: impl Into<String>,
208        content: impl Into<String>,
209    ) -> Self {
210        self.skills.push(Skill::new(name, description, content));
211        self
212    }
213
214    /// Activate a previously loaded skill by name.
215    /// If the skill does not exist, the call is silently ignored.
216    #[cfg(feature = "skill")]
217    pub fn with_skill_active(mut self, name: impl Into<String>) -> Self {
218        self.skill_names_to_activate.push(name.into());
219        self
220    }
221
222    /// Auto-discover and load skills from default paths
223    /// (`$SKILLS_HOME` or `~/.agents/skills/`), then activate them.
224    #[cfg(feature = "skill")]
225    pub fn with_skills_default_path(mut self) -> Self {
226        self.load_default_skills = true;
227        self
228    }
229
230    /// Register a tool by its type (requires `Tool + Default`).
231    #[cfg(feature = "tool")]
232    pub fn with_tool<T: Tool + Default + 'static>(mut self) -> Self {
233        self.tools.push(Arc::new(T::default()));
234        self
235    }
236
237    /// Register a pre-constructed tool.
238    #[cfg(feature = "tool")]
239    pub fn with_tool_instance(mut self, tool: Arc<dyn Tool>) -> Self {
240        self.tools.push(tool);
241        self
242    }
243
244    /// Attach a middleware chain with error channel.
245    ///
246    /// The runtime will spawn a task to consume inspector errors via `tracing::warn`.
247    #[cfg(feature = "middleware")]
248    pub fn with_middleware_bundle(mut self, bundle: MiddlewareBundle<AgentEvent>) -> Self {
249        self.middleware_bundle = Some(bundle);
250        self
251    }
252
253    /// Set a kernel-enforced sandbox policy for tool subprocesses.
254    #[cfg(feature = "sandbox")]
255    pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
256        self.sandbox_policy = Some(policy);
257        self
258    }
259
260    /// Set an application-level tool policy.
261    #[cfg(feature = "security")]
262    pub fn with_tool_policy(mut self, policy: ToolPolicy) -> Self {
263        self.tool_policy = Some(policy);
264        self
265    }
266
267    /// Register a notification callback fired when a tool call requires user approval.
268    #[cfg(feature = "security")]
269    pub fn on_approval_required(
270        mut self,
271        cb: impl Fn(Arc<str>, String, String) + Send + Sync + 'static,
272    ) -> Self {
273        self.approval_callback = Some(Arc::new(
274            move |call_id: &str, tool_name: &str, reason: &str, _paths: &[std::path::PathBuf]| {
275                cb(
276                    Arc::from(call_id),
277                    tool_name.to_string(),
278                    reason.to_string(),
279                );
280            },
281        ));
282        self
283    }
284
285    /// Set a timeout for tool call approval.
286    #[cfg(feature = "security")]
287    pub fn with_approval_timeout(mut self, timeout: std::time::Duration) -> Self {
288        self.approval_timeout = Some(timeout);
289        self
290    }
291
292    /// Register all builtin tools (Read, Write, Edit, Shell).
293    #[cfg(feature = "funera-builtin-tools")]
294    pub fn with_builtin_tools(mut self) -> Self {
295        use funera_builtin_tools::{EditTool, ReadTool, ShellTool, WriteTool};
296        self.tools.push(Arc::new(ReadTool));
297        self.tools.push(Arc::new(WriteTool));
298        self.tools.push(Arc::new(EditTool));
299        #[cfg(feature = "sandbox")]
300        if let Some(ref policy) = self.sandbox_policy {
301            self.tools
302                .push(Arc::new(ShellTool::with_sandbox(policy.clone())));
303        } else {
304            self.tools.push(Arc::new(ShellTool::new()));
305        }
306        #[cfg(not(feature = "sandbox"))]
307        self.tools.push(Arc::new(ShellTool::new()));
308        self
309    }
310
311    /// Build the runtime with the default DeepSeek provider.
312    #[cfg(feature = "deepseek")]
313    pub fn build(self) -> Result<AgentRuntime<DeepSeekProvider>, OrchestrateError> {
314        self.build_with::<DeepSeekProvider>()
315    }
316
317    /// Build the runtime with a custom LLM provider.
318    ///
319    /// ```rust,no_run
320    /// # use funera_orchestrate::{AgentRuntime, DeepSeekProvider};
321    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
322    /// let rt = AgentRuntime::<DeepSeekProvider>::builder().api_key(std::env::var("DEEPSEEK_API_KEY")?).model("deepseek-v4-flash").build_with::<DeepSeekProvider>()?;
323    /// # Ok(())
324    /// # }
325    /// ```
326    pub fn build_with<P: ChatProvider>(
327        #[allow(unused_mut)] mut self,
328    ) -> Result<AgentRuntime<P>, OrchestrateError> {
329        #[cfg(feature = "security")]
330        let api_key = {
331            self.secure_api_key
332                .take()
333                .map(|k| k.expose_secret().to_string())
334                .or_else(|| self.api_key.take())
335                .or_else(|| std::env::var("OPENAI_API_KEY").ok())
336        };
337        #[cfg(not(feature = "security"))]
338        let api_key = self
339            .api_key
340            .or_else(|| std::env::var("OPENAI_API_KEY").ok());
341        let model = self
342            .model
343            .or_else(|| std::env::var("OPENAI_MODEL").ok())
344            .unwrap_or_else(|| "gpt-4o".into());
345
346        let client = match self.client {
347            Some(c) => c,
348            None => {
349                let key = api_key.ok_or_else(|| {
350                    OrchestrateError::Config(
351                        "no API key; set OPENAI_API_KEY or call .api_key()".into(),
352                    )
353                })?;
354                let mut cfg = OpenAIConfig::default().with_api_key(key);
355                if let Some(url) = &self.base_url {
356                    cfg = cfg.with_api_base(url);
357                }
358                async_openai::Client::with_config(cfg)
359            }
360        };
361
362        // Sync sandbox policy into tool policy when only sandbox was configured.
363        #[cfg(all(feature = "sandbox", feature = "security"))]
364        {
365            if self.tool_policy.is_none()
366                && let Some(ref sp) = self.sandbox_policy
367            {
368                self.tool_policy = Some(ToolPolicy {
369                    sandbox: sp.clone(),
370                    ..Default::default()
371                });
372            }
373        }
374
375        #[cfg(feature = "security")]
376        let audit_bus = AuditBus::default();
377
378        #[cfg(feature = "tool")]
379        let registry = {
380            #[cfg(feature = "security")]
381            let mut reg = match self.tool_policy {
382                Some(ref policy) => ToolRegistry::new_from_policy(policy.clone()),
383                None => ToolRegistry::new(),
384            };
385            #[cfg(not(feature = "security"))]
386            let mut reg = ToolRegistry::new();
387            for t in self.tools {
388                reg.add_tool(t);
389            }
390
391            #[cfg(feature = "security")]
392            reg.set_audit_bus(audit_bus.clone());
393
394            #[cfg(all(feature = "sandbox", feature = "security"))]
395            if let Some(ref sp) = self.sandbox_policy {
396                if sp.enabled && (!sp.read_paths.is_empty() || !sp.read_write_paths.is_empty()) {
397                    let all_paths: Vec<_> = sp
398                        .read_paths
399                        .iter()
400                        .chain(sp.read_write_paths.iter())
401                        .cloned()
402                        .collect();
403                    if !all_paths.is_empty() {
404                        let path_guard = PathGuard::new(all_paths.iter().map(|p| p.as_path()));
405                        reg.set_path_guard(path_guard);
406                    }
407                }
408                reg.set_sandbox_paths(sp.read_paths.clone(), sp.read_write_paths.clone());
409            }
410
411            #[cfg(feature = "security")]
412            {
413                if let Some(ref cb) = self.approval_callback {
414                    reg.set_approval_callback(cb.clone());
415                }
416                if let Some(dur) = self.approval_timeout {
417                    reg.set_approval_timeout(Some(dur));
418                }
419            }
420
421            reg
422        };
423
424        #[cfg(feature = "skill")]
425        let mut skill_registry = SkillRegistry::new();
426
427        #[cfg(feature = "skill")]
428        {
429            if self.load_default_skills {
430                let default_skills = Skill::from_default_path();
431                for skill in default_skills {
432                    let name = skill.name.clone();
433                    skill_registry.add(skill);
434                    self.skill_names_to_activate.push(name);
435                }
436            }
437            for skill in self.skills {
438                skill_registry.add(skill);
439            }
440            for name in &self.skill_names_to_activate {
441                skill_registry.activate(name);
442            }
443        }
444
445        let (env, env_watcher) = FuneraEnv::new(client, &model);
446
447        #[cfg(feature = "sandbox")]
448        let env = if let Some(ref sp) = self.sandbox_policy {
449            env.with_sandbox_policy(sp.clone())
450        } else {
451            env
452        };
453
454        #[cfg(feature = "tool")]
455        let env = env.with_tool_registry(registry);
456        #[cfg(feature = "skill")]
457        let env = env.with_skill_registry(skill_registry);
458
459        // Build middleware chain
460        #[cfg(feature = "middleware")]
461        let middleware_chain = if let Some(bundle) = self.middleware_bundle.take() {
462            let MiddlewareBundle { chain, error_rx } = bundle;
463            tokio::spawn(async move {
464                let mut rx = error_rx;
465                while let Some((name, err)) = rx.recv().await {
466                    tracing::warn!("[middleware:{name}] inspector error: {err}");
467                }
468            });
469            Arc::new(chain)
470        } else {
471            let (chain, error_rx) = MiddlewareChain::<AgentEvent>::new().activate_error_channel();
472            tokio::spawn(async move {
473                let mut rx = error_rx;
474                while let Some((name, err)) = rx.recv().await {
475                    tracing::warn!("[middleware:{name}] inspector error: {err}");
476                }
477            });
478            Arc::new(chain)
479        };
480
481        // Create tool bus for ToolExecutor
482        #[cfg(feature = "tool")]
483        let (tool_bus, exec_rx) = ToolBus::new();
484
485        #[cfg(feature = "security")]
486        let tool_policy_val = self.tool_policy.clone().unwrap_or_default();
487
488        let max_iters = self.max_iterations;
489        let chan_buf = self.channel_buffer;
490
491        // Build config structs — always pass, no #[cfg] on fn args
492        let tool_cfg = {
493            #[cfg(feature = "tool")]
494            {
495                Some(EnvToolConfig { tool_bus, exec_rx })
496            }
497            #[cfg(not(feature = "tool"))]
498            {
499                None
500            }
501        };
502
503        let sec_cfg = {
504            #[cfg(feature = "security")]
505            {
506                #[cfg(feature = "sandbox")]
507                let sp = self.sandbox_policy.clone().unwrap_or_default();
508
509                Some(EnvSecurityConfig {
510                    #[cfg(feature = "sandbox")]
511                    sandbox_policy: sp,
512                    tool_policy: tool_policy_val,
513                    audit_bus,
514                })
515            }
516            #[cfg(not(feature = "security"))]
517            {
518                None
519            }
520        };
521
522        // Spawn env actor — owns FuneraEnv, watcher, ToolExecutor, all config
523        let env_cmd_tx = spawn_env_actor(env, env_watcher, max_iters, chan_buf, tool_cfg, sec_cfg);
524
525        let session_tx = spawn_session_actor();
526
527        Ok(AgentRuntime::<P> {
528            env_cmd_tx,
529            session_tx,
530            #[cfg(feature = "middleware")]
531            middleware_chain,
532            _state: PhantomData,
533            _phantom: PhantomData,
534        })
535    }
536}
537
538/// A runtime context for executing agent interactions.
539///
540/// Marker type-state: the runtime is available for a `send`/`send_stream` call.
541pub struct Idle;
542
543/// Marker type-state: a `send`/`send_stream` call is in progress.
544pub struct Acquired;
545
546/// Thin wrapper around session and env actors.
547///
548/// `AgentRuntime` owns **no mutable data** — all persistent state lives in
549/// background actors:
550///
551/// - [`EnvActor`](funera_core::env_actor) — owns [`FuneraEnv`](funera_core::env::FuneraEnv)
552///   (model, client, tool/skill registries, sandbox policy), the
553///   [`FuneraEnvWatcher`](funera_core::env::FuneraEnvWatcher) (watch-based
554///   hot-reload), [`ToolBus`](funera_core::event_bus::tool_bus::ToolBus) +
555///   [`ToolExecutor`](funera_core::re_act::tool_executor::ToolExecutor), audit bus,
556///   and env state broadcast channel.
557/// - [`SessionActor`](funera_core::chat::session) — owns `Vec<FuneraMessage>`.
558///
559/// The generic parameter `S` is a type-state marker — [`Idle`] means
560/// no `send`/`send_stream` is in progress, [`Acquired`] means one is active.
561/// Send operations consume `AgentRuntime<P, Idle>` and return a handle that
562/// eventually yields back `AgentRuntime<P, Idle>`.
563///
564/// # Mutation
565///
566/// All env mutations (e.g., [`set_model`](Self::set_model),
567/// [`add_tool`](Self::add_tool)) send an [`EnvCmd`](funera_core::env_actor::EnvCmd)
568/// to the actor, which atomically updates the internal state, pushes to the
569/// watch channel (picked up by the ReAct loop on the next iteration), and
570/// broadcasts an [`EnvStateEvent`](funera_core::event_bus::env_state_bus::EnvStateEvent).
571///
572/// # Hot-Reload
573///
574/// The ReAct loop calls [`get_react_config`](Self::get_react_config) once per
575/// `fire`/`send` call to obtain a [`ReActConfig`](funera_core::env_actor::ReActConfig)
576/// bundle containing the [`FuneraEnvWatcher`]. Every iteration, the watcher
577/// snapshots the latest model, client, tools, and skills from the watch
578/// channels — enabling zero-coordination runtime changes.
579pub struct AgentRuntime<P: ChatProvider, S = Idle> {
580    pub(crate) env_cmd_tx: mpsc::UnboundedSender<EnvCmd>,
581    pub(crate) session_tx: mpsc::UnboundedSender<SessionCmd>,
582    #[cfg(feature = "middleware")]
583    pub(crate) middleware_chain: Arc<MiddlewareChain<AgentEvent, ErrorsEnabled>>,
584    _state: PhantomData<S>,
585    _phantom: PhantomData<fn() -> P>,
586}
587
588// ── All state markers share these methods ─────────────────────
589
590impl<P: ChatProvider, S> AgentRuntime<P, S> {
591    /// Create a new builder.
592    pub fn builder() -> AgentRuntimeBuilder {
593        AgentRuntimeBuilder::new()
594    }
595
596    /// Reset the conversation session (clear message history).
597    pub fn reset(&self) {
598        let _ = self.session_tx.send(SessionCmd::Clear);
599    }
600
601    /// Access the session control channel.
602    pub fn session_tx(&self) -> mpsc::UnboundedSender<SessionCmd> {
603        self.session_tx.clone()
604    }
605
606    /// Subscribe to runtime-level environment state events.
607    pub async fn subscribe_env_state(&self) -> broadcast::Receiver<EnvStateEvent> {
608        let (respond, rx) = tokio::sync::oneshot::channel();
609        let _ = self.env_cmd_tx.send(EnvCmd::SubscribeEnvState { respond });
610        rx.await
611            .unwrap_or_else(|_| broadcast::channel::<EnvStateEvent>(1).1)
612    }
613
614    /// Subscribe to security audit events.
615    #[cfg(feature = "security")]
616    pub async fn subscribe_audit(&self) -> broadcast::Receiver<AuditEvent> {
617        let (respond, rx) = tokio::sync::oneshot::channel();
618        let _ = self.env_cmd_tx.send(EnvCmd::SubscribeAudit { respond });
619        rx.await
620            .unwrap_or_else(|_| broadcast::channel::<AuditEvent>(1).1)
621    }
622
623    /// Query the current LLM model name from the env actor.
624    pub async fn model(&self) -> String {
625        let (respond, rx) = tokio::sync::oneshot::channel();
626        let _ = self.env_cmd_tx.send(EnvCmd::GetModel { respond });
627        rx.await.unwrap_or_default()
628    }
629
630    /// Query the current sandbox policy from the env actor.
631    #[cfg(feature = "sandbox")]
632    pub async fn sandbox_policy(&self) -> SandboxPolicy {
633        let (respond, rx) = tokio::sync::oneshot::channel();
634        let _ = self.env_cmd_tx.send(EnvCmd::GetSandboxPolicy { respond });
635        rx.await.unwrap_or_default()
636    }
637
638    /// List registered tool names from the env actor.
639    #[cfg(feature = "tool")]
640    pub async fn tool_names(&self) -> Vec<String> {
641        let (respond, rx) = tokio::sync::oneshot::channel();
642        let _ = self.env_cmd_tx.send(EnvCmd::GetToolNames { respond });
643        rx.await.unwrap_or_default()
644    }
645
646    // ── Env mutation proxy methods ────────────────────────────
647
648    /// Change the LLM model name at runtime.
649    ///
650    /// Pushes to the internal watch channel (picked up by the ReAct loop on
651    /// the next iteration) and broadcasts [`EnvStateEvent::LlmChanged`].
652    pub fn set_model(&self, model: impl Into<String>) {
653        let _ = self.env_cmd_tx.send(EnvCmd::SetModel(model.into()));
654    }
655
656    /// Change the LLM client at runtime (endpoint, key, etc.).
657    pub fn set_client(&self, client: async_openai::Client<OpenAIConfig>) {
658        let _ = self.env_cmd_tx.send(EnvCmd::SetClient(client));
659    }
660
661    /// Register a new tool at runtime.
662    #[cfg(feature = "tool")]
663    pub fn add_tool(&self, tool: Arc<dyn Tool>) {
664        let _ = self.env_cmd_tx.send(EnvCmd::AddTool(tool));
665    }
666
667    /// Remove a tool by name at runtime.
668    #[cfg(feature = "tool")]
669    pub fn remove_tool(&self, name: impl Into<String>) {
670        let _ = self.env_cmd_tx.send(EnvCmd::RemoveTool(name.into()));
671    }
672
673    /// Set tool availability (enabled/disabled) at runtime.
674    #[cfg(feature = "tool")]
675    pub fn set_tool_availability(&self, name: impl Into<String>, available: bool) {
676        let _ = self.env_cmd_tx.send(EnvCmd::SetToolAvailability {
677            name: name.into(),
678            available,
679        });
680    }
681
682    /// Register a new skill at runtime.
683    #[cfg(feature = "skill")]
684    pub fn add_skill(&self, skill: Skill) {
685        let _ = self.env_cmd_tx.send(EnvCmd::AddSkill(skill));
686    }
687
688    /// Remove a skill by name at runtime.
689    #[cfg(feature = "skill")]
690    pub fn remove_skill(&self, name: impl Into<String>) {
691        let _ = self.env_cmd_tx.send(EnvCmd::RemoveSkill(name.into()));
692    }
693
694    /// Activate a skill by name at runtime. Returns `true` on success.
695    #[cfg(feature = "skill")]
696    pub async fn activate_skill(&self, name: impl Into<String>) -> bool {
697        let (respond, rx) = tokio::sync::oneshot::channel();
698        let _ = self.env_cmd_tx.send(EnvCmd::ActivateSkill {
699            name: name.into(),
700            respond,
701        });
702        rx.await.unwrap_or(false)
703    }
704
705    /// Deactivate a skill by name at runtime. Returns `true` on success.
706    #[cfg(feature = "skill")]
707    pub async fn deactivate_skill(&self, name: impl Into<String>) -> bool {
708        let (respond, rx) = tokio::sync::oneshot::channel();
709        let _ = self.env_cmd_tx.send(EnvCmd::DeactivateSkill {
710            name: name.into(),
711            respond,
712        });
713        rx.await.unwrap_or(false)
714    }
715
716    /// Set the skill system prompt at runtime.
717    #[cfg(feature = "skill")]
718    pub fn set_skill_prompt(&self, prompt: impl Into<String>) {
719        let _ = self.env_cmd_tx.send(EnvCmd::SetSkillPrompt(prompt.into()));
720    }
721
722    /// Get the current skill system prompt.
723    #[cfg(feature = "skill")]
724    pub async fn skill_prompt(&self) -> String {
725        let (respond, rx) = tokio::sync::oneshot::channel();
726        let _ = self.env_cmd_tx.send(EnvCmd::GetSkillPrompt { respond });
727        rx.await.unwrap_or_default()
728    }
729
730    // ── End env mutation methods ──────────────────────────────
731
732    /// Access the middleware chain for event filtering.
733    #[cfg(feature = "middleware")]
734    pub fn middleware_chain(&self) -> Arc<MiddlewareChain<AgentEvent, ErrorsEnabled>> {
735        self.middleware_chain.clone()
736    }
737
738    /// Approve or reject a pending tool call that is awaiting user approval.
739    #[cfg(all(feature = "tool", feature = "security"))]
740    pub async fn approve_tool_call(&self, call_id: &str, approved: bool) -> Result<(), String> {
741        let (respond, rx) = tokio::sync::oneshot::channel();
742        let _ = self.env_cmd_tx.send(EnvCmd::ApproveToolCall {
743            call_id: call_id.to_string(),
744            approved,
745            respond,
746        });
747        rx.await.unwrap_or(Err("env actor died".into()))
748    }
749
750    /// Returns a cloneable [`ApprovalHandle`](crate::send_handle::ApprovalHandle)
751    /// for approving tool calls from spawned tasks.
752    ///
753    /// Obtain this **before** passing the runtime to
754    /// [`send`](crate::Agent::send) /
755    /// [`send_stream`](crate::Agent::send_stream), which consume it.
756    ///
757    /// # Example
758    ///
759    /// ```rust,no_run
760    /// # use funera_orchestrate::{Agent, AgentRuntime, DeepSeekProvider};
761    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
762    /// # let rt = AgentRuntime::<DeepSeekProvider>::builder()
763    /// #     .api_key("sk-test").model("x").build()?;
764    /// # let agent = Agent::builder().build();
765    /// let approver = rt.approval_handle();
766    /// // spawn background task to approve tool calls ...
767    /// let (_rt, _resp) = agent.send("hello", rt).await?.await?;
768    /// # Ok(())
769    /// # }
770    /// ```
771    #[cfg(all(feature = "tool", feature = "security"))]
772    pub fn approval_handle(&self) -> crate::send_handle::ApprovalHandle {
773        crate::send_handle::ApprovalHandle::new(self.env_cmd_tx.clone())
774    }
775
776    /// Transform the runtime into `Acquired` state (internal use).
777    pub(crate) fn into_acquired(self) -> AgentRuntime<P, Acquired> {
778        AgentRuntime::<P, Acquired> {
779            env_cmd_tx: self.env_cmd_tx,
780            session_tx: self.session_tx,
781            #[cfg(feature = "middleware")]
782            middleware_chain: self.middleware_chain,
783            _state: PhantomData,
784            _phantom: PhantomData,
785        }
786    }
787
788    /// Query the env actor for the bundle of resources needed by the ReAct loop.
789    pub(crate) async fn get_react_config(&self) -> ReActConfig {
790        let (respond, rx) = tokio::sync::oneshot::channel();
791        let _ = self.env_cmd_tx.send(EnvCmd::GetReActConfig { respond });
792        rx.await.expect("env actor died")
793    }
794}
795
796// ── Acquired → Idle ─────────────────────────────────────────
797
798impl<P: ChatProvider> AgentRuntime<P, Acquired> {
799    pub(crate) fn into_idle(self) -> AgentRuntime<P, Idle> {
800        AgentRuntime::<P, Idle> {
801            env_cmd_tx: self.env_cmd_tx,
802            session_tx: self.session_tx,
803            #[cfg(feature = "middleware")]
804            middleware_chain: self.middleware_chain,
805            _state: PhantomData,
806            _phantom: PhantomData,
807        }
808    }
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use funera_core::chat::message::{FuneraMessage, MsgVariant, Role, TextMessage};
815
816    // ── builder defaults ───────────────────────────────────────────
817
818    #[test]
819    fn builder_defaults() {
820        let b = AgentRuntimeBuilder::new();
821        assert_eq!(b.max_iterations, 10);
822        assert_eq!(b.channel_buffer, 32);
823        assert!(b.api_key.is_none());
824        assert!(b.model.is_none());
825    }
826
827    #[cfg(feature = "tool")]
828    mod tool_tests {
829        use super::*;
830        use funera_core::re_act::tool::ToolCallError;
831
832        #[derive(Default)]
833        struct MockTool;
834
835        #[async_trait::async_trait]
836        impl Tool for MockTool {
837            fn name(&self) -> &str {
838                "mock_tool"
839            }
840            fn description(&self) -> &str {
841                "A mock tool for testing"
842            }
843            fn schema(&self) -> serde_json::Value {
844                serde_json::json!({})
845            }
846            async fn execute(&self, _args: serde_json::Value) -> Result<String, ToolCallError> {
847                Ok("ok".into())
848            }
849        }
850
851        #[test]
852        fn builder_defaults_tools_empty() {
853            let b = AgentRuntimeBuilder::new();
854            assert!(b.tools.is_empty());
855        }
856
857        #[test]
858        fn builder_with_tool_instance() {
859            let b = AgentRuntimeBuilder::new().with_tool_instance(Arc::new(MockTool));
860            assert_eq!(b.tools.len(), 1);
861        }
862
863        #[cfg(feature = "funera-builtin-tools")]
864        #[test]
865        fn builder_with_builtin_tools_registers_defaults() {
866            let b = AgentRuntimeBuilder::new().with_builtin_tools();
867            assert_eq!(b.tools.len(), 4);
868            for name in ["read", "write", "edit", "shell"] {
869                assert!(b.tools.iter().any(|t| t.name() == name), "missing {name}");
870            }
871        }
872
873        #[tokio::test]
874        async fn build_with_tool_adds_to_registry() {
875            let rt = AgentRuntimeBuilder::new()
876                .api_key("sk-test")
877                .model("x")
878                .with_tool::<MockTool>()
879                .build()
880                .unwrap();
881            let names = rt.tool_names().await;
882            assert!(names.contains(&"mock_tool".to_string()));
883        }
884
885        #[tokio::test]
886        async fn tool_names_empty_by_default() {
887            let rt = AgentRuntimeBuilder::new()
888                .api_key("sk-test")
889                .model("x")
890                .build()
891                .unwrap();
892            let names = rt.tool_names().await;
893            assert!(names.is_empty());
894        }
895    }
896
897    #[cfg(feature = "skill")]
898    mod skill_tests {
899        use super::*;
900
901        #[test]
902        fn builder_with_skill_inline() {
903            let b = AgentRuntimeBuilder::new().with_skill("s1", "desc", "content");
904            assert_eq!(b.skills.len(), 1);
905            assert_eq!(b.skills[0].name, "s1");
906            assert_eq!(b.skills[0].description, "desc");
907            assert_eq!(b.skills[0].content, "content");
908        }
909
910        #[test]
911        fn builder_with_skill_active_adds_to_list() {
912            let b = AgentRuntimeBuilder::new()
913                .with_skill_active("s1")
914                .with_skill_active("s2");
915            assert_eq!(b.skill_names_to_activate, vec!["s1", "s2"]);
916        }
917
918        #[test]
919        fn builder_with_skills_default_path_sets_flag() {
920            let b = AgentRuntimeBuilder::new().with_skills_default_path();
921            assert!(b.load_default_skills);
922        }
923
924        #[test]
925        fn builder_skills_combined() {
926            let b = AgentRuntimeBuilder::new()
927                .with_skill("a", "", "aaa")
928                .with_skill("b", "", "bbb")
929                .with_skill_active("a");
930            assert_eq!(b.skills.len(), 2);
931            assert_eq!(b.skill_names_to_activate, vec!["a"]);
932        }
933    }
934
935    #[test]
936    fn builder_set_max_iterations() {
937        let b = AgentRuntimeBuilder::new().max_iterations(20);
938        assert_eq!(b.max_iterations, 20);
939    }
940
941    #[test]
942    fn builder_set_channel_buffer() {
943        let b = AgentRuntimeBuilder::new().channel_buffer(64);
944        assert_eq!(b.channel_buffer, 64);
945    }
946
947    #[test]
948    fn builder_set_model() {
949        let b = AgentRuntimeBuilder::new().model("test-model");
950        assert_eq!(b.model, Some("test-model".into()));
951    }
952
953    #[test]
954    fn builder_set_api_key() {
955        let b = AgentRuntimeBuilder::new().api_key("sk-test");
956        assert_eq!(b.api_key, Some("sk-test".into()));
957    }
958
959    #[test]
960    fn builder_set_base_url() {
961        let b = AgentRuntimeBuilder::new().base_url(Some("https://example.com".into()));
962        assert_eq!(b.base_url, Some("https://example.com".into()));
963    }
964
965    #[test]
966    fn builder_set_base_url_none_noop() {
967        let b = AgentRuntimeBuilder::new().base_url(None);
968        assert!(b.base_url.is_none());
969    }
970
971    #[test]
972    fn builder_set_client() {
973        let cfg = async_openai::config::OpenAIConfig::default();
974        let client = async_openai::Client::with_config(cfg);
975        let b = AgentRuntimeBuilder::new().client(client);
976        assert!(b.client.is_some());
977    }
978
979    // ── build ──────────────────────────────────────────────────────
980
981    #[tokio::test]
982    async fn build_with_explicit_key() {
983        let rt = AgentRuntimeBuilder::new()
984            .api_key("sk-test")
985            .model("test-model")
986            .build()
987            .expect("build should succeed with api_key");
988        assert_eq!(rt.model().await, "test-model");
989    }
990
991    #[tokio::test]
992    async fn build_custom_params() {
993        let rt = AgentRuntimeBuilder::new()
994            .api_key("sk-test")
995            .model("my-model")
996            .max_iterations(15)
997            .channel_buffer(8)
998            .build()
999            .unwrap();
1000        assert_eq!(rt.model().await, "my-model");
1001    }
1002
1003    #[tokio::test]
1004    async fn build_fails_without_key() {
1005        let has_key = std::env::var("OPENAI_API_KEY").is_ok();
1006        if has_key {
1007            return;
1008        }
1009        let result = AgentRuntimeBuilder::new().model("x").build();
1010        assert!(matches!(result, Err(OrchestrateError::Config(_))));
1011    }
1012
1013    #[tokio::test]
1014    async fn build_model_fallback_default() {
1015        let has_model = std::env::var("OPENAI_MODEL").is_ok();
1016        if has_model {
1017            return;
1018        }
1019        let rt = AgentRuntimeBuilder::new()
1020            .api_key("sk-test")
1021            .build()
1022            .unwrap();
1023        assert_eq!(rt.model().await, "gpt-4o");
1024    }
1025
1026    // ── session management ─────────────────────────────────────────
1027
1028    #[tokio::test]
1029    async fn session_actor_is_alive() {
1030        let rt = AgentRuntimeBuilder::new()
1031            .api_key("sk-test")
1032            .model("x")
1033            .build()
1034            .unwrap();
1035        let tx = rt.session_tx();
1036        assert!(tx.send(SessionCmd::Clear).is_ok());
1037    }
1038
1039    #[tokio::test]
1040    async fn session_context_works_immediately() {
1041        let rt = AgentRuntimeBuilder::new()
1042            .api_key("sk-test")
1043            .model("x")
1044            .build()
1045            .unwrap();
1046        let ctx = FuneraSession::new(rt.session_tx()).session_context().await;
1047        assert!(ctx.is_empty());
1048    }
1049
1050    #[tokio::test]
1051    async fn reset_clears_messages() {
1052        let rt = AgentRuntimeBuilder::new()
1053            .api_key("sk-test")
1054            .model("x")
1055            .build()
1056            .unwrap();
1057        let session = FuneraSession::new(rt.session_tx());
1058        session.push_message(FuneraMessage::new(
1059            Role::User,
1060            MsgVariant::Text(TextMessage {
1061                text: "hi".into(),
1062                reasoning_content: None,
1063            }),
1064        ));
1065        let ctx_before = session.session_context().await;
1066        assert_eq!(ctx_before.len(), 1);
1067
1068        rt.reset();
1069
1070        let ctx_after = session.session_context().await;
1071        assert_eq!(ctx_after.len(), 0);
1072    }
1073
1074    #[tokio::test]
1075    async fn subscribe_env_state_works() {
1076        let rt = AgentRuntimeBuilder::new()
1077            .api_key("sk-test")
1078            .model("x")
1079            .build()
1080            .unwrap();
1081        let mut rx = rt.subscribe_env_state().await;
1082        rt.set_model("new-model");
1083        let got = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()).await;
1084        assert!(matches!(
1085            got,
1086            Ok(Ok(EnvStateEvent::LlmChanged(m))) if m == "new-model"
1087        ));
1088    }
1089
1090    // ── sandbox integration tests ───────────────────────────────────
1091
1092    #[cfg(feature = "sandbox")]
1093    #[tokio::test]
1094    async fn builder_sandbox_policy_flows_to_env() {
1095        let custom_policy = SandboxPolicy {
1096            read_write_paths: vec!["/project".into()],
1097            block_network: true,
1098            ..Default::default()
1099        };
1100
1101        let rt = AgentRuntimeBuilder::new()
1102            .api_key("sk-test")
1103            .model("x")
1104            .with_sandbox_policy(custom_policy.clone())
1105            .build()
1106            .unwrap();
1107
1108        let stored = rt.sandbox_policy().await;
1109        assert_eq!(stored.read_write_paths, custom_policy.read_write_paths);
1110        assert_eq!(stored.block_network, custom_policy.block_network);
1111        assert!(stored.enabled);
1112    }
1113
1114    #[cfg(feature = "sandbox")]
1115    #[tokio::test]
1116    async fn builder_no_sandbox_uses_default() {
1117        let rt = AgentRuntimeBuilder::new()
1118            .api_key("sk-test")
1119            .model("x")
1120            .build()
1121            .unwrap();
1122        let stored = rt.sandbox_policy().await;
1123        assert!(stored.enabled);
1124        assert!(stored.block_network);
1125        assert!(stored.read_paths.is_empty());
1126        assert!(stored.read_write_paths.is_empty());
1127        assert!(stored.execute_paths.is_empty());
1128    }
1129
1130    #[cfg(feature = "sandbox")]
1131    #[tokio::test]
1132    async fn builder_sandbox_with_custom_environments() {
1133        let rt = AgentRuntimeBuilder::new()
1134            .api_key("sk-test")
1135            .model("x")
1136            .with_sandbox_policy(SandboxPolicy::disabled())
1137            .build()
1138            .unwrap();
1139
1140        let stored = rt.sandbox_policy().await;
1141        assert!(!stored.enabled, "disabled policy should stay disabled");
1142    }
1143
1144    // ── approval_handle tests ───────────────────────────────────────
1145
1146    #[cfg(all(feature = "tool", feature = "security"))]
1147    mod approval_handle_tests {
1148        use super::*;
1149        use crate::send_handle::ApprovalHandle;
1150
1151        #[tokio::test]
1152        async fn runtime_provides_approval_handle() {
1153            let rt = AgentRuntimeBuilder::new()
1154                .api_key("sk-test")
1155                .model("x")
1156                .build()
1157                .unwrap();
1158
1159            let handle = rt.approval_handle();
1160            // Compile-time check: approval_handle() returns an ApprovalHandle.
1161            let _: ApprovalHandle = handle;
1162        }
1163
1164        #[tokio::test]
1165        async fn approval_handle_is_cloneable() {
1166            let rt = AgentRuntimeBuilder::new()
1167                .api_key("sk-test")
1168                .model("x")
1169                .build()
1170                .unwrap();
1171
1172            let h1 = rt.approval_handle();
1173            let h2 = h1.clone();
1174            let h3 = h2.clone();
1175
1176            // Multiple independent clones share the same underlying channel.
1177            drop(h1);
1178            drop(h2);
1179            drop(h3);
1180        }
1181
1182        #[tokio::test]
1183        async fn approval_handle_survives_runtime_drop() {
1184            let handle = {
1185                let rt = AgentRuntimeBuilder::new()
1186                    .api_key("sk-test")
1187                    .model("x")
1188                    .build()
1189                    .unwrap();
1190                rt.approval_handle()
1191            };
1192            // The handle owns its own clone of env_cmd_tx — it stays valid
1193            // even after the original runtime is dropped.
1194            drop(handle);
1195        }
1196
1197        #[test]
1198        fn approval_handle_implements_send_sync() {
1199            // ApprovalHandle must be Send + Sync for use in spawned tasks.
1200            fn assert_send_sync<T: Send + Sync>() {}
1201            assert_send_sync::<ApprovalHandle>();
1202        }
1203
1204        #[tokio::test]
1205        async fn approve_nonexistent_call_id_returns_error() {
1206            let rt = AgentRuntimeBuilder::new()
1207                .api_key("sk-test")
1208                .model("x")
1209                .build()
1210                .unwrap();
1211
1212            let handle = rt.approval_handle();
1213            let result = handle.approve_tool_call("nonexistent", true).await;
1214            assert!(result.is_err());
1215        }
1216
1217        #[tokio::test]
1218        async fn reject_nonexistent_call_id_returns_error() {
1219            let rt = AgentRuntimeBuilder::new()
1220                .api_key("sk-test")
1221                .model("x")
1222                .build()
1223                .unwrap();
1224
1225            let handle = rt.approval_handle();
1226            let result = handle.approve_tool_call("nonexistent", false).await;
1227            assert!(result.is_err());
1228        }
1229
1230        #[tokio::test]
1231        async fn cloned_handles_approve_independently() {
1232            let rt = AgentRuntimeBuilder::new()
1233                .api_key("sk-test")
1234                .model("x")
1235                .build()
1236                .unwrap();
1237
1238            let h1 = rt.approval_handle();
1239            let h2 = h1.clone();
1240
1241            // Both clones can call approve independently.
1242            let r1 = h1.approve_tool_call("a", true).await;
1243            let r2 = h2.approve_tool_call("b", false).await;
1244
1245            assert!(r1.is_err()); // "a" not in pending_approvals
1246            assert!(r2.is_err()); // "b" not in pending_approvals
1247        }
1248    }
1249}