v0.28.0 f592943

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

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