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