1use std::sync::Arc;
13
14use serde_json::Value;
15
16use crate::event::{EventSink, NoopSink};
17use crate::plugin::{
18 AfterToolCall, BeforeToolCall, ContextOverflowRecovery, ContextTransform, EventObserver,
19 FollowUpSource, Plugin, SteeringSource, ToolGate,
20};
21use crate::protocol::{default_policy, ProtocolPolicy};
22use crate::stream::{ReasoningEffort, StreamFn};
23use crate::tokens::{CharHeuristicEstimator, TokenEstimator};
24use crate::tool::{ExecutionMode, ToolRegistry};
25
26pub struct LoopConfig {
31 pub stream: Arc<dyn StreamFn>,
32 pub tools: Arc<ToolRegistry>,
33 pub event_sink: Arc<dyn EventSink>,
34
35 pub protocol: Arc<dyn ProtocolPolicy>,
43
44 pub conversation_id: Option<String>,
50
51 pub model_id: Option<String>,
59
60 pub token_estimator: Arc<dyn TokenEstimator>,
65
66 pub default_execution_mode: ExecutionMode,
72
73 pub max_tool_calls_per_turn: Option<usize>,
80
81 pub temperature: Option<f32>,
83 pub max_output_tokens: Option<u32>,
84
85 pub reasoning: ReasoningEffort,
90
91 pub provider_extras: Option<Value>,
97
98 pub(crate) overflow_recovery: Option<Arc<dyn ContextOverflowRecovery>>,
105
106 pub plain_text_terminal_fallback_tool: Option<String>,
113
114 pub plain_text_terminal_fallback_eager: bool,
123
124 pub plain_text_terminal_fallback_eager_nudge: bool,
132
133 pub(crate) plugins: PluginRegistry,
134}
135
136#[derive(Default)]
137pub(crate) struct PluginRegistry {
138 pub before_tool_call: Vec<Arc<dyn BeforeToolCall>>,
139 pub after_tool_call: Vec<Arc<dyn AfterToolCall>>,
140 pub context_transform: Vec<Arc<dyn ContextTransform>>,
141 pub event_observer: Vec<Arc<dyn EventObserver>>,
142 pub steering: Vec<Arc<dyn SteeringSource>>,
143 pub follow_up: Vec<Arc<dyn FollowUpSource>>,
144 pub tool_gate: Vec<Arc<dyn ToolGate>>,
145}
146
147pub struct AgentBuilder {
161 stream: Option<Arc<dyn StreamFn>>,
162 tools: Arc<ToolRegistry>,
163 event_sink: Arc<dyn EventSink>,
164 default_execution_mode: ExecutionMode,
165 max_tool_calls_per_turn: Option<usize>,
166 temperature: Option<f32>,
167 max_output_tokens: Option<u32>,
168 reasoning: ReasoningEffort,
169 provider_extras: Option<Value>,
170 overflow_recovery: Option<Arc<dyn ContextOverflowRecovery>>,
171 plain_text_terminal_fallback_tool: Option<String>,
172 plain_text_terminal_fallback_eager: bool,
173 plain_text_terminal_fallback_eager_nudge: bool,
174 conversation_id: Option<String>,
175 model_id: Option<String>,
176 token_estimator: Arc<dyn TokenEstimator>,
177 protocol: Arc<dyn ProtocolPolicy>,
178 plugins: PluginRegistry,
179}
180
181impl Default for AgentBuilder {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187impl AgentBuilder {
188 pub fn new() -> Self {
189 Self {
190 stream: None,
191 tools: Arc::new(ToolRegistry::new()),
192 event_sink: Arc::new(NoopSink),
193 default_execution_mode: ExecutionMode::Parallel,
194 max_tool_calls_per_turn: None,
195 temperature: None,
196 max_output_tokens: None,
197 reasoning: ReasoningEffort::default(),
198 provider_extras: None,
199 overflow_recovery: None,
200 plain_text_terminal_fallback_tool: None,
201 plain_text_terminal_fallback_eager: false,
202 plain_text_terminal_fallback_eager_nudge: false,
203 conversation_id: None,
204 model_id: None,
205 token_estimator: Arc::new(CharHeuristicEstimator),
206 protocol: default_policy(),
207 plugins: PluginRegistry::default(),
208 }
209 }
210
211 pub fn stream(mut self, stream: Arc<dyn StreamFn>) -> Self {
212 self.stream = Some(stream);
213 self
214 }
215
216 pub fn tools(mut self, tools: ToolRegistry) -> Self {
217 self.tools = Arc::new(tools);
218 self
219 }
220
221 pub fn tools_arc(mut self, tools: Arc<ToolRegistry>) -> Self {
223 self.tools = tools;
224 self
225 }
226
227 pub fn event_sink(mut self, sink: Arc<dyn EventSink>) -> Self {
228 self.event_sink = sink;
229 self
230 }
231
232 pub fn default_execution_mode(mut self, mode: ExecutionMode) -> Self {
233 self.default_execution_mode = mode;
234 self
235 }
236
237 pub fn max_tool_calls_per_turn(mut self, max: usize) -> Self {
238 self.max_tool_calls_per_turn = Some(max.max(1));
239 self
240 }
241
242 pub fn temperature(mut self, t: f32) -> Self {
243 self.temperature = Some(t);
244 self
245 }
246
247 pub fn max_output_tokens(mut self, t: u32) -> Self {
248 self.max_output_tokens = Some(t);
249 self
250 }
251
252 pub fn reasoning(mut self, level: ReasoningEffort) -> Self {
256 self.reasoning = level;
257 self
258 }
259
260 pub fn provider_extras(mut self, extras: Value) -> Self {
264 self.provider_extras = Some(extras);
265 self
266 }
267
268 pub fn overflow_recovery<R: ContextOverflowRecovery + 'static>(mut self, recovery: R) -> Self {
274 self.overflow_recovery = Some(Arc::new(recovery));
275 self
276 }
277
278 pub fn overflow_recovery_arc(mut self, recovery: Arc<dyn ContextOverflowRecovery>) -> Self {
281 self.overflow_recovery = Some(recovery);
282 self
283 }
284
285 pub fn plain_text_terminal_fallback_tool(mut self, tool_name: impl Into<String>) -> Self {
290 self.plain_text_terminal_fallback_tool = Some(tool_name.into());
291 self
292 }
293
294 pub fn plain_text_terminal_fallback_eager(mut self, eager: bool) -> Self {
302 self.plain_text_terminal_fallback_eager = eager;
303 self
304 }
305
306 pub fn plain_text_terminal_fallback_eager_nudge(mut self, on: bool) -> Self {
313 self.plain_text_terminal_fallback_eager_nudge = on;
314 self
315 }
316
317 pub fn conversation_id(mut self, id: impl Into<String>) -> Self {
323 self.conversation_id = Some(id.into());
324 self
325 }
326
327 pub fn model_id(mut self, id: impl Into<String>) -> Self {
333 self.model_id = Some(id.into());
334 self
335 }
336
337 pub fn token_estimator<E: TokenEstimator>(mut self, est: E) -> Self {
341 self.token_estimator = Arc::new(est);
342 self
343 }
344
345 pub fn token_estimator_arc(mut self, est: Arc<dyn TokenEstimator>) -> Self {
347 self.token_estimator = est;
348 self
349 }
350
351 pub fn protocol_policy(mut self, policy: Arc<dyn ProtocolPolicy>) -> Self {
358 self.protocol = policy;
359 self
360 }
361
362 pub fn before_tool_call<P: BeforeToolCall + 'static>(mut self, plugin: P) -> Self {
365 self.plugins.before_tool_call.push(Arc::new(plugin));
366 self
367 }
368
369 pub fn after_tool_call<P: AfterToolCall + 'static>(mut self, plugin: P) -> Self {
370 self.plugins.after_tool_call.push(Arc::new(plugin));
371 self
372 }
373
374 pub fn context_transform<P: ContextTransform + 'static>(mut self, plugin: P) -> Self {
375 self.plugins.context_transform.push(Arc::new(plugin));
376 self
377 }
378
379 pub fn event_observer<P: EventObserver + 'static>(mut self, plugin: P) -> Self {
380 self.plugins.event_observer.push(Arc::new(plugin));
381 self
382 }
383
384 pub fn steering<P: SteeringSource + 'static>(mut self, plugin: P) -> Self {
385 self.plugins.steering.push(Arc::new(plugin));
386 self
387 }
388
389 pub fn follow_up<P: FollowUpSource + 'static>(mut self, plugin: P) -> Self {
390 self.plugins.follow_up.push(Arc::new(plugin));
391 self
392 }
393
394 pub fn before_tool_call_arc(mut self, plugin: Arc<dyn BeforeToolCall>) -> Self {
397 self.plugins.before_tool_call.push(plugin);
398 self
399 }
400 pub fn after_tool_call_arc(mut self, plugin: Arc<dyn AfterToolCall>) -> Self {
401 self.plugins.after_tool_call.push(plugin);
402 self
403 }
404 pub fn context_transform_arc(mut self, plugin: Arc<dyn ContextTransform>) -> Self {
405 self.plugins.context_transform.push(plugin);
406 self
407 }
408 pub fn event_observer_arc(mut self, plugin: Arc<dyn EventObserver>) -> Self {
409 self.plugins.event_observer.push(plugin);
410 self
411 }
412 pub fn follow_up_arc(mut self, plugin: Arc<dyn FollowUpSource>) -> Self {
413 self.plugins.follow_up.push(plugin);
414 self
415 }
416 pub fn steering_arc(mut self, plugin: Arc<dyn SteeringSource>) -> Self {
417 self.plugins.steering.push(plugin);
418 self
419 }
420 pub fn tool_gate_arc(mut self, plugin: Arc<dyn ToolGate>) -> Self {
421 self.plugins.tool_gate.push(plugin);
422 self
423 }
424
425 pub fn plugin<P>(mut self, plugin: Arc<P>) -> Self
430 where
431 P: Plugin
432 + BeforeToolCall
433 + AfterToolCall
434 + ContextTransform
435 + EventObserver
436 + SteeringSource
437 + FollowUpSource
438 + ToolGate
439 + 'static,
440 {
441 let caps = plugin.capabilities();
442 if caps.before_tool_call {
443 self.plugins
444 .before_tool_call
445 .push(plugin.clone() as Arc<dyn BeforeToolCall>);
446 }
447 if caps.after_tool_call {
448 self.plugins
449 .after_tool_call
450 .push(plugin.clone() as Arc<dyn AfterToolCall>);
451 }
452 if caps.context_transform {
453 self.plugins
454 .context_transform
455 .push(plugin.clone() as Arc<dyn ContextTransform>);
456 }
457 if caps.event_observer {
458 self.plugins
459 .event_observer
460 .push(plugin.clone() as Arc<dyn EventObserver>);
461 }
462 if caps.steering {
463 self.plugins
464 .steering
465 .push(plugin.clone() as Arc<dyn SteeringSource>);
466 }
467 if caps.follow_up {
468 self.plugins
469 .follow_up
470 .push(plugin.clone() as Arc<dyn FollowUpSource>);
471 }
472 if caps.tool_gate {
473 self.plugins.tool_gate.push(plugin as Arc<dyn ToolGate>);
474 }
475 self
476 }
477
478 pub fn build(self) -> Result<LoopConfig, BuilderError> {
479 let stream = self.stream.ok_or(BuilderError::MissingStream)?;
480
481 Ok(LoopConfig {
482 stream,
483 tools: self.tools,
484 event_sink: self.event_sink,
485 default_execution_mode: self.default_execution_mode,
486 max_tool_calls_per_turn: self.max_tool_calls_per_turn,
487 temperature: self.temperature,
488 max_output_tokens: self.max_output_tokens,
489 reasoning: self.reasoning,
490 provider_extras: self.provider_extras,
491 overflow_recovery: self.overflow_recovery,
492 plain_text_terminal_fallback_tool: self.plain_text_terminal_fallback_tool,
493 plain_text_terminal_fallback_eager: self.plain_text_terminal_fallback_eager,
494 plain_text_terminal_fallback_eager_nudge: self.plain_text_terminal_fallback_eager_nudge,
495 conversation_id: self.conversation_id,
496 model_id: self.model_id,
497 token_estimator: self.token_estimator,
498 protocol: self.protocol,
499 plugins: self.plugins,
500 })
501 }
502}
503
504#[derive(Debug, thiserror::Error)]
505pub enum BuilderError {
506 #[error("missing stream transport: call AgentBuilder::stream() before build()")]
507 MissingStream,
508}
509
510#[derive(Debug, Clone, Default, PartialEq, Eq)]
517pub struct PluginNames {
518 pub before_tool_call: Vec<&'static str>,
519 pub after_tool_call: Vec<&'static str>,
520 pub context_transform: Vec<&'static str>,
521 pub event_observer: Vec<&'static str>,
522 pub steering: Vec<&'static str>,
523 pub follow_up: Vec<&'static str>,
524 pub tool_gate: Vec<&'static str>,
525}
526
527impl LoopConfig {
528 pub fn child_builder(&self) -> AgentBuilder {
556 let mut builder = AgentBuilder::new()
557 .stream(self.stream.clone())
558 .tools_arc(self.tools.clone())
559 .default_execution_mode(self.default_execution_mode)
560 .reasoning(self.reasoning)
561 .token_estimator_arc(self.token_estimator.clone())
562 .protocol_policy(self.protocol.clone());
563 if let Some(t) = self.temperature {
564 builder = builder.temperature(t);
565 }
566 if let Some(m) = self.max_output_tokens {
567 builder = builder.max_output_tokens(m);
568 }
569 if let Some(n) = self.max_tool_calls_per_turn {
570 builder = builder.max_tool_calls_per_turn(n);
571 }
572 if let Some(id) = &self.model_id {
573 builder = builder.model_id(id.clone());
574 }
575 if let Some(tool) = &self.plain_text_terminal_fallback_tool {
576 builder = builder
577 .plain_text_terminal_fallback_tool(tool.clone())
578 .plain_text_terminal_fallback_eager(self.plain_text_terminal_fallback_eager)
579 .plain_text_terminal_fallback_eager_nudge(
580 self.plain_text_terminal_fallback_eager_nudge,
581 );
582 }
583
584 for p in &self.plugins.before_tool_call {
585 if p.capabilities().inheritable_to_child {
586 builder = builder.before_tool_call_arc(p.clone());
587 }
588 }
589 for p in &self.plugins.after_tool_call {
590 if p.capabilities().inheritable_to_child {
591 builder = builder.after_tool_call_arc(p.clone());
592 }
593 }
594 for p in &self.plugins.context_transform {
595 if p.capabilities().inheritable_to_child {
596 builder = builder.context_transform_arc(p.clone());
597 }
598 }
599 for p in &self.plugins.event_observer {
600 if p.capabilities().inheritable_to_child {
601 builder = builder.event_observer_arc(p.clone());
602 }
603 }
604 for p in &self.plugins.steering {
605 if p.capabilities().inheritable_to_child {
606 builder = builder.steering_arc(p.clone());
607 }
608 }
609 for p in &self.plugins.follow_up {
610 if p.capabilities().inheritable_to_child {
611 builder = builder.follow_up_arc(p.clone());
612 }
613 }
614 for p in &self.plugins.tool_gate {
615 if p.capabilities().inheritable_to_child {
616 builder = builder.tool_gate_arc(p.clone());
617 }
618 }
619
620 builder
621 }
622
623 pub fn plugin_names(&self) -> PluginNames {
631 PluginNames {
632 before_tool_call: self
633 .plugins
634 .before_tool_call
635 .iter()
636 .map(|p| p.name())
637 .collect(),
638 after_tool_call: self
639 .plugins
640 .after_tool_call
641 .iter()
642 .map(|p| p.name())
643 .collect(),
644 context_transform: self
645 .plugins
646 .context_transform
647 .iter()
648 .map(|p| p.name())
649 .collect(),
650 event_observer: self
651 .plugins
652 .event_observer
653 .iter()
654 .map(|p| p.name())
655 .collect(),
656 steering: self.plugins.steering.iter().map(|p| p.name()).collect(),
657 follow_up: self.plugins.follow_up.iter().map(|p| p.name()).collect(),
658 tool_gate: self.plugins.tool_gate.iter().map(|p| p.name()).collect(),
659 }
660 }
661}
662
663#[cfg(test)]
664mod child_builder_tests {
665 use super::*;
666 use crate::plugin::{Plugin, PluginCapabilities};
667 use crate::stream::{StreamEvent, StreamFn, StreamRequest};
668 use async_trait::async_trait;
669 use futures::stream::BoxStream;
670 use futures::StreamExt;
671
672 struct EmptyStream;
673 #[async_trait]
674 impl StreamFn for EmptyStream {
675 async fn stream(
676 &self,
677 _r: StreamRequest,
678 _s: tokio_util::sync::CancellationToken,
679 ) -> BoxStream<'static, StreamEvent> {
680 futures::stream::empty().boxed()
681 }
682 }
683
684 struct ParentOnlyPlugin;
685 impl Plugin for ParentOnlyPlugin {
686 fn name(&self) -> &'static str {
687 "parent_only"
688 }
689 fn capabilities(&self) -> PluginCapabilities {
690 PluginCapabilities::event_observer()
691 }
692 }
693 #[async_trait]
694 impl crate::EventObserver for ParentOnlyPlugin {
695 async fn on_event(&self, _event: &crate::AgentEvent) {}
696 }
697
698 struct InheritablePlugin;
699 impl Plugin for InheritablePlugin {
700 fn name(&self) -> &'static str {
701 "inheritable"
702 }
703 fn capabilities(&self) -> PluginCapabilities {
704 PluginCapabilities::event_observer().with_inheritable_to_child()
705 }
706 }
707 #[async_trait]
708 impl crate::EventObserver for InheritablePlugin {
709 async fn on_event(&self, _event: &crate::AgentEvent) {}
710 }
711
712 #[test]
713 fn child_builder_inherits_only_opted_in_plugins() {
714 let parent = AgentBuilder::new()
715 .stream(Arc::new(EmptyStream))
716 .event_observer(ParentOnlyPlugin)
717 .event_observer(InheritablePlugin)
718 .build()
719 .expect("parent builds");
720
721 let child = parent.child_builder().build().expect("child builds");
722
723 let names = child.plugin_names();
724 assert_eq!(
725 names.event_observer,
726 vec!["inheritable"],
727 "child must drop parent-only plugins"
728 );
729 }
730
731 #[test]
732 fn child_builder_carries_sampling_knobs() {
733 let parent = AgentBuilder::new()
734 .stream(Arc::new(EmptyStream))
735 .temperature(0.3)
736 .max_output_tokens(8192)
737 .max_tool_calls_per_turn(3)
738 .model_id("test-model")
739 .build()
740 .expect("parent builds");
741
742 let child = parent.child_builder().build().expect("child builds");
743
744 assert_eq!(child.temperature, Some(0.3));
745 assert_eq!(child.max_output_tokens, Some(8192));
746 assert_eq!(child.max_tool_calls_per_turn, Some(3));
747 assert_eq!(child.model_id.as_deref(), Some("test-model"));
748 }
749}