Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

175
pkg/effort/effort.go Normal file
View 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
View 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)
}
}
}