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:
+53
-2
@@ -429,8 +429,10 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
// store and fold an [ATTACHED FILES] descriptor into the prompt so the agent
|
||||
// can reach them by file_id. No-op when Ports.InputFiles is nil or there are
|
||||
// no files. Done after the model/toolbox build but before the loop, so the
|
||||
// descriptor rides the very first user turn.
|
||||
input = e.stageInputFiles(runCtx, inv.RunID, ra.ID, inv.InputFiles, input)
|
||||
// descriptor rides the very first user turn. The staged file ids are kept for
|
||||
// the FinalGuard: they live under run scope but are the user's INPUTS, not
|
||||
// run-produced artifacts, so an undelivered-artifact check must exclude them.
|
||||
input, stagedFileIDs := e.stageInputFiles(runCtx, inv.RunID, ra.ID, inv.InputFiles, input)
|
||||
// One WithSteer drains BOTH the session mailbox (a tool's AttachImages) and
|
||||
// the critic's nudges before each step.
|
||||
steer := func() []llm.Message { return append(mailbox.drain(), critic.drainSteer()...) }
|
||||
@@ -452,6 +454,10 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
// step responses, not the loop's compacted history) — a host that caps size
|
||||
// bounds it. A recovered run seeds the saved transcript and continues.
|
||||
obs := stepObserver
|
||||
// noteCheckpoint appends an out-of-loop message (the FinalGuard nudge) to
|
||||
// the checkpoint transcript so a shutdown-resume of a nudged run replays
|
||||
// the nudge turn too. nil when the run is non-durable.
|
||||
var noteCheckpoint func(llm.Message)
|
||||
if ckpt != nil {
|
||||
var acc []llm.Message
|
||||
if resuming {
|
||||
@@ -469,6 +475,7 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
}
|
||||
_ = ckpt.Save(runCtx, RunCheckpointState{Messages: acc, Iteration: s.Index + 1})
|
||||
}
|
||||
noteCheckpoint = func(m llm.Message) { acc = append(acc, m) }
|
||||
}
|
||||
opts := append([]agent.Option{
|
||||
agent.WithToolbox(toolbox),
|
||||
@@ -483,6 +490,50 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
} else {
|
||||
runRes, runErr = runAgent(runCtx, ag, input, inv.Images, agent.WithSteer(steer))
|
||||
}
|
||||
|
||||
// FinalGuard (optional): the run finished successfully — before the result
|
||||
// is finalized, let the host inspect it for undelivered work (the
|
||||
// announce-then-stop failure: an artifact rendered into run storage, a
|
||||
// final text claiming "Done", and no delivery tool call). A non-empty
|
||||
// nudge runs ONE bounded extra round on the same conversation: same model,
|
||||
// same toolbox, same observers (audit/steps/critic all see it), but a
|
||||
// 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
|
||||
// stands (any attachments it queued before failing still deliver).
|
||||
if runErr == nil && runRes != nil && e.cfg.Ports.FinalGuard != nil {
|
||||
state := FinalState{
|
||||
Output: runRes.Output,
|
||||
ToolNames: toolboxNames(toolbox),
|
||||
StagedFileIDs: stagedFileIDs,
|
||||
}
|
||||
if nudge := safeFinalNudge(runCtx, e.cfg.Ports.FinalGuard, info, state); nudge != "" {
|
||||
if rec != nil {
|
||||
rec.LogEvent("final_nudge", map[string]any{"nudge": nudge})
|
||||
}
|
||||
if noteCheckpoint != nil {
|
||||
noteCheckpoint(llm.UserText(nudge))
|
||||
}
|
||||
nudgeOpts := append([]agent.Option{
|
||||
agent.WithToolbox(toolbox),
|
||||
agent.WithMaxSteps(finalNudgeMaxSteps),
|
||||
agent.WithStepObserver(obs),
|
||||
}, sharedOpts...)
|
||||
nudgeAg := agent.New(model, e.systemPrompt(ra), nudgeOpts...)
|
||||
nudgeRes, nudgeErr := nudgeAg.Run(runCtx, nudge,
|
||||
agent.WithSteer(steer), agent.WithHistory(runRes.Messages))
|
||||
if nudgeRes != nil {
|
||||
// The extra round's tokens are real spend either way.
|
||||
runRes.Usage.Add(nudgeRes.Usage)
|
||||
}
|
||||
if nudgeErr != nil {
|
||||
slog.Warn("run: final-nudge round failed; keeping original result",
|
||||
"run_id", inv.RunID, "error", nudgeErr)
|
||||
} else if nudgeRes != nil {
|
||||
runRes.Output = nudgeRes.Output
|
||||
runRes.Messages = nudgeRes.Messages
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-phase pipeline: each phase runs its own prompt/tier/tools/step-cap
|
||||
// sequentially, threading outputs through {{.<PhaseName>}} templates. The
|
||||
|
||||
Reference in New Issue
Block a user