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