runifold_agent/
structured.rs1use std::marker::PhantomData;
2
3use runifold_core::RunContext;
4use runifold_model::StructuredOutputError;
5use serde::de::DeserializeOwned;
6use thiserror::Error;
7
8use crate::{Agent, AgentError, AgentEventStream, AgentFuture, StructuredAgentOutcome};
9
10#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum StructuredAgentError {
14 #[error(transparent)]
16 Agent(#[from] AgentError),
17 #[error(transparent)]
19 Output(#[from] StructuredOutputError),
20}
21
22#[derive(Clone)]
24pub struct StructuredAgent<T> {
25 agent: Agent,
26 output: PhantomData<fn() -> T>,
27}
28
29impl<T> StructuredAgent<T> {
30 pub(crate) const fn new(agent: Agent) -> Self {
31 Self {
32 agent,
33 output: PhantomData,
34 }
35 }
36
37 pub const fn agent(&self) -> &Agent {
39 &self.agent
40 }
41
42 pub fn into_agent(self) -> Agent {
44 self.agent
45 }
46
47 pub fn stream<'a>(
53 &'a self,
54 input: impl Into<String> + Send + 'a,
55 run: &'a RunContext,
56 ) -> AgentEventStream<'a> {
57 self.agent.stream(input, run)
58 }
59}
60
61impl<T> StructuredAgent<T>
62where
63 T: DeserializeOwned + Send + 'static,
64{
65 pub fn run<'a>(
72 &'a self,
73 input: impl Into<String> + Send + 'a,
74 run: &'a RunContext,
75 ) -> AgentFuture<'a, Result<StructuredAgentOutcome<T>, StructuredAgentError>> {
76 Box::pin(async move {
77 let outcome = self.agent.run(input, run).await?;
78 Ok(outcome.into_structured()?)
79 })
80 }
81}
82
83impl<T> std::fmt::Debug for StructuredAgent<T> {
84 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 formatter
86 .debug_tuple("StructuredAgent")
87 .field(&self.agent)
88 .finish()
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use std::{collections::BTreeMap, sync::Arc};
95
96 use runifold_core::{Budget, BudgetTracker, CapabilitySet, RunContext};
97 use runifold_model::{
98 ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat,
99 StructuredOutputErrorKind,
100 };
101 use runifold_testkit::ScriptedModel;
102 use schemars::JsonSchema;
103 use serde::Deserialize;
104
105 use crate::{Agent, StructuredAgentError};
106
107 #[derive(Debug, Deserialize, Eq, JsonSchema, PartialEq)]
108 struct Answer {
109 value: u32,
110 }
111
112 fn events(text: &str) -> Vec<ModelStreamEvent> {
113 vec![
114 ModelStreamEvent::ResponseStarted {
115 id: Some("response".into()),
116 model: ModelRef::new("test", "scripted"),
117 },
118 ModelStreamEvent::ContentPartCompleted {
119 index: 0,
120 part: ContentPart::text(text),
121 },
122 ModelStreamEvent::ResponseCompleted {
123 finish_reason: FinishReason::Stop,
124 provider_metadata: BTreeMap::new(),
125 },
126 ]
127 }
128
129 fn run() -> RunContext {
130 RunContext::root(BudgetTracker::new(Budget::default()), CapabilitySet::new())
131 }
132
133 #[test]
134 fn typed_agent_uses_one_type_for_schema_and_decode() {
135 let model = ScriptedModel::new();
136 model.enqueue(events("{\"value\":42}"));
137 let agent = Agent::builder(
138 "typed",
139 Arc::new(model.clone()),
140 ModelRef::new("test", "scripted"),
141 )
142 .build_structured::<Answer>("answer")
143 .unwrap();
144 let run = run();
145
146 let typed = futures_executor::block_on(agent.run("answer", &run)).unwrap();
147
148 assert_eq!(typed.output, Answer { value: 42 });
149 let requests = model.recorded_requests();
150 let OutputFormat::JsonSchema { name, strict, .. } = &requests[0].output_format else {
151 panic!("expected JSON-schema output");
152 };
153 assert_eq!(name, "answer");
154 assert!(*strict);
155 }
156
157 #[test]
158 fn typed_agent_surfaces_local_decode_failure_separately() {
159 let model = ScriptedModel::new();
160 model.enqueue(events("{\"value\":\"wrong\"}"));
161 let agent = Agent::new("typed", Arc::new(model), ModelRef::new("test", "scripted"))
162 .into_structured::<Answer>("answer");
163 let run = run();
164
165 let error = futures_executor::block_on(agent.run("answer", &run)).unwrap_err();
166
167 assert!(matches!(
168 error,
169 StructuredAgentError::Output(ref output)
170 if output.kind == StructuredOutputErrorKind::InvalidOutput
171 ));
172 }
173}