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}