Files
2026-07-13 13:00:08 +08:00

533 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}