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