feat(agent): auto-complete tasks when all plan steps are terminal
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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).
This commit is contained in:
2026-07-15 13:06:47 +02:00
parent 3c3b12df5e
commit e3b5fdc358
3 changed files with 66 additions and 0 deletions

View File

@@ -415,6 +415,14 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if !sawSetGoal && !sawCompleteTask { if !sawSetGoal && !sawCompleteTask {
a.autoCompleteTrivialTask(ctx, sessionID, msg.Content) 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{ emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID, "session_id": sessionID,
"usage": acc.Usage, "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." 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}) emit(agentEvent{Type: "text", Data: summary, SessionID: sessionID})
if !sawCompleteTask {
a.autoCompleteIfPlanDone(ctx, sessionID, summary)
}
emit(agentEvent{Type: "done", Data: map[string]any{ emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID, "session_id": sessionID,
"correlation_id": correlationID, "correlation_id": correlationID,

View File

@@ -879,6 +879,25 @@ func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error
return err 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 // 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.*). // (the panel otherwise only sees steps live via plan.proposed/plan.step.*).
type planStep struct { type planStep struct {

View File

@@ -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) 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)
}
}