newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
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
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
//! Tenacity — how long and hard the agent pursues the task.
//!
//! Two levels today: [`Tenacity::Normal`] stops at the first plausible finish
//! with the configured initial tool-round allowance, and [`Tenacity::Relentless`]
//! lifts that allowance. Progress-backed renewal is controlled separately by
//! `workflow_grace_rounds` (zero keeps the allowance a hard cap). `grit` and `resolute` arrive in later slices of
//! `docs/design/psyche-effort-dials.md`.
//!
//! Until the initiative split (slice 1b) this dial also carried the
//! read-before-acting nudge and the plan-exit edit. Those moved unchanged to
//! [`crate::initiative`], together with the per-family defaults. Tenacity is
//! set only explicitly: `--tenacity`, `/psyche tenacity`, a conversation's
//! preference pin, or the obsessive posture. The lift is explicit-only for a
//! reason: small loop-prone families must never silently receive 10,000
//! rounds, so no persona, config or family layer feeds it.

use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;

/// Bounded sentinel used for an operator-selected relentless run. This is high
/// enough to behave as "finish the objective" while remaining finite.
pub const RELENTLESS_TOOL_ROUND_TARGET: usize = 10_000;

/// How long the harness keeps the model pursuing the task.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Tenacity {
    /// Stop at the first plausible finish, using the configured initial allowance.
    #[default]
    Normal,
    /// Also lift the initial tool-round allowance, when chosen explicitly.
    Relentless,
}

impl Tenacity {
    /// Project the default tool-round budget when this level was selected by
    /// the operator. Relentless is the only level that changes the cap: it uses
    /// the shared effectively-unlimited target without lowering a larger
    /// configured value. Callers retain explicit round overrides as the final
    /// precedence layer.
    #[must_use]
    pub fn project_tool_round_limit(self, configured: usize) -> usize {
        if self == Self::Relentless {
            configured.max(RELENTLESS_TOOL_ROUND_TARGET)
        } else {
            configured
        }
    }

    /// Stable lowercase label — the wire/config/`/psyche tenacity` spelling.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            Self::Normal => "normal",
            Self::Relentless => "relentless",
        }
    }

    /// One-line description of what this level does.
    #[must_use]
    pub fn describe(self) -> String {
        match self {
            Self::Normal => {
                "stop at the first plausible finish; fresh progress may renew the initial allowance".to_string()
            }
            Self::Relentless => format!(
                "lift the initial tool-round allowance to at least {RELENTLESS_TOOL_ROUND_TARGET} \
                 (an explicit initial allowance still wins; configured progress renewal remains active)"
            ),
        }
    }

    /// All levels, normal → relentless.
    #[must_use]
    pub fn all() -> [Self; 2] {
        [Self::Normal, Self::Relentless]
    }
}

/// The tenacity level a [`ToolRoundLimit`] recorded, exactly as written.
///
/// Settings receipts embed [`ToolRoundLimit`] and are content-addressed, so a
/// line minted before the initiative split, carrying `relaxed`, `standard` or
/// `insistent`, must still decode AND re-encode to the same bytes, or
/// `is_intact` reports it as tampered. `untagged` keeps both arms a bare label.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RecordedTenacity {
    Current(Tenacity),
    Legacy(LegacyTenacity),
}

/// Tenacity labels from before the initiative split; decode-only.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LegacyTenacity {
    Relaxed,
    Standard,
    Insistent,
}

impl RecordedTenacity {
    /// The label as recorded.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            Self::Current(t) => t.label(),
            Self::Legacy(LegacyTenacity::Relaxed) => "relaxed",
            Self::Legacy(LegacyTenacity::Standard) => "standard",
            Self::Legacy(LegacyTenacity::Insistent) => "insistent",
        }
    }
}

impl From<Tenacity> for RecordedTenacity {
    fn from(t: Tenacity) -> Self {
        Self::Current(t)
    }
}

/// Which input decided the effective tool-round limit (#1965).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolRoundLimitSource {
    /// The configured value — `[tui].max_tool_rounds` or a per-model tuning.
    Config,
    /// An operator-selected tenacity level raised it.
    Tenacity,
    /// An explicit `/rounds` / `--max-rounds` value, the outermost override.
    Override,
}

