feat(agent): plan-first gate, iterative follow-ups, reasoning persistence
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

P1 plan-first: run handler refuses without propose_plan (structural gate,
not SOUL.md prose). Plan window decoupled from set_goal — config_mutation
auto-run only on operator approval (assent window). Closes the approval-free
config_mutation hole confirmed in session d0d562e0.

P2 iteration: reopenSession flips terminal→executing, marks prior plan steps
replaced, clears outcome. proposePlan excludes replaced from in-flight check,
bumps generation. A follow-up on a completed session starts a new sub-task
with a fresh plan — no more errPlanInFlight dead end.

P3 reasoning: accumulate per-iteration text into the persisted row instead
of overwriting with the last text event. Reload shows intermediate thinking,
not just the final summary.

P4 read-only allowlist: add find, tree, locate, systemctl list-timers/
list-unit-files/show, timedatectl, hostnamectl, systemd-analyze, rclone
ls/lsl/md5sum/check/cryptcheck. Fixes the find misclassification from
d0d562e0.

P5 eval harness: new assertion kinds (proposes_plan, plan_before_run,
plan_generations), multi-turn followups, fetch /sessions/{id}/plan. Four
manifests under evals/.

P6 SOUL.md: strip degenerate-case carve-out, add ITERATE step, update
set_goal guidance.

VERSION 0.6.0 → 0.7.0
This commit is contained in:
2026-07-15 09:36:27 +02:00
parent e8b30cddcf
commit e3fa6736c0
17 changed files with 726 additions and 83 deletions

View File

@@ -466,18 +466,44 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
return nil
}
// openPlanWindow records a plan window so config_mutation `run` calls in
// this session auto-execute without per-action approval. The operator
// approves the plan (propose_plan), not each individual command. Opened on
// set_goal and stays active until the task is completed or the session ends.
func (s *store) openPlanWindow(ctx context.Context, sessionID string) {
if s == nil || sessionID == "" {
return
// reopenSession flips a terminal (done/failed) session back to `executing`
// so a follow-up message can start a new sub-task — the iteration path
// (P2, 2026-07-15). Without this, a completed session stays `done` forever
// and propose_plan refuses the new sub-task with errPlanInFlight because the
// prior plan's steps are all `done` (status <> 'pending'). reopenSession:
//
// 1. Marks all existing session_plan_steps as `replaced` (a status already
// recognized by updatePlanStep's stamp switch). The rows are KEPT — the
// generation column preserves which plan they belonged to, and the
// audit trail survives. proposePlan's anyStarted check excludes
// `replaced` (see proposePlan), so the next propose_plan takes the
// fresh-generation path rather than being refused with errPlanInFlight.
// 2. Clears outcome/summary so the panel doesn't show the old result.
// 3. Stamps last_active_at.
//
// Returns true if the session was actually reopened (was terminal), false if
// it was already active (no-op — the follow-up is just a continuation).
func (s *store) reopenSession(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return false
}
var currentStatus string
if err := s.pool.QueryRow(ctx,
`SELECT status FROM agent_sessions WHERE id = $1`, sessionID).Scan(&currentStatus); err != nil {
return false
}
if currentStatus != "done" && currentStatus != "failed" {
return false
}
s.pool.Exec(ctx,
`INSERT INTO autonomy_settings (key, value) VALUES ($1, 'active')
ON CONFLICT (key) DO UPDATE SET value = 'active'`,
"nomos:plan:"+sessionID)
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
sessionID)
s.pool.Exec(ctx,
`UPDATE agent_sessions SET status = 'executing', outcome = NULL, summary = NULL, last_active_at = now() WHERE id = $1`,
sessionID)
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.reopened", s.taskEntityPtr(ctx, sessionID),
"info", "nomos", sessionID, map[string]any{"prior_status": currentStatus})
return true
}
// planStepInput is one step as the agent proposes it.
@@ -514,8 +540,13 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
var startSeq int
var anyStarted bool
// `replaced` steps (from a prior plan generation superseded by a
// follow-up sub-task — see reopenSession) are excluded: they prove a
// prior plan was completed and superseded, not that a plan is in flight.
// Without this exclusion, reopenSession's `replaced` marking would be
// useless — propose_plan would still refuse on the follow-up.
if err := tx.QueryRow(ctx, `
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status <> 'pending'), false)
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status NOT IN ('pending', 'replaced')), false)
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&startSeq, &anyStarted); err != nil {
return nil, err
}
@@ -525,13 +556,20 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// update_plan_step + run. The caller surfaces a directive.
return nil, errPlanInFlight
}
// Fresh/revise: delete any prior pending steps and start a new
// generation. The DELETE covers the genuine pre-execution revision
// case (operator asked to revise before any step started).
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1`, sessionID); err != nil {
// Fresh/revise: delete any prior PENDING steps (the genuine pre-execution
// revision case — operator asked to revise before any step started).
// `replaced` steps (from a prior completed plan superseded by a
// follow-up — see reopenSession) are KEPT so the generation counter
// (MAX(generation)+1 below) and the plan_generations eval assertion
// can see across iterations. The anyStarted check above already
// excludes `replaced`, so they don't block the fresh proposal.
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1 AND status = 'pending'`, sessionID); err != nil {
return nil, err
}
startSeq = 0
// startSeq keeps the max(seq) from the query above: if `replaced` rows
// exist (prior generation), the new generation's steps start after them
// (no seq collisions across generations). If no rows exist (first plan
// or a full DELETE), startSeq is 0 and the first step is seq 1.
// Resolve the generation number for this plan. Generation 1 is the
// initial plan; a genuine revise (which currently goes through the same
@@ -579,15 +617,6 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// the panel to replace its list with these steps.
_ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID),
"info", "nomos", sessionID, map[string]any{"steps": out, "appended": false, "generation": nextGen})
// Record a plan-proposed window so the `run` handler knows a plan is
// pending approval and can skip per-action approval for config_mutation
// commands within the plan. Transitions to 'active' when the operator
// approves (chat-assent or button). The window key is session-scoped;
// one agent serves all sessions on this nomos instance.
s.pool.Exec(ctx,
`INSERT INTO autonomy_settings (key, value) VALUES ($1, 'active')
ON CONFLICT (key) DO UPDATE SET value = 'active'`,
"nomos:plan:"+sessionID)
return out, nil
}