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