agentd-core 1.4.0

Minimal, MCP-native agent runtime as a library: the agentic loop, supervisor, workflows, and code-registered tools (the agentd engine)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
// SPDX-License-Identifier: AGPL-3.0-only
//! The failover decision — sticky-primary with a bounded sweep.
//!
//! The client's `complete()` is wrapped: try the **active** endpoint; on a
//! FAILOVER-CLASS error (connect refused or reset, timeout, HTTP 5xx, 429 that
//! survived the endpoint's own retry, or a circuit-open skip) advance to the
//! next *available* endpoint in list order. A *non*-failover error — 401/403
//! auth, a 4xx request error, a malformed body — returns immediately, because
//! it would be identical on every endpoint and trying the rest would only burn
//! the run deadline while hiding the real cause. On success, `active` snaps back
//! to the lowest-index healthy endpoint, so serving from a fallback is temporary
//! by construction.
//!
//! This module holds the entire selection control flow; the wire, adapter and
//! JSON path sit below it untouched. Each `complete_once` dials a fresh
//! connection, which is what makes a re-dial safe to attempt at all. The only
//! state kept between calls is the cheap per-endpoint health and breaker record.

use std::time::Duration;

use super::client::IntelError;
use super::endpoints::EndpointList;
use super::health::{BreakerTransition, ErrKind};
use crate::wire::intel::{Request, Response};

/// How a single endpoint's outcome is classified for failover.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailoverClass {
    /// Try the next endpoint (connect refused/reset, timeout, 5xx, 429).
    Failover(ErrKind),
    /// Do not fail over — fatal/observation, identical on every endpoint
    /// (auth 401/403, 4xx, malformed body).
    Fatal,
}

/// Classify an [`IntelError`] for the failover sweep. This decides only whether
/// to try ANOTHER endpoint; the same-endpoint transient retry has already run
/// inside `complete_once` before an error reaches here, so anything classified
/// `Failover` has already survived that retry.
pub fn classify(err: &IntelError) -> FailoverClass {
    match err {
        // Transport-layer failures are always failover-class: the endpoint is
        // down/moving/wedged — a sibling may be fine.
        IntelError::Transport(e) => {
            use std::io::ErrorKind::*;
            let kind = match e.kind() {
                ConnectionRefused => ErrKind::Refused,
                ConnectionReset | ConnectionAborted | BrokenPipe => ErrKind::Reset,
                TimedOut | WouldBlock => ErrKind::Timeout,
                _ => ErrKind::Refused, // NotFound (DNS), other I/O → treat as down
            };
            FailoverClass::Failover(kind)
        }
        // HTTP status: 5xx and 429 are failover-class; 401/403 (auth) and other
        // 4xx are fatal — a bad request/credential is bad on every endpoint.
        IntelError::Http(code, _) => match *code {
            500 | 502 | 503 | 504 => FailoverClass::Failover(ErrKind::Http5xx),
            429 => FailoverClass::Failover(ErrKind::Http429),
            // any other 5xx is still upstream-transient
            c if (500..600).contains(&c) => FailoverClass::Failover(ErrKind::Http5xx),
            _ => FailoverClass::Fatal, // 401/403/4xx
        },
        // A malformed body is a bad response everywhere → observation/abort.
        IntelError::Parse(_) => FailoverClass::Fatal,
        // An unsupported transport is a config error, not a transient outage.
        IntelError::Unsupported(_) => FailoverClass::Fatal,
        // All-endpoints-down is already terminal — not re-classified.
        IntelError::AllEndpointsDown(_) => FailoverClass::Fatal,
    }
}

/// Is this a fatal **auth** failure (401/403)? The all-down backoff needs to
/// distinguish the two: an auth failure on every endpoint is a misconfiguration
/// and exits 4 immediately rather than entering the backoff loop. Retrying a
/// credential error would mask it as a transient outage and leave the operator
/// with a daemon that looks alive but never works.
pub fn is_auth(err: &IntelError) -> bool {
    matches!(err, IntelError::Http(401 | 403, _))
}

/// HTTP statuses a **same-endpoint** retry may clear: a 429 rate-limit or an
/// upstream 5xx blip. This set must stay identical to the failover-class HTTP
/// split in [`classify`], or a status could be retried in place yet refuse to
/// fail over (or the reverse). A non-transient 4xx — bad request, auth — is a
/// caller error that is identical on a re-dial and must surface immediately.
pub fn is_transient_status(code: u16) -> bool {
    code == 429 || (500..600).contains(&code)
}

