use serde::{Deserialize, Serialize};
use crate::manifest::{ModalitySet, Posture};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ConsumerKind {
Bot,
Human,
Terminal,
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolverContext {
pub kind: ConsumerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
#[serde(default)]
pub modalities: ModalitySet,
pub posture: Posture,
}
impl ResolverContext {
#[must_use]
pub fn human() -> Self {
Self {
kind: ConsumerKind::Human,
model_family: None,
harness: None,
modalities: ModalitySet::empty(),
posture: Posture::Attended,
}
}
#[must_use]
pub fn anonymous_agent() -> Self {
Self {
kind: ConsumerKind::Agent,
model_family: None,
harness: None,
modalities: ModalitySet::TEXT,
posture: Posture::Headless,
}
}
}
#[derive(Debug, Clone)]
pub enum ConsumerHint<'a> {
UserAgent(&'a str),
Explicit(ResolverContext),
}
const BOT_MARKERS: &[&str] = &[
"slackbot",
"twitterbot",
"facebookexternalhit",
"discordbot",
"linkedinbot",
"whatsapp",
"telegrambot",
"googlebot",
"bingbot",
"embedly",
];
const TERMINAL_MARKERS: &[&str] = &["curl", "wget", "httpie", "python-requests"];
#[must_use]
pub fn negotiate(hint: &ConsumerHint<'_>) -> ResolverContext {
match hint {
ConsumerHint::Explicit(ctx) => ctx.clone(),
ConsumerHint::UserAgent(ua) => {
let lower = ua.to_ascii_lowercase();
let kind = if BOT_MARKERS.iter().any(|m| lower.contains(m)) {
ConsumerKind::Bot
} else if TERMINAL_MARKERS.iter().any(|m| lower.contains(m)) {
ConsumerKind::Terminal
} else {
ConsumerKind::Human
};
ResolverContext {
kind,
model_family: None,
harness: None,
modalities: ModalitySet::empty(),
posture: Posture::Attended,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_agent_classes() {
let bot = negotiate(&ConsumerHint::UserAgent("Slackbot-LinkExpanding 1.0"));
assert_eq!(bot.kind, ConsumerKind::Bot);
let term = negotiate(&ConsumerHint::UserAgent("curl/8.6.0"));
assert_eq!(term.kind, ConsumerKind::Terminal);
let human = negotiate(&ConsumerHint::UserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15",
));
assert_eq!(human.kind, ConsumerKind::Human);
let unknown = negotiate(&ConsumerHint::UserAgent("SomethingNew/0.1"));
assert_eq!(
unknown.kind,
ConsumerKind::Human,
"unknown defaults to human"
);
}
#[test]
fn explicit_context_passes_through_unchanged() {
let ctx = ResolverContext {
kind: ConsumerKind::Agent,
model_family: Some("claude".into()),
harness: Some("claude-code".into()),
modalities: ModalitySet::TEXT.with(ModalitySet::VISION),
posture: Posture::Headless,
};
assert_eq!(negotiate(&ConsumerHint::Explicit(ctx.clone())), ctx);
}
#[test]
fn context_serde_roundtrip() {
let ctx = ResolverContext::anonymous_agent();
let json = serde_json::to_string(&ctx).unwrap();
let back: ResolverContext = serde_json::from_str(&json).unwrap();
assert_eq!(back, ctx);
}
}