Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/agent/core/budget.go
3433 views
1
package core
2
3
import (
4
"errors"
5
"fmt"
6
"github.com/kardolus/chatgpt-cli/agent/types"
7
"go.uber.org/zap"
8
"time"
9
)
10
11
type Budget interface {
12
Start(now time.Time)
13
AllowStep(step types.Step, now time.Time) error
14
AllowTool(kind types.ToolKind, now time.Time) error
15
AllowIteration(now time.Time) error // NEW
16
ChargeLLMTokens(tokens int, now time.Time)
17
Snapshot(now time.Time) BudgetSnapshot
18
}
19
20
const (
21
BudgetKindSteps = "steps"
22
BudgetKindShell = "shell"
23
BudgetKindLLM = "llm"
24
BudgetKindFiles = "files"
25
BudgetKindLLMTokens = "llm_tokens"
26
BudgetKindWallTime = "wall_time"
27
BudgetKindIterations = "iterations"
28
)
29
30
type BudgetLimits struct {
31
MaxSteps int
32
MaxWallTime time.Duration
33
MaxLLMTokens int
34
MaxShellCalls int
35
MaxLLMCalls int
36
MaxFileOps int
37
MaxIterations int
38
}
39
40
type BudgetSnapshot struct {
41
StartedAt time.Time
42
Elapsed time.Duration
43
Limits BudgetLimits
44
StepsUsed int
45
ShellUsed int
46
LLMUsed int
47
FileOpsUsed int
48
LLMTokensUsed int
49
IterationsUsed int
50
}
51
52
type DefaultBudget struct {
53
limits BudgetLimits
54
55
started bool
56
startedAt time.Time
57
58
stepsUsed int
59
shellUsed int
60
llmUsed int
61
fileOpsUsed int
62
llmTokensUsed int
63
iterationsUsed int
64
}
65
66
func NewDefaultBudget(limits BudgetLimits) *DefaultBudget {
67
return &DefaultBudget{limits: limits}
68
}
69
70
func (b *DefaultBudget) Start(now time.Time) {
71
b.started = true
72
b.startedAt = now
73
b.stepsUsed = 0
74
b.shellUsed = 0
75
b.llmUsed = 0
76
b.fileOpsUsed = 0
77
b.llmTokensUsed = 0
78
b.iterationsUsed = 0
79
}
80
81
func (b *DefaultBudget) Snapshot(now time.Time) BudgetSnapshot {
82
b.ensureStarted(now)
83
84
elapsed := now.Sub(b.startedAt)
85
if elapsed < 0 {
86
elapsed = 0
87
}
88
89
return BudgetSnapshot{
90
StartedAt: b.startedAt,
91
Elapsed: elapsed,
92
Limits: b.limits,
93
StepsUsed: b.stepsUsed,
94
ShellUsed: b.shellUsed,
95
LLMUsed: b.llmUsed,
96
FileOpsUsed: b.fileOpsUsed,
97
LLMTokensUsed: b.llmTokensUsed,
98
IterationsUsed: b.iterationsUsed,
99
}
100
}
101
102
func (b *DefaultBudget) ChargeLLMTokens(tokens int, now time.Time) {
103
b.ensureStarted(now)
104
if tokens <= 0 {
105
return
106
}
107
b.llmTokensUsed += tokens
108
}
109
110
func (b *DefaultBudget) AllowIteration(now time.Time) error {
111
b.ensureStarted(now)
112
113
if err := b.checkWall(now); err != nil {
114
return err
115
}
116
117
if b.limits.MaxIterations > 0 && b.iterationsUsed+1 > b.limits.MaxIterations {
118
return BudgetExceededError{
119
Kind: BudgetKindIterations,
120
Limit: b.limits.MaxIterations,
121
Used: b.iterationsUsed,
122
Message: "iteration budget exceeded",
123
}
124
}
125
126
b.iterationsUsed++
127
return nil
128
}
129
130
func (b *DefaultBudget) AllowStep(step types.Step, now time.Time) error {
131
b.ensureStarted(now)
132
133
if err := b.checkWall(now); err != nil {
134
return err
135
}
136
137
if b.limits.MaxSteps > 0 && b.stepsUsed+1 > b.limits.MaxSteps {
138
return BudgetExceededError{
139
Kind: BudgetKindSteps,
140
Limit: b.limits.MaxSteps,
141
Used: b.stepsUsed,
142
Message: "step budget exceeded",
143
}
144
}
145
146
b.stepsUsed++
147
return nil
148
}
149
150
func (b *DefaultBudget) AllowTool(kind types.ToolKind, now time.Time) error {
151
b.ensureStarted(now)
152
153
if err := b.checkWall(now); err != nil {
154
return err
155
}
156
157
switch kind {
158
case types.ToolShell:
159
if b.limits.MaxShellCalls > 0 && b.shellUsed+1 > b.limits.MaxShellCalls {
160
return BudgetExceededError{
161
Kind: BudgetKindShell,
162
Limit: b.limits.MaxShellCalls,
163
Used: b.shellUsed,
164
Message: "shell call budget exceeded",
165
}
166
}
167
b.shellUsed++
168
169
case types.ToolLLM:
170
if b.limits.MaxLLMCalls > 0 && b.llmUsed+1 > b.limits.MaxLLMCalls {
171
return BudgetExceededError{
172
Kind: BudgetKindLLM,
173
Limit: b.limits.MaxLLMCalls,
174
Used: b.llmUsed,
175
Message: "llm call budget exceeded",
176
}
177
}
178
b.llmUsed++
179
180
case types.ToolFiles:
181
if b.limits.MaxFileOps > 0 && b.fileOpsUsed+1 > b.limits.MaxFileOps {
182
return BudgetExceededError{
183
Kind: BudgetKindFiles,
184
Limit: b.limits.MaxFileOps,
185
Used: b.fileOpsUsed,
186
Message: "file ops budget exceeded",
187
}
188
}
189
b.fileOpsUsed++
190
191
default:
192
return fmt.Errorf("unknown tool kind: %q", kind)
193
}
194
195
return nil
196
}
197
198
func (b *DefaultBudget) ensureStarted(now time.Time) {
199
if b.started {
200
return
201
}
202
b.Start(now)
203
}
204
205
func (b *DefaultBudget) checkWall(now time.Time) error {
206
if b.limits.MaxWallTime <= 0 {
207
return nil
208
}
209
elapsed := now.Sub(b.startedAt)
210
if elapsed > b.limits.MaxWallTime {
211
return BudgetExceededError{
212
Kind: BudgetKindWallTime,
213
LimitD: b.limits.MaxWallTime,
214
UsedD: elapsed,
215
Message: "wall time budget exceeded",
216
}
217
}
218
return nil
219
}
220
221
// BudgetExceededError is a typed error so the Agent/Planner can branch on it.
222
type BudgetExceededError struct {
223
// "steps" | "shell" | "llm" | "files" | "llm_tokens" | "wall_time"
224
Kind string
225
Limit int
226
Used int
227
LimitD time.Duration
228
UsedD time.Duration
229
Message string
230
}
231
232
func (e BudgetExceededError) Error() string {
233
switch e.Kind {
234
case BudgetKindWallTime:
235
return fmt.Sprintf("%s: limit=%s used=%s", e.Message, e.LimitD, e.UsedD)
236
default:
237
return fmt.Sprintf("%s: kind=%s limit=%d used=%d", e.Message, e.Kind, e.Limit, e.Used)
238
}
239
}
240
241
func IsBudgetStop(err error, log *zap.SugaredLogger) bool {
242
var be BudgetExceededError
243
if errors.As(err, &be) {
244
log.Warnf("Budget exceeded (kind=%s): %v", be.Kind, err)
245
return true
246
}
247
return false
248
}
249
250