From 88b94576af13e1d1cc4f3952fabfc7da2c6c9ac4 Mon Sep 17 00:00:00 2001 From: steve Date: Fri, 10 Jul 2026 16:31:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(agent):=20address=20gadfly=20=E2=80=94=20do?= =?UTF-8?q?n't=20preamble-veto=20long=20answers;=20dedup=20ignores=20?= =?UTF-8?q?=20wrappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isSubstantiveAnswer: restore the original "≥200 chars ⇒ real answer" unconditional accept (the preamble filter now applies only in the borderline 80–200 band). Applying preambleRe regardless of length regressed the recovery path: a long front-loaded answer opening with a conversational word ("Sure,", "Let me explain:", "First,") was wrongly discarded (flagged by glm-5.2 + claude-code/opus). The NVD-style planning-narration case is already handled by the citation-dominance check, not this filter. - citations dedup: compare with angle-bracket wrappers stripped, so a front-loaded turn that listed the same sources unwrapped still suppresses the duplicate block. - doc: spell out the citationLabelRe leading class members (+ list markers); drop the stale "gated by preamble" note on recoverMinChars. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/finalize.go | 48 +++++++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/agent/finalize.go b/agent/finalize.go index 6753b10..531d94f 100644 --- a/agent/finalize.go +++ b/agent/finalize.go @@ -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 angle-bracket wrappers so a turn that + // 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", ...). @@ -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 +// 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 @@ -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)