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 ///
385 /// A stream error encountered while draining is mapped to a
386 /// [`Error::BackendError`], preserving [`Error::is_quota_error`] semantics so
387 /// the caller's retry loop can react to quota errors that only surface here.
388 async fn chat_text_once(&self, content: &Content) -> Result<String, Error> {
389 let response = self.chat_once(content).await?;
390 match response.text().await {
391 Ok(text) => Ok(text.into_string()),
392 Err(stream_err) => Err(Error::BackendError {
393 message: format!(
394 "Failed to read response text: stream error: {}",
395 stream_err.message
396 ),
397 }),
398 }
399 }
400
401 /// Return the current conversation ID, if one has been set.
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 self.conversation_id
408 .lock()
409 .inspect_err(|e| {
410 tracing::error!(
411 agent_id = self.id,
412 error = %e,
413 "conversation_id mutex poisoned"
414 );
415 })
416 // NOLINT: error already logged via inspect_err above; .ok() converts to Option for the return type
417 .ok()
418 .and_then(|guard| guard.clone())
419 }
420
421 /// Set the conversation ID (called when the SDK assigns one).
422 ///
423 /// Takes `&self` rather than `&mut self` so the handle can be shared
424 /// across concurrent tasks.
425 pub fn set_conversation_id(&self, id: String) {
426 match self.conversation_id.lock() {
427 Ok(mut guard) => {
428 *guard = Some(id);
429 }
430 Err(e) => {
431 tracing::error!(
432 agent_id = self.id,
433 error = %e,
434 "conversation_id mutex poisoned — ID will not be updated"
435 );
436 }
437 }
438 }
439
440 /// Check whether the agent has been started and is not yet shut down.
441 #[must_use]
442 pub fn is_started(&self) -> bool {
443 self.is_started.load(Ordering::SeqCst) && !self.is_shutdown.load(Ordering::SeqCst)
444 }
445
446 /// Return the agent's unique identifier.
447 #[must_use]
448 pub const fn id(&self) -> AgentId {
449 self.id
450 }
451
452 /// Return a reference to the agent's configuration.
453 #[must_use]
454 pub const fn config(&self) -> &AgentConfig {
455 &self.config
456 }
457
458 /// Return all tools available to this agent, with metadata.
459 ///
460 /// Each [`AvailableTool`](crate::tools::AvailableTool) includes the tool's
461 /// name, description, JSON parameter schema, and source tag
462 /// ([`Builtin`](crate::tools::ToolSource::Builtin),
463 /// [`Custom`](crate::tools::ToolSource::Custom), or
464 /// [`Mcp`](crate::tools::ToolSource::Mcp)).
465 ///
466 /// The list is assembled at agent creation time and is immutable for
467 /// the agent's lifetime.
468 #[must_use]
469 pub fn available_tools(&self) -> &[crate::tools::AvailableTool] {
470 &self.available_tools
471 }
472
473 /// Convenience accessor: returns just the tool names.
474 #[must_use]
475 pub fn available_tool_names(&self) -> Vec<&str> {
476 self.available_tools
477 .iter()
478 .map(|t| t.name.as_str())
479 .collect()
480 }
481
482 /// Interrupt the active chat prompt execution.
483 ///
484 /// # Errors
485 ///
486 /// Returns a [`Error`] if the cancellation call fails.
487 pub async fn cancel(&self) -> Result<(), Error> {
488 self.runtime.cancel(self.id).await
489 }
490
491 /// Wait for the conversation or active run to stabilize and become idle.
492 ///
493 /// # Errors
494 ///
495 /// Returns a [`Error`] if the wait call fails.
496 pub async fn wait_for_idle(&self) -> Result<(), Error> {
497 self.runtime.wait_for_idle(self.id).await
498 }
499
500 /// Retrieve the conversation's message history.
501 ///
502 /// # Errors
503 ///
504 /// Returns [`Error`] if the query fails.
505 pub async fn history(&self) -> Result<Vec<ConversationMessage>, Error> {
506 self.runtime.history(self.id).await
507 }
508
509 /// Return the number of completed turns in the conversation.
510 ///
511 /// # Errors
512 ///
513 /// Returns [`Error`] if the query fails.
514 pub async fn turn_count(&self) -> Result<u32, Error> {
515 self.runtime.turn_count(self.id).await
516 }
517
518 /// Return cumulative token usage across all turns.
519 ///
520 /// # Errors
521 ///
522 /// Returns [`Error`] if the query fails.
523 pub async fn total_usage(&self) -> Result<UsageMetadata, Error> {
524 self.runtime.total_usage(self.id).await
525 }
526
527 /// Return token usage from the most recent turn only.
528 ///
529 /// # Errors
530 ///
531 /// Returns [`Error`] if the query fails.
532 pub async fn last_turn_usage(&self) -> Result<UsageMetadata, Error> {
533 self.runtime.last_turn_usage(self.id).await
534 }
535
536 /// Clear the conversation history and reset state.
537 ///
538 /// # Errors
539 ///
540 /// Returns [`Error`] if the operation fails.
541 pub async fn clear_history(&self) -> Result<(), Error> {
542 self.runtime.clear_history(self.id).await
543 }
544
545 /// Return the text of the last model response, if any.
546 ///
547 /// # Errors
548 ///
549 /// Returns [`Error`] if the query fails.
550 pub async fn last_response(&self) -> Result<Option<String>, Error> {
551 self.runtime.last_response(self.id).await
552 }
553
554 /// Return the step indices at which conversation compaction occurred.
555 ///
556 /// # Errors
557 ///
558 /// Returns [`Error`] if the query fails.
559 pub async fn compaction_indices(&self) -> Result<Vec<u32>, Error> {
560 self.runtime.compaction_indices(self.id).await
561 }
562
563 /// Delete the conversation and all associated state.
564 ///
565 /// After calling this method, the agent handle is no longer usable
566 /// for chat operations. This also marks the agent as shut down.
567 ///
568 /// # Errors
569 ///
570 /// Returns [`Error`] if the delete operation fails.
571 pub async fn delete(&self) -> Result<(), Error> {
572 let result = self.runtime.delete(self.id).await;
573 self.is_shutdown.store(true, Ordering::SeqCst);
574 result
575 }
576
577 /// Disconnect from the agent without deleting its state.
578 ///
579 /// The agent's conversation state is preserved but this handle
580 /// can no longer send messages. Marks the agent as shut down.
581 ///
582 /// # Errors
583 ///
584 /// Returns [`Error`] if the disconnect operation fails.
585 pub async fn disconnect(&self) -> Result<(), Error> {
586 let result = self.runtime.disconnect(self.id).await;
587 self.is_shutdown.store(true, Ordering::SeqCst);
588 result
589 }
590
591 /// Check whether the agent is currently idle (not running a turn).
592 ///
593 /// # Errors
594 ///
595 /// Returns [`Error`] if the query fails.
596 pub async fn is_idle(&self) -> Result<bool, Error> {
597 self.runtime.is_idle(self.id).await
598 }
599
600 /// Return the structured output from the last chat response, if any.
601 ///
602 /// Only populated after a [`chat()`](Self::chat) round-trip when the
603 /// agent was configured with a `response_schema` and the model returned
604 /// a valid JSON payload.
605 #[must_use]
606 pub fn get_last_structured_output(&self) -> Option<serde_json::Value> {
607 let guard = self
608 .last_shared_state
609 .lock()
610 .inspect_err(|e| {
611 tracing::error!(
612 agent_id = self.id,
613 error = %e,
614 "last_shared_state mutex poisoned in get_last_structured_output"
615 );
616 })
617 // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
618 .ok()?;
619 let state = guard
620 .as_ref()?
621 .lock()
622 .inspect_err(|e| {
623 tracing::error!(
624 agent_id = self.id,
625 error = %e,
626 "ChatResponseSharedState mutex poisoned in get_last_structured_output"
627 );
628 })
629 // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
630 .ok()?;
631 state.structured_output.clone()
632 }
633
634 /// Return the structured output from the last chat response deserialized into `T`.
635 ///
636 /// Returns `None` if there was no structured output on the last response.
637 /// Returns `Some(Err(...))` if the structured output could not be deserialized as `T`.
638 pub fn get_last_structured_output_as<T: serde::de::DeserializeOwned>(
639 &self,
640 ) -> Option<Result<T, serde_json::Error>> {
641 self.get_last_structured_output()
642 .map(serde_json::from_value)
643 }
644
645 /// Return the usage metadata from the last chat response, if any.
646 #[must_use]
647 pub fn get_last_usage(&self) -> Option<UsageMetadata> {
648 let guard = self
649 .last_shared_state
650 .lock()
651 .inspect_err(|e| {
652 tracing::error!(
653 agent_id = self.id,
654 error = %e,
655 "last_shared_state mutex poisoned in get_last_usage"
656 );
657 })
658 // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
659 .ok()?;
660 let state = guard
661 .as_ref()?
662 .lock()
663 .inspect_err(|e| {
664 tracing::error!(
665 agent_id = self.id,
666 error = %e,
667 "ChatResponseSharedState mutex poisoned in get_last_usage"
668 );
669 })
670 // NOLINT: error already logged via inspect_err above; .ok()? propagates None on poison
671 .ok()?;
672 state.usage.clone()
673 }
674
675 /// Send a message without waiting for a response.
676 ///
677 /// Fire-and-forget: the message is delivered to the agent but no
678 /// streaming response is produced.
679 ///
680 /// This is **single-shot**; retrying is the caller's responsibility.
681 ///
682 /// # Errors
683 ///
684 /// Returns a [`Error`] if sending fails.
685 pub async fn send(&self, content: impl Into<Content>) -> Result<(), Error> {
686 if !self.is_started() {
687 return Err(Error::AgentNotStarted);
688 }
689 self.runtime.send(self.id, &content.into()).await
690 }
691
692 /// Signal that this agent is idle and ready to receive input.
693 ///
694 /// # Errors
695 ///
696 /// Returns a [`Error`] if the signal call fails.
697 pub async fn signal_idle(&self) -> Result<(), Error> {
698 self.runtime.signal_idle(self.id).await
699 }
700
701 /// Wait for the agent to wake up, returning `true` if woken or
702 /// `false` if the `timeout` elapsed.
703 ///
704 /// # Errors
705 ///
706 /// Returns a [`Error`] if the wait call fails.
707 pub async fn wait_for_wakeup(&self, timeout: std::time::Duration) -> Result<bool, Error> {
708 self.runtime.wait_for_wakeup(self.id, timeout).await
709 }
710
711 /// Gracefully shut down the agent.
712 ///
713 /// This sends a `ShutdownAgent` command to the Python runtime, which
714 /// calls `__aexit__()` on the SDK agent. The handle remains usable
715 /// for read-only queries (e.g. [`is_started()`](Self::is_started))
716 /// after shutdown.
717 ///
718 /// # Errors
719 ///
720 /// Returns a [`Error`] if shutdown fails. The `is_shutdown`
721 /// flag is always set so the `Drop` impl will not emit a warning.
722 pub async fn shutdown(&self) -> Result<(), Error> {
723 if self.is_shutdown.load(Ordering::SeqCst) {
724 tracing::debug!(agent_id = self.id, "Agent already shut down");
725 return Ok(());
726 }
727
728 tracing::info!(agent_id = self.id, "Shutting down agent");
729 let result = self.runtime.shutdown_agent(self.id).await;
730
731 // Always mark as shut down so Drop doesn't warn, even on failure.
732 self.is_shutdown.store(true, Ordering::SeqCst);
733
734 // Clean up bridge state AFTER the runtime's shutdown completes.
735 // In the live runtime, `__aexit__` fires hooks (e.g. on_session_end)
736 // that look up bridge state — so this must happen after, not before.
737 match crate::runtime::bridge_state().write() {
738 Ok(mut map) => {
739 map.remove(&self.id);
740 }
741 Err(e) => {
742 tracing::error!(
743 agent_id = self.id,
744 error = %e,
745 "BRIDGE_STATE RwLock poisoned during shutdown cleanup — \
746 bridge state entry may leak"
747 );
748 }
749 }
750
751 match result {
752 Ok(()) => {
753 tracing::info!(agent_id = self.id, "Agent shut down successfully");
754 }
755 Err(ref e) => {
756 tracing::error!(agent_id = self.id, error = ?e, "Agent shutdown failed");
757 }
758 }
759
760 result
761 }
762
763 /// Spawn a subagent from the given config, sharing this agent's runtime.
764 ///
765 /// If a `ToolRegistry` is provided and `config.tools` is empty, the
766 /// registry's definitions are automatically applied.
767 ///
768 /// # Errors
769 ///
770 /// Returns a [`Error`] if agent creation fails.
771 pub async fn spawn_subagent(
772 &self,
773 mut config: AgentConfig,
774 registry: impl Into<Option<crate::tools::ToolRegistry>>,
775 ) -> Result<Self, Error> {
776 let opt_registry = registry.into();
777 if let Some(disp) = &opt_registry
778 && config.tools.is_empty()
779 {
780 config.tools = disp.definitions();
781 }
782 let arc_registry = opt_registry.map(Arc::new);
783 Self::new(
784 Arc::clone(&self.runtime),
785 config,
786 arc_registry,
787 None,
788 self.policy_handler.clone(),
789 )
790 .await
791 }
792}
793
794impl<R: Runtime> Drop for AgentHandle<R> {
795 fn drop(&mut self) {
796 if self.is_started.load(Ordering::SeqCst) && !self.is_shutdown.load(Ordering::SeqCst) {
797 tracing::debug!(
798 agent_id = self.id,
799 "AgentHandle dropped without explicit shutdown() — \
800 sending best-effort shutdown signal"
801 );
802 // try_shutdown_agent fires a command that eventually calls
803 // handle_shutdown_agent, which cleans up bridge state AFTER
804 // __aexit__ completes (so on_session_end hooks can still
805 // find the hook runner). Do NOT clean up bridge state here.
806 self.runtime.try_shutdown_agent(self.id);
807 } else if self.is_shutdown.load(Ordering::SeqCst) {
808 // shutdown() was already called — handle_shutdown_agent
809 // already cleaned up bridge state after __aexit__. Nothing
810 // to do.
811 } else {
812 // Agent was never started (e.g. creation failed). Clean up
813 // any partial bridge state that might have been registered.
814 match crate::runtime::bridge_state().write() {
815 Ok(mut map) => {
816 map.remove(&self.id);
817 }
818 Err(e) => {
819 tracing::warn!(
820 agent_id = self.id,
821 error = %e,
822 "BRIDGE_STATE RwLock poisoned during Drop — \
823 bridge state entry for this agent may leak"
824 );
825 }
826 }
827 }
828 }
829}