Skip to main content

runifold_agent/
builder.rs

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, CompletionRequirement, GatewayMiddleware, StructuredAgent,
18    ToolErrorPolicy,
19};
20
21/// Failure while assembling an [`Agent`].
22#[derive(Clone, Debug, Error, Eq, PartialEq)]
23#[non_exhaustive]
24pub enum AgentBuildError {
25    /// Tool registration failed.
26    #[error("agent Tool registration failed: {0}")]
27    Tool(#[from] ToolRegistrationError),
28    /// Child Agent route registration failed.
29    #[error("agent route registration failed: {0}")]
30    Route(#[from] AgentRegistrationError),
31    /// One model-facing name was used by both a Tool and an Agent.
32    #[error("callable name `{0}` is registered as both a Tool and an Agent")]
33    CallableNameCollision(String),
34    /// The Agent name is blank.
35    #[error("agent name cannot be empty")]
36    EmptyName,
37    /// The configured turn limit cannot execute any model turn.
38    #[error("max_turns must be greater than zero")]
39    ZeroMaxTurns,
40    /// A successful Tool minimum was configured without a local Tool.
41    #[error("min_successful_tool_calls={minimum} requires at least one registered local Tool")]
42    MinimumSuccessfulToolCallsWithoutTool {
43        /// Configured successful local Tool-call minimum.
44        minimum: u32,
45    },
46    /// A static or dynamic context registration was invalid.
47    #[error("agent retrieval configuration failed: {0}")]
48    Retrieval(#[from] RetrievalError),
49}
50
51/// Failure while building and immediately prompting an Agent.
52#[derive(Debug, Error)]
53#[non_exhaustive]
54pub enum AgentPromptError {
55    /// Fluent Agent assembly failed before model execution.
56    #[error("failed to build agent: {0}")]
57    Build(#[from] AgentBuildError),
58    /// Canonical Agent execution failed.
59    #[error("agent prompt failed: {0}")]
60    Run(#[from] AgentError),
61}
62
63/// Fluent assembly of one canonical [`Agent`].
64///
65/// Registration failures are retained and returned by [`Self::build`] so
66/// Tool and child Agent calls remain chainable without silently replacing an
67/// existing name.
68pub struct AgentBuilder {
69    agent: Agent,
70    error: Option<AgentBuildError>,
71}
72
73impl AgentBuilder {
74    /// Creates a builder around the same execution path as [`Agent::new`].
75    pub fn new(name: impl Into<String>, model: Arc<dyn Model>, model_ref: ModelRef) -> Self {
76        Self {
77            agent: Agent::new(name, model, model_ref),
78            error: None,
79        }
80    }
81
82    /// Appends a system instruction.
83    #[must_use]
84    pub fn system(mut self, instruction: impl Into<String>) -> Self {
85        self.agent
86            .instructions
87            .push(Message::system(instruction.into()));
88        self
89    }
90
91    /// Adds one static document as untrusted user-level context.
92    ///
93    /// The document is never promoted to a system instruction. Use
94    /// [`Self::system`] for trusted application policy.
95    #[must_use]
96    pub fn context(self, text: impl Into<String>) -> Self {
97        let id = format!("static-context-{}", self.agent.context.len() + 1);
98        match Document::new(id, text) {
99            Ok(document) => self.context_document(document),
100            Err(error) => self.with_error(error.into()),
101        }
102    }
103
104    /// Adds one validated static context document.
105    #[must_use]
106    pub fn context_document(mut self, document: Document) -> Self {
107        if self.error.is_none() {
108            self.agent.context.push(document);
109        }
110        self
111    }
112
113    /// Configures reference-only artifact persistence for Tools and resolves
114    /// those references only at the final model transport boundary.
115    #[must_use]
116    pub fn artifacts(mut self, scope: ArtifactScope, store: Arc<dyn ArtifactStore>) -> Self {
117        self.agent.model = Arc::new(ArtifactResolvingModel::new(
118            self.agent.model.clone(),
119            scope.clone(),
120            store.clone(),
121        ));
122        self.agent.tools = self.agent.tools.clone().with_artifact_store(scope, store);
123        self
124    }
125
126    /// Adds an owned dynamic context source.
127    #[must_use]
128    pub fn dynamic_context<R>(self, limit: usize, retriever: R) -> Self
129    where
130        R: Retriever + 'static,
131    {
132        self.shared_dynamic_context(limit, Arc::new(retriever))
133    }
134
135    /// Adds a shared, type-erased dynamic context source.
136    #[must_use]
137    pub fn shared_dynamic_context(mut self, limit: usize, retriever: Arc<dyn Retriever>) -> Self {
138        if self.error.is_none() {
139            if limit == 0 {
140                self.error = Some(RetrievalError::ZeroLimit.into());
141            } else {
142                self.agent
143                    .dynamic_context
144                    .push(DynamicContext { limit, retriever });
145            }
146        }
147        self
148    }
149
150    /// Registers an owned Tool.
151    #[must_use]
152    pub fn tool<T>(self, tool: T) -> Self
153    where
154        T: Tool + 'static,
155    {
156        self.shared_tool(Arc::new(tool))
157    }
158
159    /// Registers a shared, type-erased Tool.
160    #[must_use]
161    pub fn shared_tool(mut self, tool: Arc<dyn Tool>) -> Self {
162        if self.error.is_none()
163            && let Err(error) = self.agent.tools.register(tool)
164        {
165            self.error = Some(error.into());
166        }
167        self
168    }
169
170    fn with_error(mut self, error: AgentBuildError) -> Self {
171        if self.error.is_none() {
172            self.error = Some(error);
173        }
174        self
175    }
176
177    /// Registers a child Agent route with explicit delegated capabilities.
178    #[must_use]
179    pub fn child(
180        mut self,
181        descriptor: AgentDescriptor,
182        child: Arc<Agent>,
183        capabilities: CapabilitySet,
184    ) -> Self {
185        if self.error.is_none() {
186            let route = AgentRoute::new(descriptor, child).with_capabilities(capabilities);
187            if let Err(error) = self.agent.agents.register(route) {
188                self.error = Some(error.into());
189            }
190        }
191        self
192    }
193
194    /// Appends Gateway around-middleware.
195    #[must_use]
196    pub fn gateway_layer(mut self, middleware: Arc<dyn GatewayMiddleware>) -> Self {
197        self.agent.agents.push_middleware(middleware);
198        self
199    }
200
201    /// Sets the maximum nested Agent delegation depth.
202    #[must_use]
203    pub fn max_delegation_depth(mut self, max_depth: u32) -> Self {
204        self.agent.agents = self.agent.agents.with_max_depth(max_depth);
205        self
206    }
207
208    /// Sets the local model-turn limit.
209    #[must_use]
210    pub const fn max_turns(mut self, max_turns: u32) -> Self {
211        self.agent.config.max_turns = max_turns;
212        self
213    }
214
215    /// Requires this many successful local Tool calls before terminal output.
216    ///
217    /// The runtime requests at least one Tool while the requirement remains
218    /// unsatisfied and returns an explicit error if the model violates that
219    /// contract. A value of zero disables the requirement.
220    #[must_use]
221    pub const fn min_successful_tool_calls(mut self, minimum: u32) -> Self {
222        self.agent.min_successful_tool_calls = minimum;
223        self
224    }
225
226    /// Sets Tool failure behavior.
227    #[must_use]
228    pub const fn tool_error_policy(mut self, policy: ToolErrorPolicy) -> Self {
229        self.agent.config.tool_error_policy = policy;
230        self
231    }
232
233    /// Sets terminal validation and bounded repair behavior.
234    #[must_use]
235    pub fn completion_requirement(self, requirement: CompletionRequirement) -> Self {
236        Self {
237            agent: self.agent.completion_requirement(requirement),
238            error: self.error,
239        }
240    }
241
242    /// Sets provider feature-degradation behavior.
243    #[must_use]
244    pub const fn feature_policy(mut self, policy: FeaturePolicy) -> Self {
245        self.agent.config.feature_policy = policy;
246        self
247    }
248
249    /// Sets the desired terminal model-output format.
250    #[must_use]
251    pub fn output_format(mut self, output_format: OutputFormat) -> Self {
252        self.agent.output_format = output_format;
253        self
254    }
255
256    /// Requests strict structured output described by the Rust type `T`.
257    #[must_use]
258    pub fn structured_output<T>(self, name: impl Into<String>) -> Self
259    where
260        T: JsonSchema,
261    {
262        self.output_format(OutputFormat::typed::<T>(name))
263    }
264
265    /// Adds a provider-hosted tool such as Ark web search.
266    #[must_use]
267    pub fn provider_tool(mut self, tool: ProviderToolSpec) -> Self {
268        self.agent.provider_tools.push(tool);
269        self
270    }
271
272    /// Replaces common model generation controls for every Agent turn.
273    #[must_use]
274    pub fn generation(mut self, generation: GenerationOptions) -> Self {
275        self.agent.generation = generation;
276        self
277    }
278
279    /// Sets the sampling temperature for every Agent turn.
280    #[must_use]
281    pub fn temperature(mut self, temperature: f64) -> Self {
282        self.agent.generation.temperature = Some(temperature);
283        self
284    }
285
286    /// Sets nucleus sampling for every Agent turn.
287    #[must_use]
288    pub fn top_p(mut self, top_p: f64) -> Self {
289        self.agent.generation.top_p = Some(top_p);
290        self
291    }
292
293    /// Sets the maximum number of output tokens for every Agent turn.
294    #[must_use]
295    pub fn max_output_tokens(mut self, max_output_tokens: u64) -> Self {
296        self.agent.generation.max_output_tokens = Some(max_output_tokens);
297        self
298    }
299
300    /// Selects streaming or complete response delivery for every Agent turn.
301    #[must_use]
302    pub const fn response_mode(mut self, response_mode: ResponseMode) -> Self {
303        self.agent.response_mode = response_mode;
304        self
305    }
306
307    /// Adds namespaced provider options to every Agent turn.
308    #[must_use]
309    pub fn provider_options(
310        mut self,
311        provider: impl Into<String>,
312        options: serde_json::Value,
313    ) -> Self {
314        self.agent.provider_options.insert(provider.into(), options);
315        self
316    }
317
318    /// Replaces all local Agent configuration.
319    #[must_use]
320    pub const fn config(mut self, config: AgentConfig) -> Self {
321        self.agent.config = config;
322        self
323    }
324
325    /// Shares a write-ahead effect coordinator with this Agent.
326    #[must_use]
327    pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
328        self.agent.effects = effects;
329        self
330    }
331
332    /// Sets recovery behavior for ambiguous callable effects.
333    #[must_use]
334    pub const fn effect_recovery_policy(mut self, policy: EffectRecoveryPolicy) -> Self {
335        self.agent.effect_recovery = policy;
336        self
337    }
338
339    /// Validates registrations and returns the canonical Agent.
340    ///
341    /// # Errors
342    ///
343    /// Returns [`AgentBuildError`] for blank identity, invalid turn bounds,
344    /// duplicate registrations, or Tool/Agent name collisions.
345    pub fn build(self) -> Result<Agent, AgentBuildError> {
346        if let Some(error) = self.error {
347            return Err(error);
348        }
349        if self.agent.name.trim().is_empty() {
350            return Err(AgentBuildError::EmptyName);
351        }
352        if self.agent.config.max_turns == 0 {
353            return Err(AgentBuildError::ZeroMaxTurns);
354        }
355        if self.agent.min_successful_tool_calls > 0 && self.agent.tools.is_empty() {
356            return Err(AgentBuildError::MinimumSuccessfulToolCallsWithoutTool {
357                minimum: self.agent.min_successful_tool_calls,
358            });
359        }
360        if let Some(collision) = self
361            .agent
362            .agents
363            .model_specs()
364            .into_iter()
365            .find(|spec| self.agent.tools.contains(&spec.name))
366        {
367            return Err(AgentBuildError::CallableNameCollision(collision.name));
368        }
369        Ok(self.agent)
370    }
371
372    /// Builds the Agent and runs one ergonomic prompt.
373    ///
374    /// This removes the explicit build step for one-shot usage while retaining
375    /// the complete canonical outcome. Use [`Self::build`] when the Agent will
376    /// be reused or executed with an explicit runtime context.
377    pub fn prompt(
378        self,
379        input: impl Into<String> + Send + 'static,
380    ) -> AgentFuture<'static, Result<AgentOutcome, AgentPromptError>> {
381        let input = input.into();
382        Box::pin(async move {
383            let agent = self.build()?;
384            Ok(agent.prompt(input).await?)
385        })
386    }
387
388    /// Builds the Agent, runs one ergonomic prompt, and returns only
389    /// model-visible text.
390    ///
391    /// This is the shortest path from provider configuration to a text answer.
392    /// Use [`Self::prompt`] when transcript, usage, warnings, and provider
393    /// events must be preserved.
394    pub fn prompt_text(
395        self,
396        input: impl Into<String> + Send + 'static,
397    ) -> AgentFuture<'static, Result<String, AgentPromptError>> {
398        let input = input.into();
399        Box::pin(async move {
400            let agent = self.build()?;
401            Ok(agent.prompt_text(input).await?)
402        })
403    }
404
405    /// Builds an Agent whose schema and local decoder are bound to `T`.
406    ///
407    /// This is the preferred terminal builder operation for structured output
408    /// because a different decode type cannot be selected later by mistake.
409    ///
410    /// # Errors
411    ///
412    /// Returns [`AgentBuildError`] under the same validation rules as
413    /// [`Self::build`].
414    pub fn build_structured<T>(
415        self,
416        name: impl Into<String>,
417    ) -> Result<StructuredAgent<T>, AgentBuildError>
418    where
419        T: JsonSchema + serde::de::DeserializeOwned + Send + 'static,
420    {
421        self.build().map(|agent| agent.into_structured::<T>(name))
422    }
423}
424
425impl std::fmt::Debug for AgentBuilder {
426    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427        formatter
428            .debug_struct("AgentBuilder")
429            .field("agent", &self.agent.name)
430            .field("error", &self.error)
431            .finish_non_exhaustive()
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use std::{collections::BTreeMap, sync::Arc};
438
439    use runifold_core::{CapabilityId, CapabilitySet, EffectClass, RiskLevel};
440    use runifold_model::{
441        ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat, ProviderToolSpec,
442        ResponseMode,
443    };
444    use runifold_testkit::ScriptedModel;
445    use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
446    use schemars::JsonSchema;
447    use serde::Deserialize;
448    use serde_json::json;
449
450    use crate::{Agent, AgentBuildError, AgentDescriptor, AgentPromptError};
451
452    struct TestTool {
453        descriptor: ToolDescriptor,
454    }
455
456    #[derive(Deserialize, JsonSchema)]
457    struct TypedAnswer {
458        value: u32,
459    }
460
461    impl TestTool {
462        fn named(name: &str) -> Self {
463            Self {
464                descriptor: ToolDescriptor {
465                    id: CapabilityId::new(),
466                    name: name.into(),
467                    version: "1".into(),
468                    description: "test".into(),
469                    input_schema: json!({"type": "object"}),
470                    output_schema: json!({"type": "object"}),
471                    effect: EffectClass::Pure,
472                    risk: RiskLevel::Low,
473                    metadata: BTreeMap::new(),
474                },
475            }
476        }
477    }
478
479    impl Tool for TestTool {
480        fn descriptor(&self) -> &ToolDescriptor {
481            &self.descriptor
482        }
483
484        fn invoke(
485            &self,
486            input: serde_json::Value,
487            _context: ToolContext,
488        ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
489            Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
490        }
491    }
492
493    #[test]
494    fn fluent_builder_assembles_the_canonical_agent() {
495        let model = Arc::new(ScriptedModel::new());
496        let agent = Agent::builder("worker", model, ModelRef::new("test", "scripted"))
497            .system("Be precise")
498            .tool(TestTool::named("lookup"))
499            .min_successful_tool_calls(3)
500            .max_turns(4)
501            .build()
502            .unwrap();
503
504        assert_eq!(agent.name, "worker");
505        assert_eq!(agent.instructions.len(), 1);
506        assert!(agent.tools.contains("lookup"));
507        assert_eq!(agent.config.max_turns, 4);
508        assert_eq!(agent.min_successful_tool_calls, 3);
509        assert_eq!(agent.callable_capabilities().len(), 1);
510    }
511
512    #[test]
513    fn builder_rejects_a_tool_minimum_without_a_local_tool() {
514        let error = Agent::builder(
515            "worker",
516            Arc::new(ScriptedModel::new()),
517            ModelRef::new("test", "scripted"),
518        )
519        .min_successful_tool_calls(1)
520        .build()
521        .unwrap_err();
522
523        assert!(matches!(
524            error,
525            AgentBuildError::MinimumSuccessfulToolCallsWithoutTool { minimum: 1 }
526        ));
527    }
528
529    #[test]
530    fn builder_retains_generation_provider_and_delivery_controls() {
531        let provider_tool = ProviderToolSpec::new("ark", "web_search").unwrap();
532        let agent = Agent::builder(
533            "researcher",
534            Arc::new(ScriptedModel::new()),
535            ModelRef::new("ark", "doubao"),
536        )
537        .temperature(0.2)
538        .top_p(0.8)
539        .max_output_tokens(4_096)
540        .response_mode(ResponseMode::Complete)
541        .provider_tool(provider_tool)
542        .provider_options("ark", json!({"thinking": {"type": "enabled"}}))
543        .build()
544        .unwrap();
545
546        assert_eq!(agent.generation.temperature, Some(0.2));
547        assert_eq!(agent.generation.top_p, Some(0.8));
548        assert_eq!(agent.generation.max_output_tokens, Some(4_096));
549        assert_eq!(agent.response_mode, ResponseMode::Complete);
550        assert_eq!(agent.provider_tools[0].tool_type, "web_search");
551        assert_eq!(agent.provider_options["ark"]["thinking"]["type"], "enabled");
552    }
553
554    #[test]
555    fn build_rejects_tool_and_agent_name_collisions() {
556        let model = Arc::new(ScriptedModel::new());
557        let child = Arc::new(Agent::new(
558            "child",
559            model.clone(),
560            ModelRef::new("test", "child"),
561        ));
562        let error = Agent::builder("parent", model, ModelRef::new("test", "parent"))
563            .tool(TestTool::named("search"))
564            .child(
565                AgentDescriptor::new("search", "delegate search"),
566                child,
567                CapabilitySet::new(),
568            )
569            .build()
570            .unwrap_err();
571
572        assert_eq!(
573            error,
574            AgentBuildError::CallableNameCollision("search".into())
575        );
576    }
577
578    #[test]
579    fn builder_derives_a_strict_output_schema_from_a_rust_type() {
580        let example = TypedAnswer { value: 7 };
581        assert_eq!(example.value, 7);
582        let agent = Agent::builder(
583            "worker",
584            Arc::new(ScriptedModel::new()),
585            ModelRef::new("test", "scripted"),
586        )
587        .structured_output::<TypedAnswer>("typed_answer")
588        .build()
589        .unwrap();
590
591        let OutputFormat::JsonSchema {
592            name,
593            schema,
594            strict,
595        } = agent.output_format
596        else {
597            panic!("expected JSON-schema output");
598        };
599        assert_eq!(name, "typed_answer");
600        assert!(strict);
601        assert_eq!(schema["properties"]["value"]["type"], "integer");
602    }
603
604    #[test]
605    fn builder_prompt_text_is_a_single_use_golden_path() {
606        let model = ScriptedModel::new();
607        model.enqueue([
608            ModelStreamEvent::ResponseStarted {
609                id: Some("response-1".into()),
610                model: ModelRef::new("test", "scripted"),
611            },
612            ModelStreamEvent::ContentPartCompleted {
613                index: 0,
614                part: ContentPart::text("done"),
615            },
616            ModelStreamEvent::ResponseCompleted {
617                finish_reason: FinishReason::Stop,
618                provider_metadata: BTreeMap::new(),
619            },
620        ]);
621
622        let text = futures_executor::block_on(
623            Agent::builder("worker", Arc::new(model), ModelRef::new("test", "scripted"))
624                .system("Be precise")
625                .prompt_text("start"),
626        )
627        .unwrap();
628
629        assert_eq!(text, "done");
630    }
631
632    #[test]
633    fn builder_prompt_reports_build_failures_before_model_execution() {
634        let error = futures_executor::block_on(
635            Agent::builder(
636                "",
637                Arc::new(ScriptedModel::new()),
638                ModelRef::new("test", "scripted"),
639            )
640            .prompt("start"),
641        )
642        .unwrap_err();
643
644        assert!(matches!(
645            error,
646            AgentPromptError::Build(AgentBuildError::EmptyName)
647        ));
648    }
649}