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