Skip to content

Commit c55bf2f

Browse files
committed
feat(flow-chat): expose retry attempt diagnostics
- Preserve original provider/transport errors, raw tool arguments, and JSON parser errors for retried attempts - Project diagnostics through events, session persistence, and history restoration - Add on-demand, copyable diagnostic details for collapsed historical attempts - Cover diagnostic merging, ordering, restoration, and event serialization
1 parent fb325ae commit c55bf2f

40 files changed

Lines changed: 977 additions & 31 deletions

File tree

src/apps/desktop/src/api/agentic_api.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3216,6 +3216,7 @@ mod tests {
32163216
first_visible_output_ms: None,
32173217
stream_duration_ms: None,
32183218
attempt_count: None,
3219+
attempt_diagnostics: vec![],
32193220
failure_category: None,
32203221
token_details: None,
32213222
status: "completed".to_string(),
@@ -3297,6 +3298,7 @@ mod tests {
32973298
first_visible_output_ms: None,
32983299
stream_duration_ms: None,
32993300
attempt_count: None,
3301+
attempt_diagnostics: vec![],
33003302
failure_category: None,
33013303
token_details: None,
33023304
status: "completed".to_string(),
@@ -3359,6 +3361,7 @@ mod tests {
33593361
first_visible_output_ms: None,
33603362
stream_duration_ms: None,
33613363
attempt_count: None,
3364+
attempt_diagnostics: vec![],
33623365
failure_category: None,
33633366
token_details: None,
33643367
status: "completed".to_string(),

src/crates/assembly/core/src/agentic/coordination/coordinator.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,6 +1229,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
12291229
first_visible_output_ms: None,
12301230
stream_duration_ms: None,
12311231
attempt_count: None,
1232+
attempt_diagnostics: vec![],
12321233
failure_category: None,
12331234
token_details: None,
12341235
status: "completed".to_string(),
@@ -1305,6 +1306,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
13051306
first_visible_output_ms: None,
13061307
stream_duration_ms: None,
13071308
attempt_count: None,
1309+
attempt_diagnostics: vec![],
13081310
failure_category: Some("context_compression".to_string()),
13091311
token_details: None,
13101312
status: "error".to_string(),

src/crates/assembly/core/src/agentic/core/message.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,7 @@ mod tests {
734734
arguments: json!({ "path": "src/main.rs" }),
735735
raw_arguments: Some(r#"{"path":"src/main.rs" "line_end":4}"#.to_string()),
736736
is_error: false,
737+
parse_error: None,
737738
recovered_from_truncation: false,
738739
repair_kind: ToolArgumentRepairKind::PermissiveNormalToolJsonRepair,
739740
});
@@ -759,6 +760,9 @@ pub struct ToolCall {
759760
/// Record whether tool parameters are valid
760761
#[serde(default)]
761762
pub is_error: bool,
763+
/// Original JSON parser error when the provider emitted invalid arguments.
764+
#[serde(default, skip_serializing_if = "Option::is_none")]
765+
pub parse_error: Option<String>,
762766
/// True when the raw JSON arguments were truncated mid-stream and we
763767
/// successfully repaired them. Downstream consumers can flag this to the
764768
/// model so it understands the content may be incomplete.
@@ -786,6 +790,7 @@ impl From<bitfun_agent_stream::ToolCall> for ToolCall {
786790
arguments: tool_call.arguments,
787791
raw_arguments: tool_call.raw_arguments,
788792
is_error: tool_call.is_error,
793+
parse_error: tool_call.parse_error,
789794
recovered_from_truncation: tool_call.recovered_from_truncation,
790795
repair_kind: tool_call.repair_kind,
791796
}

src/crates/assembly/core/src/agentic/events/types.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ pub use bitfun_events::agentic::ErrorCategory;
1010
pub use bitfun_events::{
1111
AgenticEvent as BaseAgenticEvent, AgenticEventEnvelope as EventEnvelope,
1212
AgenticEventPriority as EventPriority, DeepReviewQueueReason, DeepReviewQueueState,
13-
DeepReviewQueueStatus, SubagentParentInfo, ToolEventData,
13+
DeepReviewQueueStatus, ModelRoundAttemptDiagnostic, ModelRoundAttemptToolDiagnostic,
14+
SubagentParentInfo, ToolEventData,
1415
};
1516

1617
// ============ Core layer AgenticEvent extension ============

src/crates/assembly/core/src/agentic/execution/execution_engine.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4428,6 +4428,7 @@ mod tests {
44284428
arguments: json!({ "job_id": "job-1" }),
44294429
raw_arguments: None,
44304430
is_error: false,
4431+
parse_error: None,
44314432
recovered_from_truncation: false,
44324433
repair_kind: Default::default(),
44334434
}];
@@ -4456,6 +4457,7 @@ mod tests {
44564457
arguments: json!({ "path": "missing.txt" }),
44574458
raw_arguments: None,
44584459
is_error: false,
4460+
parse_error: None,
44594461
recovered_from_truncation: false,
44604462
repair_kind: Default::default(),
44614463
}];

src/crates/assembly/core/src/agentic/execution/round_executor.rs

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ use super::model_exchange_trace::prepare_model_exchange_trace;
66
use super::stream_processor::{StreamProcessOptions, StreamProcessor, StreamResult};
77
use super::types::{FinishReason, RoundContext, RoundResult};
88
use crate::agentic::core::{Message, ToolCall};
9-
use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData};
9+
use crate::agentic::events::{
10+
AgenticEvent, EventPriority, EventQueue, ModelRoundAttemptDiagnostic,
11+
ModelRoundAttemptToolDiagnostic, ToolEventData,
12+
};
1013
use crate::agentic::memories::{
1114
parse_bitfun_memory_citation, parse_bitfun_memory_citation_payloads,
1215
strip_bitfun_memory_citations,
@@ -62,6 +65,56 @@ impl RoundExecutor {
6265
!text.trim().is_empty()
6366
}
6467

68+
fn retry_diagnostic(
69+
attempt_id: String,
70+
attempt_index: u32,
71+
category: &str,
72+
raw_error: Option<String>,
73+
tool_calls: &[ToolCall],
74+
) -> ModelRoundAttemptDiagnostic {
75+
ModelRoundAttemptDiagnostic {
76+
attempt_id,
77+
attempt_index,
78+
category: category.to_string(),
79+
raw_error,
80+
tool_calls: tool_calls
81+
.iter()
82+
.filter(|tool_call| !tool_call.is_valid())
83+
.map(|tool_call| ModelRoundAttemptToolDiagnostic {
84+
tool_id: (!tool_call.tool_id.is_empty()).then(|| tool_call.tool_id.clone()),
85+
tool_name: (!tool_call.tool_name.is_empty())
86+
.then(|| tool_call.tool_name.clone()),
87+
raw_arguments: tool_call.raw_arguments.clone(),
88+
validation_error: tool_call.parse_error.clone(),
89+
})
90+
.collect(),
91+
}
92+
}
93+
94+
async fn record_retry_diagnostic(
95+
&self,
96+
context: &RoundContext,
97+
round_id: &str,
98+
attempt_id: String,
99+
attempt_index: u32,
100+
category: &str,
101+
raw_error: Option<String>,
102+
tool_calls: &[ToolCall],
103+
) {
104+
let diagnostic =
105+
Self::retry_diagnostic(attempt_id, attempt_index, category, raw_error, tool_calls);
106+
self.emit_event(
107+
AgenticEvent::ModelRoundAttemptSuperseded {
108+
session_id: context.session_id.clone(),
109+
turn_id: context.dialog_turn_id.clone(),
110+
round_id: round_id.to_string(),
111+
diagnostic: diagnostic.clone(),
112+
},
113+
EventPriority::High,
114+
)
115+
.await;
116+
}
117+
65118
fn parsed_memory_citation_from_stream_result(
66119
stream_result: &StreamResult,
67120
) -> Option<crate::agentic::core::message::MemoryCitation> {
@@ -248,6 +301,16 @@ impl RoundExecutor {
248301
if Self::is_transient_network_error(&err_msg)
249302
&& attempt_index < max_attempts - 1
250303
{
304+
self.record_retry_diagnostic(
305+
&context,
306+
&round_id,
307+
attempt_id.clone(),
308+
attempt_number,
309+
"transient_request_error",
310+
Some(err_msg.clone()),
311+
&[],
312+
)
313+
.await;
251314
let delay_ms = Self::retry_delay_ms_for_error(attempt_index, &err_msg);
252315
warn!(
253316
"Retrying AI request after connection failure: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, error={}",
@@ -347,6 +410,16 @@ impl RoundExecutor {
347410
&& attempt_index < max_attempts - 1
348411
&& Self::is_transient_network_error(&err_msg)
349412
{
413+
self.record_retry_diagnostic(
414+
&context,
415+
&round_id,
416+
attempt_id.clone(),
417+
attempt_number,
418+
"interrupted_tool_arguments",
419+
Some(err_msg.clone()),
420+
&result.tool_calls,
421+
)
422+
.await;
350423
Self::complete_model_exchange_trace(
351424
trace_config.as_ref(),
352425
trace_handle.as_ref(),
@@ -438,6 +511,16 @@ impl RoundExecutor {
438511
&& Self::is_transient_network_error(partial_recovery_reason)
439512
&& attempt_index < max_attempts - 1
440513
{
514+
self.record_retry_diagnostic(
515+
&context,
516+
&round_id,
517+
attempt_id.clone(),
518+
attempt_number,
519+
"partial_stream_error",
520+
Some(partial_recovery_reason.to_string()),
521+
&result.tool_calls,
522+
)
523+
.await;
441524
Self::complete_model_exchange_trace(
442525
trace_config.as_ref(),
443526
trace_handle.as_ref(),
@@ -464,6 +547,16 @@ impl RoundExecutor {
464547
if Self::is_invalid_tool_only_without_text(&result) {
465548
let err_msg = "Provider returned only invalid tool arguments".to_string();
466549
if attempt_index < max_attempts - 1 {
550+
self.record_retry_diagnostic(
551+
&context,
552+
&round_id,
553+
attempt_id.clone(),
554+
attempt_number,
555+
"invalid_tool_arguments",
556+
None,
557+
&result.tool_calls,
558+
)
559+
.await;
467560
Self::complete_model_exchange_trace(
468561
trace_config.as_ref(),
469562
trace_handle.as_ref(),
@@ -513,6 +606,16 @@ impl RoundExecutor {
513606
}
514607

515608
if no_effective_output && attempt_index < max_attempts - 1 {
609+
self.record_retry_diagnostic(
610+
&context,
611+
&round_id,
612+
attempt_id.clone(),
613+
attempt_number,
614+
"no_effective_output",
615+
None,
616+
&[],
617+
)
618+
.await;
516619
Self::complete_model_exchange_trace(
517620
trace_config.as_ref(),
518621
trace_handle.as_ref(),
@@ -569,6 +672,16 @@ impl RoundExecutor {
569672
)
570673
.await;
571674
if can_retry {
675+
self.record_retry_diagnostic(
676+
&context,
677+
&round_id,
678+
attempt_id.clone(),
679+
attempt_number,
680+
"transient_stream_error",
681+
Some(err_msg.clone()),
682+
&[],
683+
)
684+
.await;
572685
let delay_ms = Self::retry_delay_ms_for_error(attempt_index, &err_msg);
573686
warn!(
574687
"Retrying stream after transient error with no effective output: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, error={}",
@@ -1680,6 +1793,7 @@ mod tests {
16801793
arguments: json!({}),
16811794
raw_arguments: Some("{\"command\":".to_string()),
16821795
is_error: true,
1796+
parse_error: Some("EOF while parsing an object".to_string()),
16831797
recovered_from_truncation: false,
16841798
repair_kind: Default::default(),
16851799
}],
@@ -1736,11 +1850,43 @@ mod tests {
17361850
"tool_name": "Bash",
17371851
"arguments": {},
17381852
"raw_arguments": "{\"command\":",
1739-
"is_error": true
1853+
"is_error": true,
1854+
"parse_error": "EOF while parsing an object"
17401855
}]))
17411856
);
17421857
}
17431858

1859+
#[test]
1860+
fn retry_diagnostic_preserves_invalid_tool_arguments_and_parser_error() {
1861+
let diagnostic = RoundExecutor::retry_diagnostic(
1862+
"round-1:attempt:1".to_string(),
1863+
1,
1864+
"invalid_tool_arguments",
1865+
None,
1866+
&[ToolCall {
1867+
tool_id: "tool-1".to_string(),
1868+
tool_name: "Bash".to_string(),
1869+
arguments: json!({}),
1870+
raw_arguments: Some("{\"command\":".to_string()),
1871+
is_error: true,
1872+
parse_error: Some("EOF while parsing an object".to_string()),
1873+
recovered_from_truncation: false,
1874+
repair_kind: Default::default(),
1875+
}],
1876+
);
1877+
1878+
assert_eq!(diagnostic.category, "invalid_tool_arguments");
1879+
assert_eq!(diagnostic.tool_calls.len(), 1);
1880+
assert_eq!(
1881+
diagnostic.tool_calls[0].raw_arguments.as_deref(),
1882+
Some("{\"command\":")
1883+
);
1884+
assert_eq!(
1885+
diagnostic.tool_calls[0].validation_error.as_deref(),
1886+
Some("EOF while parsing an object")
1887+
);
1888+
}
1889+
17441890
#[test]
17451891
fn error_trace_response_without_stream_result_stays_empty() {
17461892
let trace = RoundExecutor::error_trace_response("error", "request failed".to_string());

src/crates/assembly/core/src/agentic/insights/collector.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,7 @@ fn rebuild_messages_from_turns(turns: &[DialogTurnData]) -> Vec<Message> {
658658
arguments: ti.tool_call.input.clone(),
659659
raw_arguments: None,
660660
is_error: false,
661+
parse_error: None,
661662
recovered_from_truncation: false,
662663
repair_kind: Default::default(),
663664
})
@@ -1129,6 +1130,7 @@ mod tests {
11291130
first_visible_output_ms: None,
11301131
stream_duration_ms: None,
11311132
attempt_count: None,
1133+
attempt_diagnostics: vec![],
11321134
failure_category: None,
11331135
token_details: None,
11341136
status: "completed".to_string(),

src/crates/assembly/core/src/agentic/memories/transcript.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ mod tests {
387387
first_visible_output_ms: None,
388388
stream_duration_ms: None,
389389
attempt_count: None,
390+
attempt_diagnostics: vec![],
390391
failure_category: None,
391392
token_details: None,
392393
status: "completed".to_string(),

src/crates/assembly/core/src/agentic/persistence/manager.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3438,6 +3438,7 @@ mod tests {
34383438
first_visible_output_ms: None,
34393439
stream_duration_ms: None,
34403440
attempt_count: None,
3441+
attempt_diagnostics: vec![],
34413442
failure_category: None,
34423443
token_details: None,
34433444
status: "completed".to_string(),

src/crates/assembly/core/src/agentic/session/compression/compressor.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,7 @@ mod tests {
611611
}),
612612
raw_arguments: None,
613613
is_error: false,
614+
parse_error: None,
614615
recovered_from_truncation: false,
615616
repair_kind: Default::default(),
616617
}],

0 commit comments

Comments
 (0)