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("a_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}