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