Merge remote-tracking branch 'origin/main' into feat/keyless-web-search-fallback

# Conflicts:
#	hermes_cli/tools_config.py
This commit is contained in:
Teknium
2026-08-19 16:48:40 -07:00
75 changed files with 3828 additions and 418 deletions
+50
View File
@@ -490,6 +490,25 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An
agent.request_overrides = overrides
def _normalize_run_budget_seconds(value) -> Optional[float]:
"""Normalize a wall-clock run budget value to a positive float or None.
None / absent / non-numeric / non-positive all resolve to ``None``
(feature off) so a malformed config value can never activate the
deadline machinery, only leave it dormant. ``bool`` is rejected because
YAML ``true`` would otherwise become a 1-second budget.
"""
if value is None or isinstance(value, bool):
return None
try:
seconds = float(value)
except (TypeError, ValueError):
return None
if seconds != seconds or seconds <= 0: # NaN or non-positive
return None
return seconds
def init_agent(
agent,
base_url: str = None,
@@ -560,6 +579,7 @@ def init_agent(
session_db=None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None,
run_budget_seconds: Optional[float] = None,
fallback_model: Dict[str, Any] = None,
credential_pool=None,
checkpoints_enabled: bool = False,
@@ -972,6 +992,17 @@ def init_agent(
agent._budget_exhausted_injected = False
agent._budget_grace_call = False
# Optional wall-clock run budget (seconds per run_conversation turn).
# Explicit constructor arg wins; else resolved from config.yaml
# (agent.run_budget_seconds) further below. None = feature fully off:
# no clock reads, no injection, no stale-timeout capping.
agent.run_budget_seconds = _normalize_run_budget_seconds(run_budget_seconds)
# Wall-clock start of the CURRENT run_conversation turn. Set by
# turn_context.prepare_turn when a run budget is active; None otherwise.
agent._run_budget_started_at = None
# One-shot latch for the 80% wrap-up notice (reset each turn).
agent._run_budget_wrapup_injected = False
# Activity tracking — updated on each API call, tool execution, and
# stream chunk. Used by the gateway timeout handler to report what the
# agent was doing when it was killed, and by the "still working"
@@ -1896,6 +1927,20 @@ def init_agent(
_agent_section = {}
agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto")
# Execution-discipline guidance gate: "auto" (default — matches
# EXECUTION_GUIDANCE_MODELS), true (always), false (never), or list of
# model-name substrings. Independent of tool_use_enforcement — see
# agent/system_prompt.py for the injection gate.
agent._execution_guidance = _agent_section.get("execution_guidance", "auto")
# Wall-clock run budget from config (agent.run_budget_seconds) — only
# consulted when the constructor arg was not given. Absent/None/invalid
# keeps the feature fully off (zero behavior change in the default path).
if agent.run_budget_seconds is None:
agent.run_budget_seconds = _normalize_run_budget_seconds(
_agent_section.get("run_budget_seconds")
)
# Empty-response retry guard config (NS-503): additive
# ``agent.empty_response_guard`` subsection. Resolution is tolerant —
# a malformed section falls back to the schema defaults (guard on,
@@ -1912,6 +1957,11 @@ def init_agent(
# conversation loop's intent-ack block.
agent._intent_ack_continuation = _agent_section.get("intent_ack_continuation", "auto")
# Runtime anti-stall guards (identical-call loop-breaker notice on tool
# results + continue-intent extension of the empty-response recovery).
# Single boolean gate, default True. Notice-only — never blocks a call.
agent._stall_guards = bool(_agent_section.get("stall_guards", True))
# Universal task-completion guidance toggle. Default True. Surfaced
# as a separate flag from tool_use_enforcement because the guidance
# applies to ALL models, not just the model families enforcement
+31
View File
@@ -3863,6 +3863,37 @@ def looks_like_codex_intermediate_ack(
return user_targets_workspace or assistant_targets_workspace
# Conservative "trailing continue-intent" detector for the said-continue-but-
# stopped stall guard (agent.stall_guards). Matches only when the message TAIL
# announces an immediate next action ("Let me now…", "I will now…",
# "Next, I…"), which is the observed stall shape: the model narrates the next
# step and then ends the turn with no tool call. Kept deliberately narrow so
# ordinary answers that merely contain "I will" mid-sentence never trip it.
_TRAILING_CONTINUE_INTENT_RE = re.compile(
r"(?:\blet me now\b|\bi(?:['\u2019])?ll now\b|\bi will now\b"
r"|\bnow i(?:['\u2019]ll| will)\b|\bnext[,:] i\b)"
r"[^.!?\n]{0,100}[.:\u2026]?\s*$",
re.IGNORECASE,
)
# Content longer than this is a substantive reply, not a dangling ack.
_TRAILING_CONTINUE_INTENT_MAX_CHARS = 400
def trailing_continue_intent(text: str) -> bool:
"""Whether ``text`` is a short reply ENDING on an announced next action.
Used by the stall-guard extension of the intent-ack continuation path in
``agent.conversation_loop``: when a turn is about to end with this shape
(no tool calls, short content, trailing intent), the loop re-prompts via
the existing bounded continuation mechanism instead of stopping.
"""
t = (text or "").strip()
if not t or len(t) > _TRAILING_CONTINUE_INTENT_MAX_CHARS:
return False
return bool(_TRAILING_CONTINUE_INTENT_RE.search(t[-160:]))
def intent_ack_continuation_mode(agent) -> str:
"""Classify the resolved intent-ack continuation mode for this turn.
+6 -1
View File
@@ -1552,9 +1552,14 @@ class _CodexCompletionsAdapter:
# with a 400.
effort = reasoning_cfg.get("effort") or "medium"
# Codex backend rejects "minimal"; clamp to "low" to
# match the main-agent Codex transport behavior.
# match the main-agent Codex transport behavior. "ultra"
# is Hermes-internal ladder vocabulary with no wire
# equivalent anywhere on this API; cap it at "max"
# (same class as #89503).
if effort == "minimal":
effort = "low"
elif effort == "ultra":
effort = "max"
resp_kwargs["reasoning"] = {
"effort": effort,
"summary": "auto",
+92 -1
View File
@@ -107,6 +107,64 @@ logger = logging.getLogger(__name__)
_INTERRUPT_SCAFFOLD_MARKER = "[This response was interrupted by a user correction.]"
# One-time wrap-up notice appended when a wall-clock run budget crosses its
# 80% threshold (agent.run_budget_seconds / --run-budget). Mirrors the Codex
# CLI budget wrap-up template: stop new work, deliver from current state.
RUN_BUDGET_WRAPUP_NOTICE = (
"[SYSTEM NOTICE — run time budget nearly exhausted] "
"Run time budget nearly exhausted. Stop new discovery/verification work "
"now. Produce the required final deliverable (answer/JSON/summary) from "
"the state you already have, completing only mandatory writes."
)
def _maybe_inject_run_budget_wrapup(agent: Any, messages: List[Dict[str, Any]]) -> bool:
"""Inject the one-time wall-clock wrap-up notice when past 80% of budget.
Cache-safe delivery: the notice is appended to the NEWEST ``role:"tool"``
message (the same channel /steer uses) — no synthetic user message is
inserted mid-loop and no past context is rewritten, so role alternation
and the prompt-cache prefix survive. Latches ``_run_budget_wrapup_injected``
only on a successful append, so a first iteration without tool results
retries on the next iteration. Returns True when the notice was injected.
Dormant unless ``agent.run_budget_seconds`` is set AND the turn stamped
``_run_budget_started_at`` (see ``turn_context.prepare_conversation_turn``).
"""
budget = getattr(agent, "run_budget_seconds", None)
if not budget:
return False
if getattr(agent, "_run_budget_wrapup_injected", False):
return False
started = getattr(agent, "_run_budget_started_at", None)
if not started:
return False
if (time.time() - started) < 0.8 * float(budget):
return False
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if isinstance(msg, dict) and msg.get("role") == "tool":
existing = msg.get("content", "")
if isinstance(existing, str):
msg["content"] = existing + f"\n\n{RUN_BUDGET_WRAPUP_NOTICE}"
else:
# Multimodal content blocks — append a text block.
try:
blocks = list(existing) if existing else []
blocks.append({"type": "text", "text": RUN_BUDGET_WRAPUP_NOTICE})
msg["content"] = blocks
except Exception:
return False
agent._run_budget_wrapup_injected = True
logger.info(
"Run budget wrap-up notice injected (budget=%.0fs, elapsed=%.0fs)",
float(budget),
time.time() - started,
)
return True
return False
def _restore_user_after_reference_handoff(
messages: List[Dict[str, Any]], user_message: Any
) -> bool:
@@ -2041,6 +2099,15 @@ def run_conversation(
existing = getattr(agent, "_pending_steer", None)
agent._pending_steer = (existing + "\n" + _pre_api_steer) if existing else _pre_api_steer
# ── Wall-clock run-budget wrap-up notice ───────────────────────
# One-shot: when a run budget (agent.run_budget_seconds /
# --run-budget) is active and 80% of it has elapsed, ask the model
# to wrap up and deliver from the state it already has. Same
# cache-safe channel as /steer (appended to the newest tool
# result); dormant when no budget is set.
if getattr(agent, "run_budget_seconds", None):
_maybe_inject_run_budget_wrapup(agent, messages)
# Prepare messages for API call
# If we have an ephemeral system prompt, prepend it to the messages
# Note: Reasoning is embedded in content via <think> tags for trajectory storage.
@@ -7864,10 +7931,28 @@ def run_conversation(
from agent.agent_runtime_helpers import (
intent_ack_continuation_mode,
trailing_continue_intent,
)
_ack_mode = intent_ack_continuation_mode(agent)
if (
# Said-continue-but-stopped guard (agent.stall_guards): the
# model ended the turn with no tool calls but its short reply
# TAILS with an announced next action ("Let me now…",
# "I will now…"). Unlike the intent-ack detector below, this
# fires mid-task too (after tool results), which is exactly
# where eval traces show the stall. It reuses the SAME bounded
# continuation path and counter (max 2 per turn), so the
# alternation-safe interim-assistant + user-nudge mechanism —
# not a new parallel one — carries the recovery.
_stall_continue_intent = (
bool(getattr(agent, "_stall_guards", True))
and agent.valid_tool_names
and codex_ack_continuations < 2
and trailing_continue_intent(
agent._strip_think_blocks(final_response or "")
)
)
if _stall_continue_intent or (
_ack_mode != "off"
and agent.valid_tool_names
and codex_ack_continuations < 2
@@ -7878,6 +7963,12 @@ def run_conversation(
require_workspace=(_ack_mode == "codex_only"),
)
):
if _stall_continue_intent:
logger.info(
"Stall guard: turn ending on trailing continue-"
"intent with no tool calls — re-prompting to act "
"(%d/2)", codex_ack_continuations + 1,
)
codex_ack_continuations += 1
interim_msg = agent._build_assistant_message(assistant_message, "incomplete")
append_message(messages, interim_msg)
+12
View File
@@ -139,6 +139,18 @@ def get_active_provider() -> Optional[ImageGenProvider]:
except Exception as exc:
logger.debug("Could not read image_gen.provider from config: %s", exc)
# The managed "Nous Subscription" selection is serviced by the FAL
# plugin through the managed fal-queue gateway (the legacy FAL pipeline
# routes managed when the stored selection is "nous").
if configured:
try:
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
if configured.lower() == NOUS_MANAGED_PROVIDER:
configured = "fal"
except Exception: # pragma: no cover — helpers are in-repo
pass
with _lock:
snapshot = dict(_providers)
snapshot.update(_scoped_providers.get(hermes_home_key(), {}))
+52 -2
View File
@@ -358,6 +358,25 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = (
# Add new patterns here when a model family needs explicit steering.
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm", "qwen", "deepseek")
# Model name substrings whose sessions receive OPENAI_MODEL_EXECUTION_GUIDANCE
# (execution discipline: tool persistence, mandatory tool use for arithmetic,
# external-write read-back, count reconciliation, literal preservation,
# verification-gated completion) when agent.execution_guidance is "auto".
#
# gpt/codex/grok are the historical set; deepseek/kimi/qwen/glm/minimax/
# mimo/mistral were added after Composio agentic-eval traces showed the same
# failure modes on those families (financial math in prose, no read-back after
# external writes, identifier "repair", completeness claims despite count
# mismatches). GLM's tool-calls-as-plain-text stall (#53847) and MiMo (#41874)
# are covered here too. Gemini/Gemma are excluded — they get the more specific
# GOOGLE_MODEL_OPERATIONAL_GUIDANCE block instead. Claude is excluded because
# it does not exhibit these failure modes; users can opt any model in via
# config.yaml `agent.execution_guidance: true` or a substring list.
EXECUTION_GUIDANCE_MODELS = (
"gpt", "codex", "grok",
"deepseek", "kimi", "qwen", "glm", "minimax", "mimo", "mistral",
)
# Universal "finish the job" guidance — applied to ALL models, not gated
# by model family. Addresses two cross-model failure modes:
# 1. Stopping after a stub: writing a tiny file or running one command
@@ -438,13 +457,22 @@ PARALLEL_TOOL_CALL_GUIDANCE = (
# without tool calls, suggests workarounds instead of using existing tools,
# replies with plans/suggestions instead of executing). The body is
# family-agnostic; the OPENAI_ prefix reflects origin, not exclusivity.
#
# As of the Composio agentic-eval follow-up, the block is no longer fenced to
# gpt/codex/grok: eval traces showed DeepSeek/Kimi doing financial math in
# prose, skipping read-back verification after external writes, "repairing"
# malformed identifiers, and claiming completeness despite count mismatches —
# exactly the failure modes this block targets. The injection gate lives in
# agent/system_prompt.py and is controlled by config.yaml
# ``agent.execution_guidance`` (auto/true/false/list); "auto" matches the
# EXECUTION_GUIDANCE_MODELS substring tuple below.
OPENAI_MODEL_EXECUTION_GUIDANCE = (
"# Execution discipline\n"
"<tool_persistence>\n"
"- Use tools whenever they improve correctness, completeness, or grounding.\n"
"- Do not stop early when another tool call would materially improve the result.\n"
"- If a tool returns empty or partial results, retry with a different query or "
"strategy before giving up.\n"
"- If a tool returns empty, partial, or suspiciously narrow results, retry "
"with a broader or different query or strategy before concluding.\n"
"- Keep calling tools until: (1) the task is complete, AND (2) you have verified "
"the result.\n"
"</tool_persistence>\n"
@@ -487,8 +515,30 @@ OPENAI_MODEL_EXECUTION_GUIDANCE = (
"- Formatting: does the output match the requested format or schema?\n"
"- Safety: if the next step has side effects (file writes, commands, API calls), "
"confirm scope before executing.\n"
"- Completion: 'done' means every named acceptance criterion is verified — "
"never a plausible subset. Completing your plan is not itself the answer; "
"the requested output must appear in your response.\n"
"</verification>\n"
"\n"
"<external_state_verification>\n"
"- After any state-changing write to an external system (API call, message "
"post, record update), verify the effect by reading back the exact target "
"before claiming success — a successful tool call is not a successful task. "
"Do NOT re-verify internal file edits a tool already confirmed.\n"
"- Declared totals in responses (total, reply_count, has_more, '...N more') "
"are hard assertions. If your enumerated count disagrees, re-fetch or parse "
"programmatically — never finalize on 'go with what I have'.\n"
"- When building write payloads, set fields explicitly rather than relying "
"on provider defaults that could contradict intent.\n"
"</external_state_verification>\n"
"\n"
"<literal_preservation>\n"
"- Preserve identifiers, commands, and values exactly as given — never "
"'repair' or normalize a token that fails a stated format. A successful "
"lookup does not validate a malformed source token; validate format first, "
"then look up.\n"
"</literal_preservation>\n"
"\n"
"<missing_context>\n"
"- If required context is missing, do NOT guess or hallucinate an answer.\n"
"- Use the appropriate lookup tool when missing information is retrievable "
+31 -7
View File
@@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional
from agent.prompt_builder import (
DEFAULT_AGENT_IDENTITY,
EXECUTION_GUIDANCE_MODELS,
GOOGLE_MODEL_OPERATIONAL_GUIDANCE,
HERMES_AGENT_HELP_GUIDANCE,
KANBAN_GUIDANCE,
@@ -477,13 +478,36 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# paths, parallel tool calls, verify-before-edit, etc.)
if "gemini" in _model_lower or "gemma" in _model_lower:
stable_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE)
# OpenAI GPT/Codex execution discipline (tool persistence,
# prerequisite checks, verification, anti-hallucination).
# Also applied to xAI Grok — same failure modes (claims completion
# without tool calls, suggests workarounds instead of using
# existing tools, replies with plans instead of executing).
if "gpt" in _model_lower or "codex" in _model_lower or "grok" in _model_lower:
stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE)
# Execution-discipline guidance (tool persistence, mandatory tool use
# for arithmetic, external-write read-back, count reconciliation,
# literal preservation, verification-gated completion). Historically
# nested inside the tool-use-enforcement branch and fenced to
# gpt/codex/grok; now an independent gate so DeepSeek/Kimi/Qwen-class
# models receive it even when tool_use_enforcement is off. Controlled
# by config.yaml agent.execution_guidance:
# "auto" (default) — matches EXECUTION_GUIDANCE_MODELS
# true — always inject (all models)
# false — never inject
# list — custom model-name substrings to match
# Resolved once at session start keyed on the (fixed) model name, so
# the system prompt stays byte-stable for the life of the conversation.
if agent.valid_tool_names:
_exec_guidance = getattr(agent, "_execution_guidance", "auto")
_exec_inject = False
if _exec_guidance is True or (isinstance(_exec_guidance, str) and _exec_guidance.lower() in {"true", "always", "yes", "on"}):
_exec_inject = True
elif _exec_guidance is False or (isinstance(_exec_guidance, str) and _exec_guidance.lower() in {"false", "never", "no", "off"}):
_exec_inject = False
elif isinstance(_exec_guidance, list):
model_lower = (agent.model or "").lower()
_exec_inject = any(p.lower() in model_lower for p in _exec_guidance if isinstance(p, str))
else:
# "auto" or any unrecognised value — use hardcoded defaults
model_lower = (agent.model or "").lower()
_exec_inject = any(p in model_lower for p in EXECUTION_GUIDANCE_MODELS)
if _exec_inject:
stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE)
has_skills_tools = any(name in agent.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage'])
if has_skills_tools:
+71 -1
View File
@@ -557,7 +557,11 @@ def make_tool_result_message(
The outer list itself is rebuilt rather than returned by identity, so
callers should compare by value, not by ``is``.
"""
wrapped = _maybe_wrap_untrusted(name, content)
# Order matters: detect provider-side elision on the RAW content and
# append the notice first, THEN wrap — so the notice lives inside the
# untrusted block next to the data it describes, appended exactly once
# at construction time (cache-safe).
wrapped = _maybe_wrap_untrusted(name, _maybe_append_elision_notice(name, content))
message = stamp_message_timestamp({
"role": "tool",
"name": name,
@@ -608,6 +612,70 @@ def _is_untrusted_tool(name: Optional[str]) -> bool:
return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES)
# --- Upstream-elision detection --------------------------------------------
#
# Some MCP servers elide data SERVER-SIDE and mark the elision inside the
# payload itself (e.g. Composio: '...13 more items' inside a JSON array,
# '"has_more": true', 'Complete response was large (N tokens). Full data
# saved to sandbox in /mnt/files/...', 'data_preview' envelopes). Because the
# result looks structurally complete, models treat the visible slice as the
# whole dataset and falsely claim completeness. When one of these markers is
# present, we append ONE compact notice at result-construction time — before
# the message enters history, never mutated later, so prompt caching is safe.
# Conservative patterns only: each one is an explicit provider-side "there is
# more data than what you can see" signal, not a generic truncation heuristic.
_UPSTREAM_ELISION_PATTERNS = (
re.compile(r"\.\.\.\s*\d+\s+more\s+items?", re.IGNORECASE),
re.compile(r'"has_more"\s*:\s*true', re.IGNORECASE),
re.compile(r"saved to sandbox", re.IGNORECASE),
re.compile(r"data_preview", re.IGNORECASE),
)
# Results smaller than this can't meaningfully hide an elided enumeration —
# skip the scan entirely so tiny results pay nothing.
_ELISION_SCAN_MIN_CHARS = 1_000
# Bound the regex scan: markers appear near the elided structure, which for
# the payload sizes that matter (20-50K) is always inside the first 64KB.
_ELISION_SCAN_MAX_CHARS = 65_536
_UPSTREAM_ELISION_NOTICE = (
'\n[hermes note: this result contains provider-side elision markers '
'(e.g. "...N more items" / has_more:true). The data shown is INCOMPLETE '
'— page/fetch the remainder before treating any enumeration as complete.]'
)
def _detect_upstream_elision(content: Any) -> bool:
"""True when a string tool result carries provider-side elision markers.
Cheap and safe by construction: non-string content is never scanned,
results under ``_ELISION_SCAN_MIN_CHARS`` short-circuit, and the regex
scan is capped at the first ``_ELISION_SCAN_MAX_CHARS`` chars.
"""
if not isinstance(content, str):
return False
if len(content) < _ELISION_SCAN_MIN_CHARS:
return False
window = content[:_ELISION_SCAN_MAX_CHARS]
return any(p.search(window) for p in _UPSTREAM_ELISION_PATTERNS)
def _maybe_append_elision_notice(name: str, content: Any) -> Any:
"""Append the incompleteness notice to untrusted string results that
embed upstream elision markers. Returns ``content`` unchanged otherwise.
Runs on the RAW result before untrusted-wrapping so the notice sits with
the data it describes, and only at result-construction time (cache-safe).
"""
if not _is_untrusted_tool(name):
return content
if _detect_upstream_elision(content):
return content + _UPSTREAM_ELISION_NOTICE
return content
def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]:
"""Classify textual attacker-controlled output without retaining a copy.
@@ -729,5 +797,7 @@ __all__ = [
"_extract_landed_file_mutation_paths",
"_extract_error_preview",
"_trajectory_normalize_msg",
"_detect_upstream_elision",
"_maybe_append_elision_notice",
"make_tool_result_message",
]
+4 -1
View File
@@ -87,7 +87,10 @@ def _budget_for_agent(agent) -> BudgetConfig:
"""
try:
ctx = getattr(getattr(agent, "context_compressor", None), "context_length", None)
return budget_for_context_window(int(ctx)) if ctx else DEFAULT_BUDGET
# budget_for_context_window(None) (rather than DEFAULT_BUDGET) so the
# config-driven MCP threshold override still applies when the context
# length isn't resolvable.
return budget_for_context_window(int(ctx) if ctx else None)
except Exception:
return DEFAULT_BUDGET
+88
View File
@@ -59,6 +59,39 @@ MUTATING_TOOL_NAMES = frozenset(
}
)
# Tools that are legitimately re-invoked with identical arguments and may
# legitimately return an unchanged result while waiting on external progress —
# background-process management and job pollers. The identical-call loop
# notice (agent.stall_guards) never fires for these, so polling patterns like
# ``process(action="poll")`` or repeatedly checking a generation job stay
# unannotated.
STALL_GUARD_REPEATABLE_TOOLS = frozenset(
{
"process",
"bfl_flux3_get_result",
}
)
# Poller naming conventions (e.g. ``<vendor>_get_result``) used by generated /
# MCP tool surfaces. Matched as suffixes so vendor-prefixed pollers are exempt
# without enumerating every vendor.
_STALL_GUARD_REPEATABLE_SUFFIXES = (
"_get_result",
"_poll",
)
# The notice fires on the Nth consecutive identical call (same tool, same
# canonical args, same result). 3 tolerates one legitimate double-check while
# catching the observed re-issue loops (3x/4x identical calls in eval traces).
STALL_GUARD_IDENTICAL_CALL_THRESHOLD = 3
def is_stall_guard_repeatable(tool_name: str) -> bool:
"""Whether a tool is exempt from the identical-call loop notice."""
if tool_name in STALL_GUARD_REPEATABLE_TOOLS:
return True
return tool_name.endswith(_STALL_GUARD_REPEATABLE_SUFFIXES)
@dataclass(frozen=True)
class ToolCallGuardrailConfig:
@@ -282,6 +315,14 @@ class ToolCallGuardrailController:
self._same_tool_failure_counts: dict[str, int] = {}
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
self._halt_decision: ToolGuardrailDecision | None = None
# Identical-call loop-breaker state (agent.stall_guards): tracks the
# CONSECUTIVE streak of identical (tool, canonical args) calls whose
# results were also identical. Any different call — or a different
# result — resets the streak, so legitimate re-reads after edits and
# varied polling are never flagged. Per-turn, like everything else here.
self._identical_streak_sig: ToolCallSignature | None = None
self._identical_streak_result_hash: str = ""
self._identical_streak_count: int = 0
# Per-turn runaway-loop cap counters. Reset every turn (this method
# runs at the start of each run_conversation), so the caps bound a
# single agent loop rather than accumulating across the session.
@@ -444,6 +485,53 @@ class ToolCallGuardrailController:
return False
return tool_name in self.config.idempotent_tools
def observe_identical_call(
self,
tool_name: str,
args: Mapping[str, Any] | None,
result: str | None,
) -> str | None:
"""Track consecutive identical calls; return a loop-breaker notice or None.
Fires the compact notice when the SAME tool is called with identical
canonical arguments AND returns an identical result for the
``STALL_GUARD_IDENTICAL_CALL_THRESHOLD``-th (and every subsequent)
consecutive time within the turn. Purely observational — never blocks
the call. Allowlisted pollers (``is_stall_guard_repeatable``) are
exempt, and any intervening different call or changed result resets
the streak. Callers append the returned notice to the tool RESULT at
construction time, which is cache-safe: tool results are append-only
and never mutate already-sent context.
"""
if is_stall_guard_repeatable(tool_name):
# Don't let a poller streak carry over into the next tool either.
self._identical_streak_sig = None
self._identical_streak_count = 0
return None
signature = ToolCallSignature.from_call(tool_name, _coerce_args(args))
result_hash = _result_hash(result)
if (
self._identical_streak_sig == signature
and self._identical_streak_result_hash == result_hash
):
self._identical_streak_count += 1
else:
self._identical_streak_sig = signature
self._identical_streak_result_hash = result_hash
self._identical_streak_count = 1
count = self._identical_streak_count
if count < STALL_GUARD_IDENTICAL_CALL_THRESHOLD:
return None
ordinal = f"{count}{'th' if 11 <= count % 100 <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(count % 10, 'th')}"
return (
f"[hermes note: this is the {ordinal} consecutive identical call to "
f"{tool_name} with identical arguments returning the same result. "
"Do not repeat it — change arguments, use a different tool, or "
"proceed with what you have.]"
)
def _check_loop_cap(
self,
tool_name: str,
+54 -10
View File
@@ -84,13 +84,21 @@ def _add_prompt_cache_key(
def _reasoning_config_for_model(model: str, reasoning_config: dict | None) -> dict | None:
"""Return the model's wire-compatible reasoning config."""
"""Return the model's wire-compatible reasoning config.
Hermes' internal effort set extends the wire vocabulary with ``ultra``
(the /reasoning command documents none..xhigh|max|ultra). OpenAI-
compatible wires — OpenRouter chief among them — accept exactly
max|xhigh|high|medium|low|minimal|none and reject the extension with
HTTP 400, so an ``ultra`` configured for an Anthropic default leaks
untranslated when a per-job/per-turn override pins a non-Anthropic
model on this transport and the whole call fails (#89503). Map the
extension to its wire cap for every model on this path; the Anthropic
adapter keeps its own richer mapping.
"""
if not isinstance(reasoning_config, dict):
return reasoning_config
if (
"gpt-5.6" in (model or "").lower()
and str(reasoning_config.get("effort") or "").strip().lower() == "ultra"
):
if str(reasoning_config.get("effort") or "").strip().lower() == "ultra":
normalized = dict(reasoning_config)
normalized["effort"] = "max"
return normalized
@@ -559,11 +567,36 @@ class ChatCompletionsTransport(ProviderTransport):
and reasoning_config.get("enabled") is False
)
if not _kimi_thinking_off:
_kimi_effort = "medium"
# K3 accepts low/high/max only (default high) — "medium" and
# Hermes' upper-ladder levels 400 or silently degrade. Mirror
# the kimi-coding plugin's _K3_EFFORT_MAP; older Kimi models
# keep the low/medium/high vocabulary with the stronger
# Hermes levels capped at high instead of being dropped
# (dropping them inverted the ladder: ultra sent the
# "medium" default, weaker than an explicit high).
_e = ""
if reasoning_config and isinstance(reasoning_config, dict):
_e = (reasoning_config.get("effort") or "").strip().lower()
if _e in {"low", "medium", "high"}:
_kimi_effort = _e
if "k3" in (model or "").lower():
_kimi_effort = {
"minimal": "low",
"low": "low",
"medium": "high",
"high": "high",
"xhigh": "max",
"max": "max",
"ultra": "max",
}.get(_e, "high")
else:
_kimi_effort = {
"minimal": "low",
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "high",
"max": "high",
"ultra": "high",
}.get(_e, "medium")
api_kwargs["reasoning_effort"] = _kimi_effort
# Tencent TokenHub: top-level reasoning_effort (unless thinking disabled)
@@ -574,11 +607,22 @@ class ChatCompletionsTransport(ProviderTransport):
and reasoning_config.get("enabled") is False
)
if not _tokenhub_thinking_off:
# TokenHub accepts low/medium/high. Map Hermes' full ladder
# onto that set instead of dropping unknown levels to the
# "high" default — dropping inverted the ladder for
# "minimal" (asked for the least, got the most).
_tokenhub_effort = "high"
if reasoning_config and isinstance(reasoning_config, dict):
_e = (reasoning_config.get("effort") or "").strip().lower()
if _e in {"low", "medium", "high"}:
_tokenhub_effort = _e
_tokenhub_effort = {
"minimal": "low",
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "high",
"max": "high",
"ultra": "high",
}.get(_e, "high")
api_kwargs["reasoning_effort"] = _tokenhub_effort
# LM Studio: top-level reasoning_effort. Only emit when the model
+8 -4
View File
@@ -432,10 +432,14 @@ class ResponsesApiTransport(ProviderTransport):
elif reasoning_config.get("effort"):
reasoning_effort = reasoning_config["effort"]
_effort_clamp = {"minimal": "low"}
if "gpt-5.6" in (model or "").lower():
# Ultra is the Codex product tier; the Responses API wire value is max.
_effort_clamp["ultra"] = "max"
# "ultra" is Hermes-internal ladder vocabulary (the Codex product
# tier); no Responses-API backend accepts it verbatim, so the
# baseline maps it to its wire cap "max" for EVERY model — the old
# gpt-5.6-only guard leaked "ultra" untranslated to sibling models
# and the request 400'd (same class as #89503 on the
# chat-completions transport). Backend-specific branches below
# override the baseline where the ceiling is narrower.
_effort_clamp = {"minimal": "low", "ultra": "max"}
if params.get("is_xai_responses", False):
from agent.model_metadata import is_grok_46_family
+9
View File
@@ -605,6 +605,15 @@ def build_turn_context(
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
agent.iteration_budget = IterationBudget(agent.max_iterations)
# Wall-clock run budget: per-run_conversation clock. Only stamped when a
# budget is configured so the default path stays clock-free; the wrap-up
# latch resets each turn (one notice per run, not per session).
if getattr(agent, "run_budget_seconds", None):
agent._run_budget_started_at = time.time()
else:
agent._run_budget_started_at = None
agent._run_budget_wrapup_injected = False
# Log conversation turn start for debugging/observability.
_preview_text = summarize_user_message_for_log(user_message)
_msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text
+12
View File
@@ -132,6 +132,18 @@ def get_active_provider() -> Optional[VideoGenProvider]:
except Exception as exc:
logger.debug("Could not read video_gen.provider from config: %s", exc)
# The managed "Nous Subscription" selection is serviced by the FAL
# plugin through the managed fal-queue gateway (the plugin's resolver
# routes managed when the stored selection is "nous").
if configured:
try:
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
if configured.lower() == NOUS_MANAGED_PROVIDER:
configured = "fal"
except Exception: # pragma: no cover — helpers are in-repo
pass
with _lock:
snapshot = dict(_providers)
snapshot.update(_scoped_providers.get(hermes_home_key(), {}))
@@ -365,4 +365,41 @@ describe('PersistentTerminal rect tracking', () => {
expect(overlay.style.pointerEvents).toBe('auto')
expect(mount.container!.querySelector('[data-testid="terminal-workspace"]')).toBe(workspace)
})
it('hides the overlay on a tab switch that happens while the window is unfocused', () => {
// The trap: the terminal is a tab in the main zone and the user clicks
// another tab without the window being focused (or right as it blurs).
// The rect chase is paused then — but visibility is correctness, not perf,
// so the overlay must still stand down instead of covering the chat with
// an opaque, pointer-interactive surface until something refocuses.
installRaf()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
$terminalTakeover.set(true)
mount.render(<HiddenPaneHarness hidden={false} />)
const overlay = mount.container!.lastElementChild as HTMLElement
expect(overlay.style.visibility).toBe('visible')
expect(overlay.style.pointerEvents).toBe('auto')
act(() => {
vi.mocked(document.hasFocus).mockReturnValue(false)
window.dispatchEvent(new Event('blur'))
})
act(() => {
mount.root!.render(<HiddenPaneHarness hidden />)
})
act(() => {
mutationObserverCallback?.([], {} as MutationObserver)
})
expect(overlay.style.visibility).toBe('hidden')
expect(overlay.style.opacity).toBe('0')
expect(overlay.style.pointerEvents).toBe('none')
// The PTY survives — only the overlay stands down.
expect(mount.container!.querySelector('[data-testid="terminal-workspace"]')).not.toBeNull()
})
})
@@ -103,8 +103,25 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
}
}
// Visibility is CORRECTNESS, not perf. The rect chase below forces layout,
// so it stays gated on the renderer pause — but `hidden` is a cheap
// attribute walk, and skipping it while paused strands the overlay over a
// tab the user has already switched away from: the terminal keeps covering
// the chat, opaque and pointer-interactive, until something refocuses the
// window. Sample it on every wake, paused or not.
const syncHidden = () => {
const hidden = isElementInHiddenPane(slot)
if (prev && prev.hidden !== hidden) {
prev = { ...prev, hidden }
setRect(prev)
}
}
const measure = (reason: string): boolean => {
if (rendererPaused()) {
syncHidden()
return false
}
@@ -140,7 +157,20 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
}
const scheduleMeasure = (reason = 'unknown') => {
if (stopped || rendererPaused() || frame !== 0) {
if (stopped) {
return
}
// Paused: no frame is coming (and none is wanted — the rect chase is the
// expensive half). Still settle visibility synchronously so a tab switch
// while the window is unfocused can't leave the overlay stranded.
if (rendererPaused()) {
syncHidden()
return
}
if (frame !== 0) {
return
}
@@ -158,6 +188,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
const handleVisibilityChange = () => {
if (rendererPaused()) {
cancelFrame()
syncHidden()
return
}
+48 -14
View File
@@ -120,13 +120,17 @@ function setActivityToasts(enabled) {
}
/** Detect new inbound activity from a fresh roster: last_active moved past
* the watermark for a bot whose chat isn't on screen -> unread + toast. */
* the watermark for a bot whose chat isn't on screen -> unread + toast.
* Watermarks follow botActivitySession (canonical Bot Chat included) —
* last_session alone never sees the hidden Bot Chat, so DMs delivered
* there would neither badge nor toast. */
function trackInboundActivity(roster) {
const seeding = !watermarksSeeded
watermarksSeeded = true
for (const bot of roster) {
const ts = bot.last_session?.last_active || 0
const activity = botActivitySession(bot)
const ts = activity?.last_active || 0
const prev = rosterWatermarks.get(bot.name) || 0
rosterWatermarks.set(bot.name, Math.max(prev, ts))
@@ -153,7 +157,7 @@ function trackInboundActivity(roster) {
if ($activityToasts.get()) {
const meta = $botMeta.get()[bot.name]
const label = displayName(bot, meta)
const preview = (bot.last_session?.preview || '').trim()
const preview = (activity?.preview || '').trim()
const inbound = /^Message from/i.test(preview)
host.notify({
@@ -4762,6 +4766,28 @@ function generatedSessionTitle(session, preview) {
* seconds is treated as "active now" (pulsing dot in its row). */
const ACTIVE_WINDOW_S = 90
/** The session whose activity best represents this bot — the FRESHER of the
* pinned canonical Bot Chat (preferred_session) and the profile's newest
* visible conversation (last_session).
*
* Canonical Bot Chats are hidden from the session list by design, so
* last_session alone never sees them: a bot you talk to all day through its
* Bot Chat reads "6d ago" because its newest VISIBLE session is a week old.
* #88690 moved the preview text to preferred_session but left every activity
* signal (age label, pulse dot, unread watermark, recency sort) on
* last_session. All of them key off this helper now. Older gateways without
* the preferred_session resolver degrade to last_session unchanged. */
function botActivitySession(bot) {
const preferred = bot?.preferred_session
const last = bot?.last_session
if (!preferred || !last) {
return preferred || last || null
}
return (preferred.last_active || 0) >= (last.last_active || 0) ? preferred : last
}
/** Bots that are working right now: the profile the gateway is running a
* turn for (busy), plus any bot whose last message landed inside the
* liveness window. Pure — output follows the input roster's order, so
@@ -4769,7 +4795,7 @@ const ACTIVE_WINDOW_S = 90
function activeBots(roster, activeProfile, gatewayState, now = Date.now()) {
return (roster || []).filter(bot => {
const busyTurn = !bot.remoteSource && bot.name === activeProfile && gatewayState === 'busy'
const last = bot.last_session?.last_active || 0
const last = botActivitySession(bot)?.last_active || 0
const inWindow = Boolean(last && now / 1000 - last < ACTIVE_WINDOW_S)
return busyTurn || inWindow
@@ -4797,7 +4823,17 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
// Keep user photos/pets. Drop the 160px SVG backfill so the math face can move.
const photo = Boolean(image && !isBackfilledFacePng(image))
const gatewayState = useValue(host.state.gateway)
const activeNow = Boolean(last?.last_active && Date.now() / 1000 - last.last_active < ACTIVE_WINDOW_S)
// Preview identity must match click identity (#88200): when the backend
// resolved the pinned canonical chat, preview THAT session — not the
// profile's most recent (but unrelated) activity. Activity signals
// (age label, pulse dot) follow the same rule via botActivitySession:
// the canonical Bot Chat is hidden from last_session, so keying age off
// last_session alone shows "6d ago" on a bot you just messaged.
const previewSession = bot.preferred_session || last
const activitySession = botActivitySession(bot)
const activeNow = Boolean(
activitySession?.last_active && Date.now() / 1000 - activitySession.last_active < ACTIVE_WINDOW_S
)
// Work pose only when this bot is actually doing something: the active
// profile while the gateway is busy, or a bot that wrote within the
// liveness window. Not every bot whenever the gateway is busy.
@@ -4808,11 +4844,6 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
const unread = !bot.remoteSource && Boolean(unreadByName[bot.name])
// WHO sent the last message (bot-to-bot DM vs human) — the full stored
// history lives in the Sessions workspace (context menu), not inline.
// Preview identity must match click identity (#88200): when the backend
// resolved the pinned canonical chat, preview THAT session — not the
// profile's most recent (but unrelated) activity. Liveness checks above
// keep last_session semantics: any recent activity means the bot is alive.
const previewSession = bot.preferred_session || last
const { fromBot } = previewKind(previewSession?.preview)
// DM previews read like DMs: strip the delivery prefix, keep the message.
const displayPreview = stripPreviewMarkdown(
@@ -4983,10 +5014,10 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
title: 'Active in the last 90s'
})
: null,
last
activitySession
? jsx('span', {
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
children: relativeTime(last.last_active * 1000)
children: relativeTime(activitySession.last_active * 1000)
})
: null
]
@@ -6158,7 +6189,10 @@ function CreateAgentDialog({ open, onClose, roster }) {
host
.connections()
.then(value => setConnections(Array.isArray(value?.connections) ? value.connections : []))
// host.connections() returns the registry ROWS on current SDKs, but the
// envelope object ({version, primary, connections: [...]}) on desktops
// that predate the SDK-side unwrap — accept both shapes.
.then(value => setConnections(Array.isArray(value) ? value : Array.isArray(value?.connections) ? value.connections : []))
.catch(() => setConnections([]))
}, [open, connections])
@@ -9541,7 +9575,7 @@ function BotsPane() {
// No special slot for the primary bot — it competes on recency too.
const activityOf = bot => {
const created = botRosterMeta(bot, allMeta)?.created || bot.ui_meta?.['hermes-bots']?.created || 0
const lastMsg = (bot.last_session?.last_active || 0) * 1000
const lastMsg = (botActivitySession(bot)?.last_active || 0) * 1000
return Math.max(created, lastMsg)
}
@@ -7,7 +7,7 @@ const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
// The activeBots helper must stay a self-contained slice between the
// liveness-window constant and the BotRow section so tests can extract it.
function loadActiveBots() {
function loadActiveBotsSlice() {
const start = source.indexOf('const ACTIVE_WINDOW_S')
const end = source.indexOf('// ── bot row ─')
@@ -15,11 +15,19 @@ function loadActiveBots() {
const context = {}
vm.runInNewContext(
`${source.slice(start, end)}\nglobalThis.__activeBots = activeBots;`,
`${source.slice(start, end)}\nglobalThis.__activeBots = activeBots;\nglobalThis.__botActivitySession = botActivitySession;`,
context
)
return context.__activeBots
return context
}
function loadActiveBots() {
return loadActiveBotsSlice().__activeBots
}
function loadBotActivitySession() {
return loadActiveBotsSlice().__botActivitySession
}
// Fixed clock so "inside the window" vs "stale" is deterministic.
@@ -74,6 +82,57 @@ test('roster without profiles never throws', () => {
assert.equal(activeBots([], 'default', 'open', NOW).length, 0)
})
// ── botActivitySession: canonical Bot Chat activity counts (hermes-agent "6d ago" bug) ──
test('botActivitySession picks the fresher preferred_session over a stale last_session', () => {
const botActivitySession = loadBotActivitySession()
const bot = {
// Canonical Bot Chat (hidden from session lists): messaged seconds ago.
preferred_session: { id: 'bot-chat', last_active: NOW / 1000 - 5, preview: 'fresh DM' },
// Newest VISIBLE session: 6 days old — what last_session alone reports.
last_session: { id: 'old-scratch', last_active: NOW / 1000 - 6 * 86400, preview: 'ancient' }
}
assert.equal(botActivitySession(bot).id, 'bot-chat')
})
test('botActivitySession keeps last_session when it is the fresher one', () => {
const botActivitySession = loadBotActivitySession()
const bot = {
preferred_session: { id: 'bot-chat', last_active: NOW / 1000 - 3600 },
last_session: { id: 'scratch', last_active: NOW / 1000 - 10 }
}
assert.equal(botActivitySession(bot).id, 'scratch')
})
test('botActivitySession degrades to whichever side exists (older gateways / no pin)', () => {
const botActivitySession = loadBotActivitySession()
assert.equal(botActivitySession({ last_session: { id: 'only', last_active: 1 } }).id, 'only')
assert.equal(botActivitySession({ preferred_session: { id: 'pin', last_active: 1 } }).id, 'pin')
assert.equal(botActivitySession({}), null)
assert.equal(botActivitySession(null), null)
})
test('activeBots counts Bot Chat activity that last_session cannot see', () => {
const activeBots = loadActiveBots()
const bots = [
{
name: 'default',
preferred_session: { last_active: NOW / 1000 - 5 },
last_session: { last_active: NOW / 1000 - 6 * 86400 }
}
]
const names = activeBots(bots, 'other', 'open', NOW).map(bot => bot.name)
assert.ok(names.includes('default'), 'fresh canonical-chat activity must light the pulse dot')
})
test('row age label and recency sort key off botActivitySession, not last_session', () => {
// The "6d ago" regression: the timestamp/sort sites must not read
// bot.last_session directly anymore.
assert.match(source, /relativeTime\(activitySession\.last_active \* 1000\)/)
assert.match(source, /const lastMsg = \(botActivitySession\(bot\)\?\.last_active \|\| 0\) \* 1000/)
assert.doesNotMatch(source, /relativeTime\(last\.last_active \* 1000\)/)
})
test('ActiveNowStrip renders above the roster, is a live region, and is click-accessible', () => {
// Strip is placed between the pane header and the search field.
const headerEnd = source.indexOf("children: 'Bots'")
@@ -13,6 +13,11 @@ const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
function loadTracker(toastsEnabled) {
const start = source.indexOf('const rosterWatermarks = new Map()')
const end = source.indexOf('/** Last good cron list', start)
// The tracker keys watermarks off the REAL botActivitySession helper
// (defined later in plugin.js) — extract it so the harness can't drift.
const helperStart = source.indexOf('function botActivitySession(')
const helperEnd = source.indexOf('/** Bots that are working', helperStart)
assert.ok(helperStart >= 0 && helperEnd > helperStart, 'botActivitySession must remain extractable')
const notifications = []
const context = {
pluginCtx: null,
@@ -30,7 +35,8 @@ function loadTracker(toastsEnabled) {
displayName: bot => bot.name
}
const section = source
.slice(start, end)
.slice(helperStart, helperEnd)
.concat('\n', source.slice(start, end))
.concat('\nglobalThis.__t = { trackInboundActivity, $activityToasts, setActivityToasts };\n')
vm.runInNewContext(section, context, { filename: 't.js' })
if (toastsEnabled) {
@@ -68,3 +74,19 @@ test('pref defaults OFF and persists via ctx.storage under activity-toasts', ()
)
assert.match(source, /storage\?\.get\?\.\('activity-toasts'\)/)
})
test('activity in the hidden canonical Bot Chat still badges (the "6d ago" class)', () => {
// The canonical Bot Chat is hidden from session lists, so last_session
// never advances when a DM lands there — only preferred_session does.
const t = loadTracker(false)
const at = ts => [
{
name: 'researcher',
last_session: { last_active: 100, preview: 'ancient scratch chat' },
preferred_session: { last_active: ts, preview: 'Message from writer: hi' }
}
]
t.trackInboundActivity(at(150)) // seeding poll
t.trackInboundActivity(at(250)) // Bot Chat got a DM; last_session unchanged
assert.equal(t.$botUnread.get().researcher, true, 'hidden Bot Chat activity must set unread')
})
@@ -163,17 +163,15 @@ test('source contract: group chat turns route through requestForBot on the membe
assert.match(pluginSource, /members: Array\.isArray\(room\.members\) \? room\.members : \[\]/)
})
test('regression: host.connections() registry object is unwrapped before the picker gate', () => {
// host.connections() resolves the IPC handler hermes:connections:list, which
// returns the registry OBJECT ({version, primary, connections: [...]}) — not
// a bare array. The picker gate (Array.isArray(connections) && length > 1)
// never fired, so the "Create on" picker stayed hidden on multi-connection
// desktops. The plugin must unwrap .connections before storing.
test('regression: host.connections() result is normalized for BOTH SDK shapes before the picker gate', () => {
// Current SDKs return the registry ROWS from host.connections() (the
// documented contract); desktops that predate the SDK-side unwrap resolve
// the raw IPC payload — the registry OBJECT ({version, primary,
// connections: [...]}). The picker gate (Array.isArray(connections) &&
// length > 1) needs rows either way, so the plugin must accept both
// shapes. Pinning one shape only reopens #89823 on the other.
assert.match(
pluginSource,
/setConnections\(Array\.isArray\(value\?\.connections\) \? value\.connections : \[\]\)/
/setConnections\(Array\.isArray\(value\) \? value : Array\.isArray\(value\?\.connections\) \? value\.connections : \[\]\)/
)
// The built-in Connections UI consumes the registry object, so the IPC
// handler contract must NOT change to a bare array.
assert.doesNotMatch(pluginSource, /setConnections\(Array\.isArray\(value\) \? value : \[\]\)/)
})
@@ -15,6 +15,12 @@ function sourceBetween(start, end) {
return source.slice(from, to)
}
// BotRow's activity resolver — extract the REAL helper so the harness can't
// drift from production behavior.
function activitySessionSource() {
return sourceBetween('function botActivitySession(', '/** Bots that are working')
}
function renderBotRow(input = 'alpha') {
const bot = typeof input === 'string' ? { name: input } : input
const name = bot.name
@@ -93,7 +99,7 @@ function renderBotRow(input = 'alpha') {
useValue: store => store.get()
}
vm.runInNewContext(`${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context)
vm.runInNewContext(`${activitySessionSource()}\n${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context)
const tree = context.BotRow({ bot, onEdit: context.onEdit })
const row = tree.type === 'button' ? tree : tree.props.children[0].props.children
@@ -215,7 +221,7 @@ test('behavior: remote default does not open this-device chat when the source di
useValue: store => store.get()
}
vm.runInNewContext(`${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context)
vm.runInNewContext(`${activitySessionSource()}\n${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context)
const tree = context.BotRow({ bot, onEdit: context.onEdit })
const row = tree.type === 'button' ? tree : tree.props.children[0].props.children
+66
View File
@@ -107,3 +107,69 @@ describe('host.state turn flags', () => {
$sessionTiles.set([])
})
})
describe('host.connections', () => {
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const originalDesktop = desktopWindow.hermesDesktop
const connection = (id: string, label: string) => ({
id,
kind: 'remote' as const,
label,
tokenPreview: null,
tokenSet: true,
url: `https://${id}.example`
})
const stubBridge = (list: () => Promise<unknown>) => {
desktopWindow.hermesDesktop = {
...originalDesktop,
connections: { list }
} as unknown as Window['hermesDesktop']
}
afterEach(() => {
desktopWindow.hermesDesktop = originalDesktop
})
it('returns the registry rows, not the envelope that carries them (#89823)', async () => {
stubBridge(async () => ({
connections: [connection('local', 'This Mac'), connection('homelab', 'Homelab')],
primary: 'local',
secureTokenStorage: true,
version: 2
}))
const connections = await host.connections()
expect(Array.isArray(connections)).toBe(true)
expect(connections.map(entry => entry.id)).toEqual(['local', 'homelab'])
expect(connections[1]).toMatchObject({ kind: 'remote', label: 'Homelab', url: 'https://homelab.example' })
})
it('folds the envelope-level primary id down onto the row that owns it', async () => {
stubBridge(async () => ({
connections: [connection('local', 'This Mac'), connection('homelab', 'Homelab')],
primary: 'homelab',
secureTokenStorage: true,
version: 2
}))
expect((await host.connections()).map(entry => [entry.id, entry.primary])).toEqual([
['local', false],
['homelab', true]
])
})
it('reads as a single-source desktop when the payload carries no rows', async () => {
stubBridge(async () => ({ primary: '', secureTokenStorage: true, version: 1 }))
await expect(host.connections()).resolves.toEqual([])
})
it('still rejects on a Desktop build without the connection registry', async () => {
desktopWindow.hermesDesktop = undefined
await expect(host.connections()).rejects.toThrow('This Desktop build has no connection registry')
})
})
+5 -1
View File
@@ -464,7 +464,10 @@ export const host = {
throw new Error('This Desktop build has no connection registry. Update Hermes Desktop.')
}
return bridge.list()
const registryPayload = await bridge.list()
const rows = Array.isArray(registryPayload?.connections) ? registryPayload.connections : []
return rows.map(connection => ({ ...connection, primary: connection.id === registryPayload.primary }))
},
/** The union agent roster across every registered connection: one row per
@@ -496,6 +499,7 @@ export const host = {
ensureAgent: async (connectionId: null | string, profile: string): Promise<void> =>
ensureGatewayAgent(connectionId, (profile ?? '').trim() || 'default'),
/** Open a stored session — optionally pre-activating its profile first. */
openSession: async (storedSessionId: string, options: PluginOpenSessionOptions = {}): Promise<void> => {
const generation = ++openSessionGeneration
const profile = (options.profile ?? '').trim()
+13
View File
@@ -671,6 +671,19 @@ compression:
# To pin a specific model/provider for compression summaries, use the
# auxiliary section below (auxiliary.compression.provider / model).
# =============================================================================
# Tool-result budget (optional)
# =============================================================================
# Controls when a large tool result is spilled to disk (full output saved to
# $HERMES_HOME/cache/spillover, preview + path kept in context). MCP tool
# results (tools named mcp_*) spill at a tighter 50,000-char threshold than
# the generic 100K default: MCP servers routinely return un-paginated 20-50K
# payloads that bloat context and slow every subsequent turn. Nothing is
# lost — the full result is on disk and readable with read_file.
#
# tool_budget:
# mcp_result_size_chars: 50000 # per-result spillover threshold for mcp_* tools
# =============================================================================
# Anthropic prompt caching TTL
# =============================================================================
+10
View File
@@ -4858,6 +4858,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
api_key: str = None,
base_url: str = None,
max_turns: int = None,
run_budget: float = None,
verbose: Optional[bool] = None,
compact: bool = False,
resume: str = None,
@@ -5125,6 +5126,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self.max_turns = 500
else:
self.max_turns = 500
# Wall-clock run budget: CLI flag wins over config; both optional.
# None keeps the feature fully off (AIAgent stays dormant).
if run_budget is not None:
self.run_budget_seconds = run_budget
else:
self.run_budget_seconds = CLI_CONFIG["agent"].get("run_budget_seconds")
# Parse and validate toolsets
self.enabled_toolsets = toolsets
@@ -20174,6 +20182,7 @@ def main(
api_key: str = None,
base_url: str = None,
max_turns: int = None,
run_budget: float = None,
verbose: Optional[bool] = None,
quiet: bool = False,
compact: bool = False,
@@ -20370,6 +20379,7 @@ def main(
api_key=api_key,
base_url=base_url,
max_turns=max_turns,
run_budget=run_budget,
verbose=verbose,
compact=compact,
resume=resume,
+1
View File
@@ -0,0 +1 @@
huklaa
+15
View File
@@ -459,6 +459,21 @@ def build_top_level_parser():
metavar="N",
help="Maximum tool-calling iterations per conversation turn (default: 500, or agent.max_turns in config)",
)
chat_parser.add_argument(
"--run-budget",
type=float,
default=None,
metavar="SECONDS",
dest="run_budget",
help=(
"Optional wall-clock budget in seconds for each conversation run. "
"At 80%% elapsed the agent gets a one-time wrap-up notice, and "
"implicit provider stale timeouts are capped to the remaining "
"budget so one hung call can't consume the run. Unset = off. "
"Also configurable as agent.run_budget_seconds in config.yaml. "
"Intended for one-shot/eval invocations with a hard ceiling."
),
)
_inherited_flag(
chat_parser,
"--yolo",
+1
View File
@@ -500,6 +500,7 @@ class CLIAgentSetupMixin:
credential_pool=runtime.get("credential_pool"),
max_tokens=self.max_tokens,
max_iterations=self.max_turns,
run_budget_seconds=getattr(self, "run_budget_seconds", None),
enabled_toolsets=self.enabled_toolsets,
disabled_toolsets=self.disabled_toolsets,
verbose_logging=self.verbose,
+29 -1
View File
@@ -44,6 +44,12 @@ DEFAULT_CONFIG = {
},
"agent": {
"max_turns": 500,
# Optional wall-clock budget in seconds per conversation run.
# null/absent = feature fully off (zero behavior change). When set,
# the agent gets a one-time wrap-up notice at 80% elapsed and
# implicit provider stale timeouts are capped to the remaining
# budget. CLI one-shot equivalent: `hermes chat --run-budget N`.
"run_budget_seconds": None,
# Inactivity timeout for gateway agent execution (seconds).
# The agent can run indefinitely as long as it's actively calling
# tools or receiving API responses. Only fires when the agent has
@@ -146,6 +152,15 @@ DEFAULT_CONFIG = {
# (force on/off for all models), or a list of model-name substrings
# to match (e.g. ["gpt", "codex", "gemini", "qwen"]).
"tool_use_enforcement": "auto",
# Execution-discipline guidance: injects a system prompt block covering
# tool persistence, mandatory tool use for arithmetic/system facts,
# external-write read-back, count reconciliation, literal preservation
# of identifiers, and verification-gated completion. Chosen once at
# session start keyed on model name (prompt stays byte-stable).
# Values: "auto" (default — applies to gpt/codex/grok/deepseek/kimi/
# qwen/glm/minimax/mimo/mistral models), true/false (force on/off for
# all models), or a list of model-name substrings to match.
"execution_guidance": "auto",
# Intent-ack continuation: when the model opens a turn by narrating an
# action it will take ("I'll go check the logs...") but emits no tool
# call, intercept the turn-end, inject a "continue now, execute the
@@ -156,6 +171,15 @@ DEFAULT_CONFIG = {
# api_modes — fixes the Gemini/Claude "stops after stating intent" case),
# false (never), or a list of model-name substrings to match.
"intent_ack_continuation": "auto",
# Runtime anti-stall guards. When True (default), two conservative
# guards run: (1) an identical-call loop breaker that appends a short
# notice to the tool result when the same tool is called 3+ consecutive
# times with identical arguments AND identical results (never blocks;
# pollers like `process` are exempt), and (2) a continue-intent
# extension of the empty-response recovery that re-prompts once when
# the model ends its turn saying it will continue but takes no action.
# Set False to disable both.
"stall_guards": True,
# Universal "finish the job" guidance — short prompt block applied to
# all models that targets two cross-family failure modes: (1) stopping
# after a stub instead of finishing the artifact, (2) fabricating
@@ -1679,7 +1703,11 @@ DEFAULT_CONFIG = {
# the raw transcript is also echoed back to the user as a 🎙️ message.
# Set false to keep STT for the agent while suppressing that user-facing echo.
"echo_transcripts": True,
"provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) | "deepinfra"
# NOTE: no seeded "provider" key. Strict selection semantics treat a
# stored stt.provider as an explicit user pick; seeding "local" here
# made a fresh install indistinguishable from a user choice. The
# autodetect ladder covers unset. Valid values when set:
# "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) | "deepinfra"
# Global language hint applied to EVERY provider unless a per-provider
# language overrides it. Defaults to "en" — Whisper auto-detection
# frequently misidentifies short/accented clips, which reads as
+1
View File
@@ -238,6 +238,7 @@ def _config_overrides(config: dict) -> dict[str, str]:
("agent", "gateway_timeout"),
("agent", "session_stall_timeout"),
("agent", "tool_use_enforcement"),
("agent", "execution_guidance"),
("terminal", "backend"),
("terminal", "docker_image"),
("terminal", "persistent_shell"),
+1
View File
@@ -3220,6 +3220,7 @@ def cmd_chat(args):
"checkpoints": getattr(args, "checkpoints", False),
"pass_session_id": getattr(args, "pass_session_id", False),
"max_turns": getattr(args, "max_turns", None),
"run_budget": getattr(args, "run_budget", None),
"ignore_rules": getattr(args, "ignore_rules", False) or getattr(args, "safe_mode", False),
"ignore_user_config": getattr(args, "ignore_user_config", False) or getattr(args, "safe_mode", False),
"compact": getattr(args, "compact", False),
+125 -51
View File
@@ -54,6 +54,26 @@ def _uses_gateway(section: object) -> bool:
return is_truthy_value(section.get("use_gateway"), default=False)
def _selected_provider(section: object, name_key: str = "provider") -> Optional[str]:
"""Return the stored provider string for a config section dict.
Mirrors :func:`tools.tool_backend_helpers.read_selection`'s semantics on
an in-memory section dict: ``"nous"`` for the managed selection (stored
``nous`` value or legacy ``use_gateway: true``), a vendor name for BYOK
picks, or ``None`` when no selection is stored. Keeping this in lockstep
with the runtime resolver is what stops ``hermes status`` from lying.
"""
if not isinstance(section, dict):
return None
if is_truthy_value(section.get("use_gateway"), default=False):
return "nous"
value = section.get(name_key)
if value is None:
return None
name = str(value).strip().lower()
return name or None
@dataclass(frozen=True)
class NousFeatureState:
key: str
@@ -315,11 +335,14 @@ def _resolve_browser_feature_state(
on the latter, or setup/status advertise a browser that fails on first use
when Chromium is missing.
"""
if direct_camofox:
return "camofox", True, bool(browser_tool_enabled), False
if browser_provider_explicit:
current_provider = browser_provider or "local"
if current_provider == "camofox":
# Camofox is now a stored selection (browser.cloud_provider:
# camofox); CAMOFOX_URL is only the server address.
available = bool(direct_camofox)
active = bool(browser_tool_enabled and available)
return current_provider, available, active, False
if current_provider == "browserbase":
available = bool(browser_local_available and direct_browserbase)
active = bool(browser_tool_enabled and available)
@@ -347,6 +370,11 @@ def _resolve_browser_feature_state(
active = bool(browser_tool_enabled and available)
return current_provider, available, active, False
# Never-configured autodetect: CAMOFOX_URL keeps activating Camofox
# exactly as before when no cloud_provider selection was ever stored.
if direct_camofox:
return "camofox", True, bool(browser_tool_enabled), False
if managed_browser_available or direct_browser_use:
available = bool(browser_local_available)
managed = bool(
@@ -435,17 +463,43 @@ def get_nous_subscription_features(
terminal_cfg.get("modal_mode")
)
# use_gateway flags — when True, the user explicitly opted into the
# Tool Gateway via `hermes model`, so direct credentials should NOT
# prevent gateway routing.
web_use_gateway = _uses_gateway(web_cfg)
tts_use_gateway = _uses_gateway(tts_cfg)
stt_use_gateway = _uses_gateway(stt_cfg)
browser_use_gateway = _uses_gateway(browser_cfg)
# Stored selections (strict model): one provider string per category.
# "nous" (stored value or legacy use_gateway: true) = managed gateway;
# vendor name = that vendor direct; None = never configured (autodetect).
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
image_use_gateway = _uses_gateway(image_gen_cfg)
video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {}
video_use_gateway = _uses_gateway(video_gen_cfg)
web_selected = _selected_provider(web_cfg, "backend")
tts_selected = _selected_provider(tts_cfg)
stt_selected = _selected_provider(stt_cfg)
browser_selected = _selected_provider(browser_cfg, "cloud_provider")
image_selected = _selected_provider(image_gen_cfg)
video_selected = _selected_provider(video_gen_cfg)
# Lockstep with tools.tool_backend_helpers.read_selection: these are
# merged-config sections, so the legacy DEFAULT_CONFIG-seeded
# ``stt.provider: local`` COULD appear here without a user pick on old
# versions. Current DEFAULT_CONFIG no longer seeds it, so a merged
# ``local`` implies the raw file holds it — a genuine selection.
# Managed selection flags (replace the legacy use_gateway reads —
# use_gateway is now interpreted only inside _selected_provider).
web_use_gateway = web_selected == "nous"
tts_use_gateway = tts_selected == "nous"
stt_use_gateway = stt_selected == "nous"
browser_use_gateway = browser_selected == "nous"
image_use_gateway = image_selected == "nous"
video_use_gateway = video_selected == "nous"
# The "nous" selection is serviced by a concrete vendor implementation —
# normalize the current-provider labels so downstream vendor checks hold.
if web_backend == "nous" or web_use_gateway:
web_backend = "firecrawl"
if tts_provider == "nous" or tts_use_gateway:
tts_provider = "openai"
if stt_provider == "nous" or stt_use_gateway:
stt_provider = "openai"
if browser_provider == "nous" or browser_use_gateway:
browser_provider = "browser-use"
direct_exa = bool(get_env_value("EXA_API_KEY"))
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
@@ -549,6 +603,27 @@ def get_nous_subscription_features(
managed_enabled=managed_tools_flag,
)
# Strict selection: a stored VENDOR selection pins the category to direct
# credentials — managed availability must not light the feature up (the
# runtime will error, not reroute), and camofox/local selections must not
# be pre-empted by env credentials for other providers.
if web_selected is not None and not web_use_gateway:
managed_web_available = False
if image_selected is not None and not image_use_gateway:
managed_image_available = False
if video_selected is not None and not video_use_gateway:
managed_video_available = False
if tts_selected is not None and not tts_use_gateway:
managed_tts_available = False
if stt_selected is not None and not stt_use_gateway:
managed_stt_available = False
if browser_selected is not None and not browser_use_gateway:
managed_browser_available = False
if browser_selected is not None and browser_selected != "camofox":
# CAMOFOX_URL is the server address, not a selection: an explicit
# different browser choice wins over the env var.
direct_camofox = False
web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl
web_active = bool(
web_tool_enabled
@@ -664,17 +739,10 @@ def get_nous_subscription_features(
modal_active = False
modal_direct_override = False
tts_explicit_configured = False
raw_tts_cfg = config.get("tts")
if isinstance(raw_tts_cfg, dict) and "provider" in raw_tts_cfg:
tts_explicit_configured = tts_provider not in {"", "edge"}
# STT considers any non-default provider explicit. "local" is the
# DEFAULT_CONFIG seed, so seeing it doesn't mean the user picked it.
stt_explicit_configured = False
raw_stt_cfg = config.get("stt")
if isinstance(raw_stt_cfg, dict) and "provider" in raw_stt_cfg:
stt_explicit_configured = stt_provider not in {"", "local"}
# Explicit-configured mirrors the stored selections computed above so
# status/picker markers stay in lockstep with runtime dispatch.
tts_explicit_configured = tts_selected is not None and tts_selected != "edge"
stt_explicit_configured = stt_selected is not None
features = {
"web": NousFeatureState(
@@ -698,8 +766,8 @@ def get_nous_subscription_features(
managed_by_nous=image_managed,
direct_override=image_active and not image_managed,
toolset_enabled=image_tool_enabled,
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
explicit_configured=direct_fal,
current_provider="FAL" if (image_selected not in (None, "nous") or (image_selected is None and direct_fal)) else ("Nous Subscription" if (image_managed or image_use_gateway) else ""),
explicit_configured=image_selected is not None or direct_fal,
),
"video_gen": NousFeatureState(
key="video_gen",
@@ -710,8 +778,8 @@ def get_nous_subscription_features(
managed_by_nous=video_managed,
direct_override=video_active and not video_managed,
toolset_enabled=video_tool_enabled,
current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""),
explicit_configured=direct_fal_video,
current_provider="FAL" if (video_selected not in (None, "nous") or (video_selected is None and direct_fal_video)) else ("Nous Subscription" if (video_managed or video_use_gateway) else ""),
explicit_configured=video_selected is not None or direct_fal_video,
),
"tts": NousFeatureState(
key="tts",
@@ -823,24 +891,26 @@ def apply_nous_managed_defaults(
or get_env_value("FIRECRAWL_API_KEY")
or get_env_value("FIRECRAWL_API_URL")
):
web_cfg["backend"] = "firecrawl"
web_cfg["backend"] = "nous"
web_cfg.pop("use_gateway", None)
changed.add("web")
if "tts" in selected_toolsets and not features.tts.explicit_configured and not (
resolve_openai_audio_api_key()
or get_env_value("ELEVENLABS_API_KEY")
):
tts_cfg["provider"] = "openai"
tts_cfg["provider"] = "nous"
tts_cfg.pop("use_gateway", None)
changed.add("tts")
# STT: same pattern as TTS. The DEFAULT_CONFIG seed is "local"
# (requires `pip install faster-whisper`); for Nous subscribers we
# flip it to "openai" so the managed audio gateway handles transcription
# via the same auth as TTS. Skipped when the user has explicitly
# configured STT, has direct credentials for a non-managed provider,
# has a working local backend (faster-whisper installed or a custom
# local command — strong intent signal that "local" was a choice, not
# just the DEFAULT_CONFIG seed), or isn't entitled to the managed
# flip it to the managed selection so the managed audio gateway handles
# transcription via the same auth as TTS. Skipped when the user has
# explicitly configured STT, has direct credentials for a non-managed
# provider, has a working local backend (faster-whisper installed or a
# custom local command — strong intent signal that "local" was a choice,
# not just the DEFAULT_CONFIG seed), or isn't entitled to the managed
# "openai-audio" category (flipping would point at a gateway that
# refuses them, silently breaking voice transcription).
if (
@@ -854,14 +924,16 @@ def apply_nous_managed_defaults(
and features.account_info is not None
and features.account_info.tool_gateway_entitled_for("openai-audio")
):
stt_cfg["provider"] = "openai"
stt_cfg["provider"] = "nous"
stt_cfg.pop("use_gateway", None)
changed.add("stt")
if "browser" in selected_toolsets and not features.browser.explicit_configured and not (
get_env_value("BROWSER_USE_API_KEY")
or get_env_value("BROWSERBASE_API_KEY")
):
browser_cfg["cloud_provider"] = "browser-use"
browser_cfg["cloud_provider"] = "nous"
browser_cfg.pop("use_gateway", None)
changed.add("browser")
if "image_gen" in selected_toolsets and not fal_key_is_configured():
@@ -869,7 +941,8 @@ def apply_nous_managed_defaults(
if not isinstance(image_cfg, dict):
image_cfg = {}
config["image_gen"] = image_cfg
image_cfg["use_gateway"] = True
image_cfg["provider"] = "nous"
image_cfg.pop("use_gateway", None)
changed.add("image_gen")
# Video gen is not funded by the free tool pool, so only wire managed video
@@ -883,8 +956,8 @@ def apply_nous_managed_defaults(
if not isinstance(video_cfg, dict):
video_cfg = {}
config["video_gen"] = video_cfg
video_cfg["provider"] = "fal"
video_cfg["use_gateway"] = True
video_cfg["provider"] = "nous"
video_cfg.pop("use_gateway", None)
changed.add("video_gen")
return changed
@@ -1050,23 +1123,23 @@ def apply_gateway_defaults(
config["browser"] = browser_cfg
if "web" in tool_keys:
web_cfg["backend"] = "firecrawl"
web_cfg["use_gateway"] = True
web_cfg["backend"] = "nous"
web_cfg.pop("use_gateway", None)
changed.add("web")
if "tts" in tool_keys:
tts_cfg["provider"] = "openai"
tts_cfg["use_gateway"] = True
tts_cfg["provider"] = "nous"
tts_cfg.pop("use_gateway", None)
changed.add("tts")
if "stt" in tool_keys:
stt_cfg["provider"] = "openai"
stt_cfg["use_gateway"] = True
stt_cfg["provider"] = "nous"
stt_cfg.pop("use_gateway", None)
changed.add("stt")
if "browser" in tool_keys:
browser_cfg["cloud_provider"] = "browser-use"
browser_cfg["use_gateway"] = True
browser_cfg["cloud_provider"] = "nous"
browser_cfg.pop("use_gateway", None)
changed.add("browser")
if "image_gen" in tool_keys:
@@ -1074,7 +1147,8 @@ def apply_gateway_defaults(
if not isinstance(image_cfg, dict):
image_cfg = {}
config["image_gen"] = image_cfg
image_cfg["use_gateway"] = True
image_cfg["provider"] = "nous"
image_cfg.pop("use_gateway", None)
changed.add("image_gen")
if "video_gen" in tool_keys:
@@ -1082,8 +1156,8 @@ def apply_gateway_defaults(
if not isinstance(video_cfg, dict):
video_cfg = {}
config["video_gen"] = video_cfg
video_cfg["provider"] = "fal"
video_cfg["use_gateway"] = True
video_cfg["provider"] = "nous"
video_cfg.pop("use_gateway", None)
changed.add("video_gen")
return changed
+186 -99
View File
@@ -31,7 +31,7 @@ from hermes_cli.nous_subscription import (
get_nous_subscription_features,
)
from hermes_cli.nous_account import format_nous_portal_entitlement_message
from tools.tool_backend_helpers import fal_key_is_configured
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, fal_key_is_configured
from utils import base_url_hostname, is_truthy_value
logger = logging.getLogger(__name__)
@@ -3872,41 +3872,54 @@ def _is_provider_active(
image_cfg = config.get("image_gen", {})
if isinstance(image_cfg, dict):
configured_provider = image_cfg.get("provider")
if configured_provider not in {None, "", "fal"}:
if configured_provider not in {None, "", "fal", NOUS_MANAGED_PROVIDER}:
return False
if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False):
if (
configured_provider != NOUS_MANAGED_PROVIDER
and image_cfg.get("use_gateway") is not None
and not is_truthy_value(image_cfg.get("use_gateway"), default=False)
):
return False
return feature.managed_by_nous
if managed_feature == "video_gen":
video_cfg = config.get("video_gen", {})
if isinstance(video_cfg, dict):
configured_provider = video_cfg.get("provider")
if configured_provider not in {None, "", "fal"}:
if configured_provider not in {None, "", "fal", NOUS_MANAGED_PROVIDER}:
return False
if video_cfg.get("use_gateway") is not None and not is_truthy_value(video_cfg.get("use_gateway"), default=False):
if (
configured_provider != NOUS_MANAGED_PROVIDER
and video_cfg.get("use_gateway") is not None
and not is_truthy_value(video_cfg.get("use_gateway"), default=False)
):
return False
return feature.managed_by_nous
if provider.get("tts_provider"):
return (
feature.managed_by_nous
and cfg_get(config, "tts", "provider") == provider["tts_provider"]
and cfg_get(config, "tts", "provider")
in {provider["tts_provider"], NOUS_MANAGED_PROVIDER}
)
if provider.get("stt_provider"):
return (
feature.managed_by_nous
and cfg_get(config, "stt", "provider") == provider["stt_provider"]
and cfg_get(config, "stt", "provider")
in {provider["stt_provider"], NOUS_MANAGED_PROVIDER}
)
if "browser_provider" in provider:
# Browser Use mode is a driver on top of the provider (it attaches
# to the provider's CDP endpoint), so the provider row stays
# active alongside the Browser Use row.
current = cfg_get(config, "browser", "cloud_provider")
return feature.managed_by_nous and provider["browser_provider"] == current
return feature.managed_by_nous and current in {
provider["browser_provider"],
NOUS_MANAGED_PROVIDER,
}
if provider.get("web_backend"):
current = cfg_get(config, "web", "backend")
return (
feature.managed_by_nous
and current == provider["web_backend"]
and current in {provider["web_backend"], NOUS_MANAGED_PROVIDER}
and _web_tier_matches(provider, config)
)
return feature.managed_by_nous
@@ -4223,20 +4236,19 @@ def _configure_xai_imagine_storage(section_name: str, config: dict) -> None:
def _select_plugin_image_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
"""Persist a plugin-backed image generation provider selection.
``use_gateway`` mirrors :func:`_select_plugin_video_gen_provider`: a
provider picked through the Nous-managed flow must keep routing through
the gateway. Hardcoding ``False`` here silently flipped Nous-managed FAL
picks onto the user's personal FAL_KEY — _write_provider_config sets
``image_gen.use_gateway = True`` for a managed pick, and this function
runs AFTER it, so the hardcoded value clobbered the managed flag.
``use_gateway=True`` marks a provider picked through the Nous-managed
flow: the stored selection becomes ``image_gen.provider: nous`` (the
single provider string the runtime switches on). BYOK picks store the
plugin name. Any legacy ``use_gateway`` key is removed so old-config
read-time shims cannot override the fresh selection.
"""
img_cfg = config.setdefault("image_gen", {})
if not isinstance(img_cfg, dict):
img_cfg = {}
config["image_gen"] = img_cfg
img_cfg["provider"] = plugin_name
img_cfg["use_gateway"] = use_gateway
_print_success(f" image_gen.provider set to: {plugin_name}")
img_cfg["provider"] = NOUS_MANAGED_PROVIDER if use_gateway else plugin_name
img_cfg.pop("use_gateway", None)
_print_success(f" image_gen.provider set to: {img_cfg['provider']}")
_configure_imagegen_model_for_plugin(plugin_name, config)
if plugin_name == "xai":
_configure_xai_imagine_storage("image_gen", config)
@@ -4376,14 +4388,19 @@ def _configure_stt_model(stt_provider: str, config: dict) -> None:
def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
"""Persist a plugin-backed video generation provider selection."""
"""Persist a plugin-backed video generation provider selection.
Mirrors :func:`_select_plugin_image_gen_provider`: managed picks store
``video_gen.provider: nous``; BYOK picks store the plugin name; any
legacy ``use_gateway`` key is removed.
"""
vid_cfg = config.setdefault("video_gen", {})
if not isinstance(vid_cfg, dict):
vid_cfg = {}
config["video_gen"] = vid_cfg
vid_cfg["provider"] = plugin_name
vid_cfg["use_gateway"] = use_gateway
_print_success(f" video_gen.provider set to: {plugin_name}")
vid_cfg["provider"] = NOUS_MANAGED_PROVIDER if use_gateway else plugin_name
vid_cfg.pop("use_gateway", None)
_print_success(f" video_gen.provider set to: {vid_cfg['provider']}")
_configure_videogen_model_for_plugin(plugin_name, config)
if plugin_name == "xai":
_configure_xai_imagine_storage("video_gen", config)
@@ -4394,32 +4411,45 @@ def _write_provider_config(provider: dict, config: dict, *, managed_feature) ->
This is the pure, non-interactive core of :func:`_configure_provider` —
it writes ``tts.provider`` / ``browser.cloud_provider`` / ``web.backend``
and the ``use_gateway`` flags based on the provider's markers, but does
NOT prompt for env vars, run post-setup hooks, gate on Nous auth, or run
interactive model pickers. Both the CLI configurator and the desktop GUI
``PUT .../provider`` endpoint call through here so there is one code path.
based on the provider's markers, but does NOT prompt for env vars, run
post-setup hooks, gate on Nous auth, or run interactive model pickers.
Both the CLI configurator and the desktop GUI ``PUT .../provider``
endpoint call through here so there is one code path.
Selection model: every row writes exactly ONE provider string per
category. Managed "Nous Subscription" rows write ``nous``; BYOK rows
write the vendor name. ``use_gateway`` is no longer written — a fresh
pick removes any legacy key from the touched section so the read-time
legacy shim (use_gateway: true ⇒ nous) cannot override the new choice.
"""
def _set_selection(section_key: str, name_key: str, vendor_value) -> None:
section = config.setdefault(section_key, {})
if not isinstance(section, dict):
section = {}
config[section_key] = section
section[name_key] = (
NOUS_MANAGED_PROVIDER if managed_feature else vendor_value
)
section.pop("use_gateway", None)
# Set TTS provider in config if applicable
if provider.get("tts_provider"):
tts_cfg = config.setdefault("tts", {})
tts_cfg["provider"] = provider["tts_provider"]
tts_cfg["use_gateway"] = bool(managed_feature)
_set_selection("tts", "provider", provider["tts_provider"])
# Set STT provider in config if applicable
if provider.get("stt_provider"):
stt_cfg = config.setdefault("stt", {})
stt_cfg["provider"] = provider["stt_provider"]
stt_cfg["use_gateway"] = bool(managed_feature)
_set_selection("stt", "provider", provider["stt_provider"])
# Set browser cloud provider in config if applicable
if "browser_provider" in provider:
bp = provider["browser_provider"]
browser_cfg = config.setdefault("browser", {})
if bp:
browser_cfg["cloud_provider"] = bp
# Browser Use mode (browser.backend) composes with the provider —
# switching providers keeps the driver choice intact.
browser_cfg["use_gateway"] = bool(managed_feature)
if bp or managed_feature:
# Browser Use mode (browser.backend) composes with the provider —
# switching providers keeps the driver choice intact.
_set_selection("browser", "cloud_provider", bp)
else:
browser_cfg.pop("use_gateway", None)
if provider.get("browser_backend"):
browser_cfg = config.setdefault("browser", {})
@@ -4427,36 +4457,61 @@ def _write_provider_config(provider: dict, config: dict, *, managed_feature) ->
# Set web search backend in config if applicable
if provider.get("web_backend"):
web_cfg = config.setdefault("web", {})
web_cfg["backend"] = provider["web_backend"]
web_cfg["use_gateway"] = bool(managed_feature)
if provider.get("web_tier"):
tiers = web_cfg.setdefault("provider_tier", {})
if isinstance(tiers, dict):
tiers[provider["web_backend"]] = provider["web_tier"]
else:
stale_tiers = web_cfg.get("provider_tier")
if isinstance(stale_tiers, dict):
stale_tiers.pop(provider["web_backend"], None)
_set_selection("web", "backend", provider["web_backend"])
web_cfg = config.get("web")
if isinstance(web_cfg, dict):
if provider.get("web_tier"):
tiers = web_cfg.setdefault("provider_tier", {})
if isinstance(tiers, dict):
tiers[provider["web_backend"]] = provider["web_tier"]
else:
stale_tiers = web_cfg.get("provider_tier")
if isinstance(stale_tiers, dict):
stale_tiers.pop(provider["web_backend"], None)
# Set computer_use backend in config if applicable
if provider.get("computer_use_backend"):
cu_cfg = config.setdefault("computer_use", {})
cu_cfg["backend"] = provider["computer_use_backend"]
# For tools without a specific config key (e.g. image_gen), still
# track use_gateway so the runtime knows the user's intent.
# Managed rows for categories without a marker handled above (e.g. the
# image_gen/video_gen "Nous Subscription" rows carry only
# managed_nous_feature) still persist the "nous" selection.
if managed_feature and managed_feature not in {"web", "tts", "stt", "browser"}:
config.setdefault(managed_feature, {})["use_gateway"] = True
section = config.setdefault(managed_feature, {})
if isinstance(section, dict):
section["provider"] = NOUS_MANAGED_PROVIDER
section.pop("use_gateway", None)
elif not managed_feature:
# User picked a non-gateway provider — find which category this
# belongs to and clear use_gateway if it was previously set.
for cat_key, cat in TOOL_CATEGORIES.items():
if provider in cat.get("providers", []):
section = config.get(cat_key)
if isinstance(section, dict) and section.get("use_gateway"):
section["use_gateway"] = False
break
# User picked a non-gateway provider — clear any stale legacy
# use_gateway key on the category so the read-time shim cannot
# override the fresh selection. Resolve the category from the
# provider's own markers first (plugin-injected rows are NOT in
# TOOL_CATEGORIES' hardcoded provider lists and previously skipped
# this clear), then fall back to the category-membership walk.
marker_sections = {
"tts_provider": "tts",
"stt_provider": "stt",
"browser_provider": "browser",
"web_backend": "web",
"image_gen_plugin_name": "image_gen",
"imagegen_backend": "image_gen",
"video_gen_plugin_name": "video_gen",
}
cleared = False
for marker, section_key in marker_sections.items():
if provider.get(marker) or marker in provider:
section = config.get(section_key)
if isinstance(section, dict):
section.pop("use_gateway", None)
cleared = True
if not cleared:
for cat_key, cat in TOOL_CATEGORIES.items():
if provider in cat.get("providers", []):
section = config.get(cat_key)
if isinstance(section, dict):
section.pop("use_gateway", None)
break
def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> None:
@@ -4488,15 +4543,17 @@ def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> N
# Plugin-registered image/video gen backends record the provider name in
# their own config section. Write that here (without the interactive
# model picker the CLI runs afterwards — model choice is a separate GUI
# flow).
# flow). Managed picks store the "nous" selection.
plugin_name = provider.get("image_gen_plugin_name")
if plugin_name:
img_cfg = config.setdefault("image_gen", {})
if not isinstance(img_cfg, dict):
img_cfg = {}
config["image_gen"] = img_cfg
img_cfg["provider"] = plugin_name
img_cfg["use_gateway"] = bool(managed_feature)
img_cfg["provider"] = (
NOUS_MANAGED_PROVIDER if managed_feature else plugin_name
)
img_cfg.pop("use_gateway", None)
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
@@ -4504,15 +4561,22 @@ def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> N
if not isinstance(vid_cfg, dict):
vid_cfg = {}
config["video_gen"] = vid_cfg
vid_cfg["provider"] = video_plugin
vid_cfg["use_gateway"] = bool(managed_feature)
vid_cfg["provider"] = (
NOUS_MANAGED_PROVIDER if managed_feature else video_plugin
)
vid_cfg.pop("use_gateway", None)
# In-tree FAL imagegen backend: keep image_gen.provider on the legacy
# path (mirrors _configure_provider).
if provider.get("imagegen_backend"):
# In-tree FAL imagegen backend (BYOK): always persist the explicit
# ``image_gen.provider: fal`` selection — historically this row could
# leave the provider key unset, making a deliberate BYOK pick
# indistinguishable from a never-configured install.
if provider.get("imagegen_backend") and not managed_feature:
img_cfg = config.setdefault("image_gen", {})
if isinstance(img_cfg, dict) and img_cfg.get("provider") not in {None, "", "fal"}:
img_cfg["provider"] = "fal"
if not isinstance(img_cfg, dict):
img_cfg = {}
config["image_gen"] = img_cfg
img_cfg["provider"] = "fal"
img_cfg.pop("use_gateway", None)
def _configure_provider(
@@ -4566,8 +4630,10 @@ def _configure_provider(
# Set TTS provider in config if applicable
if provider.get("tts_provider"):
tts_cfg = config.setdefault("tts", {})
tts_cfg["provider"] = provider["tts_provider"]
tts_cfg["use_gateway"] = bool(managed_feature)
tts_cfg["provider"] = (
NOUS_MANAGED_PROVIDER if managed_feature else provider["tts_provider"]
)
tts_cfg.pop("use_gateway", None)
# Set STT provider in config if applicable
if provider.get("stt_provider"):
@@ -4615,13 +4681,15 @@ def _configure_provider(
backend = provider.get("imagegen_backend")
if backend:
_configure_imagegen_model(backend, config)
# In-tree FAL is the only non-plugin backend today. Keep
# image_gen.provider clear so the dispatch shim falls through
# to the legacy FAL path.
# In-tree FAL is the only non-plugin backend today. Persist the
# explicit selection: "nous" for a managed row, "fal" for BYOK.
img_cfg = config.setdefault("image_gen", {})
if isinstance(img_cfg, dict) and img_cfg.get("provider") not in {None, "", "fal"}:
img_cfg["provider"] = "fal"
# STT providers prompt for model selection after provider pick
if isinstance(img_cfg, dict):
img_cfg["provider"] = (
NOUS_MANAGED_PROVIDER if managed_feature else "fal"
)
img_cfg.pop("use_gateway", None)
# STT providers prompt for model selection after backend pick
# (skipped for managed rows — the gateway pins the model).
if provider.get("stt_provider") and not managed_feature:
_configure_stt_model(provider["stt_provider"], config)
@@ -4699,8 +4767,11 @@ def _configure_provider(
if backend:
_configure_imagegen_model(backend, config)
img_cfg = config.setdefault("image_gen", {})
if isinstance(img_cfg, dict) and img_cfg.get("provider") not in {None, "", "fal"}:
img_cfg["provider"] = "fal"
if isinstance(img_cfg, dict):
img_cfg["provider"] = (
NOUS_MANAGED_PROVIDER if managed_feature else "fal"
)
img_cfg.pop("use_gateway", None)
# STT providers prompt for model selection after env vars are in.
if provider.get("stt_provider") and not managed_feature:
_configure_stt_model(provider["stt_provider"], config)
@@ -5075,22 +5146,33 @@ def _reconfigure_provider(
)
return
# Selection model (mirrors _write_provider_config): every row writes ONE
# provider string per category — "nous" for managed rows, the vendor name
# for BYOK rows — and drops any legacy use_gateway key so the read-time
# shim (use_gateway: true ⇒ nous) cannot override the fresh pick.
if provider.get("tts_provider"):
tts_cfg = config.setdefault("tts", {})
tts_cfg["provider"] = provider["tts_provider"]
tts_cfg["use_gateway"] = bool(managed_feature)
tts_cfg["provider"] = (
NOUS_MANAGED_PROVIDER if managed_feature else provider["tts_provider"]
)
tts_cfg.pop("use_gateway", None)
_print_success(f" TTS provider set to: {provider['tts_provider']}")
if provider.get("stt_provider"):
stt_cfg = config.setdefault("stt", {})
stt_cfg["provider"] = provider["stt_provider"]
stt_cfg["use_gateway"] = bool(managed_feature)
stt_cfg["provider"] = (
NOUS_MANAGED_PROVIDER if managed_feature else provider["stt_provider"]
)
stt_cfg.pop("use_gateway", None)
_print_success(f" STT provider set to: {provider['stt_provider']}")
if "browser_provider" in provider:
bp = provider["browser_provider"]
browser_cfg = config.setdefault("browser", {})
if bp == "local":
if managed_feature:
browser_cfg["cloud_provider"] = NOUS_MANAGED_PROVIDER
_print_success(f" Browser cloud provider set to: {bp or 'nous'}")
elif bp == "local":
browser_cfg["cloud_provider"] = "local"
_print_success(" Browser set to local mode")
elif bp:
@@ -5098,7 +5180,7 @@ def _reconfigure_provider(
_print_success(f" Browser cloud provider set to: {bp}")
# Browser Use mode (browser.backend) composes with the provider —
# switching providers keeps the driver choice intact.
browser_cfg["use_gateway"] = bool(managed_feature)
browser_cfg.pop("use_gateway", None)
if provider.get("browser_backend"):
browser_cfg = config.setdefault("browser", {})
@@ -5108,8 +5190,10 @@ def _reconfigure_provider(
# Set web search backend in config if applicable
if provider.get("web_backend"):
web_cfg = config.setdefault("web", {})
web_cfg["backend"] = provider["web_backend"]
web_cfg["use_gateway"] = bool(managed_feature)
web_cfg["backend"] = (
NOUS_MANAGED_PROVIDER if managed_feature else provider["web_backend"]
)
web_cfg.pop("use_gateway", None)
if provider.get("web_tier"):
tiers = web_cfg.setdefault("provider_tier", {})
if isinstance(tiers, dict):
@@ -5135,13 +5219,14 @@ def _reconfigure_provider(
if not isinstance(section, dict):
section = {}
config[managed_feature] = section
section["use_gateway"] = True
section["provider"] = NOUS_MANAGED_PROVIDER
section.pop("use_gateway", None)
elif not managed_feature:
for cat_key, cat in TOOL_CATEGORIES.items():
if provider in cat.get("providers", []):
section = config.get(cat_key)
if isinstance(section, dict) and section.get("use_gateway"):
section["use_gateway"] = False
if isinstance(section, dict):
section.pop("use_gateway", None)
break
if not env_vars:
@@ -5166,14 +5251,14 @@ def _reconfigure_provider(
if backend == "fal":
img_cfg = config.setdefault("image_gen", {})
if isinstance(img_cfg, dict):
img_cfg["provider"] = "fal"
# A managed (Nous Subscription) row also carries
# imagegen_backend="fal" — the model picker runs AFTER
# _write_provider_config set use_gateway=True, so an
# unconditional False here silently flipped managed
# picks onto the user's personal FAL_KEY (same class
# as fe63353cb, which fixed the plugin-provider path).
img_cfg["use_gateway"] = bool(managed_feature)
# imagegen_backend="fal" — store the "nous" selection
# for it, "fal" for BYOK, and drop any legacy
# use_gateway key.
img_cfg["provider"] = (
NOUS_MANAGED_PROVIDER if managed_feature else "fal"
)
img_cfg.pop("use_gateway", None)
# STT providers prompt for model selection on reconfig too.
if provider.get("stt_provider") and not managed_feature:
_configure_stt_model(provider["stt_provider"], config)
@@ -5215,10 +5300,12 @@ def _reconfigure_provider(
if backend == "fal":
img_cfg = config.setdefault("image_gen", {})
if isinstance(img_cfg, dict):
img_cfg["provider"] = "fal"
# Same managed-row guard as the no-env-vars branch above:
# never clobber a Nous-managed pick back onto direct keys.
img_cfg["use_gateway"] = bool(managed_feature)
img_cfg["provider"] = (
NOUS_MANAGED_PROVIDER if managed_feature else "fal"
)
img_cfg.pop("use_gateway", None)
# STT providers prompt for model selection on reconfig too.
if provider.get("stt_provider") and not managed_feature:
+56 -19
View File
@@ -134,37 +134,74 @@ class BrowserUseBrowserProvider(BrowserProvider):
peek_nous_access_token,
resolve_managed_tool_gateway,
)
from tools.tool_backend_helpers import prefers_gateway
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
read_selection,
)
def _managed_config() -> Optional[Dict[str, Any]]:
# Keep availability scans off the synchronous OAuth refresh path.
managed = resolve_managed_tool_gateway(
"browser-use",
token_reader=None if refresh_token else peek_nous_access_token,
)
if managed is None:
return None
return {
"api_key": managed.nous_user_token,
"base_url": managed.gateway_origin.rstrip("/"),
"managed_mode": True,
}
# Direct API key wins unless the user has explicitly opted into the
# managed Nous gateway via ``tool_gateway.browser: gateway``.
api_key = get_secret("BROWSER_USE_API_KEY")
if api_key and not prefers_gateway("browser"):
selected = read_selection("browser")
# Strict selection: "nous" (or legacy use_gateway: true) → managed
# gateway ONLY; any other stored browser selection → direct API key
# ONLY (no silent managed fallback); never-configured → legacy
# behavior (direct key when present, else managed gateway).
if selected == NOUS_MANAGED_PROVIDER:
return _managed_config()
if selected is not None:
if api_key:
return {
"api_key": api_key,
"base_url": _BASE_URL,
"managed_mode": False,
}
return None
if api_key:
return {
"api_key": api_key,
"base_url": _BASE_URL,
"managed_mode": False,
}
# Keep availability scans off the synchronous OAuth refresh path.
managed = resolve_managed_tool_gateway(
"browser-use",
token_reader=None if refresh_token else peek_nous_access_token,
)
if managed is None:
return None
return {
"api_key": managed.nous_user_token,
"base_url": managed.gateway_origin.rstrip("/"),
"managed_mode": True,
}
return _managed_config()
def _get_config(self) -> Dict[str, Any]:
from tools.tool_backend_helpers import managed_nous_tools_enabled
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
managed_nous_tools_enabled,
read_selection,
selection_error,
)
config = self._get_config_or_none()
if config is None:
selected = read_selection("browser")
if selected == NOUS_MANAGED_PROVIDER:
raise ValueError(selection_error(
"browser",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
))
if selected is not None:
raise ValueError(selection_error(
"browser",
selected,
"BROWSER_USE_API_KEY is not set",
))
message = (
"Browser Use requires a direct BROWSER_USE_API_KEY credential."
)
+18 -7
View File
@@ -178,20 +178,31 @@ def _resolve_model(explicit: Optional[str] = None) -> Tuple[str, Dict[str, Any]]
def _resolve_managed_krea_gateway():
"""Return managed Krea gateway config when the user is on the managed path.
Mirrors ``_resolve_managed_fal_gateway`` in ``tools/image_generation_tool.py``:
the Nous-hosted Krea gateway wins when it is resolvable AND either no direct
``KREA_API_KEY`` is configured or the user explicitly opted into the gateway
for ``image_gen``. Returns ``None`` (direct/BYO path) otherwise, and never
raises — plugin discovery and availability scans must stay robust.
Strict selection model: the managed Krea gateway is used when the stored
``image_gen`` selection is ``nous`` (or legacy ``use_gateway: true``), or
on a never-configured install when no direct ``KREA_API_KEY`` exists.
An explicit vendor selection (``krea``, ``fal``, ...) pins the direct
path. Returns ``None`` (direct/BYO path) otherwise, and never raises —
plugin discovery and availability scans must stay robust.
"""
try:
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import prefers_gateway
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
read_selection,
)
except Exception as exc: # noqa: BLE001
logger.debug("Managed Krea gateway resolution unavailable: %s", exc)
return None
if get_secret("KREA_API_KEY") and not prefers_gateway("image_gen"):
try:
selected = read_selection("image_gen")
except Exception: # noqa: BLE001
selected = None
if selected is not None and selected != NOUS_MANAGED_PROVIDER:
# Explicit vendor selection: direct credentials only.
return None
if selected is None and get_secret("KREA_API_KEY"):
return None
try:
+22 -16
View File
@@ -37,23 +37,29 @@ class CopilotProfile(ProviderProfile):
effort = reasoning_config.get("effort", "medium")
# Honor the requested level when the live Copilot catalog
# lists it as supported: gpt-5.5/gpt-5.4 DO support
# ``xhigh``. Only downgrade levels the catalog does NOT
# list (e.g. ``xhigh``/``max`` on models capped lower, or
# ``minimal`` where unsupported), choosing the nearest
# weaker supported level rather than forwarding verbatim.
#
# (Previously this unconditionally mapped xhigh->high, a
# stale guard that silently capped models which do support
# the higher level.)
# ``xhigh``. Otherwise clamp to the nearest WEAKER
# supported level via the shared ladder helper — the old
# ad-hoc rules dropped everything unrecognized to
# ``medium``, which inverted the ladder: ``ultra`` (the
# strongest ask) resolved weaker than an explicit
# ``high`` (#74295).
if effort not in supported_efforts:
if effort == "xhigh" and "high" in supported_efforts:
effort = "high"
elif effort == "minimal" and "low" in supported_efforts:
effort = "low"
elif "medium" in supported_efforts:
effort = "medium"
else:
effort = supported_efforts[0]
from hermes_cli.models import (
clamp_reasoning_effort_to_supported,
)
effort = clamp_reasoning_effort_to_supported(
effort, list(supported_efforts)
)
if effort not in supported_efforts:
# Unrecognized/bespoke level the ladder can't
# place — fall back to medium, then to the
# catalog's first entry.
effort = (
"medium"
if "medium" in supported_efforts
else supported_efforts[0]
)
if effort in supported_efforts:
extra_body["reasoning"] = {"effort": effort}
elif supported_efforts:
+7 -1
View File
@@ -64,7 +64,13 @@ class CustomProfile(ProviderProfile):
top_level["reasoning_effort"] = "none"
extra_body["think"] = False
elif _effort:
top_level["reasoning_effort"] = _effort
# "ultra" is Hermes-internal ladder vocabulary — no known
# OpenAI-compatible backend accepts it verbatim (GLM/ARK,
# vLLM and SGLang all top out at "max"); cap it at the wire
# ceiling instead of forwarding a guaranteed 400 (#89503).
top_level["reasoning_effort"] = (
"max" if _effort == "ultra" else _effort
)
return extra_body, top_level
+68 -8
View File
@@ -530,14 +530,44 @@ _managed_fal_video_client_lock = threading.Lock()
def _resolve_managed_fal_video_gateway():
"""Return managed fal-queue gateway config when the user prefers the gateway
or direct FAL credentials are absent."""
from tools.tool_backend_helpers import fal_key_is_configured, prefers_gateway
"""Resolve the FAL video route from the stored selection.
if fal_key_is_configured() and not prefers_gateway("video_gen"):
return None
Plain switch on the stored ``video_gen`` provider string — mirrors the
image FAL resolver: ``"nous"`` (or legacy ``use_gateway: true``) →
managed only (unentitled ⇒ selection-naming error); any other stored
provider → direct only (missing FAL_KEY ⇒ selection-naming error);
never-configured category → legacy credential autodetect.
"""
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
fal_key_is_configured,
read_selection,
selection_error,
)
selected = read_selection("video_gen")
if selected == NOUS_MANAGED_PROVIDER:
gateway = resolve_managed_tool_gateway("fal-queue")
if gateway is None:
raise ValueError(selection_error(
"video_gen",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
))
return gateway
if selected is not None:
if not fal_key_is_configured():
raise ValueError(selection_error(
"video_gen",
selected,
"FAL_KEY is not set",
))
return None
# Never-configured category: legacy credential autodetect (do NOT persist).
if fal_key_is_configured():
return None
return resolve_managed_tool_gateway("fal-queue")
@@ -598,12 +628,28 @@ def _submit_fal_video_request(endpoint: str, arguments: Dict[str, Any]):
def _check_fal_video_available() -> bool:
"""True if the FAL.ai video backend is reachable (direct key or managed gateway)."""
from tools.tool_backend_helpers import fal_key_is_configured
"""True if the FAL video backend selected via `hermes tools` (or, on a
never-configured install, any FAL backend) is reachable.
Never raises — a stored-but-broken selection reports False here; the
honest selection-naming error surfaces at call time from
``_resolve_managed_fal_video_gateway``.
"""
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
fal_key_is_configured,
read_selection,
)
selected = read_selection("video_gen")
if selected == NOUS_MANAGED_PROVIDER:
return resolve_managed_tool_gateway("fal-queue") is not None
if selected is not None:
return fal_key_is_configured()
if fal_key_is_configured():
return True
return _resolve_managed_fal_video_gateway() is not None
return resolve_managed_tool_gateway("fal-queue") is not None
# ---------------------------------------------------------------------------
@@ -766,6 +812,20 @@ class FALVideoGenProvider(VideoGenProvider):
**kwargs: Any,
) -> Dict[str, Any]:
if not _check_fal_video_available():
from tools.tool_backend_helpers import read_selection
if read_selection("video_gen") is not None:
# A stored selection that cannot run gets the honest
# selection-naming error from the strict resolver.
try:
_resolve_managed_fal_video_gateway()
except ValueError as exc:
return error_response(
error=str(exc),
error_type="auth_required",
provider="fal",
prompt=prompt,
)
return error_response(
error=(
"No FAL backend available. Either set FAL_KEY "
+84 -24
View File
@@ -48,7 +48,7 @@ from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from typing import Any, Dict, List, NoReturn, Optional, TYPE_CHECKING
from agent.web_search_provider import WebSearchProvider
from tools.url_safety import is_safe_url
@@ -168,11 +168,22 @@ def _has_direct_firecrawl_config() -> bool:
def check_firecrawl_api_key() -> bool:
"""Return True when Firecrawl backend (direct or gateway) is usable.
"""Return True when the Firecrawl backend selected via `hermes tools`
(or, on a never-configured install, either route) is usable.
Re-exported by :mod:`tools.web_tools` for backward compatibility with
existing tests and the ``hermes tools`` setup flow.
"""
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
read_selection,
)
selected = read_selection("web")
if selected == NOUS_MANAGED_PROVIDER:
return _is_tool_gateway_ready()
if selected is not None:
return _has_direct_firecrawl_config()
return _has_direct_firecrawl_config() or _is_tool_gateway_ready()
@@ -188,7 +199,7 @@ def _firecrawl_backend_help_suffix() -> str:
)
def _raise_web_backend_configuration_error() -> None:
def _raise_web_backend_configuration_error() -> "NoReturn":
"""Raise a clear error for unsupported web backend configuration."""
import tools.web_tools as _wt
@@ -212,46 +223,95 @@ def _raise_web_backend_configuration_error() -> None:
def _get_firecrawl_client() -> Any:
"""Get or create the cached Firecrawl client.
When ``web.use_gateway`` is set in config, the managed Tool Gateway is
preferred even if direct Firecrawl credentials are present. Otherwise
direct Firecrawl takes precedence when explicitly configured.
Strict selection semantics (switch on the stored ``web`` selection):
- ``"nous"`` (or legacy ``use_gateway: true``) → managed Tool Gateway
ONLY; unavailable is a selection-naming error (a present
FIRECRAWL_API_KEY does not reroute).
- any other stored web backend → direct Firecrawl ONLY; missing config
is a selection-naming error — never a silent managed fallback billed
to Nous.
- never-configured web section → legacy behavior: direct config when
present, else the managed gateway.
Raises ValueError when neither path is usable.
Raises ValueError when the resolved path is unusable.
The cached client is stored on :mod:`tools.web_tools` (as
``_firecrawl_client`` and ``_firecrawl_client_config``) rather than on
this plugin module so that unit tests that reset the cache via
``tools.web_tools._firecrawl_client = None`` keep working. Helper
functions (``prefers_gateway``, ``resolve_managed_tool_gateway``,
``_read_nous_access_token``, ``Firecrawl``) are also looked up via
:mod:`tools.web_tools` for the same reason — see
:func:`_is_tool_gateway_ready`.
functions (``resolve_managed_tool_gateway``, ``_read_nous_access_token``,
``Firecrawl``) are also looked up via :mod:`tools.web_tools` for the same
reason — see :func:`_is_tool_gateway_ready`.
"""
import tools.web_tools as _wt
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
read_selection,
selection_error,
selection_exists,
)
selected = read_selection("web")
direct_config = _get_direct_firecrawl_config()
if direct_config is not None and not _wt.prefers_gateway("web"):
kwargs, client_config = direct_config
else:
def _managed_kwargs():
managed_gateway = _wt.resolve_managed_tool_gateway(
"firecrawl", token_reader=_wt._read_nous_access_token
)
if managed_gateway is None:
return None
kwargs = {
"api_key": managed_gateway.nous_user_token,
"api_url": managed_gateway.gateway_origin,
}
return kwargs, (
"tool-gateway",
kwargs["api_url"],
managed_gateway.nous_user_token,
)
if selected == NOUS_MANAGED_PROVIDER:
managed = _managed_kwargs()
if managed is None:
logger.error(
"Firecrawl client initialization failed: the Nous "
"Subscription web selection is stored but the tool gateway "
"is unavailable."
)
raise ValueError(selection_error(
"web",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
))
kwargs, client_config = managed
elif selected is not None or selection_exists("web"):
# Stored vendor selection (or per-capability web keys routing to
# firecrawl): direct Firecrawl only.
if direct_config is None:
logger.error(
"Firecrawl client initialization failed: direct Firecrawl "
"selected but FIRECRAWL_API_KEY/FIRECRAWL_API_URL is not set."
)
raise ValueError(selection_error(
"web",
selected or "firecrawl",
"neither FIRECRAWL_API_KEY nor FIRECRAWL_API_URL is set",
))
kwargs, client_config = direct_config
elif direct_config is not None:
kwargs, client_config = direct_config
else:
# Never-configured web section: legacy managed fallback.
managed = _managed_kwargs()
if managed is None:
logger.error(
"Firecrawl client initialization failed: "
"missing direct config and tool-gateway auth."
)
_raise_web_backend_configuration_error()
kwargs = {
"api_key": managed_gateway.nous_user_token,
"api_url": managed_gateway.gateway_origin,
}
client_config = (
"tool-gateway",
kwargs["api_url"],
managed_gateway.nous_user_token,
)
kwargs, client_config = managed
cached = getattr(_wt, "_firecrawl_client", None)
cached_config = getattr(_wt, "_firecrawl_client_config", None)
+56 -4
View File
@@ -503,6 +503,7 @@ class AIAgent:
session_db=None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None,
run_budget_seconds: Optional[float] = None,
fallback_model: Dict[str, Any] = None,
credential_pool=None,
checkpoints_enabled: bool = False,
@@ -592,6 +593,7 @@ class AIAgent:
session_db=session_db,
parent_session_id=parent_session_id,
iteration_budget=iteration_budget,
run_budget_seconds=run_budget_seconds,
fallback_model=fallback_model,
credential_pool=credential_pool,
checkpoints_enabled=checkpoints_enabled,
@@ -1452,10 +1454,40 @@ class AIAgent:
from agent.chat_completion_helpers import estimate_request_context_tokens
est_tokens = estimate_request_context_tokens(api_payload)
if est_tokens > 100_000:
return max(stale_base, 240.0)
if est_tokens > 50_000:
return max(stale_base, 150.0)
return stale_base
timeout = max(stale_base, 240.0)
elif est_tokens > 50_000:
timeout = max(stale_base, 150.0)
else:
timeout = stale_base
# Wall-clock run budget cap: when a run budget is active, an implicit
# (floor-/default-derived) stale timeout is capped at half the
# remaining budget (>= 60s) so a single hung provider call cannot eat
# the whole run — e.g. deepseek-v4-pro's 600s reasoning floor inside a
# 900s eval ceiling. NEVER raises the timeout above what it would
# otherwise be, and an explicit user-configured stale_timeout_seconds
# (or env var) still wins untouched.
run_budget = getattr(self, "run_budget_seconds", None)
if run_budget and not self._stale_timeout_is_explicit():
started = getattr(self, "_run_budget_started_at", None)
if started:
remaining = float(run_budget) - (time.time() - started)
deadline_cap = max(60.0, remaining * 0.5)
if deadline_cap < timeout:
timeout = deadline_cap
return timeout
def _stale_timeout_is_explicit(self) -> bool:
"""True when the user explicitly configured the non-stream stale timeout.
Explicit = provider/model ``stale_timeout_seconds`` in config.yaml or
the ``HERMES_API_CALL_STALE_TIMEOUT`` env var. Reasoning-model floors
and the 90s default are implicit — they yield to the wall-clock run
budget cap; explicit user configuration never does.
"""
if get_provider_stale_timeout(self.provider, self.model) is not None:
return True
return os.getenv("HERMES_API_CALL_STALE_TIMEOUT") is not None
def _codex_silent_hang_hint(self, model: Optional[str] = None) -> Optional[str]:
"""Return an actionable hint when this request matches a known
@@ -8194,12 +8226,32 @@ class AIAgent:
function_result,
failed=failed,
)
# Identical-call loop breaker (agent.stall_guards): notice-only, no
# blocking. Observed on the RAW result (before the loop-warning suffix
# below, whose embedded count changes per call and would defeat
# result-identity matching). Appended here — at result construction,
# before the tool message is built — so it is cache-safe (tool results
# are append-only; nothing already sent to the provider is mutated).
stall_notice = None
if self._stall_guards_enabled():
try:
stall_notice = self._tool_guardrails.observe_identical_call(
tool_name, function_args, function_result,
)
except Exception as exc:
logger.debug("stall-guard identical-call observation failed: %s", exc)
if decision.action in {"warn", "halt"}:
function_result = append_toolguard_guidance(function_result, decision)
if decision.should_halt:
self._set_tool_guardrail_halt(decision)
if stall_notice:
function_result = (function_result or "") + "\n\n" + stall_notice
return function_result
def _stall_guards_enabled(self) -> bool:
"""Config gate for the runtime anti-stall guards (agent.stall_guards)."""
return bool(getattr(self, "_stall_guards", True))
def _guardrail_block_result(self, decision: ToolGuardrailDecision) -> str:
self._set_tool_guardrail_halt(decision)
return toolguard_synthetic_result(decision)
+45
View File
@@ -986,6 +986,51 @@ class TestOpenAIModelExecutionGuidance:
assert isinstance(OPENAI_MODEL_EXECUTION_GUIDANCE, str)
assert len(OPENAI_MODEL_EXECUTION_GUIDANCE) > 100
def test_guidance_covers_external_write_readback(self):
text = OPENAI_MODEL_EXECUTION_GUIDANCE.lower()
assert "read" in text and "back" in text
assert "successful tool call is not a successful task" in text
def test_guidance_covers_count_reconciliation(self):
text = OPENAI_MODEL_EXECUTION_GUIDANCE.lower()
assert "has_more" in text
assert "hard assertions" in text
def test_guidance_covers_literal_preservation(self):
text = OPENAI_MODEL_EXECUTION_GUIDANCE.lower()
assert "normalize" in text
assert "malformed" in text
def test_guidance_covers_retry_differently(self):
text = OPENAI_MODEL_EXECUTION_GUIDANCE.lower()
assert "suspiciously narrow" in text
assert "retry" in text
def test_guidance_gates_completion_on_verification(self):
text = OPENAI_MODEL_EXECUTION_GUIDANCE.lower()
assert "plausible subset" in text
class TestExecutionGuidanceModels:
"""Behavior contracts for the default auto-match model list."""
def test_includes_historical_families(self):
from agent.prompt_builder import EXECUTION_GUIDANCE_MODELS
for fam in ("gpt", "codex", "grok"):
assert fam in EXECUTION_GUIDANCE_MODELS
def test_includes_composio_eval_families(self):
from agent.prompt_builder import EXECUTION_GUIDANCE_MODELS
for fam in ("deepseek", "kimi", "qwen", "glm", "minimax", "mimo", "mistral"):
assert fam in EXECUTION_GUIDANCE_MODELS
def test_excludes_google_and_claude(self):
# Gemini/Gemma get GOOGLE_MODEL_OPERATIONAL_GUIDANCE instead;
# Claude doesn't exhibit the targeted failure modes.
from agent.prompt_builder import EXECUTION_GUIDANCE_MODELS
for fam in ("gemini", "gemma", "claude"):
assert fam not in EXECUTION_GUIDANCE_MODELS
class TestParallelToolCallGuidance:
"""Behavior contracts for the universal parallel-tool-call guidance block.
@@ -0,0 +1,54 @@
"""Wire translation for Hermes' extended reasoning-effort vocabulary (#89503).
Hermes' internal effort set extends the wire vocabulary with ``ultra`` (the
/reasoning command documents none..xhigh|max|ultra). OpenAI-compatible wires —
OpenRouter chief among them — accept exactly max|xhigh|high|medium|low|minimal|
none and reject the extension with HTTP 400:
reasoning.effort: Invalid option: expected one of "max"|"xhigh"|"high"|
"medium"|"low"|"minimal"|"none"
An ``ultra`` configured while the default model was Anthropic worked (the
Anthropic adapter maps its own levels), but the moment a per-job override
pinned an OpenRouter model the extension leaked through the OpenAI-compatible
transport untranslated and every call failed. ``_reasoning_config_for_model``
is the wire-compat chokepoint for this transport: it must cap the extension
for every model, not just the one vendor prefix that happened to be fixed
first.
"""
from agent.transports.chat_completions import _reasoning_config_for_model
class TestUltraEffortWireTranslation:
def test_ultra_maps_to_max_for_any_model(self):
"""The extension level caps at the wire vocabulary for every model —
including the OpenRouter vendor prefixes a per-job override pins
(#89503's nvidia/ case) and models with no vendor prefix at all."""
for model in (
"nvidia/nemotron-3.5-lightning:free",
"deepseek/deepseek-v4",
"qwen/qwen3.5-coder",
"some-internal-model",
):
out = _reasoning_config_for_model(
model, {"enabled": True, "effort": "ultra"}
)
assert out == {"enabled": True, "effort": "max"}, model
def test_gpt_56_ultra_still_maps(self):
"""The original pre-existing mapping (gpt-5.6 + ultra → max) is
preserved by the generalized one."""
out = _reasoning_config_for_model(
"gpt-5.6", {"enabled": True, "effort": "ultra"}
)
assert out == {"enabled": True, "effort": "max"}
def test_wire_native_levels_pass_through_untouched(self):
for level in ("none", "minimal", "low", "medium", "high", "xhigh", "max"):
cfg = {"enabled": True, "effort": level}
assert _reasoning_config_for_model("any/model", cfg) == cfg
def test_non_dict_and_none_pass_through(self):
assert _reasoning_config_for_model("m", None) is None
assert _reasoning_config_for_model("m", "not-a-dict") == "not-a-dict"
+324
View File
@@ -0,0 +1,324 @@
"""Tests for the optional wall-clock run budget (agent.run_budget_seconds / --run-budget).
Covers:
1. Stale-timeout deadline scaling in
``run_agent.py:AIAgent._compute_non_stream_stale_timeout``:
- an active run budget CAPS an implicit stale timeout (default 90s and
reasoning floors like deepseek's 600s) at ``max(60, remaining * 0.5)``;
- the cap never RAISES the timeout above what it would otherwise be;
- explicit user configuration (provider ``stale_timeout_seconds`` or the
``HERMES_API_CALL_STALE_TIMEOUT`` env var) always wins untouched;
- no budget => completely unchanged behavior.
2. One-time-ness of the 80% wrap-up notice injection in
``agent.conversation_loop._maybe_inject_run_budget_wrapup``:
- fires once (latched), not repeatedly;
- never fires when no budget is set or before the 80% threshold;
- appended to the newest tool message (cache-safe /steer channel), no
synthetic user message.
3. Normalization of the config/CLI value
(``agent.agent_init._normalize_run_budget_seconds``): dormant on
null/invalid/non-positive input.
"""
from __future__ import annotations
import time
from pathlib import Path
import pytest
def _write_config(tmp_path: Path, body: str) -> None:
(tmp_path / "config.yaml").write_text(body or "{}\n", encoding="utf-8")
def _make_agent(tmp_path, monkeypatch, config_body: str = "", **overrides):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / ".env").write_text("", encoding="utf-8")
monkeypatch.delenv("HERMES_API_CALL_STALE_TIMEOUT", raising=False)
_write_config(tmp_path, config_body)
from run_agent import AIAgent
kwargs = dict(
model="gpt-5.5",
provider="openai",
api_key="sk-dummy",
base_url="https://api.openai.com/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
platform="cli",
)
kwargs.update(overrides)
return AIAgent(**kwargs)
# ── normalization ──────────────────────────────────────────────────────────
@pytest.mark.parametrize("raw,expected", [
(None, None),
(0, None),
(-5, None),
("abc", None),
(True, None), # YAML `true` must not become a 1-second budget
(False, None),
(float("nan"), None),
(900, 900.0),
("850", 850.0),
(0.5, 0.5),
])
def test_normalize_run_budget_seconds(raw, expected):
from agent.agent_init import _normalize_run_budget_seconds
assert _normalize_run_budget_seconds(raw) == expected
# ── constructor / config plumbing ─────────────────────────────────────────
def test_no_budget_by_default(monkeypatch, tmp_path):
agent = _make_agent(tmp_path, monkeypatch)
assert agent.run_budget_seconds is None
assert agent._run_budget_started_at is None
assert agent._run_budget_wrapup_injected is False
def test_constructor_arg_sets_budget(monkeypatch, tmp_path):
agent = _make_agent(tmp_path, monkeypatch, run_budget_seconds=900)
assert agent.run_budget_seconds == 900.0
def test_config_key_sets_budget(monkeypatch, tmp_path):
agent = _make_agent(
tmp_path, monkeypatch,
config_body="agent:\n run_budget_seconds: 750\n",
)
assert agent.run_budget_seconds == 750.0
def test_constructor_arg_wins_over_config(monkeypatch, tmp_path):
agent = _make_agent(
tmp_path, monkeypatch,
config_body="agent:\n run_budget_seconds: 750\n",
run_budget_seconds=900,
)
assert agent.run_budget_seconds == 900.0
# ── stale-timeout deadline scaling ─────────────────────────────────────────
def test_no_budget_stale_timeout_unchanged(monkeypatch, tmp_path):
"""Without a run budget the implicit 90s default is untouched."""
import run_agent
monkeypatch.setattr(run_agent, "get_provider_stale_timeout", lambda *a, **k: None)
agent = _make_agent(tmp_path, monkeypatch)
assert agent._compute_non_stream_stale_timeout({"input": "hi"}) == 90.0
def test_active_budget_caps_implicit_reasoning_floor(monkeypatch, tmp_path):
"""deepseek-v4-pro's 600s implicit floor yields to a tighter deadline cap.
900s budget, 800s elapsed -> remaining 100s -> cap = max(60, 50) = 60s.
"""
import run_agent
monkeypatch.setattr(run_agent, "get_provider_stale_timeout", lambda *a, **k: None)
agent = _make_agent(
tmp_path, monkeypatch,
model="deepseek/deepseek-v4-pro",
run_budget_seconds=900,
)
# Sanity: implicit reasoning floor is 600s without a running clock.
base, implicit = agent._resolved_api_call_stale_timeout_base()
assert base == 600.0 and implicit is False
agent._run_budget_started_at = time.time() - 800
assert agent._compute_non_stream_stale_timeout({"input": "hi"}) == 60.0
def test_active_budget_cap_half_remaining(monkeypatch, tmp_path):
"""Cap is remaining * 0.5 when that exceeds the 60s floor."""
import run_agent
monkeypatch.setattr(run_agent, "get_provider_stale_timeout", lambda *a, **k: None)
agent = _make_agent(
tmp_path, monkeypatch,
model="deepseek/deepseek-v4-pro",
run_budget_seconds=900,
)
agent._run_budget_started_at = time.time() - 100 # remaining ~800 -> cap ~400
timeout = agent._compute_non_stream_stale_timeout({"input": "hi"})
assert 395.0 <= timeout <= 400.0
def test_active_budget_never_raises_timeout(monkeypatch, tmp_path):
"""The deadline cap NEVER loosens an already-tighter implicit timeout.
90s default with lots of remaining budget (cap would be ~445s) -> stays 90s.
"""
import run_agent
monkeypatch.setattr(run_agent, "get_provider_stale_timeout", lambda *a, **k: None)
agent = _make_agent(tmp_path, monkeypatch, run_budget_seconds=900)
agent._run_budget_started_at = time.time() - 10
assert agent._compute_non_stream_stale_timeout({"input": "hi"}) == 90.0
def test_explicit_provider_config_wins_over_budget_cap(monkeypatch, tmp_path):
"""Explicit stale_timeout_seconds is never capped by the run budget."""
import run_agent
monkeypatch.setattr(run_agent, "get_provider_stale_timeout", lambda *a, **k: 1800.0)
agent = _make_agent(tmp_path, monkeypatch, run_budget_seconds=900)
agent._run_budget_started_at = time.time() - 800
assert agent._compute_non_stream_stale_timeout({"input": "hi"}) == 1800.0
def test_explicit_env_var_wins_over_budget_cap(monkeypatch, tmp_path):
"""HERMES_API_CALL_STALE_TIMEOUT is explicit config — never capped."""
import run_agent
monkeypatch.setattr(run_agent, "get_provider_stale_timeout", lambda *a, **k: None)
agent = _make_agent(tmp_path, monkeypatch, run_budget_seconds=900)
monkeypatch.setenv("HERMES_API_CALL_STALE_TIMEOUT", "1200")
agent._run_budget_started_at = time.time() - 800
assert agent._compute_non_stream_stale_timeout({"input": "hi"}) == 1200.0
def test_budget_without_started_clock_is_inert(monkeypatch, tmp_path):
"""A configured budget with no running turn clock changes nothing."""
import run_agent
monkeypatch.setattr(run_agent, "get_provider_stale_timeout", lambda *a, **k: None)
agent = _make_agent(
tmp_path, monkeypatch,
model="deepseek/deepseek-v4-pro",
run_budget_seconds=900,
)
agent._run_budget_started_at = None
assert agent._compute_non_stream_stale_timeout({"input": "hi"}) == 600.0
# ── wrap-up injection one-time-ness ────────────────────────────────────────
class _StubAgent:
def __init__(self, budget=None, started=None):
self.run_budget_seconds = budget
self._run_budget_started_at = started
self._run_budget_wrapup_injected = False
def _tool_messages():
return [
{"role": "user", "content": "do the task"},
{"role": "assistant", "content": "", "tool_calls": [{"id": "t1"}]},
{"role": "tool", "tool_call_id": "t1", "content": "result one"},
{"role": "assistant", "content": "", "tool_calls": [{"id": "t2"}]},
{"role": "tool", "tool_call_id": "t2", "content": "result two"},
]
def test_wrapup_not_injected_when_unset():
from agent.conversation_loop import _maybe_inject_run_budget_wrapup
agent = _StubAgent(budget=None, started=time.time() - 10_000)
messages = _tool_messages()
assert _maybe_inject_run_budget_wrapup(agent, messages) is False
assert messages == _tool_messages()
def test_wrapup_not_injected_before_threshold():
from agent.conversation_loop import _maybe_inject_run_budget_wrapup
agent = _StubAgent(budget=900, started=time.time() - 100) # 11% elapsed
messages = _tool_messages()
assert _maybe_inject_run_budget_wrapup(agent, messages) is False
assert agent._run_budget_wrapup_injected is False
def test_wrapup_injected_once_after_threshold():
from agent.conversation_loop import (
RUN_BUDGET_WRAPUP_NOTICE,
_maybe_inject_run_budget_wrapup,
)
agent = _StubAgent(budget=900, started=time.time() - 800) # 89% elapsed
messages = _tool_messages()
assert _maybe_inject_run_budget_wrapup(agent, messages) is True
assert agent._run_budget_wrapup_injected is True
# Appended to the NEWEST tool message; earlier messages untouched.
assert RUN_BUDGET_WRAPUP_NOTICE in messages[-1]["content"]
assert messages[-1]["content"].startswith("result two")
assert messages[2]["content"] == "result one"
# No synthetic user message was inserted.
assert [m["role"] for m in messages] == [
"user", "assistant", "tool", "assistant", "tool",
]
# Second call: latched, no re-injection.
snapshot = [dict(m) for m in messages]
assert _maybe_inject_run_budget_wrapup(agent, messages) is False
assert messages == snapshot
assert messages[-1]["content"].count(RUN_BUDGET_WRAPUP_NOTICE) == 1
def test_wrapup_retries_when_no_tool_message_yet():
"""First iteration (no tool results) can't inject; the latch stays open
so the next iteration with a tool result delivers the notice."""
from agent.conversation_loop import (
RUN_BUDGET_WRAPUP_NOTICE,
_maybe_inject_run_budget_wrapup,
)
agent = _StubAgent(budget=900, started=time.time() - 800)
messages = [{"role": "user", "content": "do the task"}]
assert _maybe_inject_run_budget_wrapup(agent, messages) is False
assert agent._run_budget_wrapup_injected is False
messages += [
{"role": "assistant", "content": "", "tool_calls": [{"id": "t1"}]},
{"role": "tool", "tool_call_id": "t1", "content": "result"},
]
assert _maybe_inject_run_budget_wrapup(agent, messages) is True
assert RUN_BUDGET_WRAPUP_NOTICE in messages[-1]["content"]
def test_wrapup_not_injected_without_turn_clock():
from agent.conversation_loop import _maybe_inject_run_budget_wrapup
agent = _StubAgent(budget=900, started=None)
messages = _tool_messages()
assert _maybe_inject_run_budget_wrapup(agent, messages) is False
def test_wrapup_multimodal_tool_content():
"""Content-blocks tool results get a text block appended, not clobbered."""
from agent.conversation_loop import (
RUN_BUDGET_WRAPUP_NOTICE,
_maybe_inject_run_budget_wrapup,
)
agent = _StubAgent(budget=900, started=time.time() - 800)
messages = [
{"role": "user", "content": "task"},
{"role": "assistant", "content": "", "tool_calls": [{"id": "t1"}]},
{"role": "tool", "tool_call_id": "t1",
"content": [{"type": "text", "text": "block"}]},
]
assert _maybe_inject_run_budget_wrapup(agent, messages) is True
blocks = messages[-1]["content"]
assert blocks[0] == {"type": "text", "text": "block"}
assert blocks[-1] == {"type": "text", "text": RUN_BUDGET_WRAPUP_NOTICE}
# ── turn clock stamping ────────────────────────────────────────────────────
def test_turn_clock_stamped_only_with_budget(monkeypatch, tmp_path):
"""prepare-turn stamps the clock iff a budget is set, and resets the latch."""
agent_with = _make_agent(tmp_path, monkeypatch, run_budget_seconds=900)
agent_with._run_budget_wrapup_injected = True
# Mirror the turn_context.prepare block (unit-level: run the same logic).
for agent in (agent_with,):
if getattr(agent, "run_budget_seconds", None):
agent._run_budget_started_at = time.time()
else:
agent._run_budget_started_at = None
agent._run_budget_wrapup_injected = False
assert agent_with._run_budget_started_at is not None
assert agent_with._run_budget_wrapup_injected is False
+229
View File
@@ -0,0 +1,229 @@
"""Runtime anti-stall guards (agent.stall_guards).
Two guards, both notice/re-prompt-only:
1. Identical-call loop breaker — ``ToolCallGuardrailController.observe_identical_call``
appends a compact notice to the tool RESULT on the 3rd consecutive call
with identical (tool, canonical args) AND an identical result. It never
blocks execution, exempts legitimately-repeatable pollers, and resets on
any change of args, tool, or result — and per turn.
2. Said-continue-but-stopped detector — ``trailing_continue_intent`` flags a
short reply that ENDS on an announced next action, feeding the existing
bounded intent-ack continuation path (no new recovery machinery).
These assert behavior contracts, not message snapshots.
"""
from agent.agent_runtime_helpers import trailing_continue_intent
from agent.tool_guardrails import (
STALL_GUARD_IDENTICAL_CALL_THRESHOLD,
STALL_GUARD_REPEATABLE_TOOLS,
ToolCallGuardrailController,
is_stall_guard_repeatable,
)
def _observe_n(controller, n, tool="web_search", args=None, result="same result"):
notices = []
for _ in range(n):
notices.append(
controller.observe_identical_call(tool, args or {"query": "x"}, result)
)
return notices
# ── identical-call loop breaker ────────────────────────────────────────────
def test_fires_on_third_consecutive_identical_call_and_result():
c = ToolCallGuardrailController()
notices = _observe_n(c, 3)
assert notices[0] is None
assert notices[1] is None
assert notices[2] is not None
assert "hermes note" in notices[2]
assert "3rd" in notices[2]
assert "web_search" in notices[2]
def test_keeps_firing_past_threshold():
c = ToolCallGuardrailController()
notices = _observe_n(c, 4)
assert notices[3] is not None
assert "4th" in notices[3]
def test_does_not_fire_when_arguments_differ():
c = ToolCallGuardrailController()
for i in range(5):
notice = c.observe_identical_call(
"web_search", {"query": f"q{i}"}, "same result"
)
assert notice is None
def test_does_not_fire_when_results_differ():
c = ToolCallGuardrailController()
for i in range(5):
notice = c.observe_identical_call(
"terminal", {"command": "poll-status"}, f"output {i}"
)
assert notice is None
def test_streak_resets_when_a_different_call_intervenes():
c = ToolCallGuardrailController()
assert _observe_n(c, 2)[-1] is None
# Different tool breaks the consecutive streak.
assert c.observe_identical_call("read_file", {"path": "/a"}, "data") is None
# Two more of the original are a fresh streak of 2 — still no notice.
assert all(n is None for n in _observe_n(c, 2))
def test_arg_canonicalization_ignores_key_order():
c = ToolCallGuardrailController()
r = "same"
assert c.observe_identical_call("t", {"a": 1, "b": 2}, r) is None
assert c.observe_identical_call("t", {"b": 2, "a": 1}, r) is None
assert c.observe_identical_call("t", {"a": 1, "b": 2}, r) is not None
def test_allowlisted_pollers_never_fire():
c = ToolCallGuardrailController()
for tool in ("process", "bfl_flux3_get_result", "vendor_get_result", "job_poll"):
for _ in range(STALL_GUARD_IDENTICAL_CALL_THRESHOLD + 2):
assert c.observe_identical_call(tool, {"id": "j1"}, "Generating") is None
def test_allowlist_membership_contract():
# The module constant drives the exemption; suffix conventions extend it.
for tool in STALL_GUARD_REPEATABLE_TOOLS:
assert is_stall_guard_repeatable(tool)
assert is_stall_guard_repeatable("acme_get_result")
assert not is_stall_guard_repeatable("web_search")
assert not is_stall_guard_repeatable("terminal")
def test_resets_per_turn():
c = ToolCallGuardrailController()
assert _observe_n(c, 2)[-1] is None
c.reset_for_turn()
# Streak restarted: two more identical calls still under threshold.
assert all(n is None for n in _observe_n(c, 2))
# Third after the reset fires.
assert _observe_n(c, 1)[-1] is not None
def test_never_blocks_execution():
# The guard is observational only: before_call still allows the call even
# after the notice fires repeatedly.
c = ToolCallGuardrailController()
_observe_n(c, 6)
decision = c.before_call("web_search", {"query": "x"})
assert decision.allows_execution
# ── result-append integration (AIAgent._append_guardrail_observation) ─────
def _fake_agent(stall_guards=True):
from types import SimpleNamespace
from run_agent import AIAgent
agent = SimpleNamespace(
_tool_guardrails=ToolCallGuardrailController(),
_stall_guards=stall_guards,
_tool_guardrail_halt_decision=None,
)
agent._stall_guards_enabled = lambda: AIAgent._stall_guards_enabled(agent)
agent._set_tool_guardrail_halt = (
lambda decision: AIAgent._set_tool_guardrail_halt(agent, decision)
)
agent._append = (
lambda name, args, result: AIAgent._append_guardrail_observation(
agent, name, args, result, failed=False
)
)
return agent
def test_notice_appended_to_third_identical_result():
agent = _fake_agent()
args = {"query": "hermes"}
r1 = agent._append("web_search", args, "results")
r2 = agent._append("web_search", args, "results")
r3 = agent._append("web_search", args, "results")
assert "hermes note" not in r1
assert "hermes note" not in r2
assert "hermes note" in r3
assert r3.startswith("results") # notice appended, result preserved
def test_config_gate_disables_notice():
agent = _fake_agent(stall_guards=False)
args = {"query": "hermes"}
for _ in range(4):
result = agent._append("web_search", args, "results")
assert "hermes note" not in result
def test_notice_streak_keys_on_raw_result_not_annotated_result():
# The idempotent-no-progress warning suffix (whose count changes per call)
# must not defeat result-identity matching: web_search is not in the
# idempotent set by default, but read_file is — its results gain a
# changing "[Tool loop warning: ...count=N...]" suffix from after_call,
# and the streak must still be recognized from the raw result.
agent = _fake_agent()
args = {"path": "/tmp/x"}
outs = [agent._append("read_file", args, "contents") for _ in range(3)]
assert "hermes note" in outs[2]
# ── said-continue-but-stopped detector ─────────────────────────────────────
def test_detects_trailing_let_me_now():
assert trailing_continue_intent("Found the config file. Let me now update it.")
def test_detects_trailing_i_will_now():
assert trailing_continue_intent("The tests pass. I will now push the branch.")
assert trailing_continue_intent("Good. I'll now run the linter")
def test_detects_trailing_next_i():
assert trailing_continue_intent("Done with step one. Next, I check the logs")
assert trailing_continue_intent("Step one complete. Next: I run the tests")
def test_ignores_intent_followed_by_more_content():
# Intent phrase mid-message with substantive content after it — the model
# already continued; nothing dangling.
assert not trailing_continue_intent(
"I will now explain the tradeoffs. First, caching: the design keeps "
"the prefix stable. Second, alternation: roles must strictly alternate."
)
def test_ignores_long_substantive_replies():
long_reply = ("Here is the full analysis. " * 30) + "Let me now summarize."
assert not trailing_continue_intent(long_reply)
def test_ignores_plain_final_answers():
assert not trailing_continue_intent("The answer is 42.")
assert not trailing_continue_intent("All tests pass and the branch is pushed.")
assert not trailing_continue_intent("")
assert not trailing_continue_intent(None)
def test_ignores_conversational_future_offers():
# "I will" without the immediate-action shape must not trip the guard.
assert not trailing_continue_intent(
"I can help with that tomorrow if you'd like."
)
assert not trailing_continue_intent(
"If you want, I will happily review the PR once CI is green. Just say so!"
)
+81
View File
@@ -116,6 +116,87 @@ class TestCodingContextBlock:
assert "coding agent" not in _stable_prompt(agent)
class TestExecutionGuidanceInjection:
"""Injection gate for OPENAI_MODEL_EXECUTION_GUIDANCE via
``agent.execution_guidance`` (auto/true/false/list).
Background — Composio agentic-eval traces (2026-08): the block was
historically fenced to gpt/codex/grok AND nested inside the
tool-use-enforcement branch, so DeepSeek/Kimi/Qwen-class models
received no execution discipline at all. The gate is now independent
of tool_use_enforcement and defaults to a broader family list.
"""
def _prompt(self, model, execution_guidance="auto", *,
tool_use_enforcement=False,
valid_tool_names=("terminal", "read_file")):
agent = _make_agent(
valid_tool_names=list(valid_tool_names),
model=model,
_tool_use_enforcement=tool_use_enforcement,
_execution_guidance=execution_guidance,
)
return _stable_prompt(agent)
def test_deepseek_gets_guidance_by_default(self):
stable = self._prompt("deepseek/deepseek-v4-pro")
assert "Execution discipline" in stable
assert "<external_state_verification>" in stable
def test_kimi_gets_guidance_by_default(self):
assert "Execution discipline" in self._prompt("moonshotai/kimi-k3")
def test_qwen_glm_minimax_mimo_mistral_get_guidance_by_default(self):
for model in ("qwen/qwen-3-max", "z-ai/glm-5.2",
"minimax/minimax-m2", "xiaomi/mimo-v2",
"mistralai/mistral-large-3"):
assert "Execution discipline" in self._prompt(model), model
def test_gpt_still_gets_guidance(self):
assert "Execution discipline" in self._prompt("openai/gpt-5.5")
def test_grok_still_gets_guidance(self):
assert "Execution discipline" in self._prompt("xai/grok-4")
def test_independent_of_tool_use_enforcement(self):
# The gate must not require tool-use enforcement to be on.
stable = self._prompt("deepseek/deepseek-v4-flash",
tool_use_enforcement=False)
assert "Execution discipline" in stable
assert "Tool-use enforcement" not in stable
def test_claude_does_not_get_guidance_by_default(self):
assert "Execution discipline" not in self._prompt(
"anthropic/claude-opus-4.8")
def test_gemini_does_not_get_guidance_by_default(self):
assert "Execution discipline" not in self._prompt(
"google/gemini-2.5-pro")
def test_config_false_suppresses(self):
assert "Execution discipline" not in self._prompt(
"openai/gpt-5.5", execution_guidance=False)
assert "Execution discipline" not in self._prompt(
"deepseek/deepseek-v4-pro", execution_guidance="off")
def test_config_true_forces_for_any_model(self):
assert "Execution discipline" in self._prompt(
"anthropic/claude-opus-4.8", execution_guidance=True)
def test_config_list_matches_substring(self):
stable = self._prompt("mycorp/custom-llm-7b",
execution_guidance=["custom-llm", "gpt"])
assert "Execution discipline" in stable
def test_config_list_non_match_suppresses(self):
assert "Execution discipline" not in self._prompt(
"openai/gpt-5.5", execution_guidance=["deepseek"])
def test_no_tools_no_guidance(self):
assert "Execution discipline" not in self._prompt(
"deepseek/deepseek-v4-pro", valid_tool_names=())
class TestNamedProfileHintIntegration:
"""The same defect through the REAL resolution chain (#72894).
+90
View File
@@ -215,3 +215,93 @@ class TestFileMutationTargets:
},
)
assert targets == ["old/name.py", "new/name.py"]
class TestUpstreamElisionDetection:
"""Provider-side elision markers get a one-line incompleteness notice."""
def _payload(self, marker: str) -> str:
return '{"items": ["' + "x" * 1_200 + '"], ' + marker + "}"
def test_more_items_marker_detected(self):
from agent.tool_dispatch_helpers import _detect_upstream_elision
assert _detect_upstream_elision(self._payload('"note": "... 13 more items"'))
def test_has_more_true_detected(self):
from agent.tool_dispatch_helpers import _detect_upstream_elision
assert _detect_upstream_elision(self._payload('"has_more": true'))
def test_saved_to_sandbox_detected(self):
from agent.tool_dispatch_helpers import _detect_upstream_elision
assert _detect_upstream_elision(
"y" * 1_100 + " Complete response was large. Full data saved to sandbox in /mnt/files/x.json"
)
def test_data_preview_detected(self):
from agent.tool_dispatch_helpers import _detect_upstream_elision
assert _detect_upstream_elision(self._payload('"data_preview": {}'))
def test_has_more_false_not_detected(self):
from agent.tool_dispatch_helpers import _detect_upstream_elision
assert not _detect_upstream_elision(self._payload('"has_more": false'))
def test_plain_large_result_not_detected(self):
from agent.tool_dispatch_helpers import _detect_upstream_elision
assert not _detect_upstream_elision("z" * 5_000)
def test_non_string_content_skipped(self):
from agent.tool_dispatch_helpers import _detect_upstream_elision
assert not _detect_upstream_elision(None)
assert not _detect_upstream_elision({"has_more": True})
assert not _detect_upstream_elision([{"type": "text", "text": "... 5 more items"}])
def test_short_results_short_circuit(self):
from agent.tool_dispatch_helpers import _detect_upstream_elision
# Marker present but under the 1K scan floor -> skipped.
assert not _detect_upstream_elision('"has_more": true')
def test_marker_beyond_scan_cap_not_matched(self):
from agent.tool_dispatch_helpers import (
_ELISION_SCAN_MAX_CHARS,
_detect_upstream_elision,
)
content = "a" * (_ELISION_SCAN_MAX_CHARS + 10) + '"has_more": true'
assert not _detect_upstream_elision(content)
class TestElisionNoticeWiring:
"""Notice appended once at construction time, before untrusted wrapping."""
def _elided(self) -> str:
return '{"items": ["' + "x" * 1_200 + '"], "has_more": true}'
def test_notice_appended_for_mcp_tool(self):
from agent.tool_dispatch_helpers import (
_UPSTREAM_ELISION_NOTICE,
_maybe_append_elision_notice,
)
out = _maybe_append_elision_notice("mcp_composio_search", self._elided())
assert out.endswith(_UPSTREAM_ELISION_NOTICE)
def test_trusted_tool_never_annotated(self):
from agent.tool_dispatch_helpers import _maybe_append_elision_notice
content = self._elided()
assert _maybe_append_elision_notice("terminal", content) is content
def test_untrusted_without_markers_unchanged(self):
from agent.tool_dispatch_helpers import _maybe_append_elision_notice
content = "y" * 2_000
assert _maybe_append_elision_notice("mcp_x", content) is content
def test_notice_inside_untrusted_wrapper(self):
"""Order: detect on raw -> append notice -> wrap. The notice must sit
INSIDE the untrusted block, and the message is built once (cache-safe)."""
from agent.tool_dispatch_helpers import make_tool_result_message
msg = make_tool_result_message("mcp_composio_search", self._elided(), "call_1")
content = msg["content"]
assert content.startswith("<untrusted_tool_result")
assert content.rstrip().endswith("</untrusted_tool_result>")
assert "INCOMPLETE" in content
assert content.index("hermes note") < content.index("</untrusted_tool_result>")
# Exactly one notice.
assert content.count("hermes note") == 1
@@ -0,0 +1,121 @@
"""Sibling-site coverage for the reasoning-effort wire-vocabulary class (#89503).
The chat-completions chokepoint fix (ultra → max for every model) is covered
by tests/agent/test_reasoning_effort_wire_translation.py. These tests pin the
sibling sites fixed in the same sweep:
- Kimi/Moonshot top-level ``reasoning_effort``: K3 accepts low/high/max only
(docs: default high); K2-era models accept low/medium/high. Previously the
transport forwarded only {low,medium,high} and silently dropped everything
else to "medium", so K3 400'd on "medium" requests and ultra resolved
WEAKER than an explicit high (ladder inversion).
- Tencent TokenHub: accepts low/medium/high; upper-ladder levels previously
dropped to the "high" default (accidentally right) but "minimal" also
dropped to high — asked for the least, got the most.
- Codex/Responses transport: ultra → max for EVERY model, not just gpt-5.6.
"""
from agent.transports import get_transport
import agent.transports.chat_completions # noqa: F401
import agent.transports.codex # noqa: F401
def _cc():
return get_transport("chat_completions")
def _kimi_kwargs(model, reasoning_config):
return _cc().build_kwargs(
model=model,
messages=[{"role": "user", "content": "hi"}],
is_kimi=True,
reasoning_config=reasoning_config,
)
class TestKimiEffortVocabulary:
def test_k3_maps_full_hermes_ladder(self):
expected = {
"minimal": "low",
"low": "low",
"medium": "high",
"high": "high",
"xhigh": "max",
"max": "max",
"ultra": "max",
}
for hermes_level, wire_level in expected.items():
kw = _kimi_kwargs(
"kimi-k3", {"enabled": True, "effort": hermes_level}
)
assert kw["reasoning_effort"] == wire_level, hermes_level
def test_k3_default_is_high(self):
kw = _kimi_kwargs("kimi-k3", None)
assert kw["reasoning_effort"] == "high"
def test_k2_upper_ladder_caps_at_high_not_medium(self):
"""Pre-fix, ultra/max/xhigh on K2-era models silently dropped to the
'medium' default — the strongest ask resolved weaker than an explicit
high (ladder inversion, same class as #74295)."""
for level in ("xhigh", "max", "ultra"):
kw = _kimi_kwargs(
"moonshotai/kimi-k2.6", {"enabled": True, "effort": level}
)
assert kw["reasoning_effort"] == "high", level
def test_k2_native_levels_pass_through(self):
for level in ("low", "medium", "high"):
kw = _kimi_kwargs(
"moonshotai/kimi-k2.6", {"enabled": True, "effort": level}
)
assert kw["reasoning_effort"] == level
def test_k2_minimal_maps_to_low(self):
kw = _kimi_kwargs(
"moonshotai/kimi-k2.6", {"enabled": True, "effort": "minimal"}
)
assert kw["reasoning_effort"] == "low"
def test_disabled_omits_effort(self):
kw = _kimi_kwargs("kimi-k3", {"enabled": False})
assert "reasoning_effort" not in kw
class TestTokenHubEffortVocabulary:
def _kwargs(self, reasoning_config):
return _cc().build_kwargs(
model="hunyuan-t2",
messages=[{"role": "user", "content": "hi"}],
is_tokenhub=True,
reasoning_config=reasoning_config,
)
def test_upper_ladder_caps_at_high(self):
for level in ("xhigh", "max", "ultra"):
kw = self._kwargs({"enabled": True, "effort": level})
assert kw["reasoning_effort"] == "high", level
def test_minimal_maps_to_low_not_high(self):
"""Pre-fix, 'minimal' fell through to the 'high' default — asked for
the least reasoning, got the most."""
kw = self._kwargs({"enabled": True, "effort": "minimal"})
assert kw["reasoning_effort"] == "low"
def test_native_levels_pass_through(self):
for level in ("low", "medium", "high"):
kw = self._kwargs({"enabled": True, "effort": level})
assert kw["reasoning_effort"] == level
class TestCodexUltraForEveryModel:
def test_ultra_maps_to_max_for_non_gpt56_models(self):
transport = get_transport("codex_responses")
for model in ("gpt-5.6-codex", "o5-pro", "some-responses-model"):
kw = transport.build_kwargs(
model=model,
messages=[{"role": "user", "content": "hi"}],
tools=[],
reasoning_config={"enabled": True, "effort": "ultra"},
)
assert kw["reasoning"]["effort"] == "max", model
@@ -1,15 +1,18 @@
"""Regression tests for image_gen use_gateway persistence (managed FAL clobber).
"""Regression tests for image_gen provider persistence (managed FAL clobber).
Bug: ``_select_plugin_image_gen_provider`` hardcoded
``image_gen.use_gateway = False``. When a user picked FAL through the
Nous-subscription managed flow, ``_write_provider_config`` first set
``use_gateway = True`` — then the image selector ran and clobbered it back
to False, silently routing every generation through the user's personal
FAL_KEY instead of the Nous Tool Gateway (real incident: personal key
drained to zero while the subscription sat unused).
Historical bug: ``_select_plugin_image_gen_provider`` hardcoded the direct
(non-managed) routing. When a user picked FAL through the Nous-subscription
managed flow, the managed write landed first — then the image selector ran
and clobbered it back to direct, silently routing every generation through
the user's personal FAL_KEY instead of the Nous Tool Gateway (real incident:
personal key drained to zero while the subscription sat unused).
The video twin (``_select_plugin_video_gen_provider``) already accepted a
``use_gateway`` kwarg; these tests pin the image path to the same contract.
Current contract (strict provider-string selection): each picker row writes
exactly ONE provider string per category — ``image_gen.provider: nous`` for
the managed "Nous Subscription" row, ``image_gen.provider: fal`` for the
BYOK FAL row — and any legacy ``use_gateway`` key is popped so the
read-time shim (use_gateway: true ⇒ nous) cannot override the fresh pick.
The video twin (``_select_plugin_video_gen_provider``) shares the contract.
"""
from hermes_cli.tools_config import (
@@ -28,43 +31,50 @@ def _quiet(monkeypatch):
monkeypatch.setattr(tc, "_configure_videogen_model_for_plugin", lambda *a, **k: None)
def test_image_gen_selector_preserves_managed_gateway_flag(monkeypatch):
"""Managed pick: use_gateway=True must survive the selector."""
def test_image_gen_selector_preserves_managed_selection(monkeypatch):
"""Managed pick: the 'nous' provider string must survive the selector."""
_quiet(monkeypatch)
config = {}
# The managed flow first writes the managed flag...
# The managed flow first persists the managed selection...
_write_provider_config(
{"image_gen_plugin_name": "fal"}, config, managed_feature="image_gen"
)
assert config["image_gen"]["use_gateway"] is True
assert config["image_gen"]["provider"] == "nous"
assert "use_gateway" not in config["image_gen"]
# ...then the selector runs; passing the managed flag must NOT clobber it.
# ...then the selector runs; the managed kwarg must NOT clobber it
# back onto the vendor name (direct-key routing).
_select_plugin_image_gen_provider("fal", config, use_gateway=True)
assert config["image_gen"]["provider"] == "fal"
assert config["image_gen"]["use_gateway"] is True
assert config["image_gen"]["provider"] == "nous"
assert "use_gateway" not in config["image_gen"]
def test_image_gen_selector_direct_key_pick_clears_gateway(monkeypatch):
"""Non-managed pick keeps the historical default: direct key, no gateway."""
def test_image_gen_selector_direct_key_pick_writes_vendor(monkeypatch):
"""Non-managed pick writes the vendor name and pops the legacy flag."""
_quiet(monkeypatch)
config = {"image_gen": {"use_gateway": True}}
_select_plugin_image_gen_provider("fal", config)
assert config["image_gen"]["provider"] == "fal"
assert config["image_gen"]["use_gateway"] is False
assert "use_gateway" not in config["image_gen"]
def test_image_and_video_selectors_share_the_gateway_contract(monkeypatch):
def test_image_and_video_selectors_share_the_selection_contract(monkeypatch):
"""The two selectors are twins: same kwarg, same persistence behavior."""
_quiet(monkeypatch)
for use_gateway in (True, False):
config = {}
for use_gateway, expected in ((True, "nous"), (False, "fal")):
config = {
"image_gen": {"use_gateway": not use_gateway},
"video_gen": {"use_gateway": not use_gateway},
}
_select_plugin_image_gen_provider("fal", config, use_gateway=use_gateway)
_select_plugin_video_gen_provider("fal", config, use_gateway=use_gateway)
assert config["image_gen"]["use_gateway"] is use_gateway
assert config["video_gen"]["use_gateway"] is use_gateway
assert config["image_gen"]["provider"] == expected
assert config["video_gen"]["provider"] == expected
assert "use_gateway" not in config["image_gen"]
assert "use_gateway" not in config["video_gen"]
def _quiet_reconfigure(monkeypatch):
@@ -82,11 +92,12 @@ def _quiet_reconfigure(monkeypatch):
monkeypatch.setattr(ns, "ensure_nous_portal_access", lambda **k: True)
def test_reconfigure_managed_fal_row_keeps_gateway_flag(monkeypatch):
def test_reconfigure_managed_fal_row_keeps_managed_selection(monkeypatch):
"""The sibling bug of fe63353cb: the legacy-backend model-pick step in
_reconfigure_provider hardcoded use_gateway=False AFTER the managed
branch wrote True — a Nous Subscription user re-entering the picker to
change models was silently flipped onto their personal FAL_KEY."""
_reconfigure_provider hardcoded the direct selection AFTER the managed
branch wrote the managed one — a Nous Subscription user re-entering the
picker to change models was silently flipped onto their personal
FAL_KEY."""
_quiet_reconfigure(monkeypatch)
import hermes_cli.tools_config as tc
@@ -98,17 +109,17 @@ def test_reconfigure_managed_fal_row_keeps_gateway_flag(monkeypatch):
"override_env_vars": ["FAL_KEY"],
"imagegen_backend": "fal",
}
config = {"image_gen": {"model": "fal-ai/gpt-image-2"}}
config = {"image_gen": {"model": "fal-ai/gpt-image-2", "use_gateway": True}}
tc._reconfigure_provider(managed_row, config)
assert config["image_gen"]["provider"] == "fal"
assert config["image_gen"]["use_gateway"] is True
assert config["image_gen"]["provider"] == "nous"
assert "use_gateway" not in config["image_gen"]
def test_reconfigure_direct_fal_row_clears_gateway_flag(monkeypatch):
"""Direct-key FAL reconfig must still clear the flag (historical
behavior for genuinely non-managed picks)."""
def test_reconfigure_direct_fal_row_writes_vendor_selection(monkeypatch):
"""Direct-key FAL reconfig writes the vendor name and pops any stale
legacy use_gateway key so the read-time shim can't resurrect 'nous'."""
_quiet_reconfigure(monkeypatch)
import hermes_cli.tools_config as tc
@@ -121,4 +132,5 @@ def test_reconfigure_direct_fal_row_clears_gateway_flag(monkeypatch):
tc._reconfigure_provider(direct_row, config)
assert config["image_gen"]["use_gateway"] is False
assert config["image_gen"]["provider"] == "fal"
assert "use_gateway" not in config["image_gen"]
+4 -5
View File
@@ -150,9 +150,8 @@ def test_prompt_enable_tool_gateway_pool_offers_covered_tools_only(monkeypatch):
def test_apply_nous_managed_defaults_writes_video_gen_config(monkeypatch):
"""apply_nous_managed_defaults must write video_gen.provider and
video_gen.use_gateway when a Nous subscriber selects video_gen
without a direct FAL_KEY."""
"""apply_nous_managed_defaults must store the managed 'nous' selection
when a Nous subscriber selects video_gen without a direct FAL_KEY."""
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False)
@@ -167,8 +166,8 @@ def test_apply_nous_managed_defaults_writes_video_gen_config(monkeypatch):
)
assert "video_gen" in changed
assert config["video_gen"]["provider"] == "fal"
assert config["video_gen"]["use_gateway"] is True
assert config["video_gen"]["provider"] == "nous"
assert "use_gateway" not in config["video_gen"]
# ---------------------------------------------------------------------------
+3 -2
View File
@@ -56,11 +56,12 @@ class TestSttCategory:
class TestConfigWrites:
def test_write_provider_config_sets_stt_provider(self):
config = {}
config = {"stt": {"use_gateway": True}}
prov = _stt_provider_named("Groq")
_write_provider_config(prov, config, managed_feature=None)
assert config["stt"]["provider"] == "groq"
assert config["stt"]["use_gateway"] is False
# Legacy key is popped so the read-time shim can't override the pick.
assert "use_gateway" not in config["stt"]
def test_apply_provider_selection_stt(self):
+2 -2
View File
@@ -256,8 +256,8 @@ def test_first_install_nous_auto_configures_video_gen(monkeypatch):
tools_command(first_install=True, config=config)
assert config["video_gen"]["provider"] == "fal"
assert config["video_gen"]["use_gateway"] is True
assert config["video_gen"]["provider"] == "nous"
assert "use_gateway" not in config["video_gen"]
# video_gen should NOT appear in the manual configure list — it's auto-configured
assert "video_gen" not in configured
+2 -1
View File
@@ -113,7 +113,8 @@ class TestReconfigureWritesProvider:
assert config["video_gen"]["provider"] == "xai_fake"
assert config["video_gen"]["model"] == "xai_fake-video-v1"
assert config["video_gen"]["use_gateway"] is False
# Non-managed pick: no legacy use_gateway key is written.
assert "use_gateway" not in config["video_gen"]
class TestPluginVideoProvidersRow:
+4 -1
View File
@@ -2671,9 +2671,12 @@ class TestNewEndpoints:
assert data["needs_nous_auth"] is True
assert data["feature"] == "browser"
# The selection is still persisted — activation is what's gated.
# Managed rows store the single 'nous' provider string (the runtime
# maps it to the Browser Use cloud through the Nous Tool Gateway).
from hermes_cli.config import load_config
cfg = load_config()
assert cfg["browser"]["cloud_provider"] == "browser-use"
assert cfg["browser"]["cloud_provider"] == "nous"
assert "use_gateway" not in cfg["browser"]
# -- Web capability split (search vs extract backends) ------------------
+66
View File
@@ -1092,6 +1092,72 @@ class TestToolUseEnforcementConfig:
assert TOOL_USE_ENFORCEMENT_GUIDANCE not in prompt
class TestExecutionGuidanceConfig:
"""End-to-end tests for the agent.execution_guidance config option —
from config.yaml through agent_init to the built system prompt."""
def _make_agent(self, model="deepseek/deepseek-v4-pro", execution_guidance=None):
agent_cfg = {"tool_use_enforcement": False}
if execution_guidance is not None:
agent_cfg["execution_guidance"] = execution_guidance
with (
patch(
"run_agent.get_tool_definitions",
return_value=_make_tool_defs("terminal", "web_search"),
),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
patch(
"hermes_cli.config.load_config",
return_value={"agent": agent_cfg},
), patch(
"hermes_cli.config.load_config_readonly",
return_value={"agent": agent_cfg},
),
):
a = AIAgent(
model=model,
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
a.client = MagicMock()
return a
def test_deepseek_gets_guidance_by_default(self):
from agent.prompt_builder import OPENAI_MODEL_EXECUTION_GUIDANCE
agent = self._make_agent(model="deepseek/deepseek-v4-pro")
assert OPENAI_MODEL_EXECUTION_GUIDANCE in agent._build_system_prompt()
def test_gpt_still_gets_guidance(self):
from agent.prompt_builder import OPENAI_MODEL_EXECUTION_GUIDANCE
agent = self._make_agent(model="openai/gpt-4.1")
assert OPENAI_MODEL_EXECUTION_GUIDANCE in agent._build_system_prompt()
def test_config_false_suppresses(self):
from agent.prompt_builder import OPENAI_MODEL_EXECUTION_GUIDANCE
agent = self._make_agent(
model="deepseek/deepseek-v4-pro", execution_guidance=False
)
assert OPENAI_MODEL_EXECUTION_GUIDANCE not in agent._build_system_prompt()
def test_config_list_matches(self):
from agent.prompt_builder import OPENAI_MODEL_EXECUTION_GUIDANCE
agent = self._make_agent(
model="moonshotai/kimi-k3", execution_guidance=["kimi"]
)
assert OPENAI_MODEL_EXECUTION_GUIDANCE in agent._build_system_prompt()
def test_config_list_non_match_suppresses(self):
from agent.prompt_builder import OPENAI_MODEL_EXECUTION_GUIDANCE
agent = self._make_agent(
model="openai/gpt-4.1", execution_guidance=["kimi"]
)
assert OPENAI_MODEL_EXECUTION_GUIDANCE not in agent._build_system_prompt()
class TestTaskCompletionGuidance:
"""Tests for the universal task-completion / no-fabrication guidance
(config.yaml ``agent.task_completion_guidance``).
+65
View File
@@ -173,3 +173,68 @@ class TestBudgetForContextWindow:
threshold = cfg.resolve_threshold("mcp_firecrawl_firecrawl_search")
assert threshold < huge_len
assert cfg.default_result_size < huge_len
# ---------------------------------------------------------------------------
# MCP-prefix threshold (mcp_result_size)
# ---------------------------------------------------------------------------
class TestMcpPrefixThreshold:
"""mcp_* tools get the tighter 50K default, config-overridable."""
def test_default_mcp_threshold_is_50k(self):
from tools.budget_config import DEFAULT_MCP_RESULT_SIZE_CHARS
assert DEFAULT_MCP_RESULT_SIZE_CHARS == 50_000
assert DEFAULT_BUDGET.resolve_threshold("mcp_composio_search_tools") == 50_000
def test_non_mcp_tools_keep_generic_default(self):
assert DEFAULT_BUDGET.resolve_threshold("some_random_tool") == DEFAULT_RESULT_SIZE_CHARS
def test_pinned_wins_over_mcp_prefix(self):
with patch.dict(PINNED_THRESHOLDS, {"mcp_pinned_tool": float("inf")}):
assert DEFAULT_BUDGET.resolve_threshold("mcp_pinned_tool") == float("inf")
def test_tool_override_wins_over_mcp_prefix(self):
cfg = BudgetConfig(tool_overrides={"mcp_special": 75_000})
assert cfg.resolve_threshold("mcp_special") == 75_000
def test_mcp_threshold_capped_by_scaled_default(self):
"""On a small model the scaled default_result_size caps the MCP value."""
cfg = BudgetConfig(default_result_size=20_000, mcp_result_size=50_000)
assert cfg.resolve_threshold("mcp_anything") == 20_000
def test_mcp_threshold_never_exceeds_default_result_size(self):
cfg = BudgetConfig(default_result_size=100_000, mcp_result_size=999_999)
assert cfg.resolve_threshold("mcp_anything") == 100_000
def test_config_override_via_hermes_home(self, tmp_path, monkeypatch):
(tmp_path / "config.yaml").write_text(
"tool_budget:\n mcp_result_size_chars: 30000\n"
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg = budget_for_context_window(None)
assert cfg.resolve_threshold("mcp_composio_multi_execute") == 30_000
# Generic tools are untouched by the MCP knob.
assert cfg.default_result_size == DEFAULT_RESULT_SIZE_CHARS
def test_config_override_survives_window_scaling(self, tmp_path, monkeypatch):
(tmp_path / "config.yaml").write_text(
"tool_budget:\n mcp_result_size_chars: 30000\n"
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg = budget_for_context_window(200_000)
assert cfg.mcp_result_size == 30_000
def test_malformed_config_falls_back_to_default(self, tmp_path, monkeypatch):
(tmp_path / "config.yaml").write_text("tool_budget: not-a-mapping\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg = budget_for_context_window(None)
assert cfg.resolve_threshold("mcp_x_y") == 50_000
def test_scaled_small_window_caps_mcp_threshold(self, tmp_path, monkeypatch):
"""A tiny model's scaled default_result_size caps even the MCP value."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # no config.yaml
cfg = budget_for_context_window(16_384) # scaled default < 50K
assert cfg.default_result_size < 50_000
assert cfg.resolve_threshold("mcp_tool") == cfg.default_result_size
+4 -2
View File
@@ -247,7 +247,9 @@ def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_pat
_install_fake_tools_package()
_install_fake_openai_module(whisper_capture, transcription_response="hello from whisper")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("stt:\n provider: openai\n")
# The managed audio route is the stored "nous" selection (strict model);
# a stored "openai" selection now means direct credentials only.
(tmp_path / "config.yaml").write_text("stt:\n provider: nous\n")
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
@@ -257,7 +259,7 @@ def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_pat
"tools.transcription_tools",
"transcription_tools.py",
)
transcription_tools._load_stt_config = lambda: {"provider": "openai"}
transcription_tools._load_stt_config = lambda: {"provider": "nous"}
audio_path = tmp_path / "audio.wav"
audio_path.write_bytes(b"RIFF0000WAVEfmt ")
+69
View File
@@ -0,0 +1,69 @@
"""Regression tests for the MCP hard result cap (#56059).
MCP tool results had no allocation bound — a buggy or malicious MCP server
could return multi-megabyte text that floods memory and context before the
budget/spillover layer sees it. The hard cap truncates only pathological
payloads (over 2M chars by default) with a 40% head / 60% tail split;
ordinary large results pass through untouched so the 50K MCP spillover
threshold (tools/budget_config.py) can preserve them in full on disk.
Test shape adapted from PR #56511 (Tranquil-Flow); cap semantics differ —
see _MCP_HARD_RESULT_CAP_CHARS in tools/mcp_tool.py.
"""
from __future__ import annotations
from tools.mcp_tool import _MCP_HARD_RESULT_CAP_CHARS, _truncate_mcp_text_result
class TestTruncateMcpTextResult:
def test_short_result_unchanged(self):
text = "x" * 100
assert _truncate_mcp_text_result(text) == text
def test_exact_limit_unchanged(self):
text = "y" * 100
assert _truncate_mcp_text_result(text, max_chars=100) == text
def test_spillover_sized_result_passes_untouched(self):
"""A 60K result (over the 50K spillover threshold) is NOT truncated
here — the budget layer must receive it intact so spillover can
preserve the full payload on disk."""
text = "z" * 60_000
assert _truncate_mcp_text_result(text) == text
def test_pathological_result_is_truncated(self):
text = "z" * (_MCP_HARD_RESULT_CAP_CHARS + 500_000)
result = _truncate_mcp_text_result(text)
assert len(result) < len(text)
assert "TRUNCATED" in result
def test_truncation_preserves_head_and_tail(self):
head_marker = "HEAD_MARKER_START"
tail_marker = "TAIL_MARKER_END"
text = head_marker + "x" * 5000 + tail_marker
result = _truncate_mcp_text_result(text, max_chars=200)
assert result.startswith(head_marker)
assert result.endswith(tail_marker)
def test_truncation_includes_omitted_count(self):
text = "a" * 5000
result = _truncate_mcp_text_result(text, max_chars=100)
assert "4,900" in result # 5000 - 100 omitted
assert "5,000" in result # total original length
def test_truncation_uses_40_60_head_tail_split(self):
text = "H" * 40 + "M" * 5000 + "T" * 60
result = _truncate_mcp_text_result(text, max_chars=100)
assert result[:40] == "H" * 40
assert result[-60:] == "T" * 60
def test_empty_result_unchanged(self):
assert _truncate_mcp_text_result("") == ""
def test_hard_cap_sits_above_spillover_threshold(self):
"""The hard cap must stay far above the MCP spillover threshold so
spillover, not lossy truncation, handles ordinary large results."""
from tools.budget_config import DEFAULT_MCP_RESULT_SIZE_CHARS
assert _MCP_HARD_RESULT_CAP_CHARS > DEFAULT_MCP_RESULT_SIZE_CHARS * 10
@@ -0,0 +1,360 @@
"""Strict tool-provider selection: the `hermes tools` choice always wins.
Policy (owner decision): the provider string stored in config.yaml is what
runs at call time. "nous" → managed Nous Tool Gateway only; a vendor name →
that vendor direct with the user's own credentials; no key ever written →
today's credential autodetect. Credential presence must NEVER select or
reroute; a selected-but-broken provider produces an honest error naming the
selection and pointing at `hermes tools`.
Per category these tests pin the three strict behaviors:
(a) managed selection + direct key present ⇒ managed route (key ignored)
(b) vendor selection + key missing ⇒ selection-naming error, NO managed call
(c) never-configured ⇒ legacy autodetect unchanged
"""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from tools import tool_backend_helpers as tbh
MANAGED = SimpleNamespace(
nous_user_token="managed-token",
gateway_origin="https://gateway.nousresearch.com",
)
# ---------------------------------------------------------------------------
# read_selection — the shared helper
# ---------------------------------------------------------------------------
class TestReadSelection:
def _with_raw(self, raw):
return patch(
"hermes_cli.config.read_raw_config_readonly",
return_value=raw,
)
def test_never_configured_returns_none(self):
with self._with_raw({}):
assert tbh.read_selection("image_gen") is None
def test_vendor_provider_returned(self):
with self._with_raw({"image_gen": {"provider": "fal"}}):
assert tbh.read_selection("image_gen") == "fal"
def test_nous_provider_returned(self):
with self._with_raw({"image_gen": {"provider": "nous"}}):
assert tbh.read_selection("image_gen") == "nous"
def test_legacy_use_gateway_true_maps_to_nous(self):
"""Old configs stored use_gateway: true beside a vendor name — only
the managed picker row ever wrote it, so it means 'nous'."""
with self._with_raw({"video_gen": {"provider": "fal", "use_gateway": True}}):
assert tbh.read_selection("video_gen") == "nous"
def test_legacy_use_gateway_false_keeps_vendor(self):
with self._with_raw({"tts": {"provider": "openai", "use_gateway": False}}):
assert tbh.read_selection("tts") == "openai"
def test_empty_string_backend_is_no_selection(self):
"""DEFAULT_CONFIG's seeded empty strings are not selections."""
with self._with_raw({"web": {"backend": ""}}):
assert tbh.read_selection("web") is None
def test_raw_stt_local_is_a_selection(self):
"""A raw config.yaml ``stt.provider: local`` is a genuine pick: the
DEFAULT_CONFIG seed never reached disk (save_config strips schema
defaults), and the current picker's Local Whisper row writes exactly
this shape (provider only, legacy use_gateway popped). Treating it
as no-selection would silently discard the user's choice."""
with self._with_raw({"stt": {"provider": "local"}}):
assert tbh.read_selection("stt") == "local"
def test_stt_local_with_use_gateway_key_is_a_selection(self):
"""A picker-written stt section (use_gateway key present) means
local was a genuine choice."""
with self._with_raw({"stt": {"provider": "local", "use_gateway": False}}):
assert tbh.read_selection("stt") == "local"
def test_browser_backend_key_is_not_the_cloud_selection(self):
"""browser.backend is the driver choice (browser-use CLI vs built-in
tools), not the cloud provider selection."""
with self._with_raw({"browser": {"backend": "browser-use"}}):
assert tbh.read_selection("browser") is None
def test_web_per_capability_keys_mark_configured(self):
with self._with_raw({"web": {"search_backend": "searxng"}}):
assert tbh.read_selection("web") is None
assert tbh.selection_exists("web") is True
# ---------------------------------------------------------------------------
# Image generation (FAL)
# ---------------------------------------------------------------------------
class TestImageFalStrictSelection:
def test_nous_selection_routes_managed_even_with_fal_key(self):
from tools import image_generation_tool as it
with patch.object(it, "read_selection", return_value="nous"), \
patch.object(it, "fal_key_is_configured", return_value=True), \
patch.object(it, "resolve_managed_tool_gateway", return_value=MANAGED) as gw:
assert it._resolve_managed_fal_gateway() is MANAGED
gw.assert_called_once_with("fal-queue")
def test_nous_selection_unentitled_raises_selection_error(self):
from tools import image_generation_tool as it
with patch.object(it, "read_selection", return_value="nous"), \
patch.object(it, "fal_key_is_configured", return_value=True), \
patch.object(it, "resolve_managed_tool_gateway", return_value=None):
with pytest.raises(ValueError) as exc:
it._resolve_managed_fal_gateway()
assert "image_gen is configured to use nous" in str(exc.value)
assert "hermes tools" in str(exc.value)
def test_fal_selection_missing_key_errors_without_managed_call(self):
from tools import image_generation_tool as it
with patch.object(it, "read_selection", return_value="fal"), \
patch.object(it, "fal_key_is_configured", return_value=False), \
patch.object(it, "resolve_managed_tool_gateway") as gw:
with pytest.raises(ValueError) as exc:
it._resolve_managed_fal_gateway()
gw.assert_not_called()
assert "FAL_KEY" in str(exc.value)
assert "image_gen is configured to use fal" in str(exc.value)
assert "hermes tools" in str(exc.value)
def test_fal_selection_with_key_routes_direct(self):
from tools import image_generation_tool as it
with patch.object(it, "read_selection", return_value="fal"), \
patch.object(it, "fal_key_is_configured", return_value=True), \
patch.object(it, "resolve_managed_tool_gateway") as gw:
assert it._resolve_managed_fal_gateway() is None
gw.assert_not_called()
def test_never_configured_autodetect_direct_when_key_present(self):
from tools import image_generation_tool as it
with patch.object(it, "read_selection", return_value=None), \
patch.object(it, "fal_key_is_configured", return_value=True):
assert it._resolve_managed_fal_gateway() is None
def test_never_configured_autodetect_managed_when_no_key(self):
from tools import image_generation_tool as it
with patch.object(it, "read_selection", return_value=None), \
patch.object(it, "fal_key_is_configured", return_value=False), \
patch.object(it, "resolve_managed_tool_gateway", return_value=MANAGED):
assert it._resolve_managed_fal_gateway() is MANAGED
def test_check_fal_api_key_reflects_selection(self):
from tools import image_generation_tool as it
with patch.object(it, "read_selection", return_value="fal"), \
patch.object(it, "fal_key_is_configured", return_value=False), \
patch.object(it, "resolve_managed_tool_gateway", return_value=MANAGED):
# Broken vendor selection reports unavailable even though the
# managed gateway would resolve.
assert it.check_fal_api_key() is False
# ---------------------------------------------------------------------------
# Video generation (FAL plugin)
# ---------------------------------------------------------------------------
class TestVideoFalStrictSelection:
def test_nous_selection_routes_managed_even_with_fal_key(self):
from plugins.video_gen import fal as vf
with patch("tools.tool_backend_helpers.read_selection", return_value="nous"), \
patch("tools.tool_backend_helpers.fal_key_is_configured", return_value=True), \
patch("tools.managed_tool_gateway.resolve_managed_tool_gateway", return_value=MANAGED):
assert vf._resolve_managed_fal_video_gateway() is MANAGED
def test_fal_selection_missing_key_errors_without_managed_call(self):
from plugins.video_gen import fal as vf
with patch("tools.tool_backend_helpers.read_selection", return_value="fal"), \
patch("tools.tool_backend_helpers.fal_key_is_configured", return_value=False), \
patch("tools.managed_tool_gateway.resolve_managed_tool_gateway") as gw:
with pytest.raises(ValueError) as exc:
vf._resolve_managed_fal_video_gateway()
gw.assert_not_called()
assert "video_gen is configured to use fal" in str(exc.value)
assert "FAL_KEY" in str(exc.value)
def test_never_configured_autodetect_unchanged(self):
from plugins.video_gen import fal as vf
with patch("tools.tool_backend_helpers.read_selection", return_value=None), \
patch("tools.tool_backend_helpers.fal_key_is_configured", return_value=True):
assert vf._resolve_managed_fal_video_gateway() is None
# ---------------------------------------------------------------------------
# STT (OpenAI audio resolver — previously ignored the stored intent entirely)
# ---------------------------------------------------------------------------
class TestSttStrictSelection:
def test_nous_selection_beats_direct_openai_key(self):
from tools import transcription_tools as tt
with patch.object(tt, "_load_stt_config", return_value={"openai": {"api_key": "sk-direct"}}), \
patch("tools.tool_backend_helpers.read_selection", return_value="nous"), \
patch.object(tt, "resolve_managed_tool_gateway", return_value=MANAGED):
api_key, base_url = tt._resolve_openai_audio_client_config()
assert api_key == "managed-token"
assert base_url.startswith("https://gateway.nousresearch.com")
def test_vendor_selection_missing_key_errors_without_managed_call(self):
from tools import transcription_tools as tt
with patch.object(tt, "_load_stt_config", return_value={}), \
patch("tools.tool_backend_helpers.read_selection", return_value="openai"), \
patch.object(tt, "resolve_openai_audio_api_key", return_value=""), \
patch.object(tt, "resolve_managed_tool_gateway") as gw:
with pytest.raises(ValueError) as exc:
tt._resolve_openai_audio_client_config()
gw.assert_not_called()
assert "stt is configured to use openai" in str(exc.value)
assert "hermes tools" in str(exc.value)
def test_never_configured_keeps_legacy_ladder(self):
from tools import transcription_tools as tt
with patch.object(tt, "_load_stt_config", return_value={}), \
patch("tools.tool_backend_helpers.read_selection", return_value=None), \
patch.object(tt, "resolve_openai_audio_api_key", return_value="sk-env"):
api_key, base_url = tt._resolve_openai_audio_client_config()
assert api_key == "sk-env"
# ---------------------------------------------------------------------------
# Browser Use provider
# ---------------------------------------------------------------------------
class TestBrowserUseStrictSelection:
def _provider(self):
from plugins.browser.browser_use.provider import BrowserUseBrowserProvider
return BrowserUseBrowserProvider()
def test_nous_selection_routes_managed_even_with_direct_key(self):
provider = self._provider()
with patch("plugins.browser.browser_use.provider.get_secret", return_value="bu-key"), \
patch("tools.tool_backend_helpers.read_selection", return_value="nous"), \
patch("tools.managed_tool_gateway.resolve_managed_tool_gateway", return_value=MANAGED):
config = provider._get_config_or_none()
assert config["managed_mode"] is True
assert config["api_key"] == "managed-token"
def test_vendor_selection_missing_key_errors_without_managed_call(self):
provider = self._provider()
with patch("plugins.browser.browser_use.provider.get_secret", return_value=""), \
patch("tools.tool_backend_helpers.read_selection", return_value="browser-use"), \
patch("tools.managed_tool_gateway.resolve_managed_tool_gateway") as gw:
with pytest.raises(ValueError) as exc:
provider._get_config()
gw.assert_not_called()
assert "browser is configured to use browser-use" in str(exc.value)
assert "BROWSER_USE_API_KEY" in str(exc.value)
def test_never_configured_key_still_routes_direct(self):
provider = self._provider()
with patch("plugins.browser.browser_use.provider.get_secret", return_value="bu-key"), \
patch("tools.tool_backend_helpers.read_selection", return_value=None):
config = provider._get_config_or_none()
assert config["managed_mode"] is False
assert config["api_key"] == "bu-key"
# ---------------------------------------------------------------------------
# Camofox: selection over env var
# ---------------------------------------------------------------------------
class TestCamofoxSelection:
def test_camofox_selection_activates_mode(self, monkeypatch):
from tools import browser_camofox as bc
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
with patch.object(bc, "_config_cdp_url", return_value=""), \
patch("tools.tool_backend_helpers.read_selection", return_value="camofox"):
assert bc.is_camofox_mode() is True
def test_other_selection_beats_camofox_url_env(self, monkeypatch):
"""CAMOFOX_URL is the ADDRESS, not the choice: an explicit different
browser selection wins."""
from tools import browser_camofox as bc
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
with patch.object(bc, "_config_cdp_url", return_value=""), \
patch.object(bc, "get_camofox_url", return_value="http://localhost:9377"), \
patch("tools.tool_backend_helpers.read_selection", return_value="local"):
assert bc.is_camofox_mode() is False
def test_never_configured_env_url_still_activates(self, monkeypatch):
from tools import browser_camofox as bc
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
with patch.object(bc, "_config_cdp_url", return_value=""), \
patch.object(bc, "get_camofox_url", return_value="http://localhost:9377"), \
patch("tools.tool_backend_helpers.read_selection", return_value=None):
assert bc.is_camofox_mode() is True
# ---------------------------------------------------------------------------
# tools_config writers: one provider string per row, no use_gateway writes
# ---------------------------------------------------------------------------
class TestWriteProviderConfig:
def test_managed_row_writes_nous_and_clears_legacy_flag(self):
from hermes_cli.tools_config import _write_provider_config
config = {"tts": {"provider": "edge", "use_gateway": False}}
provider = {"name": "Nous Subscription", "tts_provider": "openai"}
_write_provider_config(provider, config, managed_feature="tts")
assert config["tts"]["provider"] == "nous"
assert "use_gateway" not in config["tts"]
def test_byok_row_writes_vendor_and_clears_legacy_flag(self):
from hermes_cli.tools_config import _write_provider_config
config = {"web": {"backend": "nous", "use_gateway": True}}
provider = {"name": "Tavily", "web_backend": "tavily"}
_write_provider_config(provider, config, managed_feature=None)
assert config["web"]["backend"] == "tavily"
assert "use_gateway" not in config["web"]
def test_managed_image_row_persists_nous_provider(self):
from hermes_cli.tools_config import _write_provider_config
config = {}
provider = {"name": "Nous Subscription", "imagegen_backend": "fal"}
_write_provider_config(provider, config, managed_feature="image_gen")
assert config["image_gen"]["provider"] == "nous"
assert "use_gateway" not in config["image_gen"]
def test_plugin_injected_byok_row_clears_stale_use_gateway(self):
"""Plugin-injected rows are not in TOOL_CATEGORIES' hardcoded
provider lists; the legacy clear-loop skipped them."""
from hermes_cli.tools_config import _write_provider_config
config = {"stt": {"provider": "nous", "use_gateway": True}}
provider = {"name": "Groq Whisper", "stt_provider": "groq"}
_write_provider_config(provider, config, managed_feature=None)
assert config["stt"]["provider"] == "groq"
assert "use_gateway" not in config["stt"]
+19
View File
@@ -480,3 +480,22 @@ class TestSpillover:
assert not old.exists()
assert (spill_dir / "tc_prune_1.txt").exists()
# ── recovery hint in the persisted preview ────────────────────────────
class TestRecoveryHint:
def test_preview_teaches_recovery_not_refetch(self):
msg = _build_persisted_message(
preview="preview text",
has_more=True,
original_size=60_000,
file_path="/tmp/hermes-results/r.txt",
)
assert "Recovery:" in msg
assert "execute_code" in msg
assert "re-request" in msg
# Structure preserved: tag, size, path, read_file guidance all intact.
assert msg.startswith(PERSISTED_OUTPUT_TAG)
assert msg.endswith(PERSISTED_OUTPUT_CLOSING_TAG)
assert "read_file" in msg
+2 -1
View File
@@ -43,7 +43,8 @@ class TestGetProvider:
monkeypatch.delenv("GROQ_API_KEY", raising=False)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True), \
patch("tools.transcription_tools._has_local_command", return_value=False):
patch("tools.transcription_tools._has_local_command", return_value=False), \
patch("tools.tool_backend_helpers.read_selection", return_value="local"):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "local"}) == "none"
+14
View File
@@ -131,11 +131,25 @@ class TestExplicitProviderRespected:
monkeypatch.delenv("GROQ_API_KEY", raising=False)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._has_local_command", return_value=False), \
patch("tools.tool_backend_helpers.read_selection", return_value="local"), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
result = _get_provider({"provider": "local"})
assert result == "none", f"Expected 'none' but got {result!r}"
def test_seeded_local_without_stored_selection_autodetects(self, monkeypatch):
"""The DEFAULT_CONFIG-seeded stt.provider: local (no raw-config
selection) is treated as never-configured: autodetect runs instead of
hard-pinning to a missing local backend."""
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._has_local_command", return_value=False), \
patch("tools.transcription_tools._try_lazy_install_stt", return_value=False), \
patch("tools.tool_backend_helpers.read_selection", return_value=None), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "local"}) == "groq"
def test_explicit_local_uses_local_command_fallback(self, monkeypatch):
"""Local-to-local_command fallback is fine — both are local."""
monkeypatch.setenv(
+33 -5
View File
@@ -25,7 +25,7 @@ class TestResolveOpenaiAudioClientConfig:
}
with patch.object(tts_tool, "_load_tts_config", return_value=config), \
patch.object(tts_tool, "prefers_gateway", return_value=False), \
patch.object(tts_tool, "read_selection", return_value="openai"), \
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value="env-key"), \
patch.object(tts_tool, "resolve_managed_tool_gateway", return_value=None):
assert tts_tool._resolve_openai_audio_client_config() == (
@@ -38,7 +38,7 @@ class TestResolveOpenaiAudioClientConfig:
config = {"openai": {"api_key": "cfg-key"}}
with patch.object(tts_tool, "_load_tts_config", return_value=config), \
patch.object(tts_tool, "prefers_gateway", return_value=False):
patch.object(tts_tool, "read_selection", return_value=None):
assert tts_tool._resolve_openai_audio_client_config() == (
"cfg-key",
tts_tool.DEFAULT_OPENAI_BASE_URL,
@@ -46,7 +46,9 @@ class TestResolveOpenaiAudioClientConfig:
)
def test_use_gateway_overrides_config_credentials(self):
def test_nous_selection_overrides_config_credentials(self):
"""A stored 'nous' selection (or legacy use_gateway: true) routes
managed even when direct credentials are present."""
config = {"openai": {"api_key": "cfg-key", "base_url": "http://localhost:4003/v1"}}
managed = SimpleNamespace(
nous_user_token="managed-token",
@@ -54,7 +56,7 @@ class TestResolveOpenaiAudioClientConfig:
)
with patch.object(tts_tool, "_load_tts_config", return_value=config), \
patch.object(tts_tool, "prefers_gateway", return_value=True), \
patch.object(tts_tool, "read_selection", return_value="nous"), \
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value="env-key"), \
patch.object(tts_tool, "resolve_managed_tool_gateway", return_value=managed):
assert tts_tool._resolve_openai_audio_client_config() == (
@@ -63,9 +65,35 @@ class TestResolveOpenaiAudioClientConfig:
True,
)
def test_nous_selection_unentitled_raises_selection_error(self):
"""Selected managed route + unavailable gateway = honest error naming
the selection, never a silent fall back to direct credentials."""
config = {"openai": {"api_key": "cfg-key"}}
with patch.object(tts_tool, "_load_tts_config", return_value=config), \
patch.object(tts_tool, "read_selection", return_value="nous"), \
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value="env-key"), \
patch.object(tts_tool, "resolve_managed_tool_gateway", return_value=None):
with pytest.raises(ValueError) as exc:
tts_tool._resolve_openai_audio_client_config()
assert "nous" in str(exc.value)
assert "hermes tools" in str(exc.value)
def test_vendor_selection_missing_key_raises_selection_error(self):
"""A stored vendor selection with no credentials errors by name —
NO managed gateway call is attempted."""
with patch.object(tts_tool, "_load_tts_config", return_value={"provider": "openai"}), \
patch.object(tts_tool, "read_selection", return_value="openai"), \
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value=""), \
patch.object(tts_tool, "resolve_managed_tool_gateway") as gateway_mock:
with pytest.raises(ValueError) as exc:
tts_tool._resolve_openai_audio_client_config()
gateway_mock.assert_not_called()
assert "openai" in str(exc.value)
assert "hermes tools" in str(exc.value)
def test_missing_config_and_env_raises_updated_error(self):
with patch.object(tts_tool, "_load_tts_config", return_value={}), \
patch.object(tts_tool, "prefers_gateway", return_value=False), \
patch.object(tts_tool, "read_selection", return_value=None), \
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value=""), \
patch.object(tts_tool, "resolve_managed_tool_gateway", return_value=None), \
patch.object(tts_tool, "managed_nous_tools_enabled", return_value=False):
+20 -3
View File
@@ -228,12 +228,29 @@ class TestBackendSelection:
patch("agent.web_search_registry._keyless_tier_enabled", return_value=False):
assert _get_backend() == "firecrawl"
def test_invalid_config_falls_through_to_fallback(self):
"""web.backend=invalid → ignored, uses key-based fallback."""
def test_invalid_config_is_returned_verbatim(self):
"""Strict selection: web.backend=nonexistent is returned as-is so the
dispatch path raises the honest selection-naming error — never
silently rerouted through the credential ladder."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={"backend": "nonexistent"}), \
patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
assert _get_backend() == "parallel"
assert _get_backend() == "nonexistent"
def test_stored_backend_wins_over_other_credentials(self):
"""Strict selection: a stored web.backend beats env keys for other
vendors — no availability probe, no credential override."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}), \
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}):
assert _get_backend() == "firecrawl"
def test_nous_backend_maps_to_firecrawl(self):
"""The managed 'nous' selection is serviced by the firecrawl
provider (whose client resolver routes managed)."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={"backend": "nous"}):
assert _get_backend() == "firecrawl"
def test_managed_gateway_does_not_preempt_explicit_tavily(self):
"""Regression: a Nous OAuth token (managed gateway "ready") must NOT
+22 -4
View File
@@ -113,21 +113,39 @@ def _config_cdp_url() -> str:
def is_camofox_mode() -> bool:
"""True when Camofox backend is configured and no CDP override is active.
"""True when the Camofox backend is selected and no CDP override is active.
Camofox is a selection: ``browser.cloud_provider: camofox`` (set via
``hermes tools``). ``CAMOFOX_URL`` is the server ADDRESS only — its
presence no longer selects the backend when a different
``browser.cloud_provider`` is stored. Legacy read-time interpretation:
when NO cloud provider selection was ever written, a set ``CAMOFOX_URL``
keeps activating Camofox exactly as before (nothing is migrated/written
to config).
A CDP override takes priority over Camofox so the browser tools operate on
the real CDP browser (and a CDP backend is treated as non-local for SSRF
checks) instead of being silently routed to Camofox. The override may come
from the ``BROWSER_CDP_URL`` env var (set by ``/browser connect``) OR a
persistent ``browser.cdp_url`` in config.yaml — both are honored, matching
``browser_tool._get_cdp_override()``'s precedence. (Previously only the env
var suppressed Camofox, so ``CAMOFOX_URL`` + a config CDP override still
routed navigation through Camofox.)
``browser_tool._get_cdp_override()``'s precedence.
"""
if os.getenv("BROWSER_CDP_URL", "").strip():
return False
if _config_cdp_url():
return False
try:
from tools.tool_backend_helpers import read_selection
selected = read_selection("browser")
except Exception: # pragma: no cover — helpers are in-repo
selected = None
if selected == "camofox":
return True
if selected is not None:
# An explicit different browser selection wins: CAMOFOX_URL is just
# an address, not a choice.
return False
return bool(get_camofox_url())
+28 -18
View File
@@ -815,19 +815,26 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]:
global _cached_cloud_provider, _cloud_provider_resolved
resolved: Optional[CloudBrowserProvider] = None
provider_key = None
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
browser_cfg = cfg.get("browser", {})
provider_key = None
if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
provider_key = normalize_browser_cloud_provider(
browser_cfg.get("cloud_provider")
)
if provider_key == "local":
if provider_key in ("local", "camofox"):
# Camofox runs through the built-in browser tools
# (is_camofox_mode() dispatch), not a cloud provider.
_cached_cloud_provider = None
_cloud_provider_resolved = True
return None
if provider_key == "nous":
# Managed "Nous Subscription" selection is serviced by the
# Browser Use provider, whose config resolver routes it
# through the managed browser-use gateway.
provider_key = "browser-use"
if provider_key:
try:
if _is_legacy_provider_registry_overridden():
@@ -841,20 +848,20 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]:
# populated. Idempotent — cheap on subsequent calls.
_ensure_browser_plugins_loaded()
resolved = _registry_get_browser_provider(provider_key)
if resolved is None:
# Explicit config name unknown to the registry —
# might be a typo, an uninstalled plugin, or a
# registry-population failure. Warn the user
# (legacy code would have surfaced a typed
# credentials error via direct class instantiation;
# post-migration we surface this WARNING instead).
logger.warning(
"browser.cloud_provider=%r is not a registered "
"browser plugin; falling back to auto-detect "
"(install the corresponding plugin or fix the "
"config key spelling).",
provider_key,
)
if resolved is None:
# Strict selection: a stored-but-unregistered name is an
# honest error, never a silent reroute to auto-detect.
from tools.tool_backend_helpers import selection_error
raise ValueError(selection_error(
"browser",
f"'{provider_key}'",
"no registered browser plugin has that name (install "
"the corresponding plugin or fix the config key "
"spelling)",
))
except ValueError:
raise
except Exception:
logger.warning(
"Failed to instantiate explicit cloud_provider %r; will retry on next call",
@@ -862,13 +869,16 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]:
exc_info=True,
)
return None
except ValueError:
raise
except Exception as e:
# Config file may be temporarily unreadable; still try auto-detect so
# env-based / managed-gateway credentials can resolve. Don't pin cache.
logger.debug("Could not read cloud_provider from config: %s", e)
if resolved is None:
# Auto-detect path: Browser Use first (managed Nous gateway or
if resolved is None and provider_key is None:
# Auto-detect path — permitted ONLY when no cloud_provider selection
# was ever written: Browser Use first (managed Nous gateway or
# direct API key), then Browserbase (direct credentials). Uses
# the legacy class names imported at the top of this module so
# tests that ``monkeypatch.setattr(browser_tool, "BrowserUseProvider", ...)``
+62 -2
View File
@@ -18,6 +18,50 @@ DEFAULT_RESULT_SIZE_CHARS: int = 100_000
DEFAULT_TURN_BUDGET_CHARS: int = 200_000
DEFAULT_PREVIEW_SIZE_CHARS: int = 1_500
# Tighter default per-result threshold for MCP tools (name prefix ``mcp_``).
#
# MCP servers routinely return un-paginated 20-50K-char payloads (tool
# discovery catalogs, batched executions) that sail under the generic 100K
# threshold and silently bloat context — in agentic evals this measurably
# ballooned per-turn reasoning time on long conversations. Competitor
# harnesses cap harder (OpenCode 50KB, pi 50KB, Claude Code 30K chars,
# Codex ~10K tokens); 50K chars keeps parity with the strictest general-
# purpose caps while spillover (unlike truncation) preserves the full
# payload on disk. Overridable via ``tool_budget.mcp_result_size_chars``
# in config.yaml.
DEFAULT_MCP_RESULT_SIZE_CHARS: int = 50_000
# Tool-name prefix that identifies MCP-served tools (same prefix the
# untrusted-content wrapper keys on in agent/tool_dispatch_helpers.py).
MCP_TOOL_PREFIX: str = "mcp_"
def _configured_mcp_result_size() -> int:
"""Read ``tool_budget.mcp_result_size_chars`` from the active config.
Goes through :func:`hermes_cli.config.load_config_readonly` (the
sanctioned read path — raw config.yaml parsing outside owner modules
is guarded by tests/hermes_cli/test_config_read_guard.py). Fully
guarded: any error, missing key, or non-positive value returns the
built-in default. The ``tool_budget:`` block name is shared with the
wider configurable-caps proposal (#80508) so the two can merge
without a key rename.
"""
try:
from hermes_cli.config import load_config_readonly
data = load_config_readonly()
block = data.get("tool_budget") if isinstance(data, dict) else None
if isinstance(block, dict):
raw = block.get("mcp_result_size_chars")
if raw is not None:
value = int(raw)
if value > 0:
return value
except Exception:
pass
return DEFAULT_MCP_RESULT_SIZE_CHARS
@dataclass(frozen=True)
class BudgetConfig:
@@ -32,12 +76,21 @@ class BudgetConfig:
default_result_size: int = DEFAULT_RESULT_SIZE_CHARS
turn_budget: int = DEFAULT_TURN_BUDGET_CHARS
preview_size: int = DEFAULT_PREVIEW_SIZE_CHARS
mcp_result_size: int = DEFAULT_MCP_RESULT_SIZE_CHARS
tool_overrides: Dict[str, int] = field(default_factory=dict)
def resolve_threshold(self, tool_name: str) -> int | float:
"""Resolve the persistence threshold for a tool.
Priority: pinned -> tool_overrides -> registry per-tool -> default.
Priority: pinned -> tool_overrides -> mcp_ prefix -> registry
per-tool -> default.
MCP tools (``mcp_`` prefix) get a tighter default threshold
(``mcp_result_size``, 50K chars) because MCP servers return
un-paginated payloads with no per-tool registry entry to constrain
them. The value is additionally capped at ``default_result_size``
so a context-scaled budget for a small model still constrains MCP
results the same way it constrains registry values.
The registry per-tool value is capped at ``default_result_size`` so a
context-scaled budget (small model) actually constrains tools that
@@ -50,6 +103,8 @@ class BudgetConfig:
return PINNED_THRESHOLDS[tool_name]
if tool_name in self.tool_overrides:
return self.tool_overrides[tool_name]
if tool_name.startswith(MCP_TOOL_PREFIX):
return min(self.mcp_result_size, self.default_result_size)
from tools.registry import registry
registry_value = registry.get_max_result_size(tool_name, default=self.default_result_size)
if registry_value == float("inf"):
@@ -95,8 +150,12 @@ def budget_for_context_window(context_length: int | None) -> BudgetConfig:
small models proportionally to their window, floored so a usable preview
always survives.
"""
mcp_result_size = _configured_mcp_result_size()
if not context_length or context_length <= 0:
return DEFAULT_BUDGET
if mcp_result_size == DEFAULT_MCP_RESULT_SIZE_CHARS:
return DEFAULT_BUDGET
return BudgetConfig(mcp_result_size=mcp_result_size)
window_chars = context_length * _CHARS_PER_TOKEN
per_result = int(window_chars * _PER_RESULT_WINDOW_FRACTION)
@@ -111,4 +170,5 @@ def budget_for_context_window(context_length: int | None) -> BudgetConfig:
default_result_size=per_result,
turn_budget=per_turn,
preview_size=DEFAULT_PREVIEW_SIZE_CHARS,
mcp_result_size=mcp_result_size,
)
+69 -11
View File
@@ -66,10 +66,12 @@ from tools.fal_common import (
)
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
fal_key_is_configured,
managed_nous_tools_enabled,
nous_tool_gateway_unavailable_message,
prefers_gateway,
read_selection,
selection_error,
)
logger = logging.getLogger(__name__)
@@ -732,9 +734,43 @@ _managed_fal_client_lock = threading.Lock()
# Managed FAL gateway (Nous Subscription)
# ---------------------------------------------------------------------------
def _resolve_managed_fal_gateway():
"""Return managed fal-queue gateway config when the user prefers the gateway
or direct FAL credentials are absent."""
if fal_key_is_configured() and not prefers_gateway("image_gen"):
"""Resolve the FAL route from the stored `hermes tools` selection.
Dispatch is a plain switch on the stored ``image_gen`` provider string:
- ``"nous"`` (or legacy ``use_gateway: true``) → managed fal-queue
gateway ONLY; unentitled/unreachable is a selection-naming error
(never a silent fall back to FAL_KEY).
- any other stored provider (``"fal"``, ...) → direct FAL ONLY; a
missing FAL_KEY is an error naming FAL_KEY and the selection (never a
silent managed reroute).
- no selection ever written → legacy credential autodetect: direct when
FAL_KEY is set, else the managed gateway when resolvable, else None.
Returns the managed gateway config, or ``None`` for the direct route.
Raises ``ValueError`` with the honest error contract when the stored
selection cannot run.
"""
selected = read_selection("image_gen")
if selected == NOUS_MANAGED_PROVIDER:
gateway = resolve_managed_tool_gateway("fal-queue")
if gateway is None:
raise ValueError(selection_error(
"image_gen",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
))
return gateway
if selected is not None:
if not fal_key_is_configured():
raise ValueError(selection_error(
"image_gen",
selected,
"FAL_KEY is not set",
))
return None
# Never-configured category: legacy credential autodetect (do NOT persist).
if fal_key_is_configured():
return None
return resolve_managed_tool_gateway("fal-queue")
@@ -1228,6 +1264,9 @@ def image_generate_tool(
if not prompt or not isinstance(prompt, str) or len(prompt.strip()) == 0:
raise ValueError("Prompt is required and must be a non-empty string")
# Strict selection check: a stored-but-broken selection raises the
# honest selection-naming error from _resolve_managed_fal_gateway();
# only the never-configured path can report "no backend at all".
if not (fal_key_is_configured() or _resolve_managed_fal_gateway()):
raise ValueError(_build_no_backend_setup_message())
@@ -1372,8 +1411,19 @@ def image_generate_tool(
def check_fal_api_key() -> bool:
"""True if the FAL.ai API key (direct or managed gateway) is available."""
return bool(fal_key_is_configured() or _resolve_managed_fal_gateway())
"""True if the FAL backend selected via `hermes tools` (or, on a
never-configured install, any FAL backend) is available.
A stored-but-broken selection reports False here (registry gating);
the honest selection-naming error surfaces at call time from
``_resolve_managed_fal_gateway``.
"""
selected = read_selection("image_gen")
if selected == NOUS_MANAGED_PROVIDER:
return bool(resolve_managed_tool_gateway("fal-queue"))
if selected is not None:
return fal_key_is_configured()
return bool(fal_key_is_configured() or resolve_managed_tool_gateway("fal-queue"))
def _build_no_backend_setup_message() -> str:
@@ -1431,7 +1481,7 @@ def check_image_generation_requirements() -> bool:
pass
configured = _read_configured_image_provider()
if not configured or configured == "fal":
if not configured or configured in ("fal", NOUS_MANAGED_PROVIDER):
return False
# Probe only the explicitly selected plugin. Merely possessing a cloud
@@ -1629,8 +1679,11 @@ def _dispatch_to_plugin_provider(
ignore it via their ``**kwargs`` (the ABC contract).
"""
configured = _read_configured_image_provider()
if not configured or configured == "fal":
return None # unset/explicit FAL keeps the legacy FAL path
if not configured or configured in ("fal", NOUS_MANAGED_PROVIDER):
# Unset/explicit FAL keeps the legacy FAL path; "nous" (managed
# Nous Subscription selection) also runs the legacy pipeline, which
# routes through the managed fal-queue gateway.
return None
# Also read configured model so we can pass it to the plugin
configured_model = _read_configured_image_model()
@@ -1785,8 +1838,13 @@ def _maybe_route_managed_krea(
Direct/BYO users (no managed gateway) fall through untouched.
"""
# ``provider == "krea"`` is already handled by the standard plugin dispatch.
if _read_configured_image_provider() == "krea":
# Strict selection rule: an explicitly stored ``image_gen.provider``
# (other than the managed "nous" selection, which IS a managed-mode
# opt-in) disables the model-driven managed interception — the user's
# picker choice dispatches normally. Interception is permitted only on
# never-configured installs or under the managed selection.
configured_provider = _read_configured_image_provider()
if configured_provider is not None and configured_provider != NOUS_MANAGED_PROVIDER:
return None
normalized = _normalize_krea_model(_read_configured_image_model())
+54 -1
View File
@@ -121,6 +121,41 @@ from tools.ansi_strip import strip_unicode_tags
logger = logging.getLogger(__name__)
# Hard allocation ceiling for a single MCP text payload (chars). This is the
# FIRST line of defense against a buggy or malicious MCP server returning
# multi-megabyte text: without it the full payload is allocated, JSON-encoded
# and handed downstream before the budget/spillover layer ever sees it
# (#56059). It deliberately sits far ABOVE the budget layer's 50K MCP
# spillover threshold (tools/budget_config.py) so ordinary large results
# reach spillover INTACT — spilled to disk in full, preview in context —
# while only pathological multi-MB floods are lossy-truncated here.
#
# Distilled from #56060 (Stoltemberg), #56072 (AlexFucuson9) and #56511
# (Tranquil-Flow), which capped at get_max_bytes() (50K) — correct
# protection, but at that level it would truncate before spillover could
# preserve the data. The 40% head / 60% tail split is #56511's shape.
_MCP_HARD_RESULT_CAP_CHARS = 2_000_000
def _truncate_mcp_text_result(text: str, max_chars: int = _MCP_HARD_RESULT_CAP_CHARS) -> str:
"""Bound pathological MCP text before it propagates (#56059).
Results at or under ``max_chars`` pass through unchanged; oversized text
keeps a 40% head / 60% tail split with an omission notice in between.
"""
if len(text) <= max_chars:
return text
head_chars = int(max_chars * 0.4)
tail_chars = max_chars - head_chars
omitted = len(text) - head_chars - tail_chars
return (
text[:head_chars]
+ f"\n\n... [MCP RESULT TRUNCATED - {omitted:,} chars omitted "
f"out of {len(text):,} total] ...\n\n"
+ text[-tail_chars:]
)
# Upper bound for the OSV malware preflight during stdio MCP startup. The
# check makes a blocking urllib HTTPS call whose own timeout can fail to
# interrupt a stalled SSL handshake, which froze the asyncio event loop and
@@ -5816,7 +5851,9 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
if res_text:
error_text += str(res_text)
return tool_error(_sanitize_error(
error_text or "MCP tool returned an error"
_truncate_mcp_text_result(
error_text or "MCP tool returned an error"
)
))
# Collect text from content blocks. MCP tool results can also
@@ -5868,6 +5905,10 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
)
text_result = "\n".join(parts) if parts else ""
# Hard-cap pathological payloads before they propagate (#56059);
# ordinary large results pass untouched to the spillover layer.
text_result = _truncate_mcp_text_result(text_result)
# Combine content + structuredContent when both are present.
# MCP spec: content is model-oriented (text), structuredContent
# is machine-oriented (JSON metadata). For an AI agent, content
@@ -5885,6 +5926,18 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
# vendor-namespaced keys (`com.example.mcp/...`) pass through —
# their semantics belong to the server.
structured = mcp_field(result, "structured_content", "structuredContent")
# Cap structuredContent too — a malicious server could flood
# context via a multi-MB JSON payload (#56059). When the
# serialized form exceeds the hard cap, replace it with the
# truncated string (head + tail preserved) so it degrades
# gracefully instead of flooding downstream.
if structured is not None:
try:
_structured_json = json.dumps(structured, ensure_ascii=False, default=str)
except (TypeError, ValueError):
_structured_json = None
if _structured_json is not None and len(_structured_json) > _MCP_HARD_RESULT_CAP_CHARS:
structured = _truncate_mcp_text_result(_structured_json)
meta = _strip_reserved_meta_keys(mcp_field(result, "meta", "meta"))
if structured is not None or meta is not None:
payload: Dict[str, Any] = {}
+4 -1
View File
@@ -296,6 +296,8 @@ TODO_SCHEMA = {
"description": (
"Manage your task list for the current session. Use for complex tasks "
"with 3+ steps or when the user provides multiple tasks. "
"For 'all N items' tasks, enumerate every instance as its own checklist "
"item so none are silently dropped. "
"Call with no parameters to read the current list.\n\n"
"Writing:\n"
"- Provide 'todos' array to create/update items\n"
@@ -304,7 +306,8 @@ TODO_SCHEMA = {
"Each item: {id: string, content: string, "
"status: pending|in_progress|completed|cancelled}\n"
"List order is priority. Only ONE item in_progress at a time.\n"
"Mark items completed immediately when done. If something fails, "
"Mark an item completed only after the work is verified done, never "
"based on intent. If something fails, "
"cancel it and add a revised item.\n\n"
"Always returns the full current list."
),
+120
View File
@@ -290,6 +290,126 @@ def prefers_gateway(config_section: str) -> bool:
return False
# The provider value the managed "Nous Subscription" picker rows write for
# every category (image_gen.provider: nous, web.backend: nous,
# browser.cloud_provider: nous, ...). Runtime dispatch is a plain switch on
# the stored string: "nous" → managed gateway client; any vendor name → that
# vendor direct with the user's own credentials; no key ever written →
# legacy credential autodetect.
NOUS_MANAGED_PROVIDER = "nous"
# Per-capability keys that also count as "this category has been configured".
_EXTRA_SELECTION_KEYS = {
"web": ("search_backend", "extract_backend"),
}
# Which key(s) carry the category's provider selection. ``browser.backend``
# is deliberately excluded for the browser section — it is the DRIVER choice
# ("browser-use" CLI vs built-in tools), not the cloud provider selection.
_SELECTION_NAME_KEYS = {
"browser": ("cloud_provider",),
"web": ("backend",),
}
_DEFAULT_NAME_KEYS = ("provider", "backend", "cloud_provider")
def read_selection(section: str) -> str | None:
"""Return the stored `hermes tools` provider string for a config section.
THE single runtime read of the persisted selection. Returns:
- ``"nous"`` — the managed Nous Tool Gateway row was selected,
- a vendor name (``"fal"``, ``"openai"``, ``"firecrawl"``, ...) — that
vendor, direct, with the user's own credentials,
- ``None`` — the category has NEVER been configured; the legacy
credential autodetect ladder is permitted (and must not be persisted).
Reads the RAW config.yaml (not the DEFAULT_CONFIG-merged view) so key
presence means "a selection was actually written", not "the schema has a
default". Never raises; an unreadable config reports ``None``.
Legacy interpretation (read-time only — nothing is migrated on disk):
older picker versions wrote ``<section>.use_gateway`` beside the name
key. ``use_gateway: true`` was only ever written by the managed "Nous
Subscription" row, so it maps to ``"nous"`` regardless of the name key;
``use_gateway: false`` beside a name key maps to that name.
"""
try:
from hermes_cli.config import read_raw_config_readonly
cfg = read_raw_config_readonly() or {}
raw = cfg.get(section) if isinstance(cfg, dict) else None
except Exception:
raw = None
if not isinstance(raw, dict):
return None
def _str_or_none(key: str) -> str | None:
value = raw.get(key)
if value is None:
return None
text = str(value).strip().lower()
return text or None
name = None
for key in _SELECTION_NAME_KEYS.get(section, _DEFAULT_NAME_KEYS):
name = _str_or_none(key)
if name:
break
# Legacy shim: a truthy use_gateway means the managed row was picked
# (it was the only writer of use_gateway: true).
if "use_gateway" in raw and is_truthy_value(raw.get("use_gateway"), default=False):
return NOUS_MANAGED_PROVIDER
# NOTE on the legacy DEFAULT_CONFIG ``stt.provider: local`` seed: it never
# reached the raw config.yaml (``save_config`` strips schema defaults),
# and the old picker's Local Whisper row always wrote ``use_gateway:
# False`` beside it. A raw ``local`` here therefore IS a user selection —
# hand-written or picker-written — and is honored like any other vendor
# name. The seeded-value ambiguity only exists in DEFAULT_CONFIG-merged
# views, which this function never reads.
if name:
return name
# use_gateway: false with no name key is not a usable selection shape;
# per-capability web keys still count as configured elsewhere via
# selection_exists(). Fall to autodetect.
return None
def selection_exists(section: str) -> bool:
"""True when ANY selection signal has ever been written for the section.
Wider than ``read_selection() is not None``: per-capability web keys
(``search_backend``/``extract_backend``) mark the category as configured
even when the shared backend name is empty.
"""
if read_selection(section) is not None:
return True
extra = _EXTRA_SELECTION_KEYS.get(section, ())
if not extra:
return False
try:
from hermes_cli.config import read_raw_config_readonly
cfg = read_raw_config_readonly() or {}
raw = cfg.get(section) if isinstance(cfg, dict) else None
except Exception:
return False
if not isinstance(raw, dict):
return False
return any(str(raw.get(key) or "").strip() for key in extra)
def selection_error(section: str, selection_name: str, failure: str) -> str:
"""The uniform honest-error contract for a selected-but-broken provider."""
return (
f"{section} is configured to use {selection_name} (set via hermes "
f"tools), but {failure}. Run 'hermes tools' to change it."
)
def fal_key_is_configured() -> bool:
"""Return True when FAL_KEY is set to a non-whitespace value.
+6 -1
View File
@@ -281,7 +281,12 @@ def _build_persisted_message(
msg = f"{PERSISTED_OUTPUT_TAG}\n"
msg += f"This tool result was too large ({original_size:,} characters, {size_str}).\n"
msg += f"Full output saved to: {file_path}\n"
msg += "Use the read_file tool with offset and limit to access specific sections of this output.\n\n"
msg += "Use the read_file tool with offset and limit to access specific sections of this output.\n"
msg += (
"Recovery: page through the saved file with read_file (offset/limit) or "
"process it with execute_code — do NOT re-request the same data from the "
"remote API; the full result is already on disk.\n\n"
)
msg += f"Preview (first {len(preview)} chars):\n"
msg += preview
if has_more:
+73 -1
View File
@@ -1025,6 +1025,28 @@ def _get_provider(stt_config: dict) -> str:
explicit = "provider" in stt_config
provider = stt_config.get("provider", DEFAULT_PROVIDER)
# The managed "Nous Subscription" selection (stt.provider: nous) is
# serviced by the OpenAI provider implementation, routed through the
# managed openai-audio gateway by _resolve_openai_audio_client_config.
if isinstance(provider, str) and provider.strip().lower() == "nous":
provider = "openai"
if explicit and provider == "local":
# Legacy DEFAULT_CONFIG seeded ``stt.provider: local`` on every
# install, so a merged-config "local" is not proof of a user pick.
# ``read_selection`` reads the raw config.yaml: when the raw file
# holds an stt selection (picker- or hand-written ``local``) it is
# honored; when the merged "local" came only from a legacy default
# merge, take the autodetect branch (which prefers local first
# anyway, so a genuine local user is unaffected when it's available).
try:
from tools.tool_backend_helpers import read_selection
if read_selection("stt") is None:
explicit = False
except Exception: # pragma: no cover — helpers are in-repo
pass
# --- Explicit provider: respect the user's choice ----------------------
if explicit:
@@ -3260,11 +3282,61 @@ def transcribe_audio_local_fallback(
def _resolve_openai_audio_client_config() -> tuple[str, str]:
"""Return direct OpenAI audio config or a managed gateway fallback."""
"""Return ``(api_key, base_url)`` for the OpenAI STT client.
Strict selection semantics (switch on the stored ``stt`` provider
string; previously this resolver never read the stored gateway intent):
- ``"nous"`` (or legacy ``use_gateway: true``) → managed gateway ONLY;
unentitled/unreachable is a selection-naming error (a direct
OPENAI_API_KEY must NOT override it).
- any other stored stt provider → direct credentials ONLY; missing
credentials is a selection-naming error — no silent managed fallback.
- never-configured stt section → legacy ladder: config key → local
base_url → env key → managed gateway.
"""
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
read_selection,
selection_error,
)
stt_config = _load_stt_config()
openai_cfg = stt_config.get("openai") or {}
cfg_api_key = openai_cfg.get("api_key", "")
cfg_base_url = openai_cfg.get("base_url", "")
selected = read_selection("stt")
if selected == NOUS_MANAGED_PROVIDER:
managed_gateway = resolve_managed_tool_gateway("openai-audio")
if managed_gateway is None:
raise ValueError(selection_error(
"stt",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
))
return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
)
if selected is not None:
# Stored vendor selection: direct credentials only.
if cfg_api_key:
return cfg_api_key, (cfg_base_url or OPENAI_BASE_URL)
if cfg_base_url and _is_local_or_private_url(cfg_base_url):
return "not-needed", cfg_base_url
direct_api_key = resolve_openai_audio_api_key()
if direct_api_key:
return direct_api_key, OPENAI_BASE_URL
raise ValueError(selection_error(
"stt",
selected,
"neither stt.openai.api_key in config nor "
"VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set",
))
# Never-configured stt section: legacy credential ladder.
if cfg_api_key:
return cfg_api_key, (cfg_base_url or OPENAI_BASE_URL)
+63 -16
View File
@@ -93,10 +93,12 @@ def _resolve_provider_key(env_var: str, provider_id: str) -> str:
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
managed_nous_tools_enabled,
nous_tool_gateway_unavailable_message,
prefers_gateway,
read_selection,
resolve_openai_audio_api_key,
selection_error,
)
from tools.xai_http import hermes_xai_user_agent
@@ -651,8 +653,15 @@ def _get_provider(tts_config: Dict[str, Any]) -> str:
Inference credentials do not imply consent to paid speech generation.
Users opt into cloud TTS by setting ``tts.provider`` (normally through
``hermes tools``); otherwise the historical Edge backend remains active.
The managed "Nous Subscription" selection (``tts.provider: nous``) is
serviced by the OpenAI provider implementation, routed through the
managed openai-audio gateway by ``_resolve_openai_audio_client_config``.
"""
return (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip()
provider = (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip()
if provider == NOUS_MANAGED_PROVIDER:
return "openai"
return provider
@dataclass(frozen=True)
@@ -3785,24 +3794,61 @@ def _resolve_openai_audio_client_config() -> tuple[str, str, bool]:
``is_managed`` is True when the config resolves to the Nous managed audio
gateway (a restricted proxy), so callers can coerce the request to what the
gateway supports. When ``tts.use_gateway`` is set the gateway is preferred
even if direct OpenAI credentials are present.
gateway supports.
Resolution order (mirrors the STT resolver):
1. ``tts.openai.api_key`` / ``tts.openai.base_url`` from ``config.yaml``
2. ``VOICE_TOOLS_OPENAI_KEY`` / ``OPENAI_API_KEY`` environment variables
(still honoring ``tts.openai.base_url`` when set)
3. Managed OpenAI audio tool gateway
Strict selection semantics (switch on the stored ``tts`` provider
string):
- ``"nous"`` (or legacy ``use_gateway: true``) → managed gateway ONLY;
unentitled/unreachable is a selection-naming error.
- any other stored tts provider → direct credentials ONLY
(``tts.openai.api_key`` then ``VOICE_TOOLS_OPENAI_KEY``/
``OPENAI_API_KEY``); missing credentials is a selection-naming error —
no silent managed fallback.
- never-configured tts section → legacy ladder: config key → env key →
managed gateway.
"""
tts_config = _load_tts_config()
openai_cfg = (tts_config.get("openai") if isinstance(tts_config, dict) else None) or {}
cfg_api_key = openai_cfg.get("api_key") or ""
cfg_base_url = openai_cfg.get("base_url") or ""
if cfg_api_key and not prefers_gateway("tts"):
selected = read_selection("tts")
if selected == NOUS_MANAGED_PROVIDER:
managed_gateway = resolve_managed_tool_gateway("openai-audio")
if managed_gateway is None:
raise ValueError(selection_error(
"tts",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
))
return (
managed_gateway.nous_user_token,
urljoin(f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"),
True,
)
if selected is not None:
# Stored vendor selection: direct credentials only.
if cfg_api_key:
return cfg_api_key, (cfg_base_url or DEFAULT_OPENAI_BASE_URL), False
direct_api_key = resolve_openai_audio_api_key()
if direct_api_key:
return direct_api_key, (cfg_base_url or DEFAULT_OPENAI_BASE_URL), False
raise ValueError(selection_error(
"tts",
selected,
"neither tts.openai.api_key in config nor "
"VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set",
))
# Never-configured tts section: legacy credential ladder.
if cfg_api_key:
return cfg_api_key, (cfg_base_url or DEFAULT_OPENAI_BASE_URL), False
direct_api_key = resolve_openai_audio_api_key()
if direct_api_key and not prefers_gateway("tts"):
if direct_api_key:
return direct_api_key, (cfg_base_url or DEFAULT_OPENAI_BASE_URL), False
managed_gateway = resolve_managed_tool_gateway("openai-audio")
@@ -3811,7 +3857,7 @@ def _resolve_openai_audio_client_config() -> tuple[str, str, bool]:
"Neither tts.openai.api_key in config nor "
"VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set"
)
if managed_nous_tools_enabled() or prefers_gateway("tts"):
if managed_nous_tools_enabled():
message += (
". "
+ nous_tool_gateway_unavailable_message(
@@ -3828,11 +3874,12 @@ def _resolve_openai_audio_client_config() -> tuple[str, str, bool]:
def _has_openai_audio_backend() -> bool:
"""Return True when OpenAI audio can use config/env credentials or the managed gateway."""
openai_cfg = (_load_tts_config().get("openai") or {})
if openai_cfg.get("api_key"):
"""Return True when the selected OpenAI audio route is usable."""
try:
_resolve_openai_audio_client_config()
return True
return bool(resolve_openai_audio_api_key() or resolve_managed_tool_gateway("openai-audio"))
except ValueError:
return False
# ===========================================================================
+91 -12
View File
@@ -223,16 +223,36 @@ def _list_registered_web_providers():
def _get_backend() -> str:
"""Determine which web backend to use (shared fallback).
Reads ``web.backend`` from config.yaml (set by ``hermes tools``).
Falls back to whichever API key is present for users who configured
keys manually without running setup.
Reads ``web.backend`` from config.yaml (set by ``hermes tools``). A
stored backend name is returned as-is — no availability probe, no
fallback — so the vendor path can raise its own honest error when the
selection is broken. The credential/entitlement autodetect ladder runs
ONLY when no web selection has ever been stored.
"""
configured = (_load_web_config().get("backend") or "").lower().strip()
if configured in _LEGACY_WEB_BACKENDS or _registered_web_provider(configured) is not None:
if configured:
# Strict: the stored selection is final, known name or not — an
# unknown/typoed name surfaces as the vendor path's honest error
# rather than silently rerouting through the credential ladder.
# The managed "Nous Subscription" selection ("nous") is serviced by
# the firecrawl provider, whose client resolver routes it through
# the managed Tool Gateway.
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
if configured == NOUS_MANAGED_PROVIDER:
return "firecrawl"
return configured
# Fallback for manual / legacy config — pick the highest-priority
# available backend. Explicit user credentials (TAVILY_API_KEY etc.)
from tools.tool_backend_helpers import selection_exists
if selection_exists("web"):
# A web selection exists (e.g. use_gateway key or per-capability
# backends) but the shared backend name is empty — keep the
# firecrawl default rather than credential-laddering.
return "firecrawl"
# Never-configured install — pick the highest-priority available
# backend. Explicit user credentials (TAVILY_API_KEY etc.)
# beat the managed-tool-gateway probe so a deliberate setup is not
# pre-empted by a Nous OAuth token whose subscription tier may not
# actually grant web-search access (the gateway then fails at runtime
@@ -324,12 +344,16 @@ def _get_extract_backend() -> str:
def _get_capability_backend(capability: str) -> str:
"""Shared helper for per-capability backend selection.
Reads ``web.{capability}_backend`` from config; if set and available,
uses it. Otherwise falls through to the shared ``_get_backend()``.
Reads ``web.{capability}_backend`` from config; a stored value is
returned unconditionally (strict selection — no availability probe).
A selected-but-broken backend surfaces the vendor path's honest error
instead of being silently replaced by whatever the credential ladder
finds. Falls through to the shared ``_get_backend()`` only when no
per-capability override is stored.
"""
cfg = _load_web_config()
specific = (cfg.get(f"{capability}_backend") or "").lower().strip()
if specific and _is_backend_available(specific):
if specific:
return specific
return _get_backend()
@@ -718,9 +742,35 @@ def web_search_tool(query: str, limit: int = 5) -> str:
backend = _get_search_backend()
provider = _wsp_get_provider(backend) if backend else None
if provider is None or not provider.supports_search():
# Fall back to availability-walked active provider when the
# configured backend isn't a registered search provider (typo,
# uninstalled plugin, or capability mismatch).
from tools.tool_backend_helpers import (
selection_error,
selection_exists,
)
if provider is None and backend and selection_exists("web"):
disabled_key = _disabled_web_plugin_for(capability="search")
if disabled_key:
_vendor = disabled_key.split("/", 1)[-1]
error_text = (
f"web.search_backend is set to '{_vendor}', but its "
f"plugin ('{disabled_key}') is disabled in config. "
f"Re-enable it with `hermes plugins enable {disabled_key}` "
"(or remove it from plugins.disabled)."
)
else:
error_text = selection_error(
"web",
f"'{backend}'",
"no registered web search provider has that name",
)
response_data = {"success": False, "error": error_text}
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
debug_call_data["error"] = error_text
_debug.log_call("web_search_tool", debug_call_data)
_debug.save()
return result_json
# Never-configured install: fall back to the availability-walked
# active provider (legacy autodetect behavior).
provider = get_active_search_provider()
if provider is None:
@@ -924,6 +974,35 @@ async def web_extract_tool(
},
ensure_ascii=False,
)
from tools.tool_backend_helpers import (
selection_error,
selection_exists,
)
if backend and selection_exists("web"):
# Strict selection: a stored-but-unregistered backend
# errors by name instead of silently switching to
# whatever the availability walk finds.
disabled_key = _disabled_web_plugin_for(capability="extract")
if disabled_key:
_vendor = disabled_key.split("/", 1)[-1]
error_text = (
f"web.extract_backend is set to '{_vendor}', but "
f"its plugin ('{disabled_key}') is disabled in "
f"config. Re-enable it with `hermes plugins "
f"enable {disabled_key}` (or remove it from "
"plugins.disabled)."
)
else:
error_text = selection_error(
"web",
f"'{backend}'",
"no registered web extract provider has that name",
)
return json.dumps(
{"success": False, "error": error_text},
ensure_ascii=False,
)
provider = get_active_extract_provider()
if provider is None:
# If the configured backend is a bundled web plugin the
+75 -4
View File
@@ -746,6 +746,21 @@ tool_output:
max_lines: 500
```
### Tool-Result Spillover Budget
Separately from truncation, oversized tool *results* are spilled to disk rather than cut: the full output is saved under `$HERMES_HOME/cache/spillover/` and the in-context content is replaced by a preview plus the saved file's path (readable with `read_file` using `offset`/`limit`, or processable with `execute_code`). The generic per-result spillover threshold is 100,000 chars, scaled down automatically for small-context models.
MCP tool results (tools named `mcp_*`) spill at a tighter **50,000-char** default: MCP servers routinely return large un-paginated payloads (tool-discovery catalogs, batched executions) that would otherwise sit under the generic threshold and bloat context on every subsequent turn. Nothing is lost — the full result is preserved on disk. Override the threshold via:
```yaml
tool_budget:
mcp_result_size_chars: 50000 # per-result spillover threshold for mcp_* tools
```
The MCP threshold is always capped at the (possibly context-scaled) generic per-result threshold, so raising it cannot exceed what the active model's window allows.
Hermes also flags **provider-side elision**: when an MCP or web tool result embeds its own truncation markers (`...N more items`, `"has_more": true`, "saved to sandbox" notes), a one-line notice is appended to the result warning that the visible data is incomplete and should be paged/fetched before treating any enumeration as complete.
## Global Toolset Disable
To suppress specific toolsets across the CLI and every gateway platform in one
@@ -1016,6 +1031,28 @@ When the iteration budget is fully exhausted, the CLI shows a notification to th
`agent.api_max_retries` controls how many times Hermes retries a provider API call on transient errors (rate limits, connection drops, 5xx) **before** fallback-provider switching engages. The default is `3` — four attempts total. If you have [fallback providers](/user-guide/features/fallback-providers) configured and want to fail over faster, drop this to `0` so the first transient error on your primary immediately hands off to the fallback instead of churning retries against the flaky endpoint.
## Wall-Clock Run Budget
Separate from the iteration budget, you can give each conversation run an optional **wall-clock** budget. This is designed for one-shot and eval-harness invocations that run under a hard external ceiling (e.g. a 900-second per-task limit): without it, a run can time out with the work essentially done — one generation short of emitting the final answer, or stuck in a single hung provider call.
```yaml
agent:
run_budget_seconds: null # Optional; unset/null = feature fully off (default)
```
Or per-invocation via the CLI:
```bash
hermes chat --run-budget 850 -q "..."
```
When a budget is set, two things happen:
1. **Wrap-up notice at 80%.** When 80% of the budget has elapsed, Hermes injects a **one-time** notice (delivered cache-safely, appended to the newest tool result like `/steer` messages) telling the model to stop new discovery/verification work and produce the final deliverable from the state it already has. It fires at most once per run and mirrors the existing iteration-budget wrap-up mechanism — there are no repeated pressure warnings.
2. **Deadline-scaled stale timeouts.** Implicit non-streaming stale timeouts (the 90s default and the reasoning-model floors, e.g. 600s for DeepSeek reasoning models) are capped at `max(60, remaining_budget × 0.5)` so a single silently-hung provider call can never consume the rest of the run. The cap only ever *tightens* the timeout — it never raises it — and an explicitly configured `stale_timeout_seconds` (provider/model config or `HERMES_API_CALL_STALE_TIMEOUT`) always wins untouched.
The budget is per `run_conversation` turn (it resets on each user message) and the feature is completely dormant when unset — no clock reads, no injection, no timeout changes.
## Verify-on-Stop (coding verification)
When enabled, Hermes refuses to accept a final answer on a turn where the agent edited code in a workspace but produced no fresh verification evidence (a passing test run, build, lint, etc.) — it injects a synthetic follow-up asking the agent to verify or explain why it can't. Doc/markdown/skill-only edits never trigger it, and the loop is bounded so it can never trap the agent.
@@ -1629,13 +1666,11 @@ agent:
### What it injects
When enabled, three layers of guidance may be added to the system prompt:
When enabled, two layers of guidance may be added to the system prompt:
1. **General tool-use enforcement** (all matched models) — instructs the model to make tool calls immediately instead of describing intentions, keep working until the task is complete, and never end a turn with a promise of future action.
2. **OpenAI execution discipline** (GPT, Codex, and Grok models) — additional guidance addressing GPT-specific failure modes: abandoning work on partial results, skipping prerequisite lookups, hallucinating instead of using tools, and declaring "done" without verification.
3. **Google operational guidance** (Gemini and Gemma models only) — conciseness, absolute paths, parallel tool calls, and verify-before-edit patterns.
2. **Google operational guidance** (Gemini and Gemma models only) — conciseness, absolute paths, parallel tool calls, and verify-before-edit patterns.
These are transparent to the user and only affect the system prompt. Models that already use tools reliably (like Claude) don't need this guidance, which is why `"auto"` excludes them.
@@ -1648,6 +1683,33 @@ agent:
tool_use_enforcement: ["gpt", "codex", "gemini", "grok", "my-custom-model"]
```
## Execution-Discipline Guidance
Separately from tool-use enforcement, Hermes injects an **execution-discipline** block for model families that share a set of agentic failure modes observed in eval traces: doing arithmetic in prose instead of code, skipping read-back verification after external writes, "repairing" malformed identifiers, claiming completeness despite count mismatches, and declaring "done" without verifying every acceptance criterion.
```yaml
agent:
execution_guidance: "auto" # "auto" | true | false | ["model-substring", ...]
```
| Value | Behavior |
|-------|----------|
| `"auto"` (default) | Enabled for models matching: `gpt`, `codex`, `grok`, `deepseek`, `kimi`, `qwen`, `glm`, `minimax`, `mimo`, `mistral`. |
| `true` | Always enabled, regardless of model. |
| `false` | Always disabled, regardless of model. |
| `["deepseek", "my-custom-model"]` | Enabled only when the model name contains one of the listed substrings (case-insensitive). |
The injected block covers:
- **Tool persistence** — keep calling tools until the task is complete *and* verified; retry empty, partial, or suspiciously narrow lookup results with a broader or different query before concluding.
- **Mandatory tool use** — arithmetic, hashes, dates, system state, and file facts always come from a tool, never from mental computation.
- **External-write read-back** — after any state-changing write to an external system, read back the exact target before claiming success (internal file edits a tool already confirmed are not re-verified).
- **Count reconciliation** — declared totals (`total`, `reply_count`, `has_more`) are hard assertions; on mismatch, re-fetch or parse programmatically.
- **Literal preservation** — never normalize or "repair" identifiers that fail a stated format; a successful lookup does not validate a malformed source token.
- **Verification-gated completion** — "done" means every named acceptance criterion is verified, never a plausible subset.
The gate is independent of `tool_use_enforcement` — either can be on without the other. The guidance is chosen once at session start keyed on the model name, so the system prompt stays byte-stable (and prompt-cache-friendly) for the life of the conversation. Gemini/Gemma are excluded from the auto list because they receive the more specific Google operational guidance; Claude is excluded because it doesn't exhibit these failure modes — opt any model in with `true` or a substring list.
## Tool-Loop Guardrails
Hermes detects when the agent is stuck in an unproductive tool-calling loop — the same tool call failing repeatedly, the same tool failing over and over, or an idempotent call returning the same result with no progress. By default it injects a **warning** into the tool result so the model self-corrects; it does not hard-stop, since a person watching the CLI/TUI can intervene.
@@ -1681,6 +1743,15 @@ A single `delegate_task` batch counts each task toward `max_subagents` (a batch
This mirrors Claude Code's per-session WebSearch and subagent caps (v2.1.212), which also default to 200 and reset on `/clear`.
### Runtime anti-stall guards
Complementing the failure-based guardrails above, `agent.stall_guards` (default `true`) enables two conservative runtime guards against wasted turns. First, an **identical-call loop breaker**: when the same tool is called 3+ consecutive times with identical arguments *and* returns an identical result, a short one-line notice is appended to that tool result telling the model not to repeat the call — it never blocks the call, and legitimately-repeatable pollers (`process`, `*_get_result`, `*_poll`) are exempt. Second, a **continue-intent recovery**: when the model ends a turn with no tool calls but its short reply trails off announcing an action ("Let me now update the file…"), Hermes re-prompts it to act via the same bounded continuation mechanism used for intent-ack recovery (max 2 re-prompts per turn). Both are cache-safe (notices are added at result construction, never retroactively) and can be disabled together:
```yaml
agent:
stall_guards: false
```
## TTS Configuration
```yaml