Skip to main content

clark_agent/
config.rs

1//! Loop configuration + builder.
2//!
3//! `LoopConfig` is the assembled, immutable configuration the loop
4//! reads. `AgentBuilder` is the ergonomic constructor: chain method
5//! calls to add stream transport, tools, plugins, then `.build()` to
6//! freeze.
7//!
8//! Plugins are stored as `Arc<dyn Plugin>` and queried by capability via
9//! the dispatcher (see `crate::run::PluginDispatch`). This avoids
10//! repeated trait-object downcast attempts at every hook point.
11
12use 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
26/// Assembled loop configuration. Construct via [`AgentBuilder`].
27///
28/// The system prompt is run state, not builder configuration: callers
29/// provide it through [`crate::types::AgentContext`].
30pub struct LoopConfig {
31    pub stream: Arc<dyn StreamFn>,
32    pub tools: Arc<ToolRegistry>,
33    pub event_sink: Arc<dyn EventSink>,
34
35    /// Conversation-protocol policy: the seam that supplies any
36    /// product-specific tool vocabulary (plain-text recovery prose,
37    /// tool-call alias repair, hidden-tool errors, terminal-tool
38    /// classification). Defaults to [`crate::DefaultProtocolPolicy`],
39    /// whose behavior is generic and names no specific tools. Downstream
40    /// product crates install their own via
41    /// [`AgentBuilder::protocol_policy`]. See [`crate::protocol`].
42    pub protocol: Arc<dyn ProtocolPolicy>,
43
44    /// Optional conversation identifier, surfaced to plugins via
45    /// `ToolGateContext::conversation_id`. The agent core itself does
46    /// not use this — it's metadata for diagnostics and
47    /// conversation-scoped policy. `None` when the loop is invoked
48    /// outside a conversation context (tests, isolated subagent runs).
49    pub conversation_id: Option<String>,
50
51    /// Optional model identifier surfaced to plugins via
52    /// [`crate::plugin::TransformContext::model_id`]. The loop does not
53    /// use this directly — the active `StreamFn` already knows its
54    /// model. Plugins that key per-model behavior (cache-aware
55    /// compaction, model-specific token estimators, model-specific
56    /// system reminders) read it from here. `None` when the host
57    /// runtime doesn't surface one.
58    pub model_id: Option<String>,
59
60    /// Token estimator the loop hands to context transforms. Defaults
61    /// to [`CharHeuristicEstimator`]; apps with a real tokenizer
62    /// implement [`TokenEstimator`] and supply their own via
63    /// [`AgentBuilder::token_estimator`].
64    pub token_estimator: Arc<dyn TokenEstimator>,
65
66    /// Default tool execution mode. A batch downgrades to `Sequential`
67    /// if any tool in it sets `requires_exclusive_sandbox = true`.
68    /// Set this to `Sequential` to pin the entire loop to sequential
69    /// dispatch regardless of per-tool flags (deterministic eval,
70    /// debugging, ordered replay).
71    pub default_execution_mode: ExecutionMode,
72
73    /// Optional hard cap on limit-counted tool calls executed from a
74    /// single assistant turn. When set to `1`, the loop preserves every
75    /// emitted tool call in the assistant message, executes the first
76    /// limit-counted call plus any zero-weight progress signals, appends
77    /// synthetic error results for the rest, then asks the model to choose
78    /// the next action.
79    pub max_tool_calls_per_turn: Option<usize>,
80
81    /// Optional sampling controls forwarded to the stream transport.
82    pub temperature: Option<f32>,
83    pub max_output_tokens: Option<u32>,
84
85    /// Reasoning-effort knob forwarded to the stream transport on every
86    /// turn. The single source of truth for per-request reasoning effort:
87    /// the transport reads it here rather than from per-provider extras.
88    /// Default is [`ReasoningEffort::Minimal`].
89    pub reasoning: ReasoningEffort,
90
91    /// Provider-specific extras forwarded to the stream transport on
92    /// every turn (e.g., `response_format` for structured output
93    /// enforcement, custom routing pins). Passed as-is into
94    /// [`crate::StreamRequest::provider_extras`]; `None` sends
95    /// `Value::Null`.
96    pub provider_extras: Option<Value>,
97
98    /// Recovery for a provider context-window rejection mid-run. When
99    /// `Some`, a [`crate::StreamError::ContextOverflow`] triggers the
100    /// hook (typically an aggressive compaction), the shrunk history is
101    /// persisted into the live transcript, and the loop retries the same
102    /// LLM call. Default `None` — today's behavior (the overflow ends
103    /// the run). See [`crate::ContextOverflowRecovery`].
104    pub(crate) overflow_recovery: Option<Arc<dyn ContextOverflowRecovery>>,
105
106    /// Optional terminal-tool compatibility shim for providers that cannot
107    /// honor forced tool choice. When set, a non-empty plain assistant text
108    /// stop may be converted into this terminal tool result, but only on a
109    /// turn whose advertised tool allowlist has already been narrowed to
110    /// terminal delivery tools. Default `None` preserves the strict
111    /// "terminal text must arrive through a tool call" contract.
112    pub plain_text_terminal_fallback_tool: Option<String>,
113
114    /// When true, [`Self::plain_text_terminal_fallback_tool`] fires on the
115    /// FIRST plain-text stop instead of waiting for the turn allowlist to
116    /// narrow to terminators. Intended for providers in the
117    /// "auto-when-forced" class where wire-level `tool_choice: "required"`
118    /// is rejected and so plain text is the model's default failure mode —
119    /// there's no benefit to running the narrowing-gate nudge cycle first
120    /// because the model will emit prose every time. Default `false`
121    /// preserves the post-narrowing gate for everyone else.
122    pub plain_text_terminal_fallback_eager: bool,
123
124    /// When true, the eager plain-text fallback path nudges the model with
125    /// an explicit protocol-recovery system message BEFORE synthesizing a
126    /// terminal tool result. Recovery continues until the model follows
127    /// the protocol or the caller cancels. Default `false` preserves the
128    /// original silent-synthesize behavior. Has no effect unless both
129    /// [`Self::plain_text_terminal_fallback_tool`] and
130    /// [`Self::plain_text_terminal_fallback_eager`] are set.
131    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
147/// Fluent builder for [`LoopConfig`].
148///
149/// ```ignore
150/// let config = AgentBuilder::new()
151///     .stream(provider)
152///     .tools(registry)
153///     .event_sink(channel_sink)
154///     .before_tool_call(retired_path_gate)
155///     .after_tool_call(repeat_detector)
156///     .context_transform(token_budget_pruner)
157///     .steering(steering_source)
158///     .build();
159/// ```
160pub 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    /// Variant for callers that already share a registry by `Arc`.
222    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    /// Set the reasoning-effort knob forwarded to the stream transport
253    /// on every turn. Per-run overrides flow through this typed surface
254    /// rather than through stringly-typed provider extras.
255    pub fn reasoning(mut self, level: ReasoningEffort) -> Self {
256        self.reasoning = level;
257        self
258    }
259
260    /// Set provider-specific extras forwarded to the stream transport
261    /// on every turn (e.g., `response_format` for structured output
262    /// enforcement).
263    pub fn provider_extras(mut self, extras: Value) -> Self {
264        self.provider_extras = Some(extras);
265        self
266    }
267
268    /// Enable context-overflow recovery. When a request is rejected for
269    /// exceeding the model's context window
270    /// ([`crate::StreamError::ContextOverflow`]), the loop asks `recovery`
271    /// for a smaller history, persists it, and retries the same LLM call.
272    /// Off by default. See [`ContextOverflowRecovery`] for the contract.
273    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    /// [`Self::overflow_recovery`] for a pre-wrapped `Arc` (share one
279    /// recovery across multiple builders).
280    pub fn overflow_recovery_arc(mut self, recovery: Arc<dyn ContextOverflowRecovery>) -> Self {
281        self.overflow_recovery = Some(recovery);
282        self
283    }
284
285    /// Convert plain assistant text into a terminal tool result on
286    /// terminal-only compatibility turns. Intended for providers that reject
287    /// `tool_choice: "required"` and therefore can leak final prose even
288    /// while the host advertises only delivery tools.
289    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    /// Make [`Self::plain_text_terminal_fallback_tool`] fire on the FIRST
295    /// plain-text stop instead of waiting for the turn allowlist to be
296    /// narrowed to terminators by a downstream tool gate. Use this for
297    /// providers in the "auto-when-forced" class where wire-level forcing
298    /// isn't available, so prose is the model's default failure mode and
299    /// the nudge cycle just burns turns. Has no effect unless
300    /// [`Self::plain_text_terminal_fallback_tool`] is also set.
301    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    /// Make the eager plain-text fallback path nudge the model with an
307    /// explicit protocol-recovery system message before synthesizing a
308    /// terminal tool result. It keeps nudging until the model follows the
309    /// protocol or the caller cancels. Has no effect unless
310    /// both [`Self::plain_text_terminal_fallback_tool`] and
311    /// [`Self::plain_text_terminal_fallback_eager`] are set.
312    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    /// Attach a conversation identifier so plugins can include
318    /// conversation-scoped diagnostics or policy. The agent core itself
319    /// does not consume this — it's just metadata threaded through
320    /// `ToolGateContext`. Optional; absent for tests and isolated
321    /// subagent runs.
322    pub fn conversation_id(mut self, id: impl Into<String>) -> Self {
323        self.conversation_id = Some(id.into());
324        self
325    }
326
327    /// Attach a model identifier so context transforms can read it via
328    /// [`crate::plugin::TransformContext::model_id`]. The loop itself
329    /// does not consume this; the active `StreamFn` already knows its
330    /// model. Optional — defaults to `None` (transforms see the empty
331    /// string).
332    pub fn model_id(mut self, id: impl Into<String>) -> Self {
333        self.model_id = Some(id.into());
334        self
335    }
336
337    /// Plug in a token estimator for budgeting and compaction. Defaults
338    /// to the char-heuristic estimator when not set. Pass an `Arc` if
339    /// the estimator is shared across multiple builders.
340    pub fn token_estimator<E: TokenEstimator>(mut self, est: E) -> Self {
341        self.token_estimator = Arc::new(est);
342        self
343    }
344
345    /// Variant for callers that already share an estimator by `Arc`.
346    pub fn token_estimator_arc(mut self, est: Arc<dyn TokenEstimator>) -> Self {
347        self.token_estimator = est;
348        self
349    }
350
351    /// Install a [`ProtocolPolicy`] — the seam through which a downstream
352    /// product supplies its tool vocabulary (plain-text recovery prose,
353    /// tool-call alias repair, hidden-tool errors, terminal-tool
354    /// classification). Defaults to [`crate::DefaultProtocolPolicy`] when
355    /// not set, which keeps the core free of any product tool names. See
356    /// [`crate::protocol`].
357    pub fn protocol_policy(mut self, policy: Arc<dyn ProtocolPolicy>) -> Self {
358        self.protocol = policy;
359        self
360    }
361
362    // ─── Plugin registration (one method per capability) ────────────
363
364    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    /// Variant that takes pre-`Arc`'d trait objects, useful when the
395    /// caller already has shared plugin instances.
396    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    /// Generic plugin registration. Inspects [`Plugin::capabilities`] to
426    /// decide which dispatch lists to add the plugin to. Same `Arc` is
427    /// shared across all enabled capabilities so a single plugin
428    /// instance can implement multiple traits.
429    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/// Snapshot of registered plugin names per category, in registration order.
511///
512/// Returned by [`LoopConfig::plugin_names`] for inspection / regression
513/// tests. Order matches the order the loop will invoke each plugin
514/// (left-to-right composition for `ContextTransform`, etc.). Pure read
515/// — does not clone the plugins themselves.
516#[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    /// Build an [`AgentBuilder`] pre-populated for a child run spawned
529    /// from this config.
530    ///
531    /// Inherits, by value or `Arc`:
532    /// - stream transport, tool registry, token estimator
533    /// - sampling controls (`temperature`, `max_output_tokens`,
534    ///   `reasoning`)
535    /// - default execution mode, `max_tool_calls_per_turn`
536    /// - model id
537    /// - protocol policy ([`ProtocolPolicy`])
538    /// - plain-text-terminal fallback knobs
539    /// - every plugin whose
540    ///   [`crate::plugin::PluginCapabilities::inheritable_to_child`]
541    ///   bit is set
542    ///
543    /// Does **not** inherit:
544    /// - `event_sink` — callers install a child-scoped sink before
545    ///   `build`.
546    /// - `conversation_id` — the child should carry its own identity
547    ///   via [`crate::AgentContext::identity`].
548    /// - plugins that did **not** opt in to inheritance — they remain
549    ///   parent-only.
550    ///
551    /// This is the single primitive for "spawn a fresh child agent with
552    /// the same execution shape as me." A host runtime still registers
553    /// any child-specific guards (delivery gates, terminal guards, etc.)
554    /// on top of the returned builder.
555    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    /// Plugin names per category, in registration order. The composition
624    /// order is part of the loop's external contract — bridges and host
625    /// runtimes assemble plugins in a specific order so transforms run
626    /// before token-budget pruning, gates fire before terminator
627    /// validation, etc. Tests use this to pin the assembled order so
628    /// silent reorderings during refactors surface as a diff instead of
629    /// a runtime regression.
630    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}