Skip to main content

deepstrike_core/harness/
eval.rs

1//! Evaluation primitives — the agent's "quality gate" compute.
2//!
3//! Pure computation in kernel, I/O in SDK. This module provides the **stateless** building blocks
4//! for the generate → evaluate → retry quality gate:
5//!
6//! - [`build_eval_messages`] assembles the impartial-evaluator prompt from a goal + criteria + the
7//!   agent's output (the SDK then calls the eval LLM with it).
8//! - [`parse_verdict`] parses the LLM's JSON response into a structured [`EvalResult`].
9//! - [`verdict_output_schema`] is the JSON Schema for that verdict, used as the `output_schema` of
10//!   the eval node in the [`crate::orchestration::workflow::gen_eval`] workflow template.
11//!
12//! **History (0.5.0 fold, OS-axis #6).** This replaces the former `EvalPipeline` state machine +
13//! its public SDK class. The quality gate is now expressed on the workflow substrate: the iterative
14//! retry-with-feedback loop is driven by the SDK `AttemptLoop` (the kernel `NodeKind::Loop` re-arms
15//! a single node, so per-iteration eval cannot be a static DAG), and the declarative
16//! "loop-the-worker-then-verify-with-a-structured-verdict" shape is the `gen_eval` template. Both
17//! reuse these primitives, so the verdict shape stays consistent across the two paths.
18
19use crate::types::message::{Content, CoreMessage, Role};
20
21// ---------------------------------------------------------------------------
22// Input types
23// ---------------------------------------------------------------------------
24
25/// A single evaluation criterion with optional weight and required flag.
26#[derive(Debug, Clone)]
27pub struct Criterion {
28    pub text: String,
29    /// If true, failing this criterion fails the entire evaluation.
30    pub required: bool,
31    /// Relative weight for scoring (default 1.0).
32    pub weight: f32,
33}
34
35impl Criterion {
36    pub fn required(text: impl Into<String>) -> Self {
37        Self {
38            text: text.into(),
39            required: true,
40            weight: 1.0,
41        }
42    }
43
44    pub fn optional(text: impl Into<String>) -> Self {
45        Self {
46            text: text.into(),
47            required: false,
48            weight: 1.0,
49        }
50    }
51
52    pub fn with_weight(mut self, w: f32) -> Self {
53        self.weight = w;
54        self
55    }
56}
57
58impl From<String> for Criterion {
59    fn from(s: String) -> Self {
60        Self::required(s)
61    }
62}
63
64impl From<&str> for Criterion {
65    fn from(s: &str) -> Self {
66        Self::required(s)
67    }
68}
69
70// ---------------------------------------------------------------------------
71// Output types
72// ---------------------------------------------------------------------------
73
74/// Per-criterion evaluation result.
75#[derive(Debug, Clone)]
76pub struct CriterionResult {
77    pub criterion: String,
78    pub passed: bool,
79    /// 0.0–1.0 partial credit score.
80    pub score: f32,
81    pub feedback: String,
82}
83
84/// A skill distilled from a successful run — SDK writes this to `skill_dir`.
85#[derive(Debug, Clone)]
86pub struct SkillCandidate {
87    pub name: String,
88    pub description: String,
89    pub when_to_use: Option<String>,
90    /// Markdown body only (no frontmatter) — SDK assembles the full file.
91    pub content: String,
92}
93
94/// The structured verdict produced by parsing the eval LLM's JSON response.
95#[derive(Debug, Clone)]
96pub struct EvalResult {
97    pub passed: bool,
98    /// Weighted aggregate score across all criteria (0.0–1.0).
99    pub overall_score: f32,
100    /// Human-readable summary injected into the next attempt's goal.
101    pub feedback: String,
102    /// Per-criterion breakdown.
103    pub details: Vec<CriterionResult>,
104    pub skill_candidate: Option<SkillCandidate>,
105}
106
107// ---------------------------------------------------------------------------
108// Prompt builder
109// ---------------------------------------------------------------------------
110
111/// Build the impartial-evaluator messages for one attempt: a system instruction describing the
112/// scoring contract + a user message carrying the goal, criteria, and the agent's output. The SDK
113/// calls the eval LLM with these, then feeds the response to [`parse_verdict`].
114pub fn build_eval_messages(
115    goal: &str,
116    criteria: &[Criterion],
117    result: &str,
118    attempt: u32,
119    extract_skill_on_pass: bool,
120) -> Vec<CoreMessage> {
121    let criteria_text = if criteria.is_empty() {
122        "No explicit criteria — use general quality judgement.".to_string()
123    } else {
124        criteria
125            .iter()
126            .enumerate()
127            .map(|(i, c)| {
128                let tag = if c.required {
129                    "[required]"
130                } else {
131                    "[optional]"
132                };
133                let weight = if (c.weight - 1.0).abs() > 0.01 {
134                    format!(" weight={:.1}", c.weight)
135                } else {
136                    String::new()
137                };
138                format!("{}. {}{}{}", i + 1, tag, weight, c.text)
139            })
140            .collect::<Vec<_>>()
141            .join("\n")
142    };
143
144    let details_schema = r#"[{"criterion":"...","passed":bool,"score":0.0-1.0,"feedback":"..."}]"#;
145
146    let skill_instruction = if extract_skill_on_pass {
147        "\nIf passed=true and the approach is reusable, add a \"skill\" field:\
148\n{\"name\":\"snake_case\",\"description\":\"one sentence\",\"when_to_use\":\"optional hint\",\"content\":\"markdown body (no frontmatter)\"}"
149    } else {
150        ""
151    };
152
153    let system = CoreMessage {
154        role: Role::System,
155        content: Content::Text(format!(
156            "You are an impartial evaluator. Assess whether the agent's output meets the goal and criteria.\n\
157             [required] criteria must ALL pass for overall passed=true.\n\
158             [optional] criteria contribute to overall_score but do not block passing.\n\
159             Respond with JSON only:\n\
160             {{\"passed\":bool,\"overall_score\":0.0-1.0,\"feedback\":\"concise summary\",\
161             \"details\":{details_schema}{skill_instruction}}}"
162        )),
163        tool_calls: vec![],
164    };
165
166    let user = CoreMessage {
167        role: Role::User,
168        content: Content::Text(format!(
169            "## Goal\n{goal}\n\n## Criteria\n{criteria_text}\n\n## Agent Output (attempt {attempt})\n{result}"
170        )),
171        tool_calls: vec![],
172    };
173
174    vec![system, user]
175}
176
177// ---------------------------------------------------------------------------
178// Verdict output schema (for the gen_eval workflow template's eval node)
179// ---------------------------------------------------------------------------
180
181/// JSON Schema for the verdict an eval node must produce. Used as the `output_schema` of the eval
182/// node in the [`crate::orchestration::workflow::gen_eval`] template so the SDK can instruct +
183/// validate the verdict. Matches what [`parse_verdict`] reads.
184pub fn verdict_output_schema(extract_skill_on_pass: bool) -> serde_json::Value {
185    let mut properties = serde_json::json!({
186        "passed": { "type": "boolean", "description": "true iff all [required] criteria pass" },
187        "overall_score": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
188        "feedback": { "type": "string", "description": "concise summary; on fail, what to fix next attempt" },
189        "details": {
190            "type": "array",
191            "items": {
192                "type": "object",
193                "required": ["criterion", "passed", "score", "feedback"],
194                "properties": {
195                    "criterion": { "type": "string" },
196                    "passed": { "type": "boolean" },
197                    "score": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
198                    "feedback": { "type": "string" }
199                }
200            }
201        }
202    });
203    if extract_skill_on_pass {
204        properties["skill"] = serde_json::json!({
205            "type": "object",
206            "description": "optional reusable skill distilled from a passing run",
207            "required": ["name", "description", "content"],
208            "properties": {
209                "name": { "type": "string", "description": "snake_case" },
210                "description": { "type": "string" },
211                "when_to_use": { "type": "string" },
212                "content": { "type": "string", "description": "markdown body, no frontmatter" }
213            }
214        });
215    }
216    serde_json::json!({
217        "type": "object",
218        "required": ["passed", "overall_score", "feedback"],
219        "properties": properties
220    })
221}
222
223// ---------------------------------------------------------------------------
224// Response parser
225// ---------------------------------------------------------------------------
226
227/// Parse an eval LLM's JSON response into a structured [`EvalResult`]. Tolerant of markdown fences
228/// and missing fields (defaults: `passed=false`, score derived from `passed`).
229pub fn parse_verdict(content: &str) -> EvalResult {
230    let json_str = extract_json(content);
231    let v: serde_json::Value = serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
232
233    let passed = v.get("passed").and_then(|x| x.as_bool()).unwrap_or(false);
234    let overall_score = v
235        .get("overall_score")
236        .and_then(|x| x.as_f64())
237        .map(|f| f as f32)
238        .unwrap_or(if passed { 1.0 } else { 0.0 });
239    let feedback = v
240        .get("feedback")
241        .and_then(|x| x.as_str())
242        .unwrap_or("No feedback provided.")
243        .to_string();
244
245    let details = v
246        .get("details")
247        .and_then(|d| d.as_array())
248        .map(|arr| {
249            arr.iter()
250                .filter_map(|item| {
251                    let criterion = item.get("criterion")?.as_str()?.to_string();
252                    let item_passed = item
253                        .get("passed")
254                        .and_then(|x| x.as_bool())
255                        .unwrap_or(false);
256                    let score = item
257                        .get("score")
258                        .and_then(|x| x.as_f64())
259                        .map(|f| f as f32)
260                        .unwrap_or(if item_passed { 1.0 } else { 0.0 });
261                    let item_feedback = item
262                        .get("feedback")
263                        .and_then(|x| x.as_str())
264                        .unwrap_or("")
265                        .to_string();
266                    Some(CriterionResult {
267                        criterion,
268                        passed: item_passed,
269                        score,
270                        feedback: item_feedback,
271                    })
272                })
273                .collect()
274        })
275        .unwrap_or_default();
276
277    let skill_candidate = v.get("skill").and_then(|s| {
278        let name = s.get("name")?.as_str()?.to_string();
279        let description = s.get("description")?.as_str()?.to_string();
280        let content = s.get("content")?.as_str()?.to_string();
281        if name.is_empty() {
282            return None;
283        }
284        let when_to_use = s
285            .get("when_to_use")
286            .and_then(|x| x.as_str())
287            .filter(|x| !x.is_empty())
288            .map(|x| x.to_string());
289        Some(SkillCandidate {
290            name,
291            description,
292            when_to_use,
293            content,
294        })
295    });
296
297    EvalResult {
298        passed,
299        overall_score,
300        feedback,
301        details,
302        skill_candidate,
303    }
304}
305
306fn extract_json(s: &str) -> &str {
307    // Strip ```json ... ``` fences if present.
308    if let Some(start) = s.find('{') {
309        if let Some(end) = s.rfind('}') {
310            if start <= end {
311                return &s[start..=end];
312            }
313        }
314    }
315    s
316}
317
318// ---------------------------------------------------------------------------
319// Tests
320// ---------------------------------------------------------------------------
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn build_eval_messages_carries_goal_and_criteria() {
328        let msgs = build_eval_messages(
329            "Write a function",
330            &[Criterion::required("Must handle errors")],
331            "fn foo() {}",
332            1,
333            true,
334        );
335        assert_eq!(msgs.len(), 2);
336        assert!(matches!(msgs[0].role, Role::System));
337        let Content::Text(user) = &msgs[1].content else {
338            panic!("expected text")
339        };
340        assert!(user.contains("Write a function"));
341        assert!(user.contains("[required]Must handle errors"));
342        assert!(user.contains("attempt 1"));
343        // skill instruction present when extract_skill_on_pass=true
344        let Content::Text(system) = &msgs[0].content else {
345            panic!("expected text")
346        };
347        assert!(system.contains("\"skill\""));
348    }
349
350    #[test]
351    fn build_eval_messages_omits_skill_instruction_when_disabled() {
352        let msgs = build_eval_messages("g", &[], "r", 1, false);
353        let Content::Text(system) = &msgs[0].content else {
354            panic!("expected text")
355        };
356        assert!(!system.contains("\"name\":\"snake_case\""));
357    }
358
359    #[test]
360    fn parse_verdict_survives_close_brace_before_open_brace() {
361        // Truncated/garbled evaluator output where `}` precedes `{`: the doc contract is
362        // "tolerant of malformed model output", so this must fall back, not panic.
363        let result = parse_verdict("score 8/10}\n\nverdict: {");
364        assert!(!result.passed);
365        assert_eq!(result.overall_score, 0.0);
366        assert!(result.details.is_empty());
367    }
368
369    #[test]
370    fn parse_verdict_failed_no_skill() {
371        let result = parse_verdict(
372            r#"{"passed":false,"overall_score":0.2,"feedback":"Missing error handling","details":[{"criterion":"Must handle errors","passed":false,"score":0.2,"feedback":"No error handling found"}]}"#,
373        );
374        assert!(!result.passed);
375        assert_eq!(result.feedback, "Missing error handling");
376        assert_eq!(result.details.len(), 1);
377        assert!(!result.details[0].passed);
378        assert!(result.skill_candidate.is_none());
379    }
380
381    #[test]
382    fn parse_verdict_passed_with_skill_and_details() {
383        let json = r#"{"passed":true,"overall_score":0.95,"feedback":"All criteria met","details":[{"criterion":"Must handle errors","passed":true,"score":1.0,"feedback":"Good error handling"}],"skill":{"name":"robust_api_call","description":"How to call APIs with retries","content":"Robust API Call - Always retry on 5xx."}}"#;
384        let result = parse_verdict(json);
385        assert!(result.passed);
386        assert!(result.overall_score > 0.9);
387        assert_eq!(result.details.len(), 1);
388        assert!(result.details[0].passed);
389        let skill = result.skill_candidate.unwrap();
390        assert_eq!(skill.name, "robust_api_call");
391        assert!(skill.content.contains("retry"));
392    }
393
394    #[test]
395    fn parse_verdict_strips_markdown_fences() {
396        let result = parse_verdict("```json\n{\"passed\":true,\"feedback\":\"good\"}\n```");
397        assert!(result.passed);
398    }
399
400    #[test]
401    fn criterion_from_string_is_required() {
402        let c = Criterion::from("some check");
403        assert!(c.required);
404        assert!((c.weight - 1.0).abs() < 0.001);
405    }
406
407    #[test]
408    fn optional_criterion_with_weight() {
409        let c = Criterion::optional("bonus check").with_weight(0.5);
410        assert!(!c.required);
411        assert!((c.weight - 0.5).abs() < 0.001);
412    }
413
414    #[test]
415    fn verdict_output_schema_shape() {
416        let schema = verdict_output_schema(true);
417        assert_eq!(schema["type"], "object");
418        assert!(schema["properties"]["passed"].is_object());
419        assert!(schema["properties"]["overall_score"].is_object());
420        assert!(schema["properties"]["details"].is_object());
421        assert!(schema["properties"]["skill"].is_object());
422        // skill property dropped when extraction is disabled
423        let no_skill = verdict_output_schema(false);
424        assert!(no_skill["properties"]["skill"].is_null());
425    }
426}