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) <noreply@anthropic.com>
This commit is contained in:
+111
-1
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user