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"))]
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(any(feature = "sandbox", 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 = "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<Box<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(Box::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: Box<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(Box::new(ReadTool));
297        self.tools.push(Box::new(WriteTool));
298        self.tools.push(Box::new(EditTool));
299        #[cfg(feature = "sandbox")]
300        if let Some(ref policy) = self.sandbox_policy {
301            self.tools
302                .push(Box::new(ShellTool::with_sandbox(policy.clone())));
303        } else {
304            self.tools.push(Box::new(ShellTool::new()));
305        }
306        #[cfg(not(feature = "sandbox"))]
307        self.tools.push(Box::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: Box<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    /// Transform the runtime into `Acquired` state (internal use).
751    pub(crate) fn into_acquired(self) -> AgentRuntime<P, Acquired> {
752        AgentRuntime::<P, Acquired> {
753            env_cmd_tx: self.env_cmd_tx,
754            session_tx: self.session_tx,
755            #[cfg(feature = "middleware")]
756            middleware_chain: self.middleware_chain,
757            _state: PhantomData,
758            _phantom: PhantomData,
759        }
760    }
761
762    /// Query the env actor for the bundle of resources needed by the ReAct loop.
763    pub(crate) async fn get_react_config(&self) -> ReActConfig {
764        let (respond, rx) = tokio::sync::oneshot::channel();
765        let _ = self.env_cmd_tx.send(EnvCmd::GetReActConfig { respond });
766        rx.await.expect("env actor died")
767    }
768}
769
770// ── Acquired → Idle ─────────────────────────────────────────
771
772impl<P: ChatProvider> AgentRuntime<P, Acquired> {
773    pub(crate) fn into_idle(self) -> AgentRuntime<P, Idle> {
774        AgentRuntime::<P, Idle> {
775            env_cmd_tx: self.env_cmd_tx,
776            session_tx: self.session_tx,
777            #[cfg(feature = "middleware")]
778            middleware_chain: self.middleware_chain,
779            _state: PhantomData,
780            _phantom: PhantomData,
781        }
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use funera_core::chat::message::{FuneraMessage, MsgVariant, Role, TextMessage};
789
790    // ── builder defaults ───────────────────────────────────────────
791
792    #[test]
793    fn builder_defaults() {
794        let b = AgentRuntimeBuilder::new();
795        assert_eq!(b.max_iterations, 10);
796        assert_eq!(b.channel_buffer, 32);
797        assert!(b.api_key.is_none());
798        assert!(b.model.is_none());
799    }
800
801    #[cfg(feature = "tool")]
802    mod tool_tests {
803        use super::*;
804        use funera_core::re_act::tool::ToolCallError;
805
806        #[derive(Default)]
807        struct MockTool;
808
809        #[async_trait::async_trait]
810        impl Tool for MockTool {
811            fn name(&self) -> &str {
812                "mock_tool"
813            }
814            fn description(&self) -> &str {
815                "A mock tool for testing"
816            }
817            fn schema(&self) -> serde_json::Value {
818                serde_json::json!({})
819            }
820            async fn execute(&self, _args: serde_json::Value) -> Result<String, ToolCallError> {
821                Ok("ok".into())
822            }
823        }
824
825        #[test]
826        fn builder_defaults_tools_empty() {
827            let b = AgentRuntimeBuilder::new();
828            assert!(b.tools.is_empty());
829        }
830
831        #[test]
832        fn builder_with_tool_instance() {
833            let b = AgentRuntimeBuilder::new().with_tool_instance(Box::new(MockTool));
834            assert_eq!(b.tools.len(), 1);
835        }
836
837        #[tokio::test]
838        async fn build_with_tool_adds_to_registry() {
839            let rt = AgentRuntimeBuilder::new()
840                .api_key("sk-test")
841                .model("x")
842                .with_tool::<MockTool>()
843                .build()
844                .unwrap();
845            let names = rt.tool_names().await;
846            assert!(names.contains(&"mock_tool".to_string()));
847        }
848
849        #[tokio::test]
850        async fn tool_names_empty_by_default() {
851            let rt = AgentRuntimeBuilder::new()
852                .api_key("sk-test")
853                .model("x")
854                .build()
855                .unwrap();
856            let names = rt.tool_names().await;
857            assert!(names.is_empty());
858        }
859    }
860
861    #[cfg(feature = "skill")]
862    mod skill_tests {
863        use super::*;
864
865        #[test]
866        fn builder_with_skill_inline() {
867            let b = AgentRuntimeBuilder::new().with_skill("s1", "desc", "content");
868            assert_eq!(b.skills.len(), 1);
869            assert_eq!(b.skills[0].name, "s1");
870            assert_eq!(b.skills[0].description, "desc");
871            assert_eq!(b.skills[0].content, "content");
872        }
873
874        #[test]
875        fn builder_with_skill_active_adds_to_list() {
876            let b = AgentRuntimeBuilder::new()
877                .with_skill_active("s1")
878                .with_skill_active("s2");
879            assert_eq!(b.skill_names_to_activate, vec!["s1", "s2"]);
880        }
881
882        #[test]
883        fn builder_with_skills_default_path_sets_flag() {
884            let b = AgentRuntimeBuilder::new().with_skills_default_path();
885            assert!(b.load_default_skills);
886        }
887
888        #[test]
889        fn builder_skills_combined() {
890            let b = AgentRuntimeBuilder::new()
891                .with_skill("a", "", "aaa")
892                .with_skill("b", "", "bbb")
893                .with_skill_active("a");
894            assert_eq!(b.skills.len(), 2);
895            assert_eq!(b.skill_names_to_activate, vec!["a"]);
896        }
897    }
898
899    #[test]
900    fn builder_set_max_iterations() {
901        let b = AgentRuntimeBuilder::new().max_iterations(20);
902        assert_eq!(b.max_iterations, 20);
903    }
904
905    #[test]
906    fn builder_set_channel_buffer() {
907        let b = AgentRuntimeBuilder::new().channel_buffer(64);
908        assert_eq!(b.channel_buffer, 64);
909    }
910
911    #[test]
912    fn builder_set_model() {
913        let b = AgentRuntimeBuilder::new().model("test-model");
914        assert_eq!(b.model, Some("test-model".into()));
915    }
916
917    #[test]
918    fn builder_set_api_key() {
919        let b = AgentRuntimeBuilder::new().api_key("sk-test");
920        assert_eq!(b.api_key, Some("sk-test".into()));
921    }
922
923    #[test]
924    fn builder_set_base_url() {
925        let b = AgentRuntimeBuilder::new().base_url(Some("https://example.com".into()));
926        assert_eq!(b.base_url, Some("https://example.com".into()));
927    }
928
929    #[test]
930    fn builder_set_base_url_none_noop() {
931        let b = AgentRuntimeBuilder::new().base_url(None);
932        assert!(b.base_url.is_none());
933    }
934
935    #[test]
936    fn builder_set_client() {
937        let cfg = async_openai::config::OpenAIConfig::default();
938        let client = async_openai::Client::with_config(cfg);
939        let b = AgentRuntimeBuilder::new().client(client);
940        assert!(b.client.is_some());
941    }
942
943    // ── build ──────────────────────────────────────────────────────
944
945    #[tokio::test]
946    async fn build_with_explicit_key() {
947        let rt = AgentRuntimeBuilder::new()
948            .api_key("sk-test")
949            .model("test-model")
950            .build()
951            .expect("build should succeed with api_key");
952        assert_eq!(rt.model().await, "test-model");
953    }
954
955    #[tokio::test]
956    async fn build_custom_params() {
957        let rt = AgentRuntimeBuilder::new()
958            .api_key("sk-test")
959            .model("my-model")
960            .max_iterations(15)
961            .channel_buffer(8)
962            .build()
963            .unwrap();
964        assert_eq!(rt.model().await, "my-model");
965    }
966
967    #[tokio::test]
968    async fn build_fails_without_key() {
969        let has_key = std::env::var("OPENAI_API_KEY").is_ok();
970        if has_key {
971            return;
972        }
973        let result = AgentRuntimeBuilder::new().model("x").build();
974        assert!(matches!(result, Err(OrchestrateError::Config(_))));
975    }
976
977    #[tokio::test]
978    async fn build_model_fallback_default() {
979        let has_model = std::env::var("OPENAI_MODEL").is_ok();
980        if has_model {
981            return;
982        }
983        let rt = AgentRuntimeBuilder::new()
984            .api_key("sk-test")
985            .build()
986            .unwrap();
987        assert_eq!(rt.model().await, "gpt-4o");
988    }
989
990    // ── session management ─────────────────────────────────────────
991
992    #[tokio::test]
993    async fn session_actor_is_alive() {
994        let rt = AgentRuntimeBuilder::new()
995            .api_key("sk-test")
996            .model("x")
997            .build()
998            .unwrap();
999        let tx = rt.session_tx();
1000        assert!(tx.send(SessionCmd::Clear).is_ok());
1001    }
1002
1003    #[tokio::test]
1004    async fn session_context_works_immediately() {
1005        let rt = AgentRuntimeBuilder::new()
1006            .api_key("sk-test")
1007            .model("x")
1008            .build()
1009            .unwrap();
1010        let ctx = FuneraSession::new(rt.session_tx()).session_context().await;
1011        assert!(ctx.is_empty());
1012    }
1013
1014    #[tokio::test]
1015    async fn reset_clears_messages() {
1016        let rt = AgentRuntimeBuilder::new()
1017            .api_key("sk-test")
1018            .model("x")
1019            .build()
1020            .unwrap();
1021        let session = FuneraSession::new(rt.session_tx());
1022        session.push_message(FuneraMessage::new(
1023            Role::User,
1024            MsgVariant::Text(TextMessage {
1025                text: "hi".into(),
1026                reasoning_content: None,
1027            }),
1028        ));
1029        let ctx_before = session.session_context().await;
1030        assert_eq!(ctx_before.len(), 1);
1031
1032        rt.reset();
1033
1034        let ctx_after = session.session_context().await;
1035        assert_eq!(ctx_after.len(), 0);
1036    }
1037
1038    #[tokio::test]
1039    async fn subscribe_env_state_works() {
1040        let rt = AgentRuntimeBuilder::new()
1041            .api_key("sk-test")
1042            .model("x")
1043            .build()
1044            .unwrap();
1045        let mut rx = rt.subscribe_env_state().await;
1046        rt.set_model("new-model");
1047        let got = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()).await;
1048        assert!(matches!(
1049            got,
1050            Ok(Ok(EnvStateEvent::LlmChanged(m))) if m == "new-model"
1051        ));
1052    }
1053
1054    // ── sandbox integration tests ───────────────────────────────────
1055
1056    #[cfg(feature = "sandbox")]
1057    #[tokio::test]
1058    async fn builder_sandbox_policy_flows_to_env() {
1059        let custom_policy = SandboxPolicy {
1060            read_write_paths: vec!["/project".into()],
1061            block_network: true,
1062            ..Default::default()
1063        };
1064
1065        let rt = AgentRuntimeBuilder::new()
1066            .api_key("sk-test")
1067            .model("x")
1068            .with_sandbox_policy(custom_policy.clone())
1069            .build()
1070            .unwrap();
1071
1072        let stored = rt.sandbox_policy().await;
1073        assert_eq!(stored.read_write_paths, custom_policy.read_write_paths);
1074        assert_eq!(stored.block_network, custom_policy.block_network);
1075        assert!(stored.enabled);
1076    }
1077
1078    #[cfg(feature = "sandbox")]
1079    #[tokio::test]
1080    async fn builder_no_sandbox_uses_default() {
1081        let rt = AgentRuntimeBuilder::new()
1082            .api_key("sk-test")
1083            .model("x")
1084            .build()
1085            .unwrap();
1086        let stored = rt.sandbox_policy().await;
1087        assert!(stored.enabled);
1088        assert!(stored.block_network);
1089        assert!(stored.read_paths.is_empty());
1090        assert!(stored.read_write_paths.is_empty());
1091        assert!(stored.execute_paths.is_empty());
1092    }
1093
1094    #[cfg(feature = "sandbox")]
1095    #[tokio::test]
1096    async fn builder_sandbox_with_custom_environments() {
1097        let rt = AgentRuntimeBuilder::new()
1098            .api_key("sk-test")
1099            .model("x")
1100            .with_sandbox_policy(SandboxPolicy::disabled())
1101            .build()
1102            .unwrap();
1103
1104        let stored = rt.sandbox_policy().await;
1105        assert!(!stored.enabled, "disabled policy should stay disabled");
1106    }
1107}