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}