feat(run): Ports.FinalGuard — host seam that nudges an announce-then-stop run to deliver its artifact
Gadfly review (reusable) / review (pull_request) Successful in 29s
Adversarial Review (Gadfly) / review (pull_request) Successful in 28s
executus CI / test (pull_request) Successful in 3m1s

A run can render an artifact into host storage, end on a text-only stop
turn claiming "Done — sent you the file", and never call the host's
delivery tool — the artifact strands while the user is told it shipped
(mort general run e9e7bf40 2026-07-14, gifsmith run 29170c93 2026-07-12).
The loop has no seam for the host to intervene at the stop turn.

Add Ports.FinalGuard: consulted once when a single-loop run is about to
finalize successfully. A non-empty nudge is appended to the SAME
conversation as the next user turn and the agent runs ONE bounded extra
round (same toolbox/observers, capped at finalNudgeMaxSteps=6) — enough
to deliver the stranded file or correct the final text, not enough to
start a new work spree. Guard panics and nudge-round failures are
isolated: the original successful result always survives. The nudge is
recorded as a final_nudge audit event and appended to the durable
checkpoint transcript.

stageInputFiles now also returns the staged file ids, surfaced to the
guard via FinalState.StagedFileIDs — staged inputs live under run scope
but are the user's attachments, not run-produced artifacts, so an
undelivered-artifact check must exclude them. FinalState.ToolNames lets
a host skip runs that have no delivery tool at all.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-14 23:14:09 -04:00
co-authored by Claude Fable 5
parent 8ecdadf8b8
commit 007b7539c2
6 changed files with 446 additions and 18 deletions
+80
View File
@@ -0,0 +1,80 @@
package run
import (
"context"
"log/slog"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// FinalGuard is the optional host seam consulted when a single-loop run is
// about to finalize SUCCESSFULLY (the model produced a final answer and no run
// error occurred). It exists for the "announce-then-stop" failure: an agent
// renders an artifact into host storage, narrates "Done — sent you the file",
// and ends the run without ever calling the host's delivery tool — so nothing
// reaches the user while the final text claims otherwise (observed live:
// gifsmith run 29170c93 2026-07-12, general run e9e7bf40 2026-07-14).
//
// FinalNudge returns "" to let the run finish as-is (the overwhelmingly common
// case — the host should make its check cheap), or a non-empty nudge message.
// 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
// runs again with the same toolbox, capped at finalNudgeMaxSteps steps — enough
// to deliver a stranded artifact (or correct the final text), not enough to
// start a new project. The guard is consulted at most ONCE per run; the nudge
// round's own stop turn is final. Multi-phase runs are not guarded (their
// output contract is the phase pipeline's, not a delivery surface's).
//
// 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
// successful result must never be lost to a guard bug).
type FinalGuard interface {
FinalNudge(ctx context.Context, info RunInfo, state FinalState) string
}
// FinalState is the snapshot a FinalGuard receives about the finishing run.
type FinalState struct {
// Output is the run's final answer text (what the host would deliver).
Output string
// ToolNames lists the tools that were available to the run, so a guard can
// skip nudging a run that has no way to act (e.g. no send_attachments).
ToolNames []string
// StagedFileIDs are the file ids the executor staged from the invocation's
// input attachments (Ports.InputFiles). They live under the run's file scope
// but are the USER's inputs, not run-produced artifacts — a guard checking
// for undelivered artifacts must exclude them.
StagedFileIDs []string
}
// finalNudgeMaxSteps caps the nudge round's tool-dispatch steps. Delivering a
// stranded artifact needs at most a file_list + a send_attachments + a final
// text turn; the cap keeps a model that misreads the nudge from launching a
// whole new work spree on the host's budget.
const finalNudgeMaxSteps = 6
// safeFinalNudge consults the guard behind a recover so a guard panic degrades
// to "no nudge" instead of converting a successful run into a panic-error (the
// executor's top-level recover would otherwise stamp res.Err).
func safeFinalNudge(ctx context.Context, g FinalGuard, info RunInfo, state FinalState) (nudge string) {
defer func() {
if r := recover(); r != nil {
slog.Error("run: FinalGuard panicked; skipping nudge",
"run_id", info.RunID, "panic", r)
nudge = ""
}
}()
return g.FinalNudge(ctx, info, state)
}
// toolboxNames flattens the run toolbox's tool names for FinalState (nil-safe).
func toolboxNames(b *llm.Toolbox) []string {
if b == nil {
return nil
}
tools := b.Tools()
names := make([]string, 0, len(tools))
for _, t := range tools {
names = append(names, t.Name)
}
return names
}