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
+1 -1
View File
@@ -170,7 +170,7 @@ func Capabilities(o Options) []capabilities.Capability {
return capabilities.Result{Summary: fmt.Sprintf("%d processes", len(procs)), Data: map[string]any{"processes": procs}}, nil
}},
{Name: "service.status", Description: "Report a systemd unit's load, active and sub state and main PID.", Risk: capabilities.RiskLow, TargetKind: "service", Access: capabilities.AccessRead, Idempotent: true, Timeout: 10 * time.Second,
SideEffects: "none", DefaultEffect: capabilities.Ask, Params: svc, TargetFrom: unitTarget,
SideEffects: "none", DefaultEffect: capabilities.Allow, Params: svc, TargetFrom: unitTarget,
Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) {
unit, err := unitTarget(inv.Params)
if err != nil || run == nil {
+93 -15
View File
@@ -40,6 +40,7 @@ type Approval struct {
Error string `json:"error"`
EventID ids.ID `json:"event_id,omitempty"`
CorrelationID ids.ID `json:"correlation_id,omitempty"`
Origin string `json:"origin,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
@@ -56,6 +57,7 @@ type Execution struct {
DurationMS int `json:"duration_ms"`
ApprovalID ids.ID `json:"approval_id,omitempty"`
EventID ids.ID `json:"event_id,omitempty"`
Origin string `json:"origin,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -323,6 +325,33 @@ func (s *Service) Invoke(ctx context.Context, inv Invocation) (Outcome, error) {
return s.execute(ctx, inv, c, d, decisionEv, ids.Nil)
}
// Preview reports what Invoke would decide for a request without recording
// or executing anything: parameters validated, target derived, policy,
// session and scoped grants and approval requirements applied. Planning uses
// it to choose routes; it confers nothing.
func (s *Service) Preview(ctx context.Context, inv Invocation) (Decision, error) {
c, ok := s.Registry.Get(inv.Capability)
if !ok {
return Decision{Effect: Deny, Reason: "unknown capability"}, fmt.Errorf("%w: %s", ErrUnknown, inv.Capability)
}
if c.Locked {
return Decision{Effect: Deny, Locked: true, Reason: "capability is locked to DENY by design"}, nil
}
params, err := c.ValidateParams(inv.Params)
if err != nil {
return Decision{Effect: Deny, Reason: err.Error()}, err
}
inv.Params = params
if inv.Target, err = c.ResolveTarget(inv.Target, params); err != nil {
return Decision{Effect: Deny, Reason: err.Error()}, err
}
action, _ := params["action"].(string)
if action == "" {
action = "*"
}
return s.decideInvocation(ctx, inv, c, action)
}
// decideInvocation is Decide for a concrete request: scoped grants may match
// on parameters and are consulted only when policy says ASK, and a request
// that requires approval can never be allowed without one.
@@ -349,21 +378,21 @@ func (s *Service) createApproval(ctx context.Context, inv Invocation, c Capabili
if ttl <= 0 {
ttl = 24 * time.Hour
}
a := Approval{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, RequestHash: hash, Reason: inv.Reason, Risk: c.Risk, Evidence: inv.Evidence, Status: "pending", CorrelationID: inv.Correlation, ExpiresAt: s.now().Add(ttl), CreatedAt: s.now()}
a := Approval{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, RequestHash: hash, Reason: inv.Reason, Risk: c.Risk, Evidence: inv.Evidence, Status: "pending", CorrelationID: inv.Correlation, Origin: inv.Origin, ExpiresAt: s.now().Add(ttl), CreatedAt: s.now()}
if a.Evidence == nil {
a.Evidence = []EvidenceRef{}
}
ev, err := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolRequested, Source: "capabilities", Summary: fmt.Sprintf("approval requested: %s on %q — %s", c.Name, inv.Target, firstNonEmpty(inv.Reason, "no reason given")),
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence),
Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "approval_id": string(a.ID), "risk": c.Risk}, Payload: map[string]any{"approval_id": a.ID, "capability": c.Name, "target": inv.Target, "params": inv.Params, "risk": c.Risk, "reason": inv.Reason, "expires_at": a.ExpiresAt}})
Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "approval_id": string(a.ID), "risk": c.Risk, "origin": inv.Origin}, Payload: map[string]any{"approval_id": a.ID, "capability": c.Name, "target": inv.Target, "params": inv.Params, "risk": c.Risk, "reason": inv.Reason, "expires_at": a.ExpiresAt}})
if err != nil {
return Approval{}, err
}
a.EventID = ev[0].ID
params, _ := json.Marshal(inv.Params)
evid, _ := json.Marshal(a.Evidence)
_, err = s.DB.Exec(ctx, `INSERT INTO approval_requests (id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, event_id, correlation_id, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10,$11,$12)`, a.ID, a.MindID, a.Capability, a.Target, params, a.RequestHash, a.Reason, string(a.Risk), evid, string(a.EventID), nullID(a.CorrelationID), a.ExpiresAt)
_, err = s.DB.Exec(ctx, `INSERT INTO approval_requests (id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, event_id, correlation_id, expires_at, origin)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10,$11,$12,$13)`, a.ID, a.MindID, a.Capability, a.Target, params, a.RequestHash, a.Reason, string(a.Risk), evid, string(a.EventID), nullID(a.CorrelationID), a.ExpiresAt, a.Origin)
return a, err
}
@@ -386,20 +415,20 @@ func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d D
res, execErr = c.Executor(ectx, inv)
}()
dur := time.Since(start)
exec := Execution{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, DurationMS: int(dur.Milliseconds()), ApprovalID: approvalID, CreatedAt: s.now()}
payload := map[string]any{"capability": c.Name, "target": inv.Target, "params": inv.Params, "approval_id": approvalID, "duration_ms": exec.DurationMS}
exec := Execution{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, DurationMS: int(dur.Milliseconds()), ApprovalID: approvalID, Origin: inv.Origin, CreatedAt: s.now()}
payload := map[string]any{"capability": c.Name, "target": inv.Target, "params": inv.Params, "approval_id": approvalID, "duration_ms": exec.DurationMS, "origin": inv.Origin}
var ev []events.Event
if execErr != nil {
exec.Error = execErr.Error()
payload["error"] = exec.Error
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolFailed, Source: "capabilities", Summary: fmt.Sprintf("%s on %q failed: %s", c.Name, inv.Target, truncate(exec.Error, 160)),
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID)}, Payload: payload})
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "origin": inv.Origin, "approval_id": string(approvalID)}, Payload: payload})
} else {
rj, _ := json.Marshal(res)
exec.Result = rj
payload["result"] = res
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolExecuted, Source: "capabilities", Summary: fmt.Sprintf("%s on %q: %s", c.Name, inv.Target, truncate(res.Summary, 160)),
Salience: 0.5, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "trust": c.OutputTrust}, Payload: payload})
Salience: 0.5, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "trust": c.OutputTrust, "origin": inv.Origin, "approval_id": string(approvalID)}, Payload: payload})
// Tool output re-enters cognition as an observation with tool trust.
if strings.TrimSpace(res.Output) != "" {
s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.Observation, Source: "tool:" + c.Name, Summary: "tool output from " + c.Name + ": " + truncate(oneLine(res.Output), 140), Salience: 0.6, Confidence: 0.9, CausationID: firstID(ev), CorrelationID: inv.Correlation, //nolint:errcheck
@@ -409,8 +438,8 @@ func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d D
}
exec.EventID = firstID(ev)
params, _ := json.Marshal(inv.Params)
_, err := s.DB.Exec(ctx, `INSERT INTO tool_executions (id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
exec.ID, exec.MindID, exec.Capability, exec.Target, params, nullRaw(exec.Result), exec.Error, exec.DurationMS, nullID(approvalID), nullID(exec.EventID))
_, err := s.DB.Exec(ctx, `INSERT INTO tool_executions (id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, origin) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
exec.ID, exec.MindID, exec.Capability, exec.Target, params, nullRaw(exec.Result), exec.Error, exec.DurationMS, nullID(approvalID), nullID(exec.EventID), exec.Origin)
if err != nil {
s.Log.Error("record execution failed", "err", err)
}
@@ -422,7 +451,7 @@ func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d D
// ---- approvals ----
const approvalCols = `id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, decision_by, decision_note, decided_at, session_grant, modified_params, result, error, event_id, correlation_id, expires_at, created_at`
const approvalCols = `id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, decision_by, decision_note, decided_at, session_grant, modified_params, result, error, event_id, correlation_id, expires_at, created_at, origin`
func scanApproval(row pgx.Row) (Approval, error) {
var a Approval
@@ -430,7 +459,7 @@ func scanApproval(row pgx.Row) (Approval, error) {
var modified, result *json.RawMessage
var risk string
var ev, corr *string
if err := row.Scan(&a.ID, &a.MindID, &a.Capability, &a.Target, &params, &a.RequestHash, &a.Reason, &risk, &evid, &a.Status, &a.DecisionBy, &a.DecisionNote, &a.DecidedAt, &a.SessionGrant, &modified, &result, &a.Error, &ev, &corr, &a.ExpiresAt, &a.CreatedAt); err != nil {
if err := row.Scan(&a.ID, &a.MindID, &a.Capability, &a.Target, &params, &a.RequestHash, &a.Reason, &risk, &evid, &a.Status, &a.DecisionBy, &a.DecisionNote, &a.DecidedAt, &a.SessionGrant, &modified, &result, &a.Error, &ev, &corr, &a.ExpiresAt, &a.CreatedAt, &a.Origin); err != nil {
return a, storage.Translate(err)
}
a.Risk = Risk(risk)
@@ -531,7 +560,7 @@ func (s *Service) Approve(ctx context.Context, id ids.ID, by, note string, forSe
}
ev, _ := s.Events.Append(ctx, events.New{MindID: a.MindID, Type: events.ToolAuthorised, Source: "operator", Summary: fmt.Sprintf("operator %s approved %s on %q%s", by, a.Capability, a.Target, map[bool]string{true: " for this session", false: ""}[forSession]),
Salience: 0.5, Confidence: 1, CausationID: a.EventID, CorrelationID: a.CorrelationID, Metadata: map[string]any{"approval_id": string(a.ID), "actor": by, "capability": a.Capability, "for_session": forSession}, Payload: details})
inv := Invocation{MindID: a.MindID, Capability: a.Capability, Target: a.Target, Params: params, Reason: a.Reason, Evidence: a.Evidence, Correlation: a.CorrelationID, ApprovalID: a.ID}
inv := Invocation{MindID: a.MindID, Capability: a.Capability, Target: a.Target, Params: params, Reason: a.Reason, Evidence: a.Evidence, Correlation: a.CorrelationID, ApprovalID: a.ID, Origin: a.Origin}
out, err := s.execute(ctx, inv, c, Decision{Effect: Allow, Reason: "approved by " + by}, firstID(ev), a.ID)
if err != nil {
return out, err
@@ -622,7 +651,56 @@ func (s *Service) Executions(ctx context.Context, mind ids.ID, limit int) ([]Exe
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at FROM tool_executions WHERE ($1::uuid IS NULL OR mind_id=$1) ORDER BY created_at DESC LIMIT $2`, nullID(mind), limit)
return s.queryExecutions(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at, origin FROM tool_executions WHERE ($1::uuid IS NULL OR mind_id=$1) ORDER BY created_at DESC LIMIT $2`, nullID(mind), limit)
}
// ExecutionsByOrigin lists executions a cognitive object asked for, oldest first.
func (s *Service) ExecutionsByOrigin(ctx context.Context, mind ids.ID, origin string) ([]Execution, error) {
return s.queryExecutions(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at, origin FROM tool_executions WHERE mind_id=$1 AND origin=$2 ORDER BY created_at LIMIT 100`, mind, origin)
}
// GetExecution fetches one execution.
func (s *Service) GetExecution(ctx context.Context, id ids.ID) (Execution, error) {
out, err := s.queryExecutions(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at, origin FROM tool_executions WHERE id=$1`, id)
if err != nil {
return Execution{}, err
}
if len(out) == 0 {
return Execution{}, storage.ErrNotFound
}
return out[0], nil
}
// ApprovalsByOrigin lists approval requests a cognitive object raised, oldest first.
func (s *Service) ApprovalsByOrigin(ctx context.Context, mind ids.ID, origin string) ([]Approval, error) {
rows, err := s.DB.Query(ctx, `SELECT `+approvalCols+` FROM approval_requests WHERE mind_id=$1 AND origin=$2 ORDER BY created_at LIMIT 100`, mind, origin)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Approval
for rows.Next() {
a, err := scanApproval(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// WithdrawApproval lets the requesting mind withdraw a pending approval
// request it no longer needs (its plan changed). It never approves anything.
func (s *Service) WithdrawApproval(ctx context.Context, mind, id ids.ID, reason string) error {
tag, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='withdrawn', decision_note=$3 WHERE id=$1 AND mind_id=$2 AND status='pending'`, id, mind, reason)
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
func (s *Service) queryExecutions(ctx context.Context, sql string, args ...any) ([]Execution, error) {
rows, err := s.DB.Query(ctx, sql, args...)
if err != nil {
return nil, err
}
@@ -633,7 +711,7 @@ func (s *Service) Executions(ctx context.Context, mind ids.ID, limit int) ([]Exe
var params []byte
var result *json.RawMessage
var appr, ev *string
if err := rows.Scan(&e.ID, &e.MindID, &e.Capability, &e.Target, &params, &result, &e.Error, &e.DurationMS, &appr, &ev, &e.CreatedAt); err != nil {
if err := rows.Scan(&e.ID, &e.MindID, &e.Capability, &e.Target, &params, &result, &e.Error, &e.DurationMS, &appr, &ev, &e.CreatedAt, &e.Origin); err != nil {
return nil, err
}
json.Unmarshal(params, &e.Params) //nolint:errcheck
File diff suppressed because it is too large Load Diff
+507
View File
@@ -0,0 +1,507 @@
package planning
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/internal/capabilities/host"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/predictions"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/volition"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// fakeHost stands in for systemd: a unit that restarts, and may or may not
// come back healthy.
type fakeHost struct {
mu sync.Mutex
active bool
fixOnRestart bool
restarts int
}
func (f *fakeHost) Run(_ context.Context, name string, args ...string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
switch {
case name == "systemctl" && args[0] == "restart":
f.restarts++
if f.fixOnRestart {
f.active = true
}
return nil, nil
case name == "systemctl" && args[0] == "show":
state := "failed"
if f.active {
state = "active"
}
return []byte("LoadState=loaded\nActiveState=" + state + "\nSubState=" + state + "\n"), nil
case name == "journalctl":
return []byte("07:14 atlas-worker: queue consumer lost connection to db-primary\n"), nil
}
return nil, fmt.Errorf("unexpected %s", name)
}
func (f *fakeHost) count() int { f.mu.Lock(); defer f.mu.Unlock(); return f.restarts }
type fixture struct {
t *testing.T
ctx context.Context
mind minds.Mind
es *evstore.Store
caps *capabilities.Service
vol *volition.Store
plans *Store
bel *beliefs.Store
mem *memory.Store
exec *Executor
host *fakeHost
offset time.Duration
mu sync.Mutex
}
func newFixture(t *testing.T) *fixture {
t.Helper()
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
m, err := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "planner"})
if err != nil {
t.Fatal(err)
}
f := &fixture{t: t, ctx: ctx, mind: m, host: &fakeHost{}}
f.es = evstore.NewStore(pool, nil, nil)
reg := capabilities.NewRegistry()
for _, c := range capabilities.Builtins() {
reg.Add(c)
}
for _, c := range host.Capabilities(host.Options{Runner: f.host}) {
reg.Add(c)
}
reg.Freeze()
log := slog.New(slog.DiscardHandler)
f.caps = &capabilities.Service{Registry: reg, DB: pool, Events: f.es, Log: log}
f.vol = volition.NewStore(pool)
f.plans = NewStore(pool)
f.bel = beliefs.NewStore(pool)
f.mem = memory.NewStore(ctx, pool, nil)
ps := predictions.NewStore(pool)
planner := &Planner{Registry: reg, Effect: func(ctx context.Context, mind ids.ID, capability, target string, params map[string]any) string {
d, err := f.caps.Preview(ctx, capabilities.Invocation{MindID: mind, Capability: capability, Target: target, Params: params})
if err != nil {
return "deny"
}
return string(d.Effect)
}}
f.exec = f.newExecutor(ps, planner, log)
return f
}
func (f *fixture) newExecutor(ps *predictions.Store, planner *Planner, log *slog.Logger) *Executor {
return &Executor{Plans: f.plans, Volition: f.vol, Caps: f.caps, Predictions: ps, Resolver: &predictions.Resolver{Store: ps, Events: f.es, Beliefs: f.bel, Log: log}, Events: f.es,
Minds: minds.NewStore(f.plans.db), Memory: f.mem, Planner: planner, Log: log, Now: func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return time.Now().Add(f.offset) }}
}
func (f *fixture) advance(d time.Duration) { f.mu.Lock(); f.offset += d; f.mu.Unlock() }
func (f *fixture) intention(requireApproval bool, beliefIDs ...ids.ID) volition.Intention {
f.t.Helper()
it, err := f.vol.CreateIntention(f.ctx, f.vol.Direct(f.es, "test"), volition.Intention{MindID: f.mind.ID, Description: "restart atlas-worker", Kind: volition.IntentAct, RequireApproval: requireApproval,
Proposal: &volition.Proposal{Kind: "capability", Capability: "service.restart", Target: "atlas-worker", Params: map[string]any{"service": "atlas-worker"}}, Confidence: 0.7, Priority: 0.8, BeliefIDs: beliefIDs})
if err != nil {
f.t.Fatal(err)
}
return it
}
func (f *fixture) start(it volition.Intention) Plan {
f.t.Helper()
p, err := f.exec.Start(f.ctx, volition.Recorder{Q: f.plans.db, Emit: volition.DirectEmitter(f.es), Source: "test"}, it)
if err != nil {
f.t.Fatalf("start: %v", err)
}
return p
}
func (f *fixture) tick() {
f.t.Helper()
if err := f.exec.TickMind(f.ctx, f.mind.ID); err != nil {
f.t.Fatalf("tick: %v", err)
}
}
func (f *fixture) allow(capability string) {
if _, err := f.caps.SetPolicy(f.ctx, capabilities.Policy{MindID: f.mind.ID, Capability: capability, Effect: capabilities.Allow, CreatedBy: "andy"}); err != nil {
f.t.Fatal(err)
}
}
func (f *fixture) plan(id ids.ID) Plan {
p, err := f.plans.Get(f.ctx, nil, id)
if err != nil {
f.t.Fatal(err)
}
return p
}
func (f *fixture) step(p Plan, key string) Step {
for _, s := range f.plan(p.ID).Steps {
if s.Key == key {
return s
}
}
f.t.Fatalf("no step %s", key)
return Step{}
}
func (f *fixture) intentionNow(id ids.ID) volition.Intention {
it, err := f.vol.GetIntention(f.ctx, nil, f.mind.ID, id)
if err != nil {
f.t.Fatal(err)
}
return it
}
func (f *fixture) latestPlan(it ids.ID) Plan {
ps, _ := f.plans.ForIntention(f.ctx, nil, it)
return f.plan(ps[len(ps)-1].ID)
}
func (f *fixture) eventsOf(types ...events.Type) []events.Event {
evs, _ := f.es.Query(f.ctx, evstore.Filter{MindID: f.mind.ID, Types: types, Limit: 500})
return evs
}
func TestApprovedActionIsVerifiedAndLearnt(t *testing.T) {
f := newFixture(t)
f.host.fixOnRestart = true
it := f.intention(true)
p := f.start(it)
if p.Status != PlanValidated || p.Strategy != "direct" || len(p.Steps) != 2 || !p.Steps[0].RequireApproval {
t.Fatalf("plan: %+v", p)
}
f.tick()
p1 := f.step(p, "P1")
if p1.Status != StepAwaitingApproval || p1.ApprovalID.IsZero() || p1.IntentionID.IsZero() || f.host.count() != 0 {
t.Fatalf("expected to wait for approval without acting: %+v restarts=%d", p1, f.host.count())
}
parent := f.intentionNow(it.ID)
sub := f.intentionNow(p1.IntentionID)
if parent.Status != volition.IntentionBlocked || parent.BlockedKind != volition.BlockApproval || sub.ParentID != it.ID || sub.Kind != volition.IntentRequestApproval {
t.Fatalf("intention–action gap not represented: parent %+v sub %+v", parent, sub)
}
a, _ := f.caps.GetApproval(f.ctx, p1.ApprovalID)
if !strings.Contains(a.Reason, "Expected:") || !strings.Contains(a.Reason, "Side effects:") || !strings.Contains(a.Reason, "commitment requires approval") || a.Origin != p1.Origin() {
t.Fatalf("approval does not explain itself: %q origin %q", a.Reason, a.Origin)
}
f.tick() // nothing moves while the operator has not decided
if f.host.count() != 0 {
t.Fatal("acted before approval")
}
if _, err := f.caps.Approve(f.ctx, p1.ApprovalID, "andy", "go ahead", false, nil); err != nil {
t.Fatal(err)
}
f.tick()
p = f.plan(p.ID)
if p.Status != PlanCompleted || f.host.count() != 1 {
t.Fatalf("plan should complete after verification: %s restarts=%d steps=%+v", p.Status, f.host.count(), p.Steps)
}
if got := f.intentionNow(it.ID); got.Status != volition.IntentionFulfilled {
t.Fatalf("intention: %+v", got)
}
if got := f.intentionNow(p1.IntentionID); got.Status != volition.IntentionFulfilled {
t.Fatalf("subordinate: %+v", got)
}
p1 = f.step(p, "P1")
pr, _ := f.exec.Predictions.Get(f.ctx, f.mind.ID, p1.PredictionID)
if pr.Status != predictions.StatusConfirmed || p1.ExecutionID.IsZero() {
t.Fatalf("expectation: %+v step %+v", pr, p1)
}
mems, _ := f.mem.List(f.ctx, f.mind.ID, memory.Filter{Kinds: []concepts.MemoryKind{concepts.Procedural}})
if len(mems) != 1 || !strings.Contains(mems[0].Content, "confirmed by observation") {
t.Fatalf("procedure not learnt: %+v", mems)
}
}
func TestFailedExpectationReplansWithoutRepeatingTheAction(t *testing.T) {
f := newFixture(t)
f.allow("service.restart")
res, err := f.bel.Create(f.ctx, f.plans.db, func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
evs, err := f.es.AppendIn(ctx, q, n)
if err != nil {
return events.Event{}, err
}
return evs[0], nil
}, beliefs.New{MindID: f.mind.ID, Proposition: "restarting atlas-worker clears the stalled queue", Epistemic: concepts.Hypothesis, Source: "test",
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "note", Source: "operator:andy", Weight: 0.6, Credibility: 0.9, Derivation: beliefs.DerivationOperator, Family: "operator:andy"}}})
if err != nil {
t.Fatal(err)
}
before := res.Belief.Confidence
it := f.intention(false, res.Belief.ID)
p := f.start(it)
f.tick()
if f.host.count() != 1 || f.step(p, "V1").Status != StepWaiting {
t.Fatalf("restart then wait: restarts=%d v1=%+v", f.host.count(), f.step(p, "V1"))
}
f.advance(61 * time.Second)
f.tick()
if got := f.plan(p.ID); got.Status != PlanSuperseded || got.SupersededBy.IsZero() {
t.Fatalf("failed plan should be kept as superseded history: %+v", got)
}
if len(f.eventsOf(events.PredictionError)) != 1 || len(f.eventsOf(events.PlanReplanned)) != 1 {
t.Fatal("prediction error or replan not recorded")
}
b, _ := f.bel.Get(f.ctx, nil, f.mind.ID, res.Belief.ID)
if b.Confidence >= before {
t.Fatalf("belief the expectation rested on did not weaken: %.3f → %.3f", before, b.Confidence)
}
p2 := f.latestPlan(it.ID)
if p2.Revision != 2 || p2.Strategy != "diagnose_then_escalate" || p2.Supersedes != p.ID {
t.Fatalf("second revision: %+v", p2)
}
f.tick()
r1 := f.step(p2, "R1")
if r1.Status != StepAwaitingGrant || r1.RequestID.IsZero() {
t.Fatalf("log access should be requested: %+v", r1)
}
req, _ := f.caps.GetCapabilityRequest(f.ctx, r1.RequestID)
if !strings.Contains(req.Risk, "personal") || req.Fallback == "" || req.IntentionID != it.ID {
t.Fatalf("request lacks risk, fallback or provenance: %+v", req)
}
if _, err := f.caps.DecideCapabilityRequest(f.ctx, r1.RequestID, "andy", true, "read only", capabilities.Narrowing{}); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
f.tick()
}
p2 = f.plan(p2.ID)
if p2.Status != PlanCompleted {
t.Fatalf("escalation plan: %s %+v", p2.Status, p2.Steps)
}
if f.host.count() != 1 {
t.Fatalf("the failed restart was repeated: %d restarts", f.host.count())
}
if got := f.intentionNow(it.ID); got.Status != volition.IntentionAbandoned || !strings.Contains(got.StatusReason, "without achieving") {
t.Fatalf("an escalation must not count as fulfilment: %+v", got)
}
if len(f.eventsOf(events.Observation)) == 0 {
t.Fatal("findings did not re-enter cognition as an observation")
}
}
func TestReplanLimitAndDenial(t *testing.T) {
f := newFixture(t)
it := f.intention(true)
p := f.start(it)
f.tick()
a := f.step(p, "P1").ApprovalID
if _, err := f.caps.Deny(f.ctx, a, "andy", "not during trading hours"); err != nil {
t.Fatal(err)
}
f.tick()
p2 := f.latestPlan(it.ID)
if p2.Strategy != "diagnose_then_escalate" || f.host.count() != 0 {
t.Fatalf("denial should revise, not act: %+v restarts=%d", p2, f.host.count())
}
// Deny the log request too: no strategy remains, so the intention is abandoned.
f.tick()
r1 := f.step(p2, "R1")
if _, err := f.caps.DecideCapabilityRequest(f.ctx, r1.RequestID, "andy", false, "no", capabilities.Narrowing{}); err != nil {
t.Fatal(err)
}
f.tick()
got := f.intentionNow(it.ID)
if got.Status != volition.IntentionAbandoned {
t.Fatalf("intention after exhausting strategies: %+v", got)
}
ws := f.eventsOf(events.IntentionAbandoned)
if len(ws) == 0 {
t.Fatal("abandonment not recorded")
}
}
func TestApprovalExpiryAndCapabilityRemovalMidPlan(t *testing.T) {
f := newFixture(t)
it := f.intention(true)
p := f.start(it)
f.tick()
a := f.step(p, "P1").ApprovalID
if _, err := f.plans.db.Exec(f.ctx, `UPDATE approval_requests SET expires_at=now()-interval '1 minute' WHERE id=$1`, a); err != nil {
t.Fatal(err)
}
f.tick()
if f.latestPlan(it.ID).Revision != 2 {
t.Fatal("expired approval did not lead to a revised plan")
}
g := newFixture(t)
g.allow("service.restart")
it2 := g.intention(false)
p2 := g.start(it2)
if _, err := g.caps.SetPolicy(g.ctx, capabilities.Policy{MindID: g.mind.ID, Capability: "service.restart", TargetPattern: "atlas-*", Effect: capabilities.Deny, CreatedBy: "andy"}); err != nil {
t.Fatal(err)
}
g.tick()
if g.host.count() != 0 || g.step(p2, "P1").Status != StepFailed {
t.Fatalf("capability removed mid-plan still ran: %d %+v", g.host.count(), g.step(p2, "P1"))
}
if next := g.latestPlan(it2.ID); next.Revision != 2 {
t.Fatalf("no revision after capability removal: %+v", next)
}
}
func TestRestartDuringExecutionNeverDuplicatesAnAction(t *testing.T) {
f := newFixture(t)
f.allow("service.restart")
f.host.fixOnRestart = true
it := f.intention(false)
p := f.start(it)
p1 := f.step(p, "P1")
// The process died after claiming the step and before recording anything.
if _, err := f.plans.db.Exec(f.ctx, `UPDATE plan_steps SET status='executing', attempts=1, started_at=now(), lease_until=now()-interval '1 second' WHERE id=$1`, p1.ID); err != nil {
t.Fatal(err)
}
f.tick()
p1 = f.step(p, "P1")
if p1.Status != StepBlocked || f.host.count() != 0 {
t.Fatalf("non-idempotent action repeated blindly: %+v restarts=%d", p1, f.host.count())
}
if got := f.intentionNow(it.ID); got.Status != volition.IntentionBlocked || got.BlockedKind != volition.BlockVerify {
t.Fatalf("intention should wait for verification: %+v", got)
}
if err := f.exec.RetryStep(f.ctx, p1.ID, "andy"); err != nil {
t.Fatal(err)
}
f.tick()
if f.host.count() != 1 || f.plan(p.ID).Status != PlanCompleted {
t.Fatalf("after release: restarts=%d status=%s", f.host.count(), f.plan(p.ID).Status)
}
// The process died after the action ran: the record is adopted, not repeated.
g := newFixture(t)
g.allow("service.restart")
g.host.fixOnRestart = true
it2 := g.intention(false)
pl := g.start(it2)
s := g.step(pl, "P1")
if out, err := g.caps.Invoke(g.ctx, capabilities.Invocation{MindID: g.mind.ID, Capability: "service.restart", Params: map[string]any{"service": "atlas-worker"}, Origin: s.Origin()}); err != nil || out.Status != "executed" {
t.Fatalf("simulated run: %v %+v", err, out)
}
if _, err := g.plans.db.Exec(g.ctx, `UPDATE plan_steps SET status='executing', attempts=1, started_at=now()-interval '5 seconds', lease_until=now()-interval '1 second' WHERE id=$1`, s.ID); err != nil {
t.Fatal(err)
}
g.tick()
if g.host.count() != 1 || g.plan(pl.ID).Status != PlanCompleted {
t.Fatalf("recovered outcome: restarts=%d status=%s", g.host.count(), g.plan(pl.ID).Status)
}
}
func TestConcurrentExecutorsRunAStepOnce(t *testing.T) {
f := newFixture(t)
f.allow("service.restart")
f.host.fixOnRestart = true
it := f.intention(false)
p := f.start(it)
other := f.newExecutor(f.exec.Predictions, f.exec.Planner, f.exec.Log)
var wg sync.WaitGroup
for _, e := range []*Executor{f.exec, other, f.newExecutor(f.exec.Predictions, f.exec.Planner, f.exec.Log)} {
wg.Add(1)
go func(e *Executor) {
defer wg.Done()
e.TickMind(f.ctx, f.mind.ID) //nolint:errcheck
}(e)
}
wg.Wait()
f.tick()
if f.host.count() != 1 {
t.Fatalf("duplicate scheduler execution: %d restarts", f.host.count())
}
execs, _ := f.caps.ExecutionsByOrigin(f.ctx, f.mind.ID, f.step(p, "P1").Origin())
if len(execs) != 1 {
t.Fatalf("executions recorded: %d", len(execs))
}
}
func TestBudgetPreconditionDreamAndSandbox(t *testing.T) {
f := newFixture(t)
f.allow("service.restart")
it := f.intention(false)
p := f.start(it)
if _, err := f.plans.db.Exec(f.ctx, `UPDATE plans SET budget='{"max_capability_invocations":1}', actual='{"capability_invocations":1}' WHERE id=$1`, p.ID); err != nil {
t.Fatal(err)
}
f.tick()
if f.host.count() != 0 || f.intentionNow(it.ID).Status != volition.IntentionSuspended || f.plan(p.ID).Status != PlanBlocked {
t.Fatalf("budget exhausted: restarts=%d intention=%s plan=%s", f.host.count(), f.intentionNow(it.ID).Status, f.plan(p.ID).Status)
}
// Target state changed externally: already healthy, so the restart is skipped and the intention fulfilled.
g := newFixture(t)
g.allow("service.restart")
g.host.active = true
it2 := g.intention(false)
d := Draft{Objective: "restore atlas-worker", Origin: "template", Strategy: "direct", Steps: []StepSpec{{Key: "P1", Title: "restart atlas-worker", Kind: KindCapability, Capability: "service.restart", Params: map[string]any{"service": "atlas-worker"},
Precondition: &Precondition{SatisfiedIfFalse: true, Check: Check{Capability: "service.status", Params: map[string]any{"service": "atlas-worker"}, Path: "active", Op: "eq", Value: false, Description: "atlas-worker is still down"}}}}}
v := Validate(&d, g.caps.Registry)
pl, err := g.plans.Create(g.ctx, g.exec.rec(), it2, d, v, nil)
if err != nil || !v.Valid {
t.Fatalf("%v %+v", err, v)
}
g.tick()
if g.host.count() != 0 || g.step(pl, "P1").Status != StepSkipped || g.intentionNow(it2.ID).Status != volition.IntentionFulfilled {
t.Fatalf("world change: restarts=%d step=%+v intention=%s", g.host.count(), g.step(pl, "P1"), g.intentionNow(it2.ID).Status)
}
// A dream's plan and a disabled sandbox mind never act.
h := newFixture(t)
h.allow("service.restart")
it3 := h.intention(false)
d3 := Draft{Objective: "dreamt fix", Origin: "dream", Strategy: "dream", Steps: []StepSpec{{Key: "P1", Title: "restart", Kind: KindCapability, Capability: "service.restart", Params: map[string]any{"service": "atlas-worker"}}}}
v3 := Validate(&d3, h.caps.Registry)
dp, _ := h.plans.Create(h.ctx, h.exec.rec(), it3, d3, v3, nil)
h.tick()
if dp.Status != PlanDraft || h.host.count() != 0 {
t.Fatalf("dream plan: %s restarts=%d", dp.Status, h.host.count())
}
h.start(it3)
if _, err := h.plans.db.Exec(h.ctx, `UPDATE minds SET status='disabled' WHERE id=$1`, h.mind.ID); err != nil {
t.Fatal(err)
}
h.tick()
if h.host.count() != 0 {
t.Fatal("a disabled mind acted")
}
}
func TestInvalidModelPlanNeverRuns(t *testing.T) {
f := newFixture(t)
f.exec.Planner.Model = func(context.Context, ids.ID, string, []capabilities.Capability) (string, ids.ID, error) {
return `{"steps":[{"key":"P1","title":"become root","kind":"capability","capability":"shell.exec","params":{"cmd":"sudo -i"},"status":"completed"}]}`, ids.Nil, nil
}
it, _ := f.vol.CreateIntention(f.ctx, f.vol.Direct(f.es, "test"), volition.Intention{MindID: f.mind.ID, Description: "make the dashboard faster", Kind: volition.IntentPursue})
_, err := f.exec.Start(f.ctx, f.exec.rec(), it)
if err == nil || !strings.Contains(err.Error(), "shell.exec") {
t.Fatalf("invalid model plan accepted: %v", err)
}
p := f.latestPlan(it.ID)
if p.Status != PlanDraft || p.Validation.Valid || len(p.Validation.Warnings) == 0 {
t.Fatalf("invalid plan should stay a draft with its errors and ignored fields: %+v", p)
}
f.tick()
if execs, _ := f.caps.Executions(f.ctx, f.mind.ID, 10); len(execs) != 0 {
t.Fatal("invalid plan executed")
}
}
+392
View File
@@ -0,0 +1,392 @@
package planning
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"sort"
"strings"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/internal/volition"
"github.com/andydixon/noema/pkg/ids"
)
// ErrNoPlan means no strategy is available for an intention.
var ErrNoPlan = errors.New("planning: no possible plan")
// Planner proposes drafts. Deterministic strategies come first; a model is
// consulted only when none applies, and its proposal is validated like any
// other draft.
type Planner struct {
Registry *capabilities.Registry
// Effect previews the policy effect for a request (allow, ask, deny).
Effect func(ctx context.Context, mind ids.ID, capability, target string, params map[string]any) string
// Model optionally proposes a plan as JSON. Nil disables model planning.
Model func(ctx context.Context, mind ids.ID, objective string, vocabulary []capabilities.Capability) (raw string, call ids.ID, err error)
}
// Verifier returns how to check that a writing capability achieved its
// usual outcome, or nil when no observation is known.
func Verifier(capability string, params map[string]any) (*Check, string) {
switch capability {
case "service.restart":
unit, _ := params["service"].(string)
return &Check{Capability: "service.status", Params: map[string]any{"service": unit}, Path: "active", Op: "eq", Value: true, Description: unit + " is active"}, unit + " is running normally again"
}
return nil, ""
}
type strategy struct {
name string
confidence float64
build func() (Draft, error)
}
// Propose drafts a plan for an intention, avoiding strategies that already
// failed in earlier revisions. The alternatives considered are recorded.
func (p *Planner) Propose(ctx context.Context, it volition.Intention, previous []Plan) (Draft, error) {
used := map[string]bool{}
for _, pl := range previous {
if pl.Status == PlanFailed || pl.Status == PlanAbandoned || pl.Status == PlanSuperseded || pl.Status == PlanReplanning {
used[pl.Strategy] = true
}
}
var strategies []strategy
prop := it.Proposal
switch {
case prop != nil && prop.Kind == "capability":
strategies = append(strategies, strategy{"direct", clamp(0.4+0.4*it.Confidence, 0.4, 0.85), func() (Draft, error) { return p.direct(ctx, it, *prop) }})
if unit := unitOf(prop.Params); unit != "" && strings.HasPrefix(prop.Capability, "service.") {
strategies = append(strategies, strategy{"diagnose_then_escalate", 0.5, func() (Draft, error) { return p.escalate(ctx, it, unit, *prop) }})
}
case prop != nil && prop.Kind == "investigate":
if unit := unitFromSubject(prop.Subject); unit != "" {
strategies = append(strategies, strategy{"diagnose", 0.6, func() (Draft, error) { return p.diagnose(ctx, it, unit, "diagnose") }})
}
}
if p.Model != nil {
strategies = append(strategies, strategy{"model", 0.45, func() (Draft, error) { return p.fromModel(ctx, it) }})
}
var alts []Alternative
var chosen *Draft
for _, s := range strategies {
if used[s.name] {
alts = append(alts, Alternative{Strategy: s.name, Description: "tried in an earlier revision", Why: "already failed"})
continue
}
d, err := s.build()
if err != nil {
alts = append(alts, Alternative{Strategy: s.name, Description: err.Error(), Why: "not buildable"})
continue
}
est := EstimateDraft(d, p.Registry)
score := round3(s.confidence - 0.05*float64(est.Approvals) - 0.001*est.WallSeconds - 0.01*est.Money)
a := Alternative{Strategy: s.name, Description: d.Objective, Estimate: est, ExpectedConfidence: s.confidence, Score: score}
if chosen == nil {
a.Selected, a.Why = true, "first applicable strategy in preference order that has not failed"
dd := d
chosen = &dd
}
alts = append(alts, a)
}
if chosen == nil {
return Draft{Alternatives: alts}, fmt.Errorf("%w for %q", ErrNoPlan, it.Description)
}
chosen.Alternatives = alts
if len(previous) > 0 {
chosen.Origin = map[bool]string{true: "model", false: "replan"}[chosen.Origin == "model"]
}
return *chosen, nil
}
func (p *Planner) effect(ctx context.Context, mind ids.ID, capability, target string, params map[string]any) string {
c, ok := p.Registry.Get(capability)
if !ok {
return "unknown"
}
if c.Locked {
return "locked"
}
if p.Effect == nil {
return string(c.DefaultEffect)
}
return p.Effect(ctx, mind, capability, target, params)
}
func (p *Planner) direct(ctx context.Context, it volition.Intention, prop volition.Proposal) (Draft, error) {
c, ok := p.Registry.Get(prop.Capability)
if !ok || c.Locked {
return Draft{}, fmt.Errorf("%s is not usable", prop.Capability)
}
target := prop.Target
if c.TargetFrom != nil {
target = "" // derived from the parameters during validation
}
if e := p.effect(ctx, it.MindID, prop.Capability, target, prop.Params); e == "deny" || e == "locked" || e == "unknown" {
return Draft{}, fmt.Errorf("%s is %s by policy", prop.Capability, e)
}
act := StepSpec{Key: "P1", Title: "run " + prop.Capability + " on " + firstNonEmpty(prop.Target, "its target"), Kind: KindCapability, Capability: prop.Capability, Target: target, Params: copyParams(prop.Params),
RequireApproval: it.RequireApproval, MaxAttempts: 1}
if c.Idempotent {
act.MaxAttempts = 2
}
steps := []StepSpec{act}
criteria := prop.Capability + " completes"
if ck, outcome := Verifier(prop.Capability, prop.Params); ck != nil {
steps[0].Expectation = &Expectation{Description: outcome + " within 60 seconds", Confidence: clamp(it.Confidence, 0.5, 0.9), WithinSeconds: 60, BeliefID: firstBelief(it.BeliefIDs)}
steps = append(steps, StepSpec{Key: "V1", Title: "verify: " + ck.Description, Kind: KindVerify, DependsOn: []string{"P1"}, Verify: &VerifySpec{Of: "P1", Check: *ck, WithinSeconds: 60, IntervalSeconds: 10}})
criteria = outcome + ", confirmed by observation rather than by the command returning"
}
d := Draft{Objective: it.Description, Origin: "template", Strategy: "direct", Steps: steps, CompletionCriteria: criteria, Risk: string(c.Risk) + ": " + c.SideEffects,
AbortConditions: []string{"approval is denied", "the expected outcome is not observed", "the capability becomes unavailable"}, RequireApproval: it.RequireApproval, Budget: Budget{MaxInvocations: 10}}
for _, b := range it.BeliefIDs {
d.Assumptions = append(d.Assumptions, Assumption{BeliefID: b, Proposition: "supporting belief " + string(b), MinConfidence: 0.3})
}
return d, nil
}
// diagnose builds read-only investigation of a service: its status, host
// memory and disk, and its logs. When reading logs needs approval and no
// grant is in force, the plan first requests temporary scoped access, with
// the risk and fallback stated.
func (p *Planner) diagnose(ctx context.Context, it volition.Intention, unit, name string) (Draft, error) {
var steps []StepSpec
var deps []string
add := func(s StepSpec) {
steps = append(steps, s)
deps = append(deps, s.Key)
}
usable := func(capability string, params map[string]any) (string, bool) {
e := p.effect(ctx, it.MindID, capability, "", params)
return e, e == "allow" || e == "ask"
}
svc := map[string]any{"service": unit}
if _, ok := usable("service.status", svc); ok {
add(StepSpec{Key: "D1", Title: "check the status of " + unit, Kind: KindCapability, Capability: "service.status", Params: svc, MaxAttempts: 2})
}
if _, ok := usable("host.memory.status", nil); ok {
add(StepSpec{Key: "D2", Title: "check host memory", Kind: KindCapability, Capability: "host.memory.status", MaxAttempts: 2})
}
if _, ok := usable("host.disk.usage", map[string]any{"path": "/"}); ok {
add(StepSpec{Key: "D3", Title: "check disk space", Kind: KindCapability, Capability: "host.disk.usage", Params: map[string]any{"path": "/"}, MaxAttempts: 2})
}
logParams := map[string]any{"service": unit, "since_minutes": 120, "lines": 300}
if e, ok := usable("logs.query", logParams); ok {
logStep := StepSpec{Key: "D4", Title: "read recent logs of " + unit, Kind: KindCapability, Capability: "logs.query", Params: logParams, MaxAttempts: 2}
if e == "ask" {
ops := 20
steps = append(steps, StepSpec{Key: "R1", Title: "request temporary read access to " + unit + " logs", Kind: KindRequestCapability, Request: &RequestSpec{
Capability: "logs.query", TargetPattern: strings.TrimSuffix(unit, ".service") + "*", DurationSeconds: 1800, MaxOperations: &ops,
Reason: "status and host metrics alone cannot distinguish a failure inside " + unit + " from a failure it depends on; its logs can",
Risk: "logs may contain personal identifiers and credentials", Mitigation: "e-mail and IP addresses and credential-like values are masked by default",
Fallback: "ask the operator to read the logs and report what they show", Alternatives: []string{"continue with status and host metrics only"}}})
logStep.DependsOn = []string{"R1"}
}
add(logStep)
}
if len(deps) == 0 {
return Draft{}, errors.New("no read-only capability is usable for " + unit)
}
steps = append(steps, StepSpec{Key: "S1", Title: "summarise the findings", Kind: KindInternal, DependsOn: deps, Internal: &InternalSpec{Action: "summarise_findings"}})
return Draft{Objective: "diagnose " + unit, Origin: "template", Strategy: name, Steps: steps, CompletionCriteria: "every reading is recorded and summarised as observations for belief formation",
AbortConditions: []string{"read access is denied and no other reading is possible"}, Risk: "read-only; logs may contain personal data", Budget: Budget{MaxInvocations: 12}}, nil
}
// escalate is the strategy after a direct service action failed: diagnose,
// then tell the operator what was tried and seen, without repeating the action.
func (p *Planner) escalate(ctx context.Context, it volition.Intention, unit string, prop volition.Proposal) (Draft, error) {
d, err := p.diagnose(ctx, it, unit, "diagnose_then_escalate")
if err != nil {
return Draft{}, err
}
if e := p.effect(ctx, it.MindID, "noema.note", "", map[string]any{"text": "x"}); e == "deny" || e == "locked" || e == "unknown" {
return Draft{}, errors.New("cannot leave a note for the operator")
}
d.Objective = "diagnose " + unit + " and escalate to the operator instead of repeating " + prop.Capability
d.Steps = append(d.Steps, StepSpec{Key: "N1", Title: "tell the operator what was tried and observed", Kind: KindCapability, Capability: "noema.note", DependsOn: []string{"S1"},
Params: map[string]any{"text": fmt.Sprintf("I tried %s on %s and the expected outcome did not follow. I have gathered diagnostics and am not repeating the action. Please review: %s", prop.Capability, unit, it.Description)}})
d.CompletionCriteria = "diagnostics recorded and the operator informed; the failed action is not repeated"
return d, nil
}
func (p *Planner) fromModel(ctx context.Context, it volition.Intention) (Draft, error) {
raw, call, err := p.Model(ctx, it.MindID, it.Description, p.Registry.List())
if err != nil {
return Draft{}, err
}
d, err := ParseProposal(raw)
if err != nil {
return Draft{}, err
}
d.Origin, d.Strategy, d.LLMCall = "model", "model", call
if d.Objective == "" {
d.Objective = it.Description
}
d.RequireApproval = it.RequireApproval
return d, nil
}
var jsonObject = regexp.MustCompile(`(?s)\{.*\}`)
// allowed fields of a model proposal; anything else is reported as ignored.
var (
draftFields = map[string]bool{"objective": true, "steps": true, "assumptions": true, "completion_criteria": true, "abort_conditions": true, "risk": true}
stepFields = map[string]bool{"key": true, "title": true, "kind": true, "depends_on": true, "capability": true, "target": true, "params": true, "expectation": true, "precondition": true, "request": true, "verify": true, "monitor": true, "wait": true, "internal": true, "max_attempts": true}
)
// ParseProposal reads a model's JSON plan into a draft. Fields that could
// carry authority or claim progress (status, approved, completed, policy,
// require_approval=false overrides) are not part of the structure and are
// reported in Ignored; the draft must still pass Validate.
func ParseProposal(raw string) (Draft, error) {
m := jsonObject.FindString(raw)
if m == "" {
return Draft{}, errors.New("model returned no JSON object")
}
var generic map[string]any
if err := json.Unmarshal([]byte(m), &generic); err != nil {
return Draft{}, fmt.Errorf("model plan is not valid JSON: %w", err)
}
var ignored []string
for k := range generic {
if !draftFields[k] {
ignored = append(ignored, k)
}
}
if steps, ok := generic["steps"].([]any); ok {
for i, s := range steps {
if sm, ok := s.(map[string]any); ok {
for k := range sm {
if !stepFields[k] {
ignored = append(ignored, fmt.Sprintf("steps[%d].%s", i, k))
}
}
}
}
}
var d Draft
var in struct {
Objective string `json:"objective"`
Steps []StepSpec `json:"steps"`
Assumptions []Assumption `json:"assumptions"`
CompletionCriteria string `json:"completion_criteria"`
AbortConditions []string `json:"abort_conditions"`
Risk string `json:"risk"`
}
if err := json.Unmarshal([]byte(m), &in); err != nil {
return Draft{}, fmt.Errorf("model plan does not fit the plan structure: %w", err)
}
for i := range in.Steps {
in.Steps[i].RequireApproval = false // approval requirements come from commitments and policy, never from the model
}
d.Objective, d.Steps, d.Assumptions, d.CompletionCriteria, d.AbortConditions, d.Risk = in.Objective, in.Steps, in.Assumptions, in.CompletionCriteria, in.AbortConditions, in.Risk
sort.Strings(ignored)
d.Ignored = ignored
return d, nil
}
// EstimateDraft sums capability estimates and counts approvals the plan is
// likely to need (steps whose capability asks by default or must be approved).
func EstimateDraft(d Draft, reg *capabilities.Registry) Resources {
var r Resources
for _, s := range d.Steps {
switch s.Kind {
case KindCapability:
if reg == nil {
r.Invocations++
if s.RequireApproval {
r.Approvals++
}
continue
}
c, ok := reg.Get(s.Capability)
if !ok {
continue
}
n := 1
r.Invocations += n
r.WallSeconds += c.Estimate.WallSeconds
r.Network += c.Estimate.Network
r.Money += c.Estimate.Money
r.Tokens += c.Estimate.Tokens
if s.RequireApproval || c.DefaultEffect == capabilities.Ask {
r.Approvals++
}
case KindRequestCapability:
r.Approvals++
case KindVerify:
if s.Verify != nil {
r.Invocations += max(1, s.Verify.WithinSeconds/max(1, s.Verify.IntervalSeconds))
r.WallSeconds += float64(s.Verify.WithinSeconds)
}
case KindMonitor:
if s.Monitor != nil {
r.Invocations += max(1, s.Monitor.DeadlineSeconds/max(1, s.Monitor.IntervalSeconds))
r.WallSeconds += float64(s.Monitor.StableSeconds)
}
case KindWait:
if s.Wait != nil {
r.WallSeconds += float64(s.Wait.Seconds)
}
}
}
return r
}
func unitOf(params map[string]any) string {
u, _ := params["service"].(string)
return u
}
var unitToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@_.:-]{0,127}$`)
// unitFromSubject picks a service-like token from an investigation subject.
func unitFromSubject(subject string) string {
for _, f := range strings.Fields(subject) {
f = strings.Trim(f, ".,;:!?()\"'")
if unitToken.MatchString(f) && strings.ToLower(f) != "the" && len(f) > 2 {
return strings.ToLower(f)
}
}
return ""
}
func copyParams(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func firstBelief(xs []ids.ID) ids.ID {
if len(xs) > 0 {
return xs[0]
}
return ids.Nil
}
func firstNonEmpty(xs ...string) string {
for _, x := range xs {
if x != "" {
return x
}
}
return ""
}
func clamp(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func round3(v float64) float64 { return math.Round(v*1000) / 1000 }
+424
View File
@@ -0,0 +1,424 @@
package planning
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/volition"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Store persists plans and steps.
type Store struct{ db *pgxpool.Pool }
// NewStore creates a store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
// ErrInvalidPlan marks a draft that failed validation.
var ErrInvalidPlan = errors.New("planning: plan failed validation")
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
v := string(id)
return &v
}
func setID(dst *ids.ID, src *string) {
if src != nil {
*dst = ids.ID(*src)
}
}
func js(v any, empty string) []byte {
b, err := json.Marshal(v)
if err != nil || string(b) == "null" {
return []byte(empty)
}
return b
}
func emit(ctx context.Context, r volition.Recorder, mind ids.ID, typ events.Type, summary string, salience float64, evidence []events.EvidenceRef, meta map[string]any, payload any) (ids.ID, error) {
if r.Emit == nil {
return ids.Nil, nil
}
ev, err := r.Emit(ctx, r.Q, events.New{MindID: mind, Type: typ, Source: firstNonEmpty(r.Source, "planning"), Summary: summary, Salience: salience, Confidence: 1, CausationID: r.Cause, CorrelationID: r.Correlation, Evidence: evidence, Metadata: meta, Payload: payload})
if err != nil {
return ids.Nil, err
}
return ev.ID, nil
}
// Create stores a draft as a plan for an intention. A valid draft becomes
// "validated"; an invalid one stays "draft" with its errors and never runs.
// When previous is set the new plan is its next revision and the previous
// plan is marked superseded, kept as history.
func (s *Store) Create(ctx context.Context, r volition.Recorder, it volition.Intention, d Draft, v Validation, previous *Plan) (Plan, error) {
now := time.Now()
p := Plan{ID: ids.New(), MindID: it.MindID, IntentionID: it.ID, Revision: 1, Objective: trunc(d.Objective, 500), Origin: d.Origin, Strategy: d.Strategy, Status: PlanDraft, Assumptions: d.Assumptions,
CompletionCriteria: d.CompletionCriteria, AbortConditions: d.AbortConditions, Alternatives: d.Alternatives, Risk: trunc(d.Risk, 500), Budget: d.Budget, Validation: v, RequireApproval: d.RequireApproval || it.RequireApproval,
MaxReplans: 3, LLMCall: d.LLMCall, CreatedAt: now, UpdatedAt: now}
if p.Origin == "" {
p.Origin = "template"
}
if previous != nil {
p.Revision, p.Supersedes, p.MaxReplans = previous.Revision+1, previous.ID, previous.MaxReplans
}
if v.Valid && p.Origin != "dream" {
p.Status = PlanValidated
} else if !v.Valid {
p.StatusReason = "validation failed"
} else {
p.StatusReason = "a dream proposal waits for an awake intention to adopt it"
}
for _, x := range []*[]Assumption{&p.Assumptions} {
if *x == nil {
*x = []Assumption{}
}
}
if p.AbortConditions == nil {
p.AbortConditions = []string{}
}
if p.Alternatives == nil {
p.Alternatives = []Alternative{}
}
if len(d.Ignored) > 0 {
p.Validation.Warnings = append(p.Validation.Warnings, "ignored fields of the model proposal: "+joinComma(d.Ignored))
}
if d.Estimate != nil {
p.Estimate = *d.Estimate
} else {
p.Estimate = EstimateDraft(d, nil)
}
if _, err := r.Q.Exec(ctx, `INSERT INTO plans (id, mind_id, intention_id, revision, supersedes, objective, origin, strategy, status, assumptions, completion_criteria, abort_conditions, alternatives, risk, estimate, actual, budget, validation, require_approval, max_replans, llm_call, status_reason, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'{}',$16,$17,$18,$19,$20,$21,$22,$22)`,
p.ID, p.MindID, p.IntentionID, p.Revision, nullID(p.Supersedes), p.Objective, p.Origin, p.Strategy, p.Status, js(p.Assumptions, "[]"), p.CompletionCriteria, js(p.AbortConditions, "[]"), js(p.Alternatives, "[]"), p.Risk,
js(p.Estimate, "{}"), js(p.Budget, "{}"), js(p.Validation, "{}"), p.RequireApproval, p.MaxReplans, nullID(p.LLMCall), p.StatusReason, now); err != nil {
return Plan{}, storage.Translate(err)
}
for i, st := range d.Steps {
step := Step{ID: ids.New(), PlanID: p.ID, MindID: p.MindID, Key: st.Key, Position: i, Title: trunc(st.Title, 200), Kind: st.Kind, DependsOn: st.DependsOn, Capability: st.Capability, Target: st.Target, Params: st.Params,
RequireApproval: st.RequireApproval, Expectation: st.Expectation, Precondition: st.Precondition, Spec: st, Status: StepPending, MaxAttempts: st.MaxAttempts, CreatedAt: now, UpdatedAt: now}
if step.DependsOn == nil {
step.DependsOn = []string{}
}
if step.MaxAttempts <= 0 {
step.MaxAttempts = 1
}
if step.Params == nil {
step.Params = map[string]any{}
}
spec := st
spec.Params, spec.Expectation, spec.Precondition = nil, nil, nil
var exp, pre []byte
if st.Expectation != nil {
exp = js(st.Expectation, "null")
}
if st.Precondition != nil {
pre = js(st.Precondition, "null")
}
if _, err := r.Q.Exec(ctx, `INSERT INTO plan_steps (id, plan_id, mind_id, key, position, title, kind, depends_on, capability, target, params, require_approval, expectation, precondition, spec, status, max_attempts, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'pending',$16,$17,$17)`,
step.ID, p.ID, p.MindID, step.Key, step.Position, step.Title, step.Kind, step.DependsOn, step.Capability, step.Target, js(step.Params, "{}"), step.RequireApproval, exp, pre, js(spec, "{}"), step.MaxAttempts, now); err != nil {
return Plan{}, storage.Translate(err)
}
p.Steps = append(p.Steps, step)
}
evidence := []events.EvidenceRef{{Kind: "plan", ID: string(p.ID)}, {Kind: "intention", ID: string(it.ID)}}
if previous != nil {
evidence = append(evidence, events.EvidenceRef{Kind: "plan", ID: string(previous.ID), Note: "previous revision"})
if _, err := r.Q.Exec(ctx, `UPDATE plans SET status='superseded', superseded_by=$2, closed_at=coalesce(closed_at,$3), updated_at=$3 WHERE id=$1`, previous.ID, p.ID, now); err != nil {
return Plan{}, err
}
if _, err := emit(ctx, r, p.MindID, events.PlanReplanned, fmt.Sprintf("replanned (revision %d, %s): %s — previous plan kept as history", p.Revision, p.Strategy, p.Objective), 0.5, evidence,
map[string]any{"plan_id": string(p.ID), "previous_plan_id": string(previous.ID), "intention_id": string(it.ID), "revision": p.Revision, "strategy": p.Strategy}, map[string]any{"alternatives": p.Alternatives}); err != nil {
return Plan{}, err
}
}
if _, err := emit(ctx, r, p.MindID, events.PlanCreated, fmt.Sprintf("plan (%s, %d steps, %s): %s", p.Strategy, len(p.Steps), p.Origin, p.Objective), 0.4, evidence,
map[string]any{"plan_id": string(p.ID), "intention_id": string(it.ID), "revision": p.Revision, "origin": p.Origin, "strategy": p.Strategy, "steps": len(p.Steps)}, map[string]any{"objective": p.Objective, "estimate": p.Estimate, "alternatives": p.Alternatives, "ignored": d.Ignored}); err != nil {
return Plan{}, err
}
if p.Status == PlanValidated {
if _, err := emit(ctx, r, p.MindID, events.PlanValidated, fmt.Sprintf("plan validated: %d steps, dependency order %v", len(p.Steps), v.Order), 0.3, evidence,
map[string]any{"plan_id": string(p.ID), "warnings": len(v.Warnings)}, v); err != nil {
return Plan{}, err
}
}
return p, nil
}
const planCols = `id, mind_id, intention_id, revision, supersedes, superseded_by, objective, origin, strategy, status, assumptions, completion_criteria, abort_conditions, alternatives, risk, estimate, actual, budget, validation, require_approval, max_replans, llm_call, status_reason, closed_at, created_at, updated_at`
func scanPlan(row pgx.Row) (Plan, error) {
var p Plan
var sup, supBy, call *string
var assumptions, aborts, alts, est, act, budget, val []byte
if err := row.Scan(&p.ID, &p.MindID, &p.IntentionID, &p.Revision, &sup, &supBy, &p.Objective, &p.Origin, &p.Strategy, &p.Status, &assumptions, &p.CompletionCriteria, &aborts, &alts, &p.Risk, &est, &act, &budget, &val,
&p.RequireApproval, &p.MaxReplans, &call, &p.StatusReason, &p.ClosedAt, &p.CreatedAt, &p.UpdatedAt); err != nil {
return p, storage.Translate(err)
}
setID(&p.Supersedes, sup)
setID(&p.SupersededBy, supBy)
setID(&p.LLMCall, call)
json.Unmarshal(assumptions, &p.Assumptions) //nolint:errcheck
json.Unmarshal(aborts, &p.AbortConditions) //nolint:errcheck
json.Unmarshal(alts, &p.Alternatives) //nolint:errcheck
json.Unmarshal(est, &p.Estimate) //nolint:errcheck
json.Unmarshal(act, &p.Actual) //nolint:errcheck
json.Unmarshal(budget, &p.Budget) //nolint:errcheck
json.Unmarshal(val, &p.Validation) //nolint:errcheck
return p, nil
}
const stepCols = `id, plan_id, mind_id, key, position, title, kind, depends_on, capability, target, params, require_approval, expectation, precondition, spec, status, attempts, max_attempts, lease_until, next_run_at, approval_id, request_id, execution_id, prediction_id, intention_id, result, observation, error, status_reason, started_at, finished_at, created_at, updated_at`
func scanStep(row pgx.Row) (Step, error) {
var st Step
var params, exp, pre, spec, result, obs []byte
var appr, req, exec, pred, intention *string
if err := row.Scan(&st.ID, &st.PlanID, &st.MindID, &st.Key, &st.Position, &st.Title, &st.Kind, &st.DependsOn, &st.Capability, &st.Target, &params, &st.RequireApproval, &exp, &pre, &spec, &st.Status, &st.Attempts, &st.MaxAttempts,
&st.LeaseUntil, &st.NextRunAt, &appr, &req, &exec, &pred, &intention, &result, &obs, &st.Error, &st.StatusReason, &st.StartedAt, &st.FinishedAt, &st.CreatedAt, &st.UpdatedAt); err != nil {
return st, storage.Translate(err)
}
json.Unmarshal(params, &st.Params) //nolint:errcheck
if len(exp) > 0 && string(exp) != "null" {
st.Expectation = &Expectation{}
json.Unmarshal(exp, st.Expectation) //nolint:errcheck
}
if len(pre) > 0 && string(pre) != "null" {
st.Precondition = &Precondition{}
json.Unmarshal(pre, st.Precondition) //nolint:errcheck
}
json.Unmarshal(spec, &st.Spec) //nolint:errcheck
st.Spec.Params, st.Spec.Expectation, st.Spec.Precondition = st.Params, st.Expectation, st.Precondition
if len(result) > 0 {
json.Unmarshal(result, &st.Result) //nolint:errcheck
}
if len(obs) > 0 {
json.Unmarshal(obs, &st.Observation) //nolint:errcheck
}
setID(&st.ApprovalID, appr)
setID(&st.RequestID, req)
setID(&st.ExecutionID, exec)
setID(&st.PredictionID, pred)
setID(&st.IntentionID, intention)
return st, nil
}
// Get fetches a plan with its steps.
func (s *Store) Get(ctx context.Context, q storage.Querier, id ids.ID) (Plan, error) {
if q == nil {
q = s.db
}
p, err := scanPlan(q.QueryRow(ctx, `SELECT `+planCols+` FROM plans WHERE id=$1`, id))
if err != nil {
return p, err
}
p.Steps, err = s.Steps(ctx, q, id)
return p, err
}
// Steps lists a plan's steps in position order.
func (s *Store) Steps(ctx context.Context, q storage.Querier, plan ids.ID) ([]Step, error) {
if q == nil {
q = s.db
}
rows, err := q.Query(ctx, `SELECT `+stepCols+` FROM plan_steps WHERE plan_id=$1 ORDER BY position`, plan)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Step
for rows.Next() {
st, err := scanStep(rows)
if err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
// GetStep fetches one step.
func (s *Store) GetStep(ctx context.Context, id ids.ID) (Step, error) {
return scanStep(s.db.QueryRow(ctx, `SELECT `+stepCols+` FROM plan_steps WHERE id=$1`, id))
}
// ForIntention lists an intention's plans, oldest revision first.
func (s *Store) ForIntention(ctx context.Context, q storage.Querier, intention ids.ID) ([]Plan, error) {
if q == nil {
q = s.db
}
rows, err := q.Query(ctx, `SELECT `+planCols+` FROM plans WHERE intention_id=$1 ORDER BY revision`, intention)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Plan
for rows.Next() {
p, err := scanPlan(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// List lists a mind's plans by status (empty = all), newest first.
func (s *Store) List(ctx context.Context, mind ids.ID, statuses []string, limit int) ([]Plan, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
if statuses == nil {
statuses = []string{}
}
rows, err := s.db.Query(ctx, `SELECT `+planCols+` FROM plans WHERE mind_id=$1 AND (cardinality($2::text[])=0 OR status=ANY($2)) ORDER BY created_at DESC LIMIT $3`, mind, statuses, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Plan
for rows.Next() {
p, err := scanPlan(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ActiveMinds lists minds with open plans.
func (s *Store) ActiveMinds(ctx context.Context) ([]ids.ID, error) {
rows, err := s.db.Query(ctx, `SELECT DISTINCT p.mind_id FROM plans p JOIN minds m ON m.id=p.mind_id WHERE m.status='active' AND p.status IN ('validated','waiting','executing','blocked','awaiting_approval','replanning')`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ids.ID
for rows.Next() {
var id ids.ID
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// OpenPlans lists a mind's plans that may still make progress.
func (s *Store) OpenPlans(ctx context.Context, mind ids.ID) ([]Plan, error) {
return s.List(ctx, mind, []string{PlanValidated, PlanWaiting, PlanExecuting, PlanBlocked, PlanAwaitingApproval, PlanReplanning}, 200)
}
// Claim moves a step from one status to another only if it is still in the
// expected status, so two executors cannot both act on it. It returns
// whether this caller won.
func (s *Store) Claim(ctx context.Context, st *Step, from, to string, lease time.Duration) (bool, error) {
now := time.Now()
var leaseUntil *time.Time
if lease > 0 {
l := now.Add(lease)
leaseUntil = &l
}
attempts := 0
if to == StepExecuting {
attempts = 1
}
tag, err := s.db.Exec(ctx, `UPDATE plan_steps SET status=$3, attempts=attempts+$4, lease_until=$5, started_at=coalesce(started_at,$6), updated_at=$6 WHERE id=$1 AND status=$2`, st.ID, from, to, attempts, leaseUntil, now)
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, nil
}
st.Status, st.Attempts, st.LeaseUntil = to, st.Attempts+attempts, leaseUntil
if st.StartedAt == nil {
st.StartedAt = &now
}
return true, nil
}
// Save writes a step's mutable fields.
func (s *Store) Save(ctx context.Context, q storage.Querier, st Step) error {
if q == nil {
q = s.db
}
now := time.Now()
var finished *time.Time
if StepTerminal(st.Status) {
finished = &now
}
_, err := q.Exec(ctx, `UPDATE plan_steps SET status=$2, lease_until=$3, next_run_at=$4, approval_id=$5, request_id=$6, execution_id=$7, prediction_id=$8, intention_id=$9, result=$10, observation=$11, error=$12, status_reason=$13,
finished_at=coalesce($14, finished_at), updated_at=$15 WHERE id=$1`,
st.ID, st.Status, st.LeaseUntil, st.NextRunAt, nullID(st.ApprovalID), nullID(st.RequestID), nullID(st.ExecutionID), nullID(st.PredictionID), nullID(st.IntentionID), js(st.Result, "null"), js(st.Observation, "null"),
trunc(st.Error, 2000), trunc(st.StatusReason, 500), finished, now)
return err
}
// SetStatus writes a plan's status and reason.
func (s *Store) SetStatus(ctx context.Context, q storage.Querier, id ids.ID, status, reason string) error {
if q == nil {
q = s.db
}
now := time.Now()
var closed *time.Time
if !PlanOpen(status) && status != PlanDraft {
closed = &now
}
_, err := q.Exec(ctx, `UPDATE plans SET status=$2, status_reason=$3, closed_at=coalesce($4, closed_at), updated_at=$5 WHERE id=$1`, id, status, trunc(reason, 500), closed, now)
return err
}
// AddActual adds consumed resources to a plan and returns the new totals.
func (s *Store) AddActual(ctx context.Context, id ids.ID, delta Resources) (Resources, error) {
var raw []byte
err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `SELECT actual FROM plans WHERE id=$1 FOR UPDATE`, id).Scan(&raw); err != nil {
return err
}
var cur Resources
json.Unmarshal(raw, &cur) //nolint:errcheck
cur.Invocations += delta.Invocations
cur.Approvals += delta.Approvals
cur.WallSeconds += delta.WallSeconds
cur.Network += delta.Network
cur.Money += delta.Money
cur.Tokens += delta.Tokens
raw = js(cur, "{}")
_, err := tx.Exec(ctx, `UPDATE plans SET actual=$2, updated_at=now() WHERE id=$1`, id, raw)
return err
})
var out Resources
json.Unmarshal(raw, &out) //nolint:errcheck
return out, err
}
func trunc(s string, n int) string {
if len(s) > n {
return s[:n] + "…"
}
return s
}
func joinComma(xs []string) string {
out := ""
for i, x := range xs {
if i > 0 {
out += ", "
}
out += x
}
return out
}
+299
View File
@@ -0,0 +1,299 @@
// Package planning turns intentions into explicit, revisable plans and
// carries them out (docs/planning.md). A plan is a dependency graph of steps
// with prerequisites, capabilities, expected observations, risk, resource
// estimates, completion criteria, abort conditions and alternatives. Go code
// owns validation, dependency integrity, the capability vocabulary, policy
// checks (through the capability layer), resource accounting, execution
// state, retries, deadlines and approvals. A language model may propose a
// decomposition; it is parsed into the same structure and validated like any
// other draft, and it can never name a capability that is not registered.
//
// Plans are not promises and not authority. Every action goes through
// capabilities.Service.Invoke, and replanning creates a new revision while
// the old plan stays as history.
package planning
import (
"time"
"github.com/andydixon/noema/pkg/ids"
)
// Plan statuses.
const (
PlanDraft = "draft"
PlanValidated = "validated"
PlanWaiting = "waiting"
PlanExecuting = "executing"
PlanBlocked = "blocked"
PlanAwaitingApproval = "awaiting_approval"
PlanReplanning = "replanning"
PlanCompleted = "completed"
PlanFailed = "failed"
PlanAbandoned = "abandoned"
PlanSuperseded = "superseded"
)
// PlanOpen reports whether a plan may still make progress.
func PlanOpen(status string) bool {
switch status {
case PlanValidated, PlanWaiting, PlanExecuting, PlanBlocked, PlanAwaitingApproval, PlanReplanning:
return true
}
return false
}
// Step kinds.
const (
KindCapability = "capability"
KindRequestCapability = "request_capability"
KindVerify = "verify"
KindWait = "wait"
KindMonitor = "monitor"
KindInternal = "internal"
)
// Step statuses.
const (
StepPending = "pending"
StepReady = "ready"
StepExecuting = "executing"
StepAwaitingApproval = "awaiting_approval"
StepAwaitingGrant = "awaiting_grant"
StepWaiting = "waiting"
StepCompleted = "completed"
StepFailed = "failed"
StepSkipped = "skipped"
StepBlocked = "blocked"
StepCancelled = "cancelled"
)
// StepDone reports whether a step no longer blocks its dependants.
func StepDone(status string) bool { return status == StepCompleted || status == StepSkipped }
// StepTerminal reports whether a step will not change again by itself.
func StepTerminal(status string) bool {
return status == StepCompleted || status == StepSkipped || status == StepFailed || status == StepCancelled
}
// Check observes the world through a read-only capability and compares one
// value of its structured result.
type Check struct {
Capability string `json:"capability"`
Target string `json:"target,omitempty"`
Params map[string]any `json:"params,omitempty"`
Path string `json:"path"` // dot path into the result data, e.g. "active" or "properties.ActiveState"
Op string `json:"op"` // eq | ne | lt | lte | gt | gte | contains | exists
Value any `json:"value,omitempty"` // compared value
Description string `json:"description,omitempty"`
}
// Expectation is what a consequential step should bring about. It becomes a
// prediction when the step completes; a verify step resolves it.
type Expectation struct {
Description string `json:"description"`
Confidence float64 `json:"confidence"`
WithinSeconds int `json:"within_seconds"`
BeliefID ids.ID `json:"belief_id,omitempty"` // belief the expectation rests on; a miss is evidence against it
}
// Precondition must hold before a step acts. When it does not, the step is
// skipped: if SatisfiedIfFalse, because the desired state already holds.
type Precondition struct {
Check Check `json:"check"`
SatisfiedIfFalse bool `json:"satisfied_if_false"`
}
// RequestSpec is a structured capability request a step raises.
type RequestSpec struct {
Capability string `json:"capability"`
TargetPattern string `json:"target_pattern"`
ParamConstraints map[string]string `json:"param_constraints,omitempty"`
DurationSeconds int `json:"duration_seconds"`
MaxOperations *int `json:"max_operations,omitempty"`
Reason string `json:"reason"`
Risk string `json:"risk"`
Mitigation string `json:"mitigation"`
Fallback string `json:"fallback"`
Alternatives []string `json:"alternatives,omitempty"`
}
// VerifySpec resolves another step's expectation by observation.
type VerifySpec struct {
Of string `json:"of"` // key of the step whose expectation is checked
Check Check `json:"check"`
WithinSeconds int `json:"within_seconds"` // keep checking until this long after the action
IntervalSeconds int `json:"interval_seconds"` // between checks
}
// MonitorSpec repeats a check until it holds continuously for StableSeconds,
// or fails at DeadlineSeconds.
type MonitorSpec struct {
Check Check `json:"check"`
IntervalSeconds int `json:"interval_seconds"`
StableSeconds int `json:"stable_seconds"`
DeadlineSeconds int `json:"deadline_seconds"`
}
// WaitSpec pauses a branch.
type WaitSpec struct {
Seconds int `json:"seconds"`
}
// InternalSpec is a deterministic internal step.
type InternalSpec struct {
Action string `json:"action"` // summarise_findings | record_procedure
}
// InternalActions is the closed vocabulary of internal steps.
var InternalActions = map[string]string{
"summarise_findings": "collect the observations of the steps it depends on into one record for belief formation",
"record_procedure": "record the steps that achieved the outcome as a procedural memory",
}
// StepSpec is one step of a draft.
type StepSpec struct {
Key string `json:"key"`
Title string `json:"title"`
Kind string `json:"kind"`
DependsOn []string `json:"depends_on,omitempty"`
Capability string `json:"capability,omitempty"`
Target string `json:"target,omitempty"`
Params map[string]any `json:"params,omitempty"`
RequireApproval bool `json:"require_approval,omitempty"`
MaxAttempts int `json:"max_attempts,omitempty"`
Expectation *Expectation `json:"expectation,omitempty"`
Precondition *Precondition `json:"precondition,omitempty"`
Request *RequestSpec `json:"request,omitempty"`
Verify *VerifySpec `json:"verify,omitempty"`
Monitor *MonitorSpec `json:"monitor,omitempty"`
Wait *WaitSpec `json:"wait,omitempty"`
Internal *InternalSpec `json:"internal,omitempty"`
}
// Assumption is a belief the plan depends on. If the belief falls below
// MinConfidence the plan is reconsidered.
type Assumption struct {
BeliefID ids.ID `json:"belief_id,omitempty"`
Proposition string `json:"proposition"`
MinConfidence float64 `json:"min_confidence,omitempty"`
}
// Resources are estimated or consumed resources.
type Resources struct {
Invocations int `json:"capability_invocations"`
Approvals int `json:"human_approvals"`
WallSeconds float64 `json:"wall_seconds"`
Network int `json:"network_operations"`
Money float64 `json:"money"`
Tokens int `json:"llm_tokens"`
}
// Budget caps a plan's consumption. Zero fields are unlimited except
// Invocations, which defaults to 20 so no plan can loop without bound.
type Budget struct {
MaxInvocations int `json:"max_capability_invocations"`
MaxWallSeconds float64 `json:"max_wall_seconds,omitempty"`
MaxMoney float64 `json:"max_money,omitempty"`
MaxApprovals int `json:"max_human_approvals,omitempty"`
}
// Alternative is a strategy the planner considered.
type Alternative struct {
Strategy string `json:"strategy"`
Description string `json:"description"`
Estimate Resources `json:"estimate"`
ExpectedConfidence float64 `json:"expected_confidence"`
Score float64 `json:"score"`
Selected bool `json:"selected"`
Why string `json:"why,omitempty"`
}
// Draft is a proposed plan before it is stored.
type Draft struct {
Objective string `json:"objective"`
Origin string `json:"origin"` // template | model | operator | dream | replan
Strategy string `json:"strategy"`
Steps []StepSpec `json:"steps"`
Assumptions []Assumption `json:"assumptions,omitempty"`
CompletionCriteria string `json:"completion_criteria"`
AbortConditions []string `json:"abort_conditions,omitempty"`
Alternatives []Alternative `json:"alternatives,omitempty"`
Risk string `json:"risk"`
Budget Budget `json:"budget"`
RequireApproval bool `json:"require_approval,omitempty"`
LLMCall ids.ID `json:"llm_call,omitempty"`
Ignored []string `json:"ignored,omitempty"` // fields of a model proposal that were not accepted
Estimate *Resources `json:"estimate,omitempty"`
}
// Plan is a stored plan.
type Plan struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
IntentionID ids.ID `json:"intention_id"`
Revision int `json:"revision"`
Supersedes ids.ID `json:"supersedes,omitempty"`
SupersededBy ids.ID `json:"superseded_by,omitempty"`
Objective string `json:"objective"`
Origin string `json:"origin"`
Strategy string `json:"strategy"`
Status string `json:"status"`
Assumptions []Assumption `json:"assumptions"`
CompletionCriteria string `json:"completion_criteria"`
AbortConditions []string `json:"abort_conditions"`
Alternatives []Alternative `json:"alternatives"`
Risk string `json:"risk"`
Estimate Resources `json:"estimate"`
Actual Resources `json:"actual"`
Budget Budget `json:"budget"`
Validation Validation `json:"validation"`
RequireApproval bool `json:"require_approval"`
MaxReplans int `json:"max_replans"`
LLMCall ids.ID `json:"llm_call,omitempty"`
StatusReason string `json:"status_reason"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Steps []Step `json:"steps,omitempty"`
}
// Step is a stored step with its lifecycle state.
type Step struct {
ID ids.ID `json:"id"`
PlanID ids.ID `json:"plan_id"`
MindID ids.ID `json:"mind_id"`
Key string `json:"key"`
Position int `json:"position"`
Title string `json:"title"`
Kind string `json:"kind"`
DependsOn []string `json:"depends_on"`
Capability string `json:"capability"`
Target string `json:"target"`
Params map[string]any `json:"params"`
RequireApproval bool `json:"require_approval"`
Expectation *Expectation `json:"expectation,omitempty"`
Precondition *Precondition `json:"precondition,omitempty"`
Spec StepSpec `json:"spec"`
Status string `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
LeaseUntil *time.Time `json:"lease_until,omitempty"`
NextRunAt *time.Time `json:"next_run_at,omitempty"`
ApprovalID ids.ID `json:"approval_id,omitempty"`
RequestID ids.ID `json:"request_id,omitempty"`
ExecutionID ids.ID `json:"execution_id,omitempty"`
PredictionID ids.ID `json:"prediction_id,omitempty"`
IntentionID ids.ID `json:"intention_id,omitempty"`
Result map[string]any `json:"result,omitempty"`
Observation map[string]any `json:"observation,omitempty"`
Error string `json:"error"`
StatusReason string `json:"status_reason"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Origin returns the origin string recorded on invocations for this step.
func (s Step) Origin() string { return "plan_step:" + string(s.ID) }
+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)
}
+122
View File
@@ -0,0 +1,122 @@
package planning
import (
"strings"
"testing"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/internal/capabilities/host"
)
func testRegistry() *capabilities.Registry {
r := capabilities.NewRegistry()
for _, c := range capabilities.Builtins() {
r.Add(c)
}
for _, c := range host.Capabilities(host.Options{}) {
r.Add(c)
}
r.Freeze()
return r
}
func has(v Validation, part string) bool {
for _, e := range v.Errors {
if strings.Contains(e, part) {
return true
}
}
return false
}
func TestValidateRejectsBadPlans(t *testing.T) {
reg := testRegistry()
restart := func() StepSpec {
return StepSpec{Key: "P1", Title: "restart", Kind: KindCapability, Capability: "service.restart", Params: map[string]any{"service": "atlas-worker"},
Expectation: &Expectation{Description: "atlas healthy", WithinSeconds: 60}}
}
cases := map[string]struct {
steps []StepSpec
want string
}{
"cycle": {[]StepSpec{{Key: "A", Title: "a", Kind: KindWait, Wait: &WaitSpec{Seconds: 1}, DependsOn: []string{"B"}}, {Key: "B", Title: "b", Kind: KindWait, Wait: &WaitSpec{Seconds: 1}, DependsOn: []string{"A"}}}, "cycle"},
"self": {[]StepSpec{{Key: "A", Title: "a", Kind: KindWait, Wait: &WaitSpec{Seconds: 1}, DependsOn: []string{"A"}}}, "depends on itself"},
"missing dependency": {[]StepSpec{{Key: "A", Title: "a", Kind: KindWait, Wait: &WaitSpec{Seconds: 1}, DependsOn: []string{"Z"}}}, "not a step"},
"invented capability": {[]StepSpec{{Key: "A", Title: "a", Kind: KindCapability, Capability: "shell.exec", Params: map[string]any{"cmd": "id"}}}, "not a registered capability"},
"locked capability": {[]StepSpec{{Key: "A", Title: "a", Kind: KindCapability, Capability: "sudo_shell", Params: map[string]any{"command": "id"}}}, "locked"},
"bad params": {[]StepSpec{{Key: "A", Title: "a", Kind: KindCapability, Capability: "service.restart", Params: map[string]any{"service": "atlas", "force": true}}}, "unknown parameter"},
"target mismatch": {[]StepSpec{{Key: "A", Title: "a", Kind: KindCapability, Capability: "service.restart", Target: "db.service", Params: map[string]any{"service": "atlas"}}}, "does not match"},
"invented kind": {[]StepSpec{{Key: "A", Title: "a", Kind: "teleport"}}, "not recognised"},
"duplicate key": {[]StepSpec{{Key: "A", Title: "a", Kind: KindWait, Wait: &WaitSpec{Seconds: 1}}, {Key: "A", Title: "b", Kind: KindWait, Wait: &WaitSpec{Seconds: 1}}}, "duplicate"},
"verify without expectation": {[]StepSpec{{Key: "P1", Title: "status", Kind: KindCapability, Capability: "service.status", Params: map[string]any{"service": "a"}},
{Key: "V1", Title: "v", Kind: KindVerify, DependsOn: []string{"P1"}, Verify: &VerifySpec{Of: "P1", Check: Check{Capability: "service.status", Params: map[string]any{"service": "a"}, Path: "active", Op: "eq", Value: true}}}}, "no expectation"},
"verify not dependent": {[]StepSpec{restart(), {Key: "V1", Title: "v", Kind: KindVerify, Verify: &VerifySpec{Of: "P1", Check: Check{Capability: "service.status", Params: map[string]any{"service": "a"}, Path: "active", Op: "eq", Value: true}}}}, "must depend"},
"check with a write capability": {[]StepSpec{restart(), {Key: "V1", Title: "v", Kind: KindVerify, DependsOn: []string{"P1"}, Verify: &VerifySpec{Of: "P1", Check: Check{Capability: "service.restart", Params: map[string]any{"service": "a"}, Path: "unit", Op: "exists"}}}}, "read-only"},
"internal outside vocabulary": {[]StepSpec{{Key: "I", Title: "i", Kind: KindInternal, Internal: &InternalSpec{Action: "grant_self_access"}}}, "internal action"},
"empty": {nil, "1–30 steps"},
}
for name, c := range cases {
d := Draft{Objective: "x", Origin: "model", Steps: c.steps}
v := Validate(&d, reg)
if v.Valid || !has(v, c.want) {
t.Errorf("%s: valid=%v errors=%v (want %q)", name, v.Valid, v.Errors, c.want)
}
}
d := Draft{Objective: "restart atlas", Origin: "template", Steps: []StepSpec{restart(), {Key: "V1", Title: "verify", Kind: KindVerify, DependsOn: []string{"P1"},
Verify: &VerifySpec{Of: "P1", Check: Check{Capability: "service.status", Params: map[string]any{"service": "atlas-worker"}, Path: "active", Op: "eq", Value: true}}}}}
d.Steps[0].MaxAttempts = 4
v := Validate(&d, reg)
if !v.Valid || strings.Join(v.Order, ",") != "P1,V1" || d.Steps[0].MaxAttempts != 1 || d.Steps[0].Target != "atlas-worker.service" || d.Budget.MaxInvocations != 20 {
t.Fatalf("valid plan: %+v %+v", v, d.Steps[0])
}
}
func TestModelProposalCannotCarryAuthority(t *testing.T) {
raw := `Here is my plan: {"objective":"fix atlas","authorised":true,"policy":{"service.restart":"allow"},
"steps":[{"key":"P1","title":"restart","kind":"capability","capability":"service.restart","params":{"service":"atlas"},"status":"completed","approved":true,"require_approval":false},
{"key":"P2","title":"grant myself shell","kind":"capability","capability":"shell.exec","params":{"cmd":"sudo -i"}}]}`
d, err := ParseProposal(raw)
if err != nil {
t.Fatal(err)
}
for _, f := range []string{"authorised", "policy", "steps[0].approved", "steps[0].status"} {
found := false
for _, g := range d.Ignored {
found = found || g == f
}
if !found {
t.Errorf("field %s not reported as ignored: %v", f, d.Ignored)
}
}
d.Origin = "model"
v := Validate(&d, testRegistry())
if v.Valid || !has(v, "shell.exec") {
t.Fatalf("invented capability accepted: %+v", v)
}
if _, err := ParseProposal("I restarted it already, all good."); err == nil {
t.Fatal("prose accepted as a plan")
}
}
func TestEvaluate(t *testing.T) {
data := map[string]any{"active": true, "properties": map[string]string{"ActiveState": "failed"}, "value": 0.04, "count": "12"}
for _, c := range []struct {
ck Check
want bool
}{
{Check{Path: "active", Op: "eq", Value: true}, true},
{Check{Path: "properties.ActiveState", Op: "eq", Value: "active"}, false},
{Check{Path: "value", Op: "lt", Value: 0.05}, true},
{Check{Path: "count", Op: "gte", Value: 12.0}, true},
{Check{Path: "missing", Op: "exists"}, false},
{Check{Path: "properties.ActiveState", Op: "contains", Value: "FAIL"}, true},
} {
got, _, err := Evaluate(c.ck, data)
if got != c.want || (err != nil && c.ck.Op != "exists") {
t.Errorf("%+v: %v %v", c.ck, got, err)
}
}
if _, _, err := Evaluate(Check{Path: "nope", Op: "eq", Value: 1}, data); err == nil {
t.Fatal("missing path should be an error, not false")
}
}
+10
View File
@@ -40,6 +40,8 @@ const (
// event summary of the given types within the window.
// belief: the referenced belief's confidence reaches min_confidence by the window end.
// operator: only an operator can resolve it.
// verification: a plan step's expected outcome, resolved by the planning
// executor from a read-only observation after the action.
type Criteria struct {
Kind string `json:"kind"`
Expect string `json:"expect,omitempty"` // occurs | absent
@@ -140,6 +142,9 @@ func (n *New) validate() error {
if n.Criteria.BeliefID.IsZero() || n.Criteria.MinConfidence <= 0 {
return fmt.Errorf("%w: belief criteria need belief_id and min_confidence", ErrInvalid)
}
case "verification":
// An expectation attached to a plan step; the planning executor
// resolves it by observing the world after the action.
case "operator", "":
n.Criteria.Kind = "operator"
default:
@@ -312,6 +317,11 @@ func Judge(p Prediction, now time.Time, window []events.Event, beliefConfidence
return Outcome{Status: StatusRefuted, Evidence: []events.EvidenceRef{{Kind: "belief", ID: string(p.Criteria.BeliefID)}}, Reason: "window closed below the confidence threshold"}, true
}
return Outcome{}, false
case "verification":
if now.After(p.WindowEnd.Add(time.Hour)) {
return Outcome{Status: StatusExpired, Reason: "no verification was performed within an hour of the window"}, true
}
return Outcome{}, false
default:
if now.After(p.WindowEnd.Add(7 * 24 * time.Hour)) {
return Outcome{Status: StatusExpired, Reason: "operator did not resolve it within a week of the window"}, true
+8 -3
View File
@@ -760,7 +760,9 @@ type Trigger struct {
Action *ActionInfo
// Commitment is set for commitment triggers.
Commitment *Commitment
// OutcomeObserved is set for world_changed: the desired state already holds.
// OutcomeObserved is set for world_changed when the desired state already
// holds, and for plan_completed when the plan's completion means the
// desired outcome was reached.
OutcomeObserved bool
}
@@ -805,6 +807,9 @@ func Reconsider(it Intention, t Trigger, rc ReconsiderContext) Reconsideration {
}
switch t.Kind {
case TriggerPlanCompleted:
if !t.OutcomeObserved {
return Reconsideration{Outcome: Abandon, Reason: "the plan finished without achieving the desired outcome (" + firstNonEmpty(t.Detail, "fallback strategy") + "); it has been handed to the operator"}
}
return Reconsideration{Outcome: Fulfil, Reason: "the plan completed and its checks passed"}
case TriggerGoalCompleted:
if it.Kind == IntentPursue || it.Kind == IntentInvestigate {
@@ -818,8 +823,8 @@ func Reconsider(it Intention, t Trigger, rc ReconsiderContext) Reconsideration {
case TriggerApprovalDenied:
return replanOr("the operator denied the request (" + firstNonEmpty(t.Detail, "no reason given") + ")")
case TriggerApprovalExpired:
if it.Status == IntentionBlocked && it.BlockedKind == BlockApproval && it.Reconsiderations > 0 {
return Reconsideration{Outcome: Suspend, Reason: "approval expired again without a decision; waiting rather than asking repeatedly"}
if rc.Replans > 0 {
return Reconsideration{Outcome: Suspend, Reason: "approval expired again without a decision; waiting rather than asking the operator repeatedly"}
}
return replanOr("the approval request expired before anyone decided")
case TriggerCapabilityChanged:
+4 -1
View File
@@ -278,7 +278,10 @@ func TestReconsider(t *testing.T) {
check(Trigger{Kind: TriggerCapabilityChanged, Action: restartAction("deny")}, ReconsiderContext{}, Replan)
check(Trigger{Kind: TriggerCapabilityChanged, Action: restartAction("ask")}, ReconsiderContext{}, Block)
check(Trigger{Kind: TriggerResourceExhausted, Detail: "capability invocations"}, ReconsiderContext{}, Suspend)
check(Trigger{Kind: TriggerPlanCompleted}, ReconsiderContext{}, Fulfil)
check(Trigger{Kind: TriggerApprovalExpired}, ReconsiderContext{}, Replan)
check(Trigger{Kind: TriggerApprovalExpired}, ReconsiderContext{Replans: 1}, Suspend)
check(Trigger{Kind: TriggerPlanCompleted, OutcomeObserved: true}, ReconsiderContext{}, Fulfil)
check(Trigger{Kind: TriggerPlanCompleted, Detail: "escalated"}, ReconsiderContext{}, Abandon)
check(Trigger{Kind: TriggerGoalCompleted}, ReconsiderContext{}, Abandon)
check(Trigger{Kind: TriggerWorldChanged, OutcomeObserved: true, Detail: "atlas already healthy"}, ReconsiderContext{}, Fulfil)
check(Trigger{Kind: TriggerCommitmentChanged, Commitment: &noProd, Action: restartAction("allow")}, ReconsiderContext{}, Tighten)