Skip to main content

swink_agent/
agent_options.rs

1//! Configuration options for constructing an [`Agent`](crate::Agent).
2//!
3//! [`AgentOptions`] is the single entry point for wiring up an agent: it
4//! collects the model spec, stream function, tools, hooks, and policies that
5//! the agent loop needs.
6
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use crate::async_context_transformer::AsyncContextTransformer;
12use crate::checkpoint::CheckpointStore;
13use crate::loop_::AgentEvent;
14use crate::message_provider::MessageProvider;
15use crate::retry::{DefaultRetryStrategy, RetryStrategy};
16use crate::stream::{StreamFn, StreamOptions};
17use crate::tool::{AgentTool, ApprovalMode, ToolApproval, ToolApprovalRequest};
18use crate::types::{AgentMessage, CustomMessageRegistry, LlmMessage, ModelSpec};
19
20// ─── Type aliases (module-local) ─────────────────────────────────────────────
21
22pub(crate) type ConvertToLlmFn = Arc<dyn Fn(&AgentMessage) -> Option<LlmMessage> + Send + Sync>;
23pub(crate) type TransformContextArc = Arc<dyn crate::context_transformer::ContextTransformer>;
24pub(crate) type AsyncTransformContextArc = Arc<dyn AsyncContextTransformer>;
25pub(crate) type CheckpointStoreArc = Arc<dyn CheckpointStore>;
26pub(crate) type GetApiKeyFuture = Pin<Box<dyn Future<Output = Option<String>> + Send>>;
27pub(crate) type GetApiKeyFn = dyn Fn(&str) -> GetApiKeyFuture + Send + Sync;
28pub(crate) type GetApiKeyArc = Arc<GetApiKeyFn>;
29pub(crate) type ApproveToolFuture = Pin<Box<dyn Future<Output = ToolApproval> + Send>>;
30pub(crate) type ApproveToolFn = dyn Fn(ToolApprovalRequest) -> ApproveToolFuture + Send + Sync;
31pub(crate) type ApproveToolArc = Arc<ApproveToolFn>;
32
33// ─── Plan mode addendum ───────────────────────────────────────────────────────
34
35/// Fallback addendum appended in plan mode when no custom addendum is set.
36pub const DEFAULT_PLAN_MODE_ADDENDUM: &str = "\n\nYou are in planning mode. Analyze the request and produce a step-by-step plan. Do not make any modifications or execute any write operations.";
37
38// ─── AgentOptions ─────────────────────────────────────────────────────────────
39
40/// Configuration options for constructing an [`Agent`](crate::Agent).
41pub struct AgentOptions {
42    /// System prompt (used as-is when no static/dynamic split is configured).
43    pub system_prompt: String,
44    /// Static portion of the system prompt (cacheable, immutable for the agent lifetime).
45    ///
46    /// When set, takes precedence over `system_prompt` as the system message.
47    pub static_system_prompt: Option<String>,
48    /// Dynamic portion of the system prompt (per-turn, non-cacheable).
49    ///
50    /// Called fresh each turn. Its output is injected as a separate user-role
51    /// message immediately after the system prompt, so it does not invalidate
52    /// provider-side caches.
53    pub dynamic_system_prompt: Option<Box<dyn Fn() -> String + Send + Sync>>,
54    /// Model specification.
55    pub model: ModelSpec,
56    /// Available tools.
57    pub tools: Vec<Arc<dyn AgentTool>>,
58    /// The streaming function implementation.
59    pub stream_fn: Arc<dyn StreamFn>,
60    /// Converts agent messages to LLM messages (filter custom messages).
61    pub convert_to_llm: ConvertToLlmFn,
62    /// Optional context transformer.
63    pub transform_context: Option<TransformContextArc>,
64    /// Optional async API key resolver.
65    pub get_api_key: Option<GetApiKeyArc>,
66    /// Retry strategy for transient failures.
67    pub retry_strategy: Box<dyn RetryStrategy>,
68    /// Per-call stream options.
69    pub stream_options: StreamOptions,
70    /// Steering queue drain mode.
71    pub steering_mode: crate::agent::SteeringMode,
72    /// Follow-up queue drain mode.
73    pub follow_up_mode: crate::agent::FollowUpMode,
74    /// Max retries for structured output validation.
75    pub structured_output_max_retries: usize,
76    /// Optional async callback for approving/rejecting tool calls before execution.
77    pub approve_tool: Option<ApproveToolArc>,
78    /// Controls whether the approval gate is active. Defaults to `Enabled`.
79    pub approval_mode: ApprovalMode,
80    /// Additional model specs for model cycling (each with its own stream function).
81    pub available_models: Vec<(ModelSpec, Arc<dyn StreamFn>)>,
82    /// Pre-turn policies evaluated before each LLM call.
83    pub pre_turn_policies: Vec<Arc<dyn crate::policy::PreTurnPolicy>>,
84    /// Pre-dispatch policies evaluated per tool call, before approval.
85    pub pre_dispatch_policies: Vec<Arc<dyn crate::policy::PreDispatchPolicy>>,
86    /// Post-turn policies evaluated after each completed turn.
87    pub post_turn_policies: Vec<Arc<dyn crate::policy::PostTurnPolicy>>,
88    /// Post-loop policies evaluated after the inner loop exits.
89    pub post_loop_policies: Vec<Arc<dyn crate::policy::PostLoopPolicy>>,
90    /// Event forwarders that receive all dispatched events.
91    pub event_forwarders: Vec<crate::event_forwarder::EventForwarderFn>,
92    /// Optional async context transformer (runs before the sync transformer).
93    pub async_transform_context: Option<AsyncTransformContextArc>,
94    /// Optional checkpoint store for persisting agent state.
95    pub checkpoint_store: Option<CheckpointStoreArc>,
96    /// Optional registry used to deserialize persisted [`CustomMessage`](crate::types::CustomMessage)
97    /// values when restoring from a [`Checkpoint`](crate::checkpoint::Checkpoint) or
98    /// [`LoopCheckpoint`](crate::checkpoint::LoopCheckpoint).
99    ///
100    /// When set, the agent's `restore_from_checkpoint` / `load_and_restore_checkpoint`
101    /// / `resume` / `resume_stream` paths thread this registry into
102    /// [`Checkpoint::restore_messages`](crate::checkpoint::Checkpoint::restore_messages) so that custom messages survive a round
103    /// trip through the checkpoint store. When `None`, persisted custom messages
104    /// are silently dropped on restore (legacy behavior).
105    pub custom_message_registry: Option<Arc<CustomMessageRegistry>>,
106    /// Optional metrics collector for per-turn observability.
107    pub metrics_collector: Option<Arc<dyn crate::metrics::MetricsCollector>>,
108    /// Optional custom token counter for context compaction.
109    ///
110    /// When set, the default [`SlidingWindowTransformer`](crate::SlidingWindowTransformer)
111    /// uses this counter instead of the `chars / 4` heuristic. Has no effect if a
112    /// custom `transform_context` is provided (use
113    /// [`SlidingWindowTransformer::with_token_counter`](crate::SlidingWindowTransformer::with_token_counter)
114    /// directly in that case).
115    pub token_counter: Option<Arc<dyn crate::context::TokenCounter>>,
116    /// Optional model fallback chain tried when the primary model exhausts
117    /// its retry budget on a retryable error.
118    pub fallback: Option<crate::fallback::ModelFallback>,
119    /// Optional external message provider composed with the internal queues.
120    ///
121    /// Set via [`with_message_channel`](Self::with_message_channel) or
122    /// [`with_external_message_provider`](Self::with_external_message_provider).
123    pub external_message_provider: Option<Arc<dyn MessageProvider>>,
124    /// Controls how tool calls within a turn are dispatched.
125    ///
126    /// Defaults to [`Concurrent`](crate::tool_execution_policy::ToolExecutionPolicy::Concurrent).
127    pub tool_execution_policy: crate::tool_execution_policy::ToolExecutionPolicy,
128    /// Optional addendum appended to the system prompt when entering plan mode.
129    ///
130    /// Falls back to [`DEFAULT_PLAN_MODE_ADDENDUM`] when `None`.
131    pub plan_mode_addendum: Option<String>,
132    /// Optional initial session state for pre-seeding key-value pairs.
133    pub session_state: Option<crate::SessionState>,
134    /// Optional credential resolver for tool authentication.
135    ///
136    /// When set, tools that return `Some` from [`auth_config()`](crate::AgentTool::auth_config)
137    /// will have their credentials resolved before execution.
138    pub credential_resolver: Option<Arc<dyn crate::credential::CredentialResolver>>,
139    /// Optional context caching configuration.
140    ///
141    /// When set, the turn pipeline annotates cacheable prefix messages with
142    /// [`CacheHint`](crate::context_cache::CacheHint) markers and emits
143    /// [`AgentEvent::CacheAction`](crate::AgentEvent) events.
144    pub cache_config: Option<crate::context_cache::CacheConfig>,
145    /// Plugins that contribute policies, tools, and event observers.
146    ///
147    /// Plugins are merged into the agent during construction. Plugin policies
148    /// are prepended before directly-registered policies; plugin tools are
149    /// appended after direct tools (namespaced with the plugin name).
150    #[cfg(feature = "plugins")]
151    pub plugins: Vec<Arc<dyn crate::plugin::Plugin>>,
152    /// Optional agent name used for transfer chain safety enforcement.
153    ///
154    /// When set, the loop pushes this name onto the [`TransferChain`](crate::transfer::TransferChain)
155    /// at startup so circular transfers back to this agent are detected.
156    pub agent_name: Option<String>,
157    /// Optional transfer chain carried from a previous handoff.
158    ///
159    /// When set, the loop starts with this chain instead of an empty one.
160    pub transfer_chain: Option<crate::transfer::TransferChain>,
161}
162
163impl AgentOptions {
164    /// Create options with required fields and sensible defaults.
165    #[must_use]
166    pub fn new(
167        system_prompt: impl Into<String>,
168        model: ModelSpec,
169        stream_fn: Arc<dyn StreamFn>,
170        convert_to_llm: impl Fn(&AgentMessage) -> Option<LlmMessage> + Send + Sync + 'static,
171    ) -> Self {
172        Self {
173            system_prompt: system_prompt.into(),
174            static_system_prompt: None,
175            dynamic_system_prompt: None,
176            model,
177            tools: Vec::new(),
178            stream_fn,
179            convert_to_llm: Arc::new(convert_to_llm),
180            transform_context: Some(Arc::new(
181                crate::context_transformer::SlidingWindowTransformer::new(100_000, 50_000, 2),
182            )),
183            get_api_key: None,
184            retry_strategy: Box::new(DefaultRetryStrategy::default()),
185            stream_options: StreamOptions::default(),
186            steering_mode: crate::agent::SteeringMode::default(),
187            follow_up_mode: crate::agent::FollowUpMode::default(),
188            structured_output_max_retries: 3,
189            approve_tool: None,
190            approval_mode: ApprovalMode::default(),
191            available_models: Vec::new(),
192            pre_turn_policies: Vec::new(),
193            pre_dispatch_policies: Vec::new(),
194            post_turn_policies: Vec::new(),
195            post_loop_policies: Vec::new(),
196            event_forwarders: Vec::new(),
197            async_transform_context: None,
198            checkpoint_store: None,
199            custom_message_registry: None,
200            metrics_collector: None,
201            token_counter: None,
202            fallback: None,
203            external_message_provider: None,
204            tool_execution_policy: crate::tool_execution_policy::ToolExecutionPolicy::default(),
205            plan_mode_addendum: None,
206            session_state: None,
207            credential_resolver: None,
208            cache_config: None,
209            #[cfg(feature = "plugins")]
210            plugins: Vec::new(),
211            agent_name: None,
212            transfer_chain: None,
213        }
214    }
215
216    /// Simplified constructor using [`default_convert`](crate::default_convert) and sensible defaults.
217    ///
218    /// Equivalent to `AgentOptions::new(system_prompt, model, stream_fn, default_convert)`.
219    #[must_use]
220    pub fn new_simple(
221        system_prompt: impl Into<String>,
222        model: ModelSpec,
223        stream_fn: Arc<dyn StreamFn>,
224    ) -> Self {
225        Self::new(
226            system_prompt,
227            model,
228            stream_fn,
229            crate::agent::default_convert,
230        )
231    }
232
233    /// Build options directly from a [`ModelConnections`](crate::ModelConnections) bundle.
234    ///
235    /// This avoids the manual `into_parts()` decomposition. The primary model
236    /// and stream function are extracted, and any extra models are set as
237    /// available models for cycling.
238    #[must_use]
239    pub fn from_connections(
240        system_prompt: impl Into<String>,
241        connections: crate::model_presets::ModelConnections,
242    ) -> Self {
243        let (model, stream_fn, extra_models) = connections.into_parts();
244        Self::new_simple(system_prompt, model, stream_fn).with_available_models(extra_models)
245    }
246
247    /// Set the available tools.
248    #[must_use]
249    pub fn with_tools(mut self, tools: Vec<Arc<dyn AgentTool>>) -> Self {
250        self.tools = tools;
251        self
252    }
253
254    /// Convenience: register all built-in tools (bash, read-file, write-file).
255    #[cfg(feature = "builtin-tools")]
256    #[must_use]
257    pub fn with_default_tools(self) -> Self {
258        self.with_tools(crate::tools::builtin_tools())
259    }
260
261    /// Set the retry strategy.
262    #[must_use]
263    pub fn with_retry_strategy(mut self, strategy: Box<dyn RetryStrategy>) -> Self {
264        self.retry_strategy = strategy;
265        self
266    }
267
268    /// Set the stream options.
269    #[must_use]
270    pub fn with_stream_options(mut self, options: StreamOptions) -> Self {
271        self.stream_options = options;
272        self
273    }
274
275    /// Set the context transformer.
276    #[must_use]
277    pub fn with_transform_context(
278        mut self,
279        transformer: impl crate::context_transformer::ContextTransformer + 'static,
280    ) -> Self {
281        self.transform_context = Some(Arc::new(transformer));
282        self
283    }
284
285    /// Set the context transform hook using a closure.
286    ///
287    /// Backward-compatible with the old closure-based API. The closure
288    /// receives `(&mut Vec<AgentMessage>, bool)` where the bool is the overflow signal.
289    #[must_use]
290    pub fn with_transform_context_fn(
291        mut self,
292        f: impl Fn(&mut Vec<AgentMessage>, bool) + Send + Sync + 'static,
293    ) -> Self {
294        self.transform_context = Some(Arc::new(f));
295        self
296    }
297
298    /// Set the API key resolver.
299    #[must_use]
300    pub fn with_get_api_key(
301        mut self,
302        f: impl Fn(&str) -> GetApiKeyFuture + Send + Sync + 'static,
303    ) -> Self {
304        self.get_api_key = Some(Arc::new(f));
305        self
306    }
307
308    /// Set the steering mode.
309    #[must_use]
310    pub const fn with_steering_mode(mut self, mode: crate::agent::SteeringMode) -> Self {
311        self.steering_mode = mode;
312        self
313    }
314
315    /// Set the follow-up mode.
316    #[must_use]
317    pub const fn with_follow_up_mode(mut self, mode: crate::agent::FollowUpMode) -> Self {
318        self.follow_up_mode = mode;
319        self
320    }
321
322    /// Set the max retries for structured output.
323    #[must_use]
324    pub const fn with_structured_output_max_retries(mut self, n: usize) -> Self {
325        self.structured_output_max_retries = n;
326        self
327    }
328
329    /// Set the tool approval callback.
330    #[must_use]
331    pub fn with_approve_tool(
332        mut self,
333        f: impl Fn(ToolApprovalRequest) -> ApproveToolFuture + Send + Sync + 'static,
334    ) -> Self {
335        self.approve_tool = Some(Arc::new(f));
336        self
337    }
338
339    /// Sets the tool approval callback using an async closure.
340    ///
341    /// This is a convenience wrapper around [`with_approve_tool`](Self::with_approve_tool)
342    /// that avoids the `Pin<Box<dyn Future>>` return type ceremony.
343    #[must_use]
344    pub fn with_approve_tool_async<F, Fut>(mut self, f: F) -> Self
345    where
346        F: Fn(ToolApprovalRequest) -> Fut + Send + Sync + 'static,
347        Fut: std::future::Future<Output = ToolApproval> + Send + 'static,
348    {
349        let f = std::sync::Arc::new(f);
350        self.approve_tool = Some(std::sync::Arc::new(move |req| {
351            let f = std::sync::Arc::clone(&f);
352            Box::pin(async move { f(req).await })
353        }));
354        self
355    }
356
357    /// Set the approval mode.
358    #[must_use]
359    pub const fn with_approval_mode(mut self, mode: ApprovalMode) -> Self {
360        self.approval_mode = mode;
361        self
362    }
363
364    /// Set additional models for model cycling.
365    #[must_use]
366    pub fn with_available_models(mut self, models: Vec<(ModelSpec, Arc<dyn StreamFn>)>) -> Self {
367        self.available_models = models;
368        self
369    }
370
371    /// Add a pre-turn policy (evaluated before each LLM call).
372    #[must_use]
373    pub fn with_pre_turn_policy(
374        mut self,
375        policy: impl crate::policy::PreTurnPolicy + 'static,
376    ) -> Self {
377        self.pre_turn_policies.push(Arc::new(policy));
378        self
379    }
380
381    /// Add a pre-dispatch policy (evaluated per tool call, before approval).
382    #[must_use]
383    pub fn with_pre_dispatch_policy(
384        mut self,
385        policy: impl crate::policy::PreDispatchPolicy + 'static,
386    ) -> Self {
387        self.pre_dispatch_policies.push(Arc::new(policy));
388        self
389    }
390
391    /// Add a post-turn policy (evaluated after each completed turn).
392    #[must_use]
393    pub fn with_post_turn_policy(
394        mut self,
395        policy: impl crate::policy::PostTurnPolicy + 'static,
396    ) -> Self {
397        self.post_turn_policies.push(Arc::new(policy));
398        self
399    }
400
401    /// Add a post-loop policy (evaluated after the inner loop exits).
402    #[must_use]
403    pub fn with_post_loop_policy(
404        mut self,
405        policy: impl crate::policy::PostLoopPolicy + 'static,
406    ) -> Self {
407        self.post_loop_policies.push(Arc::new(policy));
408        self
409    }
410
411    /// Add an event forwarder that receives all events dispatched by this agent.
412    #[must_use]
413    pub fn with_event_forwarder(mut self, f: impl Fn(AgentEvent) + Send + Sync + 'static) -> Self {
414        self.event_forwarders.push(Arc::new(f));
415        self
416    }
417
418    /// Set the async context transformer (runs before the sync transformer).
419    #[must_use]
420    pub fn with_async_transform_context(
421        mut self,
422        transformer: impl AsyncContextTransformer + 'static,
423    ) -> Self {
424        self.async_transform_context = Some(Arc::new(transformer));
425        self
426    }
427
428    /// Set the checkpoint store for persisting agent state.
429    #[must_use]
430    pub fn with_checkpoint_store(mut self, store: impl CheckpointStore + 'static) -> Self {
431        self.checkpoint_store = Some(Arc::new(store));
432        self
433    }
434
435    /// Set the [`CustomMessageRegistry`] used to decode persisted custom
436    /// messages when restoring from a checkpoint.
437    ///
438    /// Without this, [`Checkpoint::restore_messages`](crate::checkpoint::Checkpoint::restore_messages)
439    /// and [`LoopCheckpoint::restore_messages`](crate::checkpoint::LoopCheckpoint::restore_messages)
440    /// are called with `None` in the public agent restore/resume APIs, and any
441    /// persisted [`CustomMessage`](crate::types::CustomMessage) values are
442    /// silently dropped.
443    #[must_use]
444    pub fn with_custom_message_registry(mut self, registry: CustomMessageRegistry) -> Self {
445        self.custom_message_registry = Some(Arc::new(registry));
446        self
447    }
448
449    /// Set the [`CustomMessageRegistry`] from a shared [`Arc`], for sharing a
450    /// single registry across multiple agents.
451    #[must_use]
452    pub fn with_custom_message_registry_arc(
453        mut self,
454        registry: Arc<CustomMessageRegistry>,
455    ) -> Self {
456        self.custom_message_registry = Some(registry);
457        self
458    }
459
460    /// Set the metrics collector for per-turn observability.
461    #[must_use]
462    pub fn with_metrics_collector(
463        mut self,
464        collector: impl crate::metrics::MetricsCollector + 'static,
465    ) -> Self {
466        self.metrics_collector = Some(Arc::new(collector));
467        self
468    }
469
470    /// Set a custom token counter for context compaction.
471    ///
472    /// Replaces the default `chars / 4` heuristic used by the built-in
473    /// [`SlidingWindowTransformer`](crate::SlidingWindowTransformer). Supply a
474    /// tiktoken or provider-native tokenizer for accurate budget enforcement.
475    #[must_use]
476    pub fn with_token_counter(
477        mut self,
478        counter: impl crate::context::TokenCounter + 'static,
479    ) -> Self {
480        self.token_counter = Some(Arc::new(counter));
481        self
482    }
483
484    /// Set the model fallback chain.
485    ///
486    /// When the primary model exhausts its retry budget on a retryable error,
487    /// each fallback model is tried in order (with a fresh retry budget)
488    /// before the error is surfaced.
489    #[must_use]
490    pub fn with_model_fallback(mut self, fallback: crate::fallback::ModelFallback) -> Self {
491        self.fallback = Some(fallback);
492        self
493    }
494
495    /// Attach a push-based message channel and return the sender handle.
496    ///
497    /// Creates a [`ChannelMessageProvider`](crate::ChannelMessageProvider) that
498    /// is composed with the agent's internal steering/follow-up queues. External
499    /// code can push messages via the returned [`MessageSender`](crate::MessageSender).
500    ///
501    /// # Example
502    ///
503    /// ```ignore
504    /// let mut opts = AgentOptions::new_simple("prompt", model, stream_fn);
505    /// let sender = opts.with_message_channel();
506    /// // later, from another task:
507    /// sender.send(user_msg("follow-up directive"));
508    /// ```
509    pub fn with_message_channel(&mut self) -> crate::message_provider::MessageSender {
510        let (provider, sender) = crate::message_provider::message_channel();
511        self.external_message_provider = Some(Arc::new(provider));
512        sender
513    }
514
515    /// Set an external [`MessageProvider`] to compose with the internal queues.
516    ///
517    /// For push-based messaging, prefer [`with_message_channel`](Self::with_message_channel).
518    #[must_use]
519    pub fn with_external_message_provider(
520        mut self,
521        provider: impl MessageProvider + 'static,
522    ) -> Self {
523        self.external_message_provider = Some(Arc::new(provider));
524        self
525    }
526
527    /// Set the tool execution policy.
528    ///
529    /// Controls whether tool calls are dispatched concurrently (default),
530    /// sequentially, by priority, or via a fully custom strategy.
531    #[must_use]
532    pub fn with_tool_execution_policy(
533        mut self,
534        policy: crate::tool_execution_policy::ToolExecutionPolicy,
535    ) -> Self {
536        self.tool_execution_policy = policy;
537        self
538    }
539
540    /// Override the system prompt addendum appended when entering plan mode.
541    ///
542    /// When not set, [`DEFAULT_PLAN_MODE_ADDENDUM`] is used.
543    #[must_use]
544    pub fn with_plan_mode_addendum(mut self, addendum: impl Into<String>) -> Self {
545        self.plan_mode_addendum = Some(addendum.into());
546        self
547    }
548
549    /// Pre-seed session state with initial key-value pairs.
550    #[must_use]
551    pub fn with_initial_state(mut self, state: crate::SessionState) -> Self {
552        self.session_state = Some(state);
553        self
554    }
555
556    /// Add a single key-value pair to initial state.
557    #[must_use]
558    pub fn with_state_entry(
559        mut self,
560        key: impl Into<String>,
561        value: impl serde::Serialize,
562    ) -> Self {
563        let state = self
564            .session_state
565            .get_or_insert_with(crate::SessionState::new);
566        state
567            .set(&key.into(), value)
568            .expect("with_state_entry: value must be serializable to JSON");
569        // Flush delta so pre-seeded entries don't appear as mutations (baseline semantics).
570        state.flush_delta();
571        self
572    }
573
574    /// Configure a credential resolver for tool authentication.
575    ///
576    /// When set, tools that declare [`auth_config()`](crate::AgentTool::auth_config) will
577    /// have their credentials resolved before execution.
578    #[must_use]
579    pub fn with_credential_resolver(
580        mut self,
581        resolver: Arc<dyn crate::credential::CredentialResolver>,
582    ) -> Self {
583        self.credential_resolver = Some(resolver);
584        self
585    }
586
587    /// Set context caching configuration.
588    #[must_use]
589    pub const fn with_cache_config(mut self, config: crate::context_cache::CacheConfig) -> Self {
590        self.cache_config = Some(config);
591        self
592    }
593
594    /// Set a static system prompt (cacheable, immutable for the agent lifetime).
595    ///
596    /// When set, takes precedence over `system_prompt`.
597    #[must_use]
598    pub fn with_static_system_prompt(mut self, prompt: String) -> Self {
599        self.static_system_prompt = Some(prompt);
600        self
601    }
602
603    /// Set a dynamic system prompt closure (called fresh each turn).
604    ///
605    /// Its output is injected as a separate user-role message after the system
606    /// prompt so it does not invalidate provider-side caches.
607    #[must_use]
608    pub fn with_dynamic_system_prompt(
609        mut self,
610        f: impl Fn() -> String + Send + Sync + 'static,
611    ) -> Self {
612        self.dynamic_system_prompt = Some(Box::new(f));
613        self
614    }
615
616    /// Register a single plugin.
617    ///
618    /// If a plugin with the same [`name()`](crate::plugin::Plugin::name) is
619    /// already registered, it is replaced (matching [`PluginRegistry`](crate::plugin::PluginRegistry)
620    /// semantics).
621    #[cfg(feature = "plugins")]
622    #[must_use]
623    pub fn with_plugin(mut self, plugin: Arc<dyn crate::plugin::Plugin>) -> Self {
624        let name = plugin.name();
625        if let Some(pos) = self.plugins.iter().position(|p| p.name() == name) {
626            tracing::warn!(plugin = %name, "replacing duplicate plugin in AgentOptions");
627            self.plugins[pos] = plugin;
628        } else {
629            self.plugins.push(plugin);
630        }
631        self
632    }
633
634    /// Register multiple plugins at once.
635    ///
636    /// Duplicates (by name) are resolved with last-wins semantics, consistent
637    /// with [`PluginRegistry::register`](crate::plugin::PluginRegistry::register).
638    #[cfg(feature = "plugins")]
639    #[must_use]
640    pub fn with_plugins(mut self, plugins: Vec<Arc<dyn crate::plugin::Plugin>>) -> Self {
641        for plugin in plugins {
642            self = self.with_plugin(plugin);
643        }
644        self
645    }
646
647    /// Set the agent name for transfer chain safety enforcement.
648    ///
649    /// When set, the agent loop pushes this name onto the
650    /// [`TransferChain`](crate::transfer::TransferChain) at startup. Transfers
651    /// back to this agent (circular) or exceeding max depth are rejected.
652    #[must_use]
653    pub fn with_agent_name(mut self, name: impl Into<String>) -> Self {
654        self.agent_name = Some(name.into());
655        self
656    }
657
658    /// Seed transfer chain state from a previous handoff signal.
659    ///
660    /// Use this on the target agent so transfer safety checks continue across
661    /// agent boundaries.
662    #[must_use]
663    pub fn with_transfer_chain(mut self, chain: crate::transfer::TransferChain) -> Self {
664        self.transfer_chain = Some(chain);
665        self
666    }
667
668    /// Return the effective system prompt (static portion only).
669    ///
670    /// Returns `static_system_prompt` if set, otherwise falls back to
671    /// `system_prompt`. Does NOT include dynamic content.
672    pub fn effective_system_prompt(&self) -> &str {
673        self.static_system_prompt
674            .as_deref()
675            .unwrap_or(&self.system_prompt)
676    }
677}
678
679#[cfg(test)]
680#[cfg(feature = "plugins")]
681mod tests {
682    use super::*;
683    use crate::testing::{MockPlugin, SimpleMockStreamFn};
684    use crate::types::ModelSpec;
685
686    fn test_options() -> AgentOptions {
687        AgentOptions::new_simple(
688            "test",
689            ModelSpec::new("test-model", "test-model"),
690            Arc::new(SimpleMockStreamFn::from_text("hello")),
691        )
692    }
693
694    #[test]
695    fn with_plugin_deduplicates_by_name() {
696        let opts = test_options()
697            .with_plugin(Arc::new(MockPlugin::new("alpha").with_priority(1)))
698            .with_plugin(Arc::new(MockPlugin::new("alpha").with_priority(5)));
699
700        assert_eq!(opts.plugins.len(), 1);
701        assert_eq!(opts.plugins[0].priority(), 5);
702    }
703
704    #[test]
705    fn with_plugin_keeps_distinct_names() {
706        let opts = test_options()
707            .with_plugin(Arc::new(MockPlugin::new("alpha")))
708            .with_plugin(Arc::new(MockPlugin::new("beta")));
709
710        assert_eq!(opts.plugins.len(), 2);
711    }
712
713    #[test]
714    fn with_plugins_deduplicates_within_batch() {
715        let opts = test_options().with_plugins(vec![
716            Arc::new(MockPlugin::new("alpha").with_priority(1)),
717            Arc::new(MockPlugin::new("beta")),
718            Arc::new(MockPlugin::new("alpha").with_priority(9)),
719        ]);
720
721        assert_eq!(opts.plugins.len(), 2);
722        // Last "alpha" wins
723        let alpha = opts.plugins.iter().find(|p| p.name() == "alpha").unwrap();
724        assert_eq!(alpha.priority(), 9);
725    }
726
727    #[test]
728    fn with_plugins_deduplicates_against_existing() {
729        let opts = test_options()
730            .with_plugin(Arc::new(MockPlugin::new("alpha").with_priority(1)))
731            .with_plugins(vec![
732                Arc::new(MockPlugin::new("alpha").with_priority(7)),
733                Arc::new(MockPlugin::new("beta")),
734            ]);
735
736        assert_eq!(opts.plugins.len(), 2);
737        let alpha = opts.plugins.iter().find(|p| p.name() == "alpha").unwrap();
738        assert_eq!(alpha.priority(), 7);
739    }
740}