Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 91 additions & 3 deletions crates/libsy-llm-client/tests/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ use tracing_subscriber::layer::{Context as LayerContext, SubscriberExt};
use tracing_subscriber::registry::LookupSpan;

use switchyard_libsy::{
Algorithm, ClassifyTrigger, Driver, LibsyError, LlmClassifierConfig, LlmTaskClassifier,
PickerMode, RoutingOutcome, RuntimeModels, StageRouter, StageRouterConfig, Step,
TaskClassifierConfig,
Algorithm, ClassifierContractConfig, ClassifyTrigger, DeescalationConfig, Driver,
EscalationJudgeConfig, LibsyError, LlmClassifierConfig, LlmTaskClassifier, PickerMode,
RoutingOutcome, RuntimeModels, StageRouter, StageRouterConfig, Step, TaskClassifierConfig,
};
use switchyard_llm_client::{ClientRouter, RunObservation, RunObserver};
use switchyard_protocol::{Category, ModelId};
Expand Down Expand Up @@ -760,6 +760,94 @@ async fn affinity_warns_once_when_request_has_no_usable_identity() -> switchyard
Ok(())
}

#[tokio::test]
async fn stateful_escalation_warns_once_without_a_session_id() -> switchyard_libsy::Result<()> {
let _guard = serialize_test().lock().await;
let (store, _, _, _, _) = telemetry();
let event_count = store.events().len();
let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
contract: ClassifierContractConfig::default(),
config: EscalationJudgeConfig::default(),
max_output_tokens: 64,
})?) as Arc<dyn Algorithm>;
let client = Arc::new(JudgeClient {
judge_model: "warning-judge".into(),
outcome: JudgeOutcome::Reply(r#"{"escalate":false,"reason":"progressing"}"#),
}) as Arc<dyn RoutedLlmClient>;

for _ in 0..2 {
switchyard_llm_client::run(
router.clone(),
ClientRouter::single(client.clone()),
classifier_request(),
classifier_models("warning-judge", "warning-efficient", "warning-capable"),
None,
)
.await?;
}

let warnings = store.events()[event_count..]
.iter()
.filter(|event| {
event.target == "libsy"
&& event.level == "WARN"
&& event.fields.get("message").is_some_and(|message| {
message.contains("stateful escalation has no session ID")
})
})
.count();
assert_eq!(warnings, 1);
Ok(())
}

#[tokio::test]
async fn deescalation_evidence_stays_pending_until_confirmed() -> switchyard_libsy::Result<()> {
let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
contract: ClassifierContractConfig::default(),
config: EscalationJudgeConfig {
confirmations: 1,
deescalation: Some(DeescalationConfig {
strong_min_calls: 1,
strong_max_calls: None,
confirmations: 2,
weak_cooldown_calls: 0,
}),
..EscalationJudgeConfig::default()
},
max_output_tokens: 64,
})?) as Arc<dyn Algorithm>;
let client = |verdict| {
Arc::new(JudgeClient {
judge_model: "evidence-judge".into(),
outcome: JudgeOutcome::Reply(verdict),
}) as Arc<dyn RoutedLlmClient>
};
let request = request_with_metadata("evidence-session", "evidence-correlation");

switchyard_llm_client::run(
router.clone(),
ClientRouter::single(client(r#"{"escalate":true,"reason":"stuck"}"#)),
request.clone(),
classifier_models("evidence-judge", "evidence-efficient", "evidence-capable"),
None,
)
.await?;
let outcome = switchyard_llm_client::decide(
router,
ClientRouter::single(client(r#"{"escalate":false,"reason":"recovered"}"#)),
request,
classifier_models("evidence-judge", "evidence-efficient", "evidence-capable"),
)
.await?;

assert_eq!(outcome.selected_model_id()?.as_str(), "evidence-capable");
assert_eq!(
outcome.metadata.and_then(|metadata| metadata.evidence),
Some(json!({"source": "escalation", "verdict": "pending"}))
);
Ok(())
}

#[tokio::test]
async fn affinity_keeps_the_algorithm_selection_after_client_fallback()
-> switchyard_libsy::Result<()> {
Expand Down
Loading