feat(run): Ports.FinalGuard — host seam that nudges an announce-then-stop run to deliver its artifact
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:
+10
-10
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user