This commit is contained in:
Brais R 2026-04-24 22:53:38 +00:00 committed by GitHub
commit 0d6cc3bc70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 868 additions and 45 deletions

View file

@ -1303,13 +1303,47 @@ def _is_payment_error(exc: Exception) -> bool:
status = getattr(exc, "status_code", None)
if status == 402:
return True
err_lower = str(exc).lower()
parts = [str(exc).lower()]
body = getattr(exc, "body", None)
if isinstance(body, dict):
try:
parts.append(json.dumps(body, ensure_ascii=False).lower())
except Exception:
parts.append(str(body).lower())
err_obj = body.get("error", {}) if isinstance(body.get("error"), dict) else {}
body_msg = (err_obj.get("message") or body.get("message") or "").lower()
if body_msg:
parts.append(body_msg)
error_code = (err_obj.get("code") or err_obj.get("type") or body.get("code") or body.get("type") or "")
if error_code:
parts.append(str(error_code).lower())
metadata = err_obj.get("metadata", {}) if isinstance(err_obj, dict) else {}
raw_json = metadata.get("raw") if isinstance(metadata, dict) else None
if isinstance(raw_json, str) and raw_json.strip():
try:
inner = json.loads(raw_json)
if isinstance(inner, dict):
inner_err = inner.get("error", {}) if isinstance(inner.get("error"), dict) else {}
inner_msg = (inner_err.get("message") or inner.get("message") or "").lower()
if inner_msg:
parts.append(inner_msg)
except Exception:
parts.append(raw_json.lower())
elif body is not None:
parts.append(str(body).lower())
err_lower = " ".join(p for p in parts if p)
# OpenRouter and other providers include "credits" or "afford" in 402 bodies,
# but sometimes wrap them in 429 or other codes.
if status in (402, 429, None):
if any(kw in err_lower for kw in ("credits", "insufficient funds",
"can only afford", "billing",
"payment required")):
if any(kw in err_lower for kw in (
"credits", "insufficient funds", "insufficient balance",
"insufficient_balance", "insufficient_quota",
"can only afford", "billing",
"payment required", "payment_required",
"top up your credits",
)):
return True
return False

View file

@ -89,10 +89,13 @@ class ClassifiedError:
# Patterns that indicate billing exhaustion (not transient rate limit)
_BILLING_PATTERNS = [
"insufficient credits",
"insufficient balance",
"insufficient_balance",
"insufficient_quota",
"credit balance",
"credits have been exhausted",
"top up your credits",
"can only afford",
"payment required",
"billing hard limit",
"exceeded your current quota",
@ -589,6 +592,20 @@ def _classify_by_status(
)
if status_code == 429:
# Some providers surface billing exhaustion as 429 instead of 402 and may
# only expose the billing signal through a structured error code.
if error_code:
classified = _classify_by_error_code(error_code, error_msg, result_fn)
if classified is not None:
return classified
# Others embed the billing signal only in free-text messages.
if any(p in error_msg for p in _BILLING_PATTERNS):
return result_fn(
FailoverReason.billing,
retryable=False,
should_rotate_credential=True,
should_fallback=True,
)
# Already checked long_context_tier above; this is a normal rate limit
return result_fn(
FailoverReason.rate_limit,

View file

@ -923,6 +923,10 @@ DEFAULT_CONFIG = {
"domains": [],
"shared_files": [],
},
"tool_governance": {
"skill_allowed_tools": False,
"channel_tool_review": False,
},
},
"cron": {
@ -3763,6 +3767,8 @@ def show_config():
print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}")
print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}")
_print_tool_governance_section(config)
# Skill config
try:
@ -3821,6 +3827,78 @@ def edit_config():
subprocess.run([editor, str(config_path)])
def _print_tool_governance_section(config: dict, *, show_commands: bool = False) -> None:
print()
print(color("◆ Tool Governance", Colors.CYAN, Colors.BOLD))
tool_governance = config.get('security', {}).get('tool_governance', {})
skill_allowed_tools = tool_governance.get('skill_allowed_tools', False)
channel_tool_review = tool_governance.get('channel_tool_review', False)
print(f" skill_allowed_tools: {'on' if skill_allowed_tools else color('off', Colors.DIM)}")
print(f" channel_tool_review: {'on' if channel_tool_review else color('off', Colors.DIM)}")
print(
" security.tool_governance.skill_allowed_tools"
f" {'on' if skill_allowed_tools else color('off', Colors.DIM)}"
)
print(
" security.tool_governance.channel_tool_review"
f" {'on' if channel_tool_review else color('off', Colors.DIM)}"
)
if show_commands:
print()
print(" Presets:")
print(" hermes config governance --preset messaging-safe")
print(" hermes config governance --preset skill-safe")
print(" hermes config governance --preset balanced")
print()
print(" Enable with:")
print(" hermes config set security.tool_governance.skill_allowed_tools true")
print(" hermes config set security.tool_governance.channel_tool_review true")
print()
print(" Disable with:")
print(" hermes config set security.tool_governance.skill_allowed_tools false")
print(" hermes config set security.tool_governance.channel_tool_review false")
def _apply_tool_governance_toggles(args) -> None:
enable_all = bool(getattr(args, 'enable_all', False))
disable_all = bool(getattr(args, 'disable_all', False))
enable_skill_allowed_tools = bool(getattr(args, 'enable_skill_allowed_tools', False))
disable_skill_allowed_tools = bool(getattr(args, 'disable_skill_allowed_tools', False))
enable_channel_review = bool(getattr(args, 'enable_channel_review', False))
disable_channel_review = bool(getattr(args, 'disable_channel_review', False))
preset = getattr(args, 'preset', None)
if preset == 'messaging-safe':
enable_channel_review = True
disable_skill_allowed_tools = True
elif preset == 'skill-safe':
enable_skill_allowed_tools = True
disable_channel_review = True
elif preset == 'balanced':
enable_skill_allowed_tools = True
enable_channel_review = True
if enable_all:
enable_skill_allowed_tools = True
enable_channel_review = True
if disable_all:
disable_skill_allowed_tools = True
disable_channel_review = True
if enable_skill_allowed_tools:
set_config_value("security.tool_governance.skill_allowed_tools", "true")
elif disable_skill_allowed_tools:
set_config_value("security.tool_governance.skill_allowed_tools", "false")
if enable_channel_review:
set_config_value("security.tool_governance.channel_tool_review", "true")
elif disable_channel_review:
set_config_value("security.tool_governance.channel_tool_review", "false")
def set_config_value(key: str, value: str):
"""Set a configuration value."""
if is_managed():
@ -3931,6 +4009,8 @@ def config_command(args):
print("Examples:")
print(" hermes config set model anthropic/claude-sonnet-4")
print(" hermes config set terminal.backend docker")
print(" hermes config set security.tool_governance.skill_allowed_tools true")
print(" hermes config set security.tool_governance.channel_tool_review true")
print(" hermes config set OPENROUTER_API_KEY sk-or-...")
sys.exit(1)
set_config_value(key, value)
@ -4032,8 +4112,17 @@ def config_command(args):
print()
print(color(f" {len(missing_config)} new config option(s) available", Colors.YELLOW))
print(" Run 'hermes config migrate' to add them")
_print_tool_governance_section(load_config())
print()
elif subcmd == "governance":
print()
print(color("🛡 Tool Governance", Colors.CYAN, Colors.BOLD))
_apply_tool_governance_toggles(args)
_print_tool_governance_section(load_config(), show_commands=True)
print()
else:
print(f"Unknown config command: {subcmd}")
@ -4043,6 +4132,7 @@ def config_command(args):
print(" hermes config edit Open config in editor")
print(" hermes config set <key> <value> Set a config value")
print(" hermes config check Check for missing/outdated config")
print(" hermes config governance Show tool governance settings")
print(" hermes config migrate Update config with new options")
print(" hermes config path Show config file path")
print(" hermes config env-path Show .env file path")

