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::time::Duration;
40
41use pyo3::prelude::*;
42use tokio::sync::{mpsc, oneshot};
43
44use crate::error::Error;
45
46pub(crate) mod bridge_state;
47pub(crate) mod command_loop;
48mod config;
49pub(crate) mod ffi_dispatch;
50mod handlers;
51pub(crate) mod py_scripts;
52pub(crate) mod streaming;
53pub(crate) mod venv;
54
55#[cfg(test)]
56mod tests;
57
58// Re-export items used by sibling modules and external crate consumers.
59pub(crate) use bridge_state::{AgentBridgeState, AgentId, bridge_state, next_agent_id};
60pub use config::{BackendLogLevel, RuntimeConfig};
61pub(crate) use ffi_dispatch::{
62    dispatch_rust_hook, dispatch_rust_policy_confirm, dispatch_rust_tool,
63    initializing_hook_runners, set_agent_conversation_id,
64};
65
66/// Default delay between successive chat commands to prevent burst requests.
67pub const DEFAULT_INTER_AGENT_DELAY: Duration = Duration::from_millis(500);
68
69/// Default command channel buffer size.
70const DEFAULT_CHANNEL_CAPACITY: usize = 64;
71
72/// Default timeout for joining the Python thread on shutdown.
73const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
74
75/// Commands sent from Rust to the Python thread.
76///
77/// Each variant is constructed in `impl Runtime for PythonRuntime` and
78/// dispatched in `command_loop::run_async_command_loop`.
79pub(crate) enum PyCommand {
80    /// Create a new agent with the given configuration dict as JSON.
81    ///
82    /// The reply carries both the agent ID and tool definitions discovered
83    /// by the Python SDK (Rust tools + MCP tools — builtins are added later).
84    CreateAgent {
85        agent_id: u64,
86        config_json: String,
87        reply: oneshot::Sender<Result<(AgentId, Vec<handlers::agent::RawToolInfo>), Error>>,
88    },
89    /// Send a chat message to an agent.
90    Chat {
91        agent_id: AgentId,
92        prompt: String,
93        reply: oneshot::Sender<Result<crate::streaming::ChatResponseHandle, Error>>,
94    },
95    /// Shut down a specific agent.
96    ShutdownAgent {
97        agent_id: AgentId,
98        reply: oneshot::Sender<Result<(), Error>>,
99    },
100    /// Cancel active execution on the agent.
101    Cancel {
102        agent_id: AgentId,
103        reply: oneshot::Sender<Result<(), Error>>,
104    },
105    /// Wait for the agent to stabilize/become idle.
106    WaitForIdle {
107        agent_id: AgentId,
108        reply: oneshot::Sender<Result<(), Error>>,
109    },
110    /// Send a message without waiting for completion (fire-and-forget).
111    Send {
112        agent_id: AgentId,
113        prompt: String,
114        reply: oneshot::Sender<Result<(), Error>>,
115    },
116    /// Signal that the agent is idle.
117    SignalIdle {
118        agent_id: AgentId,
119        reply: oneshot::Sender<Result<(), Error>>,
120    },
121    /// Wait for the agent to wake up; returns true if woken, false on timeout.
122    WaitForWakeup {
123        agent_id: AgentId,
124        timeout_secs: f64,
125        reply: oneshot::Sender<Result<bool, Error>>,
126    },
127    /// Shut down the entire Python runtime.
128    Shutdown,
129    /// Retrieve the conversation's message history.
130    GetHistory {
131        agent_id: AgentId,
132        reply: oneshot::Sender<Result<Vec<crate::types::ConversationMessage>, Error>>,
133    },
134    /// Return the number of completed turns.
135    GetTurnCount {
136        agent_id: AgentId,
137        reply: oneshot::Sender<Result<u32, Error>>,
138    },
139    /// Return the number of agents currently live in the runtime registry.
140    ///
141    /// Runtime-level query (no `agent_id`): counts agents that have been
142    /// created but not yet shut down or dropped. Used for observability and
143    /// leak detection.
144    ///
145    /// Constructed by `PythonRuntime::active_agent_count()`.
146    GetActiveAgentCount {
147        reply: oneshot::Sender<Result<usize, Error>>,
148    },
149    /// Return cumulative token usage across all turns.
150    GetTotalUsage {
151        agent_id: AgentId,
152        reply: oneshot::Sender<Result<crate::types::UsageMetadata, Error>>,
153    },
154    /// Return token usage from the most recent turn.
155    GetLastTurnUsage {
156        agent_id: AgentId,
157        reply: oneshot::Sender<Result<crate::types::UsageMetadata, Error>>,
158    },
159    /// Clear the conversation history.
160    ClearHistory {
161        agent_id: AgentId,
162        reply: oneshot::Sender<Result<(), Error>>,
163    },
164    /// Return step indices where compaction occurred.
165    GetCompactionIndices {
166        agent_id: AgentId,
167        reply: oneshot::Sender<Result<Vec<u32>, Error>>,
168    },
169    /// Return the text of the last model response.
170    GetLastResponse {
171        agent_id: AgentId,
172        reply: oneshot::Sender<Result<Option<String>, Error>>,
173    },
174    /// Delete the conversation and all associated state.
175    ///
176    /// Constructed by `impl Runtime for PythonRuntime::delete()` — only
177    /// reachable when an external consumer calls `AgentHandle::delete()`.
178    Delete {
179        agent_id: AgentId,
180        reply: oneshot::Sender<Result<(), Error>>,
181    },
182    /// Disconnect from the agent without deleting state.
183    ///
184    /// Constructed by `impl Runtime for PythonRuntime::disconnect()`.
185    Disconnect {
186        agent_id: AgentId,
187        reply: oneshot::Sender<Result<(), Error>>,
188    },
189    /// Check whether the agent is currently idle.
190    ///
191    /// Constructed by `impl Runtime for PythonRuntime::is_idle()`.
192    IsIdle {
193        agent_id: AgentId,
194        reply: oneshot::Sender<Result<bool, Error>>,
195    },
196}
197
198/// Manages a dedicated Python thread with an asyncio event loop.
199///
200/// All Python/SDK interactions go through the command channel. This isolates
201/// GIL acquisition to the Python thread and keeps the tokio runtime responsive.
202pub struct PythonRuntime {
203    cmd_tx: Option<mpsc::Sender<PyCommand>>,
204    thread: Option<std::thread::JoinHandle<()>>,
205    config: RuntimeConfig,
206}
207
208impl std::fmt::Debug for PythonRuntime {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        f.debug_struct("PythonRuntime")
211            .field("config", &self.config)
212            .field(
213                "thread_running",
214                &self.thread.as_ref().is_some_and(|t| !t.is_finished()),
215            )
216            .finish_non_exhaustive()
217    }
218}
219
220impl PythonRuntime {
221    /// Spawn a new Python runtime on a dedicated thread.
222    ///
223    /// Creates an asyncio event loop in the thread and starts the command
224    /// dispatch loop.
225    ///
226    /// # Errors
227    ///
228    /// Returns `Error::BackendError` if the thread fails to spawn or
229    /// Python initialization fails.
230    pub fn new(config: RuntimeConfig) -> Result<Self, Error> {
231        let (cmd_tx, cmd_rx) = mpsc::channel(config.channel_capacity);
232
233        let thread_config = config.clone();
234        let thread = std::thread::Builder::new()
235            .name("agy-bridge-python-runtime".into())
236            .spawn(move || {
237                python_thread_main(cmd_rx, &thread_config);
238            })
239            .map_err(|e| Error::BackendError {
240                message: format!("Failed to spawn Python runtime thread: {e}"),
241            })?;
242
243        Ok(Self {
244            cmd_tx: Some(cmd_tx),
245            thread: Some(thread),
246            config,
247        })
248    }
249
250    /// Send a command to the Python thread and await the result.
251    ///
252    /// This is the primary interface for all Python interactions.
253    ///
254    /// # Errors
255    ///
256    /// Returns `Error::ChannelClosed` if the Python thread has exited or the
257    /// reply channel is dropped before a response is sent.
258    async fn send_command<T>(
259        &self,
260        operation: &str,
261        build_cmd: impl FnOnce(oneshot::Sender<Result<T, Error>>) -> PyCommand,
262    ) -> Result<T, Error> {
263        let Some(ref tx) = self.cmd_tx else {
264            return Err(Error::ChannelClosed {
265                message: format!("Python runtime thread is shut down (sending {operation})"),
266            });
267        };
268
269        let (reply_tx, reply_rx) = oneshot::channel();
270        let cmd = build_cmd(reply_tx);
271
272        tx.send(cmd).await.map_err(|e| Error::ChannelClosed {
273            message: format!("Python runtime thread has exited (sending {operation}): {e}"),
274        })?;
275
276        let result = reply_rx.await.map_err(|e| Error::ChannelClosed {
277            message: format!("Reply channel dropped for {operation}: {e}"),
278        })??;
279
280        Ok(result)
281    }
282
283    /// Return the number of agents currently live in this runtime.
284    ///
285    /// Counts agents that have been created but not yet shut down or dropped.
286    /// Primarily useful for observability and for asserting clean teardown.
287    ///
288    /// # Errors
289    ///
290    /// Returns [`Error::ChannelClosed`] if the runtime thread has exited.
291    pub(crate) async fn active_agent_count(&self) -> Result<usize, Error> {
292        self.send_command("active_agent_count", |reply| {
293            PyCommand::GetActiveAgentCount { reply }
294        })
295        .await
296    }
297
298    /// Graceful shutdown: send `Shutdown` command, then join the thread.
299    ///
300    /// # Errors
301    ///
302    /// Returns `Error::Timeout` if the thread doesn't join within the
303    /// configured shutdown timeout, or `Error::BackendError` if the
304    /// thread panicked.
305    pub async fn shutdown(mut self) -> Result<(), Error> {
306        // Signal the command loop to exit.
307        if let Some(tx) = self.cmd_tx.take()
308            && let Err(e) = tx.send(PyCommand::Shutdown).await
309        {
310            tracing::warn!("Shutdown command send failed (thread may already be exiting): {e}");
311        }
312
313        // Take the JoinHandle so Drop doesn't fire the "dropped without
314        // shutdown" warning.
315        let Some(thread) = self.thread.take() else {
316            tracing::warn!("PythonRuntime::shutdown() called but thread handle already taken");
317            return Ok(());
318        };
319
320        let shutdown_timeout = self.config.shutdown_timeout;
321        let join_result = tokio::time::timeout(
322            shutdown_timeout,
323            tokio::task::spawn_blocking(move || thread.join()),
324        )
325        .await;
326
327        match join_result {
328            Ok(Ok(Ok(()))) => {
329                tracing::info!("Python runtime thread joined successfully");
330                Ok(())
331            }
332            Ok(Ok(Err(panic_payload))) => {
333                let panic_msg = panic_payload.downcast_ref::<&str>().map_or_else(
334                    || {
335                        panic_payload
336                            .downcast_ref::<String>()
337                            .map_or_else(|| format!("{panic_payload:?}"), Clone::clone)
338                    },
339                    |s| (*s).to_string(),
340                );
341                tracing::error!(
342                    panic_message = %panic_msg,
343                    "Python runtime thread panicked during shutdown"
344                );
345                Err(Error::BackendError {
346                    message: format!("Python runtime thread panicked during shutdown: {panic_msg}"),
347                })
348            }
349            Ok(Err(join_err)) => {
350                tracing::error!("spawn_blocking join error: {join_err}");
351                Err(Error::BackendError {
352                    message: format!("Failed to join Python thread: {join_err}"),
353                })
354            }
355            Err(_elapsed) => {
356                tracing::error!(
357                    timeout_secs = shutdown_timeout.as_secs(),
358                    "Python runtime thread did not exit within shutdown timeout"
359                );
360                Err(Error::Timeout {
361                    duration: shutdown_timeout,
362                    operation: "PythonRuntime::shutdown (thread join)".to_string(),
363                })
364            }
365        }
366    }
367}
368
369impl Drop for PythonRuntime {
370    fn drop(&mut self) {
371        // If `shutdown()` was already called it took the thread handle, so
372        // there is nothing left to clean up.
373        let Some(thread) = self.thread.take() else {
374            return;
375        };
376
377        // Prompt the command loop to stop and close the channel immediately.
378        let tx = self.cmd_tx.take();
379        if let Some(ref tx) = tx
380            && let Err(e) = tx.try_send(PyCommand::Shutdown)
381        {
382            tracing::debug!(
383                error = %e,
384                "PythonRuntime::drop: could not eagerly signal shutdown; \
385                 relying on channel close"
386            );
387        }
388        // Explicitly drop sender so the receiver encounters EOF immediately.
389        drop(tx);
390
391        // Wait — bounded by the configured shutdown timeout — for the Python
392        // thread to finish releasing resources. This keeps teardown
393        // deterministic (no leaked Python objects) without risking an
394        // unbounded block if the thread misbehaves.
395        let deadline = std::time::Instant::now() + self.config.shutdown_timeout;
396        while !thread.is_finished() && std::time::Instant::now() < deadline {
397            std::thread::sleep(std::time::Duration::from_millis(5));
398        }
399
400        if thread.is_finished() {
401            if thread.join().is_err() {
402                tracing::error!("Python runtime thread panicked during drop cleanup");
403            } else {
404                tracing::debug!("Python runtime thread joined cleanly on drop");
405            }
406        } else {
407            // Dropping `cmd_tx` (right after this returns) closes the channel,
408            // so the loop still exits and cleans up; we simply stop blocking
409            // the dropping thread past the timeout.
410            tracing::warn!(
411                "Python runtime thread still running after shutdown timeout during drop — \
412                 detaching; agent cleanup will complete asynchronously"
413            );
414        }
415    }
416}
417
418/// Entry point for the dedicated Python thread.
419fn python_thread_main(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) {
420    Python::initialize();
421
422    // Environment variables are already loaded by load_dotenv() at bridge
423    // construction time, before any threads are spawned.
424
425    // Configure sys.path so the venv's site-packages are importable.
426    Python::attach(|py| {
427        if let Err(e) = venv::configure_python_sys_path(py) {
428            tracing::error!(
429                error = %e,
430                "Failed to configure Python sys.path in runtime thread — \
431                 venv imports will likely fail"
432            );
433        }
434    });
435
436    if let Err(e) = run_live_thread(cmd_rx, config) {
437        tracing::error!(error = %e, "Python runtime thread failed");
438    }
439
440    tracing::info!("Python runtime thread exiting");
441}
442
443/// Live SDK thread: creates an asyncio event loop and dispatches commands
444/// to the real Antigravity SDK via `pyo3_async_runtimes`.
445fn run_live_thread(cmd_rx: mpsc::Receiver<PyCommand>, config: &RuntimeConfig) -> Result<(), Error> {
446    Python::attach(|py| {
447        let asyncio = py.import("asyncio").map_err(|e| Error::BackendError {
448            message: format!("Failed to import asyncio: {e}"),
449        })?;
450        let event_loop =
451            asyncio
452                .call_method0("new_event_loop")
453                .map_err(|e| Error::BackendError {
454                    message: format!("Failed to create new asyncio event loop: {e}"),
455                })?;
456        asyncio
457            .call_method1("set_event_loop", (&event_loop,))
458            .map_err(|e| Error::BackendError {
459                message: format!("Failed to set asyncio event loop: {e}"),
460            })?;
461
462        // Register the event loop in the process-global helper module so it can
463        // be resolved from any thread. It is stored both as the legacy single
464        // `EVENT_LOOP` attribute and (below) in a per-runtime-thread map, so
465        // that multiple bridges in one process never clobber each other's loop.
466        let sys = py.import("sys").map_err(|e| Error::BackendError {
467            message: format!("Failed to import sys: {e}"),
468        })?;
469        let sys_modules = sys.getattr("modules").map_err(|e| Error::BackendError {
470            message: format!("Failed to get sys.modules: {e}"),
471        })?;
472        let globals_mod = if sys_modules
473            .contains(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
474            .map_err(|e| Error::BackendError {
475                message: format!("Failed to check sys.modules: {e}"),
476            })? {
477            sys_modules
478                .get_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE)
479                .map_err(|e| Error::BackendError {
480                    message: format!("Failed to get _agy_bridge_globals: {e}"),
481                })?
482        } else {
483            let types = py.import("types").map_err(|e| Error::BackendError {
484                message: format!("Failed to import types: {e}"),
485            })?;
486            let module = types
487                .getattr("ModuleType")
488                .map_err(|e| Error::BackendError {
489                    message: format!("Failed to get ModuleType: {e}"),
490                })?
491                .call1((command_loop::AGY_BRIDGE_GLOBALS_MODULE,))
492                .map_err(|e| Error::BackendError {
493                    message: format!("Failed to create ModuleType: {e}"),
494                })?;
495            sys_modules
496                .set_item(command_loop::AGY_BRIDGE_GLOBALS_MODULE, &module)
497                .map_err(|e| Error::BackendError {
498                    message: format!("Failed to register _agy_bridge_globals: {e}"),
499                })?;
500            module
501        };
502        globals_mod
503            .setattr("EVENT_LOOP", &event_loop)
504            .map_err(|e| Error::BackendError {
505                message: format!("Failed to set EVENT_LOOP in globals: {e}"),
506            })?;
507        register_thread_event_loop(py, &globals_mod, &event_loop)?;
508
509        tracing::info!("Python asyncio event loop created on runtime thread");
510
511        let inter_agent_delay = config.inter_agent_delay;
512        let stream_limits = streaming::StreamLimits::from_config(config);
513        let event_loop_obj = event_loop.clone().unbind();
514        let run_fut =
515            pyo3_async_runtimes::tokio::run_until_complete(event_loop.clone(), async move {
516                command_loop::run_async_command_loop(
517                    event_loop_obj,
518                    cmd_rx,
519                    inter_agent_delay,
520                    stream_limits,
521                )
522                .await
523            });
524
525        unregister_thread_event_loop(py, &globals_mod);
526
527        if let Err(e) = run_fut {
528            // Close the event loop best-effort before propagating.
529            if let Err(close_err) = event_loop.call_method0("close") {
530                tracing::warn!("Failed to close asyncio event loop: {close_err}");
531            }
532            return Err(Error::BackendError {
533                message: format!("Python runtime command loop failed: {e}"),
534            });
535        }
536
537        if let Err(e) = event_loop.call_method0("close") {
538            tracing::warn!("Failed to close asyncio event loop: {e}");
539        }
540
541        Ok(())
542    })
543}
544
545/// Register `event_loop` in the process-global `_agy_bridge_globals.EVENT_LOOPS`
546/// map, keyed by the current (runtime) thread's Python identity.
547///
548/// Each [`AgyBridge`](crate::AgyBridge) runs its own runtime thread with its own
549/// asyncio loop. A single shared `EVENT_LOOP` attribute would let a later bridge
550/// overwrite an earlier one's loop; keying by `threading.get_ident()` keeps them
551/// isolated. This map is the per-runtime successor to the legacy `EVENT_LOOP`
552/// attribute and is read by the fallback path in `agent_init.py`, which runs on
553/// this same runtime thread (so the idents match).
554fn register_thread_event_loop(
555    py: Python<'_>,
556    globals_mod: &Bound<'_, PyAny>,
557    event_loop: &Bound<'_, PyAny>,
558) -> Result<(), Error> {
559    let threading = py.import("threading").map_err(|e| Error::BackendError {
560        message: format!("Failed to import threading for event-loop registration: {e}"),
561    })?;
562    let thread_id = threading
563        .call_method0("get_ident")
564        .map_err(|e| Error::BackendError {
565            message: format!("Failed to read threading.get_ident(): {e}"),
566        })?;
567
568    let loops = if globals_mod
569        .hasattr("EVENT_LOOPS")
570        .map_err(|e| Error::BackendError {
571            message: format!("Failed to check for EVENT_LOOPS attribute: {e}"),
572        })? {
573        globals_mod
574            .getattr("EVENT_LOOPS")
575            .map_err(|e| Error::BackendError {
576                message: format!("Failed to get EVENT_LOOPS map: {e}"),
577            })?
578    } else {
579        let dict = pyo3::types::PyDict::new(py).into_any();
580        globals_mod
581            .setattr("EVENT_LOOPS", &dict)
582            .map_err(|e| Error::BackendError {
583                message: format!("Failed to create EVENT_LOOPS map: {e}"),
584            })?;
585        dict
586    };
587
588    loops
589        .set_item(thread_id, event_loop)
590        .map_err(|e| Error::BackendError {
591            message: format!("Failed to register runtime event loop by thread id: {e}"),
592        })?;
593    Ok(())
594}
595
596/// Unregister the current thread's event loop from the process-global
597/// `_agy_bridge_globals.EVENT_LOOPS` map on runtime thread teardown.
598fn unregister_thread_event_loop(py: Python<'_>, globals_mod: &Bound<'_, PyAny>) {
599    let unregister_res = (|| -> PyResult<()> {
600        let threading = py.import("threading")?;
601        let thread_id = threading.call_method0("get_ident")?;
602        if globals_mod.hasattr("EVENT_LOOPS")? {
603            let loops = globals_mod.getattr("EVENT_LOOPS")?;
604            let dict = loops.cast::<pyo3::types::PyDict>()?;
605            dict.del_item(thread_id)?;
606        }
607        Ok(())
608    })();
609    if let Err(e) = unregister_res {
610        tracing::debug!(error = %e, "Failed to unregister thread event loop on teardown");
611    }
612}
613
614/// Compute which SDK builtin tools are active based on the agent's
615/// [`CapabilitiesConfig`].
616///
617/// - `enabled_tools: Some(list)` → only those tools are active.
618/// - `disabled_tools: Some(list)` → all tools minus the disabled ones.
619/// - Neither set → all builtin tools are active.
620fn compute_active_builtins(
621    config: &crate::config::AgentConfig,
622) -> Vec<crate::config::BuiltinTools> {
623    let Some(caps) = config.capabilities.as_ref() else {
624        return crate::config::BuiltinTools::all_tools().to_vec();
625    };
626
627    // `enabled_tools`, when present, is authoritative: an explicit list selects
628    // exactly those tools, and an explicit empty list disables all builtins.
629    if let Some(enabled) = caps.enabled_tools.as_ref() {
630        return enabled.clone();
631    }
632
633    // Otherwise, a `disabled_tools` list subtracts from the full builtin set.
634    if let Some(disabled) = caps.disabled_tools.as_ref() {
635        return crate::config::BuiltinTools::all_tools()
636            .iter()
637            .filter(|t| !disabled.contains(t))
638            .cloned()
639            .collect();
640    }
641
642    // Neither set → all builtin tools are active.
643    crate::config::BuiltinTools::all_tools().to_vec()
644}
645
646impl crate::agent::Runtime for PythonRuntime {
647    async fn create_agent(
648        &self,
649        agent_id: u64,
650        config: crate::config::AgentConfig,
651    ) -> Result<(crate::agent::AgentId, Vec<crate::tools::AvailableTool>), Error> {
652        // The local harness persists the trajectory to `save_dir` only if that
653        // directory already exists; neither it nor the Python SDK creates it.
654        // A missing directory means persistence is silently skipped, so a later
655        // resume fails with "conversation not found". Since `save_dir` is an
656        // explicit opt-in to persistence, create it here. On failure we log
657        // rather than abort: the SDK treats a missing directory as an ephemeral
658        // session, and a subsequent resume attempt would surface the problem
659        // loudly on its own.
660        if let Some(save_dir) = config.save_dir.as_ref()
661            && let Err(e) = std::fs::create_dir_all(save_dir)
662        {
663            tracing::warn!(
664                save_dir = %save_dir.display(),
665                error = ?e,
666                "Failed to create save_dir; conversation state may not persist \
667                 and resume may fail with \"conversation not found\""
668            );
669        }
670
671        // Serialize the AgentConfig and inject the runtime's backend log
672        // level so the Python init script can configure logging without
673        // needing a separate FFI parameter.
674        let config_json = {
675            let mut val = serde_json::to_value(&config).map_err(|e| Error::BackendError {
676                message: format!("Failed to serialize AgentConfig: {e}"),
677            })?;
678            if let serde_json::Value::Object(ref mut map) = val {
679                map.insert(
680                    "_backend_log_level".to_owned(),
681                    serde_json::Value::String(self.config.backend_log_level.as_str().to_owned()),
682                );
683            }
684            serde_json::to_string(&val).map_err(|e| Error::BackendError {
685                message: format!("Failed to re-serialize config JSON: {e}"),
686            })?
687        };
688
689        // Collect the names of custom Rust tools so we can tag them correctly.
690        let custom_tool_names: std::collections::HashSet<String> =
691            config.tools.iter().map(|t| t.name.clone()).collect();
692
693        let (raw_id, raw_tools) = self
694            .send_command("create_agent", |reply| PyCommand::CreateAgent {
695                agent_id,
696                config_json,
697                reply,
698            })
699            .await?;
700
701        // Compute which builtins are active so we can tag and deduplicate them.
702        let active_builtins = compute_active_builtins(&config);
703        let builtin_names: std::collections::HashSet<&str> = active_builtins
704            .iter()
705            .map(crate::config::BuiltinTools::as_sdk_name)
706            .collect();
707
708        // Convert RawToolInfo → AvailableTool with source tags.
709        // Python's ToolRunner includes builtins in its `tools` dict, so we
710        // skip them here and add them back below with the Builtin tag.
711        let mut available_tools: Vec<crate::tools::AvailableTool> = raw_tools
712            .into_iter()
713            .filter(|raw| !builtin_names.contains(raw.name.as_str()))
714            .map(|raw| {
715                let source = if custom_tool_names.contains(&raw.name) {
716                    crate::tools::ToolSource::Custom
717                } else {
718                    crate::tools::ToolSource::Mcp
719                };
720                crate::tools::AvailableTool {
721                    name: raw.name,
722                    description: raw.description,
723                    parameter_schema: raw.parameter_schema,
724                    source,
725                }
726            })
727            .collect();
728
729        // Add builtin tools with their known descriptions.
730        for builtin in active_builtins {
731            available_tools.push(crate::tools::AvailableTool {
732                name: builtin.as_sdk_name().to_owned(),
733                description: builtin.description().to_owned(),
734                parameter_schema: serde_json::Value::Null,
735                source: crate::tools::ToolSource::Builtin,
736            });
737        }
738
739        tracing::info!(
740            agent_id = raw_id.0,
741            tool_count = available_tools.len(),
742            tools = ?available_tools.iter().map(|t| format!("{t}")).collect::<Vec<_>>(),
743            "Agent created with available tools"
744        );
745
746        Ok((raw_id.0, available_tools))
747    }
748
749    async fn chat(
750        &self,
751        agent_id: crate::agent::AgentId,
752        content: &crate::content::Content,
753    ) -> Result<crate::streaming::ChatResponseHandle, Error> {
754        let prompt = match content {
755            crate::content::Content::Text { text } => text.clone(),
756            other => crate::content::content_to_json(other)?,
757        };
758        self.send_command("chat", |reply| PyCommand::Chat {
759            agent_id: AgentId(agent_id),
760            prompt,
761            reply,
762        })
763        .await
764    }
765
766    async fn shutdown_agent(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
767        self.send_command("shutdown_agent", |reply| PyCommand::ShutdownAgent {
768            agent_id: AgentId(agent_id),
769            reply,
770        })
771        .await
772    }
773
774    fn try_shutdown_agent(&self, agent_id: crate::agent::AgentId) {
775        // Fire-and-forget: create a oneshot whose receiver we drop immediately.
776        // The Python thread will still process the shutdown; we just don't wait
777        // for the result.
778        if let Some(ref tx) = self.cmd_tx {
779            let (reply, _) = oneshot::channel();
780            if let Err(e) = tx.try_send(PyCommand::ShutdownAgent {
781                agent_id: AgentId(agent_id),
782                reply,
783            }) {
784                tracing::debug!(
785                    agent_id = agent_id,
786                    error = %e,
787                    "try_shutdown_agent: channel send failed (runtime may already be gone)"
788                );
789            }
790        }
791    }
792
793    async fn cancel(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
794        self.send_command("cancel", |reply| PyCommand::Cancel {
795            agent_id: AgentId(agent_id),
796            reply,
797        })
798        .await
799    }
800
801    async fn wait_for_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
802        self.send_command("wait_for_idle", |reply| PyCommand::WaitForIdle {
803            agent_id: AgentId(agent_id),
804            reply,
805        })
806        .await
807    }
808
809    async fn send(
810        &self,
811        agent_id: crate::agent::AgentId,
812        content: &crate::content::Content,
813    ) -> Result<(), Error> {
814        let prompt = match content {
815            crate::content::Content::Text { text } => text.clone(),
816            other => crate::content::content_to_json(other)?,
817        };
818        self.send_command("send", |reply| PyCommand::Send {
819            agent_id: AgentId(agent_id),
820            prompt,
821            reply,
822        })
823        .await
824    }
825
826    async fn signal_idle(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
827        self.send_command("signal_idle", |reply| PyCommand::SignalIdle {
828            agent_id: AgentId(agent_id),
829            reply,
830        })
831        .await
832    }
833
834    async fn wait_for_wakeup(
835        &self,
836        agent_id: crate::agent::AgentId,
837        timeout: std::time::Duration,
838    ) -> Result<bool, Error> {
839        self.send_command("wait_for_wakeup", |reply| PyCommand::WaitForWakeup {
840            agent_id: AgentId(agent_id),
841            timeout_secs: timeout.as_secs_f64(),
842            reply,
843        })
844        .await
845    }
846
847    async fn history(
848        &self,
849        agent_id: crate::agent::AgentId,
850    ) -> Result<Vec<crate::types::ConversationMessage>, Error> {
851        self.send_command("get_history", |reply| PyCommand::GetHistory {
852            agent_id: AgentId(agent_id),
853            reply,
854        })
855        .await
856    }
857
858    async fn turn_count(&self, agent_id: crate::agent::AgentId) -> Result<u32, Error> {
859        self.send_command("get_turn_count", |reply| PyCommand::GetTurnCount {
860            agent_id: AgentId(agent_id),
861            reply,
862        })
863        .await
864    }
865
866    async fn total_usage(
867        &self,
868        agent_id: crate::agent::AgentId,
869    ) -> Result<crate::types::UsageMetadata, Error> {
870        self.send_command("get_total_usage", |reply| PyCommand::GetTotalUsage {
871            agent_id: AgentId(agent_id),
872            reply,
873        })
874        .await
875    }
876
877    async fn last_turn_usage(
878        &self,
879        agent_id: crate::agent::AgentId,
880    ) -> Result<crate::types::UsageMetadata, Error> {
881        self.send_command("get_last_turn_usage", |reply| PyCommand::GetLastTurnUsage {
882            agent_id: AgentId(agent_id),
883            reply,
884        })
885        .await
886    }
887
888    async fn clear_history(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
889        self.send_command("clear_history", |reply| PyCommand::ClearHistory {
890            agent_id: AgentId(agent_id),
891            reply,
892        })
893        .await
894    }
895
896    async fn compaction_indices(&self, agent_id: crate::agent::AgentId) -> Result<Vec<u32>, Error> {
897        self.send_command("compaction_indices", |reply| {
898            PyCommand::GetCompactionIndices {
899                agent_id: AgentId(agent_id),
900                reply,
901            }
902        })
903        .await
904    }
905
906    async fn last_response(
907        &self,
908        agent_id: crate::agent::AgentId,
909    ) -> Result<Option<String>, Error> {
910        self.send_command("last_response", |reply| PyCommand::GetLastResponse {
911            agent_id: AgentId(agent_id),
912            reply,
913        })
914        .await
915    }
916
917    async fn delete(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
918        self.send_command("delete", |reply| PyCommand::Delete {
919            agent_id: AgentId(agent_id),
920            reply,
921        })
922        .await
923    }
924
925    async fn disconnect(&self, agent_id: crate::agent::AgentId) -> Result<(), Error> {
926        self.send_command("disconnect", |reply| PyCommand::Disconnect {
927            agent_id: AgentId(agent_id),
928            reply,
929        })
930        .await
931    }
932
933    async fn is_idle(&self, agent_id: crate::agent::AgentId) -> Result<bool, Error> {
934        self.send_command("is_idle", |reply| PyCommand::IsIdle {
935            agent_id: AgentId(agent_id),
936            reply,
937        })
938        .await
939    }
940}