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