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}