v0.38.0 58133ea

goal.go RawHistoryFolder

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 &copyGoal
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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close