1use std::sync::Arc;
2
3use runifold_core::{CapabilitySet, RetrySafety, RunError, RunErrorKind};
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 TerminalReviewPolicy, TerminalReviewer, ToolErrorPolicy, TurnReviewPolicy, TurnReviewer,
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
51impl AgentBuildError {
52 pub const fn diagnostic_code(&self) -> &'static str {
54 "RF-AGENT-001"
55 }
56}
57
58#[derive(Debug, Error)]
60#[non_exhaustive]
61pub enum AgentPromptError {
62 #[error("failed to build agent: {0}")]
64 Build(#[from] AgentBuildError),
65 #[error("agent prompt failed: {0}")]
67 Run(#[from] AgentError),
68}
69
70impl AgentPromptError {
71 pub fn run_error_kind(&self) -> RunErrorKind {
73 match self {
74 Self::Build(_) => RunErrorKind::InvalidInput,
75 Self::Run(error) => error.run_error_kind(),
76 }
77 }
78
79 pub fn retry_safety(&self) -> RetrySafety {
81 match self {
82 Self::Build(_) => RetrySafety::Safe,
83 Self::Run(error) => error.retry_safety(),
84 }
85 }
86
87 pub fn to_run_error(&self) -> RunError {
89 match self {
90 Self::Build(_) => RunError {
91 kind: RunErrorKind::InvalidInput,
92 message: self.to_string(),
93 retry_safety: RetrySafety::Safe,
94 metadata: std::collections::BTreeMap::new(),
95 },
96 Self::Run(error) => error.to_run_error(),
97 }
98 }
99}
100
101pub struct AgentBuilder {
107 agent: Agent,
108 error: Option<AgentBuildError>,
109}
110
111impl AgentBuilder {
112 pub fn new(name: impl Into<String>, model: Arc<dyn Model>, model_ref: ModelRef) -> Self {
114 Self {
115 agent: Agent::new(name, model, model_ref),
116 error: None,
117 }
118 }
119
120 #[must_use]
122 pub fn system(mut self, instruction: impl Into<String>) -> Self {
123 self.agent
124 .instructions
125 .push(Message::system(instruction.into()));
126 self
127 }
128
129 #[must_use]
134 pub fn context(self, text: impl Into<String>) -> Self {
135 let id = format!("static-context-{}", self.agent.context.len() + 1);
136 match Document::new(id, text) {
137 Ok(document) => self.context_document(document),
138 Err(error) => self.with_error(error.into()),
139 }
140 }
141
142 #[must_use]
144 pub fn context_document(mut self, document: Document) -> Self {
145 if self.error.is_none() {
146 self.agent.context.push(document);
147 }
148 self
149 }
150
151 #[must_use]
154 pub fn artifacts(mut self, scope: ArtifactScope, store: Arc<dyn ArtifactStore>) -> Self {
155 self.agent.model = Arc::new(ArtifactResolvingModel::new(
156 self.agent.model.clone(),
157 scope.clone(),
158 store.clone(),
159 ));
160 self.agent.tools = self.agent.tools.clone().with_artifact_store(scope, store);
161 self
162 }
163
164 #[must_use]
166 pub fn dynamic_context<R>(self, limit: usize, retriever: R) -> Self
167 where
168 R: Retriever + 'static,
169 {
170 self.shared_dynamic_context(limit, Arc::new(retriever))
171 }
172
173 #[must_use]
175 pub fn shared_dynamic_context(mut self, limit: usize, retriever: Arc<dyn Retriever>) -> Self {
176 if self.error.is_none() {
177 if limit == 0 {
178 self.error = Some(RetrievalError::ZeroLimit.into());
179 } else {
180 self.agent
181 .dynamic_context
182 .push(DynamicContext { limit, retriever });
183 }
184 }
185 self
186 }
187
188 #[must_use]
190 pub fn tool<T>(self, tool: T) -> Self
191 where
192 T: Tool + 'static,
193 {
194 self.shared_tool(Arc::new(tool))
195 }
196
197 #[must_use]
199 pub fn shared_tool(mut self, tool: Arc<dyn Tool>) -> Self {
200 if self.error.is_none()
201 && let Err(error) = self.agent.tools.register(tool)
202 {
203 self.error = Some(error.into());
204 }
205 self
206 }
207
208 fn with_error(mut self, error: AgentBuildError) -> Self {
209 if self.error.is_none() {
210 self.error = Some(error);
211 }
212 self
213 }
214
215 #[must_use]
217 pub fn child(
218 mut self,
219 descriptor: AgentDescriptor,
220 child: Arc<Agent>,
221 capabilities: CapabilitySet,
222 ) -> Self {
223 if self.error.is_none() {
224 let route = AgentRoute::new(descriptor, child).with_capabilities(capabilities);
225 if let Err(error) = self.agent.agents.register(route) {
226 self.error = Some(error.into());
227 }
228 }
229 self
230 }
231
232 #[must_use]
234 pub fn gateway_layer(mut self, middleware: Arc<dyn GatewayMiddleware>) -> Self {
235 self.agent.agents.push_middleware(middleware);
236 self
237 }
238
239 #[must_use]
241 pub fn max_delegation_depth(mut self, max_depth: u32) -> Self {
242 self.agent.agents = self.agent.agents.with_max_depth(max_depth);
243 self
244 }
245
246 #[must_use]
248 pub const fn tool_concurrency(mut self, limit: std::num::NonZeroUsize) -> Self {
249 self.agent.tool_concurrency = limit;
250 self
251 }
252
253 #[must_use]
255 pub const fn max_turns(mut self, max_turns: u32) -> Self {
256 self.agent.config.max_turns = max_turns;
257 self
258 }
259
260 #[must_use]
266 pub const fn min_successful_tool_calls(mut self, minimum: u32) -> Self {
267 self.agent.min_successful_tool_calls = minimum;
268 self
269 }
270
271 #[must_use]
273 pub const fn tool_error_policy(mut self, policy: ToolErrorPolicy) -> Self {
274 self.agent.config.tool_error_policy = policy;
275 self
276 }
277
278 #[must_use]
280 pub fn completion_requirement(self, requirement: CompletionRequirement) -> Self {
281 Self {
282 agent: self.agent.completion_requirement(requirement),
283 error: self.error,
284 }
285 }
286
287 #[must_use]
290 pub fn turn_reviewer<R>(
291 self,
292 reviewer: R,
293 policy: TurnReviewPolicy,
294 capabilities: CapabilitySet,
295 ) -> Self
296 where
297 R: TurnReviewer + 'static,
298 {
299 Self {
300 agent: self.agent.turn_reviewer(reviewer, policy, capabilities),
301 error: self.error,
302 }
303 }
304
305 #[must_use]
307 pub fn shared_turn_reviewer(
308 self,
309 reviewer: Arc<dyn TurnReviewer>,
310 policy: TurnReviewPolicy,
311 capabilities: CapabilitySet,
312 ) -> Self {
313 Self {
314 agent: self
315 .agent
316 .shared_turn_reviewer(reviewer, policy, capabilities),
317 error: self.error,
318 }
319 }
320
321 #[must_use]
324 pub fn terminal_reviewer<R>(
325 self,
326 reviewer: R,
327 policy: TerminalReviewPolicy,
328 capabilities: CapabilitySet,
329 ) -> Self
330 where
331 R: TerminalReviewer + 'static,
332 {
333 Self {
334 agent: self.agent.terminal_reviewer(reviewer, policy, capabilities),
335 error: self.error,
336 }
337 }
338
339 #[must_use]
341 pub fn shared_terminal_reviewer(
342 self,
343 reviewer: Arc<dyn TerminalReviewer>,
344 policy: TerminalReviewPolicy,
345 capabilities: CapabilitySet,
346 ) -> Self {
347 Self {
348 agent: self
349 .agent
350 .shared_terminal_reviewer(reviewer, policy, capabilities),
351 error: self.error,
352 }
353 }
354
355 #[must_use]
357 pub const fn feature_policy(mut self, policy: FeaturePolicy) -> Self {
358 self.agent.config.feature_policy = policy;
359 self
360 }
361
362 #[must_use]
364 pub fn output_format(mut self, output_format: OutputFormat) -> Self {
365 self.agent.output_format = output_format;
366 self
367 }
368
369 #[must_use]
371 pub fn structured_output<T>(self, name: impl Into<String>) -> Self
372 where
373 T: JsonSchema,
374 {
375 self.structured_output_with_strictness::<T>(name, true)
376 }
377
378 #[must_use]
381 pub fn structured_output_with_strictness<T>(self, name: impl Into<String>, strict: bool) -> Self
382 where
383 T: JsonSchema,
384 {
385 self.output_format(OutputFormat::typed_with_strictness::<T>(name, strict))
386 }
387
388 #[must_use]
390 pub fn provider_tool(mut self, tool: ProviderToolSpec) -> Self {
391 self.agent.provider_tools.push(tool);
392 self
393 }
394
395 #[must_use]
397 pub fn generation(mut self, generation: GenerationOptions) -> Self {
398 self.agent.generation = generation;
399 self
400 }
401
402 #[must_use]
404 pub fn temperature(mut self, temperature: f64) -> Self {
405 self.agent.generation.temperature = Some(temperature);
406 self
407 }
408
409 #[must_use]
411 pub fn top_p(mut self, top_p: f64) -> Self {
412 self.agent.generation.top_p = Some(top_p);
413 self
414 }
415
416 #[must_use]
418 pub fn max_output_tokens(mut self, max_output_tokens: u64) -> Self {
419 self.agent.generation.max_output_tokens = Some(max_output_tokens);
420 self
421 }
422
423 #[must_use]
425 pub const fn response_mode(mut self, response_mode: ResponseMode) -> Self {
426 self.agent.response_mode = response_mode;
427 self
428 }
429
430 #[must_use]
432 pub fn provider_options(
433 mut self,
434 provider: impl Into<String>,
435 options: serde_json::Value,
436 ) -> Self {
437 self.agent.provider_options.insert(provider.into(), options);
438 self
439 }
440
441 #[must_use]
443 pub const fn config(mut self, config: AgentConfig) -> Self {
444 self.agent.config = config;
445 self
446 }
447
448 #[must_use]
450 pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
451 self.agent.effects = effects;
452 self
453 }
454
455 #[must_use]
457 pub const fn effect_recovery_policy(mut self, policy: EffectRecoveryPolicy) -> Self {
458 self.agent.effect_recovery = policy;
459 self
460 }
461
462 pub fn build(self) -> Result<Agent, AgentBuildError> {
469 if let Some(error) = self.error {
470 return Err(error);
471 }
472 if self.agent.name.trim().is_empty() {
473 return Err(AgentBuildError::EmptyName);
474 }
475 if self.agent.config.max_turns == 0 {
476 return Err(AgentBuildError::ZeroMaxTurns);
477 }
478 if self.agent.min_successful_tool_calls > 0 && self.agent.tools.is_empty() {
479 return Err(AgentBuildError::MinimumSuccessfulToolCallsWithoutTool {
480 minimum: self.agent.min_successful_tool_calls,
481 });
482 }
483 if let Some(collision) = self
484 .agent
485 .agents
486 .model_specs()
487 .into_iter()
488 .find(|spec| self.agent.tools.contains(&spec.name))
489 {
490 return Err(AgentBuildError::CallableNameCollision(collision.name));
491 }
492 Ok(self.agent)
493 }
494
495 pub fn prompt(
501 self,
502 input: impl Into<String> + Send + 'static,
503 ) -> AgentFuture<'static, Result<AgentOutcome, AgentPromptError>> {
504 let input = input.into();
505 Box::pin(async move {
506 let agent = self.build()?;
507 Ok(agent.prompt(input).await?)
508 })
509 }
510
511 pub fn prompt_text(
518 self,
519 input: impl Into<String> + Send + 'static,
520 ) -> AgentFuture<'static, Result<String, AgentPromptError>> {
521 let input = input.into();
522 Box::pin(async move {
523 let agent = self.build()?;
524 Ok(agent.prompt_text(input).await?)
525 })
526 }
527
528 pub fn build_structured<T>(
538 self,
539 name: impl Into<String>,
540 ) -> Result<StructuredAgent<T>, AgentBuildError>
541 where
542 T: JsonSchema + serde::de::DeserializeOwned + Send + 'static,
543 {
544 self.build_structured_with_strictness::<T>(name, true)
545 }
546
547 pub fn build_structured_with_strictness<T>(
558 self,
559 name: impl Into<String>,
560 strict: bool,
561 ) -> Result<StructuredAgent<T>, AgentBuildError>
562 where
563 T: JsonSchema + serde::de::DeserializeOwned + Send + 'static,
564 {
565 self.build()
566 .map(|agent| agent.into_structured_with_strictness::<T>(name, strict))
567 }
568}
569
570impl std::fmt::Debug for AgentBuilder {
571 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572 formatter
573 .debug_struct("AgentBuilder")
574 .field("agent", &self.agent.name)
575 .field("error", &self.error)
576 .finish_non_exhaustive()
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use std::{collections::BTreeMap, sync::Arc};
583
584 use runifold_core::{
585 CapabilityId, CapabilitySet, EffectClass, RetrySafety, RiskLevel, RunErrorKind,
586 };
587 use runifold_model::{
588 ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat, ProviderToolSpec,
589 ResponseMode,
590 };
591 use runifold_testkit::ScriptedModel;
592 use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
593 use schemars::JsonSchema;
594 use serde::Deserialize;
595 use serde_json::json;
596
597 use crate::{Agent, AgentBuildError, AgentDescriptor, AgentPromptError};
598
599 struct TestTool {
600 descriptor: ToolDescriptor,
601 }
602
603 #[derive(Deserialize, JsonSchema)]
604 struct TypedAnswer {
605 value: u32,
606 }
607
608 impl TestTool {
609 fn named(name: &str) -> Self {
610 Self {
611 descriptor: ToolDescriptor {
612 id: CapabilityId::new(),
613 name: name.into(),
614 version: "1".into(),
615 description: "test".into(),
616 input_schema: json!({"type": "object"}),
617 output_schema: json!({"type": "object"}),
618 effect: EffectClass::Pure,
619 risk: RiskLevel::Low,
620 metadata: BTreeMap::new(),
621 },
622 }
623 }
624 }
625
626 impl Tool for TestTool {
627 fn descriptor(&self) -> &ToolDescriptor {
628 &self.descriptor
629 }
630
631 fn invoke(
632 &self,
633 input: serde_json::Value,
634 _context: ToolContext,
635 ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
636 Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
637 }
638 }
639
640 #[test]
641 fn fluent_builder_assembles_the_canonical_agent() {
642 let model = Arc::new(ScriptedModel::new());
643 let agent = Agent::builder("worker", model, ModelRef::new("test", "scripted"))
644 .system("Be precise")
645 .tool(TestTool::named("lookup"))
646 .min_successful_tool_calls(3)
647 .max_turns(4)
648 .build()
649 .unwrap();
650
651 assert_eq!(agent.name, "worker");
652 assert_eq!(agent.instructions.len(), 1);
653 assert!(agent.tools.contains("lookup"));
654 assert_eq!(agent.config.max_turns, 4);
655 assert_eq!(agent.min_successful_tool_calls, 3);
656 assert_eq!(agent.callable_capabilities().len(), 1);
657 }
658
659 #[test]
660 fn builder_rejects_a_tool_minimum_without_a_local_tool() {
661 let error = Agent::builder(
662 "worker",
663 Arc::new(ScriptedModel::new()),
664 ModelRef::new("test", "scripted"),
665 )
666 .min_successful_tool_calls(1)
667 .build()
668 .unwrap_err();
669
670 assert!(matches!(
671 error,
672 AgentBuildError::MinimumSuccessfulToolCallsWithoutTool { minimum: 1 }
673 ));
674 }
675
676 #[test]
677 fn builder_retains_generation_provider_and_delivery_controls() {
678 let provider_tool = ProviderToolSpec::new("ark", "web_search").unwrap();
679 let agent = Agent::builder(
680 "researcher",
681 Arc::new(ScriptedModel::new()),
682 ModelRef::new("ark", "doubao"),
683 )
684 .temperature(0.2)
685 .top_p(0.8)
686 .max_output_tokens(4_096)
687 .response_mode(ResponseMode::Complete)
688 .provider_tool(provider_tool)
689 .provider_options("ark", json!({"thinking": {"type": "enabled"}}))
690 .build()
691 .unwrap();
692
693 assert_eq!(agent.generation.temperature, Some(0.2));
694 assert_eq!(agent.generation.top_p, Some(0.8));
695 assert_eq!(agent.generation.max_output_tokens, Some(4_096));
696 assert_eq!(agent.response_mode, ResponseMode::Complete);
697 assert_eq!(agent.provider_tools[0].tool_type, "web_search");
698 assert_eq!(agent.provider_options["ark"]["thinking"]["type"], "enabled");
699 }
700
701 #[test]
702 fn build_rejects_tool_and_agent_name_collisions() {
703 let model = Arc::new(ScriptedModel::new());
704 let child = Arc::new(Agent::new(
705 "child",
706 model.clone(),
707 ModelRef::new("test", "child"),
708 ));
709 let error = Agent::builder("parent", model, ModelRef::new("test", "parent"))
710 .tool(TestTool::named("search"))
711 .child(
712 AgentDescriptor::new("search", "delegate search"),
713 child,
714 CapabilitySet::new(),
715 )
716 .build()
717 .unwrap_err();
718
719 assert_eq!(
720 error,
721 AgentBuildError::CallableNameCollision("search".into())
722 );
723 }
724
725 #[test]
726 fn builder_derives_a_strict_output_schema_from_a_rust_type() {
727 let example = TypedAnswer { value: 7 };
728 assert_eq!(example.value, 7);
729 let agent = Agent::builder(
730 "worker",
731 Arc::new(ScriptedModel::new()),
732 ModelRef::new("test", "scripted"),
733 )
734 .structured_output::<TypedAnswer>("typed_answer")
735 .build()
736 .unwrap();
737
738 let OutputFormat::JsonSchema {
739 name,
740 schema,
741 strict,
742 } = agent.output_format
743 else {
744 panic!("expected JSON-schema output");
745 };
746 assert_eq!(name, "typed_answer");
747 assert!(strict);
748 assert_eq!(schema["properties"]["value"]["type"], "integer");
749 }
750
751 #[test]
752 fn structured_builder_can_disable_provider_strictness_without_losing_typed_binding() {
753 let agent = Agent::builder(
754 "worker",
755 Arc::new(ScriptedModel::new()),
756 ModelRef::new("test", "scripted"),
757 )
758 .build_structured_with_strictness::<TypedAnswer>("typed_answer", false)
759 .unwrap();
760
761 let OutputFormat::JsonSchema { strict, .. } = &agent.agent().output_format else {
762 panic!("expected JSON-schema output");
763 };
764 assert!(!strict);
765 }
766
767 #[test]
768 fn builder_prompt_text_is_a_single_use_golden_path() {
769 let model = ScriptedModel::new();
770 model.enqueue([
771 ModelStreamEvent::ResponseStarted {
772 id: Some("response-1".into()),
773 model: ModelRef::new("test", "scripted"),
774 },
775 ModelStreamEvent::ContentPartCompleted {
776 index: 0,
777 part: ContentPart::text("done"),
778 },
779 ModelStreamEvent::ResponseCompleted {
780 finish_reason: FinishReason::Stop,
781 provider_metadata: BTreeMap::new(),
782 },
783 ]);
784
785 let text = futures_executor::block_on(
786 Agent::builder("worker", Arc::new(model), ModelRef::new("test", "scripted"))
787 .system("Be precise")
788 .prompt_text("start"),
789 )
790 .unwrap();
791
792 assert_eq!(text, "done");
793 }
794
795 #[test]
796 fn builder_prompt_reports_build_failures_before_model_execution() {
797 let error = futures_executor::block_on(
798 Agent::builder(
799 "",
800 Arc::new(ScriptedModel::new()),
801 ModelRef::new("test", "scripted"),
802 )
803 .prompt("start"),
804 )
805 .unwrap_err();
806
807 assert!(matches!(
808 &error,
809 AgentPromptError::Build(AgentBuildError::EmptyName)
810 ));
811 assert_eq!(error.run_error_kind(), RunErrorKind::InvalidInput);
812 assert_eq!(error.retry_safety(), RetrySafety::Safe);
813 assert_eq!(error.to_run_error().code(), "runifold.invalid_input");
814 }
815}