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