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:
@@ -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 {
|
||||
|
||||
@@ -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, ¶ms, &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, ¶ms, &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, ¶ms, &result, &e.Error, &e.DurationMS, &appr, &ev, &e.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&e.ID, &e.MindID, &e.Capability, &e.Target, ¶ms, &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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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, ¶ms, &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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user