234 lines
7.5 KiB
Go
234 lines
7.5 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Termination is how a run ended. Exactly one per run, always.
|
|
//
|
|
// An enum rather than a boolean and a message, because "what happened" is the
|
|
// first question asked of every trajectory — in a debugger, in an eval report,
|
|
// and in a support conversation — and a free-text reason cannot be grouped,
|
|
// counted or asserted on.
|
|
type Termination string
|
|
|
|
const (
|
|
TerminationCompleted Termination = "Completed"
|
|
TerminationBudgetExceeded Termination = "BudgetExceeded"
|
|
TerminationDeadline Termination = "Deadline"
|
|
TerminationConfirmationPending Termination = "ConfirmationPending"
|
|
TerminationToolFailure Termination = "ToolFailure"
|
|
TerminationRefused Termination = "Refused"
|
|
)
|
|
|
|
// Valid reports whether t is one of the six.
|
|
func (t Termination) Valid() bool {
|
|
switch t {
|
|
case TerminationCompleted, TerminationBudgetExceeded, TerminationDeadline,
|
|
TerminationConfirmationPending, TerminationToolFailure, TerminationRefused:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Limits are the four bounds every run carries.
|
|
//
|
|
// I3: there is no "run until done" path. A run that reaches any of these ends
|
|
// with a structured result, never an exception into user-facing text.
|
|
type Limits struct {
|
|
MaxSteps int
|
|
MaxToolCalls int
|
|
MaxTokens int64
|
|
Deadline time.Duration
|
|
}
|
|
|
|
// LimitsForTier is what a run gets when its spec declares no limits of its own.
|
|
//
|
|
// Derived from the reasoning tier because that is the only thing a Krow agent
|
|
// definition says today about how much work it is worth. A `limits:` block in
|
|
// the frontmatter would override this per agent; adding one changes the spec
|
|
// contract and the authoring UI together, so it is a deliberate schema
|
|
// decision rather than something to infer here.
|
|
//
|
|
// The numbers are chosen so that the cheapest tier cannot quietly become the
|
|
// expensive one: a fast run gets a third of a deep run's steps and a sixth of
|
|
// its deadline, so a misrouted spec shows up as a truncated answer rather than
|
|
// as a bill.
|
|
func LimitsForTier(tier string) Limits {
|
|
switch tier {
|
|
case "fast":
|
|
return Limits{MaxSteps: 3, MaxToolCalls: 4, MaxTokens: 40_000, Deadline: 20 * time.Second}
|
|
case "deep":
|
|
return Limits{MaxSteps: 12, MaxToolCalls: 20, MaxTokens: 300_000, Deadline: 120 * time.Second}
|
|
default: // balanced, and anything unrecognised — ParseTier has already normalised it
|
|
return Limits{MaxSteps: 8, MaxToolCalls: 12, MaxTokens: 120_000, Deadline: 60 * time.Second}
|
|
}
|
|
}
|
|
|
|
// Snapshot is what a budget had left at one moment. Recorded into the
|
|
// trajectory before every dispatch, so "where did the budget go" is answerable
|
|
// after the fact instead of being reconstructed from timings.
|
|
type Snapshot struct {
|
|
StepsUsed int `json:"stepsUsed"`
|
|
StepsLeft int `json:"stepsLeft"`
|
|
ToolCallsUsed int `json:"toolCallsUsed"`
|
|
ToolCallsLeft int `json:"toolCallsLeft"`
|
|
TokensUsed int64 `json:"tokensUsed"`
|
|
TokensLeft int64 `json:"tokensLeft"`
|
|
MillisLeft int64 `json:"millisLeft"`
|
|
}
|
|
|
|
// Budget tracks one run against its limits.
|
|
//
|
|
// **Everything is claimed before dispatch, never after.** A step is spent the
|
|
// moment the loop decides to take it, not when it returns — otherwise a call
|
|
// that hangs until the context dies has consumed nothing on the ledger, and a
|
|
// loop that retries it can go round forever while the budget reads full.
|
|
//
|
|
// Tokens are the exception that proves the rule: their true cost is only known
|
|
// once a response comes back, so the budget is checked before dispatch and
|
|
// charged after. That leaves one turn of overshoot, bounded by MaxOutputTokens
|
|
// on the request, which is why the model gateway takes a hard per-call ceiling
|
|
// as well as this soft per-run one.
|
|
//
|
|
// Safe for concurrent use: subagents share their parent's budget, and two
|
|
// delegated branches must not both see the last step as available.
|
|
type Budget struct {
|
|
limits Limits
|
|
start time.Time
|
|
|
|
mu sync.Mutex
|
|
steps int
|
|
toolCalls int
|
|
tokens int64
|
|
}
|
|
|
|
// NewBudget starts a budget. The wall clock starts now: a run's deadline is
|
|
// measured from when it began, not from when it first reached a model.
|
|
func NewBudget(limits Limits) *Budget {
|
|
return &Budget{limits: limits, start: time.Now()}
|
|
}
|
|
|
|
// Limits returns the bounds this budget enforces.
|
|
func (b *Budget) Limits() Limits { return b.limits }
|
|
|
|
// ClaimStep takes one step up front, reporting the termination to end with if
|
|
// there was nothing left to take.
|
|
//
|
|
// The returned Termination is empty when the claim succeeded. Callers branch on
|
|
// that rather than on a boolean, so the reason a run stopped travels with the
|
|
// refusal instead of being re-derived at the call site.
|
|
func (b *Budget) ClaimStep() Termination {
|
|
if t := b.expired(); t != "" {
|
|
return t
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.steps >= b.limits.MaxSteps {
|
|
return TerminationBudgetExceeded
|
|
}
|
|
b.steps++
|
|
return ""
|
|
}
|
|
|
|
// ClaimToolCall takes one tool call up front.
|
|
func (b *Budget) ClaimToolCall() Termination {
|
|
if t := b.expired(); t != "" {
|
|
return t
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.toolCalls >= b.limits.MaxToolCalls {
|
|
return TerminationBudgetExceeded
|
|
}
|
|
b.toolCalls++
|
|
return ""
|
|
}
|
|
|
|
// CheckTokens reports whether there is token budget left to dispatch against.
|
|
//
|
|
// Checked before, charged after — see the type comment. A run that has already
|
|
// spent its allowance stops here rather than issuing one more call it cannot
|
|
// pay for.
|
|
func (b *Budget) CheckTokens() Termination {
|
|
if t := b.expired(); t != "" {
|
|
return t
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.tokens >= b.limits.MaxTokens {
|
|
return TerminationBudgetExceeded
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ChargeTokens records what a completed call actually cost.
|
|
//
|
|
// Called for refused and failed calls too. A turn that produced no text was
|
|
// still billed, and a ledger that forgives it is a ledger a loop will happily
|
|
// repeat against.
|
|
func (b *Budget) ChargeTokens(n int64) {
|
|
if n <= 0 {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.tokens += n
|
|
}
|
|
|
|
// expired reports the deadline having passed. Separate from the step and tool
|
|
// checks because it is a different termination reason: a run that ran out of
|
|
// time did not run out of budget, and conflating them hides which bound is
|
|
// actually being hit in production.
|
|
func (b *Budget) expired() Termination {
|
|
if time.Since(b.start) >= b.limits.Deadline {
|
|
return TerminationDeadline
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Context returns a context that is cancelled at the run's deadline.
|
|
//
|
|
// The same deadline the budget enforces, so an in-flight model call is torn
|
|
// down rather than being allowed to return into a run that has already ended.
|
|
func (b *Budget) Context(parent context.Context) (context.Context, context.CancelFunc) {
|
|
return context.WithDeadline(parent, b.start.Add(b.limits.Deadline))
|
|
}
|
|
|
|
// Snapshot reads the budget without changing it.
|
|
func (b *Budget) Snapshot() Snapshot {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
left := b.limits.Deadline - time.Since(b.start)
|
|
if left < 0 {
|
|
left = 0
|
|
}
|
|
return Snapshot{
|
|
StepsUsed: b.steps,
|
|
StepsLeft: max(0, b.limits.MaxSteps-b.steps),
|
|
ToolCallsUsed: b.toolCalls,
|
|
ToolCallsLeft: max(0, b.limits.MaxToolCalls-b.toolCalls),
|
|
TokensUsed: b.tokens,
|
|
TokensLeft: maxInt64(0, b.limits.MaxTokens-b.tokens),
|
|
MillisLeft: left.Milliseconds(),
|
|
}
|
|
}
|
|
|
|
func (s Snapshot) String() string {
|
|
return fmt.Sprintf("steps %d/%d, tools %d/%d, tokens %d, %dms left",
|
|
s.StepsUsed, s.StepsUsed+s.StepsLeft,
|
|
s.ToolCallsUsed, s.ToolCallsUsed+s.ToolCallsLeft,
|
|
s.TokensUsed, s.MillisLeft)
|
|
}
|
|
|
|
func maxInt64(a, b int64) int64 {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|