1use std::sync::Arc;
2
3use runifold_core::CapabilitySet;
4use runifold_effect::{EffectExecutor, EffectRecoveryPolicy};
5use runifold_model::{
6 ArtifactResolvingModel, ArtifactScope, ArtifactStore, FeaturePolicy, GenerationOptions,
7 Message, Model, ModelRef, OutputFormat, ProviderToolSpec, ResponseMode,
8};
9use runifold_retrieval::{Document, RetrievalError, Retriever};
10use runifold_tool::{Tool, ToolRegistrationError};
11use schemars::JsonSchema;
12use thiserror::Error;
13
14use crate::agent::DynamicContext;
15use crate::{
16 Agent, AgentConfig, AgentDescriptor, AgentError, AgentFuture, AgentOutcome,
17 AgentRegistrationError, AgentRoute, CompletionRequirement, GatewayMiddleware, StructuredAgent,
18 ToolErrorPolicy,
19};
20
21#[derive(Clone, Debug, Error, Eq, PartialEq)]
23#[non_exhaustive]
24pub enum AgentBuildError {
25 #[error("agent Tool registration failed: {0}")]
27 Tool(#[from] ToolRegistrationError),
28 #[error("agent route registration failed: {0}")]
30 Route(#[from] AgentRegistrationError),
31 #[error("callable name `{0}` is registered as both a Tool and an Agent")]
33 CallableNameCollision(String),
34 #[error("agent name cannot be empty")]
36 EmptyName,
37 #[error("max_turns must be greater than zero")]
39 ZeroMaxTurns,
40 #[error("min_successful_tool_calls={minimum} requires at least one registered local Tool")]
42 MinimumSuccessfulToolCallsWithoutTool {
43 minimum: u32,
45 },
46 #[error("agent retrieval configuration failed: {0}")]
48 Retrieval(#[from] RetrievalError),
49}
50
51#[derive(Debug, Error)]
53#[non_exhaustive]
54pub enum AgentPromptError {
55 #[error("failed to build agent: {0}")]
57 Build(#[from] AgentBuildError),
58 #[error("agent prompt failed: {0}")]
60 Run(#[from] AgentError),
61}
62
63pub struct AgentBuilder {
69 agent: Agent,
70 error: Option<AgentBuildError>,
71}
72
73impl AgentBuilder {
74 pub fn new(name: impl Into<String>, model: Arc<dyn Model>, model_ref: ModelRef) -> Self {
76 Self {
77 agent: Agent::new(name, model, model_ref),
78 error: None,
79 }
80 }
81
82 #[must_use]
84 pub fn system(mut self, instruction: impl Into<String>) -> Self {
85 self.agent
86 .instructions
87 .push(Message::system(instruction.into()));
88 self
89 }
90
91 #[must_use]
96 pub fn context(self, text: impl Into<String>) -> Self {
97 let id = format!("static-context-{}", self.agent.context.len() + 1);
98 match Document::new(id, text) {
99 Ok(document) => self.context_document(document),
100 Err(error) => self.with_error(error.into()),
101 }
102 }
103
104 #[must_use]
106 pub fn context_document(mut self, document: Document) -> Self {
107 if self.error.is_none() {
108 self.agent.context.push(document);
109 }
110 self
111 }
112
113 #[must_use]
116 pub fn artifacts(mut self, scope: ArtifactScope, store: Arc<dyn ArtifactStore>) -> Self {
117 self.agent.model = Arc::new(ArtifactResolvingModel::new(
118 self.agent.model.clone(),
119 scope.clone(),
120 store.clone(),
121 ));
122 self.agent.tools = self.agent.tools.clone().with_artifact_store(scope, store);
123 self
124 }
125
126 #[must_use]
128 pub fn dynamic_context<R>(self, limit: usize, retriever: R) -> Self
129 where
130 R: Retriever + 'static,
131 {
132 self.shared_dynamic_context(limit, Arc::new(retriever))
133 }
134
135 #[must_use]
137 pub fn shared_dynamic_context(mut self, limit: usize, retriever: Arc<dyn Retriever>) -> Self {
138 if self.error.is_none() {
139 if limit == 0 {
140 self.error = Some(RetrievalError::ZeroLimit.into());
141 } else {
142 self.agent
143 .dynamic_context
144 .push(DynamicContext { limit, retriever });
145 }
146 }
147 self
148 }
149
150 #[must_use]
152 pub fn tool<T>(self, tool: T) -> Self
153 where
154 T: Tool + 'static,
155 {
156 self.shared_tool(Arc::new(tool))
157 }
158
159 #[must_use]
161 pub fn shared_tool(mut self, tool: Arc<dyn Tool>) -> Self {
162 if self.error.is_none()
163 && let Err(error) = self.agent.tools.register(tool)
164 {
165 self.error = Some(error.into());
166 }
167 self
168 }
169
170 fn with_error(mut self, error: AgentBuildError) -> Self {
171 if self.error.is_none() {
172 self.error = Some(error);
173 }
174 self
175 }
176
177 #[must_use]
179 pub fn child(
180 mut self,
181 descriptor: AgentDescriptor,
182 child: Arc<Agent>,
183 capabilities: CapabilitySet,
184 ) -> Self {
185 if self.error.is_none() {
186 let route = AgentRoute::new(descriptor, child).with_capabilities(capabilities);
187 if let Err(error) = self.agent.agents.register(route) {
188 self.error = Some(error.into());
189 }
190 }
191 self
192 }
193
194 #[must_use]
196 pub fn gateway_layer(mut self, middleware: Arc<dyn GatewayMiddleware>) -> Self {
197 self.agent.agents.push_middleware(middleware);
198 self
199 }
200
201 #[must_use]
203 pub fn max_delegation_depth(mut self, max_depth: u32) -> Self {
204 self.agent.agents = self.agent.agents.with_max_depth(max_depth);
205 self
206 }
207
208 #[must_use]
210 pub const fn max_turns(mut self, max_turns: u32) -> Self {
211 self.agent.config.max_turns = max_turns;
212 self
213 }
214
215 #[must_use]
221 pub const fn min_successful_tool_calls(mut self, minimum: u32) -> Self {
222 self.agent.min_successful_tool_calls = minimum;
223 self
224 }
225
226 #[must_use]
228 pub const fn tool_error_policy(mut self, policy: ToolErrorPolicy) -> Self {
229 self.agent.config.tool_error_policy = policy;
230 self
231 }
232
233 #[must_use]
235 pub fn completion_requirement(self, requirement: CompletionRequirement) -> Self {
236 Self {
237 agent: self.agent.completion_requirement(requirement),
238 error: self.error,
239 }
240 }
241
242 #[must_use]
244 pub const fn feature_policy(mut self, policy: FeaturePolicy) -> Self {
245 self.agent.config.feature_policy = policy;
246 self
247 }
248
249 #[must_use]
251 pub fn output_format(mut self, output_format: OutputFormat) -> Self {
252 self.agent.output_format = output_format;
253 self
254 }
255
256 #[must_use]
258 pub fn structured_output<T>(self, name: impl Into<String>) -> Self
259 where
260 T: JsonSchema,
261 {
262 self.output_format(OutputFormat::typed::<T>(name))
263 }
264
265 #[must_use]
267 pub fn provider_tool(mut self, tool: ProviderToolSpec) -> Self {
268 self.agent.provider_tools.push(tool);
269 self
270 }
271
272 #[must_use]
274 pub fn generation(mut self, generation: GenerationOptions) -> Self {
275 self.agent.generation = generation;
276 self
277 }
278
279 #[must_use]
281 pub fn temperature(mut self, temperature: f64) -> Self {
282 self.agent.generation.temperature = Some(temperature);
283 self
284 }
285
286 #[must_use]
288 pub fn top_p(mut self, top_p: f64) -> Self {
289 self.agent.generation.top_p = Some(top_p);
290 self
291 }
292
293 #[must_use]
295 pub fn max_output_tokens(mut self, max_output_tokens: u64) -> Self {
296 self.agent.generation.max_output_tokens = Some(max_output_tokens);
297 self
298 }
299
300 #[must_use]
302 pub const fn response_mode(mut self, response_mode: ResponseMode) -> Self {
303 self.agent.response_mode = response_mode;
304 self
305 }
306
307 #[must_use]
309 pub fn provider_options(
310 mut self,
311 provider: impl Into<String>,
312 options: serde_json::Value,
313 ) -> Self {
314 self.agent.provider_options.insert(provider.into(), options);
315 self
316 }
317
318 #[must_use]
320 pub const fn config(mut self, config: AgentConfig) -> Self {
321 self.agent.config = config;
322 self
323 }
324
325 #[must_use]
327 pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
328 self.agent.effects = effects;
329 self
330 }
331
332 #[must_use]
334 pub const fn effect_recovery_policy(mut self, policy: EffectRecoveryPolicy) -> Self {
335 self.agent.effect_recovery = policy;
336 self
337 }
338
339 pub fn build(self) -> Result<Agent, AgentBuildError> {
346 if let Some(error) = self.error {
347 return Err(error);
348 }
349 if self.agent.name.trim().is_empty() {
350 return Err(AgentBuildError::EmptyName);
351 }
352 if self.agent.config.max_turns == 0 {
353 return Err(AgentBuildError::ZeroMaxTurns);
354 }
355 if self.agent.min_successful_tool_calls > 0 && self.agent.tools.is_empty() {
356 return Err(AgentBuildError::MinimumSuccessfulToolCallsWithoutTool {
357 minimum: self.agent.min_successful_tool_calls,
358 });
359 }
360 if let Some(collision) = self
361 .agent
362 .agents
363 .model_specs()
364 .into_iter()
365 .find(|spec| self.agent.tools.contains(&spec.name))
366 {
367 return Err(AgentBuildError::CallableNameCollision(collision.name));
368 }
369 Ok(self.agent)
370 }
371
372 pub fn prompt(
378 self,
379 input: impl Into<String> + Send + 'static,
380 ) -> AgentFuture<'static, Result<AgentOutcome, AgentPromptError>> {
381 let input = input.into();
382 Box::pin(async move {
383 let agent = self.build()?;
384 Ok(agent.prompt(input).await?)
385 })
386 }
387
388 pub fn prompt_text(
395 self,
396 input: impl Into<String> + Send + 'static,
397 ) -> AgentFuture<'static, Result<String, AgentPromptError>> {
398 let input = input.into();
399 Box::pin(async move {
400 let agent = self.build()?;
401 Ok(agent.prompt_text(input).await?)
402 })
403 }
404
405 pub fn build_structured<T>(
415 self,
416 name: impl Into<String>,
417 ) -> Result<StructuredAgent<T>, AgentBuildError>
418 where
419 T: JsonSchema + serde::de::DeserializeOwned + Send + 'static,
420 {
421 self.build().map(|agent| agent.into_structured::<T>(name))
422 }
423}
424
425impl std::fmt::Debug for AgentBuilder {
426 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427 formatter
428 .debug_struct("AgentBuilder")
429 .field("agent", &self.agent.name)
430 .field("error", &self.error)
431 .finish_non_exhaustive()
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use std::{collections::BTreeMap, sync::Arc};
438
439 use runifold_core::{CapabilityId, CapabilitySet, EffectClass, RiskLevel};
440 use runifold_model::{
441 ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat, ProviderToolSpec,
442 ResponseMode,
443 };
444 use runifold_testkit::ScriptedModel;
445 use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
446 use schemars::JsonSchema;
447 use serde::Deserialize;
448 use serde_json::json;
449
450 use crate::{Agent, AgentBuildError, AgentDescriptor, AgentPromptError};
451
452 struct TestTool {
453 descriptor: ToolDescriptor,
454 }
455
456 #[derive(Deserialize, JsonSchema)]
457 struct TypedAnswer {
458 value: u32,
459 }
460
461 impl TestTool {
462 fn named(name: &str) -> Self {
463 Self {
464 descriptor: ToolDescriptor {
465 id: CapabilityId::new(),
466 name: name.into(),
467 version: "1".into(),
468 description: "test".into(),
469 input_schema: json!({"type": "object"}),
470 output_schema: json!({"type": "object"}),
471 effect: EffectClass::Pure,
472 risk: RiskLevel::Low,
473 metadata: BTreeMap::new(),
474 },
475 }
476 }
477 }
478
479 impl Tool for TestTool {
480 fn descriptor(&self) -> &ToolDescriptor {
481 &self.descriptor
482 }
483
484 fn invoke(
485 &self,
486 input: serde_json::Value,
487 _context: ToolContext,
488 ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
489 Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
490 }
491 }
492
493 #[test]
494 fn fluent_builder_assembles_the_canonical_agent() {
495 let model = Arc::new(ScriptedModel::new());
496 let agent = Agent::builder("worker", model, ModelRef::new("test", "scripted"))
497 .system("Be precise")
498 .tool(TestTool::named("lookup"))
499 .min_successful_tool_calls(3)
500 .max_turns(4)
501 .build()
502 .unwrap();
503
504 assert_eq!(agent.name, "worker");
505 assert_eq!(agent.instructions.len(), 1);
506 assert!(agent.tools.contains("lookup"));
507 assert_eq!(agent.config.max_turns, 4);
508 assert_eq!(agent.min_successful_tool_calls, 3);
509 assert_eq!(agent.callable_capabilities().len(), 1);
510 }
511
512 #[test]
513 fn builder_rejects_a_tool_minimum_without_a_local_tool() {
514 let error = Agent::builder(
515 "worker",
516 Arc::new(ScriptedModel::new()),
517 ModelRef::new("test", "scripted"),
518 )
519 .min_successful_tool_calls(1)
520 .build()
521 .unwrap_err();
522
523 assert!(matches!(
524 error,
525 AgentBuildError::MinimumSuccessfulToolCallsWithoutTool { minimum: 1 }
526 ));
527 }
528
529 #[test]
530 fn builder_retains_generation_provider_and_delivery_controls() {
531 let provider_tool = ProviderToolSpec::new("ark", "web_search").unwrap();
532 let agent = Agent::builder(
533 "researcher",
534 Arc::new(ScriptedModel::new()),
535 ModelRef::new("ark", "doubao"),
536 )
537 .temperature(0.2)
538 .top_p(0.8)
539 .max_output_tokens(4_096)
540 .response_mode(ResponseMode::Complete)
541 .provider_tool(provider_tool)
542 .provider_options("ark", json!({"thinking": {"type": "enabled"}}))
543 .build()
544 .unwrap();
545
546 assert_eq!(agent.generation.temperature, Some(0.2));
547 assert_eq!(agent.generation.top_p, Some(0.8));
548 assert_eq!(agent.generation.max_output_tokens, Some(4_096));
549 assert_eq!(agent.response_mode, ResponseMode::Complete);
550 assert_eq!(agent.provider_tools[0].tool_type, "web_search");
551 assert_eq!(agent.provider_options["ark"]["thinking"]["type"], "enabled");
552 }
553
554 #[test]
555 fn build_rejects_tool_and_agent_name_collisions() {
556 let model = Arc::new(ScriptedModel::new());
557 let child = Arc::new(Agent::new(
558 "child",
559 model.clone(),
560 ModelRef::new("test", "child"),
561 ));
562 let error = Agent::builder("parent", model, ModelRef::new("test", "parent"))
563 .tool(TestTool::named("search"))
564 .child(
565 AgentDescriptor::new("search", "delegate search"),
566 child,
567 CapabilitySet::new(),
568 )
569 .build()
570 .unwrap_err();
571
572 assert_eq!(
573 error,
574 AgentBuildError::CallableNameCollision("search".into())
575 );
576 }
577
578 #[test]
579 fn builder_derives_a_strict_output_schema_from_a_rust_type() {
580 let example = TypedAnswer { value: 7 };
581 assert_eq!(example.value, 7);
582 let agent = Agent::builder(
583 "worker",
584 Arc::new(ScriptedModel::new()),
585 ModelRef::new("test", "scripted"),
586 )
587 .structured_output::<TypedAnswer>("typed_answer")
588 .build()
589 .unwrap();
590
591 let OutputFormat::JsonSchema {
592 name,
593 schema,
594 strict,
595 } = agent.output_format
596 else {
597 panic!("expected JSON-schema output");
598 };
599 assert_eq!(name, "typed_answer");
600 assert!(strict);
601 assert_eq!(schema["properties"]["value"]["type"], "integer");
602 }
603
604 #[test]
605 fn builder_prompt_text_is_a_single_use_golden_path() {
606 let model = ScriptedModel::new();
607 model.enqueue([
608 ModelStreamEvent::ResponseStarted {
609 id: Some("response-1".into()),
610 model: ModelRef::new("test", "scripted"),
611 },
612 ModelStreamEvent::ContentPartCompleted {
613 index: 0,
614 part: ContentPart::text("done"),
615 },
616 ModelStreamEvent::ResponseCompleted {
617 finish_reason: FinishReason::Stop,
618 provider_metadata: BTreeMap::new(),
619 },
620 ]);
621
622 let text = futures_executor::block_on(
623 Agent::builder("worker", Arc::new(model), ModelRef::new("test", "scripted"))
624 .system("Be precise")
625 .prompt_text("start"),
626 )
627 .unwrap();
628
629 assert_eq!(text, "done");
630 }
631
632 #[test]
633 fn builder_prompt_reports_build_failures_before_model_execution() {
634 let error = futures_executor::block_on(
635 Agent::builder(
636 "",
637 Arc::new(ScriptedModel::new()),
638 ModelRef::new("test", "scripted"),
639 )
640 .prompt("start"),
641 )
642 .unwrap_err();
643
644 assert!(matches!(
645 error,
646 AgentPromptError::Build(AgentBuildError::EmptyName)
647 ));
648 }
649}