1use std::sync::Arc;
2
3use runifold_core::CapabilitySet;
4use runifold_effect::{EffectExecutor, EffectRecoveryPolicy};
5use runifold_model::{FeaturePolicy, Message, Model, ModelRef, OutputFormat};
6use runifold_retrieval::{Document, RetrievalError, Retriever};
7use runifold_tool::{Tool, ToolRegistrationError};
8use schemars::JsonSchema;
9use thiserror::Error;
10
11use crate::agent::DynamicContext;
12use crate::{
13 Agent, AgentConfig, AgentDescriptor, AgentError, AgentFuture, AgentOutcome,
14 AgentRegistrationError, AgentRoute, GatewayMiddleware, StructuredAgent, ToolErrorPolicy,
15};
16
17#[derive(Clone, Debug, Error, Eq, PartialEq)]
19#[non_exhaustive]
20pub enum AgentBuildError {
21 #[error("agent Tool registration failed: {0}")]
23 Tool(#[from] ToolRegistrationError),
24 #[error("agent route registration failed: {0}")]
26 Route(#[from] AgentRegistrationError),
27 #[error("callable name `{0}` is registered as both a Tool and an Agent")]
29 CallableNameCollision(String),
30 #[error("agent name cannot be empty")]
32 EmptyName,
33 #[error("max_turns must be greater than zero")]
35 ZeroMaxTurns,
36 #[error("agent retrieval configuration failed: {0}")]
38 Retrieval(#[from] RetrievalError),
39}
40
41#[derive(Debug, Error)]
43#[non_exhaustive]
44pub enum AgentPromptError {
45 #[error("failed to build agent: {0}")]
47 Build(#[from] AgentBuildError),
48 #[error("agent prompt failed: {0}")]
50 Run(#[from] AgentError),
51}
52
53pub struct AgentBuilder {
59 agent: Agent,
60 error: Option<AgentBuildError>,
61}
62
63impl AgentBuilder {
64 pub fn new(name: impl Into<String>, model: Arc<dyn Model>, model_ref: ModelRef) -> Self {
66 Self {
67 agent: Agent::new(name, model, model_ref),
68 error: None,
69 }
70 }
71
72 #[must_use]
74 pub fn system(mut self, instruction: impl Into<String>) -> Self {
75 self.agent
76 .instructions
77 .push(Message::system(instruction.into()));
78 self
79 }
80
81 #[must_use]
86 pub fn context(self, text: impl Into<String>) -> Self {
87 let id = format!("static-context-{}", self.agent.context.len() + 1);
88 match Document::new(id, text) {
89 Ok(document) => self.context_document(document),
90 Err(error) => self.with_error(error.into()),
91 }
92 }
93
94 #[must_use]
96 pub fn context_document(mut self, document: Document) -> Self {
97 if self.error.is_none() {
98 self.agent.context.push(document);
99 }
100 self
101 }
102
103 #[must_use]
105 pub fn dynamic_context<R>(self, limit: usize, retriever: R) -> Self
106 where
107 R: Retriever + 'static,
108 {
109 self.shared_dynamic_context(limit, Arc::new(retriever))
110 }
111
112 #[must_use]
114 pub fn shared_dynamic_context(mut self, limit: usize, retriever: Arc<dyn Retriever>) -> Self {
115 if self.error.is_none() {
116 if limit == 0 {
117 self.error = Some(RetrievalError::ZeroLimit.into());
118 } else {
119 self.agent
120 .dynamic_context
121 .push(DynamicContext { limit, retriever });
122 }
123 }
124 self
125 }
126
127 #[must_use]
129 pub fn tool<T>(self, tool: T) -> Self
130 where
131 T: Tool + 'static,
132 {
133 self.shared_tool(Arc::new(tool))
134 }
135
136 #[must_use]
138 pub fn shared_tool(mut self, tool: Arc<dyn Tool>) -> Self {
139 if self.error.is_none()
140 && let Err(error) = self.agent.tools.register(tool)
141 {
142 self.error = Some(error.into());
143 }
144 self
145 }
146
147 fn with_error(mut self, error: AgentBuildError) -> Self {
148 if self.error.is_none() {
149 self.error = Some(error);
150 }
151 self
152 }
153
154 #[must_use]
156 pub fn child(
157 mut self,
158 descriptor: AgentDescriptor,
159 child: Arc<Agent>,
160 capabilities: CapabilitySet,
161 ) -> Self {
162 if self.error.is_none() {
163 let route = AgentRoute::new(descriptor, child).with_capabilities(capabilities);
164 if let Err(error) = self.agent.agents.register(route) {
165 self.error = Some(error.into());
166 }
167 }
168 self
169 }
170
171 #[must_use]
173 pub fn gateway_layer(mut self, middleware: Arc<dyn GatewayMiddleware>) -> Self {
174 self.agent.agents.push_middleware(middleware);
175 self
176 }
177
178 #[must_use]
180 pub fn max_delegation_depth(mut self, max_depth: u32) -> Self {
181 self.agent.agents = self.agent.agents.with_max_depth(max_depth);
182 self
183 }
184
185 #[must_use]
187 pub const fn max_turns(mut self, max_turns: u32) -> Self {
188 self.agent.config.max_turns = max_turns;
189 self
190 }
191
192 #[must_use]
194 pub const fn tool_error_policy(mut self, policy: ToolErrorPolicy) -> Self {
195 self.agent.config.tool_error_policy = policy;
196 self
197 }
198
199 #[must_use]
201 pub const fn feature_policy(mut self, policy: FeaturePolicy) -> Self {
202 self.agent.config.feature_policy = policy;
203 self
204 }
205
206 #[must_use]
208 pub fn output_format(mut self, output_format: OutputFormat) -> Self {
209 self.agent.output_format = output_format;
210 self
211 }
212
213 #[must_use]
215 pub fn structured_output<T>(self, name: impl Into<String>) -> Self
216 where
217 T: JsonSchema,
218 {
219 self.output_format(OutputFormat::typed::<T>(name))
220 }
221
222 #[must_use]
224 pub const fn config(mut self, config: AgentConfig) -> Self {
225 self.agent.config = config;
226 self
227 }
228
229 #[must_use]
231 pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
232 self.agent.effects = effects;
233 self
234 }
235
236 #[must_use]
238 pub const fn effect_recovery_policy(mut self, policy: EffectRecoveryPolicy) -> Self {
239 self.agent.effect_recovery = policy;
240 self
241 }
242
243 pub fn build(self) -> Result<Agent, AgentBuildError> {
250 if let Some(error) = self.error {
251 return Err(error);
252 }
253 if self.agent.name.trim().is_empty() {
254 return Err(AgentBuildError::EmptyName);
255 }
256 if self.agent.config.max_turns == 0 {
257 return Err(AgentBuildError::ZeroMaxTurns);
258 }
259 if let Some(collision) = self
260 .agent
261 .agents
262 .model_specs()
263 .into_iter()
264 .find(|spec| self.agent.tools.contains(&spec.name))
265 {
266 return Err(AgentBuildError::CallableNameCollision(collision.name));
267 }
268 Ok(self.agent)
269 }
270
271 pub fn prompt(
277 self,
278 input: impl Into<String> + Send + 'static,
279 ) -> AgentFuture<'static, Result<AgentOutcome, AgentPromptError>> {
280 let input = input.into();
281 Box::pin(async move {
282 let agent = self.build()?;
283 Ok(agent.prompt(input).await?)
284 })
285 }
286
287 pub fn prompt_text(
294 self,
295 input: impl Into<String> + Send + 'static,
296 ) -> AgentFuture<'static, Result<String, AgentPromptError>> {
297 let input = input.into();
298 Box::pin(async move {
299 let agent = self.build()?;
300 Ok(agent.prompt_text(input).await?)
301 })
302 }
303
304 pub fn build_structured<T>(
314 self,
315 name: impl Into<String>,
316 ) -> Result<StructuredAgent<T>, AgentBuildError>
317 where
318 T: JsonSchema,
319 {
320 self.structured_output::<T>(name)
321 .build()
322 .map(StructuredAgent::new)
323 }
324}
325
326impl std::fmt::Debug for AgentBuilder {
327 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328 formatter
329 .debug_struct("AgentBuilder")
330 .field("agent", &self.agent.name)
331 .field("error", &self.error)
332 .finish_non_exhaustive()
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use std::{collections::BTreeMap, sync::Arc};
339
340 use runifold_core::{CapabilityId, CapabilitySet, EffectClass, RiskLevel};
341 use runifold_model::{ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat};
342 use runifold_testkit::ScriptedModel;
343 use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
344 use schemars::JsonSchema;
345 use serde::Deserialize;
346 use serde_json::json;
347
348 use crate::{Agent, AgentBuildError, AgentDescriptor, AgentPromptError};
349
350 struct TestTool {
351 descriptor: ToolDescriptor,
352 }
353
354 #[derive(Deserialize, JsonSchema)]
355 struct TypedAnswer {
356 value: u32,
357 }
358
359 impl TestTool {
360 fn named(name: &str) -> Self {
361 Self {
362 descriptor: ToolDescriptor {
363 id: CapabilityId::new(),
364 name: name.into(),
365 version: "1".into(),
366 description: "test".into(),
367 input_schema: json!({"type": "object"}),
368 output_schema: json!({"type": "object"}),
369 effect: EffectClass::Pure,
370 risk: RiskLevel::Low,
371 metadata: BTreeMap::new(),
372 },
373 }
374 }
375 }
376
377 impl Tool for TestTool {
378 fn descriptor(&self) -> &ToolDescriptor {
379 &self.descriptor
380 }
381
382 fn invoke(
383 &self,
384 input: serde_json::Value,
385 _context: ToolContext,
386 ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
387 Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
388 }
389 }
390
391 #[test]
392 fn fluent_builder_assembles_the_canonical_agent() {
393 let model = Arc::new(ScriptedModel::new());
394 let agent = Agent::builder("worker", model, ModelRef::new("test", "scripted"))
395 .system("Be precise")
396 .tool(TestTool::named("lookup"))
397 .max_turns(4)
398 .build()
399 .unwrap();
400
401 assert_eq!(agent.name, "worker");
402 assert_eq!(agent.instructions.len(), 1);
403 assert!(agent.tools.contains("lookup"));
404 assert_eq!(agent.config.max_turns, 4);
405 assert_eq!(agent.callable_capabilities().len(), 1);
406 }
407
408 #[test]
409 fn build_rejects_tool_and_agent_name_collisions() {
410 let model = Arc::new(ScriptedModel::new());
411 let child = Arc::new(Agent::new(
412 "child",
413 model.clone(),
414 ModelRef::new("test", "child"),
415 ));
416 let error = Agent::builder("parent", model, ModelRef::new("test", "parent"))
417 .tool(TestTool::named("search"))
418 .child(
419 AgentDescriptor::new("search", "delegate search"),
420 child,
421 CapabilitySet::new(),
422 )
423 .build()
424 .unwrap_err();
425
426 assert_eq!(
427 error,
428 AgentBuildError::CallableNameCollision("search".into())
429 );
430 }
431
432 #[test]
433 fn builder_derives_a_strict_output_schema_from_a_rust_type() {
434 let example = TypedAnswer { value: 7 };
435 assert_eq!(example.value, 7);
436 let agent = Agent::builder(
437 "worker",
438 Arc::new(ScriptedModel::new()),
439 ModelRef::new("test", "scripted"),
440 )
441 .structured_output::<TypedAnswer>("typed_answer")
442 .build()
443 .unwrap();
444
445 let OutputFormat::JsonSchema {
446 name,
447 schema,
448 strict,
449 } = agent.output_format
450 else {
451 panic!("expected JSON-schema output");
452 };
453 assert_eq!(name, "typed_answer");
454 assert!(strict);
455 assert_eq!(schema["properties"]["value"]["type"], "integer");
456 }
457
458 #[test]
459 fn builder_prompt_text_is_a_single_use_golden_path() {
460 let model = ScriptedModel::new();
461 model.enqueue([
462 ModelStreamEvent::ResponseStarted {
463 id: Some("response-1".into()),
464 model: ModelRef::new("test", "scripted"),
465 },
466 ModelStreamEvent::ContentPartCompleted {
467 index: 0,
468 part: ContentPart::text("done"),
469 },
470 ModelStreamEvent::ResponseCompleted {
471 finish_reason: FinishReason::Stop,
472 provider_metadata: BTreeMap::new(),
473 },
474 ]);
475
476 let text = futures_executor::block_on(
477 Agent::builder("worker", Arc::new(model), ModelRef::new("test", "scripted"))
478 .system("Be precise")
479 .prompt_text("start"),
480 )
481 .unwrap();
482
483 assert_eq!(text, "done");
484 }
485
486 #[test]
487 fn builder_prompt_reports_build_failures_before_model_execution() {
488 let error = futures_executor::block_on(
489 Agent::builder(
490 "",
491 Arc::new(ScriptedModel::new()),
492 ModelRef::new("test", "scripted"),
493 )
494 .prompt("start"),
495 )
496 .unwrap_err();
497
498 assert!(matches!(
499 error,
500 AgentPromptError::Build(AgentBuildError::EmptyName)
501 ));
502 }
503}