From 8bd37ed46d6b9a3db8ec364eac1056e9d00c120d Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 08:04:13 -0400 Subject: [PATCH] Load the knowledge profile on planning and ground the coach The controller loads the profile asynchronously on entering planning, mirroring the tasks fetch: generation-guarded goroutine, status enum (idle/pending/ready/absent/error), projected into State only while planning and only when a source is set. The loaded text is cached and passed to the coach as grounding; SetKnowledgePath repoints the file at runtime. nil source degrades to no view and an ungrounded coach. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/session/session.go | 112 +++++++++++++++++++++++++++++- internal/session/session_test.go | 113 ++++++++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 2 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index dc65e99..6b678d6 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -17,6 +17,7 @@ import ( "antidrift/internal/ai" "antidrift/internal/domain" "antidrift/internal/evidence" + "antidrift/internal/knowledge" "antidrift/internal/statemachine" "antidrift/internal/store" "antidrift/internal/tasks" @@ -47,6 +48,16 @@ const ( tasksError = "error" ) +const knowledgeTimeout = 10 * time.Second + +const ( + knowledgeIdle = "idle" + knowledgePending = "pending" + knowledgeReady = "ready" + knowledgeAbsent = "absent" + knowledgeError = "error" +) + const ( driftDebounce = 10 * time.Second driftTimeout = 30 * time.Second @@ -107,6 +118,13 @@ type Controller struct { tasksList []tasks.Task tasksGen int + knowledgeSrc knowledge.Source + knowledgeStatus string + knowledgeText string // cached grounding the coach reads + knowledgePath string // selected path; "" = adapter default + knowledgeChars int + knowledgeGen int + allowedClasses []string // durable: the active session's allowed window classes judge ai.DriftJudge driftStatus string @@ -167,6 +185,15 @@ type TasksView struct { Tasks []TaskView `json:"tasks,omitempty"` } +// KnowledgeView projects the ephemeral planning knowledge state (the standing +// profile that grounds the coach). The profile text is intentionally omitted — +// only its presence, source path, and size cross the wire. +type KnowledgeView struct { + Status string `json:"status"` + Path string `json:"path,omitempty"` + Chars int `json:"chars,omitempty"` +} + // WindowView / BucketView / EvidenceView are the evidence projection. type WindowView struct { Class string `json:"class"` @@ -194,6 +221,7 @@ type State struct { Evidence *EvidenceView `json:"evidence"` Coach *CoachView `json:"coach,omitempty"` Tasks *TasksView `json:"tasks,omitempty"` + Knowledge *KnowledgeView `json:"knowledge,omitempty"` Drift *DriftView `json:"drift,omitempty"` } @@ -319,6 +347,13 @@ func (c *Controller) stateLocked() State { } st.Tasks = tv } + if c.knowledgeSrc != nil { + kstatus := c.knowledgeStatus + if kstatus == "" { + kstatus = knowledgeIdle + } + st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars} + } } if c.runtimeState == domain.RuntimeActive { status := c.driftStatus @@ -366,6 +401,7 @@ func (c *Controller) EnterPlanning() error { c.runtimeState = next c.resetCoachLocked() c.startTasksFetchLocked() + c.startKnowledgeFetchLocked() return c.persistLocked() } @@ -429,6 +465,79 @@ func (c *Controller) startTasksFetchLocked() { }() } +// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source +// keeps the planning knowledge view absent and the coach ungrounded. +func (c *Controller) SetKnowledge(s knowledge.Source) { + c.mu.Lock() + c.knowledgeSrc = s + c.mu.Unlock() +} + +// SetKnowledgePath selects an explicit profile path (session-only; not +// persisted). While planning, it re-loads immediately so the indicator and the +// cached grounding update. An empty path resets to the adapter default. +func (c *Controller) SetKnowledgePath(path string) { + c.mu.Lock() + c.knowledgePath = strings.TrimSpace(path) + planning := c.runtimeState == domain.RuntimePlanning + if planning { + c.startKnowledgeFetchLocked() + } + c.mu.Unlock() + if planning { + c.notify() + } +} + +// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set. +// Mirrors startTasksFetchLocked: generation-guarded, discards stale or +// post-planning results, and notifies on completion. The loaded text is cached +// in knowledgeText for the coach to read. Caller holds mu. +func (c *Controller) startKnowledgeFetchLocked() { + c.knowledgeText = "" + c.knowledgeChars = 0 + if c.knowledgeSrc == nil { + c.knowledgeStatus = knowledgeIdle + return + } + c.knowledgeGen++ + gen := c.knowledgeGen + c.knowledgeStatus = knowledgePending + src := c.knowledgeSrc + path := c.knowledgePath + go func() { + ctx, cancel := context.WithTimeout(context.Background(), knowledgeTimeout) + defer cancel() + prof, err := src.Load(ctx, path) + + c.mu.Lock() + if gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning { + c.mu.Unlock() + return // stale or left planning: discard + } + if err != nil { + c.knowledgeStatus = knowledgeError + c.knowledgeText = "" + c.knowledgeChars = 0 + if prof.Path != "" { + c.knowledgePath = prof.Path + } + } else if prof.Text == "" { + c.knowledgeStatus = knowledgeAbsent + c.knowledgeText = "" + c.knowledgeChars = 0 + c.knowledgePath = prof.Path + } else { + c.knowledgeStatus = knowledgeReady + c.knowledgeText = prof.Text + c.knowledgeChars = len(prof.Text) + c.knowledgePath = prof.Path + } + c.mu.Unlock() + c.notify() + }() +} + // AllowedClassesForTest exposes the session allowed classes for tests. func (c *Controller) AllowedClassesForTest() []string { c.mu.Lock() @@ -545,13 +654,14 @@ func (c *Controller) RequestCoach(intent string) error { c.coachErr = "" c.coachProposal = nil coach := c.coach + grounding := c.knowledgeText c.mu.Unlock() c.notify() go func() { ctx, cancel := context.WithTimeout(context.Background(), coachTimeout) defer cancel() - prop, err := coach.Coach(ctx, intent) + prop, err := coach.Coach(ctx, intent, grounding) c.mu.Lock() if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 15def1c..641a9b9 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "sync" "sync/atomic" "testing" "time" @@ -12,6 +13,7 @@ import ( "antidrift/internal/ai" "antidrift/internal/domain" "antidrift/internal/evidence" + "antidrift/internal/knowledge" "antidrift/internal/store" "antidrift/internal/tasks" ) @@ -246,15 +248,27 @@ type fakeCoach struct { prop ai.Proposal err error gate chan struct{} // if non-nil, Coach blocks until it receives + + mu sync.Mutex + gotGrounding string } -func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { +func (f *fakeCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) { + f.mu.Lock() + f.gotGrounding = grounding + f.mu.Unlock() if f.gate != nil { <-f.gate } return f.prop, f.err } +func (f *fakeCoach) grounding() string { + f.mu.Lock() + defer f.mu.Unlock() + return f.gotGrounding +} + // waitCoachStatus polls until the coach view reaches want, or fails after 2s. func waitCoachStatus(t *testing.T, c *Controller, want string) State { t.Helper() @@ -918,3 +932,100 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) { t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks) } } + +type fakeSource struct { + profile knowledge.Profile + err error +} + +func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { + return f.profile, f.err +} + +// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s. +func waitKnowledgeStatus(t *testing.T, c *Controller, want string) State { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + st := c.State() + if st.Knowledge != nil && st.Knowledge.Status == want { + return st + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("knowledge status never reached %q (last: %+v)", want, c.State().Knowledge) + return State{} +} + +func TestEnterPlanningLoadsKnowledge(t *testing.T) { + c, _ := newTestController(t) + c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p/knowledge.md"}}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + st := waitKnowledgeStatus(t, c, "ready") + if st.Knowledge.Path != "/p/knowledge.md" || st.Knowledge.Chars != len("I value small diffs.") { + t.Fatalf("knowledge view wrong: %+v", st.Knowledge) + } +} + +func TestKnowledgeAbsentWhenEmptyText(t *testing.T) { + c, _ := newTestController(t) + c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Path: "/p/missing.md"}}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + st := waitKnowledgeStatus(t, c, "absent") + if st.Knowledge.Path != "/p/missing.md" { + t.Fatalf("absent view should still carry the path: %+v", st.Knowledge) + } +} + +func TestKnowledgeLoadError(t *testing.T) { + c, _ := newTestController(t) + c.SetKnowledge(&fakeSource{err: errors.New("permission denied")}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + waitKnowledgeStatus(t, c, "error") +} + +func TestNoSourceNoKnowledgeView(t *testing.T) { + c, _ := newTestController(t) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + if c.State().Knowledge != nil { + t.Fatalf("nil source should yield no knowledge view: %+v", c.State().Knowledge) + } +} + +func TestKnowledgeViewAbsentOutsidePlanning(t *testing.T) { + c, _ := newTestController(t) + c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "x"}}) + if c.State().Knowledge != nil { + t.Fatalf("no knowledge view while Locked: %+v", c.State().Knowledge) + } +} + +func TestCoachReceivesCachedGrounding(t *testing.T) { + c, _ := newTestController(t) + fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}} + c.SetCoach(fc) + c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p"}}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + waitKnowledgeStatus(t, c, "ready") + if err := c.RequestCoach("ship it"); err != nil { + t.Fatalf("request coach: %v", err) + } + // Poll until the coach has run (proposal ready), then assert grounding flowed. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && fc.grounding() == "" { + time.Sleep(5 * time.Millisecond) + } + if fc.grounding() != "I value small diffs." { + t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding()) + } +}