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::{FeaturePolicy, Message, Model, ModelRef, OutputFormat};
6use runifold_retrieval::{Document, RetrievalError, Retriever};
7use runifold_tool::{Tool, ToolRegistrationError};
8use schemars::JsonSchema;
9use thiserror::Error;
10
11use crate::agent::DynamicContext;
12use crate::{
13    Agent, AgentConfig, AgentDescriptor, AgentError, AgentFuture, AgentOutcome,
14    AgentRegistrationError, AgentRoute, GatewayMiddleware, StructuredAgent, ToolErrorPolicy,
15};
16
17/// Failure while assembling an [`Agent`].
18#[derive(Clone, Debug, Error, Eq, PartialEq)]
19#[non_exhaustive]
20pub enum AgentBuildError {
21    /// Tool registration failed.
22    #[error("agent Tool registration failed: {0}")]
23    Tool(#[from] ToolRegistrationError),
24    /// Child Agent route registration failed.
25    #[error("agent route registration failed: {0}")]
26    Route(#[from] AgentRegistrationError),
27    /// One model-facing name was used by both a Tool and an Agent.
28    #[error("callable name `{0}` is registered as both a Tool and an Agent")]
29    CallableNameCollision(String),
30    /// The Agent name is blank.
31    #[error("agent name cannot be empty")]
32    EmptyName,
33    /// The configured turn limit cannot execute any model turn.
34    #[error("max_turns must be greater than zero")]
35    ZeroMaxTurns,
36    /// A static or dynamic context registration was invalid.
37    #[error("agent retrieval configuration failed: {0}")]
38    Retrieval(#[from] RetrievalError),
39}
40
41/// Failure while building and immediately prompting an Agent.
42#[derive(Debug, Error)]
43#[non_exhaustive]
44pub enum AgentPromptError {
45    /// Fluent Agent assembly failed before model execution.
46    #[error("failed to build agent: {0}")]
47    Build(#[from] AgentBuildError),
48    /// Canonical Agent execution failed.
49    #[error("agent prompt failed: {0}")]
50    Run(#[from] AgentError),
51}
52
53/// Fluent assembly of one canonical [`Agent`].
54///
55/// Registration failures are retained and returned by [`Self::build`] so
56/// Tool and child Agent calls remain chainable without silently replacing an
57/// existing name.
58pub struct AgentBuilder {
59    agent: Agent,
60    error: Option<AgentBuildError>,
61}
62
63impl AgentBuilder {
64    /// Creates a builder around the same execution path as [`Agent::new`].
65    pub fn new(name: impl Into<String>, model: Arc<dyn Model>, model_ref: ModelRef) -> Self {
66        Self {
67            agent: Agent::new(name, model, model_ref),
68            error: None,
69        }
70    }
71
72    /// Appends a system instruction.
73    #[must_use]
74    pub fn system(mut self, instruction: impl Into<String>) -> Self {
75        self.agent
76            .instructions
77            .push(Message::system(instruction.into()));
78        self
79    }
80
81    /// Adds one static document as untrusted user-level context.
82    ///
83    /// The document is never promoted to a system instruction. Use
84    /// [`Self::system`] for trusted application policy.
85    #[must_use]
86    pub fn context(self, text: impl Into<String>) -> Self {
87        let id = format!("static-context-{}", self.agent.context.len() + 1);
88        match Document::new(id, text) {
89            Ok(document) => self.context_document(document),
90            Err(error) => self.with_error(error.into()),
91        }
92    }
93
94    /// Adds one validated static context document.
95    #[must_use]
96    pub fn context_document(mut self, document: Document) -> Self {
97        if self.error.is_none() {
98            self.agent.context.push(document);
99        }
100        self
101    }
102
103    /// Adds an owned dynamic context source.
104    #[must_use]
105    pub fn dynamic_context<R>(self, limit: usize, retriever: R) -> Self
106    where
107        R: Retriever + 'static,
108    {
109        self.shared_dynamic_context(limit, Arc::new(retriever))
110    }
111
112    /// Adds a shared, type-erased dynamic context source.
113    #[must_use]
114    pub fn shared_dynamic_context(mut self, limit: usize, retriever: Arc<dyn Retriever>) -> Self {
115        if self.error.is_none() {
116            if limit == 0 {
117                self.error = Some(RetrievalError::ZeroLimit.into());
118            } else {
119                self.agent
120                    .dynamic_context
121                    .push(DynamicContext { limit, retriever });
122            }
123        }
124        self
125    }
126
127    /// Registers an owned Tool.
128    #[must_use]
129    pub fn tool<T>(self, tool: T) -> Self
130    where
131        T: Tool + 'static,
132    {
133        self.shared_tool(Arc::new(tool))
134    }
135
136    /// Registers a shared, type-erased Tool.
137    #[must_use]
138    pub fn shared_tool(mut self, tool: Arc<dyn Tool>) -> Self {
139        if self.error.is_none()
140            && let Err(error) = self.agent.tools.register(tool)
141        {
142            self.error = Some(error.into());
143        }
144        self
145    }
146
147    fn with_error(mut self, error: AgentBuildError) -> Self {
148        if self.error.is_none() {
149            self.error = Some(error);
150        }
151        self
152    }
153
154    /// Registers a child Agent route with explicit delegated capabilities.
155    #[must_use]
156    pub fn child(
157        mut self,
158        descriptor: AgentDescriptor,
159        child: Arc<Agent>,
160        capabilities: CapabilitySet,
161    ) -> Self {
162        if self.error.is_none() {
163            let route = AgentRoute::new(descriptor, child).with_capabilities(capabilities);
164            if let Err(error) = self.agent.agents.register(route) {
165                self.error = Some(error.into());
166            }
167        }
168        self
169    }
170
171    /// Appends Gateway around-middleware.
172    #[must_use]
173    pub fn gateway_layer(mut self, middleware: Arc<dyn GatewayMiddleware>) -> Self {
174        self.agent.agents.push_middleware(middleware);
175        self
176    }
177
178    /// Sets the maximum nested Agent delegation depth.
179    #[must_use]
180    pub fn max_delegation_depth(mut self, max_depth: u32) -> Self {
181        self.agent.agents = self.agent.agents.with_max_depth(max_depth);
182        self
183    }
184
185    /// Sets the local model-turn limit.
186    #[must_use]
187    pub const fn max_turns(mut self, max_turns: u32) -> Self {
188        self.agent.config.max_turns = max_turns;
189        self
190    }
191
192    /// Sets Tool failure behavior.
193    #[must_use]
194    pub const fn tool_error_policy(mut self, policy: ToolErrorPolicy) -> Self {
195        self.agent.config.tool_error_policy = policy;
196        self
197    }
198
199    /// Sets provider feature-degradation behavior.
200    #[must_use]
201    pub const fn feature_policy(mut self, policy: FeaturePolicy) -> Self {
202        self.agent.config.feature_policy = policy;
203        self
204    }
205
206    /// Sets the desired terminal model-output format.
207    #[must_use]
208    pub fn output_format(mut self, output_format: OutputFormat) -> Self {
209        self.agent.output_format = output_format;
210        self
211    }
212
213    /// Requests strict structured output described by the Rust type `T`.
214    #[must_use]
215    pub fn structured_output<T>(self, name: impl Into<String>) -> Self
216    where
217        T: JsonSchema,
218    {
219        self.output_format(OutputFormat::typed::<T>(name))
220    }
221
222    /// Replaces all local Agent configuration.
223    #[must_use]
224    pub const fn config(mut self, config: AgentConfig) -> Self {
225        self.agent.config = config;
226        self
227    }
228
229    /// Shares a write-ahead effect coordinator with this Agent.
230    #[must_use]
231    pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
232        self.agent.effects = effects;
233        self
234    }
235
236    /// Sets recovery behavior for ambiguous callable effects.
237    #[must_use]
238    pub const fn effect_recovery_policy(mut self, policy: EffectRecoveryPolicy) -> Self {
239        self.agent.effect_recovery = policy;
240        self
241    }
242
243    /// Validates registrations and returns the canonical Agent.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`AgentBuildError`] for blank identity, invalid turn bounds,
248    /// duplicate registrations, or Tool/Agent name collisions.
249    pub fn build(self) -> Result<Agent, AgentBuildError> {
250        if let Some(error) = self.error {
251            return Err(error);
252        }
253        if self.agent.name.trim().is_empty() {
254            return Err(AgentBuildError::EmptyName);
255        }
256        if self.agent.config.max_turns == 0 {
257            return Err(AgentBuildError::ZeroMaxTurns);
258        }
259        if let Some(collision) = self
260            .agent
261            .agents
262            .model_specs()
263            .into_iter()
264            .find(|spec| self.agent.tools.contains(&spec.name))
265        {
266            return Err(AgentBuildError::CallableNameCollision(collision.name));
267        }
268        Ok(self.agent)
269    }
270
271    /// Builds the Agent and runs one ergonomic prompt.
272    ///
273    /// This removes the explicit build step for one-shot usage while retaining
274    /// the complete canonical outcome. Use [`Self::build`] when the Agent will
275    /// be reused or executed with an explicit runtime context.
276    pub fn prompt(
277        self,
278        input: impl Into<String> + Send + 'static,
279    ) -> AgentFuture<'static, Result<AgentOutcome, AgentPromptError>> {
280        let input = input.into();
281        Box::pin(async move {
282            let agent = self.build()?;
283            Ok(agent.prompt(input).await?)
284        })
285    }
286
287    /// Builds the Agent, runs one ergonomic prompt, and returns only
288    /// model-visible text.
289    ///
290    /// This is the shortest path from provider configuration to a text answer.
291    /// Use [`Self::prompt`] when transcript, usage, warnings, and provider
292    /// events must be preserved.
293    pub fn prompt_text(
294        self,
295        input: impl Into<String> + Send + 'static,
296    ) -> AgentFuture<'static, Result<String, AgentPromptError>> {
297        let input = input.into();
298        Box::pin(async move {
299            let agent = self.build()?;
300            Ok(agent.prompt_text(input).await?)
301        })
302    }
303
304    /// Builds an Agent whose schema and local decoder are bound to `T`.
305    ///
306    /// This is the preferred terminal builder operation for structured output
307    /// because a different decode type cannot be selected later by mistake.
308    ///
309    /// # Errors
310    ///
311    /// Returns [`AgentBuildError`] under the same validation rules as
312    /// [`Self::build`].
313    pub fn build_structured<T>(
314        self,
315        name: impl Into<String>,
316    ) -> Result<StructuredAgent<T>, AgentBuildError>
317    where
318        T: JsonSchema,
319    {
320        self.structured_output::<T>(name)
321            .build()
322            .map(StructuredAgent::new)
323    }
324}
325
326impl std::fmt::Debug for AgentBuilder {
327    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        formatter
329            .debug_struct("AgentBuilder")
330            .field("agent", &self.agent.name)
331            .field("error", &self.error)
332            .finish_non_exhaustive()
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use std::{collections::BTreeMap, sync::Arc};
339
340    use runifold_core::{CapabilityId, CapabilitySet, EffectClass, RiskLevel};
341    use runifold_model::{ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat};
342    use runifold_testkit::ScriptedModel;
343    use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
344    use schemars::JsonSchema;
345    use serde::Deserialize;
346    use serde_json::json;
347
348    use crate::{Agent, AgentBuildError, AgentDescriptor, AgentPromptError};
349
350    struct TestTool {
351        descriptor: ToolDescriptor,
352    }
353
354    #[derive(Deserialize, JsonSchema)]
355    struct TypedAnswer {
356        value: u32,
357    }
358
359    impl TestTool {
360        fn named(name: &str) -> Self {
361            Self {
362                descriptor: ToolDescriptor {
363                    id: CapabilityId::new(),
364                    name: name.into(),
365                    version: "1".into(),
366                    description: "test".into(),
367                    input_schema: json!({"type": "object"}),
368                    output_schema: json!({"type": "object"}),
369                    effect: EffectClass::Pure,
370                    risk: RiskLevel::Low,
371                    metadata: BTreeMap::new(),
372                },
373            }
374        }
375    }
376
377    impl Tool for TestTool {
378        fn descriptor(&self) -> &ToolDescriptor {
379            &self.descriptor
380        }
381
382        fn invoke(
383            &self,
384            input: serde_json::Value,
385            _context: ToolContext,
386        ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
387            Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
388        }
389    }
390
391    #[test]
392    fn fluent_builder_assembles_the_canonical_agent() {
393        let model = Arc::new(ScriptedModel::new());
394        let agent = Agent::builder("worker", model, ModelRef::new("test", "scripted"))
395            .system("Be precise")
396            .tool(TestTool::named("lookup"))
397            .max_turns(4)
398            .build()
399            .unwrap();
400
401        assert_eq!(agent.name, "worker");
402        assert_eq!(agent.instructions.len(), 1);
403        assert!(agent.tools.contains("lookup"));
404        assert_eq!(agent.config.max_turns, 4);
405        assert_eq!(agent.callable_capabilities().len(), 1);
406    }
407
408    #[test]
409    fn build_rejects_tool_and_agent_name_collisions() {
410        let model = Arc::new(ScriptedModel::new());
411        let child = Arc::new(Agent::new(
412            "child",
413            model.clone(),
414            ModelRef::new("test", "child"),
415        ));
416        let error = Agent::builder("parent", model, ModelRef::new("test", "parent"))
417            .tool(TestTool::named("search"))
418            .child(
419                AgentDescriptor::new("search", "delegate search"),
420                child,
421                CapabilitySet::new(),
422            )
423            .build()
424            .unwrap_err();
425
426        assert_eq!(
427            error,
428            AgentBuildError::CallableNameCollision("search".into())
429        );
430    }
431
432    #[test]
433    fn builder_derives_a_strict_output_schema_from_a_rust_type() {
434        let example = TypedAnswer { value: 7 };
435        assert_eq!(example.value, 7);
436        let agent = Agent::builder(
437            "worker",
438            Arc::new(ScriptedModel::new()),
439            ModelRef::new("test", "scripted"),
440        )
441        .structured_output::<TypedAnswer>("typed_answer")
442        .build()
443        .unwrap();
444
445        let OutputFormat::JsonSchema {
446            name,
447            schema,
448            strict,
449        } = agent.output_format
450        else {
451            panic!("expected JSON-schema output");
452        };
453        assert_eq!(name, "typed_answer");
454        assert!(strict);
455        assert_eq!(schema["properties"]["value"]["type"], "integer");
456    }
457
458    #[test]
459    fn builder_prompt_text_is_a_single_use_golden_path() {
460        let model = ScriptedModel::new();
461        model.enqueue([
462            ModelStreamEvent::ResponseStarted {
463                id: Some("response-1".into()),
464                model: ModelRef::new("test", "scripted"),
465            },
466            ModelStreamEvent::ContentPartCompleted {
467                index: 0,
468                part: ContentPart::text("done"),
469            },
470            ModelStreamEvent::ResponseCompleted {
471                finish_reason: FinishReason::Stop,
472                provider_metadata: BTreeMap::new(),
473            },
474        ]);
475
476        let text = futures_executor::block_on(
477            Agent::builder("worker", Arc::new(model), ModelRef::new("test", "scripted"))
478                .system("Be precise")
479                .prompt_text("start"),
480        )
481        .unwrap();
482
483        assert_eq!(text, "done");
484    }
485
486    #[test]
487    fn builder_prompt_reports_build_failures_before_model_execution() {
488        let error = futures_executor::block_on(
489            Agent::builder(
490                "",
491                Arc::new(ScriptedModel::new()),
492                ModelRef::new("test", "scripted"),
493            )
494            .prompt("start"),
495        )
496        .unwrap_err();
497
498        assert!(matches!(
499            error,
500            AgentPromptError::Build(AgentBuildError::EmptyName)
501        ));
502    }
503}