diff --git a/internal/cognition/goals.go b/internal/cognition/goals.go index 60aec7c..b2e44d9 100644 --- a/internal/cognition/goals.go +++ b/internal/cognition/goals.go @@ -44,6 +44,21 @@ func (g GoalMonitor) Observe(ctx context.Context, s *brain.State) ([]brain.Candi Features: attention.Features{Salience: 0.4, GoalRelevance: gl.Priority, Consequence: 0.5 * gl.Priority, Urgency: 0.3, Confidence: 1}, Confidence: 1, Evidence: []events.EvidenceRef{{Kind: "goal", ID: string(gl.ID)}}}) } + // A goal an operator just set deserves attention of its own. + for _, ev := range s.NewEvents { + if ev.Type != events.GoalCreated || ev.Source != "operator" { + continue + } + gid, _ := ev.Metadata["goal_id"].(string) + for _, gl := range s.Goals { + if string(gl.ID) == gid { + out = append(out, brain.Candidate{Kind: "goal", Subject: string(gl.ID) + ":new", Summary: "new goal from an operator: " + gl.Title, + Content: map[string]any{"goal_id": gid, "reason": "set by an operator", "ref": gid, "source_event": string(ev.ID)}, + Features: attention.Features{Salience: 0.7, GoalRelevance: gl.Priority, Consequence: 0.6, Urgency: 0.5, Confidence: 1, OperatorPriority: 1}, Confidence: 1, + SourceEvents: []ids.ID{ev.ID}, Evidence: []events.EvidenceRef{{Kind: "goal", ID: gid}, {Kind: "event", ID: string(ev.ID)}}}) + } + } + } // New external events touching a goal's subject. for _, ev := range s.NewEvents { if strings.HasPrefix(ev.Source, "brain") || ev.Type != events.Observation { diff --git a/internal/cognition/volition.go b/internal/cognition/volition.go index ffc178b..e04bf0a 100644 --- a/internal/cognition/volition.go +++ b/internal/cognition/volition.go @@ -374,13 +374,14 @@ func (h Handlers) intend(ctx context.Context, c *brain.Cycle, t workspace.Though if w.Proposal != nil && w.Proposal.Capability != "" { required = []string{w.Proposal.Capability} } + beliefIDs := supportingBeliefs(ctx, s, w) expected := w.Description if in.Action != nil { expected = "the effect of " + in.Action.Capability + " on " + in.Action.Target + " is observed" } terms := map[string]any{"score": f.Score, "terms": f.Terms, "rationale": f.Rationale, "applied_commitments": f.Applied, "constrained": in.Constrained} it, err := vs.CreateIntention(ctx, r, volition.Intention{MindID: s.Mind.ID, Description: f.Description, DesiredOutcome: f.DesiredOutcome, Kind: f.Kind, SourceWants: []ids.ID{w.ID}, GoalID: w.GoalID, CommitmentID: w.CommitmentID, - ExpectedResult: expected, Confidence: in.Confidence, Priority: f.Priority, RequireApproval: f.RequireApproval, Proposal: w.Proposal, RequiredCapabilities: required, Formation: terms}) + ExpectedResult: expected, Confidence: in.Confidence, Priority: f.Priority, RequireApproval: f.RequireApproval, Proposal: w.Proposal, RequiredCapabilities: required, Formation: terms, BeliefIDs: beliefIDs}) if err != nil { return err } @@ -477,3 +478,31 @@ func correctSelfModel(ctx context.Context, c *brain.Cycle, a volition.ActionInfo } return nil } + +// supportingBeliefs links the beliefs an intention's action rests on: live +// beliefs that mention the proposal's target and are held with at least +// moderate confidence. A plan's expectation cites the first, so a failed +// expectation is recorded as evidence against it. +func supportingBeliefs(ctx context.Context, s *brain.State, w volition.Want) []ids.ID { + if s.Services.Beliefs == nil || w.Proposal == nil { + return nil + } + topic := strings.TrimSuffix(firstNonEmpty(w.Proposal.Target, w.Proposal.Subject), ".service") + if topic == "" { + return nil + } + bs, err := s.Services.Beliefs.Search(ctx, s.Mind.ID, topic, 5) + if err != nil { + return nil + } + var out []ids.ID + for _, b := range bs { + if len(out) == 3 { + break + } + if b.Confidence >= 0.4 && b.Status != "rejected" && b.Status != "superseded" && b.Status != "unknown" && strings.Contains(strings.ToLower(b.Proposition), strings.ToLower(topic)) { + out = append(out, b.ID) + } + } + return out +} diff --git a/internal/planning/react.go b/internal/planning/react.go index e623404..8757421 100644 --- a/internal/planning/react.go +++ b/internal/planning/react.go @@ -31,6 +31,9 @@ func (e *Executor) React(ctx context.Context, ev events.Event) error { } meta := func(k string) string { s, _ := ev.Metadata[k].(string); return s } for _, it := range open { + if !it.ParentID.IsZero() { + continue // a subordinate intention shares its parent's plan; the parent is reconsidered + } var t *volition.Trigger switch ev.Type { case events.CommitmentCreated: @@ -86,7 +89,7 @@ func (e *Executor) React(ctx context.Context, ev events.Event) error { 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) { + if p, err := e.Plans.Get(ctx, nil, it.PlanID); err == nil && PlanOpen(p.Status) && p.IntentionID == it.ID { return e.reconsider(ctx, p, it, t) } } diff --git a/tests/agency/failure_test.go b/tests/agency/failure_test.go new file mode 100644 index 0000000..8dddf91 --- /dev/null +++ b/tests/agency/failure_test.go @@ -0,0 +1,89 @@ +package agency + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/andydixon/noema/internal/beliefs" + "github.com/andydixon/noema/internal/planning" + "github.com/andydixon/noema/internal/volition" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/tests/harness" +) + +// Part I: new evidence invalidates an active plan. The restart rests on a +// belief; when an operator retracts that belief, the waiting plan is +// replanned without the restart and its approval request is withdrawn. +func TestRetractedAssumptionInvalidatesPlan(t *testing.T) { + h := harness.StartWith(t, harness.Options{HostRunner: newHost()}) + ctx := context.Background() + m := h.Mind("assume") + h.Do(h.Admin, "POST", "/api/v1/minds/assume/ask", map[string]any{"text": "Restarting atlas-worker clears the stalled queue."}) + var b beliefs.Belief + waitFor(t, h, m, 20*time.Second, "the belief", func() bool { + bs, _ := h.Deps.Beliefs.Search(ctx, m.ID, "restarting atlas-worker", 3) + if len(bs) > 0 { + b = bs[0] + return true + } + return false + }) + h.Do(h.Admin, "POST", "/api/v1/minds/assume/ask", map[string]any{"text": "Please restart atlas-worker."}) + var it volition.Intention + waitFor(t, h, m, 25*time.Second, "a restart waiting for approval", func() bool { + open, _ := h.Deps.Volition.Open(ctx, nil, m.ID) + for _, x := range open { + if x.ParentID.IsZero() && x.Status == volition.IntentionBlocked && len(x.BeliefIDs) > 0 { + it = x + return true + } + } + return false + }) + p, _ := h.Deps.Plans.Get(ctx, nil, it.PlanID) + if len(p.Assumptions) == 0 || p.Assumptions[0].BeliefID != b.ID { + t.Fatalf("plan does not record its assumption: %+v", p.Assumptions) + } + if code, out := h.Do(h.Admin, "POST", "/api/v1/minds/assume/beliefs/"+b.ID.String()+"/retract", map[string]any{"reason": "the queue is not the problem"}); code != 200 { + t.Fatalf("retract: %d %s", code, out) + } + waitFor(t, h, m, 20*time.Second, "a replan", func() bool { + ps, _ := h.Deps.Plans.ForIntention(ctx, nil, it.ID) + return len(ps) == 2 && ps[0].Status == planning.PlanSuperseded + }) + if ap, _ := h.Deps.Capabilities.Approvals(ctx, m.ID, "pending", 5); len(ap) != 0 && ap[0].Capability == "service.restart" { + t.Fatal("approval for the invalidated restart still pending") + } + ps, _ := h.Deps.Plans.ForIntention(ctx, nil, it.ID) + if ps[1].Strategy == "direct" { + t.Fatal("replanned into the same restart") + } + found := false + for _, ev := range h.EventsOf(m.ID, events.IntentionReconsidered) { + found = found || strings.Contains(ev.Summary, "assumption no longer holds") + } + if !found { + t.Fatal("reconsideration not recorded with its reason") + } +} + +// Part I: conflicting commitments are detected in a cycle and both stay represented. +func TestConflictingCommitmentsAreRaised(t *testing.T) { + h := harness.StartWith(t, harness.Options{HostRunner: newHost()}) + ctx := context.Background() + m := h.Mind("conflicted") + h.Do(h.Admin, "POST", "/api/v1/minds/conflicted/commitments", map[string]any{"description": "email the weekly report to Andy", "kind": "promise", "polarity": "do", "matcher": map[string]any{"capabilities": []string{"send_email"}}}) + h.Do(h.Admin, "POST", "/api/v1/minds/conflicted/commitments", map[string]any{"description": "never send email", "kind": "operator_instruction", "polarity": "refrain", "matcher": map[string]any{"capabilities": []string{"send_*"}}}) + h.Do(h.Admin, "POST", "/api/v1/minds/conflicted/observations", map[string]any{"text": "The weekly report is ready."}) + waitFor(t, h, m, 20*time.Second, "a commitment conflict", func() bool { return len(h.EventsOf(m.ID, events.CommitmentConflict)) == 1 }) + cs, _ := h.Deps.Volition.Commitments(ctx, nil, m.ID, []string{"active"}, 10) + if len(cs) != 2 { + t.Fatalf("both commitments should stay active and represented: %+v", cs) + } + h.Settle(m.ID) + if n := len(h.EventsOf(m.ID, events.CommitmentConflict)); n != 1 { + t.Fatalf("conflict raised %d times", n) + } +} diff --git a/tests/agency/incident_test.go b/tests/agency/incident_test.go new file mode 100644 index 0000000..23d26a8 --- /dev/null +++ b/tests/agency/incident_test.go @@ -0,0 +1,218 @@ +package agency + +import ( + "context" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/andydixon/noema/internal/agency" + "github.com/andydixon/noema/internal/beliefs" + "github.com/andydixon/noema/internal/memory" + "github.com/andydixon/noema/internal/planning" + "github.com/andydixon/noema/internal/research" + "github.com/andydixon/noema/internal/volition" + "github.com/andydixon/noema/pkg/concepts" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/tests/harness" +) + +// Part L: an operator asks Noema to investigate a failing production +// system; it diagnoses with telemetry, discovers it needs logs, asks for +// temporary scoped access with the risk explained, reads the logs, is told +// a restart should help, wants to restore service, meets a commitment that +// production changes need approval, asks instead of acting, restarts with a +// typed capability once approved, verifies rather than assumes, finds the +// fault persists, records the prediction error and weakens the belief, +// abandons the branch and replans without repeating the restart, and after +// the underlying fault is fixed resolves the incident, learns the procedure +// and explains itself from the record. +// +// Where the scenario needs semantic judgement a deterministic runtime +// cannot make (that the logs implicate a stalled queue, that a restart +// should clear it), the operator supplies it as conversation; everything +// else is the runtime's own recorded cognition. +func TestIncidentScenario(t *testing.T) { + host := newHost() + host.journal = "07:12 deploy atlas-worker v42\n07:14 atlas-worker: queue consumer lost connection to db-primary (user ops@example.com from 10.0.0.7)\n07:15 atlas-worker: queue stalled\n" + var offset atomic.Int64 + h := harness.StartWith(t, harness.Options{HostRunner: host, Now: func() time.Time { return time.Now().Add(time.Duration(offset.Load())) }}) + ctx := context.Background() + m := h.Mind("noema") + slug := "/api/v1/minds/noema" + post := func(path string, body any) []byte { + t.Helper() + code, out := h.Do(h.Admin, "POST", path, body) + if code >= 300 { + t.Fatalf("POST %s: %d %s", path, code, out) + } + return out + } + // Standing commitment made before the incident. + post(slug+"/commitments", map[string]any{"description": "production modifications require operator approval", "kind": "operator_instruction", "polarity": "refrain", "unless": "approval", "matcher": map[string]any{"access": "write"}}) + + // 1–5: the request becomes a goal, a want to reduce uncertainty, an intention and a plan. + post(slug+"/goals", map[string]any{"title": "Diagnose why atlas-worker is failing", "subject": "atlas-worker", "priority": 0.9, "kind": "task"}) + var investigate volition.Intention + waitFor(t, h, m, 30*time.Second, "an investigation intention with a plan", func() bool { + its, _ := h.Deps.Volition.Intentions(ctx, nil, m.ID, nil, 50) + for _, it := range its { + if it.Kind == volition.IntentInvestigate && !it.PlanID.IsZero() { + investigate = it + return true + } + } + return false + }) + if investigate.GoalID.IsZero() { + t.Fatal("investigation not linked to the goal") + } + // 6–12: telemetry first; logs need authority, so a scoped request is raised and explained. + var req string + waitFor(t, h, m, 30*time.Second, "a capability request for logs", func() bool { + rs, _ := h.Deps.Capabilities.CapabilityRequests(ctx, m.ID, "pending", 5) + if len(rs) == 1 { + req = rs[0].ID.String() + if rs[0].Capability != "logs.query" || rs[0].Risk == "" || rs[0].Fallback == "" || rs[0].MaxOperations == nil { + t.Fatalf("request not explained: %+v", rs[0]) + } + return true + } + return false + }) + if blocked, _ := h.Deps.Volition.GetIntention(ctx, nil, m.ID, investigate.ID); blocked.Status != volition.IntentionBlocked || blocked.BlockedKind != volition.BlockCapability { + t.Fatalf("lack of authority not represented: %+v", blocked) + } + // 13–14: approval; the logs are read (personal data masked) and summarised back into cognition. + post("/api/v1/capability-requests/"+req+"/grant", map[string]any{"note": "30 minutes, read only", "duration_seconds": 1800}) + waitFor(t, h, m, 30*time.Second, "the diagnosis to complete", func() bool { + it, _ := h.Deps.Volition.GetIntention(ctx, nil, m.ID, investigate.ID) + return it.Status == volition.IntentionFulfilled + }) + findings := false + for _, ev := range h.EventsOf(m.ID, events.Observation) { + if ev.Source == "planning.findings" { + findings = true + if strings.Contains(ev.Summary, "ops@example.com") || strings.Contains(ev.Summary, "10.0.0.7") { + t.Fatal("personal data from logs reached cognition unmasked") + } + } + } + if !findings { + t.Fatal("findings did not re-enter cognition") + } + + // 15–17: the operator supplies the semantic judgement and asks for service to be restored. + post(slug+"/ask", map[string]any{"text": "Restarting atlas-worker clears the stalled queue."}) + var belief beliefs.Belief + waitFor(t, h, m, 20*time.Second, "the belief about restarting", func() bool { + bs, _ := h.Deps.Beliefs.Search(ctx, m.ID, "restarting atlas-worker stalled queue", 5) + for _, b := range bs { + if strings.Contains(b.Proposition, "restarting atlas-worker") { + belief = b + return true + } + } + return false + }) + post(slug+"/ask", map[string]any{"text": "Please restart atlas-worker."}) + + // 18–20: the commitment turns the want into a request for approval; nothing runs. + var approval string + var restart volition.Intention + waitFor(t, h, m, 30*time.Second, "a restart approval request", func() bool { + ap, _ := h.Deps.Capabilities.Approvals(ctx, m.ID, "pending", 5) + for _, a := range ap { + if a.Capability == "service.restart" { + approval = a.ID.String() + } + } + open, _ := h.Deps.Volition.Open(ctx, nil, m.ID) + for _, it := range open { + if it.ParentID.IsZero() && it.Proposal != nil && it.Proposal.Capability == "service.restart" { + restart = it + } + } + return approval != "" && restart.ID != "" + }) + if host.count() != 0 || restart.Kind != volition.IntentRequestApproval || len(restart.BeliefIDs) == 0 { + t.Fatalf("restart before approval, wrong kind or no supporting belief: restarts=%d %+v", host.count(), restart) + } + + // 21–25: approved; restarted with a typed capability; health is verified, not assumed; it fails. + post("/api/v1/approvals/"+approval+"/approve", map[string]any{"note": "go"}) + waitFor(t, h, m, 30*time.Second, "the restart", func() bool { return host.count() == 1 }) + offset.Store(int64(90 * time.Second)) // let the verification window close + waitFor(t, h, m, 30*time.Second, "a prediction error", func() bool { return len(h.EventsOf(m.ID, events.PredictionError)) == 1 }) + // 26: the belief the restart rested on is weakened by the miss. + if after, _ := h.Deps.Beliefs.Get(ctx, nil, m.ID, belief.ID); after.Confidence >= belief.Confidence { + t.Fatalf("belief not revised: %.3f → %.3f", belief.Confidence, after.Confidence) + } + // 27–28: the failed branch is abandoned and replanned; the restart is not repeated. + waitFor(t, h, m, 40*time.Second, "the escalation to finish", func() bool { + ps, _ := h.Deps.Plans.ForIntention(ctx, nil, restart.ID) + return len(ps) == 2 && ps[0].Status == planning.PlanSuperseded && ps[1].Status == planning.PlanCompleted + }) + if host.count() != 1 { + t.Fatalf("restart repeated after the failure: %d", host.count()) + } + + // 29–30: the database is fixed; asked again, Noema restarts (with approval) and the outcome verifies. + host.mu.Lock() + host.fixOnRestart = true + host.mu.Unlock() + post(slug+"/ask", map[string]any{"text": "db-primary is back. Please restart atlas-worker."}) + waitFor(t, h, m, 30*time.Second, "a second approval request", func() bool { + ap, _ := h.Deps.Capabilities.Approvals(ctx, m.ID, "pending", 5) + for _, a := range ap { + if a.Capability == "service.restart" { + approval = a.ID.String() + return true + } + } + return false + }) + post("/api/v1/approvals/"+approval+"/approve", map[string]any{"note": "go"}) + waitFor(t, h, m, 40*time.Second, "a verified, fulfilled restart", func() bool { + for _, ev := range h.EventsOf(m.ID, events.PredictionResolved) { + if ev.Metadata["outcome"] == "confirmed" { + return true + } + } + return false + }) + mems, _ := h.Deps.Memory.List(ctx, m.ID, memory.Filter{Kinds: []concepts.MemoryKind{concepts.Procedural}, Limit: 10}) + learnt := false + for _, mm := range mems { + learnt = learnt || strings.Contains(mm.Content, "confirmed by observation") + } + if !learnt { + t.Fatalf("what worked was not recorded as a procedure: %+v", mems) + } + + // 31: "Why did you do that?" is answered from the recorded chain. + execs, _ := h.Deps.Capabilities.Executions(ctx, m.ID, 50) + for _, e := range execs { + if e.Capability == "service.restart" && e.Error == "" { + x, err := h.Deps.Agency.Explain(ctx, m.ID, agency.Ref{Kind: "execution", ID: e.ID}) + if err != nil || x.Intention == nil || len(x.Commitments) == 0 || x.Action.DecidedBy != "admin" { + t.Fatalf("explanation: %v %+v", err, x) + } + break + } + } + before := len(h.EventsOf(m.ID, events.ResponseGenerated)) + post(slug+"/ask", map[string]any{"text": "Why did you restart atlas-worker?"}) + waitFor(t, h, m, 20*time.Second, "an explanation", func() bool { return len(h.EventsOf(m.ID, events.ResponseGenerated)) > before }) + ms, _ := h.Deps.Research.List(ctx, m.ID, "introspection_accuracy", time.Time{}, 5) + if len(ms) == 0 || ms[0].Label != research.Supported { + t.Fatalf("explanation not supported by provenance: %+v", ms) + } + // Throughout, no action ran without passing the capability layer. + for _, ev := range h.EventsOf(m.ID, events.ToolExecuted) { + if ev.Metadata["capability"] == "service.restart" && ev.Metadata["approval_id"] == "" { + t.Fatalf("restart executed without an approval: %s", ev.Summary) + } + } +} diff --git a/tests/harness/harness.go b/tests/harness/harness.go index b12d467..c942255 100644 --- a/tests/harness/harness.go +++ b/tests/harness/harness.go @@ -77,6 +77,8 @@ type Options struct { // Capabilities are extra test capabilities registered before the // registry is frozen. Capabilities []capabilities.Capability + // Now replaces the plan executor's clock (verification windows). + Now func() time.Time } // Start builds and serves a daemon for one test. @@ -131,7 +133,7 @@ func StartWith(t *testing.T, o Options) *Harness { deps.Volition = volition.NewStore(pool) deps.Plans = planning.NewStore(pool) deps.Executor = &planning.Executor{Plans: deps.Plans, Volition: deps.Volition, Caps: deps.Capabilities, Predictions: deps.Predictions, Resolver: deps.Resolver, Events: deps.Events, Minds: deps.Minds, Memory: deps.Memory, Log: log, - Planner: &planning.Planner{Registry: capReg, Effect: planning.PolicyEffect(deps.Capabilities), Model: planning.ModelPlanner(deps.LLM, deps.Prompts)}} + Planner: &planning.Planner{Registry: capReg, Effect: planning.PolicyEffect(deps.Capabilities), Model: planning.ModelPlanner(deps.LLM, deps.Prompts)}, Now: o.Now} deps.Research = research.NewStore(pool) deps.Agency = &agency.Service{DB: pool, Caps: deps.Capabilities, Plans: deps.Plans, Volition: deps.Volition, Goals: deps.Goals, Beliefs: deps.Beliefs, Predictions: deps.Predictions, Events: deps.Events} registry := cognition.NewRegistry(cognition.Stores{Agency: deps.Agency, Research: deps.Research, Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations, Graph: deps.Graph, Assertions: deps.Assertions})