Skip to main content

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}