use super::{record_run_attribution, record_run_origin, run_loop, CompletionRecording, RunOptions};
use crate::agent::AgentContext;
use crate::error::Error;
use crate::ids::ThreadId;
use crate::llm::{Message, Role};
use crate::memory::Episode;
use crate::response::{parse_structured, KlieoResponse};
pub const MAX_STRUCTURED_RETRIES: u32 = 2;
pub async fn run_structured<T: KlieoResponse>(
ctx: &AgentContext,
system_prompt: &str,
thread: ThreadId,
opts: RunOptions,
) -> Result<T, Error> {
ctx.episodic
.record(
ctx.run_id,
Episode::Started {
agent: ctx.agent_name.clone(),
},
)
.await?;
record_run_attribution(ctx).await?;
record_run_origin(ctx).await?;
let content = run_loop(
ctx,
system_prompt,
&thread,
&opts,
0,
CompletionRecording::Suppress,
)
.await?;
let mut last_err = match parse_structured::<T>(&content) {
Ok(v) => return record_completed(ctx, v).await,
Err(e) => e,
};
let retry_opts = opts.without_compaction();
for _ in 0..MAX_STRUCTURED_RETRIES {
let feedback = format!(
"Your previous reply could not be parsed: {last_err}. \
Reply again with ONLY valid JSON matching the required schema."
);
ctx.short_term
.append(
thread.clone(),
Message {
role: Role::User,
content: feedback,
tool_calls: vec![],
tool_call_id: None,
},
)
.await?;
let content = run_loop(
ctx,
system_prompt,
&thread,
&retry_opts,
0,
CompletionRecording::Suppress,
)
.await?;
match parse_structured::<T>(&content) {
Ok(v) => return record_completed(ctx, v).await,
Err(e) => last_err = e,
}
}
Err(last_err)
}
async fn record_completed<T>(ctx: &AgentContext, value: T) -> Result<T, Error> {
ctx.episodic.record(ctx.run_id, Episode::Completed).await?;
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::summarize::SummarizeOptions;
use crate::test_utils::{fake_context, FakeLlmClient, FakeLlmStep};
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
#[derive(Debug, Deserialize, PartialEq)]
struct Greeting {
greeting: String,
}
impl KlieoResponse for Greeting {
fn json_schema() -> serde_json::Value {
json!({
"type": "object",
"properties": { "greeting": { "type": "string" } },
"required": ["greeting"]
})
}
}
#[tokio::test]
async fn malformed_first_reply_retries_with_feedback_then_succeeds() {
let mut ctx = fake_context("structured-retry");
ctx.llm = Arc::new(FakeLlmClient::new("fake").with_steps(vec![
FakeLlmStep::Text("not json at all".into()),
FakeLlmStep::Text(r#"{"greeting":"hi"}"#.into()),
]));
let thread = ThreadId::new("t-structured");
let out: Greeting = run_structured(&ctx, "sys", thread.clone(), RunOptions::default())
.await
.unwrap();
assert_eq!(
out,
Greeting {
greeting: "hi".into()
}
);
let history = ctx.short_term.load(thread, 1024).await.unwrap();
let feedback_msgs: Vec<_> = history
.iter()
.filter(|m| m.content.contains("could not be parsed"))
.collect();
assert_eq!(
feedback_msgs.len(),
1,
"exactly one feedback message injected for the one bad reply"
);
}
#[tokio::test]
async fn retry_sub_call_disables_compaction_so_the_feedback_survives() {
let mut ctx = fake_context("structured-compaction-off");
ctx.llm = Arc::new(FakeLlmClient::new("fake").with_steps(vec![
FakeLlmStep::Text("not json at all".into()),
FakeLlmStep::Text(r#"{"greeting":"hi"}"#.into()),
]));
let thread = ThreadId::new("t-compaction-off");
let compaction = SummarizeOptions {
trigger_token_budget: 1,
keep_recent_messages: 0,
..SummarizeOptions::default()
};
let history_budget = crate::summarize::visible_window_tokens(&compaction);
let opts = RunOptions::default()
.with_compaction(compaction)
.with_max_history_tokens(history_budget);
let out: Greeting = run_structured(&ctx, "sys", thread, opts).await.unwrap();
assert_eq!(
out,
Greeting {
greeting: "hi".into()
},
"retry must reach the real scripted reply, not a summarizer call \
consuming it first"
);
}
#[tokio::test]
async fn valid_first_reply_needs_no_retry() {
let mut ctx = fake_context("structured-no-retry");
ctx.llm = Arc::new(
FakeLlmClient::new("fake")
.with_steps(vec![FakeLlmStep::Text(r#"{"greeting":"hi"}"#.into())]),
);
let thread = ThreadId::new("t-clean");
let out: Greeting = run_structured(&ctx, "sys", thread.clone(), RunOptions::default())
.await
.unwrap();
assert_eq!(
out,
Greeting {
greeting: "hi".into()
}
);
let history = ctx.short_term.load(thread, 1024).await.unwrap();
assert_eq!(
history.len(),
1,
"only the assistant reply — no feedback message"
);
}
#[tokio::test]
async fn exhausting_all_retries_returns_the_last_parse_error() {
let mut ctx = fake_context("structured-exhausted");
ctx.llm = Arc::new(FakeLlmClient::new("fake").with_steps(vec![
FakeLlmStep::Text("bad".into()), FakeLlmStep::Text("{\"greeting\": bad}".into()), FakeLlmStep::Text("{\"greeting\": \"ok\" extra}".into()), ]));
let thread = ThreadId::new("t-exhausted");
let err = run_structured::<Greeting>(&ctx, "sys", thread, RunOptions::default())
.await
.unwrap_err();
assert!(matches!(err, Error::BadResponse(_)));
assert!(
err.to_string().contains("line 1 col 19"),
"expected the LAST fixture's parse error (line 1 col 19), got: {err}"
);
assert_eq!(
MAX_STRUCTURED_RETRIES, 2,
"three scripted replies must exactly exhaust a budget of 1 initial + 2 retries"
);
}
#[tokio::test]
async fn retry_then_succeed_records_exactly_one_completed_episode() {
let mut ctx = fake_context("structured-episode-success");
ctx.llm = Arc::new(FakeLlmClient::new("fake").with_steps(vec![
FakeLlmStep::Text("not json at all".into()),
FakeLlmStep::Text(r#"{"greeting":"hi"}"#.into()),
]));
let thread = ThreadId::new("t-episode-success");
let _out: Greeting = run_structured(&ctx, "sys", thread, RunOptions::default())
.await
.unwrap();
let episodes = ctx.episodic.replay(ctx.run_id).await.unwrap();
let completed_count = episodes
.iter()
.filter(|e| matches!(e, Episode::Completed))
.count();
assert_eq!(
completed_count, 1,
"one logical call that retried once then succeeded must record \
Episode::Completed exactly once, not once per attempt"
);
}
#[tokio::test]
async fn exhausting_all_retries_records_zero_completed_episodes() {
let mut ctx = fake_context("structured-episode-exhausted");
ctx.llm = Arc::new(FakeLlmClient::new("fake").with_steps(vec![
FakeLlmStep::Text("bad".into()),
FakeLlmStep::Text("{\"greeting\": bad}".into()),
FakeLlmStep::Text("{\"greeting\": \"ok\" extra}".into()),
]));
let thread = ThreadId::new("t-episode-exhausted");
let result = run_structured::<Greeting>(&ctx, "sys", thread, RunOptions::default()).await;
assert!(result.is_err(), "all three scripted replies are malformed");
let episodes = ctx.episodic.replay(ctx.run_id).await.unwrap();
let completed_count = episodes
.iter()
.filter(|e| matches!(e, Episode::Completed))
.count();
assert_eq!(
completed_count, 0,
"a call that exhausts all retries and returns Err must record no \
Episode::Completed at all — the audit trail must not claim \
success for a structurally failed call"
);
}
}