1use std::time::Duration;
40
41use pyo3::prelude::*;
42use tokio::sync::{mpsc, oneshot};
43
44use crate::error::Error;
45
46pub(crate) mod bridge_state;
47pub(crate) mod command_loop;
48mod config;
49pub(crate) mod ffi_dispatch;
50mod handlers;
51pub(crate) mod py_scripts;
52pub(crate) mod streaming;
53pub(crate) mod venv;
54
55#[cfg(test)]
56mod tests;
57
58pub(crate) use bridge_state::{AgentBridgeState, AgentId, bridge_state, next_agent_id};
60pub use config::{BackendLogLevel, RuntimeConfig};
61pub(crate) use ffi_dispatch::{
62 dispatch_rust_hook, dispatch_rust_policy_confirm, dispatch_rust_tool, initializing_hook_runners,
63};
64
65pub const DEFAULT_INTER_AGENT_DELAY: Duration = Duration::from_millis(500);
67
68const DEFAULT_CHANNEL_CAPACITY: usize = 64;
70
71const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
73
74pub(crate) enum PyCommand {
79 CreateAgent {
84 agent_id: u64,
85 config_json: String,
86 reply: oneshot::Sender<Result<(AgentId, Vec<handlers::agent::RawToolInfo>), Error>>,
87 },
88 Chat {
90 agent_id: AgentId,
91 prompt: String,
92 reply: oneshot::Sender<Result<crate::streaming::ChatResponseHandle, Error>>,
93 },
94 ShutdownAgent {
96 agent_id: AgentId,
97 reply: oneshot::Sender<Result<(), Error>>,
98 },
99 Cancel {
101 agent_id: AgentId,
102 reply: oneshot::Sender<Result<(), Error>>,
103 },
104 WaitForIdle {
106 agent_id: AgentId,
107 reply: oneshot::Sender<Result<(), Error>>,
108 },
109 Send {
111 agent_id: AgentId,
112 prompt: String,
113 reply: oneshot::Sender<Result<(), Error>>,
114 },
115 SignalIdle {
117 agent_id: AgentId,
118 reply: oneshot::Sender<Result<(), Error>>,
119 },
120 WaitForWakeup {
122 agent_id: AgentId,
123 timeout_secs: f64,
124 reply: oneshot::Sender<Result<bool, Error>>,
125 },
126 Shutdown,
128 GetHistory {
130 agent_id: AgentId,
131 reply: oneshot::Sender<Result<Vec<crate::types::ConversationMessage>, Error>>,
132 },
133 GetTurnCount {
135 agent_id: AgentId,
136 reply: oneshot::Sender<Result<u32, Error>>,
137 },
138 GetActiveAgentCount {
146 reply: oneshot::Sender<Result<usize, Error>>,
147 },
148 GetTotalUsage {
150 agent_id: AgentId,
151 reply: oneshot::Sender<Result<crate::types::UsageMetadata, Error>>,
152 },
153 GetLastTurnUsage {
155 agent_id: AgentId,
156 reply: oneshot::Sender<Result<crate::types::UsageMetadata, Error>>,
157 },
158 ClearHistory {
160 agent_id: AgentId,
161 reply: oneshot::Sender<Result<(), Error>>,
162 },
163 GetCompactionIndices {
165 agent_id: AgentId,
166 reply: oneshot::Sender<Result<Vec<u32>, Error>>,
167 },
168 GetLastResponse {
170 agent_id: AgentId,
171 reply: oneshot::Sender<Result<Option<String>, Error>>,
172 },
173 Delete {
178 agent_id: AgentId,
179 reply: oneshot::Sender<Result<(), Error>>,
180 },
181 Disconnect {
185 agent_id: AgentId,
186 reply: oneshot::Sender<Result<(), Error>>,
187 },
188 IsIdle {
192 agent_id: AgentId,
193 reply: oneshot::Sender<Result<bool, Error>>,
194 },
195}
196
197pub struct PythonRuntime {
202 cmd_tx: mpsc::Sender<PyCommand>,
203 thread: Option<std::thread::JoinHandle<()>>,
204 config: RuntimeConfig,
205}
206
207impl std::fmt::Debug for PythonRuntime {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 f.debug_struct("PythonRuntime")
210 .field("config", &self.config)
211 .field(
212 "thread_running",
213 &self.thread.as_ref().is_some_and(|t| !t.is_finished()),
214 )
215 .finish_non_exhaustive()
216 }
217}
218
219impl PythonRuntime {
220 pub fn new(config: RuntimeConfig) -> Result<Self, Error> {
230 let (cmd_tx, cmd_rx) = mpsc::channel(config.channel_capacity);
231
232 let thread_config = config.clone();
233 let thread = std::thread::Builder::new()
234 .name("agy-bridge-python-runtime".into())
235 .spawn(move || {
236 python_thread_main(cmd_rx, &thread_config);
237 })
238 .map_err(|e| Error::BackendError {
239 message: format!("Failed to spawn Python runtime thread: {e}"),
240 })?;
241
242 Ok(Self {
243 cmd_tx,
244 thread: Some(thread),
245 config,
246 })
247 }
248
249 async fn send_command<T>(
258 &self,
259 operation: &str,
260 build_cmd: impl FnOnce(oneshot::Sender<Result<T, Error>>) -> PyCommand,
261 ) -> Result<T, Error> {
262 let (reply_tx, reply_rx) = oneshot::channel();
263 let cmd = build_cmd(reply_tx);
264
265 self.cmd_tx
266 .send(cmd)
267 .await
268 .map_err(|e| Error::ChannelClosed {
269 message: format!("Python runtime thread has exited (sending {operation}): {e}"),
270 })?;
271
272 let result = reply_rx.await.map_err(|e| Error::ChannelClosed {
273 message: format!("Reply channel dropped for {operation}: {e}"),
274 })??;
275
276 Ok(result)
277 }
278
279 pub(crate) async fn active_agent_count(&self) -> Result<usize, Error> {
288 self.send_command("active_agent_count", |reply| {
289 PyCommand::GetActiveAgentCount { reply }
290 })
291 .await
292 }
293
294 pub async fn shutdown(mut self) -> Result<(), Error> {
302 if let Err(e) = self.cmd_tx.send(PyCommand::Shutdown).await {
306 tracing::warn!("Shutdown command send failed (thread may already be exiting): {e}");
307 }
308
309 let Some(thread) = self.thread.take() else {
312 tracing::warn!("PythonRuntime::shutdown() called but thread handle already taken");
313 return Ok(());
314 };
315
316 let shutdown_timeout = self.config.shutdown_timeout;
317 let join_result = tokio::time::timeout(
318 shutdown_timeout,
319 tokio::task::spawn_blocking(move || thread.join()),
320 )
321 .await;
322
323 match join_result {
324 Ok(Ok(Ok(()))) => {
325 tracing::info!("Python runtime thread joined successfully");
326 Ok(())
327 }
328 Ok(Ok(Err(panic_payload))) => {
329 let panic_msg = panic_payload.downcast_ref::<&str>().map_or_else(
330 || {
331 panic_payload
332 .downcast_ref::<String>()
333 .map_or_else(|| format!("{panic_payload:?}"), Clone::clone)
334 },
335 |s| (*s).to_string(),
336 );
337 tracing::error!(
338 panic_message = %panic_msg,
339 "Python runtime thread panicked during shutdown"
340 );
341 Err(Error::BackendError {
342 message: format!("Python runtime thread panicked during shutdown: {panic_msg}"),
343 })
344 }
345 Ok(Err(join_err)) => {
346 tracing::error!("spawn_blocking join error: {join_err}");
347 Err(Error::BackendError {
348 message: format!("Failed to join Python thread: {join_err}"),
349 })
350 }
351 Err(_elapsed) => {
352 tracing::error!(
353 timeout_secs = shutdown_timeout.as_secs(),
354 "Python runtime thread did not exit within shutdown timeout"
355 );
356 Err(Error::Timeout {
357 duration: shutdown_timeout,
358 operation: "PythonRuntime::shutdown (thread join)".to_string(),
359 })
360 }
361 }
362 }
363}
364
365impl Drop for PythonRuntime {
366 fn drop(&mut self) {
367 let Some(thread) = self.thread.take() else {
370 return;
371 };
372
373 if let Err(e) = self.cmd_tx.try_send(PyCommand::Shutdown) {
379 tracing::debug!(
380 error = %e,
381 "PythonRuntime::drop: could not eagerly signal shutdown; \
382 relying on channel close"
383 );
384 }
385
386 let deadline = std::time::Instant::now() + self.config.shutdown_timeout;
391 while !thread.is_finished() && std::time::Instant::now() < deadline {
392 std::thread::sleep(std::time::Duration::from_millis(5));
393 }
394
395 if thread.is_finished() {
396 if thread.join().is_err() {
397 tracing::error!("Python runtime thread panicked during drop cleanup");
398 } else {
399 tracing::debug!("Python runtime thread joined cleanly on drop");
400 }
401 } else {
402 tracing::warn!(
406 "Python runtime thread still running after shutdown timeout during drop — \
407 detaching; agent cleanup will complete asynchronously"
408 );
409 }
410 }
411}
412
413fn python_thread_main(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) {
415 Python::initialize();
416
417 Python::attach(|py| {
422 if let Err(e) = venv::configure_python_sys_path(py) {
423 tracing::error!(
424 error = %e,
425 "Failed to configure Python sys.path in runtime thread — \
426 venv imports will likely fail"
427 );
428 }
429 });
430
431 if let Err(e) = run_live_thread(cmd_rx, config) {
432 tracing::error!(error = %e, "Python runtime thread failed");
433 }
434
435 tracing::info!("Python runtime thread exiting");
436}
437
438fn run_live_thread(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) -> Result<(), Error> {
441 Python::attach(|py| {
442 let asyncio = py.import("asyncio").map_err(|e| Error::BackendError {
443 message: format!("Failed to import asyncio: {e}"),
444 })?;
445 let event_loop =
446 asyncio
447 .call_method0("new_event_loop")
448 .map_err(|e| Error::BackendError {
449 message: format!("Failed to create new asyncio event loop: {e}"),
450 })?;
451 asyncio
452 .call_method1("set_event_loop", (&event_loop,))
453 .map_err(|e| Error::BackendError {
454 message: format!("Failed to set asyncio event loop: {e}"),
455 })?;
456
457 let sys = py.import("sys").map_err(|e| Error::BackendError {
462 message: format!("Failed to import sys: {e}"),
463 })?;
464 let sys_modules = sys.getattr("modules").map_err(|e| Error::BackendError {
465 message: format!("Failed to get sys.modules: {e}"),
466 })?;
467 let globals_mod = if sys_modules
468 .contains(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
469 .map_err(|e| Error::BackendError {
470 message: format!("Failed to check sys.modules: {e}"),
471 })? {
472 sys_modules
473 .get_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
474 .map_err(|e| Error::BackendError {
475 message: format!("Failed to get _agy_bridge_globals: {e}"),
476 })?
477 } else {
478 let types = py.import("types").map_err(|e| Error::BackendError {
479 message: format!("Failed to import types: {e}"),
480 })?;
481 let module = types
482 .getattr("ModuleType")
483 .map_err(|e| Error::BackendError {
484 message: format!("Failed to get ModuleType: {e}"),
485 })?
486 .call1((command_loop::AGY_BRIDGE_GLOBALS_MODULE,))
487 .map_err(|e| Error::BackendError {
488 message: format!("Failed to create ModuleType: {e}"),
489 })?;
490 sys_modules
491 .set_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE, &module)
492 .map_err(|e| Error::BackendError {
493 message: format!("Failed to register _agy_bridge_globals: {e}"),
494 })?;
495 module
496 };
497 globals_mod
498 .setattr("EVENT_LOOP", &event_loop)
499 .map_err(|e| Error::BackendError {
500 message: format!("Failed to set EVENT_LOOP in globals: {e}"),
501 })?;
502 register_thread_event_loop(py, &globals_mod, &event_loop)?;
503
504 tracing::info!("Python asyncio event loop created on runtime thread");
505
506 let inter_agent_delay = config.inter_agent_delay;
507 let stream_limits = streaming::StreamLimits::from_config(config);
508 let event_loop_obj = event_loop.clone().unbind();
509 let run_fut =
510 pyo3_async_runtimes::tokio::run_until_complete(event_loop.clone(), async move {
511 command_loop::run_async_command_loop(
512 event_loop_obj,
513 cmd_rx,
514 inter_agent_delay,
515 stream_limits,
516 )
517 .await
518 });
519
520 if let Err(e) = run_fut {
521 if let Err(close_err) = event_loop.call_method0("close") {
523 tracing::warn!("Failed to close asyncio event loop: {close_err}");
524 }
525 return Err(Error::BackendError {
526 message: format!("Python runtime command loop failed: {e}"),
527 });
528 }
529
530 if let Err(e) = event_loop.call_method0("close") {
531 tracing::warn!("Failed to close asyncio event loop: {e}");
532 }
533
534 Ok(())
535 })
536}
537
538fn register_thread_event_loop(
548 py: Python<'_>,
549 globals_mod: &Bound<'_, PyAny>,
550 event_loop: &Bound<'_, PyAny>,
551) -> Result<(), Error> {
552 let threading = py.import("threading").map_err(|e| Error::BackendError {
553 message: format!("Failed to import threading for event-loop registration: {e}"),
554 })?;
555 let thread_id = threading
556 .call_method0("get_ident")
557 .map_err(|e| Error::BackendError {
558 message: format!("Failed to read threading.get_ident(): {e}"),
559 })?;
560
561 let loops = if globals_mod.hasattr("EVENT_LOOPS").unwrap_or(false) {
562 globals_mod
563 .getattr("EVENT_LOOPS")
564 .map_err(|e| Error::BackendError {
565 message: format!("Failed to get EVENT_LOOPS map: {e}"),
566 })?
567 } else {
568 let dict = pyo3::types::PyDict::new(py).into_any();
569 globals_mod
570 .setattr("EVENT_LOOPS", &dict)
571 .map_err(|e| Error::BackendError {
572 message: format!("Failed to create EVENT_LOOPS map: {e}"),
573 })?;
574 dict
575 };
576
577 loops
578 .set_item(thread_id, event_loop)
579 .map_err(|e| Error::BackendError {
580 message: format!("Failed to register runtime event loop by thread id: {e}"),
581 })?;
582 Ok(())
583}
584
585fn compute_active_builtins(
592 config: &crate::config::AgentConfig,
593) -> Vec<crate::config::BuiltinTools> {
594 let Some(caps) = config.capabilities.as_ref() else {
595 return crate::config::BuiltinTools::all_tools().to_vec();
596 };
597
598 if let Some(enabled) = caps.enabled_tools.as_ref() {
601 return enabled.clone();
602 }
603
604 if let Some(disabled) = caps.disabled_tools.as_ref() {
606 return crate::config::BuiltinTools::all_tools()
607 .iter()
608 .filter(|t| !disabled.contains(t))
609 .cloned()
610 .collect();
611 }
612
613 crate::config::BuiltinTools::all_tools().to_vec()
615}
616
617impl crate::agent::Runtime for PythonRuntime {
618 async fn create_agent(
619 &self,
620 agent_id: u64,
621 config: crate::config::AgentConfig,
622 ) -> Result<(crate::agent::AgentId, Vec<crate::tools::AvailableTool>), Error> {
623 let config_json = {
627 let mut val = serde_json::to_value(&config).map_err(|e| Error::BackendError {
628 message: format!("Failed to serialize AgentConfig: {e}"),
629 })?;
630 if let serde_json::Value::Object(ref mut map) = val {
631 map.insert(
632 "_backend_log_level".to_owned(),
633 serde_json::Value::String(self.config.backend_log_level.as_str().to_owned()),
634 );
635 }
636 serde_json::to_string(&val).map_err(|e| Error::BackendError {
637 message: format!("Failed to re-serialize config JSON: {e}"),
638 })?
639 };
640
641 let custom_tool_names: std::collections::HashSet<String> =
643 config.tools.iter().map(|t| t.name.clone()).collect();
644
645 let (raw_id, raw_tools) = self
646 .send_command("create_agent", |reply| PyCommand::CreateAgent {
647 agent_id,
648 config_json,
649 reply,
650 })
651 .await?;
652
653 let active_builtins = compute_active_builtins(&config);
655 let builtin_names: std::collections::HashSet<&str> = active_builtins
656 .iter()
657 .map(crate::config::BuiltinTools::as_sdk_name)
658 .collect();
659
660 let mut available_tools: Vec<crate::tools::AvailableTool> = raw_tools
664 .into_iter()
665 .filter(|raw| !builtin_names.contains(raw.name.as_str()))
666 .map(|raw| {
667 let source = if custom_tool_names.contains(&raw.name) {
668 crate::tools::ToolSource::Custom
669 } else {
670 crate::tools::ToolSource::Mcp
671 };
672 crate::tools::AvailableTool {
673 name: raw.name,
674 description: raw.description,
675 parameter_schema: raw.parameter_schema,
676 source,
677 }
678 })
679 .collect();
680
681 for builtin in active_builtins {
683 available_tools.push(crate::tools::AvailableTool {
684 name: builtin.as_sdk_name().to_owned(),
685 description: builtin.description().to_owned(),
686 parameter_schema: serde_json::Value::Null,
687 source: crate::tools::ToolSource::Builtin,
688 });
689 }
690
691 tracing::info!(
692 agent_id = raw_id.0,
693 tool_count = available_tools.len(),
694 tools = ?available_tools.iter().map(|t| format!("{t}")).collect::<Vec<_>>(),
695 "Agent created with available tools"
696 );
697
698 Ok((raw_id.0, available_tools))
699 }
700
701 async fn chat(
702 &self,
703 agent_id: crate::agent::AgentId,
704 content: &crate::content::Content,
705 ) -> Result<crate::streaming::ChatResponseHandle, Error> {
706 let prompt = match content {
707 crate::content::Content::Text { text } => text.clone(),
708 other => crate::content::content_to_json(other)?,
709 };
710 self.send_command("chat", |reply| PyCommand::Chat {
711 agent_id: AgentId(agent_id),
712 prompt,
713 reply,
714 })
715 .await
716 }
717
718 async fn shutdown_agent(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
719 self.send_command("shutdown_agent", |reply| PyCommand::ShutdownAgent {
720 agent_id: AgentId(agent_id),
721 reply,
722 })
723 .await
724 }
725
726 fn try_shutdown_agent(&self, agent_id: crate::agent::AgentId) {
727 let (reply, _) = oneshot::channel();
731 if let Err(e) = self.cmd_tx.try_send(PyCommand::ShutdownAgent {
732 agent_id: AgentId(agent_id),
733 reply,
734 }) {
735 tracing::debug!(
736 agent_id = agent_id,
737 error = %e,
738 "try_shutdown_agent: channel send failed (runtime may already be gone)"
739 );
740 }
741 }
742
743 async fn cancel(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
744 self.send_command("cancel", |reply| PyCommand::Cancel {
745 agent_id: AgentId(agent_id),
746 reply,
747 })
748 .await
749 }
750
751 async fn wait_for_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
752 self.send_command("wait_for_idle", |reply| PyCommand::WaitForIdle {
753 agent_id: AgentId(agent_id),
754 reply,
755 })
756 .await
757 }
758
759 async fn send(
760 &self,
761 agent_id: crate::agent::AgentId,
762 content: &crate::content::Content,
763 ) -> Result<(), Error> {
764 let prompt = match content {
765 crate::content::Content::Text { text } => text.clone(),
766 other => crate::content::content_to_json(other)?,
767 };
768 self.send_command("send", |reply| PyCommand::Send {
769 agent_id: AgentId(agent_id),
770 prompt,
771 reply,
772 })
773 .await
774 }
775
776 async fn signal_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
777 self.send_command("signal_idle", |reply| PyCommand::SignalIdle {
778 agent_id: AgentId(agent_id),
779 reply,
780 })
781 .await
782 }
783
784 async fn wait_for_wakeup(
785 &self,
786 agent_id: crate::agent::AgentId,
787 timeout: std::time::Duration,
788 ) -> Result<bool, Error> {
789 self.send_command("wait_for_wakeup", |reply| PyCommand::WaitForWakeup {
790 agent_id: AgentId(agent_id),
791 timeout_secs: timeout.as_secs_f64(),
792 reply,
793 })
794 .await
795 }
796
797 async fn history(
798 &self,
799 agent_id: crate::agent::AgentId,
800 ) -> Result<Vec<crate::types::ConversationMessage>, Error> {
801 self.send_command("get_history", |reply| PyCommand::GetHistory {
802 agent_id: AgentId(agent_id),
803 reply,
804 })
805 .await
806 }
807
808 async fn turn_count(&self, agent_id: crate::agent::AgentId) -> Result<u32, Error> {
809 self.send_command("get_turn_count", |reply| PyCommand::GetTurnCount {
810 agent_id: AgentId(agent_id),
811 reply,
812 })
813 .await
814 }
815
816 async fn total_usage(
817 &self,
818 agent_id: crate::agent::AgentId,
819 ) -> Result<crate::types::UsageMetadata, Error> {
820 self.send_command("get_total_usage", |reply| PyCommand::GetTotalUsage {
821 agent_id: AgentId(agent_id),
822 reply,
823 })
824 .await
825 }
826
827 async fn last_turn_usage(
828 &self,
829 agent_id: crate::agent::AgentId,
830 ) -> Result<crate::types::UsageMetadata, Error> {
831 self.send_command("get_last_turn_usage", |reply| PyCommand::GetLastTurnUsage {
832 agent_id: AgentId(agent_id),
833 reply,
834 })
835 .await
836 }
837
838 async fn clear_history(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
839 self.send_command("clear_history", |reply| PyCommand::ClearHistory {
840 agent_id: AgentId(agent_id),
841 reply,
842 })
843 .await
844 }
845
846 async fn compaction_indices(&self, agent_id: crate::agent::AgentId) -> Result<Vec<u32>, Error> {
847 self.send_command("compaction_indices", |reply| {
848 PyCommand::GetCompactionIndices {
849 agent_id: AgentId(agent_id),
850 reply,
851 }
852 })
853 .await
854 }
855
856 async fn last_response(
857 &self,
858 agent_id: crate::agent::AgentId,
859 ) -> Result<Option<String>, Error> {
860 self.send_command("last_response", |reply| PyCommand::GetLastResponse {
861 agent_id: AgentId(agent_id),
862 reply,
863 })
864 .await
865 }
866
867 async fn delete(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
868 self.send_command("delete", |reply| PyCommand::Delete {
869 agent_id: AgentId(agent_id),
870 reply,
871 })
872 .await
873 }
874
875 async fn disconnect(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
876 self.send_command("disconnect", |reply| PyCommand::Disconnect {
877 agent_id: AgentId(agent_id),
878 reply,
879 })
880 .await
881 }
882
883 async fn is_idle(&self, agent_id: crate::agent::AgentId) -> Result<bool, Error> {
884 self.send_command("is_idle", |reply| PyCommand::IsIdle {
885 agent_id: AgentId(agent_id),
886 reply,
887 })
888 .await
889 }
890}