feat(run): Ports.FinalGuard — nudge an announce-then-stop run to deliver its stranded artifact #23

Merged
steve merged 2 commits from feat/final-guard-nudge into main 2026-07-15 16:23:42 +00:00
3 changed files with 89 additions and 18 deletions
Showing only changes of commit 7a074d8fa2 - Show all commits
+23
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
"gitea.stevedudenhoeffer.com/steve/majordomo/agent" "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
@@ -500,6 +501,15 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
// small fixed step cap so a misread nudge can't start a new work spree. // small fixed step cap so a misread nudge can't start a new work spree.
// The nudge round's failure is non-fatal — the original successful result // The nudge round's failure is non-fatal — the original successful result
// stands (any attachments it queued before failing still deliver). // stands (any attachments it queued before failing still deliver).
//
// The nudge round's job is to QUEUE deliveries (tool calls); its TEXT is
// meta-commentary ("answer above stands", "sent!") that must never replace
// the answer the user asked for — hosts deliver Output text + separately
// drained attachments, so replacing Output with the nudge round's closer
// LOSES the real answer (live regression: run d5ec39f4, a research run's
// page-cache false positive whose nudge reply "my answer above stands
// as-is" was delivered INSTEAD of the answer). Keep the original Output
// unless it was blank; log what was discarded so hosts can audit.
if runErr == nil && runRes != nil && e.cfg.Ports.FinalGuard != nil { if runErr == nil && runRes != nil && e.cfg.Ports.FinalGuard != nil {
state := FinalState{ state := FinalState{
Output: runRes.Output, Output: runRes.Output,
@@ -529,7 +539,20 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
slog.Warn("run: final-nudge round failed; keeping original result", slog.Warn("run: final-nudge round failed; keeping original result",
"run_id", inv.RunID, "error", nudgeErr) "run_id", inv.RunID, "error", nudgeErr)
} else if nudgeRes != nil { } else if nudgeRes != nil {
if strings.TrimSpace(runRes.Output) == "" {
// Degenerate original (blank stop turn): the nudge round's
// text is the only answer there is.
runRes.Output = nudgeRes.Output runRes.Output = nudgeRes.Output
} else if rec != nil {
// Discarded meta-text stays observable: a nudge round that
// DECLINED to queue while the original claims a send shows
// up here, not in the user's reply.
rec.LogEvent("final_nudge_result", map[string]any{
"discarded_output": nudgeRes.Output,
})
}
// The transcript keeps the nudge round either way (audit,
// PostRun, checkpoint continuity).
runRes.Messages = nudgeRes.Messages runRes.Messages = nudgeRes.Messages
} }
} }
+11 -4
View File
@@ -20,10 +20,17 @@ import (
// A non-empty nudge sends the loop back for ONE bounded extra round: the nudge // A non-empty nudge sends the loop back for ONE bounded extra round: the nudge
// is appended to the SAME conversation as the next user turn and the agent // is appended to the SAME conversation as the next user turn and the agent
// runs again with the same toolbox, capped at finalNudgeMaxSteps steps — enough // runs again with the same toolbox, capped at finalNudgeMaxSteps steps — enough
// to deliver a stranded artifact (or correct the final text), not enough to // to deliver a stranded artifact, not enough to start a new project. The guard
// start a new project. The guard is consulted at most ONCE per run; the nudge // is consulted at most ONCE per run; the nudge round's own stop turn is final.
// round's own stop turn is final. Multi-phase runs are not guarded (their // Multi-phase runs are not guarded (their output contract is the phase
// output contract is the phase pipeline's, not a delivery surface's). // pipeline's, not a delivery surface's).
//
// OUTPUT CONTRACT: the nudge round exists to make TOOL CALLS (queue the
// delivery); its final TEXT is discarded — the run's Output stays the original
// answer unless that answer was blank. A host's nudge message should say so
// explicitly ("any text you write here is not shown to the user"), and must
// not instruct the model to compose a corrected reply. Discarded nudge text is
// recorded as a final_nudge_result audit event.
// //
// The call runs on the run goroutine, inside the run's deadline, and is // The call runs on the run goroutine, inside the run's deadline, and is
// panic-isolated: a guard panic is logged and treated as "" (the run's // panic-isolated: a guard panic is logged and treated as "" (the run's
+54 -13
View File
@@ -41,17 +41,21 @@ func (r *eventRecorder) LogEvent(eventType string, _ map[string]any) {
// TestFinalGuardNudgeRunsOneExtraRound is the core announce-then-stop fix: the // TestFinalGuardNudgeRunsOneExtraRound is the core announce-then-stop fix: the
// model stops with "Done — sent!" (nothing delivered), the guard returns a // model stops with "Done — sent!" (nothing delivered), the guard returns a
// nudge, and the SAME conversation runs one more round — here a tool call then // nudge, and the SAME conversation runs one more round — here a tool call
// a corrected final answer. The guard is consulted exactly once (the nudge // (queueing the delivery) then a closing text turn. The guard is consulted
// round's own stop is final), and the audit log records the nudge. // exactly once (the nudge round's own stop is final), the audit log records
// the nudge, and the ORIGINAL answer text survives: the nudge round's closer
// is discarded (recorded as final_nudge_result), because hosts deliver Output
// text + separately-drained attachments — replacing Output with the closer is
// the run-d5ec39f4 lost-answer regression.
func TestFinalGuardNudgeRunsOneExtraRound(t *testing.T) { func TestFinalGuardNudgeRunsOneExtraRound(t *testing.T) {
fp := fake.New("fake") fp := fake.New("fake")
fp.Enqueue("m", fp.Enqueue("m",
fake.Reply("Done — sent you the file!"), fake.Reply("Done — sent you the file!"),
// Nudge round: an (unregistered → tolerated error result) delivery call, // Nudge round: an (unregistered → tolerated error result) delivery call,
// then the corrected final answer. // then a meta closer that must NOT become the run output.
fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ID: "c1", Name: "send_attachments", Arguments: []byte(`{}`)}}}), fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ID: "c1", Name: "send_attachments", Arguments: []byte(`{}`)}}}),
fake.Reply("delivered for real"), fake.Reply("queued the file — answer above stands"),
) )
m, _ := fp.Model("m") m, _ := fp.Model("m")
rec := &eventRecorder{} rec := &eventRecorder{}
@@ -72,8 +76,11 @@ func TestFinalGuardNudgeRunsOneExtraRound(t *testing.T) {
if res.Err != nil { if res.Err != nil {
t.Fatalf("run error: %v", res.Err) t.Fatalf("run error: %v", res.Err)
} }
if res.Output != "delivered for real" { if res.Output != "Done — sent you the file!" {
t.Fatalf("output = %q, want the nudge round's answer", res.Output) t.Fatalf("output = %q, want the ORIGINAL answer preserved (nudge closer discarded)", res.Output)
}
if n := fp.CallCount("m"); n != 3 {
t.Fatalf("model called %d times, want 3 (the nudge round must still run)", n)
} }
if guard.calls != 1 { if guard.calls != 1 {
t.Fatalf("guard consulted %d times, want exactly 1 (no re-guard of the nudge round)", guard.calls) t.Fatalf("guard consulted %d times, want exactly 1 (no re-guard of the nudge round)", guard.calls)
@@ -84,17 +91,51 @@ func TestFinalGuardNudgeRunsOneExtraRound(t *testing.T) {
if guard.state.Output != "Done — sent you the file!" { if guard.state.Output != "Done — sent you the file!" {
t.Errorf("guard state.Output = %q, want the pre-nudge final text", guard.state.Output) t.Errorf("guard state.Output = %q, want the pre-nudge final text", guard.state.Output)
} }
var sawNudgeEvent bool var sawNudge, sawResult bool
for _, ev := range rec.events { for _, ev := range rec.events {
if ev == "final_nudge" { switch ev {
sawNudgeEvent = true case "final_nudge":
sawNudge = true
case "final_nudge_result":
sawResult = true
} }
} }
if !sawNudgeEvent { if !sawNudge {
t.Errorf("audit events %v missing final_nudge", rec.events) t.Errorf("audit events %v missing final_nudge", rec.events)
} }
if rec.stats.Output != "delivered for real" { if !sawResult {
t.Errorf("audit close output = %q, want the nudged output", rec.stats.Output) t.Errorf("audit events %v missing final_nudge_result (the discarded closer must stay observable)", rec.events)
}
if rec.stats.Output != "Done — sent you the file!" {
t.Errorf("audit close output = %q, want the original answer", rec.stats.Output)
}
}
// TestFinalGuardNudgeFillsEmptyOriginal: when the ORIGINAL stop turn was blank
// (a degenerate run), the nudge round's text is the only answer there is — it
// becomes the output instead of being discarded.
func TestFinalGuardNudgeFillsEmptyOriginal(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m",
fake.Reply(" \n"),
fake.Reply("actual answer from the nudge round"),
)
m, _ := fp.Model("m")
guard := &guardFunc{nudge: func(RunInfo, FinalState) string { return "deliver or correct" }}
ex := New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: Ports{FinalGuard: guard},
})
res := ex.Run(context.Background(),
RunnableAgent{ModelTier: "m"}, tool.Invocation{RunID: "r"}, "hi")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != "actual answer from the nudge round" {
t.Fatalf("output = %q, want the nudge round's text to fill a blank original", res.Output)
} }
} }