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    /// Remove the last user+model turn pair from conversation history.
183    ///
184    /// Used for safety recovery: when a model safety-filters trip, removing
185    /// the refusal from history gives the model a fresh chance on retry.
186    RemoveLastTurn {
187        agent_id: AgentId,
188        reply: oneshot::Sender<Result<(), Error>>,
189    },
190    /// Return step indices where compaction occurred.
191    GetCompactionIndices {
192        agent_id: AgentId,
193        reply: oneshot::Sender<Result<Vec<u32>, Error>>,
194    },
195    /// Return the text of the last model response.
196    GetLastResponse {
197        agent_id: AgentId,
198        reply: oneshot::Sender<Result<Option<String>, Error>>,
199    },
200    /// Delete the conversation and all associated state.
201    ///
202    /// Constructed by `impl Runtime for PythonRuntime::delete()` — only
203    /// reachable when an external consumer calls `AgentHandle::delete()`.
204    Delete {
205        agent_id: AgentId,
206        reply: oneshot::Sender<Result<(), Error>>,
207    },
208    /// Disconnect from the agent without deleting state.
209    ///
210    /// Constructed by `impl Runtime for PythonRuntime::disconnect()`.
211    Disconnect {
212        agent_id: AgentId,
213        reply: oneshot::Sender<Result<(), Error>>,
214    },
215    /// Check whether the agent is currently idle.
216    ///
217    /// Constructed by `impl Runtime for PythonRuntime::is_idle()`.
218    IsIdle {
219        agent_id: AgentId,
220        reply: oneshot::Sender<Result<bool, Error>>,
221    },
222}
223
224/// Log verbosity for the agent backend runtime.
225///
226/// Controls the logging level of the underlying agent runtime. This is
227/// intentionally backend-agnostic — consumers should not need to know
228/// the implementation details of the runtime layer.
229#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
230#[serde(rename_all = "lowercase")]
231pub enum BackendLogLevel {
232    /// Errors only.
233    Error,
234    /// Warnings and errors (default — matches upstream SDK behavior).
235    #[default]
236    Warn,
237    /// Informational messages (verbose — includes raw protocol traffic).
238    Info,
239    /// Full debug output.
240    Debug,
241}
242
243impl BackendLogLevel {
244    /// Return the lowercase string representation used by the Python side.
245    #[must_use]
246    pub fn as_str(self) -> &'static str {
247        match self {
248            Self::Error => "error",
249            Self::Warn => "warn",
250            Self::Info => "info",
251            Self::Debug => "debug",
252        }
253    }
254}
255
256impl std::fmt::Display for BackendLogLevel {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        f.write_str(self.as_str())
259    }
260}
261
262/// Configuration for the bridge runtime.
263#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
264#[serde(default)]
265pub struct RuntimeConfig {
266    /// Channel buffer size for the command channel.
267    pub channel_capacity: usize,
268    /// Timeout for individual runtime operations.
269    pub operation_timeout: Duration,
270    /// Timeout for joining the Python thread on shutdown.
271    pub shutdown_timeout: Duration,
272    /// Timeout for a single `agent.chat()` round-trip.
273    ///
274    /// Defaults to the value of `AGI_CHAT_TIMEOUT_SECS` (env var), or 120 s.
275    pub chat_timeout: Duration,
276    /// Delay injected between successive chat commands to prevent burst requests.
277    pub inter_agent_delay: Duration,
278    /// Backend runtime log verbosity.
279    ///
280    /// Defaults to `Warn`, matching the upstream SDK's default behavior.
281    /// Set to `Info` or `Debug` for verbose protocol-level diagnostics.
282    pub backend_log_level: BackendLogLevel,
283}
284
285impl Default for RuntimeConfig {
286    fn default() -> Self {
287        let chat_timeout = default_chat_timeout();
288        Self {
289            channel_capacity: DEFAULT_CHANNEL_CAPACITY,
290            operation_timeout: default_operation_timeout(chat_timeout),
291            shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
292            chat_timeout,
293            inter_agent_delay: DEFAULT_INTER_AGENT_DELAY,
294            backend_log_level: BackendLogLevel::default(),
295        }
296    }
297}
298
299/// Manages a dedicated Python thread with an asyncio event loop.
300///
301/// All Python/SDK interactions go through the command channel. This isolates
302/// GIL acquisition to the Python thread and keeps the tokio runtime responsive.
303pub struct PythonRuntime {
304    cmd_tx: mpsc::Sender<PyCommand>,
305    thread: Option<std::thread::JoinHandle<()>>,
306    config: RuntimeConfig,
307    /// Per-runtime quota registry. Each API key gets its own [`QuotaState`],
308    /// and different `PythonRuntime` instances are fully independent.
309    quota_registry: crate::quota::QuotaRegistry,
310    /// Default quota state used by `send_command` for runtime-level backoff.
311    quota_state: Arc<QuotaState>,
312}
313
314impl std::fmt::Debug for PythonRuntime {
315    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        f.debug_struct("PythonRuntime")
317            .field("config", &self.config)
318            .field(
319                "thread_running",
320                &self.thread.as_ref().is_some_and(|t| !t.is_finished()),
321            )
322            .finish_non_exhaustive()
323    }
324}
325
326impl PythonRuntime {
327    /// Spawn a new Python runtime on a dedicated thread.
328    ///
329    /// Creates an asyncio event loop in the thread and starts the command
330    /// dispatch loop.
331    ///
332    /// # Errors
333    ///
334    /// Returns `Error::BackendError` if the thread fails to spawn or
335    /// Python initialization fails.
336    pub fn new(config: RuntimeConfig) -> Result<Self, Error> {
337        let (cmd_tx, cmd_rx) = mpsc::channel(config.channel_capacity);
338
339        let thread_config = config.clone();
340        let thread = std::thread::Builder::new()
341            .name("agy-bridge-python-runtime".into())
342            .spawn(move || {
343                python_thread_main(cmd_rx, &thread_config);
344            })
345            .map_err(|e| Error::BackendError {
346                message: format!("Failed to spawn Python runtime thread: {e}"),
347            })?;
348
349        let quota_registry = crate::quota::QuotaRegistry::new();
350        let quota_state = quota_registry.state_for_key("");
351        Ok(Self {
352            cmd_tx,
353            thread: Some(thread),
354            config,
355            quota_registry,
356            quota_state,
357        })
358    }
359
360    /// Send a command to the Python thread and await the result.
361    ///
362    /// This is the primary interface for all Python interactions. It checks
363    /// quota state before sending and applies a configurable timeout.
364    ///
365    /// # Errors
366    ///
367    /// Returns `Error::ChannelClosed` if the Python thread has exited,
368    /// `Error::Timeout` if the operation exceeds the configured timeout.
369    async fn send_command<T>(
370        &self,
371        operation: &str,
372        is_llm_op: bool,
373        build_cmd: impl FnOnce(oneshot::Sender<Result<T, Error>>) -> PyCommand,
374    ) -> Result<T, Error> {
375        let (reply_tx, reply_rx) = oneshot::channel();
376        let cmd = build_cmd(reply_tx);
377
378        self.cmd_tx
379            .send(cmd)
380            .await
381            .map_err(|e| Error::ChannelClosed {
382                message: format!("Python runtime thread has exited (sending {operation}): {e}"),
383            })?;
384
385        let result = crate::error::with_timeout(self.config.operation_timeout, operation, async {
386            reply_rx.await.map_err(|e| Error::ChannelClosed {
387                message: format!("Reply channel dropped for {operation}: {e}"),
388            })?
389        })
390        .await?;
391
392        // Only reset quota backoff for LLM operations (e.g. chat); non-LLM
393        // ops succeeding should not clear a 429 backoff.
394        if is_llm_op {
395            self.quota_state.record_success();
396        }
397
398        Ok(result)
399    }
400
401    /// Graceful shutdown: send `Shutdown` command, then join the thread.
402    ///
403    /// # Errors
404    ///
405    /// Returns `Error::Timeout` if the thread doesn't join within the
406    /// configured shutdown timeout, or `Error::BackendError` if the
407    /// thread panicked.
408    pub async fn shutdown(mut self) -> Result<(), Error> {
409        // Signal the command loop to exit.
410        // Ignoring send error: if the receiver is already gone the thread
411        // is already exiting, which is the outcome we want.
412        if let Err(e) = self.cmd_tx.send(PyCommand::Shutdown).await {
413            tracing::warn!("Shutdown command send failed (thread may already be exiting): {e}");
414        }
415
416        // Take the JoinHandle so Drop doesn't fire the "dropped without
417        // shutdown" warning.
418        let Some(thread) = self.thread.take() else {
419            tracing::warn!("PythonRuntime::shutdown() called but thread handle already taken");
420            return Ok(());
421        };
422
423        let shutdown_timeout = self.config.shutdown_timeout;
424        let join_result = tokio::time::timeout(
425            shutdown_timeout,
426            tokio::task::spawn_blocking(move || thread.join()),
427        )
428        .await;
429
430        match join_result {
431            Ok(Ok(Ok(()))) => {
432                tracing::info!("Python runtime thread joined successfully");
433                Ok(())
434            }
435            Ok(Ok(Err(panic_payload))) => {
436                let panic_msg = panic_payload.downcast_ref::<&str>().map_or_else(
437                    || {
438                        panic_payload
439                            .downcast_ref::<String>()
440                            .map_or_else(|| format!("{panic_payload:?}"), Clone::clone)
441                    },
442                    |s| (*s).to_string(),
443                );
444                tracing::error!(
445                    panic_message = %panic_msg,
446                    "Python runtime thread panicked during shutdown"
447                );
448                Err(Error::BackendError {
449                    message: format!("Python runtime thread panicked during shutdown: {panic_msg}"),
450                })
451            }
452            Ok(Err(join_err)) => {
453                tracing::error!("spawn_blocking join error: {join_err}");
454                Err(Error::BackendError {
455                    message: format!("Failed to join Python thread: {join_err}"),
456                })
457            }
458            Err(_elapsed) => {
459                tracing::error!(
460                    timeout_secs = shutdown_timeout.as_secs(),
461                    "Python runtime thread did not exit within shutdown timeout"
462                );
463                Err(Error::Timeout {
464                    duration: shutdown_timeout,
465                    operation: "PythonRuntime::shutdown (thread join)".to_string(),
466                })
467            }
468        }
469    }
470
471    /// Access the shared quota state.
472    #[must_use]
473    pub const fn quota_state(&self) -> &Arc<QuotaState> {
474        &self.quota_state
475    }
476}
477
478impl Drop for PythonRuntime {
479    fn drop(&mut self) {
480        if self.thread.is_some() {
481            tracing::warn!(
482                "PythonRuntime dropped without calling shutdown() — \
483                 Python thread may still be running"
484            );
485        }
486    }
487}
488
489/// Entry point for the dedicated Python thread.
490fn python_thread_main(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) {
491    Python::initialize();
492
493    // Environment variables are already loaded by load_dotenv() at bridge
494    // construction time, before any threads are spawned.
495
496    // Configure sys.path so the venv's site-packages are importable.
497    Python::attach(|py| {
498        if let Err(e) = venv::configure_python_sys_path(py) {
499            tracing::error!(
500                error = %e,
501                "Failed to configure Python sys.path in runtime thread — \
502                 venv imports will likely fail"
503            );
504        }
505    });
506
507    if let Err(e) = run_live_thread(cmd_rx, config) {
508        tracing::error!(error = %e, "Python runtime thread failed");
509    }
510
511    tracing::info!("Python runtime thread exiting");
512}
513
514/// Live SDK thread: creates an asyncio event loop and dispatches commands
515/// to the real Antigravity SDK via `pyo3_async_runtimes`.
516fn run_live_thread(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) -> Result<(), Error> {
517    Python::attach(|py| {
518        let asyncio = py.import("asyncio").map_err(|e| Error::BackendError {
519            message: format!("Failed to import asyncio: {e}"),
520        })?;
521        let event_loop =
522            asyncio
523                .call_method0("new_event_loop")
524                .map_err(|e| Error::BackendError {
525                    message: format!("Failed to create new asyncio event loop: {e}"),
526                })?;
527        asyncio
528            .call_method1("set_event_loop", (&event_loop,))
529            .map_err(|e| Error::BackendError {
530                message: format!("Failed to set asyncio event loop: {e}"),
531            })?;
532
533        // Register event_loop in globals for access from any thread
534        let sys = py.import("sys").map_err(|e| Error::BackendError {
535            message: format!("Failed to import sys: {e}"),
536        })?;
537        let sys_modules = sys.getattr("modules").map_err(|e| Error::BackendError {
538            message: format!("Failed to get sys.modules: {e}"),
539        })?;
540        let globals_mod = if sys_modules
541            .contains(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
542            .map_err(|e| Error::BackendError {
543                message: format!("Failed to check sys.modules: {e}"),
544            })? {
545            sys_modules
546                .get_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
547                .map_err(|e| Error::BackendError {
548                    message: format!("Failed to get _agy_bridge_globals: {e}"),
549                })?
550        } else {
551            let types = py.import("types").map_err(|e| Error::BackendError {
552                message: format!("Failed to import types: {e}"),
553            })?;
554            let module = types
555                .getattr("ModuleType")
556                .map_err(|e| Error::BackendError {
557                    message: format!("Failed to get ModuleType: {e}"),
558                })?
559                .call1((command_loop::AGY_BRIDGE_GLOBALS_MODULE,))
560                .map_err(|e| Error::BackendError {
561                    message: format!("Failed to create ModuleType: {e}"),
562                })?;
563            sys_modules
564                .set_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE, &module)
565                .map_err(|e| Error::BackendError {
566                    message: format!("Failed to register _agy_bridge_globals: {e}"),
567                })?;
568            module
569        };
570        globals_mod
571            .setattr("EVENT_LOOP", &event_loop)
572            .map_err(|e| Error::BackendError {
573                message: format!("Failed to set EVENT_LOOP in globals: {e}"),
574            })?;
575
576        tracing::info!("Python asyncio event loop created on runtime thread");
577
578        let chat_timeout = config.chat_timeout;
579        let inter_agent_delay = config.inter_agent_delay;
580        let event_loop_obj = event_loop.clone().unbind();
581        let run_fut =
582            pyo3_async_runtimes::tokio::run_until_complete(event_loop.clone(), async move {
583                command_loop::run_async_command_loop(
584                    event_loop_obj,
585                    cmd_rx,
586                    chat_timeout,
587                    inter_agent_delay,
588                )
589                .await
590            });
591
592        if let Err(e) = run_fut {
593            // Close the event loop best-effort before propagating.
594            if let Err(close_err) = event_loop.call_method0("close") {
595                tracing::warn!("Failed to close asyncio event loop: {close_err}");
596            }
597            return Err(Error::BackendError {
598                message: format!("Python runtime command loop failed: {e}"),
599            });
600        }
601
602        if let Err(e) = event_loop.call_method0("close") {
603            tracing::warn!("Failed to close asyncio event loop: {e}");
604        }
605
606        Ok(())
607    })
608}
609
610/// Compute which SDK builtin tools are active based on the agent's
611/// [`CapabilitiesConfig`].
612///
613/// - `enabled_tools: Some(list)` → only those tools are active.
614/// - `disabled_tools: Some(list)` → all tools minus the disabled ones.
615/// - Neither set → all builtin tools are active.
616fn compute_active_builtins(
617    config: &crate::config::AgentConfig,
618) -> Vec<crate::config::BuiltinTools> {
619    match config.capabilities.as_ref() {
620        Some(caps) if caps.enabled_tools.as_ref().is_some_and(|v| !v.is_empty()) => {
621            caps.enabled_tools.clone().unwrap_or_default()
622        }
623        Some(caps) if caps.enabled_tools.as_ref().is_some_and(Vec::is_empty) => {
624            // Explicitly empty = no builtins
625            Vec::new()
626        }
627        Some(caps) if caps.disabled_tools.is_some() => {
628            let disabled = caps.disabled_tools.as_ref().unwrap();
629            crate::config::BuiltinTools::all_tools()
630                .iter()
631                .filter(|t| !disabled.contains(t))
632                .cloned()
633                .collect()
634        }
635        _ => crate::config::BuiltinTools::all_tools().to_vec(),
636    }
637}
638
639impl crate::agent::Runtime for PythonRuntime {
640    async fn create_agent(
641        &self,
642        config: crate::config::AgentConfig,
643    ) -> Result<(crate::agent::AgentId, Vec<crate::tools::AvailableTool>), Error> {
644        // Serialize the AgentConfig and inject the runtime's backend log
645        // level so the Python init script can configure logging without
646        // needing a separate FFI parameter.
647        let config_json = {
648            let mut val = serde_json::to_value(&config).map_err(|e| Error::BackendError {
649                message: format!("Failed to serialize AgentConfig: {e}"),
650            })?;
651            if let serde_json::Value::Object(ref mut map) = val {
652                map.insert(
653                    "_backend_log_level".to_owned(),
654                    serde_json::Value::String(self.config.backend_log_level.as_str().to_owned()),
655                );
656            }
657            serde_json::to_string(&val).map_err(|e| Error::BackendError {
658                message: format!("Failed to re-serialize config JSON: {e}"),
659            })?
660        };
661
662        // Collect the names of custom Rust tools so we can tag them correctly.
663        let custom_tool_names: std::collections::HashSet<String> =
664            config.tools.iter().map(|t| t.name.clone()).collect();
665
666        let (raw_id, raw_tools) = self
667            .send_command("create_agent", false, |reply| PyCommand::CreateAgent {
668                config_json,
669                reply,
670            })
671            .await?;
672
673        // Compute which builtins are active so we can tag and deduplicate them.
674        let active_builtins = compute_active_builtins(&config);
675        let builtin_names: std::collections::HashSet<&str> = active_builtins
676            .iter()
677            .map(crate::config::BuiltinTools::as_sdk_name)
678            .collect();
679
680        // Convert RawToolInfo → AvailableTool with source tags.
681        // Python's ToolRunner includes builtins in its `tools` dict, so we
682        // skip them here and add them back below with the Builtin tag.
683        let mut available_tools: Vec<crate::tools::AvailableTool> = raw_tools
684            .into_iter()
685            .filter(|raw| !builtin_names.contains(raw.name.as_str()))
686            .map(|raw| {
687                let source = if custom_tool_names.contains(&raw.name) {
688                    crate::tools::ToolSource::Custom
689                } else {
690                    crate::tools::ToolSource::Mcp
691                };
692                crate::tools::AvailableTool {
693                    name: raw.name,
694                    description: raw.description,
695                    parameter_schema: raw.parameter_schema,
696                    source,
697                }
698            })
699            .collect();
700
701        // Add builtin tools with their known descriptions.
702        for builtin in active_builtins {
703            available_tools.push(crate::tools::AvailableTool {
704                name: builtin.as_sdk_name().to_owned(),
705                description: builtin.description().to_owned(),
706                parameter_schema: serde_json::Value::Null,
707                source: crate::tools::ToolSource::Builtin,
708            });
709        }
710
711        tracing::info!(
712            agent_id = raw_id.0,
713            tool_count = available_tools.len(),
714            tools = ?available_tools.iter().map(|t| format!("{t}")).collect::<Vec<_>>(),
715            "Agent created with available tools"
716        );
717
718        Ok((raw_id.0, available_tools))
719    }
720
721    async fn chat(
722        &self,
723        agent_id: crate::agent::AgentId,
724        content: &crate::content::Content,
725    ) -> Result<crate::streaming::ChatResponseHandle, Error> {
726        let prompt = match content {
727            crate::content::Content::Text { text } => text.clone(),
728            other => crate::content::content_to_json(other)?,
729        };
730        self.send_command("chat", true, |reply| PyCommand::Chat {
731            agent_id: AgentId(agent_id),
732            prompt,
733            reply,
734        })
735        .await
736    }
737
738    async fn shutdown_agent(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
739        self.send_command("shutdown_agent", false, |reply| PyCommand::ShutdownAgent {
740            agent_id: AgentId(agent_id),
741            reply,
742        })
743        .await
744    }
745
746    fn try_shutdown_agent(&self, agent_id: crate::agent::AgentId) {
747        // Fire-and-forget: create a oneshot whose receiver we drop immediately.
748        // The Python thread will still process the shutdown; we just don't wait
749        // for the result.
750        let (reply, _) = oneshot::channel();
751        if let Err(e) = self.cmd_tx.try_send(PyCommand::ShutdownAgent {
752            agent_id: AgentId(agent_id),
753            reply,
754        }) {
755            tracing::debug!(
756                agent_id = agent_id,
757                error = %e,
758                "try_shutdown_agent: channel send failed (runtime may already be gone)"
759            );
760        }
761    }
762
763    async fn cancel(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
764        self.send_command("cancel", false, |reply| PyCommand::Cancel {
765            agent_id: AgentId(agent_id),
766            reply,
767        })
768        .await
769    }
770
771    async fn wait_for_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
772        self.send_command("wait_for_idle", false, |reply| PyCommand::WaitForIdle {
773            agent_id: AgentId(agent_id),
774            reply,
775        })
776        .await
777    }
778
779    async fn send(
780        &self,
781        agent_id: crate::agent::AgentId,
782        content: &crate::content::Content,
783    ) -> Result<(), Error> {
784        let prompt = match content {
785            crate::content::Content::Text { text } => text.clone(),
786            other => crate::content::content_to_json(other)?,
787        };
788        self.send_command("send", false, |reply| PyCommand::Send {
789            agent_id: AgentId(agent_id),
790            prompt,
791            reply,
792        })
793        .await
794    }
795
796    async fn signal_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
797        self.send_command("signal_idle", false, |reply| PyCommand::SignalIdle {
798            agent_id: AgentId(agent_id),
799            reply,
800        })
801        .await
802    }
803
804    async fn wait_for_wakeup(
805        &self,
806        agent_id: crate::agent::AgentId,
807        timeout: std::time::Duration,
808    ) -> Result<bool, Error> {
809        self.send_command("wait_for_wakeup", false, |reply| PyCommand::WaitForWakeup {
810            agent_id: AgentId(agent_id),
811            timeout_secs: timeout.as_secs_f64(),
812            reply,
813        })
814        .await
815    }
816
817    async fn wait_for_quota(&self) {
818        self.quota_state.wait_for_quota().await;
819    }
820
821    async fn record_quota_hit(&self, retry_after: std::time::Duration) {
822        self.quota_state.record_quota_hit(retry_after);
823    }
824
825    fn quota_registry(&self) -> &crate::quota::QuotaRegistry {
826        &self.quota_registry
827    }
828
829    async fn history(
830        &self,
831        agent_id: crate::agent::AgentId,
832    ) -> Result<Vec<crate::types::ConversationMessage>, Error> {
833        self.send_command("get_history", false, |reply| PyCommand::GetHistory {
834            agent_id: AgentId(agent_id),
835            reply,
836        })
837        .await
838    }
839
840    async fn turn_count(&self, agent_id: crate::agent::AgentId) -> Result<u32, Error> {
841        self.send_command("get_turn_count", false, |reply| PyCommand::GetTurnCount {
842            agent_id: AgentId(agent_id),
843            reply,
844        })
845        .await
846    }
847
848    async fn total_usage(
849        &self,
850        agent_id: crate::agent::AgentId,
851    ) -> Result<crate::types::UsageMetadata, Error> {
852        self.send_command("get_total_usage", false, |reply| PyCommand::GetTotalUsage {
853            agent_id: AgentId(agent_id),
854            reply,
855        })
856        .await
857    }
858
859    async fn last_turn_usage(
860        &self,
861        agent_id: crate::agent::AgentId,
862    ) -> Result<crate::types::UsageMetadata, Error> {
863        self.send_command("get_last_turn_usage", false, |reply| {
864            PyCommand::GetLastTurnUsage {
865                agent_id: AgentId(agent_id),
866                reply,
867            }
868        })
869        .await
870    }
871
872    async fn clear_history(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
873        self.send_command("clear_history", false, |reply| PyCommand::ClearHistory {
874            agent_id: AgentId(agent_id),
875            reply,
876        })
877        .await
878    }
879
880    async fn remove_last_turn(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
881        self.send_command("remove_last_turn", false, |reply| {
882            PyCommand::RemoveLastTurn {
883                agent_id: AgentId(agent_id),
884                reply,
885            }
886        })
887        .await
888    }
889
890    async fn compaction_indices(&self, agent_id: crate::agent::AgentId) -> Result<Vec<u32>, Error> {
891        self.send_command("compaction_indices", false, |reply| {
892            PyCommand::GetCompactionIndices {
893                agent_id: AgentId(agent_id),
894                reply,
895            }
896        })
897        .await
898    }
899
900    async fn last_response(
901        &self,
902        agent_id: crate::agent::AgentId,
903    ) -> Result<Option<String>, Error> {
904        self.send_command("last_response", false, |reply| PyCommand::GetLastResponse {
905            agent_id: AgentId(agent_id),
906            reply,
907        })
908        .await
909    }
910
911    async fn delete(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
912        self.send_command("delete", false, |reply| PyCommand::Delete {
913            agent_id: AgentId(agent_id),
914            reply,
915        })
916        .await
917    }
918
919    async fn disconnect(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
920        self.send_command("disconnect", false, |reply| PyCommand::Disconnect {
921            agent_id: AgentId(agent_id),
922            reply,
923        })
924        .await
925    }
926
927    async fn is_idle(&self, agent_id: crate::agent::AgentId) -> Result<bool, Error> {
928        self.send_command("is_idle", false, |reply| PyCommand::IsIdle {
929            agent_id: AgentId(agent_id),
930            reply,
931        })
932        .await
933    }
934}
935
936#[cfg(test)]
937mod tests {
938    use std::collections::HashMap;
939
940    use super::{ffi_dispatch::check_tool_execution_allowed, *};
941
942    fn test_config() -> RuntimeConfig {
943        RuntimeConfig {
944            channel_capacity: 16,
945            operation_timeout: Duration::from_secs(10),
946            shutdown_timeout: Duration::from_secs(5),
947            chat_timeout: Duration::from_mins(1),
948            inter_agent_delay: Duration::from_millis(100),
949            backend_log_level: BackendLogLevel::default(),
950        }
951    }
952
953    #[tokio::test]
954    async fn test_runtime_creation_and_shutdown() {
955        // Shutdown should complete cleanly.
956        PythonRuntime::new(test_config())
957            .expect("Failed to create runtime")
958            .shutdown()
959            .await
960            .expect("Shutdown failed");
961    }
962
963    #[test]
964    fn runtime_config_serde_roundtrip() {
965        let config = test_config();
966        let json = serde_json::to_string(&config).unwrap();
967        let parsed: RuntimeConfig = serde_json::from_str(&json).unwrap();
968        assert_eq!(parsed.channel_capacity, 16);
969        assert_eq!(parsed.operation_timeout, Duration::from_secs(10));
970        assert_eq!(parsed.shutdown_timeout, Duration::from_secs(5));
971        assert_eq!(parsed.chat_timeout, Duration::from_mins(1));
972        assert_eq!(parsed.inter_agent_delay, Duration::from_millis(100));
973        assert_eq!(parsed.backend_log_level, BackendLogLevel::Warn);
974    }
975
976    #[test]
977    fn backend_log_level_default_is_warn() {
978        assert_eq!(BackendLogLevel::default(), BackendLogLevel::Warn);
979    }
980
981    #[test]
982    fn backend_log_level_serde_roundtrip_all_variants() {
983        for (variant, expected_str) in [
984            (BackendLogLevel::Error, "\"error\""),
985            (BackendLogLevel::Warn, "\"warn\""),
986            (BackendLogLevel::Info, "\"info\""),
987            (BackendLogLevel::Debug, "\"debug\""),
988        ] {
989            let json = serde_json::to_string(&variant).unwrap();
990            assert_eq!(json, expected_str, "serialize {variant:?}");
991            let parsed: BackendLogLevel = serde_json::from_str(&json).unwrap();
992            assert_eq!(parsed, variant, "roundtrip {variant:?}");
993        }
994    }
995
996    #[test]
997    fn backend_log_level_as_str() {
998        assert_eq!(BackendLogLevel::Error.as_str(), "error");
999        assert_eq!(BackendLogLevel::Warn.as_str(), "warn");
1000        assert_eq!(BackendLogLevel::Info.as_str(), "info");
1001        assert_eq!(BackendLogLevel::Debug.as_str(), "debug");
1002    }
1003
1004    #[test]
1005    fn backend_log_level_display() {
1006        assert_eq!(format!("{}", BackendLogLevel::Error), "error");
1007        assert_eq!(format!("{}", BackendLogLevel::Warn), "warn");
1008        assert_eq!(format!("{}", BackendLogLevel::Info), "info");
1009        assert_eq!(format!("{}", BackendLogLevel::Debug), "debug");
1010    }
1011
1012    #[test]
1013    fn runtime_config_with_custom_backend_log_level() {
1014        let config = RuntimeConfig {
1015            backend_log_level: BackendLogLevel::Debug,
1016            ..test_config()
1017        };
1018        let json = serde_json::to_string(&config).unwrap();
1019        let parsed: RuntimeConfig = serde_json::from_str(&json).unwrap();
1020        assert_eq!(parsed.backend_log_level, BackendLogLevel::Debug);
1021    }
1022
1023    #[test]
1024    fn default_operation_timeout_is_chat_plus_margin() {
1025        let config = RuntimeConfig::default();
1026        let expected = config.chat_timeout + Duration::from_mins(2);
1027        assert_eq!(
1028            config.operation_timeout, expected,
1029            "operation_timeout should be chat_timeout + 2min safety margin"
1030        );
1031    }
1032
1033    #[test]
1034    fn stop_candidate_exception_is_backend_error() {
1035        Python::initialize();
1036        Python::attach(|py| {
1037            let globals = pyo3::types::PyDict::new(py);
1038            py.run(
1039                c"
1040class StopCandidateException(Exception):
1041    pass
1042err = StopCandidateException(\"dummy\")
1043",
1044                Some(&globals),
1045                None,
1046            )
1047            .unwrap();
1048
1049            let err_obj = globals.get_item("err").unwrap().unwrap();
1050            let err = PyErr::from_value(err_obj);
1051
1052            let mapped = crate::error::classify_py_error(py, &err);
1053
1054            assert!(
1055                matches!(mapped, crate::error::Error::BackendError { .. }),
1056                "StopCandidateException should be classified as BackendError, got: {mapped:?}"
1057            );
1058        });
1059    }
1060
1061    #[test]
1062    fn max_tokens_exception_is_backend_error() {
1063        Python::initialize();
1064        Python::attach(|py| {
1065            let globals = pyo3::types::PyDict::new(py);
1066            py.run(
1067                c"
1068class MaxTokensException(Exception):
1069    pass
1070err = MaxTokensException(\"dummy\")
1071",
1072                Some(&globals),
1073                None,
1074            )
1075            .unwrap();
1076
1077            let err_obj = globals.get_item("err").unwrap().unwrap();
1078            let err = PyErr::from_value(err_obj);
1079
1080            let mapped = crate::error::classify_py_error(py, &err);
1081
1082            assert!(
1083                matches!(mapped, crate::error::Error::BackendError { .. }),
1084                "MaxTokensException should be classified as BackendError, got: {mapped:?}"
1085            );
1086        });
1087    }
1088
1089    struct MockAskUserHandler {
1090        should_allow: std::sync::atomic::AtomicBool,
1091    }
1092
1093    impl crate::policies::AskUserHandler for MockAskUserHandler {
1094        fn confirm(&self, _tool_name: &str, _tool_args: &serde_json::Value) -> bool {
1095            self.should_allow.load(std::sync::atomic::Ordering::SeqCst)
1096        }
1097    }
1098
1099    #[test]
1100    fn test_ask_user_policy_custom_tool_gating() {
1101        let agent_id: u64 = 999;
1102
1103        // 1. Setup the PolicySet with an AskUser rule for "dangerous_tool"
1104        let mut policies = crate::policies::PolicySet::new();
1105        policies
1106            .push(crate::policies::PolicyRule::AskUser {
1107                tool: "dangerous_tool".to_owned(),
1108                handler_id: "confirm_handler".to_owned(),
1109            })
1110            .unwrap();
1111
1112        // 2. Setup mock handler
1113        let handler = Arc::new(MockAskUserHandler {
1114            should_allow: std::sync::atomic::AtomicBool::new(true),
1115        });
1116
1117        // 3. Mock the tool registry
1118        let mut registry = crate::tools::ToolRegistry::new();
1119
1120        /// A dangerous tool.
1121        #[crate::llm_tool]
1122        fn dangerous_tool() -> Result<String, String> {
1123            Ok("Executed dangerous action!".to_owned())
1124        }
1125        registry.register(DangerousTool);
1126
1127        // 4. Register all state in a single bridge_state() insertion
1128        bridge_state().write().unwrap().insert(
1129            agent_id,
1130            AgentBridgeState {
1131                registry: Some(Arc::new(registry)),
1132                hook_runner: None,
1133                policies,
1134                policy_handler: Some(
1135                    Arc::clone(&handler) as Arc<dyn crate::policies::AskUserHandler>
1136                ),
1137                tool_state: Arc::new(std::sync::RwLock::new(HashMap::new())),
1138            },
1139        );
1140
1141        // 5. Simulate check_tool_execution_allowed when the AskUserHandler allows it (returns true)
1142        handler
1143            .should_allow
1144            .store(true, std::sync::atomic::Ordering::SeqCst);
1145        let res = check_tool_execution_allowed(agent_id, "dangerous_tool", "{}");
1146        assert!(res.is_ok(), "Check should succeed");
1147        assert!(
1148            res.unwrap(),
1149            "Should allow tool execution when handler returns true"
1150        );
1151
1152        // 6. Simulate check_tool_execution_allowed when the AskUserHandler denies it (returns false)
1153        handler
1154            .should_allow
1155            .store(false, std::sync::atomic::Ordering::SeqCst);
1156        let res = check_tool_execution_allowed(agent_id, "dangerous_tool", "{}");
1157        assert!(res.is_ok(), "Check should succeed");
1158        assert!(
1159            !res.unwrap(),
1160            "Should block tool execution when handler returns false"
1161        );
1162
1163        // Clean up
1164        bridge_state().write().unwrap().remove(&agent_id);
1165    }
1166
1167    // ── compute_active_builtins tests ─────────────────────────────────
1168
1169    #[test]
1170    fn builtins_default_config_returns_all() {
1171        let config = crate::config::AgentConfig::default();
1172        let builtins = super::compute_active_builtins(&config);
1173        assert_eq!(
1174            builtins.len(),
1175            crate::config::BuiltinTools::all_tools().len(),
1176            "default config should produce all builtins"
1177        );
1178    }
1179
1180    #[test]
1181    fn builtins_no_capabilities_returns_all() {
1182        let config = crate::config::AgentConfig {
1183            capabilities: None,
1184            ..crate::config::AgentConfig::default()
1185        };
1186        let builtins = super::compute_active_builtins(&config);
1187        assert_eq!(
1188            builtins.len(),
1189            crate::config::BuiltinTools::all_tools().len(),
1190        );
1191    }
1192
1193    #[test]
1194    fn builtins_enabled_tools_filters() {
1195        let config = crate::config::AgentConfig {
1196            capabilities: Some(crate::config::CapabilitiesConfig {
1197                enabled_tools: Some(vec![
1198                    crate::config::BuiltinTools::ViewFile,
1199                    crate::config::BuiltinTools::ListDir,
1200                ]),
1201                ..crate::config::CapabilitiesConfig::default()
1202            }),
1203            ..crate::config::AgentConfig::default()
1204        };
1205        let builtins = super::compute_active_builtins(&config);
1206        assert_eq!(builtins.len(), 2);
1207        assert!(builtins.contains(&crate::config::BuiltinTools::ViewFile));
1208        assert!(builtins.contains(&crate::config::BuiltinTools::ListDir));
1209    }
1210
1211    #[test]
1212    fn builtins_disabled_tools_excludes() {
1213        let config = crate::config::AgentConfig {
1214            capabilities: Some(crate::config::CapabilitiesConfig {
1215                disabled_tools: Some(vec![crate::config::BuiltinTools::RunCommand]),
1216                ..crate::config::CapabilitiesConfig::default()
1217            }),
1218            ..crate::config::AgentConfig::default()
1219        };
1220        let builtins = super::compute_active_builtins(&config);
1221        assert!(
1222            !builtins.contains(&crate::config::BuiltinTools::RunCommand),
1223            "RunCommand should be excluded"
1224        );
1225        assert!(
1226            builtins.len() == crate::config::BuiltinTools::all_tools().len() - 1,
1227            "should have all builtins minus the disabled one"
1228        );
1229    }
1230
1231    #[test]
1232    fn builtins_custom_tools_only_returns_empty() {
1233        let config = crate::config::AgentConfig {
1234            capabilities: Some(crate::config::CapabilitiesConfig::custom_tools_only()),
1235            ..crate::config::AgentConfig::default()
1236        };
1237        let builtins = super::compute_active_builtins(&config);
1238        assert!(
1239            builtins.is_empty(),
1240            "custom_tools_only should produce 0 builtins"
1241        );
1242    }
1243
1244    #[test]
1245    fn builtins_all_descriptions_non_empty() {
1246        for tool in crate::config::BuiltinTools::all_tools() {
1247            assert!(
1248                !tool.description().is_empty(),
1249                "builtin {tool:?} has empty description",
1250            );
1251        }
1252    }
1253
1254    #[test]
1255    fn builtins_all_sdk_names_non_empty() {
1256        for tool in crate::config::BuiltinTools::all_tools() {
1257            assert!(
1258                !tool.as_sdk_name().is_empty(),
1259                "builtin {tool:?} has empty SDK name",
1260            );
1261        }
1262    }
1263}