fix(agent): refuse plan re-proposal + emit done on error (close divergence chain)
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

Operator-reported bug: on 'proceed with the rest' the agent re-proposed the
plan, duplicating it in the sidebar. Root cause was a three-bug chain, not
one bug:

1. Trigger — model empty-response on 'proceed' (approval vocabulary didn't
   list 'proceed', so the agent wasn't sure it was approved and no-op'd).
2. Amplifier — chatWith emitted 'error' without 'done' on empty response
   (agent.go:370). The frontend's onComplete saw !receivedDone and
   misclassified the model failure as a network disconnect, calling
   handleDisconnect -> resumeSession.
3. Divergence — the reconnect note was generic ('report your state'), so
   the agent re-proposed + re-executed instead of advancing the plan.

Fixes (shipped, e2e-validated against the live agent on oikos-nomos-1):

- A.2: proposePlan refuses re-proposal once a step has started (returns
  errPlanInFlight). Drops the append-mode safety net (commit 5384499) that
  was the direct source of the sidebar duplication. The agent must advance
  with update_plan_step + run; the tool result directs it.
- A.1: proposePlan sets the 'generation' column on INSERT (migration 020
  added the column + frontend grouping, but the INSERT never wired it).
- A.3: propose_plan tool description restated as a crisp contract (ONCE,
  STOP and wait, REFUSES once a step started, advance with update_plan_step).
- F.3: approval vocabulary expanded to approved/yes/go/proceed/continue/ok/
  go ahead; propose_plan result string tightened to an imperative.
- B.1: chatWith emits 'done' after 'error' on every terminal path via a new
  emitError helper. The frontend now treats model errors as ended (not
  disconnected), so no auto-reconnect -> resumeSession fires.
- B.2: reconnect/resume note carries the operator's last message + an
  explicit 'advance the plan, do NOT call propose_plan again' directive when
  a plan is in flight. Wired into all 4 resume entry points (reconnect,
  /resume, idle-sweep, question-answer) via enrichResumeNote.
- B.3: resumeSession escalates the recovery note across its 3 attempts (final
  retry: 'pick the lowest-pending step, mark it running, call run — do that
  now') instead of 3 identical notes -> 3 identical empties.

Verification: TestProposePlan_RefuseInFlight replaces TestProposePlan_
AppendVsReplace. e2e conversations against the rebuilt container:
  conv2 ('proceed with the rest') -> 0 propose_plan calls, plan stayed at
    3 steps (was 6+ before), update_plan_step x5 + run x2 + complete_task.
  conv3 (full plan, 'go ahead') -> apt-get update on lxc:dns auto-ran under
    the plan window, update_entity_attributes writeback, clean complete_task.
  nomos logs show zero reconnect/resume entries for the plan-proposing
    sessions (the three-bug chain is closed).

Remaining (not in this commit): D.1 refuse complete_task without writeback
(next blocker), C.1/C.2, F.1/F.2 SOUL.md consolidation, B.4-B.6, E.1/E.2.
See plans/2026-07-14-post-fix-session-remainders.md.

Also: re-audit 2026-07-10-general-gated-execution.md — request_execution enum
retirement (60effcb) closes item 9; only auto-act revival (item 10) remains.

Version 0.4.1 -> 0.5.0 (minor: new structural behavior, not a bugfix).
This commit is contained in:
2026-07-14 15:28:33 +02:00
parent 5f82627fa8
commit 337d577f00
10 changed files with 1074 additions and 93 deletions

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
@@ -34,20 +35,16 @@ func taskToolDefs() []toolDef {
},
{
Name: "propose_plan",
Description: "Lay out ALL the ordered steps you'll take to reach the goal, in ONE " +
"call, listing every step end-to-end not just the next one. The operator " +
"sees the full list in the context panel and watches it progress; a plan " +
"with only 1 step looks broken to them even if you intend to add more later. " +
"Your FIRST step should be research (prior knowledge, relations, blast radius " +
"— not just this target's status) and your LAST step should be writing back " +
"what you learned (update_entity_attributes / create_relationship / " +
"upsert_knowledge) BEFORE complete_task — this is what keeps the knowledge " +
"graph from drifting out of date. " +
"Call this ONCE, before you start executing (after gathering what you need). " +
"As you work, call update_plan_step (not propose_plan again) to advance each " +
"step. Only re-call propose_plan if the plan itself has fundamentally changed " +
"(e.g. a new approach is needed) — in that case new steps are appended after " +
"whatever already ran, never erasing completed work.",
Description: "Propose the full ordered plan for this task. Call ONCE, before any " +
"execution, with EVERY step end-to-end (not one step at a time). FIRST step: " +
"research (prior knowledge, relations, blast radius). LAST step: write back " +
"(update_entity_attributes + create_relationship + upsert_knowledge) BEFORE " +
"complete_task — this keeps the knowledge graph from drifting. After this " +
"call: STOP and wait for operator approval (approval vocabulary: approved, " +
"yes, go, proceed, continue, ok, go ahead). Once a step has started " +
"(running/done/...), this tool REFUSES further calls — advance with " +
"update_plan_step + run instead. Re-propose only if the operator explicitly " +
"asks you to revise the whole plan.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -205,6 +202,15 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
}
persisted, err := a.store.proposePlan(ctx, sessionID, steps)
if err != nil {
if errors.Is(err, errPlanInFlight) {
// The plan is already in flight — refuse the re-proposal.
// The agent must advance the existing plan with
// update_plan_step + run. This is the structural fix for
// the "plan added twice" sidebar drift the operator
// reported: instead of appending (which duplicated) or
// wiping (which lost progress), we refuse and direct.
return "Plan already in flight — refusing duplicate proposal. Steps exist and at least one has started (running/done/...). To advance: call update_plan_step(seq=K, status=\"running\") then run(...) for step K's target, then update_plan_step(seq=K, status=\"done\"). Do NOT call propose_plan again. Re-propose only if the operator explicitly asks you to revise the whole plan, and say so in your reply before calling it.", true
}
return fmt.Sprintf("error proposing plan: %v", err), true
}
// Nudge: if the last step doesn't mention entity writeback tools,
@@ -212,7 +218,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
lastStep := steps[len(steps)-1]
hasWriteback := strings.Contains(lastStep.Title+lastStep.Detail, "update_entity_attributes") ||
strings.Contains(lastStep.Title+lastStep.Detail, "create_relationship")
result := fmt.Sprintf("Plan set: %d step(s). Now STOP and present the plan to the operator — do NOT call run yet. Wait for them to approve (they will type 'approved'/'yes'). Once approved, all config_mutation commands will auto-execute without individual approval popups.", len(persisted))
result := fmt.Sprintf("Plan set (%d steps). STOP. Wait for operator approval — do not call run yet. Approval vocabulary: \"approved\", \"yes\", \"go\", \"proceed\", \"continue\", \"ok\", \"go ahead\". On approval, advance with update_plan_step + run. Do not call propose_plan again.", len(persisted))
if !hasWriteback {
result += "\n\n⚠ The final step doesn't mention update_entity_attributes or create_relationship. Without those, any facts you discovered about entities (IPs, versions, hosts, states) will be LOST — the next session starts from scratch. Consider revising the last step to include entity writeback BEFORE completing the task."
}