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.
This commit is contained in:
@@ -155,11 +155,22 @@ func (e *Executor) TickMind(ctx context.Context, mind ids.ID) error {
|
||||
// validate, store and link. It acts on nothing; the executor picks the plan
|
||||
// up after the transaction commits.
|
||||
func (e *Executor) Start(ctx context.Context, r volition.Recorder, it volition.Intention) (Plan, error) {
|
||||
return e.start(ctx, r, it, false)
|
||||
}
|
||||
|
||||
// start plans an intention. When fresh, earlier revisions do not exclude
|
||||
// their strategies: the plan is being redone under new constraints (a
|
||||
// commitment now requires approval), not because the strategy failed.
|
||||
func (e *Executor) start(ctx context.Context, r volition.Recorder, it volition.Intention, fresh bool) (Plan, error) {
|
||||
previous, err := e.Plans.ForIntention(ctx, r.Q, it.ID)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
d, err := e.Planner.Propose(ctx, it, previous)
|
||||
considered := previous
|
||||
if fresh {
|
||||
considered = nil
|
||||
}
|
||||
d, err := e.Planner.Propose(ctx, it, considered)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
@@ -369,7 +380,7 @@ func (e *Executor) explain(p Plan, it volition.Intention, st Step, c capabilitie
|
||||
fmt.Fprintf(&b, " Alternatives considered: %s.", strings.Join(alts, ", "))
|
||||
}
|
||||
if st.RequireApproval || (it.RequireApproval && c.Access == capabilities.AccessWrite) {
|
||||
b.WriteString(" A commitment requires approval for this action.")
|
||||
b.WriteString(" A commitment or value constraint requires approval for this action.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -510,10 +521,11 @@ func (e *Executor) pollApproval(ctx context.Context, p *Plan, it volition.Intent
|
||||
if err := e.resumeParent(ctx, p, it, st, "operator "+a.DecisionBy+" approved"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
cause := a.EventID
|
||||
execs, _ := e.Caps.ExecutionsByOrigin(ctx, p.MindID, st.Origin())
|
||||
for _, x := range execs {
|
||||
if x.ApprovalID == a.ID {
|
||||
st.ExecutionID = x.ID
|
||||
st.ExecutionID, cause = x.ID, x.EventID // the expectation follows from the execution, not the request
|
||||
}
|
||||
}
|
||||
if a.Status == "failed" {
|
||||
@@ -524,7 +536,7 @@ func (e *Executor) pollApproval(ctx context.Context, p *Plan, it volition.Intent
|
||||
json.Unmarshal(a.Result, &res) //nolint:errcheck
|
||||
}
|
||||
it, _ = e.Volition.GetIntention(ctx, nil, p.MindID, it.ID)
|
||||
return e.completeAction(ctx, p, it, st, res, a.EventID)
|
||||
return e.completeAction(ctx, p, it, st, res, cause)
|
||||
case "denied":
|
||||
e.dropSubordinate(ctx, p, st, "operator denied the approval")
|
||||
return true, e.fail(ctx, p, st, "operator "+a.DecisionBy+" denied: "+a.DecisionNote, volition.TriggerApprovalDenied, nil)
|
||||
@@ -796,7 +808,7 @@ func (e *Executor) reconsider(ctx context.Context, p Plan, it volition.Intention
|
||||
return err
|
||||
}
|
||||
e.cancelOpenSteps(ctx, p, "replanning: "+rc.Reason)
|
||||
np, err := e.Start(ctx, r, cur)
|
||||
np, err := e.start(ctx, r, cur, rc.Outcome == volition.Tighten)
|
||||
if err != nil {
|
||||
reason := "no alternative plan remains: " + err.Error()
|
||||
if _, aerr := e.Volition.Apply(ctx, r, p.MindID, it.ID, volition.Reconsideration{Outcome: volition.Abandon, Reason: reason}, volition.Trigger{Kind: volition.TriggerReplanLimit, Detail: err.Error()}); aerr != nil && !errors.Is(aerr, volition.ErrClosed) {
|
||||
|
||||
@@ -203,7 +203,7 @@ func TestApprovedActionIsVerifiedAndLearnt(t *testing.T) {
|
||||
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() {
|
||||
if !strings.Contains(a.Reason, "Expected:") || !strings.Contains(a.Reason, "Side effects:") || !strings.Contains(a.Reason, "requires approval for this action") || 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
|
||||
|
||||
@@ -97,8 +97,8 @@ func (p *Planner) Propose(ctx context.Context, it volition.Intention, previous [
|
||||
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"]
|
||||
if len(previous) > 0 && chosen.Origin != "model" {
|
||||
chosen.Origin = "replan"
|
||||
}
|
||||
return *chosen, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package planning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/internal/volition"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// React reconsiders open intentions and plans when something they depend on
|
||||
// changes: a new commitment, a weakened or retracted assumption, a goal
|
||||
// completed or abandoned. It is deterministic, runs outside cognitive
|
||||
// cycles (from the event bus), and never acts; replanning may follow.
|
||||
func (e *Executor) React(ctx context.Context, ev events.Event) error {
|
||||
switch ev.Type {
|
||||
case events.CommitmentCreated, events.BeliefUpdated, events.BeliefRetracted, events.BeliefChallenged, events.GoalCompleted, events.GoalAbandoned:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
m, err := e.Minds.Get(ctx, ev.MindID)
|
||||
if err != nil || m.Status != "active" {
|
||||
return err
|
||||
}
|
||||
open, err := e.Volition.Open(ctx, nil, ev.MindID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta := func(k string) string { s, _ := ev.Metadata[k].(string); return s }
|
||||
for _, it := range open {
|
||||
var t *volition.Trigger
|
||||
switch ev.Type {
|
||||
case events.CommitmentCreated:
|
||||
if m.Config.Ablated("commitments") {
|
||||
return nil
|
||||
}
|
||||
cm, err := e.Volition.GetCommitment(ctx, nil, ev.MindID, ids.ID(meta("commitment_id")))
|
||||
if err != nil || it.Proposal == nil || it.Proposal.Kind != "capability" {
|
||||
continue
|
||||
}
|
||||
a := e.actionInfo(ctx, it)
|
||||
t = &volition.Trigger{Kind: volition.TriggerCommitmentChanged, EventID: ev.ID, Commitment: &cm, Action: a, Detail: cm.Description}
|
||||
case events.GoalCompleted, events.GoalAbandoned:
|
||||
if string(it.GoalID) != meta("goal_id") || it.GoalID.IsZero() {
|
||||
continue
|
||||
}
|
||||
kind := volition.TriggerGoalCompleted
|
||||
if ev.Type == events.GoalAbandoned {
|
||||
kind = volition.TriggerGoalAbandoned
|
||||
}
|
||||
t = &volition.Trigger{Kind: kind, EventID: ev.ID, Detail: ev.Summary}
|
||||
default: // belief changes: only plans that assume the belief care
|
||||
bid := meta("belief_id")
|
||||
if bid == "" || it.PlanID.IsZero() || e.Resolver == nil || e.Resolver.Beliefs == nil {
|
||||
continue
|
||||
}
|
||||
p, err := e.Plans.Get(ctx, nil, it.PlanID)
|
||||
if err != nil || !PlanOpen(p.Status) {
|
||||
continue
|
||||
}
|
||||
for _, a := range p.Assumptions {
|
||||
if string(a.BeliefID) != bid {
|
||||
continue
|
||||
}
|
||||
b, err := e.Resolver.Beliefs.Get(ctx, nil, ev.MindID, a.BeliefID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if b.Confidence < a.MinConfidence || b.Status == "rejected" || b.Status == "superseded" {
|
||||
t = &volition.Trigger{Kind: volition.TriggerBeliefWeakened, EventID: ev.ID, Detail: "an assumption no longer holds: \"" + b.Proposition + "\" is " + string(b.Status)}
|
||||
}
|
||||
}
|
||||
}
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
if err := e.apply(ctx, it, *t); err != nil && !errors.Is(err, volition.ErrClosed) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Executor) apply(ctx context.Context, it volition.Intention, t volition.Trigger) error {
|
||||
if !it.PlanID.IsZero() {
|
||||
if p, err := e.Plans.Get(ctx, nil, it.PlanID); err == nil && PlanOpen(p.Status) {
|
||||
return e.reconsider(ctx, p, it, t)
|
||||
}
|
||||
}
|
||||
rc := volition.Reconsider(it, t, volition.ReconsiderContext{Now: e.now()})
|
||||
if rc.Outcome == volition.Keep {
|
||||
return nil
|
||||
}
|
||||
_, err := e.Volition.Apply(ctx, e.rec(), it.MindID, it.ID, rc, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Executor) actionInfo(ctx context.Context, it volition.Intention) *volition.ActionInfo {
|
||||
p := it.Proposal
|
||||
a := &volition.ActionInfo{Capability: p.Capability, Target: p.Target, Effect: "unknown"}
|
||||
c, ok := e.Caps.Registry.Get(p.Capability)
|
||||
if !ok {
|
||||
return a
|
||||
}
|
||||
a.Access, a.Risk, a.Idempotent, a.SideEffects = string(c.Access), string(c.Risk), c.Idempotent, c.SideEffects
|
||||
if t, err := c.ResolveTarget("", p.Params); err == nil && t != "" {
|
||||
a.Target = t
|
||||
}
|
||||
if c.Locked {
|
||||
a.Effect = "locked"
|
||||
return a
|
||||
}
|
||||
d, err := e.Caps.Preview(ctx, capabilities.Invocation{MindID: it.MindID, Capability: p.Capability, Params: p.Params})
|
||||
if err != nil {
|
||||
a.Effect = "deny"
|
||||
return a
|
||||
}
|
||||
a.Effect = strings.ToLower(string(d.Effect))
|
||||
return a
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package planning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/internal/llm"
|
||||
"github.com/andydixon/noema/internal/llm/prompts"
|
||||
"github.com/andydixon/noema/internal/llm/router"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// PolicyEffect previews the policy effect of a request through the
|
||||
// capability layer, for the planner's choice of route. It confers nothing.
|
||||
func PolicyEffect(caps *capabilities.Service) func(ctx context.Context, mind ids.ID, capability, target string, params map[string]any) string {
|
||||
return func(ctx context.Context, mind ids.ID, capability, target string, params map[string]any) string {
|
||||
d, err := caps.Preview(ctx, capabilities.Invocation{MindID: mind, Capability: capability, Target: target, Params: params})
|
||||
if err != nil {
|
||||
return "deny"
|
||||
}
|
||||
if d.Locked {
|
||||
return "locked"
|
||||
}
|
||||
return string(d.Effect)
|
||||
}
|
||||
}
|
||||
|
||||
// ModelPlanner asks the model assigned to the plan role for a decomposition.
|
||||
// The response is parsed and validated by the planner like any draft.
|
||||
func ModelPlanner(r *router.Router, ps *prompts.Store) func(ctx context.Context, mind ids.ID, objective string, vocabulary []capabilities.Capability) (string, ids.ID, error) {
|
||||
return func(ctx context.Context, mind ids.ID, objective string, vocabulary []capabilities.Capability) (string, ids.ID, error) {
|
||||
if r == nil || ps == nil || !r.Available(ctx, llm.RolePlan) {
|
||||
return "", ids.Nil, errors.New("no model is assigned to the plan role")
|
||||
}
|
||||
pr, err := ps.Get(ctx, "plan")
|
||||
if err != nil {
|
||||
return "", ids.Nil, err
|
||||
}
|
||||
var usable []capabilities.Capability
|
||||
for _, c := range vocabulary {
|
||||
if !c.Locked {
|
||||
usable = append(usable, c)
|
||||
}
|
||||
}
|
||||
req, err := pr.Render(map[string]any{"Objective": objective, "Capabilities": usable})
|
||||
if err != nil {
|
||||
return "", ids.Nil, err
|
||||
}
|
||||
req.MindID, req.Purpose = mind, "plan"
|
||||
resp, err := r.Complete(ctx, req)
|
||||
if err != nil {
|
||||
return "", ids.Nil, err
|
||||
}
|
||||
raw := string(resp.JSON)
|
||||
if raw == "" {
|
||||
raw = resp.Text
|
||||
}
|
||||
return raw, resp.CallID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// WakeOn returns an after-commit function that wakes the executor when a
|
||||
// cycle formed, resumed or planned an intention.
|
||||
func (e *Executor) WakeOn() func(mind ids.ID, emitted []events.Event) {
|
||||
return func(_ ids.ID, emitted []events.Event) {
|
||||
for _, ev := range emitted {
|
||||
switch ev.Type {
|
||||
case events.PlanCreated, events.IntentionFormed, events.IntentionResumed, events.PlanReplanned:
|
||||
e.Wake()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen reacts to events from a bus subscription until ctx ends, and wakes
|
||||
// the executor for decisions made outside cycles (approvals, grants).
|
||||
func (e *Executor) Listen(ctx context.Context, ch <-chan events.Event) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch ev.Type {
|
||||
case events.ToolAuthorised, events.ToolDenied, events.ToolExecuted, events.ToolFailed:
|
||||
e.Wake()
|
||||
}
|
||||
if err := e.React(ctx, ev); err != nil && e.Log != nil {
|
||||
e.Log.Warn("plan reaction failed", "event", ev.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user