From df5700ebe317ff9f2d9ea4677513e012eb68b6f4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:07:43 -0700 Subject: [PATCH] feat(auxiliary): per-task reasoning_effort for auxiliary models (#64597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every auxiliary task block (vision, web_extract, compression, title_generation, curator, background_review, moa_reference, ...) now accepts a reasoning_effort shorthand: auxiliary: compression: reasoning_effort: low vision: reasoning_effort: none _get_task_extra_body() folds it into extra_body.reasoning, which every auxiliary wire already translates: chat.completions passes it through, the Codex Responses adapter maps it to top-level reasoning/include, and the Anthropic auxiliary adapter now forwards it into build_anthropic_kwargs(reasoning_config=...) (previously hardcoded None). An explicit extra_body.reasoning on the same task wins over the shorthand. Invalid levels are ignored with a warning. Empty string (the shipped default) is a no-op — zero behavior change. Config: reasoning_effort added to all 16 auxiliary task blocks in DEFAULT_CONFIG (no version bump — deep-merge handles new keys). --- agent/auxiliary_client.py | 44 ++++++++- cli-config.yaml.example | 8 ++ hermes_cli/config.py | 16 ++++ tests/agent/test_auxiliary_client.py | 116 +++++++++++++++++++++++ website/docs/user-guide/configuration.md | 16 ++++ 5 files changed, 195 insertions(+), 5 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e39017be0d79..e2fc920d9a39 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1306,12 +1306,24 @@ class _AnthropicCompletionsAdapter: elif choice_type in {"auto", "required", "none"}: normalized_tool_choice = choice_type + # Honor extra_body.reasoning (chat.completions shape) so auxiliary + # tasks configured with auxiliary..reasoning_effort (or an + # explicit extra_body.reasoning) control Anthropic thinking too — + # build_anthropic_kwargs translates the config dict into the native + # ``thinking`` field and handles models where thinking is mandatory. + _reasoning_cfg = None + _eb = kwargs.get("extra_body") + if isinstance(_eb, dict): + _rc = _eb.get("reasoning") + if isinstance(_rc, dict): + _reasoning_cfg = _rc + anthropic_kwargs = build_anthropic_kwargs( model=model, messages=messages, tools=tools, max_tokens=max_tokens, - reasoning_config=None, + reasoning_config=_reasoning_cfg, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, ) @@ -6141,12 +6153,34 @@ def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float: def _get_task_extra_body(task: str) -> Dict[str, Any]: - """Read auxiliary..extra_body and return a shallow copy when valid.""" + """Read auxiliary..extra_body and return a shallow copy when valid. + + Also folds in ``auxiliary..reasoning_effort`` as an + ``extra_body.reasoning`` config dict ({"enabled": ..., "effort": ...}) + when set. An explicit ``extra_body.reasoning`` in config wins over the + ``reasoning_effort`` shorthand (it is the more specific wire control). + Downstream, each wire already translates ``extra_body.reasoning``: + chat.completions passes it through, the Codex Responses adapter maps it + to top-level ``reasoning``/``include``, and the Anthropic auxiliary + client maps it to ``build_anthropic_kwargs(reasoning_config=...)``. + """ task_config = _get_auxiliary_task_config(task) raw = task_config.get("extra_body") - if isinstance(raw, dict): - return dict(raw) - return {} + result = dict(raw) if isinstance(raw, dict) else {} + if "reasoning" not in result: + effort = task_config.get("reasoning_effort") + if effort is not None and effort != "": + from hermes_constants import parse_reasoning_effort + parsed = parse_reasoning_effort(effort) + if parsed is not None: + result["reasoning"] = parsed + else: + logger.warning( + "auxiliary.%s.reasoning_effort %r is not a valid level " + "(none, minimal, low, medium, high, xhigh, max, ultra) — ignoring", + task, effort, + ) + return result # --------------------------------------------------------------------------- diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 2583bfb54f0b..a040c1a24935 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -506,11 +506,19 @@ prompt_caching: # timeout: 30 # LLM API call timeout (seconds) # download_timeout: 30 # Image HTTP download timeout (seconds) # # Increase for slow connections or self-hosted image servers +# reasoning_effort: "" # Per-task thinking level: none, minimal, low, medium, +# # high, xhigh, max, ultra. Empty = provider default. +# # Works on every auxiliary task block (vision, +# # web_extract, compression, title_generation, curator, +# # background_review, moa_reference, ...). Example: run +# # compression at "low" and vision at "none" to cut +# # side-task latency/cost on reasoning models. # # # Web page scraping / summarization + browser page text extraction # web_extract: # provider: "auto" # model: "" +# reasoning_effort: "low" # # # Gemini 3.1 TTS hidden audio-tag insertion # tts_audio_tags: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 737833fae208..fd25344bbb18 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1588,6 +1588,7 @@ DEFAULT_CONFIG = { "api_key": "", # API key for base_url (falls back to OPENAI_API_KEY) "timeout": 120, # seconds — LLM API call timeout; vision payloads need generous timeout "extra_body": {}, # OpenAI-compatible provider-specific request fields + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) "download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections }, "web_extract": { @@ -1597,6 +1598,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "compression": { "provider": "auto", @@ -1605,6 +1607,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 120, # seconds — compression summarises large contexts; increase for local models "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Note: session_search no longer uses an auxiliary LLM (PR #27590 — # single-shape tool returns DB content directly). The old @@ -1617,6 +1620,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "approval": { "provider": "auto", @@ -1625,6 +1629,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "mcp": { "provider": "auto", @@ -1633,6 +1638,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "title_generation": { "provider": "auto", @@ -1641,6 +1647,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) "language": "", }, "tts_audio_tags": { @@ -1650,6 +1657,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Triage specifier — flesh out a rough one-liner in the Kanban # Triage column into a concrete spec, then promote it to ``todo``. @@ -1663,6 +1671,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 120, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Kanban decomposer — decomposes a triage task into a graph of # child tasks routed to specialist profiles by description. @@ -1676,6 +1685,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 180, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Profile describer — auto-generates a 1-2 sentence description # of what a profile is good at. Invoked by @@ -1688,6 +1698,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 60, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Curator — skill-usage review fork. Timeout is generous because the # review pass can take several minutes on reasoning models (umbrella @@ -1701,6 +1712,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 600, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Monitor — urgency/importance classifier used by the important-mail # monitor catalog automation (cron/scripts/classify_items.py). Scores @@ -1715,6 +1727,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 60, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Background review — the post-turn self-improvement fork that decides # whether to save a memory / patch a skill. "auto" (default) = run on @@ -1734,6 +1747,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 120, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "moa_reference": { "provider": "auto", @@ -1742,6 +1756,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 900, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "moa_aggregator": { "provider": "auto", @@ -1750,6 +1765,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 900, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, }, diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 95bc4e6f1b56..2a88d0f6b39e 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -3242,6 +3242,122 @@ class TestAuxiliaryTaskExtraBody: kwargs = client.chat.completions.create.call_args.kwargs assert kwargs["extra_body"]["enable_thinking"] is True + def test_reasoning_effort_shorthand_folds_into_extra_body(self): + """auxiliary..reasoning_effort becomes extra_body.reasoning.""" + client = MagicMock() + client.base_url = "https://api.example.com/v1" + client.chat.completions.create.return_value = MagicMock() + + config = { + "auxiliary": { + "session_search": {"reasoning_effort": "low"} + } + } + + with patch("hermes_cli.config.load_config", return_value=config), patch( + "agent.auxiliary_client._get_cached_client", + return_value=(client, "glm-4.5-air"), + ): + call_llm( + task="session_search", + messages=[{"role": "user", "content": "hello"}], + ) + + kwargs = client.chat.completions.create.call_args.kwargs + assert kwargs["extra_body"]["reasoning"] == {"enabled": True, "effort": "low"} + + def test_reasoning_effort_none_disables(self): + client = MagicMock() + client.base_url = "https://api.example.com/v1" + client.chat.completions.create.return_value = MagicMock() + + config = {"auxiliary": {"session_search": {"reasoning_effort": "none"}}} + + with patch("hermes_cli.config.load_config", return_value=config), patch( + "agent.auxiliary_client._get_cached_client", + return_value=(client, "glm-4.5-air"), + ): + call_llm(task="session_search", messages=[{"role": "user", "content": "hi"}]) + + kwargs = client.chat.completions.create.call_args.kwargs + assert kwargs["extra_body"]["reasoning"] == {"enabled": False} + + def test_explicit_extra_body_reasoning_wins_over_shorthand(self): + """config extra_body.reasoning beats the reasoning_effort shorthand.""" + client = MagicMock() + client.base_url = "https://api.example.com/v1" + client.chat.completions.create.return_value = MagicMock() + + config = { + "auxiliary": { + "session_search": { + "reasoning_effort": "xhigh", + "extra_body": {"reasoning": {"effort": "none"}}, + } + } + } + + with patch("hermes_cli.config.load_config", return_value=config), patch( + "agent.auxiliary_client._get_cached_client", + return_value=(client, "glm-4.5-air"), + ): + call_llm(task="session_search", messages=[{"role": "user", "content": "hi"}]) + + kwargs = client.chat.completions.create.call_args.kwargs + assert kwargs["extra_body"]["reasoning"] == {"effort": "none"} + + def test_invalid_reasoning_effort_ignored_with_warning(self, caplog): + client = MagicMock() + client.base_url = "https://api.example.com/v1" + client.chat.completions.create.return_value = MagicMock() + + config = {"auxiliary": {"session_search": {"reasoning_effort": "warp9"}}} + + with patch("hermes_cli.config.load_config", return_value=config), patch( + "agent.auxiliary_client._get_cached_client", + return_value=(client, "glm-4.5-air"), + ), caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"): + call_llm(task="session_search", messages=[{"role": "user", "content": "hi"}]) + + kwargs = client.chat.completions.create.call_args.kwargs + assert "reasoning" not in (kwargs.get("extra_body") or {}) + assert any("reasoning_effort" in rec.message for rec in caplog.records) + + def test_empty_reasoning_effort_is_noop(self): + """The DEFAULT_CONFIG ships reasoning_effort: '' — must add nothing.""" + from agent.auxiliary_client import _get_task_extra_body + + config = {"auxiliary": {"session_search": {"reasoning_effort": ""}}} + with patch("hermes_cli.config.load_config", return_value=config): + assert _get_task_extra_body("session_search") == {} + + def test_anthropic_aux_client_forwards_extra_body_reasoning(self): + """_AnthropicCompletionsAdapter passes extra_body.reasoning into + build_anthropic_kwargs as reasoning_config.""" + from agent.auxiliary_client import _AnthropicCompletionsAdapter + + adapter = _AnthropicCompletionsAdapter(MagicMock(), "claude-sonnet-4-6", is_oauth=False) + + with patch("agent.anthropic_adapter.build_anthropic_kwargs", + return_value={"model": "claude-sonnet-4-6", "messages": [], "max_tokens": 64}) as mock_bak, \ + patch("agent.anthropic_adapter.create_anthropic_message") as mock_create, \ + patch("agent.transports.get_transport") as mock_gt: + mock_gt.return_value.normalize_response.return_value = MagicMock( + content="ok", tool_calls=None, reasoning=None, finish_reason="stop", + usage=None, provider_data=None, + ) + adapter.create( + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "hi"}], + max_tokens=64, + extra_body={"reasoning": {"enabled": True, "effort": "low"}}, + ) + + assert mock_bak.call_args.kwargs["reasoning_config"] == { + "enabled": True, "effort": "low", + } + mock_create.assert_called_once() + def test_no_warning_when_provider_is_custom(self, monkeypatch, caplog): """No warning when the provider is 'custom' — OPENAI_BASE_URL is expected.""" import agent.auxiliary_client as mod diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 67f3a0ed5c5b..c4f9e8481579 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -974,6 +974,22 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t | `model` | Which model to request | provider's default | | `base_url` | Custom OpenAI-compatible endpoint (overrides provider) | not set | +Auxiliary task blocks additionally accept a `reasoning_effort` knob: + +| Key | What it does | Default | +|-----|-------------|---------| +| `reasoning_effort` | Thinking level for that task's LLM calls: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra` | not set (provider default) | + +This is the per-task counterpart of the global `agent.reasoning_effort`: run compression at `low` or vision at `none` to cut side-task latency and cost when your main model is an expensive reasoning model, without touching your main chat behavior. It works on every auxiliary task block (`vision`, `web_extract`, `compression`, `title_generation`, `curator`, `background_review`, `moa_reference`, `moa_aggregator`, ...), across all three auxiliary wire formats (chat completions, Codex Responses, Anthropic Messages). An explicit `extra_body.reasoning` on the same task wins over the shorthand. + +```yaml +auxiliary: + compression: + reasoning_effort: "low" # summaries don't need deep thinking + vision: + reasoning_effort: "none" # disable thinking for image description +``` + When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL. Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`).