From 5c7dacf7ddc3e9032b6ed48f4293b3a6f700e361 Mon Sep 17 00:00:00 2001 From: Linearleaf Date: Mon, 7 Sep 2026 00:20:35 +0800 Subject: [PATCH] perf(agent): per-request session effort override skips the runtime rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching the reasoning effort currently pays for the full desktop build+swap rebuild (session snapshot, lease re-arm, provider re-init, history carry) even though providers already resolve effort per request: provider.Request.EffortOverride exists and the openai adapter resolves it against the endpoint's effort vocabulary on every call. Wire that existing channel to the UI switch. Agent.SetSessionEffortOverride stores a session-scoped override (atomic.Value, following the responseLanguage precedent) and accepts a level only when the running provider lists it in its per-request vocabulary (new optional probe PerRequestEfforts). Sampling requests merge it behind the governor's engaged override so a running guard cannot be outbid by a session-level depth bump. The desktop effort switch tries the fast path first and falls back to the rebuild for providers or sessions that decline it — recovery-forked sessions decline on purpose so their reanchor semantics (forked file sealed, fresh branch anchored) stay on the rebuild path. Frontends whose provider lacks per-request depth see no behavior change; the fast path degrades to the exact rebuild flow they use today. --- desktop/app.go | 20 +++++++ internal/agent/agent.go | 1 + internal/agent/effort_override.go | 80 ++++++++++++++++++++++++++ internal/agent/effort_override_test.go | 74 ++++++++++++++++++++++++ internal/agent/sampling_request.go | 2 +- internal/control/controller.go | 11 ++++ internal/provider/openai/effort.go | 7 +++ 7 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 internal/agent/effort_override.go create mode 100644 internal/agent/effort_override_test.go diff --git a/desktop/app.go b/desktop/app.go index 3ccde6043a..ca10a087f0 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -9865,6 +9865,26 @@ func (a *App) SetEffortForTab(tabID, level string) error { defer a.runtimeRebuildMu.Unlock() tab.turnStartMu.Lock() defer tab.turnStartMu.Unlock() + // Per-request fast path: providers whose effort vocabulary is request- + // scoped (provider.Request.EffortOverride) take the new depth on the next + // call, so switching costs no rebuild at all. Providers that cannot vary + // depth per request return false here and fall through to the build+swap + // path below, which keeps re-anchoring semantics (recovery branches, + // snapshot) identical to the model switch. + if ctrl := a.controllerForTab(tab); ctrl != nil { + if entry, err := a.currentProviderEntryForTab(tabID); err == nil { + if effort, err := config.NormalizeEffort(entry, level); err == nil { + if setter, ok := ctrl.(interface { + SetSessionEffortOverride(string) bool + }); ok && setter.SetSessionEffortOverride(effort) { + a.mu.Lock() + tab.effort = &effort + a.mu.Unlock() + return nil + } + } + } + } prevPath := a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f0ec557483..0131055d6f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -291,6 +291,7 @@ type Agent struct { executorHandoffGuard bool responseLanguage atomic.Value // string: auto|zh|en reasoningLanguage atomic.Value // string: auto|zh|en + sessionEffort sessionEffortOverride requireVisibleFinal bool // internal callers require final Content continuationPolicy ContinuationPolicy diff --git a/internal/agent/effort_override.go b/internal/agent/effort_override.go new file mode 100644 index 0000000000..81b9a9a555 --- /dev/null +++ b/internal/agent/effort_override.go @@ -0,0 +1,80 @@ +package agent + +import ( + "strings" + "sync/atomic" +) + +// The session-scoped effort override lets a frontend switch the reasoning +// depth without rebuilding the runtime: the override rides the per-request +// channel (provider.Request.EffortOverride), and adapters apply it only when +// the endpoint's effort vocabulary accepts it — see requestEffort in the +// openai adapter and the PerRequestEfforts probe it exposes. + +// effortVarying is implemented by providers whose effort vocabulary is +// request-scoped. Optional on purpose: providers that cannot vary depth per +// call keep the boot-time rebuild path in the frontends. +type effortVarying interface { + PerRequestEfforts() []string +} + +// sessionEffortOverride holds a session-scoped reasoning-depth override — +// empty when the configured depth stands. atomic.Value follows the +// responseLanguage/reasoningLanguage precedent: written rarely (an explicit +// switch), read on every request. +type sessionEffortOverride struct{ atomic.Value } + +// SetSessionEffortOverride stores a session-scoped effort override and +// reports whether the running provider honors per-request depth. An empty +// level clears the override and always succeeds. A non-empty level is +// accepted only when the provider lists it in its per-request vocabulary; +// returning false tells the caller to fall back to the rebuild path instead +// of writing an override that would silently degrade to the configured depth +// on the wire. +func (a *Agent) SetSessionEffortOverride(level string) bool { + level = strings.ToLower(strings.TrimSpace(level)) + if level == "" { + a.sessionEffort.Store("") + return true + } + // A recovery-forked session keeps its reanchor semantics on the rebuild + // path (forked file sealed, in-memory history anchored to a fresh branch): + // an override that skips the rebuild would leave the forked file in active + // use. Defer such sessions to the caller's fallback. + if path := strings.TrimSpace(a.sess.path); path != "" { + if meta, ok, err := LoadBranchMeta(path); err == nil && ok && meta.Recovered { + return false + } + } + varying, ok := a.svc.prov.(effortVarying) + if !ok { + return false + } + for _, l := range varying.PerRequestEfforts() { + if strings.EqualFold(strings.TrimSpace(l), level) { + a.sessionEffort.Store(level) + return true + } + } + return false +} + +// sessionEffortOverrideValue returns the stored override; empty when unset, +// which lets the configured depth stand. +func (a *Agent) sessionEffortOverrideValue() string { + if v, ok := a.sessionEffort.Load().(string); ok { + return v + } + return "" +} + +// effortOverrideForRequest picks the request-scoped depth: the governor's +// engaged override wins — a running guard must not be outbid by a +// session-level depth bump — then the session override, then the configured +// depth (empty). +func (a *Agent) effortOverrideForRequest() string { + if gov := a.governorOverride(); gov != "" { + return gov + } + return a.sessionEffortOverrideValue() +} diff --git a/internal/agent/effort_override_test.go b/internal/agent/effort_override_test.go new file mode 100644 index 0000000000..5cc26e5d03 --- /dev/null +++ b/internal/agent/effort_override_test.go @@ -0,0 +1,74 @@ +package agent + +import ( + "testing" + + "reasonix/internal/event" +) + +// varyingProvider extends the shared fakeProvider with a request-scoped +// effort vocabulary, mirroring the openai adapter's PerRequestEfforts probe. +type varyingProvider struct { + fakeProvider + efforts []string +} + +func (p *varyingProvider) PerRequestEfforts() []string { return p.efforts } + +func TestSetSessionEffortOverrideVocabularyGate(t *testing.T) { + newAgent := func(efforts []string) *Agent { + return New(&varyingProvider{efforts: efforts}, nil, NewSession("s"), Options{}, event.Discard) + } + + a := newAgent([]string{"low", "max"}) + if !a.SetSessionEffortOverride("max") { + t.Fatal("vocabulary-listed level rejected") + } + if got := a.effortOverrideForRequest(); got != "max" { + t.Fatalf("override = %q, want max", got) + } + if a.SetSessionEffortOverride("high") { + t.Fatal("level outside the vocabulary accepted") + } + if got := a.effortOverrideForRequest(); got != "max" { + t.Fatalf("override after rejected level = %q, want max", got) + } + if !a.SetSessionEffortOverride("") { + t.Fatal("clearing the override must always succeed") + } + if got := a.effortOverrideForRequest(); got != "" { + t.Fatalf("override after clear = %q, want empty", got) + } +} + +func TestSetSessionEffortOverrideNonVaryingProvider(t *testing.T) { + a := New(&fakeProvider{}, nil, NewSession("s"), Options{}, event.Discard) + if a.SetSessionEffortOverride("max") { + t.Fatal("non-varying provider accepted a per-request override") + } + if got := a.effortOverrideForRequest(); got != "" { + t.Fatalf("override = %q, want empty", got) + } +} + +func TestEffortOverrideForRequestGovernorPriority(t *testing.T) { + a := New(&varyingProvider{efforts: []string{"low", "max"}}, nil, NewSession("s"), Options{}, event.Discard) + if !a.SetSessionEffortOverride("max") { + t.Fatal("set session override") + } + + prevGovernorEnabled := governorEnabled + governorEnabled = true + t.Cleanup(func() { governorEnabled = prevGovernorEnabled }) + + // Governor disengaged: the session override stands. + if got := a.effortOverrideForRequest(); got != "max" { + t.Fatalf("override = %q, want max", got) + } + // Governor engaged: the running guard must not be outbid by a session + // depth bump. + a.task.governor.engaged = true + if got := a.effortOverrideForRequest(); got != governorEffort { + t.Fatalf("override = %q, want governor effort %q", got, governorEffort) + } +} diff --git a/internal/agent/sampling_request.go b/internal/agent/sampling_request.go index 344e144c12..ad936d738f 100644 --- a/internal/agent/sampling_request.go +++ b/internal/agent/sampling_request.go @@ -122,7 +122,7 @@ func (a *Agent) buildSamplingRequest(ctx context.Context, trigger string) (sampl MaxTokens: a.maxOutputTokens, Temperature: provider.OptionalTemperature(a.temperature), ResponseFormat: responseFormatFromRequest(ctx), - EffortOverride: a.governorOverride(), + EffortOverride: a.effortOverrideForRequest(), } if provider.NativeToolSearchEnabled(a.svc.prov) { req.ToolSearch = &provider.ToolSearch{Enabled: true} diff --git a/internal/control/controller.go b/internal/control/controller.go index e7f185c2ff..1bedf86ecd 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -959,6 +959,17 @@ func (c *Controller) recordDisplayForNewUser(startMessages int, display string) } } +// SetSessionEffortOverride applies a session-scoped reasoning-depth override +// to the running agent (no rebuild) and reports whether the provider honors +// per-request depth; false means the caller must fall back to the rebuild +// path instead of writing an override the endpoint would ignore. +func (c *Controller) SetSessionEffortOverride(level string) bool { + if c.executor == nil { + return false + } + return c.executor.SetSessionEffortOverride(level) +} + func (c *Controller) markEditedForNewUser(startMessages int, original string) { if strings.TrimSpace(original) == "" || c.executor == nil { return diff --git a/internal/provider/openai/effort.go b/internal/provider/openai/effort.go index f906984506..9bc9561708 100644 --- a/internal/provider/openai/effort.go +++ b/internal/provider/openai/effort.go @@ -110,3 +110,10 @@ func hasExplicitSupportedEfforts(levels []string) bool { } return false } + +// PerRequestEfforts reports the depth levels a request-scoped EffortOverride +// may take on this endpoint (see requestEffort). Nil or empty means the +// endpoint cannot vary depth per request, so frontends that fix effort at +// boot must keep using the configured depth (or rebuild the runtime) instead +// of writing a per-request override that would silently degrade on the wire. +func (c *client) PerRequestEfforts() []string { return c.requestEfforts }