Skip to main content

switchyard_libsy/algorithms/util/
escalation.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Trajectory-judge components for the escalation router — the judge, its verdict policy, and
5//! the transcript condenser they read.
6//!
7//! [`build_judge`] is the whole surface; the confirmation policy that consumes its verdicts
8//! lives with the assembled algorithm in [`crate::algorithms::escalation`].
9
10use serde::Deserialize;
11use serde_json::Value;
12use switchyard_protocol::{ContentBlock, Message, ModelId, Role};
13
14use super::classifier_contract::{ClassifierContract, ClassifierContractConfig};
15use super::llm_judge::{
16    ClassifierInput, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig, SerdeDecoder,
17    StructuredJudge,
18};
19use crate::core::classifier::{Classification, Score};
20use crate::core::state::State;
21use crate::{LibsyError, Result};
22use switchyard_protocol::Request;
23
24const PROMPT_TEMPLATE: &str = include_str!("../../prompts/escalation/prompt.md");
25const SCHEMA_TEMPLATE: &str = include_str!("../../prompts/escalation/schema.json");
26
27/// Separator marking where [`truncate_middle`] dropped a message's interior.
28const TRIM_MARKER: &str = " ...[trimmed] ";
29
30/// Suffix marking a transcript cut off by [`MAX_REQUEST_CHARS`].
31const TRUNCATION_SUFFIX: &str = "...<truncated>";
32
33/// Per-message cap for system and developer anchors, which carry no trajectory signal but
34/// which coding-agent harnesses make very large.
35const SYSTEM_CHARS: usize = 1_000;
36
37/// Per-message cap for task-framing user messages — every user message that precedes the first
38/// assistant reply. Coding-agent harnesses often send environment boilerplate as the first user
39/// message and the task itself as the second, so anchoring only the first would pin the
40/// boilerplate and let the task scroll out of the window. Feature specifications run to several
41/// thousand characters, so this gets the widest anchor budget.
42const TASK_CHARS: usize = 4_000;
43
44/// Backstop on the assembled transcript; the per-message caps normally bind first.
45const MAX_REQUEST_CHARS: usize = 18_000;
46
47/// The tuning surface for the trajectory judge.
48///
49/// The routing settings retain their benchmarked defaults. Everything else is a fixed invariant
50/// (the constants above).
51#[derive(Clone, Debug, Deserialize)]
52#[serde(default, deny_unknown_fields)]
53pub struct EscalationJudgeConfig {
54    /// Consecutive escalate verdicts required before a turn moves to the capable tier, which
55    /// is also the turn that latches the session. Any decline clears the streak.
56    /// `1` escalates on the first verdict; the router's main cost dial.
57    /// `2` or higher needs a session id, since the streak is retained per session.
58    pub confirmations: u32,
59    /// Trailing messages shown on top of the anchors. A loop longer than this is invisible.
60    pub recent_turn_window: usize,
61    /// Per-message cap inside the trailing window.
62    pub window_message_chars: usize,
63    /// Optional up-front capability gate. When set, the first request of a session is judged
64    /// from the task framing alone with the packaged capability forecaster, and a solve
65    /// probability below the threshold latches the session to the capable tier before the
66    /// efficient tier has spent anything. The trajectory judge takes over afterwards.
67    pub gate: Option<EscalationGateConfig>,
68}
69
70/// Numeric threshold for the escalation route's up-front capability gate.
71///
72/// Prose in the trajectory-judge prompt cannot set a split reliably: a task-level bar worded
73/// as "spans several modules" or "the hardest minority" latches almost every multi-file task.
74/// The gate reuses the capability classifier's forecast (`p_solve`) and threshold policy so the
75/// operator dials the split with a number, the same way capability mode does.
76#[derive(Clone, Debug, Deserialize, PartialEq)]
77#[serde(deny_unknown_fields)]
78pub struct EscalationGateConfig {
79    /// Lowest solve probability that keeps the session on the efficient tier. In `[0, 1]`.
80    pub base_threshold: f64,
81    /// Added once for uncertain or unmatched verdicts and twice for unsupported verdicts, as in
82    /// capability mode. `base_threshold + 2 * threshold_step` must be at most `1`.
83    #[serde(default)]
84    pub threshold_step: f64,
85    /// Replaces the packaged capability-classifier prompt for the gate call only.
86    #[serde(default)]
87    pub prompt: Option<String>,
88    /// Target the gate forecast is called through, by target name. Defaults to the route's
89    /// `classifier_target`. The gate runs once per session and benefits from a strong forecaster,
90    /// while the trajectory judge runs on every weak turn and is better served by a cheap model;
91    /// naming them separately lets a deployment pay for each where it matters. Resolved by the
92    /// deployment loader, which is why it is a name here rather than a model id.
93    #[serde(default)]
94    pub classifier_target: Option<String>,
95}
96
97impl EscalationGateConfig {
98    fn validate(&self) -> Result<()> {
99        let reject = |message: String| Err(LibsyError::AlgorithmError { message });
100        if !(0.0..=1.0).contains(&self.base_threshold) {
101            return reject(format!(
102                "gate.base_threshold must be between 0 and 1, got {}",
103                self.base_threshold
104            ));
105        }
106        if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
107            return reject(format!(
108                "gate.threshold_step must be finite and non-negative, got {}",
109                self.threshold_step
110            ));
111        }
112        let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
113        if unsupported_threshold > 1.0 {
114            return reject(format!(
115                "gate.base_threshold + 2 * gate.threshold_step must be at most 1, got {unsupported_threshold}"
116            ));
117        }
118        Ok(())
119    }
120}
121
122impl EscalationJudgeConfig {
123    /// Rejects settings that would leave the judge with nothing useful to read.
124    fn validate(&self) -> Result<()> {
125        let reject = |message: String| Err(LibsyError::AlgorithmError { message });
126        if self.confirmations == 0 {
127            return reject("confirmations must be at least 1".to_string());
128        }
129        if let Some(gate) = &self.gate {
130            gate.validate()?;
131        }
132        if self.recent_turn_window == 0 {
133            return reject("recent_turn_window must be at least 1".to_string());
134        }
135        if self.window_message_chars < 50 {
136            return reject(format!(
137                "window_message_chars must be at least 50, got {}",
138                self.window_message_chars
139            ));
140        }
141        Ok(())
142    }
143}
144
145impl Default for EscalationJudgeConfig {
146    fn default() -> Self {
147        Self {
148            confirmations: 2,
149            recent_turn_window: 28,
150            window_message_chars: 500,
151            gate: None,
152        }
153    }
154}
155
156/// The judge's verdict. The schema also requires a `reason`, which makes the judge state its
157/// case and measurably sharpens the verdict. Routing reads only the boolean; the reason is
158/// kept solely so an operator can see why the judge held or escalated when the
159/// `switchyard_libsy::algorithms::util::escalation` target is enabled at `debug`.
160#[derive(Deserialize)]
161pub(crate) struct EscalationVerdict {
162    escalate: bool,
163    #[serde(default)]
164    reason: String,
165}
166
167/// Builds the condensed trajectory presented to the escalation judge.
168pub(crate) struct EscalationInput {
169    config: EscalationJudgeConfig,
170}
171
172impl ClassifierInput for EscalationInput {
173    fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
174        let messages = &request.llm_request.messages;
175        let summary = summarize_for_judge(messages, conversation_turn(request), &self.config);
176        vec![Message::text(Role::User, summary)]
177    }
178}
179
180/// Structured trajectory judge with a typed escalation verdict.
181pub(crate) type EscalationJudge = StructuredJudge<EscalationInput, SerdeDecoder<EscalationVerdict>>;
182
183/// Maps the judge's verdict to a classification. A verdict names the tier to serve — capable
184/// on escalate, efficient on decline — so the caller reads it straight off the winning score.
185/// [`Classification::Ambiguous`] carries the unavailable case, which names no tier: both a
186/// decline and an outage stay efficient, but only a decline is evidence, so only a decline
187/// clears the streak.
188pub(crate) struct EscalationPolicy {
189    capable: ModelId,
190    efficient: ModelId,
191}
192
193impl JudgePolicy for EscalationPolicy {
194    type Verdict = EscalationVerdict;
195
196    fn to_classification(&self, verdict: Option<&EscalationVerdict>) -> Classification {
197        if let Some(verdict) = verdict {
198            tracing::debug!(
199                escalate = verdict.escalate,
200                reason = %verdict.reason,
201                "escalation judge verdict"
202            );
203        }
204        match verdict {
205            Some(verdict) if verdict.escalate => Classification::Scores(vec![Score {
206                target: self.capable.clone(),
207                confidence: 1.0,
208            }]),
209            Some(_) => Classification::Scores(vec![Score {
210                target: self.efficient.clone(),
211                confidence: 1.0,
212            }]),
213            None => Classification::Ambiguous(Vec::new()),
214        }
215    }
216}
217
218/// Maps present verdicts to stable `escalate` or `continue` values; absent verdicts add nothing.
219fn escalation_evidence(
220    _policy: &EscalationPolicy,
221    verdict: Option<&EscalationVerdict>,
222) -> Option<Value> {
223    verdict.map(|verdict| {
224        serde_json::json!({
225            "source": "escalation",
226            "verdict": if verdict.escalate { "escalate" } else { "continue" },
227        })
228    })
229}
230
231/// Builds the trajectory judge over `judge_target`, scoring `capable` when it escalates.
232///
233/// Loads the packaged prompt and schema, so an unusable asset or an unusable `config` value
234/// fails here rather than on the first request.
235pub(crate) fn build_judge(
236    judge_target: ModelId,
237    capable: ModelId,
238    efficient: ModelId,
239    contract_config: &ClassifierContractConfig,
240    config: EscalationJudgeConfig,
241    max_output_tokens: u64,
242) -> Result<JudgeClassifier<EscalationJudge, EscalationPolicy>> {
243    config.validate()?;
244    let contract =
245        ClassifierContract::from_config(contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)?;
246    Ok(JudgeClassifier::new(
247        StructuredJudge::new(
248            EscalationInput { config },
249            contract,
250            SerdeDecoder::new(),
251            JudgeRuntimeConfig::new(max_output_tokens)?,
252        ),
253        judge_target,
254        EscalationPolicy { capable, efficient },
255    )
256    .with_evidence(escalation_evidence))
257}
258
259/// The 1-indexed model invocation the transcript ends on: one per assistant reply.
260///
261/// The judge reads the turn *including* the reply it is judging, so the newest assistant
262/// message is this turn — no `+ 1`. Counting the caller's request instead would report the
263/// turn after the one under judgement.
264///
265/// Messages are already normalized by `switchyard-protocol`, so this needs no
266/// per-format branching.
267pub(crate) fn conversation_turn(request: &Request) -> usize {
268    request
269        .llm_request
270        .messages
271        .iter()
272        .filter(|message| message.role == Role::Assistant)
273        .count()
274}
275
276/// Flattens a message to plain text, tool calls and tool results included.
277///
278/// [`Message::text_content`] is deliberately not used here: it keeps only text and refusal
279/// blocks, which would erase exactly the repeated-command signal the judge's loop detection
280/// relies on.
281fn message_text(message: &Message) -> String {
282    let mut parts = Vec::new();
283    collect_text(&message.content, &mut parts);
284    parts.join(" ")
285}
286
287/// Appends the judge-relevant text of each block, descending into tool results.
288fn collect_text(content: &[ContentBlock], parts: &mut Vec<String>) {
289    for block in content {
290        match block {
291            ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
292                parts.push(text.clone());
293            }
294            ContentBlock::ToolCall(call) => {
295                parts.push(format!("tool_call {}({})", call.name, call.arguments));
296            }
297            ContentBlock::ToolResult(result) => collect_text(&result.content, parts),
298            _ => {}
299        }
300    }
301}
302
303/// Keeps the head and tail of `text` within `limit` characters.
304///
305/// The head gets two thirds of the surviving budget: for a trajectory judge the command or
306/// error signature that opens a message carries more signal than its trailing output.
307fn truncate_middle(text: &str, limit: usize) -> String {
308    let chars: Vec<char> = text.chars().collect();
309    if chars.len() <= limit {
310        return text.to_string();
311    }
312    let keep = limit
313        .saturating_sub(TRIM_MARKER.chars().count())
314        .max(20)
315        .min(chars.len());
316    let head = keep * 2 / 3;
317    let tail = keep - head;
318    let mut out: String = chars[..head].iter().collect();
319    out.push_str(TRIM_MARKER);
320    out.extend(chars[chars.len() - tail..].iter());
321    out
322}
323
324/// Renders a compact role-labelled transcript for the judge.
325///
326/// The framing anchors — system/developer messages and the first user message, where agent
327/// harnesses put the task statement — are kept unconditionally and capped individually. The
328/// trailing window carries recent activity. A coverage header states how much history is not
329/// shown, so the judge can reason about pace rather than assuming it sees everything.
330///
331/// When the assembled text still exceeds `max_request_chars`, the oldest window lines go
332/// first: for a trajectory judge the newest evidence is strictly the most valuable.
333fn summarize_for_judge(
334    messages: &[Message],
335    turn: usize,
336    config: &EscalationJudgeConfig,
337) -> String {
338    let mut anchors: Vec<String> = Vec::new();
339    let mut window: Vec<String> = Vec::new();
340    let mut assistant_seen = false;
341
342    for message in messages {
343        let text = message_text(message);
344        match message.role {
345            Role::System | Role::Developer => anchors.push(format!(
346                "[{}] {}",
347                role_label(message.role),
348                truncate_middle(&text, SYSTEM_CHARS)
349            )),
350            // Everything the user said before the agent first replied is task framing.
351            Role::User if !assistant_seen => {
352                anchors.push(format!(
353                    "[user (task)] {}",
354                    truncate_middle(&text, TASK_CHARS)
355                ));
356            }
357            role => {
358                if role == Role::Assistant {
359                    assistant_seen = true;
360                }
361                window.push(format!(
362                    "[{}] {}",
363                    role_label(role),
364                    truncate_middle(&text, config.window_message_chars)
365                ));
366            }
367        }
368    }
369
370    if window.len() > config.recent_turn_window {
371        window.drain(..window.len() - config.recent_turn_window);
372    }
373
374    let assemble = |window: &[String]| {
375        let header = format!(
376            "Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
377            window.len(),
378            messages.len(),
379        );
380        std::iter::once(header)
381            .chain(anchors.iter().cloned())
382            .chain(window.iter().cloned())
383            .collect::<Vec<_>>()
384            .join("\n")
385    };
386
387    let mut text = assemble(&window);
388    while text.chars().count() > MAX_REQUEST_CHARS && !window.is_empty() {
389        window.remove(0);
390        text = assemble(&window);
391    }
392    if text.chars().count() > MAX_REQUEST_CHARS {
393        let keep = MAX_REQUEST_CHARS.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
394        text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
395    }
396    text
397}
398
399/// The transcript label for a role.
400fn role_label(role: Role) -> &'static str {
401    match role {
402        Role::System => "system",
403        Role::Developer => "developer",
404        Role::User => "user",
405        Role::Assistant => "assistant",
406        Role::Tool => "tool",
407    }
408}
409
410/// A request whose conversation sits at `turn`: `turn - 1` prior assistant replies, each
411/// answered by a further user message.
412///
413/// Shared with the assembled router's tests, which drive the same conversation shape.
414#[cfg(test)]
415pub(crate) fn request_at_turn(session_id: Option<&str>, turn: usize) -> Request {
416    use switchyard_protocol::{LlmRequest, Metadata};
417
418    let mut messages = vec![Message::text(Role::User, "What is 2+2?")];
419    for attempt in 1..turn {
420        messages.push(Message::text(Role::Assistant, format!("attempt {attempt}")));
421        messages.push(Message::text(Role::User, format!("still wrong {attempt}")));
422    }
423    Request {
424        llm_request: LlmRequest {
425            model: Some("auto".to_string()),
426            messages,
427            ..LlmRequest::default()
428        },
429        raw_request: None,
430        metadata: session_id.map(|id| Metadata {
431            session_id: Some(id.to_string()),
432            ..Metadata::default()
433        }),
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use serde_json::json;
440    use switchyard_protocol::{ContentBlock, Message, Role, ToolCall, ToolResult};
441
442    use super::*;
443    use crate::algorithms::util::llm_judge::Judge;
444
445    fn escalation_judge(max_output_tokens: u64) -> Result<EscalationJudge> {
446        Ok(StructuredJudge::new(
447            EscalationInput {
448                config: EscalationJudgeConfig::default(),
449            },
450            ClassifierContract::from_config(
451                &ClassifierContractConfig::default(),
452                PROMPT_TEMPLATE,
453                SCHEMA_TEMPLATE,
454            )?,
455            SerdeDecoder::new(),
456            JudgeRuntimeConfig::new(max_output_tokens)?,
457        ))
458    }
459
460    #[test]
461    fn judge_request_is_rubric_plus_summary_under_a_completion_cap() -> Result<()> {
462        let judge = escalation_judge(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?;
463
464        // As the classifier calls it: the turn's reply is already on the transcript.
465        let mut judged = request_at_turn(None, 4);
466        judged
467            .llm_request
468            .messages
469            .push(Message::text(Role::Assistant, "this turn's reply"));
470        let built = judge.build_request(&State::default(), &judged);
471
472        // Rubric in instructions, condensed trajectory as the sole user message.
473        assert_eq!(built.llm_request.instructions.len(), 1);
474        assert_eq!(built.llm_request.instructions[0].role, Role::System);
475        assert_eq!(built.llm_request.messages.len(), 1);
476        assert_eq!(built.llm_request.messages[0].role, Role::User);
477        assert!(
478            built.llm_request.messages[0]
479                .text_content("")
480                .is_some_and(|text| text.contains("Conversation turn 4"))
481        );
482        // Bounded output, so a reasoning judge cannot run away mid-verdict.
483        assert_eq!(
484            built.llm_request.output.max_output_tokens,
485            Some(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
486        );
487        assert!(built.llm_request.output.response_format.is_some());
488        Ok(())
489    }
490
491    #[test]
492    fn judge_request_uses_the_configured_completion_cap() -> Result<()> {
493        let judge = escalation_judge(512)?;
494
495        let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
496
497        assert_eq!(built.llm_request.output.max_output_tokens, Some(512));
498        Ok(())
499    }
500
501    #[test]
502    fn conversation_turn_counts_assistant_replies() {
503        // The judge is handed the transcript with this turn's reply already appended, which
504        // is the shape asserted here: a request entering turn N plus its reply *is* turn N.
505        for turn in [1, 5] {
506            let mut judged = request_at_turn(None, turn);
507            judged
508                .llm_request
509                .messages
510                .push(Message::text(Role::Assistant, "this turn's reply"));
511            assert_eq!(conversation_turn(&judged), turn);
512        }
513    }
514
515    #[test]
516    fn message_text_keeps_tool_calls_and_results() {
517        let call = Message {
518            role: Role::Assistant,
519            content: vec![
520                ContentBlock::Text {
521                    text: "running it".to_string(),
522                },
523                ContentBlock::ToolCall(ToolCall {
524                    id: "call-1".to_string(),
525                    name: "bash".to_string(),
526                    arguments: json!({"cmd": "ls"}),
527                }),
528            ],
529        };
530        let text = message_text(&call);
531        assert!(text.contains("running it"), "{text}");
532        assert!(text.contains(r#"tool_call bash({"cmd":"ls"})"#), "{text}");
533
534        let result = Message {
535            role: Role::Tool,
536            content: vec![ContentBlock::ToolResult(ToolResult {
537                tool_call_id: "call-1".to_string(),
538                content: vec![ContentBlock::Text {
539                    text: "no such file".to_string(),
540                }],
541                is_error: Some(true),
542            })],
543        };
544        assert_eq!(message_text(&result), "no such file");
545    }
546
547    #[test]
548    fn truncate_middle_keeps_head_and_tail() {
549        let text = "a".repeat(40) + &"z".repeat(40);
550        let trimmed = truncate_middle(&text, 50);
551        assert!(trimmed.chars().count() <= 50, "{trimmed}");
552        assert!(trimmed.starts_with('a'));
553        assert!(trimmed.ends_with('z'));
554        assert!(trimmed.contains("[trimmed]"));
555
556        // Under the limit the text is returned untouched.
557        assert_eq!(truncate_middle("short", 50), "short");
558    }
559
560    #[test]
561    fn summary_keeps_anchors_and_the_recent_window() {
562        let mut messages = vec![
563            Message::text(Role::System, "you are a coding agent"),
564            Message::text(Role::User, "fix the failing test"),
565        ];
566        for i in 0..10 {
567            messages.push(Message::text(Role::Assistant, format!("step {i}")));
568        }
569        let config = EscalationJudgeConfig {
570            recent_turn_window: 3,
571            ..EscalationJudgeConfig::default()
572        };
573
574        let summary = summarize_for_judge(&messages, 11, &config);
575
576        assert!(
577            summary.contains("[system] you are a coding agent"),
578            "{summary}"
579        );
580        assert!(
581            summary.contains("[user (task)] fix the failing test"),
582            "{summary}"
583        );
584        assert!(summary.contains("Conversation turn 11; showing the last 3 of 12 messages"));
585        // Only the newest window entries survive.
586        assert!(summary.contains("step 9"), "{summary}");
587        assert!(summary.contains("step 7"), "{summary}");
588        assert!(!summary.contains("step 6"), "{summary}");
589    }
590
591    #[test]
592    fn summary_anchors_every_user_message_before_the_first_reply() {
593        // Codex sends environment boilerplate as the first user message and the task as the
594        // second. Both are framing; the task must stay visible after the window has moved on.
595        let mut messages = vec![
596            Message::text(
597                Role::Developer,
598                "<skills_instructions>...</skills_instructions>",
599            ),
600            Message::text(
601                Role::User,
602                "<environment_context><cwd>/app</cwd></environment_context>",
603            ),
604            Message::text(Role::User, "Implement RFC 5545 timezone interop in rrule."),
605        ];
606        for i in 0..40 {
607            messages.push(Message::text(Role::Assistant, format!("step {i}")));
608            messages.push(Message::text(Role::User, format!("later user note {i}")));
609        }
610        let config = EscalationJudgeConfig {
611            recent_turn_window: 3,
612            ..EscalationJudgeConfig::default()
613        };
614
615        let summary = summarize_for_judge(&messages, 40, &config);
616
617        assert!(
618            summary.contains("[user (task)] <environment_context>"),
619            "{summary}"
620        );
621        assert!(
622            summary.contains("[user (task)] Implement RFC 5545 timezone interop in rrule."),
623            "{summary}"
624        );
625        // User messages after the first reply are ordinary window entries, not anchors.
626        assert!(
627            !summary.contains("[user (task)] later user note"),
628            "{summary}"
629        );
630        assert!(summary.contains("[user] later user note 39"), "{summary}");
631        assert!(!summary.contains("later user note 0\n"), "{summary}");
632    }
633
634    #[test]
635    fn summary_drops_oldest_window_lines_under_the_char_cap() {
636        // MAX_REQUEST_CHARS is a backstop, not a dial: at default settings the window caps
637        // bind first (28 x 500 plus anchors sits under it), so reaching it takes an unusually
638        // wide per-message cap. That is the point — it only fires on pathological input.
639        let mut messages = vec![
640            Message::text(Role::System, "framing"),
641            Message::text(Role::User, "task"),
642        ];
643        for i in 0..20 {
644            messages.push(Message::text(
645                Role::Assistant,
646                format!("{i} {}", "x".repeat(2_000)),
647            ));
648        }
649        let config = EscalationJudgeConfig {
650            window_message_chars: 2_000,
651            ..EscalationJudgeConfig::default()
652        };
653
654        let summary = summarize_for_judge(&messages, 21, &config);
655
656        assert!(
657            summary.chars().count() <= MAX_REQUEST_CHARS,
658            "{}",
659            summary.chars().count()
660        );
661        // Anchors are never dropped, and the newest activity outlives the oldest.
662        assert!(summary.contains("[system] framing"), "{summary}");
663        assert!(summary.contains("[user (task)] task"), "{summary}");
664        assert!(summary.contains("19 xxx"), "{summary}");
665        assert!(!summary.contains("0 xxx"), "{summary}");
666    }
667}