executus CI / test (pull_request) Successful in 49s
Live regression (mort run d5ec39f4, 2026-07-15): a research run's page-cache
file false-positived the guard, and the nudge round's meta closer ("my
answer above stands as-is") REPLACED the real answer — hosts deliver
Output text plus separately-drained attachments, so the user received the
closer and never saw the answer.
New merge policy: the nudge round exists to make tool calls (queue the
delivery); its final text is discarded and recorded as a final_nudge_result
audit event, so a round that declined to queue stays observable without
reaching the user. The only exception is a blank original stop turn, where
the nudge round's text is the only answer there is. Messages/Usage still
merge (audit, PostRun, checkpoint continuity).
Additive on top of 007b753 (mort main pins that commit as a pseudo-version;
this branch must never be rewritten).
Co-Authored-By: Claude Fable 5 <[email protected]>
88 lines
3.8 KiB
Go
88 lines
3.8 KiB
Go
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, 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).
|
|
//
|
|
// OUTPUT CONTRACT: the nudge round exists to make TOOL CALLS (queue the
|
|
// delivery); its final TEXT is discarded — the run's Output stays the original
|
|
// answer unless that answer was blank. A host's nudge message should say so
|
|
// explicitly ("any text you write here is not shown to the user"), and must
|
|
// not instruct the model to compose a corrected reply. Discarded nudge text is
|
|
// recorded as a final_nudge_result audit event.
|
|
//
|
|
// 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
|
|
}
|