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
181 changes: 167 additions & 14 deletions src/openhuman/flows/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4367,15 +4367,17 @@ pub async fn flows_build(
let trail_off = !capped && proposal.is_none() && run_error.is_none();
let assistant_text = if trail_off && !text_looks_like_question(&assistant_text) {
let fallback = build_trail_off_fallback(agent.history());
let combined = combine_trail_off_fallback(&fallback, &assistant_text);
tracing::warn!(
target: "flows",
flow_id = req.flow_id.as_deref().unwrap_or("<none>"),
original_len = assistant_text.len(),
fallback_len = fallback.len(),
combined_len = combined.len(),
"[flows] flows_build: trail-off detected (no proposal, no cap, no question) — \
guaranteeing a fallback question instead of silence"
guaranteeing a fallback question while preserving the model's original text"
);
fallback
combined
} else {
assistant_text
};
Expand Down Expand Up @@ -4414,15 +4416,31 @@ pub async fn flows_build(
))
}

/// Heuristic: does `text` already end with a clear, answerable question?
/// Conservative by design (issue: builder convergence) — a false negative (an
/// actual question this misses) just wraps it in the trail-off fallback,
/// which still includes the blocker context, so the safe failure mode is
/// "over-wrap", never "under-detect and stay silent".
/// Heuristic: does `text` already contain a clear, answerable question in its
/// final paragraph? Conservative by design (issue: builder convergence) — a
/// false negative (an actual question this misses) no longer discards the
/// model's text (see `combine_trail_off_fallback`), so the safe failure mode
/// stays "add a guaranteed question on top", never "under-detect and stay
/// silent".
///
/// Regression (#4887 follow-up): the original version only checked for a `?`
/// at the very end of the text / last line, which false-negatived on the
/// extremely common LLM pattern "What's X? You can find it at Y." — a real
/// question immediately followed by a trailing instructional sentence. The
/// backstop then clobbered a specific, answerable question with a generic
/// fallback. To catch that shape, this now also scans the LAST non-empty
/// paragraph for a `?` that isn't inside inline code or a fenced code block
/// (so a literal `?` in a code sample, e.g. `WHERE id = ?`, doesn't count).
///
/// Note: the trailing-noise strip below deliberately does NOT include the
/// backtick. Stripping a trailing backtick would peel off the CLOSING
/// delimiter of a code span whose last character is `?` (e.g. `` `id = ?` ``
/// at the very end of the text), exposing that `?` as if it were a bare
/// trailing question mark and defeating the code guard entirely.
fn text_looks_like_question(text: &str) -> bool {
let trimmed = text
.trim()
.trim_end_matches(['"', '\'', ')', ']', '*', '_', '`', '.'])
.trim_end_matches(['"', '\'', ')', ']', '*', '_', '.'])
.trim_end();
if trimmed.is_empty() {
return false;
Expand All @@ -4432,15 +4450,132 @@ fn text_looks_like_question(text: &str) -> bool {
}
// The question may not be the literal last character (trailing markdown
// like a closing code fence or list marker on its own line) — fall back
// to the last non-blank line. This does NOT catch a question followed by
// a further trailing sentence ("...channel?\n\nLet me know!") — that's
// an accepted false negative: the turn still ends in a real (if
// over-eagerly replaced) question, never in silence, which is the
// invariant this function exists to protect.
trimmed
// to the last non-blank line.
if trimmed
.lines()
.rfind(|line| !line.trim().is_empty())
.is_some_and(|last_line| last_line.trim_end().ends_with('?'))
{
return true;
}
// Final-paragraph scan: a question can sit mid-paragraph, followed by a
// further trailing sentence on the SAME line/paragraph ("...ID? You can
// find it under Profile > Copy member ID."). Take the last non-blank
// paragraph and accept it if it contains a `?` that isn't inside inline
// code / a code fence.
last_paragraph(trimmed)
.as_deref()
.is_some_and(question_mark_outside_code)
}

/// Returns the last non-blank paragraph of `text` — a maximal run of
/// consecutive non-blank lines, working backward from the end and skipping
/// any trailing blank lines first. `None` if `text` has no non-blank lines.
///
/// CodeRabbit review follow-up: this used to split on the literal `"\n\n"`
/// byte sequence, which mishandles two real shapes:
/// - **CRLF input** (`"question?\r\n\r\nstatus"`): the separator is
/// `"\r\n\r\n"`, not `"\n\n"`, so the whole text was treated as ONE
/// paragraph — an earlier question could then suppress the fallback for a
/// trailing non-question status paragraph.
/// - **Whitespace-only separator lines** (`"question?\n \nstatus"` — a blank
/// line that isn't perfectly empty): same failure, same reason.
///
/// Working line-by-line via [`str::lines`] (which normalizes CRLF) and
/// treating any all-whitespace line as blank fixes both.
fn last_paragraph(text: &str) -> Option<String> {
let mut collected: Vec<&str> = Vec::new();
for line in text.lines().rev() {
if line.trim().is_empty() {
if collected.is_empty() {
continue; // still skipping trailing blank lines
}
break; // blank line marks the start of the paragraph above
}
collected.push(line);
}
if collected.is_empty() {
return None;
}
collected.reverse();
Some(collected.join("\n"))
}

/// Does `text` contain at least one *sentence-terminal* `?` that isn't
/// inside a backtick-delimited code span (inline code like `` `U...` `` or a
/// fenced block like `` ``` ``)? Follows the CommonMark code-span rule: a
/// *run* of one or more consecutive backticks opens a span, and that span is
/// closed only by the next run of the SAME length — a shorter or longer run
/// of backticks encountered while inside a span is just literal backtick
/// characters, not a delimiter.
///
/// CodeRabbit review follow-up: an earlier version tracked a running
/// per-character backtick COUNT and used its parity (even = outside code).
/// That misclassifies any multi-backtick span whose delimiter is more than
/// one backtick — e.g. ``` ``SELECT ? FROM t`` ``` opens with a 2-backtick
/// run (count 0→2, even → looks "outside" again immediately), so the `?`
/// inside a valid double-backtick span was wrongly treated as outside code.
/// Tracking delimiter run LENGTH (not raw backtick count) fixes this while
/// still handling the common single-backtick and triple-backtick-fence
/// cases, since those are just the run-length-1 and run-length-3 instances
/// of the same rule.
///
/// Codex review follow-up: a bare `?` outside code isn't necessarily a real
/// question — a status line like "Checked https://api.example/search?q=foo
/// and got 403." has one mid-token, in a URL query string. Counting that
/// would flip `text_looks_like_question` to `true` and skip
/// `combine_trail_off_fallback` entirely, leaving the user with an
/// unanswerable status note — exactly the failure mode this backstop exists
/// to prevent. So each candidate `?` is additionally required to be
/// sentence-terminal via [`is_sentence_terminal_question_mark`].
fn question_mark_outside_code(text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
// `Some(n)` while scanning is inside a code span opened by a run of `n`
// backticks; that span closes only on the next run of exactly `n`.
let mut open_run_len: Option<usize> = None;
let mut i = 0;
while i < chars.len() {
if chars[i] == '`' {
let start = i;
while i < chars.len() && chars[i] == '`' {
i += 1;
}
let run_len = i - start;
open_run_len = match open_run_len {
None => Some(run_len),
Some(n) if n == run_len => None,
Some(n) => Some(n), // mismatched run length: still inside the span
};
continue;
}
if chars[i] == '?'
&& open_run_len.is_none()
&& is_sentence_terminal_question_mark(&chars, i)
{
return true;
}
i += 1;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
false
}

/// Is the `?` at `chars[index]` sentence-terminal — i.e. does it read as an
/// actual question mark rather than a character that merely happens to be a
/// `?` mid-token (a URL query string like `search?q=foo`, a shell glob,
/// etc.)? Skips over any immediately-following closing quote/bracket
/// punctuation (`"`, `'`, right single/double quotes, `)`, `]`) and requires
/// what remains to be whitespace or the end of the text — the shape a `?`
/// takes at the end of a real sentence or clause.
fn is_sentence_terminal_question_mark(chars: &[char], index: usize) -> bool {
let mut i = index + 1;
while let Some(&c) = chars.get(i) {
if matches!(c, '"' | '\'' | '\u{2019}' | '\u{201D}' | ')' | ']') {
i += 1;
continue;
}
return c.is_whitespace();
}
true // '?' was the last character in the paragraph.
}

/// Builder-authoring tools whose result body can explain a trail-off — the
Expand Down Expand Up @@ -4480,6 +4615,24 @@ fn build_trail_off_fallback(
}
}

/// Combines the guaranteed trail-off `fallback` question with the model's own
/// `original` text instead of discarding it (#4887 follow-up, Change 2). Even
/// after loosening `text_looks_like_question`, a future false negative must
/// never destroy the model's words — it should only ever ADD the guaranteed
/// question on top. The `fallback` is prepended (so the user sees the
/// actionable question first) and the original is kept below a divider for
/// context. When `original` is empty/whitespace-only (a genuine silent
/// turn — there's nothing to preserve), returns the fallback alone rather
/// than prepending an empty divider.
fn combine_trail_off_fallback(fallback: &str, original: &str) -> String {
let trimmed_original = original.trim();
if trimmed_original.is_empty() {
fallback.to_string()
} else {
format!("{fallback}\n\n---\n\n{trimmed_original}")
}
}

/// Scans `history` in reverse for the last result from a
/// [`TRAIL_OFF_BLOCKER_TOOLS`] call that reads as a failure — a plain-text
/// error message (gate rejection), or a JSON body with `"ok": false` — and
Expand Down
144 changes: 138 additions & 6 deletions src/openhuman/flows/ops_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4751,18 +4751,120 @@ fn text_looks_like_question_detects_trailing_question_mark() {
));
}

/// A question followed by a further trailing sentence on its own line
/// ("...channel?\n\nLet me know!") is an accepted false negative — the
/// heuristic is deliberately conservative (see the function doc). Pin that
/// this case is NOT detected so a future "improvement" doesn't silently
/// change the accepted trade-off without a matching design review.
/// Regression (#4887 follow-up): a question immediately followed by a
/// trailing pleasantry/instruction in the SAME paragraph ("...to? Let me
/// know!") used to be an accepted false negative. That false negative let the
/// trail-off backstop clobber real, specific questions with a generic
/// fallback — this is now DETECTED via the final-paragraph scan in
/// `text_looks_like_question`.
///
/// Note: a question mark separated from the trailing sentence by a full
/// blank-line paragraph break (`"...to?\n\nLet me know!"`) is a DIFFERENT
/// shape — the `?` there sits in an earlier paragraph, not the last one — and
/// remains an intentional false negative: the final-paragraph scan only
/// looks at the LAST non-blank paragraph, by design (see the function doc
/// and `text_looks_like_question_ignores_question_mark_in_earlier_paragraph`
/// below, which pins that scope decision).
#[test]
fn text_looks_like_question_accepts_false_negative_on_trailing_pleasantry() {
fn text_looks_like_question_detects_same_paragraph_trailing_pleasantry() {
assert!(text_looks_like_question(
"Which channel should I post to? Let me know!"
));
}

/// Pins the intentional cross-paragraph false negative documented above: a
/// `?` that sits in an EARLIER paragraph than the last one is deliberately
/// NOT detected — the final-paragraph scan only looks at the last non-blank
/// paragraph, by design. This is harmless because the trail-off backstop's
/// fallback is non-destructive (PREPEND, not REPLACE): even when this false
/// negative fires, the model's original question is preserved below the
/// fallback rather than discarded.
#[test]
fn text_looks_like_question_ignores_question_mark_in_earlier_paragraph() {
assert!(!text_looks_like_question(
"Which channel should I post to?\n\nLet me know!"
));
}

/// The exact shape a live tester hit (#4887 regression): a clear, specific
/// question mid-sentence, immediately followed by a trailing instructional
/// sentence on the SAME paragraph/line. The old last-line-only check missed
/// this entirely; the final-paragraph scan must catch it.
#[test]
fn text_looks_like_question_detects_mid_sentence_question_with_trailing_instruction() {
assert!(text_looks_like_question(
"Alan — what's your **Slack user ID** (the `U...` code) so I can DM you the daily \
update? You can find it in Slack under Profile > Copy member ID."
));
}

/// A `?` that only appears inside inline code or a fenced code block must
/// NOT be treated as a question — the guard on `question_mark_outside_code`
/// has to hold, or a code sample like `WHERE id = ?` would false-positive.
#[test]
fn text_looks_like_question_ignores_question_mark_inside_code() {
assert!(!text_looks_like_question(
"Run the query below to check the row.\n\n`SELECT * FROM t WHERE id = ?`"
));
assert!(!text_looks_like_question(
"Here's the query:\n\n```sql\nSELECT * FROM t WHERE id = ?\n```"
));
}

/// Codex review follow-up: a `?` mid-token that isn't a real question mark —
/// e.g. a URL query string in a status update — must NOT flip
/// `text_looks_like_question` to `true`. Counting it would make `flows_build`
/// skip `combine_trail_off_fallback` entirely, leaving the user with an
/// unanswerable status note and no guaranteed question — exactly the failure
/// mode this backstop exists to prevent.
#[test]
fn text_looks_like_question_ignores_question_mark_in_url_query_string() {
assert!(!text_looks_like_question(
"Checked https://api.example/search?q=foo and got 403."
));
assert!(!text_looks_like_question(
"Ran the search with filter?status=open but the API rejected it."
));
}

/// CodeRabbit review follow-up: paragraph boundaries must be recognized for
/// CRLF line endings and whitespace-only blank lines, not just a literal
/// `"\n\n"` byte sequence — otherwise an earlier question survives into what
/// should be treated as a separate, later, non-question status paragraph,
/// and the fallback gets wrongly suppressed for that trailing paragraph.
#[test]
fn text_looks_like_question_treats_crlf_and_whitespace_lines_as_paragraph_breaks() {
// CRLF paragraph break: the earlier "?" must not leak into the final
// paragraph, which is a plain status line with no question of its own.
assert!(!text_looks_like_question(
"Which channel should I post to?\r\n\r\nPosted the update just now."
));
// Whitespace-only blank line (not perfectly empty) must also count as a
// paragraph break.
assert!(!text_looks_like_question(
"Which channel should I post to?\n \nPosted the update just now."
));
}

/// CodeRabbit review follow-up: a multi-backtick Markdown code span (e.g.
/// double backtick, used so the span can itself contain a literal single
/// backtick) must still be recognized as code — a naive backtick-count
/// parity check misclassifies it because two backticks flip parity back to
/// "even" immediately. The span must only close on a run of the SAME length
/// that opened it.
#[test]
fn text_looks_like_question_ignores_question_mark_inside_double_backtick_span() {
assert!(!text_looks_like_question(
"Run the query below to check the row.\n\n``SELECT * FROM t WHERE id = ?``"
));
// A single backtick embedded inside a double-backtick span (the classic
// reason to use a longer delimiter) must not be mistaken for the span's
// closing delimiter.
assert!(!text_looks_like_question(
"Use ``SELECT `id` FROM t WHERE id = ?`` before retrying."
));
}

#[test]
fn text_looks_like_question_rejects_status_dumps_and_silence() {
assert!(!text_looks_like_question(
Expand Down Expand Up @@ -4887,3 +4989,33 @@ fn build_trail_off_fallback_does_not_resurface_a_resolved_blocker() {
);
assert!(text_looks_like_question(&fallback));
}

/// Change 2 of the #4887 regression fix: when the trail-off backstop fires on
/// a genuine non-question (a status dump), the model's original words must
/// still be present in the combined output — the fallback question is added
/// on top, never a replacement.
#[test]
fn combine_trail_off_fallback_preserves_original_text_on_genuine_non_question() {
let original = "## Done so far\n- Checked connections\n- Verified contracts";
let fallback = build_trail_off_fallback(&[]);
let combined = combine_trail_off_fallback(&fallback, original);
// Assert the exact combined string, not just that both pieces appear
// somewhere — this pins the documented fallback-first ordering and the
// `---` divider, which a looser `contains`-based check wouldn't catch a
// regression in (e.g. original-first ordering, or a missing divider).
assert_eq!(combined, format!("{fallback}\n\n---\n\n{original}"));
// The combined text still ends in the model's original (non-question)
// words, so the "is this a question" invariant applies to the
// fallback alone, not the full combined string.
assert!(text_looks_like_question(&fallback));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Guards against prepending an empty divider when the original text is a
/// genuine silent turn (empty/whitespace-only) — there is nothing to
/// preserve, so the combined output should just be the fallback.
#[test]
fn combine_trail_off_fallback_returns_fallback_alone_for_genuine_silence() {
let fallback = build_trail_off_fallback(&[]);
assert_eq!(combine_trail_off_fallback(&fallback, ""), fallback);
assert_eq!(combine_trail_off_fallback(&fallback, " \n\n "), fallback);
}
Loading