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