Skip to main content

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}