From aaa07a7db6b93eaf8913f803aa1e9a617d5f983f Mon Sep 17 00:00:00 2001 From: Handsome-wzw <68996445+Handsome-wzw@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:51:35 +0800 Subject: [PATCH 1/6] feat(agent): per-conversation reasoning effort (picker row, /effort, session.set_effort) One conversation can now ask for more or less thinking than the configured default without moving that default. The pick rides the session's metadata beside `model` (`raven.agent.session_effort`), so it outlives the gateway and a resumed conversation runs at what it was left at; the turn entrance hands it to `_run_agent_loop`, which ranks it over the mode's effort and the configured `agents.defaults.reasoningEffort`, under a hook's rollback override. Raven's own calls only: sub-agents keep the configured default. Surfaces: `session.set_effort` (report, set or clear; every answer names what the default is on this install and the words), `session.info` carries `reasoning_effort` for the status bar, the WebUI's model picker gains an effort row under the tier row, and the TUI gains `/effort`. The settings page drew an unset `agents.defaults.reasoningEffort` as Low; the unset state is now its own rung, Provider default, and the key accepts null so that rung clears it. The using-raven guide documents both levels. Co-authored-by: Claude (claude-fable-5-1) --- CHANGELOG.md | 16 ++ CONTEXT.md | 18 ++ docs-site/docs/commands.md | 1 + docs-site/docs/commands.zh.md | 1 + docs-site/docs/using-raven.md | 25 +++ docs-site/docs/using-raven.zh.md | 19 ++ i18n/messages.json | 54 ++++++ raven/agent/loop/turn_path.py | 39 +++- raven/agent/session_effort.py | 76 ++++++++ raven/config/schema.py | 8 + raven/rpc/methods/console.py | 16 +- raven/rpc/methods/session.py | 87 +++++++++ raven/rpc/models.py | 32 ++++ rpc-schema/openrpc.json | 73 +++++++ tests/test_agent_loop_usage_sink.py | 70 +++++++ tests/test_rpc_session.py | 178 ++++++++++++++++++ tests/test_rpc_session_init_bundle.py | 3 + tests/test_rpc_settings.py | 1 + tests/test_session_effort.py | 58 ++++++ ui-tui/src/__tests__/effortCommand.test.ts | 110 +++++++++++ ui-tui/src/__tests__/slashParity.test.ts | 1 + ui-tui/src/app/slash/commands/session.ts | 50 +++++ ui-tui/src/i18n/messages.generated.ts | 31 +++ ui-tui/src/rpc/generated.ts | 38 ++++ ui-web/scripts/fixture-params.mjs | 1 + ui-web/scripts/gates/boot-order.test.mjs | 3 +- ui-web/src/app/boot.ts | 4 + ui-web/src/app/install.ts | 3 +- .../src/features/model/ModelPicker.test.tsx | 62 ++++++ ui-web/src/features/model/ModelPicker.tsx | 54 +++++- ui-web/src/features/model/manifest.ts | 2 +- ui-web/src/features/model/source.ts | 30 +++ .../settings/providers/Roles.test.tsx | 21 +++ .../src/features/settings/providers/Roles.tsx | 12 +- ui-web/src/features/settings/source.test.ts | 2 +- ui-web/src/rpc/fixtures/sessions.ts | 3 + ui-web/src/rpc/fixtures/settings.ts | 12 ++ ui-web/src/rpc/generated.ts | 34 +++- ui-web/src/state/effort.test.ts | 133 +++++++++++++ ui-web/src/state/effort.ts | 159 ++++++++++++++++ ui-web/src/state/session/registry.ts | 7 +- ui-web/src/state/session/runtime.ts | 22 ++- ui-web/src/state/session/staging.ts | 6 +- ui-web/src/state/sources.ts | 3 + ui-web/src/styles/page.css | 12 +- 45 files changed, 1558 insertions(+), 32 deletions(-) create mode 100644 raven/agent/session_effort.py create mode 100644 tests/test_session_effort.py create mode 100644 ui-tui/src/__tests__/effortCommand.test.ts create mode 100644 ui-web/src/state/effort.test.ts create mode 100644 ui-web/src/state/effort.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b252cfd98..50bf5f3e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to Raven are documented here. ### Added +- The reasoning effort can be set **per conversation**: the *Reasoning + effort* row of the WebUI's model picker, beside the sub-agent tier, and + `/effort ` in the TUI (`/effort` alone + reports, `default` returns to the global setting). The pick outranks + `agents.defaults.reasoningEffort` and the conversation's mode, is kept on the + conversation's own record so it survives a gateway restart and comes back on + resume, lands on the next turn, and moves Raven's own model calls only. + New RPC `session.set_effort` (report, set or clear; every answer names what + the default is on this install and the words), and `session.info` carries + `reasoning_effort` for the status bar. The using-raven guide has a section on + it. - The history slot is filled by a **history strategy**, `context.history.strategy`: `compacting` (the new default) or `curator` (the previous behaviour, one setting away). Compacting keeps the conversation verbatim until the window @@ -429,6 +440,11 @@ All notable changes to Raven are documented here. ### Fixed +- The settings page showed the reasoning effort as **Low** when the key was + not set at all, while the provider's own default was what ran. The unset + state is now its own rung, *Provider default*, which also clears the key + (`agents.defaults.reasoningEffort` accepts null) rather than leaving a word + the install never chose. - In the TUI on native Windows, typing with a Chinese IME no longer duplicates characters. The console was told to re-encode keys through Kitty keyboard / modifyOtherKeys, which doubles IME commits before they reach Raven's input diff --git a/CONTEXT.md b/CONTEXT.md index a34364a36..f0b2684ae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -272,6 +272,24 @@ _Avoid_: putting the scope of the control on the rungs. Each row says only what distinguishes it; that raven's own effort is unchanged is stated once by whichever surface draws the control. +**Session Reasoning Effort** (`agent/session_effort.py`; `session.set_effort` over RPC, `/effort` in +the TUI, the effort row of the WebUI's model picker): +The reasoning effort one session picked for its own model calls, kept on the session's +metadata beside `model` (`reasoning_effort`; null once cleared, since a metadata patch sets +keys and never removes one) rather than on the loop's in-memory policy, so it outlives the +gateway and a resumed conversation runs at what it was left at. `_run_agent_loop` is handed +it by the turn entrance that holds the record, the way the model is, and ranks it: the +configured `agents.defaults.reasoningEffort` underneath, a Session Mode's own +`reasoningEffort` over that, this pick over both, a hook's rollback override on top. The +words are `REASONING_EFFORTS` (`config/schema.py`), shared with the console's checker for +`agents.defaults.reasoningEffort` -- a key that is clearable (null is the provider's own +default) and that the settings page draws, when unset, as a rung of its own rather than as +`low`. Lands on the next turn, like a tier; reaches no sub-agent, unlike one -- a sub-agent +is still told the configured default (`RAVEN_PARENT_REASONING_EFFORT`). +_Avoid_: reading the pick off `SessionPolicy`. A mode switch replaces the whole policy and a +restart empties it; the record is the pick's one home, and the policy carries only the +mode's own effort. + **Subagent** (`agent/subagent/`): A background agent task spawned by `SubagentManager`. Runs with its own tool set; its result re-enters the session as a `SUBAGENT`-origin `TurnRequest` via Spine submit. Bounded by diff --git a/docs-site/docs/commands.md b/docs-site/docs/commands.md index b4c3b279e..eec9219aa 100644 --- a/docs-site/docs/commands.md +++ b/docs-site/docs/commands.md @@ -46,6 +46,7 @@ on a specific command. From a source checkout, prefix commands with `uv run`. | Task | Read | | --- | --- | | Choose a conversation or one-shot workflow | [Using Raven](using-raven.md) | +| Set how much the model thinks, globally or per conversation | [Using Raven](using-raven.md#set-the-reasoning-effort) | | Configure shipped or external agents | [Agent Integrations](agent-integrations.md) | | Continue or steer a specialist task | [Working with sub-agents](agent-collaboration.md) | | Share browser work with the agent | [Browser collaboration](browser-collaboration.md) | diff --git a/docs-site/docs/commands.zh.md b/docs-site/docs/commands.zh.md index 90440dee0..d820f01db 100644 --- a/docs-site/docs/commands.zh.md +++ b/docs-site/docs/commands.zh.md @@ -45,6 +45,7 @@ | 任务 | 阅读 | | --- | --- | | 选择对话或单次工作流 | [使用 Raven](using-raven.md) | +| 设置模型的思考深度(全局或单个对话) | [使用 Raven](using-raven.md#set-the-reasoning-effort) | | 配置随 Raven 发布或外部 Agent | [Agent 集成](agent-integrations.md) | | 继续或引导专用任务 | [与子 Agent 协作](agent-collaboration.md) | | 与 Agent 共享浏览器工作 | [浏览器协作](browser-collaboration.md) | diff --git a/docs-site/docs/using-raven.md b/docs-site/docs/using-raven.md index 290461ab2..db441495d 100644 --- a/docs-site/docs/using-raven.md +++ b/docs-site/docs/using-raven.md @@ -57,6 +57,31 @@ Use the UI's conversation picker for interactive sessions. A sub-agent `instance` continues that child's conversation; it is not the host session id or a DAG node id. See [DAG Orchestration](orchestration.md). +## Set the reasoning effort + +The reasoning effort is how much the model thinks before each answer. The +words are `minimal`, `low`, `medium` and `high`. Each provider maps them to its +own request (Anthropic's adaptive effort and thinking budgets, the +`reasoning_effort` field of OpenAI-style APIs), and a model with no thinking to +move ignores them. Left unset, the provider's own default applies. + +Two places set it: + +- **For every conversation**: in the WebUI, open Settings, then *Parameters* on + the *Chat* row, then *Reasoning effort*; or set + `agents.defaults.reasoningEffort` in `~/.raven/config.json`. The first rung, + *Provider default*, clears the key. +- **For one conversation**: the *Reasoning effort* row of the model picker in + the WebUI (open it from the model chip beside the composer), or `/effort + ` in the TUI. `/effort` alone reports the effort in force, and + `/effort default` returns the conversation to the global setting. + +A pick made for one conversation outranks the global setting and the +conversation's mode. It is kept on the conversation's own record, so it survives +a gateway restart and comes back when the conversation is resumed, and it lands +on the next turn. It moves Raven's own model calls only; sub-agents keep the +configured default. + ## Add capability deliberately Use a skill for reusable instructions, MCP for external tools, and a plugin for diff --git a/docs-site/docs/using-raven.zh.md b/docs-site/docs/using-raven.zh.md index fe613c142..4aace7e22 100644 --- a/docs-site/docs/using-raven.zh.md +++ b/docs-site/docs/using-raven.zh.md @@ -49,6 +49,25 @@ raven agent --continue -m "Expand the previous summary; do not modify files" 交互式会话通过 UI 的对话选择器切换。子 Agent 的 `instance` 续接子 Agent 对话, 不是宿主 session id 或 DAG node id,详见[DAG 编排](orchestration.md)。 +## 设置思考深度 { #set-the-reasoning-effort } + +思考深度是模型每次回答前思考多少。可选的词是 `minimal`、`low`、`medium`、`high`。 +各厂商把它们映射成自己的请求参数(Anthropic 的自适应思考强度与思考预算,OpenAI 风格 +接口的 `reasoning_effort` 字段),没有思考能力的模型会忽略它。不设置时使用厂商自己的默认值。 + +两处可以设置: + +- **对所有对话**:在 WebUI 打开设置页,在「主对话」一行点「参数」,再选「思考深度」; + 或在 `~/.raven/config.json` 里设置 `agents.defaults.reasoningEffort`。第一档 + 「厂商默认」会清掉这个键。 +- **只对当前对话**:WebUI 模型选择器(点输入框旁的模型标签打开)里的「思考深度」行, + 或 TUI 里的 `/effort <词>`。单独输入 `/effort` 查看当前生效的档位,`/effort default` + 让这个对话重新跟随全局设置。 + +单个对话的选择优先于全局设置和该对话的模式。它记录在对话自己的档案里,网关重启后仍在, +恢复对话时一并带回,从下一轮开始生效。它只影响 Raven 自己的模型调用;子智能体仍按全局 +默认值运行。 + ## 有目的地增加能力 { #add-capability-deliberately } 可复用指令用 skill,外部工具用 MCP,运行时贡献用 plugin。需要独立身份和执行配置 diff --git a/i18n/messages.json b/i18n/messages.json index 1a0776639..8569e12bf 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -241,6 +241,16 @@ "help": "查看或设置推理强度(对运行中的 agent 生效)" } }, + "effort": { + "en": { + "name": "effort", + "help": "show or set how much the model thinks for this conversation [minimal|low|medium|high|default]" + }, + "zh": { + "name": "思考深度", + "help": "查看或设置本会话的思考深度 [minimal|low|medium|high|default]" + } + }, "fast": { "zh": { "name": "快速模式", @@ -6161,6 +6171,46 @@ "en": "This is a deployment's own mode, not a built-in tier, so it is not passed on to sub-agents.", "zh": "这是部署自带的模式,不是内置档位,因此不会下发给子智能体。" }, + "gui.effort.title": { + "en": "Reasoning effort", + "zh": "思考深度" + }, + "gui.effort.default": { + "en": "Default", + "zh": "默认" + }, + "gui.effort.minimal": { + "en": "Minimal", + "zh": "最少" + }, + "gui.effort.low": { + "en": "Low", + "zh": "低" + }, + "gui.effort.medium": { + "en": "Medium", + "zh": "中" + }, + "gui.effort.high": { + "en": "High", + "zh": "高" + }, + "gui.effort.scope": { + "en": "How much the model thinks before each answer, for this conversation only. Sub-agents are not affected.", + "zh": "模型每次回答前思考多少,只作用于本会话,不影响子智能体。" + }, + "gui.effort.now": { + "en": "Default follows the global setting, which is {name} right now.", + "zh": "「默认」跟随设置页里的全局值,当前是「{name}」。" + }, + "gui.effort.provider": { + "en": "the provider's own default", + "zh": "厂商默认" + }, + "gui.effort.failed": { + "en": "Could not set the reasoning effort to {name}", + "zh": "思考深度切到「{name}」没有成功" + }, "gui.imodel.title": { "en": "Model", "zh": "模型" @@ -8569,6 +8619,10 @@ "en": "Reasoning effort", "zh": "思考深度" }, + "gui.settings.roles.effort_default": { + "en": "Provider default", + "zh": "厂商默认" + }, "gui.settings.roles.effort_minimal": { "en": "Minimal", "zh": "最少" diff --git a/raven/agent/loop/turn_path.py b/raven/agent/loop/turn_path.py index f9edf20c8..d138ca178 100644 --- a/raven/agent/loop/turn_path.py +++ b/raven/agent/loop/turn_path.py @@ -84,6 +84,7 @@ from raven.agent.loop.failure_text import attach_full_detail, response_failure_text from raven.agent.loop.first_call import FirstCallGuard from raven.agent.loop.recovery import ContinuationGate, DraftGate, cut_reasoning_head, lower_reasoning_effort +from raven.agent.session_effort import of as session_effort_of from raven.agent.tools.registry import call_failed from raven.agent.tools.removals import RemovalWatch from raven.agent.window import shrink @@ -789,6 +790,7 @@ async def _run_agent_loop( # noqa: C901 (cc 100: pre-existing, above the ceilin rerun_pending: "Callable[[str | None, list[dict], str], bool] | None" = None, spend: TurnSpend | None = None, turn_log: TurnLog | None = None, + session_effort: str | None = None, ) -> tuple[str | None, list[str], list[dict], LoopOutcome]: """Run the agent iteration loop. @@ -839,6 +841,11 @@ async def _run_agent_loop( # noqa: C901 (cc 100: pre-existing, above the ceilin hand back and keeps what the turn actually said. A caller that passes none gets one opened here, so the rerun question below is still asked of the turn rather than of the window. + + ``session_effort`` is the reasoning effort the session picked for its + own model calls (``raven.agent.session_effort``), read off its record + by the caller the way the model is. It outranks a mode's effort and + the configured default; a hook's rollback override still outranks it. """ spend = spend if spend is not None else TurnSpend(session_key) messages = initial_messages @@ -944,6 +951,13 @@ async def _shrink(pressure: WindowPressure, at_model: str | None) -> ShrinkResul # Read once, here: a mode switched mid-turn lands on the next turn. policy = self.session_policy(session_key or "") iteration_cap = policy.max_iterations or self.max_iterations + # The effort this turn's own calls ask for, short of a hook's rollback + # override: the session's own pick over its mode's. Both are the + # session's and both land on the next turn; the pick wins because a + # person set it on this conversation, and the mode's is the + # catalogue's word for every conversation in that mode. None here + # leaves the configured default to ``gen_overrides``. + turn_effort = session_effort or policy.reasoning_effort # The other bound on this turn, and the only one a product supplies rather # than the loop: whatever a hook left under ``TURN_BUDGETS_KEY``. Read here # for the same reason the cap is, and defaulting to unbounded, so an agent @@ -1194,16 +1208,18 @@ def _hook_rollback(decision) -> bool: # TokenWise before-hook: strategies may rewrite messages, tools, # or model (e.g. CacheOptimizer marks cache_control blocks). - # The session's pinned effort first, a hook's rollback override on - # top: a mode that asks for more thinking sets the turn's default, - # and a gate re-sampling one call may still move that one call. + # The session's effort first, a hook's rollback override on top: a + # mode that asks for more thinking, or the session's own pick, sets + # the turn's default, and a gate re-sampling one call may still + # move that one call. gen_overrides = { - # The configured default underneath, the session's pin over it, - # a hook's rollback override on top. The default is here rather - # than left to the provider because the provider's copy is - # frozen at construction (see ``default_reasoning_effort``). + # The configured default underneath, the session's effort over + # it (``turn_effort``: its own pick, else its mode's), a hook's + # rollback override on top. The default is here rather than + # left to the provider because the provider's copy is frozen + # at construction (see ``default_reasoning_effort``). **({"reasoning_effort": self.default_reasoning_effort} if self.default_reasoning_effort else {}), - **({"reasoning_effort": policy.reasoning_effort} if policy.reasoning_effort else {}), + **({"reasoning_effort": turn_effort} if turn_effort else {}), **(pending_gen_overrides or {}), } pending_gen_overrides = None @@ -1551,7 +1567,7 @@ def _hook_rollback(decision) -> bool: tool_call.arguments, str(result), agent_output=getattr(result, "agent_output", False) ), watch_request, - reasoning_effort=policy.reasoning_effort, + reasoning_effort=turn_effort, ) # A call that bound a Worker Table (load_playbook) moved the # dispatch enums under an identity assembled before it. @@ -2128,7 +2144,7 @@ def _hook_rollback(decision) -> bool: fallback_models, on_token_delta=on_token_delta, on_reasoning_delta=on_reasoning_delta, - reasoning_effort=policy.reasoning_effort, + reasoning_effort=turn_effort, prompt=_MAX_ITER_SYNTHESIS_PROMPT if stalled_tool is None else _STALLED_SYNTHESIS_PROMPT, fallback=( _STALLED_STATIC_FALLBACK.format(tool=stalled_tool) @@ -2696,6 +2712,9 @@ async def _attempt(seed: list[dict], attempt: int): rerun_pending=_rerun_pending, spend=spend, turn_log=live["log"], + # Read here, beside the model, because this is where the + # session is: the loop below is handed a key, not a record. + session_effort=session_effort_of(session.metadata), ) # Taken BEFORE the first attempt, because the loop appends to the list it is diff --git a/raven/agent/session_effort.py b/raven/agent/session_effort.py new file mode 100644 index 000000000..19da84fdf --- /dev/null +++ b/raven/agent/session_effort.py @@ -0,0 +1,76 @@ +"""The reasoning effort one session picked for its own model calls. + +A conversation can ask for more or less thinking than the configured default +(``agents.defaults.reasoningEffort``) without moving that default: a quick +question is not worth the time a deep one is, and the person asking is the one +who knows which is which. The pick rides the session's metadata beside +``model``, and for the same reason -- it is this conversation's own, it has to +outlive the gateway (a policy held only in memory goes back to the default on +every restart, which is what the tier does), and the turn reads it where it +reads the model. + +Where it ranks: the configured default underneath, a Session Mode's own +``reasoningEffort`` over that, this pick over both, and a hook's rollback +override over the lot (``_run_agent_loop``'s ``gen_overrides``). Raven's own +calls only: a sub-agent is told the configured default, as before. + +The words are ``REASONING_EFFORTS`` (``config/schema.py``); the provider maps +each to its own wire, and a model with no thinking to move ignores it. +""" + +from __future__ import annotations + +from typing import Any + +from loguru import logger + +from raven.config.schema import REASONING_EFFORTS + +#: Where the pick is kept on a session: the metadata dict ``model`` lives in. +#: Null, not absent, once cleared -- a metadata patch can set a key and not +#: remove one, and both spellings read as "follows the default" here. +METADATA_KEY = "reasoning_effort" + + +def of(metadata: Any) -> str | None: + """The pick a session record carries, or None while it follows the default. + + A word outside ``REASONING_EFFORTS`` reads as none: a record written by a + later raven is not a request this one can send, and the turn is better off + at the default than refused by the provider. + """ + if not isinstance(metadata, dict): + return None + stored = metadata.get(METADATA_KEY) + return stored if isinstance(stored, str) and stored in REASONING_EFFORTS else None + + +def read(sessions: Any, session_key: str | None) -> str | None: + """What one session picked, read off its record; None for one following the default.""" + if sessions is None or not session_key: + return None + try: + # `peek`, not `get_or_create`, for the reason knowledge_scope.read + # gives: a read must not cache a fresh empty session under a key the + # manager has never seen. + held = sessions.peek(session_key) + except Exception: # noqa: BLE001 - a session that cannot be read has no pick + return None + return of(getattr(held, "metadata", None)) if held is not None else None + + +def write(sessions: Any, session_key: str, effort: str | None) -> bool: + """Record the pick (None clears it), and answer whether it reached the disk. + + A session that has no record yet -- a draft the first message has not + filed -- holds it on its in-memory copy, which the first turn's save then + writes; the caller reports that as pending, the way ``session.pin`` does. + """ + persisted = False + try: + persisted = bool(sessions.append_metadata_patch(session_key, {METADATA_KEY: effort})) + except Exception: # noqa: BLE001 - the in-memory copy below still carries it + logger.warning("session {}: failed to persist the reasoning effort", session_key) + if not persisted: + sessions.get_or_create(session_key).metadata[METADATA_KEY] = effort + return persisted diff --git a/raven/config/schema.py b/raven/config/schema.py index f6505fc95..df519771c 100644 --- a/raven/config/schema.py +++ b/raven/config/schema.py @@ -787,6 +787,14 @@ class GatewayPageConfig(Base): """The built-in tiers, cheapest first. The order is what makes a clamp possible, so it is a constant here rather than something read back off the catalogue.""" +REASONING_EFFORTS: tuple[str, ...] = ("minimal", "low", "medium", "high") +"""The reasoning efforts a surface may ask for, least first: the words +``agents.defaults.reasoningEffort`` accepts, and the ones a session's own pick +(``session.set_effort``) is checked against. Each provider maps a word to its +own wire -- the adaptive efforts and thinking budgets in +``providers/anthropic_messages_provider.py``, ``reasoning_effort`` on the +OpenAI-shaped ones -- and a model with no thinking to move ignores it.""" + ROUTE_REQUIREMENTS: tuple[str, ...] = ("image_generation", "image_search") """What a route may declare its target's own pipeline spends. diff --git a/raven/rpc/methods/console.py b/raven/rpc/methods/console.py index fb2c771fd..7ffae74e1 100644 --- a/raven/rpc/methods/console.py +++ b/raven/rpc/methods/console.py @@ -25,7 +25,7 @@ from raven.config.env_file import MIRRORED_KEYS, refresh_env_file from raven.config.keys import key_as_written as _as_written -from raven.config.schema import WEB_VENDOR_ENV_VARS, WebFetchProvider, WebSearchProvider +from raven.config.schema import REASONING_EFFORTS, WEB_VENDOR_ENV_VARS, WebFetchProvider, WebSearchProvider from raven.rpc import LOCAL_CHANNEL from raven.rpc.errors import ConfigValidationError from raven.rpc.files import UPLOAD_DIR as _UPLOAD_DIR @@ -1045,6 +1045,16 @@ def chk(v: Any) -> int | None: return chk +def _chk_enum_or_null(key: str, *allowed: Any): + """An enum a surface may also clear: null takes the key back to its default.""" + inner = _chk_enum(key, *allowed) + + def chk(v: Any) -> Any: + return None if v is None else inner(v) + + return chk + + _SETTINGS_SIMPLE_KEYS: dict[str, Any] = { "tools.exec.timeout": _chk_int("tools.exec.timeout", 5, 3600), "tools.web.search.apiKey": _chk_str("tools.web.search.apiKey", 200), @@ -1086,7 +1096,9 @@ def chk(v: Any) -> int | None: "embedding": _chk_pin_pair("embedding", "model", "provider"), "vision": _chk_pin_pair("vision", "model", "provider"), "agents.defaults.enablePersonalization": _chk_bool("agents.defaults.enablePersonalization"), - "agents.defaults.reasoningEffort": _chk_enum("agents.defaults.reasoningEffort", "minimal", "low", "medium", "high"), + # Clearable: null hands the choice back to the provider's own default, + # which is what an unset key means and what the settings page offers. + "agents.defaults.reasoningEffort": _chk_enum_or_null("agents.defaults.reasoningEffort", *REASONING_EFFORTS), "permissions.mode": _chk_enum("permissions.mode", "ask", "smart", "full"), "agents.defaults.maxToolIterations": _chk_int("agents.defaults.maxToolIterations", 1, 200), "agents.defaults.contextWindowTokens": _chk_int_or_null("agents.defaults.contextWindowTokens", 1024, 100_000_000), diff --git a/raven/rpc/methods/session.py b/raven/rpc/methods/session.py index b8a73f2fd..23ed5a96f 100644 --- a/raven/rpc/methods/session.py +++ b/raven/rpc/methods/session.py @@ -36,8 +36,10 @@ from loguru import logger +from raven.agent import session_effort from raven.config.loader import drain_migration_notices, load_config, raven_home from raven.config.raven import load_raven_config +from raven.config.schema import REASONING_EFFORTS from raven.providers.rates import resolve_context_window from raven.providers.usage import reported_cost, token_count from raven.rpc.errors import ( @@ -274,6 +276,9 @@ async def _default_session_info( # through turn.send's bookkeeping, and it is as much in flight as a # typed one. "running": bool(session_key) and turn_module.is_session_answering(session_key), + # What this session's own calls think at, for the status bar beside the + # model: its own pick, else what it would run at without one. + "reasoning_effort": _effort_in_force(agent_loop, config, session_key), } # Nudge the status bar to run `raven upgrade` when the cached latest release @@ -537,6 +542,32 @@ async def session_create( } +def _default_effort(agent_loop: object, config: Any, session_key: str | None) -> str | None: + """What a session runs at with no pick of its own. + + Its mode's effort where the catalogue sets one (``raven-code`` does), else + ``agents.defaults.reasoningEffort``, else None: the provider's own default, + which is what an unset key means. Read fresh rather than off the loop, the + way ``default_reasoning_effort`` reads the file, so a settings edit shows + here without a restart. + """ + policy = getattr(agent_loop, "session_policy", None) + held = policy(session_key or "") if callable(policy) and session_key else None + mode_effort = getattr(held, "reasoning_effort", None) + if isinstance(mode_effort, str) and mode_effort: + return mode_effort + configured = getattr(getattr(getattr(config, "agents", None), "defaults", None), "reasoning_effort", None) + return configured if isinstance(configured, str) and configured else None + + +def _effort_in_force(agent_loop: object, config: Any, session_key: str | None) -> str | None: + """The effort this session's own calls run at: its pick, else the default.""" + if not session_key: + return _default_effort(agent_loop, config, None) + picked = session_effort.read(manager_for(agent_loop, config), session_key) + return picked or _default_effort(agent_loop, config, session_key) + + def _read_knowledge(agent_loop: object, config: object, session_key: str | None) -> tuple[str, ...]: """What one session may search, for the bundle that reports it.""" from raven.agent import knowledge_scope @@ -1562,6 +1593,58 @@ def _current() -> str | None: return {"mode": tier, "availableModes": menu} +async def session_set_effort( + params: dict, + *, + agent_loop_factory: "AgentLoopFactory | None" = None, +) -> dict: + """``session.set_effort`` -- report, set or clear this session's reasoning effort. + + The effort this session's own model calls run at, as a person picked it + for this conversation: the shape ``session.set_mode`` has -- three calls + told apart by which fields are present -- over a different knob. A tier + moves what raven asks of its sub-agents; this moves how much raven's own + model thinks before each answer, and reaches no sub-agent. + + Kept on the session's record (``raven.agent.session_effort``) rather than + on the loop's in-memory policy, so it outlives the gateway and a resumed + conversation runs at what it was left at. A set lands on the next turn. + + Every answer carries ``default`` -- what the session runs at without a + pick of its own -- so a control can say what "default" means on this + install, and ``options``, so it need not spell the words itself. + """ + session_key = str(params.get("session_key") or "") + raw = params.get("effort") + wanted = str(raw) if isinstance(raw, str) and raw else None + loop = _safe_invoke_factory(agent_loop_factory) + config = load_config() + mgr = manager_for(loop, config) + + def _reply(effort: str | None) -> dict: + return { + "session_key": session_key, + "effort": effort, + "default": _default_effort(loop, config, session_key or None), + "options": list(REASONING_EFFORTS), + } + + if wanted is None and not params.get("clear"): + # A draft asks too, with no key: the default and the words are what + # its first turn will run at, and the row is drawn before it is sent. + return _reply(session_effort.read(mgr, session_key) if session_key else None) + if not session_key: + raise ConfigValidationError("session_key is required to set or clear a reasoning effort") + effort = None if params.get("clear") else wanted + if effort is not None and effort not in REASONING_EFFORTS: + raise ConfigValidationError(f"no reasoning effort {effort!r}; the words are {', '.join(REASONING_EFFORTS)}") + previous = session_effort.read(mgr, session_key) + session_effort.write(mgr, session_key, effort) + if previous != effort: + logger.info("session {} reasoning effort {} -> {}", session_key, previous or "", effort or "") + return _reply(effort) + + async def session_set_harness( params: dict, *, @@ -1659,6 +1742,9 @@ async def _set_mode(params: dict) -> dict: async def _set_harness(params: dict) -> dict: return await session_set_harness(params, agent_loop_factory=agent_loop_factory) + async def _set_effort(params: dict) -> dict: + return await session_set_effort(params, agent_loop_factory=agent_loop_factory) + dispatcher.register("session.create", _create) dispatcher.register("session.close", _close) dispatcher.register("session.resume", _resume) @@ -1675,6 +1761,7 @@ async def _set_harness(params: dict) -> dict: dispatcher.register("session.branch", _branch) dispatcher.register("session.export", _export) dispatcher.register("session.set_mode", _set_mode) + dispatcher.register("session.set_effort", _set_effort) dispatcher.register("session.set_harness", _set_harness) async def _set_knowledge(params: dict) -> dict: diff --git a/raven/rpc/models.py b/raven/rpc/models.py index 31ab0ae37..fbbb58ff7 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -3468,6 +3468,30 @@ class SessionSetModeResult(_Strict): ) +class SessionSetEffortParams(_Strict): + session_key: str + effort: str | None = Field( + None, + description="The reasoning effort this session's own model calls run at, one of the words in options. " + "Omit it to report without changing.", + ) + clear: bool = Field( + False, + description="Drop the session's own pick and follow the default again. Wins over effort when both are given.", + ) + + +class SessionSetEffortResult(_Strict): + session_key: str + effort: str | None = Field(None, description="The session's own pick, or null while it follows the default.") + default: str | None = Field( + None, + description="What the session runs at with no pick of its own: its mode's effort where the catalogue sets " + "one, else agents.defaults.reasoningEffort, else null for the provider's own default.", + ) + options: list[str] = Field(default_factory=list, description="The words effort accepts, least first.") + + # --------------------------------------------------------------------------- # Method registry — used by tests/test_rpc_schema_match.py to walk every # method and compare its Pydantic Params/Result models against the OpenRPC @@ -3798,6 +3822,13 @@ class SessionInitInfo(_Strict): ), ) running: bool = Field(default=False, description="A turn is in flight on this session right now.") + reasoning_effort: str | None = Field( + default=None, + description=( + "The reasoning effort this session's own model calls run at: its own pick (session.set_effort), " + "else its mode's, else agents.defaults.reasoningEffort; null when the provider's own default stands." + ), + ) read_only: bool | None = Field( default=None, description="True for an Imported Conversation, which nothing can change; absent on every other session.", @@ -6934,6 +6965,7 @@ class ImportedUploadResult(_Strict): "session.set_knowledge": (SessionSetKnowledgeParams, SessionSetKnowledgeResult), "session.set_harness": (SessionSetHarnessParams, SessionSetHarnessResult), "session.set_mode": (SessionSetModeParams, SessionSetModeResult), + "session.set_effort": (SessionSetEffortParams, SessionSetEffortResult), # ext.list / cron.* / settings.* / channels.status / fs.* -- the console "ext.list": (ExtListParams, ExtListResult), "cron.list": (CronListParams, CronListResult), diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index cced6ad71..ddba5cfc7 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -3531,6 +3531,72 @@ }, "errors": [] }, + { + "name": "session.set_effort", + "summary": "Report, set or clear the reasoning effort this session's own model calls run at. Neither field reports; clear=true drops the pick so the session follows the default again; effort= runs from the next turn on. Kept on the session's record, so it outlives the gateway. Raven's own calls only: sub-agents keep the configured default.", + "params": [ + { + "name": "session_key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "effort", + "required": false, + "schema": { + "type": "string", + "description": "One of the words in options. Omit it to report without changing." + } + }, + { + "name": "clear", + "required": false, + "schema": { + "type": "boolean", + "description": "Drop the session's own pick and follow the default again. Wins over effort when both are given." + } + } + ], + "result": { + "name": "SessionSetEffortResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "session_key" + ], + "properties": { + "session_key": { + "type": "string" + }, + "effort": { + "type": [ + "string", + "null" + ], + "description": "The session's own pick, or null while it follows the default." + }, + "default": { + "type": [ + "string", + "null" + ], + "description": "What the session runs at with no pick of its own: its mode's effort where the catalogue sets one, else agents.defaults.reasoningEffort, else null for the provider's own default." + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The words effort accepts, least first." + } + } + } + }, + "errors": [] + }, { "name": "session.set_knowledge", "summary": "Choose which knowledge bases this session's turns may search.", @@ -12312,6 +12378,13 @@ "type": "boolean", "description": "A turn is in flight on this session right now." }, + "reasoning_effort": { + "type": [ + "string", + "null" + ], + "description": "The reasoning effort this session's own model calls run at: its own pick (session.set_effort), else its mode's, else agents.defaults.reasoningEffort; null when the provider's own default stands." + }, "read_only": { "type": "boolean", "description": "True for an Imported Conversation, which nothing can change; absent on every other session." diff --git a/tests/test_agent_loop_usage_sink.py b/tests/test_agent_loop_usage_sink.py index 9e033aaa6..93a307a80 100644 --- a/tests/test_agent_loop_usage_sink.py +++ b/tests/test_agent_loop_usage_sink.py @@ -470,6 +470,76 @@ async def test_no_configured_effort_sends_none(workspace, monkeypatch): assert provider.efforts == [None] +async def _one_turn(agent: AgentLoop, session_key: str = "s1") -> None: + await agent._process_message( + TurnRequest( + origin=Origin.USER, + source=Source(channel="test", chat_id="c1", sender_id="user", chat_type=ChatType.DM), + text="hi", + ), + session_key=session_key, + ) + + +@pytest.mark.asyncio +async def test_the_sessions_own_pick_outranks_the_configured_effort(workspace, monkeypatch): + """`session.set_effort` keeps the pick on the session's record, and the + turn reads it there, where it reads the model: over the file's default.""" + _point_config(workspace, monkeypatch, {"agents": {"defaults": {"reasoningEffort": "high"}}}) + provider = EffortProvider("stub") + agent = _make_agent(workspace, provider, model="stub", window=None) + agent.sessions.get_or_create("s1").metadata["reasoning_effort"] = "low" + + await _one_turn(agent) + + assert provider.efforts == ["low"] + + +@pytest.mark.asyncio +async def test_the_sessions_own_pick_outranks_its_modes_effort(workspace, monkeypatch): + """A mode's effort is the catalogue's word for every conversation in that + mode; the pick is a person's word for this one, so it wins -- and without a + pick the mode's effort stands, as before.""" + _point_config(workspace, monkeypatch, {}) + provider = EffortProvider("stub") + agent = _make_agent(workspace, provider, model="stub", window=None) + agent.set_session_policy("s1", mode="deep", reasoning_effort="medium") + + await _one_turn(agent) + agent.sessions.get_or_create("s1").metadata["reasoning_effort"] = "minimal" + await _one_turn(agent) + + assert provider.efforts == ["medium", "minimal"] + + +@pytest.mark.asyncio +async def test_a_pick_this_raven_does_not_know_is_ignored(workspace, monkeypatch): + """A record written by a later raven may carry a word this one cannot + send; the turn runs at the default rather than being refused.""" + _point_config(workspace, monkeypatch, {"agents": {"defaults": {"reasoningEffort": "high"}}}) + provider = EffortProvider("stub") + agent = _make_agent(workspace, provider, model="stub", window=None) + agent.sessions.get_or_create("s1").metadata["reasoning_effort"] = "turbo" + + await _one_turn(agent) + + assert provider.efforts == ["high"] + + +@pytest.mark.asyncio +async def test_a_cleared_pick_is_the_default_again(workspace, monkeypatch): + """Cleared is null on the record, not absent (a metadata patch sets keys); + both read as no pick.""" + _point_config(workspace, monkeypatch, {"agents": {"defaults": {"reasoningEffort": "medium"}}}) + provider = EffortProvider("stub") + agent = _make_agent(workspace, provider, model="stub", window=None) + agent.sessions.get_or_create("s1").metadata["reasoning_effort"] = None + + await _one_turn(agent) + + assert provider.efforts == ["medium"] + + # --------------------------------------------------------------------------- # # the cost is the turn's: every iteration, and every call it delegated # # --------------------------------------------------------------------------- # diff --git a/tests/test_rpc_session.py b/tests/test_rpc_session.py index 7020d7a45..7703b8dae 100644 --- a/tests/test_rpc_session.py +++ b/tests/test_rpc_session.py @@ -3288,6 +3288,184 @@ async def test_a_clear_wins_over_a_mode_given_alongside_it(): assert loop.policies["tui:1"] == "high" +# --------------------------------------------------------------------------- +# session.set_effort: this session's own reasoning effort +# --------------------------------------------------------------------------- + + +class _EffortLoop: + """A loop over a real session manager, which is where the pick is kept.""" + + def __init__(self, tmp_path: Path, mode_effort: str | None = None) -> None: + self.sessions = SessionManager(tmp_path) + self._mode_effort = mode_effort + + def session_policy(self, key): + from raven.agent.loop._shared import SessionPolicy + + return SessionPolicy(reasoning_effort=self._mode_effort) + + +def _configured_effort(monkeypatch: pytest.MonkeyPatch, effort: str | None) -> None: + cfg = load_config() + cfg.agents.defaults.reasoning_effort = effort + monkeypatch.setattr(session_module, "load_config", lambda: cfg) + + +def _filed(mgr: SessionManager, key: str) -> None: + """A session with a record on disk, which is what a metadata patch lands on.""" + mgr.save(mgr.get_or_create(key)) + + +async def test_an_effort_read_reports_no_pick_the_default_and_the_words(tmp_path, monkeypatch) -> None: + _configured_effort(monkeypatch, "low") + loop = _EffortLoop(tmp_path) + result = await session_module.session_set_effort({"session_key": "tui:1"}, agent_loop_factory=lambda: loop) + assert result == { + "session_key": "tui:1", + "effort": None, + "default": "low", + "options": ["minimal", "low", "medium", "high"], + } + + +async def test_an_unset_default_reports_null_not_low(tmp_path, monkeypatch) -> None: + """No configured effort means the provider's own default, which has no + word: reporting a rung for it is the mistake the settings page made.""" + _configured_effort(monkeypatch, None) + loop = _EffortLoop(tmp_path) + result = await session_module.session_set_effort({"session_key": "tui:1"}, agent_loop_factory=lambda: loop) + assert result["default"] is None + + +async def test_an_effort_set_lands_on_the_record_and_outlives_the_manager(tmp_path, monkeypatch) -> None: + _configured_effort(monkeypatch, "low") + loop = _EffortLoop(tmp_path) + _filed(loop.sessions, "tui:1") + result = await session_module.session_set_effort( + {"session_key": "tui:1", "effort": "high"}, agent_loop_factory=lambda: loop + ) + assert result["effort"] == "high" and result["default"] == "low" + assert loop.sessions.peek("tui:1").metadata["reasoning_effort"] == "high" + # On disk, not only on the cached copy: a fresh manager reads it back. + assert SessionManager(tmp_path).peek("tui:1").metadata["reasoning_effort"] == "high" + again = await session_module.session_set_effort({"session_key": "tui:1"}, agent_loop_factory=lambda: loop) + assert again["effort"] == "high" + + +async def test_an_effort_clear_returns_the_session_to_the_default(tmp_path, monkeypatch) -> None: + _configured_effort(monkeypatch, "low") + loop = _EffortLoop(tmp_path) + _filed(loop.sessions, "tui:1") + await session_module.session_set_effort({"session_key": "tui:1", "effort": "high"}, agent_loop_factory=lambda: loop) + result = await session_module.session_set_effort( + {"session_key": "tui:1", "clear": True}, agent_loop_factory=lambda: loop + ) + assert result["effort"] is None and result["default"] == "low" + # Null rather than absent: a patch sets keys, and both read as "no pick". + assert loop.sessions.peek("tui:1").metadata["reasoning_effort"] is None + assert SessionManager(tmp_path).peek("tui:1").metadata["reasoning_effort"] is None + + +async def test_an_effort_clear_wins_over_an_effort_given_alongside_it(tmp_path, monkeypatch) -> None: + _configured_effort(monkeypatch, None) + loop = _EffortLoop(tmp_path) + _filed(loop.sessions, "tui:1") + result = await session_module.session_set_effort( + {"session_key": "tui:1", "effort": "high", "clear": True}, agent_loop_factory=lambda: loop + ) + assert result["effort"] is None + assert loop.sessions.peek("tui:1").metadata["reasoning_effort"] is None + + +async def test_the_modes_effort_is_the_default_where_the_catalogue_sets_one(tmp_path, monkeypatch) -> None: + """raven-code's modes each set an effort; that, not the file's, is what a + session in such a mode runs at without a pick of its own.""" + _configured_effort(monkeypatch, "low") + loop = _EffortLoop(tmp_path, mode_effort="medium") + result = await session_module.session_set_effort({"session_key": "tui:1"}, agent_loop_factory=lambda: loop) + assert result["default"] == "medium" + + +async def test_an_unknown_effort_is_refused_naming_the_words(tmp_path, monkeypatch) -> None: + from raven.rpc.errors import ConfigValidationError + + _configured_effort(monkeypatch, None) + loop = _EffortLoop(tmp_path) + _filed(loop.sessions, "tui:1") + with pytest.raises(ConfigValidationError) as exc: + await session_module.session_set_effort( + {"session_key": "tui:1", "effort": "turbo"}, agent_loop_factory=lambda: loop + ) + assert "minimal, low, medium, high" in str(exc.value) + assert "reasoning_effort" not in loop.sessions.peek("tui:1").metadata, "a refused set must not half-land" + + +async def test_a_draft_reads_the_default_but_cannot_set_without_a_key(tmp_path, monkeypatch) -> None: + """The page's draft has no session yet; it still draws the row from the + default and the words, and stages a pick until the first message mints one.""" + from raven.rpc.errors import ConfigValidationError + + _configured_effort(monkeypatch, "medium") + loop = _EffortLoop(tmp_path) + result = await session_module.session_set_effort({"session_key": ""}, agent_loop_factory=lambda: loop) + assert result == { + "session_key": "", + "effort": None, + "default": "medium", + "options": ["minimal", "low", "medium", "high"], + } + with pytest.raises(ConfigValidationError): + await session_module.session_set_effort({"effort": "high"}, agent_loop_factory=lambda: loop) + with pytest.raises(ConfigValidationError): + await session_module.session_set_effort({"clear": True}, agent_loop_factory=lambda: loop) + + +async def test_an_effort_set_on_a_draft_is_held_until_its_first_save(tmp_path, monkeypatch) -> None: + """A conversation the first message has not filed yet has no record to + patch; the pick waits on the in-memory copy, which that first save writes.""" + _configured_effort(monkeypatch, None) + loop = _EffortLoop(tmp_path) + result = await session_module.session_set_effort( + {"session_key": "tui:draft", "effort": "minimal"}, agent_loop_factory=lambda: loop + ) + assert result["effort"] == "minimal" + held = loop.sessions.get_or_create("tui:draft") + assert held.metadata["reasoning_effort"] == "minimal" + loop.sessions.save(held) + assert SessionManager(tmp_path).peek("tui:draft").metadata["reasoning_effort"] == "minimal" + + +async def test_the_bundle_reports_the_effort_in_force(tmp_path, monkeypatch) -> None: + """The status bar sits beside the model; it shows the pick where there is + one and the default otherwise, the same answer the next turn runs at.""" + from unittest.mock import MagicMock + + _configured_effort(monkeypatch, "low") + # MagicMock for the unrelated skills/tools enumeration, as the session-info + # tests above do; the manager and the policy are real. + loop = MagicMock() + loop.model = "vendor-a/model" + loop.session_model = lambda key: "vendor-a/model" + loop.sessions = SessionManager(tmp_path) + loop.session_policy = _EffortLoop(tmp_path).session_policy + _filed(loop.sessions, "tui:1") + config = session_module.load_config() + before = await session_module._default_session_info(loop, config, "tui:1") + assert before["reasoning_effort"] == "low" + await session_module.session_set_effort({"session_key": "tui:1", "effort": "high"}, agent_loop_factory=lambda: loop) + after = await session_module._default_session_info(loop, config, "tui:1") + assert after["reasoning_effort"] == "high" + fresh = await session_module._default_session_info(loop, config, None) + assert fresh["reasoning_effort"] == "low" + + +async def test_set_effort_is_registered_beside_set_mode() -> None: + d = Dispatcher() + register_session_methods(d, agent_loop_factory=lambda: None) + assert "session.set_effort" in d.methods() and "session.set_mode" in d.methods() + + # --------------------------------------------------------------------------- # session.list {archived} and the lazy auto-archive pass # --------------------------------------------------------------------------- diff --git a/tests/test_rpc_session_init_bundle.py b/tests/test_rpc_session_init_bundle.py index c88954223..226613668 100644 --- a/tests/test_rpc_session_init_bundle.py +++ b/tests/test_rpc_session_init_bundle.py @@ -312,6 +312,9 @@ async def test_default_session_info_key_set_matches_expected_v030(fake_agent_loo "usage", "endpoint", "running", + # What the session's own calls think at, for the status bar beside the + # model: its pick (session.set_effort), else the default in force. + "reasoning_effort", } assert set(info) == expected_keys, ( f"init bundle key set drift: unexpected={set(info) - expected_keys}, missing={expected_keys - set(info)}" diff --git a/tests/test_rpc_settings.py b/tests/test_rpc_settings.py index a06d09735..5a1de8e73 100644 --- a/tests/test_rpc_settings.py +++ b/tests/test_rpc_settings.py @@ -1202,6 +1202,7 @@ async def test_settings_set_new_scalar_keys_write_and_refuse(cfg, key, good, bad "key", [ "agents.defaults.contextWindowTokens", + "agents.defaults.reasoningEffort", "context.curatorModel", "sessionTitle.provider", "sessions.autoArchiveAfterDays", diff --git a/tests/test_session_effort.py b/tests/test_session_effort.py new file mode 100644 index 000000000..3981ef619 --- /dev/null +++ b/tests/test_session_effort.py @@ -0,0 +1,58 @@ +"""raven.agent.session_effort: the pick on a session's record and how it is read.""" + +from __future__ import annotations + +from pathlib import Path + +from raven.agent import session_effort +from raven.session.manager import SessionManager + + +def test_a_record_with_no_pick_reads_as_none() -> None: + assert session_effort.of({}) is None + assert session_effort.of({"reasoning_effort": None}) is None + assert session_effort.of(None) is None + assert session_effort.of("high") is None + + +def test_only_a_word_this_raven_knows_reads_as_a_pick() -> None: + assert session_effort.of({"reasoning_effort": "high"}) == "high" + assert session_effort.of({"reasoning_effort": "turbo"}) is None + assert session_effort.of({"reasoning_effort": 3}) is None + + +def test_a_read_needs_a_manager_and_a_key_and_caches_nothing(tmp_path: Path) -> None: + mgr = SessionManager(tmp_path) + assert session_effort.read(None, "tui:1") is None + assert session_effort.read(mgr, "") is None + assert session_effort.read(mgr, "tui:never") is None + # peek, not get_or_create: the miss left no ghost behind. + assert mgr.peek("tui:never") is None + + +def test_a_write_reaches_the_disk_for_a_filed_session(tmp_path: Path) -> None: + mgr = SessionManager(tmp_path) + mgr.save(mgr.get_or_create("tui:1")) + assert session_effort.write(mgr, "tui:1", "low") is True + assert session_effort.read(mgr, "tui:1") == "low" + assert session_effort.read(SessionManager(tmp_path), "tui:1") == "low" + assert session_effort.write(mgr, "tui:1", None) is True + assert session_effort.read(SessionManager(tmp_path), "tui:1") is None + + +def test_a_write_on_an_unfiled_session_waits_on_the_copy_in_memory(tmp_path: Path) -> None: + mgr = SessionManager(tmp_path) + assert session_effort.write(mgr, "tui:draft", "high") is False + assert session_effort.read(mgr, "tui:draft") == "high" + mgr.save(mgr.get_or_create("tui:draft")) + assert session_effort.read(SessionManager(tmp_path), "tui:draft") == "high" + + +def test_a_manager_that_cannot_patch_still_holds_the_pick(tmp_path: Path) -> None: + class _Broken(SessionManager): + def append_metadata_patch(self, key, patch): + raise OSError("disk went away") + + mgr = _Broken(tmp_path) + assert session_effort.write(mgr, "tui:1", "medium") is False + assert session_effort.read(mgr, "tui:1") == "medium" diff --git a/ui-tui/src/__tests__/effortCommand.test.ts b/ui-tui/src/__tests__/effortCommand.test.ts new file mode 100644 index 000000000..b8014ae11 --- /dev/null +++ b/ui-tui/src/__tests__/effortCommand.test.ts @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EverMind. +// See NOTICES.md. +// +// `/effort`: showing and changing how much the model thinks for this +// conversation. The pick is the session's own and lives on the server, so the +// command only ever paints from the reply. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { sessionCommands } from '../app/slash/commands/session.js' +import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' +import { rpcErrorMessage } from '../lib/rpc.js' + +const cmd = sessionCommands.find(c => c.name === 'effort')! + +const WORDS = ['minimal', 'low', 'medium', 'high'] + +const run = ( + arg: string, + rpc = vi.fn(() => Promise.resolve({ session_key: 's1', effort: null, default: 'low', options: WORDS })), + sid: null | string = 's1' +) => { + const lines: string[] = [] + cmd.run( + arg, + { + gateway: { rpc }, + guarded: + (fn: (r: T) => void) => + (r: null | T) => { + if (r !== null) { + fn(r) + } + }, + guardedErr: (e: unknown) => { + lines.push(`error: ${rpcErrorMessage(e)}`) + }, + sid, + stale: () => false, + transcript: { sys: (text: string) => lines.push(text) } + } as never, + 'effort' + ) + + return { lines, rpc } +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)) + +beforeEach(() => { + resetUiState() + patchUiState({ info: { model: 'vendor/model', skills: {}, tools: {}, reasoning_effort: 'low' } }) +}) + +describe('/effort', () => { + it('refuses without a session and asks the gateway nothing', () => { + const { lines, rpc } = run('high', undefined, null) + + expect(lines).toEqual(['no active session']) + expect(rpc).not.toHaveBeenCalled() + }) + + it('reports the default when nothing is picked, naming what the default is', async () => { + const { lines, rpc } = run('') + await flush() + + expect(rpc).toHaveBeenCalledWith('session.set_effort', { session_key: 's1' }, { quiet: true }) + expect(lines).toEqual(['reasoning effort: default (low)']) + }) + + it('names the provider when no effort is configured anywhere', async () => { + const rpc = vi.fn(() => Promise.resolve({ session_key: 's1', effort: null, default: null, options: WORDS })) + const { lines } = run('', rpc) + await flush() + + expect(lines).toEqual(["reasoning effort: default (the provider's own)"]) + expect(getUiState().info?.reasoning_effort).toBe('') + }) + + it('sets a word from the reply and moves the status bar with it', async () => { + const rpc = vi.fn(() => Promise.resolve({ session_key: 's1', effort: 'high', default: 'low', options: WORDS })) + const { lines } = run('High', rpc) + await flush() + + expect(rpc).toHaveBeenCalledWith('session.set_effort', { session_key: 's1', effort: 'high' }, { quiet: true }) + expect(lines).toEqual(['reasoning effort: high (this conversation; default is low)']) + expect(getUiState().info?.reasoning_effort).toBe('high') + }) + + it.each(['default', 'reset', 'clear'])('clears the pick on `%s` and shows the default again', async word => { + const rpc = vi.fn(() => Promise.resolve({ session_key: 's1', effort: null, default: 'medium', options: WORDS })) + patchUiState(state => ({ ...state, info: { ...state.info!, reasoning_effort: 'high' } })) + const { lines } = run(word, rpc) + await flush() + + expect(rpc).toHaveBeenCalledWith('session.set_effort', { session_key: 's1', clear: true }, { quiet: true }) + expect(lines).toEqual(['reasoning effort: default (medium)']) + expect(getUiState().info?.reasoning_effort).toBe('medium') + }) + + it('lands the gateway refusal on the transcript and leaves the status bar alone', async () => { + const rpc = vi.fn(() => Promise.reject(new Error("no reasoning effort 'turbo'; the words are minimal, low, medium, high"))) + const { lines } = run('turbo', rpc) + await flush() + + expect(lines).toEqual(["error: no reasoning effort 'turbo'; the words are minimal, low, medium, high"]) + expect(getUiState().info?.reasoning_effort).toBe('low') + }) +}) diff --git a/ui-tui/src/__tests__/slashParity.test.ts b/ui-tui/src/__tests__/slashParity.test.ts index 58b5f2a2e..13890ae28 100644 --- a/ui-tui/src/__tests__/slashParity.test.ts +++ b/ui-tui/src/__tests__/slashParity.test.ts @@ -18,6 +18,7 @@ const MUTATING_COMMANDS = [ 'busy', 'clear', 'compress', + 'effort', 'fast', 'model', 'new', diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 4e1cf893d..053b40af7 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -14,6 +14,7 @@ import type { SessionUsageResponse, VoiceToggleResponse } from '../../../gatewayTypes.js' +import type { SessionSetEffortResult } from '../../../rpc/generated.js' import type { PanelSection } from '../../../types.js' import type { SlashCommand } from '../types.js' @@ -61,6 +62,9 @@ const parseModelArg = (arg: string): { provider?: string; value: string } => { // type the bare chat_id they see in filenames, so default those to tui:. const normalizeSessionId = (id: string) => (id.includes(':') ? id : `tui:${id}`) +// `/effort default` and its two synonyms, the words `/mode` reserves too. +const EFFORT_RESET_WORDS = new Set(['default', 'reset', 'clear']) + export const sessionCommands: SlashCommand[] = [ { aliases: ['bg', 'btw'], @@ -630,6 +634,52 @@ export const sessionCommands: SlashCommand[] = [ } }, + { + help: 'show or set how much the model thinks for this conversation [minimal|low|medium|high|default]', + name: 'effort', + usage: '/effort [minimal|low|medium|high|default] -- default/reset/clear follow the global setting again', + run: (arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('no active session') + } + + // The session's own reasoning effort, kept on its record by the server: + // a bare `/effort` reports, a word sets it from the next turn on, and + // the reset words drop it so the conversation follows the global + // setting (or its mode's) again. The words are the server's to refuse, + // and its refusal names them, so none are spelt here. + const want = arg.trim().toLowerCase() + const params: Record = { session_key: ctx.sid } + + if (EFFORT_RESET_WORDS.has(want)) { + params.clear = true + } else if (want) { + params.effort = want + } + + ctx.gateway + .rpc('session.set_effort', params, { quiet: true }) + .then( + ctx.guarded(r => { + const fallback = r.default ?? null + const fallbackLabel = fallback ?? "the provider's own" + ctx.transcript.sys( + r.effort + ? `reasoning effort: ${r.effort} (this conversation; default is ${fallbackLabel})` + : `reasoning effort: default (${fallbackLabel})` + ) + // The status bar shows what the next turn runs at, as session.info + // reports it: the pick, else the default. + patchUiState(state => ({ + ...state, + info: state.info ? { ...state.info, reasoning_effort: r.effort ?? fallback ?? '' } : state.info + })) + }) + ) + .catch(ctx.guardedErr) + } + }, + { help: 'toggle fast mode [normal|fast|status|on|off|toggle]', name: 'fast', diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index ba6a0034b..f156816d4 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -154,6 +154,10 @@ export const SLASH_TEXT: Record> = { name: '推理强度', help: '查看或设置推理强度(对运行中的 agent 生效)' }, + effort: { + name: '思考深度', + help: '查看或设置本会话的思考深度 [minimal|low|medium|high|default]' + }, fast: { name: '快速模式', help: '开关快速模式 [normal|fast|status|on|off|toggle]' @@ -260,6 +264,10 @@ export const SLASH_TEXT: Record> = { name: 'export', help: 'export the transcript to Markdown' }, + effort: { + name: 'effort', + help: 'show or set how much the model thinks for this conversation [minimal|low|medium|high|default]' + }, lang: { name: 'lang', help: 'switch UI language [en|zh]' @@ -1890,6 +1898,17 @@ export const UI_TEXT: Record> = { 'gui.tier.mode': 'Mode', 'gui.tier.mode_scope': "This is a deployment's own mode, not a built-in tier, so it is not passed on to sub-agents.", + 'gui.effort.title': 'Reasoning effort', + 'gui.effort.default': 'Default', + 'gui.effort.minimal': 'Minimal', + 'gui.effort.low': 'Low', + 'gui.effort.medium': 'Medium', + 'gui.effort.high': 'High', + 'gui.effort.scope': + 'How much the model thinks before each answer, for this conversation only. Sub-agents are not affected.', + 'gui.effort.now': 'Default follows the global setting, which is {name} right now.', + 'gui.effort.provider': "the provider's own default", + 'gui.effort.failed': 'Could not set the reasoning effort to {name}', 'gui.imodel.title': 'Model', 'gui.imodel.agent': "Agent's own", 'gui.imodel.agent_tip': 'Answering with whatever this agent chose. Click to give this instance its own model.', @@ -2547,6 +2566,7 @@ export const UI_TEXT: Record> = { 'gui.settings.roles.video': 'Video', 'gui.settings.roles.video_use': 'Used by video_generate; without it the tool is not registered', 'gui.settings.roles.effort': 'Reasoning effort', + 'gui.settings.roles.effort_default': 'Provider default', 'gui.settings.roles.effort_minimal': 'Minimal', 'gui.settings.roles.effort_low': 'Low', 'gui.settings.roles.effort_medium': 'Medium', @@ -5294,6 +5314,16 @@ export const UI_TEXT: Record> = { 'gui.tier.failed': '切换到 {name} 没有成功', 'gui.tier.mode': '模式', 'gui.tier.mode_scope': '这是部署自带的模式,不是内置档位,因此不会下发给子智能体。', + 'gui.effort.title': '思考深度', + 'gui.effort.default': '默认', + 'gui.effort.minimal': '最少', + 'gui.effort.low': '低', + 'gui.effort.medium': '中', + 'gui.effort.high': '高', + 'gui.effort.scope': '模型每次回答前思考多少,只作用于本会话,不影响子智能体。', + 'gui.effort.now': '「默认」跟随设置页里的全局值,当前是「{name}」。', + 'gui.effort.provider': '厂商默认', + 'gui.effort.failed': '思考深度切到「{name}」没有成功', 'gui.imodel.title': '模型', 'gui.imodel.agent': '跟随子智能体', 'gui.imodel.agent_tip': '用的是子智能体自己选的模型。点击可为这个实例单独指定模型。', @@ -5897,6 +5927,7 @@ export const UI_TEXT: Record> = { 'gui.settings.roles.video': '视频', 'gui.settings.roles.video_use': 'video_generate 工具使用;不配则该工具不注册', 'gui.settings.roles.effort': '思考深度', + 'gui.settings.roles.effort_default': '厂商默认', 'gui.settings.roles.effort_minimal': '最少', 'gui.settings.roles.effort_low': '低', 'gui.settings.roles.effort_medium': '中', diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 3c2475011..39bd129f5 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -213,6 +213,10 @@ export interface SessionInitInfo { * A turn is in flight on this session right now. */ running?: boolean; + /** + * The reasoning effort this session's own model calls run at: its own pick (session.set_effort), else its mode's, else agents.defaults.reasoningEffort; null when the provider's own default stands. + */ + reasoning_effort?: string | null; /** * True for an Imported Conversation, which nothing can change; absent on every other session. */ @@ -4753,6 +4757,40 @@ export interface SessionSetModeResult { description?: string; }[]; } +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "SessionSetEffortParams". + */ +export interface SessionSetEffortParams { + session_key: string; + /** + * One of the words in options. Omit it to report without changing. + */ + effort?: string; + /** + * Drop the session's own pick and follow the default again. Wins over effort when both are given. + */ + clear?: boolean; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "SessionSetEffortResult". + */ +export interface SessionSetEffortResult { + session_key: string; + /** + * The session's own pick, or null while it follows the default. + */ + effort?: string | null; + /** + * What the session runs at with no pick of its own: its mode's effort where the catalogue sets one, else agents.defaults.reasoningEffort, else null for the provider's own default. + */ + default?: string | null; + /** + * The words effort accepts, least first. + */ + options?: string[]; +} /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema * via the `definition` "SessionSetKnowledgeParams". diff --git a/ui-web/scripts/fixture-params.mjs b/ui-web/scripts/fixture-params.mjs index 74b359cef..912e78b56 100644 --- a/ui-web/scripts/fixture-params.mjs +++ b/ui-web/scripts/fixture-params.mjs @@ -27,6 +27,7 @@ export const PARAMS = { 'session.compress': { session_id: 'b' }, 'session.branch': { session_id: 'b' }, 'session.set_mode': { session_key: 'b', mode: 'max' }, + 'session.set_effort': { session_key: 'b', effort: 'high' }, 'turn.subscribe': { session_key: 'a' }, 'turn.unsubscribe': { subscription_id: 'sub-1' }, 'turn.send': { session_key: 'a', content: 'look at the market' }, diff --git a/ui-web/scripts/gates/boot-order.test.mjs b/ui-web/scripts/gates/boot-order.test.mjs index 4a15d2f4e..74639b76f 100644 --- a/ui-web/scripts/gates/boot-order.test.mjs +++ b/ui-web/scripts/gates/boot-order.test.mjs @@ -26,7 +26,7 @@ const installText = moduleText('app/install.ts') rest of the draw shells. */ const STEPS = [ 'lookLoad', 'paneLoad', 'setRail', 'sessionDraw', 'sessionOpen', - 'drawPerm', 'loadTier', 'drawCtx', 'bumpWs', 'drawSettings', + 'drawPerm', 'loadTier', 'loadEffort', 'drawCtx', 'bumpWs', 'drawSettings', 'setRuntime', 'goState', ] @@ -51,6 +51,7 @@ async function harness(rows = [], { connected = false, selectionOnly = true } = 'src/chrome/behaviour/panes': { load: step('paneLoad') }, 'src/state/perm': { draw: step('drawPerm') }, 'src/state/tier': { load: step('loadTier') }, + 'src/state/effort': { load: step('loadEffort') }, 'src/state/ctxChip': { draw: step('drawCtx') }, 'src/state/failureBar': { bootError: (where, error) => { throw new Error(`${where}: ${error}`) }, diff --git a/ui-web/src/app/boot.ts b/ui-web/src/app/boot.ts index ad50f792e..e240ed828 100644 --- a/ui-web/src/app/boot.ts +++ b/ui-web/src/app/boot.ts @@ -30,6 +30,7 @@ import { setCurrent as sessionSet } from '../lib/session' import { hasUpdateFlag } from '../rpc/capabilities' import { gateway } from '../rpc/gateway' import { draw as drawCtx } from '../state/ctxChip' +import { load as loadEffort } from '../state/effort' import { setRuntime } from '../state/envChip' import { bootError } from '../state/failureBar' import { load as loadLang } from '../state/lang/pick' @@ -175,6 +176,8 @@ async function sequence(): Promise { restored never changes session, so the chip would stay hidden on the one screen where the reader is about to start a conversation. */ loadTier() + /* The effort row's first read, for the same reason. */ + loadEffort() /* The import row's first read. Here rather than with the seam installers: those run before the socket opens, and a read there is refused as "not connected" -- which left a run the gateway lost to a restart, still in @@ -214,6 +217,7 @@ export function bootPage(): void { ['sessionOpen', (): void => { const first = sessionRows()[0]; if (first) void sessionOpen(first) }], ['drawPerm', (): void => drawPerm()], ['loadTier', (): void => { void loadTier() }], + ['loadEffort', (): void => { void loadEffort() }], ['drawCtx', (): void => drawCtx()], ['bumpWs', (): void => bumpWs()], ['drawSettings', (): void => redrawSettings()], diff --git a/ui-web/src/app/install.ts b/ui-web/src/app/install.ts index 38cdb0257..e55520a5b 100644 --- a/ui-web/src/app/install.ts +++ b/ui-web/src/app/install.ts @@ -29,7 +29,7 @@ import { capabilitiesSource, loadExt } from '../features/installed/source' import { knowledgeSource } from '../features/knowledge/source' import { reveal as revealDocument } from '../features/knowledge/store' import { memorySource } from '../features/memory/source' -import { blockSendForModel, modelSource, openModelsForMissingProvider, tierSource } from '../features/model/source' +import { blockSendForModel, effortSource, modelSource, openModelsForMissingProvider, tierSource } from '../features/model/source' import { onboardSource } from '../features/onboard/source' import { isOpen as onboardOpen, setBodies as setOnboardBodies, subscribe as onOnboard } from '../features/onboard/store' import { markNew } from '../features/rail/store' @@ -155,6 +155,7 @@ export function installSources(): void { sources.banner = bannerSource sources.settings = settingsSource sources.tier = tierSource + sources.effort = effortSource sources.model = modelSource composer.beforeSend = blockSendForModel diff --git a/ui-web/src/features/model/ModelPicker.test.tsx b/ui-web/src/features/model/ModelPicker.test.tsx index 5df04eee9..849f37929 100644 --- a/ui-web/src/features/model/ModelPicker.test.tsx +++ b/ui-web/src/features/model/ModelPicker.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { resetTranslator, setTranslator } from '../../i18n/t' import * as confirmStore from '../../state/confirm' +import * as effort from '../../state/effort' import * as pageStore from '../../state/page' import { resetSources, setSources } from '../../state/sources' import * as tier from '../../state/tier' @@ -1110,3 +1111,64 @@ describe('the model picker, to a screen reader', () => { expect(recents().map((b) => b.getAttribute('aria-checked'))).toEqual(['true']) }) }) + +/* The reasoning effort rides the picker as a second row under the tier, for + the composer's opening only, drawn once the server has answered + (state/effort.ts). */ +describe('the effort row', () => { + const WORDS = ['minimal', 'low', 'medium', 'high'] + const seg = (): HTMLElement[] => [...document.querySelectorAll('.mpick .model-effort .model-seg button')] + + afterEach(() => { effort._resetForTests() }) + + it('offers the default rung and every word as one segmented control, and asks for the one clicked', async () => { + install() + const asked: Array = [] + setSources({ + model: (await import('./store')).source(), + effort: { + read: async () => { asked.push(undefined); return { effort: null, default: 'low', options: WORDS } }, + set: async (id) => { asked.push(id); return { effort: id, default: 'low', options: WORDS } }, + }, + }) + await act(async () => { await effort.load() }) + mount() + openIt() + expect(seg().map((b) => b.textContent)).toEqual(['gui.effort.default', 'gui.effort.minimal', 'gui.effort.low', 'gui.effort.medium', 'gui.effort.high']) + expect(seg().map((b) => b.getAttribute('aria-checked'))).toEqual(['true', 'false', 'false', 'false', 'false']) + /* One explanation behind the question mark, naming what default means. */ + const help = document.querySelector('.mpick .model-effort .model-tier-help')! + expect(help.getAttribute('data-tip')).toBe('gui.effort.scope gui.effort.now {"name":"gui.effort.low"}') + expect(help.hasAttribute('data-tip-wrap')).toBe(true) + await act(async () => { fireEvent.click(seg()[4]!) }) + expect(asked).toEqual([undefined, 'high']) + expect(seg().map((b) => b.getAttribute('aria-checked'))).toEqual(['false', 'false', 'false', 'false', 'true']) + /* Back to the default rung: a clear, not a word. */ + await act(async () => { fireEvent.click(seg()[0]!) }) + expect(asked).toEqual([undefined, 'high', null]) + expect(seg()[0]!.getAttribute('aria-checked')).toBe('true') + /* The picker stays up: an effort is not a pick of a model. */ + expect(pick()).not.toBeNull() + }) + + it('draws no row before the server answers, and none for a settings slot', async () => { + install() + mount() + openIt() + expect(document.querySelector('.mpick .model-effort')).toBeNull() + act(() => store.close()) + setSources({ + model: (await import('./store')).source(), + effort: { + read: async () => ({ effort: null, default: 'low', options: WORDS }), + set: async (id) => ({ effort: id, default: 'low', options: WORDS }), + }, + }) + await act(async () => { await effort.load() }) + act(() => { + store.open(document.getElementById('modelChip'), undefined, undefined, + { kind: 'text', title: 'Chat', pick: async () => {} }) + }) + expect(document.querySelector('.mpick .model-effort')).toBeNull() + }) +}) diff --git a/ui-web/src/features/model/ModelPicker.tsx b/ui-web/src/features/model/ModelPicker.tsx index 7381dbbd5..c5d49f537 100644 --- a/ui-web/src/features/model/ModelPicker.tsx +++ b/ui-web/src/features/model/ModelPicker.tsx @@ -14,10 +14,10 @@ * and the window its vendor publishes (components/ModelTags). The id is on * the title, the provider is the head above, and a pick is a click. * - * Two rows that are not models ride along when the composer opened it: the - * sub-agent tier (state/tier.ts), a setting of the same conversation drawn - * where the model is chosen rather than as a chip of its own, and the door to - * the settings page. + * Three rows that are not models ride along when the composer opened it: the + * sub-agent tier (state/tier.ts) and the reasoning effort (state/effort.ts), + * two settings of the same conversation drawn where the model is chosen rather + * than as chips of their own, and the door to the settings page. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' @@ -25,6 +25,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExter import { ModelTagDefs, ModelTags } from '../../components/ModelTags' import { ProviderIcon } from '../../components/ProviderMark' import { t } from '../../i18n/t' +import * as effort from '../../state/effort' import * as lang from '../../state/lang' import * as tier from '../../state/tier' import { recent } from './recent' @@ -94,6 +95,50 @@ function TierRow(): JSX.Element | null { ) } +/** A bulb: how much the model thinks before it answers. */ +const THINK = 'M12 3.5c-3.3 0-5.8 2.5-5.8 5.6 0 1.9.9 3.2 1.9 4.2.7.7 1.1 1.4 1.2 2.3h5.4c.1-.9.5-1.6 1.2-2.3 1-1 1.9-2.3 1.9-4.2 0-3.1-2.5-5.6-5.8-5.6ZM9.8 19h4.4' + +/* The effort row: the default rung and every word the server offers as one + segmented control, the one in force pressed. Drawn under the tier row for + the same opening and under the same rule -- a conversation's setting, so + not for a settings slot -- and only once the server has answered, for the + reason state/effort.ts's load gives (an older gateway has no row to draw). + + The same shape as the tier row on purpose: a reader meets the two as one + block of per-conversation settings. The explanation sits behind the question + mark and says what "default" means on this install, because that is the one + rung whose meaning is not in its name. */ +function EffortRow(): JSX.Element | null { + useSyncExternalStore(effort.subscribe, effort.get) + if (!effort.offered()) return null + const heading = effort.heading() + const note = effort.scopeNote() + return ( +
+ + + {heading} + + + + + + {effort.options().map((row) => ( + + ))} + +
+ ) +} + /* The word for a model: the registry's label where the provider carries one, the id without its vendor half otherwise -- the rule the chip follows too, so the chip and the row it was picked from say the same thing. */ @@ -387,6 +432,7 @@ function Pick(): JSX.Element { ) : null} {at.scope === 'session' && !at.offer.title ? : null} + {at.scope === 'session' && !at.offer.title ? : null} {at.footer ? (