Skip to main content

agent_sdk/agent_loop/
builder.rs

1use crate::authority::EventAuthority;
2use crate::context::{CompactionConfig, ContextCompactor};
3use crate::hooks::{AgentHooks, DefaultHooks};
4use crate::llm::LlmProvider;
5use crate::pricing::CostEstimator;
6#[cfg(feature = "skills")]
7use crate::skills::Skill;
8use crate::stores::{EventStore, InMemoryStore, MessageStore, StateStore, ToolExecutionStore};
9use crate::tools::ToolRegistry;
10use crate::types::AgentConfig;
11use std::sync::Arc;
12
13use super::AgentLoop;
14
15/// Builder for constructing an `AgentLoop`.
16///
17/// # Example
18///
19/// ```ignore
20/// let agent = AgentLoop::builder()
21///     .provider(my_provider)
22///     .tools(my_tools)
23///     .config(AgentConfig::default())
24///     .build();
25/// ```
26pub struct AgentLoopBuilder<Ctx, P, H, M, S> {
27    // `provider` / `hooks` / `message_store` / `state_store` are stored as the
28    // bare generic type, not `Option<_>`: the type-transitioning setters
29    // ([`provider`](Self::provider), [`hooks`](Self::hooks),
30    // [`message_store`](Self::message_store), [`state_store`](Self::state_store))
31    // move the value in and flip the corresponding type parameter from the
32    // unset `()` to the concrete type. The build methods are only reachable
33    // once those parameters satisfy their trait bounds, so the values are
34    // always present — there is no runtime "not set" state to guard against.
35    provider: P,
36    tools: Option<ToolRegistry<Ctx>>,
37    hooks: H,
38    message_store: M,
39    state_store: S,
40    event_store: Option<Arc<dyn EventStore>>,
41    event_authority: Option<Arc<dyn EventAuthority>>,
42    config: Option<AgentConfig>,
43    cost_estimator: Option<Arc<dyn CostEstimator>>,
44    compaction_config: Option<CompactionConfig>,
45    compactor: Option<Arc<dyn ContextCompactor>>,
46    execution_store: Option<Arc<dyn ToolExecutionStore>>,
47    audit_sink: Option<Arc<dyn crate::hooks::ToolAuditSink>>,
48    reminder_config: Option<crate::reminders::ReminderConfig>,
49    #[cfg(feature = "otel")]
50    observability_store: Option<Arc<dyn crate::observability::ObservabilityStore>>,
51}
52
53impl<Ctx> AgentLoopBuilder<Ctx, (), (), (), ()> {
54    /// Create a new builder with no components set.
55    #[must_use]
56    pub fn new() -> Self {
57        Self {
58            provider: (),
59            tools: None,
60            hooks: (),
61            message_store: (),
62            state_store: (),
63            event_store: None,
64            event_authority: None,
65            config: None,
66            cost_estimator: None,
67            compaction_config: None,
68            compactor: None,
69            execution_store: None,
70            audit_sink: None,
71            reminder_config: None,
72            #[cfg(feature = "otel")]
73            observability_store: None,
74        }
75    }
76}
77
78impl<Ctx> Default for AgentLoopBuilder<Ctx, (), (), (), ()> {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl<Ctx, P, H, M, S> AgentLoopBuilder<Ctx, P, H, M, S> {
85    /// Set the LLM provider.
86    #[must_use]
87    pub fn provider<P2: LlmProvider>(self, provider: P2) -> AgentLoopBuilder<Ctx, P2, H, M, S> {
88        AgentLoopBuilder {
89            provider,
90            tools: self.tools,
91            hooks: self.hooks,
92            message_store: self.message_store,
93            state_store: self.state_store,
94            event_store: self.event_store,
95            event_authority: self.event_authority,
96            config: self.config,
97            cost_estimator: self.cost_estimator,
98            compaction_config: self.compaction_config,
99            compactor: self.compactor,
100            execution_store: self.execution_store,
101            audit_sink: self.audit_sink,
102            reminder_config: self.reminder_config,
103            #[cfg(feature = "otel")]
104            observability_store: self.observability_store,
105        }
106    }
107
108    /// Set the tool registry.
109    #[must_use]
110    pub fn tools(mut self, tools: ToolRegistry<Ctx>) -> Self {
111        self.tools = Some(tools);
112        self
113    }
114
115    /// Set the agent hooks.
116    #[must_use]
117    pub fn hooks<H2: AgentHooks>(self, hooks: H2) -> AgentLoopBuilder<Ctx, P, H2, M, S> {
118        AgentLoopBuilder {
119            provider: self.provider,
120            tools: self.tools,
121            hooks,
122            message_store: self.message_store,
123            state_store: self.state_store,
124            event_store: self.event_store,
125            event_authority: self.event_authority,
126            config: self.config,
127            cost_estimator: self.cost_estimator,
128            compaction_config: self.compaction_config,
129            compactor: self.compactor,
130            execution_store: self.execution_store,
131            audit_sink: self.audit_sink,
132            reminder_config: self.reminder_config,
133            #[cfg(feature = "otel")]
134            observability_store: self.observability_store,
135        }
136    }
137
138    /// Set the message store.
139    #[must_use]
140    pub fn message_store<M2: MessageStore>(
141        self,
142        message_store: M2,
143    ) -> AgentLoopBuilder<Ctx, P, H, M2, S> {
144        AgentLoopBuilder {
145            provider: self.provider,
146            tools: self.tools,
147            hooks: self.hooks,
148            message_store,
149            state_store: self.state_store,
150            event_store: self.event_store,
151            event_authority: self.event_authority,
152            config: self.config,
153            cost_estimator: self.cost_estimator,
154            compaction_config: self.compaction_config,
155            compactor: self.compactor,
156            execution_store: self.execution_store,
157            audit_sink: self.audit_sink,
158            reminder_config: self.reminder_config,
159            #[cfg(feature = "otel")]
160            observability_store: self.observability_store,
161        }
162    }
163
164    /// Set the state store.
165    #[must_use]
166    pub fn state_store<S2: StateStore>(
167        self,
168        state_store: S2,
169    ) -> AgentLoopBuilder<Ctx, P, H, M, S2> {
170        AgentLoopBuilder {
171            provider: self.provider,
172            tools: self.tools,
173            hooks: self.hooks,
174            message_store: self.message_store,
175            state_store,
176            event_store: self.event_store,
177            event_authority: self.event_authority,
178            config: self.config,
179            cost_estimator: self.cost_estimator,
180            compaction_config: self.compaction_config,
181            compactor: self.compactor,
182            execution_store: self.execution_store,
183            audit_sink: self.audit_sink,
184            reminder_config: self.reminder_config,
185            #[cfg(feature = "otel")]
186            observability_store: self.observability_store,
187        }
188    }
189
190    /// Set the authoritative event store for the loop lifecycle.
191    #[must_use]
192    pub fn event_store(mut self, store: Arc<dyn EventStore>) -> Self {
193        self.event_store = Some(store);
194        self
195    }
196
197    /// Set the event authority for envelope creation.
198    ///
199    /// When set, the authority governs how events are wrapped in envelopes
200    /// (sequence numbers, event IDs, timestamps).  In server mode the
201    /// authority seeds sequences from durable storage so ordering is
202    /// continuous across turns within a thread.
203    ///
204    /// When not set, a fresh [`LocalEventAuthority`](crate::authority::LocalEventAuthority)
205    /// starting at sequence 0 is created for each run.
206    #[must_use]
207    pub fn event_authority(mut self, authority: Arc<dyn EventAuthority>) -> Self {
208        self.event_authority = Some(authority);
209        self
210    }
211
212    /// Set the execution store for tool idempotency.
213    ///
214    /// When set, tool executions will be tracked using a write-ahead pattern:
215    /// 1. Record execution intent BEFORE calling the tool
216    /// 2. Update with result AFTER completion
217    /// 3. On retry, return cached result if execution already completed
218    ///
219    /// # Example
220    ///
221    /// ```ignore
222    /// use agent_sdk::{builder, stores::InMemoryExecutionStore};
223    ///
224    /// let agent = builder()
225    ///     .provider(my_provider)
226    ///     .execution_store(InMemoryExecutionStore::new())
227    ///     .build();
228    /// ```
229    #[must_use]
230    pub fn execution_store(mut self, store: impl ToolExecutionStore + 'static) -> Self {
231        self.execution_store = Some(Arc::new(store));
232        self
233    }
234
235    /// Set the execution store from a shared `Arc`.
236    ///
237    /// Use this when the caller needs to retain a handle to the store
238    /// (for inspection, pre-population, or sharing across loops). See
239    /// [`Self::execution_store`] for the standard owned form.
240    #[must_use]
241    pub fn execution_store_shared(mut self, store: Arc<dyn ToolExecutionStore>) -> Self {
242        self.execution_store = Some(store);
243        self
244    }
245
246    /// Set the authoritative tool audit sink.
247    ///
248    /// When set, the agent loop emits a
249    /// [`ToolAuditRecord`](crate::advanced::ToolAuditRecord) at every tool
250    /// lifecycle transition — blocked, requires-confirmation, cached,
251    /// replayed, invalidated, completed, and persistence-failed. This
252    /// gives servers a complete audit trail without relying on the weaker
253    /// `post_tool_use` hook.
254    ///
255    /// Defaults to [`NoopAuditSink`](crate::hooks::NoopAuditSink) when
256    /// not set.
257    #[must_use]
258    pub fn audit_sink(mut self, sink: impl crate::hooks::ToolAuditSink + 'static) -> Self {
259        self.audit_sink = Some(Arc::new(sink));
260        self
261    }
262
263    /// Set the audit sink from a shared `Arc`.
264    ///
265    /// Use this when the caller needs to retain a handle to the sink
266    /// (e.g. to inspect captured records from tests, or to share a
267    /// single durable sink across multiple agent loops). Passing an
268    /// `Arc<dyn ToolAuditSink>` here avoids the `Arc<Arc<S>>` double
269    /// wrap that happens when callers `Arc::clone(&sink)` a sink they
270    /// already wrapped and hand it to [`Self::audit_sink`].
271    ///
272    /// See [`Self::audit_sink`] for the standard owned form.
273    #[must_use]
274    pub fn audit_sink_shared(mut self, sink: Arc<dyn crate::hooks::ToolAuditSink>) -> Self {
275        self.audit_sink = Some(sink);
276        self
277    }
278
279    /// Set the observability store for `GenAI` payload capture.
280    #[cfg(feature = "otel")]
281    #[must_use]
282    pub fn observability_store(
283        mut self,
284        store: impl crate::observability::ObservabilityStore + 'static,
285    ) -> Self {
286        self.observability_store = Some(Arc::new(store));
287        self
288    }
289
290    /// Set the agent configuration.
291    #[must_use]
292    pub fn config(mut self, config: AgentConfig) -> Self {
293        self.config = Some(config);
294        self
295    }
296
297    /// Set the pricing source for the run's cost budget
298    /// ([`UsageLimits::max_cost_usd`](crate::types::UsageLimits::max_cost_usd)).
299    ///
300    /// The loop offers the estimator every key an LLM call could be filed
301    /// under — the provenance pair, the canonical backend of a
302    /// transport-specific provider name, and the route/vendor keys of a
303    /// vendor-slug model id — before it consults the static
304    /// [`model_capabilities`](crate::model_capabilities) table under any of
305    /// them. So a model priced only by a dynamic catalog accrues cost and can
306    /// trip the limit, and a catalog's fresher price wins over a compiled-in
307    /// rate even for a model both sources know. The static table remains the
308    /// fallback whenever the estimator prices none of the keys. Without an
309    /// estimator (the default) pricing comes from the static table alone.
310    ///
311    /// The `model-discovery` feature implements
312    /// [`CostEstimator`](crate::pricing::CostEstimator) for
313    /// [`ModelRegistry`](agent_sdk_providers::ModelRegistry), so a
314    /// feed-refreshed catalog can be passed straight in:
315    ///
316    /// ```ignore
317    /// let catalog = Arc::new(ModelRegistry::new());
318    /// catalog.refresh(&ModelsDevSource::default()).await?;
319    ///
320    /// let agent = builder()
321    ///     .provider(provider)
322    ///     .cost_estimator(catalog)
323    ///     .build();
324    /// ```
325    #[must_use]
326    pub fn cost_estimator(mut self, cost_estimator: Arc<dyn CostEstimator>) -> Self {
327        self.cost_estimator = Some(cost_estimator);
328        self
329    }
330
331    /// Enable per-tool system reminders.
332    ///
333    /// When set, the agent loop evaluates the configuration's
334    /// [`tool_reminders`](crate::reminders::ReminderConfig::tool_reminders)
335    /// after each tool executes and appends any reminder whose
336    /// [`ReminderTrigger`](crate::reminders::ReminderTrigger) matches
337    /// (wrapped in `<system-reminder>` tags) to the tool result the model
338    /// reads on the next turn.
339    ///
340    /// # Example
341    ///
342    /// ```ignore
343    /// use agent_sdk::{builder, reminders::{ReminderConfig, ToolReminder}};
344    ///
345    /// let reminders = ReminderConfig::new()
346    ///     .with_tool_reminder("write", ToolReminder::always(
347    ///         "Consider reading the file back to verify the content.",
348    ///     ));
349    /// let agent = builder()
350    ///     .provider(my_provider)
351    ///     .with_reminders(reminders)
352    ///     .build();
353    /// ```
354    #[must_use]
355    pub fn with_reminders(mut self, reminders: crate::reminders::ReminderConfig) -> Self {
356        self.reminder_config = Some(reminders);
357        self
358    }
359
360    /// Enable context compaction with the given configuration.
361    ///
362    /// When enabled, the agent will automatically compact conversation history
363    /// when it exceeds the configured token threshold.
364    ///
365    /// # Example
366    ///
367    /// ```ignore
368    /// use agent_sdk::{builder, context::CompactionConfig};
369    ///
370    /// let agent = builder()
371    ///     .provider(my_provider)
372    ///     .with_compaction(CompactionConfig::default())
373    ///     .build();
374    /// ```
375    #[must_use]
376    pub const fn with_compaction(mut self, config: CompactionConfig) -> Self {
377        self.compaction_config = Some(config);
378        self
379    }
380
381    /// Enable context compaction with default settings.
382    ///
383    /// This is a convenience method equivalent to:
384    /// ```ignore
385    /// builder.with_compaction(CompactionConfig::default())
386    /// ```
387    #[must_use]
388    pub fn with_auto_compaction(self) -> Self {
389        self.with_compaction(CompactionConfig::default())
390    }
391
392    /// Override the default compactor with a custom implementation.
393    ///
394    /// **Guardrail and pricing boundary:** the loop only wires its
395    /// `pre_llm_request` / `on_llm_response` guardrail hooks into the
396    /// compactor it constructs itself — it cannot reach inside an arbitrary
397    /// [`ContextCompactor`]'s own LLM calls. A custom compactor that talks
398    /// to a model is responsible for its own guardrail coverage (for the
399    /// built-in summarizer, construct it with
400    /// `LlmContextCompactor::with_guardrail_hooks`). Its reported usage is
401    /// priced at the *run's* provider/model for cost budgets, so
402    /// `max_cost_usd` is approximate when the compactor uses a different
403    /// backend.
404    #[must_use]
405    pub fn with_custom_compactor(mut self, compactor: impl ContextCompactor + 'static) -> Self {
406        self.compactor = Some(Arc::new(compactor));
407        self
408    }
409
410    /// Apply a skill configuration.
411    ///
412    /// This merges the skill's system prompt with the existing configuration
413    /// and filters tools based on the skill's allowed/denied lists.
414    ///
415    /// Available when the `skills` feature is enabled.
416    ///
417    /// # Example
418    ///
419    /// ```ignore
420    /// let skill = Skill::new("code-review", "You are a code reviewer...")
421    ///     .with_denied_tools(vec!["bash".into()]);
422    ///
423    /// let agent = builder()
424    ///     .provider(provider)
425    ///     .tools(tools)
426    ///     .with_skill(skill)
427    ///     .build();
428    /// ```
429    #[cfg(feature = "skills")]
430    #[must_use]
431    pub fn with_skill(mut self, skill: Skill) -> Self
432    where
433        Ctx: Send + Sync + 'static,
434    {
435        // Filter tools based on skill configuration first (before moving skill)
436        if let Some(ref mut tools) = self.tools {
437            tools.filter(|name| skill.is_tool_allowed(name));
438        }
439
440        // Merge system prompt
441        let mut config = self.config.take().unwrap_or_default();
442        if config.system_prompt.is_empty() {
443            config.system_prompt = skill.system_prompt;
444        } else {
445            config.system_prompt = format!("{}\n\n{}", config.system_prompt, skill.system_prompt);
446        }
447        self.config = Some(config);
448
449        self
450    }
451}
452
453impl<Ctx, P> AgentLoopBuilder<Ctx, P, (), (), ()>
454where
455    Ctx: Send + Sync + 'static,
456    P: LlmProvider + 'static,
457{
458    /// Build the agent loop with default hooks and in-memory message/state stores.
459    ///
460    /// This is a convenience method that uses:
461    /// - `DefaultHooks` for hooks
462    /// - `InMemoryStore` for message store
463    /// - `InMemoryStore` for state store
464    /// - `InMemoryEventStore` for the event store, when none was set
465    /// - `AgentConfig::default()` if no config is set
466    ///
467    /// Supplying an [`event_store`](Self::event_store) is optional for this
468    /// convenience build — a fresh [`InMemoryEventStore`](crate::InMemoryEventStore)
469    /// is used by default so the 30-second path needs no `Arc` ceremony. Wire
470    /// a durable store explicitly when you need persistence across process
471    /// restarts.
472    #[must_use]
473    pub fn build(self) -> AgentLoop<Ctx, P, DefaultHooks, InMemoryStore, InMemoryStore> {
474        // `self.provider` is the bare `P` moved in by `provider()`. This
475        // method is only reachable once `P: LlmProvider`, so the provider is
476        // always present — no runtime "unset" state to guard.
477        let event_store = self
478            .event_store
479            .unwrap_or_else(|| Arc::new(crate::stores::InMemoryEventStore::new()));
480        let tools = self.tools.unwrap_or_default();
481        let config = self.config.unwrap_or_default();
482
483        AgentLoop {
484            provider: Arc::new(self.provider),
485            tools: Arc::new(tools),
486            hooks: Arc::new(DefaultHooks),
487            message_store: Arc::new(InMemoryStore::new()),
488            state_store: Arc::new(InMemoryStore::new()),
489            event_store,
490            event_authority: self.event_authority,
491            config,
492            cost_estimator: self.cost_estimator,
493            compaction_config: self.compaction_config,
494            compactor: self.compactor,
495            execution_store: self.execution_store,
496            audit_sink: self
497                .audit_sink
498                .unwrap_or_else(|| Arc::new(crate::hooks::NoopAuditSink)),
499            reminder_config: self.reminder_config,
500            #[cfg(feature = "otel")]
501            observability_store: self.observability_store,
502        }
503    }
504}
505
506impl<Ctx, P, H, M, S> AgentLoopBuilder<Ctx, P, H, M, S>
507where
508    Ctx: Send + Sync + 'static,
509    P: LlmProvider + 'static,
510    H: AgentHooks + 'static,
511    M: MessageStore + 'static,
512    S: StateStore + 'static,
513{
514    /// Build the agent loop with all custom components.
515    ///
516    /// `provider`, `hooks`, `message_store`, and `state_store` are guaranteed
517    /// present by the type-state builder (this method is only callable once
518    /// each is set to a concrete type), so they cannot be missing at runtime.
519    ///
520    /// # Panics
521    ///
522    /// Panics if an [`event_store`](Self::event_store) has not been set — it is
523    /// the one component supplied via a plain `Arc` setter rather than a
524    /// type-transitioning one, so it has no compile-time "set" guarantee.
525    #[must_use]
526    pub fn build_with_stores(self) -> AgentLoop<Ctx, P, H, M, S> {
527        let tools = self.tools.unwrap_or_default();
528        let Some(event_store) = self.event_store else {
529            panic!("event_store is required when using build_with_stores");
530        };
531        let config = self.config.unwrap_or_default();
532
533        AgentLoop {
534            provider: Arc::new(self.provider),
535            tools: Arc::new(tools),
536            hooks: Arc::new(self.hooks),
537            message_store: Arc::new(self.message_store),
538            state_store: Arc::new(self.state_store),
539            event_store,
540            event_authority: self.event_authority,
541            config,
542            cost_estimator: self.cost_estimator,
543            compaction_config: self.compaction_config,
544            compactor: self.compactor,
545            execution_store: self.execution_store,
546            audit_sink: self
547                .audit_sink
548                .unwrap_or_else(|| Arc::new(crate::hooks::NoopAuditSink)),
549            reminder_config: self.reminder_config,
550            #[cfg(feature = "otel")]
551            observability_store: self.observability_store,
552        }
553    }
554}