Skip to main content

waggle_core/
context.rs

1//! The resolver's context: who is asking, and [`negotiate`] — the
2//! generalized content negotiation that turns a consumer hint into a
3//! [`ResolverContext`] (design docs `02 §2`, `06 §1`).
4//!
5//! Rich extraction (harness metadata, A2A Agent Cards) lives in
6//! `waggle-agent`; the core knows only the neutral schema and the
7//! user-agent classes every deployment needs.
8
9use serde::{Deserialize, Serialize};
10
11use crate::manifest::{ModalitySet, Posture};
12
13/// The coarse consumer class — the *maximum* actor granularity the event
14/// log may ever hold (invariant I-7).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum ConsumerKind {
18    /// An unfurl/preview bot.
19    Bot,
20    /// A human in a browser.
21    Human,
22    /// A terminal client (curl-class).
23    Terminal,
24    /// An AI agent presenting a context.
25    Agent,
26}
27
28/// The neutral resolver-context schema — waggle's lingua franca. External
29/// schemas (harness metadata, A2A cards) reach it through extractors.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ResolverContext {
32    /// The coarse consumer class.
33    pub kind: ConsumerKind,
34    /// Model family (`claude`, `gpt`, `gemini`, …), if declared. Families
35    /// only — never versions or instance identifiers (I-7).
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub model_family: Option<String>,
38    /// Harness (`claude-code`, `codex`, …), if declared.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub harness: Option<String>,
41    /// Modalities the consumer can exercise.
42    #[serde(default)]
43    pub modalities: ModalitySet,
44    /// Execution posture.
45    pub posture: Posture,
46}
47
48impl ResolverContext {
49    /// A human in a browser — the 301 path.
50    #[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    /// An anonymous agent context with nothing declared — matches only
62    /// catch-all and modality-free variants.
63    #[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/// What a consumer presented at the door.
76#[derive(Debug, Clone)]
77pub enum ConsumerHint<'a> {
78    /// An HTTP `User-Agent` string (web and terminal consumers).
79    UserAgent(&'a str),
80    /// An explicit, already-built context (agents; extractor output).
81    Explicit(ResolverContext),
82}
83
84/// Case-insensitive markers for unfurl/preview bots.
85const 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
98/// Case-insensitive markers for terminal clients.
99const TERMINAL_MARKERS: &[&str] = &["curl", "wget", "httpie", "python-requests"];
100
101/// Turn a hint into a context. Pure and total: unknown user agents are
102/// humans (the safe default — disclosure, not projection).
103#[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}