Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +9880 to +9883
}
}
}
}
prevPath := a.reconciledSessionPathForTab(tab)
if prevPath == "" {
prevPath = a.currentSessionPathFor(tab)
Expand Down
1 change: 1 addition & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions internal/agent/effort_override.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Comment on lines +44 to +48
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()
}
74 changes: 74 additions & 0 deletions internal/agent/effort_override_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/agent/sampling_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
11 changes: 11 additions & 0 deletions internal/control/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/provider/openai/effort.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Comment on lines +114 to +119
Loading