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