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