From 26bbac4ab9fcc2fa3de09f8064fcd0f7cd723281 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:22:13 -0700 Subject: [PATCH 01/30] refactor(agent/gemini,acp,credits): compact wire adapters and credits parser (parity-verified) --- agent/copilot_acp_client.py | 314 +++++++++----------------- agent/credits_tracker.py | 297 +++++++++++-------------- agent/fast_mode.py | 23 +- agent/gemini_native_adapter.py | 396 +++++++++++++-------------------- agent/gemini_schema.py | 6 +- agent/jiter_preload.py | 16 +- agent/lazy_forward.py | 17 +- agent/lmstudio_reasoning.py | 47 ++-- 8 files changed, 436 insertions(+), 680 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index d5e1bb90a4..c52131e08a 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -46,6 +46,32 @@ _ROLE_LABELS = {"system": "System", "user": "User", "assistant": "Assistant", "t # so a CLI installed mid-session is picked up. _ACP_PROBE_CACHE: dict[str, bool] = {} +_PROMPT_PREAMBLE = ( + "You are being used as the active ACP agent backend for Hermes.", + "Use ACP capabilities to complete tasks.", + "IMPORTANT: If you take an action with a tool, you MUST output tool calls using {...} blocks with JSON exactly in OpenAI function-call shape.", + "If no tool is needed, answer normally.", +) +_INITIALIZE_PARAMS = { + "protocolVersion": 1, + "clientCapabilities": {"fs": {"readTextFile": True, "writeTextFile": True}}, + "clientInfo": {"name": "hermes-agent", "title": "Hermes Agent", "version": "0.0.0"}, +} +_DEPRECATED_CLI_ERROR = ( + "Hermes ACP mode requires the NEW GitHub Copilot CLI " + "(github.com/github/copilot-cli), but the binary it just " + "spawned is the deprecated `gh copilot` extension.\n\n" + "Install the new CLI:\n" + " npm install -g @github/copilot\n" + " # then verify with: copilot --help\n\n" + "If `copilot` already resolves to the new CLI but you still see this,\n" + "point Hermes at it explicitly:\n" + " export HERMES_COPILOT_ACP_COMMAND=/path/to/new/copilot\n\n" + "Alternative: use the `copilot` provider (no ACP, hits the Copilot API\n" + "directly with a Copilot subscription token) via `hermes setup`.\n\n" + "Original error:\n" +) + def _is_gh_copilot_deprecation_message(stderr_text: str) -> bool: """True iff stderr looks like the deprecated gh-copilot extension's banner.""" @@ -54,11 +80,7 @@ def _is_gh_copilot_deprecation_message(stderr_text: str) -> bool: def _resolve_command() -> str: - return ( - os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() - or os.getenv("COPILOT_CLI_PATH", "").strip() - or "copilot" - ) + return os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() or os.getenv("COPILOT_CLI_PATH", "").strip() or "copilot" def _resolve_args() -> list[str]: @@ -70,10 +92,10 @@ def _acp_supported(command: str, args: list[str]) -> bool | None: """Tri-state probe: does ``command`` accept ``--acp``? A CLI without the flag (older releases, Claude Code v2.x) exits 1 with - ``error: unknown option '--acp'`` and the parent then waits the full - child timeout for stdout that never arrives. True = help advertises --acp; - False = help ran cleanly without it (caller fast-fails); None = inconclusive - (binary missing / --help failed), caller falls through to the normal spawn error. + ``error: unknown option '--acp'`` and the parent then waits the full child + timeout for stdout that never arrives. True = help advertises --acp; False = + help ran cleanly without it (caller fast-fails); None = inconclusive (binary + missing / --help failed), caller falls through to the normal spawn error. Only probes when ``--acp`` is among ``args`` — a custom transport is the operator's business. """ if "--acp" not in args: @@ -97,7 +119,7 @@ def _acp_supported(command: str, args: list[str]) -> bool | None: def _resolve_home_dir() -> str: - """Return a stable HOME for child ACP processes; /tmp as a last resort so the child never starts HOME-less.""" + """Stable HOME for child ACP processes; /tmp as a last resort so the child never starts HOME-less.""" home = os.environ.get("HOME", "").strip() if home: return home @@ -137,99 +159,61 @@ def _permission_denied(message_id: Any) -> dict[str, Any]: return _jsonrpc_result(message_id, {"outcome": {"outcome": "cancelled"}}) -def _model_selection_request( - session: dict[str, Any], requested_model: str -) -> tuple[str, dict[str, str]] | None: +def _enabled_ids(entries: Any, key: str) -> set[str]: + """Ids of ``entries`` (dicts) whose ``_meta.copilotEnablement`` is not ``disabled``.""" + return { + str(e.get(key) or "").strip() + for e in (entries or []) + if isinstance(e, dict) and str((e.get("_meta") or {}).get("copilotEnablement") or "").strip().lower() != "disabled" + } + + +def _model_selection_request(session: dict[str, Any], requested_model: str) -> tuple[str, dict[str, str]] | None: """Return the ACP request that selects ``requested_model`` for ``session``. - Prefer stable v1 ``session/set_config_option``. Fall back to Copilot's - pre-stabilization ``session/set_model`` extension only when no model - config option is advertised. A reported model list is authoritative: - unknown and policy-disabled ids return None instead of being sent. + Prefer stable v1 ``session/set_config_option``; fall back to Copilot's + pre-stabilization ``session/set_model`` extension only when no model config + option is advertised. A reported model list is authoritative: unknown and + policy-disabled ids return None instead of being sent. """ session_id = str(session.get("sessionId") or "").strip() requested_model = str(requested_model or "").strip() if not session_id or not requested_model or requested_model == "copilot-acp": return None - - config_options = [ - o for o in (session.get("configOptions") or []) if isinstance(o, dict) - ] model_option = next( - ( - o for o in config_options - if o.get("category") == "model" or o.get("id") == "model" - ), + (o for o in (session.get("configOptions") or []) + if isinstance(o, dict) and (o.get("category") == "model" or o.get("id") == "model")), None, ) if model_option is not None: - enabled_values = { - str(o.get("value") or "").strip() - for o in (model_option.get("options") or []) - if isinstance(o, dict) - and str( - ((o.get("_meta") or {}).get("copilotEnablement")) or "" - ).strip().lower() != "disabled" - } - if requested_model not in enabled_values: + if requested_model not in _enabled_ids(model_option.get("options"), "value"): return None - return ( - "session/set_config_option", - { - "sessionId": session_id, - "configId": str(model_option.get("id") or "model"), - "value": requested_model, - }, - ) - - advertised = [ - m - for m in ((session.get("models") or {}).get("availableModels") or []) - if isinstance(m, dict) - ] - available = { - str(m.get("modelId") or "").strip() - for m in advertised - if str( - ((m.get("_meta") or {}).get("copilotEnablement")) or "" - ).strip().lower() != "disabled" - } + return "session/set_config_option", { + "sessionId": session_id, "configId": str(model_option.get("id") or "model"), "value": requested_model, + } + available = _enabled_ids((session.get("models") or {}).get("availableModels"), "modelId") if available and requested_model not in available: return None - return ( - "session/set_model", - {"sessionId": session_id, "modelId": requested_model}, - ) + return "session/set_model", {"sessionId": session_id, "modelId": requested_model} def _format_messages_as_prompt( - messages: list[dict[str, Any]], - model: str | None = None, - tools: list[dict[str, Any]] | None = None, + messages: list[dict[str, Any]], model: str | None = None, tools: list[dict[str, Any]] | None = None, tool_choice: Any = None, ) -> str: - sections: list[str] = [ - "You are being used as the active ACP agent backend for Hermes.", - "Use ACP capabilities to complete tasks.", - "IMPORTANT: If you take an action with a tool, you MUST output tool calls using {...} blocks with JSON exactly in OpenAI function-call shape.", - "If no tool is needed, answer normally.", - ] # Deliberately no "requested model" line: the model is applied for real via ACP # session/set_model; a prompt-text mention makes a substituted backend model # FALSELY self-identify as the requested one. Identity comes from the backend. # Copilot has no tools of its own that collide with Hermes', so forward the whole toolset. - sections.extend(_render_tool_bridge_sections(tools, tool_choice)) - + sections: list[str] = [*_PROMPT_PREAMBLE, *_render_tool_bridge_sections(tools, tool_choice)] transcript: list[str] = [] for message in messages: if not isinstance(message, dict): continue role = str(message.get("role") or "unknown").strip().lower() - if role not in _ROLE_LABELS: - role = "context" rendered = _render_message_content(message.get("content")) if rendered: - transcript.append(f"{_ROLE_LABELS[role]}:\n{rendered}") + transcript.append(f"{_ROLE_LABELS.get(role, 'Context')}:\n{rendered}") if transcript: sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript)) sections.append("Continue the conversation from the latest user request.") @@ -239,23 +223,18 @@ def _format_messages_as_prompt( def _render_message_content(content: Any) -> str: if content is None: return "" - if isinstance(content, str): - return content.strip() if isinstance(content, dict): if "text" in content: return str(content.get("text") or "").strip() if isinstance(content.get("content"), str): - return str(content.get("content") or "").strip() + return content["content"].strip() return json.dumps(content, ensure_ascii=True) if isinstance(content, list): - parts: list[str] = [] - for item in content: - if isinstance(item, str): - parts.append(item) - elif isinstance(item, dict): - text = item.get("text") - if isinstance(text, str) and text.strip(): - parts.append(text.strip()) + parts = [ + item if isinstance(item, str) else item["text"].strip() + for item in content + if isinstance(item, str) or (isinstance(item, dict) and isinstance(item.get("text"), str) and item["text"].strip()) + ] return "\n".join(parts).strip() return str(content).strip() @@ -279,9 +258,9 @@ def _effective_timeout(timeout: Any) -> float: return _DEFAULT_TIMEOUT_SECONDS if isinstance(timeout, (int, float)): return float(timeout) - _candidates = [getattr(timeout, attr, None) for attr in ("read", "write", "connect", "pool", "timeout")] - _numeric = [float(v) for v in _candidates if isinstance(v, (int, float))] - return max(_numeric) if _numeric else _DEFAULT_TIMEOUT_SECONDS + candidates = [getattr(timeout, attr, None) for attr in ("read", "write", "connect", "pool", "timeout")] + numeric = [float(v) for v in candidates if isinstance(v, (int, float))] + return max(numeric) if numeric else _DEFAULT_TIMEOUT_SECONDS def _fs_read_text_file(params: dict[str, Any], cwd: str) -> Any: @@ -293,13 +272,11 @@ def _fs_read_text_file(params: dict[str, Any], cwd: str) -> Any: content = path.read_text(encoding="utf-8") except FileNotFoundError: content = "" - line = params.get("line") - limit = params.get("limit") + line, limit = params.get("line"), params.get("limit") if isinstance(line, int) and line > 1: lines = content.splitlines(keepends=True) - start = line - 1 - end = start + limit if isinstance(limit, int) and limit > 0 else None - content = "".join(lines[start:end]) + end = line - 1 + limit if isinstance(limit, int) and limit > 0 else None + content = "".join(lines[line - 1:end]) if content: content = redact_sensitive_text(content, force=True) return {"content": content} @@ -325,19 +302,6 @@ def _fs_write_text_file(params: dict[str, Any], cwd: str) -> Any: _FS_HANDLERS = {"fs/read_text_file": _fs_read_text_file, "fs/write_text_file": _fs_write_text_file} -class _ACPChatCompletions: - def __init__(self, client: "CopilotACPClient"): - self._client = client - - def create(self, **kwargs: Any) -> Any: - return self._client._create_chat_completion(**kwargs) - - -class _ACPChatNamespace: - def __init__(self, client: "CopilotACPClient"): - self.completions = _ACPChatCompletions(client) - - class CopilotACPClient: """Minimal OpenAI-client-compatible facade for Copilot ACP.""" @@ -348,17 +312,9 @@ class CopilotACPClient: HERMES_SKIP_ASYNC_WRAP = True def __init__( - self, - *, - api_key: str | None = None, - base_url: str | None = None, - default_headers: dict[str, str] | None = None, - acp_command: str | None = None, - acp_args: list[str] | None = None, - acp_cwd: str | None = None, - command: str | None = None, - args: list[str] | None = None, - **_: Any, + self, *, api_key: str | None = None, base_url: str | None = None, default_headers: dict[str, str] | None = None, + acp_command: str | None = None, acp_args: list[str] | None = None, acp_cwd: str | None = None, + command: str | None = None, args: list[str] | None = None, **_: Any, ): self.api_key = api_key or "copilot-acp" self.base_url = base_url or ACP_MARKER_BASE_URL @@ -366,7 +322,7 @@ class CopilotACPClient: self._acp_command = acp_command or command or _resolve_command() self._acp_args = list(acp_args or args or _resolve_args()) self._acp_cwd = str(Path(acp_cwd or os.getcwd()).resolve()) - self.chat = _ACPChatNamespace(self) + self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion)) self.is_closed = False self._active_process: subprocess.Popen[str] | None = None self._active_process_lock = threading.Lock() @@ -388,37 +344,23 @@ class CopilotACPClient: pass def _create_chat_completion( - self, - *, - model: str | None = None, - messages: list[dict[str, Any]] | None = None, - timeout: float | None = None, - tools: list[dict[str, Any]] | None = None, - tool_choice: Any = None, - stream: bool = False, - **_: Any, + self, *, model: str | None = None, messages: list[dict[str, Any]] | None = None, timeout: float | None = None, + tools: list[dict[str, Any]] | None = None, tool_choice: Any = None, stream: bool = False, **_: Any, ) -> Any: prompt_text = _format_messages_as_prompt(messages or [], model=model, tools=tools, tool_choice=tool_choice) - response_text, reasoning_text = self._run_prompt( - prompt_text, timeout_seconds=_effective_timeout(timeout), model=model - ) + response_text, reasoning_text = self._run_prompt(prompt_text, timeout_seconds=_effective_timeout(timeout), model=model) tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text) - - usage = SimpleNamespace( - prompt_tokens=0, - completion_tokens=0, - total_tokens=0, - prompt_tokens_details=SimpleNamespace(cached_tokens=0), - ) assistant_message = SimpleNamespace( - content=cleaned_text, - tool_calls=tool_calls, - reasoning=reasoning_text or None, - reasoning_content=reasoning_text or None, - reasoning_details=None, + content=cleaned_text, tool_calls=tool_calls, reasoning=reasoning_text or None, + reasoning_content=reasoning_text or None, reasoning_details=None, + ) + completion = SimpleNamespace( + choices=[SimpleNamespace(message=assistant_message, finish_reason="tool_calls" if tool_calls else "stop")], + usage=SimpleNamespace( + prompt_tokens=0, completion_tokens=0, total_tokens=0, prompt_tokens_details=SimpleNamespace(cached_tokens=0) + ), + model=model or "copilot-acp", ) - choice = SimpleNamespace(message=assistant_message, finish_reason="tool_calls" if tool_calls else "stop") - completion = SimpleNamespace(choices=[choice], usage=usage, model=model or "copilot-acp") return _completion_to_stream_chunks(completion) if stream else completion def _spawn(self) -> subprocess.Popen[str]: @@ -443,14 +385,9 @@ class CopilotACPClient: proc = subprocess.Popen( [self._acp_command] + self._acp_args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, encoding='utf-8', errors='replace', - bufsize=1, - cwd=self._acp_cwd, - env=_build_subprocess_env(), - creationflags=windows_hide_flags(), + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding='utf-8', errors='replace', bufsize=1, + cwd=self._acp_cwd, env=_build_subprocess_env(), creationflags=windows_hide_flags(), ) except FileNotFoundError as exc: raise RuntimeError( @@ -482,9 +419,7 @@ class CopilotACPClient: inbox.put({"raw": line.rstrip("\n")}) def _stderr_reader() -> None: - if proc.stderr is None: - return - for line in proc.stderr: + for line in proc.stderr or (): stderr_tail.append(line.rstrip("\n")) threading.Thread(target=_stdout_reader, daemon=True).start() @@ -497,20 +432,15 @@ class CopilotACPClient: request_id = next_id proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + "\n") proc.stdin.flush() - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - if proc.poll() is not None: - break + while time.monotonic() < deadline and proc.poll() is None: try: msg = inbox.get(timeout=0.1) except queue.Empty: continue if self._handle_server_message( msg, process=proc, cwd=self._acp_cwd, text_parts=text_parts, reasoning_parts=reasoning_parts - ): - continue - if msg.get("id") != request_id: + ) or msg.get("id") != request_id: continue if "error" in msg: err = msg.get("error") or {} @@ -520,85 +450,48 @@ class CopilotACPClient: stderr_text = "\n".join(stderr_tail).strip() if proc.poll() is not None and stderr_text: if _is_gh_copilot_deprecation_message(stderr_text): - raise RuntimeError( - "Hermes ACP mode requires the NEW GitHub Copilot CLI " - "(github.com/github/copilot-cli), but the binary it just " - "spawned is the deprecated `gh copilot` extension.\n\n" - "Install the new CLI:\n" - " npm install -g @github/copilot\n" - " # then verify with: copilot --help\n\n" - "If `copilot` already resolves to the new CLI but you still see this,\n" - "point Hermes at it explicitly:\n" - " export HERMES_COPILOT_ACP_COMMAND=/path/to/new/copilot\n\n" - "Alternative: use the `copilot` provider (no ACP, hits the Copilot API\n" - "directly with a Copilot subscription token) via `hermes setup`.\n\n" - f"Original error:\n{stderr_text}" - ) + raise RuntimeError(_DEPRECATED_CLI_ERROR + stderr_text) raise RuntimeError(f"Copilot ACP process exited early: {stderr_text}") raise TimeoutError(f"Timed out waiting for Copilot ACP response to {method}.") try: - _request( - "initialize", - { - "protocolVersion": 1, - "clientCapabilities": {"fs": {"readTextFile": True, "writeTextFile": True}}, - "clientInfo": {"name": "hermes-agent", "title": "Hermes Agent", "version": "0.0.0"}, - }, - ) + _request("initialize", _INITIALIZE_PARAMS) session = _request("session/new", {"cwd": self._acp_cwd, "mcpServers": []}) or {} session_id = str(session.get("sessionId") or "").strip() if not session_id: raise RuntimeError("Copilot ACP did not return a sessionId.") - # Prefer the stable ACP v1 session-config API (category="model" select # option + session/set_config_option); session/set_model is the fallback. if requested_model and requested_model != "copilot-acp": try: selection = _model_selection_request(session, requested_model) if selection is not None: - method, params = selection - _request(method, params) + _request(*selection) else: - logger.warning( - "Copilot ACP does not offer model %r; using the " - "session default.", - requested_model, - ) + logger.warning("Copilot ACP does not offer model %r; using the session default.", requested_model) except Exception as exc: logger.warning( - "Copilot ACP model selection for %r failed; continuing " - "with the session default: %s", - requested_model, - exc, + "Copilot ACP model selection for %r failed; continuing with the session default: %s", + requested_model, exc, ) - text_parts: list[str] = [] reasoning_parts: list[str] = [] _request( - "session/prompt", - {"sessionId": session_id, "prompt": [{"type": "text", "text": prompt_text}]}, - text_parts=text_parts, - reasoning_parts=reasoning_parts, + "session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": prompt_text}]}, + text_parts=text_parts, reasoning_parts=reasoning_parts, ) return "".join(text_parts), "".join(reasoning_parts) finally: self.close() def _handle_server_message( - self, - msg: dict[str, Any], - *, - process: subprocess.Popen[str], - cwd: str, - text_parts: list[str] | None, - reasoning_parts: list[str] | None, + self, msg: dict[str, Any], *, process: subprocess.Popen[str], cwd: str, + text_parts: list[str] | None, reasoning_parts: list[str] | None, ) -> bool: """Consume a server->client message; True when handled (notification or request answered).""" method = msg.get("method") if not isinstance(method, str): return False - if method == "session/update": update = (msg.get("params") or {}).get("update") or {} kind = str(update.get("sessionUpdate") or "").strip() @@ -609,22 +502,19 @@ class CopilotACPClient: elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None: reasoning_parts.append(chunk_text) return True - if process.stdin is None: return True message_id = msg.get("id") - params = msg.get("params") or {} if method == "session/request_permission": response = _permission_denied(message_id) elif method in _FS_HANDLERS: try: - response = _jsonrpc_result(message_id, _FS_HANDLERS[method](params, cwd)) + response = _jsonrpc_result(message_id, _FS_HANDLERS[method](msg.get("params") or {}, cwd)) except Exception as exc: response = _jsonrpc_error(message_id, -32602, str(exc)) else: response = _jsonrpc_error(message_id, -32601, f"ACP client method '{method}' is not supported by Hermes yet.") - process.stdin.write(json.dumps(response) + "\n") process.stdin.flush() return True diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 54f0de0795..8f7a1e769e 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -1,25 +1,13 @@ -"""Credits tracking for Nous inference API responses: parses x-nous-credits-* -(and optional x-nous-tool-pool-*) headers into a validated CreditsState, with +"""Credits tracking for Nous inference API responses: parses ``x-nous-credits-*`` +(and optional ``x-nous-tool-pool-*``) headers into a validated CreditsState, with depletion detection (paid_access), subscription-cap used_fraction, and warn-once -schema-version gating. The hardened parser used by all live consumers. - -Header schema (x-nous-credits-*; each *-micros balance has a *-usd twin holding -the server's formatted USD string): - version contract/schema version - remaining-micros/-usd total remaining balance - subscription-micros/-usd subscription balance (SIGNED; may be negative/debt) - subscription-limit-* subscription cap (PAIRED/optional) - rollover-micros rolled-over balance - purchased-micros/-usd purchased balance - denominator-kind "subscription_cap" | "none" - paid-access "true" | "false" (STRING!) - disabled-reason reason string (header omitted when null) - as-of-ms server-side timestamp (ms epoch) -Tool-pool headers use a SEPARATE prefix: x-nous-tool-pool-micros (balance) and -x-nous-tool-pool-gated-off ("true" | "false" STRING!). - -Money is handled as micros ints only; *_usd values are preserved verbatim as -the raw strings the server sent (never re-parsed to float). +schema-version gating. Header schema (each ``*-micros`` balance has a ``*-usd`` +twin holding the server's formatted USD string): version, remaining, subscription +(SIGNED; may be debt), subscription-limit (PAIRED/optional), rollover, purchased, +denominator-kind ("subscription_cap" | "none"), paid-access ("true"|"false" STRING), +disabled-reason (omitted when null), as-of-ms. Tool-pool headers: x-nous-tool-pool-micros, +x-nous-tool-pool-gated-off ("true"|"false" STRING). Money is micros ints only; +``*_usd`` strings are preserved verbatim (never re-parsed to float). """ from __future__ import annotations @@ -29,7 +17,7 @@ import os import re import time from dataclasses import dataclass -from typing import Any, Mapping, Optional +from typing import Any, Callable, Mapping, Optional from utils import is_truthy_value @@ -37,14 +25,10 @@ logger = logging.getLogger(__name__) # Warn-once latch: emit the version-unsupported warning at most once per process. _version_warning_emitted: bool = False - -# Valid denominator kinds (exhaustive set from the API contract). _VALID_DENOMINATOR_KINDS = frozenset({"subscription_cap", "none"}) - # USD format: optional leading minus, one-or-more digits, dot, exactly 2 digits. _USD_RE = re.compile(r"^-?\d+\.\d{2}$") - -_SENTINEL = object() # singleton sentinel for "parse failed" +_SENTINEL = object() # "parse failed" def _safe_int(value: Any) -> Any: @@ -59,7 +43,6 @@ def _safe_int(value: Any) -> Any: def _validate_usd(value: Optional[str]) -> bool: - """Return True iff value is a non-None string matching ^-?\\d+\\.\\d{2}$.""" return value is not None and bool(_USD_RE.match(value)) @@ -96,13 +79,13 @@ class CreditsState: @property def depleted(self) -> bool: - """Keyed off ``paid_access == False`` ONLY — never ``remaining_micros == 0``, - a false positive when the balance is zero but access is live (renewal pending).""" + """Keyed off ``paid_access == False`` ONLY — ``remaining_micros == 0`` is a + false positive when the balance is zero but access is live (renewal pending).""" return not self.paid_access @property def used_fraction(self) -> Optional[float]: - """Fraction of the subscription cap consumed, in [0.0, 1.0]; None without a + """Fraction of the subscription cap consumed in [0.0, 1.0]; None without a computable denominator. Guarded on the LIMIT FIELD (the real denominator), not ``denominator_kind`` (metadata).""" if not isinstance(self.subscription_limit_micros, int) or self.subscription_limit_micros <= 0: @@ -118,7 +101,7 @@ CREDITS_RESTORED_TTL_MS = 8000 # the only TTL notice in v1 (depletion-recove # Usage-gauge bands (ascending): (threshold_fraction, level, label_pct). One # escalating line showing the HIGHEST band reached (50 → 75 → 90); crossing up -# replaces it, recovering steps it down. The policy derives everything from it. +# replaces it, recovering steps it down. CREDITS_USAGE_BANDS: tuple[tuple[float, str, int], ...] = ((0.50, "info", 50), (0.75, "warn", 75), (0.90, "warn", 90)) CREDITS_USAGE_KEY = "credits.usage" # single key for the escalating usage notice @@ -155,14 +138,13 @@ def _sticky_notice(text: str, level: str, key: str) -> AgentNotice: def is_free_tier_model(model: str, base_url: str = "") -> bool: - """Return True when *model* is a Nous free-tier model, using ONLY local data. + """True when *model* is a Nous free-tier model, using ONLY local data. Zero-network signals: (1) ``:free`` suffix — canonical Nous free SKU marker; (2) ``stealth/`` prefix — stealth-preview SKUs are free without the suffix (naming-convention trust: a PAID ``stealth/`` model would wrongly suppress the banner); (3) a PEEK into ``hermes_cli.models``' pricing cache (filled by - the model picker; a miss never fetches — gateway sessions never run the - picker, so there only 1-2 apply). + the model picker; a miss never fetches — gateway sessions never run the picker). Fail-open to False (depleted notice still shows): a wrong warning is recoverable noise; hiding it on a paid model masks a real block. @@ -178,13 +160,26 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool: # peek_cached_pricing owns the /v1-suffix and auth-state key details. pricing = peek_cached_pricing(base_url) - if not pricing: - return False - return _is_model_free(model, pricing) + return bool(pricing) and _is_model_free(model, pricing) except Exception: return False +def _sync_notice( + key: str, want: bool, make: Callable[[], AgentNotice], active: set, to_show: list, to_clear: list +) -> Optional[str]: + """Reconcile one keyed sticky notice; returns ``"shown"``, ``"cleared"`` or None.""" + if want and key not in active: + to_show.append(make()) + active.add(key) + return "shown" + if key in active and not want: + to_clear.append(key) + active.discard(key) + return "cleared" + return None + + def evaluate_credits_notices( state: CreditsState, latch: dict, *, model_is_free: bool = False, ) -> tuple[list[AgentNotice], list[str]]: @@ -201,43 +196,35 @@ def evaluate_credits_notices( to_show: list[AgentNotice] = [] to_clear: list[str] = [] uf = state.used_fraction + active = latch["active"] # Crossing latch: band notices fire only once uf was observed below the LOWEST # band, so a session opening mid-range doesn't fire on its first observation # (the cold-start seed primes this when it WANTS an open-high warning). if uf is not None and uf < CREDITS_USAGE_BANDS[0][0]: latch["seen_below_90"] = True - # Grant-spent gate: fires only after this session OBSERVED the grant unspent # (≥1¢). Opening at grant-spent is a steady STATE (/usage carries it), not an # event. Unlike seen_below_90, seeds must NOT prime this gate. if uf is not None and uf < 1.0 and state.subscription_micros >= GRANT_UNSPENT_MIN_MICROS: latch["seen_grant_unspent"] = True - active = latch["active"] # Highest band reached (ascending → last match wins); None below all. + # Top-up suppression: with purchased credits the cap gauge is the wrong + # denominator ("90% used" on $50 of top-up is noise; it used to stick + # PERMANENTLY beside grant_spent at >=100%). grant_spent covers the + # cap-reached case; a mid-session top-up flips current_band → None and the + # clear path removes the band line. current_band: Optional[tuple[float, str, int]] = None - if uf is not None: + if uf is not None and state.purchased_micros <= 0: for band in CREDITS_USAGE_BANDS: if uf >= band[0]: current_band = band - # Top-up suppression: with purchased credits the cap gauge is the wrong - # denominator ("90% used" on $50 of top-up is noise; it used to stick - # PERMANENTLY beside grant_spent at >=100%). grant_spent below covers the - # cap-reached case; a mid-session top-up flips current_band → None and the - # clear path removes the band line. - if state.purchased_micros > 0: - current_band = None - grant_cond = ( - state.denominator_kind == "subscription_cap" and uf is not None and uf >= 1.0 and state.purchased_micros > 0 - ) - depleted_cond = not state.paid_access # ── usage gauge: highest crossed band only; replace on band change (climb or # step-down); clear below the lowest band or when the denominator vanishes. - shown_band = latch.get("usage_band") target_band = current_band[2] if (current_band and latch["seen_below_90"]) else None - if target_band != shown_band: + if target_band != latch.get("usage_band"): if CREDITS_USAGE_KEY in active: to_clear.append(CREDITS_USAGE_KEY) active.discard(CREDITS_USAGE_KEY) @@ -245,15 +232,13 @@ def evaluate_credits_notices( # Absolute dollars used (a bare "N%" is only meaningful against a Nous # cap): cap − remaining in micros, clamped [0, cap]; "$?" if a producer # set the limit without its *_usd. Re-emits on band change only. - _cap_usd = state.subscription_limit_usd or "?" - _level = current_band[1] # type: ignore[index] (current_band set when target_band set) - _lim = state.subscription_limit_micros or 0 - _used_micros = max(0, min(_lim, _lim - state.subscription_micros)) - _used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?" - _glyph = "⚠" if _level == "warn" else "•" - to_show.append( - _sticky_notice(f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap", _level, CREDITS_USAGE_KEY) - ) + level = current_band[1] # type: ignore[index] (current_band set when target_band set) + lim = state.subscription_limit_micros or 0 + used_usd = f"{max(0, min(lim, lim - state.subscription_micros)) / 1_000_000:.2f}" if lim else "?" + glyph = "⚠" if level == "warn" else "•" + to_show.append(_sticky_notice( + f"{glyph} You've used ${used_usd} of your ${state.subscription_limit_usd or '?'} cap", level, CREDITS_USAGE_KEY + )) active.add(CREDITS_USAGE_KEY) latch["usage_band"] = target_band @@ -261,52 +246,62 @@ def evaluate_credits_notices( # announcement per crossing. A header flicker (uf → None → 1.0) clears the # line but cannot re-announce; only a renewal re-opening the gate (fresh ≥1¢ # observation) arms the next. .get(): default closed for hand-built latches. - if grant_cond and "credits.grant_spent" not in active and latch.get("seen_grant_unspent", False): - to_show.append( - _sticky_notice(f"• Grant spent · ${state.purchased_usd} top-up left", "info", "credits.grant_spent") - ) - active.add("credits.grant_spent") + grant_cond = ( + state.denominator_kind == "subscription_cap" and uf is not None and uf >= 1.0 and state.purchased_micros > 0 + ) + if _sync_notice( + "credits.grant_spent", grant_cond and latch.get("seen_grant_unspent", False), + lambda: _sticky_notice(f"• Grant spent · ${state.purchased_usd} top-up left", "info", "credits.grant_spent"), + active, to_show, to_clear, + ) == "shown": latch["seen_grant_unspent"] = False - elif "credits.grant_spent" in active and not grant_cond: - to_clear.append("credits.grant_spent") - active.discard("credits.grant_spent") # ── depleted: suppressed while the model is free (inference still works). - show_depleted = depleted_cond and not model_is_free - if show_depleted and "credits.depleted" not in active: - to_show.append(_sticky_notice("✕ Credit access paused · run /topup to top up", "error", "credits.depleted")) - active.add("credits.depleted") - elif "credits.depleted" in active and not show_depleted: - to_clear.append("credits.depleted") - active.discard("credits.depleted") - if not depleted_cond: - # Genuine recovery only — switching to a free model while still - # depleted must NOT claim access was restored. - to_show.append(AgentNotice( - text="✓ Credit access restored", level="success", kind="ttl", - ttl_ms=CREDITS_RESTORED_TTL_MS, key="credits.restored", id="credits.restored", - )) + depleted_cond = not state.paid_access + if _sync_notice( + "credits.depleted", depleted_cond and not model_is_free, + lambda: _sticky_notice("✕ Credit access paused · run /topup to top up", "error", "credits.depleted"), + active, to_show, to_clear, + ) == "cleared" and not depleted_cond: + # Genuine recovery only — switching to a free model while still + # depleted must NOT claim access was restored. + to_show.append(AgentNotice( + text="✓ Credit access restored", level="success", kind="ttl", + ttl_ms=CREDITS_RESTORED_TTL_MS, key="credits.restored", id="credits.restored", + )) return (to_show, to_clear) -# (field, header, signed) — required micros fields; only subscription may be negative. -_MICROS_FIELDS: tuple[tuple[str, str, bool], ...] = ( - ("remaining_micros", "x-nous-credits-remaining-micros", False), - ("subscription_micros", "x-nous-credits-subscription-micros", True), - ("rollover_micros", "x-nous-credits-rollover-micros", False), - ("purchased_micros", "x-nous-credits-purchased-micros", False), ("as_of_ms", "x-nous-credits-as-of-ms", False), -) -_USD_FIELDS: tuple[tuple[str, str], ...] = ( - ("remaining_usd", "x-nous-credits-remaining-usd"), ("subscription_usd", "x-nous-credits-subscription-usd"), - ("purchased_usd", "x-nous-credits-purchased-usd"), -) -# (field, header, default-when-absent) — "true"/"false" (case-insensitive) STRING flags. -_BOOL_FIELDS: tuple[tuple[str, str, bool], ...] = ( - ("paid_access", "x-nous-credits-paid-access", True), # absent → fail-open (assume access) - ("tool_pool_gated_off", "x-nous-tool-pool-gated-off", False), +# Header field table: (field, header, kind). micros: required, non-negative unless +# "signed" (only subscription may be negative). usd: required ^-?\d+\.\d{2}$. +# bool: optional "true"/"false" STRING flag; the tuple's 4th item is the default. +_HEADER_FIELDS: tuple[tuple, ...] = ( + ("remaining_micros", "x-nous-credits-remaining-micros", "micros"), + ("subscription_micros", "x-nous-credits-subscription-micros", "signed"), + ("rollover_micros", "x-nous-credits-rollover-micros", "micros"), + ("purchased_micros", "x-nous-credits-purchased-micros", "micros"), + ("as_of_ms", "x-nous-credits-as-of-ms", "micros"), + ("remaining_usd", "x-nous-credits-remaining-usd", "usd"), + ("subscription_usd", "x-nous-credits-subscription-usd", "usd"), + ("purchased_usd", "x-nous-credits-purchased-usd", "usd"), + ("paid_access", "x-nous-credits-paid-access", "bool", True), # absent → fail-open (assume access) + ("tool_pool_gated_off", "x-nous-tool-pool-gated-off", "bool", False), ) +def _parse_field(kind: str, raw: Optional[str], default: Any = None) -> Any: + """One header value → field value, or ``_SENTINEL`` on a contract violation.""" + if kind in ("micros", "signed"): + val = _safe_int(raw) + return _SENTINEL if val is _SENTINEL or (kind == "micros" and val < 0) else val + if kind == "usd": + return raw if _validate_usd(raw) else _SENTINEL + if raw is None: + return default + flag = raw.strip().lower() + return _SENTINEL if flag not in ("true", "false") else flag == "true" + + def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Optional[CreditsState]: """Parse x-nous-credits-* (and x-nous-tool-pool-*) headers into a CreditsState. @@ -336,45 +331,29 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt logger.warning("credits header version %d unsupported, ignoring — update Hermes", version_val) return None fields: dict[str, Any] = {} - for name, key, signed in _MICROS_FIELDS: - val = _safe_int(lowered.get(key)) - if val is _SENTINEL or (not signed and val < 0): + for name, key, kind, *default in _HEADER_FIELDS: + val = _parse_field(kind, lowered.get(key), *default) + if val is _SENTINEL: return None fields[name] = val - # tool_pool_micros is OPTIONAL: absent → 0; present-but-invalid → miss. - _tp_raw = lowered.get("x-nous-tool-pool-micros") - _tp_val = 0 if _tp_raw is None else _safe_int(_tp_raw) - if _tp_val is _SENTINEL or _tp_val < 0: + tp_raw = lowered.get("x-nous-tool-pool-micros") + tp_val = 0 if tp_raw is None else _parse_field("micros", tp_raw) + if tp_val is _SENTINEL: return None - fields["tool_pool_micros"] = _tp_val - for name, key in _USD_FIELDS: - val = lowered.get(key, "") - if not _validate_usd(val): - return None - fields[name] = val - + fields["tool_pool_micros"] = tp_val # subscription_limit_* PAIRED + OPTIONAL: both present → validate both # (any invalid → miss); half-pair or both absent → both None, parse continues. - sub_limit_micros_raw = lowered.get("x-nous-credits-subscription-limit-micros") - sub_limit_usd_raw = lowered.get("x-nous-credits-subscription-limit-usd") - if sub_limit_micros_raw is not None and sub_limit_usd_raw is not None: - lm = _safe_int(sub_limit_micros_raw) - if lm is _SENTINEL or lm < 0 or not _validate_usd(sub_limit_usd_raw): + lim_micros_raw = lowered.get("x-nous-credits-subscription-limit-micros") + lim_usd_raw = lowered.get("x-nous-credits-subscription-limit-usd") + if lim_micros_raw is not None and lim_usd_raw is not None: + lm = _parse_field("micros", lim_micros_raw) + if lm is _SENTINEL or not _validate_usd(lim_usd_raw): return None - fields["subscription_limit_micros"] = lm - fields["subscription_limit_usd"] = sub_limit_usd_raw + fields["subscription_limit_micros"], fields["subscription_limit_usd"] = lm, lim_usd_raw denominator_kind = lowered.get("x-nous-credits-denominator-kind", "none") if denominator_kind not in _VALID_DENOMINATOR_KINDS: return None - for name, key, default in _BOOL_FIELDS: - if key not in lowered: - fields[name] = default - continue - raw = lowered[key].strip().lower() - if raw not in ("true", "false"): - return None - fields[name] = raw == "true" return CreditsState( version=version_val, denominator_kind=denominator_kind, @@ -426,21 +405,18 @@ _DEV_FIXTURES: dict[str, dict] = { def dev_fixture_credits_state() -> Optional[CreditsState]: - """Return a fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None - (unknown name / "clear" / "none" / unset → None). - - Hard prod-leak guard: applies ONLY when HERMES_DEV_CREDITS is also on, so a - stray fixture env var can never surface fabricated balances on a real account. - """ + """Fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None (unknown name / + "clear" / "none" / unset). Hard prod-leak guard: applies ONLY when + HERMES_DEV_CREDITS is also on, so a stray fixture env var can never surface + fabricated balances on a real account.""" if not is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")): return None - raw = os.environ.get("HERMES_DEV_CREDITS_FIXTURE", "").strip() - if not raw: + name = os.environ.get("HERMES_DEV_CREDITS_FIXTURE", "").strip() + if not name: return None - name = raw - if os.path.sep in raw or "/" in raw: # looks like a path → read the name from the file + if os.path.sep in name or "/" in name: # looks like a path → read the name from the file try: - with open(raw, "r", encoding="utf-8") as fh: + with open(name, "r", encoding="utf-8") as fh: name = fh.read().strip() except OSError: return None @@ -450,8 +426,7 @@ def dev_fixture_credits_state() -> Optional[CreditsState]: # Stamp what the REAL parser always guarantees so a fixture is field-identical # to a parse_credits_headers() result (differential test): version 1, and a # valid purchased_usd (a zero-top-up account still carries "0.00"). - merged = {"version": 1, "purchased_usd": "0.00", **spec} - return CreditsState(**merged, from_header=True, captured_at=time.time()) + return CreditsState(**{"version": 1, "purchased_usd": "0.00", **spec}, from_header=True, captured_at=time.time()) def _credits_state_from_account(info) -> Optional[CreditsState]: @@ -459,26 +434,26 @@ def _credits_state_from_account(info) -> Optional[CreditsState]: Float account dollars → micros plus a DISPLAY *_usd (formatting account floats is allowed; parsing a server *_usd is not). Fail-open → None.""" try: - _acc = getattr(info, "paid_service_access_info", None) - _sub = getattr(info, "subscription", None) + acc = getattr(info, "paid_service_access_info", None) + sub = getattr(info, "subscription", None) def _money(dollars) -> tuple[int, str]: # (micros, display usd); (0, "") when absent if isinstance(dollars, (int, float)): return int(round(dollars * 1_000_000)), f"{dollars:.2f}" return 0, "" - _remaining = _money(getattr(_acc, "total_usable_credits", None)) - _sub_rem = _money(getattr(_acc, "subscription_credits_remaining", None)) - _purchased = _money(getattr(_acc, "purchased_credits_remaining", None)) - _monthly = getattr(_sub, "monthly_credits", None) - _cap = _money(_monthly) if isinstance(_monthly, (int, float)) and _monthly > 0 else (None, None) - _paid = getattr(info, "paid_service_access", None) + remaining = _money(getattr(acc, "total_usable_credits", None)) + sub_rem = _money(getattr(acc, "subscription_credits_remaining", None)) + purchased = _money(getattr(acc, "purchased_credits_remaining", None)) + monthly = getattr(sub, "monthly_credits", None) + cap = _money(monthly) if isinstance(monthly, (int, float)) and monthly > 0 else (None, None) + paid = getattr(info, "paid_service_access", None) return CreditsState( - remaining_micros=_remaining[0], remaining_usd=_remaining[1], subscription_micros=_sub_rem[0], - subscription_usd=_sub_rem[1], subscription_limit_micros=_cap[0], subscription_limit_usd=_cap[1], - purchased_micros=_purchased[0], purchased_usd=_purchased[1], - rollover_micros=_money(getattr(_sub, "rollover_credits", None))[0], - denominator_kind="subscription_cap" if _cap[0] is not None else "none", - paid_access=_paid if isinstance(_paid, bool) else True, from_header=False, captured_at=time.time(), + remaining_micros=remaining[0], remaining_usd=remaining[1], subscription_micros=sub_rem[0], + subscription_usd=sub_rem[1], subscription_limit_micros=cap[0], subscription_limit_usd=cap[1], + purchased_micros=purchased[0], purchased_usd=purchased[1], + rollover_micros=_money(getattr(sub, "rollover_credits", None))[0], + denominator_kind="subscription_cap" if cap[0] is not None else "none", + paid_access=paid if isinstance(paid, bool) else True, from_header=False, captured_at=time.time(), ) except Exception: logger.debug("credits ▸ seed account→state mapping failed", exc_info=True) @@ -492,11 +467,11 @@ def _hydrate_seed_state(agent, state) -> None: agent._credits_state = state if getattr(agent, "_credits_session_start_micros", None) is None: agent._credits_session_start_micros = state.remaining_micros - _latch = getattr(agent, "_credits_latch", None) - if isinstance(_latch, dict) and state.used_fraction is not None: + latch = getattr(agent, "_credits_latch", None) + if isinstance(latch, dict) and state.used_fraction is not None: # Prime ONLY seen_below_90. Never prime seen_grant_unspent: a seed # observing grant-spent is a steady state; priming revives the nag. - _latch["seen_below_90"] = True + latch["seen_below_90"] = True emit = getattr(agent, "_emit_credits_notices", None) if callable(emit): emit() @@ -511,9 +486,7 @@ def seed_credits_at_session_start(agent) -> bool: Returns True iff it seeded this call. Never raises — credits must never block startup. """ try: - if getattr(agent, "provider", "") != "nous": - return False - if getattr(agent, "_credits_state", None) is not None: + if getattr(agent, "provider", "") != "nous" or getattr(agent, "_credits_state", None) is not None: return False try: fixture = dev_fixture_credits_state() diff --git a/agent/fast_mode.py b/agent/fast_mode.py index b258b00d89..042c63abf5 100644 --- a/agent/fast_mode.py +++ b/agent/fast_mode.py @@ -1,12 +1,11 @@ """Bounded fast-mode windows (``/fast auto`` and ``/fast cold``). -``agent.service_tier`` is ``None`` (normal), ``"priority"`` (static fast, pinned -into ``agent.request_overrides`` at build time), ``"auto"`` (every user turn opens -a window of ``agent.fast_auto_seconds``) or ``"cold"`` (only a session's first -turn, no prior history, opens it). The provider's fast override is layered onto -the request kwargs only while the window is open. Only per-request params -(``service_tier`` / ``speed``) vary — system prompt, tools, and messages are -untouched, so the prompt cache survives the boundary. +``agent.service_tier``: ``None`` (normal), ``"priority"`` (static fast, pinned into +``agent.request_overrides`` at build time), ``"auto"`` (every user turn opens a +window of ``agent.fast_auto_seconds``) or ``"cold"`` (only a session's first turn, +no prior history, opens it). The provider's fast override is layered onto request +kwargs only while the window is open; only per-request params (``service_tier`` / +``speed``) vary, so the prompt cache survives the boundary. """ from __future__ import annotations @@ -39,17 +38,13 @@ def begin_turn(agent: Any, conversation_history: Any) -> None: def effective_request_overrides(agent: Any) -> dict[str, Any]: """``agent.request_overrides`` plus the fast override while the window is open.""" overrides = dict(getattr(agent, "request_overrides", None) or {}) - if getattr(agent, "service_tier", None) not in BOUNDED_MODES or time.monotonic() >= getattr( - agent, "_fast_until", 0.0 - ): + if getattr(agent, "service_tier", None) not in BOUNDED_MODES or time.monotonic() >= getattr(agent, "_fast_until", 0.0): return overrides from hermes_cli.models import resolve_fast_mode_overrides base_url = getattr(agent, "base_url", None) if getattr(agent, "api_mode", None) == "anthropic_messages": base_url = getattr(agent, "_anthropic_base_url", None) or base_url - fast = resolve_fast_mode_overrides( - getattr(agent, "model", None), provider=getattr(agent, "provider", None), base_url=base_url + overrides.update( + resolve_fast_mode_overrides(getattr(agent, "model", None), provider=getattr(agent, "provider", None), base_url=base_url) or {} ) - if fast: - overrides.update(fast) return overrides diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 31871509c7..4a1a689f20 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -1,11 +1,10 @@ """OpenAI-compatible facade over Google AI Studio's native Gemini API. -Hermes keeps ``api_mode='chat_completions'`` for the ``gemini`` provider so the -agent loop keeps its OpenAI-shaped message flow; this shim converts those -``messages[]`` / ``tools[]`` requests into ``models/{model}:generateContent`` -payloads and converts the responses back. Google's OpenAI-compat endpoint has -been brittle for the multi-turn tool loop (auth churn, tool-call replay quirks, -thought-signature requirements); the native API is the canonical path. +The ``gemini`` provider keeps ``api_mode='chat_completions'`` so the agent loop +stays OpenAI-shaped; this shim converts ``messages[]``/``tools[]`` requests into +``models/{model}:generateContent`` payloads and converts responses back. Google's +OpenAI-compat endpoint is brittle for the multi-turn tool loop (auth churn, +tool-call replay quirks, thought-signature requirements); native is canonical. """ from __future__ import annotations @@ -41,36 +40,54 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" # Gemini's native API applies a low internal default and truncates output. GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535 +_FREE_TIER_GUIDANCE = ( + "\n\nYour Google API key is on the free tier (a few hundred requests/day for Gemini Flash models). " + "Hermes typically makes 3-10 API calls per user turn, so the free tier is exhausted in a handful of " + "messages and cannot sustain an agent session. Enable billing on your Google Cloud project and " + "regenerate the key in a billing-enabled project: https://aistudio.google.com/apikey" +) +_STANDARD_KEY_GUIDANCE = ( + "\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. Google began rejecting legacy " + "'Standard' Google Cloud keys for the Gemini API on June 19, 2026, and all Standard keys stop working in " + "September 2026. Open https://aistudio.google.com/api-keys, check the key's type and status, and create a " + "replacement Gemini API key (or, as a temporary bridge, restrict the Standard key to " + "generativelanguage.googleapis.com). Then update GEMINI_API_KEY / GOOGLE_API_KEY in ~/.hermes/.env and " + "restart your session. Details: https://ai.google.dev/gemini-api/docs/api-key" +) +# Stands in for a model turn that never arrived (stream failure / interrupt / +# quota fallback) when a human user text turn directly follows a tool-result +# turn, keeping the request alternation-valid while the user's message stays a +# turn of its own (mirrors gemini-cli's placeholder repair). +_INTERRUPTED_RESPONSE_PLACEHOLDER = "[The previous response was interrupted before it completed.]" +# Cross-provider tool_calls (e.g. fallback from xAI/Anthropic) carry no Gemini +# thoughtSignature; without this sentinel Gemini 3 thinking models reject +# replayed history with 400 INVALID_ARGUMENT. +_SKIP_SIGNATURE = "skip_thought_signature_validator" +_TOOL_CHOICE_MODES = {"auto": "AUTO", "required": "ANY", "none": "NONE"} +_FINISH_REASON_MAP = { + "STOP": "stop", "MAX_TOKENS": "length", "SAFETY": "content_filter", "RECITATION": "content_filter", "OTHER": "stop", +} +_HTTP_ERROR_CODES = {401: "gemini_unauthorized", 429: "gemini_rate_limited", 404: "gemini_model_not_found"} + def bare_gemini_model_id(model: str) -> str: """Strip Gemini's own provider prefix from an aggregator-style model id.""" name = (model or "").strip() - lowered = name.lower() for prefix in ("google/", "gemini/"): - if lowered.startswith(prefix): + if name.lower().startswith(prefix): return name[len(prefix):].strip() or name return name -def _gemini_major_version(model: str) -> Optional[int]: - """Extract the major version from a Gemini model id (``gemini-3.6-flash`` → 3).""" - match = re.match(r"gemini-(\d+)", bare_gemini_model_id(model).lower()) - return int(match.group(1)) if match else None - - def gemini_requires_tool_call_ids(model: str) -> bool: - """Whether functionCall/functionResponse parts must carry explicit ids. - - Gemini 3+ needs explicit ids so replayed parallel tool calls pair with - their responses; Gemini 2.x rejects unexpected ``id`` fields, so gate on - the major version. - """ - version = _gemini_major_version(model) - return version is not None and version >= 3 + """Gemini 3+ needs explicit functionCall/functionResponse ids so replayed + parallel tool calls pair with their responses; 2.x rejects the field.""" + match = re.match(r"gemini-(\d+)", bare_gemini_model_id(model).lower()) + return match is not None and int(match.group(1)) >= 3 def is_native_gemini_base_url(base_url: str) -> bool: - """Return True when the endpoint speaks Gemini's native REST API.""" + """True when the endpoint speaks Gemini's native REST API (not ``/openai``).""" normalized = str(base_url or "").strip().rstrip("/").lower() return "generativelanguage.googleapis.com" in normalized and not normalized.endswith("/openai") @@ -78,23 +95,19 @@ def is_native_gemini_base_url(base_url: str) -> bool: def probe_gemini_tier( api_key: str, base_url: str = DEFAULT_GEMINI_BASE_URL, *, model: str = "gemini-3.7-flash", timeout: float = 10.0 ) -> str: - """Probe a Google AI Studio API key; return ``"free"``, ``"paid"`` or - ``"unknown"`` (probe failed — callers should proceed without blocking).""" + """Probe a Google AI Studio key; return ``"free"``, ``"paid"`` or ``"unknown"`` + (probe failed — callers should proceed without blocking).""" key = (api_key or "").strip() if not key: return "unknown" - - normalized_base = str(base_url or DEFAULT_GEMINI_BASE_URL).strip().rstrip("/") or DEFAULT_GEMINI_BASE_URL - if normalized_base.lower().endswith("/openai"): - normalized_base = normalized_base[: -len("/openai")] - + base = str(base_url or DEFAULT_GEMINI_BASE_URL).strip().rstrip("/") or DEFAULT_GEMINI_BASE_URL + if base.lower().endswith("/openai"): + base = base[: -len("/openai")] payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], "generationConfig": {"maxOutputTokens": 1}} try: with httpx.Client(timeout=timeout) as client: resp = client.post( - f"{normalized_base}/models/{model}:generateContent", - params={"key": key}, - json=payload, + f"{base}/models/{model}:generateContent", params={"key": key}, json=payload, headers={"Content-Type": "application/json", "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}"}, ) except Exception as exc: @@ -108,52 +121,32 @@ def probe_gemini_tier( return "free" if int(rpd_header) <= 1000 else "paid" except (TypeError, ValueError): pass - if resp.status_code == 429: - try: - body_text = resp.text or "" - except Exception: - body_text = "" - return "free" if "free_tier" in body_text.lower() else "paid" + return "free" if "free_tier" in _response_text(resp).lower() else "paid" return "paid" if 200 <= resp.status_code < 300 else "unknown" +def _response_text(response: Any) -> str: + try: + return response.text or "" + except Exception: + return "" + + def is_free_tier_quota_error(error_message: str) -> bool: - """Return True when a Gemini 429 message indicates free-tier exhaustion.""" + """True when a Gemini 429 message indicates free-tier exhaustion.""" return bool(error_message) and "free_tier" in error_message.lower() -_FREE_TIER_GUIDANCE = ( - "\n\nYour Google API key is on the free tier (a few hundred requests/day for Gemini Flash models). " - "Hermes typically makes 3-10 API calls per user turn, so the free tier is exhausted in a handful of " - "messages and cannot sustain an agent session. Enable billing on your Google Cloud project and " - "regenerate the key in a billing-enabled project: https://aistudio.google.com/apikey" -) - - def is_standard_key_auth_error(status: int, error_message: str, reason: str = "") -> bool: - """Return True when a Gemini 401 indicates Google rejected the key TYPE. - - Google rejects legacy "Standard" Google Cloud API keys with a misleading - 401 asking for an OAuth 2 access token, optionally carrying ErrorInfo - reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``. Scoped narrowly so a plain bad - key (``API_KEY_INVALID``) keeps its existing message. - """ + """True when a Gemini 401 means Google rejected the key TYPE (legacy "Standard" + Cloud key → misleading "expected OAuth 2 access token" / ErrorInfo + ``ACCESS_TOKEN_TYPE_UNSUPPORTED``). Narrow so ``API_KEY_INVALID`` keeps its message.""" if status != 401: return False return reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED" or "expected oauth 2 access token" in (error_message or "").lower() -_STANDARD_KEY_GUIDANCE = ( - "\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. Google began rejecting legacy " - "'Standard' Google Cloud keys for the Gemini API on June 19, 2026, and all Standard keys stop working in " - "September 2026. Open https://aistudio.google.com/api-keys, check the key's type and status, and create a " - "replacement Gemini API key (or, as a temporary bridge, restrict the Standard key to " - "generativelanguage.googleapis.com). Then update GEMINI_API_KEY / GOOGLE_API_KEY in ~/.hermes/.env and " - "restart your session. Details: https://ai.google.dev/gemini-api/docs/api-key" -) - - class GeminiAPIError(Exception): """Error shape compatible with Hermes retry/error classification.""" @@ -163,26 +156,23 @@ class GeminiAPIError(Exception): details: Optional[Dict[str, Any]] = None, ) -> None: super().__init__(message) - self.code = code - self.status_code = status_code - self.response = response - self.retry_after = retry_after - self.details = details or {} + self.code, self.status_code, self.response = code, status_code, response + self.retry_after, self.details = retry_after, details or {} +# ── OpenAI → Gemini request translation ────────────────────────────────────── + def _coerce_content_to_text(content: Any) -> str: if content is None: return "" if isinstance(content, str): return content if isinstance(content, list): - pieces: List[str] = [] - for part in content: - if isinstance(part, str): - pieces.append(part) - elif isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str): - pieces.append(part["text"]) - return "\n".join(pieces) + return "\n".join( + p if isinstance(p, str) else p["text"] + for p in content + if isinstance(p, str) or (isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str)) + ) return str(content) @@ -190,21 +180,17 @@ def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: if not isinstance(content, list): text = _coerce_content_to_text(content) return [{"text": text}] if text else [] - parts: List[Dict[str, Any]] = [] for item in content: if isinstance(item, str): parts.append({"text": item}) + elif not isinstance(item, dict): continue - if not isinstance(item, dict): - continue - ptype = item.get("type") - if ptype == "text": - text = item.get("text") - if isinstance(text, str) and text: - parts.append({"text": text}) - elif ptype == "image_url": - url = ((item.get("image_url") or {}).get("url") or "") + elif item.get("type") == "text": + if isinstance(item.get("text"), str) and item["text"]: + parts.append({"text": item["text"]}) + elif item.get("type") == "image_url": + url = (item.get("image_url") or {}).get("url") or "" if not isinstance(url, str) or not url.startswith("data:"): continue try: @@ -226,13 +212,6 @@ def _tool_call_extra_signature(tool_call: Dict[str, Any]) -> Optional[str]: return sig if isinstance(sig, str) and sig else None -# Stands in for a model turn that never arrived (stream failure / interrupt / -# quota fallback) when history leaves a human user text turn directly after a -# tool-result turn, keeping the request alternation-valid while the user's -# message remains a turn of its own (mirrors gemini-cli's placeholder repair). -_INTERRUPTED_RESPONSE_PLACEHOLDER = "[The previous response was interrupted before it completed.]" - - def _tool_call_id(tool_call: Dict[str, Any]) -> str: return str(tool_call.get("id") or tool_call.get("call_id") or "") @@ -246,25 +225,19 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any], include_ids: bool args = {"_raw": args_raw} if not isinstance(args, dict): args = {"_value": args} - - part: Dict[str, Any] = {"functionCall": {"name": str(fn.get("name") or ""), "args": args}} + call: Dict[str, Any] = {"name": str(fn.get("name") or ""), "args": args} if include_ids and _tool_call_id(tool_call): - part["functionCall"]["id"] = _tool_call_id(tool_call) - # Cross-provider tool_calls (e.g. fallback from xAI/Anthropic) carry no - # Gemini thoughtSignature; without the sentinel, Gemini 3 thinking models - # reject replayed history with 400 INVALID_ARGUMENT. - part["thoughtSignature"] = _tool_call_extra_signature(tool_call) or "skip_thought_signature_validator" - return part + call["id"] = _tool_call_id(tool_call) + return {"functionCall": call, "thoughtSignature": _tool_call_extra_signature(tool_call) or _SKIP_SIGNATURE} def _looks_like_json_schema(node: Any) -> bool: - """True if a parsed value contains a JSON-Schema-style ``$ref`` pointer (``#/...``). + """True if a parsed value contains a JSON-Schema ``$ref`` pointer (``#/...``). - Gemini 3 resolves ``$ref``/``$defs`` inside a functionResponse.response - payload and rejects unknown pointers with HTTP 400, so a tool result that - is itself a JSON Schema (e.g. ``tool_describe`` output) must be forwarded - as opaque text. Detection is structural: false positives only lose the - structured shape, never the content. + Gemini 3 resolves ``$ref``/``$defs`` inside functionResponse.response and + rejects unknown pointers with HTTP 400, so a tool result that is itself a + JSON Schema (e.g. ``tool_describe`` output) must be forwarded as opaque + text. False positives only lose the structured shape, never the content. """ if isinstance(node, dict): return any( @@ -278,12 +251,11 @@ def _translate_tool_result_to_gemini( message: Dict[str, Any], tool_name_by_call_id: Optional[Dict[str, str]] = None, include_ids: bool = False, *, is_gemini3: bool = False, ) -> Dict[str, Any]: - tool_name_by_call_id = tool_name_by_call_id or {} tool_call_id = str(message.get("tool_call_id") or "") - # Gemini requires functionResponse.name to echo the matching - # functionCall.name, so the call-id mapping beats the result's own name - # (which may be an unwrapped internal name, e.g. an MCP tool via `tool_call`). - name = str(tool_name_by_call_id.get(tool_call_id) or message.get("name") or tool_call_id or "tool") + # functionResponse.name must echo the matching functionCall.name, so the + # call-id mapping beats the result's own name (which may be an unwrapped + # internal name, e.g. an MCP tool via `tool_call`). + name = str((tool_name_by_call_id or {}).get(tool_call_id) or message.get("name") or tool_call_id or "tool") raw_content = message.get("content") content = _coerce_content_to_text(raw_content) try: @@ -294,8 +266,7 @@ def _translate_tool_result_to_gemini( function_response: Dict[str, Any] = {"name": name, "response": response} if include_ids and tool_call_id: function_response["id"] = tool_call_id - # Gemini 3.x accepts images inside functionResponse.parts; 2.x rejects - # the field, so older models get the text-only downgrade. + # Gemini 3.x accepts images inside functionResponse.parts; 2.x rejects the field. if is_gemini3: image_parts = [p for p in _extract_multimodal_parts(raw_content) if "inlineData" in p] if image_parts: @@ -318,7 +289,6 @@ def _build_gemini_contents( if not isinstance(msg, dict): continue role = str(msg.get("role") or "user") - if role == "system": system_text_parts.append(_coerce_content_to_text(msg.get("content"))) continue @@ -334,47 +304,41 @@ def _build_gemini_contents( if not isinstance(tool_call, dict): continue tool_call_id = _tool_call_id(tool_call) - tool_name = str(((tool_call.get("function") or {}).get("name") or "")) + tool_name = str((tool_call.get("function") or {}).get("name") or "") if tool_call_id and tool_name: tool_name_by_call_id[tool_call_id] = tool_name parts.append(_translate_tool_call_to_gemini(tool_call, include_ids=include_tool_call_ids)) - if parts: contents.append({"role": "model" if role == "assistant" else "user", "parts": parts}) # Alternation contract for generateContent: - # 1) Adjacent same-role contents merge (consecutive same-role contents are - # rejected with HTTP 400 "multiturn requests [must] alternate"). + # 1) Adjacent same-role contents merge (else HTTP 400 "multiturn requests + # [must] alternate"). # 2) Exception: never fuse a human user text turn into a preceding user # content that only carries functionResponse parts (or vice versa) — # Gemini 3 accepts the fold but reads the text as a continuation of the # tool result and returns an empty candidate. Parallel tool results # (functionResponse + functionResponse) still merge. - # 3) The split pair is kept API-valid by interposing a placeholder model - # turn between the functionResponse content and the human text. - merged_contents: List[Dict[str, Any]] = [] + # 3) The split pair stays API-valid via an interposed placeholder model turn. + merged: List[Dict[str, Any]] = [] for content in contents: - prev = merged_contents[-1] if merged_contents else None + prev = merged[-1] if merged else None same_role = prev is not None and prev["role"] == content["role"] if same_role and content["role"] == "user" and _has_function_response(prev) != _has_function_response(content): same_role = False - merged_contents.append({"role": "model", "parts": [{"text": _INTERRUPTED_RESPONSE_PLACEHOLDER}]}) - + merged.append({"role": "model", "parts": [{"text": _INTERRUPTED_RESPONSE_PLACEHOLDER}]}) if same_role: - merged_contents[-1]["parts"].extend(content["parts"]) + merged[-1]["parts"].extend(content["parts"]) else: - merged_contents.append(content) + merged.append(content) joined_system = "\n".join(part for part in system_text_parts if part).strip() - system_instruction = {"role": "system", "parts": [{"text": joined_system}]} if joined_system else None - return merged_contents, system_instruction + return merged, ({"role": "system", "parts": [{"text": joined_system}]} if joined_system else None) def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]: - if not isinstance(tools, list): - return [] declarations: List[Dict[str, Any]] = [] - for tool in tools: + for tool in tools if isinstance(tools, list) else []: fn = (tool.get("function") or {}) if isinstance(tool, dict) else None if not isinstance(fn, dict) or not (isinstance(fn.get("name"), str) and fn["name"]): continue @@ -387,9 +351,6 @@ def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]: return [{"functionDeclarations": declarations}] if declarations else [] -_TOOL_CHOICE_MODES = {"auto": "AUTO", "required": "ANY", "none": "NONE"} - - def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]: if isinstance(tool_choice, str) and tool_choice in _TOOL_CHOICE_MODES: return {"functionCallingConfig": {"mode": _TOOL_CHOICE_MODES[tool_choice]}} @@ -417,12 +378,9 @@ def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]: def _thinking_requests_output_headroom(thinking_config: Any) -> bool: - """True when Gemini will spend output tokens on thinking. - - Thought tokens bill against ``maxOutputTokens``; a global 4096/16384 - ``max_tokens`` can be consumed entirely by high thinking, leaving - ``finishReason=MAX_TOKENS`` with no answer. - """ + """True when Gemini will spend output tokens on thinking (thought tokens bill + against ``maxOutputTokens``; a global 4096/16384 cap can be consumed entirely + by high thinking, leaving ``finishReason=MAX_TOKENS`` with no answer).""" normalized = _normalize_thinking_config(thinking_config) if not normalized: return False @@ -433,10 +391,9 @@ def _thinking_requests_output_headroom(thinking_config: Any) -> bool: def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_config: Any) -> int: - """Resolve native ``maxOutputTokens``: an omitted/invalid cap becomes the - published ceiling (Gemini truncates on its low internal default), and an - explicit cap is raised to that ceiling when thinking is enabled so thought - tokens do not starve the answer.""" + """Native ``maxOutputTokens``: an omitted/invalid cap becomes the published + ceiling (Gemini truncates on its low internal default); an explicit cap is + raised to the ceiling when thinking is enabled so thoughts don't starve the answer.""" try: requested = int(max_tokens) except (TypeError, ValueError): @@ -479,10 +436,7 @@ def build_gemini_request( return request -_FINISH_REASON_MAP = { - "STOP": "stop", "MAX_TOKENS": "length", "SAFETY": "content_filter", "RECITATION": "content_filter", "OTHER": "stop", -} - +# ── Gemini → OpenAI response translation ───────────────────────────────────── def _map_gemini_finish_reason(reason: str) -> str: return _FINISH_REASON_MAP.get(str(reason or "").upper(), "stop") @@ -494,7 +448,7 @@ def _tool_call_extra_from_part(part: Dict[str, Any]) -> Optional[Dict[str, Any]] def _new_call_id(fc: Dict[str, Any]) -> str: - """Echo Gemini's functionCall id when present, else mint an OpenAI-style one.""" + """Echo the functionCall/delta ``id`` when present, else mint an OpenAI-style one.""" fc_id = fc.get("id") return fc_id if isinstance(fc_id, str) and fc_id else f"call_{uuid.uuid4().hex[:12]}" @@ -515,13 +469,6 @@ def _usage_from_metadata(usage_meta: Dict[str, Any]) -> SimpleNamespace: ) -def _completion(model: str, message: SimpleNamespace, finish_reason: str, usage: SimpleNamespace) -> SimpleNamespace: - return SimpleNamespace( - id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object="chat.completion", created=int(time.time()), model=model, - choices=[SimpleNamespace(index=0, message=message, finish_reason=finish_reason)], usage=usage, - ) - - def _assistant_message(content: Any, tool_calls: Any, reasoning: Any) -> SimpleNamespace: return SimpleNamespace( role="assistant", content=content, tool_calls=tool_calls, @@ -531,41 +478,43 @@ def _assistant_message(content: Any, tool_calls: Any, reasoning: Any) -> SimpleN def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespace: candidates = resp.get("candidates") or [] - if not isinstance(candidates, list) or not candidates: - return _completion(model, _assistant_message("", None, None), "stop", _usage_from_metadata({})) - cand = candidates[0] if isinstance(candidates[0], dict) else {} - content_obj = cand.get("content") - parts = content_obj.get("parts") if isinstance(content_obj, dict) else [] - + cand = parts = None + if isinstance(candidates, list) and candidates: + cand = candidates[0] if isinstance(candidates[0], dict) else {} + content_obj = cand.get("content") + parts = content_obj.get("parts") if isinstance(content_obj, dict) else [] text_pieces: List[str] = [] reasoning_pieces: List[str] = [] tool_calls: List[SimpleNamespace] = [] - for index, part in enumerate(parts or []): if not isinstance(part, dict): continue if part.get("thought") is True and isinstance(part.get("text"), str): reasoning_pieces.append(part["text"]) - continue - if isinstance(part.get("text"), str): + elif isinstance(part.get("text"), str): text_pieces.append(part["text"]) - continue - fc = part.get("functionCall") - if isinstance(fc, dict) and fc.get("name"): + elif isinstance(fc := part.get("functionCall"), dict) and fc.get("name"): tool_call = SimpleNamespace( id=_new_call_id(fc), type="function", index=index, function=SimpleNamespace(name=str(fc["name"]), arguments=_dump_call_args(fc)), ) - extra_content = _tool_call_extra_from_part(part) - if extra_content: + if extra_content := _tool_call_extra_from_part(part): tool_call.extra_content = extra_content tool_calls.append(tool_call) - finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(str(cand.get("finishReason") or "")) + if cand is None: + finish_reason, usage = "stop", _usage_from_metadata({}) + else: + finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(str(cand.get("finishReason") or "")) + usage = _usage_from_metadata(resp.get("usageMetadata") or {}) message = _assistant_message( - "".join(text_pieces) if text_pieces else None, tool_calls or None, "".join(reasoning_pieces) or None + "".join(text_pieces) if text_pieces else ("" if cand is None else None), + tool_calls or None, "".join(reasoning_pieces) or None, + ) + return SimpleNamespace( + id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object="chat.completion", created=int(time.time()), model=model, + choices=[SimpleNamespace(index=0, message=message, finish_reason=finish_reason)], usage=usage, ) - return _completion(model, message, finish_reason, _usage_from_metadata(resp.get("usageMetadata") or {})) class _GeminiStreamChunk(SimpleNamespace): @@ -579,14 +528,11 @@ def _make_stream_chunk( tool_calls = None if tool_call_delta is not None: tool_delta = SimpleNamespace( - index=tool_call_delta.get("index", 0), - id=tool_call_delta.get("id") or f"call_{uuid.uuid4().hex[:12]}", - type="function", + index=tool_call_delta.get("index", 0), id=_new_call_id(tool_call_delta), type="function", function=SimpleNamespace(name=tool_call_delta.get("name") or "", arguments=tool_call_delta.get("arguments") or ""), ) - extra_content = tool_call_delta.get("extra_content") - if isinstance(extra_content, dict): - tool_delta.extra_content = extra_content + if isinstance(tool_call_delta.get("extra_content"), dict): + tool_delta.extra_content = tool_call_delta["extra_content"] tool_calls = [tool_delta] delta = SimpleNamespace( role="assistant", content=content or None, tool_calls=tool_calls, @@ -628,7 +574,6 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: cand = candidates[0] if isinstance(candidates[0], dict) else {} parts = (cand.get("content") or {}).get("parts") or [] chunks: List[_GeminiStreamChunk] = [] - for part_index, part in enumerate(parts): if not isinstance(part, dict): continue @@ -647,15 +592,14 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: ) slot = tool_call_indices.get(call_key) if slot is None: - slot = {"index": len(tool_call_indices), "id": _new_call_id(fc), "last_arguments": ""} - tool_call_indices[call_key] = slot + slot = tool_call_indices[call_key] = {"index": len(tool_call_indices), "id": _new_call_id(fc), "last_arguments": ""} # Gemini re-sends the full args each event; emit only the new suffix. last_arguments = str(slot.get("last_arguments") or "") - emitted_arguments = args_str[len(last_arguments):] if args_str.startswith(last_arguments) else args_str + emitted = args_str[len(last_arguments):] if args_str.startswith(last_arguments) else args_str slot["last_arguments"] = args_str chunks.append(_make_stream_chunk(model=model, tool_call_delta={ "index": slot["index"], "id": slot["id"], "name": name, - "arguments": emitted_arguments, "extra_content": _tool_call_extra_from_part(part), + "arguments": emitted, "extra_content": _tool_call_extra_from_part(part), })) finish_reason_raw = str(cand.get("finishReason") or "") @@ -663,26 +607,17 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: finish_chunk = _make_stream_chunk( model=model, finish_reason="tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw) ) - # Carry usageMetadata on the finish chunk so the streaming loop can - # record token counts like the non-streaming path does. - usage_meta = event.get("usageMetadata") or {} - if usage_meta: + # usageMetadata rides on the finish chunk so the streaming loop records + # token counts like the non-streaming path does. + if usage_meta := event.get("usageMetadata") or {}: finish_chunk.usage = _usage_from_metadata(usage_meta) chunks.append(finish_chunk) return chunks -_HTTP_ERROR_CODES = {401: "gemini_unauthorized", 429: "gemini_rate_limited", 404: "gemini_model_not_found"} - - def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = None) -> GeminiAPIError: status = response.status_code - if body_text is None: - try: - body_text = response.text - except Exception: - body_text = "" - body_text = body_text or "" + body_text = (_response_text(response) if body_text is None else body_text) or "" err_obj: Any = None if body_text: try: @@ -696,8 +631,8 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No err_message = str(err_obj.get("message") or "").strip() details_list = err_obj.get("details") - reason = "" - metadata: Dict[str, Any] = {} + # First google.rpc.ErrorInfo detail supplies reason/metadata. + reason, metadata = "", {} for detail in details_list if isinstance(details_list, list) else []: if isinstance(detail, dict) and not reason and str(detail.get("@type") or "").endswith("/google.rpc.ErrorInfo"): reason_value, md = detail.get("reason"), detail.get("metadata") @@ -707,12 +642,10 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No metadata = md retry_after: Optional[float] = None - header_retry = response.headers.get("Retry-After") or response.headers.get("retry-after") - if header_retry: - try: - retry_after = float(header_retry) - except (TypeError, ValueError): - pass + try: + retry_after = float(response.headers.get("Retry-After") or response.headers.get("retry-after")) + except (TypeError, ValueError): + pass if err_message: message = f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" @@ -721,27 +654,21 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No # Users who bypassed the setup wizard (raw GOOGLE_API_KEY in .env) still # need to learn that the free tier cannot sustain an agent session. if status == 429 and is_free_tier_quota_error(err_message or body_text): - message = message + _FREE_TIER_GUIDANCE + message += _FREE_TIER_GUIDANCE # Legacy "Standard" key rejection: Google's raw 401 misleadingly asks for # OAuth; append the actual fix (mint a new Gemini API key in AI Studio). if is_standard_key_auth_error(status, err_message or body_text, reason): - message = message + _STANDARD_KEY_GUIDANCE - + message += _STANDARD_KEY_GUIDANCE return GeminiAPIError( - message, - code=_HTTP_ERROR_CODES.get(status, f"gemini_http_{status}"), - status_code=status, - response=response, + message, code=_HTTP_ERROR_CODES.get(status, f"gemini_http_{status}"), status_code=status, response=response, retry_after=retry_after, details={"status": err_status, "reason": reason, "metadata": metadata, "message": err_message}, ) class GeminiNativeClient: - """Minimal OpenAI-SDK-compatible facade over Gemini's native REST API. - - ``client.chat.completions.create(**kwargs)`` mirrors the OpenAI SDK surface. - """ + """Minimal OpenAI-SDK-compatible facade (``client.chat.completions.create(**kwargs)``) + over Gemini's native REST API.""" # Declared for agent/auxiliary_client.py: already a complete client, so it # is never re-dispatched through a wire adapter. (No HERMES_SKIP_ASYNC_WRAP @@ -804,14 +731,12 @@ class GeminiNativeClient: max_tokens: Optional[int] = None, top_p: Optional[float] = None, stop: Any = None, extra_body: Optional[Dict[str, Any]] = None, timeout: Any = None, **_: Any, ) -> Any: - thinking_config = None - if isinstance(extra_body, dict): - thinking_config = extra_body.get("thinking_config") or extra_body.get("thinkingConfig") + extra = extra_body if isinstance(extra_body, dict) else {} + thinking_config = extra.get("thinking_config") or extra.get("thinkingConfig") request = build_gemini_request( messages=messages or [], tools=tools, tool_choice=tool_choice, temperature=temperature, max_tokens=max_tokens, top_p=top_p, stop=stop, thinking_config=thinking_config, model=model, ) - model = bare_gemini_model_id(model) if stream: return self._stream_completion(model=model, request=request, timeout=timeout) @@ -832,19 +757,15 @@ class GeminiNativeClient: def _stream_completion(self, *, model: str, request: Dict[str, Any], timeout: Any = None) -> Iterator[_GeminiStreamChunk]: url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse" stream_headers = {**self._headers(), "Accept": "text/event-stream"} - - def _generator() -> Iterator[_GeminiStreamChunk]: - try: - with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response: - if response.status_code != 200: - raise gemini_http_error(response, body_text=read_streaming_error_body(response)) - tool_call_indices: Dict[str, Dict[str, Any]] = {} - for event in _iter_sse_events(response): - yield from translate_stream_event(event, model, tool_call_indices) - except httpx.HTTPError as exc: - raise GeminiAPIError(f"Gemini streaming request failed: {exc}", code="gemini_stream_error") from exc - - return _generator() + try: + with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response: + if response.status_code != 200: + raise gemini_http_error(response, body_text=read_streaming_error_body(response)) + tool_call_indices: Dict[str, Dict[str, Any]] = {} + for event in _iter_sse_events(response): + yield from translate_stream_event(event, model, tool_call_indices) + except httpx.HTTPError as exc: + raise GeminiAPIError(f"Gemini streaming request failed: {exc}", code="gemini_stream_error") from exc class AsyncGeminiNativeClient: @@ -860,16 +781,15 @@ class AsyncGeminiNativeClient: self._real_client = sync_client async def _create_chat_completion(self, **kwargs: Any) -> Any: - stream = bool(kwargs.get("stream")) result = await asyncio.to_thread(self._sync.chat.completions.create, **kwargs) - if not stream: + if not kwargs.get("stream"): return result async def _async_stream() -> Any: while True: done, chunk = await asyncio.to_thread(self._sync._advance_stream_iterator, result) if done: - break + return yield chunk return _async_stream() diff --git a/agent/gemini_schema.py b/agent/gemini_schema.py index 748378a940..9f9a956f9c 100644 --- a/agent/gemini_schema.py +++ b/agent/gemini_schema.py @@ -53,10 +53,9 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: # Gemini requires every ``enum`` entry to be a string even for # integer/number/boolean types; the declared type stays intact and Gemini - # still emits typed tool arguments at runtime. + # still emits typed tool arguments at runtime. dict.fromkeys = ordered dedupe. enum_val = cleaned.get("enum") if isinstance(enum_val, list) and cleaned.get("type") in {"integer", "number", "boolean"}: - # dict.fromkeys = order-preserving dedupe. stringified = list(dict.fromkeys(v for v in map(_stringify_enum_value, enum_val) if v is not None)) if stringified: cleaned["enum"] = stringified @@ -71,13 +70,12 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: required_val = cleaned.get("required") if isinstance(required_val, list): props_val = cleaned.get("properties") - prop_names = set(props_val.keys()) if isinstance(props_val, dict) else set() + prop_names = set(props_val) if isinstance(props_val, dict) else set() valid_required = [name for name in required_val if isinstance(name, str) and name in prop_names] if not valid_required: cleaned.pop("required", None) elif len(valid_required) != len(required_val): cleaned["required"] = valid_required - return cleaned diff --git a/agent/jiter_preload.py b/agent/jiter_preload.py index 0d27b4d38b..6a69352a78 100644 --- a/agent/jiter_preload.py +++ b/agent/jiter_preload.py @@ -1,10 +1,8 @@ -"""Best-effort early import of the OpenAI SDK's native streaming parser. - -On some Windows installs ``jiter``'s native extension imports fine from the venv -but fails when first imported later inside the threaded streaming path. Loading -it once at agent-package import avoids that while preserving the SDK's normal -error path for genuinely broken installs. -""" +"""Best-effort early import of the OpenAI SDK's native streaming parser: on some +Windows installs ``jiter``'s native extension imports fine from the venv but fails +when first imported inside the threaded streaming path. Loading it once at +agent-package import avoids that while keeping the SDK's normal error path for +genuinely broken installs.""" from __future__ import annotations @@ -15,7 +13,6 @@ _JITER_PRELOAD_ERROR: Exception | None = None def preload_jiter_native_extension() -> bool: - """Import jiter's native extension early if it is available.""" global _JITER_PRELOADED, _JITER_PRELOAD_ERROR if _JITER_PRELOADED: return True @@ -25,8 +22,7 @@ def preload_jiter_native_extension() -> bool: except Exception as exc: _JITER_PRELOAD_ERROR = exc return False - _JITER_PRELOADED = True - _JITER_PRELOAD_ERROR = None + _JITER_PRELOADED, _JITER_PRELOAD_ERROR = True, None return True diff --git a/agent/lazy_forward.py b/agent/lazy_forward.py index ae65a912fb..8c7d646f54 100644 --- a/agent/lazy_forward.py +++ b/agent/lazy_forward.py @@ -1,28 +1,25 @@ """Lazy forwarding helpers shared by the AIAgent façade and its mixins. -Targets are resolved on every call, so ``patch(".")`` in tests still intercepts and the -heavy agent modules stay off the ``run_agent`` import path. +Targets are resolved on every call, so ``patch(".")`` in tests still +intercepts and the heavy agent modules stay off the ``run_agent`` import path. """ import importlib def lazy_attr(module: str, name: str): - """Resolve ``module.name`` at call time (keeps heavy/cyclic imports off the run_agent import path).""" + """Resolve ``module.name`` at call time.""" return getattr(importlib.import_module(module), name) def forward(module: str, name: str, *, static: bool = False): - """Build an AIAgent method that lazily forwards to ``module.name`` (``target(self, *args, **kwargs)``). - - The target is resolved on every call, so ``patch(".")`` in tests still intercepts and - run_agent keeps its import-time cost. ``static=True`` drops ``self``. - """ + """Build an AIAgent method that lazily forwards to ``module.name`` + (``target(self, *args, **kwargs)``; ``static=True`` drops ``self``).""" if static: def forwarder(*args, **kwargs): - return getattr(importlib.import_module(module), name)(*args, **kwargs) + return lazy_attr(module, name)(*args, **kwargs) else: def forwarder(self, *args, **kwargs): - return getattr(importlib.import_module(module), name)(self, *args, **kwargs) + return lazy_attr(module, name)(self, *args, **kwargs) forwarder.__name__ = forwarder.__qualname__ = name forwarder.__doc__ = f"Forwarder — see ``{module}.{name}``." return staticmethod(forwarder) if static else forwarder diff --git a/agent/lmstudio_reasoning.py b/agent/lmstudio_reasoning.py index 799e8dd538..0835264a77 100644 --- a/agent/lmstudio_reasoning.py +++ b/agent/lmstudio_reasoning.py @@ -1,41 +1,30 @@ -"""LM Studio reasoning-effort resolution shared by the chat-completions -transport and run_agent's iteration-limit summary path. - -LM Studio publishes per-model ``capabilities.reasoning.allowed_options`` -(``["off","on"]`` for toggle models, ``["off","minimal","low"]`` for graduated -ones). We map the user's ``reasoning_config`` onto LM Studio's OpenAI-compatible -vocabulary, then clamp against the model's allowed set so the server doesn't 400. -""" +"""LM Studio reasoning-effort resolution (chat-completions transport + run_agent's +iteration-limit summary path). LM Studio publishes per-model +``capabilities.reasoning.allowed_options`` (``["off","on"]`` for toggle models, +``["off","minimal","low"]`` for graduated ones); the user's ``reasoning_config`` is +mapped onto LM Studio's vocabulary, then clamped to the allowed set so the server +doesn't 400.""" from __future__ import annotations from typing import List, Optional -# Top-level reasoning_effort values LM Studio's OpenAI-compatible endpoint accepts. _LM_VALID_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} -# Toggle-style models publish allowed_options as ["off","on"]; map onto the -# request vocabulary. Also applied to the published allowed_options themselves. +# Toggle vocabulary → request vocabulary; also applied to published allowed_options. _LM_EFFORT_ALIASES = {"off": "none", "on": "medium"} -# Hermes' ladder grew past LM Studio's vocabulary ("max", "ultra"). Without this -# ceiling clamp they miss _LM_VALID_EFFORTS, keep the "medium" default and are -# conflated with unparseable input — asking for more yields less than "xhigh". -# Kept separate from _LM_EFFORT_ALIASES, which must not rewrite allowed_options. +# Hermes' ladder grew past LM Studio's vocabulary ("max", "ultra"); without this +# ceiling clamp they'd fall to the "medium" default (more yields less than "xhigh"). +# Separate from _LM_EFFORT_ALIASES, which must not rewrite allowed_options. _LM_EFFORT_CLAMP = {"max": "xhigh", "ultra": "xhigh"} -def resolve_lmstudio_effort( - reasoning_config: Optional[dict], - allowed_options: Optional[List[str]], -) -> Optional[str]: - """Return the ``reasoning_effort`` to send to LM Studio, or ``None``. - - ``None`` means "omit the field": the user picked a level the model can't - honor, so LM Studio falls back to the model's declared default rather than - a silently substituted effort. Falsy ``allowed_options`` (probe failed) - skips clamping and sends the resolved effort anyway. - """ +def resolve_lmstudio_effort(reasoning_config: Optional[dict], allowed_options: Optional[List[str]]) -> Optional[str]: + """Return the ``reasoning_effort`` to send to LM Studio, or ``None`` = omit the + field (the user picked a level the model can't honor, so LM Studio falls back + to the model's declared default rather than a silently substituted effort). + Falsy ``allowed_options`` (probe failed) skips clamping.""" effort = "medium" if reasoning_config and isinstance(reasoning_config, dict): if reasoning_config.get("enabled") is False: @@ -46,8 +35,6 @@ def resolve_lmstudio_effort( raw = _LM_EFFORT_CLAMP.get(raw, raw) if raw in _LM_VALID_EFFORTS: effort = raw - if allowed_options: - allowed = {_LM_EFFORT_ALIASES.get(opt, opt) for opt in allowed_options} - if effort not in allowed: - return None + if allowed_options and effort not in {_LM_EFFORT_ALIASES.get(opt, opt) for opt in allowed_options}: + return None return effort From bae5d6f6547a3591d8797d58540c49ceb5f6d02d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:27:11 -0700 Subject: [PATCH 02/30] refactor(agent/gemini,credits): restore grant_spent clear semantics; shared response envelope + thinking-key table --- agent/credits_tracker.py | 53 ++++++-------- agent/gemini_native_adapter.py | 129 +++++++++++++++++---------------- 2 files changed, 87 insertions(+), 95 deletions(-) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 8f7a1e769e..ba9b571635 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -17,7 +17,7 @@ import os import re import time from dataclasses import dataclass -from typing import Any, Callable, Mapping, Optional +from typing import Any, Mapping, Optional from utils import is_truthy_value @@ -165,21 +165,6 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool: return False -def _sync_notice( - key: str, want: bool, make: Callable[[], AgentNotice], active: set, to_show: list, to_clear: list -) -> Optional[str]: - """Reconcile one keyed sticky notice; returns ``"shown"``, ``"cleared"`` or None.""" - if want and key not in active: - to_show.append(make()) - active.add(key) - return "shown" - if key in active and not want: - to_clear.append(key) - active.discard(key) - return "cleared" - return None - - def evaluate_credits_notices( state: CreditsState, latch: dict, *, model_is_free: bool = False, ) -> tuple[list[AgentNotice], list[str]]: @@ -249,26 +234,30 @@ def evaluate_credits_notices( grant_cond = ( state.denominator_kind == "subscription_cap" and uf is not None and uf >= 1.0 and state.purchased_micros > 0 ) - if _sync_notice( - "credits.grant_spent", grant_cond and latch.get("seen_grant_unspent", False), - lambda: _sticky_notice(f"• Grant spent · ${state.purchased_usd} top-up left", "info", "credits.grant_spent"), - active, to_show, to_clear, - ) == "shown": + if grant_cond and "credits.grant_spent" not in active and latch.get("seen_grant_unspent", False): + to_show.append(_sticky_notice(f"• Grant spent · ${state.purchased_usd} top-up left", "info", "credits.grant_spent")) + active.add("credits.grant_spent") latch["seen_grant_unspent"] = False + elif "credits.grant_spent" in active and not grant_cond: + to_clear.append("credits.grant_spent") + active.discard("credits.grant_spent") # ── depleted: suppressed while the model is free (inference still works). depleted_cond = not state.paid_access - if _sync_notice( - "credits.depleted", depleted_cond and not model_is_free, - lambda: _sticky_notice("✕ Credit access paused · run /topup to top up", "error", "credits.depleted"), - active, to_show, to_clear, - ) == "cleared" and not depleted_cond: - # Genuine recovery only — switching to a free model while still - # depleted must NOT claim access was restored. - to_show.append(AgentNotice( - text="✓ Credit access restored", level="success", kind="ttl", - ttl_ms=CREDITS_RESTORED_TTL_MS, key="credits.restored", id="credits.restored", - )) + show_depleted = depleted_cond and not model_is_free + if show_depleted and "credits.depleted" not in active: + to_show.append(_sticky_notice("✕ Credit access paused · run /topup to top up", "error", "credits.depleted")) + active.add("credits.depleted") + elif "credits.depleted" in active and not show_depleted: + to_clear.append("credits.depleted") + active.discard("credits.depleted") + if not depleted_cond: + # Genuine recovery only — switching to a free model while still + # depleted must NOT claim access was restored. + to_show.append(AgentNotice( + text="✓ Credit access restored", level="success", kind="ttl", + ttl_ms=CREDITS_RESTORED_TTL_MS, key="credits.restored", id="credits.restored", + )) return (to_show, to_clear) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 4a1a689f20..51db2062a4 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -162,18 +162,34 @@ class GeminiAPIError(Exception): # ── OpenAI → Gemini request translation ────────────────────────────────────── +def _text_of(item: Any) -> Optional[str]: + """Text of an OpenAI content item (plain str or ``{"type": "text"}`` dict), else None.""" + if isinstance(item, str): + return item + if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str): + return item["text"] + return None + + def _coerce_content_to_text(content: Any) -> str: if content is None: return "" - if isinstance(content, str): - return content if isinstance(content, list): - return "\n".join( - p if isinstance(p, str) else p["text"] - for p in content - if isinstance(p, str) or (isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str)) - ) - return str(content) + return "\n".join(t for t in map(_text_of, content) if t is not None) + return content if isinstance(content, str) else str(content) + + +def _inline_data_part(url: Any) -> Optional[Dict[str, Any]]: + """``inlineData`` part for a ``data:`` URL; None for remote/malformed URLs.""" + if not isinstance(url, str) or not url.startswith("data:"): + return None + try: + header, encoded = url.split(",", 1) + mime = header.split(":", 1)[1].split(";", 1)[0] + raw = base64.b64decode(encoded) + except Exception: + return None + return {"inlineData": {"mimeType": mime, "data": base64.b64encode(raw).decode("ascii")}} def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: @@ -182,24 +198,13 @@ def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: return [{"text": text}] if text else [] parts: List[Dict[str, Any]] = [] for item in content: - if isinstance(item, str): - parts.append({"text": item}) - elif not isinstance(item, dict): - continue - elif item.get("type") == "text": - if isinstance(item.get("text"), str) and item["text"]: - parts.append({"text": item["text"]}) - elif item.get("type") == "image_url": - url = (item.get("image_url") or {}).get("url") or "" - if not isinstance(url, str) or not url.startswith("data:"): - continue - try: - header, encoded = url.split(",", 1) - mime = header.split(":", 1)[1].split(";", 1)[0] - raw = base64.b64decode(encoded) - except Exception: - continue - parts.append({"inlineData": {"mimeType": mime, "data": base64.b64encode(raw).decode("ascii")}}) + text = _text_of(item) + if text or isinstance(item, str): + parts.append({"text": text}) + elif isinstance(item, dict) and item.get("type") == "image_url": + image = _inline_data_part((item.get("image_url") or {}).get("url") or "") + if image: + parts.append(image) return parts @@ -361,19 +366,22 @@ def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any return None +# (camelCase key, snake_case alias, accepted types, normalizer) +_THINKING_KEYS = ( + ("thinkingBudget", "thinking_budget", (int, float), int), + ("includeThoughts", "include_thoughts", bool, lambda v: v), + ("thinkingLevel", "thinking_level", str, lambda v: v.strip().lower()), +) + + def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]: if not isinstance(config, dict) or not config: return None - budget = config.get("thinkingBudget", config.get("thinking_budget")) - include = config.get("includeThoughts", config.get("include_thoughts")) - level = config.get("thinkingLevel", config.get("thinking_level")) normalized: Dict[str, Any] = {} - if isinstance(budget, (int, float)): - normalized["thinkingBudget"] = int(budget) - if isinstance(include, bool): - normalized["includeThoughts"] = include - if isinstance(level, str) and level.strip(): - normalized["thinkingLevel"] = level.strip().lower() + for key, alias, types, norm in _THINKING_KEYS: + value = config.get(key, config.get(alias)) + if isinstance(value, types) and (value.strip() if isinstance(value, str) else True): + normalized[key] = norm(value) return normalized or None @@ -469,13 +477,24 @@ def _usage_from_metadata(usage_meta: Dict[str, Any]) -> SimpleNamespace: ) -def _assistant_message(content: Any, tool_calls: Any, reasoning: Any) -> SimpleNamespace: - return SimpleNamespace( - role="assistant", content=content, tool_calls=tool_calls, - reasoning=reasoning, reasoning_content=reasoning, reasoning_details=None, +def _envelope(model: str, object_: str, choice: SimpleNamespace, usage: Any, cls: type = SimpleNamespace) -> Any: + """OpenAI chat.completion / chat.completion.chunk envelope around one choice.""" + return cls( + id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object=object_, created=int(time.time()), model=model, + choices=[choice], usage=usage, ) +def _tool_call_ns(fc: Dict[str, Any], part: Dict[str, Any], index: int) -> SimpleNamespace: + tool_call = SimpleNamespace( + id=_new_call_id(fc), type="function", index=index, + function=SimpleNamespace(name=str(fc["name"]), arguments=_dump_call_args(fc)), + ) + if extra_content := _tool_call_extra_from_part(part): + tool_call.extra_content = extra_content + return tool_call + + def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespace: candidates = resp.get("candidates") or [] cand = parts = None @@ -494,27 +513,19 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa elif isinstance(part.get("text"), str): text_pieces.append(part["text"]) elif isinstance(fc := part.get("functionCall"), dict) and fc.get("name"): - tool_call = SimpleNamespace( - id=_new_call_id(fc), type="function", index=index, - function=SimpleNamespace(name=str(fc["name"]), arguments=_dump_call_args(fc)), - ) - if extra_content := _tool_call_extra_from_part(part): - tool_call.extra_content = extra_content - tool_calls.append(tool_call) + tool_calls.append(_tool_call_ns(fc, part, index)) if cand is None: finish_reason, usage = "stop", _usage_from_metadata({}) else: finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(str(cand.get("finishReason") or "")) usage = _usage_from_metadata(resp.get("usageMetadata") or {}) - message = _assistant_message( - "".join(text_pieces) if text_pieces else ("" if cand is None else None), - tool_calls or None, "".join(reasoning_pieces) or None, - ) - return SimpleNamespace( - id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object="chat.completion", created=int(time.time()), model=model, - choices=[SimpleNamespace(index=0, message=message, finish_reason=finish_reason)], usage=usage, + reasoning = "".join(reasoning_pieces) or None + message = SimpleNamespace( + role="assistant", content="".join(text_pieces) if text_pieces else ("" if cand is None else None), + tool_calls=tool_calls or None, reasoning=reasoning, reasoning_content=reasoning, reasoning_details=None, ) + return _envelope(model, "chat.completion", SimpleNamespace(index=0, message=message, finish_reason=finish_reason), usage) class _GeminiStreamChunk(SimpleNamespace): @@ -538,10 +549,8 @@ def _make_stream_chunk( role="assistant", content=content or None, tool_calls=tool_calls, reasoning=reasoning or None, reasoning_content=reasoning or None, ) - return _GeminiStreamChunk( - id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object="chat.completion.chunk", created=int(time.time()), model=model, - choices=[SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)], usage=None, - ) + choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason) + return _envelope(model, "chat.completion.chunk", choice, None, cls=_GeminiStreamChunk) def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]: @@ -701,12 +710,6 @@ class GeminiNativeClient: except Exception: pass - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - def _headers(self) -> Dict[str, str]: return { "Content-Type": "application/json", From 7b7d3bc18877f76fa30eab707f5cce65e4028f24 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:39:24 -0700 Subject: [PATCH 03/30] refactor(agent/acp,credits,gemini): compact handlers, dispatch session/update sinks, tighten docstrings --- agent/copilot_acp_client.py | 86 +++++++---------- agent/credits_tracker.py | 168 ++++++++++++--------------------- agent/gemini_native_adapter.py | 70 ++++++-------- 3 files changed, 123 insertions(+), 201 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index c52131e08a..85059964dc 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -89,19 +89,16 @@ def _resolve_args() -> list[str]: def _acp_supported(command: str, args: list[str]) -> bool | None: - """Tri-state probe: does ``command`` accept ``--acp``? - - A CLI without the flag (older releases, Claude Code v2.x) exits 1 with - ``error: unknown option '--acp'`` and the parent then waits the full child - timeout for stdout that never arrives. True = help advertises --acp; False = - help ran cleanly without it (caller fast-fails); None = inconclusive (binary - missing / --help failed), caller falls through to the normal spawn error. - Only probes when ``--acp`` is among ``args`` — a custom transport is the operator's business. - """ + """Tri-state probe: does ``command`` accept ``--acp``? A CLI without the flag + (older releases, Claude Code v2.x) exits 1 with ``unknown option '--acp'`` and the + parent would wait the full child timeout for stdout that never arrives. + True = help advertises --acp; False = help ran cleanly without it (caller + fast-fails); None = inconclusive (binary missing / --help failed → normal spawn + error). Only probes when ``--acp`` is among ``args`` — a custom transport is the + operator's business.""" if "--acp" not in args: return True - cached = _ACP_PROBE_CACHE.get(command) - if cached is not None: + if (cached := _ACP_PROBE_CACHE.get(command)) is not None: return cached try: probe = subprocess.run( @@ -120,8 +117,7 @@ def _acp_supported(command: str, args: list[str]) -> bool | None: def _resolve_home_dir() -> str: """Stable HOME for child ACP processes; /tmp as a last resort so the child never starts HOME-less.""" - home = os.environ.get("HOME", "").strip() - if home: + if home := os.environ.get("HOME", "").strip(): return home expanded = os.path.expanduser("~") if expanded and expanded != "~": @@ -129,12 +125,9 @@ def _resolve_home_dir() -> str: try: import pwd - resolved = pwd.getpwuid(os.getuid()).pw_dir.strip() # windows-footgun: ok — POSIX fallback inside try/except (pwd import fails on Windows) - if resolved: - return resolved + return pwd.getpwuid(os.getuid()).pw_dir.strip() or "/tmp" # windows-footgun: ok — POSIX fallback inside try/except (pwd import fails on Windows) except Exception: - pass - return "/tmp" + return "/tmp" def _build_subprocess_env() -> dict[str, str]: @@ -169,13 +162,10 @@ def _enabled_ids(entries: Any, key: str) -> set[str]: def _model_selection_request(session: dict[str, Any], requested_model: str) -> tuple[str, dict[str, str]] | None: - """Return the ACP request that selects ``requested_model`` for ``session``. - - Prefer stable v1 ``session/set_config_option``; fall back to Copilot's - pre-stabilization ``session/set_model`` extension only when no model config - option is advertised. A reported model list is authoritative: unknown and - policy-disabled ids return None instead of being sent. - """ + """ACP request selecting ``requested_model`` for ``session``: stable v1 + ``session/set_config_option``, else Copilot's pre-stabilization ``session/set_model`` + when no model config option is advertised. A reported model list is authoritative: + unknown and policy-disabled ids return None instead of being sent.""" session_id = str(session.get("sessionId") or "").strip() requested_model = str(requested_model or "").strip() if not session_id or not requested_model or requested_model == "copilot-acp": @@ -265,8 +255,7 @@ def _effective_timeout(timeout: Any) -> float: def _fs_read_text_file(params: dict[str, Any], cwd: str) -> Any: path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) - block_error = get_read_block_error(str(path)) - if block_error: + if block_error := get_read_block_error(str(path)): raise PermissionError(block_error) try: content = path.read_text(encoding="utf-8") @@ -274,18 +263,14 @@ def _fs_read_text_file(params: dict[str, Any], cwd: str) -> Any: content = "" line, limit = params.get("line"), params.get("limit") if isinstance(line, int) and line > 1: - lines = content.splitlines(keepends=True) end = line - 1 + limit if isinstance(limit, int) and limit > 0 else None - content = "".join(lines[line - 1:end]) - if content: - content = redact_sensitive_text(content, force=True) - return {"content": content} + content = "".join(content.splitlines(keepends=True)[line - 1:end]) + return {"content": redact_sensitive_text(content, force=True) if content else content} def _fs_write_text_file(params: dict[str, Any], cwd: str) -> Any: path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) - denied = get_write_denied_error(str(path)) - if denied: + if denied := get_write_denied_error(str(path)): raise PermissionError(denied) # Approval-gated paths (e.g. ~/.ssh/config) are only soft-gated for interactive # tools, but the ACP shim has no human channel to confirm — fail closed. @@ -329,8 +314,7 @@ class CopilotACPClient: def close(self) -> None: with self._active_process_lock: - proc = self._active_process - self._active_process = None + proc, self._active_process = self._active_process, None self.is_closed = True if proc is None: return @@ -348,17 +332,15 @@ class CopilotACPClient: tools: list[dict[str, Any]] | None = None, tool_choice: Any = None, stream: bool = False, **_: Any, ) -> Any: prompt_text = _format_messages_as_prompt(messages or [], model=model, tools=tools, tool_choice=tool_choice) - response_text, reasoning_text = self._run_prompt(prompt_text, timeout_seconds=_effective_timeout(timeout), model=model) + response_text, reasoning = self._run_prompt(prompt_text, timeout_seconds=_effective_timeout(timeout), model=model) tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text) - assistant_message = SimpleNamespace( - content=cleaned_text, tool_calls=tool_calls, reasoning=reasoning_text or None, - reasoning_content=reasoning_text or None, reasoning_details=None, + message = SimpleNamespace( + content=cleaned_text, tool_calls=tool_calls, reasoning=reasoning or None, + reasoning_content=reasoning or None, reasoning_details=None, ) completion = SimpleNamespace( - choices=[SimpleNamespace(message=assistant_message, finish_reason="tool_calls" if tool_calls else "stop")], - usage=SimpleNamespace( - prompt_tokens=0, completion_tokens=0, total_tokens=0, prompt_tokens_details=SimpleNamespace(cached_tokens=0) - ), + choices=[SimpleNamespace(message=message, finish_reason="tool_calls" if tool_calls else "stop")], + usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0, prompt_tokens_details=SimpleNamespace(cached_tokens=0)), model=model or "copilot-acp", ) return _completion_to_stream_chunks(completion) if stream else completion @@ -424,12 +406,10 @@ class CopilotACPClient: threading.Thread(target=_stdout_reader, daemon=True).start() threading.Thread(target=_stderr_reader, daemon=True).start() - next_id = 0 + request_ids = iter(range(1, 1 << 62)) def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any: - nonlocal next_id - next_id += 1 - request_id = next_id + request_id = next(request_ids) proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + "\n") proc.stdin.flush() deadline = time.monotonic() + timeout_seconds @@ -494,13 +474,13 @@ class CopilotACPClient: return False if method == "session/update": update = (msg.get("params") or {}).get("update") or {} - kind = str(update.get("sessionUpdate") or "").strip() content = update.get("content") or {} chunk_text = str(content.get("text") or "") if isinstance(content, dict) else "" - if kind == "agent_message_chunk" and chunk_text and text_parts is not None: - text_parts.append(chunk_text) - elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None: - reasoning_parts.append(chunk_text) + sink = {"agent_message_chunk": text_parts, "agent_thought_chunk": reasoning_parts}.get( + str(update.get("sessionUpdate") or "").strip() + ) + if chunk_text and sink is not None: + sink.append(chunk_text) return True if process.stdin is None: return True diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index ba9b571635..6c1880428a 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -1,14 +1,8 @@ -"""Credits tracking for Nous inference API responses: parses ``x-nous-credits-*`` -(and optional ``x-nous-tool-pool-*``) headers into a validated CreditsState, with -depletion detection (paid_access), subscription-cap used_fraction, and warn-once -schema-version gating. Header schema (each ``*-micros`` balance has a ``*-usd`` -twin holding the server's formatted USD string): version, remaining, subscription -(SIGNED; may be debt), subscription-limit (PAIRED/optional), rollover, purchased, -denominator-kind ("subscription_cap" | "none"), paid-access ("true"|"false" STRING), -disabled-reason (omitted when null), as-of-ms. Tool-pool headers: x-nous-tool-pool-micros, -x-nous-tool-pool-gated-off ("true"|"false" STRING). Money is micros ints only; -``*_usd`` strings are preserved verbatim (never re-parsed to float). -""" +"""Nous credits: parse ``x-nous-credits-*`` / ``x-nous-tool-pool-*`` response +headers into a validated CreditsState (depletion = paid_access, subscription-cap +used_fraction, warn-once schema-version gating) and drive the notice policy. +Header contract: see ``_HEADER_FIELDS``. Money is micros ints only; ``*_usd`` +strings are preserved verbatim (never re-parsed to float).""" from __future__ import annotations @@ -23,11 +17,9 @@ from utils import is_truthy_value logger = logging.getLogger(__name__) -# Warn-once latch: emit the version-unsupported warning at most once per process. -_version_warning_emitted: bool = False +_version_warning_emitted: bool = False # warn-once latch (per process) _VALID_DENOMINATOR_KINDS = frozenset({"subscription_cap", "none"}) -# USD format: optional leading minus, one-or-more digits, dot, exactly 2 digits. -_USD_RE = re.compile(r"^-?\d+\.\d{2}$") +_USD_RE = re.compile(r"^-?\d+\.\d{2}$") # optional minus, digits, exactly 2 decimals _SENTINEL = object() # "parse failed" @@ -48,12 +40,12 @@ def _validate_usd(value: Optional[str]) -> bool: @dataclass class CreditsState: - """Full credits state parsed from x-nous-credits-* response headers.""" + """Credits state parsed from x-nous-credits-* response headers.""" version: int = 0 remaining_micros: int = 0 remaining_usd: str = "" - subscription_micros: int = 0 # SIGNED — may be negative (debt). ONLY field allowed negative. + subscription_micros: int = 0 # SIGNED — the ONLY field allowed negative (debt) subscription_usd: str = "" subscription_limit_micros: Optional[int] = None # PAIRED + OPTIONAL (only when subscription_cap) subscription_limit_usd: Optional[str] = None @@ -66,7 +58,7 @@ class CreditsState: paid_access: bool = True # depletion keys off THIS == False, NEVER remaining==0 disabled_reason: Optional[str] = None # header omitted entirely when null as_of_ms: int = 0 - captured_at: float = 0.0 # time.time() when this was captured + captured_at: float = 0.0 # time.time() when captured from_header: bool = False # True only when populated by parse_credits_headers() @property @@ -98,17 +90,14 @@ class CreditsState: # paired *_TTL_MS per notice kind (AgentNotice has the field; not plumbed yet). CREDITS_NOTICE_KIND = "sticky" # v1: credits notices are sticky CREDITS_RESTORED_TTL_MS = 8000 # the only TTL notice in v1 (depletion-recovery confirmation) - # Usage-gauge bands (ascending): (threshold_fraction, level, label_pct). One -# escalating line showing the HIGHEST band reached (50 → 75 → 90); crossing up -# replaces it, recovering steps it down. +# escalating line shows the HIGHEST band reached; climbing replaces it, recovery steps down. CREDITS_USAGE_BANDS: tuple[tuple[float, str, int], ...] = ((0.50, "info", 50), (0.75, "warn", 75), (0.90, "warn", 90)) -CREDITS_USAGE_KEY = "credits.usage" # single key for the escalating usage notice - +CREDITS_USAGE_KEY = "credits.usage" # Min subscription balance counting as "grant not yet spent" for the grant_spent -# gate. 1¢: portal-seeded states (float dollars → micros) can carry sub-cent -# residue where headers report 0 — without the floor such a seed opens the gate -# and the first header re-creates the at-open nag. +# gate. 1¢: portal-seeded states (float dollars → micros) can carry sub-cent residue +# where headers report 0 — without the floor a seed opens the gate and the first +# header re-creates the at-open nag. GRANT_UNSPENT_MIN_MICROS = 10_000 @@ -120,10 +109,9 @@ def new_credits_latch() -> dict: @dataclass class AgentNotice: - """Driver-agnostic out-of-band notice, fired via ``AIAgent.notice_callback`` - (cleared via ``notice_clear_callback``); each driver renders its own way. - ``kind``/``ttl_ms`` stay expressive so a future config can switch v1's - sticky credits notices to TTL without touching the policy.""" + """Driver-agnostic out-of-band notice (``AIAgent.notice_callback`` / ``notice_clear_callback``); + each driver renders its own way. ``kind``/``ttl_ms`` stay expressive so a future + config can switch v1's sticky credits notices to TTL without touching the policy.""" text: str level: str = "info" # info | warn | error | success @@ -138,17 +126,13 @@ def _sticky_notice(text: str, level: str, key: str) -> AgentNotice: def is_free_tier_model(model: str, base_url: str = "") -> bool: - """True when *model* is a Nous free-tier model, using ONLY local data. - - Zero-network signals: (1) ``:free`` suffix — canonical Nous free SKU marker; - (2) ``stealth/`` prefix — stealth-preview SKUs are free without the suffix - (naming-convention trust: a PAID ``stealth/`` model would wrongly suppress - the banner); (3) a PEEK into ``hermes_cli.models``' pricing cache (filled by - the model picker; a miss never fetches — gateway sessions never run the picker). - - Fail-open to False (depleted notice still shows): a wrong warning is - recoverable noise; hiding it on a paid model masks a real block. - """ + """True when *model* is a Nous free-tier model, using ONLY local data: + (1) ``:free`` suffix — canonical Nous free SKU marker; (2) ``stealth/`` prefix — + stealth-preview SKUs are free without the suffix (naming-convention trust: a PAID + ``stealth/`` model would wrongly suppress the banner); (3) a PEEK into + ``hermes_cli.models``' pricing cache (filled by the model picker; a miss never + fetches). Fail-open to False (depleted notice still shows): a wrong warning is + recoverable noise; hiding it on a paid model masks a real block.""" if not model: return False if model.endswith(":free") or model.startswith("stealth/"): @@ -158,8 +142,7 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool: try: from hermes_cli.models import _is_model_free, peek_cached_pricing - # peek_cached_pricing owns the /v1-suffix and auth-state key details. - pricing = peek_cached_pricing(base_url) + pricing = peek_cached_pricing(base_url) # owns the /v1-suffix and auth-state key details return bool(pricing) and _is_model_free(model, pricing) except Exception: return False @@ -168,16 +151,12 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool: def evaluate_credits_notices( state: CreditsState, latch: dict, *, model_is_free: bool = False, ) -> tuple[list[AgentNotice], list[str]]: - """Reconcile credits notices against the latch (see :func:`new_credits_latch`). - Mutates ``latch`` IN PLACE. Pure — no I/O, no agent/run_agent imports. - - ``model_is_free`` (see :func:`is_free_tier_model`) suppresses - ``credits.depleted`` — a depleted account on a free model keeps inferencing, - so the banner is noise. Suppression does NOT emit "restored"; that fires - only on a genuine ``paid_access`` flip back to True. - - Returns ``(to_show, to_clear)``; caller emits to_clear FIRST, then to_show. - """ + """Reconcile credits notices against the latch (see :func:`new_credits_latch`); + mutates ``latch`` IN PLACE. Pure — no I/O, no agent/run_agent imports. + ``model_is_free`` suppresses ``credits.depleted`` (a depleted account on a free + model keeps inferencing) WITHOUT emitting "restored" — that fires only on a + genuine ``paid_access`` flip back to True. + Returns ``(to_show, to_clear)``; caller emits to_clear FIRST, then to_show.""" to_show: list[AgentNotice] = [] to_clear: list[str] = [] uf = state.used_fraction @@ -194,12 +173,11 @@ def evaluate_credits_notices( if uf is not None and uf < 1.0 and state.subscription_micros >= GRANT_UNSPENT_MIN_MICROS: latch["seen_grant_unspent"] = True - # Highest band reached (ascending → last match wins); None below all. - # Top-up suppression: with purchased credits the cap gauge is the wrong - # denominator ("90% used" on $50 of top-up is noise; it used to stick - # PERMANENTLY beside grant_spent at >=100%). grant_spent covers the - # cap-reached case; a mid-session top-up flips current_band → None and the - # clear path removes the band line. + # Highest band reached (ascending → last match wins); None below all. Top-up + # suppression: with purchased credits the cap gauge is the wrong denominator + # ("90% used" on $50 of top-up is noise; it used to stick PERMANENTLY beside + # grant_spent at >=100%) — grant_spent covers the cap-reached case, and a + # mid-session top-up flips current_band → None so the clear path removes the line. current_band: Optional[tuple[float, str, int]] = None if uf is not None and state.purchased_micros <= 0: for band in CREDITS_USAGE_BANDS: @@ -215,8 +193,8 @@ def evaluate_credits_notices( active.discard(CREDITS_USAGE_KEY) if target_band is not None: # Absolute dollars used (a bare "N%" is only meaningful against a Nous - # cap): cap − remaining in micros, clamped [0, cap]; "$?" if a producer - # set the limit without its *_usd. Re-emits on band change only. + # cap): cap − remaining, clamped [0, cap]; "$?" if a producer set the + # limit without its *_usd. Re-emits on band change only. level = current_band[1] # type: ignore[index] (current_band set when target_band set) lim = state.subscription_limit_micros or 0 used_usd = f"{max(0, min(lim, lim - state.subscription_micros)) / 1_000_000:.2f}" if lim else "?" @@ -251,9 +229,7 @@ def evaluate_credits_notices( elif "credits.depleted" in active and not show_depleted: to_clear.append("credits.depleted") active.discard("credits.depleted") - if not depleted_cond: - # Genuine recovery only — switching to a free model while still - # depleted must NOT claim access was restored. + if not depleted_cond: # genuine recovery only — a free-model switch while depleted is NOT "restored" to_show.append(AgentNotice( text="✓ Credit access restored", level="success", kind="ttl", ttl_ms=CREDITS_RESTORED_TTL_MS, key="credits.restored", id="credits.restored", @@ -261,9 +237,11 @@ def evaluate_credits_notices( return (to_show, to_clear) -# Header field table: (field, header, kind). micros: required, non-negative unless -# "signed" (only subscription may be negative). usd: required ^-?\d+\.\d{2}$. -# bool: optional "true"/"false" STRING flag; the tuple's 4th item is the default. +# Header contract: (field, header, kind[, default]). Each *-micros balance has a *-usd +# twin holding the server's formatted USD string. micros: required int >= 0 ("signed": +# may be negative). usd: required ^-?\d+\.\d{2}$. bool: optional "true"/"false" STRING. +# Not in the table: subscription-limit-* (PAIRED/optional), tool-pool-micros (optional), +# denominator-kind ("subscription_cap" | "none"), disabled-reason (omitted when null). _HEADER_FIELDS: tuple[tuple, ...] = ( ("remaining_micros", "x-nous-credits-remaining-micros", "micros"), ("subscription_micros", "x-nous-credits-subscription-micros", "signed"), @@ -293,17 +271,10 @@ def _parse_field(kind: str, raw: Optional[str], default: Any = None) -> Any: def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Optional[CreditsState]: """Parse x-nous-credits-* (and x-nous-tool-pool-*) headers into a CreditsState. - - Returns None (miss) on ANY of: no ``x-nous-credits-version`` header; version - != 1 (> 1 also warns once); a ``*_micros``/``as_of_ms`` field non-integer or - negative (subscription excepted); a ``*_usd`` not matching - ``^-?\\d+\\.\\d{2}$``; ``denominator_kind`` outside {"subscription_cap", - "none"}; ``paid_access``/``tool_pool_gated_off`` not exactly "true"/"false"; - any unexpected exception. - - Fail-open on the subscription_limit pair: a half-pair (only -micros or only - -usd) is treated as both-absent — the parse STILL SUCCEEDS with both None. - """ + None (miss) on ANY of: no version header; version != 1 (> 1 also warns once); + a required field violating ``_HEADER_FIELDS``; unknown ``denominator_kind``; + any unexpected exception. Fail-open on the subscription_limit pair: a + half-pair (only -micros or only -usd) parses as both-absent (both None).""" global _version_warning_emitted try: # Cheap probe before the lowercase copy (header names are case-insensitive): @@ -327,12 +298,9 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt fields[name] = val # tool_pool_micros is OPTIONAL: absent → 0; present-but-invalid → miss. tp_raw = lowered.get("x-nous-tool-pool-micros") - tp_val = 0 if tp_raw is None else _parse_field("micros", tp_raw) - if tp_val is _SENTINEL: + fields["tool_pool_micros"] = 0 if tp_raw is None else _parse_field("micros", tp_raw) + if fields["tool_pool_micros"] is _SENTINEL: return None - fields["tool_pool_micros"] = tp_val - # subscription_limit_* PAIRED + OPTIONAL: both present → validate both - # (any invalid → miss); half-pair or both absent → both None, parse continues. lim_micros_raw = lowered.get("x-nous-credits-subscription-limit-micros") lim_usd_raw = lowered.get("x-nous-credits-subscription-limit-usd") if lim_micros_raw is not None and lim_usd_raw is not None: @@ -344,16 +312,12 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt if denominator_kind not in _VALID_DENOMINATOR_KINDS: return None return CreditsState( - version=version_val, - denominator_kind=denominator_kind, + version=version_val, denominator_kind=denominator_kind, disabled_reason=lowered.get("x-nous-credits-disabled-reason"), # None if absent (omitted when null) - captured_at=time.time(), - from_header=True, - **fields, + captured_at=time.time(), from_header=True, **fields, ) except Exception: - # Fail-open → miss; breadcrumb distinguishes a parser regression from a - # legitimate no-headers response. + # Fail-open → miss; the breadcrumb distinguishes a parser regression from a no-headers response. logger.debug("credits ▸ parse_credits_headers raised (fail-open miss)", exc_info=True) return None @@ -394,10 +358,9 @@ _DEV_FIXTURES: dict[str, dict] = { def dev_fixture_credits_state() -> Optional[CreditsState]: - """Fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None (unknown name / - "clear" / "none" / unset). Hard prod-leak guard: applies ONLY when - HERMES_DEV_CREDITS is also on, so a stray fixture env var can never surface - fabricated balances on a real account.""" + """Fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None (unknown name / unset). + Prod-leak guard: applies ONLY when HERMES_DEV_CREDITS is also on, so a stray + fixture env var can never surface fabricated balances on a real account.""" if not is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")): return None name = os.environ.get("HERMES_DEV_CREDITS_FIXTURE", "").strip() @@ -427,9 +390,7 @@ def _credits_state_from_account(info) -> Optional[CreditsState]: sub = getattr(info, "subscription", None) def _money(dollars) -> tuple[int, str]: # (micros, display usd); (0, "") when absent - if isinstance(dollars, (int, float)): - return int(round(dollars * 1_000_000)), f"{dollars:.2f}" - return 0, "" + return (int(round(dollars * 1_000_000)), f"{dollars:.2f}") if isinstance(dollars, (int, float)) else (0, "") remaining = _money(getattr(acc, "total_usable_credits", None)) sub_rem = _money(getattr(acc, "subscription_credits_remaining", None)) purchased = _money(getattr(acc, "purchased_credits_remaining", None)) @@ -468,12 +429,9 @@ def _hydrate_seed_state(agent, state) -> None: def seed_credits_at_session_start(agent) -> bool: """Hydrate agent._credits_state from the portal account (or a dev fixture) and - fire the notice policy so warnings show at session OPEN. Shared by the - TUI/desktop build ("ready") and first-turn setup (plain-CLI fallback). - Idempotent once a seed or real header populated _credits_state. - - Returns True iff it seeded this call. Never raises — credits must never block startup. - """ + fire the notice policy so warnings show at session OPEN (TUI/desktop "ready" and + plain-CLI first-turn setup). Idempotent once a seed or real header populated + _credits_state. Returns True iff it seeded this call. Never raises.""" try: if getattr(agent, "provider", "") != "nous" or getattr(agent, "_credits_state", None) is not None: return False @@ -481,9 +439,7 @@ def seed_credits_at_session_start(agent) -> bool: fixture = dev_fixture_credits_state() except Exception: fixture = None - if fixture is not None: - # Synchronous: a fixture is instant, and tests rely on the state + - # notice landing before this returns. + if fixture is not None: # synchronous: instant, and tests rely on state + notice landing before return _hydrate_seed_state(agent, fixture) return True diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 51db2062a4..02bec9115a 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -272,10 +272,9 @@ def _translate_tool_result_to_gemini( if include_ids and tool_call_id: function_response["id"] = tool_call_id # Gemini 3.x accepts images inside functionResponse.parts; 2.x rejects the field. - if is_gemini3: - image_parts = [p for p in _extract_multimodal_parts(raw_content) if "inlineData" in p] - if image_parts: - function_response["parts"] = image_parts + image_parts = [p for p in _extract_multimodal_parts(raw_content) if "inlineData" in p] if is_gemini3 else [] + if image_parts: + function_response["parts"] = image_parts return {"functionResponse": function_response} @@ -308,10 +307,9 @@ def _build_gemini_contents( for tool_call in tool_calls if isinstance(tool_calls, list) else []: if not isinstance(tool_call, dict): continue - tool_call_id = _tool_call_id(tool_call) tool_name = str((tool_call.get("function") or {}).get("name") or "") - if tool_call_id and tool_name: - tool_name_by_call_id[tool_call_id] = tool_name + if _tool_call_id(tool_call) and tool_name: + tool_name_by_call_id[_tool_call_id(tool_call)] = tool_name parts.append(_translate_tool_call_to_gemini(tool_call, include_ids=include_tool_call_ids)) if parts: contents.append({"role": "model" if role == "assistant" else "user", "parts": parts}) @@ -485,12 +483,10 @@ def _envelope(model: str, object_: str, choice: SimpleNamespace, usage: Any, cls ) -def _tool_call_ns(fc: Dict[str, Any], part: Dict[str, Any], index: int) -> SimpleNamespace: - tool_call = SimpleNamespace( - id=_new_call_id(fc), type="function", index=index, - function=SimpleNamespace(name=str(fc["name"]), arguments=_dump_call_args(fc)), - ) - if extra_content := _tool_call_extra_from_part(part): +def _tool_call_ns(name: str, arguments: str, index: int, call_id: str, extra_content: Any) -> SimpleNamespace: + """OpenAI-shaped tool call; ``extra_content`` attached only when it is a dict.""" + tool_call = SimpleNamespace(id=call_id, type="function", index=index, function=SimpleNamespace(name=name, arguments=arguments)) + if isinstance(extra_content, dict): tool_call.extra_content = extra_content return tool_call @@ -513,7 +509,7 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa elif isinstance(part.get("text"), str): text_pieces.append(part["text"]) elif isinstance(fc := part.get("functionCall"), dict) and fc.get("name"): - tool_calls.append(_tool_call_ns(fc, part, index)) + tool_calls.append(_tool_call_ns(str(fc["name"]), _dump_call_args(fc), index, _new_call_id(fc), _tool_call_extra_from_part(part))) if cand is None: finish_reason, usage = "stop", _usage_from_metadata({}) @@ -538,13 +534,8 @@ def _make_stream_chunk( ) -> _GeminiStreamChunk: tool_calls = None if tool_call_delta is not None: - tool_delta = SimpleNamespace( - index=tool_call_delta.get("index", 0), id=_new_call_id(tool_call_delta), type="function", - function=SimpleNamespace(name=tool_call_delta.get("name") or "", arguments=tool_call_delta.get("arguments") or ""), - ) - if isinstance(tool_call_delta.get("extra_content"), dict): - tool_delta.extra_content = tool_call_delta["extra_content"] - tool_calls = [tool_delta] + d = tool_call_delta + tool_calls = [_tool_call_ns(d.get("name") or "", d.get("arguments") or "", d.get("index", 0), _new_call_id(d), d.get("extra_content"))] delta = SimpleNamespace( role="assistant", content=content or None, tool_calls=tool_calls, reasoning=reasoning or None, reasoning_content=reasoning or None, @@ -556,9 +547,7 @@ def _make_stream_chunk( def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]: buffer = "" for chunk in response.iter_text(): - if not chunk: - continue - buffer += chunk + buffer += chunk or "" while "\n" in buffer: line, buffer = buffer.split("\n", 1) line = line.rstrip("\r") @@ -604,11 +593,11 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: slot = tool_call_indices[call_key] = {"index": len(tool_call_indices), "id": _new_call_id(fc), "last_arguments": ""} # Gemini re-sends the full args each event; emit only the new suffix. last_arguments = str(slot.get("last_arguments") or "") - emitted = args_str[len(last_arguments):] if args_str.startswith(last_arguments) else args_str slot["last_arguments"] = args_str chunks.append(_make_stream_chunk(model=model, tool_call_delta={ "index": slot["index"], "id": slot["id"], "name": name, - "arguments": emitted, "extra_content": _tool_call_extra_from_part(part), + "arguments": args_str[len(last_arguments):] if args_str.startswith(last_arguments) else args_str, + "extra_content": _tool_call_extra_from_part(part), })) finish_reason_raw = str(cand.get("finishReason") or "") @@ -628,12 +617,11 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No status = response.status_code body_text = (_response_text(response) if body_text is None else body_text) or "" err_obj: Any = None - if body_text: - try: - parsed = json.loads(body_text) - err_obj = parsed.get("error") if isinstance(parsed, dict) else None - except (ValueError, TypeError): - pass + try: + parsed = json.loads(body_text) if body_text else None + err_obj = parsed.get("error") if isinstance(parsed, dict) else None + except (ValueError, TypeError): + pass if not isinstance(err_obj, dict): err_obj = {} err_status = str(err_obj.get("status") or "").strip() @@ -645,10 +633,8 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No for detail in details_list if isinstance(details_list, list) else []: if isinstance(detail, dict) and not reason and str(detail.get("@type") or "").endswith("/google.rpc.ErrorInfo"): reason_value, md = detail.get("reason"), detail.get("metadata") - if isinstance(reason_value, str): - reason = reason_value - if isinstance(md, dict): - metadata = md + reason = reason_value if isinstance(reason_value, str) else reason + metadata = md if isinstance(md, dict) else metadata retry_after: Optional[float] = None try: @@ -744,8 +730,9 @@ class GeminiNativeClient: if stream: return self._stream_completion(model=model, request=request, timeout=timeout) - url = f"{self.base_url}/models/{model}:generateContent" - response = self._http.post(url, json=request, headers=self._headers(), timeout=timeout) + response = self._http.post( + f"{self.base_url}/models/{model}:generateContent", json=request, headers=self._headers(), timeout=timeout + ) if response.status_code != 200: raise gemini_http_error(response) try: @@ -759,9 +746,9 @@ class GeminiNativeClient: def _stream_completion(self, *, model: str, request: Dict[str, Any], timeout: Any = None) -> Iterator[_GeminiStreamChunk]: url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse" - stream_headers = {**self._headers(), "Accept": "text/event-stream"} + headers = {**self._headers(), "Accept": "text/event-stream"} try: - with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response: + with self._http.stream("POST", url, json=request, headers=headers, timeout=timeout) as response: if response.status_code != 200: raise gemini_http_error(response, body_text=read_streaming_error_body(response)) tool_call_indices: Dict[str, Dict[str, Any]] = {} @@ -776,8 +763,7 @@ class AsyncGeminiNativeClient: def __init__(self, sync_client: GeminiNativeClient): self._sync = sync_client - self.api_key = sync_client.api_key - self.base_url = sync_client.base_url + self.api_key, self.base_url = sync_client.api_key, sync_client.base_url self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion)) # The auxiliary cache evicts entries by leaf client; GeminiNativeClient # is itself the leaf (no OpenAI client beneath it). From ef474523f6fd2836ced5456037aaf27fe467c87e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:52:31 -0700 Subject: [PATCH 04/30] refactor(agent/gemini,acp,credits): shared part classifiers, folded literals, table-driven account seed --- agent/copilot_acp_client.py | 36 ++++------ agent/credits_tracker.py | 11 ++-- agent/gemini_native_adapter.py | 116 +++++++++++++++------------------ 3 files changed, 70 insertions(+), 93 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 85059964dc..d511350379 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -58,18 +58,13 @@ _INITIALIZE_PARAMS = { "clientInfo": {"name": "hermes-agent", "title": "Hermes Agent", "version": "0.0.0"}, } _DEPRECATED_CLI_ERROR = ( - "Hermes ACP mode requires the NEW GitHub Copilot CLI " - "(github.com/github/copilot-cli), but the binary it just " + "Hermes ACP mode requires the NEW GitHub Copilot CLI (github.com/github/copilot-cli), but the binary it just " "spawned is the deprecated `gh copilot` extension.\n\n" - "Install the new CLI:\n" - " npm install -g @github/copilot\n" - " # then verify with: copilot --help\n\n" - "If `copilot` already resolves to the new CLI but you still see this,\n" - "point Hermes at it explicitly:\n" + "Install the new CLI:\n npm install -g @github/copilot\n # then verify with: copilot --help\n\n" + "If `copilot` already resolves to the new CLI but you still see this,\npoint Hermes at it explicitly:\n" " export HERMES_COPILOT_ACP_COMMAND=/path/to/new/copilot\n\n" - "Alternative: use the `copilot` provider (no ACP, hits the Copilot API\n" - "directly with a Copilot subscription token) via `hermes setup`.\n\n" - "Original error:\n" + "Alternative: use the `copilot` provider (no ACP, hits the Copilot API\ndirectly with a Copilot subscription " + "token) via `hermes setup`.\n\nOriginal error:\n" ) @@ -275,10 +270,7 @@ def _fs_write_text_file(params: dict[str, Any], cwd: str) -> Any: # Approval-gated paths (e.g. ~/.ssh/config) are only soft-gated for interactive # tools, but the ACP shim has no human channel to confirm — fail closed. if is_write_approval_required(str(path)): - raise PermissionError( - f"Write denied: '{path}' requires interactive approval " - "and cannot be written through the ACP file bridge." - ) + raise PermissionError(f"Write denied: '{path}' requires interactive approval and cannot be written through the ACP file bridge.") path.parent.mkdir(parents=True, exist_ok=True) path.write_text(str(params.get("content") or ""), encoding="utf-8") return None @@ -352,14 +344,10 @@ class CopilotACPClient: if _acp_supported(self._acp_command, self._acp_args) is False: preview = " ".join(self._acp_args[:3]) if self._acp_args else "(none)" raise RuntimeError( - f"ACP transport not supported by '{self._acp_command}': " - f"`{preview}` is rejected as an unknown option. " - f"This usually means the CLI is an older release (e.g. " - f"Claude Code v2.x) or a different tool than expected. " - f"Either install a CLI that ships with --acp support " - f"(e.g. `@github/copilot` late 2025+), or set " - f"HERMES_COPILOT_ACP_COMMAND / HERMES_COPILOT_ACP_ARGS " - f"to a working pair." + f"ACP transport not supported by '{self._acp_command}': `{preview}` is rejected as an unknown option. " + "This usually means the CLI is an older release (e.g. Claude Code v2.x) or a different tool than expected. " + "Either install a CLI that ships with --acp support (e.g. `@github/copilot` late 2025+), or set " + "HERMES_COPILOT_ACP_COMMAND / HERMES_COPILOT_ACP_ARGS to a working pair." ) try: # Hide the console the child would flash on Windows; stdio pipes stay intact. @@ -373,8 +361,8 @@ class CopilotACPClient: ) except FileNotFoundError as exc: raise RuntimeError( - f"Could not start Copilot ACP command '{self._acp_command}'. " - "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH." + f"Could not start Copilot ACP command '{self._acp_command}'. Install GitHub Copilot CLI or set " + "HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH." ) from exc if proc.stdin is None or proc.stdout is None: proc.kill() diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 6c1880428a..a5b6905ad8 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -391,16 +391,15 @@ def _credits_state_from_account(info) -> Optional[CreditsState]: def _money(dollars) -> tuple[int, str]: # (micros, display usd); (0, "") when absent return (int(round(dollars * 1_000_000)), f"{dollars:.2f}") if isinstance(dollars, (int, float)) else (0, "") - remaining = _money(getattr(acc, "total_usable_credits", None)) - sub_rem = _money(getattr(acc, "subscription_credits_remaining", None)) - purchased = _money(getattr(acc, "purchased_credits_remaining", None)) + fields: dict[str, Any] = {} + for prefix, obj, attr in (("remaining", acc, "total_usable_credits"), ("subscription", acc, "subscription_credits_remaining"), + ("purchased", acc, "purchased_credits_remaining")): + fields[f"{prefix}_micros"], fields[f"{prefix}_usd"] = _money(getattr(obj, attr, None)) monthly = getattr(sub, "monthly_credits", None) cap = _money(monthly) if isinstance(monthly, (int, float)) and monthly > 0 else (None, None) paid = getattr(info, "paid_service_access", None) return CreditsState( - remaining_micros=remaining[0], remaining_usd=remaining[1], subscription_micros=sub_rem[0], - subscription_usd=sub_rem[1], subscription_limit_micros=cap[0], subscription_limit_usd=cap[1], - purchased_micros=purchased[0], purchased_usd=purchased[1], + **fields, subscription_limit_micros=cap[0], subscription_limit_usd=cap[1], rollover_micros=_money(getattr(sub, "rollover_credits", None))[0], denominator_kind="subscription_cap" if cap[0] is not None else "none", paid_access=paid if isinstance(paid, bool) else True, from_header=False, captured_at=time.time(), diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 02bec9115a..5d3c6fd4ed 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -32,6 +32,7 @@ try: _HERMES_VERSION = str(_hermes_cli.__version__) except Exception: _HERMES_VERSION = "0.0.0" +_API_CLIENT = f"hermes-agent/{_HERMES_VERSION}" # client context per Gemini's partner-integration guidance DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" @@ -108,7 +109,7 @@ def probe_gemini_tier( with httpx.Client(timeout=timeout) as client: resp = client.post( f"{base}/models/{model}:generateContent", params={"key": key}, json=payload, - headers={"Content-Type": "application/json", "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}"}, + headers={"Content-Type": "application/json", "X-Goog-Api-Client": _API_CLIENT}, ) except Exception as exc: logger.debug("probe_gemini_tier: network error: %s", exc) @@ -164,19 +165,15 @@ class GeminiAPIError(Exception): def _text_of(item: Any) -> Optional[str]: """Text of an OpenAI content item (plain str or ``{"type": "text"}`` dict), else None.""" - if isinstance(item, str): - return item if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str): return item["text"] - return None + return item if isinstance(item, str) else None def _coerce_content_to_text(content: Any) -> str: - if content is None: - return "" if isinstance(content, list): return "\n".join(t for t in map(_text_of, content) if t is not None) - return content if isinstance(content, str) else str(content) + return "" if content is None else str(content) def _inline_data_part(url: Any) -> Optional[Dict[str, Any]]: @@ -186,10 +183,10 @@ def _inline_data_part(url: Any) -> Optional[Dict[str, Any]]: try: header, encoded = url.split(",", 1) mime = header.split(":", 1)[1].split(";", 1)[0] - raw = base64.b64decode(encoded) + data = base64.b64encode(base64.b64decode(encoded)).decode("ascii") except Exception: return None - return {"inlineData": {"mimeType": mime, "data": base64.b64encode(raw).decode("ascii")}} + return {"inlineData": {"mimeType": mime, "data": data}} def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: @@ -202,8 +199,7 @@ def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: if text or isinstance(item, str): parts.append({"text": text}) elif isinstance(item, dict) and item.get("type") == "image_url": - image = _inline_data_part((item.get("image_url") or {}).get("url") or "") - if image: + if image := _inline_data_part((item.get("image_url") or {}).get("url") or ""): parts.append(image) return parts @@ -228,9 +224,7 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any], include_ids: bool args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {} except json.JSONDecodeError: args = {"_raw": args_raw} - if not isinstance(args, dict): - args = {"_value": args} - call: Dict[str, Any] = {"name": str(fn.get("name") or ""), "args": args} + call: Dict[str, Any] = {"name": str(fn.get("name") or ""), "args": args if isinstance(args, dict) else {"_value": args}} if include_ids and _tool_call_id(tool_call): call["id"] = _tool_call_id(tool_call) return {"functionCall": call, "thoughtSignature": _tool_call_extra_signature(tool_call) or _SKIP_SIGNATURE} @@ -267,13 +261,12 @@ def _translate_tool_result_to_gemini( parsed = json.loads(content) if content.strip().startswith(("{", "[")) else None except json.JSONDecodeError: parsed = None - response = parsed if isinstance(parsed, dict) and not _looks_like_json_schema(parsed) else {"output": content} - function_response: Dict[str, Any] = {"name": name, "response": response} + structured = isinstance(parsed, dict) and not _looks_like_json_schema(parsed) + function_response: Dict[str, Any] = {"name": name, "response": parsed if structured else {"output": content}} if include_ids and tool_call_id: function_response["id"] = tool_call_id # Gemini 3.x accepts images inside functionResponse.parts; 2.x rejects the field. - image_parts = [p for p in _extract_multimodal_parts(raw_content) if "inlineData" in p] if is_gemini3 else [] - if image_parts: + if image_parts := [p for p in _extract_multimodal_parts(raw_content) if "inlineData" in p] if is_gemini3 else []: function_response["parts"] = image_parts return {"functionResponse": function_response} @@ -467,11 +460,10 @@ def _dump_call_args(fc: Dict[str, Any], **kwargs: Any) -> str: def _usage_from_metadata(usage_meta: Dict[str, Any]) -> SimpleNamespace: + count = lambda key: int(usage_meta.get(key) or 0) # noqa: E731 return SimpleNamespace( - prompt_tokens=int(usage_meta.get("promptTokenCount") or 0), - completion_tokens=int(usage_meta.get("candidatesTokenCount") or 0), - total_tokens=int(usage_meta.get("totalTokenCount") or 0), - prompt_tokens_details=SimpleNamespace(cached_tokens=int(usage_meta.get("cachedContentTokenCount") or 0)), + prompt_tokens=count("promptTokenCount"), completion_tokens=count("candidatesTokenCount"), + total_tokens=count("totalTokenCount"), prompt_tokens_details=SimpleNamespace(cached_tokens=count("cachedContentTokenCount")), ) @@ -491,6 +483,17 @@ def _tool_call_ns(name: str, arguments: str, index: int, call_id: str, extra_con return tool_call +def _part_text(part: Dict[str, Any]) -> tuple[Optional[str], bool]: + """``(text, is_thought)`` for a candidate part; ``(None, False)`` when it carries no text.""" + text = part.get("text") + return (text, part.get("thought") is True) if isinstance(text, str) else (None, False) + + +def _part_function_call(part: Dict[str, Any]) -> Optional[Dict[str, Any]]: + fc = part.get("functionCall") + return fc if isinstance(fc, dict) and fc.get("name") else None + + def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespace: candidates = resp.get("candidates") or [] cand = parts = None @@ -504,11 +507,10 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa for index, part in enumerate(parts or []): if not isinstance(part, dict): continue - if part.get("thought") is True and isinstance(part.get("text"), str): - reasoning_pieces.append(part["text"]) - elif isinstance(part.get("text"), str): - text_pieces.append(part["text"]) - elif isinstance(fc := part.get("functionCall"), dict) and fc.get("name"): + text, is_thought = _part_text(part) + if text is not None: + (reasoning_pieces if is_thought else text_pieces).append(text) + elif fc := _part_function_call(part): tool_calls.append(_tool_call_ns(str(fc["name"]), _dump_call_args(fc), index, _new_call_id(fc), _tool_call_extra_from_part(part))) if cand is None: @@ -532,10 +534,10 @@ def _make_stream_chunk( *, model: str, content: str = "", tool_call_delta: Optional[Dict[str, Any]] = None, finish_reason: Optional[str] = None, reasoning: str = "", ) -> _GeminiStreamChunk: - tool_calls = None - if tool_call_delta is not None: - d = tool_call_delta - tool_calls = [_tool_call_ns(d.get("name") or "", d.get("arguments") or "", d.get("index", 0), _new_call_id(d), d.get("extra_content"))] + d = tool_call_delta + tool_calls = None if d is None else [ + _tool_call_ns(d.get("name") or "", d.get("arguments") or "", d.get("index", 0), _new_call_id(d), d.get("extra_content")) + ] delta = SimpleNamespace( role="assistant", content=content or None, tool_calls=tool_calls, reasoning=reasoning or None, reasoning_content=reasoning or None, @@ -575,21 +577,20 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: for part_index, part in enumerate(parts): if not isinstance(part, dict): continue - if part.get("thought") is True and isinstance(part.get("text"), str): - chunks.append(_make_stream_chunk(model=model, reasoning=part["text"])) + text, is_thought = _part_text(part) + if is_thought: + chunks.append(_make_stream_chunk(model=model, reasoning=text)) continue - if isinstance(part.get("text"), str) and part["text"]: - chunks.append(_make_stream_chunk(model=model, content=part["text"])) - fc = part.get("functionCall") - if isinstance(fc, dict) and fc.get("name"): + if text: + chunks.append(_make_stream_chunk(model=model, content=text)) + if fc := _part_function_call(part): name = str(fc["name"]) args_str = _dump_call_args(fc, sort_keys=True) thought_signature = part.get("thoughtSignature") if isinstance(part.get("thoughtSignature"), str) else "" call_key = json.dumps( {"part_index": part_index, "name": name, "thought_signature": thought_signature}, sort_keys=True ) - slot = tool_call_indices.get(call_key) - if slot is None: + if (slot := tool_call_indices.get(call_key)) is None: slot = tool_call_indices[call_key] = {"index": len(tool_call_indices), "id": _new_call_id(fc), "last_arguments": ""} # Gemini re-sends the full args each event; emit only the new suffix. last_arguments = str(slot.get("last_arguments") or "") @@ -600,8 +601,7 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: "extra_content": _tool_call_extra_from_part(part), })) - finish_reason_raw = str(cand.get("finishReason") or "") - if finish_reason_raw: + if finish_reason_raw := str(cand.get("finishReason") or ""): finish_chunk = _make_stream_chunk( model=model, finish_reason="tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw) ) @@ -622,19 +622,17 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No err_obj = parsed.get("error") if isinstance(parsed, dict) else None except (ValueError, TypeError): pass - if not isinstance(err_obj, dict): - err_obj = {} + err_obj = err_obj if isinstance(err_obj, dict) else {} err_status = str(err_obj.get("status") or "").strip() err_message = str(err_obj.get("message") or "").strip() details_list = err_obj.get("details") - # First google.rpc.ErrorInfo detail supplies reason/metadata. + # First google.rpc.ErrorInfo detail supplies reason/metadata (later ones only fill gaps until reason is set). reason, metadata = "", {} for detail in details_list if isinstance(details_list, list) else []: if isinstance(detail, dict) and not reason and str(detail.get("@type") or "").endswith("/google.rpc.ErrorInfo"): - reason_value, md = detail.get("reason"), detail.get("metadata") - reason = reason_value if isinstance(reason_value, str) else reason - metadata = md if isinstance(md, dict) else metadata + reason = detail["reason"] if isinstance(detail.get("reason"), str) else reason + metadata = detail["metadata"] if isinstance(detail.get("metadata"), dict) else metadata retry_after: Optional[float] = None try: @@ -642,10 +640,10 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No except (TypeError, ValueError): pass - if err_message: - message = f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" - else: - message = f"Gemini returned HTTP {status}: {body_text[:500]}" + message = ( + f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" if err_message + else f"Gemini returned HTTP {status}: {body_text[:500]}" + ) # Users who bypassed the setup wizard (raw GOOGLE_API_KEY in .env) still # need to learn that the free tier cannot sustain an agent session. if status == 429 and is_free_tier_quota_error(err_message or body_text): @@ -685,9 +683,7 @@ class GeminiNativeClient: self._default_headers = dict(default_headers or {}) self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion)) self.is_closed = False - self._http = http_client or httpx.Client( - timeout=timeout or httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0) - ) + self._http = http_client or httpx.Client(timeout=timeout or httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0)) def close(self) -> None: self.is_closed = True @@ -698,13 +694,8 @@ class GeminiNativeClient: def _headers(self) -> Dict[str, str]: return { - "Content-Type": "application/json", - "Accept": "application/json", - "x-goog-api-key": self.api_key, - # Client context per Gemini's partner-integration guidance. - "User-Agent": f"hermes-agent/{_HERMES_VERSION} (gemini-native)", - "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}", - **self._default_headers, + "Content-Type": "application/json", "Accept": "application/json", "x-goog-api-key": self.api_key, + "User-Agent": f"{_API_CLIENT} (gemini-native)", "X-Goog-Api-Client": _API_CLIENT, **self._default_headers, } @staticmethod @@ -739,8 +730,7 @@ class GeminiNativeClient: payload = response.json() except ValueError as exc: raise GeminiAPIError( - f"Invalid JSON from Gemini native API: {exc}", code="gemini_invalid_json", - status_code=response.status_code, response=response, + f"Invalid JSON from Gemini native API: {exc}", code="gemini_invalid_json", status_code=response.status_code, response=response, ) from exc return translate_gemini_response(payload, model=model) From 607162e444f0a5c69d558993d497c6778d29e9c5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:55:25 -0700 Subject: [PATCH 05/30] refactor(agent/gemini,acp): compact WHY comments --- agent/copilot_acp_client.py | 43 ++++++++---------- agent/gemini_native_adapter.py | 81 +++++++++++++++------------------- 2 files changed, 53 insertions(+), 71 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index d511350379..f7fd22edfb 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -33,17 +33,15 @@ ACP_MARKER_BASE_URL = "acp://copilot" logger = logging.getLogger(__name__) _DEFAULT_TIMEOUT_SECONDS = 900.0 -# Stderr fingerprint of the deprecated `gh copilot` extension. Require BOTH the -# product name AND a deprecation marker: the NEW `@github/copilot` CLI (repo -# github/copilot-cli) legitimately mentions "copilot-cli" in its own banners. +# Stderr fingerprint of the deprecated `gh copilot` extension. Require BOTH the product name +# AND a deprecation marker: the NEW `@github/copilot` CLI legitimately mentions "copilot-cli". _DEPRECATION_REQUIRED = ("gh-copilot",) _DEPRECATION_MARKERS = ("has been deprecated", "no commands will be executed") _ROLE_LABELS = {"system": "System", "user": "User", "assistant": "Assistant", "tool": "Tool", "context": "Context"} -# Probe verdicts per binary path so the ~50ms --help cost is paid once per -# process. Only definitive True/False is cached; an inconclusive probe is not, -# so a CLI installed mid-session is picked up. +# Probe verdicts per binary path (~50ms --help paid once per process). Only definitive +# True/False is cached, so a CLI installed mid-session is picked up. _ACP_PROBE_CACHE: dict[str, bool] = {} _PROMPT_PREAMBLE = ( @@ -126,8 +124,8 @@ def _resolve_home_dir() -> str: def _build_subprocess_env() -> dict[str, str]: - # Copilot ACP drives a model and legitimately needs LLM provider credentials; - # the central helper still strips Tier-1 secrets (bot tokens, GitHub auth, infra). + # Copilot ACP drives a model and needs LLM provider credentials; the central helper still + # strips Tier-1 secrets (bot tokens, GitHub auth, infra). env = hermes_subprocess_env(inherit_credentials=True) env["HOME"] = _resolve_home_dir() from hermes_constants import apply_subprocess_home_env @@ -186,10 +184,9 @@ def _format_messages_as_prompt( messages: list[dict[str, Any]], model: str | None = None, tools: list[dict[str, Any]] | None = None, tool_choice: Any = None, ) -> str: - # Deliberately no "requested model" line: the model is applied for real via ACP - # session/set_model; a prompt-text mention makes a substituted backend model - # FALSELY self-identify as the requested one. Identity comes from the backend. - # Copilot has no tools of its own that collide with Hermes', so forward the whole toolset. + # Deliberately no "requested model" line: the model is applied for real via ACP session/set_model; + # a prompt-text mention makes a substituted backend model FALSELY self-identify as the requested + # one. Copilot has no tools of its own that collide with Hermes', so forward the whole toolset. sections: list[str] = [*_PROMPT_PREAMBLE, *_render_tool_bridge_sections(tools, tool_choice)] transcript: list[str] = [] for message in messages: @@ -267,8 +264,8 @@ def _fs_write_text_file(params: dict[str, Any], cwd: str) -> Any: path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) if denied := get_write_denied_error(str(path)): raise PermissionError(denied) - # Approval-gated paths (e.g. ~/.ssh/config) are only soft-gated for interactive - # tools, but the ACP shim has no human channel to confirm — fail closed. + # Approval-gated paths (e.g. ~/.ssh/config) are soft-gated for interactive tools; the ACP + # shim has no human channel to confirm — fail closed. if is_write_approval_required(str(path)): raise PermissionError(f"Write denied: '{path}' requires interactive approval and cannot be written through the ACP file bridge.") path.parent.mkdir(parents=True, exist_ok=True) @@ -282,9 +279,8 @@ _FS_HANDLERS = {"fs/read_text_file": _fs_read_text_file, "fs/write_text_file": _ class CopilotACPClient: """Minimal OpenAI-client-compatible facade for Copilot ACP.""" - # Declared for agent/auxiliary_client.py: this shim drives an ACP subprocess - # over stdio, so it is already a complete client (never re-dispatch it - # through a wire adapter) and is safe to use from async code as-is. + # Declared for agent/auxiliary_client.py: this shim drives an ACP subprocess over stdio, so it is + # already a complete client (never re-dispatch through a wire adapter) and async-safe as-is. HERMES_SKIP_TRANSPORT_WRAP = True HERMES_SKIP_ASYNC_WRAP = True @@ -338,9 +334,8 @@ class CopilotACPClient: return _completion_to_stream_chunks(completion) if stream else completion def _spawn(self) -> subprocess.Popen[str]: - # Fast-fail when the CLI rejects --acp: without the probe the parent waits - # the full child timeout for stdout that never arrives. ``None`` falls - # through to the spawn, which raises the established start error. + # Fast-fail when the CLI rejects --acp (else the parent waits the full child timeout for + # stdout that never arrives). ``None`` falls through to the spawn's established start error. if _acp_supported(self._acp_command, self._acp_args) is False: preview = " ".join(self._acp_args[:3]) if self._acp_args else "(none)" raise RuntimeError( @@ -373,9 +368,8 @@ class CopilotACPClient: return proc def _run_prompt(self, prompt_text: str, *, timeout_seconds: float, model: str | None = None) -> tuple[str, str]: - # The CLI's `--model` spawn flag is deliberately NOT used: `copilot --acp` - # validates it (unknown id aborts the spawn) but ignores it for the session. - # The model is applied after session/new via ACP-native model selection. + # The CLI's `--model` spawn flag is deliberately NOT used: `copilot --acp` validates it (unknown id + # aborts the spawn) but ignores it for the session; the model is applied after session/new instead. requested_model = str(model or "").strip() proc = self._spawn() inbox: queue.Queue[dict[str, Any]] = queue.Queue() @@ -428,8 +422,7 @@ class CopilotACPClient: session_id = str(session.get("sessionId") or "").strip() if not session_id: raise RuntimeError("Copilot ACP did not return a sessionId.") - # Prefer the stable ACP v1 session-config API (category="model" select - # option + session/set_config_option); session/set_model is the fallback. + # Stable ACP v1 session-config API first; session/set_model is the fallback. if requested_model and requested_model != "copilot-acp": try: selection = _model_selection_request(session, requested_model) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 5d3c6fd4ed..b565b544d8 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -36,9 +36,8 @@ _API_CLIENT = f"hermes-agent/{_HERMES_VERSION}" # client context per Gemini's p DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" -# Published max output-token ceiling shared by every current Gemini text model. -# Used when the caller passes max_tokens=None: unlike OpenAI-compat endpoints, -# Gemini's native API applies a low internal default and truncates output. +# Published max output-token ceiling shared by every current Gemini text model; used +# for max_tokens=None because the native API's low internal default truncates output. GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535 _FREE_TIER_GUIDANCE = ( @@ -55,14 +54,13 @@ _STANDARD_KEY_GUIDANCE = ( "generativelanguage.googleapis.com). Then update GEMINI_API_KEY / GOOGLE_API_KEY in ~/.hermes/.env and " "restart your session. Details: https://ai.google.dev/gemini-api/docs/api-key" ) -# Stands in for a model turn that never arrived (stream failure / interrupt / -# quota fallback) when a human user text turn directly follows a tool-result -# turn, keeping the request alternation-valid while the user's message stays a -# turn of its own (mirrors gemini-cli's placeholder repair). +# Stands in for a model turn that never arrived (stream failure / interrupt / quota +# fallback) when a human user text turn directly follows a tool-result turn, keeping +# the request alternation-valid while the user's message stays a turn of its own +# (mirrors gemini-cli's placeholder repair). _INTERRUPTED_RESPONSE_PLACEHOLDER = "[The previous response was interrupted before it completed.]" -# Cross-provider tool_calls (e.g. fallback from xAI/Anthropic) carry no Gemini -# thoughtSignature; without this sentinel Gemini 3 thinking models reject -# replayed history with 400 INVALID_ARGUMENT. +# Cross-provider tool_calls (e.g. fallback from xAI/Anthropic) carry no Gemini thoughtSignature; +# without this sentinel Gemini 3 thinking models reject replayed history with 400 INVALID_ARGUMENT. _SKIP_SIGNATURE = "skip_thought_signature_validator" _TOOL_CHOICE_MODES = {"auto": "AUTO", "required": "ANY", "none": "NONE"} _FINISH_REASON_MAP = { @@ -231,13 +229,11 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any], include_ids: bool def _looks_like_json_schema(node: Any) -> bool: - """True if a parsed value contains a JSON-Schema ``$ref`` pointer (``#/...``). - - Gemini 3 resolves ``$ref``/``$defs`` inside functionResponse.response and - rejects unknown pointers with HTTP 400, so a tool result that is itself a - JSON Schema (e.g. ``tool_describe`` output) must be forwarded as opaque - text. False positives only lose the structured shape, never the content. - """ + """True if a parsed value contains a JSON-Schema ``$ref`` pointer (``#/...``): Gemini 3 + resolves ``$ref``/``$defs`` inside functionResponse.response and rejects unknown + pointers with HTTP 400, so a tool result that is itself a JSON Schema (e.g. + ``tool_describe`` output) must be forwarded as opaque text. False positives only + lose the structured shape, never the content.""" if isinstance(node, dict): return any( (key == "$ref" and isinstance(value, str) and value.startswith("#/")) or _looks_like_json_schema(value) @@ -251,9 +247,8 @@ def _translate_tool_result_to_gemini( *, is_gemini3: bool = False, ) -> Dict[str, Any]: tool_call_id = str(message.get("tool_call_id") or "") - # functionResponse.name must echo the matching functionCall.name, so the - # call-id mapping beats the result's own name (which may be an unwrapped - # internal name, e.g. an MCP tool via `tool_call`). + # functionResponse.name must echo the matching functionCall.name, so the call-id + # mapping beats the result's own name (may be an unwrapped MCP name via `tool_call`). name = str((tool_name_by_call_id or {}).get(tool_call_id) or message.get("name") or tool_call_id or "tool") raw_content = message.get("content") content = _coerce_content_to_text(raw_content) @@ -307,15 +302,13 @@ def _build_gemini_contents( if parts: contents.append({"role": "model" if role == "assistant" else "user", "parts": parts}) - # Alternation contract for generateContent: - # 1) Adjacent same-role contents merge (else HTTP 400 "multiturn requests - # [must] alternate"). - # 2) Exception: never fuse a human user text turn into a preceding user - # content that only carries functionResponse parts (or vice versa) — - # Gemini 3 accepts the fold but reads the text as a continuation of the - # tool result and returns an empty candidate. Parallel tool results - # (functionResponse + functionResponse) still merge. - # 3) The split pair stays API-valid via an interposed placeholder model turn. + # Alternation contract for generateContent: 1) adjacent same-role contents merge + # (else HTTP 400 "multiturn requests [must] alternate"); 2) EXCEPT never fuse a + # human user text turn into a preceding user content that only carries + # functionResponse parts (or vice versa) — Gemini 3 accepts the fold but reads + # the text as a continuation of the tool result and returns an empty candidate + # (parallel functionResponse + functionResponse still merge); 3) the split pair + # stays API-valid via an interposed placeholder model turn. merged: List[Dict[str, Any]] = [] for content in contents: prev = merged[-1] if merged else None @@ -377,9 +370,9 @@ def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]: def _thinking_requests_output_headroom(thinking_config: Any) -> bool: - """True when Gemini will spend output tokens on thinking (thought tokens bill - against ``maxOutputTokens``; a global 4096/16384 cap can be consumed entirely - by high thinking, leaving ``finishReason=MAX_TOKENS`` with no answer).""" + """True when Gemini will spend output tokens on thinking: thought tokens bill against + ``maxOutputTokens``, so a global 4096/16384 cap can be consumed entirely by high + thinking, leaving ``finishReason=MAX_TOKENS`` with no answer.""" normalized = _normalize_thinking_config(thinking_config) if not normalized: return False @@ -390,9 +383,9 @@ def _thinking_requests_output_headroom(thinking_config: Any) -> bool: def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_config: Any) -> int: - """Native ``maxOutputTokens``: an omitted/invalid cap becomes the published - ceiling (Gemini truncates on its low internal default); an explicit cap is - raised to the ceiling when thinking is enabled so thoughts don't starve the answer.""" + """Native ``maxOutputTokens``: an omitted/invalid cap becomes the published ceiling + (Gemini truncates on its low internal default); an explicit cap is raised to the + ceiling when thinking is enabled so thoughts don't starve the answer.""" try: requested = int(max_tokens) except (TypeError, ValueError): @@ -605,8 +598,7 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: finish_chunk = _make_stream_chunk( model=model, finish_reason="tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw) ) - # usageMetadata rides on the finish chunk so the streaming loop records - # token counts like the non-streaming path does. + # usageMetadata rides on the finish chunk so the streaming loop records token counts. if usage_meta := event.get("usageMetadata") or {}: finish_chunk.usage = _usage_from_metadata(usage_meta) chunks.append(finish_chunk) @@ -644,12 +636,11 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" if err_message else f"Gemini returned HTTP {status}: {body_text[:500]}" ) - # Users who bypassed the setup wizard (raw GOOGLE_API_KEY in .env) still - # need to learn that the free tier cannot sustain an agent session. + # Users who bypassed the setup wizard (raw GOOGLE_API_KEY in .env) still need to learn + # the free tier cannot sustain an agent session. if status == 429 and is_free_tier_quota_error(err_message or body_text): message += _FREE_TIER_GUIDANCE - # Legacy "Standard" key rejection: Google's raw 401 misleadingly asks for - # OAuth; append the actual fix (mint a new Gemini API key in AI Studio). + # Legacy "Standard" key rejection: Google's raw 401 misleadingly asks for OAuth; append the real fix. if is_standard_key_auth_error(status, err_message or body_text, reason): message += _STANDARD_KEY_GUIDANCE return GeminiAPIError( @@ -663,9 +654,8 @@ class GeminiNativeClient: """Minimal OpenAI-SDK-compatible facade (``client.chat.completions.create(**kwargs)``) over Gemini's native REST API.""" - # Declared for agent/auxiliary_client.py: already a complete client, so it - # is never re-dispatched through a wire adapter. (No HERMES_SKIP_ASYNC_WRAP - # — the async path has a real conversion, AsyncGeminiNativeClient.) + # Declared for agent/auxiliary_client.py: already a complete client, never re-dispatched through + # a wire adapter. (No HERMES_SKIP_ASYNC_WRAP — the async path has a real conversion, AsyncGeminiNativeClient.) HERMES_SKIP_TRANSPORT_WRAP = True def __init__( @@ -755,8 +745,7 @@ class AsyncGeminiNativeClient: self._sync = sync_client self.api_key, self.base_url = sync_client.api_key, sync_client.base_url self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion)) - # The auxiliary cache evicts entries by leaf client; GeminiNativeClient - # is itself the leaf (no OpenAI client beneath it). + # The auxiliary cache evicts entries by leaf client; GeminiNativeClient is itself the leaf. self._real_client = sync_client async def _create_chat_completion(self, **kwargs: Any) -> Any: From 86ca0d967d5ef17936cbc3cc8c716e979bc4019c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:59:54 -0700 Subject: [PATCH 06/30] refactor(agent/gemini,acp): table-driven generationConfig, folded predicates --- agent/copilot_acp_client.py | 14 +++----- agent/gemini_native_adapter.py | 59 ++++++++++++---------------------- 2 files changed, 25 insertions(+), 48 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index f7fd22edfb..2038a10536 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -164,9 +164,7 @@ def _model_selection_request(session: dict[str, Any], requested_model: str) -> t if not session_id or not requested_model or requested_model == "copilot-acp": return None model_option = next( - (o for o in (session.get("configOptions") or []) - if isinstance(o, dict) and (o.get("category") == "model" or o.get("id") == "model")), - None, + (o for o in (session.get("configOptions") or []) if isinstance(o, dict) and "model" in (o.get("category"), o.get("id"))), None ) if model_option is not None: if requested_model not in _enabled_ids(model_option.get("options"), "value"): @@ -208,9 +206,7 @@ def _render_message_content(content: Any) -> str: if isinstance(content, dict): if "text" in content: return str(content.get("text") or "").strip() - if isinstance(content.get("content"), str): - return content["content"].strip() - return json.dumps(content, ensure_ascii=True) + return content["content"].strip() if isinstance(content.get("content"), str) else json.dumps(content, ensure_ascii=True) if isinstance(content, list): parts = [ item if isinstance(item, str) else item["text"].strip() @@ -225,8 +221,7 @@ def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path: candidate = Path(path_text) if not candidate.is_absolute(): raise PermissionError("ACP file-system paths must be absolute.") - resolved = candidate.resolve() - root = Path(cwd).resolve() + resolved, root = candidate.resolve(), Path(cwd).resolve() try: resolved.relative_to(root) except ValueError as exc: @@ -241,8 +236,7 @@ def _effective_timeout(timeout: Any) -> float: if isinstance(timeout, (int, float)): return float(timeout) candidates = [getattr(timeout, attr, None) for attr in ("read", "write", "connect", "pool", "timeout")] - numeric = [float(v) for v in candidates if isinstance(v, (int, float))] - return max(numeric) if numeric else _DEFAULT_TIMEOUT_SECONDS + return max((float(v) for v in candidates if isinstance(v, (int, float))), default=_DEFAULT_TIMEOUT_SECONDS) def _fs_read_text_file(params: dict[str, Any], cwd: str) -> Any: diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index b565b544d8..ddfb39904c 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -102,24 +102,22 @@ def probe_gemini_tier( base = str(base_url or DEFAULT_GEMINI_BASE_URL).strip().rstrip("/") or DEFAULT_GEMINI_BASE_URL if base.lower().endswith("/openai"): base = base[: -len("/openai")] - payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], "generationConfig": {"maxOutputTokens": 1}} try: with httpx.Client(timeout=timeout) as client: resp = client.post( - f"{base}/models/{model}:generateContent", params={"key": key}, json=payload, + f"{base}/models/{model}:generateContent", params={"key": key}, + json={"contents": [{"role": "user", "parts": [{"text": "hi"}]}], "generationConfig": {"maxOutputTokens": 1}}, headers={"Content-Type": "application/json", "X-Goog-Api-Client": _API_CLIENT}, ) except Exception as exc: logger.debug("probe_gemini_tier: network error: %s", exc) return "unknown" - rpd_header = {k.lower(): v for k, v in resp.headers.items()}.get("x-ratelimit-limit-requests-per-day") - if rpd_header: - # Free-tier daily caps top out at 1000 (flash-lite); Tier 1 starts ~1500+. - try: + try: + if rpd_header: # free-tier daily caps top out at 1000 (flash-lite); Tier 1 starts ~1500+ return "free" if int(rpd_header) <= 1000 else "paid" - except (TypeError, ValueError): - pass + except (TypeError, ValueError): + pass if resp.status_code == 429: return "free" if "free_tier" in _response_text(resp).lower() else "paid" return "paid" if 200 <= resp.status_code < 300 else "unknown" @@ -204,9 +202,7 @@ def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: def _tool_call_extra_signature(tool_call: Dict[str, Any]) -> Optional[str]: extra = tool_call.get("extra_content") or {} - if not isinstance(extra, dict): - return None - google = extra.get("google") or extra.get("thought_signature") + google = (extra.get("google") or extra.get("thought_signature")) if isinstance(extra, dict) else None sig = (google.get("thought_signature") or google.get("thoughtSignature")) if isinstance(google, dict) else google return sig if isinstance(sig, str) and sig else None @@ -343,11 +339,8 @@ def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]: def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]: if isinstance(tool_choice, str) and tool_choice in _TOOL_CHOICE_MODES: return {"functionCallingConfig": {"mode": _TOOL_CHOICE_MODES[tool_choice]}} - if isinstance(tool_choice, dict): - name = (tool_choice.get("function") or {}).get("name") - if isinstance(name, str) and name: - return {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": [name]}} - return None + name = (tool_choice.get("function") or {}).get("name") if isinstance(tool_choice, dict) else None + return {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": [name]}} if isinstance(name, str) and name else None # (camelCase key, snake_case alias, accepted types, normalizer) @@ -390,9 +383,7 @@ def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_conf requested = int(max_tokens) except (TypeError, ValueError): requested = 0 - if requested <= 0: - return GEMINI_DEFAULT_MAX_OUTPUT_TOKENS - if _thinking_requests_output_headroom(thinking_config): + if requested <= 0 or _thinking_requests_output_headroom(thinking_config): return max(requested, GEMINI_DEFAULT_MAX_OUTPUT_TOKENS) return requested @@ -405,26 +396,18 @@ def build_gemini_request( # Gemini 3+ both requires tool-call ids and accepts multimodal functionResponse parts. is_gemini3 = gemini_requires_tool_call_ids(model) contents, system_instruction = _build_gemini_contents(messages, include_tool_call_ids=is_gemini3, is_gemini3=is_gemini3) - request: Dict[str, Any] = {"contents": contents} - for key, value in ( - ("systemInstruction", system_instruction), - ("tools", _translate_tools_to_gemini(tools)), + optional = ( + ("systemInstruction", system_instruction), ("tools", _translate_tools_to_gemini(tools)), ("toolConfig", _translate_tool_choice_to_gemini(tool_choice)), - ): - if value: - request[key] = value - - generation_config: Dict[str, Any] = {} - if temperature is not None: - generation_config["temperature"] = temperature - generation_config["maxOutputTokens"] = _effective_gemini_max_output_tokens(max_tokens, thinking_config) - if top_p is not None: - generation_config["topP"] = top_p - if stop: - generation_config["stopSequences"] = stop if isinstance(stop, list) else [str(stop)] - if normalized_thinking := _normalize_thinking_config(thinking_config): - generation_config["thinkingConfig"] = normalized_thinking - request["generationConfig"] = generation_config + ) + request: Dict[str, Any] = {"contents": contents, **{k: v for k, v in optional if v}} + # Key order is part of the wire format (prompt-cache parity): temperature, maxOutputTokens, topP, stop, thinking. + generation = ( + ("temperature", temperature), ("maxOutputTokens", _effective_gemini_max_output_tokens(max_tokens, thinking_config)), + ("topP", top_p), ("stopSequences", (stop if isinstance(stop, list) else [str(stop)]) if stop else None), + ("thinkingConfig", _normalize_thinking_config(thinking_config)), + ) + request["generationConfig"] = {k: v for k, v in generation if v is not None} return request From 99ec0304ac9cc2ed54875e9bd3559d8e808c334f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:08:12 -0700 Subject: [PATCH 07/30] refactor(agent/acp,credits): shared reader pump, dict-comprehension header parse, sentinel sweep --- agent/copilot_acp_client.py | 34 ++++++++++++------------------- agent/credits_tracker.py | 37 +++++++++++----------------------- agent/gemini_native_adapter.py | 7 ++++--- 3 files changed, 29 insertions(+), 49 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 2038a10536..452a96701a 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -141,10 +141,6 @@ def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]: return {"jsonrpc": "2.0", "id": message_id, "error": {"code": code, "message": message}} -def _permission_denied(message_id: Any) -> dict[str, Any]: - return _jsonrpc_result(message_id, {"outcome": {"outcome": "cancelled"}}) - - def _enabled_ids(entries: Any, key: str) -> set[str]: """Ids of ``entries`` (dicts) whose ``_meta.copilotEnablement`` is not ``disabled``.""" return { @@ -369,19 +365,18 @@ class CopilotACPClient: inbox: queue.Queue[dict[str, Any]] = queue.Queue() stderr_tail: deque[str] = deque(maxlen=40) - def _stdout_reader() -> None: - for line in proc.stdout: - try: - inbox.put(json.loads(line)) - except Exception: - inbox.put({"raw": line.rstrip("\n")}) + def _decode(line: str) -> dict[str, Any]: + try: + return json.loads(line) + except Exception: + return {"raw": line.rstrip("\n")} - def _stderr_reader() -> None: - for line in proc.stderr or (): - stderr_tail.append(line.rstrip("\n")) + def _pump(stream, sink) -> None: + for line in stream or (): + sink(line) - threading.Thread(target=_stdout_reader, daemon=True).start() - threading.Thread(target=_stderr_reader, daemon=True).start() + threading.Thread(target=_pump, args=(proc.stdout, lambda line: inbox.put(_decode(line))), daemon=True).start() + threading.Thread(target=_pump, args=(proc.stderr, lambda line: stderr_tail.append(line.rstrip("\n"))), daemon=True).start() request_ids = iter(range(1, 1 << 62)) def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any: @@ -416,18 +411,15 @@ class CopilotACPClient: session_id = str(session.get("sessionId") or "").strip() if not session_id: raise RuntimeError("Copilot ACP did not return a sessionId.") - # Stable ACP v1 session-config API first; session/set_model is the fallback. if requested_model and requested_model != "copilot-acp": try: - selection = _model_selection_request(session, requested_model) - if selection is not None: + if (selection := _model_selection_request(session, requested_model)) is not None: _request(*selection) else: logger.warning("Copilot ACP does not offer model %r; using the session default.", requested_model) except Exception as exc: logger.warning( - "Copilot ACP model selection for %r failed; continuing with the session default: %s", - requested_model, exc, + "Copilot ACP model selection for %r failed; continuing with the session default: %s", requested_model, exc ) text_parts: list[str] = [] reasoning_parts: list[str] = [] @@ -462,7 +454,7 @@ class CopilotACPClient: message_id = msg.get("id") if method == "session/request_permission": - response = _permission_denied(message_id) + response = _jsonrpc_result(message_id, {"outcome": {"outcome": "cancelled"}}) elif method in _FS_HANDLERS: try: response = _jsonrpc_result(message_id, _FS_HANDLERS[method](msg.get("params") or {}, cwd)) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index a5b6905ad8..493ea8a9e1 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -290,26 +290,19 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt _version_warning_emitted = True logger.warning("credits header version %d unsupported, ignoring — update Hermes", version_val) return None - fields: dict[str, Any] = {} - for name, key, kind, *default in _HEADER_FIELDS: - val = _parse_field(kind, lowered.get(key), *default) - if val is _SENTINEL: - return None - fields[name] = val + fields: dict[str, Any] = { + name: _parse_field(kind, lowered.get(key), *default) for name, key, kind, *default in _HEADER_FIELDS + } # tool_pool_micros is OPTIONAL: absent → 0; present-but-invalid → miss. tp_raw = lowered.get("x-nous-tool-pool-micros") fields["tool_pool_micros"] = 0 if tp_raw is None else _parse_field("micros", tp_raw) - if fields["tool_pool_micros"] is _SENTINEL: - return None lim_micros_raw = lowered.get("x-nous-credits-subscription-limit-micros") lim_usd_raw = lowered.get("x-nous-credits-subscription-limit-usd") if lim_micros_raw is not None and lim_usd_raw is not None: - lm = _parse_field("micros", lim_micros_raw) - if lm is _SENTINEL or not _validate_usd(lim_usd_raw): - return None - fields["subscription_limit_micros"], fields["subscription_limit_usd"] = lm, lim_usd_raw + fields["subscription_limit_micros"] = _parse_field("micros", lim_micros_raw) + fields["subscription_limit_usd"] = _parse_field("usd", lim_usd_raw) denominator_kind = lowered.get("x-nous-credits-denominator-kind", "none") - if denominator_kind not in _VALID_DENOMINATOR_KINDS: + if _SENTINEL in fields.values() or denominator_kind not in _VALID_DENOMINATOR_KINDS: return None return CreditsState( version=version_val, denominator_kind=denominator_kind, @@ -361,10 +354,8 @@ def dev_fixture_credits_state() -> Optional[CreditsState]: """Fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None (unknown name / unset). Prod-leak guard: applies ONLY when HERMES_DEV_CREDITS is also on, so a stray fixture env var can never surface fabricated balances on a real account.""" - if not is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")): - return None name = os.environ.get("HERMES_DEV_CREDITS_FIXTURE", "").strip() - if not name: + if not name or not is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")): return None if os.path.sep in name or "/" in name: # looks like a path → read the name from the file try: @@ -372,12 +363,10 @@ def dev_fixture_credits_state() -> Optional[CreditsState]: name = fh.read().strip() except OSError: return None - spec = _DEV_FIXTURES.get(name.lower()) - if not spec: + if not (spec := _DEV_FIXTURES.get(name.lower())): return None - # Stamp what the REAL parser always guarantees so a fixture is field-identical - # to a parse_credits_headers() result (differential test): version 1, and a - # valid purchased_usd (a zero-top-up account still carries "0.00"). + # Stamp what the REAL parser always guarantees so a fixture is field-identical to a + # parse_credits_headers() result: version 1 and a valid purchased_usd (zero top-up = "0.00"). return CreditsState(**{"version": 1, "purchased_usd": "0.00", **spec}, from_header=True, captured_at=time.time()) @@ -418,11 +407,9 @@ def _hydrate_seed_state(agent, state) -> None: agent._credits_session_start_micros = state.remaining_micros latch = getattr(agent, "_credits_latch", None) if isinstance(latch, dict) and state.used_fraction is not None: - # Prime ONLY seen_below_90. Never prime seen_grant_unspent: a seed - # observing grant-spent is a steady state; priming revives the nag. + # Prime ONLY seen_below_90 — priming seen_grant_unspent would revive the nag (grant-spent is a steady state). latch["seen_below_90"] = True - emit = getattr(agent, "_emit_credits_notices", None) - if callable(emit): + if callable(emit := getattr(agent, "_emit_credits_notices", None)): emit() diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index ddfb39904c..0bef6d6cdd 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -194,9 +194,10 @@ def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: text = _text_of(item) if text or isinstance(item, str): parts.append({"text": text}) - elif isinstance(item, dict) and item.get("type") == "image_url": - if image := _inline_data_part((item.get("image_url") or {}).get("url") or ""): - parts.append(image) + elif isinstance(item, dict) and item.get("type") == "image_url" and ( + image := _inline_data_part((item.get("image_url") or {}).get("url") or "") + ): + parts.append(image) return parts From ee62112404cc0230efac7e4fd0a58e8bcd89ad62 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:14:55 -0700 Subject: [PATCH 08/30] refactor(agent/gemini): fold endpoint url building --- agent/gemini_native_adapter.py | 67 ++++++++++++++-------------------- 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 0bef6d6cdd..37b0659825 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -172,17 +172,17 @@ def _coerce_content_to_text(content: Any) -> str: return "" if content is None else str(content) -def _inline_data_part(url: Any) -> Optional[Dict[str, Any]]: - """``inlineData`` part for a ``data:`` URL; None for remote/malformed URLs.""" - if not isinstance(url, str) or not url.startswith("data:"): +def _inline_data_part(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """``inlineData`` part for an ``image_url`` item carrying a ``data:`` URL; None otherwise.""" + url = (item.get("image_url") or {}).get("url") or "" + if item.get("type") != "image_url" or not isinstance(url, str) or not url.startswith("data:"): return None try: header, encoded = url.split(",", 1) mime = header.split(":", 1)[1].split(";", 1)[0] - data = base64.b64encode(base64.b64decode(encoded)).decode("ascii") + return {"inlineData": {"mimeType": mime, "data": base64.b64encode(base64.b64decode(encoded)).decode("ascii")}} except Exception: return None - return {"inlineData": {"mimeType": mime, "data": data}} def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: @@ -194,17 +194,17 @@ def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: text = _text_of(item) if text or isinstance(item, str): parts.append({"text": text}) - elif isinstance(item, dict) and item.get("type") == "image_url" and ( - image := _inline_data_part((item.get("image_url") or {}).get("url") or "") - ): + elif isinstance(item, dict) and (image := _inline_data_part(item)): parts.append(image) return parts def _tool_call_extra_signature(tool_call: Dict[str, Any]) -> Optional[str]: + """Replayed Gemini thoughtSignature from ``extra_content`` (``google.thought_signature`` or flat).""" extra = tool_call.get("extra_content") or {} - google = (extra.get("google") or extra.get("thought_signature")) if isinstance(extra, dict) else None - sig = (google.get("thought_signature") or google.get("thoughtSignature")) if isinstance(google, dict) else google + sig = (extra.get("google") or extra.get("thought_signature")) if isinstance(extra, dict) else None + if isinstance(sig, dict): + sig = sig.get("thought_signature") or sig.get("thoughtSignature") return sig if isinstance(sig, str) and sig else None @@ -367,13 +367,11 @@ def _thinking_requests_output_headroom(thinking_config: Any) -> bool: """True when Gemini will spend output tokens on thinking: thought tokens bill against ``maxOutputTokens``, so a global 4096/16384 cap can be consumed entirely by high thinking, leaving ``finishReason=MAX_TOKENS`` with no answer.""" - normalized = _normalize_thinking_config(thinking_config) - if not normalized: - return False + normalized = _normalize_thinking_config(thinking_config) or {} + budget, has_level = normalized.get("thinkingBudget"), "thinkingLevel" in normalized if normalized.get("includeThoughts") is False: - return "thinkingLevel" in normalized or bool(normalized.get("thinkingBudget")) - budget = normalized.get("thinkingBudget") - return not (isinstance(budget, int) and budget <= 0 and "thinkingLevel" not in normalized) + return has_level or bool(budget) + return bool(normalized) and not (isinstance(budget, int) and budget <= 0 and not has_level) def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_config: Any) -> int: @@ -478,15 +476,14 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa cand = candidates[0] if isinstance(candidates[0], dict) else {} content_obj = cand.get("content") parts = content_obj.get("parts") if isinstance(content_obj, dict) else [] - text_pieces: List[str] = [] - reasoning_pieces: List[str] = [] + pieces: Dict[bool, List[str]] = {False: [], True: []} # is_thought → text pieces tool_calls: List[SimpleNamespace] = [] for index, part in enumerate(parts or []): if not isinstance(part, dict): continue text, is_thought = _part_text(part) if text is not None: - (reasoning_pieces if is_thought else text_pieces).append(text) + pieces[is_thought].append(text) elif fc := _part_function_call(part): tool_calls.append(_tool_call_ns(str(fc["name"]), _dump_call_args(fc), index, _new_call_id(fc), _tool_call_extra_from_part(part))) @@ -495,9 +492,9 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa else: finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(str(cand.get("finishReason") or "")) usage = _usage_from_metadata(resp.get("usageMetadata") or {}) - reasoning = "".join(reasoning_pieces) or None + reasoning = "".join(pieces[True]) or None message = SimpleNamespace( - role="assistant", content="".join(text_pieces) if text_pieces else ("" if cand is None else None), + role="assistant", content="".join(pieces[False]) if pieces[False] else ("" if cand is None else None), tool_calls=tool_calls or None, reasoning=reasoning, reasoning_content=reasoning, reasoning_details=None, ) return _envelope(model, "chat.completion", SimpleNamespace(index=0, message=message, finish_reason=finish_reason), usage) @@ -529,10 +526,9 @@ def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]: buffer += chunk or "" while "\n" in buffer: line, buffer = buffer.split("\n", 1) - line = line.rstrip("\r") - if not line.startswith("data: "): + data = line.rstrip("\r")[6:] if line.rstrip("\r").startswith("data: ") else None + if data is None: continue - data = line[6:] if data == "[DONE]": return try: @@ -564,9 +560,7 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: name = str(fc["name"]) args_str = _dump_call_args(fc, sort_keys=True) thought_signature = part.get("thoughtSignature") if isinstance(part.get("thoughtSignature"), str) else "" - call_key = json.dumps( - {"part_index": part_index, "name": name, "thought_signature": thought_signature}, sort_keys=True - ) + call_key = json.dumps({"part_index": part_index, "name": name, "thought_signature": thought_signature}, sort_keys=True) if (slot := tool_call_indices.get(call_key)) is None: slot = tool_call_indices[call_key] = {"index": len(tool_call_indices), "id": _new_call_id(fc), "last_arguments": ""} # Gemini re-sends the full args each event; emit only the new suffix. @@ -602,20 +596,17 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No err_status = str(err_obj.get("status") or "").strip() err_message = str(err_obj.get("message") or "").strip() details_list = err_obj.get("details") - # First google.rpc.ErrorInfo detail supplies reason/metadata (later ones only fill gaps until reason is set). reason, metadata = "", {} for detail in details_list if isinstance(details_list, list) else []: if isinstance(detail, dict) and not reason and str(detail.get("@type") or "").endswith("/google.rpc.ErrorInfo"): reason = detail["reason"] if isinstance(detail.get("reason"), str) else reason metadata = detail["metadata"] if isinstance(detail.get("metadata"), dict) else metadata - retry_after: Optional[float] = None try: retry_after = float(response.headers.get("Retry-After") or response.headers.get("retry-after")) except (TypeError, ValueError): pass - message = ( f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" if err_message else f"Gemini returned HTTP {status}: {body_text[:500]}" @@ -686,18 +677,15 @@ class GeminiNativeClient: extra_body: Optional[Dict[str, Any]] = None, timeout: Any = None, **_: Any, ) -> Any: extra = extra_body if isinstance(extra_body, dict) else {} - thinking_config = extra.get("thinking_config") or extra.get("thinkingConfig") request = build_gemini_request( - messages=messages or [], tools=tools, tool_choice=tool_choice, temperature=temperature, - max_tokens=max_tokens, top_p=top_p, stop=stop, thinking_config=thinking_config, model=model, + messages=messages or [], tools=tools, tool_choice=tool_choice, temperature=temperature, max_tokens=max_tokens, + top_p=top_p, stop=stop, thinking_config=extra.get("thinking_config") or extra.get("thinkingConfig"), model=model, ) model = bare_gemini_model_id(model) + url = f"{self.base_url}/models/{model}:" if stream: - return self._stream_completion(model=model, request=request, timeout=timeout) - - response = self._http.post( - f"{self.base_url}/models/{model}:generateContent", json=request, headers=self._headers(), timeout=timeout - ) + return self._stream_completion(model, url + "streamGenerateContent?alt=sse", request, timeout) + response = self._http.post(url + "generateContent", json=request, headers=self._headers(), timeout=timeout) if response.status_code != 200: raise gemini_http_error(response) try: @@ -708,8 +696,7 @@ class GeminiNativeClient: ) from exc return translate_gemini_response(payload, model=model) - def _stream_completion(self, *, model: str, request: Dict[str, Any], timeout: Any = None) -> Iterator[_GeminiStreamChunk]: - url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse" + def _stream_completion(self, model: str, url: str, request: Dict[str, Any], timeout: Any) -> Iterator[_GeminiStreamChunk]: headers = {**self._headers(), "Accept": "text/event-stream"} try: with self._http.stream("POST", url, json=request, headers=headers, timeout=timeout) as response: From 70ff772fbdea576042e78fee30a113dbf4aa369d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:17:01 -0700 Subject: [PATCH 09/30] refactor(agent/gemini,acp): async stream method, contextlib.suppress, compact headers --- agent/copilot_acp_client.py | 23 +++++++---------- agent/credits_tracker.py | 7 +++-- agent/gemini_native_adapter.py | 47 ++++++++++++++-------------------- 3 files changed, 31 insertions(+), 46 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 452a96701a..5b2d2e46a6 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -6,6 +6,7 @@ as one prompt, collects text chunks, and returns the minimal OpenAI-client shape from __future__ import annotations +import contextlib import json import logging import os @@ -82,13 +83,10 @@ def _resolve_args() -> list[str]: def _acp_supported(command: str, args: list[str]) -> bool | None: - """Tri-state probe: does ``command`` accept ``--acp``? A CLI without the flag - (older releases, Claude Code v2.x) exits 1 with ``unknown option '--acp'`` and the - parent would wait the full child timeout for stdout that never arrives. - True = help advertises --acp; False = help ran cleanly without it (caller - fast-fails); None = inconclusive (binary missing / --help failed → normal spawn - error). Only probes when ``--acp`` is among ``args`` — a custom transport is the - operator's business.""" + """Tri-state ``--acp`` probe (a CLI without the flag exits 1 and the parent would wait the + full child timeout for stdout that never arrives). True = help advertises --acp; False = + help ran cleanly without it (caller fast-fails); None = inconclusive (binary missing / + --help failed → normal spawn error). Skipped when ``--acp`` is not in ``args`` (custom transport).""" if "--acp" not in args: return True if (cached := _ACP_PROBE_CACHE.get(command)) is not None: @@ -112,8 +110,7 @@ def _resolve_home_dir() -> str: """Stable HOME for child ACP processes; /tmp as a last resort so the child never starts HOME-less.""" if home := os.environ.get("HOME", "").strip(): return home - expanded = os.path.expanduser("~") - if expanded and expanded != "~": + if (expanded := os.path.expanduser("~")) and expanded != "~": return expanded try: import pwd @@ -276,8 +273,8 @@ class CopilotACPClient: def __init__( self, *, api_key: str | None = None, base_url: str | None = None, default_headers: dict[str, str] | None = None, - acp_command: str | None = None, acp_args: list[str] | None = None, acp_cwd: str | None = None, - command: str | None = None, args: list[str] | None = None, **_: Any, + acp_command: str | None = None, acp_args: list[str] | None = None, acp_cwd: str | None = None, command: str | None = None, + args: list[str] | None = None, **_: Any, ): self.api_key = api_key or "copilot-acp" self.base_url = base_url or ACP_MARKER_BASE_URL @@ -300,10 +297,8 @@ class CopilotACPClient: proc.terminate() proc.wait(timeout=2) except Exception: - try: + with contextlib.suppress(Exception): proc.kill() - except Exception: - pass def _create_chat_completion( self, *, model: str | None = None, messages: list[dict[str, Any]] | None = None, timeout: float | None = None, diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 493ea8a9e1..3b125a521b 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -237,10 +237,9 @@ def evaluate_credits_notices( return (to_show, to_clear) -# Header contract: (field, header, kind[, default]). Each *-micros balance has a *-usd -# twin holding the server's formatted USD string. micros: required int >= 0 ("signed": -# may be negative). usd: required ^-?\d+\.\d{2}$. bool: optional "true"/"false" STRING. -# Not in the table: subscription-limit-* (PAIRED/optional), tool-pool-micros (optional), +# Header contract: (field, header, kind[, default]). micros: required int >= 0 ("signed": may be +# negative); usd: the server's formatted string, ^-?\d+\.\d{2}$; bool: optional "true"/"false" STRING. +# Handled inline below: subscription-limit-* (PAIRED/optional), tool-pool-micros (optional), # denominator-kind ("subscription_cap" | "none"), disabled-reason (omitted when null). _HEADER_FIELDS: tuple[tuple, ...] = ( ("remaining_micros", "x-nous-credits-remaining-micros", "micros"), diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 37b0659825..1b1d71b7f2 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -1,11 +1,8 @@ -"""OpenAI-compatible facade over Google AI Studio's native Gemini API. - -The ``gemini`` provider keeps ``api_mode='chat_completions'`` so the agent loop -stays OpenAI-shaped; this shim converts ``messages[]``/``tools[]`` requests into -``models/{model}:generateContent`` payloads and converts responses back. Google's -OpenAI-compat endpoint is brittle for the multi-turn tool loop (auth churn, -tool-call replay quirks, thought-signature requirements); native is canonical. -""" +"""OpenAI-compatible facade over Google AI Studio's native Gemini API: the ``gemini`` +provider keeps ``api_mode='chat_completions'`` so the agent loop stays OpenAI-shaped, +and this shim converts ``messages[]``/``tools[]`` into ``models/{model}:generateContent`` +payloads and responses back. Google's OpenAI-compat endpoint is brittle for the +multi-turn tool loop (auth churn, tool-call replay, thought signatures); native is canonical.""" from __future__ import annotations @@ -149,8 +146,7 @@ class GeminiAPIError(Exception): def __init__( self, message: str, *, code: str = "gemini_api_error", status_code: Optional[int] = None, - response: Optional[httpx.Response] = None, retry_after: Optional[float] = None, - details: Optional[Dict[str, Any]] = None, + response: Optional[httpx.Response] = None, retry_after: Optional[float] = None, details: Optional[Dict[str, Any]] = None, ) -> None: super().__init__(message) self.code, self.status_code, self.response = code, status_code, response @@ -220,8 +216,8 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any], include_ids: bool except json.JSONDecodeError: args = {"_raw": args_raw} call: Dict[str, Any] = {"name": str(fn.get("name") or ""), "args": args if isinstance(args, dict) else {"_value": args}} - if include_ids and _tool_call_id(tool_call): - call["id"] = _tool_call_id(tool_call) + if include_ids and (call_id := _tool_call_id(tool_call)): + call["id"] = call_id return {"functionCall": call, "thoughtSignature": _tool_call_extra_signature(tool_call) or _SKIP_SIGNATURE} @@ -629,8 +625,8 @@ class GeminiNativeClient: """Minimal OpenAI-SDK-compatible facade (``client.chat.completions.create(**kwargs)``) over Gemini's native REST API.""" - # Declared for agent/auxiliary_client.py: already a complete client, never re-dispatched through - # a wire adapter. (No HERMES_SKIP_ASYNC_WRAP — the async path has a real conversion, AsyncGeminiNativeClient.) + # For agent/auxiliary_client.py: a complete client, never re-dispatched through a wire adapter. + # (No HERMES_SKIP_ASYNC_WRAP — the async path has a real conversion, AsyncGeminiNativeClient.) HERMES_SKIP_TRANSPORT_WRAP = True def __init__( @@ -658,10 +654,8 @@ class GeminiNativeClient: pass def _headers(self) -> Dict[str, str]: - return { - "Content-Type": "application/json", "Accept": "application/json", "x-goog-api-key": self.api_key, - "User-Agent": f"{_API_CLIENT} (gemini-native)", "X-Goog-Api-Client": _API_CLIENT, **self._default_headers, - } + return {"Content-Type": "application/json", "Accept": "application/json", "x-goog-api-key": self.api_key, + "User-Agent": f"{_API_CLIENT} (gemini-native)", "X-Goog-Api-Client": _API_CLIENT, **self._default_headers} @staticmethod def _advance_stream_iterator(iterator: Iterator[_GeminiStreamChunk]) -> tuple[bool, Optional[_GeminiStreamChunk]]: @@ -721,17 +715,14 @@ class AsyncGeminiNativeClient: async def _create_chat_completion(self, **kwargs: Any) -> Any: result = await asyncio.to_thread(self._sync.chat.completions.create, **kwargs) - if not kwargs.get("stream"): - return result + return self._async_stream(result) if kwargs.get("stream") else result - async def _async_stream() -> Any: - while True: - done, chunk = await asyncio.to_thread(self._sync._advance_stream_iterator, result) - if done: - return - yield chunk - - return _async_stream() + async def _async_stream(self, iterator: Iterator[_GeminiStreamChunk]) -> Any: + while True: + done, chunk = await asyncio.to_thread(self._sync._advance_stream_iterator, iterator) + if done: + return + yield chunk async def close(self) -> None: await asyncio.to_thread(self._sync.close) From c63ce88f6a22a9ec4c5c9e7fe63bd9433024f056 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:19:05 -0700 Subject: [PATCH 10/30] refactor(agent/credits): highest-band lookup as next() --- agent/credits_tracker.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 3b125a521b..5d3b6bc2c3 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -180,9 +180,7 @@ def evaluate_credits_notices( # mid-session top-up flips current_band → None so the clear path removes the line. current_band: Optional[tuple[float, str, int]] = None if uf is not None and state.purchased_micros <= 0: - for band in CREDITS_USAGE_BANDS: - if uf >= band[0]: - current_band = band + current_band = next((b for b in reversed(CREDITS_USAGE_BANDS) if uf >= b[0]), None) # ── usage gauge: highest crossed band only; replace on band change (climb or # step-down); clear below the lowest band or when the denominator vanishes. From 6713fee8729bc76c56b9759bdd083240ec4beac4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:21:33 -0700 Subject: [PATCH 11/30] refactor(agent/gemini): extract _merge_alternating phase helper --- agent/gemini_native_adapter.py | 49 +++++++++++++++++----------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 1b1d71b7f2..7ee185e344 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -263,13 +263,34 @@ def _has_function_response(content: Dict[str, Any]) -> bool: return any(isinstance(part, dict) and "functionResponse" in part for part in content.get("parts", [])) +def _merge_alternating(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Alternation contract for generateContent: 1) adjacent same-role contents merge + (else HTTP 400 "multiturn requests [must] alternate"); 2) EXCEPT never fuse a human + user text turn into a preceding user content that only carries functionResponse + parts (or vice versa) — Gemini 3 accepts the fold but reads the text as a + continuation of the tool result and returns an empty candidate (parallel + functionResponse + functionResponse still merge); 3) the split pair stays API-valid + via an interposed placeholder model turn.""" + merged: List[Dict[str, Any]] = [] + for content in contents: + prev = merged[-1] if merged else None + same_role = prev is not None and prev["role"] == content["role"] + if same_role and content["role"] == "user" and _has_function_response(prev) != _has_function_response(content): + same_role = False + merged.append({"role": "model", "parts": [{"text": _INTERRUPTED_RESPONSE_PLACEHOLDER}]}) + if same_role: + merged[-1]["parts"].extend(content["parts"]) + else: + merged.append(content) + return merged + + def _build_gemini_contents( messages: List[Dict[str, Any]], include_tool_call_ids: bool = False, *, is_gemini3: bool = False ) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]: system_text_parts: List[str] = [] contents: List[Dict[str, Any]] = [] tool_name_by_call_id: Dict[str, str] = {} - for msg in messages: if not isinstance(msg, dict): continue @@ -285,37 +306,15 @@ def _build_gemini_contents( continue parts = _extract_multimodal_parts(msg.get("content")) tool_calls = msg.get("tool_calls") or [] - for tool_call in tool_calls if isinstance(tool_calls, list) else []: - if not isinstance(tool_call, dict): - continue + for tool_call in (tc for tc in tool_calls if isinstance(tc, dict)) if isinstance(tool_calls, list) else (): tool_name = str((tool_call.get("function") or {}).get("name") or "") if _tool_call_id(tool_call) and tool_name: tool_name_by_call_id[_tool_call_id(tool_call)] = tool_name parts.append(_translate_tool_call_to_gemini(tool_call, include_ids=include_tool_call_ids)) if parts: contents.append({"role": "model" if role == "assistant" else "user", "parts": parts}) - - # Alternation contract for generateContent: 1) adjacent same-role contents merge - # (else HTTP 400 "multiturn requests [must] alternate"); 2) EXCEPT never fuse a - # human user text turn into a preceding user content that only carries - # functionResponse parts (or vice versa) — Gemini 3 accepts the fold but reads - # the text as a continuation of the tool result and returns an empty candidate - # (parallel functionResponse + functionResponse still merge); 3) the split pair - # stays API-valid via an interposed placeholder model turn. - merged: List[Dict[str, Any]] = [] - for content in contents: - prev = merged[-1] if merged else None - same_role = prev is not None and prev["role"] == content["role"] - if same_role and content["role"] == "user" and _has_function_response(prev) != _has_function_response(content): - same_role = False - merged.append({"role": "model", "parts": [{"text": _INTERRUPTED_RESPONSE_PLACEHOLDER}]}) - if same_role: - merged[-1]["parts"].extend(content["parts"]) - else: - merged.append(content) - joined_system = "\n".join(part for part in system_text_parts if part).strip() - return merged, ({"role": "system", "parts": [{"text": joined_system}]} if joined_system else None) + return _merge_alternating(contents), ({"role": "system", "parts": [{"text": joined_system}]} if joined_system else None) def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]: From f49413fde645f8d6cb5b900e1dbaa13cd6788655 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:23:20 -0700 Subject: [PATCH 12/30] refactor(agent/gemini_schema,acp): compact schema sanitizer and client ctor --- agent/copilot_acp_client.py | 24 +++++++++--------------- agent/gemini_schema.py | 37 ++++++++++++++----------------------- 2 files changed, 23 insertions(+), 38 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 5b2d2e46a6..e15026108f 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -276,8 +276,7 @@ class CopilotACPClient: acp_command: str | None = None, acp_args: list[str] | None = None, acp_cwd: str | None = None, command: str | None = None, args: list[str] | None = None, **_: Any, ): - self.api_key = api_key or "copilot-acp" - self.base_url = base_url or ACP_MARKER_BASE_URL + self.api_key, self.base_url = api_key or "copilot-acp", base_url or ACP_MARKER_BASE_URL self._default_headers = dict(default_headers or {}) self._acp_command = acp_command or command or _resolve_command() self._acp_args = list(acp_args or args or _resolve_args()) @@ -308,8 +307,8 @@ class CopilotACPClient: response_text, reasoning = self._run_prompt(prompt_text, timeout_seconds=_effective_timeout(timeout), model=model) tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text) message = SimpleNamespace( - content=cleaned_text, tool_calls=tool_calls, reasoning=reasoning or None, - reasoning_content=reasoning or None, reasoning_details=None, + content=cleaned_text, tool_calls=tool_calls, reasoning=reasoning or None, reasoning_content=reasoning or None, + reasoning_details=None, ) completion = SimpleNamespace( choices=[SimpleNamespace(message=message, finish_reason="tool_calls" if tool_calls else "stop")], @@ -330,14 +329,12 @@ class CopilotACPClient: "HERMES_COPILOT_ACP_COMMAND / HERMES_COPILOT_ACP_ARGS to a working pair." ) try: - # Hide the console the child would flash on Windows; stdio pipes stay intact. - from hermes_cli._subprocess_compat import windows_hide_flags + from hermes_cli._subprocess_compat import windows_hide_flags # hide the Windows console flash; pipes intact proc = subprocess.Popen( - [self._acp_command] + self._acp_args, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, encoding='utf-8', errors='replace', bufsize=1, - cwd=self._acp_cwd, env=_build_subprocess_env(), creationflags=windows_hide_flags(), + [self._acp_command] + self._acp_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding='utf-8', errors='replace', bufsize=1, cwd=self._acp_cwd, env=_build_subprocess_env(), + creationflags=windows_hide_flags(), ) except FileNotFoundError as exc: raise RuntimeError( @@ -392,7 +389,6 @@ class CopilotACPClient: err = msg.get("error") or {} raise RuntimeError(f"Copilot ACP {method} failed: {err.get('message') or err}") return msg.get("result") - stderr_text = "\n".join(stderr_tail).strip() if proc.poll() is not None and stderr_text: if _is_gh_copilot_deprecation_message(stderr_text): @@ -418,10 +414,8 @@ class CopilotACPClient: ) text_parts: list[str] = [] reasoning_parts: list[str] = [] - _request( - "session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": prompt_text}]}, - text_parts=text_parts, reasoning_parts=reasoning_parts, - ) + prompt = {"sessionId": session_id, "prompt": [{"type": "text", "text": prompt_text}]} + _request("session/prompt", prompt, text_parts=text_parts, reasoning_parts=reasoning_parts) return "".join(text_parts), "".join(reasoning_parts) finally: self.close() diff --git a/agent/gemini_schema.py b/agent/gemini_schema.py index 9f9a956f9c..41b4a3e03f 100644 --- a/agent/gemini_schema.py +++ b/agent/gemini_schema.py @@ -5,37 +5,30 @@ from __future__ import annotations import math from typing import Any, Dict -# Gemini's ``FunctionDeclaration.parameters`` accepts the ``Schema`` object, -# only a subset of OpenAPI 3.0 / JSON Schema; everything else is stripped. +# Gemini's ``FunctionDeclaration.parameters`` accepts only a subset of OpenAPI 3.0 / +# JSON Schema (the ``Schema`` object); everything else is stripped. _GEMINI_SCHEMA_ALLOWED_KEYS = { - "type", "format", "title", "description", "nullable", "enum", - "maxItems", "minItems", "properties", "required", "minProperties", - "maxProperties", "minLength", "maxLength", "pattern", "example", - "anyOf", "propertyOrdering", "default", "items", "minimum", "maximum", + "type", "format", "title", "description", "nullable", "enum", "maxItems", "minItems", "properties", "required", + "minProperties", "maxProperties", "minLength", "maxLength", "pattern", "example", "anyOf", "propertyOrdering", + "default", "items", "minimum", "maximum", } def _stringify_enum_value(item: Any) -> Any: """Gemini-safe string for a scalar enum entry, or None to drop it.""" - if isinstance(item, str): - return item if isinstance(item, bool): return "true" if item else "false" if isinstance(item, (int, float)) and math.isfinite(item): return str(item) - return None + return item if isinstance(item, str) else None def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: - """Return a Gemini-compatible copy of a tool parameter schema. - - Keeps only the documented Gemini subset (drops e.g. ``$schema`` / - ``additionalProperties``) and recursively sanitizes nested - ``properties`` / ``items`` / ``anyOf`` definitions. - """ + """Gemini-compatible copy of a tool parameter schema: keeps only the documented subset + (drops e.g. ``$schema`` / ``additionalProperties``) and recursively sanitizes nested + ``properties`` / ``items`` / ``anyOf``.""" if not isinstance(schema, dict): return {} - cleaned: Dict[str, Any] = {} for key, value in schema.items(): if key not in _GEMINI_SCHEMA_ALLOWED_KEYS: @@ -56,17 +49,15 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: # still emits typed tool arguments at runtime. dict.fromkeys = ordered dedupe. enum_val = cleaned.get("enum") if isinstance(enum_val, list) and cleaned.get("type") in {"integer", "number", "boolean"}: - stringified = list(dict.fromkeys(v for v in map(_stringify_enum_value, enum_val) if v is not None)) - if stringified: + if stringified := list(dict.fromkeys(v for v in map(_stringify_enum_value, enum_val) if v is not None)): cleaned["enum"] = stringified else: cleaned.pop("enum", None) - # Gemini validates ``required`` strictly against the same node's - # ``properties`` (HTTP 400 "property is not defined"), and one bad tool - # schema fails the ENTIRE request. MCP servers routinely emit ``required`` - # without ``properties``, so keep only names that exist here; the tool - # handler still validates required fields at execution time. + # Gemini validates ``required`` strictly against the same node's ``properties`` (HTTP 400 + # "property is not defined") and one bad tool schema fails the ENTIRE request. MCP servers + # routinely emit ``required`` without ``properties``, so keep only names that exist here; + # the tool handler still validates required fields at execution time. required_val = cleaned.get("required") if isinstance(required_val, list): props_val = cleaned.get("properties") From 378c6e72e692bc2fd9eb81b0dffeff9d19b2c90c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:24:50 -0700 Subject: [PATCH 13/30] refactor(agent/credits): inline usd validator, module-level threading import --- agent/credits_tracker.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 5d3b6bc2c3..cfb3ce65b6 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging import os import re +import threading import time from dataclasses import dataclass from typing import Any, Mapping, Optional @@ -26,18 +27,12 @@ _SENTINEL = object() # "parse failed" def _safe_int(value: Any) -> Any: """Exact int (money-safe) or ``_SENTINEL``. ``int()`` directly, NOT ``int(float())`` (precision loss above 2**53 corrupts money); float-shaped strings fail.""" - if value is None: - return _SENTINEL try: - return int(str(value)) + return _SENTINEL if value is None else int(str(value)) except (TypeError, ValueError): return _SENTINEL -def _validate_usd(value: Optional[str]) -> bool: - return value is not None and bool(_USD_RE.match(value)) - - @dataclass class CreditsState: """Credits state parsed from x-nous-credits-* response headers.""" @@ -259,7 +254,7 @@ def _parse_field(kind: str, raw: Optional[str], default: Any = None) -> Any: val = _safe_int(raw) return _SENTINEL if val is _SENTINEL or (kind == "micros" and val < 0) else val if kind == "usd": - return raw if _validate_usd(raw) else _SENTINEL + return raw if raw is not None and _USD_RE.match(raw) else _SENTINEL if raw is None: return default flag = raw.strip().lower() @@ -404,8 +399,7 @@ def _hydrate_seed_state(agent, state) -> None: agent._credits_session_start_micros = state.remaining_micros latch = getattr(agent, "_credits_latch", None) if isinstance(latch, dict) and state.used_fraction is not None: - # Prime ONLY seen_below_90 — priming seen_grant_unspent would revive the nag (grant-spent is a steady state). - latch["seen_below_90"] = True + latch["seen_below_90"] = True # ONLY this gate — priming seen_grant_unspent would revive the steady-state nag if callable(emit := getattr(agent, "_emit_credits_notices", None)): emit() @@ -426,24 +420,18 @@ def seed_credits_at_session_start(agent) -> bool: _hydrate_seed_state(agent, fixture) return True - # FIRE-AND-FORGET: a slow portal must never delay "ready". The daemon - # thread re-checks idempotency (a live header may land first). - import threading - - def _bg_seed() -> None: + def _bg_seed() -> None: # FIRE-AND-FORGET: a slow portal must never delay "ready" try: from hermes_cli.nous_account import get_nous_portal_account_info info = get_nous_portal_account_info(force_fresh=True) if getattr(agent, "_credits_state", None) is not None: return # a live inference header beat us — don't clobber it - state = _credits_state_from_account(info) - if state is not None: + if (state := _credits_state_from_account(info)) is not None: _hydrate_seed_state(agent, state) except Exception: logger.debug("credits ▸ session-start seed (background) failed", exc_info=True) threading.Thread(target=_bg_seed, name="credits-seed", daemon=True).start() return True except Exception: - # Innermost log across all call sites so a dead seed is diagnosable. - logger.debug("credits ▸ session-start seed failed (fail-open)", exc_info=True) + logger.debug("credits ▸ session-start seed failed (fail-open)", exc_info=True) # innermost log: diagnosable dead seed return False From 392d79a000ee79d3ceffb4b153f6832e45e723af Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:29:50 -0700 Subject: [PATCH 14/30] refactor(agent/gemini): extract ErrorInfo/retry-after parsing helpers --- agent/gemini_native_adapter.py | 43 +++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 7ee185e344..4b5b78c90c 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -495,8 +495,7 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa return _envelope(model, "chat.completion", SimpleNamespace(index=0, message=message, finish_reason=finish_reason), usage) -class _GeminiStreamChunk(SimpleNamespace): - pass +class _GeminiStreamChunk(SimpleNamespace): ... def _make_stream_chunk( @@ -568,16 +567,32 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: })) if finish_reason_raw := str(cand.get("finishReason") or ""): - finish_chunk = _make_stream_chunk( - model=model, finish_reason="tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw) - ) - # usageMetadata rides on the finish chunk so the streaming loop records token counts. - if usage_meta := event.get("usageMetadata") or {}: + finish_reason = "tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw) + finish_chunk = _make_stream_chunk(model=model, finish_reason=finish_reason) + if usage_meta := event.get("usageMetadata") or {}: # rides on the finish chunk so the stream loop records tokens finish_chunk.usage = _usage_from_metadata(usage_meta) chunks.append(finish_chunk) return chunks +def _error_info(err_obj: Dict[str, Any]) -> tuple[str, Dict[str, Any]]: + """``(reason, metadata)`` from the first google.rpc.ErrorInfo detail (later ones fill gaps until reason is set).""" + reason, metadata = "", {} + details = err_obj.get("details") + for detail in details if isinstance(details, list) else []: + if isinstance(detail, dict) and not reason and str(detail.get("@type") or "").endswith("/google.rpc.ErrorInfo"): + reason = detail["reason"] if isinstance(detail.get("reason"), str) else reason + metadata = detail["metadata"] if isinstance(detail.get("metadata"), dict) else metadata + return reason, metadata + + +def _parse_float(value: Any) -> Optional[float]: + try: + return float(value) + except (TypeError, ValueError): + return None + + def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = None) -> GeminiAPIError: status = response.status_code body_text = (_response_text(response) if body_text is None else body_text) or "" @@ -590,18 +605,8 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No err_obj = err_obj if isinstance(err_obj, dict) else {} err_status = str(err_obj.get("status") or "").strip() err_message = str(err_obj.get("message") or "").strip() - details_list = err_obj.get("details") - # First google.rpc.ErrorInfo detail supplies reason/metadata (later ones only fill gaps until reason is set). - reason, metadata = "", {} - for detail in details_list if isinstance(details_list, list) else []: - if isinstance(detail, dict) and not reason and str(detail.get("@type") or "").endswith("/google.rpc.ErrorInfo"): - reason = detail["reason"] if isinstance(detail.get("reason"), str) else reason - metadata = detail["metadata"] if isinstance(detail.get("metadata"), dict) else metadata - retry_after: Optional[float] = None - try: - retry_after = float(response.headers.get("Retry-After") or response.headers.get("retry-after")) - except (TypeError, ValueError): - pass + reason, metadata = _error_info(err_obj) + retry_after = _parse_float(response.headers.get("Retry-After") or response.headers.get("retry-after")) message = ( f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" if err_message else f"Gemini returned HTTP {status}: {body_text[:500]}" From acd92cd107a59eb9594f7be15effac5da4c0424e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:34:08 -0700 Subject: [PATCH 15/30] refactor(agent/acp): compact module constants and helpers --- agent/copilot_acp_client.py | 39 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index e15026108f..a07c7b4677 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -33,18 +33,14 @@ from tools.environments.local import hermes_subprocess_env ACP_MARKER_BASE_URL = "acp://copilot" logger = logging.getLogger(__name__) _DEFAULT_TIMEOUT_SECONDS = 900.0 - # Stderr fingerprint of the deprecated `gh copilot` extension. Require BOTH the product name # AND a deprecation marker: the NEW `@github/copilot` CLI legitimately mentions "copilot-cli". _DEPRECATION_REQUIRED = ("gh-copilot",) _DEPRECATION_MARKERS = ("has been deprecated", "no commands will be executed") - _ROLE_LABELS = {"system": "System", "user": "User", "assistant": "Assistant", "tool": "Tool", "context": "Context"} - # Probe verdicts per binary path (~50ms --help paid once per process). Only definitive # True/False is cached, so a CLI installed mid-session is picked up. _ACP_PROBE_CACHE: dict[str, bool] = {} - _PROMPT_PREAMBLE = ( "You are being used as the active ACP agent backend for Hermes.", "Use ACP capabilities to complete tasks.", @@ -93,16 +89,15 @@ def _acp_supported(command: str, args: list[str]) -> bool | None: return cached try: probe = subprocess.run( - [command, "--help"], capture_output=True, text=True, encoding="utf-8", errors="replace", - timeout=5, stdin=subprocess.DEVNULL, + [command, "--help"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, + stdin=subprocess.DEVNULL, ) except (FileNotFoundError, subprocess.TimeoutExpired, OSError): return None if probe.returncode != 0: return None # ``--acp`` as a flag token; tolerate spacing and ``[--acp]`` variants. - verdict = bool(re.search(r"(?:^|[\s\[])--acp(?:[\s=\],]|$)", probe.stdout, re.MULTILINE)) - _ACP_PROBE_CACHE[command] = verdict + verdict = _ACP_PROBE_CACHE[command] = bool(re.search(r"(?:^|[\s\[])--acp(?:[\s=\],]|$)", probe.stdout, re.MULTILINE)) return verdict @@ -121,11 +116,12 @@ def _resolve_home_dir() -> str: def _build_subprocess_env() -> dict[str, str]: + from hermes_constants import apply_subprocess_home_env + # Copilot ACP drives a model and needs LLM provider credentials; the central helper still # strips Tier-1 secrets (bot tokens, GitHub auth, infra). env = hermes_subprocess_env(inherit_credentials=True) env["HOME"] = _resolve_home_dir() - from hermes_constants import apply_subprocess_home_env apply_subprocess_home_env(env) return env @@ -140,11 +136,8 @@ def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]: def _enabled_ids(entries: Any, key: str) -> set[str]: """Ids of ``entries`` (dicts) whose ``_meta.copilotEnablement`` is not ``disabled``.""" - return { - str(e.get(key) or "").strip() - for e in (entries or []) - if isinstance(e, dict) and str((e.get("_meta") or {}).get("copilotEnablement") or "").strip().lower() != "disabled" - } + return {str(e.get(key) or "").strip() for e in (entries or []) if isinstance(e, dict) + and str((e.get("_meta") or {}).get("copilotEnablement") or "").strip().lower() != "disabled"} def _model_selection_request(session: dict[str, Any], requested_model: str) -> tuple[str, dict[str, str]] | None: @@ -180,12 +173,9 @@ def _format_messages_as_prompt( # one. Copilot has no tools of its own that collide with Hermes', so forward the whole toolset. sections: list[str] = [*_PROMPT_PREAMBLE, *_render_tool_bridge_sections(tools, tool_choice)] transcript: list[str] = [] - for message in messages: - if not isinstance(message, dict): - continue + for message in (m for m in messages if isinstance(m, dict)): role = str(message.get("role") or "unknown").strip().lower() - rendered = _render_message_content(message.get("content")) - if rendered: + if rendered := _render_message_content(message.get("content")): transcript.append(f"{_ROLE_LABELS.get(role, 'Context')}:\n{rendered}") if transcript: sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript)) @@ -201,11 +191,8 @@ def _render_message_content(content: Any) -> str: return str(content.get("text") or "").strip() return content["content"].strip() if isinstance(content.get("content"), str) else json.dumps(content, ensure_ascii=True) if isinstance(content, list): - parts = [ - item if isinstance(item, str) else item["text"].strip() - for item in content - if isinstance(item, str) or (isinstance(item, dict) and isinstance(item.get("text"), str) and item["text"].strip()) - ] + parts = [item if isinstance(item, str) else item["text"].strip() for item in content if isinstance(item, str) + or (isinstance(item, dict) and isinstance(item.get("text"), str) and item["text"].strip())] return "\n".join(parts).strip() return str(content).strip() @@ -251,9 +238,7 @@ def _fs_write_text_file(params: dict[str, Any], cwd: str) -> Any: path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) if denied := get_write_denied_error(str(path)): raise PermissionError(denied) - # Approval-gated paths (e.g. ~/.ssh/config) are soft-gated for interactive tools; the ACP - # shim has no human channel to confirm — fail closed. - if is_write_approval_required(str(path)): + if is_write_approval_required(str(path)): # soft-gated for interactive tools; the ACP shim has no human channel → fail closed raise PermissionError(f"Write denied: '{path}' requires interactive approval and cannot be written through the ACP file bridge.") path.parent.mkdir(parents=True, exist_ok=True) path.write_text(str(params.get("content") or ""), encoding="utf-8") From f184b161b246a082a5cfe8b23d47fd4d8ba5587a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:36:51 -0700 Subject: [PATCH 16/30] refactor(agent/gemini): inline finish-reason map lookups --- agent/gemini_native_adapter.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 4b5b78c90c..9cdfb0367c 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -407,10 +407,6 @@ def build_gemini_request( # ── Gemini → OpenAI response translation ───────────────────────────────────── -def _map_gemini_finish_reason(reason: str) -> str: - return _FINISH_REASON_MAP.get(str(reason or "").upper(), "stop") - - def _tool_call_extra_from_part(part: Dict[str, Any]) -> Optional[Dict[str, Any]]: sig = part.get("thoughtSignature") return {"google": {"thought_signature": sig}} if isinstance(sig, str) and sig else None @@ -481,11 +477,10 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa pieces[is_thought].append(text) elif fc := _part_function_call(part): tool_calls.append(_tool_call_ns(str(fc["name"]), _dump_call_args(fc), index, _new_call_id(fc), _tool_call_extra_from_part(part))) - if cand is None: finish_reason, usage = "stop", _usage_from_metadata({}) else: - finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(str(cand.get("finishReason") or "")) + finish_reason = "tool_calls" if tool_calls else _FINISH_REASON_MAP.get(str(cand.get("finishReason") or "").upper(), "stop") usage = _usage_from_metadata(resp.get("usageMetadata") or {}) reasoning = "".join(pieces[True]) or None message = SimpleNamespace( @@ -567,7 +562,7 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: })) if finish_reason_raw := str(cand.get("finishReason") or ""): - finish_reason = "tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw) + finish_reason = "tool_calls" if tool_call_indices else _FINISH_REASON_MAP.get(finish_reason_raw.upper(), "stop") finish_chunk = _make_stream_chunk(model=model, finish_reason=finish_reason) if usage_meta := event.get("usageMetadata") or {}: # rides on the finish chunk so the stream loop records tokens finish_chunk.usage = _usage_from_metadata(usage_meta) From 78970a6989d1bb06d8170f26bcca522d12d17ab4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:38:50 -0700 Subject: [PATCH 17/30] refactor(agent/credits): compact CreditsState properties and usage notice text --- agent/credits_tracker.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index cfb3ce65b6..1ae21b4014 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -66,19 +66,18 @@ class CreditsState: @property def depleted(self) -> bool: - """Keyed off ``paid_access == False`` ONLY — ``remaining_micros == 0`` is a - false positive when the balance is zero but access is live (renewal pending).""" + """``paid_access == False`` ONLY — ``remaining_micros == 0`` is a false positive + when the balance is zero but access is live (renewal pending).""" return not self.paid_access @property def used_fraction(self) -> Optional[float]: - """Fraction of the subscription cap consumed in [0.0, 1.0]; None without a - computable denominator. Guarded on the LIMIT FIELD (the real denominator), - not ``denominator_kind`` (metadata).""" - if not isinstance(self.subscription_limit_micros, int) or self.subscription_limit_micros <= 0: + """Fraction of the subscription cap consumed in [0.0, 1.0]; None without a computable + denominator. Guarded on the LIMIT FIELD (the real denominator), not ``denominator_kind``.""" + lim = self.subscription_limit_micros + if not isinstance(lim, int) or lim <= 0: return None - used = self.subscription_limit_micros - self.subscription_micros - return max(0.0, min(1.0, used / self.subscription_limit_micros)) + return max(0.0, min(1.0, (lim - self.subscription_micros) / lim)) # ── Credits policy constants. Switching notices sticky→TTL later also needs a @@ -154,9 +153,7 @@ def evaluate_credits_notices( Returns ``(to_show, to_clear)``; caller emits to_clear FIRST, then to_show.""" to_show: list[AgentNotice] = [] to_clear: list[str] = [] - uf = state.used_fraction - active = latch["active"] - + uf, active = state.used_fraction, latch["active"] # Crossing latch: band notices fire only once uf was observed below the LOWEST # band, so a session opening mid-range doesn't fire on its first observation # (the cold-start seed primes this when it WANTS an open-high warning). @@ -185,16 +182,13 @@ def evaluate_credits_notices( to_clear.append(CREDITS_USAGE_KEY) active.discard(CREDITS_USAGE_KEY) if target_band is not None: - # Absolute dollars used (a bare "N%" is only meaningful against a Nous - # cap): cap − remaining, clamped [0, cap]; "$?" if a producer set the - # limit without its *_usd. Re-emits on band change only. + # Absolute dollars used (a bare "N%" is only meaningful against a Nous cap): cap − remaining, + # clamped [0, cap]; "$?" if a producer set the limit without its *_usd. Re-emits on band change only. level = current_band[1] # type: ignore[index] (current_band set when target_band set) lim = state.subscription_limit_micros or 0 used_usd = f"{max(0, min(lim, lim - state.subscription_micros)) / 1_000_000:.2f}" if lim else "?" - glyph = "⚠" if level == "warn" else "•" - to_show.append(_sticky_notice( - f"{glyph} You've used ${used_usd} of your ${state.subscription_limit_usd or '?'} cap", level, CREDITS_USAGE_KEY - )) + text = f"{'⚠' if level == 'warn' else '•'} You've used ${used_usd} of your ${state.subscription_limit_usd or '?'} cap" + to_show.append(_sticky_notice(text, level, CREDITS_USAGE_KEY)) active.add(CREDITS_USAGE_KEY) latch["usage_band"] = target_band From 52a6d97bfc107b43c6012e713e18dc44ae4eddbd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:40:16 -0700 Subject: [PATCH 18/30] refactor(agent/gemini): compact envelope/namespace builders --- agent/gemini_native_adapter.py | 35 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 9cdfb0367c..282b00958d 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -435,10 +435,8 @@ def _usage_from_metadata(usage_meta: Dict[str, Any]) -> SimpleNamespace: def _envelope(model: str, object_: str, choice: SimpleNamespace, usage: Any, cls: type = SimpleNamespace) -> Any: """OpenAI chat.completion / chat.completion.chunk envelope around one choice.""" - return cls( - id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object=object_, created=int(time.time()), model=model, - choices=[choice], usage=usage, - ) + return cls(id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object=object_, created=int(time.time()), model=model, + choices=[choice], usage=usage) def _tool_call_ns(name: str, arguments: str, index: int, call_id: str, extra_content: Any) -> SimpleNamespace: @@ -483,10 +481,8 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa finish_reason = "tool_calls" if tool_calls else _FINISH_REASON_MAP.get(str(cand.get("finishReason") or "").upper(), "stop") usage = _usage_from_metadata(resp.get("usageMetadata") or {}) reasoning = "".join(pieces[True]) or None - message = SimpleNamespace( - role="assistant", content="".join(pieces[False]) if pieces[False] else ("" if cand is None else None), - tool_calls=tool_calls or None, reasoning=reasoning, reasoning_content=reasoning, reasoning_details=None, - ) + message = SimpleNamespace(role="assistant", content="".join(pieces[False]) if pieces[False] else ("" if cand is None else None), + tool_calls=tool_calls or None, reasoning=reasoning, reasoning_content=reasoning, reasoning_details=None) return _envelope(model, "chat.completion", SimpleNamespace(index=0, message=message, finish_reason=finish_reason), usage) @@ -501,10 +497,8 @@ def _make_stream_chunk( tool_calls = None if d is None else [ _tool_call_ns(d.get("name") or "", d.get("arguments") or "", d.get("index", 0), _new_call_id(d), d.get("extra_content")) ] - delta = SimpleNamespace( - role="assistant", content=content or None, tool_calls=tool_calls, - reasoning=reasoning or None, reasoning_content=reasoning or None, - ) + delta = SimpleNamespace(role="assistant", content=content or None, tool_calls=tool_calls, reasoning=reasoning or None, + reasoning_content=reasoning or None) choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason) return _envelope(model, "chat.completion.chunk", choice, None, cls=_GeminiStreamChunk) @@ -515,10 +509,10 @@ def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]: buffer += chunk or "" while "\n" in buffer: line, buffer = buffer.split("\n", 1) - data = line.rstrip("\r")[6:] if line.rstrip("\r").startswith("data: ") else None - if data is None: + line = line.rstrip("\r") + if not line.startswith("data: "): continue - if data == "[DONE]": + if (data := line[6:]) == "[DONE]": return try: payload = json.loads(data) @@ -606,23 +600,20 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" if err_message else f"Gemini returned HTTP {status}: {body_text[:500]}" ) - # Users who bypassed the setup wizard (raw GOOGLE_API_KEY in .env) still need to learn - # the free tier cannot sustain an agent session. + # Users who bypassed the setup wizard (raw GOOGLE_API_KEY in .env) still need to learn the free + # tier cannot sustain an agent session; a legacy "Standard" key gets the real fix (Google's raw 401 asks for OAuth). if status == 429 and is_free_tier_quota_error(err_message or body_text): message += _FREE_TIER_GUIDANCE - # Legacy "Standard" key rejection: Google's raw 401 misleadingly asks for OAuth; append the real fix. if is_standard_key_auth_error(status, err_message or body_text, reason): message += _STANDARD_KEY_GUIDANCE return GeminiAPIError( message, code=_HTTP_ERROR_CODES.get(status, f"gemini_http_{status}"), status_code=status, response=response, - retry_after=retry_after, - details={"status": err_status, "reason": reason, "metadata": metadata, "message": err_message}, + retry_after=retry_after, details={"status": err_status, "reason": reason, "metadata": metadata, "message": err_message}, ) class GeminiNativeClient: - """Minimal OpenAI-SDK-compatible facade (``client.chat.completions.create(**kwargs)``) - over Gemini's native REST API.""" + """Minimal OpenAI-SDK-compatible facade (``client.chat.completions.create(**kwargs)``) over Gemini's native REST API.""" # For agent/auxiliary_client.py: a complete client, never re-dispatched through a wire adapter. # (No HERMES_SKIP_ASYNC_WRAP — the async path has a real conversion, AsyncGeminiNativeClient.) From 1b61d2e9c5ac944166554e61de717c454a5305b8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:44:10 -0700 Subject: [PATCH 19/30] refactor(agent/gemini): extract _error_object --- agent/gemini_native_adapter.py | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 282b00958d..7589242ef8 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -549,12 +549,9 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: # Gemini re-sends the full args each event; emit only the new suffix. last_arguments = str(slot.get("last_arguments") or "") slot["last_arguments"] = args_str - chunks.append(_make_stream_chunk(model=model, tool_call_delta={ - "index": slot["index"], "id": slot["id"], "name": name, - "arguments": args_str[len(last_arguments):] if args_str.startswith(last_arguments) else args_str, - "extra_content": _tool_call_extra_from_part(part), - })) - + delta = {"index": slot["index"], "id": slot["id"], "name": name, "extra_content": _tool_call_extra_from_part(part), + "arguments": args_str[len(last_arguments):] if args_str.startswith(last_arguments) else args_str} + chunks.append(_make_stream_chunk(model=model, tool_call_delta=delta)) if finish_reason_raw := str(cand.get("finishReason") or ""): finish_reason = "tool_calls" if tool_call_indices else _FINISH_REASON_MAP.get(finish_reason_raw.upper(), "stop") finish_chunk = _make_stream_chunk(model=model, finish_reason=finish_reason) @@ -575,27 +572,27 @@ def _error_info(err_obj: Dict[str, Any]) -> tuple[str, Dict[str, Any]]: return reason, metadata -def _parse_float(value: Any) -> Optional[float]: +def _error_object(body_text: str) -> Dict[str, Any]: + """The ``error`` object of a Google JSON error body, or ``{}``.""" try: - return float(value) - except (TypeError, ValueError): - return None + parsed = json.loads(body_text) if body_text else None + except (ValueError, TypeError): + return {} + err_obj = parsed.get("error") if isinstance(parsed, dict) else None + return err_obj if isinstance(err_obj, dict) else {} def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = None) -> GeminiAPIError: status = response.status_code body_text = (_response_text(response) if body_text is None else body_text) or "" - err_obj: Any = None - try: - parsed = json.loads(body_text) if body_text else None - err_obj = parsed.get("error") if isinstance(parsed, dict) else None - except (ValueError, TypeError): - pass - err_obj = err_obj if isinstance(err_obj, dict) else {} + err_obj = _error_object(body_text) err_status = str(err_obj.get("status") or "").strip() err_message = str(err_obj.get("message") or "").strip() reason, metadata = _error_info(err_obj) - retry_after = _parse_float(response.headers.get("Retry-After") or response.headers.get("retry-after")) + try: + retry_after: Optional[float] = float(response.headers.get("Retry-After") or response.headers.get("retry-after")) + except (TypeError, ValueError): + retry_after = None message = ( f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" if err_message else f"Gemini returned HTTP {status}: {body_text[:500]}" From b84223fd0640e9b14125df63f43752df3f51111f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:46:27 -0700 Subject: [PATCH 20/30] refactor(agent/gemini,acp,credits): fold literals and single-use temporaries --- agent/copilot_acp_client.py | 18 +++++++----------- agent/credits_tracker.py | 11 +++++------ agent/gemini_native_adapter.py | 12 ++++++------ 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index a07c7b4677..fef87d5659 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -198,10 +198,9 @@ def _render_message_content(content: Any) -> str: def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path: - candidate = Path(path_text) - if not candidate.is_absolute(): + if not Path(path_text).is_absolute(): raise PermissionError("ACP file-system paths must be absolute.") - resolved, root = candidate.resolve(), Path(cwd).resolve() + resolved, root = Path(path_text).resolve(), Path(cwd).resolve() try: resolved.relative_to(root) except ValueError as exc: @@ -308,9 +307,9 @@ class CopilotACPClient: if _acp_supported(self._acp_command, self._acp_args) is False: preview = " ".join(self._acp_args[:3]) if self._acp_args else "(none)" raise RuntimeError( - f"ACP transport not supported by '{self._acp_command}': `{preview}` is rejected as an unknown option. " - "This usually means the CLI is an older release (e.g. Claude Code v2.x) or a different tool than expected. " - "Either install a CLI that ships with --acp support (e.g. `@github/copilot` late 2025+), or set " + f"ACP transport not supported by '{self._acp_command}': `{preview}` is rejected as an unknown option. This " + "usually means the CLI is an older release (e.g. Claude Code v2.x) or a different tool than expected. Either " + "install a CLI that ships with --acp support (e.g. `@github/copilot` late 2025+), or set " "HERMES_COPILOT_ACP_COMMAND / HERMES_COPILOT_ACP_ARGS to a working pair." ) try: @@ -417,15 +416,12 @@ class CopilotACPClient: update = (msg.get("params") or {}).get("update") or {} content = update.get("content") or {} chunk_text = str(content.get("text") or "") if isinstance(content, dict) else "" - sink = {"agent_message_chunk": text_parts, "agent_thought_chunk": reasoning_parts}.get( - str(update.get("sessionUpdate") or "").strip() - ) - if chunk_text and sink is not None: + sinks = {"agent_message_chunk": text_parts, "agent_thought_chunk": reasoning_parts} + if chunk_text and (sink := sinks.get(str(update.get("sessionUpdate") or "").strip())) is not None: sink.append(chunk_text) return True if process.stdin is None: return True - message_id = msg.get("id") if method == "session/request_permission": response = _jsonrpc_result(message_id, {"outcome": {"outcome": "cancelled"}}) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 1ae21b4014..b575df965b 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -367,17 +367,16 @@ def _credits_state_from_account(info) -> Optional[CreditsState]: def _money(dollars) -> tuple[int, str]: # (micros, display usd); (0, "") when absent return (int(round(dollars * 1_000_000)), f"{dollars:.2f}") if isinstance(dollars, (int, float)) else (0, "") fields: dict[str, Any] = {} - for prefix, obj, attr in (("remaining", acc, "total_usable_credits"), ("subscription", acc, "subscription_credits_remaining"), - ("purchased", acc, "purchased_credits_remaining")): - fields[f"{prefix}_micros"], fields[f"{prefix}_usd"] = _money(getattr(obj, attr, None)) + for prefix, attr in (("remaining", "total_usable_credits"), ("subscription", "subscription_credits_remaining"), + ("purchased", "purchased_credits_remaining")): + fields[f"{prefix}_micros"], fields[f"{prefix}_usd"] = _money(getattr(acc, attr, None)) monthly = getattr(sub, "monthly_credits", None) cap = _money(monthly) if isinstance(monthly, (int, float)) and monthly > 0 else (None, None) paid = getattr(info, "paid_service_access", None) return CreditsState( - **fields, subscription_limit_micros=cap[0], subscription_limit_usd=cap[1], - rollover_micros=_money(getattr(sub, "rollover_credits", None))[0], + **fields, subscription_limit_micros=cap[0], subscription_limit_usd=cap[1], from_header=False, captured_at=time.time(), + rollover_micros=_money(getattr(sub, "rollover_credits", None))[0], paid_access=paid if isinstance(paid, bool) else True, denominator_kind="subscription_cap" if cap[0] is not None else "none", - paid_access=paid if isinstance(paid, bool) else True, from_header=False, captured_at=time.time(), ) except Exception: logger.debug("credits ▸ seed account→state mapping failed", exc_info=True) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 7589242ef8..e8df963bcc 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -370,9 +370,9 @@ def _thinking_requests_output_headroom(thinking_config: Any) -> bool: def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_config: Any) -> int: - """Native ``maxOutputTokens``: an omitted/invalid cap becomes the published ceiling - (Gemini truncates on its low internal default); an explicit cap is raised to the - ceiling when thinking is enabled so thoughts don't starve the answer.""" + """Native ``maxOutputTokens``: an omitted/invalid cap becomes the published ceiling (Gemini + truncates on its low internal default); an explicit cap is raised to the ceiling when + thinking is enabled so thoughts don't starve the answer.""" try: requested = int(max_tokens) except (TypeError, ValueError): @@ -622,9 +622,9 @@ class GeminiNativeClient: ) -> None: if not (api_key or "").strip(): raise RuntimeError( - "Gemini native client requires an API key, but none was provided. Set GOOGLE_API_KEY or " - "GEMINI_API_KEY in your environment / ~/.hermes/.env (get one at https://aistudio.google.com/app/apikey), " - "or run `hermes setup` to configure the Google provider." + "Gemini native client requires an API key, but none was provided. Set GOOGLE_API_KEY or GEMINI_API_KEY in your " + "environment / ~/.hermes/.env (get one at https://aistudio.google.com/app/apikey), or run `hermes setup` to " + "configure the Google provider." ) self.api_key = api_key self.base_url = (base_url or DEFAULT_GEMINI_BASE_URL).rstrip("/").removesuffix("/openai") From ad9d2d8c83876137b37e95f1d03c60f35edde986 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:49:34 -0700 Subject: [PATCH 21/30] refactor(agent/credits,gemini): derive header names from field table; contextlib.suppress --- agent/credits_tracker.py | 46 ++++++++++++++++------------------ agent/gemini_native_adapter.py | 24 +++++++----------- 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index b575df965b..063de81a92 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -12,6 +12,7 @@ import re import threading import time from dataclasses import dataclass +from decimal import Decimal from typing import Any, Mapping, Optional from utils import is_truthy_value @@ -96,8 +97,7 @@ GRANT_UNSPENT_MIN_MICROS = 10_000 def new_credits_latch() -> dict: - """Fresh notice latch for :func:`evaluate_credits_notices`. Every producer must - build it here so a new gate key lands everywhere at once.""" + """Fresh notice latch for :func:`evaluate_credits_notices`; every producer builds it here so a new gate key lands everywhere.""" return {"active": set(), "seen_below_90": False, "usage_band": None, "seen_grant_unspent": False} @@ -224,33 +224,33 @@ def evaluate_credits_notices( return (to_show, to_clear) -# Header contract: (field, header, kind[, default]). micros: required int >= 0 ("signed": may be -# negative); usd: the server's formatted string, ^-?\d+\.\d{2}$; bool: optional "true"/"false" STRING. -# Handled inline below: subscription-limit-* (PAIRED/optional), tool-pool-micros (optional), +# Header contract: (field, kind[, default-when-absent]); a field is REQUIRED unless it has a default. +# Header name = ``x-nous-credits-`` (``x-nous-`` for tool_pool_*), underscores → dashes. +# micros: int >= 0 ("signed": may be negative); usd: the server's formatted string ^-?\d+\.\d{2}$ +# (never re-parsed); bool: "true"/"false" STRING. Handled inline: subscription-limit-* (PAIRED/optional), # denominator-kind ("subscription_cap" | "none"), disabled-reason (omitted when null). _HEADER_FIELDS: tuple[tuple, ...] = ( - ("remaining_micros", "x-nous-credits-remaining-micros", "micros"), - ("subscription_micros", "x-nous-credits-subscription-micros", "signed"), - ("rollover_micros", "x-nous-credits-rollover-micros", "micros"), - ("purchased_micros", "x-nous-credits-purchased-micros", "micros"), - ("as_of_ms", "x-nous-credits-as-of-ms", "micros"), - ("remaining_usd", "x-nous-credits-remaining-usd", "usd"), - ("subscription_usd", "x-nous-credits-subscription-usd", "usd"), - ("purchased_usd", "x-nous-credits-purchased-usd", "usd"), - ("paid_access", "x-nous-credits-paid-access", "bool", True), # absent → fail-open (assume access) - ("tool_pool_gated_off", "x-nous-tool-pool-gated-off", "bool", False), + ("remaining_micros", "micros"), ("subscription_micros", "signed"), ("rollover_micros", "micros"), + ("purchased_micros", "micros"), ("as_of_ms", "micros"), ("tool_pool_micros", "micros", 0), + ("remaining_usd", "usd"), ("subscription_usd", "usd"), ("purchased_usd", "usd"), + ("paid_access", "bool", True), # absent → fail-open (assume access) + ("tool_pool_gated_off", "bool", False), ) -def _parse_field(kind: str, raw: Optional[str], default: Any = None) -> Any: - """One header value → field value, or ``_SENTINEL`` on a contract violation.""" +def _header_name(field: str) -> str: + return "x-nous-" + ("" if field.startswith("tool_pool_") else "credits-") + field.replace("_", "-") + + +def _parse_field(kind: str, raw: Optional[str], default: Any = _SENTINEL) -> Any: + """One header value → field value; ``default`` when absent, ``_SENTINEL`` on a contract violation.""" + if raw is None: + return default if kind in ("micros", "signed"): val = _safe_int(raw) return _SENTINEL if val is _SENTINEL or (kind == "micros" and val < 0) else val if kind == "usd": - return raw if raw is not None and _USD_RE.match(raw) else _SENTINEL - if raw is None: - return default + return raw if _USD_RE.match(raw) else _SENTINEL flag = raw.strip().lower() return _SENTINEL if flag not in ("true", "false") else flag == "true" @@ -277,11 +277,8 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt logger.warning("credits header version %d unsupported, ignoring — update Hermes", version_val) return None fields: dict[str, Any] = { - name: _parse_field(kind, lowered.get(key), *default) for name, key, kind, *default in _HEADER_FIELDS + name: _parse_field(kind, lowered.get(_header_name(name)), *default) for name, kind, *default in _HEADER_FIELDS } - # tool_pool_micros is OPTIONAL: absent → 0; present-but-invalid → miss. - tp_raw = lowered.get("x-nous-tool-pool-micros") - fields["tool_pool_micros"] = 0 if tp_raw is None else _parse_field("micros", tp_raw) lim_micros_raw = lowered.get("x-nous-credits-subscription-limit-micros") lim_usd_raw = lowered.get("x-nous-credits-subscription-limit-usd") if lim_micros_raw is not None and lim_usd_raw is not None: @@ -308,7 +305,6 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt def _fixture(remaining: str, subscription: str, limit: Optional[str] = None, purchased: Optional[str] = None, *, paid: bool = True, reason: Optional[str] = None) -> dict: """Fixture spec from *_usd strings; micros derived exactly (Decimal).""" - from decimal import Decimal d: dict = {} for field, usd in (("remaining", remaining), ("subscription", subscription), ("subscription_limit", limit), ("purchased", purchased)): diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index e8df963bcc..ea6e9b05ba 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio import base64 +import contextlib import json import logging import re @@ -97,8 +98,7 @@ def probe_gemini_tier( if not key: return "unknown" base = str(base_url or DEFAULT_GEMINI_BASE_URL).strip().rstrip("/") or DEFAULT_GEMINI_BASE_URL - if base.lower().endswith("/openai"): - base = base[: -len("/openai")] + base = re.sub(r"/openai\Z", "", base, flags=re.IGNORECASE) try: with httpx.Client(timeout=timeout) as client: resp = client.post( @@ -222,11 +222,10 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any], include_ids: bool def _looks_like_json_schema(node: Any) -> bool: - """True if a parsed value contains a JSON-Schema ``$ref`` pointer (``#/...``): Gemini 3 - resolves ``$ref``/``$defs`` inside functionResponse.response and rejects unknown - pointers with HTTP 400, so a tool result that is itself a JSON Schema (e.g. - ``tool_describe`` output) must be forwarded as opaque text. False positives only - lose the structured shape, never the content.""" + """True if a parsed value contains a JSON-Schema ``$ref`` pointer (``#/...``): Gemini 3 resolves + ``$ref``/``$defs`` inside functionResponse.response and rejects unknown pointers with HTTP 400, so a + tool result that is itself a JSON Schema (e.g. ``tool_describe`` output) is forwarded as opaque text. + False positives only lose the structured shape, never the content.""" if isinstance(node, dict): return any( (key == "$ref" and isinstance(value, str) and value.startswith("#/")) or _looks_like_json_schema(value) @@ -299,9 +298,7 @@ def _build_gemini_contents( system_text_parts.append(_coerce_content_to_text(msg.get("content"))) continue if role in {"tool", "function"}: - part = _translate_tool_result_to_gemini( - msg, tool_name_by_call_id=tool_name_by_call_id, include_ids=include_tool_call_ids, is_gemini3=is_gemini3 - ) + part = _translate_tool_result_to_gemini(msg, tool_name_by_call_id, include_tool_call_ids, is_gemini3=is_gemini3) contents.append({"role": "user", "parts": [part]}) continue parts = _extract_multimodal_parts(msg.get("content")) @@ -586,8 +583,7 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No status = response.status_code body_text = (_response_text(response) if body_text is None else body_text) or "" err_obj = _error_object(body_text) - err_status = str(err_obj.get("status") or "").strip() - err_message = str(err_obj.get("message") or "").strip() + err_status, err_message = (str(err_obj.get(k) or "").strip() for k in ("status", "message")) reason, metadata = _error_info(err_obj) try: retry_after: Optional[float] = float(response.headers.get("Retry-After") or response.headers.get("retry-after")) @@ -635,10 +631,8 @@ class GeminiNativeClient: def close(self) -> None: self.is_closed = True - try: + with contextlib.suppress(Exception): self._http.close() - except Exception: - pass def _headers(self) -> Dict[str, str]: return {"Content-Type": "application/json", "Accept": "application/json", "x-goog-api-key": self.api_key, From f6aae3022e3721472ab7d02c2217e7daf06a2636 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:52:50 -0700 Subject: [PATCH 22/30] refactor(agent/gemini,acp,credits): extract _function_declaration, compact signatures --- agent/copilot_acp_client.py | 11 +++----- agent/credits_tracker.py | 7 ++--- agent/gemini_native_adapter.py | 50 ++++++++++++++++------------------ 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index fef87d5659..ab0dba7365 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -155,9 +155,8 @@ def _model_selection_request(session: dict[str, Any], requested_model: str) -> t if model_option is not None: if requested_model not in _enabled_ids(model_option.get("options"), "value"): return None - return "session/set_config_option", { - "sessionId": session_id, "configId": str(model_option.get("id") or "model"), "value": requested_model, - } + params = {"sessionId": session_id, "configId": str(model_option.get("id") or "model"), "value": requested_model} + return "session/set_config_option", params available = _enabled_ids((session.get("models") or {}).get("availableModels"), "modelId") if available and requested_model not in available: return None @@ -165,8 +164,7 @@ def _model_selection_request(session: dict[str, Any], requested_model: str) -> t def _format_messages_as_prompt( - messages: list[dict[str, Any]], model: str | None = None, tools: list[dict[str, Any]] | None = None, - tool_choice: Any = None, + messages: list[dict[str, Any]], model: str | None = None, tools: list[dict[str, Any]] | None = None, tool_choice: Any = None, ) -> str: # Deliberately no "requested model" line: the model is applied for real via ACP session/set_model; # a prompt-text mention makes a substituted backend model FALSELY self-identify as the requested @@ -405,8 +403,7 @@ class CopilotACPClient: self.close() def _handle_server_message( - self, msg: dict[str, Any], *, process: subprocess.Popen[str], cwd: str, - text_parts: list[str] | None, reasoning_parts: list[str] | None, + self, msg: dict[str, Any], *, process: subprocess.Popen[str], cwd: str, text_parts: list[str] | None, reasoning_parts: list[str] | None, ) -> bool: """Consume a server->client message; True when handled (notification or request answered).""" method = msg.get("method") diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 063de81a92..93456790e4 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -142,9 +142,7 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool: return False -def evaluate_credits_notices( - state: CreditsState, latch: dict, *, model_is_free: bool = False, -) -> tuple[list[AgentNotice], list[str]]: +def evaluate_credits_notices(state: CreditsState, latch: dict, *, model_is_free: bool = False) -> tuple[list[AgentNotice], list[str]]: """Reconcile credits notices against the latch (see :func:`new_credits_latch`); mutates ``latch`` IN PLACE. Pure — no I/O, no agent/run_agent imports. ``model_is_free`` suppresses ``credits.depleted`` (a depleted account on a free @@ -306,8 +304,7 @@ def _fixture(remaining: str, subscription: str, limit: Optional[str] = None, pur *, paid: bool = True, reason: Optional[str] = None) -> dict: """Fixture spec from *_usd strings; micros derived exactly (Decimal).""" d: dict = {} - for field, usd in (("remaining", remaining), ("subscription", subscription), - ("subscription_limit", limit), ("purchased", purchased)): + for field, usd in (("remaining", remaining), ("subscription", subscription), ("subscription_limit", limit), ("purchased", purchased)): if usd is not None: d[f"{field}_micros"], d[f"{field}_usd"] = int(Decimal(usd) * 1_000_000), usd if limit is not None: diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index ea6e9b05ba..cace2cb904 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -99,13 +99,11 @@ def probe_gemini_tier( return "unknown" base = str(base_url or DEFAULT_GEMINI_BASE_URL).strip().rstrip("/") or DEFAULT_GEMINI_BASE_URL base = re.sub(r"/openai\Z", "", base, flags=re.IGNORECASE) + payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], "generationConfig": {"maxOutputTokens": 1}} + headers = {"Content-Type": "application/json", "X-Goog-Api-Client": _API_CLIENT} try: with httpx.Client(timeout=timeout) as client: - resp = client.post( - f"{base}/models/{model}:generateContent", params={"key": key}, - json={"contents": [{"role": "user", "parts": [{"text": "hi"}]}], "generationConfig": {"maxOutputTokens": 1}}, - headers={"Content-Type": "application/json", "X-Goog-Api-Client": _API_CLIENT}, - ) + resp = client.post(f"{base}/models/{model}:generateContent", params={"key": key}, json=payload, headers=headers) except Exception as exc: logger.debug("probe_gemini_tier: network error: %s", exc) return "unknown" @@ -235,8 +233,7 @@ def _looks_like_json_schema(node: Any) -> bool: def _translate_tool_result_to_gemini( - message: Dict[str, Any], tool_name_by_call_id: Optional[Dict[str, str]] = None, include_ids: bool = False, - *, is_gemini3: bool = False, + message: Dict[str, Any], tool_name_by_call_id: Optional[Dict[str, str]] = None, include_ids: bool = False, *, is_gemini3: bool = False, ) -> Dict[str, Any]: tool_call_id = str(message.get("tool_call_id") or "") # functionResponse.name must echo the matching functionCall.name, so the call-id @@ -314,18 +311,20 @@ def _build_gemini_contents( return _merge_alternating(contents), ({"role": "system", "parts": [{"text": joined_system}]} if joined_system else None) +def _function_declaration(tool: Any) -> Optional[Dict[str, Any]]: + fn = (tool.get("function") or {}) if isinstance(tool, dict) else None + if not isinstance(fn, dict) or not (isinstance(fn.get("name"), str) and fn["name"]): + return None + decl: Dict[str, Any] = {"name": fn["name"]} + if isinstance(fn.get("description"), str) and fn["description"]: + decl["description"] = fn["description"] + if isinstance(fn.get("parameters"), dict): + decl["parameters"] = sanitize_gemini_tool_parameters(fn["parameters"]) + return decl + + def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]: - declarations: List[Dict[str, Any]] = [] - for tool in tools if isinstance(tools, list) else []: - fn = (tool.get("function") or {}) if isinstance(tool, dict) else None - if not isinstance(fn, dict) or not (isinstance(fn.get("name"), str) and fn["name"]): - continue - decl: Dict[str, Any] = {"name": fn["name"]} - if isinstance(fn.get("description"), str) and fn["description"]: - decl["description"] = fn["description"] - if isinstance(fn.get("parameters"), dict): - decl["parameters"] = sanitize_gemini_tool_parameters(fn["parameters"]) - declarations.append(decl) + declarations = [d for d in map(_function_declaration, tools if isinstance(tools, list) else []) if d] return [{"functionDeclarations": declarations}] if declarations else [] @@ -487,8 +486,7 @@ class _GeminiStreamChunk(SimpleNamespace): ... def _make_stream_chunk( - *, model: str, content: str = "", tool_call_delta: Optional[Dict[str, Any]] = None, - finish_reason: Optional[str] = None, reasoning: str = "", + *, model: str, content: str = "", tool_call_delta: Optional[Dict[str, Any]] = None, finish_reason: Optional[str] = None, reasoning: str = "", ) -> _GeminiStreamChunk: d = tool_call_delta tool_calls = None if d is None else [ @@ -646,10 +644,9 @@ class GeminiNativeClient: return True, None def _create_chat_completion( - self, *, model: str = "gemini-3.7-flash", messages: Optional[List[Dict[str, Any]]] = None, - stream: bool = False, tools: Any = None, tool_choice: Any = None, temperature: Optional[float] = None, - max_tokens: Optional[int] = None, top_p: Optional[float] = None, stop: Any = None, - extra_body: Optional[Dict[str, Any]] = None, timeout: Any = None, **_: Any, + self, *, model: str = "gemini-3.7-flash", messages: Optional[List[Dict[str, Any]]] = None, stream: bool = False, + tools: Any = None, tool_choice: Any = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None, + top_p: Optional[float] = None, stop: Any = None, extra_body: Optional[Dict[str, Any]] = None, timeout: Any = None, **_: Any, ) -> Any: extra = extra_body if isinstance(extra_body, dict) else {} request = build_gemini_request( @@ -688,11 +685,10 @@ class AsyncGeminiNativeClient: """Async wrapper used by auxiliary_client for native Gemini calls.""" def __init__(self, sync_client: GeminiNativeClient): - self._sync = sync_client + # ``_real_client``: the auxiliary cache evicts entries by leaf client; GeminiNativeClient is itself the leaf. + self._sync = self._real_client = sync_client self.api_key, self.base_url = sync_client.api_key, sync_client.base_url self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion)) - # The auxiliary cache evicts entries by leaf client; GeminiNativeClient is itself the leaf. - self._real_client = sync_client async def _create_chat_completion(self, **kwargs: Any) -> Any: result = await asyncio.to_thread(self._sync.chat.completions.create, **kwargs) From 5547b702941318d270ce8c047857e3192791ff0f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:55:33 -0700 Subject: [PATCH 23/30] refactor(agent/acp): compact model selection and timeout normalisation --- agent/copilot_acp_client.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index ab0dba7365..b825b72af9 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -149,18 +149,13 @@ def _model_selection_request(session: dict[str, Any], requested_model: str) -> t requested_model = str(requested_model or "").strip() if not session_id or not requested_model or requested_model == "copilot-acp": return None - model_option = next( - (o for o in (session.get("configOptions") or []) if isinstance(o, dict) and "model" in (o.get("category"), o.get("id"))), None - ) - if model_option is not None: - if requested_model not in _enabled_ids(model_option.get("options"), "value"): + options = [o for o in (session.get("configOptions") or []) if isinstance(o, dict) and "model" in (o.get("category"), o.get("id"))] + if options: + if requested_model not in _enabled_ids(options[0].get("options"), "value"): return None - params = {"sessionId": session_id, "configId": str(model_option.get("id") or "model"), "value": requested_model} - return "session/set_config_option", params + return "session/set_config_option", {"sessionId": session_id, "configId": str(options[0].get("id") or "model"), "value": requested_model} available = _enabled_ids((session.get("models") or {}).get("availableModels"), "modelId") - if available and requested_model not in available: - return None - return "session/set_model", {"sessionId": session_id, "modelId": requested_model} + return None if available and requested_model not in available else ("session/set_model", {"sessionId": session_id, "modelId": requested_model}) def _format_messages_as_prompt( @@ -208,8 +203,6 @@ def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path: def _effective_timeout(timeout: Any) -> float: """Normalise a float or httpx.Timeout-like object to wall-clock seconds (largest component wins).""" - if timeout is None: - return _DEFAULT_TIMEOUT_SECONDS if isinstance(timeout, (int, float)): return float(timeout) candidates = [getattr(timeout, attr, None) for attr in ("read", "write", "connect", "pool", "timeout")] From 2526ab98072a628abfc0087ca538a1e32fc1ee42 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:57:53 -0700 Subject: [PATCH 24/30] refactor(agent/credits,gemini): comprehension-based thinking normalizer, fold state ctor --- agent/credits_tracker.py | 22 +++++++++------------- agent/gemini_native_adapter.py | 12 +++++------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 93456790e4..eab8cf278e 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -261,8 +261,8 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt half-pair (only -micros or only -usd) parses as both-absent (both None).""" global _version_warning_emitted try: - # Cheap probe before the lowercase copy (header names are case-insensitive): - # bail when the version header is absent — the hot path for non-Nous providers. + # Cheap probe before the lowercase copy (header names are case-insensitive): bail when the + # version header is absent — the hot path for non-Nous providers. if not any(k.lower() == "x-nous-credits-version" for k in headers): return None lowered = {k.lower(): v for k, v in headers.items()} @@ -285,21 +285,17 @@ def parse_credits_headers(headers: Mapping[str, str], provider: str = "") -> Opt denominator_kind = lowered.get("x-nous-credits-denominator-kind", "none") if _SENTINEL in fields.values() or denominator_kind not in _VALID_DENOMINATOR_KINDS: return None - return CreditsState( - version=version_val, denominator_kind=denominator_kind, - disabled_reason=lowered.get("x-nous-credits-disabled-reason"), # None if absent (omitted when null) - captured_at=time.time(), from_header=True, **fields, - ) - except Exception: - # Fail-open → miss; the breadcrumb distinguishes a parser regression from a no-headers response. + disabled_reason = lowered.get("x-nous-credits-disabled-reason") # None if absent (omitted when null) + return CreditsState(version=version_val, denominator_kind=denominator_kind, disabled_reason=disabled_reason, + captured_at=time.time(), from_header=True, **fields) + except Exception: # fail-open → miss; the breadcrumb distinguishes a parser regression from a no-headers response logger.debug("credits ▸ parse_credits_headers raised (fail-open miss)", exc_info=True) return None -# ── Dev fixtures (HERMES_DEV_CREDITS_FIXTURE): throwaway scaffolding to trigger -# any notice state without real spend. Value is a state NAME or a FILE PATH whose -# contents are a name (re-read every turn → `echo depleted > /tmp/cf` flips live). -# Drives the per-turn capture/notice path, the cold-start seed, and /usage. +# ── Dev fixtures (HERMES_DEV_CREDITS_FIXTURE): throwaway scaffolding to trigger any notice state +# without real spend. Value is a state NAME or a FILE PATH whose contents are a name (re-read every +# turn → `echo depleted > /tmp/cf` flips live). Drives per-turn notices, the cold-start seed, and /usage. def _fixture(remaining: str, subscription: str, limit: Optional[str] = None, purchased: Optional[str] = None, *, paid: bool = True, reason: Optional[str] = None) -> dict: """Fixture spec from *_usd strings; micros derived exactly (Decimal).""" diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index cace2cb904..dfdb8784c2 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -344,13 +344,11 @@ _THINKING_KEYS = ( def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]: - if not isinstance(config, dict) or not config: + if not isinstance(config, dict): return None - normalized: Dict[str, Any] = {} - for key, alias, types, norm in _THINKING_KEYS: - value = config.get(key, config.get(alias)) - if isinstance(value, types) and (value.strip() if isinstance(value, str) else True): - normalized[key] = norm(value) + values = {key: config.get(key, config.get(alias)) for key, alias, _, _ in _THINKING_KEYS} + normalized = {key: norm(values[key]) for key, _, types, norm in _THINKING_KEYS + if isinstance(values[key], types) and (values[key].strip() if isinstance(values[key], str) else True)} return normalized or None @@ -669,8 +667,8 @@ class GeminiNativeClient: return translate_gemini_response(payload, model=model) def _stream_completion(self, model: str, url: str, request: Dict[str, Any], timeout: Any) -> Iterator[_GeminiStreamChunk]: - headers = {**self._headers(), "Accept": "text/event-stream"} try: + headers = {**self._headers(), "Accept": "text/event-stream"} with self._http.stream("POST", url, json=request, headers=headers, timeout=timeout) as response: if response.status_code != 200: raise gemini_http_error(response, body_text=read_streaming_error_body(response)) From 0d6ed9cd6990d709f8b8ff50e901fcdbcc068268 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:00:38 -0700 Subject: [PATCH 25/30] refactor(agent/gemini,acp): hoist missing-key error, sentinel-based stream advance --- agent/copilot_acp_client.py | 10 +++------- agent/gemini_native_adapter.py | 21 ++++++++++----------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index b825b72af9..a7c1133675 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -312,10 +312,8 @@ class CopilotACPClient: creationflags=windows_hide_flags(), ) except FileNotFoundError as exc: - raise RuntimeError( - f"Could not start Copilot ACP command '{self._acp_command}'. Install GitHub Copilot CLI or set " - "HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH." - ) from exc + raise RuntimeError(f"Could not start Copilot ACP command '{self._acp_command}'. Install GitHub Copilot CLI or set " + "HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH.") from exc if proc.stdin is None or proc.stdout is None: proc.kill() raise RuntimeError("Copilot ACP process did not expose stdin/stdout pipes.") @@ -384,9 +382,7 @@ class CopilotACPClient: else: logger.warning("Copilot ACP does not offer model %r; using the session default.", requested_model) except Exception as exc: - logger.warning( - "Copilot ACP model selection for %r failed; continuing with the session default: %s", requested_model, exc - ) + logger.warning("Copilot ACP model selection for %r failed; continuing with the session default: %s", requested_model, exc) text_parts: list[str] = [] reasoning_parts: list[str] = [] prompt = {"sessionId": session_id, "prompt": [{"type": "text", "text": prompt_text}]} diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index dfdb8784c2..96eef24b4e 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -60,11 +60,17 @@ _INTERRUPTED_RESPONSE_PLACEHOLDER = "[The previous response was interrupted befo # Cross-provider tool_calls (e.g. fallback from xAI/Anthropic) carry no Gemini thoughtSignature; # without this sentinel Gemini 3 thinking models reject replayed history with 400 INVALID_ARGUMENT. _SKIP_SIGNATURE = "skip_thought_signature_validator" +_END = object() # stream-exhausted marker for _advance_stream_iterator _TOOL_CHOICE_MODES = {"auto": "AUTO", "required": "ANY", "none": "NONE"} _FINISH_REASON_MAP = { "STOP": "stop", "MAX_TOKENS": "length", "SAFETY": "content_filter", "RECITATION": "content_filter", "OTHER": "stop", } _HTTP_ERROR_CODES = {401: "gemini_unauthorized", 429: "gemini_rate_limited", 404: "gemini_model_not_found"} +_MISSING_KEY_ERROR = ( + "Gemini native client requires an API key, but none was provided. Set GOOGLE_API_KEY or GEMINI_API_KEY in your " + "environment / ~/.hermes/.env (get one at https://aistudio.google.com/app/apikey), or run `hermes setup` to " + "configure the Google provider." +) def bare_gemini_model_id(model: str) -> str: @@ -613,16 +619,11 @@ class GeminiNativeClient: timeout: Any = None, http_client: Optional[httpx.Client] = None, **_: Any, ) -> None: if not (api_key or "").strip(): - raise RuntimeError( - "Gemini native client requires an API key, but none was provided. Set GOOGLE_API_KEY or GEMINI_API_KEY in your " - "environment / ~/.hermes/.env (get one at https://aistudio.google.com/app/apikey), or run `hermes setup` to " - "configure the Google provider." - ) - self.api_key = api_key + raise RuntimeError(_MISSING_KEY_ERROR) + self.api_key, self.is_closed = api_key, False self.base_url = (base_url or DEFAULT_GEMINI_BASE_URL).rstrip("/").removesuffix("/openai") self._default_headers = dict(default_headers or {}) self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion)) - self.is_closed = False self._http = http_client or httpx.Client(timeout=timeout or httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0)) def close(self) -> None: @@ -636,10 +637,8 @@ class GeminiNativeClient: @staticmethod def _advance_stream_iterator(iterator: Iterator[_GeminiStreamChunk]) -> tuple[bool, Optional[_GeminiStreamChunk]]: - try: - return False, next(iterator) - except StopIteration: - return True, None + chunk = next(iterator, _END) + return (True, None) if chunk is _END else (False, chunk) def _create_chat_completion( self, *, model: str = "gemini-3.7-flash", messages: Optional[List[Dict[str, Any]]] = None, stream: bool = False, From 2f13a6092dd61f34b16e1655952b92e45cfaba3a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:04:27 -0700 Subject: [PATCH 26/30] refactor(agent/gemini): _multimodal_part mapper --- agent/copilot_acp_client.py | 4 ++-- agent/gemini_native_adapter.py | 37 +++++++++++++++------------------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index a7c1133675..96497712c0 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -293,8 +293,8 @@ class CopilotACPClient: return _completion_to_stream_chunks(completion) if stream else completion def _spawn(self) -> subprocess.Popen[str]: - # Fast-fail when the CLI rejects --acp (else the parent waits the full child timeout for - # stdout that never arrives). ``None`` falls through to the spawn's established start error. + # Fast-fail when the CLI rejects --acp (else the parent waits the full child timeout for stdout that + # never arrives). ``None`` falls through to the spawn's established start error. if _acp_supported(self._acp_command, self._acp_args) is False: preview = " ".join(self._acp_args[:3]) if self._acp_args else "(none)" raise RuntimeError( diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 96eef24b4e..7b7041340d 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -83,8 +83,8 @@ def bare_gemini_model_id(model: str) -> str: def gemini_requires_tool_call_ids(model: str) -> bool: - """Gemini 3+ needs explicit functionCall/functionResponse ids so replayed - parallel tool calls pair with their responses; 2.x rejects the field.""" + """Gemini 3+ needs explicit functionCall/functionResponse ids so replayed parallel tool calls + pair with their responses; 2.x rejects the field.""" match = re.match(r"gemini-(\d+)", bare_gemini_model_id(model).lower()) return match is not None and int(match.group(1)) >= 3 @@ -98,8 +98,7 @@ def is_native_gemini_base_url(base_url: str) -> bool: def probe_gemini_tier( api_key: str, base_url: str = DEFAULT_GEMINI_BASE_URL, *, model: str = "gemini-3.7-flash", timeout: float = 10.0 ) -> str: - """Probe a Google AI Studio key; return ``"free"``, ``"paid"`` or ``"unknown"`` - (probe failed — callers should proceed without blocking).""" + """Probe a Google AI Studio key → ``"free"`` | ``"paid"`` | ``"unknown"`` (probe failed; callers proceed without blocking).""" key = (api_key or "").strip() if not key: return "unknown" @@ -148,17 +147,14 @@ def is_standard_key_auth_error(status: int, error_message: str, reason: str = "" class GeminiAPIError(Exception): """Error shape compatible with Hermes retry/error classification.""" - def __init__( - self, message: str, *, code: str = "gemini_api_error", status_code: Optional[int] = None, - response: Optional[httpx.Response] = None, retry_after: Optional[float] = None, details: Optional[Dict[str, Any]] = None, - ) -> None: + def __init__(self, message: str, *, code: str = "gemini_api_error", status_code: Optional[int] = None, + response: Optional[httpx.Response] = None, retry_after: Optional[float] = None, details: Optional[Dict[str, Any]] = None): super().__init__(message) self.code, self.status_code, self.response = code, status_code, response self.retry_after, self.details = retry_after, details or {} # ── OpenAI → Gemini request translation ────────────────────────────────────── - def _text_of(item: Any) -> Optional[str]: """Text of an OpenAI content item (plain str or ``{"type": "text"}`` dict), else None.""" if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str): @@ -185,18 +181,18 @@ def _inline_data_part(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: return None +def _multimodal_part(item: Any) -> Optional[Dict[str, Any]]: + text = _text_of(item) + if text or isinstance(item, str): + return {"text": text} + return _inline_data_part(item) if isinstance(item, dict) else None + + def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]: - if not isinstance(content, list): - text = _coerce_content_to_text(content) - return [{"text": text}] if text else [] - parts: List[Dict[str, Any]] = [] - for item in content: - text = _text_of(item) - if text or isinstance(item, str): - parts.append({"text": text}) - elif isinstance(item, dict) and (image := _inline_data_part(item)): - parts.append(image) - return parts + if isinstance(content, list): + return [p for p in map(_multimodal_part, content) if p] + text = _coerce_content_to_text(content) + return [{"text": text}] if text else [] def _tool_call_extra_signature(tool_call: Dict[str, Any]) -> Optional[str]: @@ -406,7 +402,6 @@ def build_gemini_request( # ── Gemini → OpenAI response translation ───────────────────────────────────── - def _tool_call_extra_from_part(part: Dict[str, Any]) -> Optional[Dict[str, Any]]: sig = part.get("thoughtSignature") return {"google": {"thought_signature": sig}} if isinstance(sig, str) and sig else None From a61a384e0e7bab7974431373ba46dd8513c33bf1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:06:37 -0700 Subject: [PATCH 27/30] refactor(agent/gemini): fold schema detection and call-id lookups --- agent/gemini_native_adapter.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 7b7041340d..84a56a610c 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -175,8 +175,8 @@ def _inline_data_part(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: return None try: header, encoded = url.split(",", 1) - mime = header.split(":", 1)[1].split(";", 1)[0] - return {"inlineData": {"mimeType": mime, "data": base64.b64encode(base64.b64decode(encoded)).decode("ascii")}} + data = base64.b64encode(base64.b64decode(encoded)).decode("ascii") + return {"inlineData": {"mimeType": header.split(":", 1)[1].split(";", 1)[0], "data": data}} except Exception: return None @@ -227,10 +227,7 @@ def _looks_like_json_schema(node: Any) -> bool: tool result that is itself a JSON Schema (e.g. ``tool_describe`` output) is forwarded as opaque text. False positives only lose the structured shape, never the content.""" if isinstance(node, dict): - return any( - (key == "$ref" and isinstance(value, str) and value.startswith("#/")) or _looks_like_json_schema(value) - for key, value in node.items() - ) + return any((k == "$ref" and isinstance(v, str) and v.startswith("#/")) or _looks_like_json_schema(v) for k, v in node.items()) return isinstance(node, list) and any(_looks_like_json_schema(item) for item in node) @@ -304,8 +301,8 @@ def _build_gemini_contents( tool_calls = msg.get("tool_calls") or [] for tool_call in (tc for tc in tool_calls if isinstance(tc, dict)) if isinstance(tool_calls, list) else (): tool_name = str((tool_call.get("function") or {}).get("name") or "") - if _tool_call_id(tool_call) and tool_name: - tool_name_by_call_id[_tool_call_id(tool_call)] = tool_name + if (call_id := _tool_call_id(tool_call)) and tool_name: + tool_name_by_call_id[call_id] = tool_name parts.append(_translate_tool_call_to_gemini(tool_call, include_ids=include_tool_call_ids)) if parts: contents.append({"role": "model" if role == "assistant" else "user", "parts": parts}) From c0b6d4c07dab099f618b35258b57265f8b7749e1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:09:02 -0700 Subject: [PATCH 28/30] refactor(agent/gemini,acp): fold predicates and async stream loop --- agent/copilot_acp_client.py | 7 +++---- agent/gemini_native_adapter.py | 17 ++++++----------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 96497712c0..db3ccd0e2b 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -265,11 +265,10 @@ class CopilotACPClient: with self._active_process_lock: proc, self._active_process = self._active_process, None self.is_closed = True - if proc is None: - return try: - proc.terminate() - proc.wait(timeout=2) + if proc is not None: + proc.terminate() + proc.wait(timeout=2) except Exception: with contextlib.suppress(Exception): proc.kill() diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 84a56a610c..0353623b62 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -136,12 +136,10 @@ def is_free_tier_quota_error(error_message: str) -> bool: def is_standard_key_auth_error(status: int, error_message: str, reason: str = "") -> bool: - """True when a Gemini 401 means Google rejected the key TYPE (legacy "Standard" - Cloud key → misleading "expected OAuth 2 access token" / ErrorInfo - ``ACCESS_TOKEN_TYPE_UNSUPPORTED``). Narrow so ``API_KEY_INVALID`` keeps its message.""" - if status != 401: - return False - return reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED" or "expected oauth 2 access token" in (error_message or "").lower() + """True when a Gemini 401 means Google rejected the key TYPE (legacy "Standard" Cloud key → misleading + "expected OAuth 2 access token" / ErrorInfo ``ACCESS_TOKEN_TYPE_UNSUPPORTED``). Narrow so ``API_KEY_INVALID`` + keeps its message.""" + return status == 401 and (reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED" or "expected oauth 2 access token" in (error_message or "").lower()) class GeminiAPIError(Exception): @@ -684,11 +682,8 @@ class AsyncGeminiNativeClient: return self._async_stream(result) if kwargs.get("stream") else result async def _async_stream(self, iterator: Iterator[_GeminiStreamChunk]) -> Any: - while True: - done, chunk = await asyncio.to_thread(self._sync._advance_stream_iterator, iterator) - if done: - return - yield chunk + while not (step := await asyncio.to_thread(self._sync._advance_stream_iterator, iterator))[0]: + yield step[1] async def close(self) -> None: await asyncio.to_thread(self._sync.close) From 46f52ead28211022b97b4201e0f9d0589401b428 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:10:29 -0700 Subject: [PATCH 29/30] refactor(agent/credits,gemini): tighten docstrings --- agent/credits_tracker.py | 26 ++++++++++---------------- agent/gemini_native_adapter.py | 18 ++++++++---------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index eab8cf278e..82e1b05217 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -306,9 +306,7 @@ def _fixture(remaining: str, subscription: str, limit: Optional[str] = None, pur if limit is not None: d["denominator_kind"] = "subscription_cap" d["paid_access"] = paid - if reason is not None: - d["disabled_reason"] = reason - return d + return d if reason is None else {**d, "disabled_reason": reason} _DEV_FIXTURES: dict[str, dict] = { @@ -326,9 +324,8 @@ _DEV_FIXTURES: dict[str, dict] = { def dev_fixture_credits_state() -> Optional[CreditsState]: - """Fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None (unknown name / unset). - Prod-leak guard: applies ONLY when HERMES_DEV_CREDITS is also on, so a stray - fixture env var can never surface fabricated balances on a real account.""" + """Fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None (unknown name / unset). Prod-leak guard: + applies ONLY when HERMES_DEV_CREDITS is also on, so a stray fixture env var never surfaces fabricated balances.""" name = os.environ.get("HERMES_DEV_CREDITS_FIXTURE", "").strip() if not name or not is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")): return None @@ -346,9 +343,8 @@ def dev_fixture_credits_state() -> Optional[CreditsState]: def _credits_state_from_account(info) -> Optional[CreditsState]: - """Map a NousPortalAccountInfo into a header-shaped CreditsState for the seed. - Float account dollars → micros plus a DISPLAY *_usd (formatting account floats - is allowed; parsing a server *_usd is not). Fail-open → None.""" + """Map a NousPortalAccountInfo into a header-shaped CreditsState for the seed. Float account dollars → + micros plus a DISPLAY *_usd (formatting account floats is allowed; parsing a server *_usd is not). Fail-open → None.""" try: acc = getattr(info, "paid_service_access_info", None) sub = getattr(info, "subscription", None) @@ -373,9 +369,8 @@ def _credits_state_from_account(info) -> Optional[CreditsState]: def _hydrate_seed_state(agent, state) -> None: - """Install a seed CreditsState on the agent and fire the notice policy once. - Primes the crossing gate: the cold-start snapshot IS the first observation, so - a session opening in a band warns immediately. Safe from a worker thread.""" + """Install a seed CreditsState on the agent and fire the notice policy once. Primes the crossing gate: + the cold-start snapshot IS the first observation, so a session opening in a band warns immediately.""" agent._credits_state = state if getattr(agent, "_credits_session_start_micros", None) is None: agent._credits_session_start_micros = state.remaining_micros @@ -387,10 +382,9 @@ def _hydrate_seed_state(agent, state) -> None: def seed_credits_at_session_start(agent) -> bool: - """Hydrate agent._credits_state from the portal account (or a dev fixture) and - fire the notice policy so warnings show at session OPEN (TUI/desktop "ready" and - plain-CLI first-turn setup). Idempotent once a seed or real header populated - _credits_state. Returns True iff it seeded this call. Never raises.""" + """Hydrate agent._credits_state from the portal account (or a dev fixture) and fire the notice policy so + warnings show at session OPEN (TUI/desktop "ready" and plain-CLI first-turn setup). Idempotent once a seed + or real header populated _credits_state. Returns True iff it seeded this call. Never raises.""" try: if getattr(agent, "provider", "") != "nous" or getattr(agent, "_credits_state", None) is not None: return False diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 0353623b62..13611ef41e 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -257,13 +257,12 @@ def _has_function_response(content: Dict[str, Any]) -> bool: def _merge_alternating(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Alternation contract for generateContent: 1) adjacent same-role contents merge - (else HTTP 400 "multiturn requests [must] alternate"); 2) EXCEPT never fuse a human - user text turn into a preceding user content that only carries functionResponse - parts (or vice versa) — Gemini 3 accepts the fold but reads the text as a - continuation of the tool result and returns an empty candidate (parallel - functionResponse + functionResponse still merge); 3) the split pair stays API-valid - via an interposed placeholder model turn.""" + """Alternation contract for generateContent: 1) adjacent same-role contents merge (else HTTP 400 + "multiturn requests [must] alternate"); 2) EXCEPT never fuse a human user text turn into a preceding + user content that only carries functionResponse parts (or vice versa) — Gemini 3 accepts the fold but + reads the text as a continuation of the tool result and returns an empty candidate (parallel + functionResponse + functionResponse still merge); 3) the split pair stays API-valid via an interposed + placeholder model turn.""" merged: List[Dict[str, Any]] = [] for content in contents: prev = merged[-1] if merged else None @@ -350,9 +349,8 @@ def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]: def _thinking_requests_output_headroom(thinking_config: Any) -> bool: - """True when Gemini will spend output tokens on thinking: thought tokens bill against - ``maxOutputTokens``, so a global 4096/16384 cap can be consumed entirely by high - thinking, leaving ``finishReason=MAX_TOKENS`` with no answer.""" + """True when Gemini will spend output tokens on thinking: thought tokens bill against ``maxOutputTokens``, + so a global 4096/16384 cap can be consumed entirely by high thinking (``finishReason=MAX_TOKENS``, no answer).""" normalized = _normalize_thinking_config(thinking_config) or {} budget, has_level = normalized.get("thinkingBudget"), "thinkingLevel" in normalized if normalized.get("includeThoughts") is False: From 1ff3a849b9a6957991aa8790fa8fe2992b87988a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:14:01 -0700 Subject: [PATCH 30/30] refactor(agent/gemini,acp): fold tool-call namespace and finish-reason resolution --- agent/copilot_acp_client.py | 6 ++---- agent/credits_tracker.py | 27 +++++++++++---------------- agent/gemini_native_adapter.py | 13 ++++--------- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index db3ccd0e2b..902b25092e 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -74,8 +74,7 @@ def _resolve_command() -> str: def _resolve_args() -> list[str]: - raw = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() - return shlex.split(raw) if raw else ["--acp", "--stdio"] + return shlex.split(os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()) or ["--acp", "--stdio"] def _acp_supported(command: str, args: list[str]) -> bool | None: @@ -257,8 +256,7 @@ class CopilotACPClient: self._acp_args = list(acp_args or args or _resolve_args()) self._acp_cwd = str(Path(acp_cwd or os.getcwd()).resolve()) self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion)) - self.is_closed = False - self._active_process: subprocess.Popen[str] | None = None + self.is_closed, self._active_process = False, None self._active_process_lock = threading.Lock() def close(self) -> None: diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 82e1b05217..0b54133550 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -103,9 +103,8 @@ def new_credits_latch() -> dict: @dataclass class AgentNotice: - """Driver-agnostic out-of-band notice (``AIAgent.notice_callback`` / ``notice_clear_callback``); - each driver renders its own way. ``kind``/``ttl_ms`` stay expressive so a future - config can switch v1's sticky credits notices to TTL without touching the policy.""" + """Driver-agnostic out-of-band notice (``AIAgent.notice_callback`` / ``notice_clear_callback``); each driver + renders its own way. ``kind``/``ttl_ms`` stay expressive so a future config can switch v1's sticky notices to TTL.""" text: str level: str = "info" # info | warn | error | success @@ -120,13 +119,11 @@ def _sticky_notice(text: str, level: str, key: str) -> AgentNotice: def is_free_tier_model(model: str, base_url: str = "") -> bool: - """True when *model* is a Nous free-tier model, using ONLY local data: - (1) ``:free`` suffix — canonical Nous free SKU marker; (2) ``stealth/`` prefix — - stealth-preview SKUs are free without the suffix (naming-convention trust: a PAID - ``stealth/`` model would wrongly suppress the banner); (3) a PEEK into - ``hermes_cli.models``' pricing cache (filled by the model picker; a miss never - fetches). Fail-open to False (depleted notice still shows): a wrong warning is - recoverable noise; hiding it on a paid model masks a real block.""" + """True when *model* is a Nous free-tier model, using ONLY local data: (1) ``:free`` suffix — canonical + Nous free SKU marker; (2) ``stealth/`` prefix — stealth-preview SKUs are free without the suffix + (naming-convention trust: a PAID ``stealth/`` model would wrongly suppress the banner); (3) a PEEK into + ``hermes_cli.models``' pricing cache (filled by the model picker; a miss never fetches). Fail-open to + False (depleted notice still shows): a wrong warning is recoverable noise; hiding it masks a real block.""" if not model: return False if model.endswith(":free") or model.startswith("stealth/"): @@ -143,12 +140,10 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool: def evaluate_credits_notices(state: CreditsState, latch: dict, *, model_is_free: bool = False) -> tuple[list[AgentNotice], list[str]]: - """Reconcile credits notices against the latch (see :func:`new_credits_latch`); - mutates ``latch`` IN PLACE. Pure — no I/O, no agent/run_agent imports. - ``model_is_free`` suppresses ``credits.depleted`` (a depleted account on a free - model keeps inferencing) WITHOUT emitting "restored" — that fires only on a - genuine ``paid_access`` flip back to True. - Returns ``(to_show, to_clear)``; caller emits to_clear FIRST, then to_show.""" + """Reconcile credits notices against the latch (see :func:`new_credits_latch`); mutates ``latch`` IN + PLACE. Pure — no I/O, no agent/run_agent imports. ``model_is_free`` suppresses ``credits.depleted`` (a + depleted account on a free model keeps inferencing) WITHOUT emitting "restored" — that fires only on a + genuine ``paid_access`` flip back to True. Returns ``(to_show, to_clear)``; caller emits to_clear FIRST.""" to_show: list[AgentNotice] = [] to_clear: list[str] = [] uf, active = state.used_fraction, latch["active"] diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 13611ef41e..fb693723c0 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -429,10 +429,8 @@ def _envelope(model: str, object_: str, choice: SimpleNamespace, usage: Any, cls def _tool_call_ns(name: str, arguments: str, index: int, call_id: str, extra_content: Any) -> SimpleNamespace: """OpenAI-shaped tool call; ``extra_content`` attached only when it is a dict.""" - tool_call = SimpleNamespace(id=call_id, type="function", index=index, function=SimpleNamespace(name=name, arguments=arguments)) - if isinstance(extra_content, dict): - tool_call.extra_content = extra_content - return tool_call + extra = {"extra_content": extra_content} if isinstance(extra_content, dict) else {} + return SimpleNamespace(id=call_id, type="function", index=index, function=SimpleNamespace(name=name, arguments=arguments), **extra) def _part_text(part: Dict[str, Any]) -> tuple[Optional[str], bool]: @@ -463,11 +461,8 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa pieces[is_thought].append(text) elif fc := _part_function_call(part): tool_calls.append(_tool_call_ns(str(fc["name"]), _dump_call_args(fc), index, _new_call_id(fc), _tool_call_extra_from_part(part))) - if cand is None: - finish_reason, usage = "stop", _usage_from_metadata({}) - else: - finish_reason = "tool_calls" if tool_calls else _FINISH_REASON_MAP.get(str(cand.get("finishReason") or "").upper(), "stop") - usage = _usage_from_metadata(resp.get("usageMetadata") or {}) + finish_reason = "tool_calls" if tool_calls else _FINISH_REASON_MAP.get(str((cand or {}).get("finishReason") or "").upper(), "stop") + usage = _usage_from_metadata((resp.get("usageMetadata") or {}) if cand is not None else {}) reasoning = "".join(pieces[True]) or None message = SimpleNamespace(role="assistant", content="".join(pieces[False]) if pieces[False] else ("" if cand is None else None), tool_calls=tool_calls or None, reasoning=reasoning, reasoning_content=reasoning, reasoning_details=None)