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