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 max_turns(mut self, max_turns: u32) -> Self {
242 self.agent.config.max_turns = max_turns;
243 self
244 }
245
246 #[must_use]
252 pub const fn min_successful_tool_calls(mut self, minimum: u32) -> Self {
253 self.agent.min_successful_tool_calls = minimum;
254 self
255 }
256
257 #[must_use]
259 pub const fn tool_error_policy(mut self, policy: ToolErrorPolicy) -> Self {
260 self.agent.config.tool_error_policy = policy;
261 self
262 }
263
264 #[must_use]
266 pub fn completion_requirement(self, requirement: CompletionRequirement) -> Self {
267 Self {
268 agent: self.agent.completion_requirement(requirement),
269 error: self.error,
270 }
271 }
272
273 #[must_use]
276 pub fn turn_reviewer<R>(
277 self,
278 reviewer: R,
279 policy: TurnReviewPolicy,
280 capabilities: CapabilitySet,
281 ) -> Self
282 where
283 R: TurnReviewer + 'static,
284 {
285 Self {
286 agent: self.agent.turn_reviewer(reviewer, policy, capabilities),
287 error: self.error,
288 }
289 }
290
291 #[must_use]
293 pub fn shared_turn_reviewer(
294 self,
295 reviewer: Arc<dyn TurnReviewer>,
296 policy: TurnReviewPolicy,
297 capabilities: CapabilitySet,
298 ) -> Self {
299 Self {
300 agent: self
301 .agent
302 .shared_turn_reviewer(reviewer, policy, capabilities),
303 error: self.error,
304 }
305 }
306
307 #[must_use]
310 pub fn terminal_reviewer<R>(
311 self,
312 reviewer: R,
313 policy: TerminalReviewPolicy,
314 capabilities: CapabilitySet,
315 ) -> Self
316 where
317 R: TerminalReviewer + 'static,
318 {
319 Self {
320 agent: self.agent.terminal_reviewer(reviewer, policy, capabilities),
321 error: self.error,
322 }
323 }
324
325 #[must_use]
327 pub fn shared_terminal_reviewer(
328 self,
329 reviewer: Arc<dyn TerminalReviewer>,
330 policy: TerminalReviewPolicy,
331 capabilities: CapabilitySet,
332 ) -> Self {
333 Self {
334 agent: self
335 .agent
336 .shared_terminal_reviewer(reviewer, policy, capabilities),
337 error: self.error,
338 }
339 }
340
341 #[must_use]
343 pub const fn feature_policy(mut self, policy: FeaturePolicy) -> Self {
344 self.agent.config.feature_policy = policy;
345 self
346 }
347
348 #[must_use]
350 pub fn output_format(mut self, output_format: OutputFormat) -> Self {
351 self.agent.output_format = output_format;
352 self
353 }
354
355 #[must_use]
357 pub fn structured_output<T>(self, name: impl Into<String>) -> Self
358 where
359 T: JsonSchema,
360 {
361 self.structured_output_with_strictness::<T>(name, true)
362 }
363
364 #[must_use]
367 pub fn structured_output_with_strictness<T>(self, name: impl Into<String>, strict: bool) -> Self
368 where
369 T: JsonSchema,
370 {
371 self.output_format(OutputFormat::typed_with_strictness::<T>(name, strict))
372 }
373
374 #[must_use]
376 pub fn provider_tool(mut self, tool: ProviderToolSpec) -> Self {
377 self.agent.provider_tools.push(tool);
378 self
379 }
380
381 #[must_use]
383 pub fn generation(mut self, generation: GenerationOptions) -> Self {
384 self.agent.generation = generation;
385 self
386 }
387
388 #[must_use]
390 pub fn temperature(mut self, temperature: f64) -> Self {
391 self.agent.generation.temperature = Some(temperature);
392 self
393 }
394
395 #[must_use]
397 pub fn top_p(mut self, top_p: f64) -> Self {
398 self.agent.generation.top_p = Some(top_p);
399 self
400 }
401
402 #[must_use]
404 pub fn max_output_tokens(mut self, max_output_tokens: u64) -> Self {
405 self.agent.generation.max_output_tokens = Some(max_output_tokens);
406 self
407 }
408
409 #[must_use]
411 pub const fn response_mode(mut self, response_mode: ResponseMode) -> Self {
412 self.agent.response_mode = response_mode;
413 self
414 }
415
416 #[must_use]
418 pub fn provider_options(
419 mut self,
420 provider: impl Into<String>,
421 options: serde_json::Value,
422 ) -> Self {
423 self.agent.provider_options.insert(provider.into(), options);
424 self
425 }
426
427 #[must_use]
429 pub const fn config(mut self, config: AgentConfig) -> Self {
430 self.agent.config = config;
431 self
432 }
433
434 #[must_use]
436 pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
437 self.agent.effects = effects;
438 self
439 }
440
441 #[must_use]
443 pub const fn effect_recovery_policy(mut self, policy: EffectRecoveryPolicy) -> Self {
444 self.agent.effect_recovery = policy;
445 self
446 }
447
448 pub fn build(self) -> Result<Agent, AgentBuildError> {
455 if let Some(error) = self.error {
456 return Err(error);
457 }
458 if self.agent.name.trim().is_empty() {
459 return Err(AgentBuildError::EmptyName);
460 }
461 if self.agent.config.max_turns == 0 {
462 return Err(AgentBuildError::ZeroMaxTurns);
463 }
464 if self.agent.min_successful_tool_calls > 0 && self.agent.tools.is_empty() {
465 return Err(AgentBuildError::MinimumSuccessfulToolCallsWithoutTool {
466 minimum: self.agent.min_successful_tool_calls,
467 });
468 }
469 if let Some(collision) = self
470 .agent
471 .agents
472 .model_specs()
473 .into_iter()
474 .find(|spec| self.agent.tools.contains(&spec.name))
475 {
476 return Err(AgentBuildError::CallableNameCollision(collision.name));
477 }
478 Ok(self.agent)
479 }
480
481 pub fn prompt(
487 self,
488 input: impl Into<String> + Send + 'static,
489 ) -> AgentFuture<'static, Result<AgentOutcome, AgentPromptError>> {
490 let input = input.into();
491 Box::pin(async move {
492 let agent = self.build()?;
493 Ok(agent.prompt(input).await?)
494 })
495 }
496
497 pub fn prompt_text(
504 self,
505 input: impl Into<String> + Send + 'static,
506 ) -> AgentFuture<'static, Result<String, AgentPromptError>> {
507 let input = input.into();
508 Box::pin(async move {
509 let agent = self.build()?;
510 Ok(agent.prompt_text(input).await?)
511 })
512 }
513
514 pub fn build_structured<T>(
524 self,
525 name: impl Into<String>,
526 ) -> Result<StructuredAgent<T>, AgentBuildError>
527 where
528 T: JsonSchema + serde::de::DeserializeOwned + Send + 'static,
529 {
530 self.build_structured_with_strictness::<T>(name, true)
531 }
532
533 pub fn build_structured_with_strictness<T>(
544 self,
545 name: impl Into<String>,
546 strict: bool,
547 ) -> Result<StructuredAgent<T>, AgentBuildError>
548 where
549 T: JsonSchema + serde::de::DeserializeOwned + Send + 'static,
550 {
551 self.build()
552 .map(|agent| agent.into_structured_with_strictness::<T>(name, strict))
553 }
554}
555
556impl std::fmt::Debug for AgentBuilder {
557 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558 formatter
559 .debug_struct("AgentBuilder")
560 .field("agent", &self.agent.name)
561 .field("error", &self.error)
562 .finish_non_exhaustive()
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use std::{collections::BTreeMap, sync::Arc};
569
570 use runifold_core::{
571 CapabilityId, CapabilitySet, EffectClass, RetrySafety, RiskLevel, RunErrorKind,
572 };
573 use runifold_model::{
574 ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat, ProviderToolSpec,
575 ResponseMode,
576 };
577 use runifold_testkit::ScriptedModel;
578 use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
579 use schemars::JsonSchema;
580 use serde::Deserialize;
581 use serde_json::json;
582
583 use crate::{Agent, AgentBuildError, AgentDescriptor, AgentPromptError};
584
585 struct TestTool {
586 descriptor: ToolDescriptor,
587 }
588
589 #[derive(Deserialize, JsonSchema)]
590 struct TypedAnswer {
591 value: u32,
592 }
593
594 impl TestTool {
595 fn named(name: &str) -> Self {
596 Self {
597 descriptor: ToolDescriptor {
598 id: CapabilityId::new(),
599 name: name.into(),
600 version: "1".into(),
601 description: "test".into(),
602 input_schema: json!({"type": "object"}),
603 output_schema: json!({"type": "object"}),
604 effect: EffectClass::Pure,
605 risk: RiskLevel::Low,
606 metadata: BTreeMap::new(),
607 },
608 }
609 }
610 }
611
612 impl Tool for TestTool {
613 fn descriptor(&self) -> &ToolDescriptor {
614 &self.descriptor
615 }
616
617 fn invoke(
618 &self,
619 input: serde_json::Value,
620 _context: ToolContext,
621 ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
622 Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
623 }
624 }
625
626 #[test]
627 fn fluent_builder_assembles_the_canonical_agent() {
628 let model = Arc::new(ScriptedModel::new());
629 let agent = Agent::builder("worker", model, ModelRef::new("test", "scripted"))
630 .system("Be precise")
631 .tool(TestTool::named("lookup"))
632 .min_successful_tool_calls(3)
633 .max_turns(4)
634 .build()
635 .unwrap();
636
637 assert_eq!(agent.name, "worker");
638 assert_eq!(agent.instructions.len(), 1);
639 assert!(agent.tools.contains("lookup"));
640 assert_eq!(agent.config.max_turns, 4);
641 assert_eq!(agent.min_successful_tool_calls, 3);
642 assert_eq!(agent.callable_capabilities().len(), 1);
643 }
644
645 #[test]
646 fn builder_rejects_a_tool_minimum_without_a_local_tool() {
647 let error = Agent::builder(
648 "worker",
649 Arc::new(ScriptedModel::new()),
650 ModelRef::new("test", "scripted"),
651 )
652 .min_successful_tool_calls(1)
653 .build()
654 .unwrap_err();
655
656 assert!(matches!(
657 error,
658 AgentBuildError::MinimumSuccessfulToolCallsWithoutTool { minimum: 1 }
659 ));
660 }
661
662 #[test]
663 fn builder_retains_generation_provider_and_delivery_controls() {
664 let provider_tool = ProviderToolSpec::new("ark", "web_search").unwrap();
665 let agent = Agent::builder(
666 "researcher",
667 Arc::new(ScriptedModel::new()),
668 ModelRef::new("ark", "doubao"),
669 )
670 .temperature(0.2)
671 .top_p(0.8)
672 .max_output_tokens(4_096)
673 .response_mode(ResponseMode::Complete)
674 .provider_tool(provider_tool)
675 .provider_options("ark", json!({"thinking": {"type": "enabled"}}))
676 .build()
677 .unwrap();
678
679 assert_eq!(agent.generation.temperature, Some(0.2));
680 assert_eq!(agent.generation.top_p, Some(0.8));
681 assert_eq!(agent.generation.max_output_tokens, Some(4_096));
682 assert_eq!(agent.response_mode, ResponseMode::Complete);
683 assert_eq!(agent.provider_tools[0].tool_type, "web_search");
684 assert_eq!(agent.provider_options["ark"]["thinking"]["type"], "enabled");
685 }
686
687 #[test]
688 fn build_rejects_tool_and_agent_name_collisions() {
689 let model = Arc::new(ScriptedModel::new());
690 let child = Arc::new(Agent::new(
691 "child",
692 model.clone(),
693 ModelRef::new("test", "child"),
694 ));
695 let error = Agent::builder("parent", model, ModelRef::new("test", "parent"))
696 .tool(TestTool::named("search"))
697 .child(
698 AgentDescriptor::new("search", "delegate search"),
699 child,
700 CapabilitySet::new(),
701 )
702 .build()
703 .unwrap_err();
704
705 assert_eq!(
706 error,
707 AgentBuildError::CallableNameCollision("search".into())
708 );
709 }
710
711 #[test]
712 fn builder_derives_a_strict_output_schema_from_a_rust_type() {
713 let example = TypedAnswer { value: 7 };
714 assert_eq!(example.value, 7);
715 let agent = Agent::builder(
716 "worker",
717 Arc::new(ScriptedModel::new()),
718 ModelRef::new("test", "scripted"),
719 )
720 .structured_output::<TypedAnswer>("typed_answer")
721 .build()
722 .unwrap();
723
724 let OutputFormat::JsonSchema {
725 name,
726 schema,
727 strict,
728 } = agent.output_format
729 else {
730 panic!("expected JSON-schema output");
731 };
732 assert_eq!(name, "typed_answer");
733 assert!(strict);
734 assert_eq!(schema["properties"]["value"]["type"], "integer");
735 }
736
737 #[test]
738 fn structured_builder_can_disable_provider_strictness_without_losing_typed_binding() {
739 let agent = Agent::builder(
740 "worker",
741 Arc::new(ScriptedModel::new()),
742 ModelRef::new("test", "scripted"),
743 )
744 .build_structured_with_strictness::<TypedAnswer>("typed_answer", false)
745 .unwrap();
746
747 let OutputFormat::JsonSchema { strict, .. } = &agent.agent().output_format else {
748 panic!("expected JSON-schema output");
749 };
750 assert!(!strict);
751 }
752
753 #[test]
754 fn builder_prompt_text_is_a_single_use_golden_path() {
755 let model = ScriptedModel::new();
756 model.enqueue([
757 ModelStreamEvent::ResponseStarted {
758 id: Some("response-1".into()),
759 model: ModelRef::new("test", "scripted"),
760 },
761 ModelStreamEvent::ContentPartCompleted {
762 index: 0,
763 part: ContentPart::text("done"),
764 },
765 ModelStreamEvent::ResponseCompleted {
766 finish_reason: FinishReason::Stop,
767 provider_metadata: BTreeMap::new(),
768 },
769 ]);
770
771 let text = futures_executor::block_on(
772 Agent::builder("worker", Arc::new(model), ModelRef::new("test", "scripted"))
773 .system("Be precise")
774 .prompt_text("start"),
775 )
776 .unwrap();
777
778 assert_eq!(text, "done");
779 }
780
781 #[test]
782 fn builder_prompt_reports_build_failures_before_model_execution() {
783 let error = futures_executor::block_on(
784 Agent::builder(
785 "",
786 Arc::new(ScriptedModel::new()),
787 ModelRef::new("test", "scripted"),
788 )
789 .prompt("start"),
790 )
791 .unwrap_err();
792
793 assert!(matches!(
794 &error,
795 AgentPromptError::Build(AgentBuildError::EmptyName)
796 ));
797 assert_eq!(error.run_error_kind(), RunErrorKind::InvalidInput);
798 assert_eq!(error.retry_safety(), RetrySafety::Safe);
799 assert_eq!(error.to_run_error().code(), "runifold.invalid_input");
800 }
801}