Files
enkly-ai/pkg/effort/effort.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

176 lines
7.5 KiB
Go

// 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,
}
}
}