Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ Cross-package release notes for relayburn. Package changelogs contain package-le

## [Unreleased]


### Added

- `burn summary`: one-line `Turn outcomes: …` breakdown of assistant
`stop_reason` counts, plus a `stopReasons` block in `--json`. (#437)
- Ledger fingerprint primitive (`{count}:{maxMtimeUnix}:{totalBytes}`) for
cheap "did anything change" polling. Exposed as `LedgerHandle::fingerprint`
on the Rust SDK, `sdk.fingerprint()` on `@relayburn/sdk`,
Expand All @@ -23,7 +24,11 @@ Cross-package release notes for relayburn. Package changelogs contain package-le
`queued_command` attachment with `commandMode`), so a real prompt that
literally types `<task-notification>` is not filtered. Drops user-turn
inflation from background Bash completions.

- **BREAKING** `relayburn-sdk`: `TurnRecord.stop_reason` is now an
`Option<StopReason>` enum (kebab-case wire form); deserialization is
lenient so pre-3.0 ledgers replay cleanly. (#437)
- `relayburn-sdk` ledger schema bumps to v2: `turns` gains a
`stop_reason TEXT` column, migrated in place on `Ledger::open`. (#437)

## [2.10.0] - 2026-05-24

Expand Down
61 changes: 57 additions & 4 deletions crates/relayburn-cli/src/commands/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ use clap::Args;
use relayburn_sdk::{
ingest_all, summary_fidelity_summary_to_value, summary_replacement_savings_to_value,
CostBreakdown, CoverageField, Enrichment, FidelityClass, FidelitySummary, Ledger, LedgerHandle,
LedgerOpenOptions, OutcomeLabel, QualityResult, RelationshipType, SubagentTreeNode,
SubagentTypeStats, SummaryByToolReport, SummaryGroupBy, SummaryGroupedReport,
SummaryRelationshipReport, SummaryReport, SummaryReportMode, SummaryReportOptions,
SummarySubagentTreeReport, UsageCostAggregateRow,
LedgerOpenOptions, OutcomeLabel, QualityResult, RelationshipType, StopReasonCounts,
SubagentTreeNode, SubagentTypeStats, SummaryByToolReport, SummaryGroupBy,
SummaryGroupedReport, SummaryRelationshipReport, SummaryReport, SummaryReportMode,
SummaryReportOptions, SummarySubagentTreeReport, UsageCostAggregateRow,
};
use serde_json::{json, Map, Value};

Expand Down Expand Up @@ -490,6 +490,7 @@ fn grouped_json_value(
summary_replacement_savings_to_value(&report.replacement_savings),
);
}
payload.insert("stopReasons".into(), stop_reasons_to_json(&report.stop_reasons));
if let Some(quality) = report.quality.as_ref() {
payload.insert("quality".into(), json!(quality));
}
Expand Down Expand Up @@ -968,6 +969,11 @@ fn emit_human(report: &SummaryGroupedReport, ingest_report: &relayburn_sdk::Inge
lines.push(String::new());
}

if !report.stop_reasons.is_empty() {
lines.push(format_stop_reasons_line(&report.stop_reasons));
lines.push(String::new());
}

if any_partial {
lines.push(format_partial_footer(&report.rows));
lines.push(String::new());
Expand Down Expand Up @@ -1036,6 +1042,52 @@ fn render_quality(q: &QualityResult) -> String {
.join("\n")
}

/// Human-readable outcome line for `burn summary`, e.g.
/// `Turn outcomes: 142 end_turn, 3 max_tokens, 1 refusal, 0 pause`.
///
/// Always renders `end_turn` / `max_tokens` / `refusal` / `pause` because
/// users want to see the zero — "no refusals" is a meaningful signal.
/// Other buckets (`tool_use`, `stop_sequence`, `silent`, `none`) appear
/// only when non-zero so the line stays scannable. Labels stay snake_case
/// to match the historical Anthropic spelling the issue specified.
fn format_stop_reasons_line(s: &StopReasonCounts) -> String {
let mut parts: Vec<String> = vec![
format!("{} end_turn", format_uint(s.end_turn)),
format!("{} max_tokens", format_uint(s.max_tokens)),
format!("{} refusal", format_uint(s.refusal)),
format!("{} pause", format_uint(s.pause_turn)),
];
if s.tool_use > 0 {
parts.push(format!("{} tool_use", format_uint(s.tool_use)));
}
if s.stop_sequence > 0 {
parts.push(format!("{} stop_sequence", format_uint(s.stop_sequence)));
}
if s.silent > 0 {
parts.push(format!("{} silent", format_uint(s.silent)));
}
if s.none > 0 {
parts.push(format!("{} none", format_uint(s.none)));
}
format!("Turn outcomes: {}", parts.join(", "))
}

/// JSON shape for the outcome breakdown. Keys are camelCase to match the
/// rest of the summary surface; every bucket is emitted unconditionally so
/// downstream consumers can index keys without `?` plumbing.
fn stop_reasons_to_json(s: &StopReasonCounts) -> Value {
json!({
"endTurn": s.end_turn,
"maxTokens": s.max_tokens,
"pauseTurn": s.pause_turn,
"stopSequence": s.stop_sequence,
"toolUse": s.tool_use,
"refusal": s.refusal,
"silent": s.silent,
"none": s.none,
})
}

fn format_replacement_savings_line(s: &relayburn_sdk::ReplacementSavingsSummary) -> String {
let call_word = if s.calls == 1 { "call" } else { "calls" };
format!(
Expand Down Expand Up @@ -1181,6 +1233,7 @@ mod tests {
fidelity: relayburn_sdk::summarize_fidelity(&[]),
per_cell_fidelity: json!({"groupBy": "model"}),
replacement_savings: relayburn_sdk::ReplacementSavingsSummary::default(),
stop_reasons: relayburn_sdk::StopReasonCounts::default(),
quality: Some(QualityResult::default()),
};

Expand Down
56 changes: 29 additions & 27 deletions crates/relayburn-sdk/src/analyze/quality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

use std::collections::HashMap;

use crate::reader::{ContentKind, ContentRecord, ContentRole, TurnRecord};
use crate::reader::{ContentKind, ContentRecord, ContentRole, StopReason, TurnRecord};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
Expand Down Expand Up @@ -324,9 +324,9 @@ fn ending_role(turns: &[&TurnRecord]) -> EndingRole {
// reason means user-ended (session died after a tool_use). When the
// source doesn't record stopReason at all (e.g. Codex), return Unknown.
let last = turns.last().expect("turns non-empty");
match &last.stop_reason {
match last.stop_reason {
None => EndingRole::Unknown,
Some(s) if s == "end_turn" => EndingRole::Assistant,
Some(StopReason::EndTurn) => EndingRole::Assistant,
Some(_) => EndingRole::User,
}
}
Expand Down Expand Up @@ -525,7 +525,7 @@ mod tests {
ts: Option<String>,
session_id: Option<String>,
source: Option<SourceKind>,
stop_reason: Option<Option<String>>,
stop_reason: Option<Option<StopReason>>,
tool_calls: Option<Vec<ToolCall>>,
retries: Option<Option<u64>>,
has_edits: Option<bool>,
Expand Down Expand Up @@ -582,13 +582,13 @@ mod tests {
turn(TurnOverrides {
message_id: "m1".into(),
turn_index: 0,
stop_reason: Some(Some("tool_use".into())),
stop_reason: Some(Some(StopReason::ToolUse)),
..Default::default()
}),
turn(TurnOverrides {
message_id: "m2".into(),
turn_index: 1,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
];
Expand All @@ -603,7 +603,7 @@ mod tests {
let turns = vec![turn(TurnOverrides {
message_id: "m1".into(),
turn_index: 0,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
})];
let o = infer_outcome("s", &turns, None, fixed_now());
Expand All @@ -617,7 +617,7 @@ mod tests {
let turns = vec![turn(TurnOverrides {
message_id: "m1".into(),
turn_index: 0,
stop_reason: Some(Some("tool_use".into())),
stop_reason: Some(Some(StopReason::ToolUse)),
..Default::default()
})];
let o = infer_outcome("s", &turns, None, fixed_now());
Expand All @@ -633,21 +633,21 @@ mod tests {
message_id: "m1".into(),
turn_index: 0,
ts: Some("2026-04-20T00:00:00.000Z".into()),
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
turn(TurnOverrides {
message_id: "m2".into(),
turn_index: 1,
ts: Some("2026-04-20T00:01:00.000Z".into()),
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
turn(TurnOverrides {
message_id: "m3".into(),
turn_index: 2,
ts: Some("2026-04-20T00:02:00.000Z".into()),
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
];
Expand All @@ -664,9 +664,11 @@ mod tests {
turn(TurnOverrides {
message_id: format!("m{i}"),
turn_index: i as u64,
stop_reason: Some(Some(
if i == 9 { "tool_use" } else { "end_turn" }.to_string(),
)),
stop_reason: Some(Some(if i == 9 {
StopReason::ToolUse
} else {
StopReason::EndTurn
})),
..Default::default()
})
})
Expand All @@ -683,19 +685,19 @@ mod tests {
turn(TurnOverrides {
message_id: "m1".into(),
turn_index: 0,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
turn(TurnOverrides {
message_id: "m2".into(),
turn_index: 1,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
turn(TurnOverrides {
message_id: "m3".into(),
turn_index: 2,
stop_reason: Some(Some("tool_use".into())),
stop_reason: Some(Some(StopReason::ToolUse)),
..Default::default()
}),
];
Expand All @@ -711,27 +713,27 @@ mod tests {
turn(TurnOverrides {
message_id: "m1".into(),
turn_index: 0,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
turn(TurnOverrides {
message_id: "m2".into(),
turn_index: 1,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
tool_calls: Some(vec![tc("u1", "Bash", Some(true))]),
..Default::default()
}),
turn(TurnOverrides {
message_id: "m3".into(),
turn_index: 2,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
tool_calls: Some(vec![tc("u2", "Bash", Some(true))]),
..Default::default()
}),
turn(TurnOverrides {
message_id: "m4".into(),
turn_index: 3,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
tool_calls: Some(vec![tc("u3", "Bash", Some(true))]),
..Default::default()
}),
Expand All @@ -748,7 +750,7 @@ mod tests {
turn(TurnOverrides {
message_id: format!("m{}", i + 1),
turn_index: i,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
})
})
Expand Down Expand Up @@ -825,7 +827,7 @@ mod tests {
turn(TurnOverrides {
message_id: format!("m{}", i + 1),
turn_index: i,
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
})
})
Expand Down Expand Up @@ -942,29 +944,29 @@ mod tests {
turn_index: 0,
session_id: Some("A".into()),
has_edits: Some(true),
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
turn(TurnOverrides {
message_id: "a2".into(),
turn_index: 1,
session_id: Some("A".into()),
has_edits: Some(true),
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
turn(TurnOverrides {
message_id: "a3".into(),
turn_index: 2,
session_id: Some("A".into()),
stop_reason: Some(Some("end_turn".into())),
stop_reason: Some(Some(StopReason::EndTurn)),
..Default::default()
}),
turn(TurnOverrides {
message_id: "b1".into(),
turn_index: 0,
session_id: Some("B".into()),
stop_reason: Some(Some("tool_use".into())),
stop_reason: Some(Some(StopReason::ToolUse)),
..Default::default()
}),
];
Expand Down
Loading
Loading