agentd/intel/discovery.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Optional model discovery (capability-negotiated). RFC 0018 §5.4.
3//!
4//! agentd may learn what an endpoint serves via a tiny handshake — **off unless
5//! an endpoint looks discovery-capable, silent on failure, never fatal, never on
6//! the hot path, never at startup before a side effect** (RFC 0018 decision 5).
7//!
8//! The probe is one hand-rolled HTTP `GET /v1/models` over the EXISTING intel
9//! transport ([`super::endpoints::Endpoint::discover_models`]) — no new client,
10//! no streaming, zero new deps. For an OpenAI-compatible endpoint it parses
11//! `{ "data": [ { "id": "…" } ] }`; the `anthropic` dialect has no list endpoint
12//! → it contributes nothing (the configured `model` is dialed regardless).
13//!
14//! The SURFACE that consumes this — `agentd://intelligence` + the capabilities
15//! manifest `intelligence.models` — is supervisor-side (RFC 0018 §4.4 / RFC 0015
16//! §capabilities). The architecture adaptation of the RFC's "lazy on first
17//! `complete_once`" (a child-side hook) is to probe **supervisor-side, lazily +
18//! cached, on read of the served surface**: those reads are infrequent and
19//! operator-driven, so a cached probe there is the right seam, and it keeps the
20//! additive discovery field off the control protocol. The cache + TTL live in
21//! [`crate::mcp::server::ServeCtx`]; this module is the pure probe.
22
23use std::time::Duration;
24
25use super::endpoints::EndpointList;
26
27/// The default per-probe timeout (RFC 0018 §5.4 — "a SHORT timeout, it's
28/// best-effort"). Discovery is off the hot path; a slow/wedged endpoint must not
29/// stall the operator-driven read that triggered the probe.
30pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
31
32/// The discovery outcome for the served surface (RFC 0018 §5.4):
33/// - `discovery`: at least one endpoint answered `/v1/models`.
34/// - `models`: the union of discovered ids across endpoints **+** the configured
35/// `model`, de-duplicated, order-stable (`[]` if none discovered AND no model).
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct DiscoveryResult {
38 pub discovery: bool,
39 pub models: Vec<String>,
40}
41
42/// Probe every OpenAI-compatible endpoint in `list` for its served models and
43/// fold the result into a [`DiscoveryResult`] (RFC 0018 §5.4). `model` is the
44/// configured model id, always unioned in (it is usable regardless of whether
45/// any endpoint answered discovery). Best-effort + silent: a per-endpoint probe
46/// that fails (404 / connection / non-JSON) simply contributes no models and does
47/// not flip `discovery` — it is NEVER fatal, NEVER a failover-class error (§5.4).
48///
49/// Not called on the hot path or at startup — only when the served
50/// `agentd://intelligence` / live `agentd://capabilities` surface is actually
51/// read (the supervisor caches it; [`crate::mcp::server::ServeCtx`]).
52pub fn discover(list: &EndpointList, model: Option<&str>, timeout: Duration) -> DiscoveryResult {
53 let mut models: Vec<String> = Vec::new();
54 let mut any = false;
55
56 for ep in list.iter() {
57 let discovered = ep.discover_models(timeout);
58 if !discovered.is_empty() {
59 any = true;
60 for m in discovered {
61 if !models.contains(&m) {
62 models.push(m);
63 }
64 }
65 }
66 }
67
68 // §5.4: union of discovered + the configured model. The configured model is
69 // always usable, so it joins the set — but its presence alone does NOT set
70 // `discovery` (which means "an endpoint answered the probe").
71 if let Some(m) = model
72 && !m.is_empty()
73 && !models.contains(&m.to_string())
74 {
75 models.push(m.to_string());
76 }
77
78 DiscoveryResult {
79 discovery: any,
80 models,
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 fn list_of(uri: &str) -> EndpointList {
89 EndpointList::parse_with_env(uri, None, &|_| None).unwrap()
90 }
91
92 // A tiny single-shot HTTP server answering a fixed status + body to one GET,
93 // so the probe dials a REAL endpoint over the real transport.
94 use std::io::{Read, Write};
95 use std::net::TcpListener;
96
97 fn serve_once(status: u16, body: &'static str) -> String {
98 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
99 let port = listener.local_addr().unwrap().port();
100 std::thread::spawn(move || {
101 if let Ok((mut s, _)) = listener.accept() {
102 let mut buf = [0u8; 2048];
103 let _ = s.read(&mut buf); // drain the request line + headers
104 let resp = format!(
105 "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
106 body.len()
107 );
108 let _ = s.write_all(resp.as_bytes());
109 let _ = s.flush();
110 }
111 });
112 format!("http://127.0.0.1:{port}")
113 }
114
115 fn dead_endpoint() -> String {
116 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
117 let port = listener.local_addr().unwrap().port();
118 drop(listener);
119 format!("http://127.0.0.1:{port}")
120 }
121
122 #[test]
123 fn discovers_models_and_unions_configured() {
124 let uri = serve_once(
125 200,
126 r#"{"data":[{"id":"claude-opus-4"},{"id":"claude-haiku-4"}]}"#,
127 );
128 let list = list_of(&uri);
129 let r = discover(&list, Some("claude-opus-4"), Duration::from_secs(2));
130 assert!(r.discovery, "an endpoint answered /v1/models");
131 // union of discovered + configured, de-duplicated (opus appears once).
132 assert_eq!(
133 r.models,
134 vec!["claude-opus-4".to_string(), "claude-haiku-4".to_string()]
135 );
136 }
137
138 #[test]
139 fn configured_model_is_added_when_not_already_discovered() {
140 let uri = serve_once(200, r#"{"data":[{"id":"served-model"}]}"#);
141 let list = list_of(&uri);
142 let r = discover(&list, Some("configured-model"), Duration::from_secs(2));
143 assert!(r.discovery);
144 assert_eq!(
145 r.models,
146 vec!["served-model".to_string(), "configured-model".to_string()]
147 );
148 }
149
150 #[test]
151 fn http_404_degrades_silently_to_no_discovery() {
152 // 404 → discovery unsupported for the endpoint: discovery=false, but the
153 // configured model is still in `models` (it is usable regardless).
154 let uri = serve_once(404, r#"{"error":"not found"}"#);
155 let list = list_of(&uri);
156 let r = discover(&list, Some("only-configured"), Duration::from_secs(2));
157 assert!(!r.discovery, "a 404 is not an answer");
158 assert_eq!(r.models, vec!["only-configured".to_string()]);
159 }
160
161 #[test]
162 fn connection_failure_degrades_silently() {
163 let list = list_of(&dead_endpoint());
164 let r = discover(&list, Some("m"), Duration::from_secs(1));
165 assert!(!r.discovery);
166 assert_eq!(r.models, vec!["m".to_string()]);
167 }
168
169 #[test]
170 fn non_json_body_degrades_silently() {
171 let uri = serve_once(200, "<html>not json</html>");
172 let list = list_of(&uri);
173 let r = discover(&list, Some("m"), Duration::from_secs(2));
174 assert!(!r.discovery, "a non-JSON 200 yields no models");
175 assert_eq!(r.models, vec!["m".to_string()]);
176 }
177
178 #[test]
179 fn no_configured_and_no_discovery_is_empty() {
180 let list = list_of(&dead_endpoint());
181 let r = discover(&list, None, Duration::from_secs(1));
182 assert!(!r.discovery);
183 assert!(r.models.is_empty(), "[] if none discovered + no model");
184 }
185}