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