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}