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
51pub 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 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 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 pub fn model(mut self, model: impl Into<String>) -> Self {
152 self.model = Some(model.into());
153 self
154 }
155
156 pub fn client(mut self, client: async_openai::Client<OpenAIConfig>) -> Self {
158 self.client = Some(client);
159 self
160 }
161
162 pub fn max_iterations(mut self, n: usize) -> Self {
164 self.max_iterations = n;
165 self
166 }
167
168 pub fn channel_buffer(mut self, n: usize) -> Self {
170 self.channel_buffer = n;
171 self
172 }
173
174 #[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 #[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 #[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 #[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 #[cfg(feature = "skill")]
225 pub fn with_skills_default_path(mut self) -> Self {
226 self.load_default_skills = true;
227 self
228 }
229
230 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[cfg(feature = "deepseek")]
313 pub fn build(self) -> Result<AgentRuntime<DeepSeekProvider>, OrchestrateError> {
314 self.build_with::<DeepSeekProvider>()
315 }
316
317 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 #[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 #[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 #[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 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 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
538pub struct Idle;
542
543pub struct Acquired;
545
546pub 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
588impl<P: ChatProvider, S> AgentRuntime<P, S> {
591 pub fn builder() -> AgentRuntimeBuilder {
593 AgentRuntimeBuilder::new()
594 }
595
596 pub fn reset(&self) {
598 let _ = self.session_tx.send(SessionCmd::Clear);
599 }
600
601 pub fn session_tx(&self) -> mpsc::UnboundedSender<SessionCmd> {
603 self.session_tx.clone()
604 }
605
606 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 #[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 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 #[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 #[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 pub fn set_model(&self, model: impl Into<String>) {
653 let _ = self.env_cmd_tx.send(EnvCmd::SetModel(model.into()));
654 }
655
656 pub fn set_client(&self, client: async_openai::Client<OpenAIConfig>) {
658 let _ = self.env_cmd_tx.send(EnvCmd::SetClient(client));
659 }
660
661 #[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 #[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 #[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 #[cfg(feature = "skill")]
684 pub fn add_skill(&self, skill: Skill) {
685 let _ = self.env_cmd_tx.send(EnvCmd::AddSkill(skill));
686 }
687
688 #[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 #[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 #[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 #[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 #[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 #[cfg(feature = "middleware")]
734 pub fn middleware_chain(&self) -> Arc<MiddlewareChain<AgentEvent, ErrorsEnabled>> {
735 self.middleware_chain.clone()
736 }
737
738 #[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 #[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 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 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
796impl<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 #[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 #[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 #[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 #[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 #[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 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 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 drop(handle);
1195 }
1196
1197 #[test]
1198 fn approval_handle_implements_send_sync() {
1199 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 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()); assert!(r2.is_err()); }
1248 }
1249}