use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use serde_json::Value;
use crate::normalizer::{ToolInvocation, ToolInvocationResult};
use crate::registry::KernelError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RetryClass {
Transient,
Permanent,
}
pub trait RetryClassifier: Send + Sync {
fn classify(&self, error: &KernelError) -> RetryClass;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DefaultRetryClassifier;
impl RetryClassifier for DefaultRetryClassifier {
fn classify(&self, error: &KernelError) -> RetryClass {
match error {
KernelError::ToolFailed(_) | KernelError::SkillFailed(_) => RetryClass::Transient,
KernelError::ToolNotFound(_)
| KernelError::ToolNotAuthorised(_)
| KernelError::SkillNotFound(_)
| KernelError::ToolNotApplicable(_)
| KernelError::InvalidArgument(_)
| KernelError::NormalizerFailed(_)
| KernelError::ToolDispatchTerminated(_)
| KernelError::BudgetFailed(_)
| KernelError::Serde(_) => RetryClass::Permanent,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ToolCallFingerprint(pub u64);
impl ToolInvocation {
pub fn fingerprint(&self) -> ToolCallFingerprint {
let mut hasher = DefaultHasher::new();
self.name.hash(&mut hasher);
canonicalize_value(&self.args).to_string().hash(&mut hasher);
ToolCallFingerprint(hasher.finish())
}
}
fn canonicalize_value(value: &Value) -> Value {
match value {
Value::Array(items) => Value::Array(items.iter().map(canonicalize_value).collect()),
Value::Object(map) => {
let mut out = serde_json::Map::new();
for (key, inner) in map {
out.insert(key.clone(), canonicalize_value(inner));
}
Value::Object(out)
}
other => other.clone(),
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum HistoryEntry {
Completed {
invocation: ToolInvocation,
output: Value,
},
Failed {
invocation: ToolInvocation,
class: RetryClass,
message: String,
},
}
impl HistoryEntry {
pub fn fingerprint(&self) -> ToolCallFingerprint {
match self {
HistoryEntry::Completed { invocation, .. }
| HistoryEntry::Failed { invocation, .. } => invocation.fingerprint(),
}
}
pub fn completed(result: ToolInvocationResult) -> Self {
HistoryEntry::Completed {
invocation: result.invocation,
output: result.output,
}
}
pub fn failed<C: RetryClassifier>(
invocation: ToolInvocation,
error: &KernelError,
classifier: &C,
) -> Self {
HistoryEntry::Failed {
invocation,
class: classifier.classify(error),
message: error.to_string(),
}
}
}
pub fn repair_history(entries: &[HistoryEntry]) -> Vec<HistoryEntry> {
let mut order: Vec<ToolCallFingerprint> = Vec::new();
let mut chosen: std::collections::HashMap<ToolCallFingerprint, usize> =
std::collections::HashMap::new();
let mut has_completed: std::collections::HashSet<ToolCallFingerprint> =
std::collections::HashSet::new();
for (idx, entry) in entries.iter().enumerate() {
let fp = entry.fingerprint();
if let std::collections::hash_map::Entry::Vacant(slot) = chosen.entry(fp) {
order.push(fp);
slot.insert(idx);
if matches!(entry, HistoryEntry::Completed { .. }) {
has_completed.insert(fp);
}
continue;
}
match entry {
HistoryEntry::Completed { .. } => {
if !has_completed.contains(&fp) {
chosen.insert(fp, idx);
has_completed.insert(fp);
}
}
HistoryEntry::Failed { .. } => {
if !has_completed.contains(&fp) {
chosen.insert(fp, idx);
}
}
}
}
order
.into_iter()
.filter_map(|fp| chosen.get(&fp).and_then(|&i| entries.get(i)).cloned())
.collect()
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use serde_json::json;
fn inv(name: &str, args: Value) -> ToolInvocation {
ToolInvocation::new(name, args).unwrap()
}
#[test]
fn default_classifier_marks_tool_failed_transient() {
let c = DefaultRetryClassifier;
assert_eq!(
c.classify(&KernelError::ToolFailed("boom".into())),
RetryClass::Transient,
);
assert_eq!(
c.classify(&KernelError::SkillFailed("boom".into())),
RetryClass::Transient,
);
}
#[test]
fn default_classifier_marks_policy_errors_permanent() {
let c = DefaultRetryClassifier;
for err in [
KernelError::ToolNotFound("x".into()),
KernelError::ToolNotAuthorised("x".into()),
KernelError::SkillNotFound("x".into()),
KernelError::ToolNotApplicable("x".into()),
KernelError::InvalidArgument("x".into()),
KernelError::NormalizerFailed("x".into()),
KernelError::ToolDispatchTerminated("x".into()),
KernelError::BudgetFailed("x".into()),
] {
assert_eq!(c.classify(&err), RetryClass::Permanent, "{err:?}");
}
}
#[test]
fn fingerprint_is_stable_for_same_invocation() {
let a = inv("search", json!({"q": "rig", "limit": 5}));
let b = inv("search", json!({"q": "rig", "limit": 5}));
assert_eq!(a.fingerprint(), b.fingerprint());
}
#[test]
fn fingerprint_is_order_independent_for_object_args() {
let a = inv("search", json!({"q": "rig", "limit": 5}));
let b = inv("search", json!({"limit": 5, "q": "rig"}));
assert_eq!(a.fingerprint(), b.fingerprint());
}
#[test]
fn fingerprint_differs_when_args_differ() {
let a = inv("search", json!({"q": "rig"}));
let b = inv("search", json!({"q": "tokio"}));
assert_ne!(a.fingerprint(), b.fingerprint());
}
#[test]
fn fingerprint_differs_when_tool_name_differs() {
let a = inv("search", json!({"q": "rig"}));
let b = inv("lookup", json!({"q": "rig"}));
assert_ne!(a.fingerprint(), b.fingerprint());
}
#[test]
fn repair_keeps_first_completion_after_retries() {
let i = inv("search", json!({"q": "rig"}));
let history = vec![
HistoryEntry::Failed {
invocation: i.clone(),
class: RetryClass::Transient,
message: "timeout".into(),
},
HistoryEntry::Completed {
invocation: i.clone(),
output: json!({"hits": 1}),
},
HistoryEntry::Completed {
invocation: i,
output: json!({"hits": 99}),
},
];
let repaired = repair_history(&history);
assert_eq!(repaired.len(), 1);
match &repaired[0] {
HistoryEntry::Completed { output, .. } => assert_eq!(output, &json!({"hits": 1})),
other => panic!("expected Completed, got {other:?}"),
}
}
#[test]
fn repair_keeps_last_failure_when_no_completion() {
let i = inv("search", json!({"q": "rig"}));
let history = vec![
HistoryEntry::Failed {
invocation: i.clone(),
class: RetryClass::Transient,
message: "first".into(),
},
HistoryEntry::Failed {
invocation: i,
class: RetryClass::Permanent,
message: "last".into(),
},
];
let repaired = repair_history(&history);
assert_eq!(repaired.len(), 1);
match &repaired[0] {
HistoryEntry::Failed { message, class, .. } => {
assert_eq!(message, "last");
assert_eq!(*class, RetryClass::Permanent);
}
other => panic!("expected Failed, got {other:?}"),
}
}
#[test]
fn repair_preserves_first_occurrence_order_across_fingerprints() {
let a = inv("a", json!({"k": 1}));
let b = inv("b", json!({"k": 2}));
let history = vec![
HistoryEntry::Completed {
invocation: a.clone(),
output: json!(null),
},
HistoryEntry::Completed {
invocation: b.clone(),
output: json!(null),
},
HistoryEntry::Completed {
invocation: a,
output: json!("ignored"),
},
];
let repaired = repair_history(&history);
assert_eq!(repaired.len(), 2);
assert_eq!(
repaired[0].fingerprint(),
inv("a", json!({"k": 1})).fingerprint()
);
assert_eq!(
repaired[1].fingerprint(),
inv("b", json!({"k": 2})).fingerprint()
);
}
#[test]
fn repair_is_idempotent() {
let i = inv("search", json!({"q": "rig"}));
let history = vec![
HistoryEntry::Failed {
invocation: i.clone(),
class: RetryClass::Transient,
message: "x".into(),
},
HistoryEntry::Completed {
invocation: i,
output: json!({"ok": true}),
},
];
let once = repair_history(&history);
let twice = repair_history(&once);
assert_eq!(once, twice);
}
#[test]
fn repair_on_empty_history_returns_empty() {
assert!(repair_history(&[]).is_empty());
}
#[test]
fn history_entry_failed_helper_records_classifier_verdict() {
let entry = HistoryEntry::failed(
inv("search", json!({"q": "rig"})),
&KernelError::ToolFailed("flake".into()),
&DefaultRetryClassifier,
);
match entry {
HistoryEntry::Failed { class, message, .. } => {
assert_eq!(class, RetryClass::Transient);
assert!(message.contains("flake"));
}
other => panic!("expected Failed, got {other:?}"),
}
}
}