This commit is contained in:
175
pkg/effort/effort.go
Normal file
175
pkg/effort/effort.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Package effort defines enkly's manual reasoning-depth control: five
|
||||
// named levels (Low, Medium, High, Extra, Max) a caller can pick to
|
||||
// arbitrate between speed and precision for one agent turn, modeled on
|
||||
// Claude's effort parameter. It is deliberately a small, dependency-free
|
||||
// package — just a Level type plus the static Profile each one maps to
|
||||
// — so both the agent service (which actually runs a turn at a given
|
||||
// level) and any future transport (gRPC field, HTTP query param, a
|
||||
// gateway settings dropdown) can depend on it without pulling in
|
||||
// agentrun or vllmkit.
|
||||
//
|
||||
// vLLM/Qwen has no first-class notion of "effort" the way the
|
||||
// Anthropic API does (a token budget plus a reasoning-depth knob) — the
|
||||
// only dial it exposes per request is vllmkit.Client's think string
|
||||
// ("" / "true" / "false" / "low" / "medium" / "high", all of which
|
||||
// collapse to a single on/off enable_thinking toggle on the wire — see
|
||||
// vllmkit.Client.Think's doc comment) plus the ordinary generation
|
||||
// options (max_tokens, temperature, top_p). Profile is this package's
|
||||
// honest mapping of Claude's five-level vocabulary onto that smaller
|
||||
// vocabulary: Think picks the per-round reasoning-effort floor (see
|
||||
// agentrun.EffortThinkPolicy), MaxRounds caps how many tool-call
|
||||
// round-trips a turn is allowed before Low has to converge fast and Max
|
||||
// can keep investigating, and NumPredictCap bounds a single reply's
|
||||
// length so Low genuinely costs less and Max isn't truncated mid-answer.
|
||||
package effort
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level is one of five named reasoning-depth settings, ordered from
|
||||
// fastest/cheapest to deepest/most thorough.
|
||||
type Level string
|
||||
|
||||
const (
|
||||
Low Level = "low"
|
||||
Medium Level = "medium"
|
||||
High Level = "high"
|
||||
Extra Level = "extra"
|
||||
Max Level = "max"
|
||||
)
|
||||
|
||||
// Default is used whenever no level was explicitly selected — the same
|
||||
// balance the agent struck before effort control existed.
|
||||
const Default = Medium
|
||||
|
||||
// All lists every level in ascending order of depth, for validation
|
||||
// messages and a future settings-UI dropdown.
|
||||
var All = []Level{Low, Medium, High, Extra, Max}
|
||||
|
||||
// Parse normalizes free-form text (a slash-directive argument, a query
|
||||
// param, a gRPC field once wired) into a Level. Case/space-insensitive;
|
||||
// a handful of obvious synonyms are accepted so "quick"/"fast" from the
|
||||
// fast-mode vocabulary and "deep"/"thorough" from everyday language
|
||||
// both resolve sensibly instead of erroring on a near-miss.
|
||||
func Parse(s string) (Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "":
|
||||
return Default, nil
|
||||
case "low", "fast", "quick", "speed":
|
||||
return Low, nil
|
||||
case "medium", "default", "normal", "balanced":
|
||||
return Medium, nil
|
||||
case "high", "deep", "careful":
|
||||
return High, nil
|
||||
case "extra", "extra-high", "extra_high", "thorough":
|
||||
return Extra, nil
|
||||
case "max", "maximum", "deepest", "ultra":
|
||||
return Max, nil
|
||||
default:
|
||||
return "", fmt.Errorf("effort: unknown level %q (want one of low, medium, high, extra, max)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Valid reports whether l is one of the five defined levels — distinct
|
||||
// from Parse accepting synonyms: Valid is for checking an already
|
||||
// canonical value (e.g. one stored verbatim), Parse is for user input.
|
||||
func (l Level) Valid() bool {
|
||||
switch l {
|
||||
case Low, Medium, High, Extra, Max:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Profile is the concrete tuning one Level resolves to. Every field has
|
||||
// a conservative, locally-reasonable value — there's no remote
|
||||
// "effort=max" service to defer to the way the Anthropic API has, so
|
||||
// these numbers are enkly's own calibration for enkly's vLLM
|
||||
// deployment, meant to be adjusted (see cmd/server/main.go's
|
||||
// ENKLY_EFFORT_* overrides) rather than treated as gospel.
|
||||
type Profile struct {
|
||||
// Level this profile was resolved from, for logging.
|
||||
Level Level
|
||||
// Think is the thinking-effort floor passed to the chat model on
|
||||
// every round of the turn (see vllmkit.Client's Think field). A
|
||||
// per-round policy (agentrun.EffortThinkPolicy) may still ask for
|
||||
// more on round 0 or less on a retry — this is the floor beneath
|
||||
// which it won't go, not an override of every round alike.
|
||||
Think string
|
||||
// MaxRounds caps tool-call round-trips for a turn run at this
|
||||
// level. Low converges fast on purpose; Max gets the same ceiling
|
||||
// dispatch_workflow's own sub-runs use, so a Max-effort turn can
|
||||
// still delegate broad work instead of exhausting its own budget
|
||||
// investigating serially.
|
||||
MaxRounds int
|
||||
// NumPredictCap bounds a single model reply's length in tokens (0
|
||||
// = no cap beyond the client's own configured default). Only Low
|
||||
// sets one — the other levels trust the model to stop when it's
|
||||
// done rather than truncating a legitimately long answer.
|
||||
NumPredictCap int
|
||||
// ContextBudgetChars overrides agentrun's default context-budget
|
||||
// threshold (see agentrun.Agent.ContextBudgetChars) for a turn run
|
||||
// at this level — Max keeps more tool-result history verbatim
|
||||
// before compacting, trading context budget for fewer "re-call the
|
||||
// tool if you need it again" gaps on a long investigation.
|
||||
ContextBudgetChars int
|
||||
// AdaptiveThinking, when true, lets the per-round policy raise
|
||||
// effort above Think on a round that looks like it needs it (an
|
||||
// error result, a round deep into a long turn) instead of holding
|
||||
// every round at exactly Think — see agentrun.AdaptiveThinkPolicy.
|
||||
// Low and Medium leave it off (predictable cost); High and above
|
||||
// turn it on (predictable quality).
|
||||
AdaptiveThinking bool
|
||||
// MaxParallelSubagents is the ceiling dispatch_workflow enforces
|
||||
// for a single dispatch_workflow call made during a turn run at
|
||||
// this level — see toolexec's workflow orchestrator. Low effort
|
||||
// still allows some parallel fan-out (a migration-shaped request
|
||||
// at Low effort is still a migration), just a smaller one.
|
||||
MaxParallelSubagents int
|
||||
}
|
||||
|
||||
// defaultContextBudgetChars mirrors agentrun's own unexported default
|
||||
// (contextBudgetChars) so Resolve's Medium profile reproduces the
|
||||
// agent's pre-effort-control behavior exactly. Keeping one literal
|
||||
// value here rather than importing agentrun (which would create an
|
||||
// import cycle: agentrun -> effort -> agentrun) is deliberate — see
|
||||
// agentrun.Agent.ContextBudgetChars's doc comment for the other half of
|
||||
// this contract.
|
||||
const defaultContextBudgetChars = 60_000
|
||||
|
||||
// Resolve returns the Profile for l. An invalid l (never produced by
|
||||
// Parse, but possible from a stored/decoded value) resolves as Medium
|
||||
// rather than panicking or erroring — effort control degrading to "the
|
||||
// behavior enkly always had" is always a safe failure mode.
|
||||
func (l Level) Resolve() Profile {
|
||||
switch l {
|
||||
case Low:
|
||||
return Profile{
|
||||
Level: Low, Think: "low", MaxRounds: 6, NumPredictCap: 700,
|
||||
ContextBudgetChars: defaultContextBudgetChars / 2, AdaptiveThinking: false, MaxParallelSubagents: 16,
|
||||
}
|
||||
case High:
|
||||
return Profile{
|
||||
Level: High, Think: "high", MaxRounds: 16,
|
||||
ContextBudgetChars: defaultContextBudgetChars * 2, AdaptiveThinking: true, MaxParallelSubagents: 96,
|
||||
}
|
||||
case Extra:
|
||||
return Profile{
|
||||
Level: Extra, Think: "high", MaxRounds: 24,
|
||||
ContextBudgetChars: defaultContextBudgetChars * 4, AdaptiveThinking: true, MaxParallelSubagents: 192,
|
||||
}
|
||||
case Max:
|
||||
return Profile{
|
||||
Level: Max, Think: "high", MaxRounds: 32,
|
||||
ContextBudgetChars: defaultContextBudgetChars * 8, AdaptiveThinking: true, MaxParallelSubagents: 256,
|
||||
}
|
||||
default: // Medium and anything unrecognized
|
||||
return Profile{
|
||||
Level: Medium, Think: "", MaxRounds: 12,
|
||||
ContextBudgetChars: defaultContextBudgetChars, AdaptiveThinking: false, MaxParallelSubagents: 48,
|
||||
}
|
||||
}
|
||||
}
|
||||
102
pkg/effort/effort_test.go
Normal file
102
pkg/effort/effort_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package effort
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Level
|
||||
wantErr bool
|
||||
}{
|
||||
{"", Default, false},
|
||||
{"low", Low, false},
|
||||
{" LOW ", Low, false},
|
||||
{"fast", Low, false},
|
||||
{"medium", Medium, false},
|
||||
{"default", Medium, false},
|
||||
{"high", High, false},
|
||||
{"deep", High, false},
|
||||
{"extra", Extra, false},
|
||||
{"thorough", Extra, false},
|
||||
{"max", Max, false},
|
||||
{"ultra", Max, false},
|
||||
{"bogus", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := Parse(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Parse(%q) = %q, nil; want an error", tc.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("Parse(%q) returned unexpected error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("Parse(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValid(t *testing.T) {
|
||||
for _, l := range All {
|
||||
if !l.Valid() {
|
||||
t.Errorf("%q should be Valid", l)
|
||||
}
|
||||
}
|
||||
if Level("bogus").Valid() {
|
||||
t.Error(`Level("bogus").Valid() = true, want false`)
|
||||
}
|
||||
if Level("").Valid() {
|
||||
t.Error(`Level("").Valid() = true, want false`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOrdering(t *testing.T) {
|
||||
// Depth should be monotonic across the five levels for every knob
|
||||
// that's supposed to scale with effort — a regression here (e.g.
|
||||
// Extra accidentally configured with a smaller budget than High)
|
||||
// would quietly break the "arbitrate between speed and precision"
|
||||
// contract the whole feature exists to provide.
|
||||
levels := []Level{Low, Medium, High, Extra, Max}
|
||||
var prevRounds, prevBudget, prevParallel int
|
||||
for i, l := range levels {
|
||||
p := l.Resolve()
|
||||
if p.Level != l {
|
||||
t.Errorf("%s.Resolve().Level = %s, want %s", l, p.Level, l)
|
||||
}
|
||||
if i > 0 {
|
||||
if p.MaxRounds < prevRounds {
|
||||
t.Errorf("%s.MaxRounds (%d) < %s.MaxRounds (%d); want non-decreasing", l, p.MaxRounds, levels[i-1], prevRounds)
|
||||
}
|
||||
if p.ContextBudgetChars < prevBudget {
|
||||
t.Errorf("%s.ContextBudgetChars (%d) < %s.ContextBudgetChars (%d); want non-decreasing", l, p.ContextBudgetChars, levels[i-1], prevBudget)
|
||||
}
|
||||
if p.MaxParallelSubagents < prevParallel {
|
||||
t.Errorf("%s.MaxParallelSubagents (%d) < %s.MaxParallelSubagents (%d); want non-decreasing", l, p.MaxParallelSubagents, levels[i-1], prevParallel)
|
||||
}
|
||||
}
|
||||
prevRounds, prevBudget, prevParallel = p.MaxRounds, p.ContextBudgetChars, p.MaxParallelSubagents
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInvalidFallsBackToMedium(t *testing.T) {
|
||||
got := Level("not-a-real-level").Resolve()
|
||||
want := Medium.Resolve()
|
||||
if got != want {
|
||||
t.Errorf("invalid Level.Resolve() = %+v, want Medium's profile %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowHasOutputCapOthersDont(t *testing.T) {
|
||||
if Low.Resolve().NumPredictCap == 0 {
|
||||
t.Error("Low should set a NumPredictCap")
|
||||
}
|
||||
for _, l := range []Level{Medium, High, Extra, Max} {
|
||||
if got := l.Resolve().NumPredictCap; got != 0 {
|
||||
t.Errorf("%s.NumPredictCap = %d, want 0 (uncapped)", l, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user