use std::sync::Arc;
use lingshu_types::Message;
use edgequake_llm::LLMProvider;
use crate::config::ShadowJudgeConfig;
use crate::conversation::build_chat_messages;
const SHADOW_JUDGE_SYSTEM_PROMPT: &str = "\
You are a task-completion oracle. Your ONLY output is a JSON object — no prose outside the JSON.
Output schema:
{\"verdict\":\"complete\"|\"incomplete\",\"confidence\":0.0-1.0,\"reason\":\"<one sentence>\",\"steering_hint\":\"<specific next action if incomplete, otherwise null>\"}
Strict rules:
- \"complete\" means EVERY part of the user's original request is DONE with concrete evidence in the conversation.
- If the agent announced a future action but has not yet executed it, output \"incomplete\".
- If any explicitly requested sub-task is missing evidence of completion, output \"incomplete\".
- When uncertain, prefer \"incomplete\".
- Output ONLY the JSON object. No markdown fences. No commentary.";
const SHADOW_JUDGE_QUERY: &str = "\
[shadow-judge query]
Review the entire conversation above. Has the agent's most recent response fully \
completed the original user request? Check every sub-goal explicitly. If any sub-goal \
was promised or implied but not yet evidenced with tool output or concrete content, \
output \"incomplete\". Output the JSON verdict now.";
#[derive(Debug, Clone)]
pub struct ShadowVerdict {
pub is_complete: bool,
pub confidence: f32,
pub reason: String,
pub steering_hint: Option<String>,
pub input_tokens: u32,
pub output_tokens: u32,
}
pub async fn run_shadow_judge(
provider: &Arc<dyn LLMProvider>,
model: &str,
messages: &[Message],
config: &ShadowJudgeConfig,
) -> Option<ShadowVerdict> {
if messages.len() < config.min_messages_before_enable {
tracing::debug!(
msg_count = messages.len(),
min = config.min_messages_before_enable,
"shadow judge: skipping — session too short"
);
return None;
}
let context_slice = if config.context_messages > 0 && messages.len() > config.context_messages {
&messages[messages.len() - config.context_messages..]
} else {
messages
};
let mut chat_messages =
build_chat_messages(Some(SHADOW_JUDGE_SYSTEM_PROMPT), context_slice, None, false);
chat_messages.push(edgequake_llm::ChatMessage::user(SHADOW_JUDGE_QUERY));
let response = match provider
.chat_with_tools(&chat_messages, &[], None, None)
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(
error = %e,
model = model,
"shadow judge: API call failed (non-fatal, falling through to loop break)"
);
return None;
}
};
let raw_text = response.content.trim().to_string();
let input_tokens = response.prompt_tokens as u32;
let output_tokens = response.completion_tokens as u32;
tracing::debug!(
raw = %raw_text,
input_tokens,
output_tokens,
"shadow judge: raw response"
);
parse_shadow_verdict(&raw_text, input_tokens, output_tokens)
}
pub fn resolve_shadow_provider_and_model(
shadow_cfg: &ShadowJudgeConfig,
auxiliary_model: Option<&str>,
main_provider: Arc<dyn LLMProvider>,
main_model: &str,
) -> (Arc<dyn LLMProvider>, String) {
crate::auxiliary_model::resolve_side_task_provider_and_model(
shadow_cfg.model.as_deref(),
auxiliary_model,
main_provider,
main_model,
"shadow judge",
)
}
fn parse_shadow_verdict(
text: &str,
input_tokens: u32,
output_tokens: u32,
) -> Option<ShadowVerdict> {
let stripped = text
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let start = stripped.find('{')?;
let end = stripped.rfind('}').map(|i| i + 1)?;
let json_slice = &stripped[start..end];
let v: serde_json::Value = serde_json::from_str(json_slice).ok()?;
let verdict_str = v["verdict"].as_str()?;
let is_complete = verdict_str == "complete";
let confidence = v["confidence"].as_f64().unwrap_or(0.5) as f32;
let reason = v["reason"]
.as_str()
.unwrap_or("no reason provided")
.to_string();
let steering_hint = v["steering_hint"]
.as_str()
.filter(|s| !s.is_empty() && *s != "null")
.map(str::to_string);
Some(ShadowVerdict {
is_complete,
confidence,
reason,
steering_hint,
input_tokens,
output_tokens,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_complete_verdict_ok() {
let json = r#"{"verdict":"complete","confidence":0.95,"reason":"All files created.","steering_hint":null}"#;
let v =
parse_shadow_verdict(json, 100, 20).expect("expected valid complete shadow verdict");
assert!(v.is_complete);
assert!((v.confidence - 0.95).abs() < 0.01);
assert_eq!(v.reason, "All files created.");
assert!(v.steering_hint.is_none());
assert_eq!(v.input_tokens, 100);
assert_eq!(v.output_tokens, 20);
}
#[test]
fn parse_incomplete_verdict_with_hint() {
let json = r#"{"verdict":"incomplete","confidence":0.88,"reason":"CSS file missing.","steering_hint":"Create style.css with the game styles."}"#;
let v = parse_shadow_verdict(json, 200, 30)
.expect("expected valid incomplete shadow verdict with hint");
assert!(!v.is_complete);
assert!((v.confidence - 0.88).abs() < 0.01);
assert!(v.steering_hint.is_some());
assert!(
v.steering_hint
.as_deref()
.is_some_and(|hint| hint.contains("style.css"))
);
}
#[test]
fn parse_strips_markdown_fences() {
let json = "```json\n{\"verdict\":\"complete\",\"confidence\":0.9,\"reason\":\"done\",\"steering_hint\":null}\n```";
let v = parse_shadow_verdict(json, 10, 5).expect("expected fenced JSON shadow verdict");
assert!(v.is_complete);
}
#[test]
fn parse_json_fence_no_lang_tag() {
let json = "```\n{\"verdict\":\"incomplete\",\"confidence\":0.7,\"reason\":\"not done\",\"steering_hint\":\"keep going\"}\n```";
let v = parse_shadow_verdict(json, 0, 0)
.expect("expected fenced JSON shadow verdict without language tag");
assert!(!v.is_complete);
}
#[test]
fn parse_invalid_json_returns_none() {
assert!(parse_shadow_verdict("This is not JSON at all.", 0, 0).is_none());
}
#[test]
fn parse_missing_verdict_field_returns_none() {
let json = r#"{"confidence":0.9,"reason":"done","steering_hint":null}"#;
assert!(parse_shadow_verdict(json, 0, 0).is_none());
}
#[test]
fn parse_json_with_leading_prose() {
let json = r#"Here is my verdict: {"verdict":"incomplete","confidence":0.75,"reason":"JS missing.","steering_hint":"Write game.js."}"#;
let v = parse_shadow_verdict(json, 0, 0)
.expect("expected parser to recover JSON shadow verdict after prose prefix");
assert!(!v.is_complete);
assert!(v.steering_hint.is_some());
}
#[test]
fn parse_null_string_steering_hint_becomes_none() {
let json = r#"{"verdict":"complete","confidence":0.99,"reason":"All done.","steering_hint":"null"}"#;
let v = parse_shadow_verdict(json, 0, 0)
.expect("expected valid shadow verdict with string null steering hint");
assert!(v.steering_hint.is_none());
}
#[test]
fn parse_empty_string_steering_hint_becomes_none() {
let json =
r#"{"verdict":"complete","confidence":0.99,"reason":"All done.","steering_hint":""}"#;
let v = parse_shadow_verdict(json, 0, 0)
.expect("expected valid shadow verdict with empty steering hint");
assert!(v.steering_hint.is_none());
}
#[test]
fn default_shadow_judge_config_is_disabled() {
let cfg = ShadowJudgeConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.max_per_session, 5);
assert!((cfg.confidence_threshold - 0.70).abs() < 0.001);
assert_eq!(cfg.context_messages, 20);
assert_eq!(cfg.min_messages_before_enable, 4);
}
#[test]
fn resolve_no_override_returns_main_model() {
use edgequake_llm::MockProvider;
use std::sync::Arc;
let cfg = ShadowJudgeConfig::default();
assert!(cfg.model.is_none());
let mock: Arc<dyn edgequake_llm::LLMProvider> = Arc::new(MockProvider::new());
let (returned_provider, returned_model) = resolve_shadow_provider_and_model(
&cfg,
None,
mock.clone(),
"anthropic/claude-sonnet-4",
);
assert_eq!(returned_model, "anthropic/claude-sonnet-4");
assert!(Arc::ptr_eq(&returned_provider, &mock));
}
#[test]
fn resolve_auxiliary_model_overrides_when_no_shadow_model() {
use edgequake_llm::MockProvider;
use std::sync::Arc;
let cfg = ShadowJudgeConfig::default(); let mock: Arc<dyn edgequake_llm::LLMProvider> = Arc::new(MockProvider::new());
let (returned_provider, returned_model) = resolve_shadow_provider_and_model(
&cfg,
Some("bare-model-name"),
mock.clone(),
"main/model",
);
assert_eq!(returned_model, "bare-model-name");
assert!(Arc::ptr_eq(&returned_provider, &mock));
}
}