Skip to main content

agy_bridge/agent/
mod.rs

1//! Agent lifecycle management for the Antigravity SDK bridge.
2//!
3//! Provides [`AgentHandle`](crate::agent::AgentHandle) which wraps the lifecycle of a single SDK agent:
4//! creation, chatting, conversation tracking, and shutdown with RAII warnings.
5
6use std::sync::{
7    Arc, Mutex,
8    atomic::{AtomicBool, Ordering},
9};
10
11use crate::{
12    config::AgentConfig,
13    content::Content,
14    error::Error,
15    streaming::{ChatResponseHandle, ChatResponseSharedState},
16    types::{ConversationMessage, UsageMetadata},
17};
18
19#[cfg(test)]
20pub(crate) mod mock;
21
22/// Unique identifier for an agent within the bridge.
23pub type AgentId = u64;
24
25/// Trait abstracting the Python runtime interface.
26///
27/// This allows unit tests to inject a mock runtime without requiring a live
28/// Python interpreter. The real implementation will call through to `PyO3`.
29// NOLINT: async_fn_in_trait is intentional — Runtime is not object-safe by design
30#[expect(
31    async_fn_in_trait,
32    reason = "Runtime is not object-safe by design; callers always know the concrete type"
33)]
34pub trait Runtime: Send + Sync {
35    /// Create an agent from the given config, returning its ID and the list
36    /// of all available tools (custom, MCP, and builtin) with metadata.
37    ///
38    /// `agent_id` is a process-globally-unique identifier allocated by the
39    /// caller *before* creation, so per-agent initialization state can be
40    /// registered under it without any cross-agent locking.
41    async fn create_agent(
42        &self,
43        agent_id: u64,
44        config: AgentConfig,
45    ) -> Result<(AgentId, Vec<crate::tools::AvailableTool>), Error>;
46
47    /// Send a chat message to the agent, returning a streaming response handle.
48    ///
49    /// The `content` parameter accepts any [`Content`] variant: plain text,
50    /// images, documents, audio, video, or a multi-part list.
51    async fn chat(&self, agent_id: AgentId, content: &Content)
52    -> Result<ChatResponseHandle, Error>;
53
54    /// Gracefully shut down the agent.
55    async fn shutdown_agent(&self, agent_id: AgentId) -> Result<(), Error>;
56
57    /// Interrupt any active prompt/chat run.
58    async fn cancel(&self, agent_id: AgentId) -> Result<(), Error>;
59
60    /// Wait for the active run or conversational loop to stabilize.
61    async fn wait_for_idle(&self, agent_id: AgentId) -> Result<(), Error>;
62
63    /// Send a message without waiting for completion.
64    async fn send(&self, agent_id: AgentId, content: &Content) -> Result<(), Error>;
65
66    /// Signal that the agent is idle.
67    async fn signal_idle(&self, agent_id: AgentId) -> Result<(), Error>;
68
69    /// Wait for the agent to wake up. Returns true if woken, false if timed out.
70    async fn wait_for_wakeup(
71        &self,
72        agent_id: AgentId,
73        timeout: std::time::Duration,
74    ) -> Result<bool, Error>;
75
76    /// Retrieve the conversation's message history.
77    async fn history(&self, agent_id: AgentId) -> Result<Vec<ConversationMessage>, Error>;
78
79    /// Return the number of completed turns in the conversation.
80    async fn turn_count(&self, agent_id: AgentId) -> Result<u32, Error>;
81
82    /// Return cumulative token usage across all turns.
83    async fn total_usage(&self, agent_id: AgentId) -> Result<UsageMetadata, Error>;
84
85    /// Return token usage from the most recent turn only.
86    async fn last_turn_usage(&self, agent_id: AgentId) -> Result<UsageMetadata, Error>;
87
88    /// Clear the conversation history and reset state.
89    async fn clear_history(&self, agent_id: AgentId) -> Result<(), Error>;
90
91    /// Return the text of the last model response, if any.
92    ///
93    /// Default implementation returns `Ok(None)`.
94    async fn last_response(&self, _agent_id: AgentId) -> Result<Option<String>, Error> {
95        Ok(None)
96    }
97
98    /// Return the step indices at which compaction occurred.
99    ///
100    /// Default implementation returns an empty list.
101    async fn compaction_indices(&self, _agent_id: AgentId) -> Result<Vec<u32>, Error> {
102        Ok(Vec::new())
103    }
104
105    /// Delete the conversation and all associated state.
106    ///
107    /// Default implementation is a no-op that returns `Ok(())`.
108    async fn delete(&self, _agent_id: AgentId) -> Result<(), Error> {
109        Ok(())
110    }
111
112    /// Disconnect from the agent without deleting state.
113    ///
114    /// Default implementation is a no-op that returns `Ok(())`.
115    async fn disconnect(&self, _agent_id: AgentId) -> Result<(), Error> {
116        Ok(())
117    }
118
119    /// Check whether the agent is currently idle (not running a turn).
120    ///
121    /// Default implementation returns `Ok(true)`.
122    async fn is_idle(&self, _agent_id: AgentId) -> Result<bool, Error> {
123        Ok(true)
124    }
125
126    /// Best-effort synchronous shutdown signal, called from [`Drop`].
127    ///
128    /// Unlike [`shutdown_agent`](Self::shutdown_agent), this is sync and
129    /// fire-and-forget — it cannot return errors. The default is a no-op;
130    /// implementations backed by a command channel should `try_send` a
131    /// shutdown command here.
132    fn try_shutdown_agent(&self, _agent_id: AgentId) {}
133}
134
135/// Handle to a running agent.
136///
137/// Wraps the agent's lifecycle: creation, chat, and shutdown.
138///
139/// Call [`shutdown()`](Self::shutdown) for a clean, error-reported shutdown.
140/// If the handle is dropped without calling `shutdown()`, a best-effort
141/// background shutdown is spawned via [`tokio::spawn`] — the Python agent
142/// will be cleaned up, but errors are only logged, not returned.
143///
144/// Most methods take `&self` — interior mutability is used where needed
145/// so multiple concurrent operations can share a single handle.
146///
147/// # Mutex choice
148///
149/// This type uses [`std::sync::Mutex`] rather than [`tokio::sync::Mutex`]
150/// because every lock acquisition is a brief, synchronous operation (pointer
151/// swap or clone) that **never** spans an `.await` point. For these
152/// microsecond critical sections, `std::sync::Mutex` is both simpler and
153/// lower-overhead than the async alternative.
154pub struct AgentHandle<R: Runtime + 'static> {
155    id: AgentId,
156    runtime: Arc<R>,
157    config: AgentConfig,
158    /// Kept alive for the agent's lifetime so the global `BRIDGE_STATE`
159    /// entry isn't the only strong reference.
160    _registry: Option<Arc<crate::tools::ToolRegistry>>,
161    /// Kept alive to preserve a strong reference to the policy confirmation handler.
162    policy_handler: Option<Arc<dyn crate::policies::AskUserHandler>>,
163    conversation_id: Arc<Mutex<Option<String>>>,
164    is_started: AtomicBool,
165    is_shutdown: AtomicBool,
166    /// All tools available to this agent — custom Rust tools, MCP tools, and
167    /// SDK builtins — with metadata about source, description, and schema.
168    available_tools: Vec<crate::tools::AvailableTool>,
169    /// Shared state from the last completed chat response, used to surface
170    /// `get_last_structured_output()` without round-tripping to Python.
171    ///
172    /// Wrapped in a `Mutex` so `chat()` can take `&self` instead of `&mut self`,
173    /// enabling concurrent usage patterns. The lock is brief (pointer swap only).
174    last_shared_state: Mutex<Option<Arc<Mutex<ChatResponseSharedState>>>>,
175}
176
177/// RAII guard that removes an agent's entry from the initializing hook-runner
178/// registry when dropped, guaranteeing no stale entry survives — whether
179/// [`AgentHandle::new`] succeeds, returns early on error, or panics.
180struct InitializingHookGuard(u64);
181
182impl Drop for InitializingHookGuard {
183    fn drop(&mut self) {
184        match crate::runtime::initializing_hook_runners().write() {
185            Ok(mut map) => {
186                map.remove(&self.0);
187            }
188            Err(e) => {
189                tracing::error!(
190                    agent_id = self.0,
191                    error = %e,
192                    "initializing hook runners lock poisoned during cleanup — \
193                     stale hook runner may persist"
194                );
195            }
196        }
197    }
198}
199
200impl<R: Runtime> AgentHandle<R> {
201    /// Create a new agent from the given runtime and configuration.
202    ///
203    /// This sends a `CreateAgent` command to the Python runtime, waits for
204    /// quota availability, and returns the handle.
205    ///
206    /// # Errors
207    ///
208    /// Returns a [`Error`] if agent creation fails (e.g. invalid config,
209    /// Python error, or quota exceeded).
210    pub async fn new(
211        runtime: Arc<R>,
212        config: AgentConfig,
213        registry: Option<Arc<crate::tools::ToolRegistry>>,
214        hook_runner: Option<Arc<crate::hooks::Hooks>>,
215        policy_handler: Option<Arc<dyn crate::policies::AskUserHandler>>,
216    ) -> Result<Self, Error> {
217        // Allocate the (process-globally-unique) agent ID up front so we can
218        // register per-agent initialization state *before* creation, keyed by
219        // the ID. This avoids any process-wide lock across `create_agent`, so
220        // concurrent creations — on the same or different bridges — never
221        // block one another.
222        let agent_id_u64 = crate::runtime::next_agent_id();
223
224        let effective_hook_runner =
225            hook_runner.unwrap_or_else(|| Arc::new(crate::hooks::Hooks::new()));
226
227        // Install the hook runner in the per-agent initializing registry so
228        // hooks that fire during `__aenter__` (before the permanent bridge
229        // state exists) resolve correctly. `InitializingHookGuard` removes the
230        // entry on every exit path, including early errors.
231        match crate::runtime::initializing_hook_runners().write() {
232            Ok(mut map) => {
233                map.insert(agent_id_u64, Arc::clone(&effective_hook_runner));
234            }
235            Err(e) => {
236                return Err(Error::BackendError {
237                    message: format!(
238                        "initializing hook runners lock poisoned — hooks cannot be installed: {e}"
239                    ),
240                });
241            }
242        }
243        let _init_guard = InitializingHookGuard(agent_id_u64);
244
245        let (agent_id, available_tools) =
246            runtime.create_agent(agent_id_u64, config.clone()).await?;
247        debug_assert_eq!(
248            agent_id, agent_id_u64,
249            "runtime must echo the caller-provided agent ID"
250        );
251        tracing::info!(agent_id, "Agent created successfully");
252
253        let conversation_id = Self::setup_bridge_state(
254            &runtime,
255            agent_id,
256            &config,
257            registry.as_ref(),
258            effective_hook_runner,
259            policy_handler.as_ref(),
260        )
261        .await?;
262
263        Ok(Self {
264            id: agent_id,
265            runtime,
266            config,
267            _registry: registry,
268            policy_handler,
269            conversation_id,
270            is_started: AtomicBool::new(true),
271            is_shutdown: AtomicBool::new(false),
272            available_tools,
273            last_shared_state: Mutex::new(None),
274        })
275    }
276
277    async fn setup_bridge_state(
278        runtime: &Arc<R>,
279        id: AgentId,
280        config: &AgentConfig,
281        registry: Option<&Arc<crate::tools::ToolRegistry>>,
282        effective_hook_runner: Arc<crate::hooks::Hooks>,
283        policy_handler: Option<&Arc<dyn crate::policies::AskUserHandler>>,
284    ) -> Result<Arc<Mutex<Option<String>>>, Error> {
285        let policies_set = crate::policies::PolicySet::validated_from(config.policies.clone())?;
286        let conversation_id = Arc::new(Mutex::new(config.conversation_id.clone()));
287        let bridge_entry = crate::runtime::AgentBridgeState {
288            registry: registry.map(Arc::clone),
289            hook_runner: Some(effective_hook_runner),
290            policies: policies_set,
291            policy_handler: policy_handler.map(Arc::clone),
292            tool_state: llm_tool::SharedState::new(),
293            conversation_id: Arc::clone(&conversation_id),
294            last_tool_error: std::sync::Mutex::new(None),
295        };
296        let bridge_insert_failed = match crate::runtime::bridge_state().write() {
297            Ok(mut map) => {
298                map.insert(id, bridge_entry);
299                false
300            }
301            Err(e) => {
302                tracing::error!(
303                    agent_id = id,
304                    error = %e,
305                    "Failed to acquire write lock on BRIDGE_STATE — agent would be unusable"
306                );
307                true
308            }
309        };
310        if bridge_insert_failed {
311            if let Err(shutdown_err) = runtime.shutdown_agent(id).await {
312                tracing::error!(
313                    agent_id = id,
314                    error = ?shutdown_err,
315                    "Failed to shut down agent after BRIDGE_STATE lock failure"
316                );
317            }
318            return Err(Error::BackendError {
319                message: "BRIDGE_STATE RwLock poisoned during agent creation".to_string(),
320            });
321        }
322        Ok(conversation_id)
323    }
324
325    /// Send a message and receive a streaming response.
326    ///
327    /// Accepts any type that converts into [`Content`]: `&str`, `String`,
328    /// [`Image`](crate::content::Image), [`Document`](crate::content::Document),
329    /// [`Audio`](crate::content::Audio), [`Video`](crate::content::Video), or a
330    /// `Vec<ContentPrimitive>` for multimodal input.
331    ///
332    /// This is **single-shot**: a quota error (HTTP 429) or any other failure
333    /// is returned immediately. Retrying is the caller's responsibility, so a
334    /// caller running its own retry loop is never double-retried.
335    ///
336    /// # Errors
337    ///
338    /// Returns a [`Error`] on chat failure (Python error, timeout, etc.).
339    pub async fn chat(&self, content: impl Into<Content>) -> Result<ChatResponseHandle, Error> {
340        if !self.is_started() {
341            return Err(Error::AgentNotStarted);
342        }
343        self.chat_once(&content.into()).await
344    }
345
346    /// Perform a single (non-retrying) chat turn, recording the streaming
347    /// shared state for later `get_last_structured_output()` access.
348    async fn chat_once(&self, content: &Content) -> Result<ChatResponseHandle, Error> {
349        let handle = self.runtime.chat(self.id, content).await?;
350        match self.last_shared_state.lock() {
351            Ok(mut guard) => {
352                *guard = Some(Arc::clone(&handle.shared_state));
353            }
354            Err(e) => {
355                tracing::error!(
356                    agent_id = self.id,
357                    error = %e,
358                    "last_shared_state mutex poisoned — streaming metadata may be stale"
359                );
360            }
361        }
362        Ok(handle)
363    }
364
365    /// Send a message and return the final text response.
366    ///
367    /// This is a convenience wrapper around [`chat`](Self::chat) that drains
368    /// the streaming response into a single `String`. If tools were associated
369    /// with the agent at creation time, the Python runtime handles tool
370    /// execution automatically.
371    ///
372    /// Like [`chat`](Self::chat), this is **single-shot**: quota / 429 errors
373    /// that surface *during* streaming are returned to the caller immediately.
374    /// Retrying is the caller's responsibility.
375    ///
376    /// # Errors
377    ///
378    /// Returns [`Error`] if the chat turn fails or stream errors occur.
379    pub async fn chat_text(&self, message: impl Into<Content>) -> Result<String, Error> {
380        self.chat_text_once(&message.into()).await
381    }
382
383    /// Perform a single (non-retrying) chat turn and drain it to text.
384    async fn chat_text_once(&self, content: &Content) -> Result<String, Error> {
385        let response = self.chat_once(content).await?;
386        let text = response.text().await?;
387        Ok(text.into_string())
388    }
389
390    /// Return this agent's SDK conversation id, if one is known.
391    ///
392    /// This mirrors the Antigravity SDK session: it is the trajectory
393    /// (`cascade_id`) that the local harness assigns during the first turn, and
394    /// becomes available once that turn has started. If you supplied a
395    /// [`conversation_id`](crate::config::AgentConfig::conversation_id) to
396    /// resume a prior conversation, this returns that same id. Before the first
397    /// turn (or for a never-run agent) it is `None`.
398    ///
399    /// Persist the returned id (together with the agent's
400    /// [`save_dir`](crate::config::AgentConfig::save_dir)) to resume the
401    /// conversation later.
402    ///
403    /// Returns a cloned `String` because the underlying value is behind a
404    /// [`Mutex`] (interior mutability for `&self` access).
405    #[must_use]
406    pub fn conversation_id(&self) -> Option<String> {
407        let guard = self.conversation_id.lock().unwrap_or_else(|e| {
408            tracing::warn!(agent_id = self.id, error = %e, "conversation_id mutex was poisoned, recovering");
409            e.into_inner()
410        });
411        guard.clone()
412    }
413
414    /// Check whether the agent has been started and is not yet shut down.
415    #[must_use]
416    pub fn is_started(&self) -> bool {
417        self.is_started.load(Ordering::SeqCst) && !self.is_shutdown.load(Ordering::SeqCst)
418    }
419
420    /// Return the agent's unique identifier.
421    #[must_use]
422    pub const fn id(&self) -> AgentId {
423        self.id
424    }
425
426    /// Return a reference to the agent's configuration.
427    #[must_use]
428    pub const fn config(&self) -> &AgentConfig {
429        &self.config
430    }
431
432    /// Return all tools available to this agent, with metadata.
433    ///
434    /// Each [`AvailableTool`](crate::tools::AvailableTool) includes the tool's
435    /// name, description, JSON parameter schema, and source tag
436    /// ([`Builtin`](crate::tools::ToolSource::Builtin),
437    /// [`Custom`](crate::tools::ToolSource::Custom), or
438    /// [`Mcp`](crate::tools::ToolSource::Mcp)).
439    ///
440    /// The list is assembled at agent creation time and is immutable for
441    /// the agent's lifetime.
442    #[must_use]
443    pub fn available_tools(&self) -> &[crate::tools::AvailableTool] {
444        &self.available_tools
445    }
446
447    /// Convenience accessor: returns just the tool names.
448    #[must_use]
449    pub fn available_tool_names(&self) -> Vec<&str> {
450        self.available_tools
451            .iter()
452            .map(|t| t.name.as_str())
453            .collect()
454    }
455
456    /// Interrupt the active chat prompt execution.
457    ///
458    /// # Errors
459    ///
460    /// Returns a [`Error`] if the cancellation call fails.
461    pub async fn cancel(&self) -> Result<(), Error> {
462        self.runtime.cancel(self.id).await
463    }
464
465    /// Wait for the conversation or active run to stabilize and become idle.
466    ///
467    /// # Errors
468    ///
469    /// Returns a [`Error`] if the wait call fails.
470    pub async fn wait_for_idle(&self) -> Result<(), Error> {
471        self.runtime.wait_for_idle(self.id).await
472    }
473
474    /// Retrieve the conversation's message history.
475    ///
476    /// # Errors
477    ///
478    /// Returns [`Error`] if the query fails.
479    pub async fn history(&self) -> Result<Vec<ConversationMessage>, Error> {
480        self.runtime.history(self.id).await
481    }
482
483    /// Return the number of completed turns in the conversation.
484    ///
485    /// # Errors
486    ///
487    /// Returns [`Error`] if the query fails.
488    pub async fn turn_count(&self) -> Result<u32, Error> {
489        self.runtime.turn_count(self.id).await
490    }
491
492    /// Return cumulative token usage across all turns.
493    ///
494    /// # Errors
495    ///
496    /// Returns [`Error`] if the query fails.
497    pub async fn total_usage(&self) -> Result<UsageMetadata, Error> {
498        self.runtime.total_usage(self.id).await
499    }
500
501    /// Return token usage from the most recent turn only.
502    ///
503    /// # Errors
504    ///
505    /// Returns [`Error`] if the query fails.
506    pub async fn last_turn_usage(&self) -> Result<UsageMetadata, Error> {
507        self.runtime.last_turn_usage(self.id).await
508    }
509
510    /// Clear the conversation history and reset state.
511    ///
512    /// # Errors
513    ///
514    /// Returns [`Error`] if the operation fails.
515    pub async fn clear_history(&self) -> Result<(), Error> {
516        self.runtime.clear_history(self.id).await
517    }
518
519    /// Return the text of the last model response, if any.
520    ///
521    /// # Errors
522    ///
523    /// Returns [`Error`] if the query fails.
524    pub async fn last_response(&self) -> Result<Option<String>, Error> {
525        self.runtime.last_response(self.id).await
526    }
527
528    /// Return the step indices at which conversation compaction occurred.
529    ///
530    /// # Errors
531    ///
532    /// Returns [`Error`] if the query fails.
533    pub async fn compaction_indices(&self) -> Result<Vec<u32>, Error> {
534        self.runtime.compaction_indices(self.id).await
535    }
536
537    /// Delete the conversation and all associated state.
538    ///
539    /// After calling this method, the agent handle is no longer usable
540    /// for chat operations. This also marks the agent as shut down.
541    ///
542    /// # Errors
543    ///
544    /// Returns [`Error`] if the delete operation fails.
545    pub async fn delete(&self) -> Result<(), Error> {
546        let result = self.runtime.delete(self.id).await;
547        self.is_shutdown.store(true, Ordering::SeqCst);
548        result
549    }
550
551    /// Disconnect from the agent without deleting its state.
552    ///
553    /// The agent's conversation state is preserved but this handle
554    /// can no longer send messages. Marks the agent as shut down.
555    ///
556    /// # Errors
557    ///
558    /// Returns [`Error`] if the disconnect operation fails.
559    pub async fn disconnect(&self) -> Result<(), Error> {
560        let result = self.runtime.disconnect(self.id).await;
561        self.is_shutdown.store(true, Ordering::SeqCst);
562        result
563    }
564
565    /// Check whether the agent is currently idle (not running a turn).
566    ///
567    /// # Errors
568    ///
569    /// Returns [`Error`] if the query fails.
570    pub async fn is_idle(&self) -> Result<bool, Error> {
571        self.runtime.is_idle(self.id).await
572    }
573
574    /// Return the structured output from the last chat response as raw JSON.
575    ///
576    /// Returns `None` if the agent was not configured with `response_schema`
577    /// or if the model did not produce structured output.
578    #[must_use]
579    pub fn get_last_structured_output(&self) -> Option<serde_json::Value> {
580        let guard = self.last_shared_state.lock().unwrap_or_else(|e| {
581            tracing::warn!(
582                agent_id = self.id,
583                error = %e,
584                "last_shared_state mutex was poisoned, recovering"
585            );
586            e.into_inner()
587        });
588        let state_arc = guard.as_ref()?;
589        let state = state_arc.lock().unwrap_or_else(|e| {
590            tracing::warn!(
591                agent_id = self.id,
592                error = %e,
593                "ChatResponseSharedState mutex was poisoned, recovering"
594            );
595            e.into_inner()
596        });
597        state.structured_output.clone()
598    }
599
600    /// Return the structured output from the last chat response deserialized into `T`.
601    ///
602    /// Returns `None` if there was no structured output on the last response.
603    /// Returns `Some(Err(...))` if the structured output could not be deserialized as `T`.
604    pub fn get_last_structured_output_as<T: serde::de::DeserializeOwned>(
605        &self,
606    ) -> Option<Result<T, serde_json::Error>> {
607        self.get_last_structured_output()
608            .map(serde_json::from_value)
609    }
610
611    /// Return the usage metadata from the last chat response, if any.
612    #[must_use]
613    pub fn get_last_usage(&self) -> Option<UsageMetadata> {
614        let guard = self.last_shared_state.lock().unwrap_or_else(|e| {
615            tracing::warn!(
616                agent_id = self.id,
617                error = %e,
618                "last_shared_state mutex was poisoned, recovering"
619            );
620            e.into_inner()
621        });
622        let state_arc = guard.as_ref()?;
623        let state = state_arc.lock().unwrap_or_else(|e| {
624            tracing::warn!(
625                agent_id = self.id,
626                error = %e,
627                "ChatResponseSharedState mutex was poisoned, recovering"
628            );
629            e.into_inner()
630        });
631        state.usage.clone()
632    }
633
634    /// Send a message without waiting for a response.
635    ///
636    /// Fire-and-forget: the message is delivered to the agent but no
637    /// streaming response is produced.
638    ///
639    /// This is **single-shot**; retrying is the caller's responsibility.
640    ///
641    /// # Errors
642    ///
643    /// Returns a [`Error`] if sending fails.
644    pub async fn send(&self, content: impl Into<Content>) -> Result<(), Error> {
645        if !self.is_started() {
646            return Err(Error::AgentNotStarted);
647        }
648        self.runtime.send(self.id, &content.into()).await
649    }
650
651    /// Signal that this agent is idle and ready to receive input.
652    ///
653    /// # Errors
654    ///
655    /// Returns a [`Error`] if the signal call fails.
656    pub async fn signal_idle(&self) -> Result<(), Error> {
657        self.runtime.signal_idle(self.id).await
658    }
659
660    /// Wait for the agent to wake up, returning `true` if woken or
661    /// `false` if the `timeout` elapsed.
662    ///
663    /// # Errors
664    ///
665    /// Returns a [`Error`] if the wait call fails.
666    pub async fn wait_for_wakeup(&self, timeout: std::time::Duration) -> Result<bool, Error> {
667        self.runtime.wait_for_wakeup(self.id, timeout).await
668    }
669
670    /// Gracefully shut down the agent.
671    ///
672    /// This sends a `ShutdownAgent` command to the Python runtime, which
673    /// calls `__aexit__()` on the SDK agent. The handle remains usable
674    /// for read-only queries (e.g. [`is_started()`](Self::is_started))
675    /// after shutdown.
676    ///
677    /// # Errors
678    ///
679    /// Returns a [`Error`] if shutdown fails. The `is_shutdown`
680    /// flag is always set so the `Drop` impl will not emit a warning.
681    pub async fn shutdown(&self) -> Result<(), Error> {
682        if self.is_shutdown.load(Ordering::SeqCst) {
683            tracing::debug!(agent_id = self.id, "Agent already shut down");
684            return Ok(());
685        }
686
687        tracing::info!(agent_id = self.id, "Shutting down agent");
688        let result = self.runtime.shutdown_agent(self.id).await;
689
690        // Always mark as shut down so Drop doesn't warn, even on failure.
691        self.is_shutdown.store(true, Ordering::SeqCst);
692
693        // Clean up bridge state AFTER the runtime's shutdown completes.
694        // In the live runtime, `__aexit__` fires hooks (e.g. on_session_end)
695        // that look up bridge state — so this must happen after, not before.
696        match crate::runtime::bridge_state().write() {
697            Ok(mut map) => {
698                map.remove(&self.id);
699            }
700            Err(e) => {
701                tracing::error!(
702                    agent_id = self.id,
703                    error = %e,
704                    "BRIDGE_STATE RwLock poisoned during shutdown cleanup — \
705                     bridge state entry may leak"
706                );
707            }
708        }
709
710        match result {
711            Ok(()) => {
712                tracing::info!(agent_id = self.id, "Agent shut down successfully");
713            }
714            Err(ref e) => {
715                tracing::error!(agent_id = self.id, error = ?e, "Agent shutdown failed");
716            }
717        }
718
719        result
720    }
721
722    /// Spawn a subagent from the given config, sharing this agent's runtime.
723    ///
724    /// If a `ToolRegistry` is provided and `config.tools` is empty, the
725    /// registry's definitions are automatically applied.
726    ///
727    /// # Errors
728    ///
729    /// Returns a [`Error`] if agent creation fails.
730    pub async fn spawn_subagent(
731        &self,
732        mut config: AgentConfig,
733        registry: impl Into<Option<crate::tools::ToolRegistry>>,
734    ) -> Result<Self, Error> {
735        let opt_registry = registry.into();
736        if let Some(disp) = &opt_registry
737            && config.tools.is_empty()
738        {
739            config.tools = disp.definitions();
740        }
741        let arc_registry = opt_registry.map(Arc::new);
742        Self::new(
743            Arc::clone(&self.runtime),
744            config,
745            arc_registry,
746            None,
747            self.policy_handler.clone(),
748        )
749        .await
750    }
751}
752
753impl<R: Runtime> Drop for AgentHandle<R> {
754    fn drop(&mut self) {
755        if self.is_started.load(Ordering::SeqCst) && !self.is_shutdown.load(Ordering::SeqCst) {
756            tracing::debug!(
757                agent_id = self.id,
758                "AgentHandle dropped without explicit shutdown() — \
759                 sending best-effort shutdown signal"
760            );
761            // try_shutdown_agent fires a command that eventually calls
762            // handle_shutdown_agent, which cleans up bridge state AFTER
763            // __aexit__ completes (so on_session_end hooks can still
764            // find the hook runner). Do NOT clean up bridge state here.
765            self.runtime.try_shutdown_agent(self.id);
766        } else if self.is_shutdown.load(Ordering::SeqCst) {
767            // shutdown() was already called — handle_shutdown_agent
768            // already cleaned up bridge state after __aexit__. Nothing
769            // to do.
770        } else {
771            // Agent was never started (e.g. creation failed). Clean up
772            // any partial bridge state that might have been registered.
773            match crate::runtime::bridge_state().write() {
774                Ok(mut map) => {
775                    map.remove(&self.id);
776                }
777                Err(e) => {
778                    tracing::warn!(
779                        agent_id = self.id,
780                        error = %e,
781                        "BRIDGE_STATE RwLock poisoned during Drop — \
782                         bridge state entry for this agent may leak"
783                    );
784                }
785            }
786        }
787    }
788}