View file

@ -130,6 +130,27 @@ def check_info(text: str):
print(f" {color('', Colors.CYAN)} {text}")
def _report_tool_governance(config: dict) -> None:
tool_governance = (config.get("security") or {}).get("tool_governance") or {}
print()
print(color("◆ Tool Governance", Colors.CYAN, Colors.BOLD))
skill_allowed_tools = tool_governance.get("skill_allowed_tools", False)
channel_tool_review = tool_governance.get("channel_tool_review", False)
check_ok(
f"Skill allowed-tools enforcement: {'enabled' if skill_allowed_tools else 'disabled'}",
"(config.yaml)",
)
check_ok(
f"Channel tool review: {'enabled' if channel_tool_review else 'disabled'}",
"(config.yaml)",
)
if not skill_allowed_tools and not channel_tool_review:
check_info("Both governance policies are advisory/off by default until explicitly enabled")
def _check_gateway_service_linger(issues: list[str]) -> None:
"""Warn when a systemd user gateway service will stop after logout."""
try:
@ -465,6 +486,14 @@ def run_doctor(args):
except Exception:
pass
try:
import yaml
with open(config_path, encoding="utf-8") as f:
raw_config = yaml.safe_load(f) or {}
_report_tool_governance(raw_config)
except Exception:
pass
# =========================================================================
# Check: Auth providers
# =========================================================================
@ -937,9 +966,10 @@ def run_doctor(args):
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
# MiniMax: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", True),
# MiniMax providers do not expose a shared /models endpoint; skip live health
# probes here and rely on runtime requests / static catalog validation instead.
("MiniMax", ("MINIMAX_API_KEY",), None, "MINIMAX_BASE_URL", False),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), None, "MINIMAX_CN_BASE_URL", False),
("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),

View file

@ -7256,12 +7256,12 @@ For more help on a command:
"setup",
help="Interactive setup wizard",
description="Configure Hermes Agent with an interactive wizard. "
"Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent",
"Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent|security",
)
setup_parser.add_argument(
"section",
nargs="?",
choices=["model", "tts", "terminal", "gateway", "tools", "agent"],
choices=["model", "tts", "terminal", "gateway", "tools", "agent", "security"],
default=None,
help="Run a specific setup section instead of the full wizard",
)
@ -7823,6 +7823,36 @@ Examples:
# config check
config_subparsers.add_parser("check", help="Check for missing/outdated config")
# config governance
config_governance = config_subparsers.add_parser("governance", help="Show tool governance settings")
config_governance.add_argument("--enable-all", action="store_true", help="Enable all tool governance policies")
config_governance.add_argument("--disable-all", action="store_true", help="Disable all tool governance policies")
config_governance.add_argument(
"--preset",
choices=["messaging-safe", "skill-safe", "balanced"],
help="Apply a governance preset",
)
config_governance.add_argument(
"--enable-skill-allowed-tools",
action="store_true",
help="Enable skill allowed-tools enforcement",
)
config_governance.add_argument(
"--disable-skill-allowed-tools",
action="store_true",
help="Disable skill allowed-tools enforcement",
)
config_governance.add_argument(
"--enable-channel-review",
action="store_true",
help="Enable channel tool review",
)
config_governance.add_argument(
"--disable-channel-review",
action="store_true",
help="Disable channel tool review",
)
# config migrate
config_subparsers.add_parser("migrate", help="Update config with new options")

