fix(agent): task completion safety net — stop tasks sticking at Running
Implements fixes 1-3 of plans/2026-07-11-task-completion-safety-net.md. Confirmed live that 50/50 production sessions never reached a terminal status because the model almost never calls complete_task, even for trivial single-tool Q&A turns SOUL.md explicitly calls out as needing it. - Inline safety net (agent.go): a session that never called set_goal never framed itself as a structured task, so its first plain-text turn-end IS the task ending — auto-complete it there instead of leaving status stuck at its creation default forever. - Idle sweep (continue.go, new completion_nudges column): goal-bearing sessions that stall get one nudge, then auto-close with outcome=partial if the nudge goes unanswered, mirroring the pattern resumeSession already uses for a different stuck-session failure mode. Fix 4 (backfill of the 50 already-stuck live sessions) is deliberately separate — deferred until this is deployed and verified live, per the plan's implementation order. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,16 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
"[System: this task has been running long enough that only the most recent %d turns of its history are included above your context — earlier turns happened but aren't shown. If you need to know what was already tried or found, check search_knowledge/get_entity_knowledge (if you recorded it) rather than assuming it didn't happen.]",
|
||||
historyWindowSize)))
|
||||
}
|
||||
// sawSetGoal / sawCompleteTask track whether this session has EVER framed
|
||||
// itself as a structured task (set_goal) or already reached a terminal
|
||||
// state (complete_task) — across both replayed history and this turn's
|
||||
// own tool calls (updated again below as they happen live). Used by the
|
||||
// end-of-turn safety net (plans/2026-07-11-task-completion-safety-net.md,
|
||||
// fix 1): most sessions are a single trivial Q&A exchange that answers in
|
||||
// text and never calls either tool, leaving agent_sessions.status stuck
|
||||
// at its creation-time default forever. If a session never framed itself
|
||||
// as a task, its first plain-text turn-end IS the task ending.
|
||||
var sawSetGoal, sawCompleteTask bool
|
||||
var lastAssistantCalls []persistedCall
|
||||
for _, m := range history {
|
||||
text := extractText(m.Content)
|
||||
@@ -216,6 +226,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
messages = append(messages, assistantToolCallMessage(calls))
|
||||
for _, c := range calls {
|
||||
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
|
||||
switch c.name {
|
||||
case "set_goal":
|
||||
sawSetGoal = true
|
||||
case "complete_task":
|
||||
sawCompleteTask = true
|
||||
}
|
||||
}
|
||||
lastAssistantCalls = calls
|
||||
}
|
||||
@@ -358,6 +374,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
|
||||
if len(msg.ToolCalls) == 0 {
|
||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||
if !sawSetGoal && !sawCompleteTask {
|
||||
a.autoCompleteTrivialTask(ctx, sessionID, msg.Content)
|
||||
}
|
||||
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||
"session_id": sessionID,
|
||||
"usage": acc.Usage,
|
||||
@@ -377,6 +396,13 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
args = map[string]any{}
|
||||
}
|
||||
|
||||
switch tc.Function.Name {
|
||||
case "set_goal":
|
||||
sawSetGoal = true
|
||||
case "complete_task":
|
||||
sawCompleteTask = true
|
||||
}
|
||||
|
||||
emit(agentEvent{
|
||||
Type: "tool_use",
|
||||
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
|
||||
|
||||
Reference in New Issue
Block a user