/// The result of one failover sweep, plus the side-channel of breaker and
/// active-endpoint transitions. The sweep observes these but emits nothing
/// itself; the caller turns them into metrics, events and the
/// `agentd://intelligence` body, which keeps this module free of observability
/// dependencies.
pub struct SweepResult {
    pub outcome: Result<Response, IntelError>,
    /// `(from, to)` if a failover advanced the endpoint within the sweep.
    pub failover: Option<(usize, usize)>,
    /// Breaker transitions observed, as `(endpoint_index, transition)`.
    pub breaker_changes: Vec<(usize, BreakerTransition)>,
    /// The new active index if it changed (failover or snap-back).
    pub active_change: Option<usize>,
    /// The endpoint that ultimately served the request (on success).
    pub served_by: Option<usize>,
}

/// Drive one bounded failover sweep for a single logical `complete`. The sweep
/// visits at most `eps.len()` distinct endpoints and each of them at most once,
/// so one `complete` can never loop over the list.
pub fn complete_resilient(
    list: &mut EndpointList,
    req: &Request,
    timeout: Duration,
    trace_id: Option<&str>,
) -> SweepResult {
    let order = list.attempt_order();
    let cfg = *list.breaker_config();
    let mut breaker_changes = Vec::new();
    let mut failover = None;
    let mut last_err: Option<IntelError> = None;
    let mut prev_idx: Option<usize> = None;

    // Every breaker is OPEN and still cooling, so there is nothing to dial. The
    // caller turns this terminal into exit 4 in `once` mode, or into a backoff
    // and re-arm for a long-lived daemon.
    if order.is_empty() {
        return SweepResult {
            outcome: Err(IntelError::AllEndpointsDown(None)),
            failover: None,
            breaker_changes,
            active_change: None,
            served_by: None,
        };
    }

    for idx in order {
        // A second-or-later attempt within one sweep IS a failover advance, and
        // is what the caller reports as such.
        if let Some(prev) = prev_idx
            && prev != idx
        {
            failover = Some((prev, idx));
        }
        prev_idx = Some(idx);

        match list.ep(idx).complete_once(req, timeout, trace_id) {
            Ok((resp, latency)) => {
                if let Some(t) = list.ep(idx).health.record_success(latency) {
                    breaker_changes.push((idx, t));
                }
                let mut active_change = list.set_active(idx);
                // Snap back to the lowest-index healthy endpoint (sticky-primary).
                if let Some(snapped) = list.prefer_lowest_healthy() {
                    active_change = Some(snapped);
                }
                return SweepResult {
                    outcome: Ok(resp),
                    failover,
                    breaker_changes,
                    active_change,
                    served_by: Some(idx),
                };
            }
            Err(e) => match classify(&e) {
                FailoverClass::Failover(kind) => {
                    if let Some(t) = list.ep(idx).health.record_failure(kind, &cfg) {
                        breaker_changes.push((idx, t));
                    }
                    last_err = Some(e);
                    continue; // advance to the next available endpoint
                }
                FailoverClass::Fatal => {
                    // Auth/4xx/malformed: same on every endpoint → return now.
                    return SweepResult {
                        outcome: Err(e),
                        failover,
                        breaker_changes,
                        active_change: None,
                        served_by: None,
                    };
                }
            },
        }
    }

    // Every available endpoint failed over, so the whole list is down. The last
    // failover-class error is carried along as the cause, because the terminal
    // on its own tells an operator nothing about why.
    SweepResult {
        outcome: Err(IntelError::AllEndpointsDown(last_err.map(Box::new))),
        failover,
        breaker_changes,
        active_change: None,
        served_by: None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io;

    fn io_err(kind: io::ErrorKind) -> IntelError {
        IntelError::Transport(io::Error::new(kind, "x"))
    }

    #[test]
    fn transport_errors_are_failover_class() {
        assert!(matches!(
            classify(&io_err(io::ErrorKind::ConnectionRefused)),
            FailoverClass::Failover(ErrKind::Refused)
        ));
        assert!(matches!(
            classify(&io_err(io::ErrorKind::TimedOut)),
            FailoverClass::Failover(ErrKind::Timeout)
        ));
        assert!(matches!(
            classify(&io_err(io::ErrorKind::ConnectionReset)),
            FailoverClass::Failover(ErrKind::Reset)
        ));
    }

    #[test]
    fn http_5xx_and_429_failover_but_4xx_does_not() {
        assert!(matches!(
            classify(&IntelError::Http(503, "x".into())),
            FailoverClass::Failover(ErrKind::Http5xx)
        ));
        assert!(matches!(
            classify(&IntelError::Http(429, "x".into())),
            FailoverClass::Failover(ErrKind::Http429)
        ));
        // auth / request error → fatal, NOT failover
        assert_eq!(
            classify(&IntelError::Http(401, "x".into())),
            FailoverClass::Fatal
        );
        assert_eq!(
            classify(&IntelError::Http(403, "x".into())),
            FailoverClass::Fatal
        );
        assert_eq!(
            classify(&IntelError::Http(400, "x".into())),
            FailoverClass::Fatal
        );
        assert_eq!(
            classify(&IntelError::Http(404, "x".into())),
            FailoverClass::Fatal
        );
    }

    #[test]
    fn malformed_body_is_fatal_not_failover() {
        assert_eq!(
            classify(&IntelError::Parse("bad json".into())),
            FailoverClass::Fatal
        );
    }

    #[test]
    fn auth_detection_distinguishes_from_all_down() {
        assert!(is_auth(&IntelError::Http(401, "x".into())));
        assert!(is_auth(&IntelError::Http(403, "x".into())));
        assert!(!is_auth(&IntelError::Http(503, "x".into())));
        assert!(!is_auth(&io_err(io::ErrorKind::ConnectionRefused)));
    }

    #[test]
    fn transient_status_matches_the_failover_class_split() {
        // The same-endpoint retry set (429 plus every 5xx) must match
        // `classify`'s failover-class HTTP codes exactly.
        for c in [429, 500, 502, 503, 504, 599] {
            assert!(is_transient_status(c), "{c} should be transient");
        }
        for c in [200, 400, 401, 403, 404, 418] {
            assert!(!is_transient_status(c), "{c} should NOT be transient");
        }
    }

    // --- Sweep integration tests over real TCP endpoints -------------------
    // A tiny single-shot HTTP server returns a fixed status (+ a canned
    // OpenAI-compatible body for 200) so the sweep dials a *real* endpoint via
    // `complete_once`. A closed/never-bound port gives a connect failure.

    use std::io::{Read, Write};
    use std::net::TcpListener;

    /// Bind `127.0.0.1:0`, serve one request returning `status`, and return the
    /// `http://127.0.0.1:<port>` URI. The thread self-terminates after one conn.
    fn serve_status(status: u16) -> String {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        std::thread::spawn(move || {
            if let Ok((mut s, _)) = listener.accept() {
                let mut buf = [0u8; 2048];
                let _ = s.read(&mut buf); // drain the request
                let body = if status == 200 {
                    r#"{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}"#
                } else {
                    r#"{"error":{"message":"boom"}}"#
                };
                let resp = format!(
                    "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                    body.len()
                );
                let _ = s.write_all(resp.as_bytes());
                let _ = s.flush();
            }
        });
        format!("http://127.0.0.1:{port}")
    }

    /// Bind `127.0.0.1:0` and serve one response per element of `statuses` on
    /// successive connections — so a *same-endpoint* retry (which re-dials) walks
    /// into the next status in the list. Returns the URI.
    fn serve_sequence(statuses: Vec<u16>) -> String {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        std::thread::spawn(move || {
            for status in statuses {
                let Ok((mut s, _)) = listener.accept() else {
                    break;
                };
                let mut buf = [0u8; 2048];
                let _ = s.read(&mut buf);
                let body = if status == 200 {
                    r#"{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}"#
                } else {
                    r#"{"error":{"message":"boom"}}"#
                };
                let resp = format!(
                    "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                    body.len()
                );
                let _ = s.write_all(resp.as_bytes());
                let _ = s.flush();
            }
        });
        format!("http://127.0.0.1:{port}")
    }

    /// A bound-then-dropped listener yields a port nothing listens on → connect
    /// refused (a failover-class transport error).
    fn dead_endpoint() -> String {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        drop(listener);
        format!("http://127.0.0.1:{port}")
    }

    fn req() -> Request {
        Request {
            model: "m".into(),
            messages: vec![crate::wire::intel::Message::user("hi")],
            tools: Vec::new(),
            max_tokens: 16,
            temperature: Some(0.0),
        }
    }

    fn list_of(uris: &[String]) -> EndpointList {
        EndpointList::parse_with_env(&uris.join(","), None, &|_| None).unwrap()
    }

    #[test]
    fn connect_failure_advances_to_next_healthy_endpoint() {
        let good = serve_status(200);
        let mut list = list_of(&[dead_endpoint(), good]);
        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        assert!(r.outcome.is_ok(), "sweep failed over to the healthy ep");
        assert_eq!(r.served_by, Some(1));
        // a failover advance was recorded (0 → 1)
        assert_eq!(r.failover, Some((0, 1)));
    }

    #[test]
    fn http_5xx_advances_to_next_endpoint() {
        let bad = serve_status(503);
        let good = serve_status(200);
        let mut list = list_of(&[bad, good]);
        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        assert!(r.outcome.is_ok());
        assert_eq!(r.served_by, Some(1));
    }

    #[test]
    fn http_4xx_does_not_failover() {
        let bad = serve_status(400);
        let good = serve_status(200);
        let mut list = list_of(&[bad, good]);
        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        // a 4xx is fatal — the sweep returns it WITHOUT trying endpoint 1.
        assert!(matches!(r.outcome, Err(IntelError::Http(400, _))));
        assert_eq!(r.served_by, None);
        assert_eq!(r.failover, None);
    }

    #[test]
    fn auth_401_does_not_failover() {
        let bad = serve_status(401);
        let good = serve_status(200);
        let mut list = list_of(&[bad, good]);
        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        assert!(matches!(r.outcome, Err(IntelError::Http(401, _))));
        assert!(is_auth(&r.outcome.unwrap_err()));
    }

    #[test]
    fn circuit_broken_endpoint_is_skipped() {
        let good = serve_status(200);
        let mut list = list_of(&[dead_endpoint(), good]);
        let cfg = *list.breaker_config();
        // open endpoint 0's breaker up front (threshold 3)
        for _ in 0..3 {
            list.ep(0).health.record_failure(ErrKind::Refused, &cfg);
        }
        // the sweep skips the broken endpoint 0 entirely → serves on 1, no
        // failover advance recorded (0 was never dialed).
        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        assert!(r.outcome.is_ok());
        assert_eq!(r.served_by, Some(1));
        assert_eq!(r.failover, None, "broken ep was skipped, not failed-over");
    }

    #[test]
    fn all_endpoints_down_yields_all_endpoints_down_error() {
        let mut list = list_of(&[dead_endpoint(), dead_endpoint()]);
        // One sweep over two dead endpoints exhausts the list, since each one
        // failed over, giving the all-down terminal that maps to exit 4 in
        // `once` mode.
        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        assert!(matches!(r.outcome, Err(IntelError::AllEndpointsDown(_))));
        // After enough sweeps the breakers open and `all_down()` (breaker-state)
        // also reports true — at which point `attempt_order()` is empty.
        for _ in 0..3 {
            let _ = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        }
        assert!(list.all_down());
        assert!(list.attempt_order().is_empty());
    }

    #[test]
    fn transient_5xx_is_retried_on_the_same_endpoint() {
        // One 503 then a 200 on a SINGLE endpoint: complete_once's same-endpoint
        // retry rides out the blip and succeeds in place, with no failover and
        // no exit 4. This matters most in once-mode, which arms no higher-level
        // retry loop, so without this retry a bare 503 would go straight to
        // exit 4.
        let ep = serve_sequence(vec![503, 200]);
        let mut list = list_of(&[ep]);
        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        assert!(r.outcome.is_ok(), "same-endpoint retry cleared the 503");
        assert_eq!(r.served_by, Some(0));
        assert_eq!(r.failover, None, "handled in place, not failed over");
    }

    #[test]
    fn transient_429_is_retried_then_succeeds() {
        let ep = serve_sequence(vec![429, 200]);
        let mut list = list_of(&[ep]);
        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        assert!(r.outcome.is_ok(), "429 rate-limit blip was retried");
        assert_eq!(r.served_by, Some(0));
    }

    #[test]
    fn non_transient_4xx_is_not_retried() {
        // 400 then 200 on one endpoint: because a 4xx is NOT retried, the first
        // (400) response surfaces immediately and the 200 is never consumed. If
        // the retry wrongly fired on 4xx, this would spuriously succeed.
        let ep = serve_sequence(vec![400, 200]);
        let mut list = list_of(&[ep]);
        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
        assert!(
            matches!(r.outcome, Err(IntelError::Http(400, _))),
            "4xx must surface on the first dial, not be retried"
        );
    }
}