package modes import ( "context" "fmt" "strings" agenttools "github.com/bnema/zut/packages/agent/tools" "github.com/bnema/zut/packages/core" "github.com/bnema/zut/packages/provider" ) const goalContinueMetaKey = "zut_goal_continue" // goalInterruptReassessMetaKey marks the hidden post-interruption // instruction appended before the next ordinary user prompt. It is filtered // from the rendered transcript like goalContinueMetaKey. const goalInterruptReassessMetaKey = "zut_goal_interrupt_reassess" // interruptedGoalState is transient controller state recording that Esc // cancelled the running turn of an active goal. nextPromptArmed means the // immediately following ordinary user prompt must carry the hidden // reassessment instruction; returnAfterTurn means that prompt completed // cleanly and the same goal may resume once via requestGoalContinuationIfIdle. type interruptedGoalState struct { goalID string missionID string nextPromptArmed bool returnAfterTurn bool } func (i *Interactive) goalUpdatesAvailable() bool { ag := i.Agent() if ag == nil { return false } _, ok := ag.ToolsSnapshot()[agenttools.UpdateGoalToolName] return ok } func (i *Interactive) goalContinuationMessage() (provider.Message, bool) { if i.cfg.CurrentGoal == nil || !i.goalUpdatesAvailable() { return provider.Message{}, false } goal := i.cfg.CurrentGoal() if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" { return provider.Message{}, false } text := goal.Objective + "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal. If the current objective has become invalid and a replacement is necessary, you may supersede it only with a concrete objective inside the same mission; never use superseded to avoid difficult work or broaden scope." if goal.ConsecutiveNoProgressTurns > 0 { text += " You ended the prior continuation without taking a concrete action. Inspect the current state and take the next action now; do not merely say that you will continue." } if goal.ID != "" { text += " When superseding this goal, use goal_id " + goal.ID + "." } if goal.MissionID != "" { text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission." } return provider.Message{ Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}, Meta: map[string]string{goalContinueMetaKey: "true"}, }, true } // armInterruptedGoalReturn records a transient return intent when Esc cancels // the running turn of an active goal. The persisted goal stays active; only // the next ordinary user prompt carries the hidden reassessment instruction // and may resume the same goal afterwards. func (i *Interactive) armInterruptedGoalReturn() { if i.cfg.CurrentGoal == nil { return } goal := i.cfg.CurrentGoal() if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" { return } i.mu.Lock() i.interruptedGoalReturn = &interruptedGoalState{ goalID: goal.ID, missionID: goal.MissionID, nextPromptArmed: true, } i.mu.Unlock() } // interruptedGoalMatchesLocked reports whether transient interruption state // still targets the currently persisted active goal. Callers must hold i.mu. func (i *Interactive) interruptedGoalMatchesLocked() bool { state := i.interruptedGoalReturn if state == nil || i.cfg.CurrentGoal == nil { return false } goal := i.cfg.CurrentGoal() if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" { return false } if state.goalID != "" && goal.ID != "" && state.goalID != goal.ID { return false } if state.missionID != "" && goal.MissionID != "" && state.missionID != goal.MissionID { return false } return true } // clearInterruptedGoalReturn drops transient interruption state without // touching the persisted goal. func (i *Interactive) clearInterruptedGoalReturn() { i.mu.Lock() i.interruptedGoalReturn = nil i.mu.Unlock() } // consumeInterruptedGoalPrompt binds pending interruption state to the // ordinary user turn that is actually starting. It appends the hidden // reassessment instruction before that prompt and records that a clean // completion of this turn owns one automatic return to the same goal. func (i *Interactive) consumeInterruptedGoalPrompt() { i.mu.Lock() state := i.interruptedGoalReturn matches := i.interruptedGoalMatchesLocked() if state == nil || !state.nextPromptArmed || !matches { // Stale or mismatched state must never leak into an unrelated turn. if state != nil && (!state.nextPromptArmed && !state.returnAfterTurn || !matches) { i.interruptedGoalReturn = nil } i.mu.Unlock() return } state.nextPromptArmed = false state.returnAfterTurn = true i.mu.Unlock() message, ok := i.goalInterruptReassessmentMessage() if !ok { i.mu.Lock() if i.interruptedGoalReturn == state { i.interruptedGoalReturn = nil } i.mu.Unlock() return } ag := i.Agent() if ag == nil { i.mu.Lock() if i.interruptedGoalReturn == state { i.interruptedGoalReturn = nil } i.mu.Unlock() return } ag.AppendUserContext(userMessageText(message), message.Meta) } // goalInterruptReassessmentMessage builds the hidden instruction appended // immediately before the next ordinary user prompt after Esc. It requires // handling the latest user message first and resuming incidental work // autonomously; update_goal is reserved for genuine lifecycle transitions. func (i *Interactive) goalInterruptReassessmentMessage() (provider.Message, bool) { if i.cfg.CurrentGoal == nil { return provider.Message{}, false } goal := i.cfg.CurrentGoal() if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" { return provider.Message{}, false } text := "The previous turn was interrupted by the user; the autonomous goal below remains active. " + "Handle the latest user message first. Classify it as changing, settling, blocking, or incidental to that goal. " + "If it is incidental, answer it briefly and then continue the goal autonomously without waiting for the user to say continue. " + "Call update_goal only for a genuine complete, blocked, or superseded transition.\n\nActive goal: " + goal.Objective if goal.ID != "" { text += " When superseding this goal, use goal_id " + goal.ID + "." } if goal.MissionID != "" { text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission." } return provider.Message{ Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}, Meta: map[string]string{goalInterruptReassessMetaKey: "true"}, }, true } func (i *Interactive) goalIdleLocked() bool { return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0 } // interruptedPromptPendingLocked reports whether Esc armed a reassessment // prompt that has not yet started. While set, autonomous goal wakes must // yield so the next ordinary user message is handled first. Callers must // hold i.mu. func (i *Interactive) interruptedPromptPendingLocked() bool { state := i.interruptedGoalReturn return state != nil && state.nextPromptArmed } // interruptedPromptPending reports whether Esc armed a reassessment prompt // that has not yet started. func (i *Interactive) interruptedPromptPending() bool { i.mu.Lock() defer i.mu.Unlock() return i.interruptedPromptPendingLocked() } func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool { message, ok := i.goalContinuationMessage() if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil || i.interruptedPromptPending() { return false } goal := copySessionGoal(i.cfg.CurrentGoal()) if goal == nil || i.limitGoalBeforeRun(goal) { return false } i.mu.Lock() if !i.goalIdleLocked() { i.mu.Unlock() return false } i.busy = true i.mu.Unlock() run, err := i.startGoalRun(goal) if err != nil { i.mu.Lock() i.busy = false i.mu.Unlock() i.ReportError(err) return false } if run == nil { i.mu.Lock() i.busy = false i.mu.Unlock() return false } i.startGoalContinuation(parent, message, run) return true } // startReservedGoalContinuation starts a goal turn after another controller // has already reserved the interactive turn slot by setting busy. It is used // by compact handoffs so a fresh-context continuation receives the same lease, // accounting, and no-progress policy as an ordinary idle goal turn. func (i *Interactive) startReservedGoalContinuation(parent context.Context) { message, ok := i.goalContinuationMessage() if !ok || i.cfg.CurrentGoal == nil || i.interruptedPromptPending() { i.mu.Lock() i.busy = false i.mu.Unlock() i.resetCompactHandoff() i.invalidate() return } goal := copySessionGoal(i.cfg.CurrentGoal()) if goal == nil || i.limitGoalBeforeRun(goal) { i.mu.Lock() i.busy = false i.mu.Unlock() i.resetCompactHandoff() i.invalidate() return } run, err := i.startGoalRun(goal) if err != nil || run == nil { i.mu.Lock() i.busy = false i.mu.Unlock() if err != nil { i.ReportError(err) } i.invalidate() return } i.startGoalContinuation(parent, message, run) } func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) { goalMessage, goalActive := i.goalContinuationMessage() i.mu.Lock() ag := i.agent if ag == nil { i.busy = false pendingIdleWork := i.takePendingIdleWorkLocked() i.mu.Unlock() runPendingIdleWork(pendingIdleWork) i.abandonGoalRun(run) i.invalidate() return } if len(i.queued) > 0 { next := i.queued[0] i.queued = i.queued[1:] i.mu.Unlock() i.abandonGoalRun(run) i.startTurnWithImages(parent, next.Text, next.Images) return } if ag.QueuedMessageCount() > 0 { i.mu.Unlock() i.abandonGoalRun(run) i.startTurnRequest(parent, "", nil, true, false) return } if !goalActive || userMessageText(goalMessage) != userMessageText(message) { i.busy = false pendingIdleWork := i.takePendingIdleWorkLocked() i.mu.Unlock() runPendingIdleWork(pendingIdleWork) // The objective may have been replaced while the prior turn was // finishing. Re-evaluate it now instead of waiting for another event; // the idle gate still gives queued user work priority. i.abandonGoalRun(run) i.requestGoalContinuationIfIdle(parent) i.invalidate() return } i.mu.Unlock() ag.AppendUserContext(userMessageText(message), message.Meta) // Persistence callbacks run synchronously from AppendUserContext, so never // hold the TUI mutex across it. Re-check user work that may have arrived // while the hidden continuation was being persisted. i.mu.Lock() if len(i.queued) > 0 { next := i.queued[0] i.queued = i.queued[1:] i.mu.Unlock() i.abandonGoalRun(run) i.startTurnWithImages(parent, next.Text, next.Images) return } i.mu.Unlock() i.startTurnRequest(parent, "", nil, true, false) } func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal { if goal == nil { return nil } copyGoal := *goal return ©Goal } func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) { if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil { return } goal := copySessionGoal(i.cfg.CurrentGoal()) if goal == nil || goal.Status != core.GoalActive { return } goal.Status = status goal.Reason = reason if err := i.cfg.PersistGoal(goal); err != nil { i.ReportError(fmt.Errorf("persist goal: %w", err)) return } i.setGoalStatus(goal) } func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) { if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil { i.setGoalCommandError("goal: session persistence is unavailable") return } current := copySessionGoal(i.cfg.CurrentGoal()) if len(parts) == 2 && strings.EqualFold(parts[1], "history") { if i.cfg.CurrentGoalHistory == nil { i.setGoalCommandError("goal: history is unavailable") return } history := i.cfg.CurrentGoalHistory() if len(history) == 0 { i.setGoalCommandStatus("no goal history") return } entries := make([]string, 0, len(history)) for _, goal := range history { owner := goal.Owner if owner == "" { owner = core.GoalOwnerUser } entry := fmt.Sprintf("%s (%s): %s", goal.Status, owner, goal.Objective) if goal.Reason != "" { entry += " (" + goal.Reason + ")" } entries = append(entries, entry) } i.setGoalCommandStatus("goal history: " + strings.Join(entries, " → ")) return } if len(parts) == 1 { if current == nil { i.setGoalCommandStatus("no autonomous goal") return } owner := current.Owner if owner == "" { owner = core.GoalOwnerUser } status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective) if current.TokenBudget != nil { status += fmt.Sprintf(" (%d / %d tokens)", current.TokensUsed, *current.TokenBudget) } else if current.TokensUsed > 0 { status += fmt.Sprintf(" (%d tokens)", current.TokensUsed) } if current.Reason != "" { status += " (" + current.Reason + ")" } i.setGoalCommandStatus(status) return } startIfIdle := false startTitle := false arg := "" if len(parts) == 2 { arg = strings.ToLower(parts[1]) } switch arg { case "pause": if current == nil { i.setGoalCommandError("goal: no goal to pause") return } current.Status = core.GoalPaused current.Reason = "" // An explicit pause is durable: drop any armed Esc return so the // next prompt cannot resume the goal. /goal resume restarts it. i.clearInterruptedGoalReturn() case "resume": if current == nil { i.setGoalCommandError("goal: no goal to resume") return } current.Status = core.GoalActive current.Reason = "" startIfIdle = true case "clear": if err := i.cfg.PersistGoal(nil); err != nil { i.setGoalCommandError("goal: " + err.Error()) return } i.clearInterruptedGoalReturn() i.setGoalStatus(nil) i.setGoalCommandStatus("autonomous goal cleared") return default: objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0])) if objective == "" { i.setGoalCommandError("usage: /goal |pause|resume|clear|history") return } current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser} if i.cfg.GoalMaxTokenBudget != nil { budget := *i.cfg.GoalMaxTokenBudget if budget > 0 { current.TokenBudget = &budget } } startIfIdle = true startTitle = true } if startIfIdle && !i.goalUpdatesAvailable() { i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools") return } if err := i.cfg.PersistGoal(current); err != nil { i.setGoalCommandError("goal: " + err.Error()) return } // An explicit resume or replacement supersedes Esc-armed reassessment: // the command owns the next wake, not the pending ordinary prompt. if startIfIdle { i.clearInterruptedGoalReturn() if i.agent != nil { i.agent.ResetRepetitionGuard() } } i.setGoalStatus(current) i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective)) if startTitle { i.maybeStartSessionTitle(ctx, current.Objective) } if startIfIdle { i.requestGoalContinuationIfIdle(ctx) } } // RefreshGoal reloads the status badge after the CLI commits a session switch. func (i *Interactive) RefreshGoal() { // A session replacement invalidates transient interruption state: the next // prompt in the new session must not inherit the prior goal's return. i.clearInterruptedGoalReturn() if i.cfg.CurrentGoal == nil { i.setGoalStatus(nil) return } i.setGoalStatus(i.cfg.CurrentGoal()) } func (i *Interactive) setGoalStatus(goal *core.SessionGoal) { status := core.GoalStatus("") if goal != nil { status = goal.Status } i.mu.Lock() i.goalStatus = status i.mu.Unlock() i.invalidate() } func (i *Interactive) setGoalCommandStatus(message string) { i.mu.Lock() i.statusOK = message i.statusErr = "" i.mu.Unlock() i.invalidate() } func (i *Interactive) setGoalCommandError(message string) { i.mu.Lock() i.statusOK = "" i.statusErr = message i.mu.Unlock() i.invalidate() }