Skip to main content

runifold_agent/
builder.rs

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    TerminalReviewPolicy, TerminalReviewer, ToolErrorPolicy, TurnReviewPolicy, TurnReviewer,
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
51impl AgentBuildError {
52    /// Returns the stable build diagnostic code.
53    pub const fn diagnostic_code(&self) -> &'static str {
54        "RF-AGENT-001"
55    }
56}
57
58/// Failure while building and immediately prompting an Agent.
59#[derive(Debug, Error)]
60#[non_exhaustive]
61pub enum AgentPromptError {
62    /// Fluent Agent assembly failed before model execution.
63    #[error("failed to build agent: {0}")]
64    Build(#[from] AgentBuildError),
65    /// Canonical Agent execution failed.
66    #[error("agent prompt failed: {0}")]
67    Run(#[from] AgentError),
68}
69
70impl AgentPromptError {
71    /// Returns the stable run-level failure category for one-shot callers.
72    pub fn run_error_kind(&self) -> RunErrorKind {
73        match self {
74            Self::Build(_) => RunErrorKind::InvalidInput,
75            Self::Run(error) => error.run_error_kind(),
76        }
77    }
78
79    /// Returns whether retrying the complete one-shot operation is known safe.
80    pub fn retry_safety(&self) -> RetrySafety {
81        match self {
82            Self::Build(_) => RetrySafety::Safe,
83            Self::Run(error) => error.retry_safety(),
84        }
85    }
86
87    /// Normalizes build and execution failures into one public policy type.
88    pub fn to_run_error(&self) -> RunError {
89        match self {
90            Self::Build(_) => RunError {
91                kind: RunErrorKind::InvalidInput,
92                message: self.to_string(),
93                retry_safety: RetrySafety::Safe,
94                metadata: std::collections::BTreeMap::new(),
95            },
96            Self::Run(error) => error.to_run_error(),
97        }
98    }
99}
100
101/// Fluent assembly of one canonical [`Agent`].
102///
103/// Registration failures are retained and returned by [`Self::build`] so
104/// Tool and child Agent calls remain chainable without silently replacing an
105/// existing name.
106pub struct AgentBuilder {
107    agent: Agent,
108    error: Option<AgentBuildError>,
109}
110
111impl AgentBuilder {
112    /// Creates a builder around the same execution path as [`Agent::new`].
113    pub fn new(name: impl Into<String>, model: Arc<dyn Model>, model_ref: ModelRef) -> Self {
114        Self {
115            agent: Agent::new(name, model, model_ref),
116            error: None,
117        }
118    }
119
120    /// Appends a system instruction.
121    #[must_use]
122    pub fn system(mut self, instruction: impl Into<String>) -> Self {
123        self.agent
124            .instructions
125            .push(Message::system(instruction.into()));
126        self
127    }
128
129    /// Adds one static document as untrusted user-level context.
130    ///
131    /// The document is never promoted to a system instruction. Use
132    /// [`Self::system`] for trusted application policy.
133    #[must_use]
134    pub fn context(self, text: impl Into<String>) -> Self {
135        let id = format!("static-context-{}", self.agent.context.len() + 1);
136        match Document::new(id, text) {
137            Ok(document) => self.context_document(document),
138            Err(error) => self.with_error(error.into()),
139        }
140    }
141
142    /// Adds one validated static context document.
143    #[must_use]
144    pub fn context_document(mut self, document: Document) -> Self {
145        if self.error.is_none() {
146            self.agent.context.push(document);
147        }
148        self
149    }
150
151    /// Configures reference-only artifact persistence for Tools and resolves
152    /// those references only at the final model transport boundary.
153    #[must_use]
154    pub fn artifacts(mut self, scope: ArtifactScope, store: Arc<dyn ArtifactStore>) -> Self {
155        self.agent.model = Arc::new(ArtifactResolvingModel::new(
156            self.agent.model.clone(),
157            scope.clone(),
158            store.clone(),
159        ));
160        self.agent.tools = self.agent.tools.clone().with_artifact_store(scope, store);
161        self
162    }
163
164    /// Adds an owned dynamic context source.
165    #[must_use]
166    pub fn dynamic_context<R>(self, limit: usize, retriever: R) -> Self
167    where
168        R: Retriever + 'static,
169    {
170        self.shared_dynamic_context(limit, Arc::new(retriever))
171    }
172
173    /// Adds a shared, type-erased dynamic context source.
174    #[must_use]
175    pub fn shared_dynamic_context(mut self, limit: usize, retriever: Arc<dyn Retriever>) -> Self {
176        if self.error.is_none() {
177            if limit == 0 {
178                self.error = Some(RetrievalError::ZeroLimit.into());
179            } else {
180                self.agent
181                    .dynamic_context
182                    .push(DynamicContext { limit, retriever });
183            }
184        }
185        self
186    }
187
188    /// Registers an owned Tool.
189    #[must_use]
190    pub fn tool<T>(self, tool: T) -> Self
191    where
192        T: Tool + 'static,
193    {
194        self.shared_tool(Arc::new(tool))
195    }
196
197    /// Registers a shared, type-erased Tool.
198    #[must_use]
199    pub fn shared_tool(mut self, tool: Arc<dyn Tool>) -> Self {
200        if self.error.is_none()
201            && let Err(error) = self.agent.tools.register(tool)
202        {
203            self.error = Some(error.into());
204        }
205        self
206    }
207
208    fn with_error(mut self, error: AgentBuildError) -> Self {
209        if self.error.is_none() {
210            self.error = Some(error);
211        }
212        self
213    }
214
215    /// Registers a child Agent route with explicit delegated capabilities.
216    #[must_use]
217    pub fn child(
218        mut self,
219        descriptor: AgentDescriptor,
220        child: Arc<Agent>,
221        capabilities: CapabilitySet,
222    ) -> Self {
223        if self.error.is_none() {
224            let route = AgentRoute::new(descriptor, child).with_capabilities(capabilities);
225            if let Err(error) = self.agent.agents.register(route) {
226                self.error = Some(error.into());
227            }
228        }
229        self
230    }
231
232    /// Appends Gateway around-middleware.
233    #[must_use]
234    pub fn gateway_layer(mut self, middleware: Arc<dyn GatewayMiddleware>) -> Self {
235        self.agent.agents.push_middleware(middleware);
236        self
237    }
238
239    /// Sets the maximum nested Agent delegation depth.
240    #[must_use]
241    pub fn max_delegation_depth(mut self, max_depth: u32) -> Self {
242        self.agent.agents = self.agent.agents.with_max_depth(max_depth);
243        self
244    }
245
246    /// Bounds concurrent read-only local tools; defaults to one.
247    #[must_use]
248    pub const fn tool_concurrency(mut self, limit: std::num::NonZeroUsize) -> Self {
249        self.agent.tool_concurrency = limit;
250        self
251    }
252
253    /// Sets the local model-turn limit.
254    #[must_use]
255    pub const fn max_turns(mut self, max_turns: u32) -> Self {
256        self.agent.config.max_turns = max_turns;
257        self
258    }
259
260    /// Requires this many successful local Tool calls before terminal output.
261    ///
262    /// The runtime requests at least one Tool while the requirement remains
263    /// unsatisfied and returns an explicit error if the model violates that
264    /// contract. A value of zero disables the requirement.
265    #[must_use]
266    pub const fn min_successful_tool_calls(mut self, minimum: u32) -> Self {
267        self.agent.min_successful_tool_calls = minimum;
268        self
269    }
270
271    /// Sets Tool failure behavior.
272    #[must_use]
273    pub const fn tool_error_policy(mut self, policy: ToolErrorPolicy) -> Self {
274        self.agent.config.tool_error_policy = policy;
275        self
276    }
277
278    /// Sets terminal validation and bounded repair behavior.
279    #[must_use]
280    pub fn completion_requirement(self, requirement: CompletionRequirement) -> Self {
281        Self {
282            agent: self.agent.completion_requirement(requirement),
283            error: self.error,
284        }
285    }
286
287    /// Installs internal model-turn review before selected responses can
288    /// execute tools or enter completion.
289    #[must_use]
290    pub fn turn_reviewer<R>(
291        self,
292        reviewer: R,
293        policy: TurnReviewPolicy,
294        capabilities: CapabilitySet,
295    ) -> Self
296    where
297        R: TurnReviewer + 'static,
298    {
299        Self {
300            agent: self.agent.turn_reviewer(reviewer, policy, capabilities),
301            error: self.error,
302        }
303    }
304
305    /// Installs a shared, type-erased internal-turn reviewer.
306    #[must_use]
307    pub fn shared_turn_reviewer(
308        self,
309        reviewer: Arc<dyn TurnReviewer>,
310        policy: TurnReviewPolicy,
311        capabilities: CapabilitySet,
312    ) -> Self {
313        Self {
314            agent: self
315                .agent
316                .shared_turn_reviewer(reviewer, policy, capabilities),
317            error: self.error,
318        }
319    }
320
321    /// Installs semantic terminal review with bounded repair and attenuated
322    /// reviewer capabilities.
323    #[must_use]
324    pub fn terminal_reviewer<R>(
325        self,
326        reviewer: R,
327        policy: TerminalReviewPolicy,
328        capabilities: CapabilitySet,
329    ) -> Self
330    where
331        R: TerminalReviewer + 'static,
332    {
333        Self {
334            agent: self.agent.terminal_reviewer(reviewer, policy, capabilities),
335            error: self.error,
336        }
337    }
338
339    /// Installs a shared, type-erased semantic terminal reviewer.
340    #[must_use]
341    pub fn shared_terminal_reviewer(
342        self,
343        reviewer: Arc<dyn TerminalReviewer>,
344        policy: TerminalReviewPolicy,
345        capabilities: CapabilitySet,
346    ) -> Self {
347        Self {
348            agent: self
349                .agent
350                .shared_terminal_reviewer(reviewer, policy, capabilities),
351            error: self.error,
352        }
353    }
354
355    /// Sets provider feature-degradation behavior.
356    #[must_use]
357    pub const fn feature_policy(mut self, policy: FeaturePolicy) -> Self {
358        self.agent.config.feature_policy = policy;
359        self
360    }
361
362    /// Sets the desired terminal model-output format.
363    #[must_use]
364    pub fn output_format(mut self, output_format: OutputFormat) -> Self {
365        self.agent.output_format = output_format;
366        self
367    }
368
369    /// Requests strict structured output described by the Rust type `T`.
370    #[must_use]
371    pub fn structured_output<T>(self, name: impl Into<String>) -> Self
372    where
373        T: JsonSchema,
374    {
375        self.structured_output_with_strictness::<T>(name, true)
376    }
377
378    /// Requests structured output described by `T` with explicit provider
379    /// strictness.
380    #[must_use]
381    pub fn structured_output_with_strictness<T>(self, name: impl Into<String>, strict: bool) -> Self
382    where
383        T: JsonSchema,
384    {
385        self.output_format(OutputFormat::typed_with_strictness::<T>(name, strict))
386    }
387
388    /// Adds a provider-hosted tool such as Ark web search.
389    #[must_use]
390    pub fn provider_tool(mut self, tool: ProviderToolSpec) -> Self {
391        self.agent.provider_tools.push(tool);
392        self
393    }
394
395    /// Replaces common model generation controls for every Agent turn.
396    #[must_use]
397    pub fn generation(mut self, generation: GenerationOptions) -> Self {
398        self.agent.generation = generation;
399        self
400    }
401
402    /// Sets the sampling temperature for every Agent turn.
403    #[must_use]
404    pub fn temperature(mut self, temperature: f64) -> Self {
405        self.agent.generation.temperature = Some(temperature);
406        self
407    }
408
409    /// Sets nucleus sampling for every Agent turn.
410    #[must_use]
411    pub fn top_p(mut self, top_p: f64) -> Self {
412        self.agent.generation.top_p = Some(top_p);
413        self
414    }
415
416    /// Sets the maximum number of output tokens for every Agent turn.
417    #[must_use]
418    pub fn max_output_tokens(mut self, max_output_tokens: u64) -> Self {
419        self.agent.generation.max_output_tokens = Some(max_output_tokens);
420        self
421    }
422
423    /// Selects streaming or complete response delivery for every Agent turn.
424    #[must_use]
425    pub const fn response_mode(mut self, response_mode: ResponseMode) -> Self {
426        self.agent.response_mode = response_mode;
427        self
428    }
429
430    /// Adds namespaced provider options to every Agent turn.
431    #[must_use]
432    pub fn provider_options(
433        mut self,
434        provider: impl Into<String>,
435        options: serde_json::Value,
436    ) -> Self {
437        self.agent.provider_options.insert(provider.into(), options);
438        self
439    }
440
441    /// Replaces all local Agent configuration.
442    #[must_use]
443    pub const fn config(mut self, config: AgentConfig) -> Self {
444        self.agent.config = config;
445        self
446    }
447
448    /// Shares a write-ahead effect coordinator with this Agent.
449    #[must_use]
450    pub fn effect_executor(mut self, effects: EffectExecutor) -> Self {
451        self.agent.effects = effects;
452        self
453    }
454
455    /// Sets recovery behavior for ambiguous callable effects.
456    #[must_use]
457    pub const fn effect_recovery_policy(mut self, policy: EffectRecoveryPolicy) -> Self {
458        self.agent.effect_recovery = policy;
459        self
460    }
461
462    /// Validates registrations and returns the canonical Agent.
463    ///
464    /// # Errors
465    ///
466    /// Returns [`AgentBuildError`] for blank identity, invalid turn bounds,
467    /// duplicate registrations, or Tool/Agent name collisions.
468    pub fn build(self) -> Result<Agent, AgentBuildError> {
469        if let Some(error) = self.error {
470            return Err(error);
471        }
472        if self.agent.name.trim().is_empty() {
473            return Err(AgentBuildError::EmptyName);
474        }
475        if self.agent.config.max_turns == 0 {
476            return Err(AgentBuildError::ZeroMaxTurns);
477        }
478        if self.agent.min_successful_tool_calls > 0 && self.agent.tools.is_empty() {
479            return Err(AgentBuildError::MinimumSuccessfulToolCallsWithoutTool {
480                minimum: self.agent.min_successful_tool_calls,
481            });
482        }
483        if let Some(collision) = self
484            .agent
485            .agents
486            .model_specs()
487            .into_iter()
488            .find(|spec| self.agent.tools.contains(&spec.name))
489        {
490            return Err(AgentBuildError::CallableNameCollision(collision.name));
491        }
492        Ok(self.agent)
493    }
494
495    /// Builds the Agent and runs one ergonomic prompt.
496    ///
497    /// This removes the explicit build step for one-shot usage while retaining
498    /// the complete canonical outcome. Use [`Self::build`] when the Agent will
499    /// be reused or executed with an explicit runtime context.
500    pub fn prompt(
501        self,
502        input: impl Into<String> + Send + 'static,
503    ) -> AgentFuture<'static, Result<AgentOutcome, AgentPromptError>> {
504        let input = input.into();
505        Box::pin(async move {
506            let agent = self.build()?;
507            Ok(agent.prompt(input).await?)
508        })
509    }
510
511    /// Builds the Agent, runs one ergonomic prompt, and returns only
512    /// model-visible text.
513    ///
514    /// This is the shortest path from provider configuration to a text answer.
515    /// Use [`Self::prompt`] when transcript, usage, warnings, and provider
516    /// events must be preserved.
517    pub fn prompt_text(
518        self,
519        input: impl Into<String> + Send + 'static,
520    ) -> AgentFuture<'static, Result<String, AgentPromptError>> {
521        let input = input.into();
522        Box::pin(async move {
523            let agent = self.build()?;
524            Ok(agent.prompt_text(input).await?)
525        })
526    }
527
528    /// Builds an Agent whose schema and local decoder are bound to `T`.
529    ///
530    /// This is the preferred terminal builder operation for structured output
531    /// because a different decode type cannot be selected later by mistake.
532    ///
533    /// # Errors
534    ///
535    /// Returns [`AgentBuildError`] under the same validation rules as
536    /// [`Self::build`].
537    pub fn build_structured<T>(
538        self,
539        name: impl Into<String>,
540    ) -> Result<StructuredAgent<T>, AgentBuildError>
541    where
542        T: JsonSchema + serde::de::DeserializeOwned + Send + 'static,
543    {
544        self.build_structured_with_strictness::<T>(name, true)
545    }
546
547    /// Builds an Agent whose schema and local decoder are bound to `T`, with
548    /// explicit provider strictness.
549    ///
550    /// Local structured-output validation and bounded repair remain enabled
551    /// when provider strictness is disabled.
552    ///
553    /// # Errors
554    ///
555    /// Returns [`AgentBuildError`] under the same validation rules as
556    /// [`Self::build`].
557    pub fn build_structured_with_strictness<T>(
558        self,
559        name: impl Into<String>,
560        strict: bool,
561    ) -> Result<StructuredAgent<T>, AgentBuildError>
562    where
563        T: JsonSchema + serde::de::DeserializeOwned + Send + 'static,
564    {
565        self.build()
566            .map(|agent| agent.into_structured_with_strictness::<T>(name, strict))
567    }
568}
569
570impl std::fmt::Debug for AgentBuilder {
571    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572        formatter
573            .debug_struct("AgentBuilder")
574            .field("agent", &self.agent.name)
575            .field("error", &self.error)
576            .finish_non_exhaustive()
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use std::{collections::BTreeMap, sync::Arc};
583
584    use runifold_core::{
585        CapabilityId, CapabilitySet, EffectClass, RetrySafety, RiskLevel, RunErrorKind,
586    };
587    use runifold_model::{
588        ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat, ProviderToolSpec,
589        ResponseMode,
590    };
591    use runifold_testkit::ScriptedModel;
592    use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
593    use schemars::JsonSchema;
594    use serde::Deserialize;
595    use serde_json::json;
596
597    use crate::{Agent, AgentBuildError, AgentDescriptor, AgentPromptError};
598
599    struct TestTool {
600        descriptor: ToolDescriptor,
601    }
602
603    #[derive(Deserialize, JsonSchema)]
604    struct TypedAnswer {
605        value: u32,
606    }
607
608    impl TestTool {
609        fn named(name: &str) -> Self {
610            Self {
611                descriptor: ToolDescriptor {
612                    id: CapabilityId::new(),
613                    name: name.into(),
614                    version: "1".into(),
615                    description: "test".into(),
616                    input_schema: json!({"type": "object"}),
617                    output_schema: json!({"type": "object"}),
618                    effect: EffectClass::Pure,
619                    risk: RiskLevel::Low,
620                    metadata: BTreeMap::new(),
621                },
622            }
623        }
624    }
625
626    impl Tool for TestTool {
627        fn descriptor(&self) -> &ToolDescriptor {
628            &self.descriptor
629        }
630
631        fn invoke(
632            &self,
633            input: serde_json::Value,
634            _context: ToolContext,
635        ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
636            Box::pin(async move { Ok(ToolOutput::model_visible(input)) })
637        }
638    }
639
640    #[test]
641    fn fluent_builder_assembles_the_canonical_agent() {
642        let model = Arc::new(ScriptedModel::new());
643        let agent = Agent::builder("worker", model, ModelRef::new("test", "scripted"))
644            .system("Be precise")
645            .tool(TestTool::named("lookup"))
646            .min_successful_tool_calls(3)
647            .max_turns(4)
648            .build()
649            .unwrap();
650
651        assert_eq!(agent.name, "worker");
652        assert_eq!(agent.instructions.len(), 1);
653        assert!(agent.tools.contains("lookup"));
654        assert_eq!(agent.config.max_turns, 4);
655        assert_eq!(agent.min_successful_tool_calls, 3);
656        assert_eq!(agent.callable_capabilities().len(), 1);
657    }
658
659    #[test]
660    fn builder_rejects_a_tool_minimum_without_a_local_tool() {
661        let error = Agent::builder(
662            "worker",
663            Arc::new(ScriptedModel::new()),
664            ModelRef::new("test", "scripted"),
665        )
666        .min_successful_tool_calls(1)
667        .build()
668        .unwrap_err();
669
670        assert!(matches!(
671            error,
672            AgentBuildError::MinimumSuccessfulToolCallsWithoutTool { minimum: 1 }
673        ));
674    }
675
676    #[test]
677    fn builder_retains_generation_provider_and_delivery_controls() {
678        let provider_tool = ProviderToolSpec::new("ark", "web_search").unwrap();
679        let agent = Agent::builder(
680            "researcher",
681            Arc::new(ScriptedModel::new()),
682            ModelRef::new("ark", "doubao"),
683        )
684        .temperature(0.2)
685        .top_p(0.8)
686        .max_output_tokens(4_096)
687        .response_mode(ResponseMode::Complete)
688        .provider_tool(provider_tool)
689        .provider_options("ark", json!({"thinking": {"type": "enabled"}}))
690        .build()
691        .unwrap();
692
693        assert_eq!(agent.generation.temperature, Some(0.2));
694        assert_eq!(agent.generation.top_p, Some(0.8));
695        assert_eq!(agent.generation.max_output_tokens, Some(4_096));
696        assert_eq!(agent.response_mode, ResponseMode::Complete);
697        assert_eq!(agent.provider_tools[0].tool_type, "web_search");
698        assert_eq!(agent.provider_options["ark"]["thinking"]["type"], "enabled");
699    }
700
701    #[test]
702    fn build_rejects_tool_and_agent_name_collisions() {
703        let model = Arc::new(ScriptedModel::new());
704        let child = Arc::new(Agent::new(
705            "child",
706            model.clone(),
707            ModelRef::new("test", "child"),
708        ));
709        let error = Agent::builder("parent", model, ModelRef::new("test", "parent"))
710            .tool(TestTool::named("search"))
711            .child(
712                AgentDescriptor::new("search", "delegate search"),
713                child,
714                CapabilitySet::new(),
715            )
716            .build()
717            .unwrap_err();
718
719        assert_eq!(
720            error,
721            AgentBuildError::CallableNameCollision("search".into())
722        );
723    }
724
725    #[test]
726    fn builder_derives_a_strict_output_schema_from_a_rust_type() {
727        let example = TypedAnswer { value: 7 };
728        assert_eq!(example.value, 7);
729        let agent = Agent::builder(
730            "worker",
731            Arc::new(ScriptedModel::new()),
732            ModelRef::new("test", "scripted"),
733        )
734        .structured_output::<TypedAnswer>("typed_answer")
735        .build()
736        .unwrap();
737
738        let OutputFormat::JsonSchema {
739            name,
740            schema,
741            strict,
742        } = agent.output_format
743        else {
744            panic!("expected JSON-schema output");
745        };
746        assert_eq!(name, "typed_answer");
747        assert!(strict);
748        assert_eq!(schema["properties"]["value"]["type"], "integer");
749    }
750
751    #[test]
752    fn structured_builder_can_disable_provider_strictness_without_losing_typed_binding() {
753        let agent = Agent::builder(
754            "worker",
755            Arc::new(ScriptedModel::new()),
756            ModelRef::new("test", "scripted"),
757        )
758        .build_structured_with_strictness::<TypedAnswer>("typed_answer", false)
759        .unwrap();
760
761        let OutputFormat::JsonSchema { strict, .. } = &agent.agent().output_format else {
762            panic!("expected JSON-schema output");
763        };
764        assert!(!strict);
765    }
766
767    #[test]
768    fn builder_prompt_text_is_a_single_use_golden_path() {
769        let model = ScriptedModel::new();
770        model.enqueue([
771            ModelStreamEvent::ResponseStarted {
772                id: Some("response-1".into()),
773                model: ModelRef::new("test", "scripted"),
774            },
775            ModelStreamEvent::ContentPartCompleted {
776                index: 0,
777                part: ContentPart::text("done"),
778            },
779            ModelStreamEvent::ResponseCompleted {
780                finish_reason: FinishReason::Stop,
781                provider_metadata: BTreeMap::new(),
782            },
783        ]);
784
785        let text = futures_executor::block_on(
786            Agent::builder("worker", Arc::new(model), ModelRef::new("test", "scripted"))
787                .system("Be precise")
788                .prompt_text("start"),
789        )
790        .unwrap();
791
792        assert_eq!(text, "done");
793    }
794
795    #[test]
796    fn builder_prompt_reports_build_failures_before_model_execution() {
797        let error = futures_executor::block_on(
798            Agent::builder(
799                "",
800                Arc::new(ScriptedModel::new()),
801                ModelRef::new("test", "scripted"),
802            )
803            .prompt("start"),
804        )
805        .unwrap_err();
806
807        assert!(matches!(
808            &error,
809            AgentPromptError::Build(AgentBuildError::EmptyName)
810        ));
811        assert_eq!(error.run_error_kind(), RunErrorKind::InvalidInput);
812        assert_eq!(error.retry_safety(), RetrySafety::Safe);
813        assert_eq!(error.to_run_error().code(), "runifold.invalid_input");
814    }
815}