feat(planning): plans, validation, planner and a restart-safe executor

Plans turn intentions into revisable dependency graphs (migration 0027):
objective, assumptions, ordered or dependent steps, capabilities,
expected observations, preconditions, risk, estimates, budget,
completion criteria, abort conditions and the alternatives considered.

- Validation owns structure: acyclic dependencies, registered and
  unlocked capabilities only, schema-valid parameters, targets that agree
  with parameters, read-only checks, a closed set of step kinds and
  internal actions. Model proposals are parsed into the same structure;
  fields that could carry authority or claim progress are reported as
  ignored, and an invalid draft is stored as a draft that never runs.
- The planner prefers deterministic strategies: a direct action verified
  by observation, a read-only diagnosis that requests temporary scoped log
  access with risk and fallback stated, and an escalation that diagnoses
  and informs the operator instead of repeating a failed action. Failed
  strategies are not reused; a model is consulted only when none applies.
- The executor holds no state in memory: it claims a step with a
  conditional update, acts only through Invoke and RequestCapability, and
  records the outcome. Waiting for approval or access forms a subordinate
  intention and blocks the parent with the reason. Expectations become
  verification predictions resolved from a read-only observation, so an
  HTTP-level success is never taken as the outcome; a miss is a prediction
  error that weakens the supporting belief and triggers reconsideration.
  Replanning creates a new revision, withdraws open requests of the old
  one and keeps it as history, within a replan limit. Checks never raise
  approval prompts. Budgets stop runaway plans. Preconditions skip actions
  when the world already changed. Dream plans and disabled sandbox minds
  never act. An interrupted non-idempotent action with no record is
  blocked for verification instead of repeated; recorded outcomes are
  adopted. Completed plans are stored as procedural memories; a completed
  escalation does not count as fulfilling the intention.
- Executions and approvals record their origin; minds may withdraw their
  own pending approval requests; Preview reports a decision without side
  effects; service.status is allowed by default as a bounded read.
