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#[derive(Clone, Debug, Error, Eq, PartialEq)]
17#[non_exhaustive]
18pub enum AgentBuildError {
19 #[error("agent Tool registration failed: {0}")]
21 Tool(#[from] ToolRegistrationError),
22 #[error("agent route registration failed: {0}")]
24 Route(#[from] AgentRegistrationError),
25 #[error("callable name `{0}` is registered as both a Tool and an Agent")]
27 CallableNameCollision(String),
28 #[error("agent name cannot be empty")]
30 EmptyName,
31 #[error("max_turns must be greater than zero")]
33 ZeroMaxTurns,
34}
35
36pub struct AgentBuilder {
42 agent: Agent,
43 error: Option<AgentBuildError>,
44}
45
46impl AgentBuilder {
47 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
154 pub const fn config(mut self, config: AgentConfig) -> Self {
155 self.agent.config = config;
156 self
157 }
158
159 #[must_use]
161 pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
162 self.agent.effects = effects;
163 self
164 }
165
166 #[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 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 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}