Skip to main content

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}