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
1 change: 1 addition & 0 deletions crates/libsy/examples/research_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ async fn main() -> Result<()> {
capability_elevated_floor: None,
session_affinity: false,
message_hash_fallback: false,
recent_turn_window: None,
},
)?);

Expand Down
1 change: 1 addition & 0 deletions crates/libsy/examples/research_agent_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ async fn main() -> Result<()> {
capability_elevated_floor: None,
session_affinity: false,
message_hash_fallback: false,
recent_turn_window: None,
},
)?);

Expand Down
8 changes: 6 additions & 2 deletions crates/libsy/src/algorithms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ pub mod llm_class;
pub mod noop;
pub mod passthrough;
pub mod rand;
pub mod stage;

pub use fall_through::{FallThrough, FallThroughDecision};
pub use fall_through::{DefaultTarget, FallThrough, FallThroughDecision};
pub use llm_class::{LlmTaskClassifier, TaskClassifierConfig};
pub use noop::{Noop, NoopDecision};
pub use passthrough::{Passthrough, PassthroughDecision};
pub use rand::{Random, RandomClassifier, RandomDecision};
pub use util::{AffinityRouter, SubagentOverride};
pub use stage::{LlmFallback, StageRouter, StageRouterConfig};
pub use util::{
append_note, AffinityRouter, SubagentOverride, SystemPromptProcessor, TargetPrompts,
};

pub mod util;

Expand Down
88 changes: 68 additions & 20 deletions crates/libsy/src/algorithms/fall_through.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor Author

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.

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 = ()> {
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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))
Expand Down Expand Up @@ -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]
Expand All @@ -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);
Expand All @@ -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(())
}

Expand Down
Loading
Loading