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}