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}