View file

@ -596,6 +596,8 @@ def _print_setup_summary(config: dict, hermes_home):
)
print(f" {color('hermes config set <key> <value>', Colors.GREEN)}")
print(" Set a specific value")
print(" e.g. security.tool_governance.skill_allowed_tools true")
print(" security.tool_governance.channel_tool_review true")
print()
print(" Or edit the files directly:")
print(f" {color(f'nano {get_config_path()}', Colors.DIM)}")
@ -612,6 +614,49 @@ def _print_setup_summary(config: dict, hermes_home):
print()
def _prompt_tool_governance_defaults(config: dict) -> None:
"""Offer optional low-risk governance defaults during setup."""
print()
print_header("Tool Governance (optional)")
print_info("Optional low-risk guardrails for messaging and skill-driven sessions.")
print_info("Defaults stay off unless you enable them here.")
print()
if not prompt_yes_no("Configure tool governance defaults now?", False):
return
choice = prompt_choice(
"Which governance preset would you like?",
[
"Messaging-safe — enable channel tool review",
"Skill-safe — enforce skill allowed-tools",
"Enable both low-risk policies",
],
0,
)
tool_governance = config.setdefault("security", {}).setdefault("tool_governance", {})
tool_governance.setdefault("skill_allowed_tools", False)
tool_governance.setdefault("channel_tool_review", False)
if choice == 0:
tool_governance["channel_tool_review"] = True
tool_governance["skill_allowed_tools"] = False
elif choice == 1:
tool_governance["skill_allowed_tools"] = True
tool_governance["channel_tool_review"] = False
elif choice == 2:
tool_governance["channel_tool_review"] = True
tool_governance["skill_allowed_tools"] = True
print_success("Tool governance defaults updated")
print_info("You can change these later with 'hermes config governance'.")
def setup_security(config: dict):
"""Configure security and governance defaults."""
_prompt_tool_governance_defaults(config)
def _prompt_container_resources(config: dict):
"""Prompt for container resource settings (Docker, Singularity, Modal, Daytona)."""
terminal = config.setdefault("terminal", {})
@ -2861,6 +2906,7 @@ SETUP_SECTIONS = [
("gateway", "Messaging Platforms (Gateway)", setup_gateway),
("tools", "Tools", setup_tools),
("agent", "Agent Settings", setup_agent_settings),
("security", "Security & Governance", setup_security),
]
# The returning-user menu intentionally omits standalone TTS because model setup
@ -2872,6 +2918,7 @@ RETURNING_USER_MENU_SECTION_KEYS = [
"gateway",
"tools",
"agent",
"security",
]
@ -2886,6 +2933,7 @@ def run_setup_wizard(args):
hermes setup gateway just messaging platforms
hermes setup tools just tool configuration
hermes setup agent just agent settings
hermes setup security security/governance defaults
"""
from hermes_cli.config import is_managed, managed_error
if is_managed():
@ -3003,6 +3051,7 @@ def run_setup_wizard(args):
"Messaging Platforms (Gateway)",
"Tools",
"Agent Settings",
"Security & Governance",
"Exit",
]
choice = prompt_choice("What would you like to do?", menu_choices, 0)
@ -3014,10 +3063,10 @@ def run_setup_wizard(args):
elif choice == 1:
# Full setup — fall through to run all sections
pass
elif choice == 7:
elif choice == 8:
print_info("Exiting. Run 'hermes setup' again when ready.")
return
elif 2 <= choice <= 6:
elif 2 <= choice <= 7:
# Individual section — map by key, not by position.
# SETUP_SECTIONS includes TTS but the returning-user menu skips it,
# so positional indexing (choice - 2) would dispatch the wrong section.
@ -3082,6 +3131,8 @@ def run_setup_wizard(args):
if not (migration_ran and _skip_configured_section(config, "tools", "Tools")):
setup_tools(config, first_install=not is_existing)
_prompt_tool_governance_defaults(config)
# Save and show summary
save_config(config)
_print_setup_summary(config, hermes_home)
@ -3149,6 +3200,8 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
setup_gateway(config)
save_config(config)
_prompt_tool_governance_defaults(config)
print()
print_success("Setup complete! You're ready to go.")
print()

View file

@ -82,6 +82,8 @@ TIPS = [
"hermes config set KEY VALUE auto-routes secrets to .env and everything else to config.yaml.",
"hermes config edit opens config.yaml in your default editor.",
"hermes config check scans for missing or stale configuration options.",
"hermes config governance shows tool-governance status and supports quick presets like --preset messaging-safe.",
"hermes setup security opens the dedicated setup section for tool-governance defaults.",
"hermes sessions browse opens an interactive session picker with search.",
"hermes sessions stats shows session counts by platform and database size.",
"hermes sessions prune --older-than 30 cleans up old sessions.",
@ -127,7 +129,7 @@ TIPS = [
# --- Tools & Capabilities ---
"execute_code runs Python scripts that call Hermes tools programmatically — results stay out of context.",
"delegate_task spawns up to 3 concurrent sub-agents by default (delegation.max_concurrent_children) with isolated contexts for parallel work.",
"delegate_task spawns up to 3 sub-agents by default (see delegation.max_concurrent_children) with isolated contexts for parallel work.",
"web_extract works on PDF URLs — pass any PDF link and it converts to markdown.",
"search_files is ripgrep-backed and faster than grep — use it instead of terminal grep.",
"patch uses 9 fuzzy matching strategies so minor whitespace differences won't break edits.",

View file

@ -2292,6 +2292,36 @@ class AIAgent:
and getattr(self, "platform", "") == "cli"
)
def _should_suppress_gateway_lifecycle_status(self, message: str) -> bool:
"""Return True when a lifecycle status is too noisy for chat gateways.
CLI users still see every lifecycle event through ``_vprint``. Messaging
platforms, however, should not be flooded with internal retry/fallback
chatter when a final assistant response will summarize the failure.
"""
raw_platform = getattr(self, "platform", "") or ""
platform = getattr(raw_platform, "value", raw_platform)
platform = str(platform).strip().lower()
if not platform or platform == "cli":
return False
text = (message or "").strip()
if not text:
return False
noisy_prefixes = (
"⚠️ Rate limited — switching to fallback provider...",
"💸 Provider credits/balance exhausted — switching to fallback provider...",
"⚠️ Empty/malformed response — switching to fallback...",
"🔄 Primary model failed — switching to fallback:",
"⏱️ Rate limit reached. Waiting",
"❌ Rate limited after ",
)
if any(text.startswith(prefix) for prefix in noisy_prefixes):
return True
if text.startswith("⚠️ Max retries (") and "trying fallback" in text.lower():
return True
return False
def _emit_status(self, message: str) -> None:
"""Emit a lifecycle status message to both CLI and gateway channels.
@ -2307,6 +2337,8 @@ class AIAgent:
except Exception:
pass
if self.status_callback:
if self._should_suppress_gateway_lifecycle_status(message):
return
try:
self.status_callback("lifecycle", message)
except Exception:
@ -2544,19 +2576,20 @@ class AIAgent:
return 300.0, True
def _compute_non_stream_stale_timeout(self, messages: list[dict[str, Any]]) -> float:
def _compute_non_stream_stale_timeout(self, api_kwargs: dict[str, Any]) -> float:
"""Compute the effective non-stream stale timeout for this request."""
stale_base, uses_implicit_default = self._resolved_api_call_stale_timeout_base()
base_url = getattr(self, "_base_url", None) or self.base_url or ""
if uses_implicit_default and base_url and is_local_endpoint(base_url):
return float("inf")
est_tokens = sum(len(str(v)) for v in messages) // 4
if est_tokens > 100_000:
return max(stale_base, 600.0)
if est_tokens > 50_000:
return max(stale_base, 450.0)
return stale_base
est_tokens = self._estimate_request_context_tokens(api_kwargs)
return self._scale_stale_timeout_for_context(
stale_base,
est_tokens,
medium_timeout=450.0,
large_timeout=600.0,
)
def _is_openrouter_url(self) -> bool:
"""Return True when the base URL targets OpenRouter."""
@ -5634,6 +5667,36 @@ class AIAgent:
timeout=get_provider_request_timeout(self.provider, self.model),
)
@staticmethod
def _rough_payload_chars(value: Any) -> int:
if value is None:
return 0
try:
return len(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
except Exception:
return len(str(value))
def _estimate_request_context_tokens(self, api_kwargs: dict) -> int:
"""Roughly estimate request size across chat-completions and Responses payloads."""
total_chars = 0
for key in ("system", "messages", "input", "instructions", "tools"):
total_chars += self._rough_payload_chars(api_kwargs.get(key))
return (total_chars + 3) // 4 if total_chars > 0 else 0
@staticmethod
def _scale_stale_timeout_for_context(
base_timeout: float,
est_tokens: int,
*,
medium_timeout: float,
large_timeout: float,
) -> float:
if est_tokens > 100_000:
return max(base_timeout, large_timeout)
if est_tokens > 50_000:
return max(base_timeout, medium_timeout)
return base_timeout
def _interruptible_api_call(self, api_kwargs: dict):
"""
Run the API call in a background thread so the main conversation loop
@ -5701,9 +5764,7 @@ class AIAgent:
# httpx timeout (default 1800s) with zero feedback. The stale
# detector kills the connection early so the main retry loop can
# apply richer recovery (credential rotation, provider fallback).
_stale_timeout = self._compute_non_stream_stale_timeout(
api_kwargs.get("messages", [])
)
_stale_timeout = self._compute_non_stream_stale_timeout(api_kwargs)
_call_start = time.time()
self._touch_activity("waiting for non-streaming API response")
@ -5727,7 +5788,7 @@ class AIAgent:
# arrives within the configured timeout.
_elapsed = time.time() - _call_start
if _elapsed > _stale_timeout:
_est_ctx = sum(len(str(v)) for v in api_kwargs.get("messages", [])) // 4
_est_ctx = self._estimate_request_context_tokens(api_kwargs)
logger.warning(
"Non-streaming API call stale for %.0fs (threshold %.0fs). "
"model=%s context=~%s tokens. Killing connection.",
@ -6561,13 +6622,13 @@ class AIAgent:
# when the context is large. Without this, the stale detector kills
# healthy connections during the model's thinking phase, producing
# spurious RemoteProtocolError ("peer closed connection").
_est_tokens = sum(len(str(v)) for v in api_kwargs.get("messages", [])) // 4
if _est_tokens > 100_000:
_stream_stale_timeout = max(_stream_stale_timeout_base, 300.0)
elif _est_tokens > 50_000:
_stream_stale_timeout = max(_stream_stale_timeout_base, 240.0)
else:
_stream_stale_timeout = _stream_stale_timeout_base
_est_tokens = self._estimate_request_context_tokens(api_kwargs)
_stream_stale_timeout = self._scale_stale_timeout_for_context(
_stream_stale_timeout_base,
_est_tokens,
medium_timeout=240.0,
large_timeout=300.0,
)
t = threading.Thread(target=_call, daemon=True)
t.start()
@ -6597,7 +6658,7 @@ class AIAgent:
# inner retry loop can start a fresh connection.
_stale_elapsed = time.time() - last_chunk_time["t"]
if _stale_elapsed > _stream_stale_timeout:
_est_ctx = sum(len(str(v)) for v in api_kwargs.get("messages", [])) // 4
_est_ctx = self._estimate_request_context_tokens(api_kwargs)
logger.warning(
"Stream stale for %.0fs (threshold %.0fs) — no chunks received. "
"model=%s context=~%s tokens. Killing connection.",
@ -11081,7 +11142,10 @@ class AIAgent:
self._credential_pool
)
if not pool_may_recover:
self._emit_status("⚠️ Rate limited — switching to fallback provider...")
if classified.reason == FailoverReason.billing:
self._emit_status("💸 Provider credits/balance exhausted — switching to fallback provider...")
else:
self._emit_status("⚠️ Rate limited — switching to fallback provider...")
if self._try_activate_fallback(reason=classified.reason):
retry_count = 0
compression_attempts = 0
@ -11363,7 +11427,6 @@ class AIAgent:
and not classified.should_compress
and classified.reason not in (
FailoverReason.rate_limit,
FailoverReason.billing,
FailoverReason.overloaded,
FailoverReason.context_overflow,
FailoverReason.payload_too_large,
@ -11394,7 +11457,7 @@ class AIAgent:
self._vprint(f"{self.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True)
self._vprint(f"{self.log_prefix} 🌐 Endpoint: {_base}", force=True)
# Actionable guidance for common auth errors
if classified.is_auth or classified.reason == FailoverReason.billing:
if classified.is_auth:
if _provider == "openai-codex" and status_code == 401:
self._vprint(f"{self.log_prefix} 💡 Codex OAuth token was rejected (HTTP 401). Your token may have been", force=True)
self._vprint(f"{self.log_prefix} refreshed by another client (Codex CLI, VS Code). To fix:", force=True)
@ -11406,6 +11469,12 @@ class AIAgent:
self._vprint(f"{self.log_prefix} • Does your account have access to {_model}?", force=True)
if base_url_host_matches(str(_base), "openrouter.ai"):
self._vprint(f"{self.log_prefix} • Check credits: https://openrouter.ai/settings/credits", force=True)
elif classified.reason == FailoverReason.billing:
self._vprint(f"{self.log_prefix} 💡 Provider balance/credits appear exhausted for this request.", force=True)
if "openrouter" in str(_base).lower():
self._vprint(f"{self.log_prefix} • Top up credits: https://openrouter.ai/settings/credits", force=True)
elif _provider == "minimax":
self._vprint(f"{self.log_prefix} • Check MiniMax account balance / billing before retrying.", force=True)
else:
self._vprint(f"{self.log_prefix} 💡 This type of error won't be fixed by retrying.", force=True)
logging.error(f"{self.log_prefix}Non-retryable client error: {api_error}")

View file

@ -664,6 +664,23 @@ class TestIsPaymentError:
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_with_insufficient_balance_message(self):
exc = Exception("HTTP 429: insufficient balance (1008)")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_with_billing_message_in_structured_body(self):
exc = Exception("provider error")
exc.status_code = 429
exc.body = {"error": {"message": "insufficient balance (1008)"}}
assert _is_payment_error(exc) is True
def test_429_with_billing_error_code_in_structured_body(self):
exc = Exception("provider error")
exc.status_code = 429
exc.body = {"error": {"code": "payment_required", "message": "provider error"}}
assert _is_payment_error(exc) is True
def test_429_without_credits_message_is_not_payment(self):
"""Normal rate limits should NOT be treated as payment errors."""
exc = Exception("Rate limit exceeded, try again in 2 seconds")

View file

@ -250,6 +250,24 @@ class TestClassifyApiError:
assert result.reason == FailoverReason.rate_limit
assert result.should_fallback is True
def test_429_insufficient_balance_classified_as_billing(self):
e = MockAPIError("HTTP 429: insufficient balance (1008)", status_code=429)
result = classify_api_error(e, provider="minimax")
assert result.reason == FailoverReason.billing
assert result.retryable is False
assert result.should_rotate_credential is True
assert result.should_fallback is True
def test_429_payment_required_error_code_classified_as_billing(self):
e = MockAPIError(
"provider error",
status_code=429,
body={"error": {"code": "payment_required", "message": "provider error"}},
)
result = classify_api_error(e, provider="openrouter")
assert result.reason == FailoverReason.billing
assert result.retryable is False
def test_alibaba_rate_increased_too_quickly(self):
"""Alibaba/DashScope returns a unique throttling message.

View file

@ -399,22 +399,23 @@ def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch,
@pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"])
def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url):
def _run_doctor_with_single_api_key_provider(monkeypatch, tmp_path, env_var, env_value, provider_name, base_env=None, base_url=None):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
(home / ".env").write_text("OPENCODE_GO_API_KEY=***\n", encoding="utf-8")
(home / ".env").write_text(f"{env_var}=***\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test")
if base_url:
monkeypatch.setenv("OPENCODE_GO_BASE_URL", base_url)
else:
monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
monkeypatch.setenv(env_var, env_value)
if base_env:
if base_url:
monkeypatch.setenv(base_env, base_url)
else:
monkeypatch.delenv(base_env, raising=False)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
@ -438,15 +439,89 @@ def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path
import httpx
monkeypatch.setattr(httpx, "get", fake_get)
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert any(
"OpenCode Go" in line and "(key configured)" in line
provider_name in line and "(key configured)" in line
for line in out.splitlines()
)
return out, calls
@pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"])
def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url):
_, calls = _run_doctor_with_single_api_key_provider(
monkeypatch,
tmp_path,
env_var="OPENCODE_GO_API_KEY",
env_value="sk-test",
provider_name="OpenCode Go",
base_env="OPENCODE_GO_BASE_URL",
base_url=base_url,
)
assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls)
assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls)
@pytest.mark.parametrize("base_url", [None, "https://api.minimax.io/anthropic"])
def test_run_doctor_minimax_skips_invalid_models_probe(monkeypatch, tmp_path, base_url):
_, calls = _run_doctor_with_single_api_key_provider(
monkeypatch,
tmp_path,
env_var="MINIMAX_API_KEY",
env_value="sk-test",
provider_name="MiniMax",
base_env="MINIMAX_BASE_URL",
base_url=base_url,
)
assert not any("minimax" in url.lower() and "models" in url.lower() for url, _, _ in calls)
def test_run_doctor_reports_tool_governance_settings(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"""
memory: {}
security:
tool_governance:
skill_allowed_tools: true
channel_tool_review: false
""".strip()
+ "\n",
encoding="utf-8",
)
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
except Exception:
pass
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "Tool Governance" in out
assert "Skill allowed-tools enforcement: enabled" in out
assert "Channel tool review: disabled" in out

