-
Notifications
You must be signed in to change notification settings - Fork 31
feat(libsy): stage router with handoff notes, per-tier prompts, and LLM fallback #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ use async_trait::async_trait; | |
| use parking_lot::Mutex; | ||
| use tokio::sync::Mutex as AsyncMutex; | ||
|
|
||
| use crate::core::{Classifier, Event, Processor, Score}; | ||
| use crate::core::{Classification, Classifier, Event, Processor, Score}; | ||
| use crate::{ | ||
| Algorithm, Context, Decision, Driver, LibsyError, LlmTargetSet, Request, Response, Result, | ||
| RoutedLlmClient, | ||
|
|
@@ -54,7 +54,42 @@ impl Decision for FallThroughDecision { | |
| } | ||
| } | ||
|
|
||
| /// Processor chain → classifier cascade → routed model call. See the [module docs](self). | ||
| /// Terminal classifier for a cascade whose classifiers may all abstain. | ||
| /// | ||
| /// A classifier abstains when it cannot decide, which lets the next one try. The | ||
| /// last has no next, so a cascade that could abstain all the way through needs a | ||
| /// decider that never does. Which target that is belongs to whoever assembles the | ||
| /// cascade, not to the classifiers in it. | ||
| pub struct DefaultTarget { | ||
| target: String, | ||
| } | ||
|
|
||
| impl DefaultTarget { | ||
| /// Close a cascade with `target`. | ||
| pub fn new(target: impl Into<String>) -> Self { | ||
| Self { | ||
| target: target.into(), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[async_trait] | ||
| impl<S: Send> Classifier<S> for DefaultTarget { | ||
| async fn score( | ||
| &self, | ||
| _state: &mut S, | ||
| _request: &mut Request, | ||
| _driver: Option<&Driver>, | ||
| ) -> Result<Classification> { | ||
| // Zero confidence: this is a fallback, not a judgement. | ||
| Ok(Classification::Scores(vec![Score { | ||
| target: self.target.clone(), | ||
| confidence: 0.0, | ||
| }])) | ||
| } | ||
| } | ||
|
|
||
| /// Processor chain → classifier cascade → routed model call. See the module docs. | ||
| /// | ||
| /// The generic state type is shared by every processor and classifier in the composition. | ||
| pub struct FallThrough<S = ()> { | ||
|
|
@@ -193,12 +228,10 @@ where | |
| // 2. Fall through the cascade: the first classifier to score decides (argmax). The | ||
| // per-request driver is offered to each — driver-backed classifiers use it. | ||
| let mut maybe_score: Option<Score> = None; | ||
| let mut maybe_tier: Option<&'static str> = None; | ||
| for classifier in &self.classifiers { | ||
| let scores = classifier.score(state, request, Some(driver)).await?; | ||
| maybe_score = scores.argmax(false)?; | ||
| if let Some(s) = maybe_score.as_ref() { | ||
| maybe_tier = classifier.routing_tier(&s.target); | ||
| if maybe_score.is_some() { | ||
| break; | ||
| } | ||
| } | ||
|
|
@@ -208,26 +241,39 @@ where | |
| }); | ||
| }; | ||
|
|
||
| // 3. Resolve the target and publish the decision. | ||
| // 3. Resolve the target and publish the decision. A tier is a fact about the | ||
| // target, not about which classifier happened to name it first, so any member | ||
| // of the cascade may answer — a terminal fallback that decides a turn would | ||
| // otherwise leave the call untiered. | ||
| let target = self.targets.get_target(&score.target)?; | ||
| let decision: Arc<dyn Decision> = Arc::new(FallThroughDecision { | ||
| selected_model: score.target.clone(), | ||
| reasoning: (self.decision_reason)(&self.name, &score), | ||
| tier: maybe_tier, | ||
| tier: self | ||
| .classifiers | ||
| .iter() | ||
| .find_map(|classifier| classifier.routing_tier(&score.target)), | ||
| }); | ||
| driver.info(ctx.clone(), decision.clone()).await?; | ||
|
|
||
| // 4. Replay the decision to the processors so stateful ones can bind it. | ||
| // 4. Post-decision replay, in two passes over the same chain. Every processor sees | ||
| // the decision first, so stateful ones bind it; only then is the outbound | ||
| // request offered for rewriting, paired with the decision that routed it. The | ||
| // order matters: a processor rewriting the request in the second pass sees the | ||
| // state every processor settled in the first. | ||
| for processor in &self.processors { | ||
| let event = Event::Decision { | ||
| request, | ||
| decision: decision.as_ref(), | ||
| }; | ||
| processor.process(state, event).await?; | ||
| } | ||
| for processor in &self.processors { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @linj-glitch please grab this for your latching mechanism with escalation router. For confirmation count and session affinity, this will be the path for next request to skip all classifiers.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cc: @messiaen for confirmation |
||
| processor | ||
| .process( | ||
| state, | ||
| Event::Decision { | ||
| request, | ||
| decision: decision.as_ref(), | ||
| }, | ||
| ) | ||
| .await?; | ||
| let event = Event::ModelRequest { | ||
| request, | ||
| decision: decision.as_ref(), | ||
| }; | ||
| processor.process(state, event).await?; | ||
| } | ||
|
|
||
| Ok((target, decision)) | ||
|
|
@@ -500,10 +546,11 @@ mod tests { | |
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn processor_observes_request_and_decision() -> Result<()> { | ||
| async fn processor_observes_request_decision_then_model_request() -> Result<()> { | ||
| use parking_lot::Mutex; | ||
|
|
||
| // Records which event kinds it saw, proving the request-then-decision replay. | ||
| // Records which event kinds it saw, proving the replay order: the inbound | ||
| // request, then the decision, then the request on its way to the model. | ||
| struct RecordingProcessor(Arc<Mutex<Vec<&'static str>>>); | ||
|
|
||
| #[async_trait] | ||
|
|
@@ -512,6 +559,7 @@ mod tests { | |
| let kind = match event { | ||
| Event::Request(_) => "request", | ||
| Event::Decision { .. } => "decision", | ||
| Event::ModelRequest { .. } => "model_request", | ||
| _ => "other", | ||
| }; | ||
| self.0.lock().push(kind); | ||
|
|
@@ -525,7 +573,7 @@ mod tests { | |
| .with_classifier(fixed(vec![score("strong", 1.0)])); | ||
| run(router).await?; | ||
|
|
||
| assert_eq!(*seen.lock(), vec!["request", "decision"]); | ||
| assert_eq!(*seen.lock(), vec!["request", "decision", "model_request"]); | ||
| Ok(()) | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No preference on how we are setting default target when all algorithms fail to provide a score. Please help review.