Handle partial answers consistently across agent workflows (#51249)

## Why

Partial answers contain answer fragments while a turn is still in progress. Treating them as final answers defers mailbox delivery and can count empty fragments as empty final responses; filtering them from forked histories and guardian transcripts loses answer content.

## What changed

- Allow mailbox preemption after `partial_answer` messages and keep mailbox delivery open.
- Preserve partial answers in forked agent histories and include them as protected assistant entries in guardian transcripts.
- Exclude empty partial answers from empty-final-response accounting for goals.

## Testing

Add regression coverage for partial-answer phases in live events, sampling continuations, follow-up requests, and forked child requests, plus mailbox preemption, goal accounting, and guardian transcript inclusion.

GitOrigin-RevId: 208c8933e211b42b6c2d0bec6952527b8c9c120e
This commit is contained in:
rb-oai
2026-10-06 03:27:04 +00:00
committed by copyberry
parent 8b6bb1c77d
commit 989c01a41a
16 changed files with 408 additions and 22 deletions
+1 -1
View File
@@ -172,7 +172,7 @@ impl GoalAccountingState {
});
turn.has_activity |= has_text || message.questions.is_some();
turn.empty_final |=
!has_text && !matches!(message.phase, Some(MessagePhase::Commentary));
!has_text && matches!(message.phase, Some(MessagePhase::FinalAnswer) | None);
}
TurnItem::Reasoning(reasoning) => {
turn.has_activity |= reasoning
+8 -1
View File
@@ -76,6 +76,7 @@ fn empty_continuations_require_three_turns_without_activity_or_goal_changes() {
("goal", 5),
("reset", 6),
("missing final", 6),
("partial answer", 6),
] {
let state = GoalAccountingState::default();
for turn in 1..=blocking_turn {
@@ -87,7 +88,13 @@ fn empty_continuations_require_three_turns_without_activity_or_goal_changes() {
};
state.start_turn(&id, ModeKind::Default, &TokenUsage::default());
state.mark_turn_goal_active(&id, goal_id);
if !(turn == 3 && interruption == "missing final") {
if turn == 3 && interruption == "partial answer" {
let mut partial = empty_final.clone();
if let TurnItem::AgentMessage(message) = &mut partial {
message.phase = Some(MessagePhase::PartialAnswer);
}
state.record_item(&id, &partial);
} else if !(turn == 3 && interruption == "missing final") {
state.record_item(&id, &empty_final);
}
// Admission can finish after output arrives, but before turn-stop evaluation.