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 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}
214
215impl std::fmt::Debug for PythonRuntime {
216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217 f.debug_struct("PythonRuntime")
218 .field("config", &self.config)
219 .field(
220 "thread_running",
221 &self.thread.as_ref().is_some_and(|t| !t.is_finished()),
222 )
223 .finish_non_exhaustive()
224 }
225}
226
227impl PythonRuntime {
228 pub fn new(config: RuntimeConfig) -> Result<Self, Error> {
238 let (cmd_tx, cmd_rx) = mpsc::channel(config.channel_capacity);
239
240 let thread_config = config.clone();
241 let thread = std::thread::Builder::new()
242 .name("agy-bridge-python-runtime".into())
243 .spawn(move || {
244 python_thread_main(cmd_rx, &thread_config);
245 })
246 .map_err(|e| Error::BackendError {
247 message: format!("Failed to spawn Python runtime thread: {e}"),
248 })?;
249
250 Ok(Self {
251 cmd_tx,
252 thread: Some(thread),
253 config,
254 })
255 }
256
257 async fn send_command<T>(
266 &self,
267 operation: &str,
268 build_cmd: impl FnOnce(oneshot::Sender<Result<T, Error>>) -> PyCommand,
269 ) -> Result<T, Error> {
270 let (reply_tx, reply_rx) = oneshot::channel();
271 let cmd = build_cmd(reply_tx);
272
273 self.cmd_tx
274 .send(cmd)
275 .await
276 .map_err(|e| Error::ChannelClosed {
277 message: format!("Python runtime thread has exited (sending {operation}): {e}"),
278 })?;
279
280 let result = reply_rx.await.map_err(|e| Error::ChannelClosed {
281 message: format!("Reply channel dropped for {operation}: {e}"),
282 })??;
283
284 Ok(result)
285 }
286
287 pub(crate) async fn active_agent_count(&self) -> Result<usize, Error> {
296 self.send_command("active_agent_count", |reply| {
297 PyCommand::GetActiveAgentCount { reply }
298 })
299 .await
300 }
301
302 pub async fn shutdown(mut self) -> Result<(), Error> {
310 if let Err(e) = self.cmd_tx.send(PyCommand::Shutdown).await {
314 tracing::warn!("Shutdown command send failed (thread may already be exiting): {e}");
315 }
316
317 let Some(thread) = self.thread.take() else {
320 tracing::warn!("PythonRuntime::shutdown() called but thread handle already taken");
321 return Ok(());
322 };
323
324 let shutdown_timeout = self.config.shutdown_timeout;
325 let join_result = tokio::time::timeout(
326 shutdown_timeout,
327 tokio::task::spawn_blocking(move || thread.join()),
328 )
329 .await;
330
331 match join_result {
332 Ok(Ok(Ok(()))) => {
333 tracing::info!("Python runtime thread joined successfully");
334 Ok(())
335 }
336 Ok(Ok(Err(panic_payload))) => {
337 let panic_msg = panic_payload.downcast_ref::<&str>().map_or_else(
338 || {
339 panic_payload
340 .downcast_ref::<String>()
341 .map_or_else(|| format!("{panic_payload:?}"), Clone::clone)
342 },
343 |s| (*s).to_string(),
344 );
345 tracing::error!(
346 panic_message = %panic_msg,
347 "Python runtime thread panicked during shutdown"
348 );
349 Err(Error::BackendError {
350 message: format!("Python runtime thread panicked during shutdown: {panic_msg}"),
351 })
352 }
353 Ok(Err(join_err)) => {
354 tracing::error!("spawn_blocking join error: {join_err}");
355 Err(Error::BackendError {
356 message: format!("Failed to join Python thread: {join_err}"),
357 })
358 }
359 Err(_elapsed) => {
360 tracing::error!(
361 timeout_secs = shutdown_timeout.as_secs(),
362 "Python runtime thread did not exit within shutdown timeout"
363 );
364 Err(Error::Timeout {
365 duration: shutdown_timeout,
366 operation: "PythonRuntime::shutdown (thread join)".to_string(),
367 })
368 }
369 }
370 }
371}
372
373impl Drop for PythonRuntime {
374 fn drop(&mut self) {
375 let Some(thread) = self.thread.take() else {
378 return;
379 };
380
381 if let Err(e) = self.cmd_tx.try_send(PyCommand::Shutdown) {
387 tracing::debug!(
388 error = %e,
389 "PythonRuntime::drop: could not eagerly signal shutdown; \
390 relying on channel close"
391 );
392 }
393
394 let deadline = std::time::Instant::now() + self.config.shutdown_timeout;
399 while !thread.is_finished() && std::time::Instant::now() < deadline {
400 std::thread::sleep(std::time::Duration::from_millis(5));
401 }
402
403 if thread.is_finished() {
404 if thread.join().is_err() {
405 tracing::error!("Python runtime thread panicked during drop cleanup");
406 } else {
407 tracing::debug!("Python runtime thread joined cleanly on drop");
408 }
409 } else {
410 tracing::warn!(
414 "Python runtime thread still running after shutdown timeout during drop — \
415 detaching; agent cleanup will complete asynchronously"
416 );
417 }
418 }
419}
420
421fn python_thread_main(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) {
423 Python::initialize();
424
425 Python::attach(|py| {
430 if let Err(e) = venv::configure_python_sys_path(py) {
431 tracing::error!(
432 error = %e,
433 "Failed to configure Python sys.path in runtime thread — \
434 venv imports will likely fail"
435 );
436 }
437 });
438
439 if let Err(e) = run_live_thread(cmd_rx, config) {
440 tracing::error!(error = %e, "Python runtime thread failed");
441 }
442
443 tracing::info!("Python runtime thread exiting");
444}
445
446fn run_live_thread(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) -> Result<(), Error> {
449 Python::attach(|py| {
450 let asyncio = py.import("asyncio").map_err(|e| Error::BackendError {
451 message: format!("Failed to import asyncio: {e}"),
452 })?;
453 let event_loop =
454 asyncio
455 .call_method0("new_event_loop")
456 .map_err(|e| Error::BackendError {
457 message: format!("Failed to create new asyncio event loop: {e}"),
458 })?;
459 asyncio
460 .call_method1("set_event_loop", (&event_loop,))
461 .map_err(|e| Error::BackendError {
462 message: format!("Failed to set asyncio event loop: {e}"),
463 })?;
464
465 let sys = py.import("sys").map_err(|e| Error::BackendError {
467 message: format!("Failed to import sys: {e}"),
468 })?;
469 let sys_modules = sys.getattr("modules").map_err(|e| Error::BackendError {
470 message: format!("Failed to get sys.modules: {e}"),
471 })?;
472 let globals_mod = if sys_modules
473 .contains(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
474 .map_err(|e| Error::BackendError {
475 message: format!("Failed to check sys.modules: {e}"),
476 })? {
477 sys_modules
478 .get_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
479 .map_err(|e| Error::BackendError {
480 message: format!("Failed to get _agy_bridge_globals: {e}"),
481 })?
482 } else {
483 let types = py.import("types").map_err(|e| Error::BackendError {
484 message: format!("Failed to import types: {e}"),
485 })?;
486 let module = types
487 .getattr("ModuleType")
488 .map_err(|e| Error::BackendError {
489 message: format!("Failed to get ModuleType: {e}"),
490 })?
491 .call1((command_loop::AGY_BRIDGE_GLOBALS_MODULE,))
492 .map_err(|e| Error::BackendError {
493 message: format!("Failed to create ModuleType: {e}"),
494 })?;
495 sys_modules
496 .set_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE, &module)
497 .map_err(|e| Error::BackendError {
498 message: format!("Failed to register _agy_bridge_globals: {e}"),
499 })?;
500 module
501 };
502 globals_mod
503 .setattr("EVENT_LOOP", &event_loop)
504 .map_err(|e| Error::BackendError {
505 message: format!("Failed to set EVENT_LOOP in globals: {e}"),
506 })?;
507
508 tracing::info!("Python asyncio event loop created on runtime thread");
509
510 let inter_agent_delay = config.inter_agent_delay;
511 let event_loop_obj = event_loop.clone().unbind();
512 let run_fut =
513 pyo3_async_runtimes::tokio::run_until_complete(event_loop.clone(), async move {
514 command_loop::run_async_command_loop(event_loop_obj, cmd_rx, inter_agent_delay)
515 .await
516 });
517
518 if let Err(e) = run_fut {
519 if let Err(close_err) = event_loop.call_method0("close") {
521 tracing::warn!("Failed to close asyncio event loop: {close_err}");
522 }
523 return Err(Error::BackendError {
524 message: format!("Python runtime command loop failed: {e}"),
525 });
526 }
527
528 if let Err(e) = event_loop.call_method0("close") {
529 tracing::warn!("Failed to close asyncio event loop: {e}");
530 }
531
532 Ok(())
533 })
534}
535
536fn compute_active_builtins(
543 config: &crate::config::AgentConfig,
544) -> Vec<crate::config::BuiltinTools> {
545 let Some(caps) = config.capabilities.as_ref() else {
546 return crate::config::BuiltinTools::all_tools().to_vec();
547 };
548
549 if let Some(enabled) = caps.enabled_tools.as_ref() {
552 return enabled.clone();
553 }
554
555 if let Some(disabled) = caps.disabled_tools.as_ref() {
557 return crate::config::BuiltinTools::all_tools()
558 .iter()
559 .filter(|t| !disabled.contains(t))
560 .cloned()
561 .collect();
562 }
563
564 crate::config::BuiltinTools::all_tools().to_vec()
566}
567
568impl crate::agent::Runtime for PythonRuntime {
569 async fn create_agent(
570 &self,
571 agent_id: u64,
572 config: crate::config::AgentConfig,
573 ) -> Result<(crate::agent::AgentId, Vec<crate::tools::AvailableTool>), Error> {
574 let config_json = {
578 let mut val = serde_json::to_value(&config).map_err(|e| Error::BackendError {
579 message: format!("Failed to serialize AgentConfig: {e}"),
580 })?;
581 if let serde_json::Value::Object(ref mut map) = val {
582 map.insert(
583 "_backend_log_level".to_owned(),
584 serde_json::Value::String(self.config.backend_log_level.as_str().to_owned()),
585 );
586 }
587 serde_json::to_string(&val).map_err(|e| Error::BackendError {
588 message: format!("Failed to re-serialize config JSON: {e}"),
589 })?
590 };
591
592 let custom_tool_names: std::collections::HashSet<String> =
594 config.tools.iter().map(|t| t.name.clone()).collect();
595
596 let (raw_id, raw_tools) = self
597 .send_command("create_agent", |reply| PyCommand::CreateAgent {
598 agent_id,
599 config_json,
600 reply,
601 })
602 .await?;
603
604 let active_builtins = compute_active_builtins(&config);
606 let builtin_names: std::collections::HashSet<&str> = active_builtins
607 .iter()
608 .map(crate::config::BuiltinTools::as_sdk_name)
609 .collect();
610
611 let mut available_tools: Vec<crate::tools::AvailableTool> = raw_tools
615 .into_iter()
616 .filter(|raw| !builtin_names.contains(raw.name.as_str()))
617 .map(|raw| {
618 let source = if custom_tool_names.contains(&raw.name) {
619 crate::tools::ToolSource::Custom
620 } else {
621 crate::tools::ToolSource::Mcp
622 };
623 crate::tools::AvailableTool {
624 name: raw.name,
625 description: raw.description,
626 parameter_schema: raw.parameter_schema,
627 source,
628 }
629 })
630 .collect();
631
632 for builtin in active_builtins {
634 available_tools.push(crate::tools::AvailableTool {
635 name: builtin.as_sdk_name().to_owned(),
636 description: builtin.description().to_owned(),
637 parameter_schema: serde_json::Value::Null,
638 source: crate::tools::ToolSource::Builtin,
639 });
640 }
641
642 tracing::info!(
643 agent_id = raw_id.0,
644 tool_count = available_tools.len(),
645 tools = ?available_tools.iter().map(|t| format!("{t}")).collect::<Vec<_>>(),
646 "Agent created with available tools"
647 );
648
649 Ok((raw_id.0, available_tools))
650 }
651
652 async fn chat(
653 &self,
654 agent_id: crate::agent::AgentId,
655 content: &crate::content::Content,
656 ) -> Result<crate::streaming::ChatResponseHandle, Error> {
657 let prompt = match content {
658 crate::content::Content::Text { text } => text.clone(),
659 other => crate::content::content_to_json(other)?,
660 };
661 self.send_command("chat", |reply| PyCommand::Chat {
662 agent_id: AgentId(agent_id),
663 prompt,
664 reply,
665 })
666 .await
667 }
668
669 async fn shutdown_agent(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
670 self.send_command("shutdown_agent", |reply| PyCommand::ShutdownAgent {
671 agent_id: AgentId(agent_id),
672 reply,
673 })
674 .await
675 }
676
677 fn try_shutdown_agent(&self, agent_id: crate::agent::AgentId) {
678 let (reply, _) = oneshot::channel();
682 if let Err(e) = self.cmd_tx.try_send(PyCommand::ShutdownAgent {
683 agent_id: AgentId(agent_id),
684 reply,
685 }) {
686 tracing::debug!(
687 agent_id = agent_id,
688 error = %e,
689 "try_shutdown_agent: channel send failed (runtime may already be gone)"
690 );
691 }
692 }
693
694 async fn cancel(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
695 self.send_command("cancel", |reply| PyCommand::Cancel {
696 agent_id: AgentId(agent_id),
697 reply,
698 })
699 .await
700 }
701
702 async fn wait_for_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
703 self.send_command("wait_for_idle", |reply| PyCommand::WaitForIdle {
704 agent_id: AgentId(agent_id),
705 reply,
706 })
707 .await
708 }
709
710 async fn send(
711 &self,
712 agent_id: crate::agent::AgentId,
713 content: &crate::content::Content,
714 ) -> Result<(), Error> {
715 let prompt = match content {
716 crate::content::Content::Text { text } => text.clone(),
717 other => crate::content::content_to_json(other)?,
718 };
719 self.send_command("send", |reply| PyCommand::Send {
720 agent_id: AgentId(agent_id),
721 prompt,
722 reply,
723 })
724 .await
725 }
726
727 async fn signal_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
728 self.send_command("signal_idle", |reply| PyCommand::SignalIdle {
729 agent_id: AgentId(agent_id),
730 reply,
731 })
732 .await
733 }
734
735 async fn wait_for_wakeup(
736 &self,
737 agent_id: crate::agent::AgentId,
738 timeout: std::time::Duration,
739 ) -> Result<bool, Error> {
740 self.send_command("wait_for_wakeup", |reply| PyCommand::WaitForWakeup {
741 agent_id: AgentId(agent_id),
742 timeout_secs: timeout.as_secs_f64(),
743 reply,
744 })
745 .await
746 }
747
748 async fn history(
749 &self,
750 agent_id: crate::agent::AgentId,
751 ) -> Result<Vec<crate::types::ConversationMessage>, Error> {
752 self.send_command("get_history", |reply| PyCommand::GetHistory {
753 agent_id: AgentId(agent_id),
754 reply,
755 })
756 .await
757 }
758
759 async fn turn_count(&self, agent_id: crate::agent::AgentId) -> Result<u32, Error> {
760 self.send_command("get_turn_count", |reply| PyCommand::GetTurnCount {
761 agent_id: AgentId(agent_id),
762 reply,
763 })
764 .await
765 }
766
767 async fn total_usage(
768 &self,
769 agent_id: crate::agent::AgentId,
770 ) -> Result<crate::types::UsageMetadata, Error> {
771 self.send_command("get_total_usage", |reply| PyCommand::GetTotalUsage {
772 agent_id: AgentId(agent_id),
773 reply,
774 })
775 .await
776 }
777
778 async fn last_turn_usage(
779 &self,
780 agent_id: crate::agent::AgentId,
781 ) -> Result<crate::types::UsageMetadata, Error> {
782 self.send_command("get_last_turn_usage", |reply| PyCommand::GetLastTurnUsage {
783 agent_id: AgentId(agent_id),
784 reply,
785 })
786 .await
787 }
788
789 async fn clear_history(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
790 self.send_command("clear_history", |reply| PyCommand::ClearHistory {
791 agent_id: AgentId(agent_id),
792 reply,
793 })
794 .await
795 }
796
797 async fn remove_last_turn(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
798 self.send_command("remove_last_turn", |reply| PyCommand::RemoveLastTurn {
799 agent_id: AgentId(agent_id),
800 reply,
801 })
802 .await
803 }
804
805 async fn compaction_indices(&self, agent_id: crate::agent::AgentId) -> Result<Vec<u32>, Error> {
806 self.send_command("compaction_indices", |reply| {
807 PyCommand::GetCompactionIndices {
808 agent_id: AgentId(agent_id),
809 reply,
810 }
811 })
812 .await
813 }
814
815 async fn last_response(
816 &self,
817 agent_id: crate::agent::AgentId,
818 ) -> Result<Option<String>, Error> {
819 self.send_command("last_response", |reply| PyCommand::GetLastResponse {
820 agent_id: AgentId(agent_id),
821 reply,
822 })
823 .await
824 }
825
826 async fn delete(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
827 self.send_command("delete", |reply| PyCommand::Delete {
828 agent_id: AgentId(agent_id),
829 reply,
830 })
831 .await
832 }
833
834 async fn disconnect(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
835 self.send_command("disconnect", |reply| PyCommand::Disconnect {
836 agent_id: AgentId(agent_id),
837 reply,
838 })
839 .await
840 }
841
842 async fn is_idle(&self, agent_id: crate::agent::AgentId) -> Result<bool, Error> {
843 self.send_command("is_idle", |reply| PyCommand::IsIdle {
844 agent_id: AgentId(agent_id),
845 reply,
846 })
847 .await
848 }
849}