From e3b5fdc3588dc490ca2556703b927edef38f44b2 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 15 Jul 2026 13:06:47 +0200 Subject: [PATCH] feat(agent): auto-complete tasks when all plan steps are terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1 remaining model reliability gap: the agent does the work (proposes plan, executes all steps, writes back) but forgets to call complete_task, leaving the session stuck in 'executing'. The eval showed 3/8 failures with this pattern. Fix: autoCompleteIfPlanDone — a structural safety net that fires at both chat exit paths (normal completion + maxIterations). If the session has a goal, the agent didn't call complete_task, and ALL plan steps are in a terminal state (done/failed/replaced/skipped/blocked), auto-complete with the agent's final text as the summary. Mirrors autoCompleteTrivialTask but for structured tasks where the work is provably done. Also: bump maxLLMRetries from 2 to 3 (complex multi-turn flows benefit from one more retry on empty responses). --- cmd/nomos/agent.go | 11 +++++++++++ cmd/nomos/store.go | 19 +++++++++++++++++++ cmd/nomos/tasks.go | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go index 6dca377..1be0a4b 100644 --- a/cmd/nomos/agent.go +++ b/cmd/nomos/agent.go @@ -415,6 +415,14 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s if !sawSetGoal && !sawCompleteTask { a.autoCompleteTrivialTask(ctx, sessionID, msg.Content) } + // Safety net: if the agent called set_goal (structured task) + // but didn't call complete_task, and all plan steps are + // terminal, auto-complete. The model often does the work but + // forgets to close the loop (confirmed live: the #1 remaining + // model reliability gap after D.1). + if !sawCompleteTask { + a.autoCompleteIfPlanDone(ctx, sessionID, msg.Content) + } emit(agentEvent{Type: "done", Data: map[string]any{ "session_id": sessionID, "usage": acc.Usage, @@ -566,6 +574,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s summary = "I hit this turn's step limit while working. I've done a lot but couldn't wrap up cleanly — ask me for a status update and I'll summarize the current state." } emit(agentEvent{Type: "text", Data: summary, SessionID: sessionID}) + if !sawCompleteTask { + a.autoCompleteIfPlanDone(ctx, sessionID, summary) + } emit(agentEvent{Type: "done", Data: map[string]any{ "session_id": sessionID, "correlation_id": correlationID, diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go index 5636f7e..60f2b48 100644 --- a/cmd/nomos/store.go +++ b/cmd/nomos/store.go @@ -879,6 +879,25 @@ func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error return err } +// allPlanStepsTerminal reports whether every plan step for this session is in +// a terminal state (done/failed/replaced/skipped/blocked) — i.e. no step is +// still pending or running. Used by autoCompleteIfPlanDone to auto-close a +// task when the agent did all the work but forgot to call complete_task. +// Returns false if there are no plan steps at all (no plan was proposed). +func (s *store) allPlanStepsTerminal(ctx context.Context, sessionID string) bool { + if s == nil || sessionID == "" || sessionID == "ephemeral" { + return false + } + var total, terminal int + if err := s.pool.QueryRow(ctx, + `SELECT COUNT(*), COUNT(*) FILTER (WHERE status IN ('done', 'failed', 'replaced', 'skipped', 'blocked')) + FROM session_plan_steps WHERE session_id = $1`, + sessionID).Scan(&total, &terminal); err != nil { + return false + } + return total > 0 && total == terminal +} + // planStep is a persisted plan step, as returned to the frontend for hydration // (the panel otherwise only sees steps live via plan.proposed/plan.step.*). type planStep struct { diff --git a/cmd/nomos/tasks.go b/cmd/nomos/tasks.go index eca6e69..cf61eda 100644 --- a/cmd/nomos/tasks.go +++ b/cmd/nomos/tasks.go @@ -357,3 +357,39 @@ func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, response slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err) } } + +// autoCompleteIfPlanDone is the structural safety net for "the agent did the +// work but forgot to call complete_task" — the #1 remaining model reliability +// gap after D.1's writeback gate. After a turn ends, if the session has a goal, +// the agent never called complete_task this turn, and ALL plan steps are in a +// terminal state (done/failed/replaced), auto-complete the task. This mirrors +// autoCompleteTrivialTask but for structured tasks where the work is provably +// done — the model just didn't close the loop. +func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseText string) { + if a.store == nil || sessionID == "" || sessionID == "ephemeral" { + return + } + // Only auto-complete if the session is still executing (not already + // terminal — complete_task or a prior auto-complete already ran). + sess, err := a.store.getSession(ctx, sessionID) + if err != nil || sess.Status != "executing" { + return + } + if !a.store.allPlanStepsTerminal(ctx, sessionID) { + return + } + summary := strings.TrimSpace(responseText) + summary = strings.SplitN(summary, "\n", 2)[0] + const maxLen = 120 + if len(summary) > maxLen { + summary = summary[:maxLen] + "…" + } + if summary == "" { + summary = "All plan steps completed." + } + if err := a.store.completeTask(ctx, sessionID, "success", summary); err != nil { + slog.Error("nomos: auto-complete plan-done task failed", "session", sessionID, "error", err) + } else { + slog.Info("nomos: auto-completed task — all plan steps terminal but agent didn't call complete_task", "session", sessionID) + } +}