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. Three 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 deferring closer ("Citations are logged. Short version: …", "Done — // that's the full chain above. Short version: …"): the model points at the // answer it had already written and compresses it into a one-liner (mort // run b3cb9ee9 — a 2,089-char answer shrank to a 153-byte closer; mort // issue #1611 — a 2,245-char analysis shrank to 220 bytes). The // compression is strictly poorer than the front-loaded answer, so recover // the prior turn and DISCARD the closer — but only when the prior turn // clearly dwarfs it, because unlike a bare back-reference this closer DOES // carry answer content (see modeSummary). // // The last two shapes share one signal — the terminal DEFERS: it tells us the // answer is somewhere the user cannot see (pointsAbove, or the bookkeeping // ack). They differ only in whether the terminal also carries content of its // own, so a new deferral phrasing added to pointsAbove is covered in both the // bare and the "+ compression" variant at once. // // A citations addendum is tested first and wins over the other two (a short // terminal can match more than one shape), so its links are never discarded. // The back-reference test wins over the summary-closer test: a terminal // matching both ("Citations are logged. As I said above…") carries no answer // content of its own, so the looser back-ref recovery bar — not the summary // closer's dwarf ratio — is the right one. 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 compressed answer 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 { mode := modeBackRef switch { case isCitationsOnly(terminal): mode = modeCitations case isWeakFinal(terminal): mode = modeBackRef case isSummaryCloser(terminal): mode = modeSummary default: return terminal } rec, ok := lastSubstantiveAssistantText(msgs, terminal, mode) if !ok { return terminal } if mode == modeCitations { // 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) } // recoveryMode selects the bar a prior assistant turn must clear to replace // the terminal turn (see isSubstantiveAnswer) and what finalOutput does with // the terminal once recovery succeeds. type recoveryMode int const ( // modeBackRef: the terminal is empty or a pure back-reference — worthless // on its own, so any real prior answer replaces it and it is discarded. modeBackRef recoveryMode = iota // modeCitations: the terminal is a sources-only addendum — not a rival // answer, so the dwarf ratio is skipped and the addendum is kept, appended // below the recovered answer. modeCitations // modeSummary: the terminal acknowledges the citation round and may carry // a short compression of the front-loaded answer. Unlike a back-reference // it DOES contain answer content, so it is only replaced when a prior turn // clearly dwarfs it — the ratio is mandatory at every length, the recovery // scan stops at the most recent user message (a compression can only be of // THIS turn's answer; never resurrect one from an earlier question), and // the closer is discarded (its content is a strict subset of what it // replaced). modeSummary ) // 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", ...). It is a list of fixed phrasings; aboveRefRe // covers the open-ended half of the same family. var backRefRe = regexp.MustCompile(`(?i)(already answered|see above|as (i )?(said|mentioned|stated|noted)|answered (that )?above|per my (previous|earlier))`) // aboveRefRe matches a DEICTIC "above" — one pointing at earlier text rather // than serving as a preposition. What follows the word separates the two uses: // the deictic use ends its clause ("that's the full chain above.", "as shown // above,", a line that simply ends in "above"), while the preposition always // continues into a noun phrase ("above 100°C", "above the fold", "above all, // the ..."). Only the clause-final form matches, so the open-ended half of the // back-reference family is covered without enumerating every phrasing a model // might invent — backRefRe's fixed list kept missing new ones (mort issue // #1611: "Done — that's the full chain above."). // // The terminator set deliberately excludes the ASCII hyphen. `\b` holds // between "above" and "-", so a literal '-' in the class made every // hyphenated compound — "above-average", "above-board", "above-ground" — // read as a clause-final deictic and put a legitimate short answer at risk of // being discarded. The em dash stays: a model writes "…above — see the // links", never "above-" as a separator. CR is listed alongside LF so a // CRLF transcript does not quietly lose every line-final "above". var aboveRefRe = regexp.MustCompile(`(?i)\babove\b[ \t]*([.,;:!?)\]"'’”—]|\r|\n|$)`) // pointsAbove reports whether a deictic "above" appears in the terminal's // OPENING. The offset bound is what makes a bare "above" safe to key on: with // almost no text before it in THIS message, the reference cannot be pointing // at the message's own content, so it must point at a turn the user never saw // (the harness delivers only the final turn). // // This is the DEFERRAL SIGNAL SHARED by both recovery shapes — isWeakFinal and // isSummaryCloser both call it, so a widening here reaches the bare closer and // the "+ compression" closer at once. Its sibling backRefRe is deliberately // NOT shared: those fixed phrases are matched anywhere in the text, which is // only safe under isWeakFinal's 120-byte cap. Folding the two into one // predicate for tidiness would hand isSummaryCloser an unanchored match across // 300 bytes — widening the gate, not deduplicating it. func pointsAbove(t string) bool { loc := aboveRefRe.FindStringIndex(t) return loc != nil && loc[0] <= backRefHeadChars } // bareAbovePointer reports whether the terminal is a deictic back-reference and // essentially NOTHING ELSE — the form isWeakFinal may discard outright. // // pointsAbove alone is not enough for that, because a pointer can share a short // terminal with the answer: "That's the chain above. Ship Tuesday." is 37 bytes // and the decision is the four words the pointer is not. Discarding it in // favour of an earlier turn throws away the only thing the user needed. Same // shape as the summary-closer fix — a pointer says where the detail is, it does // not say the text beside it is disposable. // // So the reference's own clause is cut out and whatever remains must be filler: // nothing, punctuation, or a throat-clearing "Done —". Cutting the CLAUSE (from // the end of the previous sentence through the reference) rather than testing // position is also what makes a mixed terminal safe — "Anything above 100 // boils. See the note above." keeps its first sentence and is correctly not // bare, and a comparative "above," with an interjection after it keeps the rest // of its own sentence for the same reason. func bareAbovePointer(t string) bool { loc := aboveRefRe.FindStringIndex(t) if loc == nil || loc[0] > backRefHeadChars { return false } start := 0 if k := strings.LastIndexAny(t[:loc[0]], ".!?\n"); k >= 0 { start = k + 1 } rest := strings.TrimSpace(t[:start]) + " " + strings.TrimSpace(t[loc[1]:]) return bareRemainderRe.MatchString(strings.Trim(rest, pointerResidueCutset)) } // pointerResidueCutset is trimmed from both ends of what survives cutting the // pointer's clause — the brackets, quotes, and punctuation a model wraps a // back-reference in ("(Already answered above.)"). const pointerResidueCutset = " \t\r\n.,;:!?()[]{}\"'“”‘’*_-—" // bareRemainderRe matches a remainder that carries no answer: empty, or only // the filler a closer opens with. Shares fillerWords with summaryPreface so the // two lists cannot drift. var bareRemainderRe = regexp.MustCompile(`(?i)^(` + fillerWords + `[\s,.!:—-]*)*$`) // compressionMarkerRe matches a model announcing that what follows is the // short form of something longer ("Short version: …", "TL;DR: …", "In short, // …"). It is the second half of the deictic summary-closer test: the pointer // says the full answer is elsewhere, and this says the text beside it is a // condensation rather than new reasoning. // // Both halves are required, because a deictic pointer alone does not mean the // terminal is disposable. "Given the analysis above, I recommend option B // because X" opens with a pointer and then states a CONCLUSION the earlier // turn never contained — discarding it in favour of that turn would throw away // the answer. A compression marker is the model telling us the opposite. // // A marker WITHOUT a pointer stays out of scope, as summaryCloserRe's own // comment explains: a user who asked for brevity is answered with exactly that // shape. // The marker must OPEN a sentence. Mid-sentence the same words are ordinary // prose carrying new content — "Given the analysis above, the bottom line is // that we need a different vendor" states a conclusion, it does not announce a // condensation — and treating that as disposable is the very failure the // two-part test exists to prevent. var compressionMarkerRe = regexp.MustCompile(`(?i)(^|[.!?:;—]\s*|\n\s*)(short version|shorter version|short answer|tl;?dr|in short|in brief|in summary|in sum|bottom line|net[- ]net|the gist)\b`) // summaryCloserRe matches a terminal turn that OPENS with a bookkeeping // acknowledgment of the citation round — "Citations are logged.", "Sources // cited.", "Logged the citations." — the shape a model produces when it // front-loaded its answer into an earlier cite-call turn and closes by // acknowledging the tool results, often followed by a "Short version: …" // compression of the answer it already wrote. The ack clause must end at a // sentence terminator ([.!]) DIRECTLY after the verb: "The citations are // recorded in the court transcript…" is a real answer about citations, not // bookkeeping, and must never match. A compression marker without the ack // ("Short version: no.") is deliberately out of scope — a user who asked for // brevity would be answered with exactly that shape, and misclassifying it // would hijack a legitimate answer; an unmatched closer merely keeps today's // behavior (fail closed). Assembled from named fragments so the alternations // stay legible and extendable. const ( // fillerWords are the throat-clearing tokens a closer opens with. Shared // with bareRemainderRe, which has to recognise exactly the same set as // "not answer content". fillerWords = `(done|all set|ok(ay)?)` summaryPreface = `(` + fillerWords + `[\s,.!:—-]+)?` // optional "Done —" style opener summaryNouns = `(citations?|sources?|references?|claims?)` // "all" appears here AND in summaryArticle on purpose: as a quantifier // between noun and verb ("Citations all logged.") and as a determiner // before the noun ("All claims cited.", "Logged all the citations."). summaryCopulas = `((are|were|have\s+been|all)\s+)*` summaryVerbs = `(logged|recorded|cited|saved|noted|captured|filed)` summaryArticle = `((all|the)\s+)*` // star, not ?: "Logged all the citations." ) var summaryCloserRe = regexp.MustCompile(`(?i)^` + leadingMarkers + summaryPreface + `(` + summaryArticle + summaryNouns + `\s+` + summaryCopulas + summaryVerbs + `|logged\s+` + summaryArticle + summaryNouns + `)[.!]`) // 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)^` + leadingMarkers + `(sources?|references?|citations?|works cited|further reading)\b[\s*_]*[::\-—]`) // leadingMarkers tolerates markdown noise before a label: emphasis (*, _), // list (-, +, *), block-quote (>), and ATX-heading (#) markers, with their // whitespace. Shared by citationLabelRe and summaryCloserRe so the two // classifiers cannot drift apart (the first draft of the summary class // dropped '+' by hand-copying this set). const leadingMarkers = `[\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 // backRefHeadChars bounds how far into the terminal a deictic "above" may // sit and still read as pointing OUTSIDE this message (see pointsAbove). // It IS weakFinalMaxChars — the same guard ("there is not enough text // before the reference for it to be pointing at content inside this // turn"), expressed as an offset because a summary closer carries a // compression AFTER the pointer and so is not itself short. Defined by // reference, not by repeating the literal: tuning the weak cap without the // offset following it would split one rule into two. backRefHeadChars = weakFinalMaxChars // 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 // summaryCloserMaxChars bounds a summary closer: room for the ack sentence // plus a couple of compression sentences (the b3cb9ee9 closer was 153 // bytes — Go len(), which is what every threshold here compares). Beyond // this the "short version" is substantial enough that replacing it risks // losing content the front-loaded turn never had. summaryCloserMaxChars = 300 ) // 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. The length // cap is what keeps a genuine answer that merely contains a back-reference // phrase mid-sentence out of the class; past the cap a deferring terminal is // isSummaryCloser's business, under the stricter dwarf bar. // // The deictic half additionally demands that the pointer be the WHOLE message // (bareAbovePointer), not merely present in it. Both classes now enforce the // same rule from opposite ends: a terminal is only disposable when it carries // no answer of its own — proved here by there being nothing beside the pointer, // and in isSummaryCloser by the model declaring what is beside it a // compression. func isWeakFinal(s string) bool { t := strings.TrimSpace(s) if t == "" { return true } return len(t) <= weakFinalMaxChars && (backRefRe.MatchString(t) || bareAbovePointer(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 } // isSummaryCloser reports whether a terminal turn defers to an earlier answer // and is short enough that whatever follows the deferral can only be a // compression of it. Two openers qualify: // // - a complete bookkeeping ack sentence — "Citations are logged." — which is // summaryCloserRe, and carries no answer content of its own; // - a deictic back-reference in the opening PLUS a compression marker — // mort issue #1611's "Done — that's the full chain above. Short version: // …" — which the ack shape alone did not cover. // // The second opener needs both halves. A pointer on its own does not make a // terminal disposable: "Given the analysis above, I recommend option B because // X" points backwards and then states a conclusion the earlier turn never // contained, and replacing it with that turn would discard the answer. The // compression marker is the model saying the opposite — that what follows is // the short form of something it already wrote. // // Whether the fuller answer actually exists is modeSummary's job — the dwarf // ratio in isSubstantiveAnswer keeps a matching closer in place when nothing // earlier clearly outweighs it. // // pointsAbove is used here; its sibling backRefRe is NOT. Those fixed phrases // are matched anywhere in the text, and a 300-byte closer has room for a real // answer that merely mentions "as I said" mid-sentence. pointsAbove is // offset-bounded, so it stays anchored to the opening. func isSummaryCloser(s string) bool { t := strings.TrimSpace(s) if t == "" || len(t) > summaryCloserMaxChars { return false } return summaryCloserRe.MatchString(t) || (pointsAbove(t) && compressionMarkerRe.MatchString(t)) } // 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. mode selects the recovery bar (see // isSubstantiveAnswer). Returns ("", false) when nothing qualifies. func lastSubstantiveAssistantText(msgs []llm.Message, terminal string, mode recoveryMode) (string, bool) { tt := strings.TrimSpace(terminal) for i := len(msgs) - 1; i >= 0; i-- { m := msgs[i] if mode == modeSummary && m.Role == llm.RoleUser { // A summary closer compresses THIS turn's front-loaded answer, so // the scan must not cross into an earlier question: once the dwarf // ratio has rejected the current turn's text, walking further back // would resurrect a stale answer to a DIFFERENT question — strictly // worse than keeping the closer. (A mid-run steer message is also a // user-role boundary; recovery then fails closed, which is fine.) // The other modes keep their historical unbounded scan. break } 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, mode) { 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. // // modeSummary demands the dwarf ratio FIRST, at every length: a summary closer // carries a real (compressed) answer, so replacing it is only justified when // the prior turn is clearly the fuller original it was compressed from. // // A sufficiently long turn (>= recoverMinChars) is otherwise 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 — for modeBackRef only — also clearly dwarf the terminal (a citations // addendum is not a rival answer, so its length is irrelevant; a summary // closer already proved the ratio above). func isSubstantiveAnswer(txt, terminal string, mode recoveryMode) bool { dwarfs := len(txt) >= recoverRatio*len(terminal) if mode == modeSummary && !dwarfs { return false } if len(txt) >= recoverMinChars { return true } if len(txt) < recoverFloorChars || preambleRe.MatchString(txt) { return false } return mode != modeBackRef || dwarfs }