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