Skip to main content

agentmux_srv/ambient/
outcome.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! One outcome per ambient call, logged and counted.
5//!
6//! The module used to be silent: its failure paths logged at debug, so a rejected,
7//! empty or failed title left no trace, and diagnosing the `(none yet)` swarm row
8//! took a database copy (docs/specs/SPEC_AMBIENT_SWARM_SUMMARY_HARDENING_2026_10_02.md
9//! section 2.1 defect 6, section 5.8). Every call now ends in exactly one
10//! [`Outcome`], written as one structured info line and counted per purpose, and
11//! the counts are readable over the `ambient.outcomes` RPC (the Instance panel).
12//! The rejected text is logged, truncated, because rejected values are the corpus
13//! the title predicate is tuned with.
14
15use std::collections::BTreeMap;
16use std::sync::{Mutex, OnceLock};
17
18/// How an ambient call ended.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Outcome {
21    /// The reply passed validation and was used.
22    Accepted,
23    /// The model answered `SKIP` (`reply`): nothing to write, or for a title, no
24    /// change. A healthy answer.
25    Skipped,
26    /// No model call: a check in code already showed there is nothing to write
27    /// (for a suggestion, the assistant's last message waits for the user).
28    Gated,
29    /// The reply was refused; the reason says by which rule.
30    Rejected(RejectReason),
31    /// There was nothing to send: no conversation in the digest, or no message
32    /// and no title to work from. No model call was made.
33    EmptyDigest,
34    /// A newer request for the same key replaced this one, before or during the
35    /// call.
36    Superseded,
37    /// The CLI could not be run or failed.
38    CliFailed,
39    /// The CLI ran past its time limit.
40    Timeout,
41    /// Admitted, then given up before any model call: the block or its CLI path
42    /// could not be resolved, or a purpose found nothing to do. Recorded when an
43    /// admitted call is dropped without running (`call::Slot`'s `Drop`).
44    NotRun,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum RejectReason {
49    /// Blank after trimming.
50    Empty,
51    /// Refused by the purpose's own check after a well-formed `ANSWER:` (too
52    /// long, a placeholder title, a risky suggestion).
53    Other,
54    /// Not in the reply format (`reply`): no `ANSWER:` prefix and not `SKIP`.
55    Format,
56}
57
58impl Outcome {
59    /// The stable label used in the log line and the counters.
60    pub fn label(&self) -> &'static str {
61        match self {
62            Outcome::Accepted => "accepted",
63            Outcome::Skipped => "skipped",
64            Outcome::Gated => "gated",
65            Outcome::Rejected(RejectReason::Format) => "rejected:format",
66            Outcome::Rejected(RejectReason::Empty) => "rejected:empty",
67            Outcome::Rejected(RejectReason::Other) => "rejected:other",
68            Outcome::EmptyDigest => "empty_digest",
69            Outcome::Superseded => "superseded",
70            Outcome::CliFailed => "cli_failed",
71            Outcome::Timeout => "timeout",
72            Outcome::NotRun => "not_run",
73        }
74    }
75}
76
77/// Classify a failed CLI call. `cancelled` is whether the gateway cancelled it
78/// because a newer request superseded it.
79pub fn classify_error(error: &str, cancelled: bool) -> Outcome {
80    if cancelled {
81        return Outcome::Superseded;
82    }
83    let lower = error.to_ascii_lowercase();
84    if lower.contains("timeout") || lower.contains("timed out") {
85        Outcome::Timeout
86    } else {
87        Outcome::CliFailed
88    }
89}
90
91type Counts = BTreeMap<&'static str, BTreeMap<&'static str, u64>>;
92
93fn counts() -> &'static Mutex<Counts> {
94    static COUNTS: OnceLock<Mutex<Counts>> = OnceLock::new();
95    COUNTS.get_or_init(|| Mutex::new(BTreeMap::new()))
96}
97
98/// Longest text logged with a rejected or kept outcome.
99const LOGGED_TEXT_MAX_CHARS: usize = 120;
100
101fn truncated(text: &str) -> String {
102    let mut out: String = text.chars().take(LOGGED_TEXT_MAX_CHARS).collect();
103    if text.chars().count() > LOGGED_TEXT_MAX_CHARS {
104        out.push('…');
105    }
106    out.replace('\n', " ⏎ ")
107}
108
109/// How long a call waited and ran, for the outcome line. Both in milliseconds.
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
111pub struct Timing {
112    /// Waiting for a concurrency permit, from admission.
113    pub queued_ms: u64,
114    /// The CLI call itself, spawn to exit (or cancellation, or the time limit).
115    pub run_ms: u64,
116}
117
118/// Record one outcome without timings: count it, and log it at info with the
119/// purpose and entity. See [`record_timed`].
120pub fn record(purpose: &'static str, entity: &str, outcome: Outcome, text: Option<&str>) {
121    record_timed(purpose, entity, outcome, text, None);
122}
123
124/// Record one outcome: count it, and log it at info with the purpose, the entity
125/// and, when the call ran, how long it queued and ran. `text` is logged
126/// (truncated) for a rejected reply, which is the corpus the validators are tuned
127/// with, and for an accepted one, which is what the user is shown: without it, a
128/// bad answer that passed could not be found afterwards.
129pub fn record_timed(purpose: &'static str, entity: &str, outcome: Outcome, text: Option<&str>, timing: Option<Timing>) {
130    if let Ok(mut map) = counts().lock() {
131        *map.entry(purpose)
132            .or_default()
133            .entry(outcome.label())
134            .or_default() += 1;
135    }
136    let text = match outcome {
137        Outcome::Rejected(_) | Outcome::Accepted => text.map(truncated),
138        _ => None,
139    };
140    let queued_ms = timing.map(|t| t.queued_ms);
141    let run_ms = timing.map(|t| t.run_ms);
142    tracing::info!(
143        purpose,
144        entity,
145        outcome = outcome.label(),
146        text = text.as_deref(),
147        queued_ms,
148        run_ms,
149        "ambient outcome"
150    );
151}
152
153/// Every count since this srv started, by purpose then outcome label.
154pub fn snapshot() -> BTreeMap<String, BTreeMap<String, u64>> {
155    let Ok(map) = counts().lock() else {
156        return BTreeMap::new();
157    };
158    map.iter()
159        .map(|(purpose, by)| {
160            (
161                purpose.to_string(),
162                by.iter()
163                    .map(|(label, n)| (label.to_string(), *n))
164                    .collect(),
165            )
166        })
167        .collect()
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn a_failure_is_superseded_timed_out_or_a_cli_failure() {
176        assert_eq!(classify_error("cancelled", true), Outcome::Superseded);
177        assert_eq!(
178            classify_error("haiku call timed out after 15s", false),
179            Outcome::Timeout
180        );
181        assert_eq!(
182            classify_error("spawn failed: not found", false),
183            Outcome::CliFailed
184        );
185    }
186
187    #[test]
188    fn outcomes_are_counted_per_purpose() {
189        // A purpose no other test uses, so parallel tests cannot disturb it.
190        let p = "test_purpose_outcome_counts";
191        record(p, "b1", Outcome::Accepted, None);
192        record(p, "b1", Outcome::Accepted, None);
193        record(p, "b2", Outcome::Rejected(RejectReason::Format), Some("(none yet)"));
194        record(p, "b3", Outcome::Skipped, Some("SKIP"));
195        let snap = snapshot();
196        let by = &snap[p];
197        assert_eq!(by["accepted"], 2);
198        assert_eq!(by["rejected:format"], 1);
199        assert_eq!(by["skipped"], 1);
200    }
201
202    #[test]
203    fn logged_text_is_bounded_and_on_one_line() {
204        let long = "x".repeat(500);
205        assert_eq!(truncated(&long).chars().count(), LOGGED_TEXT_MAX_CHARS + 1);
206        assert!(!truncated("a\nb").contains('\n'));
207    }
208}