Files
noema/internal/planning/planner.go
T
Andy Dixon 03e2d7890a feat(cognition): wanting, intending and acting inside the cognitive cycle
Volition and planning now run as part of cognition (PLAN §8.2 item 3):

- A volition hook derives wants after each cycle from what it perceived
  and produced, satisfies answer wants when a reply is sent, raises due,
  overdue, expired and conflicting commitments, and expires faded wants.
  Events produced by earlier cycles are not re-derived.
- An intend process proposes deciding about the strongest undecided
  wants; the candidate competes for the workspace, so attention gates
  what the mind decides to pursue. The handler makes value tensions
  explicit (caution against disruptive writes, privacy against reading
  personal data), resolves them with recorded scores, runs deterministic
  formation with commitments, policy preview, goals and values, records
  the decision whatever it is, and plans a formed intention in the same
  transaction. Operator requests carry their own weight in formation.
- Explicit requests in conversation ("restart <unit>", "logs for <unit>",
  "fetch <url>") become wants with proposed means instead of direct
  invocations; the reply says the request was recorded and that acting
  depends on commitments and policy.
- The executor is woken after commits, runs in a scheduler-owned loop and
  reacts to the event bus: a new commitment tightens or abandons affected
  intentions (withdrawing their pending approvals), a weakened assumption
  or a finished goal triggers reconsideration. A tightened plan may reuse
  its strategy; a failed one may not.
- Metacognition gains volition checks: strong wants repeatedly set aside,
  intentions with no recorded reason, intentions a commitment forbids,
  commitments that cannot be kept, churn, repeated failed execution,
  cost outweighing value, and intentions that outlived their goal.
- A plan model role and prompt let a model propose decompositions that
  the planner validates. Ablations are a fork-only configuration field.
- Approvals required by an intention are described as coming from a
  commitment or value constraint. The expectation after an approved
  action is linked to the execution event, not the request.
- The wants.values column of the unreleased migration 0026 is renamed
  value_keys: VALUES is reserved and the generic snapshot restore does
  not quote identifiers.
- The hostile-output security test now uses a message that asks for no
  action, because an operator's "status of <unit>" is legitimately a
  read-only status check.
2026-09-15 21:04:07 +01:00

393 lines
16 KiB
Go

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 != "model" {
chosen.Origin = "replan"
}
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 }