use serde::{Deserialize, Serialize};
use crate::model::StopReason;
pub const TURN_RECOVERY_SCHEMA: &str = "pi.turn_recovery.v1";
pub const MAX_AUTO_CONTINUATIONS: u8 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TurnRecoveryMode {
Off,
#[default]
Conservative,
Aggressive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RecoveryClass {
CleanStop,
BudgetTruncated,
UnclosedStructure,
SemanticPrematureStop,
}
impl RecoveryClass {
#[must_use]
pub const fn reason(self) -> &'static str {
match self {
Self::CleanStop => "clean stop",
Self::BudgetTruncated => "response truncated by token budget",
Self::UnclosedStructure => "response ended inside unfinished output",
Self::SemanticPrematureStop => "announced work was not started",
}
}
const fn actionable_in(self, mode: TurnRecoveryMode) -> bool {
match self {
Self::CleanStop => false,
Self::BudgetTruncated | Self::UnclosedStructure => {
!matches!(mode, TurnRecoveryMode::Off)
}
Self::SemanticPrematureStop => matches!(mode, TurnRecoveryMode::Aggressive),
}
}
}
#[must_use]
pub fn classify(stop_reason: StopReason, text: &str) -> RecoveryClass {
match stop_reason {
StopReason::Length => return RecoveryClass::BudgetTruncated,
StopReason::Stop => {}
_ => return RecoveryClass::CleanStop,
}
let trimmed = text.trim_end();
if trimmed.is_empty() {
return RecoveryClass::CleanStop;
}
if has_unclosed_fence(trimmed) || ends_on_dangling_bullet(trimmed) {
return RecoveryClass::UnclosedStructure;
}
if ends_on_unfulfilled_promise(trimmed) {
return RecoveryClass::SemanticPrematureStop;
}
RecoveryClass::CleanStop
}
fn has_unclosed_fence(text: &str) -> bool {
let mut open_fence: Option<(u8, usize)> = None;
for line in text.lines() {
let indent = line.bytes().take_while(|byte| *byte == b' ').count();
if indent > 3 {
continue;
}
let line = &line[indent..];
let Some(marker @ (b'`' | b'~')) = line.as_bytes().first().copied() else {
continue;
};
let length = line.bytes().take_while(|byte| *byte == marker).count();
if length < 3 {
continue;
}
let suffix = &line[length..];
match open_fence {
Some((open_marker, open_length))
if marker == open_marker
&& length >= open_length
&& suffix.bytes().all(|byte| matches!(byte, b' ' | b'\t')) =>
{
open_fence = None;
}
None if marker != b'`' || !suffix.contains('`') => {
open_fence = Some((marker, length));
}
_ => {}
}
}
open_fence.is_some()
}
fn ends_on_dangling_bullet(text: &str) -> bool {
let mut lines = text.lines().rev();
let Some(last) = lines.next() else {
return false;
};
let indent = last.bytes().take_while(|byte| *byte == b' ').count();
if indent > 3 || last[indent..].starts_with('\t') {
return false;
}
let last = last.trim();
if matches!(last, "-" | "*" | "+") {
return true;
}
let Some((delimiter, remainder)) = ordered_bullet(last) else {
return false;
};
if !remainder.is_empty() {
return false;
}
let Some(previous) = lines.find(|line| !line.trim().is_empty()) else {
return false;
};
let previous_indent = previous.bytes().take_while(|byte| *byte == b' ').count();
if previous_indent != indent || previous[previous_indent..].starts_with('\t') {
return false;
}
ordered_bullet(previous.trim()).is_some_and(|(previous_delimiter, content)| {
previous_delimiter == delimiter && !content.trim().is_empty()
})
}
fn ordered_bullet(line: &str) -> Option<(u8, &str)> {
let digits = line.bytes().take_while(u8::is_ascii_digit).count();
if !(1..=9).contains(&digits) {
return None;
}
let delimiter @ (b'.' | b')') = line.as_bytes().get(digits).copied()? else {
return None;
};
let remainder = &line[digits + 1..];
if remainder
.as_bytes()
.first()
.copied()
.is_some_and(|byte| !matches!(byte, b' ' | b'\t'))
{
return None;
}
Some((delimiter, remainder))
}
fn ends_on_unfulfilled_promise(text: &str) -> bool {
let tail: String = text
.chars()
.rev()
.take(240)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let tail_lower = tail.to_lowercase();
let Some(position) = PROMISE_PHRASES
.iter()
.filter_map(|phrase| tail_lower.rfind(phrase))
.max()
else {
return false;
};
let after = &tail_lower[position..]; let interior = after.trim_end_matches(['.', ':', '!', '…', ' ', '\n']);
!interior.contains(". ") && !interior.contains(":\n\n")
}
const PROMISE_PHRASES: &[&str] = &[
"i will now",
"i'll now",
"let me now",
"i am going to",
"i'm going to",
"next, i will",
"next, i'll",
"now i will",
"now i'll",
"proceeding to",
];
#[derive(Debug, Clone)]
pub struct RecoveryAction {
pub class: RecoveryClass,
pub nudge_text: String,
}
#[derive(Debug)]
pub struct TurnRecoveryState {
mode: TurnRecoveryMode,
continuations: u8,
}
impl TurnRecoveryState {
#[must_use]
pub const fn new(mode: TurnRecoveryMode) -> Self {
Self {
mode,
continuations: 0,
}
}
#[must_use]
pub const fn continuations(&self) -> u8 {
self.continuations
}
pub fn evaluate(&mut self, stop_reason: StopReason, text: &str) -> Option<RecoveryAction> {
if matches!(self.mode, TurnRecoveryMode::Off) {
return None;
}
let class = classify(stop_reason, text);
if !class.actionable_in(self.mode) {
return None;
}
if self.continuations > 0 && !matches!(class, RecoveryClass::BudgetTruncated) {
return None;
}
if self.continuations >= MAX_AUTO_CONTINUATIONS {
tracing::info!(
schema = TURN_RECOVERY_SCHEMA,
class = ?class,
cap = MAX_AUTO_CONTINUATIONS,
"auto-continuation cap reached; leaving the stop to the user"
);
return None;
}
self.continuations += 1;
tracing::info!(
schema = TURN_RECOVERY_SCHEMA,
class = ?class,
continuation = self.continuations,
"auto-continuing unexpected stop"
);
Some(RecoveryAction {
class,
nudge_text: format!(
"[auto-continue {}/{}: {}] Continue from exactly where you stopped. \
Do not repeat content you already produced; finish the remaining work.",
self.continuations,
MAX_AUTO_CONTINUATIONS,
class.reason()
),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn length_stop_is_budget_truncated() {
assert_eq!(
classify(StopReason::Length, "half a sentence"),
RecoveryClass::BudgetTruncated
);
}
#[test]
fn clean_prose_is_clean() {
assert_eq!(
classify(StopReason::Stop, "All done. The tests pass."),
RecoveryClass::CleanStop
);
}
#[test]
fn non_stop_reasons_never_classify() {
for reason in [
StopReason::ToolUse,
StopReason::Error,
StopReason::Aborted,
StopReason::PauseTurn,
] {
assert_eq!(
classify(reason, "```\nunclosed"),
RecoveryClass::CleanStop,
"{reason:?}"
);
}
}
#[test]
fn unclosed_fence_detected() {
let text = "Here is the fix:\n```rust\nfn main() {\n let x = 1;";
assert_eq!(
classify(StopReason::Stop, text),
RecoveryClass::UnclosedStructure
);
let closed = "Here is the fix:\n```rust\nfn main() {}\n```\nDone.";
assert_eq!(classify(StopReason::Stop, closed), RecoveryClass::CleanStop);
}
#[test]
fn completed_fences_can_contain_literal_delimiters() {
for text in [
"````markdown\n```rust\nfn main() {}\n```\n````",
"~~~text\n``` is literal code\n~~~",
"```text\n~~~ is literal code\n```",
"~~~rust\nlet x = 1;\n~~~~",
" ```text\npayload\n ```\t",
"~~~example with `backticks`\ncontent\n~~~",
"```rust\nfn main() {}\n```\n~~~text\ndone\n~~~",
] {
assert_eq!(
classify(StopReason::Stop, text),
RecoveryClass::CleanStop,
"completed fence: {text:?}"
);
}
}
#[test]
fn fence_closers_must_match_the_opener() {
for text in [
"~~~rust\nfn main() {",
"````text\ncontent\n```",
"~~~text\ncontent\n```",
"```text\ncontent\n~~~",
"```text\ncontent\n```not a closing fence",
"```text\ncontent\n ```",
"~~~text\ncontent\n~~",
] {
assert_eq!(
classify(StopReason::Stop, text),
RecoveryClass::UnclosedStructure,
"unfinished fence: {text:?}"
);
}
}
#[test]
fn literal_backticks_do_not_open_a_fence() {
for text in [
" ```rust\n literal indented code",
"\t```rust\n\tliteral indented code",
"```invalid`info\nordinary prose",
"Inline ```code``` is complete.",
"Unicode prose: 日本語, café, 🦀.",
] {
assert_eq!(
classify(StopReason::Stop, text),
RecoveryClass::CleanStop,
"not a fence: {text:?}"
);
}
}
#[test]
fn dangling_bullet_detected() {
let text = "Plan:\n1. read the file\n2.";
assert_eq!(
classify(StopReason::Stop, text),
RecoveryClass::UnclosedStructure
);
let fine = "Plan:\n1. read the file\n2. edit it";
assert_eq!(classify(StopReason::Stop, fine), RecoveryClass::CleanStop);
}
#[test]
fn ordered_dangling_bullets_require_list_context() {
for text in [
"42.",
"The answer is:\n42.",
"1.",
"Plan:\n1.read the file\n2.",
"Plan:\n1. read the file\n2)",
"Plan:\n1. read the file\n 2.",
"Plan:\n1. read the file\n1234567890.",
" 1. literal code\n 2.",
" -",
"\t-",
] {
assert_eq!(
classify(StopReason::Stop, text),
RecoveryClass::CleanStop,
"ambiguous or literal marker: {text:?}"
);
}
for text in [
"1. read the file\n2.",
"1) read the file\n2)",
"1. read the file\n\n2. \n",
" 1. read the file\n 2.",
"1.\tread the file\n2.",
"Plan:\n-",
"Plan:\n*",
"Plan:\n+",
] {
assert_eq!(
classify(StopReason::Stop, text),
RecoveryClass::UnclosedStructure,
"unfinished list: {text:?}"
);
}
}
#[test]
fn completed_markdown_does_not_spend_recovery_budget() {
let mut state = TurnRecoveryState::new(TurnRecoveryMode::Conservative);
for text in [
"42.",
"~~~markdown\n``` literal fence\n~~~",
" ``` literal indented code",
] {
assert!(state.evaluate(StopReason::Stop, text).is_none(), "{text:?}");
}
assert_eq!(state.continuations(), 0);
assert!(state.evaluate(StopReason::Length, "cut off").is_some());
assert_eq!(state.continuations(), 1);
}
#[test]
fn unfulfilled_promise_detected() {
let text = "The bug is in parse(). I will now edit the three files.";
assert_eq!(
classify(StopReason::Stop, text),
RecoveryClass::SemanticPrematureStop
);
let fulfilled =
"I will now edit the file. Done — the change is applied and the test passes.";
assert_eq!(
classify(StopReason::Stop, fulfilled),
RecoveryClass::CleanStop
);
}
#[test]
fn mode_gating_matrix() {
let semantic = "I'll now update the config.";
let budget_text = "cut off";
let mut off = TurnRecoveryState::new(TurnRecoveryMode::Off);
assert!(off.evaluate(StopReason::Length, budget_text).is_none());
let mut conservative = TurnRecoveryState::new(TurnRecoveryMode::Conservative);
assert!(
conservative
.evaluate(StopReason::Length, budget_text)
.is_some(),
"conservative handles budget truncation"
);
assert!(
conservative.evaluate(StopReason::Stop, semantic).is_none(),
"conservative ignores the semantic class"
);
let mut aggressive = TurnRecoveryState::new(TurnRecoveryMode::Aggressive);
assert!(
aggressive.evaluate(StopReason::Stop, semantic).is_some(),
"aggressive handles the semantic class"
);
}
#[test]
fn cap_stops_after_two() {
let mut state = TurnRecoveryState::new(TurnRecoveryMode::Conservative);
assert!(state.evaluate(StopReason::Length, "a").is_some());
assert!(state.evaluate(StopReason::Length, "b").is_some());
assert!(
state.evaluate(StopReason::Length, "c").is_none(),
"third auto-continue must be refused"
);
assert_eq!(state.continuations(), 2);
}
#[test]
fn clean_stops_do_not_consume_the_cap() {
let mut state = TurnRecoveryState::new(TurnRecoveryMode::Conservative);
for _ in 0..10 {
assert!(state.evaluate(StopReason::Stop, "all done.").is_none());
}
assert_eq!(state.continuations(), 0);
}
#[test]
fn structure_heuristics_only_apply_to_the_first_stop() {
let mut state = TurnRecoveryState::new(TurnRecoveryMode::Conservative);
assert!(
state
.evaluate(StopReason::Length, "```rust\nfn main() {")
.is_some()
);
assert!(
state
.evaluate(StopReason::Stop, "}\n```\nAll done.")
.is_none(),
"a continuation closing an earlier fence must not re-trigger"
);
assert!(
state
.evaluate(StopReason::Length, "more truncation")
.is_some(),
"provider-signaled truncation still continues"
);
}
#[test]
fn nudge_text_carries_reason_and_counter() {
let mut state = TurnRecoveryState::new(TurnRecoveryMode::Conservative);
let action = state
.evaluate(StopReason::Length, "partial")
.expect("actionable");
assert!(action.nudge_text.contains("auto-continue 1/2"));
assert!(action.nudge_text.contains("token budget"));
}
}