v0.32.0 362913c

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. A stale run is discarded
130// without changing a newer goal.
131func (i *Interactive) finishGoalRun(contextLimited bool) bool {
132	i.mu.Lock()
133	run := i.goalRun
134	i.goalRun = nil
135	i.mu.Unlock()
136	if run == nil || i.cfg.CurrentGoal == nil {
137		return false
138	}
139	goal := copySessionGoal(i.cfg.CurrentGoal())
140	if goal == nil || goal.ID != run.goalID {
141		return false
142	}
143	goal.ContinuationID = ""
144	goal.TokensUsed += run.tokens
145
146	persistRuntime := func() bool {
147		if i.cfg.PersistGoalRuntime == nil {
148			return true
149		}
150		if err := i.cfg.PersistGoalRuntime(goal); err != nil {
151			i.ReportError(fmt.Errorf("persist goal runtime: %w", err))
152			return false
153		}
154		i.setGoalStatus(goal)
155		return true
156	}
157	if goal.Status != core.GoalActive {
158		persistRuntime()
159		return false
160	}
161	if goal.TokenBudget != nil && goal.TokensUsed >= *goal.TokenBudget {
162		goal.Status = core.GoalBudgetLimited
163		goal.Reason = "configured token budget exhausted"
164		if i.cfg.PersistGoal == nil {
165			i.ReportError(fmt.Errorf("goal persistence is unavailable"))
166			return false
167		}
168		if err := i.cfg.PersistGoal(goal); err != nil {
169			i.ReportError(fmt.Errorf("persist goal budget limit: %w", err))
170			return false
171		}
172		i.setGoalStatus(goal)
173		return false
174	}
175	if contextLimited {
176		// The compact handoff provisions a fresh turn below. Do not let an
177		// execution-capacity boundary consume the no-progress allowance.
178	} else if run.hadTool {
179		goal.ConsecutiveNoProgressTurns = 0
180	} else {
181		goal.ConsecutiveNoProgressTurns++
182		if goal.ConsecutiveNoProgressTurns >= maxConsecutiveGoalNoProgressTurns {
183			goal.Status = core.GoalStalled
184			goal.Reason = "agent ended consecutive continuations without a concrete action"
185			if i.cfg.PersistGoal == nil {
186				i.ReportError(fmt.Errorf("goal persistence is unavailable"))
187				return false
188			}
189			if err := i.cfg.PersistGoal(goal); err != nil {
190				i.ReportError(fmt.Errorf("persist stalled goal: %w", err))
191				return false
192			}
193			i.setGoalStatus(goal)
194			return false
195		}
196	}
197	return persistRuntime()
198}
199
200// recoverGoalRun clears a lease left by a previous process. The new process
201// has no provider turn associated with it, so retaining that lease would make
202// the active goal permanently unschedulable.
203func (i *Interactive) recoverGoalRun() {
204	if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil {
205		return
206	}
207	goal := copySessionGoal(i.cfg.CurrentGoal())
208	if goal == nil || goal.Status != core.GoalActive || goal.ContinuationID == "" {
209		return
210	}
211	goal.ContinuationID = ""
212	if err := i.cfg.PersistGoalRuntime(goal); err != nil {
213		i.ReportError(fmt.Errorf("recover goal continuation: %w", err))
214	}
215}

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