mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(auxiliary): per-task reasoning_effort for auxiliary models (#64597)
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).
This commit is contained in:
parent
f7198a2055
commit
df5700ebe3
5 changed files with 195 additions and 5 deletions
|
|
@ -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.<task>.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.<task>.extra_body and return a shallow copy when valid."""
|
||||
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid.
|
||||
|
||||
Also folds in ``auxiliary.<task>.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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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.<task>.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
|
||||
|
|
|
|||
|
|
@ -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"`).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue