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