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}