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::atomic::AtomicBool;
13use std::sync::Arc;
14
15use serde_json::Value;
16
17use crate::event::{EventSink, NoopSink};
18use crate::plugin::{
19    AfterToolCall, BeforeToolCall, ContextTransform, EventObserver, FollowUpSource, Plugin,
20    SteeringSource, ToolGate,
21};
22use crate::plugins::graceful_turn_limit::GracefulTurnLimit;
23use crate::protocol::{default_policy, ProtocolPolicy};
24use crate::stream::{ReasoningEffort, StreamFn};
25use crate::tokens::{CharHeuristicEstimator, TokenEstimator};
26use crate::tool::{ExecutionMode, ToolRegistry};
27
28/// Default number of grace iterations the soft-limit warning leaves before
29/// the hard `max_iterations` cap. Sized to give a wrap-up turn plus a
30/// couple of recovery turns when the model needs them.
31pub const DEFAULT_GRACE_ITERATIONS: usize = 5;
32
33/// Assembled loop configuration. Construct via [`AgentBuilder`].
34///
35/// The system prompt is run state, not builder configuration: callers
36/// provide it through [`crate::types::AgentContext`].
37pub struct LoopConfig {
38    pub stream: Arc<dyn StreamFn>,
39    pub tools: Arc<ToolRegistry>,
40    pub event_sink: Arc<dyn EventSink>,
41
42    /// Conversation-protocol policy: the seam that supplies any
43    /// product-specific tool vocabulary (plain-text recovery prose,
44    /// tool-call alias repair, hidden-tool errors, terminal-tool
45    /// classification). Defaults to [`crate::DefaultProtocolPolicy`],
46    /// whose behavior is generic and names no specific tools. Downstream
47    /// product crates install their own via
48    /// [`AgentBuilder::protocol_policy`]. See [`crate::protocol`].
49    pub protocol: Arc<dyn ProtocolPolicy>,
50
51    /// Optional conversation identifier, surfaced to plugins via
52    /// `ToolGateContext::conversation_id`. The agent core itself does
53    /// not use this — it's metadata for diagnostics and
54    /// conversation-scoped policy. `None` when the loop is invoked
55    /// outside a conversation context (tests, isolated subagent runs).
56    pub conversation_id: Option<String>,
57
58    /// Optional model identifier surfaced to plugins via
59    /// [`crate::plugin::TransformContext::model_id`]. The loop does not
60    /// use this directly — the active `StreamFn` already knows its
61    /// model. Plugins that key per-model behavior (cache-aware
62    /// compaction, model-specific token estimators, model-specific
63    /// system reminders) read it from here. `None` when the host
64    /// runtime doesn't surface one.
65    pub model_id: Option<String>,
66
67    /// Token estimator the loop hands to context transforms. Defaults
68    /// to [`CharHeuristicEstimator`]; apps with a real tokenizer
69    /// implement [`TokenEstimator`] and supply their own via
70    /// [`AgentBuilder::token_estimator`].
71    pub token_estimator: Arc<dyn TokenEstimator>,
72
73    /// Default tool execution mode. A batch downgrades to `Sequential`
74    /// if any tool in it sets `requires_exclusive_sandbox = true`.
75    /// Set this to `Sequential` to pin the entire loop to sequential
76    /// dispatch regardless of per-tool flags (deterministic eval,
77    /// debugging, ordered replay).
78    pub default_execution_mode: ExecutionMode,
79
80    /// Optional hard cap on limit-counted tool calls executed from a
81    /// single assistant turn. When set to `1`, the loop preserves every
82    /// emitted tool call in the assistant message, executes the first
83    /// limit-counted call plus any zero-weight progress signals, appends
84    /// synthetic error results for the rest, then asks the model to choose
85    /// the next action.
86    pub max_tool_calls_per_turn: Option<usize>,
87
88    /// Optional sampling controls forwarded to the stream transport.
89    pub temperature: Option<f32>,
90    pub max_output_tokens: Option<u32>,
91
92    /// Reasoning-effort knob forwarded to the stream transport on every
93    /// turn. The single source of truth for per-request reasoning effort:
94    /// the transport reads it here rather than from per-provider extras.
95    /// Default is [`ReasoningEffort::Minimal`].
96    pub reasoning: ReasoningEffort,
97
98    /// Provider-specific extras forwarded to the stream transport on
99    /// every turn (e.g., `response_format` for structured output
100    /// enforcement, custom routing pins). Passed as-is into
101    /// [`crate::StreamRequest::provider_extras`]; `None` sends
102    /// `Value::Null`.
103    pub provider_extras: Option<Value>,
104
105    /// Recovery strategy for `StopReason::MaxTokens` truncations. When
106    /// `Some`, the loop discards a truncated assistant turn and
107    /// re-streams with a larger cap up to `max_attempts` times before
108    /// accepting the truncated turn. Default `None` — today's
109    /// behavior. Opt-in because the cost can be large (worst case
110    /// 8× output tokens with `Double` × 3 attempts).
111    pub max_output_tokens_recovery: Option<MaxTokensRecovery>,
112
113    /// Hard ceiling on iterations within a single `run`. Prevents
114    /// runaway loops if neither the model nor tools ever vote to
115    /// terminate. `None` = unbounded.
116    pub max_iterations: Option<usize>,
117
118    /// Number of no-tool assistant stops the loop may recover from
119    /// before treating another no-tool stop as a typed failure. `None`
120    /// preserves the generic core's historical natural-stop behavior.
121    pub empty_outcome_retry_budget: Option<usize>,
122
123    /// Optional terminal-tool compatibility shim for providers that cannot
124    /// honor forced tool choice. When set, a non-empty plain assistant text
125    /// stop may be converted into this terminal tool result, but only on a
126    /// turn whose advertised tool allowlist has already been narrowed to
127    /// terminal delivery tools. Default `None` preserves the strict
128    /// "terminal text must arrive through a tool call" contract.
129    pub plain_text_terminal_fallback_tool: Option<String>,
130
131    /// When true, [`Self::plain_text_terminal_fallback_tool`] fires on the
132    /// FIRST plain-text stop instead of waiting for the turn allowlist to
133    /// narrow to terminators. Intended for providers in the
134    /// "auto-when-forced" class where wire-level `tool_choice: "required"`
135    /// is rejected and so plain text is the model's default failure mode —
136    /// there's no benefit to running the narrowing-gate nudge cycle first
137    /// because the model will emit prose every time. Default `false`
138    /// preserves the post-narrowing gate for everyone else.
139    pub plain_text_terminal_fallback_eager: bool,
140
141    /// When true, the eager plain-text fallback path nudges the model with
142    /// an explicit protocol-recovery system message BEFORE synthesizing a
143    /// terminal tool result, giving the model a bounded number of retries
144    /// to follow the protocol. Only synthesizes as a last-resort after the
145    /// nudges are exhausted. Default `false` preserves the original
146    /// silent-synthesize behavior. Has no effect unless both
147    /// [`Self::plain_text_terminal_fallback_tool`] and
148    /// [`Self::plain_text_terminal_fallback_eager`] are set.
149    pub plain_text_terminal_fallback_eager_nudge: bool,
150
151    /// Number of iterations before `max_iterations` at which the
152    /// graceful-turn-limit plugin injects a one-shot wrap-up steering
153    /// message. `0` disables the soft warning (behavior identical to
154    /// pre-grace versions). Has no effect when `max_iterations` is `None`.
155    pub grace_iterations: usize,
156
157    /// One-shot flag flipped by the graceful-turn-limit plugin when it
158    /// emits its wrap-up steering message. The loop reads this at end of
159    /// run to choose between `LoopOutcome::WrappedUp` and
160    /// `LoopOutcome::Done`. `None` when no plugin is installed (no soft
161    /// warning configured).
162    pub(crate) grace_signal: Option<Arc<AtomicBool>>,
163
164    pub(crate) plugins: PluginRegistry,
165}
166
167#[derive(Default)]
168pub(crate) struct PluginRegistry {
169    pub before_tool_call: Vec<Arc<dyn BeforeToolCall>>,
170    pub after_tool_call: Vec<Arc<dyn AfterToolCall>>,
171    pub context_transform: Vec<Arc<dyn ContextTransform>>,
172    pub event_observer: Vec<Arc<dyn EventObserver>>,
173    pub steering: Vec<Arc<dyn SteeringSource>>,
174    pub follow_up: Vec<Arc<dyn FollowUpSource>>,
175    pub tool_gate: Vec<Arc<dyn ToolGate>>,
176}
177
178/// Recovery strategy when the provider returns `StopReason::MaxTokens`.
179///
180/// On hit, the loop discards the truncated assistant turn (the
181/// `MessageStart`/`MessageEnd` events for it still fired — listeners
182/// correlate via the new `AgentEvent::OutputTokensEscalation`) and
183/// re-streams with a higher cap.
184///
185/// Bounded by `max_attempts` per turn. Hits the `ceiling` if set.
186/// `Fixed` ladders run out by definition once `attempts >=
187/// caps.len()`.
188#[derive(Debug, Clone)]
189pub struct MaxTokensRecovery {
190    /// Hard upper bound on retries within a single turn. The loop
191    /// emits at most `max_attempts` escalation events per turn; the
192    /// `attempts + 1`th call simply uses the ladder's last cap and
193    /// the result is accepted regardless.
194    pub max_attempts: u8,
195    /// How to derive the next cap from the previous one.
196    pub scaling: TokenScaling,
197    /// Hard upper bound on the cap itself. `None` means no ceiling
198    /// (relies on `max_attempts` to bound the spend). Recovery stops
199    /// short when the next computed cap would equal or fall below
200    /// the previous one (no progress).
201    pub ceiling: Option<u32>,
202}
203
204/// How successive recovery attempts grow `max_output_tokens`.
205#[derive(Debug, Clone)]
206pub enum TokenScaling {
207    /// Double per attempt: 4096 → 8192 → 16384 → ... Worst case
208    /// `2^max_attempts` × the starting cap. Default for callers
209    /// that prefer a small ladder with big steps.
210    Double,
211    /// Add a fixed step per attempt: 4096 → 4096+step → 4096+2·step.
212    /// Predictable cost ladder; better when the model usually only
213    /// needs a little more room.
214    Linear { step: u32 },
215    /// Explicit progression: `caps[0]` for the first retry, `caps[1]`
216    /// for the second, etc. Lets callers express "try 8k then 16k
217    /// then give up" without computing scales.
218    Fixed(Vec<u32>),
219}
220
221impl MaxTokensRecovery {
222    /// Default: 3 retries with doubling, no ceiling. Meant as the
223    /// "least-config option" for callers who just want the recovery
224    /// without tuning. Real deployments usually pin a `ceiling`
225    /// matching their model's hard max.
226    pub fn doubling() -> Self {
227        Self {
228            max_attempts: 3,
229            scaling: TokenScaling::Double,
230            ceiling: None,
231        }
232    }
233
234    /// Compute the cap for retry attempt `attempt_zero_indexed`
235    /// (0 = the first retry, after the original turn). Returns
236    /// `None` when the ladder cannot make further progress (Fixed
237    /// exhausted, ceiling reached at the previous step).
238    pub fn next_cap(&self, prev_cap: u32, attempt_zero_indexed: u8) -> Option<u32> {
239        let raw = match &self.scaling {
240            TokenScaling::Double => prev_cap.saturating_mul(2),
241            TokenScaling::Linear { step } => prev_cap.saturating_add(*step),
242            TokenScaling::Fixed(caps) => {
243                let idx = attempt_zero_indexed as usize;
244                *caps.get(idx)?
245            }
246        };
247        let bounded = match self.ceiling {
248            Some(c) => raw.min(c),
249            None => raw,
250        };
251        if bounded > prev_cap {
252            Some(bounded)
253        } else {
254            None
255        }
256    }
257}
258
259/// Fluent builder for [`LoopConfig`].
260///
261/// ```ignore
262/// let config = AgentBuilder::new()
263///     .stream(provider)
264///     .tools(registry)
265///     .event_sink(channel_sink)
266///     .before_tool_call(retired_path_gate)
267///     .after_tool_call(repeat_detector)
268///     .context_transform(token_budget_pruner)
269///     .steering(steering_source)
270///     .max_iterations(50)
271///     .build();
272/// ```
273pub struct AgentBuilder {
274    stream: Option<Arc<dyn StreamFn>>,
275    tools: Arc<ToolRegistry>,
276    event_sink: Arc<dyn EventSink>,
277    default_execution_mode: ExecutionMode,
278    max_tool_calls_per_turn: Option<usize>,
279    temperature: Option<f32>,
280    max_output_tokens: Option<u32>,
281    reasoning: ReasoningEffort,
282    provider_extras: Option<Value>,
283    max_output_tokens_recovery: Option<MaxTokensRecovery>,
284    max_iterations: Option<usize>,
285    empty_outcome_retry_budget: Option<usize>,
286    plain_text_terminal_fallback_tool: Option<String>,
287    plain_text_terminal_fallback_eager: bool,
288    plain_text_terminal_fallback_eager_nudge: bool,
289    grace_iterations: usize,
290    graceful_turn_limit_message_provider: Option<Arc<dyn Fn() -> String + Send + Sync>>,
291    graceful_turn_limit_grace_provider: Option<Arc<dyn Fn() -> usize + Send + Sync>>,
292    conversation_id: Option<String>,
293    model_id: Option<String>,
294    token_estimator: Arc<dyn TokenEstimator>,
295    protocol: Arc<dyn ProtocolPolicy>,
296    plugins: PluginRegistry,
297}
298
299impl Default for AgentBuilder {
300    fn default() -> Self {
301        Self::new()
302    }
303}
304
305impl AgentBuilder {
306    pub fn new() -> Self {
307        Self {
308            stream: None,
309            tools: Arc::new(ToolRegistry::new()),
310            event_sink: Arc::new(NoopSink),
311            default_execution_mode: ExecutionMode::Parallel,
312            max_tool_calls_per_turn: None,
313            temperature: None,
314            max_output_tokens: None,
315            reasoning: ReasoningEffort::default(),
316            provider_extras: None,
317            max_output_tokens_recovery: None,
318            max_iterations: None,
319            empty_outcome_retry_budget: None,
320            plain_text_terminal_fallback_tool: None,
321            plain_text_terminal_fallback_eager: false,
322            plain_text_terminal_fallback_eager_nudge: false,
323            grace_iterations: DEFAULT_GRACE_ITERATIONS,
324            graceful_turn_limit_message_provider: None,
325            graceful_turn_limit_grace_provider: None,
326            conversation_id: None,
327            model_id: None,
328            token_estimator: Arc::new(CharHeuristicEstimator),
329            protocol: default_policy(),
330            plugins: PluginRegistry::default(),
331        }
332    }
333
334    pub fn stream(mut self, stream: Arc<dyn StreamFn>) -> Self {
335        self.stream = Some(stream);
336        self
337    }
338
339    pub fn tools(mut self, tools: ToolRegistry) -> Self {
340        self.tools = Arc::new(tools);
341        self
342    }
343
344    /// Variant for callers that already share a registry by `Arc`.
345    pub fn tools_arc(mut self, tools: Arc<ToolRegistry>) -> Self {
346        self.tools = tools;
347        self
348    }
349
350    pub fn event_sink(mut self, sink: Arc<dyn EventSink>) -> Self {
351        self.event_sink = sink;
352        self
353    }
354
355    pub fn default_execution_mode(mut self, mode: ExecutionMode) -> Self {
356        self.default_execution_mode = mode;
357        self
358    }
359
360    pub fn max_tool_calls_per_turn(mut self, max: usize) -> Self {
361        self.max_tool_calls_per_turn = Some(max.max(1));
362        self
363    }
364
365    pub fn temperature(mut self, t: f32) -> Self {
366        self.temperature = Some(t);
367        self
368    }
369
370    pub fn max_output_tokens(mut self, t: u32) -> Self {
371        self.max_output_tokens = Some(t);
372        self
373    }
374
375    /// Set the reasoning-effort knob forwarded to the stream transport
376    /// on every turn. Per-run overrides flow through this typed surface
377    /// rather than through stringly-typed provider extras.
378    pub fn reasoning(mut self, level: ReasoningEffort) -> Self {
379        self.reasoning = level;
380        self
381    }
382
383    /// Set provider-specific extras forwarded to the stream transport
384    /// on every turn (e.g., `response_format` for structured output
385    /// enforcement).
386    pub fn provider_extras(mut self, extras: Value) -> Self {
387        self.provider_extras = Some(extras);
388        self
389    }
390
391    /// Enable max-output-tokens recovery. When the provider returns
392    /// `StopReason::MaxTokens`, the loop discards the truncated turn
393    /// and re-streams with a larger cap up to `recovery.max_attempts`
394    /// times. Off by default — opt in by passing a configured
395    /// `MaxTokensRecovery`. See the type for cost discussion.
396    pub fn max_output_tokens_recovery(mut self, recovery: MaxTokensRecovery) -> Self {
397        self.max_output_tokens_recovery = Some(recovery);
398        self
399    }
400
401    pub fn max_iterations(mut self, n: usize) -> Self {
402        self.max_iterations = Some(n);
403        self
404    }
405
406    /// Enable the no-tool outcome watchdog. `n` is the number of
407    /// no-tool assistant stops that recovery plugins may handle; the
408    /// next no-tool stop ends the run with a typed
409    /// [`crate::error::LoopError`].
410    pub fn empty_outcome_retry_budget(mut self, n: usize) -> Self {
411        self.empty_outcome_retry_budget = Some(n);
412        self
413    }
414
415    /// Convert plain assistant text into a terminal tool result on
416    /// terminal-only compatibility turns. Intended for providers that reject
417    /// `tool_choice: "required"` and therefore can leak final prose even
418    /// while the host advertises only delivery tools.
419    pub fn plain_text_terminal_fallback_tool(mut self, tool_name: impl Into<String>) -> Self {
420        self.plain_text_terminal_fallback_tool = Some(tool_name.into());
421        self
422    }
423
424    /// Make [`Self::plain_text_terminal_fallback_tool`] fire on the FIRST
425    /// plain-text stop instead of waiting for the turn allowlist to be
426    /// narrowed to terminators by a downstream tool gate. Use this for
427    /// providers in the "auto-when-forced" class where wire-level forcing
428    /// isn't available, so prose is the model's default failure mode and
429    /// the nudge cycle just burns turns. Has no effect unless
430    /// [`Self::plain_text_terminal_fallback_tool`] is also set.
431    pub fn plain_text_terminal_fallback_eager(mut self, eager: bool) -> Self {
432        self.plain_text_terminal_fallback_eager = eager;
433        self
434    }
435
436    /// Make the eager plain-text fallback path nudge the model with an
437    /// explicit protocol-recovery system message before synthesizing a
438    /// terminal tool result, giving up to a bounded number of retries to
439    /// follow the protocol. Off by default — opt in for evals and runs
440    /// where silently laundering prose into delivery is a worse outcome
441    /// than a small number of extra streaming turns. Has no effect unless
442    /// both [`Self::plain_text_terminal_fallback_tool`] and
443    /// [`Self::plain_text_terminal_fallback_eager`] are set.
444    pub fn plain_text_terminal_fallback_eager_nudge(mut self, on: bool) -> Self {
445        self.plain_text_terminal_fallback_eager_nudge = on;
446        self
447    }
448
449    /// Override the grace window used by the auto-installed
450    /// graceful-turn-limit plugin. Pass `0` to disable the soft warning
451    /// entirely (the loop will hit `max_iterations` with no advance
452    /// notice). Default is [`DEFAULT_GRACE_ITERATIONS`].
453    pub fn grace_iterations(mut self, n: usize) -> Self {
454        self.grace_iterations = n;
455        self
456    }
457
458    /// Override the one-shot wrap-up message emitted by the
459    /// auto-installed graceful-turn-limit plugin. Hosts can use this to
460    /// make the warning aware of product state while keeping the core
461    /// loop independent of product-specific types.
462    pub fn graceful_turn_limit_message_provider<F>(mut self, provider: F) -> Self
463    where
464        F: Fn() -> String + Send + Sync + 'static,
465    {
466        self.graceful_turn_limit_message_provider = Some(Arc::new(provider));
467        self
468    }
469
470    /// Supply a dynamic grace-iterations provider for the
471    /// auto-installed graceful-turn-limit plugin. The callback is
472    /// invoked on every steering poll, and its return value is
473    /// clamped into `[1, max_iterations - 1]`. Use this to scale the
474    /// wrap-up window with the size of the work in flight (e.g. more
475    /// open plan phases ⇒ a longer wrap-up window so a partial
476    /// delivery can still land). When unset, the static
477    /// [`grace_iterations`](Self::grace_iterations) value is used.
478    pub fn graceful_turn_limit_grace_provider<F>(mut self, provider: F) -> Self
479    where
480        F: Fn() -> usize + Send + Sync + 'static,
481    {
482        self.graceful_turn_limit_grace_provider = Some(Arc::new(provider));
483        self
484    }
485
486    /// Attach a conversation identifier so plugins can include
487    /// conversation-scoped diagnostics or policy. The agent core itself
488    /// does not consume this — it's just metadata threaded through
489    /// `ToolGateContext`. Optional; absent for tests and isolated
490    /// subagent runs.
491    pub fn conversation_id(mut self, id: impl Into<String>) -> Self {
492        self.conversation_id = Some(id.into());
493        self
494    }
495
496    /// Attach a model identifier so context transforms can read it via
497    /// [`crate::plugin::TransformContext::model_id`]. The loop itself
498    /// does not consume this; the active `StreamFn` already knows its
499    /// model. Optional — defaults to `None` (transforms see the empty
500    /// string).
501    pub fn model_id(mut self, id: impl Into<String>) -> Self {
502        self.model_id = Some(id.into());
503        self
504    }
505
506    /// Plug in a token estimator for budgeting and compaction. Defaults
507    /// to the char-heuristic estimator when not set. Pass an `Arc` if
508    /// the estimator is shared across multiple builders.
509    pub fn token_estimator<E: TokenEstimator>(mut self, est: E) -> Self {
510        self.token_estimator = Arc::new(est);
511        self
512    }
513
514    /// Variant for callers that already share an estimator by `Arc`.
515    pub fn token_estimator_arc(mut self, est: Arc<dyn TokenEstimator>) -> Self {
516        self.token_estimator = est;
517        self
518    }
519
520    /// Install a [`ProtocolPolicy`] — the seam through which a downstream
521    /// product supplies its tool vocabulary (plain-text recovery prose,
522    /// tool-call alias repair, hidden-tool errors, terminal-tool
523    /// classification). Defaults to [`crate::DefaultProtocolPolicy`] when
524    /// not set, which keeps the core free of any product tool names. See
525    /// [`crate::protocol`].
526    pub fn protocol_policy(mut self, policy: Arc<dyn ProtocolPolicy>) -> Self {
527        self.protocol = policy;
528        self
529    }
530
531    // ─── Plugin registration (one method per capability) ────────────
532
533    pub fn before_tool_call<P: BeforeToolCall + 'static>(mut self, plugin: P) -> Self {
534        self.plugins.before_tool_call.push(Arc::new(plugin));
535        self
536    }
537
538    pub fn after_tool_call<P: AfterToolCall + 'static>(mut self, plugin: P) -> Self {
539        self.plugins.after_tool_call.push(Arc::new(plugin));
540        self
541    }
542
543    pub fn context_transform<P: ContextTransform + 'static>(mut self, plugin: P) -> Self {
544        self.plugins.context_transform.push(Arc::new(plugin));
545        self
546    }
547
548    pub fn event_observer<P: EventObserver + 'static>(mut self, plugin: P) -> Self {
549        self.plugins.event_observer.push(Arc::new(plugin));
550        self
551    }
552
553    pub fn steering<P: SteeringSource + 'static>(mut self, plugin: P) -> Self {
554        self.plugins.steering.push(Arc::new(plugin));
555        self
556    }
557
558    pub fn follow_up<P: FollowUpSource + 'static>(mut self, plugin: P) -> Self {
559        self.plugins.follow_up.push(Arc::new(plugin));
560        self
561    }
562
563    /// Variant that takes pre-`Arc`'d trait objects, useful when the
564    /// caller already has shared plugin instances.
565    pub fn before_tool_call_arc(mut self, plugin: Arc<dyn BeforeToolCall>) -> Self {
566        self.plugins.before_tool_call.push(plugin);
567        self
568    }
569    pub fn after_tool_call_arc(mut self, plugin: Arc<dyn AfterToolCall>) -> Self {
570        self.plugins.after_tool_call.push(plugin);
571        self
572    }
573    pub fn context_transform_arc(mut self, plugin: Arc<dyn ContextTransform>) -> Self {
574        self.plugins.context_transform.push(plugin);
575        self
576    }
577    pub fn event_observer_arc(mut self, plugin: Arc<dyn EventObserver>) -> Self {
578        self.plugins.event_observer.push(plugin);
579        self
580    }
581    pub fn follow_up_arc(mut self, plugin: Arc<dyn FollowUpSource>) -> Self {
582        self.plugins.follow_up.push(plugin);
583        self
584    }
585    pub fn steering_arc(mut self, plugin: Arc<dyn SteeringSource>) -> Self {
586        self.plugins.steering.push(plugin);
587        self
588    }
589    pub fn tool_gate_arc(mut self, plugin: Arc<dyn ToolGate>) -> Self {
590        self.plugins.tool_gate.push(plugin);
591        self
592    }
593
594    /// Generic plugin registration. Inspects [`Plugin::capabilities`] to
595    /// decide which dispatch lists to add the plugin to. Same `Arc` is
596    /// shared across all enabled capabilities so a single plugin
597    /// instance can implement multiple traits.
598    pub fn plugin<P>(mut self, plugin: Arc<P>) -> Self
599    where
600        P: Plugin
601            + BeforeToolCall
602            + AfterToolCall
603            + ContextTransform
604            + EventObserver
605            + SteeringSource
606            + FollowUpSource
607            + ToolGate
608            + 'static,
609    {
610        let caps = plugin.capabilities();
611        if caps.before_tool_call {
612            self.plugins
613                .before_tool_call
614                .push(plugin.clone() as Arc<dyn BeforeToolCall>);
615        }
616        if caps.after_tool_call {
617            self.plugins
618                .after_tool_call
619                .push(plugin.clone() as Arc<dyn AfterToolCall>);
620        }
621        if caps.context_transform {
622            self.plugins
623                .context_transform
624                .push(plugin.clone() as Arc<dyn ContextTransform>);
625        }
626        if caps.event_observer {
627            self.plugins
628                .event_observer
629                .push(plugin.clone() as Arc<dyn EventObserver>);
630        }
631        if caps.steering {
632            self.plugins
633                .steering
634                .push(plugin.clone() as Arc<dyn SteeringSource>);
635        }
636        if caps.follow_up {
637            self.plugins
638                .follow_up
639                .push(plugin.clone() as Arc<dyn FollowUpSource>);
640        }
641        if caps.tool_gate {
642            self.plugins.tool_gate.push(plugin as Arc<dyn ToolGate>);
643        }
644        self
645    }
646
647    pub fn build(mut self) -> Result<LoopConfig, BuilderError> {
648        let stream = self.stream.ok_or(BuilderError::MissingStream)?;
649
650        // Auto-install the graceful-turn-limit plugin when both a hard
651        // cap and a grace window are configured. Mirrors how
652        // `ThinkingTagStreamFilter` is auto-wired by the bridge: callers
653        // shouldn't have to remember to register the standard
654        // safety-net plugins.
655        let grace_signal = match (self.max_iterations, self.grace_iterations) {
656            (Some(max), grace) if grace > 0 => {
657                let grace_provider = self.graceful_turn_limit_grace_provider.take();
658                let message_provider = self
659                    .graceful_turn_limit_message_provider
660                    .take()
661                    .unwrap_or_else(|| {
662                        Arc::new(|| GracefulTurnLimit::default_wrap_up_message().to_string())
663                    });
664                let plugin = GracefulTurnLimit::from_hard_cap_with_providers(
665                    max,
666                    grace,
667                    message_provider,
668                    grace_provider,
669                );
670                if let Some(plugin) = plugin {
671                    let signal = plugin.signal();
672                    let arc: Arc<GracefulTurnLimit> = Arc::new(plugin);
673                    self.plugins
674                        .event_observer
675                        .push(arc.clone() as Arc<dyn EventObserver>);
676                    self.plugins.steering.push(arc as Arc<dyn SteeringSource>);
677                    Some(signal)
678                } else {
679                    // grace >= max: no useful soft window — skip install.
680                    None
681                }
682            }
683            _ => None,
684        };
685
686        Ok(LoopConfig {
687            stream,
688            tools: self.tools,
689            event_sink: self.event_sink,
690            default_execution_mode: self.default_execution_mode,
691            max_tool_calls_per_turn: self.max_tool_calls_per_turn,
692            temperature: self.temperature,
693            max_output_tokens: self.max_output_tokens,
694            reasoning: self.reasoning,
695            provider_extras: self.provider_extras,
696            max_output_tokens_recovery: self.max_output_tokens_recovery,
697            max_iterations: self.max_iterations,
698            empty_outcome_retry_budget: self.empty_outcome_retry_budget,
699            plain_text_terminal_fallback_tool: self.plain_text_terminal_fallback_tool,
700            plain_text_terminal_fallback_eager: self.plain_text_terminal_fallback_eager,
701            plain_text_terminal_fallback_eager_nudge: self.plain_text_terminal_fallback_eager_nudge,
702            grace_iterations: self.grace_iterations,
703            conversation_id: self.conversation_id,
704            model_id: self.model_id,
705            token_estimator: self.token_estimator,
706            protocol: self.protocol,
707            grace_signal,
708            plugins: self.plugins,
709        })
710    }
711}
712
713#[derive(Debug, thiserror::Error)]
714pub enum BuilderError {
715    #[error("missing stream transport: call AgentBuilder::stream() before build()")]
716    MissingStream,
717}
718
719/// Snapshot of registered plugin names per category, in registration order.
720///
721/// Returned by [`LoopConfig::plugin_names`] for inspection / regression
722/// tests. Order matches the order the loop will invoke each plugin
723/// (left-to-right composition for `ContextTransform`, etc.). Pure read
724/// — does not clone the plugins themselves.
725#[derive(Debug, Clone, Default, PartialEq, Eq)]
726pub struct PluginNames {
727    pub before_tool_call: Vec<&'static str>,
728    pub after_tool_call: Vec<&'static str>,
729    pub context_transform: Vec<&'static str>,
730    pub event_observer: Vec<&'static str>,
731    pub steering: Vec<&'static str>,
732    pub follow_up: Vec<&'static str>,
733    pub tool_gate: Vec<&'static str>,
734}
735
736impl LoopConfig {
737    /// Build an [`AgentBuilder`] pre-populated for a child run spawned
738    /// from this config.
739    ///
740    /// Inherits, by value or `Arc`:
741    /// - stream transport, tool registry, token estimator
742    /// - sampling controls (`temperature`, `max_output_tokens`,
743    ///   `reasoning`)
744    /// - max-output-tokens recovery ladder
745    /// - default execution mode, `max_tool_calls_per_turn`
746    /// - model id, grace iterations
747    /// - protocol policy ([`ProtocolPolicy`])
748    /// - plain-text-terminal fallback knobs
749    /// - every plugin whose
750    ///   [`crate::plugin::PluginCapabilities::inheritable_to_child`]
751    ///   bit is set
752    ///
753    /// Does **not** inherit:
754    /// - `event_sink` — callers install a child-scoped sink before
755    ///   `build`.
756    /// - `max_iterations` — children get their own budget; defaults
757    ///   to unbounded until the caller sets one.
758    /// - `empty_outcome_retry_budget` — child runs make independent
759    ///   recovery decisions.
760    /// - `conversation_id` — the child should carry its own identity
761    ///   via [`crate::AgentContext::identity`].
762    /// - plugins that did **not** opt in to inheritance — they remain
763    ///   parent-only.
764    ///
765    /// This is the single primitive for "spawn a fresh child agent with
766    /// the same execution shape as me." A host runtime still registers
767    /// any child-specific guards (delivery gates, terminal guards, etc.)
768    /// on top of the returned builder.
769    pub fn child_builder(&self) -> AgentBuilder {
770        let mut builder = AgentBuilder::new()
771            .stream(self.stream.clone())
772            .tools_arc(self.tools.clone())
773            .default_execution_mode(self.default_execution_mode)
774            .reasoning(self.reasoning)
775            .grace_iterations(self.grace_iterations)
776            .token_estimator_arc(self.token_estimator.clone())
777            .protocol_policy(self.protocol.clone());
778        if let Some(t) = self.temperature {
779            builder = builder.temperature(t);
780        }
781        if let Some(m) = self.max_output_tokens {
782            builder = builder.max_output_tokens(m);
783        }
784        if let Some(n) = self.max_tool_calls_per_turn {
785            builder = builder.max_tool_calls_per_turn(n);
786        }
787        if let Some(r) = self.max_output_tokens_recovery.clone() {
788            builder = builder.max_output_tokens_recovery(r);
789        }
790        if let Some(id) = &self.model_id {
791            builder = builder.model_id(id.clone());
792        }
793        if let Some(tool) = &self.plain_text_terminal_fallback_tool {
794            builder = builder
795                .plain_text_terminal_fallback_tool(tool.clone())
796                .plain_text_terminal_fallback_eager(self.plain_text_terminal_fallback_eager)
797                .plain_text_terminal_fallback_eager_nudge(
798                    self.plain_text_terminal_fallback_eager_nudge,
799                );
800        }
801
802        for p in &self.plugins.before_tool_call {
803            if p.capabilities().inheritable_to_child {
804                builder = builder.before_tool_call_arc(p.clone());
805            }
806        }
807        for p in &self.plugins.after_tool_call {
808            if p.capabilities().inheritable_to_child {
809                builder = builder.after_tool_call_arc(p.clone());
810            }
811        }
812        for p in &self.plugins.context_transform {
813            if p.capabilities().inheritable_to_child {
814                builder = builder.context_transform_arc(p.clone());
815            }
816        }
817        for p in &self.plugins.event_observer {
818            if p.capabilities().inheritable_to_child {
819                builder = builder.event_observer_arc(p.clone());
820            }
821        }
822        for p in &self.plugins.steering {
823            if p.capabilities().inheritable_to_child {
824                builder = builder.steering_arc(p.clone());
825            }
826        }
827        for p in &self.plugins.follow_up {
828            if p.capabilities().inheritable_to_child {
829                builder = builder.follow_up_arc(p.clone());
830            }
831        }
832        for p in &self.plugins.tool_gate {
833            if p.capabilities().inheritable_to_child {
834                builder = builder.tool_gate_arc(p.clone());
835            }
836        }
837
838        builder
839    }
840
841    /// Plugin names per category, in registration order. The composition
842    /// order is part of the loop's external contract — bridges and host
843    /// runtimes assemble plugins in a specific order so transforms run
844    /// before token-budget pruning, gates fire before terminator
845    /// validation, etc. Tests use this to pin the assembled order so
846    /// silent reorderings during refactors surface as a diff instead of
847    /// a runtime regression.
848    pub fn plugin_names(&self) -> PluginNames {
849        PluginNames {
850            before_tool_call: self
851                .plugins
852                .before_tool_call
853                .iter()
854                .map(|p| p.name())
855                .collect(),
856            after_tool_call: self
857                .plugins
858                .after_tool_call
859                .iter()
860                .map(|p| p.name())
861                .collect(),
862            context_transform: self
863                .plugins
864                .context_transform
865                .iter()
866                .map(|p| p.name())
867                .collect(),
868            event_observer: self
869                .plugins
870                .event_observer
871                .iter()
872                .map(|p| p.name())
873                .collect(),
874            steering: self.plugins.steering.iter().map(|p| p.name()).collect(),
875            follow_up: self.plugins.follow_up.iter().map(|p| p.name()).collect(),
876            tool_gate: self.plugins.tool_gate.iter().map(|p| p.name()).collect(),
877        }
878    }
879}
880
881#[cfg(test)]
882mod recovery_tests {
883    use super::*;
884
885    #[test]
886    fn doubling_walks_powers_of_two() {
887        let recovery = MaxTokensRecovery::doubling();
888        assert_eq!(recovery.next_cap(4096, 0), Some(8192));
889        assert_eq!(recovery.next_cap(8192, 1), Some(16384));
890        assert_eq!(recovery.next_cap(16384, 2), Some(32768));
891    }
892
893    #[test]
894    fn ceiling_clamps_growth() {
895        let recovery = MaxTokensRecovery {
896            max_attempts: 3,
897            scaling: TokenScaling::Double,
898            ceiling: Some(10_000),
899        };
900        assert_eq!(recovery.next_cap(4096, 0), Some(8192));
901        // Doubling 8192 -> 16384, clamped to 10_000 (still > prev).
902        assert_eq!(recovery.next_cap(8192, 1), Some(10_000));
903        // Already at ceiling: no progress possible.
904        assert_eq!(recovery.next_cap(10_000, 2), None);
905    }
906
907    #[test]
908    fn linear_step_adds_per_attempt() {
909        let recovery = MaxTokensRecovery {
910            max_attempts: 3,
911            scaling: TokenScaling::Linear { step: 2_000 },
912            ceiling: None,
913        };
914        assert_eq!(recovery.next_cap(4096, 0), Some(6_096));
915        assert_eq!(recovery.next_cap(6_096, 1), Some(8_096));
916    }
917
918    #[test]
919    fn fixed_progression_runs_out() {
920        let recovery = MaxTokensRecovery {
921            max_attempts: 4,
922            scaling: TokenScaling::Fixed(vec![8_000, 16_000]),
923            ceiling: None,
924        };
925        assert_eq!(recovery.next_cap(4_096, 0), Some(8_000));
926        assert_eq!(recovery.next_cap(8_000, 1), Some(16_000));
927        // Ladder exhausted even though attempts remain.
928        assert_eq!(recovery.next_cap(16_000, 2), None);
929    }
930
931    #[test]
932    fn no_progress_returns_none() {
933        // Linear with step=0 cannot make progress.
934        let recovery = MaxTokensRecovery {
935            max_attempts: 3,
936            scaling: TokenScaling::Linear { step: 0 },
937            ceiling: None,
938        };
939        assert_eq!(recovery.next_cap(4_096, 0), None);
940    }
941}
942
943#[cfg(test)]
944mod child_builder_tests {
945    use super::*;
946    use crate::plugin::{Plugin, PluginCapabilities};
947    use crate::stream::{StreamEvent, StreamFn, StreamRequest};
948    use async_trait::async_trait;
949    use futures::stream::BoxStream;
950    use futures::StreamExt;
951
952    struct EmptyStream;
953    #[async_trait]
954    impl StreamFn for EmptyStream {
955        async fn stream(
956            &self,
957            _r: StreamRequest,
958            _s: tokio_util::sync::CancellationToken,
959        ) -> BoxStream<'static, StreamEvent> {
960            futures::stream::empty().boxed()
961        }
962    }
963
964    struct ParentOnlyPlugin;
965    impl Plugin for ParentOnlyPlugin {
966        fn name(&self) -> &'static str {
967            "parent_only"
968        }
969        fn capabilities(&self) -> PluginCapabilities {
970            PluginCapabilities::event_observer()
971        }
972    }
973    #[async_trait]
974    impl crate::EventObserver for ParentOnlyPlugin {
975        async fn on_event(&self, _event: &crate::AgentEvent) {}
976    }
977
978    struct InheritablePlugin;
979    impl Plugin for InheritablePlugin {
980        fn name(&self) -> &'static str {
981            "inheritable"
982        }
983        fn capabilities(&self) -> PluginCapabilities {
984            PluginCapabilities::event_observer().with_inheritable_to_child()
985        }
986    }
987    #[async_trait]
988    impl crate::EventObserver for InheritablePlugin {
989        async fn on_event(&self, _event: &crate::AgentEvent) {}
990    }
991
992    #[test]
993    fn child_builder_inherits_only_opted_in_plugins() {
994        let parent = AgentBuilder::new()
995            .stream(Arc::new(EmptyStream))
996            .event_observer(ParentOnlyPlugin)
997            .event_observer(InheritablePlugin)
998            .max_iterations(10)
999            .build()
1000            .expect("parent builds");
1001
1002        let child = parent.child_builder().build().expect("child builds");
1003
1004        let names = child.plugin_names();
1005        assert_eq!(
1006            names.event_observer,
1007            vec!["inheritable"],
1008            "child must drop parent-only plugins"
1009        );
1010    }
1011
1012    #[test]
1013    fn child_builder_carries_sampling_and_recovery_knobs() {
1014        let parent = AgentBuilder::new()
1015            .stream(Arc::new(EmptyStream))
1016            .temperature(0.3)
1017            .max_output_tokens(8192)
1018            .max_tool_calls_per_turn(3)
1019            .max_output_tokens_recovery(MaxTokensRecovery::doubling())
1020            .model_id("test-model")
1021            .build()
1022            .expect("parent builds");
1023
1024        let child = parent.child_builder().build().expect("child builds");
1025
1026        assert_eq!(child.temperature, Some(0.3));
1027        assert_eq!(child.max_output_tokens, Some(8192));
1028        assert_eq!(child.max_tool_calls_per_turn, Some(3));
1029        assert!(child.max_output_tokens_recovery.is_some());
1030        assert_eq!(child.model_id.as_deref(), Some("test-model"));
1031    }
1032
1033    #[test]
1034    fn child_builder_does_not_inherit_max_iterations() {
1035        let parent = AgentBuilder::new()
1036            .stream(Arc::new(EmptyStream))
1037            .max_iterations(50)
1038            .build()
1039            .expect("parent builds");
1040
1041        let child = parent.child_builder().build().expect("child builds");
1042        assert_eq!(
1043            child.max_iterations, None,
1044            "child gets its own iteration budget, not the parent's"
1045        );
1046    }
1047}