View file

@ -2,6 +2,7 @@
import os
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
import pytest
@ -40,9 +41,154 @@ def test_show_config_marks_placeholders(tmp_path, capsys):
assert "hermes config set <key> <value>" in out
def test_show_config_surfaces_tool_governance_settings(tmp_path, capsys):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
show_config()
out = capsys.readouterr().out
assert "Tool Governance" in out
assert "security.tool_governance.skill_allowed_tools" in out
assert "security.tool_governance.channel_tool_review" in out
def test_setup_summary_marks_placeholders(tmp_path, capsys):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
_print_setup_summary({"tts": {"provider": "edge"}}, tmp_path)
out = capsys.readouterr().out
assert "hermes config set <key> <value>" in out
def test_setup_summary_mentions_tool_governance_examples(tmp_path, capsys):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
_print_setup_summary({"tts": {"provider": "edge"}}, tmp_path)
out = capsys.readouterr().out
assert "security.tool_governance.skill_allowed_tools" in out
assert "security.tool_governance.channel_tool_review" in out
def test_config_check_surfaces_tool_governance_settings(tmp_path, capsys):
config_path = Path(tmp_path) / "config.yaml"
config_path.write_text(
"""
security:
tool_governance:
skill_allowed_tools: true
channel_tool_review: false
""".strip()
+ "\n",
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config_command(Namespace(config_command="check"))
out = capsys.readouterr().out
assert "Tool Governance" in out
assert "skill_allowed_tools: on" in out
assert "channel_tool_review: off" in out
def test_config_governance_command_shows_focus_view(tmp_path, capsys):
config_path = Path(tmp_path) / "config.yaml"
config_path.write_text(
"""
security:
tool_governance:
skill_allowed_tools: false
channel_tool_review: true
""".strip()
+ "\n",
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config_command(Namespace(config_command="governance"))
out = capsys.readouterr().out
assert "Tool Governance" in out
assert "skill_allowed_tools: off" in out
assert "channel_tool_review: on" in out
assert "hermes config set security.tool_governance.skill_allowed_tools true" in out
assert "hermes config set security.tool_governance.channel_tool_review true" in out
def test_config_governance_enable_all_updates_config(tmp_path, capsys):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config_command(
Namespace(
config_command="governance",
enable_all=True,
disable_all=False,
enable_skill_allowed_tools=False,
disable_skill_allowed_tools=False,
enable_channel_review=False,
disable_channel_review=False,
)
)
out = capsys.readouterr().out
saved = (Path(tmp_path) / "config.yaml").read_text(encoding="utf-8")
assert "skill_allowed_tools: true" in saved
assert "channel_tool_review: true" in saved
assert "skill_allowed_tools: on" in out
assert "channel_tool_review: on" in out
def test_config_governance_can_toggle_individual_policy(tmp_path, capsys):
config_path = Path(tmp_path) / "config.yaml"
config_path.write_text(
"""
security:
tool_governance:
skill_allowed_tools: true
channel_tool_review: true
""".strip()
+ "\n",
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config_command(
Namespace(
config_command="governance",
enable_all=False,
disable_all=False,
enable_skill_allowed_tools=False,
disable_skill_allowed_tools=True,
enable_channel_review=False,
disable_channel_review=False,
preset=None,
)
)
out = capsys.readouterr().out
saved = config_path.read_text(encoding="utf-8")
assert "skill_allowed_tools: false" in saved
assert "channel_tool_review: true" in saved
assert "skill_allowed_tools: off" in out
assert "channel_tool_review: on" in out
def test_config_governance_preset_messaging_safe_updates_config(tmp_path, capsys):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config_command(
Namespace(
config_command="governance",
enable_all=False,
disable_all=False,
enable_skill_allowed_tools=False,
disable_skill_allowed_tools=False,
enable_channel_review=False,
disable_channel_review=False,
preset="messaging-safe",
)
)
out = capsys.readouterr().out
saved = (Path(tmp_path) / "config.yaml").read_text(encoding="utf-8")
assert "skill_allowed_tools: false" in saved
assert "channel_tool_review: true" in saved
assert "skill_allowed_tools: off" in out
assert "channel_tool_review: on" in out

View file

@ -2,6 +2,7 @@
import json
import sys
import types
from argparse import Namespace
import pytest
@ -223,6 +224,86 @@ def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys):
assert "restart" in out.lower()
def test_prompt_tool_governance_defaults_can_enable_both(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config = load_config()
yes_no_answers = iter([True])
choice_answers = iter([2])
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: next(yes_no_answers))
monkeypatch.setattr(setup_mod, "prompt_choice", lambda *args, **kwargs: next(choice_answers))
setup_mod._prompt_tool_governance_defaults(config)
assert config["security"]["tool_governance"]["skill_allowed_tools"] is True
assert config["security"]["tool_governance"]["channel_tool_review"] is True
def test_run_first_time_quick_setup_offers_tool_governance(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config = load_config()
calls = []
monkeypatch.setattr(setup_mod, "setup_model_provider", lambda cfg, quick=True: calls.append(("model", quick)))
monkeypatch.setattr(setup_mod, "setup_gateway", lambda cfg: calls.append(("gateway", None)))
monkeypatch.setattr(setup_mod, "_prompt_tool_governance_defaults", lambda cfg: calls.append(("governance", None)))
monkeypatch.setattr(setup_mod, "_print_setup_summary", lambda cfg, home: calls.append(("summary", None)))
monkeypatch.setattr(setup_mod, "_offer_launch_chat", lambda: calls.append(("chat", None)))
monkeypatch.setattr(setup_mod, "prompt_choice", lambda *args, **kwargs: 1)
setup_mod._run_first_time_quick_setup(config, tmp_path, is_existing=False)
assert ("governance", None) in calls
def test_run_setup_wizard_full_setup_offers_tool_governance(tmp_path, monkeypatch):
args = Namespace(non_interactive=False, section=None, reset=False)
config = load_config()
calls = []
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(setup_mod, "ensure_hermes_home", lambda: None)
monkeypatch.setattr(setup_mod, "load_config", lambda: config)
monkeypatch.setattr(setup_mod, "get_hermes_home", lambda: tmp_path)
monkeypatch.setattr(setup_mod, "is_interactive_stdin", lambda: True)
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "")
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
monkeypatch.setattr(setup_mod, "_offer_openclaw_migration", lambda home: False)
choices = iter([1])
monkeypatch.setattr(setup_mod, "prompt_choice", lambda *args, **kwargs: next(choices))
monkeypatch.setattr(setup_mod, "setup_model_provider", lambda cfg: calls.append("model"))
monkeypatch.setattr(setup_mod, "setup_terminal_backend", lambda cfg: calls.append("terminal"))
monkeypatch.setattr(setup_mod, "setup_agent_settings", lambda cfg: calls.append("agent"))
monkeypatch.setattr(setup_mod, "setup_gateway", lambda cfg: calls.append("gateway"))
monkeypatch.setattr(setup_mod, "setup_tools", lambda cfg, first_install=True: calls.append("tools"))
monkeypatch.setattr(setup_mod, "_prompt_tool_governance_defaults", lambda cfg: calls.append("governance"))
monkeypatch.setattr(setup_mod, "_print_setup_summary", lambda cfg, home: calls.append("summary"))
monkeypatch.setattr(setup_mod, "_offer_launch_chat", lambda: calls.append("chat"))
monkeypatch.setattr(setup_mod, "save_config", lambda cfg: calls.append("save"))
setup_mod.run_setup_wizard(args)
assert "governance" in calls
def test_run_setup_wizard_security_section_dispatches_governance(tmp_path, monkeypatch):
args = Namespace(non_interactive=False, section="security", reset=False)
config = load_config()
calls = []
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(setup_mod, "ensure_hermes_home", lambda: None)
monkeypatch.setattr(setup_mod, "load_config", lambda: config)
monkeypatch.setattr(setup_mod, "get_hermes_home", lambda: tmp_path)
monkeypatch.setattr(setup_mod, "is_interactive_stdin", lambda: True)
monkeypatch.setattr(setup_mod, "_prompt_tool_governance_defaults", lambda cfg: calls.append("governance"))
monkeypatch.setattr(setup_mod, "save_config", lambda cfg: calls.append("save"))
setup_mod.run_setup_wizard(args)
assert calls == ["governance", "save"]
def test_setup_syncs_custom_provider_removal_from_disk(tmp_path, monkeypatch):
"""Removing the last custom provider in model setup should persist."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))

View file

@ -226,6 +226,7 @@ class TestNonInteractiveSetup:
"Messaging Platforms (Gateway)",
"Tools",
"Agent Settings",
"Security & Governance",
"Exit",
]
@ -244,3 +245,19 @@ class TestNonInteractiveSetup:
main_mod.main()
assert received["section"] == "tts"
def test_main_accepts_security_setup_section(self, monkeypatch):
"""`hermes setup security` should parse and dispatch like other setup sections."""
from hermes_cli import main as main_mod
received = {}
def fake_cmd_setup(args):
received["section"] = args.section
monkeypatch.setattr(main_mod, "cmd_setup", fake_cmd_setup)
monkeypatch.setattr("sys.argv", ["hermes", "setup", "security"])
main_mod.main()
assert received["section"] == "security"

View file

@ -285,7 +285,7 @@ def test_default_non_stream_stale_timeout_auto_disables_for_local_endpoints(monk
platform="cli",
)
assert agent._compute_non_stream_stale_timeout([]) == float("inf")
assert agent._compute_non_stream_stale_timeout({"messages": []}) == float("inf")
def test_explicit_non_stream_stale_timeout_is_honored_for_local_endpoints(monkeypatch, tmp_path):
@ -305,4 +305,4 @@ def test_explicit_non_stream_stale_timeout_is_honored_for_local_endpoints(monkey
platform="cli",
)
assert agent._compute_non_stream_stale_timeout([]) == 300.0
assert agent._compute_non_stream_stale_timeout({"messages": []}) == 300.0

View file

@ -70,3 +70,11 @@ class TestTipIntegrationInCLI:
# Should not contain nested/broken Rich tags
assert markup.count("[/]") == 1
assert "[dim #B8860B]" in markup
class TestToolGovernanceTips:
def test_has_config_governance_tip(self):
assert any("hermes config governance" in tip for tip in TIPS)
def test_has_setup_security_tip(self):
assert any("hermes setup security" in tip for tip in TIPS)

View file

@ -438,6 +438,80 @@ class TestBuildApiKwargsCodex:
assert "function" not in tools[0]
class TestEstimateRequestContextTokens:
def test_chat_completions_counts_messages_and_tools(self, monkeypatch):
agent = _make_agent(monkeypatch, "openrouter")
api_kwargs = {
"model": "anthropic/claude-sonnet-4-20250514",
"messages": [
{"role": "system", "content": "a" * 4000},
{"role": "user", "content": "b" * 4000},
],
"tools": _tool_defs("web_search", "terminal"),
}
est = agent._estimate_request_context_tokens(api_kwargs)
assert est > 2000
def test_codex_responses_counts_input_and_instructions(self, monkeypatch):
agent = _make_agent(
monkeypatch,
"openai-codex",
api_mode="codex_responses",
base_url="https://chatgpt.com/backend-api/codex",
)
api_kwargs = {
"model": "gpt-5.4",
"instructions": "system:" + ("y" * 10000),
"input": [{"role": "user", "content": "x" * 410000}],
"tools": [
{
"type": "function",
"name": "web_search",
"description": "search",
"parameters": {"type": "object", "properties": {}},
}
],
}
assert "messages" not in api_kwargs
est = agent._estimate_request_context_tokens(api_kwargs)
assert est > 100000
assert agent._scale_stale_timeout_for_context(
300.0,
est,
medium_timeout=450.0,
large_timeout=600.0,
) == 600.0
assert agent._scale_stale_timeout_for_context(
180.0,
est,
medium_timeout=240.0,
large_timeout=300.0,
) == 300.0
def test_anthropic_counts_top_level_system(self, monkeypatch):
agent = _make_agent(monkeypatch, "anthropic", api_mode="anthropic_messages")
api_kwargs = {
"model": "claude-sonnet-4.6",
"system": "policy:" + ("s" * 240000),
"messages": [{"role": "user", "content": "hi"}],
"tools": _tool_defs("web_search"),
}
est = agent._estimate_request_context_tokens(api_kwargs)
assert est > 60000
assert agent._scale_stale_timeout_for_context(
300.0,
est,
medium_timeout=450.0,
large_timeout=600.0,
) == 450.0
# ── Message conversion tests ────────────────────────────────────────────────
class TestChatMessagesToResponsesInput:

View file

@ -22,6 +22,7 @@ import run_agent
from run_agent import AIAgent
from agent.error_classifier import FailoverReason
from agent.prompt_builder import DEFAULT_AGENT_IDENTITY
from gateway.config import Platform
# ---------------------------------------------------------------------------
@ -1629,6 +1630,67 @@ class TestExecuteToolCalls:
assert "API call failed" not in output
assert "Rate limit reached" not in output
def test_emit_status_suppresses_noisy_gateway_fallback_messages(self, agent):
agent.platform = "telegram"
agent.status_callback = MagicMock()
with patch.object(agent, "_vprint") as mock_vprint:
agent._emit_status("⚠️ Rate limited — switching to fallback provider...")
mock_vprint.assert_called_once()
agent.status_callback.assert_not_called()
def test_emit_status_forwards_non_noisy_gateway_messages(self, agent):
agent.platform = "telegram"
agent.status_callback = MagicMock()
with patch.object(agent, "_vprint") as mock_vprint:
agent._emit_status("🗜️ Context reduced to 120,000 tokens (was 240,000), retrying...")
mock_vprint.assert_called_once()
agent.status_callback.assert_called_once_with("lifecycle", "🗜️ Context reduced to 120,000 tokens (was 240,000), retrying...")
def test_emit_status_handles_platform_enum_for_gateway_suppression(self, agent):
agent.platform = Platform.TELEGRAM
agent.status_callback = MagicMock()
with patch.object(agent, "_vprint") as mock_vprint:
agent._emit_status("⚠️ Rate limited — switching to fallback provider...")
mock_vprint.assert_called_once()
agent.status_callback.assert_not_called()
def test_billing_429_does_not_emit_rate_limit_backoff_status(self, agent):
class _Billing429Error(Exception):
status_code = 429
def __str__(self):
return "HTTP 429: insufficient balance (1008)"
agent._cached_system_prompt = "You are helpful."
agent._use_prompt_caching = False
agent.tool_delay = 0
agent.compression_enabled = False
agent.save_trajectories = False
agent.base_url = "https://api.minimax.io/v1/"
status_messages = []
with (
patch.object(agent, "_interruptible_api_call", side_effect=_Billing429Error()),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch.object(agent, "_emit_status", side_effect=status_messages.append),
patch("run_agent.time.sleep", return_value=None),
):
result = agent.run_conversation("hello")
assert result["completed"] is False
assert result["final_response"] is None
assert "insufficient balance (1008)" in result["error"]
assert result["api_calls"] == 1
assert not any("Rate limit reached. Waiting" in msg for msg in status_messages)
class TestConcurrentToolExecution:
"""Tests for _execute_tool_calls_concurrent and dispatch logic."""