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