diff --git a/agent/finalize.go b/agent/finalize.go index 1ffc9a8..ed08094 100644 --- a/agent/finalize.go +++ b/agent/finalize.go @@ -36,9 +36,9 @@ import ( // 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 +// 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 @@ -130,26 +130,33 @@ var backRefRe = regexp.MustCompile(`(?i)(already answered|see above|as (i )?(sai // 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|$)`) +// +// 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. +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). +// +// 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 } -// 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 @@ -219,11 +226,13 @@ const ( 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 + // 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). @@ -256,7 +265,7 @@ func isWeakFinal(s string) bool { if t == "" { return true } - return len(t) <= weakFinalMaxChars && isBackRef(t) + return len(t) <= weakFinalMaxChars && (backRefRe.MatchString(t) || pointsAbove(t)) } // isCitationsOnly reports whether a terminal turn is essentially just a diff --git a/agent/finalize_test.go b/agent/finalize_test.go index d2bb61a..e300523 100644 --- a/agent/finalize_test.go +++ b/agent/finalize_test.go @@ -51,6 +51,13 @@ func TestIsWeakFinal(t *testing.T) { // that had never been posted. const closer1611 = "Done — that's the full chain above. Short version: it's not one incident, it's the confluence of the Iran war, the Epstein files, and three ex-allies now openly plotting a third-party movement that finally set him off." +// analysis1611 stands in for that run's front-loaded analysis: long enough to +// dwarf closer1611 (>3x its 220 bytes). Shared by the finalOutput table and +// the end-to-end Run test so the two cannot drift apart. +func analysis1611() string { + return strings.TrimSpace(strings.Repeat("The break was the Iran strikes, then the Epstein files. ", 12)) +} + func TestPointsAbove(t *testing.T) { cases := []struct { name string @@ -72,13 +79,24 @@ func TestPointsAbove(t *testing.T) { {"above-all", "Above all, keep the deploy green.", false}, {"above-average", "Turnout was above average in three counties.", false}, + // Hyphenated compounds. \b holds between "above" and "-", so a literal + // '-' in the terminator class made all of these read as deictic — the + // space-separated cases above did NOT cover it (gadfly, 3 models). + {"hyphen-above-average", "Turnout was above-average in three counties.", false}, + {"hyphen-above-board", "The deal was above-board from the start.", false}, + {"hyphen-above-ground", "Run the above-ground cable along the fence.", false}, + {"hyphen-above-mentioned", "The above-mentioned findings are attached.", false}, + {"no-above-at-all", "42", false}, {"empty", "", false}, // Offset bound: past backRefHeadChars there IS enough text before the - // reference for it to be pointing inside this same message. - {"late-reference-not-a-pointer", strings.Repeat("x", backRefHeadChars+1) + " as shown above.", false}, - {"reference-at-the-bound", strings.Repeat("x", backRefHeadChars-6) + " above.", true}, + // reference for it to be pointing inside this same message. The + // lengths are LITERALS, not backRefHeadChars +/- n: a case sized from + // the constant it is meant to pin moves with it, and a break-check + // that widened the bound to 100000 sailed straight through. + {"late-reference-not-a-pointer", strings.Repeat("x", 200) + " as shown above.", false}, + {"reference-at-the-bound", strings.Repeat("x", 100) + " above.", true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -87,6 +105,18 @@ func TestPointsAbove(t *testing.T) { } }) } + + // The two bounds are ONE guard expressed two ways (see backRefHeadChars). + // Pinned here so decoupling them is a test failure, not a silent drift. + if backRefHeadChars != weakFinalMaxChars { + t.Errorf("backRefHeadChars = %d, weakFinalMaxChars = %d: the offset bound and the "+ + "weak-final cap are the same guard and must stay equal", + backRefHeadChars, weakFinalMaxChars) + } + if weakFinalMaxChars != 120 { + t.Errorf("weakFinalMaxChars = %d, want 120: the literal-length cases in this table "+ + "pin the bound at 120 and must be resized with it", weakFinalMaxChars) + } } func TestIsCitationsOnly(t *testing.T) { @@ -208,7 +238,7 @@ func TestFinalOutput(t *testing.T) { // and long enough (>~92 bytes) that longAnswer would fail the summary bar. bothMatchCloser := "Citations are logged. As I mentioned above, the full detail on the money sources is in my earlier message." // The #1611 pair: a front-loaded analysis that dwarfs its 220-byte closer. - analysis1611 := strings.TrimSpace(strings.Repeat("The break was the Iran strikes, then the Epstein files. ", 12)) // >3x closer1611 + analysis := analysis1611() // A terminal using "above" as a PREPOSITION — not a back-reference, so it // must survive verbatim next to a dwarfing prior turn. prepositionalTerminal := "Anything above 100 degrees boils off, which is exactly why the sample evaporated overnight in the unsealed tray." @@ -460,12 +490,12 @@ func TestFinalOutput(t *testing.T) { name: "above-pointer closer discarded when the front-loaded answer dwarfs it", msgs: []llm.Message{ llm.UserText("what set off the Truth Social rant?"), - asst(analysis1611, cite...), + asst(analysis, cite...), llm.ToolResultsMessage(llm.ToolResult{ID: "c1", Name: "cite", Content: "ok"}), asst(closer1611), }, terminal: closer1611, - want: analysis1611, + want: analysis, }, { // The dwarf ratio governs the new opener too: a prior turn that is @@ -652,7 +682,7 @@ func TestRun_RecoversFrontLoadedAnswerWithCitations(t *testing.T) { // compression. The delivered output must be the front-loaded analysis, with no // extra model call. func TestRun_RecoversFrontLoadedAnswerOverAboveRefCloser(t *testing.T) { - analysis := strings.TrimSpace(strings.Repeat("The break was the Iran strikes, then the Epstein files. ", 12)) + analysis := analysis1611() fp := fake.New("fp") fp.Enqueue("test-model", fake.ReplyWith(llm.Response{