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    ///
385    /// A stream error encountered while draining is mapped to a
386    /// [`Error::BackendError`], preserving [`Error::is_quota_error`] semantics so
387    /// the caller's retry loop can react to quota errors that only surface here.
388    async fn chat_text_once(&self, content: &Content) -> Result<String, Error> {
389        let response = self.chat_once(content).await?;
390        match response.text().await {
391            Ok(text) => Ok(text.into_string()),
392            Err(stream_err) => Err(Error::BackendError {
393                message: format!(
394                    "Failed to read response text: stream error: {}",
395                    stream_err.message
396                ),
397            }),
398        }
399    }
400
401    /// Return the current conversation ID, if one has been set.
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        self.conversation_id
408            .lock()
409            .inspect_err(|e| {
410                tracing::error!(
411                    agent_id = self.id,
412                    error = %e,
413                    "conversation_id mutex poisoned"
414                );
415            })
416            // NOLINT: error already logged via inspect_err above; .ok() converts to Option for the return type
417            .ok()
418            .and_then(|guard| guard.clone())
419    }
420
421    /// Set the conversation ID (called when the SDK assigns one).
422    ///
423    /// Takes `&self` rather than `&mut self` so the handle can be shared
424    /// across concurrent tasks.
425    pub fn set_conversation_id(&self, id: String) {
426        match self.conversation_id.lock() {
427            Ok(mut guard) => {
428                *guard = Some(id);
429            }
430            Err(e) => {
431                tracing::error!(
432                    agent_id = self.id,
433                    error = %e,
434                    "conversation_id mutex poisoned — ID will not be updated"
435                );
436            }
437        }
438    }
439
440    /// Check whether the agent has been started and is not yet shut down.
441    #[must_use]
442    pub fn is_started(&self) -> bool {
443        self.is_started.load(Ordering::SeqCst) && !self.is_shutdown.load(Ordering::SeqCst)
444    }
445
446    /// Return the agent's unique identifier.
447    #[must_use]
448    pub const fn id(&self) -> AgentId {
449        self.id
450    }
451
452    /// Return a reference to the agent's configuration.
453    #[must_use]
454    pub const fn config(&self) -> &AgentConfig {
455        &self.config
456    }
457
458    /// Return all tools available to this agent, with metadata.
459    ///
460    /// Each [`AvailableTool`](crate::tools::AvailableTool) includes the tool's
461    /// name, description, JSON parameter schema, and source tag
462    /// ([`Builtin`](crate::tools::ToolSource::Builtin),
463    /// [`Custom`](crate::tools::ToolSource::Custom), or
464    /// [`Mcp`](crate::tools::ToolSource::Mcp)).
465    ///
466    /// The list is assembled at agent creation time and is immutable for
467    /// the agent's lifetime.
468    #[must_use]
469    pub fn available_tools(&self) -> &[crate::tools::AvailableTool] {
470        &self.available_tools
471    }
472
473    /// Convenience accessor: returns just the tool names.
474    #[must_use]
475    pub fn available_tool_names(&self) -> Vec<&str> {
476        self.available_tools
477            .iter()
478            .map(|t| t.name.as_str())
479            .collect()
480    }
481
482    /// Interrupt the active chat prompt execution.
483    ///
484    /// # Errors
485    ///
486    /// Returns a [`Error`] if the cancellation call fails.
487    pub async fn cancel(&self) -> Result<(), Error> {
488        self.runtime.cancel(self.id).await
489    }
490
491    /// Wait for the conversation or active run to stabilize and become idle.
492    ///
493    /// # Errors
494    ///
495    /// Returns a [`Error`] if the wait call fails.
496    pub async fn wait_for_idle(&self) -> Result<(), Error> {
497        self.runtime.wait_for_idle(self.id).await
498    }
499
500    /// Retrieve the conversation's message history.
501    ///
502    /// # Errors
503    ///
504    /// Returns [`Error`] if the query fails.
505    pub async fn history(&self) -> Result<Vec<ConversationMessage>, Error> {
506        self.runtime.history(self.id).await
507    }
508
509    /// Return the number of completed turns in the conversation.
510    ///
511    /// # Errors
512    ///
513    /// Returns [`Error`] if the query fails.
514    pub async fn turn_count(&self) -> Result<u32, Error> {
515        self.runtime.turn_count(self.id).await
516    }
517
518    /// Return cumulative token usage across all turns.
519    ///
520    /// # Errors
521    ///
522    /// Returns [`Error`] if the query fails.
523    pub async fn total_usage(&self) -> Result<UsageMetadata, Error> {
524        self.runtime.total_usage(self.id).await
525    }
526
527    /// Return token usage from the most recent turn only.
528    ///
529    /// # Errors
530    ///
531    /// Returns [`Error`] if the query fails.
532    pub async fn last_turn_usage(&self) -> Result<UsageMetadata, Error> {
533        self.runtime.last_turn_usage(self.id).await
534    }
535
536    /// Clear the conversation history and reset state.
537    ///
538    /// # Errors
539    ///
540    /// Returns [`Error`] if the operation fails.
541    pub async fn clear_history(&self) -> Result<(), Error> {
542        self.runtime.clear_history(self.id).await
543    }
544
545    /// Return the text of the last model response, if any.
546    ///
547    /// # Errors
548    ///
549    /// Returns [`Error`] if the query fails.
550    pub async fn last_response(&self) -> Result<Option<String>, Error> {
551        self.runtime.last_response(self.id).await
552    }
553
554    /// Return the step indices at which conversation compaction occurred.
555    ///
556    /// # Errors
557    ///
558    /// Returns [`Error`] if the query fails.
559    pub async fn compaction_indices(&self) -> Result<Vec<u32>, Error> {
560        self.runtime.compaction_indices(self.id).await
561    }
562
563    /// Delete the conversation and all associated state.
564    ///
565    /// After calling this method, the agent handle is no longer usable
566    /// for chat operations. This also marks the agent as shut down.
567    ///
568    /// # Errors
569    ///
570    /// Returns [`Error`] if the delete operation fails.
571    pub async fn delete(&self) -> Result<(), Error> {
572        let result = self.runtime.delete(self.id).await;
573        self.is_shutdown.store(true, Ordering::SeqCst);
574        result
575    }
576
577    /// Disconnect from the agent without deleting its state.
578    ///
579    /// The agent's conversation state is preserved but this handle
580    /// can no longer send messages. Marks the agent as shut down.
581    ///
582    /// # Errors
583    ///
584    /// Returns [`Error`] if the disconnect operation fails.
585    pub async fn disconnect(&self) -> Result<(), Error> {
586        let result = self.runtime.disconnect(self.id).await;
587        self.is_shutdown.store(true, Ordering::SeqCst);
588        result
589    }
590
591    /// Check whether the agent is currently idle (not running a turn).
592    ///
593    /// # Errors
594    ///
595    /// Returns [`Error`] if the query fails.
596    pub async fn is_idle(&self) -> Result<bool, Error> {
597        self.runtime.is_idle(self.id).await
598    }
599
600    /// Return the structured output from the last chat response, if any.
601    ///
602    /// Only populated after a [`chat()`](Self::chat) round-trip when the
603    /// agent was configured with a `response_schema` and the model returned
604    /// a valid JSON payload.
605    #[must_use]
606    pub fn get_last_structured_output(&self) -> Option<serde_json::Value> {
607        let guard = self
608            .last_shared_state
609            .lock()
610            .inspect_err(|e| {
611                tracing::error!(
612                    agent_id = self.id,
613                    error = %e,
614                    "last_shared_state mutex poisoned in get_last_structured_output"
615                );
616            })
617            // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
618            .ok()?;
619        let state = guard
620            .as_ref()?
621            .lock()
622            .inspect_err(|e| {
623                tracing::error!(
624                    agent_id = self.id,
625                    error = %e,
626                    "ChatResponseSharedState mutex poisoned in get_last_structured_output"
627                );
628            })
629            // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
630            .ok()?;
631        state.structured_output.clone()
632    }
633
634    /// Return the structured output from the last chat response deserialized into `T`.
635    ///
636    /// Returns `None` if there was no structured output on the last response.
637    /// Returns `Some(Err(...))` if the structured output could not be deserialized as `T`.
638    pub fn get_last_structured_output_as<T: serde::de::DeserializeOwned>(
639        &self,
640    ) -> Option<Result<T, serde_json::Error>> {
641        self.get_last_structured_output()
642            .map(serde_json::from_value)
643    }
644
645    /// Return the usage metadata from the last chat response, if any.
646    #[must_use]
647    pub fn get_last_usage(&self) -> Option<UsageMetadata> {
648        let guard = self
649            .last_shared_state
650            .lock()
651            .inspect_err(|e| {
652                tracing::error!(
653                    agent_id = self.id,
654                    error = %e,
655                    "last_shared_state mutex poisoned in get_last_usage"
656                );
657            })
658            // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
659            .ok()?;
660        let state = guard
661            .as_ref()?
662            .lock()
663            .inspect_err(|e| {
664                tracing::error!(
665                    agent_id = self.id,
666                    error = %e,
667                    "ChatResponseSharedState mutex poisoned in get_last_usage"
668                );
669            })
670            // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
671            .ok()?;
672        state.usage.clone()
673    }
674
675    /// Send a message without waiting for a response.
676    ///
677    /// Fire-and-forget: the message is delivered to the agent but no
678    /// streaming response is produced.
679    ///
680    /// This is **single-shot**; retrying is the caller's responsibility.
681    ///
682    /// # Errors
683    ///
684    /// Returns a [`Error`] if sending fails.
685    pub async fn send(&self, content: impl Into<Content>) -> Result<(), Error> {
686        if !self.is_started() {
687            return Err(Error::AgentNotStarted);
688        }
689        self.runtime.send(self.id, &content.into()).await
690    }
691
692    /// Signal that this agent is idle and ready to receive input.
693    ///
694    /// # Errors
695    ///
696    /// Returns a [`Error`] if the signal call fails.
697    pub async fn signal_idle(&self) -> Result<(), Error> {
698        self.runtime.signal_idle(self.id).await
699    }
700
701    /// Wait for the agent to wake up, returning `true` if woken or
702    /// `false` if the `timeout` elapsed.
703    ///
704    /// # Errors
705    ///
706    /// Returns a [`Error`] if the wait call fails.
707    pub async fn wait_for_wakeup(&self, timeout: std::time::Duration) -> Result<bool, Error> {
708        self.runtime.wait_for_wakeup(self.id, timeout).await
709    }
710
711    /// Gracefully shut down the agent.
712    ///
713    /// This sends a `ShutdownAgent` command to the Python runtime, which
714    /// calls `__aexit__()` on the SDK agent. The handle remains usable
715    /// for read-only queries (e.g. [`is_started()`](Self::is_started))
716    /// after shutdown.
717    ///
718    /// # Errors
719    ///
720    /// Returns a [`Error`] if shutdown fails. The `is_shutdown`
721    /// flag is always set so the `Drop` impl will not emit a warning.
722    pub async fn shutdown(&self) -> Result<(), Error> {
723        if self.is_shutdown.load(Ordering::SeqCst) {
724            tracing::debug!(agent_id = self.id, "Agent already shut down");
725            return Ok(());
726        }
727
728        tracing::info!(agent_id = self.id, "Shutting down agent");
729        let result = self.runtime.shutdown_agent(self.id).await;
730
731        // Always mark as shut down so Drop doesn't warn, even on failure.
732        self.is_shutdown.store(true, Ordering::SeqCst);
733
734        // Clean up bridge state AFTER the runtime's shutdown completes.
735        // In the live runtime, `__aexit__` fires hooks (e.g. on_session_end)
736        // that look up bridge state — so this must happen after, not before.
737        match crate::runtime::bridge_state().write() {
738            Ok(mut map) => {
739                map.remove(&self.id);
740            }
741            Err(e) => {
742                tracing::error!(
743                    agent_id = self.id,
744                    error = %e,
745                    "BRIDGE_STATE RwLock poisoned during shutdown cleanup — \
746                     bridge state entry may leak"
747                );
748            }
749        }
750
751        match result {
752            Ok(()) => {
753                tracing::info!(agent_id = self.id, "Agent shut down successfully");
754            }
755            Err(ref e) => {
756                tracing::error!(agent_id = self.id, error = ?e, "Agent shutdown failed");
757            }
758        }
759
760        result
761    }
762
763    /// Spawn a subagent from the given config, sharing this agent's runtime.
764    ///
765    /// If a `ToolRegistry` is provided and `config.tools` is empty, the
766    /// registry's definitions are automatically applied.
767    ///
768    /// # Errors
769    ///
770    /// Returns a [`Error`] if agent creation fails.
771    pub async fn spawn_subagent(
772        &self,
773        mut config: AgentConfig,
774        registry: impl Into<Option<crate::tools::ToolRegistry>>,
775    ) -> Result<Self, Error> {
776        let opt_registry = registry.into();
777        if let Some(disp) = &opt_registry
778            && config.tools.is_empty()
779        {
780            config.tools = disp.definitions();
781        }
782        let arc_registry = opt_registry.map(Arc::new);
783        Self::new(
784            Arc::clone(&self.runtime),
785            config,
786            arc_registry,
787            None,
788            self.policy_handler.clone(),
789        )
790        .await
791    }
792}
793
794impl<R: Runtime> Drop for AgentHandle<R> {
795    fn drop(&mut self) {
796        if self.is_started.load(Ordering::SeqCst) && !self.is_shutdown.load(Ordering::SeqCst) {
797            tracing::debug!(
798                agent_id = self.id,
799                "AgentHandle dropped without explicit shutdown() — \
800                 sending best-effort shutdown signal"
801            );
802            // try_shutdown_agent fires a command that eventually calls
803            // handle_shutdown_agent, which cleans up bridge state AFTER
804            // __aexit__ completes (so on_session_end hooks can still
805            // find the hook runner). Do NOT clean up bridge state here.
806            self.runtime.try_shutdown_agent(self.id);
807        } else if self.is_shutdown.load(Ordering::SeqCst) {
808            // shutdown() was already called — handle_shutdown_agent
809            // already cleaned up bridge state after __aexit__. Nothing
810            // to do.
811        } else {
812            // Agent was never started (e.g. creation failed). Clean up
813            // any partial bridge state that might have been registered.
814            match crate::runtime::bridge_state().write() {
815                Ok(mut map) => {
816                    map.remove(&self.id);
817                }
818                Err(e) => {
819                    tracing::warn!(
820                        agent_id = self.id,
821                        error = %e,
822                        "BRIDGE_STATE RwLock poisoned during Drop — \
823                         bridge state entry for this agent may leak"
824                    );
825                }
826            }
827        }
828    }
829}