impl ToolRoundLimitSource {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Config => "config",
            Self::Tenacity => "tenacity",
            Self::Override => "override",
        }
    }
}

/// The effective tool-round limit **and how it was reached** (#1965).
///
/// # Why this is a struct and not a `usize`
///
/// [`resolve_tool_round_limit`] promised, in its own doc comment, to compose
/// this "without losing provenance" — and returned a bare number, so the
/// provenance was computed and discarded at the one site responsible for
/// keeping it. A session escalated 40 rounds to effectively unlimited and left
/// no record anywhere: not in config, not in a receipt, not in a turn row.
/// Runs then reached rounds 145, 236, 285 and 320.
///
/// Carrying the derivation in the return type means a caller cannot record the
/// number while dropping where it came from — the number and its justification
/// are one value. That is the same move as `#1908`'s: make the lossy call
/// impossible to write rather than remember to write it correctly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolRoundLimit {
    /// The initial allowance; the tool loop separately applies progress renewal.
    pub rounds: usize,
    /// Which input won.
    pub source: ToolRoundLimitSource,
    /// What config alone would have given — kept so a reader can see the
    /// ESCALATION, not merely the result. "320 rounds" is a number; "320,
    /// from an override, over a configured 40" is an explanation.
    pub configured: usize,
    /// The operator-selected level, when one was in play, as recorded.
    pub tenacity: Option<RecordedTenacity>,
}

impl ToolRoundLimit {
    /// Whether the effective limit differs from what config alone would give —
    /// the condition worth telling an operator about.
    #[must_use]
    pub fn is_escalated(&self) -> bool {
        self.rounds != self.configured
    }
}

/// Compose the tool-round safety valve without losing provenance. Callers pass
/// only a direct operator choice ([`cli_tenacity`]), never a resolved default,
/// so no automatic layer can silently grant 10,000 rounds. An explicit
/// `/rounds`/`--max-rounds` value remains the outermost initial-allowance override.
/// Set `workflow_grace_rounds = 0` when this value must be a hard round cap.
///
/// Returns a [`ToolRoundLimit`] rather than a number, so the derivation cannot
/// be dropped on the way to a durable record — see that type's docs (#1965).
#[must_use]
pub fn resolve_tool_round_limit(
    configured: usize,
    explicit_tenacity: Option<Tenacity>,
    explicit_rounds: Option<usize>,
) -> ToolRoundLimit {
    if let Some(rounds) = explicit_rounds {
        return ToolRoundLimit {
            rounds,
            source: ToolRoundLimitSource::Override,
            configured,
            tenacity: explicit_tenacity.map(RecordedTenacity::from),
        };
    }
    // A tenacity level that does not RAISE the limit did not decide it — the
    // configured value did, and saying "tenacity" there would name a cause that
    // changed nothing.
    if let Some(level) = explicit_tenacity {
        let projected = level.project_tool_round_limit(configured);
        if projected != configured {
            return ToolRoundLimit {
                rounds: projected,
                source: ToolRoundLimitSource::Tenacity,
                configured,
                tenacity: Some(level.into()),
            };
        }
    }
    ToolRoundLimit {
        rounds: configured,
        source: ToolRoundLimitSource::Config,
        configured,
        tenacity: explicit_tenacity.map(RecordedTenacity::from),
    }
}

impl fmt::Display for Tenacity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.label())
    }
}

impl FromStr for Tenacity {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let s = s.trim().to_ascii_lowercase();
        if s == "default" {
            return Ok(Self::default());
        }
        if let Some(level) = Self::all().into_iter().find(|l| l.label() == s) {
            return Ok(level);
        }
        if let Some(new) = crate::psyche_import::split_tenacity(&s) {
            return Err(format!(
                "tenacity '{s}' was split: its read-before-acting half is now \
                 `--initiative {new}`; tenacity is normal|relentless"
            ));
        }
        Err(format!("unknown tenacity '{s}' (normal|relentless)"))
    }
}

