mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix: preserve custom provider output caps
Preserve per-provider max_output_tokens through config normalization and runtime delegation resolution so Spark/vLLM subagents do not request the default 65K output budget against a 128K context window. Add a chat-completions preflight clamp for input+output budgets and regressions for vLLM output-cap handling.
This commit is contained in:
parent
0ca2a927cf
commit
cba9975aba
9 changed files with 410 additions and 5 deletions
|
|
@ -786,6 +786,9 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
|||
"promptId": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
_ctx_len = getattr(agent, "context_compressor", None)
|
||||
_ctx_len = _ctx_len.context_length if _ctx_len else None
|
||||
|
||||
# ── Provider profile path (registered providers) ───────────────────
|
||||
# Profiles handle per-provider quirks via hooks. When a profile is
|
||||
# found, delegate fully; otherwise fall through to the legacy flag path.
|
||||
|
|
@ -814,6 +817,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
|||
max_tokens=agent.max_tokens,
|
||||
ephemeral_max_output_tokens=_ephemeral_out,
|
||||
max_tokens_param_fn=agent._max_tokens_param,
|
||||
context_length=_ctx_len,
|
||||
reasoning_config=agent.reasoning_config,
|
||||
request_overrides=agent.request_overrides,
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
|
|
@ -846,6 +850,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
|||
max_tokens=agent.max_tokens,
|
||||
ephemeral_max_output_tokens=_ephemeral_out,
|
||||
max_tokens_param_fn=agent._max_tokens_param,
|
||||
context_length=_ctx_len,
|
||||
reasoning_config=agent.reasoning_config,
|
||||
request_overrides=agent.request_overrides,
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
|
|
|
|||
|
|
@ -1228,10 +1228,18 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
|||
# the window this stays None, so the caller correctly falls through to
|
||||
# compression instead of futilely shrinking the output cap.
|
||||
_m_vllm_input = re.search(
|
||||
r'prompt contains (?:at least )?(\d+)\s*input tokens', error_lower
|
||||
r'prompt contains (?:(at least) )?(\d+)\s*input tokens', error_lower
|
||||
)
|
||||
if _m_ctx_tok and _m_vllm_input:
|
||||
_available = int(_m_ctx_tok.group(1)) - int(_m_vllm_input.group(1))
|
||||
# vLLM's "at least N input tokens" is a lower bound chosen from the
|
||||
# requested output cap, not the real prompt token count. Treating it
|
||||
# as exact makes Hermes stair-step max_tokens down by tiny margins
|
||||
# (65536 → 65471 → 65406 …) until recovery exhausts. Only use exact
|
||||
# forms; lower-bound forms should fall through to compression/preflight
|
||||
# instead of pretending we know the available output budget.
|
||||
if _m_vllm_input.group(1):
|
||||
return None
|
||||
_available = int(_m_ctx_tok.group(1)) - int(_m_vllm_input.group(2))
|
||||
if _available >= 1:
|
||||
return _available
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ reasoning configuration, temperature handling, and extra_body assembly.
|
|||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
||||
|
|
@ -19,6 +20,87 @@ from agent.transports.base import ProviderTransport
|
|||
from agent.transports.types import NormalizedResponse, ToolCall, Usage
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OUTPUT_CAP_CONTEXT_MARGIN_TOKENS = 1024
|
||||
|
||||
|
||||
def _rough_chars(value: Any) -> int:
|
||||
if value is None:
|
||||
return 0
|
||||
if isinstance(value, str):
|
||||
return len(value)
|
||||
return len(str(value))
|
||||
|
||||
|
||||
def _estimate_input_tokens_for_output_cap(api_kwargs: dict[str, Any]) -> int:
|
||||
"""Cheap estimate of non-output request tokens for max_tokens clamping.
|
||||
|
||||
This deliberately mirrors Hermes' rough char/4 budgeting rather than
|
||||
provider tokenization. The clamp is a preflight guardrail: if it is still
|
||||
too optimistic, provider error handling and compression remain as backup.
|
||||
"""
|
||||
total_chars = 0
|
||||
for key in ("messages", "tools", "extra_body", "response_format"):
|
||||
total_chars += _rough_chars(api_kwargs.get(key))
|
||||
return max(0, total_chars // 4)
|
||||
|
||||
|
||||
def _clamp_output_cap_to_context_window(
|
||||
api_kwargs: dict[str, Any],
|
||||
*,
|
||||
context_length: Any,
|
||||
) -> None:
|
||||
"""Keep requested output budget inside the total context window.
|
||||
|
||||
Providers validate ``input_tokens + max_tokens <= context_length``. Hermes
|
||||
used to clamp max_tokens only against the model's standalone output limit,
|
||||
so a half-full 131k local model plus a 65k default output cap failed before
|
||||
compression had a chance to run. Clamp the outgoing cap using the current
|
||||
request estimate so long sessions survive model switches and large tools.
|
||||
"""
|
||||
try:
|
||||
ctx = int(context_length or 0)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if ctx <= 0:
|
||||
return
|
||||
|
||||
output_key = None
|
||||
requested = None
|
||||
for key in ("max_output_tokens", "max_completion_tokens", "max_tokens"):
|
||||
raw = api_kwargs.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value > 0:
|
||||
output_key = key
|
||||
requested = value
|
||||
break
|
||||
if not output_key or requested is None:
|
||||
return
|
||||
|
||||
estimated_input = _estimate_input_tokens_for_output_cap(api_kwargs)
|
||||
available = ctx - estimated_input - _OUTPUT_CAP_CONTEXT_MARGIN_TOKENS
|
||||
safe_cap = max(1, available)
|
||||
if requested <= safe_cap:
|
||||
return
|
||||
|
||||
api_kwargs[output_key] = safe_cap
|
||||
logger.info(
|
||||
"Clamped %s from %s to %s for context_length=%s estimated_input=%s margin=%s",
|
||||
output_key,
|
||||
requested,
|
||||
safe_cap,
|
||||
ctx,
|
||||
estimated_input,
|
||||
_OUTPUT_CAP_CONTEXT_MARGIN_TOKENS,
|
||||
)
|
||||
|
||||
|
||||
def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> dict | None:
|
||||
"""Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig."""
|
||||
if reasoning_config is None or not isinstance(reasoning_config, dict):
|
||||
|
|
@ -236,6 +318,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
max_tokens: int | None — user-configured max tokens
|
||||
ephemeral_max_output_tokens: int | None — one-shot override
|
||||
max_tokens_param_fn: callable — returns {max_tokens: N} or {max_completion_tokens: N}
|
||||
context_length: int | None — total model context window for output-cap clamping
|
||||
reasoning_config: dict | None
|
||||
request_overrides: dict | None
|
||||
session_id: str | None
|
||||
|
|
@ -454,6 +537,11 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
if overrides:
|
||||
api_kwargs.update(overrides)
|
||||
|
||||
_clamp_output_cap_to_context_window(
|
||||
api_kwargs,
|
||||
context_length=params.get("context_length"),
|
||||
)
|
||||
|
||||
return api_kwargs
|
||||
|
||||
def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
|
||||
|
|
@ -596,6 +684,11 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
if extra_body:
|
||||
api_kwargs["extra_body"] = extra_body
|
||||
|
||||
_clamp_output_cap_to_context_window(
|
||||
api_kwargs,
|
||||
context_length=params.get("context_length"),
|
||||
)
|
||||
|
||||
return api_kwargs
|
||||
|
||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
||||
|
|
|
|||
|
|
@ -4517,6 +4517,8 @@ def _normalize_custom_provider_entry(
|
|||
"apiKeyEnv": "key_env", # alias — OpenClaw-compatible + docs variant
|
||||
"defaultModel": "default_model",
|
||||
"contextLength": "context_length",
|
||||
"maxOutputTokens": "max_output_tokens",
|
||||
"maxTokens": "max_tokens",
|
||||
"rateLimitDelay": "rate_limit_delay",
|
||||
}
|
||||
# api_key_env is a documented snake_case alias for key_env (see
|
||||
|
|
@ -4532,7 +4534,7 @@ def _normalize_custom_provider_entry(
|
|||
"provider",
|
||||
"name", "api", "url", "base_url", "api_key", "key_env", "api_key_env",
|
||||
"api_mode", "transport", "model", "default_model", "models",
|
||||
"context_length", "rate_limit_delay",
|
||||
"context_length", "max_output_tokens", "max_tokens", "rate_limit_delay",
|
||||
"request_timeout_seconds", "stale_timeout_seconds",
|
||||
"discover_models", "extra_body", "extra_headers",
|
||||
"ssl_ca_cert", "ssl_verify",
|
||||
|
|
@ -4648,6 +4650,16 @@ def _normalize_custom_provider_entry(
|
|||
if isinstance(context_length, int) and context_length > 0:
|
||||
normalized["context_length"] = context_length
|
||||
|
||||
# Per-provider output caps are distinct from context_length: they limit the
|
||||
# response budget for one turn, while context_length is input + output.
|
||||
# Normalize both documented and legacy spellings to the canonical field so
|
||||
# runtime provider resolution can safely pass the cap to AIAgent.max_tokens.
|
||||
for output_key in ("max_output_tokens", "max_tokens"):
|
||||
output_cap = entry.get(output_key)
|
||||
if isinstance(output_cap, int) and output_cap > 0:
|
||||
normalized["max_output_tokens"] = output_cap
|
||||
break
|
||||
|
||||
rate_limit_delay = entry.get("rate_limit_delay")
|
||||
if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:
|
||||
normalized["rate_limit_delay"] = rate_limit_delay
|
||||
|
|
@ -4701,6 +4713,7 @@ def _custom_provider_entry_to_provider_config(
|
|||
"key_env",
|
||||
"models",
|
||||
"context_length",
|
||||
"max_output_tokens",
|
||||
"rate_limit_delay",
|
||||
"discover_models",
|
||||
"extra_body",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import pytest
|
|||
|
||||
from hermes_cli.config import (
|
||||
_PROVIDER_NORMALIZE_WARNED,
|
||||
_custom_provider_entry_to_provider_config,
|
||||
_normalize_custom_provider_entry,
|
||||
)
|
||||
|
||||
|
|
@ -239,6 +240,49 @@ class TestNormalizeCustomProviderEntry:
|
|||
assert result is not None
|
||||
assert "models" not in result
|
||||
|
||||
def test_max_output_tokens_preserved(self, caplog):
|
||||
"""Per-provider output caps must survive compatibility normalization.
|
||||
|
||||
Regression: ``custom_providers[].max_output_tokens`` was accepted by
|
||||
runtime resolution, but the compatibility normalizer stripped it before
|
||||
the runtime resolver could lift it onto ``AIAgent.max_tokens``.
|
||||
"""
|
||||
entry = {
|
||||
"name": "spark-qwen",
|
||||
"base_url": "http://spark.local:8001/v1",
|
||||
"max_output_tokens": 16_384,
|
||||
}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _normalize_custom_provider_entry(entry)
|
||||
assert result is not None
|
||||
assert result["max_output_tokens"] == 16_384
|
||||
assert not any("unknown config keys" in r.message.lower() for r in caplog.records)
|
||||
|
||||
def test_legacy_max_tokens_normalized_to_max_output_tokens(self):
|
||||
"""Legacy provider-local ``max_tokens`` maps to the canonical output cap."""
|
||||
entry = {
|
||||
"name": "spark-qwen",
|
||||
"base_url": "http://spark.local:8001/v1",
|
||||
"max_tokens": 8_192,
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry)
|
||||
assert result is not None
|
||||
assert result["max_output_tokens"] == 8_192
|
||||
assert "max_tokens" not in result
|
||||
|
||||
def test_max_output_tokens_preserved_when_migrating_to_providers_shape(self):
|
||||
"""Legacy custom-provider migration must not drop output caps."""
|
||||
result = _custom_provider_entry_to_provider_config(
|
||||
{
|
||||
"name": "spark-qwen",
|
||||
"base_url": "http://spark.local:8001/v1",
|
||||
"max_output_tokens": 16_384,
|
||||
},
|
||||
provider_key="spark-qwen",
|
||||
)
|
||||
assert result is not None
|
||||
assert result["max_output_tokens"] == 16_384
|
||||
|
||||
def test_env_var_placeholder_in_base_url_not_rejected(self):
|
||||
"""A base_url that is an un-expanded ${ENV_VAR} placeholder must not be
|
||||
rejected as an invalid URL — it is expanded at runtime, so a caller
|
||||
|
|
|
|||
|
|
@ -848,6 +848,52 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch):
|
|||
assert resolved["source"] == "custom_provider:Local"
|
||||
|
||||
|
||||
def test_named_custom_provider_preserves_legacy_list_output_cap(monkeypatch):
|
||||
"""Legacy custom provider caps must reach runtime provider resolution.
|
||||
|
||||
Spark/vLLM workers need this cap so an uncapped Codex parent does not make
|
||||
the child request the transport default 65K output budget against a 128K
|
||||
context window.
|
||||
"""
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "spark-qwen",
|
||||
"base_url": "http://spark.local:8001/v1",
|
||||
"api_key": "spark-key",
|
||||
"api_mode": "chat_completions",
|
||||
"model": "/models/Qwen3.6-35B-A3B-NVFP4",
|
||||
"context_length": 131_072,
|
||||
"max_output_tokens": 16_384,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"resolve_provider",
|
||||
lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError(
|
||||
"resolve_provider should not be called for named custom providers"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="custom:spark-qwen")
|
||||
|
||||
assert resolved["provider"] == "custom"
|
||||
assert resolved["api_mode"] == "chat_completions"
|
||||
assert resolved["base_url"] == "http://spark.local:8001/v1"
|
||||
assert resolved["api_key"] == "spark-key"
|
||||
assert resolved["model"] == "/models/Qwen3.6-35B-A3B-NVFP4"
|
||||
assert resolved["max_output_tokens"] == 16_384
|
||||
|
||||
|
||||
def test_bare_custom_resolves_providers_dict_entry_named_custom(monkeypatch):
|
||||
"""A request for bare ``provider="custom"`` must resolve a literal
|
||||
``providers.custom`` entry (e.g. a cliproxy endpoint) instead of falling
|
||||
|
|
|
|||
|
|
@ -74,6 +74,25 @@ class TestParseAvailableOutputTokens:
|
|||
msg = "max_tokens: 9999 > context_window: 10000 - input_tokens: 9999 = available_tokens: 1"
|
||||
assert self._parse(msg) == 1
|
||||
|
||||
def test_vllm_exact_input_tokens_format(self):
|
||||
msg = (
|
||||
"This model's maximum context length is 131072 tokens. However, "
|
||||
"you requested 65536 output tokens and your prompt contains 70000 "
|
||||
"input tokens, for a total of 135536 tokens."
|
||||
)
|
||||
assert self._parse(msg) == 61_072
|
||||
|
||||
def test_vllm_at_least_input_tokens_is_not_exact_available_budget(self):
|
||||
"""vLLM lower-bound wording is not the real prompt token count."""
|
||||
msg = (
|
||||
"This model's maximum context length is 131072 tokens. However, "
|
||||
"you requested 65536 output tokens and your prompt contains at least "
|
||||
"65537 input tokens, for a total of at least 131073 tokens. Please "
|
||||
"reduce the length of the input prompt or the number of requested "
|
||||
"output tokens."
|
||||
)
|
||||
assert self._parse(msg) is None
|
||||
|
||||
# ── Should NOT detect (returns None) ─────────────────────────────────
|
||||
|
||||
def test_prompt_too_long_is_not_output_cap_error(self):
|
||||
|
|
@ -263,6 +282,31 @@ class TestEphemeralMaxOutputTokens:
|
|||
assert kwargs["max_tokens"] == 8_192
|
||||
|
||||
|
||||
class TestChatCompletionsOutputCapClamp:
|
||||
def _build(self, *, content_chars: int, context_length: int = 131_072, max_tokens: int = 65_536):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
transport = ChatCompletionsTransport()
|
||||
return transport.build_kwargs(
|
||||
model="/models/Qwen3.6-35B-A3B-NVFP4",
|
||||
messages=[{"role": "user", "content": "x" * content_chars}],
|
||||
tools=None,
|
||||
max_tokens=max_tokens,
|
||||
max_tokens_param_fn=lambda value: {"max_tokens": value},
|
||||
context_length=context_length,
|
||||
)
|
||||
|
||||
def test_clamps_output_cap_when_prompt_plus_output_would_exceed_context(self):
|
||||
kwargs = self._build(content_chars=280_000)
|
||||
|
||||
assert 1 <= kwargs["max_tokens"] < 65_536
|
||||
|
||||
def test_leaves_output_cap_when_prompt_plus_output_fits_context(self):
|
||||
kwargs = self._build(content_chars=4_000)
|
||||
|
||||
assert kwargs["max_tokens"] == 65_536
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: error handler does NOT halve context_length for output-cap errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -411,6 +411,61 @@ class TestDelegateTask(unittest.TestCase):
|
|||
self.assertEqual(kwargs["provider"], parent.provider)
|
||||
self.assertEqual(kwargs["api_mode"], parent.api_mode)
|
||||
|
||||
def test_child_uses_override_max_tokens_over_uncapped_parent(self):
|
||||
"""Delegation-provider output caps must reach the child agent.
|
||||
|
||||
Regression: Codex parents often have max_tokens=None, but Spark/vLLM
|
||||
children need a concrete cap so they do not request a 65K output budget
|
||||
against a 128K context window.
|
||||
"""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
parent.max_tokens = None
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
_build_child_agent(
|
||||
task_index=0,
|
||||
goal="Use capped Spark child",
|
||||
context=None,
|
||||
toolsets=None,
|
||||
model="/models/Qwen3.6-35B-A3B-NVFP4",
|
||||
max_iterations=10,
|
||||
parent_agent=parent,
|
||||
task_count=1,
|
||||
override_provider="custom:spark-qwen",
|
||||
override_base_url="http://spark.local:8001/v1",
|
||||
override_api_key="spark-test",
|
||||
override_api_mode="chat_completions",
|
||||
override_max_tokens=16_384,
|
||||
)
|
||||
|
||||
_, kwargs = MockAgent.call_args
|
||||
self.assertEqual(kwargs["max_tokens"], 16_384)
|
||||
|
||||
def test_child_inherits_parent_max_tokens_without_override(self):
|
||||
parent = _make_mock_parent(depth=0)
|
||||
parent.max_tokens = 8_192
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
_build_child_agent(
|
||||
task_index=0,
|
||||
goal="Inherit parent cap",
|
||||
context=None,
|
||||
toolsets=None,
|
||||
model=None,
|
||||
max_iterations=10,
|
||||
parent_agent=parent,
|
||||
task_count=1,
|
||||
)
|
||||
|
||||
_, kwargs = MockAgent.call_args
|
||||
self.assertEqual(kwargs["max_tokens"], 8_192)
|
||||
|
||||
def test_child_inherits_parent_print_fn(self):
|
||||
parent = _make_mock_parent(depth=0)
|
||||
sink = MagicMock()
|
||||
|
|
@ -1112,6 +1167,28 @@ class TestDelegationCredentialResolution(unittest.TestCase):
|
|||
self.assertEqual(creds["api_key"], "local-key")
|
||||
self.assertEqual(creds["api_mode"], "chat_completions")
|
||||
|
||||
def test_direct_endpoint_honors_configured_max_tokens(self):
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "qwen2.5-coder",
|
||||
"base_url": "http://localhost:1234/v1",
|
||||
"api_key": "local-key",
|
||||
"max_tokens": "8192",
|
||||
}
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
self.assertEqual(creds["max_tokens"], 8192)
|
||||
|
||||
def test_direct_endpoint_honors_max_output_tokens_alias(self):
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "qwen2.5-coder",
|
||||
"base_url": "http://localhost:1234/v1",
|
||||
"api_key": "local-key",
|
||||
"max_output_tokens": 16_384,
|
||||
}
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
self.assertEqual(creds["max_tokens"], 16_384)
|
||||
|
||||
def test_direct_endpoint_auto_detects_anthropic_messages_suffix(self):
|
||||
# Issue #10213: Azure AI Foundry exposes Anthropic-compatible models at
|
||||
# a /anthropic URL suffix. Subagents must pick anthropic_messages
|
||||
|
|
@ -1263,6 +1340,48 @@ class TestDelegationCredentialResolution(unittest.TestCase):
|
|||
requested="crof.ai", target_model="deepseek-v4-pro-CEER"
|
||||
)
|
||||
|
||||
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
|
||||
def test_provider_resolution_propagates_runtime_max_output_tokens(self, mock_resolve):
|
||||
mock_resolve.return_value = {
|
||||
"provider": "custom",
|
||||
"model": "/models/Qwen3.6-35B-A3B-NVFP4",
|
||||
"base_url": "http://spark.local:8001/v1",
|
||||
"api_key": "spark-test",
|
||||
"api_mode": "chat_completions",
|
||||
"max_output_tokens": 16_384,
|
||||
}
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "/models/Qwen3.6-35B-A3B-NVFP4",
|
||||
"provider": "custom:spark-qwen",
|
||||
}
|
||||
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
|
||||
self.assertEqual(creds["provider"], "custom:spark-qwen")
|
||||
self.assertEqual(creds["max_tokens"], 16_384)
|
||||
|
||||
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
|
||||
def test_configured_delegation_max_tokens_overrides_runtime_cap(self, mock_resolve):
|
||||
mock_resolve.return_value = {
|
||||
"provider": "custom",
|
||||
"model": "/models/Qwen3.6-35B-A3B-NVFP4",
|
||||
"base_url": "http://spark.local:8001/v1",
|
||||
"api_key": "spark-test",
|
||||
"api_mode": "chat_completions",
|
||||
"max_output_tokens": 65_536,
|
||||
}
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "/models/Qwen3.6-35B-A3B-NVFP4",
|
||||
"provider": "custom:spark-qwen",
|
||||
"max_tokens": 8_192,
|
||||
}
|
||||
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
|
||||
self.assertEqual(creds["max_tokens"], 8_192)
|
||||
|
||||
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
|
||||
def test_standard_provider_not_overwritten_by_configured_name(self, mock_resolve):
|
||||
"""Standard (non-custom) providers must still return runtime identity,
|
||||
|
|
|
|||
|
|
@ -588,6 +588,19 @@ DEFAULT_MAX_ITERATIONS = 50
|
|||
# headroom budget (see _apply_summary_budget). Belt-and-suspenders for
|
||||
# models that ignore the "be concise" instruction. 0 disables the ceiling.
|
||||
DEFAULT_MAX_SUMMARY_CHARS = 24000
|
||||
|
||||
|
||||
def _positive_int_or_none(value: Any) -> Optional[int]:
|
||||
"""Coerce a config/runtime value to a positive int, else None."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
# Fraction of the parent's *remaining* context headroom that the whole batch
|
||||
# of subagent summaries is allowed to consume. The per-summary budget is this
|
||||
# slice divided across the batch, so N children can't collectively blow the
|
||||
|
|
@ -1055,6 +1068,7 @@ def _build_child_agent(
|
|||
override_base_url: Optional[str] = None,
|
||||
override_api_key: Optional[str] = None,
|
||||
override_api_mode: Optional[str] = None,
|
||||
override_max_tokens: Optional[int] = None,
|
||||
# ACP transport overrides from trusted delegation config.
|
||||
override_acp_command: Optional[str] = None,
|
||||
override_acp_args: Optional[List[str]] = None,
|
||||
|
|
@ -1298,6 +1312,12 @@ def _build_child_agent(
|
|||
# openrouter/pareto-code), so we keep it inherited even when the
|
||||
# provider is overridden — it's a no-op on any other model.
|
||||
|
||||
child_max_tokens: Any = (
|
||||
override_max_tokens
|
||||
if override_max_tokens is not None
|
||||
else getattr(parent_agent, "max_tokens", None)
|
||||
)
|
||||
|
||||
child = AIAgent(
|
||||
base_url=effective_base_url,
|
||||
api_key=effective_api_key,
|
||||
|
|
@ -1307,7 +1327,7 @@ def _build_child_agent(
|
|||
acp_command=effective_acp_command,
|
||||
acp_args=effective_acp_args,
|
||||
max_iterations=max_iterations,
|
||||
max_tokens=getattr(parent_agent, "max_tokens", None),
|
||||
max_tokens=child_max_tokens,
|
||||
reasoning_config=child_reasoning,
|
||||
prefill_messages=getattr(parent_agent, "prefill_messages", None),
|
||||
fallback_model=parent_fallback,
|
||||
|
|
@ -1458,7 +1478,7 @@ def _dump_subagent_timeout_diagnostic(
|
|||
_w("## Child config")
|
||||
for attr in (
|
||||
"model", "provider", "api_mode", "base_url", "max_iterations",
|
||||
"quiet_mode", "skip_memory", "skip_context_files", "platform",
|
||||
"max_tokens", "quiet_mode", "skip_memory", "skip_context_files", "platform",
|
||||
"_delegate_role", "_delegate_depth",
|
||||
):
|
||||
try:
|
||||
|
|
@ -2500,6 +2520,7 @@ def delegate_task(
|
|||
override_base_url=creds["base_url"],
|
||||
override_api_key=creds["api_key"],
|
||||
override_api_mode=creds["api_mode"],
|
||||
override_max_tokens=creds.get("max_tokens"),
|
||||
override_acp_command=creds.get("command"),
|
||||
override_acp_args=creds.get("args"),
|
||||
role=effective_role,
|
||||
|
|
@ -2999,6 +3020,10 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
|
|||
configured_base_url = str(cfg.get("base_url") or "").strip() or None
|
||||
configured_api_key = str(cfg.get("api_key") or "").strip() or None
|
||||
configured_api_mode = str(cfg.get("api_mode") or "").strip().lower() or None
|
||||
configured_max_tokens = (
|
||||
_positive_int_or_none(cfg.get("max_tokens"))
|
||||
or _positive_int_or_none(cfg.get("max_output_tokens"))
|
||||
)
|
||||
|
||||
# Native-SDK providers (Bedrock, Vertex, Google GenAI) speak their own
|
||||
# wire protocol — they cannot be reached via OpenAI chat_completions against
|
||||
|
|
@ -3054,6 +3079,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
|
|||
"base_url": configured_base_url,
|
||||
"api_key": api_key,
|
||||
"api_mode": api_mode,
|
||||
"max_tokens": configured_max_tokens,
|
||||
}
|
||||
|
||||
if not configured_provider:
|
||||
|
|
@ -3064,6 +3090,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
|
|||
"base_url": None,
|
||||
"api_key": None,
|
||||
"api_mode": None,
|
||||
"max_tokens": configured_max_tokens,
|
||||
}
|
||||
|
||||
# Provider is configured — resolve full credentials
|
||||
|
|
@ -3080,6 +3107,11 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
|
|||
) from exc
|
||||
|
||||
api_key = runtime.get("api_key", "")
|
||||
runtime_max_tokens = (
|
||||
configured_max_tokens
|
||||
or _positive_int_or_none(runtime.get("max_output_tokens"))
|
||||
or _positive_int_or_none(runtime.get("max_tokens"))
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"Delegation provider '{configured_provider}' resolved but has no API key. "
|
||||
|
|
@ -3092,6 +3124,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
|
|||
"base_url": runtime.get("base_url"),
|
||||
"api_key": api_key,
|
||||
"api_mode": runtime.get("api_mode"),
|
||||
"max_tokens": runtime_max_tokens,
|
||||
"command": runtime.get("command"),
|
||||
"args": list(runtime.get("args") or []),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue