fix(agent): recover front-loaded answer past a citations-only terminal turn #11

Merged
steve merged 5 commits from fix/finalize-citations-only-terminal into main 2026-07-10 16:47:32 +00:00
Showing only changes of commit 88b94576af - Show all commits
+30 -18
View File
1
@@ -45,15 +45,26 @@ func finalOutput(msgs []llm.Message, terminal string) string {
}
if citations {
// Preserve the citations addendum below the recovered answer, unless the
// recovered turn already carries it verbatim (guards against a duplicate
// sources block when the front-loaded turn included its own citations).
if tail := strings.TrimSpace(terminal); !strings.Contains(rec, tail) {
// recovered turn already carries it (guards against a duplicate sources
// block when the front-loaded turn included its own citations). The
// containment test ignores <url> angle-bracket wrappers so a turn that
Outdated
Review

🟡 citations dedup uses raw substring Contains(rec, tail); can drop or duplicate citations on minor text differences

correctness · flagged by 1 model

  • agent/finalize.go:50 — citations dedup is a raw substring Contains, which can both over- and under-trigger. Confirmed at lines 46-53: tail := strings.TrimSpace(terminal) is checked via strings.Contains(rec, tail). If the recovered answer contains the exact sources string as a substring in a different context, the citations are silently dropped; conversely if the prior turn's citations differ by even trailing punctuation/whitespace from the terminal's, the block is appended twice. L…

🪰 Gadfly · advisory

🟡 **citations dedup uses raw substring Contains(rec, tail); can drop or duplicate citations on minor text differences** _correctness · flagged by 1 model_ - **`agent/finalize.go:50` — citations dedup is a raw substring `Contains`, which can both over- and under-trigger.** Confirmed at lines 46-53: `tail := strings.TrimSpace(terminal)` is checked via `strings.Contains(rec, tail)`. If the recovered answer contains the exact sources string as a substring in a different context, the citations are silently dropped; conversely if the prior turn's citations differ by even trailing punctuation/whitespace from the terminal's, the block is appended twice. L… <sub>🪰 Gadfly · advisory</sub>
// listed the same sources unwrapped still suppresses the duplicate.
if tail := strings.TrimSpace(terminal); !strings.Contains(stripURLAngles(rec), stripURLAngles(tail)) {
return rec + "\n\n" + tail
}
}
return rec
}
// stripURLAngles removes the <…> wrappers Discord uses to suppress link embeds,
// so the citations dedup compares URLs regardless of that formatting delta.
func stripURLAngles(s string) string {
if !strings.ContainsAny(s, "<>") {
return s
}
return strings.NewReplacer("<", "", ">", "").Replace(s)
}
// backRefRe matches a terminal turn that merely points back to an earlier
// message instead of stating the answer ("(Already answered above.)",
// "see above", "as I said", ...).
1
@@ -66,11 +77,12 @@ var preambleRe = regexp.MustCompile(`(?i)^(let me|let'?s|i'?ll|i will|first[, ]|
// citationLabelRe matches a terminal turn that OPENS with a sources/citations
// heading — the shape a model produces when it front-loads its answer into an
// earlier tool-call turn and closes with only its sources. Leading markdown
// emphasis / list / block-quote / ATX-heading markers (with their whitespace,
// via \s in the class) are tolerated before the label, and closing emphasis
// (** / __) plus whitespace between the label and the colon/dash separator.
// Anchored at ^ so a normal answer that merely mentions "sources" mid-sentence,
// or ends with a "Sources:" section AFTER its prose, is never matched.
// emphasis (*, _), list (-, +, *), block-quote (>), and ATX-heading (#) markers
// — with their whitespace, since \s is in the class are tolerated before the
// label, as is closing emphasis (** / __) plus whitespace between the label and
// the colon/dash separator. Anchored at ^ so a normal answer that merely
// mentions "sources" mid-sentence, or ends with a "Sources:" section AFTER its
Outdated
Review

citationResidueCutset declared as standalone const instead of in the const block immediately below

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **citationResidueCutset declared as standalone const instead of in the const block immediately below** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
// prose, is never matched.
var citationLabelRe = regexp.MustCompile(`(?i)^[\s>#*_+-]*(sources?|references?|citations?|works cited|further reading)\b[\s*_]*[:\-—]`)
// linkRe matches a whole markdown link "[label](url)" or a bare URL. Used both
@@ -89,7 +101,8 @@ const (
// longer than this, so it is never treated as weak.
weakFinalMaxChars = 120
// recoverMinChars: a prior assistant turn this long is treated as a real
// answer regardless of how it opens (still gated by the preamble check).
// answer regardless of how it opens (the preamble filter is not applied at
// this length — see isSubstantiveAnswer).
recoverMinChars = 200
// recoverFloorChars / recoverRatio gate the borderline band: a shorter
// prior turn must clear the floor and — unless the terminal is a citations
1
@@ -164,20 +177,19 @@ func lastSubstantiveAssistantText(msgs []llm.Message, terminal string, citations
// isSubstantiveAnswer reports whether txt (a prior assistant turn) reads like a
// real answer rather than a preamble, relative to the terminal text.
//
// A preamble ("Let me look that up…") is never the answer regardless of length,
// so that check applies BEFORE the length shortcut — otherwise a long planning
// narration would be recovered. A sufficiently long non-preamble turn is
// accepted; a shorter one must clear a floor and, unless the terminal is a
// citations addendum (not a rival answer, so its length is irrelevant), also
// A sufficiently long turn (>= recoverMinChars) is accepted unconditionally: a
// multi-hundred-char turn is an answer even when it opens conversationally
// ("Sure, here's…", "Let me explain: …"), so the preamble filter is NOT applied
// to it — applying it there would drop a legitimate long front-loaded answer.
// Only in the borderline band does a turn have to clear a floor, not read like a
// short planning preamble ("Let me look that up…"), and — unless the terminal is
// a citations addendum (not a rival answer, so its length is irrelevant) — also
// clearly dwarf the terminal.
func isSubstantiveAnswer(txt, terminal string, citations bool) bool {
if preambleRe.MatchString(txt) {
return false
}
if len(txt) >= recoverMinChars {
return true
}
if len(txt) < recoverFloorChars {
if len(txt) < recoverFloorChars || preambleRe.MatchString(txt) {
return false
}
return citations || len(txt) >= recoverRatio*len(terminal)