use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use clawft_types::provider::LlmResponse;
use clawft_types::routing::AuthContext;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
pub messages: Vec<LlmMessage>,
#[serde(default)]
pub tools: Vec<serde_json::Value>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub max_tokens: Option<i32>,
#[serde(default)]
pub temperature: Option<f64>,
#[serde(default, skip_deserializing)]
pub auth_context: Option<AuthContext>,
#[serde(default)]
pub complexity_boost: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmMessage {
pub role: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone)]
pub struct TaskProfile {
pub task_type: TaskType,
pub complexity: f32,
pub keywords: Vec<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TaskType {
Chat,
CodeGeneration,
CodeReview,
Research,
Creative,
Analysis,
ToolUse,
Unknown,
}
#[derive(Debug, Clone, Default)]
pub struct RoutingDecision {
pub provider: String,
pub model: String,
pub reason: String,
pub tier: Option<String>,
pub cost_estimate_usd: Option<f64>,
pub escalated: bool,
pub budget_constrained: bool,
pub sender_id: Option<String>,
}
impl RoutingDecision {
pub fn redacted_reason(&self) -> &'static str {
let r = self.reason.as_str();
if r.contains("model_override") {
return "model_override_bypass";
}
if r.contains("rate limited") {
if r.contains("not permitted") {
return "tier_check_failed";
}
return "rate_limited";
}
if r.contains("not permitted") || r.contains("denied") {
return "tier_check_failed";
}
if r.contains("cost") || r.contains("budget") || r.contains("over") {
return "cost_cap_hit";
}
if r.contains("no tiers") {
return "no_tiers_available";
}
if r.contains("fallback") {
return "fallback_chosen";
}
if r.contains("static") {
return "static_routing";
}
if self.budget_constrained {
return "budget_constrained";
}
if self.escalated {
return "escalated";
}
"tiered_routing"
}
}
#[derive(Debug, Clone)]
pub struct ResponseOutcome {
pub success: bool,
pub quality: QualityScore,
pub latency_ms: u64,
}
#[derive(Debug, Clone)]
pub struct QualityScore {
pub overall: f32,
pub relevance: f32,
pub coherence: f32,
}
#[derive(Debug, Clone)]
pub struct AssembledContext {
pub messages: Vec<LlmMessage>,
pub token_estimate: usize,
pub truncated: bool,
}
#[derive(Debug, Clone)]
pub struct TransportRequest {
pub provider: String,
pub model: String,
pub messages: Vec<LlmMessage>,
pub tools: Vec<serde_json::Value>,
pub max_tokens: Option<i32>,
pub temperature: Option<f64>,
pub tool_choice: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct Trajectory {
pub request: ChatRequest,
pub routing: RoutingDecision,
pub response: LlmResponse,
pub quality: QualityScore,
}
#[derive(Debug, Clone)]
pub struct LearningSignal {
pub feedback_type: String,
pub value: f32,
}
pub trait TaskClassifier: Send + Sync {
fn classify(&self, request: &ChatRequest) -> TaskProfile;
}
#[async_trait]
pub trait ModelRouter: Send + Sync {
async fn route(&self, request: &ChatRequest, profile: &TaskProfile) -> RoutingDecision;
fn update(&self, decision: &RoutingDecision, outcome: &ResponseOutcome);
}
#[async_trait]
pub trait ContextAssembler: Send + Sync {
async fn assemble(&self, request: &ChatRequest, profile: &TaskProfile) -> AssembledContext;
}
#[cfg(not(feature = "browser"))]
pub type StreamCallback = Box<dyn FnMut(&str) -> bool + Send>;
#[cfg(feature = "browser")]
pub type StreamCallback = Box<dyn FnMut(&str) -> bool>;
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
pub trait LlmTransport: Send + Sync {
async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse>;
async fn complete_stream(
&self,
request: &TransportRequest,
mut callback: StreamCallback,
) -> clawft_types::Result<LlmResponse> {
let response = self.complete(request).await?;
for block in &response.content {
if let clawft_types::provider::ContentBlock::Text { text } = block
&& !callback(text)
{
break;
}
}
Ok(response)
}
}
pub trait QualityScorer: Send + Sync {
fn score(&self, request: &ChatRequest, response: &LlmResponse) -> QualityScore;
}
pub trait LearningBackend: Send + Sync {
fn record(&self, trajectory: &Trajectory);
fn adapt(&self, signal: &LearningSignal);
fn evolve_prompt(&self, prompt: &str) -> String {
prompt.to_string()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum BudgetResult {
Approved,
DailyLimitExceeded { spent: f64, limit: f64 },
MonthlyLimitExceeded { spent: f64, limit: f64 },
}
impl BudgetResult {
pub fn is_approved(&self) -> bool {
matches!(self, BudgetResult::Approved)
}
}
pub trait CostTrackable: Send + Sync {
fn check_budget(
&self,
sender_id: &str,
estimated_cost: f64,
daily_limit: f64,
monthly_limit: f64,
) -> BudgetResult;
fn record_estimated(&self, sender_id: &str, estimated_cost: f64);
fn record_actual(&self, sender_id: &str, estimated_cost: f64, actual_cost: f64);
}
pub trait RateLimitable: Send + Sync {
fn check(&self, sender_id: &str, limit: u32) -> bool;
}
pub struct Pipeline {
pub classifier: Arc<dyn TaskClassifier>,
pub router: Arc<dyn ModelRouter>,
pub assembler: Arc<dyn ContextAssembler>,
pub transport: Arc<dyn LlmTransport>,
pub scorer: Arc<dyn QualityScorer>,
pub learner: Arc<dyn LearningBackend>,
}
fn apply_prompt_evolution(
messages: Vec<LlmMessage>,
learner: &dyn LearningBackend,
) -> Vec<LlmMessage> {
let mut mutated = false;
let out: Vec<LlmMessage> = messages
.into_iter()
.map(|m| {
if !mutated && m.role == "system" {
let new_content = learner.evolve_prompt(&m.content);
if new_content != m.content {
tracing::debug!(
before_chars = m.content.len(),
after_chars = new_content.len(),
"pipeline: learner mutated system prompt"
);
}
mutated = true; LlmMessage {
role: m.role,
content: new_content,
tool_call_id: m.tool_call_id,
tool_calls: m.tool_calls,
}
} else {
m
}
})
.collect();
out
}
pub struct PipelineRegistry {
pipelines: HashMap<TaskType, Pipeline>,
default: Pipeline,
trajectory_learner: Option<Arc<crate::pipeline::learner::TrajectoryLearner>>,
decision_history: Arc<crate::pipeline::decision_history::RoutingDecisionHistory>,
}
impl PipelineRegistry {
pub fn new(default: Pipeline) -> Self {
Self {
pipelines: HashMap::new(),
default,
trajectory_learner: None,
decision_history: Arc::new(
crate::pipeline::decision_history::RoutingDecisionHistory::new(),
),
}
}
pub fn with_trajectory_learner(
mut self,
learner: Arc<crate::pipeline::learner::TrajectoryLearner>,
) -> Self {
self.trajectory_learner = Some(learner);
self
}
pub fn trajectory_learner(
&self,
) -> Option<&Arc<crate::pipeline::learner::TrajectoryLearner>> {
self.trajectory_learner.as_ref()
}
pub fn with_decision_history(
mut self,
history: Arc<crate::pipeline::decision_history::RoutingDecisionHistory>,
) -> Self {
self.decision_history = history;
self
}
pub fn decision_history(
&self,
) -> &Arc<crate::pipeline::decision_history::RoutingDecisionHistory> {
&self.decision_history
}
fn record_decision(&self, request: &ChatRequest, routing: &RoutingDecision) -> u64 {
let channel = request
.auth_context
.as_ref()
.map(|a| a.channel.as_str());
self.decision_history
.record_with_channel(routing, channel)
}
fn record_call(
&self,
id: u64,
result: &clawft_types::Result<LlmResponse>,
latency_ms: u64,
) {
let usage = result.as_ref().ok().map(|r| &r.usage);
self.decision_history.record_outcome(
id,
crate::pipeline::decision_history::CallOutcome {
ok: result.is_ok(),
input_tokens: usage.map(|u| u64::from(u.input_tokens)),
output_tokens: usage.map(|u| u64::from(u.output_tokens)),
latency_ms,
},
);
}
pub fn register(&mut self, task_type: TaskType, pipeline: Pipeline) {
self.pipelines.insert(task_type, pipeline);
}
pub fn get(&self, task_type: &TaskType) -> &Pipeline {
self.pipelines.get(task_type).unwrap_or(&self.default)
}
pub async fn complete(&self, request: &ChatRequest) -> clawft_types::Result<LlmResponse> {
let profile = self.default.classifier.classify(request);
let pipeline = self.get(&profile.task_type);
let routing = pipeline.router.route(request, &profile).await;
let history_id = self.record_decision(request, &routing);
let context = pipeline.assembler.assemble(request, &profile).await;
let messages = apply_prompt_evolution(context.messages, pipeline.learner.as_ref());
let transport_request = TransportRequest {
provider: routing.provider.clone(),
model: routing.model.clone(),
messages,
tools: request.tools.clone(),
max_tokens: request.max_tokens,
temperature: request.temperature,
tool_choice: request.tool_choice.clone(),
};
let start_ms = crate::runtime::now_millis();
let result = pipeline.transport.complete(&transport_request).await;
let latency_ms = crate::runtime::now_millis().saturating_sub(start_ms);
self.record_call(history_id, &result, latency_ms);
let response = result?;
let quality = pipeline.scorer.score(request, &response);
let trajectory = Trajectory {
request: request.clone(),
routing: routing.clone(),
response: response.clone(),
quality,
};
pipeline.learner.record(&trajectory);
let outcome = ResponseOutcome {
success: true,
quality: trajectory.quality,
latency_ms,
};
pipeline.router.update(&routing, &outcome);
Ok(response)
}
pub async fn complete_stream(
&self,
request: &ChatRequest,
callback: StreamCallback,
) -> clawft_types::Result<LlmResponse> {
let profile = self.default.classifier.classify(request);
let pipeline = self.get(&profile.task_type);
let routing = pipeline.router.route(request, &profile).await;
let history_id = self.record_decision(request, &routing);
let context = pipeline.assembler.assemble(request, &profile).await;
let messages = apply_prompt_evolution(context.messages, pipeline.learner.as_ref());
let transport_request = TransportRequest {
provider: routing.provider.clone(),
model: routing.model.clone(),
messages,
tools: request.tools.clone(),
max_tokens: request.max_tokens,
temperature: request.temperature,
tool_choice: request.tool_choice.clone(),
};
let start_ms = crate::runtime::now_millis();
let result = pipeline
.transport
.complete_stream(&transport_request, callback)
.await;
let latency_ms = crate::runtime::now_millis().saturating_sub(start_ms);
self.record_call(history_id, &result, latency_ms);
let response = result?;
let quality = pipeline.scorer.score(request, &response);
let trajectory = Trajectory {
request: request.clone(),
routing: routing.clone(),
response: response.clone(),
quality,
};
pipeline.learner.record(&trajectory);
let outcome = ResponseOutcome {
success: true,
quality: trajectory.quality,
latency_ms,
};
pipeline.router.update(&routing, &outcome);
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clawft_types::provider::{ContentBlock, StopReason, Usage};
#[test]
fn chat_request_construction() {
let req = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: Some("gpt-4o".into()),
max_tokens: Some(1024),
temperature: Some(0.7),
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
};
assert_eq!(req.messages.len(), 1);
assert_eq!(req.model.as_deref(), Some("gpt-4o"));
}
#[test]
fn llm_message_with_tool_call_id() {
let msg = LlmMessage {
role: "tool".into(),
content: "result data".into(),
tool_call_id: Some("call-123".into()),
tool_calls: None,
};
assert_eq!(msg.tool_call_id.as_deref(), Some("call-123"));
}
#[test]
fn task_profile_construction() {
let profile = TaskProfile {
task_type: TaskType::CodeGeneration,
complexity: 0.7,
keywords: vec!["implement".into(), "function".into()],
};
assert_eq!(profile.task_type, TaskType::CodeGeneration);
assert!(profile.complexity > 0.5);
assert_eq!(profile.keywords.len(), 2);
}
#[test]
fn routing_decision_construction() {
let decision = RoutingDecision {
provider: "anthropic".into(),
model: "claude-opus-4-5".into(),
reason: "high complexity code task".into(),
..Default::default()
};
assert_eq!(decision.provider, "anthropic");
assert!(decision.tier.is_none());
assert!(decision.cost_estimate_usd.is_none());
assert!(!decision.escalated);
assert!(!decision.budget_constrained);
}
fn assert_redacts(reason: &str, category: &str) {
let d = RoutingDecision {
reason: reason.into(),
..Default::default()
};
let red = d.redacted_reason();
assert_eq!(
red, category,
"reason {reason:?} -> {red}, expected {category}"
);
assert!(
red.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
"category {red} must be ascii snake_case"
);
}
#[test]
fn redacted_reason_strips_user_metadata() {
let raw = "tiered routing: complexity=0.85, tier=premium, level=2, user=alice@example.com";
let d = RoutingDecision {
reason: raw.into(),
..Default::default()
};
let red = d.redacted_reason();
assert!(!red.contains("alice"));
assert!(!red.contains("@example.com"));
assert!(!red.contains("0.85"));
assert!(!red.contains("premium"));
assert!(!red.contains("level=2"));
}
#[test]
fn redacted_reason_categories() {
assert_redacts("static routing (Level 0)", "static_routing");
assert_redacts("rate limited: using fallback model", "rate_limited");
assert_redacts(
"rate limited: fallback model not permitted for user tier",
"tier_check_failed",
);
assert_redacts(
"fallback chain: fallback_model denied for user tier",
"tier_check_failed",
);
assert_redacts("daily budget cap reached", "cost_cap_hit");
assert_redacts("no tiers or fallback model available", "no_tiers_available");
assert_redacts(
"fallback to configured fallback_model 'groq/llama-3.1-8b'",
"fallback_chosen",
);
assert_redacts(
"tiered routing: complexity=0.50, tier=standard, level=1, user=carol",
"tiered_routing",
);
}
#[test]
fn redacted_reason_uses_structured_fields_when_text_silent() {
let escalated = RoutingDecision {
reason: String::new(),
escalated: true,
..Default::default()
};
assert_eq!(escalated.redacted_reason(), "escalated");
let constrained = RoutingDecision {
reason: String::new(),
budget_constrained: true,
..Default::default()
};
assert_eq!(constrained.redacted_reason(), "budget_constrained");
}
#[test]
fn redacted_reason_default_is_safe() {
let d = RoutingDecision::default();
let red = d.redacted_reason();
assert!(
["tiered_routing", "static_routing"].contains(&red),
"unexpected default category: {red}"
);
}
#[test]
fn quality_score_construction() {
let score = QualityScore {
overall: 0.9,
relevance: 0.95,
coherence: 0.85,
};
assert!(score.overall > 0.0 && score.overall <= 1.0);
}
#[test]
fn assembled_context_construction() {
let ctx = AssembledContext {
messages: vec![LlmMessage {
role: "system".into(),
content: "You are a helpful assistant.".into(),
tool_call_id: None,
tool_calls: None,
}],
token_estimate: 50,
truncated: false,
};
assert!(!ctx.truncated);
assert_eq!(ctx.token_estimate, 50);
}
#[test]
fn transport_request_construction() {
let req = TransportRequest {
provider: "openai".into(),
model: "gpt-4o".into(),
messages: vec![],
tools: vec![],
max_tokens: Some(2048),
temperature: None,
tool_choice: None,
};
assert_eq!(req.provider, "openai");
assert!(req.temperature.is_none());
}
#[test]
fn response_outcome_construction() {
let score = QualityScore {
overall: 0.8,
relevance: 0.9,
coherence: 0.7,
};
let outcome = ResponseOutcome {
success: true,
quality: score,
latency_ms: 1500,
};
assert!(outcome.success);
assert_eq!(outcome.latency_ms, 1500);
}
#[test]
fn learning_signal_construction() {
let signal = LearningSignal {
feedback_type: "thumbs_up".into(),
value: 1.0,
};
assert_eq!(signal.feedback_type, "thumbs_up");
assert!((signal.value - 1.0).abs() < f32::EPSILON);
}
#[test]
fn chat_request_serde_roundtrip() {
let req = ChatRequest {
messages: vec![
LlmMessage {
role: "system".into(),
content: "You are helpful.".into(),
tool_call_id: None,
tool_calls: None,
},
LlmMessage {
role: "user".into(),
content: "Write a function".into(),
tool_call_id: None,
tool_calls: None,
},
],
tools: vec![serde_json::json!({"type": "function", "name": "web_search"})],
model: Some("gpt-4o".into()),
max_tokens: Some(4096),
temperature: Some(0.5),
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
};
let json = serde_json::to_string(&req).unwrap();
let restored: ChatRequest = serde_json::from_str(&json).unwrap();
assert_eq!(restored.messages.len(), 2);
assert_eq!(restored.messages[0].role, "system");
assert_eq!(restored.model.as_deref(), Some("gpt-4o"));
assert_eq!(restored.max_tokens, Some(4096));
assert_eq!(restored.tools.len(), 1);
assert!(restored.auth_context.is_none());
}
#[test]
fn llm_message_serde_roundtrip() {
let msg = LlmMessage {
role: "tool".into(),
content: "search results".into(),
tool_call_id: Some("tc-42".into()),
tool_calls: None,
};
let json = serde_json::to_string(&msg).unwrap();
let restored: LlmMessage = serde_json::from_str(&json).unwrap();
assert_eq!(restored.role, "tool");
assert_eq!(restored.tool_call_id.as_deref(), Some("tc-42"));
}
#[test]
fn llm_message_serde_skips_none_tool_call_id() {
let msg = LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(!json.contains("tool_call_id"));
}
#[test]
fn task_type_serde_roundtrip() {
let types = [
TaskType::Chat,
TaskType::CodeGeneration,
TaskType::CodeReview,
TaskType::Research,
TaskType::Creative,
TaskType::Analysis,
TaskType::ToolUse,
TaskType::Unknown,
];
for tt in &types {
let json = serde_json::to_string(tt).unwrap();
let restored: TaskType = serde_json::from_str(&json).unwrap();
assert_eq!(&restored, tt);
}
}
#[test]
fn task_type_equality_and_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(TaskType::CodeGeneration);
set.insert(TaskType::CodeGeneration);
assert_eq!(set.len(), 1);
set.insert(TaskType::Chat);
assert_eq!(set.len(), 2);
}
#[test]
fn chat_request_with_defaults_deserializes() {
let json = r#"{"messages": [{"role": "user", "content": "hi"}]}"#;
let req: ChatRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.messages.len(), 1);
assert!(req.tools.is_empty());
assert!(req.model.is_none());
assert!(req.max_tokens.is_none());
assert!(req.temperature.is_none());
}
struct TestClassifier {
task_type: TaskType,
}
impl TaskClassifier for TestClassifier {
fn classify(&self, _request: &ChatRequest) -> TaskProfile {
TaskProfile {
task_type: self.task_type.clone(),
complexity: 0.5,
keywords: vec![],
}
}
}
struct TestRouter {
provider: String,
model: String,
}
#[async_trait]
impl ModelRouter for TestRouter {
async fn route(&self, _request: &ChatRequest, _profile: &TaskProfile) -> RoutingDecision {
RoutingDecision {
provider: self.provider.clone(),
model: self.model.clone(),
reason: "test".into(),
..Default::default()
}
}
fn update(&self, _decision: &RoutingDecision, _outcome: &ResponseOutcome) {}
}
struct TestAssembler;
#[async_trait]
impl ContextAssembler for TestAssembler {
async fn assemble(
&self,
request: &ChatRequest,
_profile: &TaskProfile,
) -> AssembledContext {
AssembledContext {
messages: request.messages.clone(),
token_estimate: 100,
truncated: false,
}
}
}
struct TestTransport;
#[async_trait]
impl LlmTransport for TestTransport {
async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
Ok(LlmResponse {
id: "test-resp".into(),
content: vec![ContentBlock::Text {
text: "Hello from test transport".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 10,
output_tokens: 5,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
struct TestScorer;
impl QualityScorer for TestScorer {
fn score(&self, _request: &ChatRequest, _response: &LlmResponse) -> QualityScore {
QualityScore {
overall: 1.0,
relevance: 1.0,
coherence: 1.0,
}
}
}
struct TestLearner;
impl LearningBackend for TestLearner {
fn record(&self, _trajectory: &Trajectory) {}
fn adapt(&self, _signal: &LearningSignal) {}
}
fn make_test_pipeline(task_type: TaskType, provider: &str, model: &str) -> Pipeline {
Pipeline {
classifier: Arc::new(TestClassifier { task_type }),
router: Arc::new(TestRouter {
provider: provider.into(),
model: model.into(),
}),
assembler: Arc::new(TestAssembler),
transport: Arc::new(TestTransport),
scorer: Arc::new(TestScorer),
learner: Arc::new(TestLearner),
}
}
#[test]
fn pipeline_registry_new() {
let registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
let pipeline = registry.get(&TaskType::CodeGeneration);
let _ = pipeline.classifier.classify(&ChatRequest {
messages: vec![],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
});
}
#[test]
fn pipeline_registry_register_and_get() {
let mut registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
registry.register(
TaskType::CodeGeneration,
make_test_pipeline(TaskType::CodeGeneration, "anthropic", "claude-opus-4-5"),
);
let _code_pipeline = registry.get(&TaskType::CodeGeneration);
let _default_pipeline = registry.get(&TaskType::Research);
}
#[tokio::test]
async fn pipeline_registry_complete_orchestrates_all_stages() {
let registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
};
let response = registry.complete(&request).await.unwrap();
assert_eq!(response.id, "test-resp");
assert_eq!(response.stop_reason, StopReason::EndTurn);
match &response.content[0] {
ContentBlock::Text { text } => {
assert_eq!(text, "Hello from test transport");
}
_ => panic!("expected text block"),
}
}
#[tokio::test]
async fn pipeline_registry_complete_records_decision_history() {
let registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
for i in 0..3 {
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: format!("hello-{i}"),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
};
registry.complete(&request).await.unwrap();
}
let hist = registry.decision_history();
assert_eq!(hist.len(), 3);
let entries = hist.recent(10);
assert_eq!(entries[0].provider, "openai");
assert_eq!(entries[0].model, "gpt-4o");
assert_eq!(entries[0].reason_category, "tiered_routing");
assert_eq!(entries[0].ok, Some(true));
assert_eq!((entries[0].input_tokens, entries[0].output_tokens), (Some(10), Some(5)));
assert!(entries[0].latency_ms.is_some());
}
#[tokio::test]
async fn pipeline_registry_complete_uses_specialized_pipeline() {
let mut registry = PipelineRegistry::new(make_test_pipeline(
TaskType::CodeGeneration,
"default-provider",
"default-model",
));
registry.register(
TaskType::CodeGeneration,
make_test_pipeline(TaskType::CodeGeneration, "anthropic", "claude-opus-4-5"),
);
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "write code".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
};
let response = registry.complete(&request).await.unwrap();
assert_eq!(response.id, "test-resp");
}
#[test]
fn test_chat_request_skip_deserializing_auth_context() {
let json = r#"{
"messages": [{"role": "user", "content": "hi"}],
"auth_context": {
"sender_id": "injected",
"channel": "evil",
"permissions": {"level": 2}
}
}"#;
let req: ChatRequest = serde_json::from_str(json).unwrap();
assert!(
req.auth_context.is_none(),
"auth_context should be None after deserialization (skip_deserializing)"
);
}
#[test]
fn test_chat_request_without_auth_context_deserializes() {
let json = r#"{"messages": [{"role": "user", "content": "hi"}]}"#;
let req: ChatRequest = serde_json::from_str(json).unwrap();
assert!(req.auth_context.is_none());
assert_eq!(req.messages.len(), 1);
}
#[test]
fn test_chat_request_none_auth_context_serializes_as_null() {
let req = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hi".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
};
let json = serde_json::to_string(&req).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
parsed.get("auth_context").is_some(),
"auth_context field should be present in serialized JSON"
);
assert!(
parsed["auth_context"].is_null(),
"auth_context: None should serialize as null, got: {}",
parsed["auth_context"]
);
}
#[test]
fn test_chat_request_with_auth_context_serializes_but_not_roundtrip() {
use clawft_types::routing::{AuthContext, UserPermissions};
let req = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hi".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: Some(AuthContext {
sender_id: "user_123".into(),
channel: "telegram".into(),
permissions: UserPermissions::default(),
}),
complexity_boost: 0.0,
tool_choice: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(
json.contains("auth_context"),
"auth_context with Some value should appear in serialized JSON"
);
assert!(
json.contains("user_123"),
"sender_id should appear in serialized JSON"
);
let restored: ChatRequest = serde_json::from_str(&json).unwrap();
assert!(
restored.auth_context.is_none(),
"auth_context should be None after deserialization roundtrip"
);
}
#[tokio::test]
async fn test_pipeline_registry_passes_auth_context() {
use clawft_types::routing::AuthContext;
let registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: Some(AuthContext::cli_default()),
complexity_boost: 0.0,
tool_choice: None,
};
let response = registry.complete(&request).await.unwrap();
assert_eq!(response.id, "test-resp");
}
#[tokio::test]
async fn test_pipeline_registry_works_without_auth_context() {
let registry =
PipelineRegistry::new(make_test_pipeline(TaskType::Chat, "openai", "gpt-4o"));
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
};
let response = registry.complete(&request).await.unwrap();
assert_eq!(response.id, "test-resp");
}
struct CaptureTransport {
system_prompts: std::sync::Mutex<Vec<String>>,
}
#[async_trait]
impl LlmTransport for CaptureTransport {
async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
if let Some(sys) = request.messages.iter().find(|m| m.role == "system") {
self.system_prompts
.lock()
.unwrap()
.push(sys.content.clone());
}
Ok(LlmResponse {
id: "capture-resp".into(),
content: vec![ContentBlock::Text {
text: "ok".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
#[tokio::test]
async fn e2e_evolution_ready_ga_prompt_reaches_transport() {
use crate::pipeline::learner::{TrajectoryLearner, TrajectoryLearnerConfig};
use crate::pipeline::mutation::GaConfig;
let learner = Arc::new(TrajectoryLearner::new(TrajectoryLearnerConfig {
max_trajectories: 32,
poor_threshold: 0.6,
evolution_trigger_count: 2,
check_interval: 1,
ga: GaConfig {
population_size: 6,
elite_count: 2,
generations: 2,
crossover_rate: 0.4,
min_improvement: 0.0,
},
population_path: None,
}));
let poor = Trajectory {
request: ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
},
routing: RoutingDecision {
provider: "test".into(),
model: "test".into(),
reason: "test".into(),
..Default::default()
},
response: LlmResponse {
id: "pre".into(),
content: vec![ContentBlock::Text {
text: "bad".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
total_tokens: 0,
},
metadata: HashMap::new(),
},
quality: QualityScore {
overall: 0.2,
relevance: 0.1,
coherence: 0.2,
},
};
learner.record(&poor);
learner.record(&poor);
assert!(
learner.is_evolution_ready(),
"precondition: evolution_ready must be set before complete()"
);
let transport = Arc::new(CaptureTransport {
system_prompts: std::sync::Mutex::new(Vec::new()),
});
let seed_system =
"Use the search tool to find docs\nAlways verify output\nEnsure accuracy".to_string();
struct SysAssembler {
system: String,
}
#[async_trait]
impl ContextAssembler for SysAssembler {
async fn assemble(
&self,
request: &ChatRequest,
_profile: &TaskProfile,
) -> AssembledContext {
let mut messages = vec![LlmMessage {
role: "system".into(),
content: self.system.clone(),
tool_call_id: None,
tool_calls: None,
}];
messages.extend(request.messages.clone());
AssembledContext {
messages,
token_estimate: 100,
truncated: false,
}
}
}
let pipeline = Pipeline {
classifier: Arc::new(TestClassifier {
task_type: TaskType::Chat,
}),
router: Arc::new(TestRouter {
provider: "test".into(),
model: "test".into(),
}),
assembler: Arc::new(SysAssembler {
system: seed_system.clone(),
}),
transport: transport.clone(),
scorer: Arc::new(TestScorer),
learner: learner.clone() as Arc<dyn LearningBackend>,
};
let registry =
PipelineRegistry::new(pipeline).with_trajectory_learner(Arc::clone(&learner));
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "write a function".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
};
let response = registry.complete(&request).await.unwrap();
assert_eq!(response.id, "capture-resp");
assert!(
!learner.is_evolution_ready(),
"complete() must clear evolution_ready after the GA run"
);
assert!(
learner.population_snapshot().is_some(),
"GA population must be retained after evolution"
);
let seen = transport.system_prompts.lock().unwrap().clone();
assert_eq!(seen.len(), 1, "transport must receive exactly one complete()");
let delivered = &seen[0];
assert!(
!delivered.trim().is_empty(),
"evolved system prompt must not be empty"
);
if let Some(last) = learner.last_evolved_prompt() {
assert_eq!(
&last, delivered,
"transport system prompt must equal last_evolved_prompt"
);
}
}
#[test]
fn apply_prompt_evolution_noop_without_system_message() {
let learner = TestLearner;
let msgs = vec![LlmMessage {
role: "user".into(),
content: "hi".into(),
tool_call_id: None,
tool_calls: None,
}];
let out = apply_prompt_evolution(msgs.clone(), &learner);
assert_eq!(out.len(), 1);
assert_eq!(out[0].content, "hi");
}
}