diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index be8036253553..51eadeab4cb9 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -74,6 +74,45 @@ INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response (" +def _next_truncated_tool_call_cap(agent: Any, api_kwargs: Any, retry_index: int) -> int: + """Return the one-shot output cap for a truncated tool-call retry. + + Prefer the largest explicit cap we know about. The old inline logic grew + from a small base (often 4096), which meant a session configured for 100k + output could still retry at 8k/12k when the prepared request was stale or + provider-normalized. For truncated JSON tool arguments, retrying below the + configured/requested ceiling is pure theatre. + """ + configured_cap = getattr(agent, "max_tokens", None) + try: + configured_cap = int(configured_cap) if configured_cap else None + except (TypeError, ValueError): + configured_cap = None + + extractor = getattr(agent, "_requested_output_cap_from_api_kwargs", None) + if callable(extractor): + requested_cap = extractor(api_kwargs) + else: + requested_cap = None + if isinstance(api_kwargs, dict): + for key in ("max_output_tokens", "max_completion_tokens", "max_tokens"): + raw_value = api_kwargs.get(key) + if raw_value is None: + continue + try: + value = int(raw_value) + except (TypeError, ValueError): + continue + if value > 0: + requested_cap = value + break + candidates = [cap for cap in (configured_cap, requested_cap) if isinstance(cap, int) and cap > 0] + if candidates: + return max(candidates) + + return max(4096 * (retry_index + 1), 32768) + + def _image_error_max_dimension(error: Exception) -> Optional[int]: """Extract a provider-reported image dimension ceiling, if present.""" parts = [] @@ -1869,13 +1908,11 @@ def _perform_api_call(next_api_kwargs): # network stall doesn't need a bigger budget, but # a genuine output-cap truncation does, and the # boost is harmless for the stall case. - _tc_boost_base = agent.max_tokens if agent.max_tokens else 4096 - _tc_boost = _tc_boost_base * (2 ** truncated_tool_call_retries) - _tc_requested_cap = agent._requested_output_cap_from_api_kwargs(api_kwargs) - if _tc_requested_cap is not None: - _tc_boost = max(_tc_boost, _tc_requested_cap) - _tc_boost_cap = max(32768, _tc_requested_cap or 0) - agent._ephemeral_max_output_tokens = min(_tc_boost, _tc_boost_cap) + agent._ephemeral_max_output_tokens = _next_truncated_tool_call_cap( + agent, + api_kwargs, + truncated_tool_call_retries, + ) # Don't append the broken response to messages; # just re-run the same API call from the current # message state, giving the model another chance. @@ -1891,14 +1928,24 @@ def _perform_api_call(next_api_kwargs): f"{agent.log_prefix}⚠️ Truncated tool call response detected again — refusing to execute incomplete tool arguments.", force=True, ) - agent._cleanup_task_resources(effective_task_id) - agent._persist_session(messages, conversation_history) _final_response = ( "Stream repeatedly dropped mid tool-call (network); " "the tool was not executed" if _is_stub_stall else "Response truncated due to output length limit" ) + messages.append({ + "role": "assistant", + "content": ( + f"{_final_response}. No tool was executed because " + "the model response ended before valid tool-call " + "arguments were complete. Retry with smaller or " + "chunked tool arguments." + ), + }) + agent._session_messages = messages + agent._cleanup_task_resources(effective_task_id) + agent._persist_session(messages, conversation_history) return { "final_response": _final_response, "messages": messages, diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index a71d88356985..8c2ea4c1cb30 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -82,6 +82,7 @@ def _ensure_runtime_credentials(self) -> bool: resolved_acp_command = runtime.get("command") resolved_acp_args = list(runtime.get("args") or []) resolved_credential_pool = runtime.get("credential_pool") + resolved_max_output_tokens = runtime.get("max_output_tokens") # A callable api_key is a bearer-token provider (Azure Foundry # Entra ID — ``azure_identity_adapter.build_token_provider``). # The OpenAI SDK accepts ``Callable[[], str]`` for ``api_key`` and @@ -123,6 +124,11 @@ def _ensure_runtime_credentials(self) -> bool: self.acp_command = resolved_acp_command self.acp_args = resolved_acp_args self._credential_pool = resolved_credential_pool + self._runtime_max_output_tokens = ( + resolved_max_output_tokens + if isinstance(resolved_max_output_tokens, int) and resolved_max_output_tokens > 0 + else None + ) self._provider_source = runtime.get("source") self.api_key = api_key self.base_url = base_url @@ -189,6 +195,7 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict: "command": self.acp_command, "args": list(self.acp_args or []), "credential_pool": getattr(self, "_credential_pool", None), + "max_tokens": getattr(self, "max_tokens", None) or getattr(self, "_runtime_max_output_tokens", None), } route = { "model": self.model, @@ -200,6 +207,7 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict: runtime["api_mode"], runtime["command"], tuple(runtime["args"]), + runtime.get("max_tokens") or runtime.get("max_output_tokens"), ), } @@ -349,7 +357,11 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No acp_command=runtime.get("command"), acp_args=runtime.get("args"), credential_pool=runtime.get("credential_pool"), - max_tokens=self.max_tokens, + max_tokens=( + getattr(self, "max_tokens", None) + or runtime.get("max_tokens") + or runtime.get("max_output_tokens") + ), max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, disabled_toolsets=self.disabled_toolsets, @@ -422,6 +434,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No runtime.get("api_mode"), runtime.get("command"), tuple(runtime.get("args") or ()), + runtime.get("max_tokens") or runtime.get("max_output_tokens"), ) # Force-create DB row on /title intent, then apply title. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 17f506a5d679..b7176241032a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4476,6 +4476,7 @@ def _normalize_custom_provider_entry( _KNOWN_KEYS = { "name", "api", "url", "base_url", "api_key", "key_env", "api_key_env", "api_mode", "transport", "model", "default_model", "models", + "max_output_tokens", "max_tokens", "context_length", "rate_limit_delay", "request_timeout_seconds", "stale_timeout_seconds", "discover_models", "extra_body", @@ -4573,6 +4574,12 @@ def _normalize_custom_provider_entry( if isinstance(context_length, int) and context_length > 0: normalized["context_length"] = context_length + for output_cap_key in ("max_output_tokens", "max_tokens"): + output_cap = entry.get(output_cap_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 @@ -4608,6 +4615,7 @@ def _custom_provider_entry_to_provider_config( "api_key", "key_env", "models", + "max_output_tokens", "context_length", "rate_limit_delay", "discover_models", diff --git a/tests/test_output_truncation_runtime.py b/tests/test_output_truncation_runtime.py new file mode 100644 index 000000000000..0b96d27267e6 --- /dev/null +++ b/tests/test_output_truncation_runtime.py @@ -0,0 +1,94 @@ +from types import SimpleNamespace + + +def test_custom_provider_normalizer_preserves_output_cap_fields(): + from hermes_cli.config import get_compatible_custom_providers + + cfg = { + "custom_providers": [ + { + "name": "hyperspace-responses", + "base_url": "http://localhost:6658/openai/v1", + "api_key": "test-key", + "model": "gpt-5.5", + "api_mode": "codex_responses", + "max_output_tokens": 100000, + } + ] + } + + providers = get_compatible_custom_providers(cfg) + + assert providers[0]["max_output_tokens"] == 100000 + + +def test_cli_runtime_output_cap_reaches_agent_constructor(monkeypatch): + from tests.cli.test_cli_provider_resolution import _import_cli + + cli = _import_cli() + captured = {} + + class FakeAgent: + def __init__(self, **kwargs): + captured.update(kwargs) + self._print_fn = None + + monkeypatch.setattr("hermes_cli.mcp_startup.wait_for_mcp_discovery", lambda: None) + monkeypatch.setattr(cli, "AIAgent", FakeAgent) + monkeypatch.setattr(cli.HermesCLI, "_ensure_runtime_credentials", lambda self: True) + + shell = cli.HermesCLI(model="gpt-5.5", compact=True, max_turns=1) + shell.agent = None + shell.api_key = "test-key" + shell.base_url = "http://localhost:6658/openai/v1" + shell.provider = "custom" + shell.api_mode = "codex_responses" + shell.max_tokens = None + + assert shell._init_agent( + model_override="gpt-5.5", + runtime_override={ + "api_key": "test-key", + "base_url": "http://localhost:6658/openai/v1", + "provider": "custom", + "api_mode": "codex_responses", + "max_output_tokens": 100000, + }, + ) is True + + assert captured["api_mode"] == "codex_responses" + assert captured["max_tokens"] == 100000 + + +def test_turn_route_signature_includes_runtime_output_cap(): + from tests.cli.test_cli_provider_resolution import _import_cli + + cli = _import_cli() + shell = cli.HermesCLI(model="gpt-5.5", compact=True, max_turns=1) + shell.api_key = "test-key" + shell.base_url = "http://localhost:6658/openai/v1" + shell.provider = "custom" + shell.api_mode = "codex_responses" + shell.acp_command = None + shell.acp_args = [] + shell._credential_pool = None + shell.max_tokens = 100000 + shell.service_tier = None + + route = shell._resolve_turn_agent_config("hi") + + assert route["runtime"]["max_tokens"] == 100000 + assert route["signature"][-1] == 100000 + + +def test_truncated_tool_retry_uses_configured_or_requested_cap(): + from agent.conversation_loop import _next_truncated_tool_call_cap + + agent = SimpleNamespace(max_tokens=100000) + assert _next_truncated_tool_call_cap(agent, {"max_completion_tokens": 4096}, 1) == 100000 + + agent = SimpleNamespace(max_tokens=32000) + assert _next_truncated_tool_call_cap(agent, {"max_completion_tokens": 100000}, 1) == 100000 + + agent = SimpleNamespace(max_tokens=None) + assert _next_truncated_tool_call_cap(agent, {}, 1) == 32768