contextgraph_host/provider.rs
1//! The uniform provider handle.
2//!
3//! Every context source — an in-process built-in, a child process over
4//! stdio, or a remote HTTP endpoint — reaches the host through one trait,
5//! [`ContextProvider`]. Its shape mirrors the two Context Graph Protocol methods a host always
6//! needs: capability negotiation (cached from the handshake, SPEC.md §3) and
7//! `context/query` (SPEC.md §5). `stella-context` and any other Rust agent drive
8//! all three provider kinds through this single interface
9//! (`SPEC.md` §1 — "usable by any other Rust agent that wants Context Graph Protocol
10//! support").
11
12use async_trait::async_trait;
13use contextgraph_types::{
14 Capabilities, ContextQuery, ContextQueryResult, FrameKind, ProviderInfo, Verdict,
15 VerifyRequest, VerifyResponse,
16};
17
18use crate::error::HostError;
19
20/// A registered Context Graph Protocol provider, queryable behind one handle regardless of
21/// transport. `info()`/`capabilities()` return values cached at handshake
22/// time, so they are cheap synchronous getters even for out-of-process
23/// providers.
24#[async_trait]
25pub trait ContextProvider: Send + Sync {
26 /// The provider's host-facing id — its routing key and its consent key
27 /// (`SPEC.md` §4 and §10).
28 fn id(&self) -> &str;
29
30 /// Identity + declared data-flow direction, surfaced at consent time
31 /// (SPEC.md §3, ).
32 fn info(&self) -> &ProviderInfo;
33
34 /// Capabilities negotiated at the handshake (SPEC.md §3) — which frame kinds
35 /// and filters this provider serves, whether it upserts, does graph, is
36 /// an embedder, or supports subscriptions.
37 fn capabilities(&self) -> &Capabilities;
38
39 /// Answer a context query with budgeted, provenance-carrying frames
40 /// (SPEC.md §5). The host — not the provider — enforces the budget and consent;
41 /// a provider that over-runs its budget is caught by the host, not
42 /// trusted (`crate::host`).
43 async fn query(&self, query: &ContextQuery) -> Result<ContextQueryResult, HostError>;
44
45 /// Revalidate frames the host already holds, without any frame body
46 /// travelling (`docs/context-reuse.md` §4 `context/verify`).
47 ///
48 /// Defaults to answering [`Verdict::Unknown`](contextgraph_types::Verdict::Unknown)
49 /// for every requested identity, so an existing provider implements
50 /// nothing and is simply treated as unable to vouch for its frames — the
51 /// host then re-queries them. A provider that overrides this **MUST** also
52 /// advertise [`Capabilities::verify`](contextgraph_types::Capabilities::verify),
53 /// since the host only asks providers that declare support.
54 async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
55 Ok(VerifyResponse::uniform(request, Verdict::Unknown))
56 }
57
58 /// Shut the provider down cleanly (SPEC.md §3 lifecycle). In-process providers
59 /// default to a no-op; transport-backed providers send `shutdown` and
60 /// reap their child. Overridable.
61 async fn shutdown(&self) -> Result<(), HostError> {
62 Ok(())
63 }
64}
65
66/// The snake_case wire name of a [`FrameKind`], matching its `serde`
67/// representation and the strings a provider lists in
68/// [`Capabilities::query`]'s `kinds`.
69pub fn frame_kind_name(kind: FrameKind) -> &'static str {
70 match kind {
71 FrameKind::Snippet => "snippet",
72 FrameKind::Symbol => "symbol",
73 FrameKind::Fact => "fact",
74 FrameKind::Doc => "doc",
75 FrameKind::Memory => "memory",
76 FrameKind::Episode => "episode",
77 FrameKind::Graph => "graph",
78 }
79}
80
81/// Whether a provider is worth querying for a given request. A query with no
82/// `kinds` filter matches every provider (the host wants everyone's best
83/// frames, SPEC.md §5); otherwise a provider matches when it declares at least one
84/// of the requested frame kinds. Used by `query_all` to fan out only to
85/// relevant providers.
86pub fn capability_matches(caps: &Capabilities, query: &ContextQuery) -> bool {
87 if query.kinds.is_empty() {
88 return true;
89 }
90 query.kinds.iter().any(|requested| {
91 let name = frame_kind_name(*requested);
92 caps.query.kinds.iter().any(|served| served == name)
93 })
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use contextgraph_types::capability::QueryCapability;
100
101 fn caps_for(kinds: &[&str]) -> Capabilities {
102 Capabilities {
103 query: QueryCapability {
104 kinds: kinds.iter().map(|k| k.to_string()).collect(),
105 },
106 ..Capabilities::default()
107 }
108 }
109
110 fn query_for(kinds: Vec<FrameKind>) -> ContextQuery {
111 ContextQuery {
112 goal: "g".into(),
113 query_text: None,
114 embedding: None,
115 kinds,
116 anchors: vec![],
117 max_frames: 5,
118 max_tokens: 1000,
119 as_of: None,
120 representation_preferences: vec![],
121 }
122 }
123
124 #[test]
125 fn frame_kind_names_match_serde_snake_case() {
126 // The names a provider lists must be exactly the frames' serde names.
127 for (kind, name) in [
128 (FrameKind::Snippet, "snippet"),
129 (FrameKind::Symbol, "symbol"),
130 (FrameKind::Fact, "fact"),
131 (FrameKind::Doc, "doc"),
132 (FrameKind::Memory, "memory"),
133 (FrameKind::Episode, "episode"),
134 (FrameKind::Graph, "graph"),
135 ] {
136 assert_eq!(frame_kind_name(kind), name);
137 let serde_name = serde_json::to_value(kind).unwrap();
138 assert_eq!(serde_name, serde_json::Value::String(name.to_string()));
139 }
140 }
141
142 #[test]
143 fn an_empty_kind_filter_matches_every_provider() {
144 let caps = caps_for(&["doc"]);
145 assert!(capability_matches(&caps, &query_for(vec![])));
146 }
147
148 #[test]
149 fn a_kind_filter_matches_only_overlapping_providers() {
150 let doc_provider = caps_for(&["doc", "snippet"]);
151 assert!(capability_matches(
152 &doc_provider,
153 &query_for(vec![FrameKind::Doc])
154 ));
155 assert!(capability_matches(
156 &doc_provider,
157 &query_for(vec![FrameKind::Fact, FrameKind::Snippet])
158 ));
159 assert!(!capability_matches(
160 &doc_provider,
161 &query_for(vec![FrameKind::Memory, FrameKind::Episode])
162 ));
163 }
164}