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
+10 -10
View File
@@ -44,7 +44,7 @@ func newStagerExecutor(s InputFileStager) *Executor {
func TestStageInputFiles(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
out := ex.stageInputFiles(context.Background(), "run-1", "agent-1",
out, _ := ex.stageInputFiles(context.Background(), "run-1", "agent-1",
[]tool.InputFile{{Name: "clip.mp3", MimeType: "audio/mpeg", Data: []byte("abcd")}},
"transcribe this")
@@ -65,7 +65,7 @@ func TestStageInputFiles(t *testing.T) {
// drops the run.
func TestStageInputFilesNoStager(t *testing.T) {
ex := newStagerExecutor(nil) // Ports.InputFiles == nil
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "x.bin", Data: []byte("z")}}, "prompt")
if out != "prompt" {
t.Errorf("nil stager changed the prompt: %q", out)
@@ -75,7 +75,7 @@ func TestStageInputFilesNoStager(t *testing.T) {
// TestStageInputFilesNoFiles: no attachments leaves the prompt untouched.
func TestStageInputFilesNoFiles(t *testing.T) {
ex := newStagerExecutor(&stagerFunc{})
out := ex.stageInputFiles(context.Background(), "r", "a", nil, "prompt")
out, _ := ex.stageInputFiles(context.Background(), "r", "a", nil, "prompt")
if out != "prompt" {
t.Errorf("no files changed the prompt: %q", out)
}
@@ -86,7 +86,7 @@ func TestStageInputFilesNoFiles(t *testing.T) {
func TestStageInputFilesDedup(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
out := ex.stageInputFiles(context.Background(), "r", "a", []tool.InputFile{
out, _ := ex.stageInputFiles(context.Background(), "r", "a", []tool.InputFile{
{Name: "a.wav", MimeType: "audio/wav", Data: []byte("1")},
{Name: "a.wav", MimeType: "audio/wav", Data: []byte("2")},
}, "go")
@@ -106,13 +106,13 @@ func TestStageInputFilesDedup(t *testing.T) {
func TestStageInputFilesSkipsBad(t *testing.T) {
// Empty data → skipped; with no good files the prompt is returned as-is.
ex := newStagerExecutor(&stagerFunc{})
if out := ex.stageInputFiles(context.Background(), "r", "a",
if out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "empty.bin", Data: nil}}, "p"); out != "p" {
t.Errorf("empty file should be skipped, got %q", out)
}
// A stager error → that file is dropped; nothing staged → prompt unchanged.
exErr := newStagerExecutor(&stagerFunc{err: errors.New("disk full")})
if out := exErr.stageInputFiles(context.Background(), "r", "a",
if out, _ := exErr.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "x.bin", Data: []byte("z")}}, "p"); out != "p" {
t.Errorf("save error should drop the file and leave the prompt, got %q", out)
}
@@ -124,7 +124,7 @@ func TestStageInputFilesOversize(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
big := make([]byte, maxInputFileBytes+1)
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "huge.bin", Data: big}}, "p")
if out != "p" || len(st.staged) != 0 {
t.Errorf("oversized file should be skipped: out=%q staged=%d", out, len(st.staged))
@@ -177,7 +177,7 @@ func TestSanitizeName(t *testing.T) {
func TestStageInputFilesSanitizesTraversal(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "../../../etc/passwd", MimeType: "text/plain", Data: []byte("x")}}, "go")
if len(st.staged) != 1 || st.staged[0].name != "passwd" {
t.Fatalf("staged name = %+v, want passwd", st.staged)
@@ -202,7 +202,7 @@ func TestSanitizeFieldStripsBidiAndControl(t *testing.T) {
func TestStageInputFilesSanitizesMime(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "c.wav", MimeType: "audio/wav\ninjected", Data: []byte("x")}}, "go")
if len(st.staged) != 1 || strings.ContainsAny(st.staged[0].mime, "\n\r") {
t.Errorf("mime not sanitized before staging: %+v", st.staged)
@@ -216,7 +216,7 @@ func TestStageInputFilesSanitizesMime(t *testing.T) {
// file (no blank file_id in the descriptor).
func TestStageInputFilesEmptyFileID(t *testing.T) {
ex := newStagerExecutor(emptyIDStager{})
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "x.bin", Data: []byte("z")}}, "p")
if out != "p" {
t.Errorf("empty file_id should drop the file, got %q", out)