Skip to main content

agent_sdk/
agent_loop.rs

1//! Agent loop orchestration module.
2//!
3//! This module contains the core agent loop that orchestrates LLM calls,
4//! tool execution, and event handling. The agent loop is the main entry point
5//! for running an AI agent.
6//!
7//! # Architecture
8//!
9//! The agent loop works as follows:
10//! 1. Receives a user message
11//! 2. Sends the message to the LLM provider
12//! 3. Processes the LLM response (text or tool calls)
13//! 4. If tool calls are present, executes them and feeds results back to LLM
14//! 5. Repeats until the LLM responds with only text (no tool calls)
15//! 6. Persists events throughout to the configured event store
16//!
17//! # Building an Agent
18//!
19//! Use the builder pattern via [`builder()`] or [`AgentLoopBuilder`]:
20//!
21//! ```ignore
22//! use agent_sdk::{builder, providers::AnthropicProvider};
23//!
24//! let agent = builder()
25//!     .provider(AnthropicProvider::sonnet(api_key))
26//!     .tools(my_tools)
27//!     .event_store(event_store)
28//!     .build();
29//! ```
30
31mod budget;
32mod builder;
33mod helpers;
34mod idempotency;
35mod listen;
36mod llm;
37mod run_loop;
38#[cfg(test)]
39mod test_utils;
40#[cfg(test)]
41mod tests;
42mod tool_execution;
43mod turn;
44mod types;
45
46use self::run_loop::{run_loop, run_single_turn};
47use self::types::{RunLoopParameters, TurnParameters};
48use crate::types::TurnOptions;
49
50pub use self::builder::AgentLoopBuilder;
51
52use crate::authority::{EventAuthority, LocalEventAuthority};
53use crate::context::{CompactionConfig, ContextCompactor};
54use crate::events::{AgentEvent, AgentEventEnvelope};
55use crate::hooks::AgentHooks;
56use crate::llm::LlmProvider;
57use crate::stores::{EventStore, MessageStore, StateStore, StoredTurnEvents, ToolExecutionStore};
58use crate::tools::{ToolContext, ToolRegistry};
59use crate::types::{AgentConfig, AgentError, AgentInput, AgentRunState, RunOptions, ThreadId};
60use async_trait::async_trait;
61use futures::FutureExt;
62use futures::Stream;
63use std::future::Future;
64use std::panic::AssertUnwindSafe;
65use std::pin::Pin;
66use std::sync::Arc;
67use std::task::{Context, Poll};
68use tokio::sync::{mpsc, oneshot};
69use tokio_util::sync::CancellationToken;
70
71/// Bound on the [`AgentLoop::run_stream`] tee channel.
72///
73/// The event store is the durable source of truth, so the live stream is a
74/// best-effort mirror. Bounding the channel keeps a slow (or stalled) stream
75/// consumer from growing memory without limit: once this many events are
76/// buffered, [`TeeEventStore::append`] drops the newest event rather than
77/// blocking the run loop (whose forward progress and persistence must not
78/// depend on a consumer's read rate). Callers that need every event should
79/// read the configured [`EventStore`] back instead of relying on the stream.
80const RUN_STREAM_CHANNEL_CAPACITY: usize = 1024;
81
82/// How long [`TeeEventStore::append`] waits to forward a **terminal** event
83/// onto a full tee channel before dropping it.
84///
85/// Terminal events are the stream's closing marker; silently dropping one on
86/// a full buffer would let the stream end with no terminal frame. Blocking
87/// briefly gives a slow-but-live consumer time to drain a slot, while the
88/// bound keeps a dead (never-reading) consumer from stalling the run.
89const RUN_STREAM_TERMINAL_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
90
91/// Whether an event is a terminal run-closing marker on the live stream.
92///
93/// These are the events emitted exactly once at the end of a run
94/// ([`AgentEvent::Done`], [`AgentEvent::BudgetExceeded`],
95/// [`AgentEvent::Cancelled`], [`AgentEvent::Refusal`]); a stream consumer
96/// relies on observing one of them before the stream closes.
97const fn is_terminal_stream_event(event: &AgentEvent) -> bool {
98    matches!(
99        event,
100        AgentEvent::Done { .. }
101            | AgentEvent::BudgetExceeded { .. }
102            | AgentEvent::Cancelled { .. }
103            | AgentEvent::Refusal { .. }
104    )
105}
106
107/// An [`EventStore`] decorator that delegates every append to the wrapped
108/// store and, **after the durable append succeeds**, forwards a clone of the
109/// [`AgentEvent`] to a bounded channel.
110///
111/// This is the tee behind [`AgentLoop::run_stream`]: the run loop writes
112/// every event through the configured store as usual, and the matching
113/// [`AgentEvent`] is mirrored onto the stream so callers consume events live
114/// without implementing an [`EventStore`]. Persistence comes first — a
115/// failed durable append never reaches the stream, so consumers cannot
116/// observe a phantom event. The forward is best-effort and lossy under
117/// backpressure — if the consumer has dropped the stream, or is too slow and
118/// the bounded buffer ([`RUN_STREAM_CHANNEL_CAPACITY`]) is full, a
119/// non-terminal event is dropped from the live stream and the run continues
120/// unaffected. Terminal events (see [`is_terminal_stream_event`]) instead
121/// wait up to [`RUN_STREAM_TERMINAL_SEND_TIMEOUT`] for buffer space so a
122/// slow-but-live consumer still receives the closing marker. Dropped events
123/// remain durably recorded in `inner`.
124struct TeeEventStore {
125    inner: Arc<dyn EventStore>,
126    tx: mpsc::Sender<AgentEvent>,
127}
128
129#[async_trait]
130impl EventStore for TeeEventStore {
131    async fn append(
132        &self,
133        thread_id: &ThreadId,
134        turn: usize,
135        envelope: AgentEventEnvelope,
136    ) -> anyhow::Result<()> {
137        let event = envelope.event.clone();
138        // Persist FIRST: forwarding before a failed durable append would let
139        // the stream consumer observe an event that never landed in the
140        // store (a phantom event).
141        self.inner.append(thread_id, turn, envelope).await?;
142
143        if is_terminal_stream_event(&event) {
144            // Terminal events are the stream's closing marker: give a
145            // slow-but-live consumer a bounded window to make room instead
146            // of silently dropping the frame, while a dead consumer can
147            // only stall the run for the timeout.
148            if self
149                .tx
150                .send_timeout(event, RUN_STREAM_TERMINAL_SEND_TIMEOUT)
151                .await
152                .is_err()
153            {
154                log::debug!(
155                    "run_stream tee could not deliver terminal event within \
156                     {RUN_STREAM_TERMINAL_SEND_TIMEOUT:?}; dropping it from the live stream \
157                     (still persisted to the store)"
158                );
159            }
160        } else if let Err(mpsc::error::TrySendError::Full(_)) = self.tx.try_send(event) {
161            // `try_send` (never `send().await`): the run loop must not stall
162            // on a slow stream consumer. A `Full` error means the consumer
163            // is behind, so the event is dropped from the live stream only —
164            // it was already persisted to `inner` above.
165            log::debug!(
166                "run_stream tee channel full (capacity {RUN_STREAM_CHANNEL_CAPACITY}); \
167                 dropping event from live stream (still persisted to the store)"
168            );
169        }
170        Ok(())
171    }
172
173    async fn finish_turn(&self, thread_id: &ThreadId, turn: usize) -> anyhow::Result<()> {
174        self.inner.finish_turn(thread_id, turn).await
175    }
176
177    async fn get_turn(
178        &self,
179        thread_id: &ThreadId,
180        turn: usize,
181    ) -> anyhow::Result<Option<StoredTurnEvents>> {
182        self.inner.get_turn(thread_id, turn).await
183    }
184
185    async fn get_turns(&self, thread_id: &ThreadId) -> anyhow::Result<Vec<StoredTurnEvents>> {
186        self.inner.get_turns(thread_id).await
187    }
188
189    async fn get_events(&self, thread_id: &ThreadId) -> anyhow::Result<Vec<AgentEventEnvelope>> {
190        self.inner.get_events(thread_id).await
191    }
192
193    async fn event_count(&self, thread_id: &ThreadId) -> anyhow::Result<usize> {
194        self.inner.event_count(thread_id).await
195    }
196
197    async fn get_events_since(
198        &self,
199        thread_id: &ThreadId,
200        offset: usize,
201    ) -> anyhow::Result<Vec<AgentEventEnvelope>> {
202        self.inner.get_events_since(thread_id, offset).await
203    }
204
205    async fn clear(&self, thread_id: &ThreadId) -> anyhow::Result<()> {
206        self.inner.clear(thread_id).await
207    }
208}
209
210/// Run the agent loop with panic isolation at the spawned-task boundary.
211///
212/// A panic anywhere inside the run loop — most importantly inside the
213/// LLM provider call or the compaction provider call, neither of which
214/// is otherwise guarded — would unwind the spawned task, drop the
215/// `state_tx` oneshot, and surface to the caller as an opaque
216/// [`oneshot::error::RecvError`] (and, for subagents, be misclassified
217/// as `Disconnected`). Catching the unwind here turns it into a
218/// structured [`AgentRunState::Error`] that the task can still send on
219/// `state_tx`, so the run ends observably rather than silently tearing
220/// down the channel.
221///
222/// `AssertUnwindSafe` is sound at this boundary: the run loop owns its
223/// `RunLoopParameters` by value, so nothing it touches outlives the
224/// caught panic and is observed afterwards. The only value produced is
225/// the returned `AgentRunState`. Tool-level panics are already caught
226/// closer to the tool boundary (see
227/// `agent_loop::helpers::catch_tool_panic`) so the assistant
228/// `tool_use` / `tool_result` history stays balanced; this guard is the
229/// outer safety net for everything else.
230async fn run_loop_isolated<Ctx, P, H, M, S>(
231    params: RunLoopParameters<Ctx, P, H, M, S>,
232) -> AgentRunState
233where
234    Ctx: Send + Sync + Clone + 'static,
235    P: LlmProvider,
236    H: AgentHooks,
237    M: MessageStore,
238    S: StateStore,
239{
240    match AssertUnwindSafe(run_loop(params)).catch_unwind().await {
241        Ok(state) => state,
242        Err(payload) => {
243            let message = self::helpers::panic_payload_message(payload.as_ref());
244            log::error!("agent run loop panicked: {message}");
245            AgentRunState::Error(AgentError::new(
246                format!("Agent run panicked: {message}"),
247                false,
248            ))
249        }
250    }
251}
252
253/// Drop a spawned run task's [`tokio::task::JoinHandle`], logging a
254/// `debug!` to make the detach visible.
255///
256/// `run` / `run_with_options` intentionally drop the handle: the run is
257/// stopped through the cancel token or the per-tool timeout, not by
258/// aborting the task. Surfacing the detach at `debug` level gives a
259/// breadcrumb when a subprocess-backed tool that ignores the
260/// cooperative-cancel contract leaks a process after cancellation.
261fn warn_on_detached_run_handle(handle: tokio::task::JoinHandle<()>) {
262    log::debug!(
263        "agent run JoinHandle dropped (task detached); the run can only be \
264         stopped via its cancel token or per-tool timeout. Subprocess-backed \
265         tools must honour kill_on_drop or a token-aware kill to avoid leaks"
266    );
267    drop(handle);
268}
269
270/// Await a run's state receiver, mapping a dropped channel to an `anyhow`
271/// error so `run`/`run_with_options` can present an `impl Future` instead of
272/// a bare [`oneshot::Receiver`].
273///
274/// A panic inside the run is already converted to
275/// [`AgentRunState::Error`] before the channel send (see
276/// [`run_loop_isolated`]), so the only way `recv` fails is the sender being
277/// dropped without sending — a runtime shutdown rather than an agent error.
278async fn recv_run_state(
279    state_rx: oneshot::Receiver<AgentRunState>,
280) -> anyhow::Result<AgentRunState> {
281    state_rx
282        .await
283        .map_err(|_| anyhow::anyhow!("agent run task was dropped before reporting a final state"))
284}
285
286/// Handle to a persistent agent thread.
287///
288/// Returned by [`AgentLoop::run_persistent`]. Allows the caller to send
289/// new messages to the running agent and cancel execution.
290pub struct AgentHandle {
291    /// Send new messages to the running agent. The agent processes them as new
292    /// user turns once it parks between turns.
293    ///
294    /// Only [`AgentInput::Text`] and [`AgentInput::Message`] are supported on
295    /// this channel — they are the only variants that represent a fresh user
296    /// turn. Injecting [`AgentInput::Resume`], [`AgentInput::SubmitToolResults`],
297    /// or [`AgentInput::Continue`] ends the run with [`AgentRunState::Error`]
298    /// (those belong to the single-turn `run_turn` flow, not the persistent
299    /// loop). Dropping the sender ends the run cleanly with `Done`.
300    pub input_tx: mpsc::Sender<AgentInput>,
301    /// Final run state (sent once when the agent completes).
302    pub state_rx: oneshot::Receiver<AgentRunState>,
303    /// Cancel the running agent.
304    pub cancel_token: CancellationToken,
305}
306
307/// A live event stream paired with the run's final state.
308///
309/// Returned by [`AgentLoop::run_stream`]. `events` yields each
310/// [`AgentEvent`] as the run persists it (see the delivery semantics on
311/// [`AgentLoop::run_stream`]) and ends when the run finishes. `final_state`
312/// resolves exactly once with the run's terminal [`AgentRunState`] — the
313/// only place that state lives: an
314/// [`AgentRunState::AwaitingConfirmation`] carries the continuation needed
315/// to resume, and a startup failure surfaces as [`AgentRunState::Error`]
316/// even when no event was ever emitted. Await it after (or concurrently
317/// with) consuming `events`; dropping it is fine when only the event feed
318/// matters.
319pub struct RunStream {
320    /// Live event feed; ends when the run completes (see
321    /// [`RunEventStream`] for the exact termination contract).
322    pub events: RunEventStream,
323    /// Resolves with the run's final state. Fails only if the runtime
324    /// shut down before the run could report (see
325    /// [`AgentLoop::run`]'s error contract).
326    pub final_state: oneshot::Receiver<AgentRunState>,
327}
328
329/// Live event feed for [`RunStream`].
330///
331/// Ends when either the tee channel closes (every sender dropped) or the
332/// run itself completes — whichever happens first. The completion signal
333/// matters because the tee's channel sender travels inside every
334/// [`ToolContext`] clone handed to tools (via the turn-scoped event
335/// store): a tool that parks a clone in background work would otherwise
336/// keep the channel open past `Done` and the stream would never end even
337/// though `final_state` already resolved.
338///
339/// Ordering guarantee: the completion signal fires only AFTER the run
340/// future resolved — i.e. after the terminal event was persisted and
341/// forwarded — and buffered events are always drained before the stream
342/// ends, so no event emitted by the run is lost. Events appended by leaked
343/// tool-context clones after run completion are not delivered on the live
344/// stream (they are still persisted to the underlying store).
345pub struct RunEventStream {
346    rx: mpsc::Receiver<AgentEvent>,
347    run_completed: Pin<Box<tokio_util::sync::WaitForCancellationFutureOwned>>,
348    draining: bool,
349}
350
351impl Stream for RunEventStream {
352    type Item = AgentEvent;
353
354    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
355        let this = self.get_mut();
356        // Observe run completion BEFORE the channel: a leaked sender clone
357        // that keeps appending would otherwise hold the channel ready on
358        // every poll and starve the completion signal forever. Closing the
359        // receiver stops any further sends while the already-buffered
360        // (finite) events — including the terminal one, which the run task
361        // forwards before firing the signal — still drain below.
362        if !this.draining && this.run_completed.as_mut().poll(cx).is_ready() {
363            this.draining = true;
364            this.rx.close();
365        }
366        match this.rx.poll_recv(cx) {
367            Poll::Ready(Some(event)) => Poll::Ready(Some(event)),
368            Poll::Ready(None) => Poll::Ready(None),
369            // Reachable only while the run is live (both the token and the
370            // channel registered wakers above); a closed, drained channel
371            // reports `None` from `poll_recv` itself.
372            Poll::Pending => {
373                if this.draining {
374                    Poll::Ready(None)
375                } else {
376                    Poll::Pending
377                }
378            }
379        }
380    }
381}
382
383/// Inputs shared by the three `spawn_run_loop` callers
384/// (`run_abortable_with_options`, `run_persistent_with_options`,
385/// `run_stream_with_options`). Bundled so the private spawn helper takes a
386/// single argument instead of a long positional list.
387struct SpawnRunLoopParams<Ctx> {
388    event_store: Arc<dyn EventStore>,
389    thread_id: ThreadId,
390    input: AgentInput,
391    tool_context: ToolContext<Ctx>,
392    cancel_token: CancellationToken,
393    run_options: RunOptions,
394    input_rx: Option<mpsc::Receiver<AgentInput>>,
395    /// Cancelled by the spawned task right after the run future resolves
396    /// (before the final state is sent), so `run_stream` consumers get a
397    /// stream-termination signal that does not depend on every tee sender
398    /// clone being dropped. `None` for the non-streaming entry points.
399    run_completed: Option<CancellationToken>,
400}
401
402/// Configuration bundle for constructing an [`AgentLoop`] with compaction.
403pub struct AgentLoopCompactionConfig {
404    pub agent_config: AgentConfig,
405    pub compaction_config: CompactionConfig,
406}
407
408impl AgentLoopCompactionConfig {
409    #[must_use]
410    pub const fn new(agent_config: AgentConfig, compaction_config: CompactionConfig) -> Self {
411        Self {
412            agent_config,
413            compaction_config,
414        }
415    }
416}
417
418/// The main agent loop that orchestrates LLM calls and tool execution.
419///
420/// `AgentLoop` is the core component that:
421/// - Manages conversation state via message and state stores
422/// - Calls the LLM provider and processes responses
423/// - Executes tools through the tool registry
424/// - Persists events to the configured event store
425/// - Enforces hooks for tool permissions and lifecycle events
426///
427/// # Type Parameters
428///
429/// - `Ctx`: Application-specific context passed to tools (e.g., user ID, database)
430/// - `P`: The LLM provider implementation
431/// - `H`: The hooks implementation for lifecycle customization
432/// - `M`: The message store implementation
433/// - `S`: The state store implementation
434///
435/// # Event Storage
436///
437/// Every loop instance requires an [`EventStore`] configured at construction
438/// time. Events are written to that store for the entire lifecycle of the loop,
439/// and callers read them back from the store instead of receiving an in-process
440/// channel from the runtime.
441///
442/// # Running the Agent
443///
444/// ```ignore
445/// let final_state = agent.run(
446///     thread_id,
447///     AgentInput::Text("Hello!".to_string()),
448///     tool_ctx,
449/// );
450/// let state = final_state.await?;
451/// let events = event_store.get_events(&thread_id).await?;
452/// ```
453pub struct AgentLoop<Ctx, P, H, M, S>
454where
455    P: LlmProvider,
456    H: AgentHooks,
457    M: MessageStore,
458    S: StateStore,
459{
460    pub(super) provider: Arc<P>,
461    pub(super) tools: Arc<ToolRegistry<Ctx>>,
462    pub(super) hooks: Arc<H>,
463    pub(super) message_store: Arc<M>,
464    pub(super) state_store: Arc<S>,
465    pub(super) event_store: Arc<dyn EventStore>,
466    pub(super) event_authority: Option<Arc<dyn EventAuthority>>,
467    pub(super) config: AgentConfig,
468    pub(super) compaction_config: Option<CompactionConfig>,
469    pub(super) compactor: Option<Arc<dyn ContextCompactor>>,
470    pub(super) execution_store: Option<Arc<dyn ToolExecutionStore>>,
471    pub(super) audit_sink: Arc<dyn crate::hooks::ToolAuditSink>,
472    pub(super) reminder_config: Option<crate::reminders::ReminderConfig>,
473    #[cfg(feature = "otel")]
474    pub(super) observability_store: Option<Arc<dyn crate::observability::ObservabilityStore>>,
475}
476
477/// Create a new builder for constructing an `AgentLoop`.
478#[must_use]
479pub fn builder<Ctx>() -> AgentLoopBuilder<Ctx, (), (), (), ()> {
480    AgentLoopBuilder::new()
481}
482
483impl<Ctx, P, H, M, S> AgentLoop<Ctx, P, H, M, S>
484where
485    Ctx: Send + Sync + 'static,
486    P: LlmProvider + 'static,
487    H: AgentHooks + 'static,
488    M: MessageStore + 'static,
489    S: StateStore + 'static,
490{
491    /// Create a new agent loop with all components specified directly.
492    #[must_use]
493    pub fn new(
494        provider: P,
495        tools: ToolRegistry<Ctx>,
496        hooks: H,
497        message_store: M,
498        state_store: S,
499        event_store: Arc<dyn EventStore>,
500        config: AgentConfig,
501    ) -> Self {
502        Self {
503            provider: Arc::new(provider),
504            tools: Arc::new(tools),
505            hooks: Arc::new(hooks),
506            message_store: Arc::new(message_store),
507            state_store: Arc::new(state_store),
508            event_store,
509            event_authority: None,
510            config,
511            compaction_config: None,
512            compactor: None,
513            execution_store: None,
514            audit_sink: Arc::new(crate::hooks::NoopAuditSink),
515            reminder_config: None,
516            #[cfg(feature = "otel")]
517            observability_store: None,
518        }
519    }
520
521    /// Create a new agent loop with compaction enabled.
522    #[must_use]
523    pub fn with_compaction(
524        provider: P,
525        tools: ToolRegistry<Ctx>,
526        hooks: H,
527        message_store: M,
528        state_store: S,
529        event_store: Arc<dyn EventStore>,
530        config: AgentLoopCompactionConfig,
531    ) -> Self {
532        let AgentLoopCompactionConfig {
533            agent_config,
534            compaction_config,
535        } = config;
536        Self {
537            provider: Arc::new(provider),
538            tools: Arc::new(tools),
539            hooks: Arc::new(hooks),
540            message_store: Arc::new(message_store),
541            state_store: Arc::new(state_store),
542            event_store,
543            event_authority: None,
544            config: agent_config,
545            compaction_config: Some(compaction_config),
546            compactor: None,
547            execution_store: None,
548            audit_sink: Arc::new(crate::hooks::NoopAuditSink),
549            reminder_config: None,
550            #[cfg(feature = "otel")]
551            observability_store: None,
552        }
553    }
554
555    /// Set the authoritative tool audit sink.
556    ///
557    /// When set, the loop emits a [`ToolAuditRecord`](crate::advanced::ToolAuditRecord)
558    /// at every tool-lifecycle transition (blocked, requires-confirmation,
559    /// cached, replayed, invalidated, completed, persistence-failed).
560    ///
561    /// The default is [`NoopAuditSink`](crate::hooks::NoopAuditSink) which
562    /// discards every record — suitable for local/CLI usage. Servers should
563    /// swap in a durable sink.
564    #[must_use]
565    pub fn with_audit_sink(mut self, sink: impl crate::hooks::ToolAuditSink + 'static) -> Self {
566        self.audit_sink = Arc::new(sink);
567        self
568    }
569
570    /// Set the observability store for `GenAI` payload capture.
571    ///
572    /// When set, the store is called at each LLM request boundary to decide
573    /// whether payloads are inlined on spans, externalized, or omitted.
574    #[cfg(feature = "otel")]
575    #[must_use]
576    pub fn with_observability_store(
577        mut self,
578        store: impl crate::observability::ObservabilityStore + 'static,
579    ) -> Self {
580        self.observability_store = Some(Arc::new(store));
581        self
582    }
583
584    /// Resolve the event authority for this run.
585    ///
586    /// If an external authority was configured via the builder, use it.
587    /// Otherwise create a fresh [`LocalEventAuthority`] that starts at 0
588    /// (the pre-existing local/CLI behaviour).
589    fn resolve_authority(&self) -> Arc<dyn EventAuthority> {
590        self.event_authority
591            .clone()
592            .unwrap_or_else(|| Arc::new(LocalEventAuthority::new()))
593    }
594
595    /// Run the agent loop.
596    ///
597    /// This method allows the agent to pause when a tool requires confirmation,
598    /// returning an `AgentRunState::AwaitingConfirmation` that contains the
599    /// state needed to resume.
600    ///
601    /// When the `cancel_token` is cancelled, the agent interrupts in-flight
602    /// work at the SDK boundary: the LLM stream and any non-streaming LLM call
603    /// are raced against the token (see `agent_loop/llm.rs`), and in-flight
604    /// tool executions are dropped with balanced `ToolResult`s synthesized so
605    /// the conversation history stays consistent. The run then ends with
606    /// `AgentRunState::Cancelled` — cancellation is *not* deferred to a turn
607    /// boundary. Tools that hold OS resources must honour the
608    /// [cooperative-cancel contract](crate::tools::Tool#cooperative-cancellation);
609    /// see the section below.
610    ///
611    /// # Arguments
612    ///
613    /// * `thread_id` - The thread identifier for this conversation
614    /// * `input` - Either a new text message or a resume with confirmation decision
615    /// * `tool_context` - Context passed to tools
616    /// * `cancel_token` - Token to signal cancellation from outside
617    ///
618    /// # Returns
619    ///
620    /// A future that resolves to the final [`AgentRunState`]. Awaiting it
621    /// drives the run to completion:
622    ///
623    /// ```ignore
624    /// let final_state = agent.run(thread_id, input, tool_ctx, cancel).await?;
625    /// ```
626    ///
627    /// The future is `'static` — the run is already spawned on a Tokio task
628    /// before this returns, so dropping the future does **not** stop the run
629    /// (use `cancel_token`). Awaiting it only waits for the result.
630    ///
631    /// # Example
632    ///
633    /// ```ignore
634    /// let cancel = CancellationToken::new();
635    /// let final_state = agent.run(
636    ///     thread_id,
637    ///     AgentInput::Text("Hello".to_string()),
638    ///     tool_ctx,
639    ///     cancel.clone(),
640    /// ).await?;
641    ///
642    /// match final_state {
643    ///     AgentRunState::Done { .. } => { /* completed */ }
644    ///     AgentRunState::Cancelled { .. } => { /* user cancelled */ }
645    ///     AgentRunState::AwaitingConfirmation { continuation, .. } => {
646    ///         // Get user decision, then resume:
647    ///         let state2 = agent.run(
648    ///             thread_id,
649    ///             AgentInput::Resume {
650    ///                 continuation,
651    ///                 tool_call_id: id,
652    ///                 confirmed: true,
653    ///                 rejection_reason: None,
654    ///             },
655    ///             tool_ctx,
656    ///             cancel.clone(),
657    ///         ).await?;
658    ///     }
659    ///     AgentRunState::Error(e) => { /* handle error */ }
660    /// }
661    /// ```
662    /// # Cancellation, timeout, and the dropped `JoinHandle`
663    ///
664    /// `run` spawns the agent loop on a Tokio task and returns a future over
665    /// the state channel — it **drops** the task's
666    /// [`tokio::task::JoinHandle`]. Dropping a `JoinHandle` *detaches* the
667    /// task rather than aborting it, so the only ways to stop an in-flight
668    /// run are the `cancel_token` (cooperative) or the per-tool
669    /// [`AgentConfig::tool_timeout_ms`](crate::types::AgentConfig::tool_timeout_ms)
670    /// boundary. Callers that need to forcibly abort must use
671    /// [`run_abortable`](Self::run_abortable) and keep the handle.
672    ///
673    /// Because the handle is dropped, a tool that holds a subprocess open
674    /// must obey the
675    /// [cooperative-cancel contract](crate::tools::Tool#cooperative-cancellation)
676    /// (`kill_on_drop` or a token-aware `kill`) or the subprocess will
677    /// outlive the cancelled / timed-out run. A `debug!` is logged here so
678    /// the detach is visible when chasing a leaked subprocess.
679    ///
680    /// # Errors
681    ///
682    /// Returns an error only if the spawned run task is dropped before it can
683    /// report a final state (e.g. a runtime shutdown). A panic inside the run
684    /// is caught and surfaced as [`AgentRunState::Error`], not as an `Err`.
685    pub fn run(
686        &self,
687        thread_id: ThreadId,
688        input: AgentInput,
689        tool_context: ToolContext<Ctx>,
690        cancel_token: CancellationToken,
691    ) -> impl Future<Output = anyhow::Result<AgentRunState>> + Send + 'static
692    where
693        Ctx: Clone,
694    {
695        let (state_rx, handle) = self.run_abortable(thread_id, input, tool_context, cancel_token);
696        warn_on_detached_run_handle(handle);
697        recv_run_state(state_rx)
698    }
699
700    /// Like [`run`](Self::run), but with caller-supplied trace metadata.
701    ///
702    /// Equivalent to `run` except that the supplied [`RunOptions`]
703    /// configure session/user IDs (propagated as `session.id` /
704    /// `user.id` baggage), `langfuse.trace.{name,tags,metadata.*,
705    /// input,output}`, `langfuse.{release,environment}`, and the
706    /// trace-text truncation ceiling.
707    ///
708    /// Use this instead of `run` whenever the consumer needs the
709    /// SDK to populate Langfuse trace metadata; `run` itself
710    /// continues to delegate here with `RunOptions::default()`.
711    ///
712    /// # Errors
713    ///
714    /// See [`run`](Self::run).
715    pub fn run_with_options(
716        &self,
717        thread_id: ThreadId,
718        input: AgentInput,
719        tool_context: ToolContext<Ctx>,
720        cancel_token: CancellationToken,
721        run_options: RunOptions,
722    ) -> impl Future<Output = anyhow::Result<AgentRunState>> + Send + 'static
723    where
724        Ctx: Clone,
725    {
726        let (state_rx, handle) = self.run_abortable_with_options(
727            thread_id,
728            input,
729            tool_context,
730            cancel_token,
731            run_options,
732        );
733        warn_on_detached_run_handle(handle);
734        recv_run_state(state_rx)
735    }
736
737    /// Like [`run`](Self::run), but also returns the [`tokio::task::JoinHandle`] for the
738    /// spawned task.
739    ///
740    /// Callers that need to forcibly abort the agent loop (e.g. subagent
741    /// timeout) can call [`tokio::task::JoinHandle::abort`] on the returned handle.
742    /// Aborting the handle drops the in-flight LLM stream immediately
743    /// instead of waiting for the current turn to finish.
744    pub fn run_abortable(
745        &self,
746        thread_id: ThreadId,
747        input: AgentInput,
748        tool_context: ToolContext<Ctx>,
749        cancel_token: CancellationToken,
750    ) -> (
751        oneshot::Receiver<AgentRunState>,
752        tokio::task::JoinHandle<()>,
753    )
754    where
755        Ctx: Clone,
756    {
757        self.run_abortable_with_options(
758            thread_id,
759            input,
760            tool_context,
761            cancel_token,
762            RunOptions::default(),
763        )
764    }
765
766    /// Like [`run_abortable`](Self::run_abortable), but with
767    /// caller-supplied trace metadata. See [`run_with_options`](Self::run_with_options).
768    pub fn run_abortable_with_options(
769        &self,
770        thread_id: ThreadId,
771        input: AgentInput,
772        tool_context: ToolContext<Ctx>,
773        cancel_token: CancellationToken,
774        run_options: RunOptions,
775    ) -> (
776        oneshot::Receiver<AgentRunState>,
777        tokio::task::JoinHandle<()>,
778    )
779    where
780        Ctx: Clone,
781    {
782        self.spawn_run_loop(SpawnRunLoopParams {
783            event_store: Arc::clone(&self.event_store),
784            thread_id,
785            input,
786            tool_context,
787            cancel_token,
788            run_options,
789            input_rx: None,
790            run_completed: None,
791        })
792    }
793
794    /// Spawn the run loop on a Tokio task and return its state channel +
795    /// join handle.
796    ///
797    /// Shared by [`run_abortable_with_options`](Self::run_abortable_with_options),
798    /// [`run_persistent_with_options`](Self::run_persistent_with_options), and
799    /// [`run_stream_with_options`](Self::run_stream_with_options) so all three
800    /// build [`RunLoopParameters`] identically. The `event_store` is a
801    /// parameter (not always `self.event_store`) so the streaming path can
802    /// inject a teeing decorator.
803    fn spawn_run_loop(
804        &self,
805        SpawnRunLoopParams {
806            event_store,
807            thread_id,
808            input,
809            tool_context,
810            cancel_token,
811            run_options,
812            input_rx,
813            run_completed,
814        }: SpawnRunLoopParams<Ctx>,
815    ) -> (
816        oneshot::Receiver<AgentRunState>,
817        tokio::task::JoinHandle<()>,
818    )
819    where
820        Ctx: Clone,
821    {
822        // `run_options` only feeds OTel root-span metadata. On
823        // non-otel builds the value is genuinely not needed —
824        // explicitly drop it so the unused-variable / needless-pass
825        // lints stay quiet without us reaching for an
826        // `#[allow(...)]`.
827        #[cfg(not(feature = "otel"))]
828        drop(run_options);
829
830        let (state_tx, state_rx) = oneshot::channel();
831        let authority = self.resolve_authority();
832
833        let provider = Arc::clone(&self.provider);
834        let tools = Arc::clone(&self.tools);
835        let hooks = Arc::clone(&self.hooks);
836        let message_store = Arc::clone(&self.message_store);
837        let state_store = Arc::clone(&self.state_store);
838        let config = self.config.clone();
839        let compaction_config = self.compaction_config.clone();
840        let compactor = self.compactor.clone();
841        let execution_store = self.execution_store.clone();
842        let audit_sink = Arc::clone(&self.audit_sink);
843        let reminder_config = self.reminder_config.clone();
844        #[cfg(feature = "otel")]
845        let observability_store = self.observability_store.clone();
846        #[cfg(feature = "otel")]
847        let parent_cx = crate::observability::context::capture_context();
848
849        let task = async move {
850            let result = run_loop_isolated(RunLoopParameters {
851                event_store,
852                authority,
853                thread_id,
854                input,
855                tool_context,
856                provider,
857                tools,
858                hooks,
859                message_store,
860                state_store,
861                config,
862                compaction_config,
863                compactor,
864                execution_store,
865                audit_sink,
866                cancel_token,
867                input_rx,
868                reminder_config,
869                #[cfg(feature = "otel")]
870                run_options,
871                #[cfg(feature = "otel")]
872                observability_store,
873            })
874            .await;
875
876            // Signal run completion BEFORE handing over the final state:
877            // every event the run emitted has already been persisted and
878            // forwarded (the tee runs inside the loop), so a consumer that
879            // observes `final_state` can rely on the event stream
880            // terminating even if a tool leaked a ToolContext clone that
881            // still holds the tee's sender.
882            if let Some(run_completed) = run_completed {
883                run_completed.cancel();
884            }
885            let _ = state_tx.send(result);
886        };
887
888        #[cfg(feature = "otel")]
889        let task = {
890            use opentelemetry::trace::FutureExt;
891            task.with_context(parent_cx)
892        };
893
894        let handle = tokio::spawn(task);
895
896        (state_rx, handle)
897    }
898
899    /// Run the agent with a persistent input channel.
900    ///
901    /// Unlike [`Self::run`], this returns an [`AgentHandle`] that allows the caller
902    /// to inject new user messages into the running agent via `input_tx`.
903    /// The agent will process the initial input, then wait for new messages
904    /// on the channel between turns instead of exiting on `Done`.
905    ///
906    /// The agent exits when:
907    /// - The `input_tx` sender is dropped (no more messages) — reports `Done`
908    /// - The `cancel_token` is cancelled — reports `Cancelled`
909    /// - Max turns exceeded — reports `Error`
910    /// - An unsupported input variant is injected, or appending an injected
911    ///   message fails — reports `Error` (see [`AgentHandle::input_tx`])
912    pub fn run_persistent(
913        &self,
914        thread_id: ThreadId,
915        input: AgentInput,
916        tool_context: ToolContext<Ctx>,
917        cancel_token: CancellationToken,
918    ) -> AgentHandle
919    where
920        Ctx: Clone,
921    {
922        self.run_persistent_with_options(
923            thread_id,
924            input,
925            tool_context,
926            cancel_token,
927            RunOptions::default(),
928        )
929    }
930
931    /// Like [`run_persistent`](Self::run_persistent), but with
932    /// caller-supplied trace metadata. See
933    /// [`run_with_options`](Self::run_with_options).
934    pub fn run_persistent_with_options(
935        &self,
936        thread_id: ThreadId,
937        input: AgentInput,
938        tool_context: ToolContext<Ctx>,
939        cancel_token: CancellationToken,
940        run_options: RunOptions,
941    ) -> AgentHandle
942    where
943        Ctx: Clone,
944    {
945        let (input_tx, input_rx) = mpsc::channel(32);
946        let cancel_handle = cancel_token.clone();
947
948        let (state_rx, handle) = self.spawn_run_loop(SpawnRunLoopParams {
949            event_store: Arc::clone(&self.event_store),
950            thread_id,
951            input,
952            tool_context,
953            cancel_token,
954            run_options,
955            input_rx: Some(input_rx),
956            run_completed: None,
957        });
958        // The persistent run is stopped via the cancel token or by dropping
959        // `input_tx`, not by aborting the task, so detach the handle.
960        drop(handle);
961
962        AgentHandle {
963            input_tx,
964            state_rx,
965            cancel_token: cancel_handle,
966        }
967    }
968
969    /// Stream the agent's [`AgentEvent`]s live as they are emitted.
970    ///
971    /// Returns a [`RunStream`]: a live event feed plus a handle to the
972    /// run's final [`AgentRunState`]. [`RunStream::events`] yields each
973    /// [`AgentEvent`] the moment the run loop writes it to the event store,
974    /// so callers consume events in real time without implementing an
975    /// [`EventStore`]. The same events are still persisted to the loop's
976    /// configured store — the stream is an additional tee, not a
977    /// replacement. [`RunStream::final_state`] resolves once when the run
978    /// ends; it is the only place the terminal state lives, including
979    /// [`AgentRunState::AwaitingConfirmation`] (whose continuation is
980    /// required to resume) and startup errors that occur before any event
981    /// is emitted.
982    ///
983    /// The run is spawned on a Tokio task before this returns; the stream
984    /// ends when the run finishes (or is cancelled via `cancel_token`) —
985    /// termination does not depend on tools dropping their [`ToolContext`]
986    /// clones (see [`RunEventStream`]). Dropping the stream does **not**
987    /// stop the run — use `cancel_token`.
988    ///
989    /// # Delivery semantics
990    ///
991    /// Events reach the stream only **after** their durable append to the
992    /// event store succeeds, so the stream never shows an event the store
993    /// rejected. The stream is bounded and lossy under backpressure: when
994    /// the buffer is full, non-terminal events are dropped from the live
995    /// feed (they remain in the store). Terminal events (`Done`,
996    /// `BudgetExceeded`, `Cancelled`, `Refusal`) are special-cased — the
997    /// run waits up to one second for buffer space so a slow-but-live
998    /// consumer still receives the closing marker, while a consumer that
999    /// never reads cannot stall the run beyond that bound. Callers that
1000    /// need every event must read the configured [`EventStore`] back.
1001    ///
1002    /// This is additive alongside [`run`](Self::run) /
1003    /// [`run_persistent`](Self::run_persistent): use it when you want a
1004    /// live event feed without reading the store back.
1005    pub fn run_stream(
1006        &self,
1007        thread_id: ThreadId,
1008        input: AgentInput,
1009        tool_context: ToolContext<Ctx>,
1010        cancel_token: CancellationToken,
1011    ) -> RunStream
1012    where
1013        Ctx: Clone,
1014    {
1015        self.run_stream_with_options(
1016            thread_id,
1017            input,
1018            tool_context,
1019            cancel_token,
1020            RunOptions::default(),
1021        )
1022    }
1023
1024    /// Like [`run_stream`](Self::run_stream), but with caller-supplied trace
1025    /// metadata. See [`run_with_options`](Self::run_with_options).
1026    pub fn run_stream_with_options(
1027        &self,
1028        thread_id: ThreadId,
1029        input: AgentInput,
1030        tool_context: ToolContext<Ctx>,
1031        cancel_token: CancellationToken,
1032        run_options: RunOptions,
1033    ) -> RunStream
1034    where
1035        Ctx: Clone,
1036    {
1037        let (tx, rx) = mpsc::channel(RUN_STREAM_CHANNEL_CAPACITY);
1038        let event_store: Arc<dyn EventStore> = Arc::new(TeeEventStore {
1039            inner: Arc::clone(&self.event_store),
1040            tx,
1041        });
1042
1043        // Completion signal for the event stream: tool contexts carry the
1044        // tee (and thus a channel sender), so channel EOF alone cannot be
1045        // relied on — a tool may leak a ToolContext clone into background
1046        // work. See `RunEventStream` for the termination contract.
1047        let run_completed = CancellationToken::new();
1048
1049        let (state_rx, handle) = self.spawn_run_loop(SpawnRunLoopParams {
1050            event_store,
1051            thread_id,
1052            input,
1053            tool_context,
1054            cancel_token,
1055            run_options,
1056            input_rx: None,
1057            run_completed: Some(run_completed.clone()),
1058        });
1059        // The run drives itself to completion. Detach the join handle —
1060        // when the run ends, the completion signal (or the tee sender
1061        // dropping) closes the stream — and hand the state receiver to the
1062        // caller: the terminal state (AwaitingConfirmation continuations,
1063        // startup errors) only exists there.
1064        warn_on_detached_run_handle(handle);
1065
1066        RunStream {
1067            events: RunEventStream {
1068                rx,
1069                run_completed: Box::pin(run_completed.cancelled_owned()),
1070                draining: false,
1071            },
1072            final_state: state_rx,
1073        }
1074    }
1075
1076    /// Run a single turn of the agent loop — the authoritative server boundary.
1077    ///
1078    /// Unlike `run()`, this method executes exactly one turn **directly in the
1079    /// caller's task** (no `tokio::spawn`) and returns the result inline. This
1080    /// enables external orchestration where each turn can be dispatched as a
1081    /// separate message (e.g., via Artemis or another message queue).
1082    ///
1083    /// When the `cancel_token` is cancelled, the turn will be aborted before
1084    /// starting execution and return `TurnOutcome::Cancelled`.
1085    ///
1086    /// # Arguments
1087    ///
1088    /// * `thread_id` - The thread identifier for this conversation
1089    /// * `input` - Text to start, Resume after confirmation, or Continue after a turn
1090    /// * `tool_context` - Context passed to tools
1091    /// * `cancel_token` - Token to signal cancellation from outside
1092    /// * `options` - Execution options (tool runtime strategy, durability)
1093    ///
1094    /// # Returns
1095    ///
1096    /// A [`crate::types::TurnOutcome`] returned only after the configured event store's
1097    /// `finish_turn(thread_id, turn)` barrier has completed.
1098    ///
1099    /// Every variant except [`crate::types::TurnOutcome::Error`] carries a
1100    /// structured [`crate::types::TurnSummary`] in the `summary` field. This
1101    /// summary is the **authoritative** server-facing outcome contract —
1102    /// it contains the provider/model provenance, response ID, stop reason,
1103    /// tool-call count, duration, and execution options for the turn. Server
1104    /// code should read from `summary` rather than the legacy per-variant
1105    /// fields (`total_turns`, `input_tokens`, `output_tokens`, …), which are
1106    /// retained only for backwards compatibility with local callers.
1107    ///
1108    /// # Turn Outcomes
1109    ///
1110    /// - `NeedsMoreTurns` - Turn completed, call again with `AgentInput::Continue`
1111    /// - `Done` - Agent completed successfully
1112    /// - `AwaitingConfirmation` - Tool needs confirmation, call again with `AgentInput::Resume`
1113    /// - `PendingToolCalls` - Tools need external execution (only with `ToolRuntime::External`)
1114    /// - `Cancelled` - Turn was cancelled via the token
1115    /// - `Error` - An error occurred (no summary attached)
1116    ///
1117    /// # Example
1118    ///
1119    /// ```ignore
1120    /// use std::sync::Arc;
1121    /// use agent_sdk::{InMemoryEventStore, TurnOptions};
1122    ///
1123    /// let cancel = CancellationToken::new();
1124    /// let event_store = Arc::new(InMemoryEventStore::new());
1125    /// let outcome = agent.run_turn(
1126    ///     thread_id.clone(),
1127    ///     AgentInput::Text("What is 2+2?".to_string()),
1128    ///     tool_ctx.clone(),
1129    ///     cancel,
1130    ///     TurnOptions::default(),
1131    /// ).await;
1132    ///
1133    /// let events = event_store.get_events(&thread_id).await?;
1134    ///
1135    /// // Read server-facing metadata from the TurnSummary.
1136    /// if let Some(summary) = outcome.summary() {
1137    ///     println!(
1138    ///         "turn={} provider={} model={} stop={:?} response_id={:?}",
1139    ///         summary.turn,
1140    ///         summary.provenance.provider,
1141    ///         summary.provenance.model,
1142    ///         summary.stop_reason,
1143    ///         summary.response_id,
1144    ///     );
1145    /// }
1146    ///
1147    /// // Branch on the variant for flow control.
1148    /// match outcome {
1149    ///     TurnOutcome::NeedsMoreTurns { turn, .. } => {
1150    ///         // Dispatch another message to continue
1151    ///     }
1152    ///     TurnOutcome::Done { .. } => {
1153    ///         // Conversation complete
1154    ///     }
1155    ///     TurnOutcome::PendingToolCalls { tool_calls, .. } => {
1156    ///         // Execute tools externally, then call run_turn with Continue
1157    ///     }
1158    ///     _ => {}
1159    /// }
1160    /// ```
1161    pub async fn run_turn(
1162        &self,
1163        thread_id: ThreadId,
1164        input: AgentInput,
1165        tool_context: ToolContext<Ctx>,
1166        cancel_token: CancellationToken,
1167        options: TurnOptions,
1168    ) -> crate::types::TurnOutcome
1169    where
1170        Ctx: Clone,
1171    {
1172        self.run_turn_with_options(
1173            thread_id,
1174            input,
1175            tool_context,
1176            cancel_token,
1177            options,
1178            RunOptions::default(),
1179        )
1180        .await
1181    }
1182
1183    /// Like [`run_turn`](Self::run_turn), but with caller-supplied
1184    /// trace metadata.
1185    ///
1186    /// See [`run_with_options`](Self::run_with_options) for the full
1187    /// [`RunOptions`] contract. The `turn_options` parameter retains
1188    /// its existing semantics (tool runtime / strict durability);
1189    /// `run_options` is layered on top to populate Langfuse trace
1190    /// metadata on the root `invoke_agent` span.
1191    pub async fn run_turn_with_options(
1192        &self,
1193        thread_id: ThreadId,
1194        input: AgentInput,
1195        tool_context: ToolContext<Ctx>,
1196        cancel_token: CancellationToken,
1197        turn_options: TurnOptions,
1198        run_options: RunOptions,
1199    ) -> crate::types::TurnOutcome
1200    where
1201        Ctx: Clone,
1202    {
1203        // See `run_abortable_with_options` for why we explicitly
1204        // drop `run_options` on non-otel builds.
1205        #[cfg(not(feature = "otel"))]
1206        drop(run_options);
1207
1208        let authority = self.resolve_authority();
1209
1210        run_single_turn(TurnParameters {
1211            event_store: Arc::clone(&self.event_store),
1212            authority,
1213            thread_id,
1214            input,
1215            tool_context,
1216            provider: Arc::clone(&self.provider),
1217            tools: Arc::clone(&self.tools),
1218            hooks: Arc::clone(&self.hooks),
1219            message_store: Arc::clone(&self.message_store),
1220            state_store: Arc::clone(&self.state_store),
1221            config: self.config.clone(),
1222            compaction_config: self.compaction_config.clone(),
1223            compactor: self.compactor.clone(),
1224            execution_store: self.execution_store.clone(),
1225            audit_sink: Arc::clone(&self.audit_sink),
1226            cancel_token,
1227            turn_options,
1228            reminder_config: self.reminder_config.clone(),
1229            #[cfg(feature = "otel")]
1230            run_options,
1231            #[cfg(feature = "otel")]
1232            observability_store: self.observability_store.clone(),
1233        })
1234        .await
1235    }
1236}
1237
1238/// High-level convenience API for agents whose tools take no application
1239/// context (`Ctx = ()`).
1240///
1241/// [`ask`](Self::ask) and [`send`](Self::send) collapse the four pieces of
1242/// ceremony in the low-level [`run`](Self::run) path — constructing a
1243/// [`ToolContext::new(())`](crate::ToolContext::new), creating a
1244/// [`CancellationToken`], awaiting the run, and reassembling the assistant
1245/// text out of the event store — into a single call that returns a `String`.
1246///
1247/// Reach for [`run`](Self::run) or [`run_turn`](Self::run_turn) when you need
1248/// application context, cancellation, confirmation flow, or access to the raw
1249/// [`AgentRunState`].
1250impl<P, H, M, S> AgentLoop<(), P, H, M, S>
1251where
1252    P: LlmProvider + 'static,
1253    H: AgentHooks + 'static,
1254    M: MessageStore + 'static,
1255    S: StateStore + 'static,
1256{
1257    /// Ask the agent a question and return its assembled reply.
1258    ///
1259    /// This is the 30-second on-ramp: it builds a fresh
1260    /// [`ToolContext::new(())`](crate::ToolContext::new) and a
1261    /// [`CancellationToken`] internally, runs the agent to completion, and
1262    /// returns the assistant text emitted during this call concatenated into
1263    /// one `String`.
1264    ///
1265    /// For confirmation flows, application context, or explicit cancellation,
1266    /// use [`run`](Self::run) directly.
1267    ///
1268    /// # Errors
1269    ///
1270    /// Returns an error if the run task is dropped before reporting a state,
1271    /// if the run ends in [`AgentRunState::Error`], if it stops on a usage
1272    /// budget ([`AgentRunState::BudgetExceeded`] — the reply would be
1273    /// missing or truncated), or if the event store cannot be read back.
1274    pub async fn ask(
1275        &self,
1276        thread_id: ThreadId,
1277        text: impl Into<String>,
1278    ) -> anyhow::Result<String> {
1279        self.send(thread_id, AgentInput::Text(text.into())).await
1280    }
1281
1282    /// Send an [`AgentInput`] to the agent and return its assembled reply.
1283    ///
1284    /// Like [`ask`](Self::ask) but accepts a full [`AgentInput`] (e.g. to
1285    /// resume after confirmation). Builds the
1286    /// [`ToolContext`](crate::ToolContext) and [`CancellationToken`]
1287    /// internally and returns the assistant text emitted during this call.
1288    ///
1289    /// # Errors
1290    ///
1291    /// Returns an error if the run task is dropped before reporting a state,
1292    /// if the run ends in [`AgentRunState::Error`], if it stops on a usage
1293    /// budget ([`AgentRunState::BudgetExceeded`] — the reply would be
1294    /// missing or truncated), or if the event store cannot be read back.
1295    pub async fn send(&self, thread_id: ThreadId, input: AgentInput) -> anyhow::Result<String> {
1296        use crate::events::AgentEvent;
1297
1298        // Snapshot the existing event count so we only assemble text emitted
1299        // by this call, not earlier turns persisted on the same thread.
1300        // `event_count` + `get_events_since` avoid materializing (and cloning)
1301        // the entire thread history twice per call — repeated sends on a
1302        // long-lived thread would otherwise be O(n^2) cumulative.
1303        let baseline = self.event_store.event_count(&thread_id).await?;
1304
1305        let state = self
1306            .run(
1307                thread_id.clone(),
1308                input,
1309                ToolContext::new(()),
1310                CancellationToken::new(),
1311            )
1312            .await?;
1313
1314        match state {
1315            AgentRunState::Error(error) => return Err(anyhow::Error::new(error)),
1316            // A budget stop is not a completed answer: a pre-entry
1317            // rejection produced no text at all and a mid-run stop only
1318            // interim text — returning either as `Ok` would read as a
1319            // finished reply. Surface it as an error naming the limit.
1320            AgentRunState::BudgetExceeded {
1321                limit,
1322                estimated_cost_usd,
1323                ..
1324            } => {
1325                let cost_note = estimated_cost_usd
1326                    .map_or_else(String::new, |cost| format!(", estimated cost: ${cost:.4}"));
1327                return Err(anyhow::anyhow!(
1328                    "agent run stopped: usage budget exceeded (limit: {limit:?}{cost_note})"
1329                ));
1330            }
1331            _ => {}
1332        }
1333
1334        let events = self
1335            .event_store
1336            .get_events_since(&thread_id, baseline)
1337            .await?;
1338        let reply = events
1339            .into_iter()
1340            .filter_map(|envelope| match envelope.event {
1341                AgentEvent::Text { text, .. } => Some(text),
1342                _ => None,
1343            })
1344            .collect::<String>();
1345
1346        Ok(reply)
1347    }
1348}