533 lines
17 KiB
Go
533 lines
17 KiB
Go
package control
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"unicode"
|
||
|
||
"reasonix/internal/evidence"
|
||
fileencoding "reasonix/internal/fileutil/encoding"
|
||
"reasonix/internal/store"
|
||
)
|
||
|
||
const (
|
||
maxGoalAutoTurns = 50
|
||
maxGoalIdleTurns = 2
|
||
goalContinueTurn = "Continue pursuing the active goal under its task contract. If it is complete, provide the concise final result and end with [goal:complete]. If progress genuinely requires user-only information, an irreversible or externally visible operation, or a changed scope, end with [goal:blocked:<short reason>]. Otherwise use sensible defaults, do the next useful work, and end with [goal:continue]."
|
||
goalSelfCheckTurn = "The agent signaled goal completion and all tasks are marked done. Before finalizing, perform a brief quality self-check:\n1. Verify any changed files compile or parse correctly\n2. Run the relevant tests if applicable\n3. Confirm the original request, output format, constraints, and success criteria are met\nIf everything checks out, signal [goal:complete]. If issues are found, fix them and signal [goal:complete] when done."
|
||
goalCompleteNotice = "goal complete"
|
||
)
|
||
|
||
// goalMachine owns the active goal's finite-state machine and its persistence.
|
||
// It is a strict leaf: its methods take only the machine's own locks and never
|
||
// call back into the Controller, so the controller may hold c.mu while invoking
|
||
// a getter without risking lock inversion. The FSM is pure — advance() takes
|
||
// already-gathered inputs (the parsed marker, the executor's todo snapshot and
|
||
// readiness, whether a tool ran) and returns what to persist plus a notice, so
|
||
// no disk or executor work happens under mu.
|
||
type goalMachine struct {
|
||
// mu guards the FSM fields below; every critical section under it is short
|
||
// and non-blocking (no disk I/O, no executor calls).
|
||
mu sync.Mutex
|
||
goal string
|
||
status string
|
||
researchMode GoalResearchMode
|
||
autoResearchTaskID string
|
||
turns int
|
||
blocks int
|
||
block string
|
||
interceptMsg string
|
||
intercepts int
|
||
strict bool
|
||
selfCheckDone bool
|
||
idleTurns int
|
||
|
||
// statePath is the persisted goal-state sidecar; empty disables persistence.
|
||
statePath string
|
||
// writeMu serializes goal-state disk writes so concurrent saves don't
|
||
// interleave or land out of order. Taken OFF mu by writeState.
|
||
writeMu sync.Mutex
|
||
}
|
||
|
||
// goalState is the serializable form of a running goal.
|
||
type goalState struct {
|
||
Goal string `json:"goal,omitempty"`
|
||
Status string `json:"status,omitempty"`
|
||
ResearchMode GoalResearchMode `json:"researchMode,omitempty"`
|
||
AutoResearchTaskID string `json:"autoResearchTaskID,omitempty"`
|
||
Turns int `json:"turns,omitempty"`
|
||
Blocks int `json:"blocks,omitempty"`
|
||
Block string `json:"block,omitempty"`
|
||
Strict bool `json:"strict,omitempty"`
|
||
Todos []evidence.TodoItem `json:"todos,omitempty"`
|
||
}
|
||
|
||
// goalAdvanceInput carries everything the FSM needs for one continuation step,
|
||
// gathered by the caller off the machine's lock.
|
||
type goalAdvanceInput struct {
|
||
status string // parsed marker status ("" when the turn carried no marker)
|
||
reason string // blocked reason from the marker, if any
|
||
toolCalled bool // whether the last turn made any tool call
|
||
todos []evidence.TodoItem
|
||
readiness string // executor.GoalReadinessFailure()
|
||
}
|
||
|
||
// goalAdvanceResult reports the FSM step's outcome. data/path/ok describe the
|
||
// state to persist (built under mu when something changed); notice is surfaced
|
||
// to the user; cont reports whether the goal loop should continue.
|
||
type goalAdvanceResult struct {
|
||
notice string
|
||
cont bool
|
||
path string
|
||
data []byte
|
||
ok bool
|
||
}
|
||
|
||
// goalStatePath derives a session's persisted goal-state sidecar.
|
||
func goalStatePath(sessionPath string) string {
|
||
return store.SessionGoalState(sessionPath)
|
||
}
|
||
|
||
func (g *goalMachine) setStatePath(path string) {
|
||
g.mu.Lock()
|
||
g.statePath = path
|
||
g.mu.Unlock()
|
||
}
|
||
|
||
// snapshot returns the fields Compose injects into outgoing turns.
|
||
func (g *goalMachine) snapshot() (goal, status string, mode GoalResearchMode, autoResearchTaskID string) {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
return g.goal, g.status, g.researchMode, g.autoResearchTaskID
|
||
}
|
||
|
||
func (g *goalMachine) goalText() string {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
return g.goal
|
||
}
|
||
|
||
func (g *goalMachine) currentAutoResearchTaskID() string {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning {
|
||
return ""
|
||
}
|
||
return g.autoResearchTaskID
|
||
}
|
||
|
||
// active reports whether a goal is currently running.
|
||
func (g *goalMachine) active() bool {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
return strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning
|
||
}
|
||
|
||
// statusForDisplay maps the empty zero status to "stopped" for frontends.
|
||
func (g *goalMachine) statusForDisplay() string {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
if g.status == "" {
|
||
return GoalStatusStopped
|
||
}
|
||
return g.status
|
||
}
|
||
|
||
// set installs a session-scoped goal (or clears it when goal is empty), resets
|
||
// the per-goal counters, and returns the state to persist. ok is false (no
|
||
// persistence) when the goal is unchanged or no state path is configured.
|
||
func (g *goalMachine) set(goal string, mode GoalResearchMode, autoResearchTaskID string, todos []evidence.TodoItem) (string, []byte, bool) {
|
||
goal = strings.TrimSpace(goal)
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode && g.autoResearchTaskID == autoResearchTaskID {
|
||
return "", nil, false
|
||
}
|
||
g.turns, g.blocks, g.block = 0, 0, ""
|
||
g.interceptMsg, g.intercepts = "", 0
|
||
g.selfCheckDone, g.idleTurns, g.strict = false, 0, false
|
||
if goal == "" {
|
||
g.goal, g.status, g.researchMode, g.autoResearchTaskID = "", GoalStatusStopped, GoalResearchAuto, ""
|
||
} else {
|
||
g.goal, g.status, g.researchMode, g.autoResearchTaskID = goal, GoalStatusRunning, mode, autoResearchTaskID
|
||
}
|
||
return g.buildStateLocked(todos)
|
||
}
|
||
|
||
func (g *goalMachine) setStrict(strict bool, todos []evidence.TodoItem) (string, []byte, bool) {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
g.strict = strict
|
||
return g.buildStateLocked(todos)
|
||
}
|
||
|
||
// stop transitions a running goal to the given terminal status and clears the
|
||
// transient intercept/idle bookkeeping.
|
||
func (g *goalMachine) stop(status string, todos []evidence.TodoItem) (string, []byte, bool) {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
if strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning {
|
||
g.status = status
|
||
}
|
||
g.interceptMsg = ""
|
||
g.intercepts = 0
|
||
g.selfCheckDone = false
|
||
g.idleTurns = 0
|
||
return g.buildStateLocked(todos)
|
||
}
|
||
|
||
// takeIntercept consumes a pending continuation-turn override, if any.
|
||
func (g *goalMachine) takeIntercept() (string, bool) {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
if g.interceptMsg == "" {
|
||
return "", false
|
||
}
|
||
msg := g.interceptMsg
|
||
g.interceptMsg = ""
|
||
return msg, true
|
||
}
|
||
|
||
// advance runs one continuation step of the goal FSM from already-gathered
|
||
// inputs. It mutates the machine, decides whether to keep looping, and builds
|
||
// the state to persist when the goal reached a terminal/notice point.
|
||
func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning {
|
||
return goalAdvanceResult{cont: false}
|
||
}
|
||
g.turns++
|
||
var notice string
|
||
switch in.status {
|
||
case GoalStatusComplete:
|
||
if incomplete := formatIncompleteTodos(in.todos, in.readiness); len(incomplete) > 0 && (g.strict || g.intercepts == 0) {
|
||
// In strict mode every claim is blocked until todos are done;
|
||
// otherwise only the first consecutive claim is intercepted.
|
||
g.intercepts++
|
||
g.interceptMsg = incomplete
|
||
break
|
||
}
|
||
// Todos are all done — in strict mode run self-check before final
|
||
// completion. Non-strict mode completes immediately.
|
||
if g.strict && !g.selfCheckDone {
|
||
g.selfCheckDone = true
|
||
g.interceptMsg = goalSelfCheckTurn
|
||
break
|
||
}
|
||
// Self-check passed — complete the goal.
|
||
g.intercepts = 0
|
||
g.selfCheckDone = false
|
||
g.idleTurns = 0
|
||
g.goal = ""
|
||
g.status = GoalStatusComplete
|
||
g.blocks = 0
|
||
g.block = ""
|
||
g.interceptMsg = ""
|
||
notice = goalCompleteNotice
|
||
case GoalStatusBlocked:
|
||
reason := cleanGoalBlockReason(in.reason)
|
||
if reason == "" {
|
||
reason = "blocked"
|
||
}
|
||
if sameGoalBlock(g.block, reason) {
|
||
g.blocks++
|
||
} else {
|
||
g.blocks = 1
|
||
g.block = reason
|
||
}
|
||
if g.blocks >= 3 {
|
||
g.status = GoalStatusBlocked
|
||
notice = "goal blocked: " + reason
|
||
}
|
||
default:
|
||
g.blocks = 0
|
||
g.block = ""
|
||
g.intercepts = 0
|
||
g.selfCheckDone = false
|
||
g.idleTurns = 0
|
||
}
|
||
// Idle detection: if the agent went multiple turns without any tool calls,
|
||
// inject a reminder to make progress (unless the goal is already completing
|
||
// or hitting the auto-turn limit).
|
||
if notice == "" && g.interceptMsg == "" {
|
||
if in.toolCalled {
|
||
g.idleTurns = 0
|
||
} else {
|
||
g.idleTurns++
|
||
if g.idleTurns >= maxGoalIdleTurns {
|
||
g.idleTurns = 0
|
||
g.interceptMsg = "No tool calls in recent turns. Either make progress with tools or signal [goal:blocked:<reason>]."
|
||
}
|
||
}
|
||
}
|
||
if notice == "" && g.turns >= maxGoalAutoTurns {
|
||
g.status = GoalStatusBlocked
|
||
g.block = "goal continuation limit reached"
|
||
g.intercepts = 0
|
||
g.selfCheckDone = false
|
||
g.interceptMsg = ""
|
||
g.idleTurns = 0
|
||
notice = g.block
|
||
}
|
||
res := goalAdvanceResult{notice: notice, cont: notice == ""}
|
||
if notice != "" {
|
||
res.path, res.data, res.ok = g.buildStateLocked(in.todos)
|
||
}
|
||
return res
|
||
}
|
||
|
||
// buildStateLocked marshals the current goal state for persistence. The caller
|
||
// holds mu; this only reads in-memory state, never touching disk. Returns ok=false
|
||
// when persistence is disabled (no state path). The matching writeState does the
|
||
// disk write OFF mu so the per-turn save can't stall a status poll.
|
||
func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string, data []byte, ok bool) {
|
||
if g.statePath == "" {
|
||
return "", nil, false
|
||
}
|
||
state := goalState{
|
||
Goal: g.goal,
|
||
Status: g.status,
|
||
ResearchMode: g.researchMode,
|
||
AutoResearchTaskID: g.autoResearchTaskID,
|
||
Turns: g.turns,
|
||
Blocks: g.blocks,
|
||
Block: g.block,
|
||
Strict: g.strict,
|
||
Todos: todos,
|
||
}
|
||
b, err := json.Marshal(state)
|
||
if err != nil {
|
||
slog.Warn("controller: marshal goal state", "err", err)
|
||
return "", nil, false
|
||
}
|
||
return g.statePath, b, true
|
||
}
|
||
|
||
// writeState persists pre-marshaled goal-state bytes to disk, OFF mu and
|
||
// serialized by writeMu so concurrent saves don't interleave or land out of
|
||
// order. Best-effort: failures are logged, not surfaced.
|
||
func (g *goalMachine) writeState(path string, data []byte) {
|
||
if path == "" || data == nil {
|
||
return
|
||
}
|
||
g.writeMu.Lock()
|
||
defer g.writeMu.Unlock()
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
slog.Warn("controller: goal state dir", "err", err)
|
||
return
|
||
}
|
||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||
slog.Warn("controller: write goal state", "err", err)
|
||
}
|
||
}
|
||
|
||
// persistWithTodos re-persists goal state with the given todos, without
|
||
// changing any in-memory goal fields. Used after force-completing todos on
|
||
// goal completion so a session reload does not revert to the old incomplete
|
||
// todo state.
|
||
func (g *goalMachine) persistWithTodos(todos []evidence.TodoItem) {
|
||
g.mu.Lock()
|
||
path, data, ok := g.buildStateLocked(todos)
|
||
g.mu.Unlock()
|
||
if ok {
|
||
g.writeState(path, data)
|
||
}
|
||
}
|
||
|
||
// terminalTodosFromState reads the persisted goal-state sidecar and returns its
|
||
// todo snapshot only after the goal has reached a terminal state. Running goal
|
||
// state is not refreshed on every todo_write, so its todos may be older than the
|
||
// transcript rebuilt by Agent.SetSession.
|
||
func (g *goalMachine) terminalTodosFromState(sessionPath string) ([]evidence.TodoItem, bool) {
|
||
if strings.TrimSpace(sessionPath) == "" {
|
||
return nil, false
|
||
}
|
||
data, err := fileencoding.ReadFileUTF8(goalStatePath(sessionPath))
|
||
if err != nil {
|
||
if !os.IsNotExist(err) {
|
||
slog.Warn("controller: read goal state", "err", err)
|
||
}
|
||
return nil, false
|
||
}
|
||
var state goalState
|
||
if err := json.Unmarshal(data, &state); err != nil {
|
||
slog.Warn("controller: parse goal state", "err", err)
|
||
return nil, false
|
||
}
|
||
switch state.Status {
|
||
case GoalStatusComplete, GoalStatusBlocked, GoalStatusStopped:
|
||
default:
|
||
return nil, false
|
||
}
|
||
if len(state.Todos) == 0 {
|
||
return nil, false
|
||
}
|
||
return append([]evidence.TodoItem(nil), state.Todos...), true
|
||
}
|
||
|
||
// restoreRunningFromState reloads the active running goal from the persisted
|
||
// sidecar during cold resume. Terminal sidecar data is intentionally ignored:
|
||
// terminal todo repair is handled by terminalTodosFromState without reviving the
|
||
// goal loop.
|
||
func (g *goalMachine) restoreRunningFromState(sessionPath string) {
|
||
if strings.TrimSpace(sessionPath) == "" {
|
||
return
|
||
}
|
||
data, err := fileencoding.ReadFileUTF8(goalStatePath(sessionPath))
|
||
if err != nil {
|
||
if !os.IsNotExist(err) {
|
||
slog.Warn("controller: read goal state", "err", err)
|
||
}
|
||
return
|
||
}
|
||
var state goalState
|
||
if err := json.Unmarshal(data, &state); err != nil {
|
||
slog.Warn("controller: parse goal state", "err", err)
|
||
return
|
||
}
|
||
if state.Status != GoalStatusRunning || strings.TrimSpace(state.Goal) == "" {
|
||
return
|
||
}
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
g.goal = strings.TrimSpace(state.Goal)
|
||
g.status = GoalStatusRunning
|
||
g.researchMode = state.ResearchMode
|
||
g.autoResearchTaskID = strings.TrimSpace(state.AutoResearchTaskID)
|
||
g.turns = state.Turns
|
||
g.blocks = state.Blocks
|
||
g.block = state.Block
|
||
g.strict = state.Strict
|
||
g.interceptMsg, g.intercepts = "", 0
|
||
g.selfCheckDone, g.idleTurns = false, 0
|
||
}
|
||
|
||
// formatIncompleteTodos renders the reminder shown when [goal:complete] arrives
|
||
// while the executor's canonical todos or project-readiness checks aren't done.
|
||
// Returns empty when nothing is blocking. Pure: the caller gathers todos and the
|
||
// readiness reason from the executor off the goal lock.
|
||
func formatIncompleteTodos(todos []evidence.TodoItem, readiness string) string {
|
||
var parts []string
|
||
if len(todos) > 0 {
|
||
if incomplete := evidence.IncompleteTodos(todos); len(incomplete) > 0 {
|
||
var b strings.Builder
|
||
b.WriteString("the following tasks are still incomplete:")
|
||
for _, t := range incomplete {
|
||
fmt.Fprintf(&b, "\n - %s (%s)", t.Content, t.Status)
|
||
}
|
||
parts = append(parts, b.String())
|
||
}
|
||
}
|
||
if readiness != "" {
|
||
parts = append(parts, readiness)
|
||
}
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("Goal signaled complete but issues remain:\n")
|
||
for _, p := range parts {
|
||
b.WriteString("- ")
|
||
b.WriteString(p)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("Fix or use todo_write/complete_step to mark done, then [goal:complete] again.")
|
||
return b.String()
|
||
}
|
||
|
||
func parseGoalStatusMarker(text string) (status, reason string, ok bool) {
|
||
lines := strings.Split(text, "\n")
|
||
for i := len(lines) - 1; i >= 0; i-- {
|
||
line := strings.TrimSpace(lines[i])
|
||
if line == "" {
|
||
continue
|
||
}
|
||
lower := strings.ToLower(line)
|
||
switch lower {
|
||
case "[goal:complete]":
|
||
return GoalStatusComplete, "", true
|
||
case "[goal:continue]":
|
||
return GoalStatusRunning, "", true
|
||
}
|
||
const blockedPrefix = "[goal:blocked:"
|
||
if strings.HasPrefix(lower, blockedPrefix) && strings.HasSuffix(line, "]") {
|
||
return GoalStatusBlocked, strings.TrimSpace(line[len(blockedPrefix) : len(line)-1]), true
|
||
}
|
||
return "", "", false
|
||
}
|
||
return "", "", false
|
||
}
|
||
|
||
func sameGoalBlock(a, b string) bool {
|
||
return normalizeGoalBlockReason(a) == normalizeGoalBlockReason(b)
|
||
}
|
||
|
||
func cleanGoalBlockReason(reason string) string {
|
||
return strings.Trim(strings.TrimSpace(reason), " \t\r\n::,,.。;;!!??-—_[]()()")
|
||
}
|
||
|
||
func normalizeGoalBlockReason(reason string) string {
|
||
reason = strings.ToLower(cleanGoalBlockReason(reason))
|
||
var b strings.Builder
|
||
lastSpace := true
|
||
for _, r := range reason {
|
||
switch {
|
||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||
b.WriteRune(r)
|
||
lastSpace = false
|
||
default:
|
||
if !lastSpace {
|
||
b.WriteByte(' ')
|
||
lastSpace = true
|
||
}
|
||
}
|
||
}
|
||
return strings.Join(strings.Fields(b.String()), " ")
|
||
}
|
||
|
||
// ShortGoalForNotice collapses whitespace and truncates a goal for one-line UI.
|
||
func ShortGoalForNotice(goal string) string {
|
||
goal = strings.Join(strings.Fields(goal), " ")
|
||
runes := []rune(goal)
|
||
const max = 160
|
||
if len(runes) <= max {
|
||
return goal
|
||
}
|
||
return string(runes[:max]) + "..."
|
||
}
|
||
|
||
// goalTodos snapshots the executor's canonical todos for goal-state persistence.
|
||
func (c *Controller) goalTodos() []evidence.TodoItem {
|
||
if c.executor == nil {
|
||
return nil
|
||
}
|
||
return c.executor.CanonicalTodoState()
|
||
}
|
||
|
||
// persistGoalState writes a freshly built goal state to disk, off c.mu. The
|
||
// executor guard preserves the original behavior of skipping persistence when
|
||
// no executor is attached.
|
||
func (c *Controller) persistGoalState(path string, data []byte, ok bool) {
|
||
if !ok || c.executor == nil {
|
||
return
|
||
}
|
||
c.goals.writeState(path, data)
|
||
}
|
||
|
||
func (c *Controller) restoreTerminalGoalTodos(sessionPath string) {
|
||
if c.executor == nil {
|
||
return
|
||
}
|
||
todos, ok := c.goals.terminalTodosFromState(sessionPath)
|
||
if !ok {
|
||
return
|
||
}
|
||
c.executor.ReplaceTodoState(todos)
|
||
}
|