1package modes
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 agenttools "github.com/bnema/zut/packages/agent/tools"
9 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/provider"
11)
12
13const goalContinueMetaKey = "zut_goal_continue"
14
15func (i *Interactive) goalUpdatesAvailable() bool {
16 ag := i.Agent()
17 if ag == nil {
18 return false
19 }
20 _, ok := ag.ToolsSnapshot()[agenttools.UpdateGoalToolName]
21 return ok
22}
23
24func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
25 if i.cfg.CurrentGoal == nil || !i.goalUpdatesAvailable() {
26 return provider.Message{}, false
27 }
28 goal := i.cfg.CurrentGoal()
29 if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
30 return provider.Message{}, false
31 }
32 text := goal.Objective +
33 "\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."
34 if goal.ConsecutiveNoProgressTurns > 0 {
35 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."
36 }
37 if goal.MissionID != "" {
38 text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
39 }
40 return provider.Message{
41 Role: provider.RoleUser,
42 Content: []provider.Content{provider.TextBlock{Text: text}},
43 Meta: map[string]string{goalContinueMetaKey: "true"},
44 }, true
45}
46
47func (i *Interactive) goalIdleLocked() bool {
48 return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0
49}
50
51func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
52 message, ok := i.goalContinuationMessage()
53 if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil {
54 return false
55 }
56 goal := copySessionGoal(i.cfg.CurrentGoal())
57 if goal == nil || i.limitGoalBeforeRun(goal) {
58 return false
59 }
60 i.mu.Lock()
61 if !i.goalIdleLocked() {
62 i.mu.Unlock()
63 return false
64 }
65 i.busy = true
66 i.mu.Unlock()
67 run, err := i.startGoalRun(goal)
68 if err != nil {
69 i.mu.Lock()
70 i.busy = false
71 i.mu.Unlock()
72 i.ReportError(err)
73 return false
74 }
75 if run == nil {
76 i.mu.Lock()
77 i.busy = false
78 i.mu.Unlock()
79 return false
80 }
81 i.startGoalContinuation(parent, message, run)
82 return true
83}
84
85func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) {
86 goalMessage, goalActive := i.goalContinuationMessage()
87 i.mu.Lock()
88 ag := i.agent
89 if ag == nil {
90 i.busy = false
91 pendingIdleWork := i.takePendingIdleWorkLocked()
92 i.mu.Unlock()
93 runPendingIdleWork(pendingIdleWork)
94 i.abandonGoalRun(run)
95 i.invalidate()
96 return
97 }
98 if len(i.queued) > 0 {
99 next := i.queued[0]
100 i.queued = i.queued[1:]
101 i.mu.Unlock()
102 i.abandonGoalRun(run)
103 i.startTurnWithImages(parent, next.Text, next.Images)
104 return
105 }
106 if ag.QueuedMessageCount() > 0 {
107 i.mu.Unlock()
108 i.abandonGoalRun(run)
109 i.startTurnRequest(parent, "", nil, true, false)
110 return
111 }
112 if !goalActive || userMessageText(goalMessage) != userMessageText(message) {
113 i.busy = false
114 pendingIdleWork := i.takePendingIdleWorkLocked()
115 i.mu.Unlock()
116 runPendingIdleWork(pendingIdleWork)
117 // The objective may have been replaced while the prior turn was
118 // finishing. Re-evaluate it now instead of waiting for another event;
119 // the idle gate still gives queued user work priority.
120 i.abandonGoalRun(run)
121 i.requestGoalContinuationIfIdle(parent)
122 i.invalidate()
123 return
124 }
125 i.mu.Unlock()
126 ag.AppendUserContext(userMessageText(message), message.Meta)
127
128 // Persistence callbacks run synchronously from AppendUserContext, so never
129 // hold the TUI mutex across it. Re-check user work that may have arrived
130 // while the hidden continuation was being persisted.
131 i.mu.Lock()
132 if len(i.queued) > 0 {
133 next := i.queued[0]
134 i.queued = i.queued[1:]
135 i.mu.Unlock()
136 i.abandonGoalRun(run)
137 i.startTurnWithImages(parent, next.Text, next.Images)
138 return
139 }
140 i.mu.Unlock()
141 i.startTurnRequest(parent, "", nil, true, false)
142}
143
144func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal {
145 if goal == nil {
146 return nil
147 }
148 copyGoal := *goal
149 return ©Goal
150}
151
152func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) {
153 if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
154 return
155 }
156 goal := copySessionGoal(i.cfg.CurrentGoal())
157 if goal == nil || goal.Status != core.GoalActive {
158 return
159 }
160 goal.Status = status
161 goal.Reason = reason
162 if err := i.cfg.PersistGoal(goal); err != nil {
163 i.ReportError(fmt.Errorf("persist goal: %w", err))
164 return
165 }
166 i.setGoalStatus(goal)
167}
168
169func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) {
170 if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
171 i.setGoalCommandError("goal: session persistence is unavailable")
172 return
173 }
174
175 current := copySessionGoal(i.cfg.CurrentGoal())
176 if len(parts) == 2 && strings.EqualFold(parts[1], "history") {
177 if i.cfg.CurrentGoalHistory == nil {
178 i.setGoalCommandError("goal: history is unavailable")
179 return
180 }
181 history := i.cfg.CurrentGoalHistory()
182 if len(history) == 0 {
183 i.setGoalCommandStatus("no goal history")
184 return
185 }
186 entries := make([]string, 0, len(history))
187 for _, goal := range history {
188 owner := goal.Owner
189 if owner == "" {
190 owner = core.GoalOwnerUser
191 }
192 entry := fmt.Sprintf("%s (%s): %s", goal.Status, owner, goal.Objective)
193 if goal.Reason != "" {
194 entry += " (" + goal.Reason + ")"
195 }
196 entries = append(entries, entry)
197 }
198 i.setGoalCommandStatus("goal history: " + strings.Join(entries, " → "))
199 return
200 }
201 if len(parts) == 1 {
202 if current == nil {
203 i.setGoalCommandStatus("no autonomous goal")
204 return
205 }
206 owner := current.Owner
207 if owner == "" {
208 owner = core.GoalOwnerUser
209 }
210 status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective)
211 if current.TokenBudget != nil {
212 status += fmt.Sprintf(" (%d / %d tokens)", current.TokensUsed, *current.TokenBudget)
213 } else if current.TokensUsed > 0 {
214 status += fmt.Sprintf(" (%d tokens)", current.TokensUsed)
215 }
216 if current.Reason != "" {
217 status += " (" + current.Reason + ")"
218 }
219 i.setGoalCommandStatus(status)
220 return
221 }
222
223 startIfIdle := false
224 startTitle := false
225 arg := ""
226 if len(parts) == 2 {
227 arg = strings.ToLower(parts[1])
228 }
229 switch arg {
230 case "pause":
231 if current == nil {
232 i.setGoalCommandError("goal: no goal to pause")
233 return
234 }
235 current.Status = core.GoalPaused
236 current.Reason = ""
237 case "resume":
238 if current == nil {
239 i.setGoalCommandError("goal: no goal to resume")
240 return
241 }
242 current.Status = core.GoalActive
243 current.Reason = ""
244 startIfIdle = true
245 case "clear":
246 if err := i.cfg.PersistGoal(nil); err != nil {
247 i.setGoalCommandError("goal: " + err.Error())
248 return
249 }
250 i.setGoalStatus(nil)
251 i.setGoalCommandStatus("autonomous goal cleared")
252 return
253 default:
254 objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))
255 if objective == "" {
256 i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear|history")
257 return
258 }
259 current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser}
260 if i.cfg.GoalMaxTokenBudget != nil {
261 budget := *i.cfg.GoalMaxTokenBudget
262 if budget > 0 {
263 current.TokenBudget = &budget
264 }
265 }
266 startIfIdle = true
267 startTitle = true
268 }
269
270 if startIfIdle && !i.goalUpdatesAvailable() {
271 i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools")
272 return
273 }
274 if err := i.cfg.PersistGoal(current); err != nil {
275 i.setGoalCommandError("goal: " + err.Error())
276 return
277 }
278 i.setGoalStatus(current)
279 i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))
280 if startTitle {
281 i.maybeStartSessionTitle(ctx, current.Objective)
282 }
283 if startIfIdle {
284 i.requestGoalContinuationIfIdle(ctx)
285 }
286}
287
288// RefreshGoal reloads the status badge after the CLI commits a session switch.
289func (i *Interactive) RefreshGoal() {
290 if i.cfg.CurrentGoal == nil {
291 i.setGoalStatus(nil)
292 return
293 }
294 i.setGoalStatus(i.cfg.CurrentGoal())
295}
296
297func (i *Interactive) setGoalStatus(goal *core.SessionGoal) {
298 status := core.GoalStatus("")
299 if goal != nil {
300 status = goal.Status
301 }
302 i.mu.Lock()
303 i.goalStatus = status
304 i.mu.Unlock()
305 i.invalidate()
306}
307
308func (i *Interactive) setGoalCommandStatus(message string) {
309 i.mu.Lock()
310 i.statusOK = message
311 i.statusErr = ""
312 i.mu.Unlock()
313 i.invalidate()
314}
315
316func (i *Interactive) setGoalCommandError(message string) {
317 i.mu.Lock()
318 i.statusOK = ""
319 i.statusErr = message
320 i.mu.Unlock()
321 i.invalidate()
322}