279 lines
9.8 KiB
Rust
Raw Normal View History

feat: Phase 5.3 - Multi-Agent Learning Infrastructure Implement intelligent agent learning from Knowledge Graph execution history with per-task-type expertise tracking, recency bias, and learning curves. ## Phase 5.3 Implementation ### Learning Infrastructure (✅ Complete) - LearningProfileService with per-task-type expertise metrics - TaskTypeExpertise model tracking success_rate, confidence, learning curves - Recency bias weighting: recent 7 days weighted 3x higher (exponential decay) - Confidence scoring prevents overfitting: min(1.0, executions / 20) - Learning curves computed from daily execution windows ### Agent Scoring Service (✅ Complete) - Unified AgentScore combining SwarmCoordinator + learning profiles - Scoring formula: 0.3*base + 0.5*expertise + 0.2*confidence - Rank agents by combined score for intelligent assignment - Support for recency-biased scoring (recent_success_rate) - Methods: rank_agents, select_best, rank_agents_with_recency ### KG Integration (✅ Complete) - KGPersistence::get_executions_for_task_type() - query by agent + task type - KGPersistence::get_agent_executions() - all executions for agent - Coordinator::load_learning_profile_from_kg() - core KG→Learning integration - Coordinator::load_all_learning_profiles() - batch load for multiple agents - Convert PersistedExecution → ExecutionData for learning calculations ### Agent Assignment Integration (✅ Complete) - AgentCoordinator uses learning profiles for task assignment - extract_task_type() infers task type from title/description - assign_task() scores candidates using AgentScoringService - Fallback to load-based selection if no learning data available - Learning profiles stored in coordinator.learning_profiles RwLock ### Profile Adapter Enhancements (✅ Complete) - create_learning_profile() - initialize empty profiles - add_task_type_expertise() - set task-type expertise - update_profile_with_learning() - update swarm profiles from learning ## Files Modified ### vapora-knowledge-graph/src/persistence.rs (+30 lines) - get_executions_for_task_type(agent_id, task_type, limit) - get_agent_executions(agent_id, limit) ### vapora-agents/src/coordinator.rs (+100 lines) - load_learning_profile_from_kg() - core KG integration method - load_all_learning_profiles() - batch loading for agents - assign_task() already uses learning-based scoring via AgentScoringService ### Existing Complete Implementation - vapora-knowledge-graph/src/learning.rs - calculation functions - vapora-agents/src/learning_profile.rs - data structures and expertise - vapora-agents/src/scoring.rs - unified scoring service - vapora-agents/src/profile_adapter.rs - adapter methods ## Tests Passing - learning_profile: 7 tests ✅ - scoring: 5 tests ✅ - profile_adapter: 6 tests ✅ - coordinator: learning-specific tests ✅ ## Data Flow 1. Task arrives → AgentCoordinator::assign_task() 2. Extract task_type from description 3. Query KG for task-type executions (load_learning_profile_from_kg) 4. Calculate expertise with recency bias 5. Score candidates (SwarmCoordinator + learning) 6. Assign to top-scored agent 7. Execution result → KG → Update learning profiles ## Key Design Decisions ✅ Recency bias: 7-day half-life with 3x weight for recent performance ✅ Confidence scoring: min(1.0, total_executions / 20) prevents overfitting ✅ Hierarchical scoring: 30% base load, 50% expertise, 20% confidence ✅ KG query limit: 100 recent executions per task-type for performance ✅ Async loading: load_learning_profile_from_kg supports concurrent loads ## Next: Phase 5.4 - Cost Optimization Ready to implement budget enforcement and cost-aware provider selection.
2026-01-11 13:03:53 +00:00
use crate::learning_profile::LearningProfile;
use vapora_swarm::messages::AgentProfile;
/// Unified agent score combining SwarmCoordinator metrics and learning expertise.
#[derive(Debug, Clone)]
pub struct AgentScore {
/// Agent identifier
pub agent_id: String,
/// Base score from SwarmCoordinator: success_rate / (1 + current_load)
pub base_score: f64,
/// Expertise score from LearningProfile for specific task type (0.0-1.0)
pub expertise_score: f64,
/// Confidence in expertise score based on execution count (0.0-1.0)
pub confidence: f64,
/// Final combined score: 0.3*base + 0.5*expertise + 0.2*confidence
pub final_score: f64,
/// Human-readable explanation of scoring breakdown
pub reasoning: String,
}
/// Service for ranking agents based on learning profiles and swarm metrics.
pub struct AgentScoringService;
impl AgentScoringService {
/// Rank candidate agents for task assignment using combined scoring.
///
/// Scoring formula:
/// - base_score = success_rate / (1 + current_load) [from SwarmCoordinator]
/// - expertise_score = learned success rate for task_type
/// - confidence = min(1.0, total_executions / 20) [prevents overfitting]
/// - final_score = 0.3*base + 0.5*expertise + 0.2*confidence
///
/// Returns agents ranked by final_score (highest first).
pub fn rank_agents(
candidates: Vec<AgentProfile>,
task_type: &str,
learning_profiles: &[(String, LearningProfile)],
) -> Vec<AgentScore> {
let mut scores: Vec<AgentScore> = candidates
.into_iter()
.map(|agent| {
let base_score = agent.success_rate / (1.0 + agent.current_load);
let (expertise_score, confidence) = learning_profiles
.iter()
.find(|(id, _)| id == &agent.id)
.map(|(_, profile)| {
(
profile.get_task_type_score(task_type),
profile.get_confidence(task_type),
)
})
.unwrap_or((agent.success_rate, 0.0));
let final_score = 0.3 * base_score + 0.5 * expertise_score + 0.2 * confidence;
let reasoning = format!(
"{}(base={:.2}, load={:.2}, expertise={:.2}, confidence={:.2})",
agent.id, base_score, agent.current_load, expertise_score, confidence
);
AgentScore {
agent_id: agent.id.clone(),
base_score,
expertise_score,
confidence,
final_score,
reasoning,
}
})
.collect();
scores.sort_by(|a, b| {
b.final_score
.partial_cmp(&a.final_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scores
}
/// Select best agent from candidates for task assignment.
/// Returns the top-ranked agent or None if no candidates available.
pub fn select_best(
candidates: Vec<AgentProfile>,
task_type: &str,
learning_profiles: &[(String, LearningProfile)],
) -> Option<AgentScore> {
Self::rank_agents(candidates, task_type, learning_profiles)
.into_iter()
.next()
}
/// Calculate blended score prioritizing task-type expertise.
/// Uses recent_success_rate if available (recency bias from learning profile).
pub fn rank_agents_with_recency(
candidates: Vec<AgentProfile>,
task_type: &str,
learning_profiles: &[(String, LearningProfile)],
) -> Vec<AgentScore> {
let mut scores: Vec<AgentScore> = candidates
.into_iter()
.map(|agent| {
let base_score = agent.success_rate / (1.0 + agent.current_load);
let (expertise_score, confidence) = learning_profiles
.iter()
.find(|(id, _)| id == &agent.id)
.map(|(_, profile)| {
// Use recent_success_rate if available (weighted 3x for last 7 days)
let recent = profile.get_recent_score(task_type);
let conf = profile.get_confidence(task_type);
(recent, conf)
})
.unwrap_or((agent.success_rate, 0.0));
let final_score = 0.3 * base_score + 0.5 * expertise_score + 0.2 * confidence;
let reasoning = format!(
"{}(recent={:.2}, confidence={:.2})",
agent.id, expertise_score, confidence
);
AgentScore {
agent_id: agent.id.clone(),
base_score,
expertise_score,
confidence,
final_score,
reasoning,
}
})
.collect();
scores.sort_by(|a, b| {
b.final_score
.partial_cmp(&a.final_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scores
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_mock_agent(id: &str, success_rate: f64, load: f64) -> AgentProfile {
AgentProfile {
id: id.to_string(),
roles: vec![],
capabilities: vec![],
current_load: load,
success_rate,
availability: true,
}
}
fn create_mock_learning(agent_id: &str, expertise: f64, confidence: f64) -> LearningProfile {
use crate::learning_profile::TaskTypeExpertise;
let mut profile = LearningProfile::new(agent_id.to_string());
let exp = TaskTypeExpertise {
success_rate: expertise,
total_executions: (confidence * 20.0) as u32,
recent_success_rate: expertise,
avg_duration_ms: 100.0,
learning_curve: Vec::new(),
confidence,
};
profile.set_task_type_expertise("coding".to_string(), exp);
profile
}
#[test]
fn test_rank_agents_basic() {
let candidates = vec![
create_mock_agent("agent-a", 0.8, 0.4),
create_mock_agent("agent-b", 0.6, 0.2),
create_mock_agent("agent-c", 0.9, 0.5),
];
let learning = vec![
("agent-a".to_string(), create_mock_learning("agent-a", 0.85, 0.8)),
("agent-b".to_string(), create_mock_learning("agent-b", 0.70, 0.6)),
("agent-c".to_string(), create_mock_learning("agent-c", 0.75, 0.5)),
];
let ranked = AgentScoringService::rank_agents(candidates, "coding", &learning);
assert_eq!(ranked.len(), 3);
// Verify sorted by final_score descending
for i in 1..ranked.len() {
assert!(ranked[i - 1].final_score >= ranked[i].final_score);
}
}
#[test]
fn test_select_best() {
let candidates = vec![
create_mock_agent("agent-a", 0.8, 0.4),
create_mock_agent("agent-b", 0.6, 0.2),
];
let learning = vec![
("agent-a".to_string(), create_mock_learning("agent-a", 0.85, 0.8)),
("agent-b".to_string(), create_mock_learning("agent-b", 0.70, 0.6)),
];
let best = AgentScoringService::select_best(candidates, "coding", &learning);
assert!(best.is_some());
assert_eq!(best.unwrap().agent_id, "agent-a");
}
#[test]
fn test_rank_agents_no_learning_data() {
let candidates = vec![
create_mock_agent("agent-a", 0.8, 0.4),
create_mock_agent("agent-b", 0.6, 0.2),
];
let ranked = AgentScoringService::rank_agents(candidates, "coding", &[]);
assert_eq!(ranked.len(), 2);
// Should still rank by base score when no learning data
assert!(ranked[0].final_score > 0.0);
}
#[test]
fn test_recency_bias_scoring() {
let candidates = vec![
create_mock_agent("agent-a", 0.5, 0.3),
create_mock_agent("agent-b", 0.5, 0.3),
];
let mut learning_a = LearningProfile::new("agent-a".to_string());
use crate::learning_profile::TaskTypeExpertise;
learning_a.set_task_type_expertise(
"coding".to_string(),
TaskTypeExpertise {
success_rate: 0.7,
total_executions: 20,
recent_success_rate: 0.95, // Recent success much higher
avg_duration_ms: 100.0,
learning_curve: Vec::new(),
confidence: 1.0,
},
);
let learning = vec![("agent-a".to_string(), learning_a)];
let ranked = AgentScoringService::rank_agents_with_recency(candidates, "coding", &learning);
assert_eq!(ranked.len(), 2);
// agent-a should rank higher due to recent success
assert_eq!(ranked[0].agent_id, "agent-a");
}
#[test]
fn test_confidence_weights_low_sample_count() {
let candidates = vec![
create_mock_agent("agent-a", 0.9, 0.0), // High success but...
create_mock_agent("agent-b", 0.8, 0.0), // Moderate success
];
let learning = vec![
("agent-a".to_string(), create_mock_learning("agent-a", 0.9, 0.05)), // Low confidence
("agent-b".to_string(), create_mock_learning("agent-b", 0.8, 0.95)), // High confidence
];
let ranked = AgentScoringService::rank_agents(candidates, "coding", &learning);
// agent-b should rank higher due to higher confidence despite lower expertise
assert_eq!(ranked[0].agent_id, "agent-b");
}
#[test]
fn test_empty_candidates() {
let ranked = AgentScoringService::rank_agents(Vec::new(), "coding", &[]);
assert_eq!(ranked.len(), 0);
}
}