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;
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 RemoveLastTurn {
168 agent_id: AgentId,
169 reply: oneshot::Sender<Result<(), Error>>,
170 },
171 GetCompactionIndices {
173 agent_id: AgentId,
174 reply: oneshot::Sender<Result<Vec<u32>, Error>>,
175 },
176 GetLastResponse {
178 agent_id: AgentId,
179 reply: oneshot::Sender<Result<Option<String>, Error>>,
180 },
181 Delete {
186 agent_id: AgentId,
187 reply: oneshot::Sender<Result<(), Error>>,
188 },
189 Disconnect {
193 agent_id: AgentId,
194 reply: oneshot::Sender<Result<(), Error>>,
195 },
196 IsIdle {
200 agent_id: AgentId,
201 reply: oneshot::Sender<Result<bool, Error>>,
202 },
203}
204
205pub struct PythonRuntime {
210 cmd_tx: mpsc::Sender<PyCommand>,
211 thread: Option<std::thread::JoinHandle<()>>,
212 config: RuntimeConfig,
213 quota_registry: crate::quota::QuotaRegistry,
216 quota_state: Arc<QuotaState>,
218}
219
220impl std::fmt::Debug for PythonRuntime {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 f.debug_struct("PythonRuntime")
223 .field("config", &self.config)
224 .field(
225 "thread_running",
226 &self.thread.as_ref().is_some_and(|t| !t.is_finished()),
227 )
228 .finish_non_exhaustive()
229 }
230}
231
232impl PythonRuntime {
233 pub fn new(config: RuntimeConfig) -> Result<Self, Error> {
243 let (cmd_tx, cmd_rx) = mpsc::channel(config.channel_capacity);
244
245 let thread_config = config.clone();
246 let thread = std::thread::Builder::new()
247 .name("agy-bridge-python-runtime".into())
248 .spawn(move || {
249 python_thread_main(cmd_rx, &thread_config);
250 })
251 .map_err(|e| Error::BackendError {
252 message: format!("Failed to spawn Python runtime thread: {e}"),
253 })?;
254
255 let quota_registry = crate::quota::QuotaRegistry::new();
256 let quota_state = quota_registry.state_for_key("");
257 Ok(Self {
258 cmd_tx,
259 thread: Some(thread),
260 config,
261 quota_registry,
262 quota_state,
263 })
264 }
265
266 async fn send_command<T>(
276 &self,
277 operation: &str,
278 is_llm_op: bool,
279 build_cmd: impl FnOnce(oneshot::Sender<Result<T, Error>>) -> PyCommand,
280 ) -> Result<T, Error> {
281 let (reply_tx, reply_rx) = oneshot::channel();
282 let cmd = build_cmd(reply_tx);
283
284 self.cmd_tx
285 .send(cmd)
286 .await
287 .map_err(|e| Error::ChannelClosed {
288 message: format!("Python runtime thread has exited (sending {operation}): {e}"),
289 })?;
290
291 let result = reply_rx.await.map_err(|e| Error::ChannelClosed {
292 message: format!("Reply channel dropped for {operation}: {e}"),
293 })??;
294
295 if is_llm_op {
298 self.quota_state.record_success();
299 }
300
301 Ok(result)
302 }
303
304 pub(crate) async fn active_agent_count(&self) -> Result<usize, Error> {
313 self.send_command("active_agent_count", false, |reply| {
314 PyCommand::GetActiveAgentCount { reply }
315 })
316 .await
317 }
318
319 pub async fn shutdown(mut self) -> Result<(), Error> {
327 if let Err(e) = self.cmd_tx.send(PyCommand::Shutdown).await {
331 tracing::warn!("Shutdown command send failed (thread may already be exiting): {e}");
332 }
333
334 let Some(thread) = self.thread.take() else {
337 tracing::warn!("PythonRuntime::shutdown() called but thread handle already taken");
338 return Ok(());
339 };
340
341 let shutdown_timeout = self.config.shutdown_timeout;
342 let join_result = tokio::time::timeout(
343 shutdown_timeout,
344 tokio::task::spawn_blocking(move || thread.join()),
345 )
346 .await;
347
348 match join_result {
349 Ok(Ok(Ok(()))) => {
350 tracing::info!("Python runtime thread joined successfully");
351 Ok(())
352 }
353 Ok(Ok(Err(panic_payload))) => {
354 let panic_msg = panic_payload.downcast_ref::<&str>().map_or_else(
355 || {
356 panic_payload
357 .downcast_ref::<String>()
358 .map_or_else(|| format!("{panic_payload:?}"), Clone::clone)
359 },
360 |s| (*s).to_string(),
361 );
362 tracing::error!(
363 panic_message = %panic_msg,
364 "Python runtime thread panicked during shutdown"
365 );
366 Err(Error::BackendError {
367 message: format!("Python runtime thread panicked during shutdown: {panic_msg}"),
368 })
369 }
370 Ok(Err(join_err)) => {
371 tracing::error!("spawn_blocking join error: {join_err}");
372 Err(Error::BackendError {
373 message: format!("Failed to join Python thread: {join_err}"),
374 })
375 }
376 Err(_elapsed) => {
377 tracing::error!(
378 timeout_secs = shutdown_timeout.as_secs(),
379 "Python runtime thread did not exit within shutdown timeout"
380 );
381 Err(Error::Timeout {
382 duration: shutdown_timeout,
383 operation: "PythonRuntime::shutdown (thread join)".to_string(),
384 })
385 }
386 }
387 }
388
389 #[must_use]
391 pub const fn quota_state(&self) -> &Arc<QuotaState> {
392 &self.quota_state
393 }
394}
395
396impl Drop for PythonRuntime {
397 fn drop(&mut self) {
398 let Some(thread) = self.thread.take() else {
401 return;
402 };
403
404 if let Err(e) = self.cmd_tx.try_send(PyCommand::Shutdown) {
410 tracing::debug!(
411 error = %e,
412 "PythonRuntime::drop: could not eagerly signal shutdown; \
413 relying on channel close"
414 );
415 }
416
417 let deadline = std::time::Instant::now() + self.config.shutdown_timeout;
422 while !thread.is_finished() && std::time::Instant::now() < deadline {
423 std::thread::sleep(std::time::Duration::from_millis(5));
424 }
425
426 if thread.is_finished() {
427 if thread.join().is_err() {
428 tracing::error!("Python runtime thread panicked during drop cleanup");
429 } else {
430 tracing::debug!("Python runtime thread joined cleanly on drop");
431 }
432 } else {
433 tracing::warn!(
437 "Python runtime thread still running after shutdown timeout during drop — \
438 detaching; agent cleanup will complete asynchronously"
439 );
440 }
441 }
442}
443
444fn python_thread_main(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) {
446 Python::initialize();
447
448 Python::attach(|py| {
453 if let Err(e) = venv::configure_python_sys_path(py) {
454 tracing::error!(
455 error = %e,
456 "Failed to configure Python sys.path in runtime thread — \
457 venv imports will likely fail"
458 );
459 }
460 });
461
462 if let Err(e) = run_live_thread(cmd_rx, config) {
463 tracing::error!(error = %e, "Python runtime thread failed");
464 }
465
466 tracing::info!("Python runtime thread exiting");
467}
468
469fn run_live_thread(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) -> Result<(), Error> {
472 Python::attach(|py| {
473 let asyncio = py.import("asyncio").map_err(|e| Error::BackendError {
474 message: format!("Failed to import asyncio: {e}"),
475 })?;
476 let event_loop =
477 asyncio
478 .call_method0("new_event_loop")
479 .map_err(|e| Error::BackendError {
480 message: format!("Failed to create new asyncio event loop: {e}"),
481 })?;
482 asyncio
483 .call_method1("set_event_loop", (&event_loop,))
484 .map_err(|e| Error::BackendError {
485 message: format!("Failed to set asyncio event loop: {e}"),
486 })?;
487
488 let sys = py.import("sys").map_err(|e| Error::BackendError {
490 message: format!("Failed to import sys: {e}"),
491 })?;
492 let sys_modules = sys.getattr("modules").map_err(|e| Error::BackendError {
493 message: format!("Failed to get sys.modules: {e}"),
494 })?;
495 let globals_mod = if sys_modules
496 .contains(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
497 .map_err(|e| Error::BackendError {
498 message: format!("Failed to check sys.modules: {e}"),
499 })? {
500 sys_modules
501 .get_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
502 .map_err(|e| Error::BackendError {
503 message: format!("Failed to get _agy_bridge_globals: {e}"),
504 })?
505 } else {
506 let types = py.import("types").map_err(|e| Error::BackendError {
507 message: format!("Failed to import types: {e}"),
508 })?;
509 let module = types
510 .getattr("ModuleType")
511 .map_err(|e| Error::BackendError {
512 message: format!("Failed to get ModuleType: {e}"),
513 })?
514 .call1((command_loop::AGY_BRIDGE_GLOBALS_MODULE,))
515 .map_err(|e| Error::BackendError {
516 message: format!("Failed to create ModuleType: {e}"),
517 })?;
518 sys_modules
519 .set_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE, &module)
520 .map_err(|e| Error::BackendError {
521 message: format!("Failed to register _agy_bridge_globals: {e}"),
522 })?;
523 module
524 };
525 globals_mod
526 .setattr("EVENT_LOOP", &event_loop)
527 .map_err(|e| Error::BackendError {
528 message: format!("Failed to set EVENT_LOOP in globals: {e}"),
529 })?;
530
531 tracing::info!("Python asyncio event loop created on runtime thread");
532
533 let inter_agent_delay = config.inter_agent_delay;
534 let event_loop_obj = event_loop.clone().unbind();
535 let run_fut =
536 pyo3_async_runtimes::tokio::run_until_complete(event_loop.clone(), async move {
537 command_loop::run_async_command_loop(event_loop_obj, cmd_rx, inter_agent_delay)
538 .await
539 });
540
541 if let Err(e) = run_fut {
542 if let Err(close_err) = event_loop.call_method0("close") {
544 tracing::warn!("Failed to close asyncio event loop: {close_err}");
545 }
546 return Err(Error::BackendError {
547 message: format!("Python runtime command loop failed: {e}"),
548 });
549 }
550
551 if let Err(e) = event_loop.call_method0("close") {
552 tracing::warn!("Failed to close asyncio event loop: {e}");
553 }
554
555 Ok(())
556 })
557}
558
559fn compute_active_builtins(
566 config: &crate::config::AgentConfig,
567) -> Vec<crate::config::BuiltinTools> {
568 let Some(caps) = config.capabilities.as_ref() else {
569 return crate::config::BuiltinTools::all_tools().to_vec();
570 };
571
572 if let Some(enabled) = caps.enabled_tools.as_ref() {
575 return enabled.clone();
576 }
577
578 if let Some(disabled) = caps.disabled_tools.as_ref() {
580 return crate::config::BuiltinTools::all_tools()
581 .iter()
582 .filter(|t| !disabled.contains(t))
583 .cloned()
584 .collect();
585 }
586
587 crate::config::BuiltinTools::all_tools().to_vec()
589}
590
591impl crate::agent::Runtime for PythonRuntime {
592 async fn create_agent(
593 &self,
594 agent_id: u64,
595 config: crate::config::AgentConfig,
596 ) -> Result<(crate::agent::AgentId, Vec<crate::tools::AvailableTool>), Error> {
597 let config_json = {
601 let mut val = serde_json::to_value(&config).map_err(|e| Error::BackendError {
602 message: format!("Failed to serialize AgentConfig: {e}"),
603 })?;
604 if let serde_json::Value::Object(ref mut map) = val {
605 map.insert(
606 "_backend_log_level".to_owned(),
607 serde_json::Value::String(self.config.backend_log_level.as_str().to_owned()),
608 );
609 }
610 serde_json::to_string(&val).map_err(|e| Error::BackendError {
611 message: format!("Failed to re-serialize config JSON: {e}"),
612 })?
613 };
614
615 let custom_tool_names: std::collections::HashSet<String> =
617 config.tools.iter().map(|t| t.name.clone()).collect();
618
619 let (raw_id, raw_tools) = self
620 .send_command("create_agent", false, |reply| PyCommand::CreateAgent {
621 agent_id,
622 config_json,
623 reply,
624 })
625 .await?;
626
627 let active_builtins = compute_active_builtins(&config);
629 let builtin_names: std::collections::HashSet<&str> = active_builtins
630 .iter()
631 .map(crate::config::BuiltinTools::as_sdk_name)
632 .collect();
633
634 let mut available_tools: Vec<crate::tools::AvailableTool> = raw_tools
638 .into_iter()
639 .filter(|raw| !builtin_names.contains(raw.name.as_str()))
640 .map(|raw| {
641 let source = if custom_tool_names.contains(&raw.name) {
642 crate::tools::ToolSource::Custom
643 } else {
644 crate::tools::ToolSource::Mcp
645 };
646 crate::tools::AvailableTool {
647 name: raw.name,
648 description: raw.description,
649 parameter_schema: raw.parameter_schema,
650 source,
651 }
652 })
653 .collect();
654
655 for builtin in active_builtins {
657 available_tools.push(crate::tools::AvailableTool {
658 name: builtin.as_sdk_name().to_owned(),
659 description: builtin.description().to_owned(),
660 parameter_schema: serde_json::Value::Null,
661 source: crate::tools::ToolSource::Builtin,
662 });
663 }
664
665 tracing::info!(
666 agent_id = raw_id.0,
667 tool_count = available_tools.len(),
668 tools = ?available_tools.iter().map(|t| format!("{t}")).collect::<Vec<_>>(),
669 "Agent created with available tools"
670 );
671
672 Ok((raw_id.0, available_tools))
673 }
674
675 async fn chat(
676 &self,
677 agent_id: crate::agent::AgentId,
678 content: &crate::content::Content,
679 ) -> Result<crate::streaming::ChatResponseHandle, Error> {
680 let prompt = match content {
681 crate::content::Content::Text { text } => text.clone(),
682 other => crate::content::content_to_json(other)?,
683 };
684 self.send_command("chat", true, |reply| PyCommand::Chat {
685 agent_id: AgentId(agent_id),
686 prompt,
687 reply,
688 })
689 .await
690 }
691
692 async fn shutdown_agent(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
693 self.send_command("shutdown_agent", false, |reply| PyCommand::ShutdownAgent {
694 agent_id: AgentId(agent_id),
695 reply,
696 })
697 .await
698 }
699
700 fn try_shutdown_agent(&self, agent_id: crate::agent::AgentId) {
701 let (reply, _) = oneshot::channel();
705 if let Err(e) = self.cmd_tx.try_send(PyCommand::ShutdownAgent {
706 agent_id: AgentId(agent_id),
707 reply,
708 }) {
709 tracing::debug!(
710 agent_id = agent_id,
711 error = %e,
712 "try_shutdown_agent: channel send failed (runtime may already be gone)"
713 );
714 }
715 }
716
717 async fn cancel(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
718 self.send_command("cancel", false, |reply| PyCommand::Cancel {
719 agent_id: AgentId(agent_id),
720 reply,
721 })
722 .await
723 }
724
725 async fn wait_for_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
726 self.send_command("wait_for_idle", false, |reply| PyCommand::WaitForIdle {
727 agent_id: AgentId(agent_id),
728 reply,
729 })
730 .await
731 }
732
733 async fn send(
734 &self,
735 agent_id: crate::agent::AgentId,
736 content: &crate::content::Content,
737 ) -> Result<(), Error> {
738 let prompt = match content {
739 crate::content::Content::Text { text } => text.clone(),
740 other => crate::content::content_to_json(other)?,
741 };
742 self.send_command("send", false, |reply| PyCommand::Send {
743 agent_id: AgentId(agent_id),
744 prompt,
745 reply,
746 })
747 .await
748 }
749
750 async fn signal_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
751 self.send_command("signal_idle", false, |reply| PyCommand::SignalIdle {
752 agent_id: AgentId(agent_id),
753 reply,
754 })
755 .await
756 }
757
758 async fn wait_for_wakeup(
759 &self,
760 agent_id: crate::agent::AgentId,
761 timeout: std::time::Duration,
762 ) -> Result<bool, Error> {
763 self.send_command("wait_for_wakeup", false, |reply| PyCommand::WaitForWakeup {
764 agent_id: AgentId(agent_id),
765 timeout_secs: timeout.as_secs_f64(),
766 reply,
767 })
768 .await
769 }
770
771 async fn wait_for_quota(&self) {
772 self.quota_state.wait_for_quota().await;
773 }
774
775 async fn record_quota_hit(&self, retry_after: std::time::Duration) {
776 self.quota_state.record_quota_hit(retry_after);
777 }
778
779 fn quota_registry(&self) -> &crate::quota::QuotaRegistry {
780 &self.quota_registry
781 }
782
783 async fn history(
784 &self,
785 agent_id: crate::agent::AgentId,
786 ) -> Result<Vec<crate::types::ConversationMessage>, Error> {
787 self.send_command("get_history", false, |reply| PyCommand::GetHistory {
788 agent_id: AgentId(agent_id),
789 reply,
790 })
791 .await
792 }
793
794 async fn turn_count(&self, agent_id: crate::agent::AgentId) -> Result<u32, Error> {
795 self.send_command("get_turn_count", false, |reply| PyCommand::GetTurnCount {
796 agent_id: AgentId(agent_id),
797 reply,
798 })
799 .await
800 }
801
802 async fn total_usage(
803 &self,
804 agent_id: crate::agent::AgentId,
805 ) -> Result<crate::types::UsageMetadata, Error> {
806 self.send_command("get_total_usage", false, |reply| PyCommand::GetTotalUsage {
807 agent_id: AgentId(agent_id),
808 reply,
809 })
810 .await
811 }
812
813 async fn last_turn_usage(
814 &self,
815 agent_id: crate::agent::AgentId,
816 ) -> Result<crate::types::UsageMetadata, Error> {
817 self.send_command("get_last_turn_usage", false, |reply| {
818 PyCommand::GetLastTurnUsage {
819 agent_id: AgentId(agent_id),
820 reply,
821 }
822 })
823 .await
824 }
825
826 async fn clear_history(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
827 self.send_command("clear_history", false, |reply| PyCommand::ClearHistory {
828 agent_id: AgentId(agent_id),
829 reply,
830 })
831 .await
832 }
833
834 async fn remove_last_turn(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
835 self.send_command("remove_last_turn", false, |reply| {
836 PyCommand::RemoveLastTurn {
837 agent_id: AgentId(agent_id),
838 reply,
839 }
840 })
841 .await
842 }
843
844 async fn compaction_indices(&self, agent_id: crate::agent::AgentId) -> Result<Vec<u32>, Error> {
845 self.send_command("compaction_indices", false, |reply| {
846 PyCommand::GetCompactionIndices {
847 agent_id: AgentId(agent_id),
848 reply,
849 }
850 })
851 .await
852 }
853
854 async fn last_response(
855 &self,
856 agent_id: crate::agent::AgentId,
857 ) -> Result<Option<String>, Error> {
858 self.send_command("last_response", false, |reply| PyCommand::GetLastResponse {
859 agent_id: AgentId(agent_id),
860 reply,
861 })
862 .await
863 }
864
865 async fn delete(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
866 self.send_command("delete", false, |reply| PyCommand::Delete {
867 agent_id: AgentId(agent_id),
868 reply,
869 })
870 .await
871 }
872
873 async fn disconnect(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
874 self.send_command("disconnect", false, |reply| PyCommand::Disconnect {
875 agent_id: AgentId(agent_id),
876 reply,
877 })
878 .await
879 }
880
881 async fn is_idle(&self, agent_id: crate::agent::AgentId) -> Result<bool, Error> {
882 self.send_command("is_idle", false, |reply| PyCommand::IsIdle {
883 agent_id: AgentId(agent_id),
884 reply,
885 })
886 .await
887 }
888}