// The explicit operator choice (`--tenacity`, `/psyche tenacity`, a pin, the
// obsessive posture). It is the only tenacity input: there is no persona,
// config or family layer, because the round-cap lift must stay explicit.
static CLI_TENACITY: std::sync::Mutex<Option<Tenacity>> = std::sync::Mutex::new(None);
// #1998: the two inputs to `resolve_tool_round_limit` that were NOT here.
//
// Its other inputs have always been process globals in this module. The last — the `/rounds` session override — was a local variable inside
// `run_chat`, which is exactly what #1965's evidence complains about: "the
// effective limit is recomputed per dispatch … and a session-local
// `max_tool_rounds_override` echoed only to the truncated alternate-screen
// terminal". A local cannot be read by a receipt writer, by a status line, or
// by anything outside the one function that declares it, which is why the
// number that ran a session to round 320 was unrecoverable afterwards.
//
// `CONFIGURED_TOOL_ROUNDS` is the config/model-tuned baseline the override is
// derived AGAINST, installed by the session when the active model settles —
// the same shape and the same reason as `initiative`'s active family. With both here,
// the whole derivation is computable from anywhere.
static SESSION_TOOL_ROUNDS: std::sync::Mutex<Option<usize>> = std::sync::Mutex::new(None);
static CONFIGURED_TOOL_ROUNDS: std::sync::Mutex<Option<usize>> = std::sync::Mutex::new(None);

std::thread_local! {
    /// A driven turn resolves tenacity before crossing onto its dedicated
    /// thread. Keeping that value here makes every downstream resolver read in
    /// the turn (workflow steering and tool dispatch included) observe the same
    /// immutable posture without replacing the interactive process globals.
    static EFFECTIVE_TENACITY_OVERRIDE: std::cell::Cell<Option<Tenacity>> =
        const { std::cell::Cell::new(None) };
}

/// Restores the prior current-thread override on drop. The `Rc` marker keeps
/// the guard on the thread whose TLS slot it owns; driven turns use a
/// current-thread runtime, so the guard safely spans the whole async turn.
#[must_use]
pub struct ScopedEffectiveTenacity {
    previous: Option<Tenacity>,
    _thread_bound: std::marker::PhantomData<std::rc::Rc<()>>,
}

impl Drop for ScopedEffectiveTenacity {
    fn drop(&mut self) {
        let _ = EFFECTIVE_TENACITY_OVERRIDE.try_with(|slot| slot.set(self.previous));
    }
}

/// Override [`effective_tenacity`] on the current thread until the returned
/// guard drops. Overrides nest in lexical (LIFO) order.
///
/// `pub` since #1669: a session that runs its turn on its own thread captures
/// this dial there, so the embedding crate needs it. Prefer
/// [`crate::psyche::capture_turn_psyche`] at a turn boundary, so tenacity is
/// never pinned without the other dials.
pub fn scoped_effective_tenacity(level: Tenacity) -> ScopedEffectiveTenacity {
    let previous = EFFECTIVE_TENACITY_OVERRIDE.with(|slot| slot.replace(Some(level)));
    ScopedEffectiveTenacity {
        previous,
        _thread_bound: std::marker::PhantomData,
    }
}

/// Install the explicit CLI `--tenacity` override (highest priority). Call once,
/// before the agentic loop starts.
pub fn set_cli_tenacity(level: Tenacity) {
    if let Ok(mut slot) = CLI_TENACITY.lock() {
        *slot = Some(level);
    }
}

/// Clear the explicit CLI `--tenacity` override, so tenacity is `Normal` again.
/// The complement of [`set_cli_tenacity`]: it lets a surface
/// express "inherit" (no override) rather than pinning the currently-resolved
/// value — e.g. the config panel must not persist an untouched dial.
pub fn clear_cli_tenacity() {
    if let Ok(mut slot) = CLI_TENACITY.lock() {
        *slot = None;
    }
}

/// The raw CLI `--tenacity` override, if one is installed (`None` = `Normal`).
/// Distinct from [`effective_tenacity`], which also honours a turn's capture.
#[must_use]
pub fn cli_tenacity() -> Option<Tenacity> {
    CLI_TENACITY.lock().ok().and_then(|s| *s)
}

/// The tenacity in effect: a turn's captured value, else the explicit
/// operator choice, else `Normal`.
#[must_use]
pub fn effective_tenacity() -> Tenacity {
    if let Some(level) = EFFECTIVE_TENACITY_OVERRIDE.with(std::cell::Cell::get) {
        return level;
    }
    cli_tenacity().unwrap_or_default()
}

