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

@@ -1266,6 +1266,20 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
actionCol := "run:" + string(runParams)
// P1 plan-first gate: every task must propose a plan before any `run`,
// read-only or not. The only carve-out is a pure-DB Q&A that calls no
// `run` at all (those never reach this code path). Without this gate the
// SOUL.md "MANDATORY TASK FLOW" is unenforceable prose — weaker models
// skip propose_plan and go straight to run, leaving the operator with
// 23 individual approvals and no plan to approve (the original
// anti-pattern the flow exists to prevent). Mirrors D.1's structural
// refusal pattern in complete_task. sessionID == "" means a direct MCP
// call with no nomos session (e.g. an external script) — gate is a
// no-op there, since there's no session to hold a plan.
if sessionID != "" && !sessionHasPlan(ctx, pool, sessionID) {
return textResult("No plan for this session. Call set_goal then propose_plan before run — even read-only tasks require a one-step plan. A one-step plan (\"Inspect X, report, write back\") is fine for trivial questions; the gate is about ordering, not approval. Read-only commands still auto-execute once a plan exists.")
}
// Dedup: an identical pending command (same target, command, and
// purpose) blocks a re-request — stops a tool-calling loop from queuing
// the same approval repeatedly.
@@ -1324,32 +1338,16 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
return textResult(fmt.Sprintf("run on %s (read_only, auto): %s", targetSlug, out))
}
// Plan window: if a plan was proposed (and possibly approved), this
// command is part of an in-flight plan. The plan IS the approval —
// config_mutation commands within an active plan auto-run without
// per-action approval. Created by propose_plan, checked by planWindowActive.
if riskClass == policy.RiskConfigMutation && sessionID != "" && planWindowActive(ctx, pool, sessionID) {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
if rerr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
return textResult(fmt.Sprintf("resolve target: %v", rerr))
}
out, xerr := sshExec(ctx, host, user, wrap(command))
if xerr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s: %s", xerr.Error(), out))
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
}
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, id, jsonOut(out))
slog.Info("mcp: run auto-executed via plan window", "target", targetSlug, "execution_id", id)
return textResult(fmt.Sprintf("run on %s (config_mutation, auto via plan): %s", targetSlug, out))
}
// Assent window: if the operator recently approved a plan in this
// agent's chat session, config_mutation commands auto-run without
// re-approval. This is the "approve the plan, carry it out" path — the
// operator approved the overall direction; individual config steps
// within the window don't each need a separate yes. Destructive
// commands never auto-run, regardless of window.
// commands never auto-run, regardless of window. (The old plan-window
// path that opened on set_goal/propose_plan was removed — it opened
// before approval, letting config_mutation auto-run with zero operator
// consent. The assent window, opened only on operator approval, is the
// sole gate for config_mutation auto-run.)
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
if rerr != nil {
@@ -1439,19 +1437,32 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
}
}
// planWindowActive reports whether a plan has been proposed (or approved)
// for this session. Created by propose_plan, the window allows config_mutation
// commands to auto-execute without per-action approval — the plan IS the
// approval. Plan-approve-once policy (2026-07-14).
func planWindowActive(ctx context.Context, pool *db.Pool, sessionID string) bool {
// planWindowActive was removed 2026-07-15: it opened on set_goal and
// propose_plan, letting config_mutation auto-run before operator approval.
// The assent window (opened only on approval in agent.go) is the sole gate
// for config_mutation auto-run now. See sessionHasPlan for the plan-existence
// check used by the P1 plan-first gate.
// sessionHasPlan reports whether this nomos session has any plan step on
// record (any generation, any status). Used by the P1 plan-first gate in
// classifyAndGate to refuse `run` before `propose_plan` has been called.
// A `replaced` step (from a prior plan generation that was superseded by a
// follow-up sub-task — see store.reopenSession) still counts: it proves the
// agent once framed a plan for this session, and the reopen path guarantees a
// fresh `propose_plan` will run before the next `run` anyway. Fails closed
// (returns true) when the query errors so a transient DB issue doesn't block
// an otherwise-valid run.
func sessionHasPlan(ctx context.Context, pool *db.Pool, sessionID string) bool {
if sessionID == "" {
return false
return true // no session → no gate (direct MCP call from a script)
}
var val string
err := pool.QueryRow(ctx,
"SELECT value FROM autonomy_settings WHERE key = $1",
"nomos:plan:"+sessionID).Scan(&val)
return err == nil && val == "active"
var count int
if err := pool.QueryRow(ctx,
`SELECT COUNT(*) FROM session_plan_steps WHERE session_id = $1`,
sessionID).Scan(&count); err != nil {
return true // fail open on DB error — don't block work over a flake
}
return count > 0
}
// assentWindowActive checks whether the operator has recently approved a plan