Skip to main content

agy_bridge/runtime/
mod.rs

1//! Python runtime manager: owns a dedicated Python thread with an asyncio event loop.
2//!
3//! The `PythonRuntime` struct bridges Rust's tokio async world with Python's asyncio
4//! by running a command dispatch loop in a dedicated thread. Rust sends `PyCommand`
5//! messages via an `mpsc` channel, and receives results via per-command `oneshot` channels.
6//!
7//! # Threading architecture
8//!
9//! - **One Python thread**: All GIL acquisition is confined to a single dedicated thread
10//!   (`agy-bridge-python-runtime`). This thread runs an asyncio event loop via
11//!   `pyo3_async_runtimes::tokio::run_until_complete`.
12//!
13//! - **Concurrent command processing**: Commands received from the `mpsc` channel are
14//!   **not** serialized. Each command spawns a future into a `FuturesUnordered` task set,
15//!   and `tokio::select!` drives both incoming commands and in-flight task completions.
16//!   Multiple chats/operations run concurrently through the Python asyncio event loop.
17//!
18//! - **Rust tool dispatch**: When the Python SDK invokes a Rust tool, `dispatch_rust_tool`
19//!   reads tool state from `BRIDGE_STATE`, then uses `future_into_py` to run the async
20//!   tool on the tokio runtime — keeping the Python thread unblocked for other coroutines.
21//!
22//! - **Hook/policy dispatch**: Similarly, `dispatch_rust_hook` and `dispatch_rust_policy_confirm`
23//!   use `spawn_blocking` to run synchronous hook callbacks without holding the GIL.
24//!
25//! # Why global state (`BRIDGE_STATE`)?
26//!
27//! The Python SDK's tool/hook/policy callbacks are dispatched via PyO3 `#[pyfunction]`
28//! entries (e.g. `dispatch_rust_tool`, `dispatch_rust_hook`). These functions are
29//! registered as plain Python callables and receive **only** the arguments the SDK
30//! passes (agent ID + serialized context). There is no way to thread a Rust reference
31//! or `Arc` through the Python call boundary.
32//!
33//! Therefore per-agent state (tool registries, hook runners, policy sets) is stored in
34//! a global `RwLock<HashMap<AgentId, AgentBridgeState>>`. The agent ID is used as a
35//! lookup key, and the lock is held only for brief `HashMap` operations (never across
36//! `.await` points). This is the standard pattern for PyO3 FFI bridges that need to
37//! associate Rust state with Python-side identifiers.
38
39use 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
54// Re-export items used by sibling modules and external crate consumers.
55pub(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/// Safety-net timeout for a single `send_command` round-trip.
62///
63/// This is the *outer* Rust-side timeout that wraps all commands sent to the
64/// Python thread (chat, `create_agent`, cancel, `get_history`, …).  The Python
65/// side applies its own, tighter timeouts (`chat_timeout`, `HANDLER_TIMEOUT`),
66/// so this value should only fire if the Python thread is completely stuck.
67///
68/// Defaults to `chat_timeout + 2 minutes` to give inner timeouts room to
69/// fire first.
70#[must_use]
71pub fn default_operation_timeout(chat_timeout: Duration) -> Duration {
72    chat_timeout + Duration::from_mins(2)
73}
74/// Default timeout (seconds) for a single `agent.chat()` round-trip.
75/// 120s (2 min) is generous for a normal turn while detecting stalls quickly.
76pub const DEFAULT_CHAT_TIMEOUT_SECS: u64 = 120;
77
78/// Default delay between successive chat commands to prevent burst requests.
79pub const DEFAULT_INTER_AGENT_DELAY: Duration = Duration::from_millis(500);
80
81/// Default command channel buffer size.
82const DEFAULT_CHANNEL_CAPACITY: usize = 64;
83
84/// Default timeout for joining the Python thread on shutdown.
85const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
86
87/// Returns the default chat round-trip timeout, configurable via
88/// `AGI_CHAT_TIMEOUT_SECS` (defaults to 120 s).
89#[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
104/// Commands sent from Rust to the Python thread.
105///
106/// Each variant is constructed in `impl Runtime for PythonRuntime` and
107/// dispatched in `command_loop::run_async_command_loop`.
108pub(crate) enum PyCommand {
109    /// Create a new agent with the given configuration dict as JSON.
110    ///
111    /// The reply carries both the agent ID and tool definitions discovered
112    /// by the Python SDK (Rust tools + MCP tools — builtins are added later).
113    CreateAgent {
114        config_json: String,
115        reply: oneshot::Sender<Result<(AgentId, Vec<handlers::agent::RawToolInfo>), Error>>,
116    },
117    /// Send a chat message to an agent.
118    Chat {
119        agent_id: AgentId,
120        prompt: String,
121        reply: oneshot::Sender<Result<crate::streaming::ChatResponseHandle, Error>>,
122    },
123    /// Shut down a specific agent.
124    ShutdownAgent {
125        agent_id: AgentId,
126        reply: oneshot::Sender<Result<(), Error>>,
127    },
128    /// Cancel active execution on the agent.
129    Cancel {
130        agent_id: AgentId,
131        reply: oneshot::Sender<Result<(), Error>>,
132    },
133    /// Wait for the agent to stabilize/become idle.
134    WaitForIdle {
135        agent_id: AgentId,
136        reply: oneshot::Sender<Result<(), Error>>,
137    },
138    /// Send a message without waiting for completion (fire-and-forget).
139    Send {
140        agent_id: AgentId,
141        prompt: String,
142        reply: oneshot::Sender<Result<(), Error>>,
143    },
144    /// Signal that the agent is idle.
145    SignalIdle {
146        agent_id: AgentId,
147        reply: oneshot::Sender<Result<(), Error>>,
148    },
149    /// Wait for the agent to wake up; returns true if woken, false on timeout.
150    WaitForWakeup {
151        agent_id: AgentId,
152        timeout_secs: f64,
153        reply: oneshot::Sender<Result<bool, Error>>,
154    },
155    /// Shut down the entire Python runtime.
156    Shutdown,
157    /// Retrieve the conversation's message history.
158    GetHistory {
159        agent_id: AgentId,
160        reply: oneshot::Sender<Result<Vec<crate::types::ConversationMessage>, Error>>,
161    },
162    /// Return the number of completed turns.
163    GetTurnCount {
164        agent_id: AgentId,
165        reply: oneshot::Sender<Result<u32, Error>>,
166    },
167    /// Return cumulative token usage across all turns.
168    GetTotalUsage {
169        agent_id: AgentId,
170        reply: oneshot::Sender<Result<crate::types::UsageMetadata, Error>>,
171    },
172    /// Return token usage from the most recent turn.
173    GetLastTurnUsage {
174        agent_id: AgentId,
175        reply: oneshot::Sender<Result<crate::types::UsageMetadata, Error>>,
176    },
177    /// Clear the conversation history.
178    ClearHistory {
179        agent_id: AgentId,
180        reply: oneshot::Sender<Result<(), Error>>,
181    },
182    /// Return step indices where compaction occurred.
183    GetCompactionIndices {
184        agent_id: AgentId,
185        reply: oneshot::Sender<Result<Vec<u32>, Error>>,
186    },
187    /// Return the text of the last model response.
188    GetLastResponse {
189        agent_id: AgentId,
190        reply: oneshot::Sender<Result<Option<String>, Error>>,
191    },
192    /// Delete the conversation and all associated state.
193    ///
194    /// Constructed by `impl Runtime for PythonRuntime::delete()` — only
195    /// reachable when an external consumer calls `AgentHandle::delete()`.
196    Delete {
197        agent_id: AgentId,
198        reply: oneshot::Sender<Result<(), Error>>,
199    },
200    /// Disconnect from the agent without deleting state.
201    ///
202    /// Constructed by `impl Runtime for PythonRuntime::disconnect()`.
203    Disconnect {
204        agent_id: AgentId,
205        reply: oneshot::Sender<Result<(), Error>>,
206    },
207    /// Check whether the agent is currently idle.
208    ///
209    /// Constructed by `impl Runtime for PythonRuntime::is_idle()`.
210    IsIdle {
211        agent_id: AgentId,
212        reply: oneshot::Sender<Result<bool, Error>>,
213    },
214}
215
216/// Log verbosity for the agent backend runtime.
217///
218/// Controls the logging level of the underlying agent runtime. This is
219/// intentionally backend-agnostic — consumers should not need to know
220/// the implementation details of the runtime layer.
221#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
222#[serde(rename_all = "lowercase")]
223pub enum BackendLogLevel {
224    /// Errors only.
225    Error,
226    /// Warnings and errors (default — matches upstream SDK behavior).
227    #[default]
228    Warn,
229    /// Informational messages (verbose — includes raw protocol traffic).
230    Info,
231    /// Full debug output.
232    Debug,
233}
234
235impl BackendLogLevel {
236    /// Return the lowercase string representation used by the Python side.
237    #[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/// Configuration for the bridge runtime.
255#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
256#[serde(default)]
257pub struct RuntimeConfig {
258    /// Channel buffer size for the command channel.
259    pub channel_capacity: usize,
260    /// Timeout for individual runtime operations.
261    pub operation_timeout: Duration,
262    /// Timeout for joining the Python thread on shutdown.
263    pub shutdown_timeout: Duration,
264    /// Timeout for a single `agent.chat()` round-trip.
265    ///
266    /// Defaults to the value of `AGI_CHAT_TIMEOUT_SECS` (env var), or 120 s.
267    pub chat_timeout: Duration,
268    /// Delay injected between successive chat commands to prevent burst requests.
269    pub inter_agent_delay: Duration,
270    /// Backend runtime log verbosity.
271    ///
272    /// Defaults to `Warn`, matching the upstream SDK's default behavior.
273    /// Set to `Info` or `Debug` for verbose protocol-level diagnostics.
274    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
291/// Manages a dedicated Python thread with an asyncio event loop.
292///
293/// All Python/SDK interactions go through the command channel. This isolates
294/// GIL acquisition to the Python thread and keeps the tokio runtime responsive.
295pub struct PythonRuntime {
296    cmd_tx: mpsc::Sender<PyCommand>,
297    thread: Option<std::thread::JoinHandle<()>>,
298    config: RuntimeConfig,
299    /// Per-runtime quota registry. Each API key gets its own [`QuotaState`],
300    /// and different `PythonRuntime` instances are fully independent.
301    quota_registry: crate::quota::QuotaRegistry,
302    /// Default quota state used by `send_command` for runtime-level backoff.
303    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    /// Spawn a new Python runtime on a dedicated thread.
320    ///
321    /// Creates an asyncio event loop in the thread and starts the command
322    /// dispatch loop.
323    ///
324    /// # Errors
325    ///
326    /// Returns `Error::BackendError` if the thread fails to spawn or
327    /// Python initialization fails.
328    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    /// Send a command to the Python thread and await the result.
353    ///
354    /// This is the primary interface for all Python interactions. It checks
355    /// quota state before sending and applies a configurable timeout.
356    ///
357    /// # Errors
358    ///
359    /// Returns `Error::ChannelClosed` if the Python thread has exited,
360    /// `Error::Timeout` if the operation exceeds the configured timeout.
361    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        // Only reset quota backoff for LLM operations (e.g. chat); non-LLM
385        // ops succeeding should not clear a 429 backoff.
386        if is_llm_op {
387            self.quota_state.record_success();
388        }
389
390        Ok(result)
391    }
392
393    /// Graceful shutdown: send `Shutdown` command, then join the thread.
394    ///
395    /// # Errors
396    ///
397    /// Returns `Error::Timeout` if the thread doesn't join within the
398    /// configured shutdown timeout, or `Error::BackendError` if the
399    /// thread panicked.
400    pub async fn shutdown(mut self) -> Result<(), Error> {
401        // Signal the command loop to exit.
402        // Ignoring send error: if the receiver is already gone the thread
403        // is already exiting, which is the outcome we want.
404        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        // Take the JoinHandle so Drop doesn't fire the "dropped without
409        // shutdown" warning.
410        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    /// Access the shared quota state.
464    #[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
481/// Entry point for the dedicated Python thread.
482fn python_thread_main(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) {
483    Python::initialize();
484
485    // Environment variables are already loaded by load_dotenv() at bridge
486    // construction time, before any threads are spawned.
487
488    // Configure sys.path so the venv's site-packages are importable.
489    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
506/// Live SDK thread: creates an asyncio event loop and dispatches commands
507/// to the real Antigravity SDK via `pyo3_async_runtimes`.
508fn 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        // Register event_loop in globals for access from any thread
526        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            // Close the event loop best-effort before propagating.
586            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
602/// Compute which SDK builtin tools are active based on the agent's
603/// [`CapabilitiesConfig`].
604///
605/// - `enabled_tools: Some(list)` → only those tools are active.
606/// - `disabled_tools: Some(list)` → all tools minus the disabled ones.
607/// - Neither set → all builtin tools are active.
608fn 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            // Explicitly empty = no builtins
617            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        // Serialize the AgentConfig and inject the runtime's backend log
637        // level so the Python init script can configure logging without
638        // needing a separate FFI parameter.
639        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        // Collect the names of custom Rust tools so we can tag them correctly.
655        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        // Compute which builtins are active so we can tag and deduplicate them.
666        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        // Convert RawToolInfo → AvailableTool with source tags.
673        // Python's ToolRunner includes builtins in its `tools` dict, so we
674        // skip them here and add them back below with the Builtin tag.
675        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        // Add builtin tools with their known descriptions.
694        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        // Fire-and-forget: create a oneshot whose receiver we drop immediately.
740        // The Python thread will still process the shutdown; we just don't wait
741        // for the result.
742        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        // Shutdown should complete cleanly.
938        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        // 1. Setup the PolicySet with an AskUser rule for "dangerous_tool"
1086        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        // 2. Setup mock handler
1095        let handler = Arc::new(MockAskUserHandler {
1096            should_allow: std::sync::atomic::AtomicBool::new(true),
1097        });
1098
1099        // 3. Mock the tool registry
1100        let mut registry = crate::tools::ToolRegistry::new();
1101
1102        /// A dangerous tool.
1103        #[crate::llm_tool]
1104        fn dangerous_tool() -> Result<String, String> {
1105            Ok("Executed dangerous action!".to_owned())
1106        }
1107        registry.register(DangerousTool);
1108
1109        // 4. Register all state in a single bridge_state() insertion
1110        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        // 5. Simulate check_tool_execution_allowed when the AskUserHandler allows it (returns true)
1124        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        // 6. Simulate check_tool_execution_allowed when the AskUserHandler denies it (returns false)
1135        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        // Clean up
1146        bridge_state().write().unwrap().remove(&agent_id);
1147    }
1148
1149    // ── compute_active_builtins tests ─────────────────────────────────
1150
1151    #[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}