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 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
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]
275 pub const fn feature_policy(mut self, policy: FeaturePolicy) -> Self {
276 self.agent.config.feature_policy = policy;
277 self
278 }
279
280 #[must_use]
282 pub fn output_format(mut self, output_format: OutputFormat) -> Self {
283 self.agent.output_format = output_format;
284 self
285 }
286
287 #[must_use]
289 pub fn structured_output<T>(self, name: impl Into<String>) -> Self
290 where
291 T: JsonSchema,
292 {
293 self.output_format(OutputFormat::typed::<T>(name))
294 }
295
296 #[must_use]
298 pub fn provider_tool(mut self, tool: ProviderToolSpec) -> Self {
299 self.agent.provider_tools.push(tool);
300 self
301 }
302
303 #[must_use]
305 pub fn generation(mut self, generation: GenerationOptions) -> Self {
306 self.agent.generation = generation;
307 self
308 }
309
310 #[must_use]
312 pub fn temperature(mut self, temperature: f64) -> Self {
313 self.agent.generation.temperature = Some(temperature);
314 self
315 }
316
317 #[must_use]
319 pub fn top_p(mut self, top_p: f64) -> Self {
320 self.agent.generation.top_p = Some(top_p);
321 self
322 }
323
324 #[must_use]
326 pub fn max_output_tokens(mut self, max_output_tokens: u64) -> Self {
327 self.agent.generation.max_output_tokens = Some(max_output_tokens);
328 self
329 }
330
331 #[must_use]
333 pub const fn response_mode(mut self, response_mode: ResponseMode) -> Self {
334 self.agent.response_mode = response_mode;
335 self
336 }
337
338 #[must_use]
340 pub fn provider_options(
341 mut self,
342 provider: impl Into<String>,
343 options: serde_json::Value,
344 ) -> Self {
345 self.agent.provider_options.insert(provider.into(), options);
346 self
347 }
348
349 #[must_use]
351 pub const fn config(mut self, config: AgentConfig) -> Self {
352 self.agent.config = config;
353 self
354 }
355
356 #[must_use]
358 pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
359 self.agent.effects = effects;
360 self
361 }
362
363 #[must_use]
365 pub const fn effect_recovery_policy(mut self, policy: EffectRecoveryPolicy) -> Self {
366 self.agent.effect_recovery = policy;
367 self
368 }
369
370 pub fn build(self) -> Result<Agent, AgentBuildError> {
377 if let Some(error) = self.error {
378 return Err(error);
379 }
380 if self.agent.name.trim().is_empty() {
381 return Err(AgentBuildError::EmptyName);
382 }
383 if self.agent.config.max_turns == 0 {
384 return Err(AgentBuildError::ZeroMaxTurns);
385 }
386 if self.agent.min_successful_tool_calls > 0 && self.agent.tools.is_empty() {
387 return Err(AgentBuildError::MinimumSuccessfulToolCallsWithoutTool {
388 minimum: self.agent.min_successful_tool_calls,
389 });
390 }
391 if let Some(collision) = self
392 .agent
393 .agents
394 .model_specs()
395 .into_iter()
396 .find(|spec| self.agent.tools.contains(&spec.name))
397 {
398 return Err(AgentBuildError::CallableNameCollision(collision.name));
399 }
400 Ok(self.agent)
401 }
402
403 pub fn prompt(
409 self,
410 input: impl Into<String> + Send + 'static,
411 ) -> AgentFuture<'static, Result<AgentOutcome, AgentPromptError>> {
412 let input = input.into();
413 Box::pin(async move {
414 let agent = self.build()?;
415 Ok(agent.prompt(input).await?)
416 })
417 }
418
419 pub fn prompt_text(
426 self,
427 input: impl Into<String> + Send + 'static,
428 ) -> AgentFuture<'static, Result<String, AgentPromptError>> {
429 let input = input.into();
430 Box::pin(async move {
431 let agent = self.build()?;
432 Ok(agent.prompt_text(input).await?)
433 })
434 }
435
436 pub fn build_structured<T>(
446 self,
447 name: impl Into<String>,
448 ) -> Result<StructuredAgent<T>, AgentBuildError>
449 where
450 T: JsonSchema + serde::de::DeserializeOwned + Send + 'static,
451 {
452 self.build().map(|agent| agent.into_structured::<T>(name))
453 }
454}
455
456impl std::fmt::Debug for AgentBuilder {
457 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
458 formatter
459 .debug_struct("AgentBuilder")
460 .field("agent", &self.agent.name)
461 .field("error", &self.error)
462 .finish_non_exhaustive()
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use std::{collections::BTreeMap, sync::Arc};
469
470 use runifold_core::{
471 CapabilityId, CapabilitySet, EffectClass, RetrySafety, RiskLevel, RunErrorKind,
472 };
473 use runifold_model::{
474 ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat, ProviderToolSpec,
475 ResponseMode,
476 };
477 use runifold_testkit::ScriptedModel;
478 use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
479 use schemars::JsonSchema;
480 use serde::Deserialize;
481 use serde_json::json;
482
483 use crate::{Agent, AgentBuildError, AgentDescriptor, AgentPromptError};
484
485 struct TestTool {
486 descriptor: ToolDescriptor,
487 }
488
489 #[derive(Deserialize, JsonSchema)]
490 struct TypedAnswer {
491 value: u32,
492 }
493
494 impl TestTool {
495 fn named(name: &str) -> Self {
496 Self {
497 descriptor: ToolDescriptor {
498 id: CapabilityId::new(),
499 name: name.into(),
500 version: "1".into(),
501 description: "test".into(),
502 input_schema: json!({"type": "object"}),
503 output_schema: json!({"type": "object"}),
504 effect: EffectClass::Pure,
505 risk: RiskLevel::Low,
506 metadata: BTreeMap::new(),
507 },
508 }
509 }
510 }
511
512 impl Tool for TestTool {
513 fn descriptor(&self) -> &ToolDescriptor {
514 &self.descriptor
515 }
516
517 fn invoke(
518 &self,
519 input: serde_json::Value,
520 _context: ToolContext,
521 ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
522 Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
523 }
524 }
525
526 #[test]
527 fn fluent_builder_assembles_the_canonical_agent() {
528 let model = Arc::new(ScriptedModel::new());
529 let agent = Agent::builder("worker", model, ModelRef::new("test", "scripted"))
530 .system("Be precise")
531 .tool(TestTool::named("lookup"))
532 .min_successful_tool_calls(3)
533 .max_turns(4)
534 .build()
535 .unwrap();
536
537 assert_eq!(agent.name, "worker");
538 assert_eq!(agent.instructions.len(), 1);
539 assert!(agent.tools.contains("lookup"));
540 assert_eq!(agent.config.max_turns, 4);
541 assert_eq!(agent.min_successful_tool_calls, 3);
542 assert_eq!(agent.callable_capabilities().len(), 1);
543 }
544
545 #[test]
546 fn builder_rejects_a_tool_minimum_without_a_local_tool() {
547 let error = Agent::builder(
548 "worker",
549 Arc::new(ScriptedModel::new()),
550 ModelRef::new("test", "scripted"),
551 )
552 .min_successful_tool_calls(1)
553 .build()
554 .unwrap_err();
555
556 assert!(matches!(
557 error,
558 AgentBuildError::MinimumSuccessfulToolCallsWithoutTool { minimum: 1 }
559 ));
560 }
561
562 #[test]
563 fn builder_retains_generation_provider_and_delivery_controls() {
564 let provider_tool = ProviderToolSpec::new("ark", "web_search").unwrap();
565 let agent = Agent::builder(
566 "researcher",
567 Arc::new(ScriptedModel::new()),
568 ModelRef::new("ark", "doubao"),
569 )
570 .temperature(0.2)
571 .top_p(0.8)
572 .max_output_tokens(4_096)
573 .response_mode(ResponseMode::Complete)
574 .provider_tool(provider_tool)
575 .provider_options("ark", json!({"thinking": {"type": "enabled"}}))
576 .build()
577 .unwrap();
578
579 assert_eq!(agent.generation.temperature, Some(0.2));
580 assert_eq!(agent.generation.top_p, Some(0.8));
581 assert_eq!(agent.generation.max_output_tokens, Some(4_096));
582 assert_eq!(agent.response_mode, ResponseMode::Complete);
583 assert_eq!(agent.provider_tools[0].tool_type, "web_search");
584 assert_eq!(agent.provider_options["ark"]["thinking"]["type"], "enabled");
585 }
586
587 #[test]
588 fn build_rejects_tool_and_agent_name_collisions() {
589 let model = Arc::new(ScriptedModel::new());
590 let child = Arc::new(Agent::new(
591 "child",
592 model.clone(),
593 ModelRef::new("test", "child"),
594 ));
595 let error = Agent::builder("parent", model, ModelRef::new("test", "parent"))
596 .tool(TestTool::named("search"))
597 .child(
598 AgentDescriptor::new("search", "delegate search"),
599 child,
600 CapabilitySet::new(),
601 )
602 .build()
603 .unwrap_err();
604
605 assert_eq!(
606 error,
607 AgentBuildError::CallableNameCollision("search".into())
608 );
609 }
610
611 #[test]
612 fn builder_derives_a_strict_output_schema_from_a_rust_type() {
613 let example = TypedAnswer { value: 7 };
614 assert_eq!(example.value, 7);
615 let agent = Agent::builder(
616 "worker",
617 Arc::new(ScriptedModel::new()),
618 ModelRef::new("test", "scripted"),
619 )
620 .structured_output::<TypedAnswer>("typed_answer")
621 .build()
622 .unwrap();
623
624 let OutputFormat::JsonSchema {
625 name,
626 schema,
627 strict,
628 } = agent.output_format
629 else {
630 panic!("expected JSON-schema output");
631 };
632 assert_eq!(name, "typed_answer");
633 assert!(strict);
634 assert_eq!(schema["properties"]["value"]["type"], "integer");
635 }
636
637 #[test]
638 fn builder_prompt_text_is_a_single_use_golden_path() {
639 let model = ScriptedModel::new();
640 model.enqueue([
641 ModelStreamEvent::ResponseStarted {
642 id: Some("response-1".into()),
643 model: ModelRef::new("test", "scripted"),
644 },
645 ModelStreamEvent::ContentPartCompleted {
646 index: 0,
647 part: ContentPart::text("done"),
648 },
649 ModelStreamEvent::ResponseCompleted {
650 finish_reason: FinishReason::Stop,
651 provider_metadata: BTreeMap::new(),
652 },
653 ]);
654
655 let text = futures_executor::block_on(
656 Agent::builder("worker", Arc::new(model), ModelRef::new("test", "scripted"))
657 .system("Be precise")
658 .prompt_text("start"),
659 )
660 .unwrap();
661
662 assert_eq!(text, "done");
663 }
664
665 #[test]
666 fn builder_prompt_reports_build_failures_before_model_execution() {
667 let error = futures_executor::block_on(
668 Agent::builder(
669 "",
670 Arc::new(ScriptedModel::new()),
671 ModelRef::new("test", "scripted"),
672 )
673 .prompt("start"),
674 )
675 .unwrap_err();
676
677 assert!(matches!(
678 &error,
679 AgentPromptError::Build(AgentBuildError::EmptyName)
680 ));
681 assert_eq!(error.run_error_kind(), RunErrorKind::InvalidInput);
682 assert_eq!(error.retry_safety(), RetrySafety::Safe);
683 assert_eq!(error.to_run_error().code(), "runifold.invalid_input");
684 }
685}