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