/// A complete snapshot of **every** mutable global in this module — the CLI
/// override and the two round-cap inputs. The test guard snapshots and
/// restores this as one unit (with [`crate::initiative`]'s sibling snapshot)
/// so no input can leak between tests.
#[doc(hidden)]
pub struct TenacityRuntimeSnapshot {
    cli: Option<Tenacity>,
    session_rounds: Option<usize>,
    configured_rounds: Option<usize>,
}

/// Install (or clear, with `None`) the operator's session tool-round override —
/// what `/rounds <n>` sets and `/rounds reset` releases.
///
/// This is the OUTERMOST input to [`resolve_tool_round_limit`]. Call it only
/// where the change is an operator decision, and record that decision: an
/// escalation here is the exact event #1965 was filed about.
pub fn set_session_tool_rounds(rounds: Option<usize>) {
    if let Ok(mut slot) = SESSION_TOOL_ROUNDS.lock() {
        *slot = rounds;
    }
}

/// The operator's session tool-round override, if one is installed.
#[must_use]
pub fn session_tool_rounds() -> Option<usize> {
    SESSION_TOOL_ROUNDS.lock().ok().and_then(|s| *s)
}

/// Install (or forget, with `None`) the config/model-tuned round cap for the
/// ACTIVE model — the baseline an override is measured against. Called by the
/// session wherever it already derives that number, the same way
/// `set_active_model_family` is.
///
/// `Option` for symmetry with [`set_session_tool_rounds`], and because
/// "forget the baseline" is a real state: no model has settled yet, and a
/// receipt written then must say so rather than reuse a stale number.
pub fn set_configured_tool_rounds(rounds: Option<usize>) {
    if let Ok(mut slot) = CONFIGURED_TOOL_ROUNDS.lock() {
        *slot = rounds;
    }
}

/// The installed config/model baseline, or `None` when no session has settled
/// a model yet.
///
/// Deliberately NOT defaulted to 40. A receipt that says "configured 40" when
/// nothing installed a baseline is a confident lie about where a number came
/// from, which is the failure this whole line exists to stop; `None` says
/// "unknown", and callers render that honestly.
#[must_use]
pub fn configured_tool_rounds() -> Option<usize> {
    CONFIGURED_TOOL_ROUNDS.lock().ok().and_then(|s| *s)
}

/// The cap a turn would run under right now, **with its derivation**, from the
/// globals alone (#1998).
///
/// `None` only when no baseline has been installed — see
/// [`configured_tool_rounds`]. This is what lets a receipt carry the whole
/// `ToolRoundLimit` rather than a bare number, without the writer needing the
/// config, the model card, or the session's locals.
#[must_use]
pub fn session_tool_round_limit() -> Option<ToolRoundLimit> {
    Some(resolve_tool_round_limit(
        configured_tool_rounds()?,
        cli_tenacity(),
        session_tool_rounds(),
    ))
}

/// Snapshot every tenacity-resolution global (see [`TenacityRuntimeSnapshot`]) —
/// including the two round-cap inputs #1998 moved here.
#[doc(hidden)]
#[must_use]
pub fn snapshot_runtime_state() -> TenacityRuntimeSnapshot {
    TenacityRuntimeSnapshot {
        cli: cli_tenacity(),
        session_rounds: session_tool_rounds(),
        configured_rounds: configured_tool_rounds(),
    }
}

