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