1use agentforge_core::{
2 AgentFile, DimensionScore, Result, Scenario, ScoringMethod, ToolCallStep, Trace, TraceStep,
3};
4use jsonschema::Validator;
5
6#[derive(Debug, Default)]
8pub struct DeterministicResult {
9 pub tool_selection: DimensionScore,
10 pub argument_correctness: DimensionScore,
11 pub schema_compliance: DimensionScore,
12 pub instruction_adherence: DimensionScore,
13 pub path_efficiency: DimensionScore,
14 pub failure_reasons: Vec<String>,
16}
17
18#[allow(clippy::field_reassign_with_default)]
20pub fn run_deterministic_checks(
21 trace: &Trace,
22 scenario: &Scenario,
23 agent: &AgentFile,
24) -> DeterministicResult {
25 let mut result = DeterministicResult::default();
26
27 result.tool_selection = check_tool_selection(trace, scenario);
29 if result.tool_selection.value < 1.0 {
30 result.failure_reasons.push(format!(
31 "Tool selection failed (score={:.2})",
32 result.tool_selection.value
33 ));
34 }
35
36 result.argument_correctness = check_argument_correctness(trace, scenario);
38 if result.argument_correctness.value < 1.0 {
39 result.failure_reasons.push(format!(
40 "Argument correctness failed (score={:.2})",
41 result.argument_correctness.value
42 ));
43 }
44
45 result.schema_compliance = check_schema_compliance(trace, scenario, agent);
47 if result.schema_compliance.value < 1.0 {
48 result.failure_reasons.push(format!(
49 "Schema compliance failed (score={:.2})",
50 result.schema_compliance.value
51 ));
52 }
53
54 result.instruction_adherence = check_constraint_keywords(trace, agent);
56 if result.instruction_adherence.value < 1.0 {
57 result.failure_reasons.push(format!(
58 "Instruction adherence failed (score={:.2})",
59 result.instruction_adherence.value
60 ));
61 }
62
63 result.path_efficiency = check_path_efficiency(trace, scenario);
65
66 result
67}
68
69fn check_tool_selection(trace: &Trace, scenario: &Scenario) -> DimensionScore {
71 let required_tools: Vec<&str> = scenario
72 .expected
73 .tool_calls
74 .iter()
75 .filter(|tc| tc.required)
76 .map(|tc| tc.tool_name.as_str())
77 .collect();
78
79 if required_tools.is_empty() {
80 return DimensionScore {
82 value: 1.0,
83 confidence: 1.0,
84 method: ScoringMethod::Deterministic,
85 rationale: Some("No required tools specified".to_string()),
86 };
87 }
88
89 let called_tools: Vec<&str> = trace
90 .steps
91 .iter()
92 .filter_map(|s| {
93 if let TraceStep::ToolCall(tc) = s {
94 Some(tc.tool_name.as_str())
95 } else {
96 None
97 }
98 })
99 .collect();
100
101 let matched = required_tools
102 .iter()
103 .filter(|&&rt| called_tools.contains(&rt))
104 .count();
105
106 let score = matched as f64 / required_tools.len() as f64;
107 let rationale = if score < 1.0 {
108 let missing: Vec<&str> = required_tools
109 .iter()
110 .filter(|&&rt| !called_tools.contains(&rt))
111 .copied()
112 .collect();
113 Some(format!("Missing required tools: {}", missing.join(", ")))
114 } else {
115 Some("All required tools called".to_string())
116 };
117
118 DimensionScore {
119 value: score,
120 confidence: 1.0,
121 method: ScoringMethod::Deterministic,
122 rationale,
123 }
124}
125
126fn check_argument_correctness(trace: &Trace, scenario: &Scenario) -> DimensionScore {
128 let tool_calls: Vec<&ToolCallStep> = trace
129 .steps
130 .iter()
131 .filter_map(|s| {
132 if let TraceStep::ToolCall(tc) = s {
133 Some(tc)
134 } else {
135 None
136 }
137 })
138 .collect();
139
140 if tool_calls.is_empty() {
141 let has_required = scenario.expected.tool_calls.iter().any(|tc| tc.required);
143 return DimensionScore {
144 value: if has_required { 0.0 } else { 1.0 },
145 confidence: 1.0,
146 method: ScoringMethod::Deterministic,
147 rationale: if has_required {
148 Some("Required tools were not called".to_string())
149 } else {
150 Some("No tools called and none required".to_string())
151 },
152 };
153 }
154
155 let mut total_checks = 0usize;
156 let mut passed_checks = 0usize;
157 let mut failure_details = Vec::new();
158
159 for tc_step in &tool_calls {
160 let expected = scenario
162 .expected
163 .tool_calls
164 .iter()
165 .find(|etc| etc.tool_name == tc_step.tool_name);
166
167 if let Some(exp) = expected {
168 if let Some(arg_schema) = &exp.argument_schema {
169 total_checks += 1;
170 match validate_against_schema(&tc_step.arguments, arg_schema) {
171 Ok(true) => {
172 passed_checks += 1;
173 }
174 Ok(false) => {
175 failure_details.push(format!(
176 "Tool '{}' argument schema validation failed",
177 tc_step.tool_name
178 ));
179 }
180 Err(e) => {
181 failure_details
182 .push(format!("Tool '{}' schema error: {}", tc_step.tool_name, e));
183 }
184 }
185 } else {
186 total_checks += 1;
188 passed_checks += 1;
189 }
190 }
191 }
192
193 let score = if total_checks == 0 {
194 1.0
195 } else {
196 passed_checks as f64 / total_checks as f64
197 };
198
199 DimensionScore {
200 value: score,
201 confidence: 1.0,
202 method: ScoringMethod::Deterministic,
203 rationale: if failure_details.is_empty() {
204 Some("All tool arguments valid".to_string())
205 } else {
206 Some(failure_details.join("; "))
207 },
208 }
209}
210
211fn validate_against_schema(value: &serde_json::Value, schema: &serde_json::Value) -> Result<bool> {
213 match Validator::new(schema) {
214 Ok(compiled) => Ok(compiled.is_valid(value)),
215 Err(e) => {
216 tracing::warn!(error = %e, "Invalid JSON schema in scenario");
218 Ok(true)
219 }
220 }
221}
222
223fn check_schema_compliance(
225 trace: &Trace,
226 scenario: &Scenario,
227 agent: &AgentFile,
228) -> DimensionScore {
229 let schema = scenario
231 .expected
232 .output_schema
233 .as_ref()
234 .or(agent.output_schema.as_ref());
235
236 let schema = match schema {
237 Some(s) => s,
238 None => {
239 return DimensionScore {
240 value: 1.0,
241 confidence: 0.5,
242 method: ScoringMethod::Deterministic,
243 rationale: Some("No output schema defined — skipping compliance check".to_string()),
244 }
245 }
246 };
247
248 let output = match &trace.final_output {
249 Some(o) => o,
250 None => {
251 return DimensionScore {
252 value: 0.0,
253 confidence: 1.0,
254 method: ScoringMethod::Deterministic,
255 rationale: Some("No final output captured".to_string()),
256 }
257 }
258 };
259
260 match validate_against_schema(output, schema) {
261 Ok(valid) => DimensionScore {
262 value: if valid { 1.0 } else { 0.0 },
263 confidence: 1.0,
264 method: ScoringMethod::Deterministic,
265 rationale: Some(if valid {
266 "Output matches schema".to_string()
267 } else {
268 "Output does not match schema".to_string()
269 }),
270 },
271 Err(e) => DimensionScore {
272 value: 0.5,
273 confidence: 0.3,
274 method: ScoringMethod::Deterministic,
275 rationale: Some(format!("Schema validation error: {e}")),
276 },
277 }
278}
279
280fn check_constraint_keywords(trace: &Trace, agent: &AgentFile) -> DimensionScore {
282 if agent.constraints.is_empty() {
283 return DimensionScore {
284 value: 1.0,
285 confidence: 0.5,
286 method: ScoringMethod::Deterministic,
287 rationale: Some("No constraints defined".to_string()),
288 };
289 }
290
291 let all_text = collect_assistant_text(trace);
293 if all_text.is_empty() {
294 return DimensionScore {
295 value: 1.0,
296 confidence: 0.5,
297 method: ScoringMethod::Deterministic,
298 rationale: Some("No assistant text to check".to_string()),
299 };
300 }
301
302 let all_text_lower = all_text.to_lowercase();
303
304 let mut violations = Vec::new();
306 for constraint in &agent.constraints {
307 let c_lower = constraint.to_lowercase();
308 if let Some(after_never) = c_lower.strip_prefix("never ") {
310 let forbidden_words: Vec<&str> = after_never.split_whitespace().take(3).collect();
313 for word in forbidden_words {
314 if word.len() > 4 && all_text_lower.contains(word) {
315 violations.push(format!("Potential constraint breach: '{}'", constraint));
318 break;
319 }
320 }
321 }
322 }
323
324 let score = if violations.is_empty() { 1.0 } else { 0.0 };
325 DimensionScore {
326 value: score,
327 confidence: if violations.is_empty() { 0.7 } else { 0.9 },
328 method: ScoringMethod::Deterministic,
329 rationale: if violations.is_empty() {
330 Some("No obvious constraint violations detected".to_string())
331 } else {
332 Some(violations.join("; "))
333 },
334 }
335}
336
337fn check_path_efficiency(trace: &Trace, scenario: &Scenario) -> DimensionScore {
339 let expected_min = scenario
340 .expected
341 .tool_calls
342 .iter()
343 .filter(|tc| tc.required)
344 .count();
345
346 let actual_calls = trace
347 .steps
348 .iter()
349 .filter(|s| matches!(s, TraceStep::ToolCall(_)))
350 .count();
351
352 if expected_min == 0 {
353 let llm_call_count = trace
355 .steps
356 .iter()
357 .filter(|s| matches!(s, TraceStep::LlmCall(_)))
358 .count();
359 let score = if llm_call_count <= 3 { 1.0 } else { 0.5 };
360 return DimensionScore {
361 value: score,
362 confidence: 0.8,
363 method: ScoringMethod::Deterministic,
364 rationale: Some(format!(
365 "{} LLM calls for a no-tool scenario",
366 llm_call_count
367 )),
368 };
369 }
370
371 let score = if actual_calls == 0 {
372 0.0
373 } else if actual_calls <= expected_min {
374 1.0
375 } else {
376 (expected_min as f64 / actual_calls as f64).min(1.0)
378 };
379
380 DimensionScore {
381 value: score,
382 confidence: 0.9,
383 method: ScoringMethod::Deterministic,
384 rationale: Some(format!(
385 "Expected ~{} tool calls, actual {} calls",
386 expected_min, actual_calls
387 )),
388 }
389}
390
391fn collect_assistant_text(trace: &Trace) -> String {
392 let mut texts = Vec::new();
393 for step in &trace.steps {
394 if let TraceStep::FinalOutput(fo) = step {
395 if let Some(resp) = fo.output.get("response").and_then(|r| r.as_str()) {
396 texts.push(resp.to_string());
397 }
398 }
399 }
400 texts.join(" ")
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use agentforge_core::{
407 DifficultyTier, ExpectedToolCall, FailureCluster, FinalOutputStep, ModelConfig,
408 ModelProvider, ScenarioExpected, ScenarioInput, ScenarioSource, ToolCallStep, TraceStatus,
409 };
410 use chrono::Utc;
411 use uuid::Uuid;
412
413 fn make_scenario(tool_name: &str, required: bool) -> Scenario {
414 Scenario {
415 id: Uuid::new_v4(),
416 agent_id: Uuid::new_v4(),
417 input: ScenarioInput {
418 user_message: "test".to_string(),
419 conversation_history: vec![],
420 context: None,
421 },
422 expected: ScenarioExpected {
423 tool_calls: vec![ExpectedToolCall {
424 tool_name: tool_name.to_string(),
425 required,
426 argument_schema: Some(serde_json::json!({
427 "type": "object",
428 "properties": {"id": {"type": "string"}},
429 "required": ["id"]
430 })),
431 }],
432 output_schema: Some(serde_json::json!({
433 "type": "object",
434 "properties": {"response": {"type": "string"}},
435 "required": ["response"]
436 })),
437 pass_criteria: "Agent should call the tool".to_string(),
438 min_turns: Some(1),
439 max_turns: Some(5),
440 },
441 difficulty: DifficultyTier::Easy,
442 domain: None,
443 source: ScenarioSource::SchemaDerived,
444 tags: vec![],
445 created_at: Utc::now(),
446 }
447 }
448
449 fn make_trace_with_tool_call(tool_name: &str, args: serde_json::Value) -> Trace {
450 let run_id = Uuid::new_v4();
451 let scenario_id = Uuid::new_v4();
452 Trace {
453 id: Uuid::new_v4(),
454 run_id,
455 scenario_id,
456 status: TraceStatus::Pass,
457 steps: vec![
458 TraceStep::ToolCall(ToolCallStep {
459 index: 0,
460 tool_name: tool_name.to_string(),
461 call_id: "call_1".to_string(),
462 arguments: args,
463 timestamp: Utc::now(),
464 }),
465 TraceStep::FinalOutput(FinalOutputStep {
466 index: 1,
467 output: serde_json::json!({"response": "Order status is 'shipped'."}),
468 timestamp: Utc::now(),
469 }),
470 ],
471 final_output: Some(serde_json::json!({"response": "Order status is 'shipped'."})),
472 scores: None,
473 aggregate_score: None,
474 failure_cluster: FailureCluster::NoFailure,
475 failure_reason: None,
476 review_needed: false,
477 llm_calls: 1,
478 tool_invocations: 1,
479 input_tokens: 50,
480 output_tokens: 20,
481 latency_ms: 500,
482 retry_count: 0,
483 seed: 0,
484 created_at: Utc::now(),
485 }
486 }
487
488 fn make_simple_agent() -> AgentFile {
489 AgentFile {
490 agentforge_schema_version: "1".to_string(),
491 name: "test".to_string(),
492 version: "1.0.0".to_string(),
493 model: ModelConfig {
494 provider: ModelProvider::Openai,
495 model_id: "gpt-4o".to_string(),
496 temperature: None,
497 max_tokens: None,
498 top_p: None,
499 },
500 system_prompt: "You are helpful.".to_string(),
501 tools: vec![],
502 output_schema: Some(serde_json::json!({
503 "type": "object",
504 "properties": {"response": {"type": "string"}},
505 "required": ["response"]
506 })),
507 constraints: vec!["Never share passwords.".to_string()],
508 eval_hints: None,
509 metadata: None,
510 }
511 }
512
513 #[test]
514 fn tool_selection_passes_when_required_tool_called() {
515 let scenario = make_scenario("get_order", true);
516 let trace = make_trace_with_tool_call("get_order", serde_json::json!({"id": "ORD-123"}));
517 let score = check_tool_selection(&trace, &scenario);
518 assert_eq!(score.value, 1.0);
519 }
520
521 #[test]
522 fn tool_selection_fails_when_required_tool_missing() {
523 let scenario = make_scenario("get_order", true);
524 let trace = make_trace_with_tool_call("wrong_tool", serde_json::json!({}));
526 let score = check_tool_selection(&trace, &scenario);
527 assert_eq!(score.value, 0.0);
528 }
529
530 #[test]
531 fn schema_compliance_passes_for_valid_output() {
532 let scenario = make_scenario("get_order", false);
533 let trace = make_trace_with_tool_call("get_order", serde_json::json!({"id": "ORD-123"}));
534 let agent = make_simple_agent();
535 let score = check_schema_compliance(&trace, &scenario, &agent);
536 assert_eq!(score.value, 1.0);
537 }
538
539 #[test]
540 fn schema_compliance_fails_for_missing_required_field() {
541 let scenario = make_scenario("get_order", false);
542 let mut trace =
543 make_trace_with_tool_call("get_order", serde_json::json!({"id": "ORD-123"}));
544 trace.final_output = Some(serde_json::json!({"action_taken": "resolved"})); let agent = make_simple_agent();
547 let score = check_schema_compliance(&trace, &scenario, &agent);
548 assert_eq!(score.value, 0.0);
549 }
550
551 #[test]
552 fn constraint_check_passes_when_no_violations() {
553 let trace = make_trace_with_tool_call("get_order", serde_json::json!({}));
554 let agent = make_simple_agent();
555 let score = check_constraint_keywords(&trace, &agent);
556 assert_eq!(score.value, 1.0);
558 }
559
560 #[test]
561 fn argument_correctness_passes_for_valid_args() {
562 let scenario = make_scenario("get_order", true);
563 let trace = make_trace_with_tool_call("get_order", serde_json::json!({"id": "ORD-123"}));
564 let score = check_argument_correctness(&trace, &scenario);
565 assert_eq!(score.value, 1.0);
566 }
567}