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| 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}