mort issue #1611: run 8eea3e82 front-loaded a 2,245-char analysis into its cite-call turn and closed with 220 bytes — "Done — that's the full chain above. Short version: …". The user got the 220 bytes and a pointer at a chain that was never posted. finalOutput already had three shapes for this pathology, and the closer matched none of them: too long for the weak-final cap (220 > 120), no citations heading, and no "Citations are logged." ack to open the summary-closer class. So it was delivered verbatim. The three shapes were each a separate vocabulary of ack phrases, which is why a fourth phrasing walked straight through. Two of them are really one signal — the terminal DEFERS, telling us the answer is somewhere the user cannot see — differing only in whether the terminal also carries content of its own. That signal is now isBackRef, shared by both, so a new phrasing is added once and covered in the bare and the "+ compression" variant at the same time. Its open-ended half is aboveRefRe: a DEICTIC "above", separated from the preposition by what follows the word. The deictic use ends its clause ("that's the full chain above.", "as shown above,"); the preposition always continues into a noun phrase ("above 100°C", "above the fold", "above all, …"). pointsAbove additionally requires the reference in the terminal's first 120 bytes — with almost no text before it in THIS message, it cannot be pointing at the message's own content. isSummaryCloser now opens on either the citations ack or pointsAbove. Recovery is unchanged: the mandatory dwarf ratio and the user-message scan boundary still gate it, so a closer only loses to a prior turn in the same user turn that is clearly the fuller original. Break-checked: reverting either classifier, dropping the clause-final rule, or dropping the offset bound each kills a named test; the unmutated control survives.
371 lines
18 KiB
Go
371 lines
18 KiB
Go
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 (isBackRef, or the bookkeeping ack).
|
||
// They differ only in whether the terminal also carries content of its own, so
|
||
// a new deferral phrase added to that shared signal 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 <url> 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.").
|
||
var aboveRefRe = regexp.MustCompile(`(?i)\babove\b[ \t]*([.,;:!?)\]"'’”—-]|\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).
|
||
func pointsAbove(t string) bool {
|
||
loc := aboveRefRe.FindStringIndex(t)
|
||
return loc != nil && loc[0] <= backRefHeadChars
|
||
}
|
||
|
||
// isBackRef reports whether a terminal turn defers to earlier content instead
|
||
// of stating the answer — the signal shared by the back-reference and
|
||
// summary-closer shapes (see finalOutput). Extend the class here, once, rather
|
||
// than in either caller.
|
||
func isBackRef(t string) bool {
|
||
return backRefRe.MatchString(t) || pointsAbove(t)
|
||
}
|
||
|
||
// 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 (
|
||
summaryPreface = `((done|all set|ok(ay)?)[\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).
|
||
// Same guard as weakFinalMaxChars — "there is not enough text before the
|
||
// reference for it to be pointing at content inside this turn" — but
|
||
// expressed as an offset, because a summary closer carries a compression
|
||
// AFTER the pointer and so is not itself short.
|
||
backRefHeadChars = 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
|
||
// 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.
|
||
func isWeakFinal(s string) bool {
|
||
t := strings.TrimSpace(s)
|
||
if t == "" {
|
||
return true
|
||
}
|
||
return len(t) <= weakFinalMaxChars && isBackRef(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 "citations are logged"
|
||
// -style ack sentence (summaryCloserRe), and a deictic back-reference in the
|
||
// terminal's opening (pointsAbove — mort issue #1611's "Done — that's the full
|
||
// chain above. Short version: …", which the ack shape alone did not cover).
|
||
// 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.
|
||
//
|
||
// Only pointsAbove is used here, not the whole isBackRef class: backRefRe's
|
||
// fixed phrases can appear 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)
|
||
}
|
||
|
||
// 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
|
||
}
|