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)