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:
2026-06-01 08:04:13 -04:00
parent 0f1790c8d5
commit 8bd37ed46d
2 changed files with 223 additions and 2 deletions
+111 -1
View File
@@ -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 {