use std::marker::PhantomData;
use runifold_core::RunContext;
use runifold_model::StructuredOutputError;
use serde::de::DeserializeOwned;
use thiserror::Error;
use crate::{Agent, AgentError, AgentEventStream, AgentFuture, StructuredAgentOutcome};
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum StructuredAgentError {
#[error(transparent)]
Agent(#[from] AgentError),
#[error(transparent)]
Output(#[from] StructuredOutputError),
}
#[derive(Clone)]
pub struct StructuredAgent<T> {
agent: Agent,
output: PhantomData<fn() -> T>,
}
impl<T> StructuredAgent<T> {
pub(crate) const fn new(agent: Agent) -> Self {
Self {
agent,
output: PhantomData,
}
}
pub const fn agent(&self) -> &Agent {
&self.agent
}
pub fn into_agent(self) -> Agent {
self.agent
}
pub fn stream<'a>(
&'a self,
input: impl Into<String> + Send + 'a,
run: &'a RunContext,
) -> AgentEventStream<'a> {
self.agent.stream(input, run)
}
}
impl<T> StructuredAgent<T>
where
T: DeserializeOwned + Send + 'static,
{
pub fn run<'a>(
&'a self,
input: impl Into<String> + Send + 'a,
run: &'a RunContext,
) -> AgentFuture<'a, Result<StructuredAgentOutcome<T>, StructuredAgentError>> {
Box::pin(async move {
let outcome = self.agent.run(input, run).await?;
Ok(outcome.into_structured()?)
})
}
}
impl<T> std::fmt::Debug for StructuredAgent<T> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_tuple("StructuredAgent")
.field(&self.agent)
.finish()
}
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeMap, sync::Arc};
use runifold_core::{Budget, BudgetTracker, CapabilitySet, RunContext};
use runifold_model::{
ContentPart, FinishReason, ModelRef, ModelStreamEvent, OutputFormat,
StructuredOutputErrorKind,
};
use runifold_testkit::ScriptedModel;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{Agent, StructuredAgentError};
#[derive(Debug, Deserialize, Eq, JsonSchema, PartialEq)]
struct Answer {
value: u32,
}
fn events(text: &str) -> Vec<ModelStreamEvent> {
vec![
ModelStreamEvent::ResponseStarted {
id: Some("response".into()),
model: ModelRef::new("test", "scripted"),
},
ModelStreamEvent::ContentPartCompleted {
index: 0,
part: ContentPart::text(text),
},
ModelStreamEvent::ResponseCompleted {
finish_reason: FinishReason::Stop,
provider_metadata: BTreeMap::new(),
},
]
}
fn run() -> RunContext {
RunContext::root(BudgetTracker::new(Budget::default()), CapabilitySet::new())
}
#[test]
fn typed_agent_uses_one_type_for_schema_and_decode() {
let model = ScriptedModel::new();
model.enqueue(events("{\"value\":42}"));
let agent = Agent::builder(
"typed",
Arc::new(model.clone()),
ModelRef::new("test", "scripted"),
)
.build_structured::<Answer>("answer")
.unwrap();
let run = run();
let typed = futures_executor::block_on(agent.run("answer", &run)).unwrap();
assert_eq!(typed.output, Answer { value: 42 });
let requests = model.recorded_requests();
let OutputFormat::JsonSchema { name, strict, .. } = &requests[0].output_format else {
panic!("expected JSON-schema output");
};
assert_eq!(name, "answer");
assert!(*strict);
}
#[test]
fn typed_agent_surfaces_local_decode_failure_separately() {
let model = ScriptedModel::new();
model.enqueue(events("{\"value\":\"wrong\"}"));
let agent = Agent::new("typed", Arc::new(model), ModelRef::new("test", "scripted"))
.into_structured::<Answer>("answer");
let run = run();
let error = futures_executor::block_on(agent.run("answer", &run)).unwrap_err();
assert!(matches!(
error,
StructuredAgentError::Output(ref output)
if output.kind == StructuredOutputErrorKind::InvalidOutput
));
}
}