/// Restore every tenacity-resolution global from a snapshot (see
/// [`TenacityRuntimeSnapshot`]). Total: every input is overwritten, so a test
/// that installed an override is fully undone.
#[doc(hidden)]
pub fn restore_runtime_state(snapshot: TenacityRuntimeSnapshot) {
    if let Ok(mut s) = CLI_TENACITY.lock() {
        *s = snapshot.cli;
    }
    if let Ok(mut s) = SESSION_TOOL_ROUNDS.lock() {
        *s = snapshot.session_rounds;
    }
    if let Ok(mut s) = CONFIGURED_TOOL_ROUNDS.lock() {
        *s = snapshot.configured_rounds;
    }
}

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

    /// **The round-cap globals are readable from outside `run_chat`** — which
    /// is the whole point of moving them (#1998). A local could not be read by
    /// a receipt writer, which is why the escalation that produced #1965 was
    /// unrecoverable.
    #[test]
    fn the_session_override_and_its_baseline_round_trip() {
        let _g = crate::test_guard::GlobalSettingsGuard::acquire();
        set_session_tool_rounds(None);
        assert_eq!(session_tool_rounds(), None, "no override by default");
        set_session_tool_rounds(Some(320));
        assert_eq!(session_tool_rounds(), Some(320));
        set_session_tool_rounds(None);
        assert_eq!(session_tool_rounds(), None, "the override releases");

        set_configured_tool_rounds(Some(40));
        assert_eq!(configured_tool_rounds(), Some(40));
    }

    /// **The whole derivation is computable from the globals alone.**
    ///
    /// This is the capability the receipt needs: `320, from an override, over
    /// a configured 40` without the writer holding the config, the model card
    /// or the session's locals.
    #[test]
    fn the_derivation_is_computable_from_the_globals() {
        let _g = crate::test_guard::GlobalSettingsGuard::acquire();
        set_configured_tool_rounds(Some(40));
        set_cli_tenacity(Tenacity::Relentless);
        set_session_tool_rounds(Some(320));

        let limit = session_tool_round_limit().expect("a baseline is installed");
        assert_eq!(limit.rounds, 320);
        assert_eq!(limit.source, ToolRoundLimitSource::Override);
        assert_eq!(limit.configured, 40);
        assert_eq!(limit.tenacity, Some(Tenacity::Relentless.into()));
        assert!(limit.is_escalated(), "320 over a configured 40");

        // Releasing the override changes which input won — the field that
        // makes the record an explanation rather than a number.
        set_session_tool_rounds(None);
        let limit = session_tool_round_limit().expect("a baseline is installed");
        assert_eq!(limit.source, ToolRoundLimitSource::Tenacity);
        assert_eq!(limit.configured, 40);
    }

    /// **An uninstalled baseline says so, rather than guessing 40.**
    ///
    /// Anti-vacuous twin for the two above: if this returned a default, every
    /// assertion about `configured` would hold over a receipt that invented
    /// the number it claims the limit was measured against.
    #[test]
    fn no_baseline_means_no_derivation_not_a_default() {
        let _g = crate::test_guard::GlobalSettingsGuard::acquire();
        set_configured_tool_rounds(None);
        assert_eq!(configured_tool_rounds(), None);
        assert!(
            session_tool_round_limit().is_none(),
            "a derivation without a baseline would be a confident invention"
        );
    }

    #[test]
    fn normal_is_the_default_and_leaves_the_cap_alone() {
        assert_eq!(Tenacity::default(), Tenacity::Normal);
        assert_eq!(Tenacity::Normal.project_tool_round_limit(40), 40);
    }

    #[test]
    fn scoped_override_is_nested_thread_local_and_restores_the_global_resolution() {
        use crate::test_guard::GlobalSettingsGuard;
        let _settings = GlobalSettingsGuard::acquire();
        clear_cli_tenacity();
        assert_eq!(effective_tenacity(), Tenacity::Normal);

        let outer = scoped_effective_tenacity(Tenacity::Relentless);
        assert_eq!(effective_tenacity(), Tenacity::Relentless);

        {
            let _inner = scoped_effective_tenacity(Tenacity::Normal);
            assert_eq!(effective_tenacity(), Tenacity::Normal);
        }
        assert_eq!(effective_tenacity(), Tenacity::Relentless);

        let other_thread = std::thread::spawn(effective_tenacity)
            .join()
            .expect("tenacity probe thread");
        assert_eq!(
            other_thread,
            Tenacity::Normal,
            "the override must stay local to the driven turn thread"
        );

        drop(outer);
        set_cli_tenacity(Tenacity::Relentless);
        assert_eq!(effective_tenacity(), Tenacity::Relentless);
    }

    #[test]
    fn relentless_can_project_an_operator_selected_round_target() {
        assert_eq!(Tenacity::Normal.project_tool_round_limit(40), 40);
        assert_eq!(
            Tenacity::Relentless.project_tool_round_limit(40),
            RELENTLESS_TOOL_ROUND_TARGET
        );
        assert_eq!(
            Tenacity::Relentless.project_tool_round_limit(20_000),
            20_000,
            "the posture must not lower an already larger configured budget"
        );
        let overridden = resolve_tool_round_limit(40, Some(Tenacity::Relentless), Some(7));
        assert_eq!(
            (overridden.rounds, overridden.source),
            (7, ToolRoundLimitSource::Override),
            "a direct round limit remains the outermost operator choice"
        );
    }

    #[test]
    fn parse_is_case_insensitive_and_round_trips_the_label() {
        for t in Tenacity::all() {
            assert_eq!(t.label().parse::<Tenacity>().unwrap(), t);
            assert_eq!(t.to_string(), t.label());
        }
        assert_eq!(
            "  RELENTLESS ".parse::<Tenacity>().unwrap(),
            Tenacity::Relentless
        );
        assert_eq!("default".parse::<Tenacity>().unwrap(), Tenacity::Normal);
        assert!("banana".parse::<Tenacity>().is_err());
    }

    /// Regression (slice 1b): an old tenacity label is refused with the
    /// initiative flag that replaces it. Before the split `insistent` parsed.
    #[test]
    fn an_old_level_is_refused_with_its_initiative_replacement() {
        for (old, new) in [
            ("relaxed", "patient"),
            ("standard", "measured"),
            ("insistent", "decisive"),
        ] {
            let err = old.parse::<Tenacity>().unwrap_err();
            assert!(err.contains(&format!("--initiative {new}")), "{err}");
        }
    }

    #[test]
    fn serde_uses_the_lowercase_label() {
        let json = serde_json::to_string(&Tenacity::Relentless).unwrap();
        assert_eq!(json, "\"relentless\"");
        let back: Tenacity = serde_json::from_str("\"normal\"").unwrap();
        assert_eq!(back, Tenacity::Normal);
        assert!(serde_json::from_str::<Tenacity>("\"standard\"").is_err());
    }

    /// A recorded level keeps its exact label through a decode/encode round
    /// trip, old vocabulary included: receipts are content-addressed.
    #[test]
    fn a_recorded_level_round_trips_old_and_new_labels_verbatim() {
        for label in ["relaxed", "standard", "insistent", "normal", "relentless"] {
            let json = format!("\"{label}\"");
            let r: RecordedTenacity = serde_json::from_str(&json).unwrap();
            assert_eq!(serde_json::to_string(&r).unwrap(), json);
            assert_eq!(r.label(), label);
        }
        assert_eq!(
            serde_json::from_str::<RecordedTenacity>("\"relentless\"").unwrap(),
            RecordedTenacity::Current(Tenacity::Relentless)
        );
    }

    #[test]
    fn snapshot_restore_round_trips_every_tenacity_global() {
        // The guard must isolate every input in this module: the CLI override
        // and, since #1998, the two round-cap globals.
        use crate::test_guard::GlobalSettingsGuard;
        let _g = GlobalSettingsGuard::acquire();

        clear_cli_tenacity();
        set_session_tool_rounds(None);
        set_configured_tool_rounds(None);
        let snap = snapshot_runtime_state();

        set_cli_tenacity(Tenacity::Relentless);
        set_session_tool_rounds(Some(320));
        set_configured_tool_rounds(Some(40));
        assert_eq!(cli_tenacity(), Some(Tenacity::Relentless));

        restore_runtime_state(snap);
        assert_eq!(cli_tenacity(), None, "CLI tenacity restored");
        assert_eq!(
            session_tool_rounds(),
            None,
            "the /rounds override restored — a leaked 320 would escalate the next test"
        );
        assert_eq!(configured_tool_rounds(), None);
    }

    #[test]
    fn guarded_state_is_restored_even_when_a_test_panics() {
        // CR3 area 4: restoration must survive a panic (Drop runs during unwind).
        use crate::test_guard::GlobalSettingsGuard;
        let result = std::panic::catch_unwind(|| {
            let _g = GlobalSettingsGuard::acquire();
            clear_cli_tenacity();
            set_session_tool_rounds(Some(4242));
            panic!("intentional panic inside a guarded test");
        });
        assert!(result.is_err(), "the guarded closure panicked as intended");
        let _g = GlobalSettingsGuard::acquire();
        assert_ne!(
            session_tool_rounds(),
            Some(4242),
            "GlobalSettingsGuard::drop restored the round override during unwind"
        );
    }
}