Skip to main content

ferrin_core/agent/
tool_loop_agent.rs

1//! The built-in tool-loop agent.
2
3use std::fmt;
4use std::marker::PhantomData;
5use std::sync::Arc;
6
7use ferrin_spec::LanguageModelRef;
8use ferrin_tool::ToolSet;
9
10use super::Agent;
11use super::AgentCall;
12use super::AgentInput;
13use super::AgentStreamCall;
14use super::PrepareCall;
15use super::PrepareCallInput;
16use super::PreparedCall;
17use super::options::CallOptionsValidator;
18use crate::error::Error;
19use crate::generate_text::GenerateText;
20use crate::generate_text::GenerateTextResult;
21use crate::generate_text::config::CallConfig;
22use crate::generate_text::step_count;
23use crate::output::NoOutput;
24use crate::output::Output;
25use crate::output::OutputHandler;
26use crate::prompt::Instructions;
27use crate::stream_text::StreamText;
28use crate::stream_text::StreamTextResult;
29
30/// User-Agent product appended to agent requests.
31pub const AGENT_USER_AGENT: &str = "ferrin-agent/tool-loop";
32
33/// Default stop condition when none is configured.
34const DEFAULT_MAX_STEPS: u32 = 20;
35
36struct Settings<Opt, Out> {
37    id: Option<String>,
38    config: CallConfig,
39    output: Arc<dyn OutputHandler<Out>>,
40    prepare_call: Option<Arc<dyn PrepareCall<Opt>>>,
41    call_options_validator: Option<CallOptionsValidator<Opt>>,
42}
43
44/// An agent that runs the tool loop of `generate_text`/`stream_text` with
45/// fixed settings. Build with [`ToolLoopAgent::builder`].
46pub struct ToolLoopAgent<Opt = (), Out = ()> {
47    settings: Arc<Settings<Opt, Out>>,
48}
49
50impl<Opt, Out> Clone for ToolLoopAgent<Opt, Out> {
51    fn clone(&self) -> Self {
52        Self {
53            settings: Arc::clone(&self.settings),
54        }
55    }
56}
57
58impl<Opt, Out> fmt::Debug for ToolLoopAgent<Opt, Out> {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.debug_struct("ToolLoopAgent")
61            .field("id", &self.settings.id)
62            .field("config", &self.settings.config)
63            .field("has_prepare_call", &self.settings.prepare_call.is_some())
64            .field(
65                "has_call_options_schema",
66                &self.settings.call_options_validator.is_some(),
67            )
68            .finish()
69    }
70}
71
72impl ToolLoopAgent {
73    /// Starts building an agent around `model`.
74    #[must_use]
75    pub fn builder(model: impl Into<LanguageModelRef>) -> ToolLoopAgentBuilder<(), ()> {
76        ToolLoopAgentBuilder {
77            id: None,
78            config: CallConfig::new(model.into()),
79            output: Arc::new(NoOutput),
80            prepare_call: None,
81            call_options_validator: None,
82            _options: PhantomData,
83        }
84    }
85}
86
87impl<Opt: Send + 'static, Out: Send + 'static> ToolLoopAgent<Opt, Out> {
88    /// The prepared settings of a call, before the prepare function runs.
89    fn effective(&self, call: &AgentCall<Opt>) -> PreparedCall {
90        let config = &self.settings.config;
91        PreparedCall {
92            input: call.input.clone(),
93            instructions: config.system.clone(),
94            allow_system_in_messages: config.allow_system_in_messages,
95            model: config.model.clone(),
96            tools: config.tools.clone(),
97            tool_choice: config.tool_choice.clone(),
98            active_tools: config.active_tools.clone(),
99            tool_order: config.tool_order.clone(),
100            tools_context: config.tools_context.clone(),
101            runtime_context: config.runtime_context.clone(),
102            tool_approval: config.tool_approval.clone(),
103            tool_approval_secret: config.tool_approval_secret.clone(),
104            tool_callers: config.tool_callers.clone(),
105            prepare_step: config.prepare_step.clone(),
106            repair_tool_call: config.repair_tool_call.clone(),
107            refine_tool_inputs: config.refine_tool_inputs.clone(),
108            download: config.download.clone(),
109            settings: config.settings.clone(),
110            stop_conditions: if config.stop_conditions.is_empty() {
111                vec![Arc::new(step_count(DEFAULT_MAX_STEPS))]
112            } else {
113                config.stop_conditions.clone()
114            },
115            timeout: config.timeout.clone(),
116            retry_policy: config.retry_policy.clone(),
117            include: config.include,
118            max_tool_concurrency: config.max_tool_concurrency,
119            telemetry: config.telemetry.clone(),
120        }
121    }
122
123    /// Builds the call configuration for `call`.
124    async fn prepare(&self, call: AgentCall<Opt>) -> Result<CallConfig, Error> {
125        let mut prepared = self.effective(&call);
126        let AgentCall {
127            options,
128            cancellation,
129            timeout,
130            hooks,
131            #[cfg(feature = "sandbox")]
132            sandbox,
133            ..
134        } = call;
135        let options = match &self.settings.call_options_validator {
136            Some(validate) => validate(options)?,
137            None => options,
138        };
139        if let Some(prepare_call) = &self.settings.prepare_call {
140            prepared = prepare_call
141                .prepare_call(PrepareCallInput {
142                    options,
143                    defaults: prepared,
144                    #[cfg(feature = "sandbox")]
145                    sandbox: sandbox.clone(),
146                })
147                .await?;
148        }
149        let mut config = self.settings.config.clone();
150        match prepared.input {
151            AgentInput::Prompt(prompt) => {
152                config.prompt = Some(prompt);
153                config.messages = None;
154            }
155            AgentInput::Messages(messages) => {
156                config.prompt = None;
157                config.messages = Some(messages);
158            }
159        }
160        config.system = prepared.instructions;
161        config.allow_system_in_messages = prepared.allow_system_in_messages;
162        config.model = prepared.model;
163        config.tools = prepared.tools;
164        config.tool_choice = prepared.tool_choice;
165        config.active_tools = prepared.active_tools;
166        config.tool_order = prepared.tool_order;
167        config.tools_context = prepared.tools_context;
168        config.runtime_context = prepared.runtime_context;
169        config.tool_approval = prepared.tool_approval;
170        config.tool_approval_secret = prepared.tool_approval_secret;
171        config.tool_callers = prepared.tool_callers;
172        config.prepare_step = prepared.prepare_step;
173        config.repair_tool_call = prepared.repair_tool_call;
174        config.refine_tool_inputs = prepared.refine_tool_inputs;
175        config.download = prepared.download;
176        config.settings = prepared.settings;
177        config.settings.headers = config
178            .settings
179            .headers
180            .with_user_agent_suffix([AGENT_USER_AGENT]);
181        config.stop_conditions = prepared.stop_conditions;
182        config.timeout = timeout.unwrap_or(prepared.timeout);
183        config.retry_policy = prepared.retry_policy;
184        config.include = prepared.include;
185        config.max_tool_concurrency = prepared.max_tool_concurrency;
186        config.telemetry = prepared.telemetry;
187        if config.telemetry.function_id.is_none() {
188            config.telemetry.function_id = self.settings.id.clone();
189        }
190        config.cancellation = cancellation;
191        // Agent hooks begin before call hooks; all run concurrently to completion.
192        config.hooks = self.settings.config.hooks.clone().merged(hooks);
193        #[cfg(feature = "sandbox")]
194        if let Some(sandbox) = sandbox {
195            config.sandbox = Some(sandbox);
196        }
197        Ok(config)
198    }
199}
200
201impl<Opt: Send + 'static, Out: Send + 'static> Agent for ToolLoopAgent<Opt, Out> {
202    type Options = Opt;
203    type Output = Out;
204
205    fn id(&self) -> Option<&str> {
206        self.settings.id.as_deref()
207    }
208
209    fn tools(&self) -> &ToolSet {
210        &self.settings.config.tools
211    }
212
213    async fn generate(&self, call: AgentCall<Opt>) -> Result<GenerateTextResult<Out>, Error> {
214        let config = self.prepare(call).await?;
215        GenerateText {
216            config,
217            output: Arc::clone(&self.settings.output),
218        }
219        .await
220    }
221
222    async fn stream(&self, call: AgentStreamCall<Opt>) -> Result<StreamTextResult<Out>, Error> {
223        let AgentStreamCall { call, stream } = call;
224        let config = self.prepare(call).await?;
225        StreamText {
226            config,
227            output: Arc::clone(&self.settings.output),
228            stream,
229        }
230        .await
231    }
232}
233
234/// Builder for [`ToolLoopAgent`]. Besides the methods below it accepts every
235/// call setting of `generate_text` (prompt-independent ones).
236pub struct ToolLoopAgentBuilder<Opt, Out> {
237    id: Option<String>,
238    pub(crate) config: CallConfig,
239    output: Arc<dyn OutputHandler<Out>>,
240    prepare_call: Option<Arc<dyn PrepareCall<Opt>>>,
241    call_options_validator: Option<CallOptionsValidator<Opt>>,
242    _options: PhantomData<fn() -> Opt>,
243}
244
245impl<Opt, Out> fmt::Debug for ToolLoopAgentBuilder<Opt, Out> {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        f.debug_struct("ToolLoopAgentBuilder")
248            .field("id", &self.id)
249            .field("config", &self.config)
250            .field("has_prepare_call", &self.prepare_call.is_some())
251            .field(
252                "has_call_options_schema",
253                &self.call_options_validator.is_some(),
254            )
255            .finish()
256    }
257}
258
259crate::builder::impl_call_builder!(ToolLoopAgentBuilder<Opt, Out>);
260
261impl<Opt, Out> ToolLoopAgentBuilder<Opt, Out> {
262    /// Sets the agent id (also the default telemetry `function_id`).
263    #[must_use]
264    pub fn id(mut self, id: impl Into<String>) -> Self {
265        self.id = Some(id.into());
266        self
267    }
268
269    /// Sets the system instructions (alias of `system`).
270    #[must_use]
271    pub fn instructions(mut self, instructions: impl Into<Instructions>) -> Self {
272        self.config.system = Some(instructions.into());
273        self
274    }
275
276    /// Sets the structured output.
277    #[must_use]
278    pub fn output<T>(self, output: Output<T>) -> ToolLoopAgentBuilder<Opt, T> {
279        ToolLoopAgentBuilder {
280            id: self.id,
281            config: self.config,
282            output: output.handler(),
283            prepare_call: self.prepare_call,
284            call_options_validator: self.call_options_validator,
285            _options: PhantomData,
286        }
287    }
288
289    /// Sets the per-call options type, resetting preparation and validation
290    /// callbacks configured for the previous type.
291    #[must_use]
292    pub fn call_options<O>(self) -> ToolLoopAgentBuilder<O, Out> {
293        ToolLoopAgentBuilder {
294            id: self.id,
295            config: self.config,
296            output: self.output,
297            prepare_call: None,
298            call_options_validator: None,
299            _options: PhantomData,
300        }
301    }
302
303    /// Sets the function that rewrites settings per call.
304    #[must_use]
305    pub fn prepare_call(mut self, prepare: impl PrepareCall<Opt> + 'static) -> Self {
306        self.prepare_call = Some(Arc::new(prepare));
307        self
308    }
309
310    /// Validates and normalizes call options before preparing a generation or stream.
311    ///
312    /// The schema receives serialized options and its validated value replaces them.
313    /// Validation failures return [`Error::InvalidArgument`] before callbacks or model
314    /// calls start. Without this option, call options need not implement `Serialize`.
315    /// Changing the option type with [`Self::call_options`] clears this schema.
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// use ferrin_core::ToolLoopAgent;
321    /// use ferrin_schema::Schema;
322    /// use serde_json::{Value, json};
323    ///
324    /// let agent = ToolLoopAgent::builder("provider:model")
325    ///     .call_options::<Value>()
326    ///     .call_options_schema(Schema::from_json_schema(json!({
327    ///         "type": "object", "required": ["tenant"],
328    ///         "properties": { "tenant": { "type": "string" } }
329    ///     })))
330    ///     .build();
331    /// ```
332    #[must_use]
333    pub fn call_options_schema(mut self, schema: ferrin_schema::Schema<Opt>) -> Self
334    where
335        Opt: serde::Serialize + 'static,
336    {
337        self.call_options_validator = Some(super::options::validator(schema));
338        self
339    }
340
341    /// Finishes the agent.
342    #[must_use]
343    pub fn build(self) -> ToolLoopAgent<Opt, Out> {
344        ToolLoopAgent {
345            settings: Arc::new(Settings {
346                id: self.id,
347                config: self.config,
348                output: self.output,
349                prepare_call: self.prepare_call,
350                call_options_validator: self.call_options_validator,
351            }),
352        }
353    }
354}