Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 56 additions & 9 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion hermes_cli/cli_agent_setup_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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"),
),
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_output_truncation_runtime.py
Original file line number Diff line number Diff line change
@@ -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
Loading