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