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/// Default backoff duration when a quota/429 error doesn't include a
20/// `Retry-After` header.
21const DEFAULT_QUOTA_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
22
23/// Duration reported to the caller when all quota retries are exhausted.
24const QUOTA_EXHAUSTED_RETRY_AFTER: std::time::Duration = std::time::Duration::from_mins(2);
25
26#[cfg(test)]
27pub(crate) mod mock;
28
29/// Unique identifier for an agent within the bridge.
30pub type AgentId = u64;
31
32/// Trait abstracting the Python runtime interface.
33///
34/// This allows unit tests to inject a mock runtime without requiring a live
35/// Python interpreter. The real implementation will call through to `PyO3`.
36// NOLINT: async_fn_in_trait is intentional — Runtime is not object-safe by design
37#[expect(
38    async_fn_in_trait,
39    reason = "Runtime is not object-safe by design; callers always know the concrete type"
40)]
41pub trait Runtime: Send + Sync {
42    /// Create an agent from the given config, returning its ID and the list
43    /// of all available tools (custom, MCP, and builtin) with metadata.
44    async fn create_agent(
45        &self,
46        config: AgentConfig,
47    ) -> Result<(AgentId, Vec<crate::tools::AvailableTool>), Error>;
48
49    /// Send a chat message to the agent, returning a streaming response handle.
50    ///
51    /// The `content` parameter accepts any [`Content`] variant: plain text,
52    /// images, documents, audio, video, or a multi-part list.
53    async fn chat(&self, agent_id: AgentId, content: &Content)
54    -> Result<ChatResponseHandle, Error>;
55
56    /// Gracefully shut down the agent.
57    async fn shutdown_agent(&self, agent_id: AgentId) -> Result<(), Error>;
58
59    /// Interrupt any active prompt/chat run.
60    async fn cancel(&self, agent_id: AgentId) -> Result<(), Error>;
61
62    /// Wait for the active run or conversational loop to stabilize.
63    async fn wait_for_idle(&self, agent_id: AgentId) -> Result<(), Error>;
64
65    /// Send a message without waiting for completion.
66    async fn send(&self, agent_id: AgentId, content: &Content) -> Result<(), Error>;
67
68    /// Signal that the agent is idle.
69    async fn signal_idle(&self, agent_id: AgentId) -> Result<(), Error>;
70
71    /// Wait for the agent to wake up. Returns true if woken, false if timed out.
72    async fn wait_for_wakeup(
73        &self,
74        agent_id: AgentId,
75        timeout: std::time::Duration,
76    ) -> Result<bool, Error>;
77
78    /// Wait if we're in a quota backoff period.
79    async fn wait_for_quota(&self);
80
81    /// Record a quota hit with the suggested retry duration.
82    async fn record_quota_hit(&self, retry_after: std::time::Duration);
83
84    /// Access this runtime's per-key quota registry.
85    ///
86    /// Each runtime owns its own [`QuotaRegistry`](crate::quota::QuotaRegistry),
87    /// so different runtimes have fully independent quota tracking.
88    fn quota_registry(&self) -> &crate::quota::QuotaRegistry;
89
90    /// Retrieve the conversation's message history.
91    async fn history(&self, agent_id: AgentId) -> Result<Vec<ConversationMessage>, Error>;
92
93    /// Return the number of completed turns in the conversation.
94    async fn turn_count(&self, agent_id: AgentId) -> Result<u32, Error>;
95
96    /// Return cumulative token usage across all turns.
97    async fn total_usage(&self, agent_id: AgentId) -> Result<UsageMetadata, Error>;
98
99    /// Return token usage from the most recent turn only.
100    async fn last_turn_usage(&self, agent_id: AgentId) -> Result<UsageMetadata, Error>;
101
102    /// Clear the conversation history and reset state.
103    async fn clear_history(&self, agent_id: AgentId) -> Result<(), Error>;
104
105    /// Return the text of the last model response, if any.
106    ///
107    /// Default implementation returns `Ok(None)`.
108    async fn last_response(&self, _agent_id: AgentId) -> Result<Option<String>, Error> {
109        Ok(None)
110    }
111
112    /// Return the step indices at which compaction occurred.
113    ///
114    /// Default implementation returns an empty list.
115    async fn compaction_indices(&self, _agent_id: AgentId) -> Result<Vec<u32>, Error> {
116        Ok(Vec::new())
117    }
118
119    /// Delete the conversation and all associated state.
120    ///
121    /// Default implementation is a no-op that returns `Ok(())`.
122    async fn delete(&self, _agent_id: AgentId) -> Result<(), Error> {
123        Ok(())
124    }
125
126    /// Disconnect from the agent without deleting state.
127    ///
128    /// Default implementation is a no-op that returns `Ok(())`.
129    async fn disconnect(&self, _agent_id: AgentId) -> Result<(), Error> {
130        Ok(())
131    }
132
133    /// Check whether the agent is currently idle (not running a turn).
134    ///
135    /// Default implementation returns `Ok(true)`.
136    async fn is_idle(&self, _agent_id: AgentId) -> Result<bool, Error> {
137        Ok(true)
138    }
139
140    /// Best-effort synchronous shutdown signal, called from [`Drop`].
141    ///
142    /// Unlike [`shutdown_agent`](Self::shutdown_agent), this is sync and
143    /// fire-and-forget — it cannot return errors. The default is a no-op;
144    /// implementations backed by a command channel should `try_send` a
145    /// shutdown command here.
146    fn try_shutdown_agent(&self, _agent_id: AgentId) {}
147}
148
149/// Handle to a running agent.
150///
151/// Wraps the agent's lifecycle: creation, chat, and shutdown.
152///
153/// Call [`shutdown()`](Self::shutdown) for a clean, error-reported shutdown.
154/// If the handle is dropped without calling `shutdown()`, a best-effort
155/// background shutdown is spawned via [`tokio::spawn`] — the Python agent
156/// will be cleaned up, but errors are only logged, not returned.
157///
158/// Most methods take `&self` — interior mutability is used where needed
159/// so multiple concurrent operations can share a single handle.
160///
161/// # Mutex choice
162///
163/// This type uses [`std::sync::Mutex`] rather than [`tokio::sync::Mutex`]
164/// because every lock acquisition is a brief, synchronous operation (pointer
165/// swap or clone) that **never** spans an `.await` point. For these
166/// microsecond critical sections, `std::sync::Mutex` is both simpler and
167/// lower-overhead than the async alternative.
168pub struct AgentHandle<R: Runtime + 'static> {
169    id: AgentId,
170    runtime: Arc<R>,
171    config: AgentConfig,
172    /// Per-API-key quota state. Agents sharing the same effective API key
173    /// share backoff tracking; agents with different keys are independent.
174    quota_state: Arc<crate::quota::QuotaState>,
175    /// Kept alive for the agent's lifetime so the global `BRIDGE_STATE`
176    /// entry isn't the only strong reference.
177    _registry: Option<Arc<crate::tools::ToolRegistry>>,
178    /// Kept alive to preserve a strong reference to the policy confirmation handler.
179    policy_handler: Option<Arc<dyn crate::policies::AskUserHandler>>,
180    conversation_id: Arc<Mutex<Option<String>>>,
181    is_started: AtomicBool,
182    is_shutdown: AtomicBool,
183    /// All tools available to this agent — custom Rust tools, MCP tools, and
184    /// SDK builtins — with metadata about source, description, and schema.
185    available_tools: Vec<crate::tools::AvailableTool>,
186    /// Shared state from the last completed chat response, used to surface
187    /// `get_last_structured_output()` without round-tripping to Python.
188    ///
189    /// Wrapped in a `Mutex` so `chat()` can take `&self` instead of `&mut self`,
190    /// enabling concurrent usage patterns. The lock is brief (pointer swap only).
191    last_shared_state: Mutex<Option<Arc<Mutex<ChatResponseSharedState>>>>,
192}
193
194impl<R: Runtime> AgentHandle<R> {
195    /// Create a new agent from the given runtime and configuration.
196    ///
197    /// This sends a `CreateAgent` command to the Python runtime, waits for
198    /// quota availability, and returns the handle.
199    ///
200    /// # Errors
201    ///
202    /// Returns a [`Error`] if agent creation fails (e.g. invalid config,
203    /// Python error, or quota exceeded).
204    pub async fn new(
205        runtime: Arc<R>,
206        config: AgentConfig,
207        registry: Option<Arc<crate::tools::ToolRegistry>>,
208        hook_runner: Option<Arc<crate::hooks::Hooks>>,
209        policy_handler: Option<Arc<dyn crate::policies::AskUserHandler>>,
210    ) -> Result<Self, Error> {
211        // NOLINT: empty string default is intentional — agents without an API key share one quota bucket
212        let quota_key = config.effective_api_key().unwrap_or_default();
213        let quota_state = runtime.quota_registry().state_for_key(&quota_key);
214
215        let _guard = crate::runtime::CREATE_AGENT_HOOK_GUARD.lock().await;
216
217        let effective_hook_runner =
218            hook_runner.unwrap_or_else(|| Arc::new(crate::hooks::Hooks::new()));
219
220        match crate::runtime::INITIALIZING_HOOK_RUNNER.lock() {
221            Ok(mut opt) => {
222                *opt = Some(Arc::clone(&effective_hook_runner));
223            }
224            Err(e) => {
225                return Err(Error::BackendError {
226                    message: format!(
227                        "INITIALIZING_HOOK_RUNNER mutex poisoned — hooks cannot be installed: {e}"
228                    ),
229                });
230            }
231        }
232
233        let (agent_id, available_tools) = runtime.create_agent(config.clone()).await?;
234        tracing::info!(agent_id, "Agent created successfully");
235
236        let conversation_id = match Self::setup_bridge_state(
237            &runtime,
238            agent_id,
239            &config,
240            registry.as_ref(),
241            effective_hook_runner,
242            policy_handler.as_ref(),
243        )
244        .await
245        {
246            Ok(conv_id) => conv_id,
247            Err(e) => return Err(e),
248        };
249
250        match crate::runtime::INITIALIZING_HOOK_RUNNER.lock() {
251            Ok(mut opt) => {
252                *opt = None;
253            }
254            Err(e) => {
255                tracing::error!(
256                    agent_id,
257                    error = %e,
258                    "INITIALIZING_HOOK_RUNNER mutex poisoned during cleanup — \
259                     stale hook runner may persist"
260                );
261            }
262        }
263
264        Ok(Self {
265            id: agent_id,
266            runtime,
267            config,
268            quota_state,
269            _registry: registry,
270            policy_handler,
271            conversation_id,
272            is_started: AtomicBool::new(true),
273            is_shutdown: AtomicBool::new(false),
274            available_tools,
275            last_shared_state: Mutex::new(None),
276        })
277    }
278
279    async fn setup_bridge_state(
280        runtime: &Arc<R>,
281        id: AgentId,
282        config: &AgentConfig,
283        registry: Option<&Arc<crate::tools::ToolRegistry>>,
284        effective_hook_runner: Arc<crate::hooks::Hooks>,
285        policy_handler: Option<&Arc<dyn crate::policies::AskUserHandler>>,
286    ) -> Result<Arc<Mutex<Option<String>>>, Error> {
287        let policies_set = crate::policies::PolicySet::validated_from(config.policies.clone())?;
288        let conversation_id = Arc::new(Mutex::new(config.conversation_id.clone()));
289        let bridge_entry = crate::runtime::AgentBridgeState {
290            registry: registry.map(Arc::clone),
291            hook_runner: Some(effective_hook_runner),
292            policies: policies_set,
293            policy_handler: policy_handler.map(Arc::clone),
294            tool_state: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
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    /// Automatically backs off on quota limits (HTTP 429).
333    ///
334    /// # Errors
335    ///
336    /// Returns a [`Error`] on chat failure (Python error, timeout, etc.).
337    pub async fn chat(&self, content: impl Into<Content>) -> Result<ChatResponseHandle, Error> {
338        if !self.is_started() {
339            return Err(Error::AgentNotStarted);
340        }
341
342        let content = content.into();
343        // NOLINT: zero retries is the correct default — no automatic retry unless explicitly configured
344        let max_retries = self.config.max_quota_retries.unwrap_or(0);
345
346        let handle = 'retry: {
347            for attempt in 0..=max_retries {
348                if attempt > 0 {
349                    self.quota_state.wait_for_quota().await;
350                }
351                match self.runtime.chat(self.id, &content).await {
352                    Ok(h) => break 'retry h,
353                    Err(Error::QuotaExceeded { retry_after }) => {
354                        self.handle_quota_error("chat", attempt, max_retries, retry_after)?;
355                    }
356                    Err(ref e) if e.is_quota_error() => {
357                        self.handle_quota_error(
358                            "chat",
359                            attempt,
360                            max_retries,
361                            DEFAULT_QUOTA_BACKOFF,
362                        )?;
363                    }
364                    Err(e) => return Err(e),
365                }
366            }
367            return Err(Error::QuotaExceeded {
368                retry_after: QUOTA_EXHAUSTED_RETRY_AFTER,
369            });
370        };
371
372        match self.last_shared_state.lock() {
373            Ok(mut guard) => {
374                *guard = Some(Arc::clone(&handle.shared_state));
375            }
376            Err(e) => {
377                tracing::error!(
378                    agent_id = self.id,
379                    error = %e,
380                    "last_shared_state mutex poisoned — streaming metadata may be stale"
381                );
382            }
383        }
384        Ok(handle)
385    }
386
387    /// Send a message and return the final text response.
388    ///
389    /// This is a convenience wrapper around [`chat`](Self::chat) that drains
390    /// the streaming response into a single `String`. If tools were associated
391    /// with the agent at creation time, the Python runtime handles tool
392    /// execution automatically.
393    ///
394    /// # Errors
395    ///
396    /// Returns [`Error`] if the chat turn fails or stream errors occur.
397    pub async fn chat_text(&self, message: impl Into<Content>) -> Result<String, Error> {
398        let response = self.chat(message.into()).await?;
399        let text = response.text().await.map_err(|e| Error::BackendError {
400            message: format!("Failed to read response text: {}", Error::from(e)),
401        })?;
402        Ok(text.into_string())
403    }
404
405    /// Return the current conversation ID, if one has been set.
406    ///
407    /// Returns a cloned `String` because the underlying value is behind a
408    /// [`Mutex`] (interior mutability for `&self` access).
409    #[must_use]
410    pub fn conversation_id(&self) -> Option<String> {
411        self.conversation_id
412            .lock()
413            .inspect_err(|e| {
414                tracing::error!(
415                    agent_id = self.id,
416                    error = %e,
417                    "conversation_id mutex poisoned"
418                );
419            })
420            // NOLINT: error already logged via inspect_err above; .ok() converts to Option for the return type
421            .ok()
422            .and_then(|guard| guard.clone())
423    }
424
425    /// Set the conversation ID (called when the SDK assigns one).
426    ///
427    /// Takes `&self` rather than `&mut self` so the handle can be shared
428    /// across concurrent tasks.
429    pub fn set_conversation_id(&self, id: String) {
430        match self.conversation_id.lock() {
431            Ok(mut guard) => {
432                *guard = Some(id);
433            }
434            Err(e) => {
435                tracing::error!(
436                    agent_id = self.id,
437                    error = %e,
438                    "conversation_id mutex poisoned — ID will not be updated"
439                );
440            }
441        }
442    }
443
444    /// Check whether the agent has been started and is not yet shut down.
445    #[must_use]
446    pub fn is_started(&self) -> bool {
447        self.is_started.load(Ordering::SeqCst) && !self.is_shutdown.load(Ordering::SeqCst)
448    }
449
450    /// Return the agent's unique identifier.
451    #[must_use]
452    pub const fn id(&self) -> AgentId {
453        self.id
454    }
455
456    /// Return a reference to the agent's configuration.
457    #[must_use]
458    pub const fn config(&self) -> &AgentConfig {
459        &self.config
460    }
461
462    /// Return all tools available to this agent, with metadata.
463    ///
464    /// Each [`AvailableTool`](crate::tools::AvailableTool) includes the tool's
465    /// name, description, JSON parameter schema, and source tag
466    /// ([`Builtin`](crate::tools::ToolSource::Builtin),
467    /// [`Custom`](crate::tools::ToolSource::Custom), or
468    /// [`Mcp`](crate::tools::ToolSource::Mcp)).
469    ///
470    /// The list is assembled at agent creation time and is immutable for
471    /// the agent's lifetime.
472    #[must_use]
473    pub fn available_tools(&self) -> &[crate::tools::AvailableTool] {
474        &self.available_tools
475    }
476
477    /// Convenience accessor: returns just the tool names.
478    #[must_use]
479    pub fn available_tool_names(&self) -> Vec<&str> {
480        self.available_tools
481            .iter()
482            .map(|t| t.name.as_str())
483            .collect()
484    }
485
486    /// Interrupt the active chat prompt execution.
487    ///
488    /// # Errors
489    ///
490    /// Returns a [`Error`] if the cancellation call fails.
491    pub async fn cancel(&self) -> Result<(), Error> {
492        self.runtime.cancel(self.id).await
493    }
494
495    /// Wait for the conversation or active run to stabilize and become idle.
496    ///
497    /// # Errors
498    ///
499    /// Returns a [`Error`] if the wait call fails.
500    pub async fn wait_for_idle(&self) -> Result<(), Error> {
501        self.runtime.wait_for_idle(self.id).await
502    }
503
504    /// Retrieve the conversation's message history.
505    ///
506    /// # Errors
507    ///
508    /// Returns [`Error`] if the query fails.
509    pub async fn history(&self) -> Result<Vec<ConversationMessage>, Error> {
510        self.runtime.history(self.id).await
511    }
512
513    /// Return the number of completed turns in the conversation.
514    ///
515    /// # Errors
516    ///
517    /// Returns [`Error`] if the query fails.
518    pub async fn turn_count(&self) -> Result<u32, Error> {
519        self.runtime.turn_count(self.id).await
520    }
521
522    /// Return cumulative token usage across all turns.
523    ///
524    /// # Errors
525    ///
526    /// Returns [`Error`] if the query fails.
527    pub async fn total_usage(&self) -> Result<UsageMetadata, Error> {
528        self.runtime.total_usage(self.id).await
529    }
530
531    /// Return token usage from the most recent turn only.
532    ///
533    /// # Errors
534    ///
535    /// Returns [`Error`] if the query fails.
536    pub async fn last_turn_usage(&self) -> Result<UsageMetadata, Error> {
537        self.runtime.last_turn_usage(self.id).await
538    }
539
540    /// Clear the conversation history and reset state.
541    ///
542    /// # Errors
543    ///
544    /// Returns [`Error`] if the operation fails.
545    pub async fn clear_history(&self) -> Result<(), Error> {
546        self.runtime.clear_history(self.id).await
547    }
548
549    /// Return the text of the last model response, if any.
550    ///
551    /// # Errors
552    ///
553    /// Returns [`Error`] if the query fails.
554    pub async fn last_response(&self) -> Result<Option<String>, Error> {
555        self.runtime.last_response(self.id).await
556    }
557
558    /// Return the step indices at which conversation compaction occurred.
559    ///
560    /// # Errors
561    ///
562    /// Returns [`Error`] if the query fails.
563    pub async fn compaction_indices(&self) -> Result<Vec<u32>, Error> {
564        self.runtime.compaction_indices(self.id).await
565    }
566
567    /// Delete the conversation and all associated state.
568    ///
569    /// After calling this method, the agent handle is no longer usable
570    /// for chat operations. This also marks the agent as shut down.
571    ///
572    /// # Errors
573    ///
574    /// Returns [`Error`] if the delete operation fails.
575    pub async fn delete(&self) -> Result<(), Error> {
576        let result = self.runtime.delete(self.id).await;
577        self.is_shutdown.store(true, Ordering::SeqCst);
578        result
579    }
580
581    /// Disconnect from the agent without deleting its state.
582    ///
583    /// The agent's conversation state is preserved but this handle
584    /// can no longer send messages. Marks the agent as shut down.
585    ///
586    /// # Errors
587    ///
588    /// Returns [`Error`] if the disconnect operation fails.
589    pub async fn disconnect(&self) -> Result<(), Error> {
590        let result = self.runtime.disconnect(self.id).await;
591        self.is_shutdown.store(true, Ordering::SeqCst);
592        result
593    }
594
595    /// Check whether the agent is currently idle (not running a turn).
596    ///
597    /// # Errors
598    ///
599    /// Returns [`Error`] if the query fails.
600    pub async fn is_idle(&self) -> Result<bool, Error> {
601        self.runtime.is_idle(self.id).await
602    }
603
604    /// Return the structured output from the last chat response, if any.
605    ///
606    /// Only populated after a [`chat()`](Self::chat) round-trip when the
607    /// agent was configured with a `response_schema` and the model returned
608    /// a valid JSON payload.
609    #[must_use]
610    pub fn get_last_structured_output(&self) -> Option<serde_json::Value> {
611        let guard = self
612            .last_shared_state
613            .lock()
614            .inspect_err(|e| {
615                tracing::error!(
616                    agent_id = self.id,
617                    error = %e,
618                    "last_shared_state mutex poisoned in get_last_structured_output"
619                );
620            })
621            // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
622            .ok()?;
623        let state = guard
624            .as_ref()?
625            .lock()
626            .inspect_err(|e| {
627                tracing::error!(
628                    agent_id = self.id,
629                    error = %e,
630                    "ChatResponseSharedState mutex poisoned in get_last_structured_output"
631                );
632            })
633            // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
634            .ok()?;
635        state.structured_output.clone()
636    }
637
638    /// Return the structured output from the last chat response deserialized into `T`.
639    ///
640    /// Returns `None` if there was no structured output on the last response.
641    /// Returns `Some(Err(...))` if the structured output could not be deserialized as `T`.
642    pub fn get_last_structured_output_as<T: serde::de::DeserializeOwned>(
643        &self,
644    ) -> Option<Result<T, serde_json::Error>> {
645        self.get_last_structured_output()
646            .map(serde_json::from_value)
647    }
648
649    /// Return the usage metadata from the last chat response, if any.
650    #[must_use]
651    pub fn get_last_usage(&self) -> Option<UsageMetadata> {
652        let guard = self
653            .last_shared_state
654            .lock()
655            .inspect_err(|e| {
656                tracing::error!(
657                    agent_id = self.id,
658                    error = %e,
659                    "last_shared_state mutex poisoned in get_last_usage"
660                );
661            })
662            // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
663            .ok()?;
664        let state = guard
665            .as_ref()?
666            .lock()
667            .inspect_err(|e| {
668                tracing::error!(
669                    agent_id = self.id,
670                    error = %e,
671                    "ChatResponseSharedState mutex poisoned in get_last_usage"
672                );
673            })
674            // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
675            .ok()?;
676        state.usage.clone()
677    }
678
679    /// Send a message without waiting for a response.
680    ///
681    /// Fire-and-forget: the message is delivered to the agent but no
682    /// streaming response is produced.
683    ///
684    /// # Errors
685    ///
686    /// Returns a [`Error`] if sending fails.
687    pub async fn send(&self, content: impl Into<Content>) -> Result<(), Error> {
688        if !self.is_started() {
689            return Err(Error::AgentNotStarted);
690        }
691
692        let content = content.into();
693
694        // NOLINT: zero retries is the correct default — no automatic retry unless explicitly configured
695        let max_retries = self.config.max_quota_retries.unwrap_or(0);
696
697        for attempt in 0..=max_retries {
698            if attempt > 0 {
699                self.quota_state.wait_for_quota().await;
700            }
701            match self.runtime.send(self.id, &content).await {
702                Ok(()) => return Ok(()),
703                Err(Error::QuotaExceeded { retry_after }) => {
704                    self.handle_quota_error("send", attempt, max_retries, retry_after)?;
705                }
706                Err(ref e) if e.is_quota_error() => {
707                    self.handle_quota_error("send", attempt, max_retries, DEFAULT_QUOTA_BACKOFF)?;
708                }
709                Err(e) => return Err(e),
710            }
711        }
712        Err(Error::QuotaExceeded {
713            retry_after: QUOTA_EXHAUSTED_RETRY_AFTER,
714        })
715    }
716
717    /// Signal that this agent is idle and ready to receive input.
718    ///
719    /// # Errors
720    ///
721    /// Returns a [`Error`] if the signal call fails.
722    pub async fn signal_idle(&self) -> Result<(), Error> {
723        self.runtime.signal_idle(self.id).await
724    }
725
726    /// Wait for the agent to wake up, returning `true` if woken or
727    /// `false` if the `timeout` elapsed.
728    ///
729    /// # Errors
730    ///
731    /// Returns a [`Error`] if the wait call fails.
732    pub async fn wait_for_wakeup(&self, timeout: std::time::Duration) -> Result<bool, Error> {
733        self.runtime.wait_for_wakeup(self.id, timeout).await
734    }
735
736    /// Handle a quota/429 error from a retryable operation.
737    fn handle_quota_error(
738        &self,
739        operation: &str,
740        attempt: u32,
741        max_retries: u32,
742        retry_after: std::time::Duration,
743    ) -> Result<(), Error> {
744        if attempt >= max_retries {
745            return Err(Error::QuotaExceeded { retry_after });
746        }
747        tracing::warn!(
748            agent_id = self.id,
749            attempt = attempt + 1,
750            max = max_retries,
751            retry_after_ms = u64::try_from(retry_after.as_millis()).unwrap_or_else(|e| {
752                tracing::warn!("Int conversion failed: {e}");
753                u64::MAX
754            }),
755            "Quota exceeded on {operation} — recording hit and retrying"
756        );
757        self.quota_state.record_quota_hit(retry_after);
758        Ok(())
759    }
760
761    /// Gracefully shut down the agent.
762    ///
763    /// This sends a `ShutdownAgent` command to the Python runtime, which
764    /// calls `__aexit__()` on the SDK agent. The handle remains usable
765    /// for read-only queries (e.g. [`is_started()`](Self::is_started))
766    /// after shutdown.
767    ///
768    /// # Errors
769    ///
770    /// Returns a [`Error`] if shutdown fails. The `is_shutdown`
771    /// flag is always set so the `Drop` impl will not emit a warning.
772    pub async fn shutdown(&self) -> Result<(), Error> {
773        if self.is_shutdown.load(Ordering::SeqCst) {
774            tracing::debug!(agent_id = self.id, "Agent already shut down");
775            return Ok(());
776        }
777
778        tracing::info!(agent_id = self.id, "Shutting down agent");
779        let result = self.runtime.shutdown_agent(self.id).await;
780
781        // Always mark as shut down so Drop doesn't warn, even on failure.
782        self.is_shutdown.store(true, Ordering::SeqCst);
783
784        // Clean up bridge state AFTER the runtime's shutdown completes.
785        // In the live runtime, `__aexit__` fires hooks (e.g. on_session_end)
786        // that look up bridge state — so this must happen after, not before.
787        match crate::runtime::bridge_state().write() {
788            Ok(mut map) => {
789                map.remove(&self.id);
790            }
791            Err(e) => {
792                tracing::error!(
793                    agent_id = self.id,
794                    error = %e,
795                    "BRIDGE_STATE RwLock poisoned during shutdown cleanup — \
796                     bridge state entry may leak"
797                );
798            }
799        }
800
801        match result {
802            Ok(()) => {
803                tracing::info!(agent_id = self.id, "Agent shut down successfully");
804            }
805            Err(ref e) => {
806                tracing::error!(agent_id = self.id, error = ?e, "Agent shutdown failed");
807            }
808        }
809
810        result
811    }
812
813    /// Spawn a subagent from the given config, sharing this agent's runtime.
814    ///
815    /// If a `ToolRegistry` is provided and `config.tools` is empty, the
816    /// registry's definitions are automatically applied.
817    ///
818    /// # Errors
819    ///
820    /// Returns a [`Error`] if agent creation fails.
821    pub async fn spawn_subagent(
822        &self,
823        mut config: AgentConfig,
824        registry: impl Into<Option<crate::tools::ToolRegistry>>,
825    ) -> Result<Self, Error> {
826        let opt_registry = registry.into();
827        if let Some(disp) = &opt_registry
828            && config.tools.is_empty()
829        {
830            config.tools = disp.definitions();
831        }
832        let arc_registry = opt_registry.map(Arc::new);
833        Self::new(
834            Arc::clone(&self.runtime),
835            config,
836            arc_registry,
837            None,
838            self.policy_handler.clone(),
839        )
840        .await
841    }
842}
843
844impl<R: Runtime> Drop for AgentHandle<R> {
845    fn drop(&mut self) {
846        if self.is_started.load(Ordering::SeqCst) && !self.is_shutdown.load(Ordering::SeqCst) {
847            tracing::debug!(
848                agent_id = self.id,
849                "AgentHandle dropped without explicit shutdown() — \
850                 sending best-effort shutdown signal"
851            );
852            // try_shutdown_agent fires a command that eventually calls
853            // handle_shutdown_agent, which cleans up bridge state AFTER
854            // __aexit__ completes (so on_session_end hooks can still
855            // find the hook runner). Do NOT clean up bridge state here.
856            self.runtime.try_shutdown_agent(self.id);
857        } else if self.is_shutdown.load(Ordering::SeqCst) {
858            // shutdown() was already called — handle_shutdown_agent
859            // already cleaned up bridge state after __aexit__. Nothing
860            // to do.
861        } else {
862            // Agent was never started (e.g. creation failed). Clean up
863            // any partial bridge state that might have been registered.
864            match crate::runtime::bridge_state().write() {
865                Ok(mut map) => {
866                    map.remove(&self.id);
867                }
868                Err(e) => {
869                    tracing::warn!(
870                        agent_id = self.id,
871                        error = %e,
872                        "BRIDGE_STATE RwLock poisoned during Drop — \
873                         bridge state entry for this agent may leak"
874                    );
875                }
876            }
877        }
878    }
879}