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