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}