v0.51.0 a9d674e

goal_runtime.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"strings"
6
7	agenttools "github.com/bnema/zut/packages/agent/tools"
8	"github.com/bnema/zut/packages/core"
9	"github.com/bnema/zut/packages/provider"
10	"github.com/google/uuid"
11)
12
13// A second empty continuation is enough to distinguish a model that briefly
14// paused to summarize from one that is only acknowledging the continuation
15// prompt. This is a progress invariant, not a resource limit: productive
16// autonomous goals remain unbounded unless the user configures a budget.
17const maxConsecutiveGoalNoProgressTurns = 2
18
19type goalContinuationRun struct {
20	id      string
21	goalID  string
22	tokens  uint64
23	hadTool bool
24}
25
26func goalTokenCount(usage provider.Usage) uint64 {
27	// OutputTokens already includes provider-reported reasoning tokens. Cache
28	// usage is separately billable and therefore included in the goal total.
29	counts := []int{usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens}
30	var total uint64
31	for _, count := range counts {
32		if count > 0 {
33			total += uint64(count)
34		}
35	}
36	return total
37}
38
39func (i *Interactive) limitGoalBeforeRun(goal *core.SessionGoal) bool {
40	if goal == nil || goal.Status != core.GoalActive || goal.TokenBudget == nil || goal.TokensUsed < *goal.TokenBudget {
41		return false
42	}
43	goal = copySessionGoal(goal)
44	goal.Status = core.GoalBudgetLimited
45	goal.Reason = "configured token budget exhausted"
46	goal.ContinuationID = ""
47	if i.cfg.PersistGoal == nil {
48		i.ReportError(fmt.Errorf("goal persistence is unavailable"))
49		return true
50	}
51	if err := i.cfg.PersistGoal(goal); err != nil {
52		i.ReportError(fmt.Errorf("persist goal budget limit: %w", err))
53		return true
54	}
55	i.setGoalStatus(goal)
56	return true
57}
58
59func (i *Interactive) startGoalRun(goal *core.SessionGoal) (*goalContinuationRun, error) {
60	if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
61		return nil, nil
62	}
63	i.mu.Lock()
64	if i.goalRun != nil {
65		i.mu.Unlock()
66		return nil, nil
67	}
68	run := &goalContinuationRun{id: uuid.NewString(), goalID: goal.ID}
69	i.goalRun = run
70	i.mu.Unlock()
71
72	goal = copySessionGoal(goal)
73	goal.ContinuationID = run.id
74	if i.cfg.PersistGoalRuntime == nil {
75		return run, nil
76	}
77	if err := i.cfg.PersistGoalRuntime(goal); err != nil {
78		i.clearGoalRun(run.id)
79		return nil, fmt.Errorf("persist goal continuation: %w", err)
80	}
81	return run, nil
82}
83
84func (i *Interactive) clearGoalRun(runID string) {
85	i.mu.Lock()
86	if i.goalRun != nil && i.goalRun.id == runID {
87		i.goalRun = nil
88	}
89	i.mu.Unlock()
90}
91
92func (i *Interactive) abandonGoalRun(run *goalContinuationRun) {
93	if run == nil {
94		return
95	}
96	i.clearGoalRun(run.id)
97	if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil {
98		return
99	}
100	goal := copySessionGoal(i.cfg.CurrentGoal())
101	if goal == nil || goal.ID != run.goalID || goal.ContinuationID != run.id {
102		return
103	}
104	goal.ContinuationID = ""
105	if err := i.cfg.PersistGoalRuntime(goal); err != nil {
106		i.ReportError(fmt.Errorf("clear goal continuation: %w", err))
107	}
108}
109
110func (i *Interactive) observeGoalRun(ev core.AgentEvent) {
111	i.mu.Lock()
112	defer i.mu.Unlock()
113	if i.goalRun == nil {
114		return
115	}
116	switch e := ev.(type) {
117	case core.EvUsage:
118		i.goalRun.tokens += goalTokenCount(e.Usage)
119	case core.EvToolCall:
120		if e.Name != agenttools.UpdateGoalToolName {
121			i.goalRun.hadTool = true
122		}
123	}
124}
125
126// finishGoalRun records accounting exactly once and returns whether the
127// controller should attempt another autonomous continuation. Context-limited
128// turns retain their prior progress classification because output truncation
129// is not evidence that the agent stopped acting. Cancelled runs keep lease
130// cleanup and token accounting but never consume the no-progress allowance:
131// the user interrupted before the model could act. A stale run is discarded
132// without changing a newer goal.
133func (i *Interactive) finishGoalRun(contextLimited bool) bool {
134	return i.finishGoalRunCancelled(contextLimited, false)
135}
136
137func (i *Interactive) stallGoalRun(run *goalContinuationRun, reason string) bool {
138	if run == nil || i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
139		return false
140	}
141	goal := copySessionGoal(i.cfg.CurrentGoal())
142	if goal == nil || goal.Status != core.GoalActive || run.goalID != "" && goal.ID != run.goalID {
143		return false
144	}
145	goal.Status = core.GoalStalled
146	goal.Reason = reason
147	if err := i.cfg.PersistGoal(goal); err != nil {
148		i.ReportError(fmt.Errorf("persist stalled goal: %w", err))
149		return false
150	}
151	i.setGoalStatus(goal)
152	return true
153}
154
155// finishGoalRunCancelled is finishGoalRun with an explicit cancellation
156// signal from the turn owner. Esc-armed reassessment relies on this so an
157// interrupted corrective continuation cannot stall the still-active goal.
158func (i *Interactive) finishGoalRunCancelled(contextLimited, cancelled bool) bool {
159	i.mu.Lock()
160	run := i.goalRun
161	i.goalRun = nil
162	i.mu.Unlock()
163	if run == nil || i.cfg.CurrentGoal == nil {
164		return false
165	}
166	goal := copySessionGoal(i.cfg.CurrentGoal())
167	if goal == nil || goal.ID != run.goalID {
168		return false
169	}
170	goal.ContinuationID = ""
171	goal.TokensUsed += run.tokens
172
173	persistRuntime := func() bool {
174		if i.cfg.PersistGoalRuntime == nil {
175			return true
176		}
177		if err := i.cfg.PersistGoalRuntime(goal); err != nil {
178			i.ReportError(fmt.Errorf("persist goal runtime: %w", err))
179			return false
180		}
181		i.setGoalStatus(goal)
182		return true
183	}
184	if goal.Status != core.GoalActive {
185		persistRuntime()
186		return false
187	}
188	if goal.TokenBudget != nil && goal.TokensUsed >= *goal.TokenBudget {
189		goal.Status = core.GoalBudgetLimited
190		goal.Reason = "configured token budget exhausted"
191		if i.cfg.PersistGoal == nil {
192			i.ReportError(fmt.Errorf("goal persistence is unavailable"))
193			return false
194		}
195		if err := i.cfg.PersistGoal(goal); err != nil {
196			i.ReportError(fmt.Errorf("persist goal budget limit: %w", err))
197			return false
198		}
199		i.setGoalStatus(goal)
200		return false
201	}
202	if cancelled {
203		// Esc arrived before the model could act; preserve the prior
204		// progress classification exactly like the capacity boundary below.
205	} else if contextLimited {
206		// The compact handoff provisions a fresh turn below. Do not let an
207		// execution-capacity boundary consume the no-progress allowance.
208	} else if run.hadTool {
209		goal.ConsecutiveNoProgressTurns = 0
210	} else {
211		goal.ConsecutiveNoProgressTurns++
212		if goal.ConsecutiveNoProgressTurns >= maxConsecutiveGoalNoProgressTurns {
213			goal.Status = core.GoalStalled
214			goal.Reason = "agent ended consecutive continuations without a concrete action"
215			if i.cfg.PersistGoal == nil {
216				i.ReportError(fmt.Errorf("goal persistence is unavailable"))
217				return false
218			}
219			if err := i.cfg.PersistGoal(goal); err != nil {
220				i.ReportError(fmt.Errorf("persist stalled goal: %w", err))
221				return false
222			}
223			i.setGoalStatus(goal)
224			return false
225		}
226	}
227	return persistRuntime()
228}
229
230// recoverGoalRun clears a lease left by a previous process. The new process
231// has no provider turn associated with it, so retaining that lease would make
232// the active goal permanently unschedulable.
233func (i *Interactive) recoverGoalRun() {
234	if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil {
235		return
236	}
237	goal := copySessionGoal(i.cfg.CurrentGoal())
238	if goal == nil || goal.Status != core.GoalActive || goal.ContinuationID == "" {
239		return
240	}
241	goal.ContinuationID = ""
242	if err := i.cfg.PersistGoalRuntime(goal); err != nil {
243		i.ReportError(fmt.Errorf("recover goal continuation: %w", err))
244	}
245}

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 477

↑↓ moveenter openesc close