feat(agent): auto-complete tasks when all plan steps are terminal
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:
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user