use super::model::TraceAssertion;
use super::EpisodeGraph;
use crate::errors::diagnostic::Diagnostic;
pub fn evaluate(
graph: &EpisodeGraph,
assertions: &[TraceAssertion],
) -> anyhow::Result<Vec<Diagnostic>> {
let mut out = vec![];
for a in assertions {
if let Some(d) = check_one(graph, a) {
out.push(d);
}
}
Ok(out)
}
pub fn ineffective_reason(a: &TraceAssertion) -> Option<Diagnostic> {
let no_trace = EpisodeGraph {
episode_id: "static_config_sweep".into(),
steps: vec![],
tool_calls: vec![],
};
check_one(&no_trace, a)
.filter(|d| d.code == "E_ASSERT_INEFFECTIVE" || d.code == "E_CONFIG_ERROR")
}
fn check_one(graph: &EpisodeGraph, a: &TraceAssertion) -> Option<Diagnostic> {
match a {
TraceAssertion::TraceMustCallTool { tool, min_calls } => {
if let Some(d) = names_no_tool(tool, "trace_must_call_tool", EMPTY_TOOL_NEVER_HOLDS) {
return Some(d);
}
let actual = graph
.tool_calls
.iter()
.filter(|t| t.tool_name.as_deref() == Some(tool.as_str()))
.count();
let min = min_calls.unwrap_or(1);
if min == 0 {
return Some(ineffective(
"trace_must_call_tool",
"min_calls",
"`min_calls: 0` is satisfied by every trace, including one where the tool is \
never called.",
"Use `min_calls: 1` or higher, or express \"never called\" with \
`trace_must_not_call_tool`.",
));
}
if (actual as u32) < min {
return Some(make_diag(
"E_TRACE_ASSERT_FAIL",
&format!(
"Expected tool '{}' to be called at least {} times, but got {}.",
tool, min, actual
),
Some(format!("Must call tool: {}", tool)),
None,
));
}
}
TraceAssertion::TraceMustNotCallTool { tool } => {
if let Some(d) = names_no_tool(tool, "trace_must_not_call_tool", EMPTY_TOOL_NEVER_FAILS)
{
return Some(d);
}
if let Some(call) = graph
.tool_calls
.iter()
.find(|t| t.tool_name.as_deref() == Some(tool.as_str()))
{
return Some(make_diag(
"E_TRACE_ASSERT_FAIL",
&format!(
"Expected tool '{}' NOT to be called, but it was called.",
tool
),
Some(format!("Must not call tool: {}", tool)),
Some(serde_json::json!({
"failing_step_id": call.step_id,
"failing_tool": tool,
"failing_call_index": call.call_index
})),
));
}
}
TraceAssertion::TraceToolSequence {
sequence,
allow_other_tools,
} => {
if *allow_other_tools {
if sequence.is_empty() {
return Some(ineffective(
"trace_tool_sequence",
"sequence",
"an empty `sequence` with `allow_other_tools: true` is contained in every \
trace, so the assertion cannot fail.",
"Name the tools the trace must contain, or set \
`allow_other_tools: false` to assert that no tool was called.",
));
}
if let Err(msg) = check_subsequence(&graph.tool_calls, sequence) {
return Some(make_diag(
"E_TRACE_ASSERT_FAIL",
&msg,
Some(format!("Tool sequence (subsequence): {:?}", sequence)),
None,
));
}
} else {
let actual_seq: Vec<String> = graph
.tool_calls
.iter()
.filter_map(|t| t.tool_name.clone())
.collect();
if actual_seq != *sequence {
return Some(make_diag(
"E_TRACE_ASSERT_FAIL",
&format!(
"Expected exact tool sequence {:?}, got {:?}.",
sequence, actual_seq
),
Some(format!("Tool sequence (exact): {:?}", sequence)),
None,
));
}
}
}
TraceAssertion::TraceMaxSteps { max } => {
if *max == u32::MAX {
return Some(ineffective(
"trace_max_steps",
"max",
"`max` is the largest representable bound, so no trace can exceed it and the \
assertion cannot fail.",
"Set `max` to the step budget the agent is actually expected to stay within.",
));
}
let count = graph.steps.len();
if count > *max as usize {
return Some(make_diag(
"E_TRACE_ASSERT_FAIL",
&format!("Expected at most {} steps, got {}.", max, count),
Some(format!("Max steps: {}", max)),
None,
));
}
}
TraceAssertion::ArgsValid {
tool,
test_args,
policy,
expect,
} => {
let Some(args) = test_args else {
return Some(ineffective(
"args_valid",
"test_args",
"`args_valid` is only evaluated against the arguments supplied in `test_args`; \
with that field absent the assertion checks nothing.",
"Give the assertion `test_args`, or drop it and constrain the trace with \
`trace_must_call_tool` or `trace_tool_sequence`.",
));
};
{
let Some(pol) = policy else {
return Some(make_diag(
"E_CONFIG_ERROR",
"ArgsValid assertion requires 'policy' field (schema) when used in unit test mode.",
None,
None
));
};
let schema = pol.get("schema").unwrap_or(pol);
let policy_map = serde_json::json!({ tool: schema });
let verdict = crate::policy_engine::evaluate_tool_args(&policy_map, tool, args);
let expected_pass = match expected_pass(expect, "args_valid") {
Ok(v) => v,
Err(d) => return Some(*d),
};
let actual_pass = verdict.status == crate::policy_engine::VerdictStatus::Allowed;
if expected_pass != actual_pass {
return Some(make_diag(
"E_POLICY_ASSERT_FAIL",
&format!(
"ArgsValid check failed. Expected {}, got {}. Reason: {:?}",
if expected_pass { "PASS" } else { "FAIL" },
if actual_pass { "PASS" } else { "FAIL" },
verdict.details
),
None,
Some(serde_json::json!({
"tool": tool,
"args": args,
"verdict": verdict
})),
));
}
}
}
TraceAssertion::SequenceValid {
test_trace,
test_trace_raw,
policy,
expect,
} => {
if test_trace.is_some() && test_trace_raw.is_none() {
return Some(ineffective(
"sequence_valid",
"test_trace",
"`test_trace` is not evaluated; only `test_trace_raw` is read, so this \
assertion checks nothing.",
"Move the steps to `test_trace_raw` as a list of `{ tool: <name> }` entries.",
));
}
let Some(trace_vals) = test_trace_raw else {
return Some(ineffective(
"sequence_valid",
"test_trace_raw",
"`sequence_valid` is only evaluated against the steps supplied in \
`test_trace_raw`; with that field absent the assertion checks nothing.",
"Give the assertion `test_trace_raw`, or constrain the recorded trace with \
`trace_tool_sequence` instead.",
));
};
{
let Some(pol) = policy else {
return Some(ineffective(
"sequence_valid",
"policy",
"`sequence_valid` has no policy to evaluate the steps against, so the \
assertion checks nothing.",
"Give the assertion a `policy` carrying a `regex` field.",
));
};
{
let tools: Vec<String> = trace_vals
.iter()
.filter_map(|v| {
v.get("tool")
.or(v.get("tool_name"))
.and_then(|s| s.as_str())
.map(|s| s.to_string())
})
.collect();
if tools.len() != trace_vals.len() {
return Some(ineffective(
"sequence_valid",
"test_trace_raw",
"an entry in `test_trace_raw` names no tool under `tool` or \
`tool_name`, so it is dropped and the sequence checked is shorter \
than the one written.",
"Give every entry a `tool` key naming one tool.",
));
}
let regex = pol
.get("regex")
.and_then(|s| s.as_str())
.filter(|r| !r.is_empty());
let Some(regex) = regex else {
return Some(ineffective(
"sequence_valid",
"policy.regex",
"the policy carries no usable `regex`, so there is no constraint to \
evaluate the steps against — an absent, non-string, or empty \
pattern matches every possible trace.",
"Add a `regex` field to the policy describing the permitted tool \
sequence.",
));
};
let verdict = crate::policy_engine::evaluate_sequence(regex, &tools);
let expected_pass = match expected_pass(expect, "sequence_valid") {
Ok(v) => v,
Err(d) => return Some(*d),
};
let actual_pass =
verdict.status == crate::policy_engine::VerdictStatus::Allowed;
if expected_pass != actual_pass {
return Some(make_diag(
"E_POLICY_ASSERT_FAIL",
&format!(
"SequenceValid check failed. Expected {}, got {}.",
if expected_pass { "PASS" } else { "FAIL" },
if actual_pass { "PASS" } else { "FAIL" }
),
None,
None,
));
}
}
}
}
TraceAssertion::ToolBlocklist {
test_tool_calls,
policy,
expect,
} => {
let Some(tools) = test_tool_calls else {
return Some(ineffective(
"tool_blocklist",
"test_tool_calls",
"`tool_blocklist` is only evaluated against the calls supplied in \
`test_tool_calls`; with that field absent the assertion checks nothing.",
"Give the assertion `test_tool_calls`, or constrain the recorded trace with \
`trace_must_not_call_tool` instead.",
));
};
{
let Some(pol) = policy else {
return Some(ineffective(
"tool_blocklist",
"policy",
"`tool_blocklist` has no policy to evaluate the calls against, so the \
assertion checks nothing.",
"Give the assertion a `policy` carrying a `blocked` list.",
));
};
{
if tools.is_empty() {
return Some(ineffective(
"tool_blocklist",
"test_tool_calls",
"`test_tool_calls` is empty, so there is no call to match against the \
blocklist and the assertion cannot fail.",
"List the calls the policy should be evaluated against, or remove the \
assertion.",
));
}
let blocked_value = pol.get("blocked");
let Some(blocked_raw) = blocked_value.and_then(|v| v.as_array()) else {
return Some(ineffective(
"tool_blocklist",
"policy.blocked",
if blocked_value.is_some() {
"the policy's `blocked` is not a list, so no tool name can be \
matched against it and the assertion cannot fail."
} else {
"the policy carries no `blocked` list, so every tool call is \
admitted and the assertion cannot fail."
},
"Give `blocked` an array of tool names.",
));
};
if blocked_raw.iter().any(|v| !v.is_string()) {
return Some(ineffective(
"tool_blocklist",
"policy.blocked",
"`blocked` contains an entry that is not a tool name, which would be \
dropped and leave the assertion checking less than it appears to.",
"Make every entry in `blocked` a string naming one tool.",
));
}
let blocked: Vec<String> = blocked_raw
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if blocked.is_empty() {
return Some(ineffective(
"tool_blocklist",
"policy.blocked",
"the `blocked` list is empty, so every tool call is admitted and the \
assertion cannot fail.",
"Name at least one disallowed tool in `blocked`, or remove the \
assertion.",
));
}
let expected_pass = match expected_pass(expect, "tool_blocklist") {
Ok(v) => v,
Err(d) => return Some(*d),
};
let mut actual_pass = true;
for t in tools {
if blocked.contains(t) {
actual_pass = false;
break;
}
}
if expected_pass != actual_pass {
return Some(make_diag(
"E_POLICY_ASSERT_FAIL",
&format!(
"ToolBlocklist check failed. Expected {}, got {}.",
if expected_pass { "PASS" } else { "FAIL" },
if actual_pass { "PASS" } else { "FAIL" }
),
None,
None,
));
}
}
}
}
}
None
}
fn check_subsequence(
calls: &[crate::storage::rows::ToolCallRow],
expected: &[String],
) -> Result<(), String> {
let mut current_idx = 0;
for expected_tool in expected {
let mut found = false;
while current_idx < calls.len() {
let row = &calls[current_idx];
current_idx += 1;
if row.tool_name.as_deref() == Some(expected_tool.as_str()) {
found = true;
break;
}
}
if !found {
return Err(format!(
"Expected tool '{}' in sequence, but not found (missing or out of order).",
expected_tool
));
}
}
Ok(())
}
fn names_no_tool(tool: &str, variant: &str, why: &'static str) -> Option<Diagnostic> {
tool.is_empty().then(|| {
ineffective(
variant,
"tool",
why,
"Name the tool the assertion is about.",
)
})
}
const EMPTY_TOOL_NEVER_FAILS: &str =
"`tool` is empty, so it names no recorded call and the assertion can never fail.";
const EMPTY_TOOL_NEVER_HOLDS: &str =
"`tool` is empty, so it names no recorded call and the assertion can never be satisfied.";
fn expected_pass(expect: &Option<String>, variant: &str) -> Result<bool, Box<Diagnostic>> {
match expect.as_deref() {
None | Some("pass") => Ok(true),
Some("fail") => Ok(false),
Some(other) => Err(Box::new(make_diag(
"E_CONFIG_ERROR",
&format!(
"Assertion `{variant}` has an unrecognized `expect` value; \
the only accepted values are `pass` and `fail`."
),
None,
Some(
serde_json::json!({ "assertion": variant, "field": "expect", "length": other.len() }),
),
))),
}
}
fn ineffective(variant: &str, field: &str, why: &str, fix: &str) -> Diagnostic {
Diagnostic {
code: "E_ASSERT_INEFFECTIVE".to_string(),
severity: "error".to_string(),
source: "agent_assertions".to_string(),
message: format!("Assertion `{variant}` checks nothing: {why}"),
context: serde_json::json!({ "assertion": variant, "field": field }),
fix_steps: vec![fix.to_string()],
}
}
fn make_diag(
code: &str,
message: &str,
_expected: Option<String>,
context: Option<serde_json::Value>,
) -> Diagnostic {
Diagnostic {
code: code.to_string(),
severity: "error".to_string(),
source: "agent_assertions".to_string(),
message: message.to_string(),
context: context.unwrap_or(serde_json::json!({})),
fix_steps: vec![],
}
}