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, GatewayMiddleware, StructuredAgent, ToolErrorPolicy,
18};
19
20/// Failure while assembling an [`Agent`].
21#[derive(Clone, Debug, Error, Eq, PartialEq)]
22#[non_exhaustive]
23pub enum AgentBuildError {
24    /// Tool registration failed.
25    #[error("agent Tool registration failed: {0}")]
26    Tool(#[from] ToolRegistrationError),
27    /// Child Agent route registration failed.
28    #[error("agent route registration failed: {0}")]
29    Route(#[from] AgentRegistrationError),
30    /// One model-facing name was used by both a Tool and an Agent.
31    #[error("callable name `{0}` is registered as both a Tool and an Agent")]
32    CallableNameCollision(String),
33    /// The Agent name is blank.
34    #[error("agent name cannot be empty")]
35    EmptyName,
36    /// The configured turn limit cannot execute any model turn.
37    #[error("max_turns must be greater than zero")]
38    ZeroMaxTurns,
39    /// A successful Tool minimum was configured without a local Tool.
40    #[error("min_successful_tool_calls={minimum} requires at least one registered local Tool")]
41    MinimumSuccessfulToolCallsWithoutTool {
42        /// Configured successful local Tool-call minimum.
43        minimum: u32,
44    },
45    /// A static or dynamic context registration was invalid.
46    #[error("agent retrieval configuration failed: {0}")]
47    Retrieval(#[from] RetrievalError),
48}
49
50/// Failure while building and immediately prompting an Agent.
51#[derive(Debug, Error)]
52#[non_exhaustive]
53pub enum AgentPromptError {
54    /// Fluent Agent assembly failed before model execution.
55    #[error("failed to build agent: {0}")]
56    Build(#[from] AgentBuildError),
57    /// Canonical Agent execution failed.
58    #[error("agent prompt failed: {0}")]
59    Run(#[from] AgentError),
60}
61
62/// Fluent assembly of one canonical [`Agent`].
63///
64/// Registration failures are retained and returned by [`Self::build`] so
65/// Tool and child Agent calls remain chainable without silently replacing an
66/// existing name.
67pub struct AgentBuilder {
68    agent: Agent,
69    error: Option<AgentBuildError>,
70}
71
72impl AgentBuilder {
73    /// Creates a builder around the same execution path as [`Agent::new`].
74    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    /// Appends a system instruction.
82    #[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    /// Adds one static document as untrusted user-level context.
91    ///
92    /// The document is never promoted to a system instruction. Use
93    /// [`Self::system`] for trusted application policy.
94    #[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    /// Adds one validated static context document.
104    #[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    /// Configures reference-only artifact persistence for Tools and resolves
113    /// those references only at the final model transport boundary.
114    #[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    /// Adds an owned dynamic context source.
126    #[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    /// Adds a shared, type-erased dynamic context source.
135    #[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    /// Registers an owned Tool.
150    #[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    /// Registers a shared, type-erased Tool.
159    #[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    /// Registers a child Agent route with explicit delegated capabilities.
177    #[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    /// Appends Gateway around-middleware.
194    #[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    /// Sets the maximum nested Agent delegation depth.
201    #[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    /// Sets the local model-turn limit.
208    #[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    /// Requires this many successful local Tool calls before terminal output.
215    ///
216    /// The runtime requests at least one Tool while the requirement remains
217    /// unsatisfied and returns an explicit error if the model violates that
218    /// contract. A value of zero disables the requirement.
219    #[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    /// Sets Tool failure behavior.
226    #[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    /// Sets provider feature-degradation behavior.
233    #[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    /// Sets the desired terminal model-output format.
240    #[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    /// Requests strict structured output described by the Rust type `T`.
247    #[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    /// Adds a provider-hosted tool such as Ark web search.
256    #[must_use]
257    pub fn provider_tool(mut self, tool: ProviderToolSpec) -> Self {
258        self.agent.provider_tools.push(tool);
259        self
260    }
261
262    /// Replaces common model generation controls for every Agent turn.
263    #[must_use]
264    pub fn generation(mut self, generation: GenerationOptions) -> Self {
265        self.agent.generation = generation;
266        self
267    }
268
269    /// Sets the sampling temperature for every Agent turn.
270    #[must_use]
271    pub fn temperature(mut self, temperature: f64) -> Self {
272        self.agent.generation.temperature = Some(temperature);
273        self
274    }
275
276    /// Sets nucleus sampling for every Agent turn.
277    #[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    /// Sets the maximum number of output tokens for every Agent turn.
284    #[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    /// Selects streaming or complete response delivery for every Agent turn.
291    #[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    /// Adds namespaced provider options to every Agent turn.
298    #[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    /// Replaces all local Agent configuration.
309    #[must_use]
310    pub const fn config(mut self, config: AgentConfig) -> Self {
311        self.agent.config = config;
312        self
313    }
314
315    /// Shares a write-ahead effect coordinator with this Agent.
316    #[must_use]
317    pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
318        self.agent.effects = effects;
319        self
320    }
321
322    /// Sets recovery behavior for ambiguous callable effects.
323    #[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    /// Validates registrations and returns the canonical Agent.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`AgentBuildError`] for blank identity, invalid turn bounds,
334    /// duplicate registrations, or Tool/Agent name collisions.
335    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    /// Builds the Agent and runs one ergonomic prompt.
363    ///
364    /// This removes the explicit build step for one-shot usage while retaining
365    /// the complete canonical outcome. Use [`Self::build`] when the Agent will
366    /// be reused or executed with an explicit runtime context.
367    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    /// Builds the Agent, runs one ergonomic prompt, and returns only
379    /// model-visible text.
380    ///
381    /// This is the shortest path from provider configuration to a text answer.
382    /// Use [`Self::prompt`] when transcript, usage, warnings, and provider
383    /// events must be preserved.
384    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    /// Builds an Agent whose schema and local decoder are bound to `T`.
396    ///
397    /// This is the preferred terminal builder operation for structured output
398    /// because a different decode type cannot be selected later by mistake.
399    ///
400    /// # Errors
401    ///
402    /// Returns [`AgentBuildError`] under the same validation rules as
403    /// [`Self::build`].
404    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}