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}