feat(agent): plan-first gate, iterative follow-ups, reasoning persistence
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:
@@ -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(¤tStatus); 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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user