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
15// goalInterruptReassessMetaKey marks the hidden post-interruption
16// instruction appended before the next ordinary user prompt. It is filtered
17// from the rendered transcript like goalContinueMetaKey.
18const goalInterruptReassessMetaKey = "zut_goal_interrupt_reassess"
19
20// interruptedGoalState is transient controller state recording that Esc
21// cancelled the running turn of an active goal. nextPromptArmed means the
22// immediately following ordinary user prompt must carry the hidden
23// reassessment instruction; returnAfterTurn means that prompt completed
24// cleanly and the same goal may resume once via requestGoalContinuationIfIdle.
25type interruptedGoalState struct {
26 goalID string
27 missionID string
28 nextPromptArmed bool
29 returnAfterTurn bool
30}
31
32func (i *Interactive) goalUpdatesAvailable() bool {
33 ag := i.Agent()
34 if ag == nil {
35 return false
36 }
37 _, ok := ag.ToolsSnapshot()[agenttools.UpdateGoalToolName]
38 return ok
39}
40
41func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
42 if i.cfg.CurrentGoal == nil || !i.goalUpdatesAvailable() {
43 return provider.Message{}, false
44 }
45 goal := i.cfg.CurrentGoal()
46 if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
47 return provider.Message{}, false
48 }
49 text := goal.Objective +
50 "\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."
51 if goal.ConsecutiveNoProgressTurns > 0 {
52 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."
53 }
54 if goal.ID != "" {
55 text += " When superseding this goal, use goal_id " + goal.ID + "."
56 }
57 if goal.MissionID != "" {
58 text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
59 }
60 return provider.Message{
61 Role: provider.RoleUser,
62 Content: []provider.Content{provider.TextBlock{Text: text}},
63 Meta: map[string]string{goalContinueMetaKey: "true"},
64 }, true
65}
66
67// armInterruptedGoalReturn records a transient return intent when Esc cancels
68// the running turn of an active goal. The persisted goal stays active; only
69// the next ordinary user prompt carries the hidden reassessment instruction
70// and may resume the same goal afterwards.
71func (i *Interactive) armInterruptedGoalReturn() {
72 if i.cfg.CurrentGoal == nil {
73 return
74 }
75 goal := i.cfg.CurrentGoal()
76 if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
77 return
78 }
79 i.mu.Lock()
80 i.interruptedGoalReturn = &interruptedGoalState{
81 goalID: goal.ID,
82 missionID: goal.MissionID,
83 nextPromptArmed: true,
84 }
85 i.mu.Unlock()
86}
87
88// interruptedGoalMatchesLocked reports whether transient interruption state
89// still targets the currently persisted active goal. Callers must hold i.mu.
90func (i *Interactive) interruptedGoalMatchesLocked() bool {
91 state := i.interruptedGoalReturn
92 if state == nil || i.cfg.CurrentGoal == nil {
93 return false
94 }
95 goal := i.cfg.CurrentGoal()
96 if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
97 return false
98 }
99 if state.goalID != "" && goal.ID != "" && state.goalID != goal.ID {
100 return false
101 }
102 if state.missionID != "" && goal.MissionID != "" && state.missionID != goal.MissionID {
103 return false
104 }
105 return true
106}
107
108// clearInterruptedGoalReturn drops transient interruption state without
109// touching the persisted goal.
110func (i *Interactive) clearInterruptedGoalReturn() {
111 i.mu.Lock()
112 i.interruptedGoalReturn = nil
113 i.mu.Unlock()
114}
115
116// consumeInterruptedGoalPrompt binds pending interruption state to the
117// ordinary user turn that is actually starting. It appends the hidden
118// reassessment instruction before that prompt and records that a clean
119// completion of this turn owns one automatic return to the same goal.
120func (i *Interactive) consumeInterruptedGoalPrompt() {
121 i.mu.Lock()
122 state := i.interruptedGoalReturn
123 matches := i.interruptedGoalMatchesLocked()
124 if state == nil || !state.nextPromptArmed || !matches {
125 // Stale or mismatched state must never leak into an unrelated turn.
126 if state != nil && (!state.nextPromptArmed && !state.returnAfterTurn || !matches) {
127 i.interruptedGoalReturn = nil
128 }
129 i.mu.Unlock()
130 return
131 }
132 state.nextPromptArmed = false
133 state.returnAfterTurn = true
134 i.mu.Unlock()
135
136 message, ok := i.goalInterruptReassessmentMessage()
137 if !ok {
138 i.mu.Lock()
139 if i.interruptedGoalReturn == state {
140 i.interruptedGoalReturn = nil
141 }
142 i.mu.Unlock()
143 return
144 }
145 ag := i.Agent()
146 if ag == nil {
147 i.mu.Lock()
148 if i.interruptedGoalReturn == state {
149 i.interruptedGoalReturn = nil
150 }
151 i.mu.Unlock()
152 return
153 }
154 ag.AppendUserContext(userMessageText(message), message.Meta)
155}
156
157// goalInterruptReassessmentMessage builds the hidden instruction appended
158// immediately before the next ordinary user prompt after Esc. It requires
159// handling the latest user message first and resuming incidental work
160// autonomously; update_goal is reserved for genuine lifecycle transitions.
161func (i *Interactive) goalInterruptReassessmentMessage() (provider.Message, bool) {
162 if i.cfg.CurrentGoal == nil {
163 return provider.Message{}, false
164 }
165 goal := i.cfg.CurrentGoal()
166 if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
167 return provider.Message{}, false
168 }
169 text := "The previous turn was interrupted by the user; the autonomous goal below remains active. " +
170 "Handle the latest user message first. Classify it as changing, settling, blocking, or incidental to that goal. " +
171 "If it is incidental, answer it briefly and then continue the goal autonomously without waiting for the user to say continue. " +
172 "Call update_goal only for a genuine complete, blocked, or superseded transition.\n\nActive goal: " + goal.Objective
173 if goal.ID != "" {
174 text += " When superseding this goal, use goal_id " + goal.ID + "."
175 }
176 if goal.MissionID != "" {
177 text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
178 }
179 return provider.Message{
180 Role: provider.RoleUser,
181 Content: []provider.Content{provider.TextBlock{Text: text}},
182 Meta: map[string]string{goalInterruptReassessMetaKey: "true"},
183 }, true
184}
185
186func (i *Interactive) goalIdleLocked() bool {
187 return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0
188}
189
190// interruptedPromptPendingLocked reports whether Esc armed a reassessment
191// prompt that has not yet started. While set, autonomous goal wakes must
192// yield so the next ordinary user message is handled first. Callers must
193// hold i.mu.
194func (i *Interactive) interruptedPromptPendingLocked() bool {
195 state := i.interruptedGoalReturn
196 return state != nil && state.nextPromptArmed
197}
198
199// interruptedPromptPending reports whether Esc armed a reassessment prompt
200// that has not yet started.
201func (i *Interactive) interruptedPromptPending() bool {
202 i.mu.Lock()
203 defer i.mu.Unlock()
204 return i.interruptedPromptPendingLocked()
205}
206
207func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
208 message, ok := i.goalContinuationMessage()
209 if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil || i.interruptedPromptPending() {
210 return false
211 }
212 goal := copySessionGoal(i.cfg.CurrentGoal())
213 if goal == nil || i.limitGoalBeforeRun(goal) {
214 return false
215 }
216 i.mu.Lock()
217 if !i.goalIdleLocked() {
218 i.mu.Unlock()
219 return false
220 }
221 i.busy = true
222 i.mu.Unlock()
223 run, err := i.startGoalRun(goal)
224 if err != nil {
225 i.mu.Lock()
226 i.busy = false
227 i.mu.Unlock()
228 i.ReportError(err)
229 return false
230 }
231 if run == nil {
232 i.mu.Lock()
233 i.busy = false
234 i.mu.Unlock()
235 return false
236 }
237 i.startGoalContinuation(parent, message, run)
238 return true
239}
240
241// startReservedGoalContinuation starts a goal turn after another controller
242// has already reserved the interactive turn slot by setting busy. It is used
243// by compact handoffs so a fresh-context continuation receives the same lease,
244// accounting, and no-progress policy as an ordinary idle goal turn.
245func (i *Interactive) startReservedGoalContinuation(parent context.Context) {
246 message, ok := i.goalContinuationMessage()
247 if !ok || i.cfg.CurrentGoal == nil || i.interruptedPromptPending() {
248 i.mu.Lock()
249 i.busy = false
250 i.mu.Unlock()
251 i.resetCompactHandoff()
252 i.invalidate()
253 return
254 }
255 goal := copySessionGoal(i.cfg.CurrentGoal())
256 if goal == nil || i.limitGoalBeforeRun(goal) {
257 i.mu.Lock()
258 i.busy = false
259 i.mu.Unlock()
260 i.resetCompactHandoff()
261 i.invalidate()
262 return
263 }
264 run, err := i.startGoalRun(goal)
265 if err != nil || run == nil {
266 i.mu.Lock()
267 i.busy = false
268 i.mu.Unlock()
269 if err != nil {
270 i.ReportError(err)
271 }
272 i.invalidate()
273 return
274 }
275 i.startGoalContinuation(parent, message, run)
276}
277
278func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) {
279 goalMessage, goalActive := i.goalContinuationMessage()
280 i.mu.Lock()
281 ag := i.agent
282 if ag == nil {
283 i.busy = false
284 pendingIdleWork := i.takePendingIdleWorkLocked()
285 i.mu.Unlock()
286 runPendingIdleWork(pendingIdleWork)
287 i.abandonGoalRun(run)
288 i.invalidate()
289 return
290 }
291 if len(i.queued) > 0 {
292 next := i.queued[0]
293 i.queued = i.queued[1:]
294 i.mu.Unlock()
295 i.abandonGoalRun(run)
296 i.startTurnWithImages(parent, next.Text, next.Images)
297 return
298 }
299 if ag.QueuedMessageCount() > 0 {
300 i.mu.Unlock()
301 i.abandonGoalRun(run)
302 i.startTurnRequest(parent, "", nil, true, false)
303 return
304 }
305 if !goalActive || userMessageText(goalMessage) != userMessageText(message) {
306 i.busy = false
307 pendingIdleWork := i.takePendingIdleWorkLocked()
308 i.mu.Unlock()
309 runPendingIdleWork(pendingIdleWork)
310 // The objective may have been replaced while the prior turn was
311 // finishing. Re-evaluate it now instead of waiting for another event;
312 // the idle gate still gives queued user work priority.
313 i.abandonGoalRun(run)
314 i.requestGoalContinuationIfIdle(parent)
315 i.invalidate()
316 return
317 }
318 i.mu.Unlock()
319 ag.AppendUserContext(userMessageText(message), message.Meta)
320
321 // Persistence callbacks run synchronously from AppendUserContext, so never
322 // hold the TUI mutex across it. Re-check user work that may have arrived
323 // while the hidden continuation was being persisted.
324 i.mu.Lock()
325 if len(i.queued) > 0 {
326 next := i.queued[0]
327 i.queued = i.queued[1:]
328 i.mu.Unlock()
329 i.abandonGoalRun(run)
330 i.startTurnWithImages(parent, next.Text, next.Images)
331 return
332 }
333 i.mu.Unlock()
334 i.startTurnRequest(parent, "", nil, true, false)
335}
336
337func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal {
338 if goal == nil {
339 return nil
340 }
341 copyGoal := *goal
342 return ©Goal
343}
344
345func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) {
346 if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
347 return
348 }
349 goal := copySessionGoal(i.cfg.CurrentGoal())
350 if goal == nil || goal.Status != core.GoalActive {
351 return
352 }
353 goal.Status = status
354 goal.Reason = reason
355 if err := i.cfg.PersistGoal(goal); err != nil {
356 i.ReportError(fmt.Errorf("persist goal: %w", err))
357 return
358 }
359 i.setGoalStatus(goal)
360}
361
362func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) {
363 if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
364 i.setGoalCommandError("goal: session persistence is unavailable")
365 return
366 }
367
368 current := copySessionGoal(i.cfg.CurrentGoal())
369 if len(parts) == 2 && strings.EqualFold(parts[1], "history") {
370 if i.cfg.CurrentGoalHistory == nil {
371 i.setGoalCommandError("goal: history is unavailable")
372 return
373 }
374 history := i.cfg.CurrentGoalHistory()
375 if len(history) == 0 {
376 i.setGoalCommandStatus("no goal history")
377 return
378 }
379 entries := make([]string, 0, len(history))
380 for _, goal := range history {
381 owner := goal.Owner
382 if owner == "" {
383 owner = core.GoalOwnerUser
384 }
385 entry := fmt.Sprintf("%s (%s): %s", goal.Status, owner, goal.Objective)
386 if goal.Reason != "" {
387 entry += " (" + goal.Reason + ")"
388 }
389 entries = append(entries, entry)
390 }
391 i.setGoalCommandStatus("goal history: " + strings.Join(entries, " → "))
392 return
393 }
394 if len(parts) == 1 {
395 if current == nil {
396 i.setGoalCommandStatus("no autonomous goal")
397 return
398 }
399 owner := current.Owner
400 if owner == "" {
401 owner = core.GoalOwnerUser
402 }
403 status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective)
404 if current.TokenBudget != nil {
405 status += fmt.Sprintf(" (%d / %d tokens)", current.TokensUsed, *current.TokenBudget)
406 } else if current.TokensUsed > 0 {
407 status += fmt.Sprintf(" (%d tokens)", current.TokensUsed)
408 }
409 if current.Reason != "" {
410 status += " (" + current.Reason + ")"
411 }
412 i.setGoalCommandStatus(status)
413 return
414 }
415
416 startIfIdle := false
417 startTitle := false
418 arg := ""
419 if len(parts) == 2 {
420 arg = strings.ToLower(parts[1])
421 }
422 switch arg {
423 case "pause":
424 if current == nil {
425 i.setGoalCommandError("goal: no goal to pause")
426 return
427 }
428 current.Status = core.GoalPaused
429 current.Reason = ""
430 // An explicit pause is durable: drop any armed Esc return so the
431 // next prompt cannot resume the goal. /goal resume restarts it.
432 i.clearInterruptedGoalReturn()
433 case "resume":
434 if current == nil {
435 i.setGoalCommandError("goal: no goal to resume")
436 return
437 }
438 current.Status = core.GoalActive
439 current.Reason = ""
440 startIfIdle = true
441 case "clear":
442 if err := i.cfg.PersistGoal(nil); err != nil {
443 i.setGoalCommandError("goal: " + err.Error())
444 return
445 }
446 i.clearInterruptedGoalReturn()
447 i.setGoalStatus(nil)
448 i.setGoalCommandStatus("autonomous goal cleared")
449 return
450 default:
451 objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))
452 if objective == "" {
453 i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear|history")
454 return
455 }
456 current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser}
457 if i.cfg.GoalMaxTokenBudget != nil {
458 budget := *i.cfg.GoalMaxTokenBudget
459 if budget > 0 {
460 current.TokenBudget = &budget
461 }
462 }
463 startIfIdle = true
464 startTitle = true
465 }
466
467 if startIfIdle && !i.goalUpdatesAvailable() {
468 i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools")
469 return
470 }
471 if err := i.cfg.PersistGoal(current); err != nil {
472 i.setGoalCommandError("goal: " + err.Error())
473 return
474 }
475 // An explicit resume or replacement supersedes Esc-armed reassessment:
476 // the command owns the next wake, not the pending ordinary prompt.
477 if startIfIdle {
478 i.clearInterruptedGoalReturn()
479 }
480 i.setGoalStatus(current)
481 i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))
482 if startTitle {
483 i.maybeStartSessionTitle(ctx, current.Objective)
484 }
485 if startIfIdle {
486 i.requestGoalContinuationIfIdle(ctx)
487 }
488}
489
490// RefreshGoal reloads the status badge after the CLI commits a session switch.
491func (i *Interactive) RefreshGoal() {
492 // A session replacement invalidates transient interruption state: the next
493 // prompt in the new session must not inherit the prior goal's return.
494 i.clearInterruptedGoalReturn()
495 if i.cfg.CurrentGoal == nil {
496 i.setGoalStatus(nil)
497 return
498 }
499 i.setGoalStatus(i.cfg.CurrentGoal())
500}
501
502func (i *Interactive) setGoalStatus(goal *core.SessionGoal) {
503 status := core.GoalStatus("")
504 if goal != nil {
505 status = goal.Status
506 }
507 i.mu.Lock()
508 i.goalStatus = status
509 i.mu.Unlock()
510 i.invalidate()
511}
512
513func (i *Interactive) setGoalCommandStatus(message string) {
514 i.mu.Lock()
515 i.statusOK = message
516 i.statusErr = ""
517 i.mu.Unlock()
518 i.invalidate()
519}
520
521func (i *Interactive) setGoalCommandError(message string) {
522 i.mu.Lock()
523 i.statusOK = ""
524 i.statusErr = message
525 i.mu.Unlock()
526 i.invalidate()
527}