package agent import ( "regexp" "strings" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" ) // finalOutput selects the user-facing answer when the loop reaches a clean // terminal turn (one with no tool calls). // // Normally that terminal turn's text IS the answer: well-behaved models defer // their answer to the final, tool-free turn. But some models — notably several // open-weight ones — "front-load" their full answer into an earlier turn that // ALSO calls a tool (e.g. answer text alongside a citation call), then close // with a degenerate terminal turn that is not itself the answer. Two shapes are // recovered from the transcript (zero extra model calls): // // - a trivial back-reference ("(Already answered above.)", "see above", …): // the real answer sits earlier, so recover it and DISCARD the worthless // closer. // - a sources/citations-only addendum ("Sources: [x](…), [y](…)"): the model // front-loaded the prose answer and closed with just its citations (the // glm-5.2 "cite" pattern behind mort issue #1418). The citations are real, // useful content — unlike a back-reference — so recover the prior answer and // KEEP the citations, appended below it. // // A citations addendum is tested first and wins over the back-reference test (a // short terminal can be both), so its links are never discarded. When the // terminal text stands on its own it is returned unchanged; when it is // degenerate but nothing better can be recovered, it is returned as-is (a bare // sources list still beats nothing). // // msgs must already include the terminal assistant message as its last element // (the loop appends it before calling this); terminal is that message's text. func finalOutput(msgs []llm.Message, terminal string) string { citations := isCitationsOnly(terminal) if !citations && !isWeakFinal(terminal) { return terminal } rec, ok := lastSubstantiveAssistantText(msgs, terminal, citations) if !ok { return terminal } if citations { // Preserve the citations addendum below the recovered answer, unless the // 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", ...). var backRefRe = regexp.MustCompile(`(?i)(already answered|see above|as (i )?(said|mentioned|stated|noted)|answered (that )?above|per my (previous|earlier))`) // preambleRe matches intent-announcing prefixes ("Let me search...", "I'll // check...") so a preamble is never mistaken for the answer during recovery. var preambleRe = regexp.MustCompile(`(?i)^(let me|let'?s|i'?ll|i will|first[, ]|sure[,. ]|okay[,. ]|on it|checking)`) // 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 (>), 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 // to require that a citations terminal carries at least one link and to strip // links out when measuring how citation-dominated the terminal is. var linkRe = regexp.MustCompile(`\[[^\]]*\]\([^)]*\)|https?://\S+`) // citationResidueCutset is trimmed from the ends of a citations terminal's // non-link remainder before measuring it — list bullets, separators, and the // short per-source annotations models add in parentheses. const citationResidueCutset = " \t\r\n,;.:|·•*_()[]—-" const ( // weakFinalMaxChars bounds how long a back-reference closer can be. A // genuine final answer that merely contains "as I said" mid-sentence is // 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 (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 // addendum, which is not a rival answer — also clearly dwarf the (very // short) terminal. See isSubstantiveAnswer. recoverFloorChars = 80 recoverRatio = 3 // citationDominatedDivisor: a citations terminal's non-link remainder must // be at most len/N of the whole, so a prose answer that merely opens with // "Source:" and cites a URL mid-sentence is not mistaken for a bare list. citationDominatedDivisor = 3 ) // isWeakFinal reports whether a terminal turn's text fails to stand on its own // as the answer: empty/whitespace, or a short pure back-reference. func isWeakFinal(s string) bool { t := strings.TrimSpace(s) if t == "" { return true } return len(t) <= weakFinalMaxChars && backRefRe.MatchString(t) } // isCitationsOnly reports whether a terminal turn is essentially just a // sources/citations addendum: it OPENS with a citations heading, carries at // least one link, and — once the heading and links are removed — is dominated // by that citation structure (only list punctuation and short per-source // annotations remain). The dominance check is what separates a bare sources // list (recover the front-loaded answer, keep the links) from a real prose // answer that merely opens with "Source:" and references a URL mid-sentence // (leave it as the answer). Unlike a back-reference closer the links are worth // keeping, so finalOutput appends them to the recovered answer. // // A citations terminal whose sources are bare domains (no scheme, no markdown // link) is intentionally out of scope — there is no reliable link signal, so it // is left as-is rather than risk misclassifying prose. func isCitationsOnly(s string) bool { t := strings.TrimSpace(s) if !citationLabelRe.MatchString(t) { return false } body := citationLabelRe.ReplaceAllString(t, "") if !linkRe.MatchString(body) { return false } residue := strings.Trim(linkRe.ReplaceAllString(body, ""), citationResidueCutset) return len(residue) <= len(t)/citationDominatedDivisor } // lastSubstantiveAssistantText scans msgs newest→oldest (skipping the terminal // turn and empty tool-only turns) for the most recent assistant turn whose text // reads like a real answer. citations selects the recovery bar (see // isSubstantiveAnswer). Returns ("", false) when nothing qualifies. func lastSubstantiveAssistantText(msgs []llm.Message, terminal string, citations bool) (string, bool) { tt := strings.TrimSpace(terminal) for i := len(msgs) - 1; i >= 0; i-- { m := msgs[i] if m.Role != llm.RoleAssistant { continue } txt := strings.TrimSpace(m.Text()) if txt == "" || txt == tt { continue // the terminal turn itself, or an empty tool-only turn } if isSubstantiveAnswer(txt, tt, citations) { return txt, true } } return "", false } // isSubstantiveAnswer reports whether txt (a prior assistant turn) reads like a // real answer rather than a preamble, relative to the terminal text. // // 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 len(txt) >= recoverMinChars { return true } if len(txt) < recoverFloorChars || preambleRe.MatchString(txt) { return false } return citations || len(txt) >= recoverRatio*len(terminal) }