use crate::{Event, EventKind};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InterventionLayer {
EnvironmentContract,
ActionRealization,
TrajectoryRegulation,
ProceduralSkill,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarnessIntervention {
pub layer: InterventionLayer,
pub target: String,
pub trigger: String,
pub intervention: String,
pub evidence_count: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AdaptationReport {
pub interventions: Vec<HarnessIntervention>,
pub parse_errors: usize,
}
fn error_text(ev: &Event) -> String {
const KEYS: [&str; 4] = ["error", "message", "reason", "trajectory_persist_error"];
let mut parts = Vec::new();
for k in KEYS {
if let Some(s) = ev.data.get(k).and_then(|v| v.as_str()) {
parts.push(s.to_string());
}
}
parts.join("; ")
}
fn looks_like_realization(err: &str) -> bool {
let e = err.to_lowercase();
[
"no tool",
"not registered",
"param",
"schema",
"required",
"type mismatch",
"invalid argument",
"unknown tool",
]
.iter()
.any(|kw| e.contains(kw))
}
pub fn diagnose(events: &[Event], min_occurrences: usize) -> AdaptationReport {
let min = min_occurrences.max(1);
let mut rejected: HashMap<String, usize> = HashMap::new();
let mut retried: HashMap<String, usize> = HashMap::new();
let mut failed: HashMap<String, (usize, String)> = HashMap::new();
let mut replan_exhausted: HashMap<String, (usize, String)> = HashMap::new();
for ev in events {
match ev.kind {
EventKind::ActionRejected => {
if let Some(id) = &ev.action_id {
*rejected.entry(id.clone()).or_insert(0) += 1;
}
}
EventKind::ActionRetrying => {
if let Some(id) = &ev.action_id {
*retried.entry(id.clone()).or_insert(0) += 1;
}
}
EventKind::ActionFailed => {
if let Some(id) = &ev.action_id {
let e = failed.entry(id.clone()).or_insert((0, String::new()));
e.0 += 1;
if e.1.is_empty() {
e.1 = error_text(ev);
}
}
}
EventKind::ReplanExhausted => {
let pid = ev.proposal_id.clone().unwrap_or_else(|| "unknown".to_string());
let e = replan_exhausted.entry(pid).or_insert((0, String::new()));
e.0 += 1;
if e.1.is_empty() {
e.1 = error_text(ev);
}
}
_ => {}
}
}
let mut interventions = Vec::new();
for (id, count) in &rejected {
if *count >= min {
interventions.push(HarnessIntervention {
layer: InterventionLayer::EnvironmentContract,
target: id.clone(),
trigger: format!("action '{id}' rejected before execution {count}×"),
intervention: format!(
"calibrate the tool contract/permission for '{id}' so the model stops proposing a disallowed or ill-formed call (clarify the description/constraints up front)"
),
evidence_count: *count,
});
}
}
for (id, (count, err)) in &failed {
if *count < min {
continue;
}
let (layer, intervention) = if looks_like_realization(err) {
(
InterventionLayer::ActionRealization,
format!(
"add an action-realization fixup for '{id}' (normalize params / tool name) — recurring structural error: {}",
if err.is_empty() { "<none recorded>" } else { err }
),
)
} else {
(
InterventionLayer::TrajectoryRegulation,
format!(
"add a recovery/circuit-breaker for '{id}' — it fails at runtime repeatedly{}",
if err.is_empty() { String::new() } else { format!(": {err}") }
),
)
};
interventions.push(HarnessIntervention {
layer,
target: id.clone(),
trigger: format!("action '{id}' failed {count}×"),
intervention,
evidence_count: *count,
});
}
for (id, count) in &retried {
if *count >= min {
interventions.push(HarnessIntervention {
layer: InterventionLayer::TrajectoryRegulation,
target: id.clone(),
trigger: format!("action '{id}' retried {count}×"),
intervention: format!(
"cap retries for '{id}' and route to replan/alternative instead of thrashing"
),
evidence_count: *count,
});
}
}
for (pid, (count, reason)) in &replan_exhausted {
if *count >= min {
interventions.push(HarnessIntervention {
layer: InterventionLayer::TrajectoryRegulation,
target: format!("proposal:{pid}"),
trigger: format!("replanning exhausted {count}× for proposal '{pid}'"),
intervention: format!(
"revisit the goal/contract or seed a procedural skill — replanning repeatedly gives up{}",
if reason.is_empty() { String::new() } else { format!(" ({reason})") }
),
evidence_count: *count,
});
}
}
interventions.sort_by(|a, b| {
b.evidence_count
.cmp(&a.evidence_count)
.then(a.target.cmp(&b.target))
});
AdaptationReport {
interventions,
parse_errors: 0,
}
}
pub fn diagnose_from_jsonl(jsonl: &str, min_occurrences: usize) -> AdaptationReport {
let mut parse_errors = 0usize;
let events: Vec<Event> = jsonl
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| match serde_json::from_str::<Event>(l) {
Ok(ev) => Some(ev),
Err(_) => {
parse_errors += 1;
None
}
})
.collect();
let mut report = diagnose(&events, min_occurrences);
report.parse_errors = parse_errors;
report
}
#[cfg(test)]
mod tests {
use super::*;
use crate::EventLog;
fn log_with(events: &[(EventKind, Option<&str>, Option<&str>, Vec<(&str, &str)>)]) -> EventLog {
let mut log = EventLog::new();
for (kind, action, proposal, data) in events {
let map: HashMap<String, serde_json::Value> = data
.iter()
.map(|(k, v)| (k.to_string(), serde_json::Value::from(*v)))
.collect();
log.append(kind.clone(), *action, *proposal, map);
}
log
}
#[test]
fn recurring_rejection_is_environment_contract() {
let log = log_with(&[
(EventKind::ActionRejected, Some("a1"), Some("p"), vec![]),
(EventKind::ActionRejected, Some("a1"), Some("p"), vec![]),
]);
let r = diagnose(log.events(), 2);
assert_eq!(r.interventions.len(), 1);
assert_eq!(r.interventions[0].layer, InterventionLayer::EnvironmentContract);
assert_eq!(r.interventions[0].evidence_count, 2);
assert_eq!(r.interventions[0].target, "a1");
}
#[test]
fn one_off_is_not_emitted() {
let log = log_with(&[(EventKind::ActionRejected, Some("a1"), Some("p"), vec![])]);
let r = diagnose(log.events(), 2);
assert!(r.interventions.is_empty(), "a single failure is noise, not a pattern");
}
#[test]
fn structural_failure_is_action_realization() {
let log = log_with(&[
(EventKind::ActionFailed, Some("a2"), Some("p"), vec![("error", "missing required param 'path'")]),
(EventKind::ActionFailed, Some("a2"), Some("p"), vec![("error", "missing required param 'path'")]),
]);
let r = diagnose(log.events(), 2);
assert_eq!(r.interventions[0].layer, InterventionLayer::ActionRealization);
}
#[test]
fn runtime_failure_is_trajectory_regulation() {
let log = log_with(&[
(EventKind::ActionFailed, Some("a3"), Some("p"), vec![("error", "connection timed out")]),
(EventKind::ActionFailed, Some("a3"), Some("p"), vec![("error", "connection timed out")]),
]);
let r = diagnose(log.events(), 2);
assert_eq!(r.interventions[0].layer, InterventionLayer::TrajectoryRegulation);
}
#[test]
fn replan_exhausted_targets_proposal() {
let log = log_with(&[
(EventKind::ReplanExhausted, None, Some("p9"), vec![("reason", "callback_error")]),
(EventKind::ReplanExhausted, None, Some("p9"), vec![("reason", "callback_error")]),
]);
let r = diagnose(log.events(), 2);
assert_eq!(r.interventions[0].layer, InterventionLayer::TrajectoryRegulation);
assert_eq!(r.interventions[0].target, "proposal:p9");
}
#[test]
fn results_sorted_by_evidence_desc() {
let log = log_with(&[
(EventKind::ActionRejected, Some("low"), Some("p"), vec![]),
(EventKind::ActionRejected, Some("low"), Some("p"), vec![]),
(EventKind::ActionRetrying, Some("high"), Some("p"), vec![]),
(EventKind::ActionRetrying, Some("high"), Some("p"), vec![]),
(EventKind::ActionRetrying, Some("high"), Some("p"), vec![]),
]);
let r = diagnose(log.events(), 2);
assert_eq!(r.interventions[0].target, "high"); assert_eq!(r.interventions[0].evidence_count, 3);
}
#[test]
fn jsonl_counts_parse_errors() {
let jsonl = [
"not json",
r#"{"kind":"action_rejected","action_id":"a1","data":{},"timestamp":"2026-06-28T00:00:00Z"}"#,
r#"{"kind":"action_rejected","action_id":"a1","data":{},"timestamp":"2026-06-28T00:00:01Z"}"#,
]
.join("\n");
let r = diagnose_from_jsonl(&jsonl, 2);
assert_eq!(r.parse_errors, 1);
assert_eq!(r.interventions.len(), 1);
}
}