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