use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::api::ApiClient;
use crate::message::Message;
use crate::reflection::{FailureAnalysis, ReflectionContext, ReflectionError, Reflector};
use crate::structured::request_structured;
use crate::tool::ToolSchema;
const DEFAULT_PROMPT: &str = "\
You are a tool-failure analyst for an LLM agent loop. Given a tool name, \
the JSON input that was passed, the tool's input schema (if provided), and \
the error that resulted, classify the failure and suggest a correction.\n\
\n\
Respond with a single JSON object matching this exact shape:\n\
{\n\
\"is_recoverable\": <boolean>,\n\
\"root_cause\": <string>,\n\
\"severity\": \"low\" | \"medium\" | \"high\" | \"critical\",\n\
\"correction\": {\n\
\"correction_type\": \"input_fix\" | \"tool_change\" | \"prerequisite_fix\" | \"approach_change\" | \"escalate\",\n\
\"description\": <string>,\n\
\"modified_input\": <object or null>,\n\
\"alternative_tool\": <string or null>,\n\
\"guidance\": <string or null>\n\
} | null,\n\
\"context\": <string>\n\
}\n\
\n\
Only set \"modified_input\" when \"correction_type\" is \"input_fix\", and only \
when you can produce an input that conforms to the tool's schema. Prefer \
\"is_recoverable\": false over inventing a correction.";
pub struct LlmReflector {
client: Arc<dyn ApiClient>,
system_prompt: String,
}
impl LlmReflector {
#[must_use]
pub fn new(client: Arc<dyn ApiClient>) -> Self {
Self {
client,
system_prompt: DEFAULT_PROMPT.to_string(),
}
}
#[must_use]
pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = prompt.into();
self
}
}
impl std::fmt::Debug for LlmReflector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LlmReflector")
.field("client", &"<dyn ApiClient>")
.field(
"system_prompt",
&format!("{} chars", self.system_prompt.len()),
)
.finish()
}
}
impl Reflector for LlmReflector {
fn analyze(
&self,
error: &str,
tool_name: &str,
tool_input: &serde_json::Value,
tool_schema: Option<&ToolSchema>,
context: &ReflectionContext,
) -> Pin<Box<dyn Future<Output = Result<FailureAnalysis, ReflectionError>> + Send + '_>> {
let schema_value = tool_schema.map(|s| s.input_schema.clone());
let user_message =
build_user_message(error, tool_name, tool_input, schema_value.as_ref(), context);
let system = self.system_prompt.clone();
let client = std::sync::Arc::clone(&self.client);
Box::pin(async move {
let analysis = request_structured::<FailureAnalysis>(
&*client,
vec![Message::user(user_message)],
Some(system),
)
.await
.map_err(|e| ReflectionError::Internal(format!("{e}")))?;
#[cfg(feature = "schema_validation")]
validate_modified_input(&analysis, schema_value.as_ref())?;
Ok(analysis)
})
}
}
fn build_user_message(
error: &str,
tool_name: &str,
tool_input: &serde_json::Value,
tool_schema: Option<&serde_json::Value>,
context: &ReflectionContext,
) -> String {
let schema_line = match tool_schema {
Some(schema) => format!("Schema: {schema}\n"),
None => String::new(),
};
format!(
"Tool: {tool_name}\n\
Input: {tool_input}\n\
{schema_line}\
Error: {error}\n\
Task: {task}\n\
Attempt: {attempt} of {max}",
tool_name = tool_name,
tool_input = tool_input,
schema_line = schema_line,
error = error,
task = context.task,
attempt = context.attempt.saturating_add(1),
max = context.max_attempts,
)
}
#[cfg(feature = "schema_validation")]
fn validate_modified_input(
analysis: &FailureAnalysis,
tool_schema: Option<&serde_json::Value>,
) -> Result<(), ReflectionError> {
let Some(correction) = &analysis.correction else {
return Ok(());
};
let Some(modified_input) = &correction.modified_input else {
return Ok(());
};
let Some(schema) = tool_schema else {
return Ok(());
};
if !jsonschema::is_valid(schema, modified_input) {
return Err(ReflectionError::Internal(
"corrected input does not match the tool's schema".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::error::ApiError;
use crate::message::MessagePart;
use crate::reflection::{Correction, CorrectionType, FailureSeverity};
use crate::structured::RequestOptions;
use crate::tool::ToolSchema;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
#[derive(Clone)]
struct Captured {
system: Option<String>,
user: Option<String>,
}
struct CannedMock {
response: serde_json::Value,
captured: Arc<Mutex<Option<Captured>>>,
}
impl ApiClient for CannedMock {
fn model(&self) -> String {
"test".to_string()
}
fn stream_messages(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn futures::Stream<Item = Result<crate::stream::StreamEvent, ApiError>>
+ Send
+ 'static,
>,
> {
Box::pin(futures::stream::empty())
}
fn create_message(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_,
>,
> {
let message = crate::message::Message::assistant(self.response.to_string());
Box::pin(async move {
Ok(crate::api::NonStreamingResponse {
message,
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
fn create_message_with_options(
&self,
request: &crate::api::StreamRequest,
_options: RequestOptions,
) -> Pin<
Box<
dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_,
>,
> {
let crate::api::StreamRequest {
messages,
system,
tools: _,
} = request;
let user = messages.first().and_then(|m| {
m.parts.first().and_then(|p| match p {
MessagePart::Text { text } => Some(text.clone()),
_ => None,
})
});
*self.captured.lock().unwrap() = Some(Captured {
system: system.clone(),
user,
});
let message = crate::message::Message::assistant(self.response.to_string());
Box::pin(async move {
Ok(crate::api::NonStreamingResponse {
message,
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
}
struct ErrorMock;
impl ApiClient for ErrorMock {
fn model(&self) -> String {
"test".to_string()
}
fn stream_messages(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn futures::Stream<Item = Result<crate::stream::StreamEvent, ApiError>>
+ Send
+ 'static,
>,
> {
Box::pin(futures::stream::empty())
}
fn create_message(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant(""),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
fn create_message_with_options(
&self,
_request: &crate::api::StreamRequest,
_options: RequestOptions,
) -> Pin<
Box<
dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_,
>,
> {
Box::pin(async { Err(ApiError::http("upstream 500".to_string())) })
}
}
struct ProseMock(ErrorMock);
impl ApiClient for ProseMock {
fn model(&self) -> String {
self.0.model()
}
fn stream_messages(
&self,
request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn futures::Stream<Item = Result<crate::stream::StreamEvent, ApiError>>
+ Send
+ 'static,
>,
> {
self.0.stream_messages(request)
}
fn create_message(
&self,
request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_,
>,
> {
self.0.create_message(request)
}
fn create_message_with_options(
&self,
_request: &crate::api::StreamRequest,
_options: RequestOptions,
) -> Pin<
Box<
dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant("I cannot produce that."),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
}
fn fixture_analysis() -> serde_json::Value {
serde_json::json!({
"is_recoverable": true,
"root_cause": "file not found",
"severity": "medium",
"correction": {
"correction_type": "input_fix",
"description": "fix the path",
"modified_input": {"path": "/correct/path"},
"alternative_tool": null,
"guidance": null
},
"context": "open() call"
})
}
fn ctx() -> ReflectionContext {
ReflectionContext {
task: "fix the bug".to_string(),
attempt: 0,
max_attempts: 3,
}
}
#[tokio::test]
async fn llm_reflector_returns_typed_analysis() {
let captured = Arc::new(Mutex::new(None));
let client = Arc::new(CannedMock {
response: fixture_analysis(),
captured: captured.clone(),
});
let reflector = LlmReflector::new(client);
let analysis = reflector
.analyze(
"open: file not found",
"read",
&serde_json::json!({"path": "/wrong"}),
None,
&ctx(),
)
.await
.expect("should succeed");
assert!(analysis.is_recoverable);
assert_eq!(analysis.root_cause, "file not found");
assert_eq!(analysis.severity, FailureSeverity::Medium);
let correction = analysis.correction.expect("correction present");
assert_eq!(correction.correction_type, CorrectionType::InputFix);
assert_eq!(correction.description, "fix the path");
assert_eq!(
correction.modified_input,
Some(serde_json::json!({"path": "/correct/path"}))
);
}
#[tokio::test]
async fn llm_reflector_api_error_maps_to_internal() {
let client: Arc<dyn ApiClient> = Arc::new(ErrorMock);
let reflector = LlmReflector::new(client);
let err = reflector
.analyze("e", "t", &serde_json::json!({}), None, &ctx())
.await
.expect_err("should fail");
assert!(
matches!(err, ReflectionError::Internal(ref msg) if msg.contains("upstream 500")),
"got: {err:?}"
);
}
#[tokio::test]
async fn llm_reflector_prose_maps_to_internal() {
let client: Arc<dyn ApiClient> = Arc::new(ProseMock(ErrorMock));
let reflector = LlmReflector::new(client);
let err = reflector
.analyze("e", "t", &serde_json::json!({}), None, &ctx())
.await
.expect_err("should fail");
assert!(matches!(err, ReflectionError::Internal(_)), "got: {err:?}");
}
#[tokio::test]
async fn llm_reflector_uses_default_prompt() {
let captured = Arc::new(Mutex::new(None));
let client = Arc::new(CannedMock {
response: fixture_analysis(),
captured: captured.clone(),
});
let reflector = LlmReflector::new(client);
let result = reflector
.analyze("e", "t", &serde_json::json!({}), None, &ctx())
.await;
assert!(result.is_ok(), "analyze should succeed: {:?}", result.err());
let cap = captured.lock().unwrap().clone().expect("captured");
assert_eq!(cap.system.as_deref(), Some(DEFAULT_PROMPT));
}
#[tokio::test]
async fn llm_reflector_with_system_prompt_overrides() {
let captured = Arc::new(Mutex::new(None));
let client = Arc::new(CannedMock {
response: fixture_analysis(),
captured: captured.clone(),
});
let reflector = LlmReflector::new(client).with_system_prompt("custom analyst prompt");
let result = reflector
.analyze("e", "t", &serde_json::json!({}), None, &ctx())
.await;
assert!(result.is_ok(), "analyze should succeed: {:?}", result.err());
let cap = captured.lock().unwrap().clone().expect("captured");
assert_eq!(cap.system.as_deref(), Some("custom analyst prompt"));
}
#[tokio::test]
async fn llm_reflector_user_message_carries_all_fields() {
let captured = Arc::new(Mutex::new(None));
let client = Arc::new(CannedMock {
response: fixture_analysis(),
captured: captured.clone(),
});
let reflector = LlmReflector::new(client);
let result = reflector
.analyze(
"the error text",
"the_tool",
&serde_json::json!({"k": "v"}),
None,
&ctx(),
)
.await;
assert!(result.is_ok(), "analyze should succeed: {:?}", result.err());
let cap = captured.lock().unwrap().clone().expect("captured");
let user = cap.user.expect("user message");
assert!(user.contains("the error text"), "user: {user}");
assert!(user.contains("the_tool"), "user: {user}");
assert!(user.contains("\"k\""), "user: {user}");
assert!(user.contains("fix the bug"), "user: {user}");
}
#[tokio::test]
async fn llm_reflector_user_message_includes_schema() {
let captured = Arc::new(Mutex::new(None));
let client = Arc::new(CannedMock {
response: fixture_analysis(),
captured: captured.clone(),
});
let reflector = LlmReflector::new(client);
let tool_schema = ToolSchema {
tool: "read".into(),
description: "Read a file".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}),
};
let result = reflector
.analyze(
"e",
"read",
&serde_json::json!({"path": "/wrong"}),
Some(&tool_schema),
&ctx(),
)
.await;
assert!(result.is_ok(), "analyze should succeed: {:?}", result.err());
let cap = captured.lock().unwrap().clone().expect("captured");
let user = cap.user.expect("user message");
assert!(
user.contains("Schema:"),
"expected the schema to appear in the user message: {user}"
);
assert!(
user.contains("\"required\""),
"expected schema content in the user message: {user}"
);
}
#[cfg(feature = "schema_validation")]
#[tokio::test]
async fn llm_reflector_validates_modified_input_pass() {
let schema = serde_json::json!({
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
"additionalProperties": false
});
let tool_schema = ToolSchema {
tool: "read".into(),
description: "Read a file".into(),
input_schema: schema,
};
let client = Arc::new(CannedMock {
response: fixture_analysis(),
captured: Arc::new(Mutex::new(None)),
});
let reflector = LlmReflector::new(client);
let result = reflector
.analyze(
"e",
"read",
&serde_json::json!({}),
Some(&tool_schema),
&ctx(),
)
.await;
assert!(
result.is_ok(),
"valid modified_input should pass: {:?}",
result.err()
);
}
#[cfg(feature = "schema_validation")]
#[tokio::test]
async fn llm_reflector_validates_modified_input_fail() {
let schema = serde_json::json!({
"type": "object",
"properties": {"n": {"type": "number"}},
"required": ["n"],
"additionalProperties": false
});
let tool_schema = ToolSchema {
tool: "calc".into(),
description: "Calculate".into(),
input_schema: schema,
};
let client = Arc::new(CannedMock {
response: fixture_analysis(),
captured: Arc::new(Mutex::new(None)),
});
let reflector = LlmReflector::new(client);
let err = reflector
.analyze(
"e",
"calc",
&serde_json::json!({}),
Some(&tool_schema),
&ctx(),
)
.await
.expect_err("invalid modified_input should fail");
assert!(
matches!(err, ReflectionError::Internal(ref m) if m.contains("does not match")),
"got: {err:?}"
);
}
#[cfg(feature = "schema_validation")]
#[tokio::test]
async fn llm_reflector_skips_validation_when_no_schema() {
let client = Arc::new(CannedMock {
response: fixture_analysis(),
captured: Arc::new(Mutex::new(None)),
});
let reflector = LlmReflector::new(client);
let result = reflector
.analyze("e", "t", &serde_json::json!({}), None, &ctx())
.await;
assert!(result.is_ok());
}
#[test]
#[cfg(feature = "schema_validation")]
fn validate_modified_input_noop_without_correction() {
let analysis = FailureAnalysis {
is_recoverable: false,
root_cause: "x".to_string(),
severity: FailureSeverity::Low,
correction: None,
context: String::new(),
};
assert!(validate_modified_input(&analysis, Some(&serde_json::json!({}))).is_ok());
}
#[test]
#[cfg(feature = "schema_validation")]
fn validate_modified_input_noop_without_modified_input() {
let analysis = FailureAnalysis {
is_recoverable: true,
root_cause: "x".to_string(),
severity: FailureSeverity::Low,
correction: Some(Correction {
correction_type: CorrectionType::ApproachChange,
description: "no modified input".to_string(),
modified_input: None,
alternative_tool: None,
guidance: None,
}),
context: String::new(),
};
assert!(validate_modified_input(&analysis, Some(&serde_json::json!({}))).is_ok());
}
#[test]
#[cfg(feature = "schema_validation")]
fn validate_modified_input_skips_when_no_schema() {
let analysis = FailureAnalysis {
is_recoverable: true,
root_cause: "x".to_string(),
severity: FailureSeverity::Low,
correction: Some(Correction {
correction_type: CorrectionType::InputFix,
description: "fix".to_string(),
modified_input: Some(serde_json::json!({"anything": true})),
alternative_tool: None,
guidance: None,
}),
context: String::new(),
};
assert!(validate_modified_input(&analysis, None).is_ok());
}
#[test]
#[cfg(feature = "schema_validation")]
fn validate_modified_input_rejects_mismatched_schema() {
let analysis = FailureAnalysis {
is_recoverable: true,
root_cause: "x".to_string(),
severity: FailureSeverity::Low,
correction: Some(Correction {
correction_type: CorrectionType::InputFix,
description: "fix".to_string(),
modified_input: Some(serde_json::json!({"wrong": "shape"})),
alternative_tool: None,
guidance: None,
}),
context: String::new(),
};
let schema = serde_json::json!({
"type": "object",
"properties": {"n": {"type": "number"}},
"required": ["n"],
"additionalProperties": false
});
let result = validate_modified_input(&analysis, Some(&schema));
assert!(
matches!(result, Err(ReflectionError::Internal(_))),
"with schema_validation the mismatch should fail: {result:?}"
);
}
#[test]
fn build_user_message_contains_all_fields() {
let msg = build_user_message(
"the error",
"the_tool",
&serde_json::json!({"k": "v"}),
None,
&ReflectionContext {
task: "the task".to_string(),
attempt: 1,
max_attempts: 4,
},
);
assert!(msg.contains("the_tool"), "tool name missing: {msg}");
assert!(msg.contains("the error"), "error missing: {msg}");
assert!(msg.contains("\"k\""), "input missing: {msg}");
assert!(msg.contains("the task"), "task missing: {msg}");
}
#[test]
fn build_user_message_attempt_is_one_indexed() {
let msg = build_user_message(
"e",
"t",
&serde_json::json!({}),
None,
&ReflectionContext {
task: "x".to_string(),
attempt: 0,
max_attempts: 3,
},
);
assert!(
msg.contains("Attempt: 1 of 3"),
"expected 1-indexed attempt in: {msg}"
);
}
#[test]
fn build_user_message_saturates_attempt_overflow() {
let msg = build_user_message(
"e",
"t",
&serde_json::json!({}),
None,
&ReflectionContext {
task: "x".to_string(),
attempt: u32::MAX,
max_attempts: u32::MAX,
},
);
assert!(msg.contains(&u32::MAX.to_string()));
}
#[test]
fn build_user_message_includes_schema_when_present() {
let schema = serde_json::json!({
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
});
let msg = build_user_message(
"e",
"read",
&serde_json::json!({"path": "/wrong"}),
Some(&schema),
&ReflectionContext {
task: "x".to_string(),
attempt: 0,
max_attempts: 3,
},
);
assert!(
msg.contains("Schema:"),
"expected a Schema: line when schema is supplied: {msg}"
);
assert!(
msg.contains("\"required\""),
"schema content missing from message: {msg}"
);
}
#[test]
fn build_user_message_omits_schema_line_when_none() {
let msg = build_user_message(
"e",
"t",
&serde_json::json!({}),
None,
&ReflectionContext {
task: "x".to_string(),
attempt: 0,
max_attempts: 3,
},
);
assert!(
!msg.contains("Schema:"),
"no Schema: line expected when schema is None: {msg}"
);
}
}