This commit is contained in:
Andy Dixon
2026-09-14 23:38:44 +01:00
parent 9db188ffe6
commit 813b067842
14 changed files with 3464 additions and 20 deletions
+417
View File
@@ -0,0 +1,417 @@
package planning
import (
"errors"
"fmt"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"github.com/andydixon/noema/internal/capabilities"
)
// Validation is the result of checking a draft.
type Validation struct {
Valid bool `json:"valid"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Order []string `json:"order"` // a topological order of step keys
}
// Limits on plans.
const (
MaxSteps = 30
MaxWaitSeconds = 7 * 24 * 3600
MaxMonitorSeconds = 7 * 24 * 3600
)
var keyRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,16}$`)
// Validate checks structure, dependency integrity and the capability
// vocabulary. Nothing a draft says is trusted: capability names must be
// registered and not locked, parameters must satisfy the schema, targets
// must agree with parameters, checks must use read-only capabilities, and
// the dependency graph must be acyclic.
func Validate(d *Draft, reg *capabilities.Registry) Validation {
v := Validation{Errors: []string{}, Warnings: []string{}, Order: []string{}}
fail := func(format string, args ...any) { v.Errors = append(v.Errors, fmt.Sprintf(format, args...)) }
d.Objective = strings.TrimSpace(d.Objective)
if d.Objective == "" || len(d.Objective) > 500 {
fail("objective is required (at most 500 characters)")
}
switch d.Origin {
case "template", "model", "operator", "dream", "replan":
default:
fail("origin %q is not recognised", d.Origin)
}
if len(d.Steps) == 0 || len(d.Steps) > MaxSteps {
fail("a plan needs 1–%d steps, not %d", MaxSteps, len(d.Steps))
}
if d.Budget.MaxInvocations <= 0 {
d.Budget.MaxInvocations = 20
}
keys := map[string]int{}
for i := range d.Steps {
st := &d.Steps[i]
st.Title = strings.TrimSpace(st.Title)
if !keyRe.MatchString(st.Key) {
fail("step %d: key %q must be 1–16 letters, digits, '-' or '_'", i+1, st.Key)
continue
}
if _, dup := keys[st.Key]; dup {
fail("step %s: duplicate key", st.Key)
}
keys[st.Key] = i
if st.Title == "" || len(st.Title) > 200 {
fail("step %s: title is required (at most 200 characters)", st.Key)
}
if st.MaxAttempts <= 0 {
st.MaxAttempts = 1
}
if st.MaxAttempts > 5 {
st.MaxAttempts = 5
}
}
for i := range d.Steps {
st := &d.Steps[i]
for _, dep := range st.DependsOn {
if dep == st.Key {
fail("step %s depends on itself", st.Key)
} else if _, ok := keys[dep]; !ok {
fail("step %s depends on %q, which is not a step of this plan", st.Key, dep)
}
}
validateStep(st, d.Steps, keys, reg, fail, &v)
}
if len(v.Errors) == 0 {
order, err := topo(d.Steps)
if err != nil {
fail("%v", err)
} else {
v.Order = order
}
}
for _, a := range d.Assumptions {
if a.MinConfidence < 0 || a.MinConfidence > 1 {
fail("assumption %q: min_confidence must be in [0,1]", a.Proposition)
}
}
v.Valid = len(v.Errors) == 0
return v
}
func validateStep(st *StepSpec, all []StepSpec, keys map[string]int, reg *capabilities.Registry, fail func(string, ...any), v *Validation) {
registered := func(name string) (capabilities.Capability, bool) {
c, ok := reg.Get(name)
if !ok {
fail("step %s: %q is not a registered capability", st.Key, name)
return c, false
}
if c.Locked {
fail("step %s: %s is locked and can never run", st.Key, name)
return c, false
}
return c, true
}
checkCheck := func(label string, ck *Check) {
c, ok := registered(ck.Capability)
if !ok {
return
}
if c.Access != capabilities.AccessRead {
fail("step %s: %s check must use a read-only capability, not %s", st.Key, label, c.Name)
}
p, err := c.ValidateParams(ck.Params)
if err != nil {
fail("step %s: %s check parameters: %v", st.Key, label, err)
return
}
ck.Params = p
if ck.Target, err = c.ResolveTarget(ck.Target, p); err != nil {
fail("step %s: %s check target: %v", st.Key, label, err)
}
switch ck.Op {
case "eq", "ne", "lt", "lte", "gt", "gte", "contains", "exists":
default:
fail("step %s: %s check operator %q is not recognised", st.Key, label, ck.Op)
}
if ck.Path == "" {
fail("step %s: %s check needs a result path", st.Key, label)
}
}
populated := 0
for _, x := range []bool{st.Request != nil, st.Verify != nil, st.Monitor != nil, st.Wait != nil, st.Internal != nil} {
if x {
populated++
}
}
if populated > 1 {
fail("step %s: more than one kind-specific section", st.Key)
}
switch st.Kind {
case KindCapability:
c, ok := registered(st.Capability)
if !ok {
return
}
p, err := c.ValidateParams(st.Params)
if err != nil {
fail("step %s: parameters: %v", st.Key, err)
return
}
st.Params = p
if st.Target, err = c.ResolveTarget(st.Target, p); err != nil {
fail("step %s: target: %v", st.Key, err)
}
if c.Access == capabilities.AccessWrite && !c.Idempotent && st.MaxAttempts > 1 {
v.Warnings = append(v.Warnings, fmt.Sprintf("step %s: %s is not idempotent; retries reduced to one attempt", st.Key, c.Name))
st.MaxAttempts = 1
}
if st.Expectation != nil {
if st.Expectation.Confidence <= 0 || st.Expectation.Confidence >= 1 {
st.Expectation.Confidence = 0.7
}
if st.Expectation.WithinSeconds <= 0 || st.Expectation.WithinSeconds > 86400 {
st.Expectation.WithinSeconds = 300
}
if strings.TrimSpace(st.Expectation.Description) == "" {
fail("step %s: an expectation needs a description", st.Key)
}
} else if c.Access == capabilities.AccessWrite {
v.Warnings = append(v.Warnings, fmt.Sprintf("step %s: %s changes the world but states no expected outcome", st.Key, c.Name))
}
if st.Precondition != nil {
checkCheck("precondition", &st.Precondition.Check)
}
case KindRequestCapability:
if st.Request == nil {
fail("step %s: request_capability needs a request", st.Key)
return
}
if _, ok := registered(st.Request.Capability); !ok {
return
}
if st.Request.DurationSeconds <= 0 || st.Request.DurationSeconds > int(capabilities.MaxGrantDuration.Seconds()) {
fail("step %s: request duration must be 1 second to 7 days", st.Key)
}
if strings.TrimSpace(st.Request.Reason) == "" {
fail("step %s: a capability request needs a reason", st.Key)
}
case KindVerify:
if st.Verify == nil {
fail("step %s: verify needs a verify section", st.Key)
return
}
i, ok := keys[st.Verify.Of]
if !ok {
fail("step %s: verifies %q, which is not a step", st.Key, st.Verify.Of)
return
}
if all[i].Expectation == nil {
fail("step %s: step %s has no expectation to verify", st.Key, st.Verify.Of)
}
if !dependsOn(all, keys, st.Key, st.Verify.Of) {
fail("step %s: must depend on %s, the step it verifies", st.Key, st.Verify.Of)
}
checkCheck("verify", &st.Verify.Check)
if st.Verify.IntervalSeconds <= 0 {
st.Verify.IntervalSeconds = 10
}
if st.Verify.WithinSeconds <= 0 {
st.Verify.WithinSeconds = 60
}
case KindMonitor:
if st.Monitor == nil {
fail("step %s: monitor needs a monitor section", st.Key)
return
}
checkCheck("monitor", &st.Monitor.Check)
m := st.Monitor
if m.IntervalSeconds < 5 {
m.IntervalSeconds = 5
}
if m.DeadlineSeconds <= 0 || m.DeadlineSeconds > MaxMonitorSeconds || m.StableSeconds < 0 || m.StableSeconds > m.DeadlineSeconds {
fail("step %s: monitor needs 0 ≤ stable ≤ deadline ≤ 7 days", st.Key)
}
case KindWait:
if st.Wait == nil || st.Wait.Seconds <= 0 || st.Wait.Seconds > MaxWaitSeconds {
fail("step %s: wait needs 1 second to 7 days", st.Key)
}
case KindInternal:
if st.Internal == nil || InternalActions[st.Internal.Action] == "" {
fail("step %s: internal action must be one of %s", st.Key, strings.Join(sortedKeys(InternalActions), ", "))
}
default:
fail("step %s: kind %q is not recognised", st.Key, st.Kind)
}
if st.Kind != KindCapability && (st.Capability != "" || st.Expectation != nil) {
v.Warnings = append(v.Warnings, fmt.Sprintf("step %s: capability and expectation are ignored on %s steps", st.Key, st.Kind))
st.Capability, st.Target, st.Params, st.Expectation = "", "", nil, nil
}
}
func dependsOn(all []StepSpec, keys map[string]int, from, to string) bool {
seen := map[string]bool{}
var walk func(k string) bool
walk = func(k string) bool {
if seen[k] {
return false
}
seen[k] = true
i, ok := keys[k]
if !ok {
return false
}
for _, d := range all[i].DependsOn {
if d == to || walk(d) {
return true
}
}
return false
}
return walk(from)
}
// ErrCycle marks cyclic dependencies.
var ErrCycle = errors.New("dependencies form a cycle")
func topo(steps []StepSpec) ([]string, error) {
indeg := map[string]int{}
next := map[string][]string{}
for _, s := range steps {
indeg[s.Key] += 0
for _, d := range s.DependsOn {
indeg[s.Key]++
next[d] = append(next[d], s.Key)
}
}
var queue, order []string
for _, s := range steps {
if indeg[s.Key] == 0 {
queue = append(queue, s.Key)
}
}
for len(queue) > 0 {
k := queue[0]
queue = queue[1:]
order = append(order, k)
for _, n := range next[k] {
indeg[n]--
if indeg[n] == 0 {
queue = append(queue, n)
}
}
}
if len(order) != len(steps) {
var stuck []string
for _, s := range steps {
if indeg[s.Key] > 0 {
stuck = append(stuck, s.Key)
}
}
sort.Strings(stuck)
return nil, fmt.Errorf("%w among %s", ErrCycle, strings.Join(stuck, ", "))
}
return order, nil
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Evaluate applies a check to a capability result's data. It returns whether
// the check holds and the observed value.
func Evaluate(ck Check, data map[string]any) (bool, any, error) {
val, ok := lookup(data, ck.Path)
if ck.Op == "exists" {
return ok, val, nil
}
if !ok {
return false, nil, fmt.Errorf("result has no %q", ck.Path)
}
switch ck.Op {
case "eq":
return equal(val, ck.Value), val, nil
case "ne":
return !equal(val, ck.Value), val, nil
case "contains":
return strings.Contains(strings.ToLower(fmt.Sprint(val)), strings.ToLower(fmt.Sprint(ck.Value))), val, nil
case "lt", "lte", "gt", "gte":
a, aok := number(val)
b, bok := number(ck.Value)
if !aok || !bok {
return false, val, fmt.Errorf("%q is not numeric", ck.Path)
}
switch ck.Op {
case "lt":
return a < b, val, nil
case "lte":
return a <= b, val, nil
case "gt":
return a > b, val, nil
}
return a >= b, val, nil
}
return false, val, fmt.Errorf("unknown operator %q", ck.Op)
}
func lookup(data map[string]any, path string) (any, bool) {
var cur any = data
for _, part := range strings.Split(path, ".") {
switch m := cur.(type) {
case map[string]any:
v, ok := m[part]
if !ok {
return nil, false
}
cur = v
case map[string]string:
v, ok := m[part]
if !ok {
return nil, false
}
cur = v
default:
return nil, false
}
}
return cur, true
}
func number(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int64:
return float64(x), true
case uint64:
return float64(x), true
case string:
f, err := strconv.ParseFloat(x, 64)
return f, err == nil
}
return 0, false
}
func equal(a, b any) bool {
if fa, ok := number(a); ok {
if fb, ok := number(b); ok {
return fa == fb
}
}
if reflect.DeepEqual(a, b) {
return true
}
return fmt.Sprint(a) == fmt.Sprint(b)
}