Propose built every strategy to record the alternatives, and the model strategy's build is a live call to the plan role, so planning a direct action spent a model call on an alternative that could never be selected (B2, docs/planning.md). The model is now recorded as "not consulted" once a deterministic draft is chosen, and the approver's reason no longer lists it among the alternatives considered. A test counts the calls both ways.
400 lines
16 KiB
Go
400 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
|
|
}
|
|
if s.name == "model" && chosen != nil {
|
|
// A model is consulted only when no deterministic strategy
|
|
// applies; building it here would spend a live call on an
|
|
// alternative that cannot be selected.
|
|
alts = append(alts, Alternative{Strategy: s.name, Description: "model decomposition", ExpectedConfidence: s.confidence, Why: "not consulted: a deterministic strategy applies"})
|
|
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 }
|