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    /// Pricing source consulted before the static capability table when the
469    /// run's cost budget (`UsageLimits::max_cost_usd`) prices token usage.
470    /// `None` prices from the static table alone.
471    pub(super) cost_estimator: Option<Arc<dyn crate::pricing::CostEstimator>>,
472    pub(super) compaction_config: Option<CompactionConfig>,
473    pub(super) compactor: Option<Arc<dyn ContextCompactor>>,
474    pub(super) execution_store: Option<Arc<dyn ToolExecutionStore>>,
475    pub(super) audit_sink: Arc<dyn crate::hooks::ToolAuditSink>,
476    pub(super) reminder_config: Option<crate::reminders::ReminderConfig>,
477    #[cfg(feature = "otel")]
478    pub(super) observability_store: Option<Arc<dyn crate::observability::ObservabilityStore>>,
479}
480
481/// Create a new builder for constructing an `AgentLoop`.
482#[must_use]
483pub fn builder<Ctx>() -> AgentLoopBuilder<Ctx, (), (), (), ()> {
484    AgentLoopBuilder::new()
485}
486
487impl<Ctx, P, H, M, S> AgentLoop<Ctx, P, H, M, S>
488where
489    Ctx: Send + Sync + 'static,
490    P: LlmProvider + 'static,
491    H: AgentHooks + 'static,
492    M: MessageStore + 'static,
493    S: StateStore + 'static,
494{
495    /// Create a new agent loop with all components specified directly.
496    #[must_use]
497    pub fn new(
498        provider: P,
499        tools: ToolRegistry<Ctx>,
500        hooks: H,
501        message_store: M,
502        state_store: S,
503        event_store: Arc<dyn EventStore>,
504        config: AgentConfig,
505    ) -> Self {
506        Self {
507            provider: Arc::new(provider),
508            tools: Arc::new(tools),
509            hooks: Arc::new(hooks),
510            message_store: Arc::new(message_store),
511            state_store: Arc::new(state_store),
512            event_store,
513            event_authority: None,
514            config,
515            cost_estimator: None,
516            compaction_config: None,
517            compactor: None,
518            execution_store: None,
519            audit_sink: Arc::new(crate::hooks::NoopAuditSink),
520            reminder_config: None,
521            #[cfg(feature = "otel")]
522            observability_store: None,
523        }
524    }
525
526    /// Create a new agent loop with compaction enabled.
527    #[must_use]
528    pub fn with_compaction(
529        provider: P,
530        tools: ToolRegistry<Ctx>,
531        hooks: H,
532        message_store: M,
533        state_store: S,
534        event_store: Arc<dyn EventStore>,
535        config: AgentLoopCompactionConfig,
536    ) -> Self {
537        let AgentLoopCompactionConfig {
538            agent_config,
539            compaction_config,
540        } = config;
541        Self {
542            provider: Arc::new(provider),
543            tools: Arc::new(tools),
544            hooks: Arc::new(hooks),
545            message_store: Arc::new(message_store),
546            state_store: Arc::new(state_store),
547            event_store,
548            event_authority: None,
549            config: agent_config,
550            cost_estimator: None,
551            compaction_config: Some(compaction_config),
552            compactor: None,
553            execution_store: None,
554            audit_sink: Arc::new(crate::hooks::NoopAuditSink),
555            reminder_config: None,
556            #[cfg(feature = "otel")]
557            observability_store: None,
558        }
559    }
560
561    /// Set the authoritative tool audit sink.
562    ///
563    /// When set, the loop emits a [`ToolAuditRecord`](crate::advanced::ToolAuditRecord)
564    /// at every tool-lifecycle transition (blocked, requires-confirmation,
565    /// cached, replayed, invalidated, completed, persistence-failed).
566    ///
567    /// The default is [`NoopAuditSink`](crate::hooks::NoopAuditSink) which
568    /// discards every record — suitable for local/CLI usage. Servers should
569    /// swap in a durable sink.
570    #[must_use]
571    pub fn with_audit_sink(mut self, sink: impl crate::hooks::ToolAuditSink + 'static) -> Self {
572        self.audit_sink = Arc::new(sink);
573        self
574    }
575
576    /// Set the observability store for `GenAI` payload capture.
577    ///
578    /// When set, the store is called at each LLM request boundary to decide
579    /// whether payloads are inlined on spans, externalized, or omitted.
580    #[cfg(feature = "otel")]
581    #[must_use]
582    pub fn with_observability_store(
583        mut self,
584        store: impl crate::observability::ObservabilityStore + 'static,
585    ) -> Self {
586        self.observability_store = Some(Arc::new(store));
587        self
588    }
589
590    /// Resolve the event authority for this run.
591    ///
592    /// If an external authority was configured via the builder, use it.
593    /// Otherwise create a fresh [`LocalEventAuthority`] that starts at 0
594    /// (the pre-existing local/CLI behaviour).
595    fn resolve_authority(&self) -> Arc<dyn EventAuthority> {
596        self.event_authority
597            .clone()
598            .unwrap_or_else(|| Arc::new(LocalEventAuthority::new()))
599    }
600
601    /// Run the agent loop.
602    ///
603    /// This method allows the agent to pause when a tool requires confirmation,
604    /// returning an `AgentRunState::AwaitingConfirmation` that contains the
605    /// state needed to resume.
606    ///
607    /// When the `cancel_token` is cancelled, the agent interrupts in-flight
608    /// work at the SDK boundary: the LLM stream and any non-streaming LLM call
609    /// are raced against the token (see `agent_loop/llm.rs`), and in-flight
610    /// tool executions are dropped with balanced `ToolResult`s synthesized so
611    /// the conversation history stays consistent. The run then ends with
612    /// `AgentRunState::Cancelled` — cancellation is *not* deferred to a turn
613    /// boundary. Tools that hold OS resources must honour the
614    /// [cooperative-cancel contract](crate::tools::Tool#cooperative-cancellation);
615    /// see the section below.
616    ///
617    /// # Arguments
618    ///
619    /// * `thread_id` - The thread identifier for this conversation
620    /// * `input` - Either a new text message or a resume with confirmation decision
621    /// * `tool_context` - Context passed to tools
622    /// * `cancel_token` - Token to signal cancellation from outside
623    ///
624    /// # Returns
625    ///
626    /// A future that resolves to the final [`AgentRunState`]. Awaiting it
627    /// drives the run to completion:
628    ///
629    /// ```ignore
630    /// let final_state = agent.run(thread_id, input, tool_ctx, cancel).await?;
631    /// ```
632    ///
633    /// The future is `'static` — the run is already spawned on a Tokio task
634    /// before this returns, so dropping the future does **not** stop the run
635    /// (use `cancel_token`). Awaiting it only waits for the result.
636    ///
637    /// # Example
638    ///
639    /// ```ignore
640    /// let cancel = CancellationToken::new();
641    /// let final_state = agent.run(
642    ///     thread_id,
643    ///     AgentInput::Text("Hello".to_string()),
644    ///     tool_ctx,
645    ///     cancel.clone(),
646    /// ).await?;
647    ///
648    /// match final_state {
649    ///     AgentRunState::Done { .. } => { /* completed */ }
650    ///     AgentRunState::Cancelled { .. } => { /* user cancelled */ }
651    ///     AgentRunState::AwaitingConfirmation { continuation, .. } => {
652    ///         // Get user decision, then resume:
653    ///         let state2 = agent.run(
654    ///             thread_id,
655    ///             AgentInput::Resume {
656    ///                 continuation,
657    ///                 tool_call_id: id,
658    ///                 confirmed: true,
659    ///                 rejection_reason: None,
660    ///             },
661    ///             tool_ctx,
662    ///             cancel.clone(),
663    ///         ).await?;
664    ///     }
665    ///     AgentRunState::Error(e) => { /* handle error */ }
666    /// }
667    /// ```
668    /// # Cancellation, timeout, and the dropped `JoinHandle`
669    ///
670    /// `run` spawns the agent loop on a Tokio task and returns a future over
671    /// the state channel — it **drops** the task's
672    /// [`tokio::task::JoinHandle`]. Dropping a `JoinHandle` *detaches* the
673    /// task rather than aborting it, so the only ways to stop an in-flight
674    /// run are the `cancel_token` (cooperative) or the per-tool
675    /// [`AgentConfig::tool_timeout_ms`](crate::types::AgentConfig::tool_timeout_ms)
676    /// boundary. Callers that need to forcibly abort must use
677    /// [`run_abortable`](Self::run_abortable) and keep the handle.
678    ///
679    /// Because the handle is dropped, a tool that holds a subprocess open
680    /// must obey the
681    /// [cooperative-cancel contract](crate::tools::Tool#cooperative-cancellation)
682    /// (`kill_on_drop` or a token-aware `kill`) or the subprocess will
683    /// outlive the cancelled / timed-out run. A `debug!` is logged here so
684    /// the detach is visible when chasing a leaked subprocess.
685    ///
686    /// # Errors
687    ///
688    /// Returns an error only if the spawned run task is dropped before it can
689    /// report a final state (e.g. a runtime shutdown). A panic inside the run
690    /// is caught and surfaced as [`AgentRunState::Error`], not as an `Err`.
691    pub fn run(
692        &self,
693        thread_id: ThreadId,
694        input: AgentInput,
695        tool_context: ToolContext<Ctx>,
696        cancel_token: CancellationToken,
697    ) -> impl Future<Output = anyhow::Result<AgentRunState>> + Send + 'static
698    where
699        Ctx: Clone,
700    {
701        let (state_rx, handle) = self.run_abortable(thread_id, input, tool_context, cancel_token);
702        warn_on_detached_run_handle(handle);
703        recv_run_state(state_rx)
704    }
705
706    /// Like [`run`](Self::run), but with caller-supplied trace metadata.
707    ///
708    /// Equivalent to `run` except that the supplied [`RunOptions`]
709    /// configure session/user IDs (propagated as `session.id` /
710    /// `user.id` baggage), `langfuse.trace.{name,tags,metadata.*,
711    /// input,output}`, `langfuse.{release,environment}`, and the
712    /// trace-text truncation ceiling.
713    ///
714    /// Use this instead of `run` whenever the consumer needs the
715    /// SDK to populate Langfuse trace metadata; `run` itself
716    /// continues to delegate here with `RunOptions::default()`.
717    ///
718    /// # Errors
719    ///
720    /// See [`run`](Self::run).
721    pub fn run_with_options(
722        &self,
723        thread_id: ThreadId,
724        input: AgentInput,
725        tool_context: ToolContext<Ctx>,
726        cancel_token: CancellationToken,
727        run_options: RunOptions,
728    ) -> impl Future<Output = anyhow::Result<AgentRunState>> + Send + 'static
729    where
730        Ctx: Clone,
731    {
732        let (state_rx, handle) = self.run_abortable_with_options(
733            thread_id,
734            input,
735            tool_context,
736            cancel_token,
737            run_options,
738        );
739        warn_on_detached_run_handle(handle);
740        recv_run_state(state_rx)
741    }
742
743    /// Like [`run`](Self::run), but also returns the [`tokio::task::JoinHandle`] for the
744    /// spawned task.
745    ///
746    /// Callers that need to forcibly abort the agent loop (e.g. subagent
747    /// timeout) can call [`tokio::task::JoinHandle::abort`] on the returned handle.
748    /// Aborting the handle drops the in-flight LLM stream immediately
749    /// instead of waiting for the current turn to finish.
750    pub fn run_abortable(
751        &self,
752        thread_id: ThreadId,
753        input: AgentInput,
754        tool_context: ToolContext<Ctx>,
755        cancel_token: CancellationToken,
756    ) -> (
757        oneshot::Receiver<AgentRunState>,
758        tokio::task::JoinHandle<()>,
759    )
760    where
761        Ctx: Clone,
762    {
763        self.run_abortable_with_options(
764            thread_id,
765            input,
766            tool_context,
767            cancel_token,
768            RunOptions::default(),
769        )
770    }
771
772    /// Like [`run_abortable`](Self::run_abortable), but with
773    /// caller-supplied trace metadata. See [`run_with_options`](Self::run_with_options).
774    pub fn run_abortable_with_options(
775        &self,
776        thread_id: ThreadId,
777        input: AgentInput,
778        tool_context: ToolContext<Ctx>,
779        cancel_token: CancellationToken,
780        run_options: RunOptions,
781    ) -> (
782        oneshot::Receiver<AgentRunState>,
783        tokio::task::JoinHandle<()>,
784    )
785    where
786        Ctx: Clone,
787    {
788        self.spawn_run_loop(SpawnRunLoopParams {
789            event_store: Arc::clone(&self.event_store),
790            thread_id,
791            input,
792            tool_context,
793            cancel_token,
794            run_options,
795            input_rx: None,
796            run_completed: None,
797        })
798    }
799
800    /// Spawn the run loop on a Tokio task and return its state channel +
801    /// join handle.
802    ///
803    /// Shared by [`run_abortable_with_options`](Self::run_abortable_with_options),
804    /// [`run_persistent_with_options`](Self::run_persistent_with_options), and
805    /// [`run_stream_with_options`](Self::run_stream_with_options) so all three
806    /// build [`RunLoopParameters`] identically. The `event_store` is a
807    /// parameter (not always `self.event_store`) so the streaming path can
808    /// inject a teeing decorator.
809    fn spawn_run_loop(
810        &self,
811        SpawnRunLoopParams {
812            event_store,
813            thread_id,
814            input,
815            tool_context,
816            cancel_token,
817            run_options,
818            input_rx,
819            run_completed,
820        }: SpawnRunLoopParams<Ctx>,
821    ) -> (
822        oneshot::Receiver<AgentRunState>,
823        tokio::task::JoinHandle<()>,
824    )
825    where
826        Ctx: Clone,
827    {
828        // `run_options` only feeds OTel root-span metadata. On
829        // non-otel builds the value is genuinely not needed —
830        // explicitly drop it so the unused-variable / needless-pass
831        // lints stay quiet without us reaching for an
832        // `#[allow(...)]`.
833        #[cfg(not(feature = "otel"))]
834        drop(run_options);
835
836        let (state_tx, state_rx) = oneshot::channel();
837        let authority = self.resolve_authority();
838
839        let provider = Arc::clone(&self.provider);
840        let tools = Arc::clone(&self.tools);
841        let hooks = Arc::clone(&self.hooks);
842        let message_store = Arc::clone(&self.message_store);
843        let state_store = Arc::clone(&self.state_store);
844        let config = self.config.clone();
845        let cost_estimator = self.cost_estimator.clone();
846        let compaction_config = self.compaction_config.clone();
847        let compactor = self.compactor.clone();
848        let execution_store = self.execution_store.clone();
849        let audit_sink = Arc::clone(&self.audit_sink);
850        let reminder_config = self.reminder_config.clone();
851        #[cfg(feature = "otel")]
852        let observability_store = self.observability_store.clone();
853        #[cfg(feature = "otel")]
854        let parent_cx = crate::observability::context::capture_context();
855
856        let task = async move {
857            let result = run_loop_isolated(RunLoopParameters {
858                event_store,
859                authority,
860                thread_id,
861                input,
862                tool_context,
863                provider,
864                tools,
865                hooks,
866                message_store,
867                state_store,
868                config,
869                cost_estimator,
870                compaction_config,
871                compactor,
872                execution_store,
873                audit_sink,
874                cancel_token,
875                input_rx,
876                reminder_config,
877                #[cfg(feature = "otel")]
878                run_options,
879                #[cfg(feature = "otel")]
880                observability_store,
881            })
882            .await;
883
884            // Signal run completion BEFORE handing over the final state:
885            // every event the run emitted has already been persisted and
886            // forwarded (the tee runs inside the loop), so a consumer that
887            // observes `final_state` can rely on the event stream
888            // terminating even if a tool leaked a ToolContext clone that
889            // still holds the tee's sender.
890            if let Some(run_completed) = run_completed {
891                run_completed.cancel();
892            }
893            let _ = state_tx.send(result);
894        };
895
896        #[cfg(feature = "otel")]
897        let task = {
898            use opentelemetry::trace::FutureExt;
899            task.with_context(parent_cx)
900        };
901
902        let handle = tokio::spawn(task);
903
904        (state_rx, handle)
905    }
906
907    /// Run the agent with a persistent input channel.
908    ///
909    /// Unlike [`Self::run`], this returns an [`AgentHandle`] that allows the caller
910    /// to inject new user messages into the running agent via `input_tx`.
911    /// The agent will process the initial input, then wait for new messages
912    /// on the channel between turns instead of exiting on `Done`.
913    ///
914    /// The agent exits when:
915    /// - The `input_tx` sender is dropped (no more messages) — reports `Done`
916    /// - The `cancel_token` is cancelled — reports `Cancelled`
917    /// - Max turns exceeded — reports `Error`
918    /// - An unsupported input variant is injected, or appending an injected
919    ///   message fails — reports `Error` (see [`AgentHandle::input_tx`])
920    pub fn run_persistent(
921        &self,
922        thread_id: ThreadId,
923        input: AgentInput,
924        tool_context: ToolContext<Ctx>,
925        cancel_token: CancellationToken,
926    ) -> AgentHandle
927    where
928        Ctx: Clone,
929    {
930        self.run_persistent_with_options(
931            thread_id,
932            input,
933            tool_context,
934            cancel_token,
935            RunOptions::default(),
936        )
937    }
938
939    /// Like [`run_persistent`](Self::run_persistent), but with
940    /// caller-supplied trace metadata. See
941    /// [`run_with_options`](Self::run_with_options).
942    pub fn run_persistent_with_options(
943        &self,
944        thread_id: ThreadId,
945        input: AgentInput,
946        tool_context: ToolContext<Ctx>,
947        cancel_token: CancellationToken,
948        run_options: RunOptions,
949    ) -> AgentHandle
950    where
951        Ctx: Clone,
952    {
953        let (input_tx, input_rx) = mpsc::channel(32);
954        let cancel_handle = cancel_token.clone();
955
956        let (state_rx, handle) = self.spawn_run_loop(SpawnRunLoopParams {
957            event_store: Arc::clone(&self.event_store),
958            thread_id,
959            input,
960            tool_context,
961            cancel_token,
962            run_options,
963            input_rx: Some(input_rx),
964            run_completed: None,
965        });
966        // The persistent run is stopped via the cancel token or by dropping
967        // `input_tx`, not by aborting the task, so detach the handle.
968        drop(handle);
969
970        AgentHandle {
971            input_tx,
972            state_rx,
973            cancel_token: cancel_handle,
974        }
975    }
976
977    /// Stream the agent's [`AgentEvent`]s live as they are emitted.
978    ///
979    /// Returns a [`RunStream`]: a live event feed plus a handle to the
980    /// run's final [`AgentRunState`]. [`RunStream::events`] yields each
981    /// [`AgentEvent`] the moment the run loop writes it to the event store,
982    /// so callers consume events in real time without implementing an
983    /// [`EventStore`]. The same events are still persisted to the loop's
984    /// configured store — the stream is an additional tee, not a
985    /// replacement. [`RunStream::final_state`] resolves once when the run
986    /// ends; it is the only place the terminal state lives, including
987    /// [`AgentRunState::AwaitingConfirmation`] (whose continuation is
988    /// required to resume) and startup errors that occur before any event
989    /// is emitted.
990    ///
991    /// The run is spawned on a Tokio task before this returns; the stream
992    /// ends when the run finishes (or is cancelled via `cancel_token`) —
993    /// termination does not depend on tools dropping their [`ToolContext`]
994    /// clones (see [`RunEventStream`]). Dropping the stream does **not**
995    /// stop the run — use `cancel_token`.
996    ///
997    /// # Delivery semantics
998    ///
999    /// Events reach the stream only **after** their durable append to the
1000    /// event store succeeds, so the stream never shows an event the store
1001    /// rejected. The stream is bounded and lossy under backpressure: when
1002    /// the buffer is full, non-terminal events are dropped from the live
1003    /// feed (they remain in the store). Terminal events (`Done`,
1004    /// `BudgetExceeded`, `Cancelled`, `Refusal`) are special-cased — the
1005    /// run waits up to one second for buffer space so a slow-but-live
1006    /// consumer still receives the closing marker, while a consumer that
1007    /// never reads cannot stall the run beyond that bound. Callers that
1008    /// need every event must read the configured [`EventStore`] back.
1009    ///
1010    /// This is additive alongside [`run`](Self::run) /
1011    /// [`run_persistent`](Self::run_persistent): use it when you want a
1012    /// live event feed without reading the store back.
1013    pub fn run_stream(
1014        &self,
1015        thread_id: ThreadId,
1016        input: AgentInput,
1017        tool_context: ToolContext<Ctx>,
1018        cancel_token: CancellationToken,
1019    ) -> RunStream
1020    where
1021        Ctx: Clone,
1022    {
1023        self.run_stream_with_options(
1024            thread_id,
1025            input,
1026            tool_context,
1027            cancel_token,
1028            RunOptions::default(),
1029        )
1030    }
1031
1032    /// Like [`run_stream`](Self::run_stream), but with caller-supplied trace
1033    /// metadata. See [`run_with_options`](Self::run_with_options).
1034    pub fn run_stream_with_options(
1035        &self,
1036        thread_id: ThreadId,
1037        input: AgentInput,
1038        tool_context: ToolContext<Ctx>,
1039        cancel_token: CancellationToken,
1040        run_options: RunOptions,
1041    ) -> RunStream
1042    where
1043        Ctx: Clone,
1044    {
1045        let (tx, rx) = mpsc::channel(RUN_STREAM_CHANNEL_CAPACITY);
1046        let event_store: Arc<dyn EventStore> = Arc::new(TeeEventStore {
1047            inner: Arc::clone(&self.event_store),
1048            tx,
1049        });
1050
1051        // Completion signal for the event stream: tool contexts carry the
1052        // tee (and thus a channel sender), so channel EOF alone cannot be
1053        // relied on — a tool may leak a ToolContext clone into background
1054        // work. See `RunEventStream` for the termination contract.
1055        let run_completed = CancellationToken::new();
1056
1057        let (state_rx, handle) = self.spawn_run_loop(SpawnRunLoopParams {
1058            event_store,
1059            thread_id,
1060            input,
1061            tool_context,
1062            cancel_token,
1063            run_options,
1064            input_rx: None,
1065            run_completed: Some(run_completed.clone()),
1066        });
1067        // The run drives itself to completion. Detach the join handle —
1068        // when the run ends, the completion signal (or the tee sender
1069        // dropping) closes the stream — and hand the state receiver to the
1070        // caller: the terminal state (AwaitingConfirmation continuations,
1071        // startup errors) only exists there.
1072        warn_on_detached_run_handle(handle);
1073
1074        RunStream {
1075            events: RunEventStream {
1076                rx,
1077                run_completed: Box::pin(run_completed.cancelled_owned()),
1078                draining: false,
1079            },
1080            final_state: state_rx,
1081        }
1082    }
1083
1084    /// Run a single turn of the agent loop — the authoritative server boundary.
1085    ///
1086    /// Unlike `run()`, this method executes exactly one turn **directly in the
1087    /// caller's task** (no `tokio::spawn`) and returns the result inline. This
1088    /// enables external orchestration where each turn can be dispatched as a
1089    /// separate message (e.g., via Artemis or another message queue).
1090    ///
1091    /// When the `cancel_token` is cancelled, the turn will be aborted before
1092    /// starting execution and return `TurnOutcome::Cancelled`.
1093    ///
1094    /// # Arguments
1095    ///
1096    /// * `thread_id` - The thread identifier for this conversation
1097    /// * `input` - Text to start, Resume after confirmation, or Continue after a turn
1098    /// * `tool_context` - Context passed to tools
1099    /// * `cancel_token` - Token to signal cancellation from outside
1100    /// * `options` - Execution options (tool runtime strategy, durability)
1101    ///
1102    /// # Returns
1103    ///
1104    /// A [`crate::types::TurnOutcome`] returned only after the configured event store's
1105    /// `finish_turn(thread_id, turn)` barrier has completed.
1106    ///
1107    /// Every variant except [`crate::types::TurnOutcome::Error`] carries a
1108    /// structured [`crate::types::TurnSummary`] in the `summary` field. This
1109    /// summary is the **authoritative** server-facing outcome contract —
1110    /// it contains the provider/model provenance, response ID, stop reason,
1111    /// tool-call count, duration, and execution options for the turn. Server
1112    /// code should read from `summary` rather than the legacy per-variant
1113    /// fields (`total_turns`, `input_tokens`, `output_tokens`, …), which are
1114    /// retained only for backwards compatibility with local callers.
1115    ///
1116    /// # Turn Outcomes
1117    ///
1118    /// - `NeedsMoreTurns` - Turn completed, call again with `AgentInput::Continue`
1119    /// - `Done` - Agent completed successfully
1120    /// - `AwaitingConfirmation` - Tool needs confirmation, call again with `AgentInput::Resume`
1121    /// - `PendingToolCalls` - Tools need external execution (only with `ToolRuntime::External`)
1122    /// - `Cancelled` - Turn was cancelled via the token
1123    /// - `Error` - An error occurred (no summary attached)
1124    ///
1125    /// # Example
1126    ///
1127    /// ```ignore
1128    /// use std::sync::Arc;
1129    /// use agent_sdk::{InMemoryEventStore, TurnOptions};
1130    ///
1131    /// let cancel = CancellationToken::new();
1132    /// let event_store = Arc::new(InMemoryEventStore::new());
1133    /// let outcome = agent.run_turn(
1134    ///     thread_id.clone(),
1135    ///     AgentInput::Text("What is 2+2?".to_string()),
1136    ///     tool_ctx.clone(),
1137    ///     cancel,
1138    ///     TurnOptions::default(),
1139    /// ).await;
1140    ///
1141    /// let events = event_store.get_events(&thread_id).await?;
1142    ///
1143    /// // Read server-facing metadata from the TurnSummary.
1144    /// if let Some(summary) = outcome.summary() {
1145    ///     println!(
1146    ///         "turn={} provider={} model={} stop={:?} response_id={:?}",
1147    ///         summary.turn,
1148    ///         summary.provenance.provider,
1149    ///         summary.provenance.model,
1150    ///         summary.stop_reason,
1151    ///         summary.response_id,
1152    ///     );
1153    /// }
1154    ///
1155    /// // Branch on the variant for flow control.
1156    /// match outcome {
1157    ///     TurnOutcome::NeedsMoreTurns { turn, .. } => {
1158    ///         // Dispatch another message to continue
1159    ///     }
1160    ///     TurnOutcome::Done { .. } => {
1161    ///         // Conversation complete
1162    ///     }
1163    ///     TurnOutcome::PendingToolCalls { tool_calls, .. } => {
1164    ///         // Execute tools externally, then call run_turn with Continue
1165    ///     }
1166    ///     _ => {}
1167    /// }
1168    /// ```
1169    pub async fn run_turn(
1170        &self,
1171        thread_id: ThreadId,
1172        input: AgentInput,
1173        tool_context: ToolContext<Ctx>,
1174        cancel_token: CancellationToken,
1175        options: TurnOptions,
1176    ) -> crate::types::TurnOutcome
1177    where
1178        Ctx: Clone,
1179    {
1180        self.run_turn_with_options(
1181            thread_id,
1182            input,
1183            tool_context,
1184            cancel_token,
1185            options,
1186            RunOptions::default(),
1187        )
1188        .await
1189    }
1190
1191    /// Like [`run_turn`](Self::run_turn), but with caller-supplied
1192    /// trace metadata.
1193    ///
1194    /// See [`run_with_options`](Self::run_with_options) for the full
1195    /// [`RunOptions`] contract. The `turn_options` parameter retains
1196    /// its existing semantics (tool runtime / strict durability);
1197    /// `run_options` is layered on top to populate Langfuse trace
1198    /// metadata on the root `invoke_agent` span.
1199    pub async fn run_turn_with_options(
1200        &self,
1201        thread_id: ThreadId,
1202        input: AgentInput,
1203        tool_context: ToolContext<Ctx>,
1204        cancel_token: CancellationToken,
1205        turn_options: TurnOptions,
1206        run_options: RunOptions,
1207    ) -> crate::types::TurnOutcome
1208    where
1209        Ctx: Clone,
1210    {
1211        // See `run_abortable_with_options` for why we explicitly
1212        // drop `run_options` on non-otel builds.
1213        #[cfg(not(feature = "otel"))]
1214        drop(run_options);
1215
1216        let authority = self.resolve_authority();
1217
1218        run_single_turn(TurnParameters {
1219            event_store: Arc::clone(&self.event_store),
1220            authority,
1221            thread_id,
1222            input,
1223            tool_context,
1224            provider: Arc::clone(&self.provider),
1225            tools: Arc::clone(&self.tools),
1226            hooks: Arc::clone(&self.hooks),
1227            message_store: Arc::clone(&self.message_store),
1228            state_store: Arc::clone(&self.state_store),
1229            config: self.config.clone(),
1230            cost_estimator: self.cost_estimator.clone(),
1231            compaction_config: self.compaction_config.clone(),
1232            compactor: self.compactor.clone(),
1233            execution_store: self.execution_store.clone(),
1234            audit_sink: Arc::clone(&self.audit_sink),
1235            cancel_token,
1236            turn_options,
1237            reminder_config: self.reminder_config.clone(),
1238            #[cfg(feature = "otel")]
1239            run_options,
1240            #[cfg(feature = "otel")]
1241            observability_store: self.observability_store.clone(),
1242        })
1243        .await
1244    }
1245}
1246
1247/// High-level convenience API for agents whose tools take no application
1248/// context (`Ctx = ()`).
1249///
1250/// [`ask`](Self::ask) and [`send`](Self::send) collapse the four pieces of
1251/// ceremony in the low-level [`run`](Self::run) path — constructing a
1252/// [`ToolContext::new(())`](crate::ToolContext::new), creating a
1253/// [`CancellationToken`], awaiting the run, and reassembling the assistant
1254/// text out of the event store — into a single call that returns a `String`.
1255///
1256/// Reach for [`run`](Self::run) or [`run_turn`](Self::run_turn) when you need
1257/// application context, cancellation, confirmation flow, or access to the raw
1258/// [`AgentRunState`].
1259impl<P, H, M, S> AgentLoop<(), P, H, M, S>
1260where
1261    P: LlmProvider + 'static,
1262    H: AgentHooks + 'static,
1263    M: MessageStore + 'static,
1264    S: StateStore + 'static,
1265{
1266    /// Ask the agent a question and return its assembled reply.
1267    ///
1268    /// This is the 30-second on-ramp: it builds a fresh
1269    /// [`ToolContext::new(())`](crate::ToolContext::new) and a
1270    /// [`CancellationToken`] internally, runs the agent to completion, and
1271    /// returns the assistant text emitted during this call concatenated into
1272    /// one `String`.
1273    ///
1274    /// For confirmation flows, application context, or explicit cancellation,
1275    /// use [`run`](Self::run) directly.
1276    ///
1277    /// # Errors
1278    ///
1279    /// Returns an error if the run task is dropped before reporting a state,
1280    /// if the run ends in [`AgentRunState::Error`], if it stops on a usage
1281    /// budget ([`AgentRunState::BudgetExceeded`] — the reply would be
1282    /// missing or truncated), or if the event store cannot be read back.
1283    pub async fn ask(
1284        &self,
1285        thread_id: ThreadId,
1286        text: impl Into<String>,
1287    ) -> anyhow::Result<String> {
1288        self.send(thread_id, AgentInput::Text(text.into())).await
1289    }
1290
1291    /// Send an [`AgentInput`] to the agent and return its assembled reply.
1292    ///
1293    /// Like [`ask`](Self::ask) but accepts a full [`AgentInput`] (e.g. to
1294    /// resume after confirmation). Builds the
1295    /// [`ToolContext`](crate::ToolContext) and [`CancellationToken`]
1296    /// internally and returns the assistant text emitted during this call.
1297    ///
1298    /// # Errors
1299    ///
1300    /// Returns an error if the run task is dropped before reporting a state,
1301    /// if the run ends in [`AgentRunState::Error`], if it stops on a usage
1302    /// budget ([`AgentRunState::BudgetExceeded`] — the reply would be
1303    /// missing or truncated), or if the event store cannot be read back.
1304    pub async fn send(&self, thread_id: ThreadId, input: AgentInput) -> anyhow::Result<String> {
1305        use crate::events::AgentEvent;
1306
1307        // Snapshot the existing event count so we only assemble text emitted
1308        // by this call, not earlier turns persisted on the same thread.
1309        // `event_count` + `get_events_since` avoid materializing (and cloning)
1310        // the entire thread history twice per call — repeated sends on a
1311        // long-lived thread would otherwise be O(n^2) cumulative.
1312        let baseline = self.event_store.event_count(&thread_id).await?;
1313
1314        let state = self
1315            .run(
1316                thread_id.clone(),
1317                input,
1318                ToolContext::new(()),
1319                CancellationToken::new(),
1320            )
1321            .await?;
1322
1323        match state {
1324            AgentRunState::Error(error) => return Err(anyhow::Error::new(error)),
1325            // A budget stop is not a completed answer: a pre-entry
1326            // rejection produced no text at all and a mid-run stop only
1327            // interim text — returning either as `Ok` would read as a
1328            // finished reply. Surface it as an error naming the limit.
1329            AgentRunState::BudgetExceeded {
1330                limit,
1331                estimated_cost_usd,
1332                ..
1333            } => {
1334                let cost_note = estimated_cost_usd
1335                    .map_or_else(String::new, |cost| format!(", estimated cost: ${cost:.4}"));
1336                return Err(anyhow::anyhow!(
1337                    "agent run stopped: usage budget exceeded (limit: {limit:?}{cost_note})"
1338                ));
1339            }
1340            _ => {}
1341        }
1342
1343        let events = self
1344            .event_store
1345            .get_events_since(&thread_id, baseline)
1346            .await?;
1347        let reply = events
1348            .into_iter()
1349            .filter_map(|envelope| match envelope.event {
1350                AgentEvent::Text { text, .. } => Some(text),
1351                _ => None,
1352            })
1353            .collect::<String>();
1354
1355        Ok(reply)
1356    }
1357}