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 (or correct the final text), 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). // // 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 }