1use std::{sync::Arc, time::Duration};
40
41use pyo3::prelude::*;
42use tokio::sync::{mpsc, oneshot};
43
44use crate::{error::Error, quota::QuotaState};
45
46pub(crate) mod bridge_state;
47pub(crate) mod command_loop;
48pub(crate) mod ffi_dispatch;
49mod handlers;
50pub(crate) mod py_scripts;
51pub(crate) mod streaming;
52pub(crate) mod venv;
53
54pub(crate) use bridge_state::{AgentBridgeState, AgentId, bridge_state};
56pub(crate) use ffi_dispatch::{
57 CREATE_AGENT_HOOK_GUARD, INITIALIZING_HOOK_RUNNER, dispatch_rust_hook,
58 dispatch_rust_policy_confirm, dispatch_rust_tool,
59};
60
61#[must_use]
71pub fn default_operation_timeout(chat_timeout: Duration) -> Duration {
72 chat_timeout + Duration::from_mins(2)
73}
74pub const DEFAULT_CHAT_TIMEOUT_SECS: u64 = 120;
77
78pub const DEFAULT_INTER_AGENT_DELAY: Duration = Duration::from_millis(500);
80
81const DEFAULT_CHANNEL_CAPACITY: usize = 64;
83
84const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
86
87#[must_use]
90pub fn default_chat_timeout() -> Duration {
91 let secs = std::env::var("AGI_CHAT_TIMEOUT_SECS").map_or(DEFAULT_CHAT_TIMEOUT_SECS, |val| {
92 val.parse::<u64>().unwrap_or_else(|e| {
93 tracing::warn!(
94 value = %val,
95 error = %e,
96 "Invalid AGI_CHAT_TIMEOUT_SECS, using default {DEFAULT_CHAT_TIMEOUT_SECS}s"
97 );
98 DEFAULT_CHAT_TIMEOUT_SECS
99 })
100 });
101 Duration::from_secs(secs)
102}
103
104pub(crate) enum PyCommand {
109 CreateAgent {
114 config_json: String,
115 reply: oneshot::Sender<Result<(AgentId, Vec<handlers::agent::RawToolInfo>), Error>>,
116 },
117 Chat {
119 agent_id: AgentId,
120 prompt: String,
121 reply: oneshot::Sender<Result<crate::streaming::ChatResponseHandle, Error>>,
122 },
123 ShutdownAgent {
125 agent_id: AgentId,
126 reply: oneshot::Sender<Result<(), Error>>,
127 },
128 Cancel {
130 agent_id: AgentId,
131 reply: oneshot::Sender<Result<(), Error>>,
132 },
133 WaitForIdle {
135 agent_id: AgentId,
136 reply: oneshot::Sender<Result<(), Error>>,
137 },
138 Send {
140 agent_id: AgentId,
141 prompt: String,
142 reply: oneshot::Sender<Result<(), Error>>,
143 },
144 SignalIdle {
146 agent_id: AgentId,
147 reply: oneshot::Sender<Result<(), Error>>,
148 },
149 WaitForWakeup {
151 agent_id: AgentId,
152 timeout_secs: f64,
153 reply: oneshot::Sender<Result<bool, Error>>,
154 },
155 Shutdown,
157 GetHistory {
159 agent_id: AgentId,
160 reply: oneshot::Sender<Result<Vec<crate::types::ConversationMessage>, Error>>,
161 },
162 GetTurnCount {
164 agent_id: AgentId,
165 reply: oneshot::Sender<Result<u32, Error>>,
166 },
167 GetTotalUsage {
169 agent_id: AgentId,
170 reply: oneshot::Sender<Result<crate::types::UsageMetadata, Error>>,
171 },
172 GetLastTurnUsage {
174 agent_id: AgentId,
175 reply: oneshot::Sender<Result<crate::types::UsageMetadata, Error>>,
176 },
177 ClearHistory {
179 agent_id: AgentId,
180 reply: oneshot::Sender<Result<(), Error>>,
181 },
182 RemoveLastTurn {
187 agent_id: AgentId,
188 reply: oneshot::Sender<Result<(), Error>>,
189 },
190 GetCompactionIndices {
192 agent_id: AgentId,
193 reply: oneshot::Sender<Result<Vec<u32>, Error>>,
194 },
195 GetLastResponse {
197 agent_id: AgentId,
198 reply: oneshot::Sender<Result<Option<String>, Error>>,
199 },
200 Delete {
205 agent_id: AgentId,
206 reply: oneshot::Sender<Result<(), Error>>,
207 },
208 Disconnect {
212 agent_id: AgentId,
213 reply: oneshot::Sender<Result<(), Error>>,
214 },
215 IsIdle {
219 agent_id: AgentId,
220 reply: oneshot::Sender<Result<bool, Error>>,
221 },
222}
223
224#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
230#[serde(rename_all = "lowercase")]
231pub enum BackendLogLevel {
232 Error,
234 #[default]
236 Warn,
237 Info,
239 Debug,
241}
242
243impl BackendLogLevel {
244 #[must_use]
246 pub fn as_str(self) -> &'static str {
247 match self {
248 Self::Error => "error",
249 Self::Warn => "warn",
250 Self::Info => "info",
251 Self::Debug => "debug",
252 }
253 }
254}
255
256impl std::fmt::Display for BackendLogLevel {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 f.write_str(self.as_str())
259 }
260}
261
262#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
264#[serde(default)]
265pub struct RuntimeConfig {
266 pub channel_capacity: usize,
268 pub operation_timeout: Duration,
270 pub shutdown_timeout: Duration,
272 pub chat_timeout: Duration,
276 pub inter_agent_delay: Duration,
278 pub backend_log_level: BackendLogLevel,
283}
284
285impl Default for RuntimeConfig {
286 fn default() -> Self {
287 let chat_timeout = default_chat_timeout();
288 Self {
289 channel_capacity: DEFAULT_CHANNEL_CAPACITY,
290 operation_timeout: default_operation_timeout(chat_timeout),
291 shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
292 chat_timeout,
293 inter_agent_delay: DEFAULT_INTER_AGENT_DELAY,
294 backend_log_level: BackendLogLevel::default(),
295 }
296 }
297}
298
299pub struct PythonRuntime {
304 cmd_tx: mpsc::Sender<PyCommand>,
305 thread: Option<std::thread::JoinHandle<()>>,
306 config: RuntimeConfig,
307 quota_registry: crate::quota::QuotaRegistry,
310 quota_state: Arc<QuotaState>,
312}
313
314impl std::fmt::Debug for PythonRuntime {
315 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316 f.debug_struct("PythonRuntime")
317 .field("config", &self.config)
318 .field(
319 "thread_running",
320 &self.thread.as_ref().is_some_and(|t| !t.is_finished()),
321 )
322 .finish_non_exhaustive()
323 }
324}
325
326impl PythonRuntime {
327 pub fn new(config: RuntimeConfig) -> Result<Self, Error> {
337 let (cmd_tx, cmd_rx) = mpsc::channel(config.channel_capacity);
338
339 let thread_config = config.clone();
340 let thread = std::thread::Builder::new()
341 .name("agy-bridge-python-runtime".into())
342 .spawn(move || {
343 python_thread_main(cmd_rx, &thread_config);
344 })
345 .map_err(|e| Error::BackendError {
346 message: format!("Failed to spawn Python runtime thread: {e}"),
347 })?;
348
349 let quota_registry = crate::quota::QuotaRegistry::new();
350 let quota_state = quota_registry.state_for_key("");
351 Ok(Self {
352 cmd_tx,
353 thread: Some(thread),
354 config,
355 quota_registry,
356 quota_state,
357 })
358 }
359
360 async fn send_command<T>(
370 &self,
371 operation: &str,
372 is_llm_op: bool,
373 build_cmd: impl FnOnce(oneshot::Sender<Result<T, Error>>) -> PyCommand,
374 ) -> Result<T, Error> {
375 let (reply_tx, reply_rx) = oneshot::channel();
376 let cmd = build_cmd(reply_tx);
377
378 self.cmd_tx
379 .send(cmd)
380 .await
381 .map_err(|e| Error::ChannelClosed {
382 message: format!("Python runtime thread has exited (sending {operation}): {e}"),
383 })?;
384
385 let result = crate::error::with_timeout(self.config.operation_timeout, operation, async {
386 reply_rx.await.map_err(|e| Error::ChannelClosed {
387 message: format!("Reply channel dropped for {operation}: {e}"),
388 })?
389 })
390 .await?;
391
392 if is_llm_op {
395 self.quota_state.record_success();
396 }
397
398 Ok(result)
399 }
400
401 pub async fn shutdown(mut self) -> Result<(), Error> {
409 if let Err(e) = self.cmd_tx.send(PyCommand::Shutdown).await {
413 tracing::warn!("Shutdown command send failed (thread may already be exiting): {e}");
414 }
415
416 let Some(thread) = self.thread.take() else {
419 tracing::warn!("PythonRuntime::shutdown() called but thread handle already taken");
420 return Ok(());
421 };
422
423 let shutdown_timeout = self.config.shutdown_timeout;
424 let join_result = tokio::time::timeout(
425 shutdown_timeout,
426 tokio::task::spawn_blocking(move || thread.join()),
427 )
428 .await;
429
430 match join_result {
431 Ok(Ok(Ok(()))) => {
432 tracing::info!("Python runtime thread joined successfully");
433 Ok(())
434 }
435 Ok(Ok(Err(panic_payload))) => {
436 let panic_msg = panic_payload.downcast_ref::<&str>().map_or_else(
437 || {
438 panic_payload
439 .downcast_ref::<String>()
440 .map_or_else(|| format!("{panic_payload:?}"), Clone::clone)
441 },
442 |s| (*s).to_string(),
443 );
444 tracing::error!(
445 panic_message = %panic_msg,
446 "Python runtime thread panicked during shutdown"
447 );
448 Err(Error::BackendError {
449 message: format!("Python runtime thread panicked during shutdown: {panic_msg}"),
450 })
451 }
452 Ok(Err(join_err)) => {
453 tracing::error!("spawn_blocking join error: {join_err}");
454 Err(Error::BackendError {
455 message: format!("Failed to join Python thread: {join_err}"),
456 })
457 }
458 Err(_elapsed) => {
459 tracing::error!(
460 timeout_secs = shutdown_timeout.as_secs(),
461 "Python runtime thread did not exit within shutdown timeout"
462 );
463 Err(Error::Timeout {
464 duration: shutdown_timeout,
465 operation: "PythonRuntime::shutdown (thread join)".to_string(),
466 })
467 }
468 }
469 }
470
471 #[must_use]
473 pub const fn quota_state(&self) -> &Arc<QuotaState> {
474 &self.quota_state
475 }
476}
477
478impl Drop for PythonRuntime {
479 fn drop(&mut self) {
480 if self.thread.is_some() {
481 tracing::warn!(
482 "PythonRuntime dropped without calling shutdown() — \
483 Python thread may still be running"
484 );
485 }
486 }
487}
488
489fn python_thread_main(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) {
491 Python::initialize();
492
493 Python::attach(|py| {
498 if let Err(e) = venv::configure_python_sys_path(py) {
499 tracing::error!(
500 error = %e,
501 "Failed to configure Python sys.path in runtime thread — \
502 venv imports will likely fail"
503 );
504 }
505 });
506
507 if let Err(e) = run_live_thread(cmd_rx, config) {
508 tracing::error!(error = %e, "Python runtime thread failed");
509 }
510
511 tracing::info!("Python runtime thread exiting");
512}
513
514fn run_live_thread(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) -> Result<(), Error> {
517 Python::attach(|py| {
518 let asyncio = py.import("asyncio").map_err(|e| Error::BackendError {
519 message: format!("Failed to import asyncio: {e}"),
520 })?;
521 let event_loop =
522 asyncio
523 .call_method0("new_event_loop")
524 .map_err(|e| Error::BackendError {
525 message: format!("Failed to create new asyncio event loop: {e}"),
526 })?;
527 asyncio
528 .call_method1("set_event_loop", (&event_loop,))
529 .map_err(|e| Error::BackendError {
530 message: format!("Failed to set asyncio event loop: {e}"),
531 })?;
532
533 let sys = py.import("sys").map_err(|e| Error::BackendError {
535 message: format!("Failed to import sys: {e}"),
536 })?;
537 let sys_modules = sys.getattr("modules").map_err(|e| Error::BackendError {
538 message: format!("Failed to get sys.modules: {e}"),
539 })?;
540 let globals_mod = if sys_modules
541 .contains(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
542 .map_err(|e| Error::BackendError {
543 message: format!("Failed to check sys.modules: {e}"),
544 })? {
545 sys_modules
546 .get_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
547 .map_err(|e| Error::BackendError {
548 message: format!("Failed to get _agy_bridge_globals: {e}"),
549 })?
550 } else {
551 let types = py.import("types").map_err(|e| Error::BackendError {
552 message: format!("Failed to import types: {e}"),
553 })?;
554 let module = types
555 .getattr("ModuleType")
556 .map_err(|e| Error::BackendError {
557 message: format!("Failed to get ModuleType: {e}"),
558 })?
559 .call1((command_loop::AGY_BRIDGE_GLOBALS_MODULE,))
560 .map_err(|e| Error::BackendError {
561 message: format!("Failed to create ModuleType: {e}"),
562 })?;
563 sys_modules
564 .set_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE, &module)
565 .map_err(|e| Error::BackendError {
566 message: format!("Failed to register _agy_bridge_globals: {e}"),
567 })?;
568 module
569 };
570 globals_mod
571 .setattr("EVENT_LOOP", &event_loop)
572 .map_err(|e| Error::BackendError {
573 message: format!("Failed to set EVENT_LOOP in globals: {e}"),
574 })?;
575
576 tracing::info!("Python asyncio event loop created on runtime thread");
577
578 let chat_timeout = config.chat_timeout;
579 let inter_agent_delay = config.inter_agent_delay;
580 let event_loop_obj = event_loop.clone().unbind();
581 let run_fut =
582 pyo3_async_runtimes::tokio::run_until_complete(event_loop.clone(), async move {
583 command_loop::run_async_command_loop(
584 event_loop_obj,
585 cmd_rx,
586 chat_timeout,
587 inter_agent_delay,
588 )
589 .await
590 });
591
592 if let Err(e) = run_fut {
593 if let Err(close_err) = event_loop.call_method0("close") {
595 tracing::warn!("Failed to close asyncio event loop: {close_err}");
596 }
597 return Err(Error::BackendError {
598 message: format!("Python runtime command loop failed: {e}"),
599 });
600 }
601
602 if let Err(e) = event_loop.call_method0("close") {
603 tracing::warn!("Failed to close asyncio event loop: {e}");
604 }
605
606 Ok(())
607 })
608}
609
610fn compute_active_builtins(
617 config: &crate::config::AgentConfig,
618) -> Vec<crate::config::BuiltinTools> {
619 match config.capabilities.as_ref() {
620 Some(caps) if caps.enabled_tools.as_ref().is_some_and(|v| !v.is_empty()) => {
621 caps.enabled_tools.clone().unwrap_or_default()
622 }
623 Some(caps) if caps.enabled_tools.as_ref().is_some_and(Vec::is_empty) => {
624 Vec::new()
626 }
627 Some(caps) if caps.disabled_tools.is_some() => {
628 let disabled = caps.disabled_tools.as_ref().unwrap();
629 crate::config::BuiltinTools::all_tools()
630 .iter()
631 .filter(|t| !disabled.contains(t))
632 .cloned()
633 .collect()
634 }
635 _ => crate::config::BuiltinTools::all_tools().to_vec(),
636 }
637}
638
639impl crate::agent::Runtime for PythonRuntime {
640 async fn create_agent(
641 &self,
642 config: crate::config::AgentConfig,
643 ) -> Result<(crate::agent::AgentId, Vec<crate::tools::AvailableTool>), Error> {
644 let config_json = {
648 let mut val = serde_json::to_value(&config).map_err(|e| Error::BackendError {
649 message: format!("Failed to serialize AgentConfig: {e}"),
650 })?;
651 if let serde_json::Value::Object(ref mut map) = val {
652 map.insert(
653 "_backend_log_level".to_owned(),
654 serde_json::Value::String(self.config.backend_log_level.as_str().to_owned()),
655 );
656 }
657 serde_json::to_string(&val).map_err(|e| Error::BackendError {
658 message: format!("Failed to re-serialize config JSON: {e}"),
659 })?
660 };
661
662 let custom_tool_names: std::collections::HashSet<String> =
664 config.tools.iter().map(|t| t.name.clone()).collect();
665
666 let (raw_id, raw_tools) = self
667 .send_command("create_agent", false, |reply| PyCommand::CreateAgent {
668 config_json,
669 reply,
670 })
671 .await?;
672
673 let active_builtins = compute_active_builtins(&config);
675 let builtin_names: std::collections::HashSet<&str> = active_builtins
676 .iter()
677 .map(crate::config::BuiltinTools::as_sdk_name)
678 .collect();
679
680 let mut available_tools: Vec<crate::tools::AvailableTool> = raw_tools
684 .into_iter()
685 .filter(|raw| !builtin_names.contains(raw.name.as_str()))
686 .map(|raw| {
687 let source = if custom_tool_names.contains(&raw.name) {
688 crate::tools::ToolSource::Custom
689 } else {
690 crate::tools::ToolSource::Mcp
691 };
692 crate::tools::AvailableTool {
693 name: raw.name,
694 description: raw.description,
695 parameter_schema: raw.parameter_schema,
696 source,
697 }
698 })
699 .collect();
700
701 for builtin in active_builtins {
703 available_tools.push(crate::tools::AvailableTool {
704 name: builtin.as_sdk_name().to_owned(),
705 description: builtin.description().to_owned(),
706 parameter_schema: serde_json::Value::Null,
707 source: crate::tools::ToolSource::Builtin,
708 });
709 }
710
711 tracing::info!(
712 agent_id = raw_id.0,
713 tool_count = available_tools.len(),
714 tools = ?available_tools.iter().map(|t| format!("{t}")).collect::<Vec<_>>(),
715 "Agent created with available tools"
716 );
717
718 Ok((raw_id.0, available_tools))
719 }
720
721 async fn chat(
722 &self,
723 agent_id: crate::agent::AgentId,
724 content: &crate::content::Content,
725 ) -> Result<crate::streaming::ChatResponseHandle, Error> {
726 let prompt = match content {
727 crate::content::Content::Text { text } => text.clone(),
728 other => crate::content::content_to_json(other)?,
729 };
730 self.send_command("chat", true, |reply| PyCommand::Chat {
731 agent_id: AgentId(agent_id),
732 prompt,
733 reply,
734 })
735 .await
736 }
737
738 async fn shutdown_agent(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
739 self.send_command("shutdown_agent", false, |reply| PyCommand::ShutdownAgent {
740 agent_id: AgentId(agent_id),
741 reply,
742 })
743 .await
744 }
745
746 fn try_shutdown_agent(&self, agent_id: crate::agent::AgentId) {
747 let (reply, _) = oneshot::channel();
751 if let Err(e) = self.cmd_tx.try_send(PyCommand::ShutdownAgent {
752 agent_id: AgentId(agent_id),
753 reply,
754 }) {
755 tracing::debug!(
756 agent_id = agent_id,
757 error = %e,
758 "try_shutdown_agent: channel send failed (runtime may already be gone)"
759 );
760 }
761 }
762
763 async fn cancel(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
764 self.send_command("cancel", false, |reply| PyCommand::Cancel {
765 agent_id: AgentId(agent_id),
766 reply,
767 })
768 .await
769 }
770
771 async fn wait_for_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
772 self.send_command("wait_for_idle", false, |reply| PyCommand::WaitForIdle {
773 agent_id: AgentId(agent_id),
774 reply,
775 })
776 .await
777 }
778
779 async fn send(
780 &self,
781 agent_id: crate::agent::AgentId,
782 content: &crate::content::Content,
783 ) -> Result<(), Error> {
784 let prompt = match content {
785 crate::content::Content::Text { text } => text.clone(),
786 other => crate::content::content_to_json(other)?,
787 };
788 self.send_command("send", false, |reply| PyCommand::Send {
789 agent_id: AgentId(agent_id),
790 prompt,
791 reply,
792 })
793 .await
794 }
795
796 async fn signal_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
797 self.send_command("signal_idle", false, |reply| PyCommand::SignalIdle {
798 agent_id: AgentId(agent_id),
799 reply,
800 })
801 .await
802 }
803
804 async fn wait_for_wakeup(
805 &self,
806 agent_id: crate::agent::AgentId,
807 timeout: std::time::Duration,
808 ) -> Result<bool, Error> {
809 self.send_command("wait_for_wakeup", false, |reply| PyCommand::WaitForWakeup {
810 agent_id: AgentId(agent_id),
811 timeout_secs: timeout.as_secs_f64(),
812 reply,
813 })
814 .await
815 }
816
817 async fn wait_for_quota(&self) {
818 self.quota_state.wait_for_quota().await;
819 }
820
821 async fn record_quota_hit(&self, retry_after: std::time::Duration) {
822 self.quota_state.record_quota_hit(retry_after);
823 }
824
825 fn quota_registry(&self) -> &crate::quota::QuotaRegistry {
826 &self.quota_registry
827 }
828
829 async fn history(
830 &self,
831 agent_id: crate::agent::AgentId,
832 ) -> Result<Vec<crate::types::ConversationMessage>, Error> {
833 self.send_command("get_history", false, |reply| PyCommand::GetHistory {
834 agent_id: AgentId(agent_id),
835 reply,
836 })
837 .await
838 }
839
840 async fn turn_count(&self, agent_id: crate::agent::AgentId) -> Result<u32, Error> {
841 self.send_command("get_turn_count", false, |reply| PyCommand::GetTurnCount {
842 agent_id: AgentId(agent_id),
843 reply,
844 })
845 .await
846 }
847
848 async fn total_usage(
849 &self,
850 agent_id: crate::agent::AgentId,
851 ) -> Result<crate::types::UsageMetadata, Error> {
852 self.send_command("get_total_usage", false, |reply| PyCommand::GetTotalUsage {
853 agent_id: AgentId(agent_id),
854 reply,
855 })
856 .await
857 }
858
859 async fn last_turn_usage(
860 &self,
861 agent_id: crate::agent::AgentId,
862 ) -> Result<crate::types::UsageMetadata, Error> {
863 self.send_command("get_last_turn_usage", false, |reply| {
864 PyCommand::GetLastTurnUsage {
865 agent_id: AgentId(agent_id),
866 reply,
867 }
868 })
869 .await
870 }
871
872 async fn clear_history(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
873 self.send_command("clear_history", false, |reply| PyCommand::ClearHistory {
874 agent_id: AgentId(agent_id),
875 reply,
876 })
877 .await
878 }
879
880 async fn remove_last_turn(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
881 self.send_command("remove_last_turn", false, |reply| {
882 PyCommand::RemoveLastTurn {
883 agent_id: AgentId(agent_id),
884 reply,
885 }
886 })
887 .await
888 }
889
890 async fn compaction_indices(&self, agent_id: crate::agent::AgentId) -> Result<Vec<u32>, Error> {
891 self.send_command("compaction_indices", false, |reply| {
892 PyCommand::GetCompactionIndices {
893 agent_id: AgentId(agent_id),
894 reply,
895 }
896 })
897 .await
898 }
899
900 async fn last_response(
901 &self,
902 agent_id: crate::agent::AgentId,
903 ) -> Result<Option<String>, Error> {
904 self.send_command("last_response", false, |reply| PyCommand::GetLastResponse {
905 agent_id: AgentId(agent_id),
906 reply,
907 })
908 .await
909 }
910
911 async fn delete(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
912 self.send_command("delete", false, |reply| PyCommand::Delete {
913 agent_id: AgentId(agent_id),
914 reply,
915 })
916 .await
917 }
918
919 async fn disconnect(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
920 self.send_command("disconnect", false, |reply| PyCommand::Disconnect {
921 agent_id: AgentId(agent_id),
922 reply,
923 })
924 .await
925 }
926
927 async fn is_idle(&self, agent_id: crate::agent::AgentId) -> Result<bool, Error> {
928 self.send_command("is_idle", false, |reply| PyCommand::IsIdle {
929 agent_id: AgentId(agent_id),
930 reply,
931 })
932 .await
933 }
934}
935
936#[cfg(test)]
937mod tests {
938 use std::collections::HashMap;
939
940 use super::{ffi_dispatch::check_tool_execution_allowed, *};
941
942 fn test_config() -> RuntimeConfig {
943 RuntimeConfig {
944 channel_capacity: 16,
945 operation_timeout: Duration::from_secs(10),
946 shutdown_timeout: Duration::from_secs(5),
947 chat_timeout: Duration::from_mins(1),
948 inter_agent_delay: Duration::from_millis(100),
949 backend_log_level: BackendLogLevel::default(),
950 }
951 }
952
953 #[tokio::test]
954 async fn test_runtime_creation_and_shutdown() {
955 PythonRuntime::new(test_config())
957 .expect("Failed to create runtime")
958 .shutdown()
959 .await
960 .expect("Shutdown failed");
961 }
962
963 #[test]
964 fn runtime_config_serde_roundtrip() {
965 let config = test_config();
966 let json = serde_json::to_string(&config).unwrap();
967 let parsed: RuntimeConfig = serde_json::from_str(&json).unwrap();
968 assert_eq!(parsed.channel_capacity, 16);
969 assert_eq!(parsed.operation_timeout, Duration::from_secs(10));
970 assert_eq!(parsed.shutdown_timeout, Duration::from_secs(5));
971 assert_eq!(parsed.chat_timeout, Duration::from_mins(1));
972 assert_eq!(parsed.inter_agent_delay, Duration::from_millis(100));
973 assert_eq!(parsed.backend_log_level, BackendLogLevel::Warn);
974 }
975
976 #[test]
977 fn backend_log_level_default_is_warn() {
978 assert_eq!(BackendLogLevel::default(), BackendLogLevel::Warn);
979 }
980
981 #[test]
982 fn backend_log_level_serde_roundtrip_all_variants() {
983 for (variant, expected_str) in [
984 (BackendLogLevel::Error, "\"error\""),
985 (BackendLogLevel::Warn, "\"warn\""),
986 (BackendLogLevel::Info, "\"info\""),
987 (BackendLogLevel::Debug, "\"debug\""),
988 ] {
989 let json = serde_json::to_string(&variant).unwrap();
990 assert_eq!(json, expected_str, "serialize {variant:?}");
991 let parsed: BackendLogLevel = serde_json::from_str(&json).unwrap();
992 assert_eq!(parsed, variant, "roundtrip {variant:?}");
993 }
994 }
995
996 #[test]
997 fn backend_log_level_as_str() {
998 assert_eq!(BackendLogLevel::Error.as_str(), "error");
999 assert_eq!(BackendLogLevel::Warn.as_str(), "warn");
1000 assert_eq!(BackendLogLevel::Info.as_str(), "info");
1001 assert_eq!(BackendLogLevel::Debug.as_str(), "debug");
1002 }
1003
1004 #[test]
1005 fn backend_log_level_display() {
1006 assert_eq!(format!("{}", BackendLogLevel::Error), "error");
1007 assert_eq!(format!("{}", BackendLogLevel::Warn), "warn");
1008 assert_eq!(format!("{}", BackendLogLevel::Info), "info");
1009 assert_eq!(format!("{}", BackendLogLevel::Debug), "debug");
1010 }
1011
1012 #[test]
1013 fn runtime_config_with_custom_backend_log_level() {
1014 let config = RuntimeConfig {
1015 backend_log_level: BackendLogLevel::Debug,
1016 ..test_config()
1017 };
1018 let json = serde_json::to_string(&config).unwrap();
1019 let parsed: RuntimeConfig = serde_json::from_str(&json).unwrap();
1020 assert_eq!(parsed.backend_log_level, BackendLogLevel::Debug);
1021 }
1022
1023 #[test]
1024 fn default_operation_timeout_is_chat_plus_margin() {
1025 let config = RuntimeConfig::default();
1026 let expected = config.chat_timeout + Duration::from_mins(2);
1027 assert_eq!(
1028 config.operation_timeout, expected,
1029 "operation_timeout should be chat_timeout + 2min safety margin"
1030 );
1031 }
1032
1033 #[test]
1034 fn stop_candidate_exception_is_backend_error() {
1035 Python::initialize();
1036 Python::attach(|py| {
1037 let globals = pyo3::types::PyDict::new(py);
1038 py.run(
1039 c"
1040class StopCandidateException(Exception):
1041 pass
1042err = StopCandidateException(\"dummy\")
1043",
1044 Some(&globals),
1045 None,
1046 )
1047 .unwrap();
1048
1049 let err_obj = globals.get_item("err").unwrap().unwrap();
1050 let err = PyErr::from_value(err_obj);
1051
1052 let mapped = crate::error::classify_py_error(py, &err);
1053
1054 assert!(
1055 matches!(mapped, crate::error::Error::BackendError { .. }),
1056 "StopCandidateException should be classified as BackendError, got: {mapped:?}"
1057 );
1058 });
1059 }
1060
1061 #[test]
1062 fn max_tokens_exception_is_backend_error() {
1063 Python::initialize();
1064 Python::attach(|py| {
1065 let globals = pyo3::types::PyDict::new(py);
1066 py.run(
1067 c"
1068class MaxTokensException(Exception):
1069 pass
1070err = MaxTokensException(\"dummy\")
1071",
1072 Some(&globals),
1073 None,
1074 )
1075 .unwrap();
1076
1077 let err_obj = globals.get_item("err").unwrap().unwrap();
1078 let err = PyErr::from_value(err_obj);
1079
1080 let mapped = crate::error::classify_py_error(py, &err);
1081
1082 assert!(
1083 matches!(mapped, crate::error::Error::BackendError { .. }),
1084 "MaxTokensException should be classified as BackendError, got: {mapped:?}"
1085 );
1086 });
1087 }
1088
1089 struct MockAskUserHandler {
1090 should_allow: std::sync::atomic::AtomicBool,
1091 }
1092
1093 impl crate::policies::AskUserHandler for MockAskUserHandler {
1094 fn confirm(&self, _tool_name: &str, _tool_args: &serde_json::Value) -> bool {
1095 self.should_allow.load(std::sync::atomic::Ordering::SeqCst)
1096 }
1097 }
1098
1099 #[test]
1100 fn test_ask_user_policy_custom_tool_gating() {
1101 let agent_id: u64 = 999;
1102
1103 let mut policies = crate::policies::PolicySet::new();
1105 policies
1106 .push(crate::policies::PolicyRule::AskUser {
1107 tool: "dangerous_tool".to_owned(),
1108 handler_id: "confirm_handler".to_owned(),
1109 })
1110 .unwrap();
1111
1112 let handler = Arc::new(MockAskUserHandler {
1114 should_allow: std::sync::atomic::AtomicBool::new(true),
1115 });
1116
1117 let mut registry = crate::tools::ToolRegistry::new();
1119
1120 #[crate::llm_tool]
1122 fn dangerous_tool() -> Result<String, String> {
1123 Ok("Executed dangerous action!".to_owned())
1124 }
1125 registry.register(DangerousTool);
1126
1127 bridge_state().write().unwrap().insert(
1129 agent_id,
1130 AgentBridgeState {
1131 registry: Some(Arc::new(registry)),
1132 hook_runner: None,
1133 policies,
1134 policy_handler: Some(
1135 Arc::clone(&handler) as Arc<dyn crate::policies::AskUserHandler>
1136 ),
1137 tool_state: Arc::new(std::sync::RwLock::new(HashMap::new())),
1138 },
1139 );
1140
1141 handler
1143 .should_allow
1144 .store(true, std::sync::atomic::Ordering::SeqCst);
1145 let res = check_tool_execution_allowed(agent_id, "dangerous_tool", "{}");
1146 assert!(res.is_ok(), "Check should succeed");
1147 assert!(
1148 res.unwrap(),
1149 "Should allow tool execution when handler returns true"
1150 );
1151
1152 handler
1154 .should_allow
1155 .store(false, std::sync::atomic::Ordering::SeqCst);
1156 let res = check_tool_execution_allowed(agent_id, "dangerous_tool", "{}");
1157 assert!(res.is_ok(), "Check should succeed");
1158 assert!(
1159 !res.unwrap(),
1160 "Should block tool execution when handler returns false"
1161 );
1162
1163 bridge_state().write().unwrap().remove(&agent_id);
1165 }
1166
1167 #[test]
1170 fn builtins_default_config_returns_all() {
1171 let config = crate::config::AgentConfig::default();
1172 let builtins = super::compute_active_builtins(&config);
1173 assert_eq!(
1174 builtins.len(),
1175 crate::config::BuiltinTools::all_tools().len(),
1176 "default config should produce all builtins"
1177 );
1178 }
1179
1180 #[test]
1181 fn builtins_no_capabilities_returns_all() {
1182 let config = crate::config::AgentConfig {
1183 capabilities: None,
1184 ..crate::config::AgentConfig::default()
1185 };
1186 let builtins = super::compute_active_builtins(&config);
1187 assert_eq!(
1188 builtins.len(),
1189 crate::config::BuiltinTools::all_tools().len(),
1190 );
1191 }
1192
1193 #[test]
1194 fn builtins_enabled_tools_filters() {
1195 let config = crate::config::AgentConfig {
1196 capabilities: Some(crate::config::CapabilitiesConfig {
1197 enabled_tools: Some(vec![
1198 crate::config::BuiltinTools::ViewFile,
1199 crate::config::BuiltinTools::ListDir,
1200 ]),
1201 ..crate::config::CapabilitiesConfig::default()
1202 }),
1203 ..crate::config::AgentConfig::default()
1204 };
1205 let builtins = super::compute_active_builtins(&config);
1206 assert_eq!(builtins.len(), 2);
1207 assert!(builtins.contains(&crate::config::BuiltinTools::ViewFile));
1208 assert!(builtins.contains(&crate::config::BuiltinTools::ListDir));
1209 }
1210
1211 #[test]
1212 fn builtins_disabled_tools_excludes() {
1213 let config = crate::config::AgentConfig {
1214 capabilities: Some(crate::config::CapabilitiesConfig {
1215 disabled_tools: Some(vec![crate::config::BuiltinTools::RunCommand]),
1216 ..crate::config::CapabilitiesConfig::default()
1217 }),
1218 ..crate::config::AgentConfig::default()
1219 };
1220 let builtins = super::compute_active_builtins(&config);
1221 assert!(
1222 !builtins.contains(&crate::config::BuiltinTools::RunCommand),
1223 "RunCommand should be excluded"
1224 );
1225 assert!(
1226 builtins.len() == crate::config::BuiltinTools::all_tools().len() - 1,
1227 "should have all builtins minus the disabled one"
1228 );
1229 }
1230
1231 #[test]
1232 fn builtins_custom_tools_only_returns_empty() {
1233 let config = crate::config::AgentConfig {
1234 capabilities: Some(crate::config::CapabilitiesConfig::custom_tools_only()),
1235 ..crate::config::AgentConfig::default()
1236 };
1237 let builtins = super::compute_active_builtins(&config);
1238 assert!(
1239 builtins.is_empty(),
1240 "custom_tools_only should produce 0 builtins"
1241 );
1242 }
1243
1244 #[test]
1245 fn builtins_all_descriptions_non_empty() {
1246 for tool in crate::config::BuiltinTools::all_tools() {
1247 assert!(
1248 !tool.description().is_empty(),
1249 "builtin {tool:?} has empty description",
1250 );
1251 }
1252 }
1253
1254 #[test]
1255 fn builtins_all_sdk_names_non_empty() {
1256 for tool in crate::config::BuiltinTools::all_tools() {
1257 assert!(
1258 !tool.as_sdk_name().is_empty(),
1259 "builtin {tool:?} has empty SDK name",
1260 );
1261 }
1262 }
1263}