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}