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
+11 -6
View File
@@ -30,10 +30,11 @@ const maxInputFiles = 32
//
// Best-effort: a nil stager, no files, or a per-file save error degrades to
// "skip that file" — the run still proceeds. Returns the (possibly augmented)
// prompt.
func (e *Executor) stageInputFiles(ctx context.Context, runID, agentID string, files []tool.InputFile, prompt string) string {
// prompt plus the staged file ids (for the FinalGuard: staged inputs live under
// run scope but are not run-produced artifacts).
func (e *Executor) stageInputFiles(ctx context.Context, runID, agentID string, files []tool.InputFile, prompt string) (string, []string) {
if e.cfg.Ports.InputFiles == nil || len(files) == 0 {
return prompt
return prompt, nil
}
// Count cap: bound how many attachments one run can stage, independent of the
// per-file byte cap (defense-in-depth against a flood of tiny files).
@@ -87,7 +88,11 @@ func (e *Executor) stageInputFiles(ctx context.Context, runID, agentID string, f
staged = append(staged, stagedFile{name: name, mime: mime, fileID: sanitizeField(fileID), size: len(f.Data)})
}
if len(staged) == 0 {
return prompt
return prompt, nil
}
stagedIDs := make([]string, 0, len(staged))
for _, s := range staged {
stagedIDs = append(stagedIDs, s.fileID)
}
var b strings.Builder
@@ -101,9 +106,9 @@ func (e *Executor) stageInputFiles(ctx context.Context, runID, agentID string, f
}
if strings.TrimSpace(prompt) == "" {
return b.String()
return b.String(), stagedIDs
}
return prompt + "\n\n" + b.String()
return prompt + "\n\n" + b.String(), stagedIDs
}
// sanitizeName reduces an untrusted attachment filename to a safe base name. It