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
7 changes: 6 additions & 1 deletion api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4332,6 +4332,7 @@ def get_reasoning_status(
model_id: str | None = None,
provider_id: str | None = None,
base_url: str | None = None,
reasoning_effort: str | None = None,
) -> dict:
"""Return current reasoning configuration from the active profile's
config.yaml — the same source of truth the CLI reads from.
Expand All @@ -4344,7 +4345,11 @@ def get_reasoning_status(
display_cfg = config_data.get("display") or {}
agent_cfg = config_data.get("agent") or {}
show_raw = display_cfg.get("show_reasoning") if isinstance(display_cfg, dict) else None
effort_raw = agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None
effort_raw = (
reasoning_effort
if reasoning_effort is not None
else agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None
)

resolve_model = model_id
resolve_provider = provider_id
Expand Down
9 changes: 7 additions & 2 deletions api/gateway_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,16 @@ def _gateway_use_runs_api_enabled(config_data=None, environ: dict[str, str] | No
return raw in ("1", "true", "yes", "on")


def _gateway_reasoning_effort_for_request(cfg, *, model=None, model_provider=None):
def _gateway_reasoning_effort_for_request(cfg, *, model=None, model_provider=None, reasoning_effort=None):
"""Read and coerce user-configured reasoning effort for a gateway request."""
try:
cfg_data = cfg if isinstance(cfg, dict) else {}
effort_cfg = cfg_data.get("agent", {}) if isinstance(cfg_data, dict) else {}
effort_raw = effort_cfg.get("reasoning_effort") if isinstance(effort_cfg, dict) else None
effort_raw = (
reasoning_effort
if reasoning_effort is not None
else effort_cfg.get("reasoning_effort") if isinstance(effort_cfg, dict) else None
)
coerced = coerce_reasoning_effort_for_model(
effort_raw,
model,
Expand Down Expand Up @@ -995,6 +999,7 @@ def put_gateway_event(event, data):
cfg,
model=model,
model_provider=model_provider,
reasoning_effort=getattr(s, "reasoning_effort", None),
)
base_url = _gateway_base_url(cfg)
api_key = _gateway_api_key()
Expand Down
4 changes: 3 additions & 1 deletion api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
workspace=str(DEFAULT_WORKSPACE), created_workspace=None,
model=DEFAULT_MODEL,
model_provider=None,
reasoning_effort=None,
messages=None, created_at=None, updated_at=None,
tool_calls=None, pinned: bool=False, archived: bool=False,
project_id: str=None, profile=None,
Expand Down Expand Up @@ -1263,6 +1264,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
)
self.model = model
self.model_provider = str(model_provider).strip().lower() if model_provider else None
self.reasoning_effort = reasoning_effort
# #5979: signature of the model the user DELIBERATELY picked this session
# (``"<model>\x1f<provider>"``), or None. Used by the streaming resolver
# to preserve a custom-proxy vendor namespace on a COLD catalog ONLY when
Expand Down Expand Up @@ -1393,7 +1395,7 @@ def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
# without parsing the full messages array (which may be 400KB+).
# Fields are listed in the order they should appear in the JSON file.
METADATA_FIELDS = [
'session_id', 'title', 'workspace', 'created_workspace', 'model', 'model_provider', 'model_explicit_pick_signature', 'created_at', 'updated_at',
'session_id', 'title', 'workspace', 'created_workspace', 'model', 'model_provider', 'reasoning_effort', 'model_explicit_pick_signature', 'created_at', 'updated_at',
'pinned', 'archived', 'project_id', 'profile',
'input_tokens', 'output_tokens', 'estimated_cost',
'cache_read_tokens', 'cache_write_tokens',
Expand Down
33 changes: 33 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13383,12 +13383,15 @@ def handle_get(handler, parsed) -> bool:
model_id = (query.get("model", [""])[0] or "").strip() or None
provider_id = (query.get("provider", [""])[0] or "").strip() or None
base_url = (query.get("base_url", [""])[0] or "").strip() or None
session_id = (query.get("session_id", [""])[0] or "").strip()
session = get_session(session_id, metadata_only=True) if session_id else None
return j(
handler,
get_reasoning_status(
model_id=model_id,
provider_id=provider_id,
base_url=base_url,
reasoning_effort=getattr(session, "reasoning_effort", None),
),
)

Expand Down Expand Up @@ -15412,6 +15415,11 @@ def _commit_prev_session_memory(_sid=prev_session_id):
enabled_toolsets=getattr(session, "enabled_toolsets", None),
context_length=getattr(session, "context_length", None),
threshold_tokens=getattr(session, "threshold_tokens", None),
# Reasoning effort is a per-session override (#6809). Without
# carrying it, the duplicate reads None and silently falls back
# to the profile-global default, so a source session set to
# xhigh produces a copy running at whatever the profile says.
reasoning_effort=getattr(session, "reasoning_effort", None),
truncation_watermark=getattr(session, "truncation_watermark", None),
truncation_boundary=getattr(session, "truncation_boundary", None),
# context_messages is the authoritative model-facing prefix — must be
Expand Down Expand Up @@ -15553,6 +15561,22 @@ def _commit_prev_session_memory(_sid=prev_session_id):
model_id = str(body.get("model") or "").strip() or None
provider_id = str(body.get("provider") or "").strip() or None
base_url = str(body.get("base_url") or "").strip() or None
session_id = str(body.get("session_id") or "").strip()
if session_id:
raw = str(effort or "").strip().lower()
if raw and raw != "none" and raw not in api_config.VALID_REASONING_EFFORTS:
raise ValueError(f"Unknown reasoning effort '{effort}'.")
session = _get_or_materialize_session(session_id)
with _get_session_agent_lock(session_id):
session.reasoning_effort = raw
session.save()
api_config._evict_session_agent(session_id)
return j(handler, get_reasoning_status(
model_id=model_id,
provider_id=provider_id,
base_url=base_url,
reasoning_effort=raw,
))
return j(
handler,
set_reasoning_effort(
Expand Down Expand Up @@ -16271,6 +16295,10 @@ def _draft_mark(name):
enabled_toolsets=getattr(source, "enabled_toolsets", None),
context_length=getattr(source, "context_length", None),
threshold_tokens=getattr(source, "threshold_tokens", None),
# Reasoning effort is a per-session override (#6809). A branch that
# drops it reads None and silently falls back to the profile-global
# default, so a fork of an xhigh session runs at the profile value.
reasoning_effort=getattr(source, "reasoning_effort", None),
# context_messages — truncated to fork prefix (not full parent copy)
context_messages=copy.deepcopy(forked_context),
# Gateway routing — inherit from source
Expand Down Expand Up @@ -23771,6 +23799,11 @@ def _handle_session_compression_recovery_start(handler, body):
enabled_toolsets=copy.deepcopy(getattr(source, "enabled_toolsets", None)),
context_length=getattr(source, "context_length", None),
threshold_tokens=getattr(source, "threshold_tokens", None),
# Reasoning effort is a per-session override (#6809). The
# focused continuation keeps the source lane's model settings,
# so it must keep the effort too; dropping it reads None and
# silently falls back to the profile-global default.
reasoning_effort=getattr(source, "reasoning_effort", None),
gateway_routing=copy.deepcopy(getattr(source, "gateway_routing", None)),
gateway_routing_history=copy.deepcopy(getattr(source, "gateway_routing_history", None) or []),
parent_session_id=getattr(source, "session_id", sid),
Expand Down
7 changes: 6 additions & 1 deletion api/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -10203,7 +10203,12 @@ def _fallback_entries(_raw):
# the key is absent or invalid, pass None → agent uses its default.
try:
_effort_cfg = _cfg.get('agent', {}) if isinstance(_cfg, dict) else {}
_effort_raw = _effort_cfg.get('reasoning_effort') if isinstance(_effort_cfg, dict) else None
_session_effort = getattr(_session_meta, 'reasoning_effort', None) if _session_meta else None
_effort_raw = (
_session_effort
if _session_effort is not None
else _effort_cfg.get('reasoning_effort') if isinstance(_effort_cfg, dict) else None
)
_effort = coerce_reasoning_effort_for_model(
_effort_raw,
resolved_model,
Expand Down
93 changes: 88 additions & 5 deletions static/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -1903,17 +1903,100 @@ function cmdReasoning(args){
return true;
}
if(EFFORTS.includes(arg)){
// Persist via /api/reasoning → config.yaml agent.reasoning_effort.
// Takes effect on the NEXT session/turn (agent re-reads config at
// construction time), matching CLI semantics where `/reasoning high`
// also forces an agent re-init.
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
// Scope the write to the active session, exactly like the composer chip.
// Before this, /reasoning <effort> POSTed a bare {effort} and hit the
// profile-global default, so a session holding a persisted override kept
// its old effort while this toast claimed the new value applied.
// _reasoningEffortContext() adds session_id only when a session is active;
// with no session there is no override to scope to and the global write is
// the correct behaviour (it is what /reasoning does before the first chat).
//
// These four ui.js symbols are MANDATORY, called directly with no `typeof`
// fallback. index.html loads ui.js (1775) before commands.js (1779), both
// `defer`, so ui.js has run to completion before this handler can exist. A
// fallback here therefore never covers a legitimate load order — it only
// covers dependency failure or bundle skew, and in both of those cases the
// old fallbacks FAILED OPEN into exactly the two defects this change set
// exists to close: a missing context helper POSTed a bare {effort} and
// mutated the PROFILE-GLOBAL default, and a missing sequence or predicate
// helper applied a superseded chip write and toast. Fail closed instead.
//
// A missing symbol now raises a ReferenceError. We catch it here rather
// than let it escape: messages.js:1494 invokes this handler with no
// try/catch, so an escaping throw would abort send() before it clears the
// composer and hides the command dropdown, and send() is async and called
// unawaited (ui.js:8418) so the throw would surface only as an unhandled
// rejection. Report it as a toast and mutate nothing.
//
// RESOLVE AND VALIDATE EVERY DEPENDENCY BEFORE MUTATING ANYTHING.
// `_reasoningFetchSeq` is a SHARED dispatch generation: fetchReasoningChip()
// and syncReasoningChip() in ui.js compare their captured sequence against
// it. Advancing it and then failing supersedes a cold in-flight chip fetch
// that captured the old value. That fetch then returns early at its
// stale-generation check while `_lastReasoningFetchKey` stays set, so a
// same-key syncReasoningChip() short-circuits instead of retrying and chip
// hydration is stranded until something else invalidates the key. Merely
// invoking an unavailable command must not be able to do that.
//
// So: read the context and the query, require a CALLABLE predicate and a
// FINITE SAFE-INTEGER counter, and only then increment and bind. A
// present-but-undefined or non-callable helper is as unusable as an absent
// one — assignment alone succeeding is not evidence the helper works, and
// the old code discovered that only inside .then(), after the POST had
// already changed server state. A counter holding `undefined` is worse than
// absent: prefix increment yields NaN, which throws nothing and makes every
// later generation comparison false.
let payload,key,seq,current;
try{
const ctx=_reasoningEffortContext();
payload=Object.assign({effort:arg},ctx);
// Same stale-context guard as the chip POST: a request dispatched from
// session A can resolve after the user switches to session B, and applying
// it there would poison B's chip and cache. Snapshot the dispatch key and
// sequence number now, then discard a superseded response silently — no
// chip write, no toast.
key=_reasoningEffortQuery();
// Bind the predicate EAGERLY rather than referencing it inside current().
// A lazy reference resolves only when the response settles, which is
// AFTER the POST has already gone out: the ReferenceError would then fire
// inside .then() as an unhandled rejection, having already mutated
// server state. Capturing it here moves the failure ahead of dispatch, so
// an unavailable predicate sends nothing at all.
const isCurrent=_reasoningDispatchIsCurrent;
if(typeof isCurrent!=='function'){
throw new TypeError('_reasoningDispatchIsCurrent is not callable');
}
// Read the counter BEFORE writing it, so an unusable value fails closed
// with the generation untouched.
const prevSeq=_reasoningFetchSeq;
if(!Number.isSafeInteger(prevSeq)){
throw new TypeError('_reasoningFetchSeq is not a safe integer');
}
// Every dependency is now validated: this is the first mutation.
seq=++_reasoningFetchSeq;
current=function(){return isCurrent(seq,key);};
}catch(e){
// Fail closed: no /api/reasoning POST, no chip write, and no toast that
// claims an effort was saved. The message is a literal, matching the
// status branch's '/reasoning — status unavailable' above: a handler
// whose purpose is surviving a missing dependency must not itself depend
// on t() from i18n.js.
showToast(BRAIN+' /reasoning '+arg+' \u2014 unavailable: '
+(e&&e.message?e.message:'reasoning helpers missing'));
return true;
}
// Takes effect on the NEXT turn (the agent re-reads its effort at
// construction time), matching CLI semantics where `/reasoning high` also
// forces an agent re-init.
api('/api/reasoning',{method:'POST',body:JSON.stringify(payload)})
.then(function(st){
if(!current()) return;
const eff=(st && st.reasoning_effort)||arg;
showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)');
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff, st||{});
})
.catch(function(e){
if(!current()) return;
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
});
return true;
Expand Down
40 changes: 33 additions & 7 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -5167,6 +5167,7 @@ function _reasoningEffortContext(){
provider=_modelStateForSelect(sel, model).model_provider||'';
}
const ctx={};
if(S&&S.session&&S.session.session_id) ctx.session_id=S.session.session_id;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if(model) ctx.model=model;
if(provider) ctx.provider=provider;
return ctx;
Expand Down Expand Up @@ -5263,14 +5264,26 @@ function _applyReasoningChip(eff){
// topbar syncs can serve the cached chip state instead of re-hitting the
// network. null = never fetched.
let _lastReasoningFetchKey=null;
// Monotonic dispatch counter. Each fetchReasoningChip() increments it and the
// async handlers capture their own value; a response (success OR failure) only
// applies if it is still the most recent dispatch. This defeats out-of-order
// resolution even when two fetches share the same model/provider key (e.g. a
// profile switch that resets the cache and refetches the same default model but
// a different agent.reasoning_effort) — #4650 review.
// Monotonic dispatch counter. Every reasoning request — the fetchReasoningChip()
// GET and the chip-selection POST alike — increments it and the async handlers
// capture their own value; a response (success OR failure) only applies if it is
// still the most recent dispatch. This defeats out-of-order resolution even when
// two dispatches share the same model/provider key (e.g. a profile switch that
// resets the cache and refetches the same default model but a different
// agent.reasoning_effort) — #4650 review.
let _reasoningFetchSeq=0;

// True when the dispatch identified by (seq, key) is still the one the UI is
// waiting on. Both halves are load-bearing: the sequence number rejects a
// dispatch that a newer one superseded for the SAME session, and the key
// comparison rejects a response that lands after the active session, model, or
// provider changed. A stale response must be discarded silently — applying it
// would write one session's effort onto another session's chip, which is the
// session-confusion class the per-session storage fix removes.
function _reasoningDispatchIsCurrent(seq, key){
return seq===_reasoningFetchSeq && key===_reasoningEffortQuery();
}

function fetchReasoningChip(keyOverride){
// Set the cache key OPTIMISTICALLY before the request so rapid routine syncs
// while this GET is in flight short-circuit instead of re-dispatching (that
Expand Down Expand Up @@ -5401,15 +5414,28 @@ document.addEventListener('click',function(e){
// (#6219 round-3)
if(opt){
const payload=Object.assign({effort:effort},_reasoningEffortContext());
// Snapshot the dispatch identity BEFORE the request. A POST dispatched
// from session A can resolve AFTER the user switches to session B; without
// this guard the late response writes A's effort onto B's chip and poisons
// B's cache — the same session-confusion the per-session storage fix
// removes, on the async-completion path. Discard a stale response
// silently: no chip write, no cache write, and no toast, because a toast
// for a session the user already left is itself misinformation.
const key=_reasoningEffortQuery();
const seq=++_reasoningFetchSeq;
api('/api/reasoning',{method:'POST',body:JSON.stringify(payload)})
.then(function(st){
if(!_reasoningDispatchIsCurrent(seq,key)) return;
// For Default (effort=''), the returned reasoning_effort is '' (clear)
// — display 'Default' rather than an empty toast.
const display=(st&&st.reasoning_effort)||effort||'Default';
_applyReasoningChip((st&&st.reasoning_effort)||effort, st||{});
showToast('🧠 Reasoning effort set to '+display);
})
.catch(function(){showToast('🧠 Failed to set effort');});
.catch(function(){
if(!_reasoningDispatchIsCurrent(seq,key)) return;
showToast('🧠 Failed to set effort');
Comment on lines +5435 to +5437

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed POST strands chip state

When a user switches from session X to session Y and selects an effort before Y's chip GET completes, the POST supersedes that GET. If the POST fails, this handler retains Y's optimistic fetch key and X's cached effort, causing later topbar synchronizations to keep displaying X's effort in session Y instead of refetching.

});
closeReasoningDropdown();
}
}
Expand Down
Loading
Loading