Skip to main content

runifold_agent/
builder.rs

1use std::sync::Arc;
2
3use runifold_core::CapabilitySet;
4use runifold_effect::{EffectExecutor, EffectRecoveryPolicy};
5use runifold_model::{FeaturePolicy, Message, Model, ModelRef, OutputFormat};
6use runifold_tool::{Tool, ToolRegistrationError};
7use schemars::JsonSchema;
8use thiserror::Error;
9
10use crate::{
11    Agent, AgentConfig, AgentDescriptor, AgentRegistrationError, AgentRoute, GatewayMiddleware,
12    StructuredAgent, ToolErrorPolicy,
13};
14
15/// Failure while assembling an [`Agent`].
16#[derive(Clone, Debug, Error, Eq, PartialEq)]
17#[non_exhaustive]
18pub enum AgentBuildError {
19    /// Tool registration failed.
20    #[error("agent Tool registration failed: {0}")]
21    Tool(#[from] ToolRegistrationError),
22    /// Child Agent route registration failed.
23    #[error("agent route registration failed: {0}")]
24    Route(#[from] AgentRegistrationError),
25    /// One model-facing name was used by both a Tool and an Agent.
26    #[error("callable name `{0}` is registered as both a Tool and an Agent")]
27    CallableNameCollision(String),
28    /// The Agent name is blank.
29    #[error("agent name cannot be empty")]
30    EmptyName,
31    /// The configured turn limit cannot execute any model turn.
32    #[error("max_turns must be greater than zero")]
33    ZeroMaxTurns,
34}
35
36/// Fluent assembly of one canonical [`Agent`].
37///
38/// Registration failures are retained and returned by [`Self::build`] so
39/// Tool and child Agent calls remain chainable without silently replacing an
40/// existing name.
41pub struct AgentBuilder {
42    agent: Agent,
43    error: Option<AgentBuildError>,
44}
45
46impl AgentBuilder {
47    /// Creates a builder around the same execution path as [`Agent::new`].
48    pub fn new(name: impl Into<String>, model: Arc<dyn Model>, model_ref: ModelRef) -> Self {
49        Self {
50            agent: Agent::new(name, model, model_ref),
51            error: None,
52        }
53    }
54
55    /// Appends a system instruction.
56    #[must_use]
57    pub fn system(mut self, instruction: impl Into<String>) -> Self {
58        self.agent
59            .instructions
60            .push(Message::system(instruction.into()));
61        self
62    }
63
64    /// Registers an owned Tool.
65    #[must_use]
66    pub fn tool<T>(self, tool: T) -> Self
67    where
68        T: Tool + 'static,
69    {
70        self.shared_tool(Arc::new(tool))
71    }
72
73    /// Registers a shared, type-erased Tool.
74    #[must_use]
75    pub fn shared_tool(mut self, tool: Arc<dyn Tool>) -> Self {
76        if self.error.is_none() {
77            if let Err(error) = self.agent.tools.register(tool) {
78                self.error = Some(error.into());
79            }
80        }
81        self
82    }
83
84    /// Registers a child Agent route with explicit delegated capabilities.
85    #[must_use]
86    pub fn child(
87        mut self,
88        descriptor: AgentDescriptor,
89        child: Arc<Agent>,
90        capabilities: CapabilitySet,
91    ) -> Self {
92        if self.error.is_none() {
93            let route = AgentRoute::new(descriptor, child).with_capabilities(capabilities);
94            if let Err(error) = self.agent.agents.register(route) {
95                self.error = Some(error.into());
96            }
97        }
98        self
99    }
100
101    /// Appends Gateway around-middleware.
102    #[must_use]
103    pub fn gateway_layer(mut self, middleware: Arc<dyn GatewayMiddleware>) -> Self {
104        self.agent.agents.push_middleware(middleware);
105        self
106    }
107
108    /// Sets the maximum nested Agent delegation depth.
109    #[must_use]
110    pub fn max_delegation_depth(mut self, max_depth: u32) -> Self {
111        self.agent.agents = self.agent.agents.with_max_depth(max_depth);
112        self
113    }
114
115    /// Sets the local model-turn limit.
116    #[must_use]
117    pub const fn max_turns(mut self, max_turns: u32) -> Self {
118        self.agent.config.max_turns = max_turns;
119        self
120    }
121
122    /// Sets Tool failure behavior.
123    #[must_use]
124    pub const fn tool_error_policy(mut self, policy: ToolErrorPolicy) -> Self {
125        self.agent.config.tool_error_policy = policy;
126        self
127    }
128
129    /// Sets provider feature-degradation behavior.
130    #[must_use]
131    pub const fn feature_policy(mut self, policy: FeaturePolicy) -> Self {
132        self.agent.config.feature_policy = policy;
133        self
134    }
135
136    /// Sets the desired terminal model-output format.
137    #[must_use]
138    pub fn output_format(mut self, output_format: OutputFormat) -> Self {
139        self.agent.output_format = output_format;
140        self
141    }
142
143    /// Requests strict structured output described by the Rust type `T`.
144    #[must_use]
145    pub fn structured_output<T>(self, name: impl Into<String>) -> Self
146    where
147        T: JsonSchema,
148    {
149        self.output_format(OutputFormat::typed::<T>(name))
150    }
151
152    /// Replaces all local Agent configuration.
153    #[must_use]
154    pub const fn config(mut self, config: AgentConfig) -> Self {
155        self.agent.config = config;
156        self
157    }
158
159    /// Shares a write-ahead effect coordinator with this Agent.
160    #[must_use]
161    pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
162        self.agent.effects = effects;
163        self
164    }
165
166    /// Sets recovery behavior for ambiguous callable effects.
167    #[must_use]
168    pub const fn effect_recovery_policy(mut self, policy: EffectRecoveryPolicy) -> Self {
169        self.agent.effect_recovery = policy;
170        self
171    }
172
173    /// Validates registrations and returns the canonical Agent.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`AgentBuildError`] for blank identity, invalid turn bounds,
178    /// duplicate registrations, or Tool/Agent name collisions.
179    pub fn build(self) -> Result<Agent, AgentBuildError> {
180        if let Some(error) = self.error {
181            return Err(error);
182        }
183        if self.agent.name.trim().is_empty() {
184            return Err(AgentBuildError::EmptyName);
185        }
186        if self.agent.config.max_turns == 0 {
187            return Err(AgentBuildError::ZeroMaxTurns);
188        }
189        if let Some(collision) = self
190            .agent
191            .agents
192            .model_specs()
193            .into_iter()
194            .find(|spec| self.agent.tools.contains(&spec.name))
195        {
196            return Err(AgentBuildError::CallableNameCollision(collision.name));
197        }
198        Ok(self.agent)
199    }
200
201    /// Builds an Agent whose schema and local decoder are bound to `T`.
202    ///
203    /// This is the preferred terminal builder operation for structured output
204    /// because a different decode type cannot be selected later by mistake.
205    ///
206    /// # Errors
207    ///
208    /// Returns [`AgentBuildError`] under the same validation rules as
209    /// [`Self::build`].
210    pub fn build_structured<T>(
211        self,
212        name: impl Into<String>,
213    ) -> Result<StructuredAgent<T>, AgentBuildError>
214    where
215        T: JsonSchema,
216    {
217        self.structured_output::<T>(name)
218            .build()
219            .map(StructuredAgent::new)
220    }
221}
222
223impl std::fmt::Debug for AgentBuilder {
224    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        formatter
226            .debug_struct("AgentBuilder")
227            .field("agent", &self.agent.name)
228            .field("error", &self.error)
229            .finish_non_exhaustive()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use std::{collections::BTreeMap, sync::Arc};
236
237    use runifold_core::{CapabilityId, CapabilitySet, EffectClass, RiskLevel};
238    use runifold_model::{ModelRef, OutputFormat};
239    use runifold_testkit::ScriptedModel;
240    use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
241    use schemars::JsonSchema;
242    use serde::Deserialize;
243    use serde_json::json;
244
245    use crate::{Agent, AgentBuildError, AgentDescriptor};
246
247    struct TestTool {
248        descriptor: ToolDescriptor,
249    }
250
251    #[derive(Deserialize, JsonSchema)]
252    struct TypedAnswer {
253        value: u32,
254    }
255
256    impl TestTool {
257        fn named(name: &str) -> Self {
258            Self {
259                descriptor: ToolDescriptor {
260                    id: CapabilityId::new(),
261                    name: name.into(),
262                    version: "1".into(),
263                    description: "test".into(),
264                    input_schema: json!({"type": "object"}),
265                    output_schema: json!({"type": "object"}),
266                    effect: EffectClass::Pure,
267                    risk: RiskLevel::Low,
268                    metadata: BTreeMap::new(),
269                },
270            }
271        }
272    }
273
274    impl Tool for TestTool {
275        fn descriptor(&self) -> &ToolDescriptor {
276            &self.descriptor
277        }
278
279        fn invoke(
280            &self,
281            input: serde_json::Value,
282            _context: ToolContext,
283        ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
284            Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
285        }
286    }
287
288    #[test]
289    fn fluent_builder_assembles_the_canonical_agent() {
290        let model = Arc::new(ScriptedModel::new());
291        let agent = Agent::builder("worker", model, ModelRef::new("test", "scripted"))
292            .system("Be precise")
293            .tool(TestTool::named("lookup"))
294            .max_turns(4)
295            .build()
296            .unwrap();
297
298        assert_eq!(agent.name, "worker");
299        assert_eq!(agent.instructions.len(), 1);
300        assert!(agent.tools.contains("lookup"));
301        assert_eq!(agent.config.max_turns, 4);
302        assert_eq!(agent.callable_capabilities().len(), 1);
303    }
304
305    #[test]
306    fn build_rejects_tool_and_agent_name_collisions() {
307        let model = Arc::new(ScriptedModel::new());
308        let child = Arc::new(Agent::new(
309            "child",
310            model.clone(),
311            ModelRef::new("test", "child"),
312        ));
313        let error = Agent::builder("parent", model, ModelRef::new("test", "parent"))
314            .tool(TestTool::named("search"))
315            .child(
316                AgentDescriptor::new("search", "delegate search"),
317                child,
318                CapabilitySet::new(),
319            )
320            .build()
321            .unwrap_err();
322
323        assert_eq!(
324            error,
325            AgentBuildError::CallableNameCollision("search".into())
326        );
327    }
328
329    #[test]
330    fn builder_derives_a_strict_output_schema_from_a_rust_type() {
331        let example = TypedAnswer { value: 7 };
332        assert_eq!(example.value, 7);
333        let agent = Agent::builder(
334            "worker",
335            Arc::new(ScriptedModel::new()),
336            ModelRef::new("test", "scripted"),
337        )
338        .structured_output::<TypedAnswer>("typed_answer")
339        .build()
340        .unwrap();
341
342        let OutputFormat::JsonSchema {
343            name,
344            schema,
345            strict,
346        } = agent.output_format
347        else {
348            panic!("expected JSON-schema output");
349        };
350        assert_eq!(name, "typed_answer");
351        assert!(strict);
352        assert_eq!(schema["properties"]["value"]["type"], "integer");
353    }
354}