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