1use serde::{Deserialize, Serialize};
10
11use crate::manifest::{ModalitySet, Posture};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum ConsumerKind {
18 Bot,
20 Human,
22 Terminal,
24 Agent,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ResolverContext {
32 pub kind: ConsumerKind,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub model_family: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub harness: Option<String>,
41 #[serde(default)]
43 pub modalities: ModalitySet,
44 pub posture: Posture,
46}
47
48impl ResolverContext {
49 #[must_use]
51 pub fn human() -> Self {
52 Self {
53 kind: ConsumerKind::Human,
54 model_family: None,
55 harness: None,
56 modalities: ModalitySet::empty(),
57 posture: Posture::Attended,
58 }
59 }
60
61 #[must_use]
64 pub fn anonymous_agent() -> Self {
65 Self {
66 kind: ConsumerKind::Agent,
67 model_family: None,
68 harness: None,
69 modalities: ModalitySet::TEXT,
70 posture: Posture::Headless,
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
77pub enum ConsumerHint<'a> {
78 UserAgent(&'a str),
80 Explicit(ResolverContext),
82}
83
84const BOT_MARKERS: &[&str] = &[
86 "slackbot",
87 "twitterbot",
88 "facebookexternalhit",
89 "discordbot",
90 "linkedinbot",
91 "whatsapp",
92 "telegrambot",
93 "googlebot",
94 "bingbot",
95 "embedly",
96];
97
98const TERMINAL_MARKERS: &[&str] = &["curl", "wget", "httpie", "python-requests"];
100
101#[must_use]
104pub fn negotiate(hint: &ConsumerHint<'_>) -> ResolverContext {
105 match hint {
106 ConsumerHint::Explicit(ctx) => ctx.clone(),
107 ConsumerHint::UserAgent(ua) => {
108 let lower = ua.to_ascii_lowercase();
109 let kind = if BOT_MARKERS.iter().any(|m| lower.contains(m)) {
110 ConsumerKind::Bot
111 } else if TERMINAL_MARKERS.iter().any(|m| lower.contains(m)) {
112 ConsumerKind::Terminal
113 } else {
114 ConsumerKind::Human
115 };
116 ResolverContext {
117 kind,
118 model_family: None,
119 harness: None,
120 modalities: ModalitySet::empty(),
121 posture: Posture::Attended,
122 }
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn user_agent_classes() {
133 let bot = negotiate(&ConsumerHint::UserAgent("Slackbot-LinkExpanding 1.0"));
134 assert_eq!(bot.kind, ConsumerKind::Bot);
135 let term = negotiate(&ConsumerHint::UserAgent("curl/8.6.0"));
136 assert_eq!(term.kind, ConsumerKind::Terminal);
137 let human = negotiate(&ConsumerHint::UserAgent(
138 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15",
139 ));
140 assert_eq!(human.kind, ConsumerKind::Human);
141 let unknown = negotiate(&ConsumerHint::UserAgent("SomethingNew/0.1"));
142 assert_eq!(
143 unknown.kind,
144 ConsumerKind::Human,
145 "unknown defaults to human"
146 );
147 }
148
149 #[test]
150 fn explicit_context_passes_through_unchanged() {
151 let ctx = ResolverContext {
152 kind: ConsumerKind::Agent,
153 model_family: Some("claude".into()),
154 harness: Some("claude-code".into()),
155 modalities: ModalitySet::TEXT.with(ModalitySet::VISION),
156 posture: Posture::Headless,
157 };
158 assert_eq!(negotiate(&ConsumerHint::Explicit(ctx.clone())), ctx);
159 }
160
161 #[test]
162 fn context_serde_roundtrip() {
163 let ctx = ResolverContext::anonymous_agent();
164 let json = serde_json::to_string(&ctx).unwrap();
165 let back: ResolverContext = serde_json::from_str(&json).unwrap();
166 assert_eq!(back, ctx);
167 }
168}