Skip to main content

ferrin_core/agent/
tool_loop_agent.rs

1//! The built-in tool-loop agent.
2
3use std::fmt;
4use std::future::Future;
5use std::marker::PhantomData;
6use std::sync::Arc;
7
8use ferrin_spec::BoxFuture;
9use ferrin_spec::JsonValue;
10use ferrin_spec::LanguageModelRef;
11use ferrin_spec::ToolChoice;
12use ferrin_spec::ToolName;
13use ferrin_tool::ToolSet;
14
15use super::Agent;
16use super::AgentCall;
17use super::AgentInput;
18use super::AgentStreamCall;
19use crate::error::Error;
20use crate::generate_text::GenerateText;
21use crate::generate_text::GenerateTextResult;
22use crate::generate_text::Include;
23use crate::generate_text::StopCondition;
24use crate::generate_text::config::CallConfig;
25use crate::generate_text::step_count;
26use crate::output::NoOutput;
27use crate::output::Output;
28use crate::output::OutputHandler;
29use crate::prompt::CallSettings;
30use crate::prompt::Instructions;
31use crate::retry::RetryPolicy;
32use crate::stream_text::StreamText;
33use crate::stream_text::StreamTextResult;
34use crate::telemetry::TelemetryOptions;
35use crate::timeout::Timeout;
36
37/// User-Agent product appended to agent requests.
38pub const AGENT_USER_AGENT: &str = "ferrin-agent/tool-loop";
39
40/// Default stop condition when none is configured.
41const DEFAULT_MAX_STEPS: u32 = 20;
42
43/// The effective settings of one call, as seen by [`PrepareCall`].
44///
45/// Every field starts with the agent's configuration (with the call's
46/// input and timeout applied); the prepare function may rewrite any of
47/// them. `None` means "not set".
48#[derive(Clone)]
49pub struct PreparedCall {
50    /// The prompt or conversation.
51    pub input: AgentInput,
52    /// System instructions.
53    pub instructions: Option<Instructions>,
54    /// Whether system messages are allowed inside the conversation.
55    pub allow_system_in_messages: bool,
56    /// The model.
57    pub model: LanguageModelRef,
58    /// The tools.
59    pub tools: ToolSet,
60    /// Tool choice.
61    pub tool_choice: Option<ToolChoice>,
62    /// Tools sent to the model (all when `None`).
63    pub active_tools: Option<Vec<ToolName>>,
64    /// Tool order sent to the model.
65    pub tool_order: Vec<ToolName>,
66    /// Shared tool context.
67    pub tools_context: Option<JsonValue>,
68    /// Sampling settings, headers and provider options.
69    pub settings: CallSettings,
70    /// Stop conditions (default: twenty steps).
71    pub stop_conditions: Vec<Arc<dyn StopCondition>>,
72    /// Timeouts.
73    pub timeout: Timeout,
74    /// Retry policy.
75    pub retry_policy: RetryPolicy,
76    /// Payloads copied into step results.
77    pub include: Include,
78    /// Tool concurrency limit.
79    pub max_tool_concurrency: Option<usize>,
80    /// Telemetry options.
81    pub telemetry: TelemetryOptions,
82}
83
84impl fmt::Debug for PreparedCall {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("PreparedCall")
87            .field("input", &self.input)
88            .field("instructions", &self.instructions)
89            .field("allow_system_in_messages", &self.allow_system_in_messages)
90            .field("model", &self.model)
91            .field("tools", &self.tools.names().collect::<Vec<_>>())
92            .field("tool_choice", &self.tool_choice)
93            .field("active_tools", &self.active_tools)
94            .field("tool_order", &self.tool_order)
95            .field("tools_context", &self.tools_context)
96            .field("settings", &self.settings)
97            .field("stop_conditions", &self.stop_conditions.len())
98            .field("timeout", &self.timeout)
99            .field("retry_policy", &self.retry_policy)
100            .field("include", &self.include)
101            .field("max_tool_concurrency", &self.max_tool_concurrency)
102            .finish_non_exhaustive()
103    }
104}
105
106/// Input of [`PrepareCall`].
107#[derive(Debug)]
108pub struct PrepareCallInput<Opt> {
109    /// The per-call options.
110    pub options: Opt,
111    /// The effective settings of the call, to be returned (possibly
112    /// modified).
113    pub defaults: PreparedCall,
114}
115
116/// Rewrites the settings of a call from its options (templated
117/// instructions, per-tenant tools, ...).
118pub trait PrepareCall<Opt>: Send + Sync {
119    /// Produces the settings to use.
120    fn prepare_call(
121        &self,
122        input: PrepareCallInput<Opt>,
123    ) -> BoxFuture<'_, Result<PreparedCall, Error>>;
124}
125
126impl<Opt, F, Fut> PrepareCall<Opt> for F
127where
128    F: Fn(PrepareCallInput<Opt>) -> Fut + Send + Sync,
129    Fut: Future<Output = Result<PreparedCall, Error>> + Send + 'static,
130{
131    fn prepare_call(
132        &self,
133        input: PrepareCallInput<Opt>,
134    ) -> BoxFuture<'_, Result<PreparedCall, Error>> {
135        Box::pin(self(input))
136    }
137}
138
139struct Settings<Opt, Out> {
140    id: Option<String>,
141    config: CallConfig,
142    output: Arc<dyn OutputHandler<Out>>,
143    prepare_call: Option<Arc<dyn PrepareCall<Opt>>>,
144}
145
146/// An agent that runs the tool loop of `generate_text`/`stream_text` with
147/// fixed settings. Build with [`ToolLoopAgent::builder`].
148pub struct ToolLoopAgent<Opt = (), Out = ()> {
149    settings: Arc<Settings<Opt, Out>>,
150}
151
152impl<Opt, Out> Clone for ToolLoopAgent<Opt, Out> {
153    fn clone(&self) -> Self {
154        Self {
155            settings: Arc::clone(&self.settings),
156        }
157    }
158}
159
160impl<Opt, Out> fmt::Debug for ToolLoopAgent<Opt, Out> {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        f.debug_struct("ToolLoopAgent")
163            .field("id", &self.settings.id)
164            .field("config", &self.settings.config)
165            .field("has_prepare_call", &self.settings.prepare_call.is_some())
166            .finish()
167    }
168}
169
170impl ToolLoopAgent {
171    /// Starts building an agent around `model`.
172    #[must_use]
173    pub fn builder(model: impl Into<LanguageModelRef>) -> ToolLoopAgentBuilder<(), ()> {
174        ToolLoopAgentBuilder {
175            id: None,
176            config: CallConfig::new(model.into()),
177            output: Arc::new(NoOutput),
178            prepare_call: None,
179            _options: PhantomData,
180        }
181    }
182}
183
184impl<Opt: Send + 'static, Out: Send + 'static> ToolLoopAgent<Opt, Out> {
185    /// The prepared settings of a call, before the prepare function runs.
186    fn effective(&self, call: &AgentCall<Opt>) -> PreparedCall {
187        let config = &self.settings.config;
188        PreparedCall {
189            input: call.input.clone(),
190            instructions: config.system.clone(),
191            allow_system_in_messages: config.allow_system_in_messages,
192            model: config.model.clone(),
193            tools: config.tools.clone(),
194            tool_choice: config.tool_choice.clone(),
195            active_tools: config.active_tools.clone(),
196            tool_order: config.tool_order.clone(),
197            tools_context: config.tools_context.clone(),
198            settings: config.settings.clone(),
199            stop_conditions: if config.stop_conditions.is_empty() {
200                vec![Arc::new(step_count(DEFAULT_MAX_STEPS))]
201            } else {
202                config.stop_conditions.clone()
203            },
204            timeout: call
205                .timeout
206                .clone()
207                .unwrap_or_else(|| config.timeout.clone()),
208            retry_policy: config.retry_policy.clone(),
209            include: config.include,
210            max_tool_concurrency: config.max_tool_concurrency,
211            telemetry: config.telemetry.clone(),
212        }
213    }
214
215    /// Builds the call configuration for `call`.
216    async fn prepare(&self, call: AgentCall<Opt>) -> Result<CallConfig, Error> {
217        let mut prepared = self.effective(&call);
218        let AgentCall {
219            options,
220            cancellation,
221            hooks,
222            #[cfg(feature = "sandbox")]
223            sandbox,
224            ..
225        } = call;
226        if let Some(prepare_call) = &self.settings.prepare_call {
227            prepared = prepare_call
228                .prepare_call(PrepareCallInput {
229                    options,
230                    defaults: prepared,
231                })
232                .await?;
233        }
234        let mut config = self.settings.config.clone();
235        match prepared.input {
236            AgentInput::Prompt(prompt) => {
237                config.prompt = Some(prompt);
238                config.messages = None;
239            }
240            AgentInput::Messages(messages) => {
241                config.prompt = None;
242                config.messages = Some(messages);
243            }
244        }
245        config.system = prepared.instructions;
246        config.allow_system_in_messages = prepared.allow_system_in_messages;
247        config.model = prepared.model;
248        config.tools = prepared.tools;
249        config.tool_choice = prepared.tool_choice;
250        config.active_tools = prepared.active_tools;
251        config.tool_order = prepared.tool_order;
252        config.tools_context = prepared.tools_context;
253        config.settings = prepared.settings;
254        config.settings.headers = config
255            .settings
256            .headers
257            .with_user_agent_suffix([AGENT_USER_AGENT]);
258        config.stop_conditions = prepared.stop_conditions;
259        config.timeout = prepared.timeout;
260        config.retry_policy = prepared.retry_policy;
261        config.include = prepared.include;
262        config.max_tool_concurrency = prepared.max_tool_concurrency;
263        config.telemetry = prepared.telemetry;
264        if config.telemetry.function_id.is_none() {
265            config.telemetry.function_id = self.settings.id.clone();
266        }
267        config.cancellation = cancellation;
268        // Agent hooks run before the call's hooks.
269        config.hooks = self.settings.config.hooks.clone().merged(hooks);
270        #[cfg(feature = "sandbox")]
271        if let Some(sandbox) = sandbox {
272            config.sandbox = Some(sandbox);
273        }
274        Ok(config)
275    }
276}
277
278impl<Opt: Send + 'static, Out: Send + 'static> Agent for ToolLoopAgent<Opt, Out> {
279    type Options = Opt;
280    type Output = Out;
281
282    fn id(&self) -> Option<&str> {
283        self.settings.id.as_deref()
284    }
285
286    fn tools(&self) -> &ToolSet {
287        &self.settings.config.tools
288    }
289
290    async fn generate(&self, call: AgentCall<Opt>) -> Result<GenerateTextResult<Out>, Error> {
291        let config = self.prepare(call).await?;
292        GenerateText {
293            config,
294            output: Arc::clone(&self.settings.output),
295        }
296        .await
297    }
298
299    async fn stream(&self, call: AgentStreamCall<Opt>) -> Result<StreamTextResult<Out>, Error> {
300        let AgentStreamCall { call, stream } = call;
301        let config = self.prepare(call).await?;
302        StreamText {
303            config,
304            output: Arc::clone(&self.settings.output),
305            stream,
306        }
307        .await
308    }
309}
310
311/// Builder for [`ToolLoopAgent`]. Besides the methods below it accepts every
312/// call setting of `generate_text` (prompt-independent ones).
313pub struct ToolLoopAgentBuilder<Opt, Out> {
314    id: Option<String>,
315    pub(crate) config: CallConfig,
316    output: Arc<dyn OutputHandler<Out>>,
317    prepare_call: Option<Arc<dyn PrepareCall<Opt>>>,
318    _options: PhantomData<fn() -> Opt>,
319}
320
321impl<Opt, Out> fmt::Debug for ToolLoopAgentBuilder<Opt, Out> {
322    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323        f.debug_struct("ToolLoopAgentBuilder")
324            .field("id", &self.id)
325            .field("config", &self.config)
326            .field("has_prepare_call", &self.prepare_call.is_some())
327            .finish()
328    }
329}
330
331crate::builder::impl_call_builder!(ToolLoopAgentBuilder<Opt, Out>);
332
333impl<Opt, Out> ToolLoopAgentBuilder<Opt, Out> {
334    /// Sets the agent id (also the default telemetry `function_id`).
335    #[must_use]
336    pub fn id(mut self, id: impl Into<String>) -> Self {
337        self.id = Some(id.into());
338        self
339    }
340
341    /// Sets the system instructions (alias of `system`).
342    #[must_use]
343    pub fn instructions(mut self, instructions: impl Into<Instructions>) -> Self {
344        self.config.system = Some(instructions.into());
345        self
346    }
347
348    /// Sets the structured output.
349    #[must_use]
350    pub fn output<T>(self, output: Output<T>) -> ToolLoopAgentBuilder<Opt, T> {
351        ToolLoopAgentBuilder {
352            id: self.id,
353            config: self.config,
354            output: output.handler(),
355            prepare_call: self.prepare_call,
356            _options: PhantomData,
357        }
358    }
359
360    /// Sets the per-call options type. Resets any prepare function set
361    /// before (it was typed for the previous options).
362    #[must_use]
363    pub fn call_options<O>(self) -> ToolLoopAgentBuilder<O, Out> {
364        ToolLoopAgentBuilder {
365            id: self.id,
366            config: self.config,
367            output: self.output,
368            prepare_call: None,
369            _options: PhantomData,
370        }
371    }
372
373    /// Sets the function that rewrites settings per call.
374    #[must_use]
375    pub fn prepare_call(mut self, prepare: impl PrepareCall<Opt> + 'static) -> Self {
376        self.prepare_call = Some(Arc::new(prepare));
377        self
378    }
379
380    /// Finishes the agent.
381    #[must_use]
382    pub fn build(self) -> ToolLoopAgent<Opt, Out> {
383        ToolLoopAgent {
384            settings: Arc::new(Settings {
385                id: self.id,
386                config: self.config,
387                output: self.output,
388                prepare_call: self.prepare_call,
389            }),
390        }
391    }
392}