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