mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
ci: apply all automatic linter fixes available
This commit is contained in:
1 parent
f6258f6cfb
commit
d76cce4f11
2366 files changed
+14579
-14723
No files matched your search
@@ -11,7 +11,7 @@ Usage::
|
||||
# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
|
||||
# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale.
|
||||
try:
|
||||
import hermes_bootstrap # noqa: F401
|
||||
import hermes_bootstrap
|
||||
except ModuleNotFoundError as exc:
|
||||
# Partial ``hermes update`` (git-reset landed, ``uv pip install -e .`` did not).
|
||||
if exc.name != "hermes_bootstrap":
|
||||
@@ -111,8 +111,8 @@ def _print_version() -> None:
|
||||
|
||||
|
||||
def _run_check() -> None:
|
||||
import acp # noqa: F401
|
||||
from acp_adapter.server import HermesACPAgent # noqa: F401
|
||||
import acp
|
||||
from acp_adapter.server import HermesACPAgent
|
||||
|
||||
print("Hermes ACP check OK")
|
||||
|
||||
|
||||
+10
-10
@@ -67,7 +67,7 @@ def _send_update(conn: acp.Client, session_id: str, loop: asyncio.AbstractEventL
|
||||
logger.debug("Failed to send ACP update", exc_info=True)
|
||||
|
||||
|
||||
def _upgrade_queue(tool_call_ids: Dict[str, Deque[str]], name: str) -> Deque[str] | None:
|
||||
def _upgrade_queue(tool_call_ids: dict[str, deque[str]], name: str) -> deque[str] | None:
|
||||
"""Fetch the per-tool FIFO of pending call IDs, upgrading a legacy bare-string entry in place."""
|
||||
queue = tool_call_ids.get(name)
|
||||
if isinstance(queue, str):
|
||||
@@ -76,8 +76,8 @@ def _upgrade_queue(tool_call_ids: Dict[str, Deque[str]], name: str) -> Deque[str
|
||||
|
||||
|
||||
def close_tool_call(
|
||||
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, tool_call_ids: Dict[str, Deque[str]],
|
||||
tool_call_meta: Dict[str, Dict[str, Any]], name: str, result: Any = None, is_error: bool = False,
|
||||
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, tool_call_ids: dict[str, deque[str]],
|
||||
tool_call_meta: dict[str, dict[str, Any]], name: str, result: Any = None, is_error: bool = False,
|
||||
) -> str | None:
|
||||
"""Close the oldest open ACP tool call for ``name``; returns its id, or None when none is open."""
|
||||
queue = _upgrade_queue(tool_call_ids, name)
|
||||
@@ -95,8 +95,8 @@ def close_tool_call(
|
||||
|
||||
|
||||
def flush_open_tool_calls(
|
||||
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, tool_call_ids: Dict[str, Deque[str]],
|
||||
tool_call_meta: Dict[str, Dict[str, Any]],
|
||||
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, tool_call_ids: dict[str, deque[str]],
|
||||
tool_call_meta: dict[str, dict[str, Any]],
|
||||
) -> int:
|
||||
"""Close every tool call still open at the end of a turn, and report how many there were.
|
||||
|
||||
@@ -117,10 +117,10 @@ def flush_open_tool_calls(
|
||||
|
||||
|
||||
def make_tool_progress_cb(
|
||||
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, tool_call_ids: Dict[str, Deque[str]],
|
||||
tool_call_meta: Dict[str, Dict[str, Any]],
|
||||
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, tool_call_ids: dict[str, deque[str]],
|
||||
tool_call_meta: dict[str, dict[str, Any]],
|
||||
edit_approval_policy_getter: Callable[[], tuple[str, str | None]] | None = None,
|
||||
turn_state: Dict[str, Any] | None = None,
|
||||
turn_state: dict[str, Any] | None = None,
|
||||
) -> Callable:
|
||||
"""Create a ``tool_progress_callback`` for AIAgent.
|
||||
|
||||
@@ -253,8 +253,8 @@ def make_message_cb(
|
||||
|
||||
|
||||
def make_step_cb(
|
||||
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, tool_call_ids: Dict[str, Deque[str]],
|
||||
tool_call_meta: Dict[str, Dict[str, Any]], turn_state: Dict[str, Any] | None = None,
|
||||
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, tool_call_ids: dict[str, deque[str]],
|
||||
tool_call_meta: dict[str, dict[str, Any]], turn_state: dict[str, Any] | None = None,
|
||||
) -> Callable:
|
||||
"""Create a ``step_callback(api_call_count: int, prev_tools: list)`` for AIAgent."""
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, st
|
||||
}
|
||||
|
||||
def _entry_catalog(entry: dict) -> tuple[str, str, list[tuple[str, str]]] | None:
|
||||
field = lambda key: str(entry.get(key) or "").strip() # noqa: E731
|
||||
field = lambda key: str(entry.get(key) or "").strip()
|
||||
provider_key, name, base_url = field("provider_key"), field("name"), field("base_url")
|
||||
if provider_key.lower() in disabled_keys or not name or not base_url:
|
||||
return None
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Any, Dict, Optional
|
||||
_MAX_WALK = 100
|
||||
|
||||
|
||||
def _get_row(db: Any, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
def _get_row(db: Any, session_id: str) -> Optional[dict[str, Any]]:
|
||||
try:
|
||||
return db.get_session(session_id)
|
||||
except Exception:
|
||||
@@ -31,7 +31,7 @@ def _is_compression_end(row: Any) -> bool:
|
||||
|
||||
def build_session_provenance(
|
||||
db: Any, acp_session_id: str, current_hermes_session_id: str, *, previous_hermes_session_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Build ``_meta.hermes.sessionProvenance`` for an ACP session.
|
||||
|
||||
``db`` must expose ``get_session``. ``current_hermes_session_id`` is the live
|
||||
@@ -62,7 +62,7 @@ def build_session_provenance(
|
||||
# A continuation is a session whose immediate parent ended with end_reason='compression'.
|
||||
is_continuation = bool(parent_id) and _is_compression_end(_get_row(db, parent_id))
|
||||
|
||||
provenance: Dict[str, Any] = {
|
||||
provenance: dict[str, Any] = {
|
||||
"acpSessionId": acp_session_id, "currentHermesSessionId": current_hermes_session_id,
|
||||
"rootHermesSessionId": root_id, "parentHermesSessionId": parent_id,
|
||||
"sessionKind": "continuation" if is_continuation else "root", "compressionDepth": compression_depth,
|
||||
@@ -79,7 +79,7 @@ def build_session_provenance(
|
||||
|
||||
def session_provenance_meta(
|
||||
db: Any, acp_session_id: str, current_hermes_session_id: str, *, previous_hermes_session_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Return a ready ``_meta`` payload: ``{"hermes": {"sessionProvenance": ...}}``."""
|
||||
prov = build_session_provenance(db, acp_session_id, current_hermes_session_id,
|
||||
previous_hermes_session_id=previous_hermes_session_id)
|
||||
|
||||
@@ -899,13 +899,13 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
"""Install the ACP streaming callbacks on the session agent for one turn."""
|
||||
cbs = _TurnCallbacks()
|
||||
if conn:
|
||||
tool_call_ids: dict[str, Deque[str]] = defaultdict(deque)
|
||||
tool_call_ids: dict[str, deque[str]] = defaultdict(deque)
|
||||
tool_call_meta: dict[str, dict[str, Any]] = {}
|
||||
cbs.tool_call_ids, cbs.tool_call_meta = tool_call_ids, tool_call_meta
|
||||
# Shared with the step callback so a runtime that projects
|
||||
# ``tool.completed`` closes each call once, not twice.
|
||||
turn_state: dict[str, Any] = {}
|
||||
policy_getter = lambda: self._edit_approval_policy_for_state(state) # noqa: E731
|
||||
policy_getter = lambda: self._edit_approval_policy_for_state(state)
|
||||
cbs.tool_progress_cb = make_tool_progress_cb(
|
||||
conn, session_id, loop, tool_call_ids, tool_call_meta, edit_approval_policy_getter=policy_getter,
|
||||
turn_state=turn_state,
|
||||
@@ -925,7 +925,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
|
||||
cbs.stream_delta_cb = stream_delta_cb
|
||||
# Closes the synthetic permission-request bubble once the user has answered.
|
||||
send_update = lambda update: _send_update(conn, session_id, loop, update) # noqa: E731
|
||||
send_update = lambda update: _send_update(conn, session_id, loop, update)
|
||||
cbs.approval_cb = make_approval_callback(conn.request_permission, loop, session_id, send_update=send_update)
|
||||
try:
|
||||
from acp_adapter.edit_approval import make_acp_edit_approval_requester
|
||||
|
||||
@@ -100,8 +100,8 @@ def _register_task_cwd(task_id: str, cwd: str) -> None:
|
||||
logger.debug("Failed to register ACP task cwd override", exc_info=True)
|
||||
|
||||
|
||||
def _expand_acp_enabled_toolsets(toolsets: List[str] | None = None,
|
||||
mcp_server_names: List[str] | None = None) -> List[str]:
|
||||
def _expand_acp_enabled_toolsets(toolsets: list[str] | None = None,
|
||||
mcp_server_names: list[str] | None = None) -> list[str]:
|
||||
"""Return ACP toolsets plus explicit MCP server toolsets for this session."""
|
||||
names = [n for n in (["hermes-acp"] if toolsets is None else toolsets) if n]
|
||||
names += [f"mcp-{s}" for s in (mcp_server_names or []) if s]
|
||||
@@ -118,12 +118,12 @@ def _parse_model_config(mc: Any) -> dict:
|
||||
|
||||
|
||||
def _session_info(sid: str, cwd: str, model: Any, history_len: int, title: Any, preview: Any,
|
||||
updated_at: Any) -> Dict[str, Any]:
|
||||
updated_at: Any) -> dict[str, Any]:
|
||||
return {"session_id": sid, "cwd": cwd, "model": model, "history_len": history_len,
|
||||
"title": _build_session_title(title, preview, cwd), "updated_at": _format_updated_at(updated_at)}
|
||||
|
||||
|
||||
def _first_user_preview(history: List[Dict[str, Any]], default: str) -> str:
|
||||
def _first_user_preview(history: list[dict[str, Any]], default: str) -> str:
|
||||
return next((str(m.get("content") or "").strip() for m in history
|
||||
if m.get("role") == "user" and str(m.get("content") or "").strip()), default)
|
||||
|
||||
@@ -136,7 +136,7 @@ class SessionState:
|
||||
agent: Any # AIAgent instance
|
||||
cwd: str = "."
|
||||
model: str = ""
|
||||
history: List[Dict[str, Any]] = field(default_factory=list)
|
||||
history: list[dict[str, Any]] = field(default_factory=list)
|
||||
cancel_event: Any = None # threading.Event
|
||||
is_running: bool = False
|
||||
# A state-mutating slash command (/reset, /compress, /model) is in flight. Turn claims
|
||||
@@ -144,7 +144,7 @@ class SessionState:
|
||||
# a bare is_running check in the slash thread would leave a check-then-act window where
|
||||
# a prompt claims the turn mid-mutation.
|
||||
command_op: bool = False
|
||||
queued_prompts: List[str] = field(default_factory=list)
|
||||
queued_prompts: list[str] = field(default_factory=list)
|
||||
runtime_lock: Any = field(default_factory=threading.Lock)
|
||||
current_prompt_text: str = ""
|
||||
interrupted_prompt_text: str = ""
|
||||
@@ -162,7 +162,7 @@ class SessionManager:
|
||||
def __init__(self, agent_factory=None, db=None):
|
||||
"""``agent_factory``: AIAgent-like factory (tests); default builds a real AIAgent from
|
||||
the runtime provider config. ``db``: SessionDB; default lazily opens ``~/.hermes/state.db``."""
|
||||
self._sessions: Dict[str, SessionState] = {}
|
||||
self._sessions: dict[str, SessionState] = {}
|
||||
self._lock = threading.Lock()
|
||||
# Serializes DB restores: session construction runs off the event loop, so two
|
||||
# overlapping session/load for one id must share a single agent build.
|
||||
@@ -207,7 +207,7 @@ class SessionManager:
|
||||
logger.info("Forked ACP session %s -> %s", session_id, new_id)
|
||||
return state
|
||||
|
||||
def list_sessions(self, cwd: str | None = None) -> List[Dict[str, Any]]:
|
||||
def list_sessions(self, cwd: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return lightweight info dicts for all sessions (memory + database)."""
|
||||
normalized_cwd = _normalize_cwd_for_compare(cwd) if cwd else None
|
||||
db = self._get_db()
|
||||
@@ -304,7 +304,7 @@ class SessionManager:
|
||||
# ---- persistence via SessionDB ------------------------------------------
|
||||
|
||||
def _install_state(self, session_id: str, agent: Any, cwd: str, model: str,
|
||||
history: List[Dict[str, Any]], *, persist: bool = True) -> SessionState:
|
||||
history: list[dict[str, Any]], *, persist: bool = True) -> SessionState:
|
||||
"""Build a SessionState, register it in memory, bind its cwd for tools, optionally persist."""
|
||||
state = SessionState(session_id=session_id, agent=agent, cwd=cwd, model=model,
|
||||
history=history, cancel_event=threading.Event())
|
||||
|
||||
+14
-14
@@ -15,7 +15,7 @@ from agent.display import build_tool_preview
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hermes tool name -> ACP ToolKind (anything unlisted is "other").
|
||||
TOOL_KIND_MAP: Dict[str, ToolKind] = {
|
||||
TOOL_KIND_MAP: dict[str, ToolKind] = {
|
||||
name: kind
|
||||
for kind, names in {
|
||||
"read": ("read_file", "skill_view", "skills_list", "browser_snapshot", "browser_vision",
|
||||
@@ -55,7 +55,7 @@ _POLISHED_TOOLS = {
|
||||
}
|
||||
|
||||
_EMPTYISH = (None, "", [], {})
|
||||
Args = Dict[str, Any]
|
||||
Args = dict[str, Any]
|
||||
_Formatter = Callable[[str, Optional[str], Optional[Args]], Optional[str]]
|
||||
|
||||
|
||||
@@ -521,7 +521,7 @@ def _format_media_or_cron_result(tool_name: str, data: Args, args: Args) -> Opti
|
||||
return "\n".join([f"✅ {tool_name} completed", *(f"- **{k}:** {data.get(k)}" for k in keys if data.get(k))])
|
||||
|
||||
|
||||
def _format_structured_value(key: str, value: Any, *, indent: int = 0, max_depth: int = 3, max_items: int = 8) -> List[str]:
|
||||
def _format_structured_value(key: str, value: Any, *, indent: int = 0, max_depth: int = 3, max_items: int = 8) -> list[str]:
|
||||
"""Render nested JSON-ish values as compact Markdown bullets, not inline blobs."""
|
||||
pad = " " * indent
|
||||
bullet = f"{pad}- "
|
||||
@@ -530,7 +530,7 @@ def _format_structured_value(key: str, value: Any, *, indent: int = 0, max_depth
|
||||
def _line(text: str) -> str:
|
||||
return f"{bullet}{label} {text}" if label else f"{bullet}{text}"
|
||||
|
||||
def _child(child_key: str, child_value: Any, extra_indent: int) -> List[str]:
|
||||
def _child(child_key: str, child_value: Any, extra_indent: int) -> list[str]:
|
||||
return _format_structured_value(child_key, child_value, indent=indent + extra_indent, max_depth=max_depth - 1,
|
||||
max_items=max_items)
|
||||
|
||||
@@ -617,7 +617,7 @@ def _format_generic_structured_result(tool_name: str, result: Optional[str], *,
|
||||
return _truncate_text("\n".join(lines), limit=7000)
|
||||
|
||||
|
||||
_COMPLETION_FORMATTERS: Dict[str, _Formatter] = {
|
||||
_COMPLETION_FORMATTERS: dict[str, _Formatter] = {
|
||||
"todo": _format_todo_result,
|
||||
"read_file": _format_read_file_result,
|
||||
"write_file": _format_edit_result,
|
||||
@@ -636,10 +636,10 @@ _COMPLETION_FORMATTERS: Dict[str, _Formatter] = {
|
||||
}
|
||||
|
||||
|
||||
def _parse_unified_diff_content(diff_text: str) -> List[Any]:
|
||||
def _parse_unified_diff_content(diff_text: str) -> list[Any]:
|
||||
"""Convert unified diff text into ACP diff content blocks (one per ``---``/``+++`` pair)."""
|
||||
content: List[Any] = []
|
||||
state: Dict[str, Any] = {"old": None, "new": None, "old_lines": [], "new_lines": []}
|
||||
content: list[Any] = []
|
||||
state: dict[str, Any] = {"old": None, "new": None, "old_lines": [], "new_lines": []}
|
||||
|
||||
def _flush() -> None:
|
||||
old_path, new_path = state["old"], state["new"]
|
||||
@@ -674,7 +674,7 @@ def _parse_unified_diff_content(diff_text: str) -> List[Any]:
|
||||
|
||||
def _build_tool_complete_content(
|
||||
tool_name: str, result: Optional[str], *, function_args: Optional[Args] = None, snapshot: Any = None
|
||||
) -> List[Any]:
|
||||
) -> list[Any]:
|
||||
"""Build structured ACP completion content, falling back to plain text."""
|
||||
if tool_name == "skill_manage":
|
||||
try:
|
||||
@@ -697,7 +697,7 @@ def _build_tool_complete_content(
|
||||
# --- ToolCallStart / ToolCallProgress events ---------------------------------
|
||||
|
||||
|
||||
def _more(items: list, shown: int, unit: str = "") -> List[str]:
|
||||
def _more(items: list, shown: int, unit: str = "") -> list[str]:
|
||||
"""``["... N more<unit>"]`` trailer when ``items`` overflowed the ``shown`` cap, else ``[]``."""
|
||||
return [f"... {len(items) - shown} more{unit}"] if len(items) > shown else []
|
||||
|
||||
@@ -724,7 +724,7 @@ def _start_skill_manage(args: Args) -> Any:
|
||||
target = str(args.get("file_path") or "file")
|
||||
return acp.tool_diff_content(path=f"skills/{name}/{target}", new_text=str(args.get("file_content") or ""))
|
||||
if action in {"delete", "remove_file"}:
|
||||
return f"Removing {str(args.get('file_path') or file_path)} from skill '{name}'"
|
||||
return f"Removing {args.get('file_path') or file_path!s} from skill '{name}'"
|
||||
return f"Running skill_manage action '{action}' on skill '{name}' ({file_path})"
|
||||
|
||||
|
||||
@@ -751,7 +751,7 @@ def _preview(label: str, value: str, limit: int) -> str:
|
||||
# Per-tool start-content builders returning text or one ACP content block. ``None`` means the
|
||||
# title/location already identify the target (read_file, web_extract): a synthetic content block
|
||||
# would make Zed render an unhelpful Output section before completion.
|
||||
_START_CONTENT_BUILDERS: Dict[str, Optional[Callable[[Args], Any]]] = {
|
||||
_START_CONTENT_BUILDERS: dict[str, Optional[Callable[[Args], Any]]] = {
|
||||
"patch": lambda a: (
|
||||
f"Preparing {a.get('mode', 'replace')} edit for {a.get('path') or 'patch input'}. Approval prompt shows the diff."
|
||||
),
|
||||
@@ -787,7 +787,7 @@ def build_tool_start(tool_call_id: str, tool_name: str, arguments: Args, *, edit
|
||||
(mirrors ``get_cute_tool_message`` in ``agent/display.py``)."""
|
||||
try:
|
||||
return _build_tool_start(tool_call_id, tool_name, arguments, edit_diff=edit_diff)
|
||||
except Exception as exc: # noqa: BLE001 — a tool-call render must never abort the turn
|
||||
except Exception as exc:
|
||||
logger.debug("ACP tool-start render failed for %r: %s", tool_name, exc)
|
||||
safe_name = tool_name if isinstance(tool_name, str) and tool_name else "tool"
|
||||
return acp.start_tool_call(tool_call_id, safe_name, kind=get_tool_kind(safe_name), content=None, locations=[])
|
||||
@@ -846,7 +846,7 @@ def build_tool_abandoned(tool_call_id: str, tool_name: str) -> ToolCallProgress:
|
||||
)
|
||||
|
||||
|
||||
def extract_locations(arguments: Args) -> List[ToolCallLocation]:
|
||||
def extract_locations(arguments: Args) -> list[ToolCallLocation]:
|
||||
"""Extract file-system locations from tool arguments."""
|
||||
if not (path := arguments.get("path")):
|
||||
return []
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Agent internals extracted from run_agent.py so it stays focused on AIAgent."""
|
||||
|
||||
from . import jiter_preload as _jiter_preload # noqa: F401
|
||||
from . import jiter_preload as _jiter_preload
|
||||
+34
-34
@@ -170,7 +170,7 @@ def _custom_provider_runtime_ids(value: Any) -> set[str]:
|
||||
|
||||
|
||||
def _build_codex_gpt5_autoraise_notice(
|
||||
autoraise: Dict[str, Any], context_length: Optional[int] = None
|
||||
autoraise: dict[str, Any], context_length: Optional[int] = None
|
||||
) -> str:
|
||||
"""One-time notice when Codex gpt-5.x raises compaction (``autoraise``: model/from/to).
|
||||
|
||||
@@ -197,7 +197,7 @@ def _build_codex_gpt5_autoraise_notice(
|
||||
def _resolve_compression_threshold(
|
||||
global_threshold: float, model_cthresh: Optional[float], *, model: Optional[str] = None,
|
||||
is_codex_autoraise: bool,
|
||||
) -> tuple[float, Optional[Dict[str, Any]]]:
|
||||
) -> tuple[float, Optional[dict[str, Any]]]:
|
||||
"""Global compaction threshold merged with a per-model override.
|
||||
|
||||
Returns ``(threshold, autoraise_notice)``; the notice is set only when a Codex autoraise
|
||||
@@ -218,7 +218,7 @@ def _codex_gpt55_autoraise_notice_marker():
|
||||
return get_hermes_home() / ".codex_gpt55_autoraise_notice"
|
||||
|
||||
|
||||
def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str:
|
||||
def _codex_gpt55_autoraise_notice_state(autoraise: dict[str, Any]) -> str:
|
||||
"""Notice identity keyed on what it displays (model + from→to percentages).
|
||||
|
||||
An unchanged threshold stays silent across restarts; a changed global threshold or a
|
||||
@@ -230,7 +230,7 @@ def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str:
|
||||
return f"{model}:{from_pct}:{to_pct}"
|
||||
|
||||
|
||||
def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool:
|
||||
def _codex_gpt55_autoraise_notice_seen(autoraise: dict[str, Any]) -> bool:
|
||||
"""True if this exact notice was already shown for this profile (unreadable = unseen)."""
|
||||
try:
|
||||
current = _codex_gpt55_autoraise_notice_state(autoraise)
|
||||
@@ -241,7 +241,7 @@ def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None:
|
||||
def _record_codex_gpt55_autoraise_notice(autoraise: dict[str, Any]) -> None:
|
||||
"""Persist that the notice was shown. Best-effort: a failure only re-shows it later."""
|
||||
with suppress(OSError, KeyError, TypeError, ValueError):
|
||||
marker = _codex_gpt55_autoraise_notice_marker()
|
||||
@@ -255,7 +255,7 @@ def _normalized_custom_base_url(value: Any) -> str:
|
||||
return value.strip().rstrip("/")
|
||||
|
||||
|
||||
def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> bool:
|
||||
def _custom_provider_model_matches(agent_model: str, entry: dict[str, Any]) -> bool:
|
||||
agent_model_norm = str(agent_model or "").strip().lower()
|
||||
# Multi-model entries (`providers.<name>.models` mapping / legacy `models:` list):
|
||||
# matching ANY catalog entry counts, else a provider whose `model` differs from the
|
||||
@@ -269,8 +269,8 @@ def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> b
|
||||
|
||||
|
||||
def _custom_provider_extra_body_for_agent(
|
||||
*, provider: str, model: str, base_url: str, custom_providers: List[Dict[str, Any]]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
*, provider: str, model: str, base_url: str, custom_providers: list[dict[str, Any]]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
provider_norm = (provider or "").strip().lower()
|
||||
if provider_norm != "custom" and not provider_norm.startswith("custom:"):
|
||||
return None
|
||||
@@ -279,7 +279,7 @@ def _custom_provider_extra_body_for_agent(
|
||||
if not target_url:
|
||||
return None
|
||||
|
||||
fallback: Optional[Dict[str, Any]] = None
|
||||
fallback: Optional[dict[str, Any]] = None
|
||||
for entry in custom_providers or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -302,7 +302,7 @@ def _custom_provider_extra_body_for_agent(
|
||||
return fallback
|
||||
|
||||
|
||||
def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, Any]]) -> None:
|
||||
def _merge_custom_provider_extra_body(agent, custom_providers: list[dict[str, Any]]) -> None:
|
||||
extra_body = _custom_provider_extra_body_for_agent(
|
||||
provider=agent.provider, model=agent.model, base_url=agent.base_url,
|
||||
custom_providers=custom_providers,
|
||||
@@ -361,12 +361,12 @@ def _parse_config_int(raw: Any, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _cfg_flag(cfg: Dict[str, Any], key: str, default: bool) -> bool:
|
||||
def _cfg_flag(cfg: dict[str, Any], key: str, default: bool) -> bool:
|
||||
"""Legacy string-set truthiness used by the ``compression`` section."""
|
||||
return str(cfg.get(key, default)).lower() in {"true", "1", "yes"}
|
||||
|
||||
|
||||
def _cfg_dict(cfg: Dict[str, Any], key: str) -> Dict[str, Any]:
|
||||
def _cfg_dict(cfg: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
"""``cfg[key]`` if it is a mapping, else ``{}`` (malformed sections are ignored)."""
|
||||
section = cfg.get(key, {})
|
||||
return section if isinstance(section, dict) else {}
|
||||
@@ -519,14 +519,14 @@ def _finalize_routing(agent, api_mode, credential_pool):
|
||||
).start()
|
||||
|
||||
|
||||
def _set_defaults(agent, table: Dict[str, Any]) -> None:
|
||||
def _set_defaults(agent, table: dict[str, Any]) -> None:
|
||||
"""Assign each ``name -> value`` on ``agent``; callables are factories (fresh per agent)."""
|
||||
for name, value in table.items():
|
||||
setattr(agent, name, value() if callable(value) else value)
|
||||
|
||||
|
||||
# Control-flow state (interrupts / steer / redirect / delegation / background review).
|
||||
_CONTROL_STATE: Dict[str, Any] = {
|
||||
_CONTROL_STATE: dict[str, Any] = {
|
||||
"_executing_tools": False, # lets _vprint print while tools run with stream consumers on
|
||||
"_trim_after_tool_batch": False, # a >=1 MB tool result was committed; trim once the batch unwinds
|
||||
"_tool_guardrails": ToolCallGuardrailController,
|
||||
@@ -565,7 +565,7 @@ _CONTROL_STATE: Dict[str, Any] = {
|
||||
}
|
||||
|
||||
# Per-turn bookkeeping: budgets, activity tracking, rate-limit/credits telemetry.
|
||||
_TURN_STATE: Dict[str, Any] = {
|
||||
_TURN_STATE: dict[str, Any] = {
|
||||
# Intermediate pressure warnings made models give up early; ordinary conversations
|
||||
# remain opt-in. Dispatcher workers receive a bounded completion checkpoint.
|
||||
"_iteration_budget_warning_injected": False,
|
||||
@@ -595,7 +595,7 @@ _TURN_STATE: Dict[str, Any] = {
|
||||
}
|
||||
|
||||
# Session persistence state.
|
||||
_SESSION_STATE: Dict[str, Any] = {
|
||||
_SESSION_STATE: dict[str, Any] = {
|
||||
"_session_messages": list,
|
||||
# Responses encrypted-reasoning replay. The first ``invalid_encrypted_content`` rejection only
|
||||
# strips the stale blobs (a rotated sealing key); a second one means the route cannot round-trip
|
||||
@@ -636,7 +636,7 @@ _SESSION_STATE: Dict[str, Any] = {
|
||||
}
|
||||
|
||||
# Streaming delivery state.
|
||||
_STREAM_STATE: Dict[str, Any] = {
|
||||
_STREAM_STATE: dict[str, Any] = {
|
||||
"_stream_callback": None, # streaming TTS; set early so _vprint can reference it
|
||||
"_stream_needs_break": False, # one "\n\n" before the next real text delta after tools
|
||||
# Stateful scrubbers: <memory-context> / thinking spans split across deltas defeat
|
||||
@@ -765,7 +765,7 @@ def _init_anthropic_client(agent, api_key, base_url, _provider_timeout):
|
||||
try:
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001 — never block startup on this
|
||||
except Exception as _mm_exc:
|
||||
logging.getLogger(__name__).warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"(%s); falling back to static bearer that will expire ~15min in.",
|
||||
@@ -813,7 +813,7 @@ def _init_bedrock_client(agent, base_url):
|
||||
print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock, {agent._bedrock_region}{_gr_label})")
|
||||
|
||||
|
||||
def _explicit_client_kwargs(agent, api_key, base_url, _provider_timeout) -> Dict[str, Any]:
|
||||
def _explicit_client_kwargs(agent, api_key, base_url, _provider_timeout) -> dict[str, Any]:
|
||||
"""OpenAI-client kwargs from explicit CLI/gateway credentials (auth already resolved)."""
|
||||
_parsed_url = urlparse(base_url)
|
||||
client_kwargs = {"api_key": api_key, "base_url": base_url}
|
||||
@@ -844,7 +844,7 @@ def _explicit_client_kwargs(agent, api_key, base_url, _provider_timeout) -> Dict
|
||||
return client_kwargs
|
||||
|
||||
|
||||
def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Optional[Dict[str, Any]]:
|
||||
def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Optional[dict[str, Any]]:
|
||||
"""OpenAI-client kwargs via the centralized provider router (no explicit creds).
|
||||
|
||||
Falls through to the init-time fallback chain, then raises with the missing-key /
|
||||
@@ -953,7 +953,7 @@ def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Optional[
|
||||
)
|
||||
|
||||
|
||||
def _apply_openai_header_policy(agent, client_kwargs: Dict[str, Any]) -> None:
|
||||
def _apply_openai_header_policy(agent, client_kwargs: dict[str, Any]) -> None:
|
||||
"""Mutate ``client_kwargs`` (== ``agent._client_kwargs``) with header/TLS policy, in order:
|
||||
OpenRouter Claude beta header → model.default_headers → custom-provider TLS/extra_headers."""
|
||||
# Fine-grained tool streaming for Claude on OpenRouter: without the beta header
|
||||
@@ -1054,7 +1054,7 @@ def _lazy_headers(module: str, name: str, pass_key: bool = False, pass_base: boo
|
||||
|
||||
# Host → default_headers factory for explicit base_url client construction. Ordered: first
|
||||
# host match wins; no match falls back to the provider profile's declared headers.
|
||||
_HOST_DEFAULT_HEADERS: List[tuple[str, Callable[[Any, str], Dict[str, str]]]] = [
|
||||
_HOST_DEFAULT_HEADERS: list[tuple[str, Callable[[Any, str], dict[str, str]]]] = [
|
||||
("openrouter.ai", _lazy_headers("agent.auxiliary_client", "build_or_headers")),
|
||||
("integrate.api.nvidia.com",
|
||||
_lazy_headers("agent.auxiliary_client", "build_nvidia_nim_headers", pass_base=True)),
|
||||
@@ -1074,7 +1074,7 @@ def _host_default_headers_factory(base_url: str):
|
||||
return None
|
||||
|
||||
|
||||
def _client_kwargs_from_routed(client, timeout) -> Dict[str, Any]:
|
||||
def _client_kwargs_from_routed(client, timeout) -> dict[str, Any]:
|
||||
"""OpenAI-client kwargs mirroring a router-resolved client, keeping its provider headers
|
||||
(SDK stores them in ``_custom_headers``; older/mocked clients expose ``default_headers``)."""
|
||||
kwargs = {"api_key": client.api_key, "base_url": str(client.base_url)}
|
||||
@@ -1255,7 +1255,7 @@ def _apply_display_config(agent, _agent_cfg, platform):
|
||||
_ra().logger.warning("Tool loop guardrail config ignored: %s", _tlg_err)
|
||||
|
||||
|
||||
def _memory_provider_init_kwargs(agent, platform) -> Dict[str, Any]:
|
||||
def _memory_provider_init_kwargs(agent, platform) -> dict[str, Any]:
|
||||
"""Scoping kwargs for ``MemoryManager.initialize_all`` (status_callback is CLI-only:
|
||||
gateway status travels a different path and the indicator no-ops without it)."""
|
||||
kwargs = {
|
||||
@@ -1456,7 +1456,7 @@ def _positive_int(raw: Any, *, reject: tuple = ()) -> Optional[int]:
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _compression_threshold(agent, cfg: Dict[str, Any]) -> tuple[float, bool]:
|
||||
def _compression_threshold(agent, cfg: dict[str, Any]) -> tuple[float, bool]:
|
||||
"""Global threshold merged with the per-model override; stashes the autoraise notice.
|
||||
Codex gpt-5.4/5.5 raise to 85% (272K cap → 50% would compact at ~136K); the opt-out flag
|
||||
restores the global value, and the notice has its own display gate."""
|
||||
@@ -1487,7 +1487,7 @@ def _compression_threshold(agent, cfg: Dict[str, Any]) -> tuple[float, bool]:
|
||||
return threshold, notice_enabled
|
||||
|
||||
|
||||
def _compression_codex_settings(cfg: Dict[str, Any]) -> tuple[str, bool, Optional[int]]:
|
||||
def _compression_codex_settings(cfg: dict[str, Any]) -> tuple[str, bool, Optional[int]]:
|
||||
"""``codex_app_server_auto`` / ``codex_responses_native`` / ``codex_responses_compact_threshold``."""
|
||||
app_server_auto = str(cfg.get("codex_app_server_auto", "native") or "native").lower()
|
||||
if app_server_auto not in {"native", "hermes", "off"}:
|
||||
@@ -2300,7 +2300,7 @@ def _init_usage_state(agent):
|
||||
|
||||
|
||||
# Per-session usage accounting.
|
||||
_USAGE_STATE: Dict[str, Any] = {
|
||||
_USAGE_STATE: dict[str, Any] = {
|
||||
"_user_turn_count": 0,
|
||||
"_is_user_initiated_turn": False, # Copilot x-initiator: first call of a user turn = "user"
|
||||
# Usage anchors (agent/usage_anchor.py): last response's exact usage + transcript
|
||||
@@ -2362,11 +2362,11 @@ def init_agent(
|
||||
agent, base_url: str = None, api_key: str = None, provider: str = None, api_mode: str = None,
|
||||
acp_command: str = None, acp_args: list[str] | None = None, command: str = None,
|
||||
args: list[str] | None = None, model: str = "", max_iterations: int = sys.maxsize,
|
||||
enabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None,
|
||||
enabled_toolsets: list[str] = None, disabled_toolsets: list[str] = None,
|
||||
save_trajectories: bool = False, verbose_logging: bool = False, quiet_mode: bool = False,
|
||||
tool_progress_mode: str = "all", ephemeral_system_prompt: str = None,
|
||||
log_prefix_chars: int = 100, log_prefix: str = "", providers_allowed: List[str] = None,
|
||||
providers_ignored: List[str] = None, providers_order: List[str] = None,
|
||||
log_prefix_chars: int = 100, log_prefix: str = "", providers_allowed: list[str] = None,
|
||||
providers_ignored: list[str] = None, providers_order: list[str] = None,
|
||||
provider_sort: str = None, provider_require_parameters: bool = False,
|
||||
provider_data_collection: str = None, openrouter_min_coding_score: Optional[float] = None,
|
||||
session_id: str = None, tool_progress_callback: callable = None,
|
||||
@@ -2381,18 +2381,18 @@ def init_agent(
|
||||
notice_callback: callable = None, notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
reaction_callback: Optional[Callable[[str], None]] = None, max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None, service_tier: str = None,
|
||||
request_overrides: Dict[str, Any] = None, prefill_messages: List[Dict[str, Any]] = None,
|
||||
reasoning_config: dict[str, Any] = None, service_tier: str = None,
|
||||
request_overrides: dict[str, Any] = None, prefill_messages: list[dict[str, Any]] = None,
|
||||
platform: str = None, user_id: str = None, user_id_alt: str = None, user_name: str = None,
|
||||
chat_id: str = None, chat_name: str = None, chat_type: str = None, thread_id: str = None,
|
||||
gateway_session_key: str = None, skip_context_files: bool = False,
|
||||
load_soul_identity: bool = False, skip_memory: bool = False,
|
||||
skip_background_review: bool = False, session_db=None, parent_session_id: str = None,
|
||||
iteration_budget: "IterationBudget" = None, run_budget_seconds: Optional[float] = None,
|
||||
fallback_model: Dict[str, Any] = None, credential_pool=None, checkpoints_enabled: bool = False,
|
||||
fallback_model: dict[str, Any] = None, credential_pool=None, checkpoints_enabled: bool = False,
|
||||
checkpoint_max_snapshots: int = 20, checkpoint_max_total_size_mb: int = 500,
|
||||
checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False,
|
||||
requested_provider: str = None, capabilities: Optional[Dict[str, bool]] = None, cwd: Optional[str] = None,
|
||||
requested_provider: str = None, capabilities: Optional[dict[str, bool]] = None, cwd: Optional[str] = None,
|
||||
side_agent: bool = False, memory_manager=None,
|
||||
tool_result_metadata_callback: Optional[Callable[..., dict]] = None,
|
||||
):
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Dict, List
|
||||
logger = logging.getLogger("run_agent")
|
||||
|
||||
|
||||
def _fallback_entries(fallback_model) -> List[Dict[str, Any]]:
|
||||
def _fallback_entries(fallback_model) -> list[dict[str, Any]]:
|
||||
"""Normalize legacy single-dict ``fallback_model`` / list ``fallback_providers``."""
|
||||
if isinstance(fallback_model, dict):
|
||||
fallback_model = [fallback_model]
|
||||
|
||||
@@ -107,7 +107,7 @@ _TRAJECTORY_SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
def _trajectory_gpt_prefix(msg: Dict[str, Any]) -> str:
|
||||
def _trajectory_gpt_prefix(msg: dict[str, Any]) -> str:
|
||||
"""Leading ``<think>`` block from native reasoning tokens, if any."""
|
||||
if msg.get("reasoning") and msg["reasoning"].strip():
|
||||
return f"<think>\n{msg['reasoning']}\n</think>\n"
|
||||
@@ -119,7 +119,7 @@ def _with_think_block(content: str) -> str:
|
||||
return content if "<think>" in content else "<think>\n</think>\n" + content
|
||||
|
||||
|
||||
def _trajectory_tool_call_turn(msg: Dict[str, Any]) -> str:
|
||||
def _trajectory_tool_call_turn(msg: dict[str, Any]) -> str:
|
||||
content = _trajectory_gpt_prefix(msg)
|
||||
if msg.get("content") and msg["content"].strip():
|
||||
# <REASONING_SCRATCHPAD> -> <think> (model reasons via XML when native thinking is off)
|
||||
@@ -139,7 +139,7 @@ def _trajectory_tool_call_turn(msg: Dict[str, Any]) -> str:
|
||||
return _with_think_block(content).rstrip()
|
||||
|
||||
|
||||
def _trajectory_tool_responses(msg: Dict[str, Any], messages: List[Dict[str, Any]], start: int) -> Tuple[List[str], int]:
|
||||
def _trajectory_tool_responses(msg: dict[str, Any], messages: list[dict[str, Any]], start: int) -> tuple[list[str], int]:
|
||||
"""Collect the ``<tool_response>`` blocks for the tool run starting at ``start``; returns ``(blocks, next_index)``."""
|
||||
tool_responses = []
|
||||
j = start
|
||||
@@ -166,7 +166,7 @@ def _trajectory_tool_responses(msg: Dict[str, Any], messages: List[Dict[str, Any
|
||||
return tool_responses, j
|
||||
|
||||
|
||||
def convert_to_trajectory_format(agent, messages: List[Dict[str, Any]], user_query: str, completed: bool) -> List[Dict[str, Any]]:
|
||||
def convert_to_trajectory_format(agent, messages: list[dict[str, Any]], user_query: str, completed: bool) -> list[dict[str, Any]]:
|
||||
"""Convert internal message history to trajectory format for saving."""
|
||||
# Trajectories are text-only: swap image-bearing tool messages for their text_summary so ~1MB
|
||||
# base64 blobs are not embedded.
|
||||
@@ -313,7 +313,7 @@ def sanitize_tool_call_arguments(
|
||||
# Session-scoped in-flight registry for note_turn_start: the gateway caches agents per routing key
|
||||
# while the transcript is keyed by session_id, so two agent objects can run concurrent turns on one
|
||||
# session unseen by per-agent state.
|
||||
_INFLIGHT_TURNS_BY_SESSION: Dict[str, Tuple[str, float]] = {}
|
||||
_INFLIGHT_TURNS_BY_SESSION: dict[str, tuple[str, float]] = {}
|
||||
_INFLIGHT_TURNS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ def note_turn_persisted(agent):
|
||||
agent._inflight_turn_session_id = None
|
||||
|
||||
|
||||
def _is_codex_interim(m: Dict) -> bool:
|
||||
def _is_codex_interim(m: dict) -> bool:
|
||||
"""Codex Responses interim turn: carries its own continuation state, replayed verbatim."""
|
||||
return bool(
|
||||
m.get("codex_reasoning_items")
|
||||
@@ -383,7 +383,7 @@ def _is_codex_interim(m: Dict) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _merge_assistant_into(prev: Dict, msg: Dict) -> bool:
|
||||
def _merge_assistant_into(prev: dict, msg: dict) -> bool:
|
||||
"""Fold consecutive assistant *msg* into *prev* (union tool_calls, concat text). Returns whether *msg*'s
|
||||
text survives: multimodal (list) content is never joined."""
|
||||
from agent.context_compressor import _DB_PERSISTED_MARKER
|
||||
@@ -463,7 +463,7 @@ def _merge_assistant_into(prev: Dict, msg: Dict) -> bool:
|
||||
return text_kept
|
||||
|
||||
|
||||
def _remember_absorbed_row(survivor: Dict[str, Any], dropped: Dict[str, Any], *, folded: bool) -> None:
|
||||
def _remember_absorbed_row(survivor: dict[str, Any], dropped: dict[str, Any], *, folded: bool) -> None:
|
||||
"""Retire *dropped*'s row ids onto *survivor*; record its uid as a merge witness only when *folded* (its
|
||||
text survives). An empty incoming turn still merges; stamping an empty list would change a message that
|
||||
absorbed nothing. A dropped id that equals the survivor's own live id (the display-marker merge adopts
|
||||
@@ -496,7 +496,7 @@ def _remember_absorbed_row(survivor: Dict[str, Any], dropped: Dict[str, Any], *,
|
||||
record_absorbed_message(survivor, dropped)
|
||||
|
||||
|
||||
def _count_unnamed_row(survivor: Dict[str, Any], retired: Dict[str, Any]) -> None:
|
||||
def _count_unnamed_row(survivor: dict[str, Any], retired: dict[str, Any]) -> None:
|
||||
"""On a reload without row ids nothing names *retired*'s durable row once the repair takes the dict
|
||||
out of the list, so *survivor* counts it. A dict that counts rows was loaded too: a merge may have
|
||||
popped its marker since. Call before the merge rewrites the survivor: *retired*'s own fields are
|
||||
@@ -505,7 +505,7 @@ def _count_unnamed_row(survivor: Dict[str, Any], retired: Dict[str, Any]) -> Non
|
||||
from agent.conversation_compression_archive import (
|
||||
OWN_ROW, RETIRED_DURABLE_ROWS, UNNAMED_DURABLE_ROWS, retired_row_payload)
|
||||
|
||||
def loaded(message: Dict[str, Any]) -> bool:
|
||||
def loaded(message: dict[str, Any]) -> bool:
|
||||
return bool(message.get(_DB_PERSISTED_MARKER) or message.get(UNNAMED_DURABLE_ROWS))
|
||||
|
||||
if loaded(survivor) and loaded(retired) and not isinstance(retired.get("_row_id"), int):
|
||||
@@ -516,7 +516,7 @@ def _count_unnamed_row(survivor: Dict[str, Any], retired: Dict[str, Any]) -> Non
|
||||
survivor.setdefault(RETIRED_DURABLE_ROWS, []).append(retired_row_payload(retired))
|
||||
|
||||
|
||||
def _remember_own_row(survivor: Dict[str, Any]) -> None:
|
||||
def _remember_own_row(survivor: dict[str, Any]) -> None:
|
||||
"""An assistant fold rewrites *survivor*'s text, so on a reload without row ids its own row no longer
|
||||
matches it by content. Record its loaded fields first, once."""
|
||||
from agent.context_compressor import _DB_PERSISTED_MARKER
|
||||
@@ -528,7 +528,7 @@ def _remember_own_row(survivor: Dict[str, Any]) -> None:
|
||||
survivor.setdefault(RETIRED_DURABLE_ROWS, []).append({**retired_row_payload(survivor), OWN_ROW: True})
|
||||
|
||||
|
||||
def _retire_dropped_row(kept: List[Dict], dropped: Dict[str, Any], leading: List[Dict]) -> None:
|
||||
def _retire_dropped_row(kept: list[dict], dropped: dict[str, Any], leading: list[dict]) -> None:
|
||||
"""A durable row the repair drops was still handed to the caller, so the survivor before it stands
|
||||
for it. Left unnamed, an in-place compaction takes it for a row another surface appended and
|
||||
re-sequences it behind the running turn. A drop with no survivor before it waits in *leading*."""
|
||||
@@ -539,17 +539,17 @@ def _retire_dropped_row(kept: List[Dict], dropped: Dict[str, Any], leading: List
|
||||
_remember_absorbed_row(kept[-1], dropped, folded=False)
|
||||
|
||||
|
||||
def _retire_leading_drops(kept: List[Dict], leading: List[Dict]) -> None:
|
||||
def _retire_leading_drops(kept: list[dict], leading: list[dict]) -> None:
|
||||
"""Rows dropped ahead of the first survivor sit right before its own row, so it stands for them."""
|
||||
for dropped in leading if kept and isinstance(kept[0], dict) else ():
|
||||
_count_unnamed_row(kept[0], dropped)
|
||||
_remember_absorbed_row(kept[0], dropped, folded=False)
|
||||
|
||||
|
||||
def _merge_consecutive_assistants(messages: List[Dict]) -> Tuple[List[Dict], int]:
|
||||
def _merge_consecutive_assistants(messages: list[dict]) -> tuple[list[dict], int]:
|
||||
"""Pass 0: merge consecutive assistant turns (codex interims exempt)."""
|
||||
repairs = 0
|
||||
collapsed: List[Dict] = []
|
||||
collapsed: list[dict] = []
|
||||
for msg in messages:
|
||||
prev = collapsed[-1] if collapsed and isinstance(collapsed[-1], dict) else None
|
||||
if (
|
||||
@@ -572,12 +572,12 @@ def _merge_consecutive_assistants(messages: List[Dict]) -> Tuple[List[Dict], int
|
||||
return collapsed, repairs
|
||||
|
||||
|
||||
def _drop_stray_tool_results(messages: List[Dict]) -> Tuple[List[Dict], int]:
|
||||
def _drop_stray_tool_results(messages: list[dict]) -> tuple[list[dict], int]:
|
||||
"""Pass 1: drop tool results not following a known assistant tool call. Consumes the whole
|
||||
alias group (call_id/id/response_item_id/composite) so a duplicate keyed on a sibling
|
||||
alias is not replayed to strict providers."""
|
||||
repairs = 0
|
||||
known_tool_ids: Dict[str, int] = {} # alias -> group id; reset by assistant/user turns
|
||||
known_tool_ids: dict[str, int] = {} # alias -> group id; reset by assistant/user turns
|
||||
# Pass 1: drop stray tool messages that don't follow a known assistant tool call. A Responses call can
|
||||
# have several equivalent spellings (call_id, id, response_item_id, or a composite ``call|item`` id), so
|
||||
# consume the whole alias group when one spelling is matched. Alias expansion lives in
|
||||
@@ -585,8 +585,8 @@ def _drop_stray_tool_results(messages: List[Dict]) -> Tuple[List[Dict], int]:
|
||||
# owner) — which also handles SDK tool_call objects, preserving the #91768 dict-or-object tolerance.
|
||||
matched_tool_groups: set = set()
|
||||
next_tool_group = 0
|
||||
filtered: List[Dict] = []
|
||||
leading: List[Dict] = []
|
||||
filtered: list[dict] = []
|
||||
leading: list[dict] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role") if isinstance(msg, dict) else None
|
||||
if role in ("assistant", "user"):
|
||||
@@ -617,15 +617,15 @@ def _drop_stray_tool_results(messages: List[Dict]) -> Tuple[List[Dict], int]:
|
||||
return filtered, repairs
|
||||
|
||||
|
||||
def _prune_unanswered_tool_calls(messages: List[Dict]) -> Tuple[List[Dict], int]:
|
||||
def _prune_unanswered_tool_calls(messages: list[dict]) -> tuple[list[dict], int]:
|
||||
"""Pass 2: prune tool_calls not answered in the IMMEDIATELY following tool run (a displaced
|
||||
result masks the per-call stub pass and strict providers 400). Payload-empty turns are
|
||||
dropped; codex interims exempt."""
|
||||
from agent.context_compressor import _DB_PERSISTED_MARKER
|
||||
|
||||
repairs = 0
|
||||
pruned: List[Dict] = []
|
||||
leading: List[Dict] = []
|
||||
pruned: list[dict] = []
|
||||
leading: list[dict] = []
|
||||
for i, msg in enumerate(messages):
|
||||
if not (
|
||||
isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("tool_calls")
|
||||
@@ -659,7 +659,7 @@ def _prune_unanswered_tool_calls(messages: List[Dict]) -> Tuple[List[Dict], int]
|
||||
return pruned, repairs
|
||||
|
||||
|
||||
def _merge_consecutive_users(messages: List[Dict]) -> Tuple[List[Dict], int]:
|
||||
def _merge_consecutive_users(messages: list[dict]) -> tuple[list[dict], int]:
|
||||
"""Pass 3: merge consecutive plain-text user messages (no user input lost)."""
|
||||
from agent.context_compressor import _DB_PERSISTED_MARKER, split_user_originated_turn
|
||||
from agent.conversation_compression_archive import MERGED_DURABLE_ROWS
|
||||
@@ -670,7 +670,7 @@ def _merge_consecutive_users(messages: List[Dict]) -> Tuple[List[Dict], int]:
|
||||
return isinstance(content, str) and not content.startswith(SessionDB._CONTENT_JSON_PREFIX)
|
||||
|
||||
repairs = 0
|
||||
merged: List[Dict] = []
|
||||
merged: list[dict] = []
|
||||
for msg in messages:
|
||||
prev = merged[-1] if merged and isinstance(merged[-1], dict) else None
|
||||
if (
|
||||
@@ -744,7 +744,7 @@ _SEQUENCE_REPAIR_PASSES = (
|
||||
_merge_consecutive_users,
|
||||
)
|
||||
|
||||
def _normalize_sentinel_encoded_content(messages: List[Dict]) -> None:
|
||||
def _normalize_sentinel_encoded_content(messages: list[dict]) -> None:
|
||||
"""Decode any sentinel-encoded row content in place before the alternation passes run, so an
|
||||
image-bearing turn is never merged as text (#125299). A multimodal turn can re-enter the working set
|
||||
as its ``\\x00json:[…]`` string (e.g. after a proactive prune re-inserts history, #124102). A body
|
||||
@@ -762,7 +762,7 @@ def _normalize_sentinel_encoded_content(messages: List[Dict]) -> None:
|
||||
# it makes the append-only flush re-append this user turn as a duplicate (#125331).
|
||||
|
||||
|
||||
def repair_message_sequence(agent, messages: List[Dict]) -> int:
|
||||
def repair_message_sequence(agent, messages: list[dict]) -> int:
|
||||
"""Collapse malformed role-alternation left in the live history; returns repair count.
|
||||
Providers require strict alternation after the system message (violations: silent empty
|
||||
responses or 400s); this is the pre-call belt for host-fed, resumed or replayed histories.
|
||||
@@ -785,7 +785,7 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
|
||||
return repairs
|
||||
|
||||
|
||||
def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int:
|
||||
def repair_message_sequence_with_cursor(agent, messages: list[dict]) -> int:
|
||||
"""Run :func:`repair_message_sequence` and keep ``_last_flushed_db_idx`` consistent. Repair
|
||||
shrinks the list in place; counting identity-preserved survivors of the flushed prefix gives
|
||||
the exact new cursor, whereas a ``min()`` clamp would skip unflushed rows (used only without a snapshot)."""
|
||||
@@ -876,7 +876,7 @@ _USAGE_LIMIT_REASON_TOKENS = ("usage_limit_reached", "gousagelimit")
|
||||
_USAGE_LIMIT_MESSAGE_TOKENS = ("usage limit reached", "usage limit has been reached")
|
||||
|
||||
|
||||
def _failed_credential_identity(agent, pool) -> Tuple[Optional[str], Optional[str]]:
|
||||
def _failed_credential_identity(agent, pool) -> tuple[Optional[str], Optional[str]]:
|
||||
"""``(api_key_hint, credential_id)`` of the key actually dispatched, not ``pool.current()``:
|
||||
the shared pointer often points at a different healthy entry, and marking it exhausted
|
||||
can take the whole pool offline from one 429."""
|
||||
@@ -1006,7 +1006,7 @@ def _recover_rate_limit(pool, *, has_retried_429, error_context, api_key_hint, c
|
||||
def recover_with_credential_pool(
|
||||
agent, *, status_code: Optional[int], has_retried_429: bool,
|
||||
classified_reason: Optional[FailoverReason] = None,
|
||||
error_context: Optional[Dict[str, Any]] = None, billing_unverified: bool = False,
|
||||
error_context: Optional[dict[str, Any]] = None, billing_unverified: bool = False,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Attempt credential recovery via pool rotation; returns (recovered, has_retried_429).
|
||||
Rate limits: retry once, then rotate. Billing: rotate immediately. Auth: refresh before
|
||||
@@ -1133,7 +1133,7 @@ def recover_with_credential_pool(
|
||||
return False, has_retried_429
|
||||
|
||||
|
||||
def _apply_primary_runtime_fields(agent, rt: Dict[str, Any]) -> None:
|
||||
def _apply_primary_runtime_fields(agent, rt: dict[str, Any]) -> None:
|
||||
"""Copy the identity/transport fields of a ``_primary_runtime`` snapshot onto ``agent``
|
||||
(shared by transport recovery and turn-start restore; the caller rebuilds the client)."""
|
||||
agent.model = rt["model"]
|
||||
@@ -1150,7 +1150,7 @@ def _apply_primary_runtime_fields(agent, rt: Dict[str, Any]) -> None:
|
||||
agent._client_kwargs = dict(rt["client_kwargs"])
|
||||
|
||||
|
||||
def _build_anthropic_client_from_runtime(agent, rt: Dict[str, Any]) -> None:
|
||||
def _build_anthropic_client_from_runtime(agent, rt: dict[str, Any]) -> None:
|
||||
"""Rebuild the native Anthropic client from a ``_primary_runtime`` snapshot."""
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
agent._anthropic_api_key = rt["anthropic_api_key"]
|
||||
@@ -1163,7 +1163,7 @@ def _build_anthropic_client_from_runtime(agent, rt: Dict[str, Any]) -> None:
|
||||
agent.client = None
|
||||
|
||||
|
||||
def _rebuild_primary_client(agent, rt: Dict[str, Any], *, reason: str) -> None:
|
||||
def _rebuild_primary_client(agent, rt: dict[str, Any], *, reason: str) -> None:
|
||||
"""Rebuild the primary client from a ``_primary_runtime`` snapshot (MoA facade / native Anthropic / OpenAI wire)."""
|
||||
if (agent.provider or "").strip().lower() == "moa":
|
||||
# MoA has empty client_kwargs; rebuild via the shared facade factory so the
|
||||
@@ -1243,9 +1243,9 @@ _UNMERGEABLE = object()
|
||||
|
||||
|
||||
def drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]], *, drop_codex_reasoning_items: bool = True,
|
||||
messages: list[dict[str, Any]], *, drop_codex_reasoning_items: bool = True,
|
||||
drop_nudge_marker: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop thinking-only assistant turns and merge adjacent user messages left behind, on the
|
||||
per-call ``api_messages`` copy only (``agent.messages`` is never mutated). Drop-and-merge
|
||||
(not stub text) keeps history honest and preserves role alternation.
|
||||
@@ -1262,7 +1262,7 @@ def drop_thinking_only_and_merge_users(
|
||||
and not _ra().AIAgent._is_thinking_only_assistant(m, drop_codex_reasoning_items=drop_codex_reasoning_items)
|
||||
]
|
||||
dropped = len(messages) - len(kept)
|
||||
merged: List[Dict[str, Any]] = []
|
||||
merged: list[dict[str, Any]] = []
|
||||
merges = 0
|
||||
for m in kept:
|
||||
prev = merged[-1] if merged else None
|
||||
@@ -1312,7 +1312,7 @@ def _primary_reset_gate_blocks(agent, rt, primary_provider, primary_runtime_base
|
||||
return False, prefetched_pool, prefetched
|
||||
|
||||
|
||||
def _restore_runtime_capabilities(agent, rt: Dict[str, Any]) -> None:
|
||||
def _restore_runtime_capabilities(agent, rt: dict[str, Any]) -> None:
|
||||
# ``capabilities`` is the legacy key from the initial capability propagation patch.
|
||||
raw = rt["runtime_capabilities"] if "runtime_capabilities" in rt else rt.get("capabilities")
|
||||
if isinstance(raw, dict):
|
||||
@@ -1508,7 +1508,7 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]:
|
||||
(OpenRouter unified), else inline thinking blocks in the content; None when absent."""
|
||||
from agent.message_content import flatten_message_text
|
||||
|
||||
parts: List[str] = []
|
||||
parts: list[str] = []
|
||||
|
||||
def _add(text) -> None:
|
||||
text = flatten_message_text(text, sep="")
|
||||
@@ -1539,8 +1539,8 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]:
|
||||
return "\n\n".join(parts) if parts else None
|
||||
|
||||
|
||||
def _api_error_debug_info(error: Exception) -> Dict[str, Any]:
|
||||
info: Dict[str, Any] = {"type": type(error).__name__, "message": str(error)}
|
||||
def _api_error_debug_info(error: Exception) -> dict[str, Any]:
|
||||
info: dict[str, Any] = {"type": type(error).__name__, "message": str(error)}
|
||||
info.update({
|
||||
k: v for k in ("status_code", "request_id", "code", "param", "type", "body")
|
||||
if (v := getattr(error, k, None)) is not None
|
||||
@@ -1556,7 +1556,7 @@ def _api_error_debug_info(error: Exception) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def dump_api_request_debug(
|
||||
agent, api_kwargs: Dict[str, Any], *, reason: str, error: Optional[Exception] = None
|
||||
agent, api_kwargs: dict[str, Any], *, reason: str, error: Optional[Exception] = None
|
||||
) -> Optional[Path]:
|
||||
"""Dump the request body from api_kwargs (minus transport keys) for debugging provider 4xx failures."""
|
||||
try:
|
||||
@@ -1573,7 +1573,7 @@ def dump_api_request_debug(
|
||||
endpoint = {"codex_responses": "/responses", "anthropic_messages": "/messages"}.get(
|
||||
agent.api_mode, "/chat/completions"
|
||||
)
|
||||
dump_payload: Dict[str, Any] = {
|
||||
dump_payload: dict[str, Any] = {
|
||||
"timestamp": datetime.now().isoformat(), "session_id": agent.session_id, "reason": reason,
|
||||
"request": {
|
||||
"method": "POST", "url": f"{agent.base_url.rstrip('/')}{endpoint}",
|
||||
@@ -1669,7 +1669,7 @@ def plan_cache_sections_for_destination(
|
||||
messages: list, tools: Optional[list], *, provider: str, base_url: str, api_mode: str,
|
||||
model: str, cache_disabled: Optional[bool] = None, cache_ttl: Optional[str] = None,
|
||||
static_system_prefix: Optional[str] = None,
|
||||
) -> Tuple[list, list]:
|
||||
) -> tuple[list, list]:
|
||||
"""Plan request-local cache sections for one resolved destination (MoA / auxiliary senders):
|
||||
stripped copies (non-caching route) or a ``build_prompt_cache_plan`` layout; never mutates
|
||||
inputs. ``cache_disabled``/``cache_ttl`` default to live config so the operator's disable and
|
||||
@@ -2132,7 +2132,7 @@ _SWITCH_SNAPSHOT_FIELDS = (
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _snapshot_switch_state(agent) -> Dict[str, Any]:
|
||||
def _snapshot_switch_state(agent) -> dict[str, Any]:
|
||||
"""Snapshot every field the swap+rebuild mutates so a failed rebuild rolls back atomically
|
||||
(else a new model name + OLD client 400s next turn). The sentinel distinguishes unset from
|
||||
None: tests build bare agents via ``__new__`` without all fields."""
|
||||
@@ -2142,7 +2142,7 @@ def _snapshot_switch_state(agent) -> Dict[str, Any]:
|
||||
return snapshot
|
||||
|
||||
|
||||
def _restore_switch_snapshot(agent, snapshot: Dict[str, Any]) -> None:
|
||||
def _restore_switch_snapshot(agent, snapshot: dict[str, Any]) -> None:
|
||||
for name, value in snapshot.items():
|
||||
if value is _MISSING:
|
||||
continue # attribute did not exist before the swap; don't fabricate it
|
||||
@@ -2221,7 +2221,7 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new
|
||||
try:
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001
|
||||
except Exception as _mm_exc:
|
||||
logger.warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"on switch (%s); using static bearer.", _mm_exc,
|
||||
@@ -2299,7 +2299,7 @@ def _swap_switch_runtime(agent, new_model, new_provider, api_key, base_url, api_
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
agent._credential_pool = load_pool(new_provider)
|
||||
except Exception as _pool_exc: # noqa: BLE001
|
||||
except Exception as _pool_exc:
|
||||
logger.warning(
|
||||
"switch_model: credential pool reload failed for %s (%s); "
|
||||
"continuing without pool rotation this turn", new_provider, _pool_exc,
|
||||
@@ -2384,7 +2384,7 @@ def _update_switch_compressor(agent, custom_providers, effective_context_length,
|
||||
revalidate_compression_feasibility(agent)
|
||||
|
||||
|
||||
def _build_primary_runtime_snapshot(agent, api_mode) -> Dict[str, Any]:
|
||||
def _build_primary_runtime_snapshot(agent, api_mode) -> dict[str, Any]:
|
||||
"""The ``_primary_runtime`` record that persists a switch across turns."""
|
||||
cc = getattr(agent, "context_compressor", None) or None
|
||||
rt = {
|
||||
@@ -2548,7 +2548,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
tool_call_id: Optional[str] = None, messages: list = None,
|
||||
pre_tool_block_checked: bool = False,
|
||||
skip_tool_request_middleware: bool = False,
|
||||
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||||
tool_request_middleware_trace: Optional[list[dict[str, Any]]] = None,
|
||||
skip_tool_execution_middleware: bool = False) -> str:
|
||||
"""Invoke a single tool (agent-level or registry-dispatched) and return the result string;
|
||||
no display logic. Used by the concurrent path; the sequential path keeps its own inline
|
||||
@@ -2651,8 +2651,8 @@ def repair_tool_call(agent, tool_name: str) -> str | None:
|
||||
tool_name = tool_name[:_idx]
|
||||
if not tool_name:
|
||||
return None
|
||||
_norm = lambda s: s.lower().replace("-", "_").replace(" ", "_") # noqa: E731
|
||||
_camel_snake = lambda s: re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower() # noqa: E731
|
||||
_norm = lambda s: s.lower().replace("-", "_").replace(" ", "_")
|
||||
_camel_snake = lambda s: re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower()
|
||||
|
||||
def _strip_tool_suffix(s: str) -> str | None:
|
||||
lc = s.lower()
|
||||
@@ -2687,7 +2687,7 @@ def repair_tool_call(agent, tool_name: str) -> str | None:
|
||||
# still fire per window).
|
||||
_EMPTY_HEAL_ESCALATE_AFTER = 3
|
||||
_EMPTY_HEAL_WINDOW_S = 600.0
|
||||
_empty_heal_log_state: Dict[str, Dict[str, Any]] = {}
|
||||
_empty_heal_log_state: dict[str, dict[str, Any]] = {}
|
||||
_empty_heal_log_lock = threading.Lock()
|
||||
# Sessions already told ONCE (out-of-band, never in conversation context); kept apart from the
|
||||
# windowed log state so a new window never re-arms the notice.
|
||||
@@ -2697,7 +2697,7 @@ _empty_heal_log_lock = threading.Lock()
|
||||
_empty_heal_user_notified: set = set()
|
||||
# One-shot pending notices keyed by session, drained via ``consume_pending_sanitizer_heal_notice``
|
||||
# and delivered via the status/warning callback.
|
||||
_empty_heal_pending_notice: Dict[str, str] = {}
|
||||
_empty_heal_pending_notice: dict[str, str] = {}
|
||||
|
||||
|
||||
def _content_has_payload(content: Any) -> bool:
|
||||
@@ -2713,7 +2713,7 @@ def _content_has_payload(content: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _msg_has_payload(msg: Dict[str, Any]) -> bool:
|
||||
def _msg_has_payload(msg: dict[str, Any]) -> bool:
|
||||
"""True if ``msg`` carries anything the API treats as non-empty content (text, multimodal
|
||||
blocks, tool_calls, reasoning). Role-agnostic counterpart of ``AIAgent._is_thinking_only_assistant``.
|
||||
Codex Responses item carriers persist with content:"" by design (text lives in codex_*_items
|
||||
@@ -2728,7 +2728,7 @@ def _msg_has_payload(msg: Dict[str, Any]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def fill_empty_non_final_wire_payload(msg: Dict[str, Any], *, is_final: bool) -> bool:
|
||||
def fill_empty_non_final_wire_payload(msg: dict[str, Any], *, is_final: bool) -> bool:
|
||||
"""Write the interrupted placeholder onto an empty non-final wire copy; True when filled.
|
||||
Pass the per-call copy only; durable history must not be mutated."""
|
||||
if is_final or not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"):
|
||||
@@ -2765,7 +2765,7 @@ def consume_pending_sanitizer_heal_notice() -> Optional[str]:
|
||||
return _empty_heal_pending_notice.pop(key, None)
|
||||
|
||||
|
||||
def get_sanitizer_heal_stats() -> Dict[str, Dict[str, Any]]:
|
||||
def get_sanitizer_heal_stats() -> dict[str, dict[str, Any]]:
|
||||
"""Read-only per-session sanitiser heal counters (``heal_events``, ``messages_healed``, ``escalated``) for diagnostics."""
|
||||
with _empty_heal_log_lock:
|
||||
return {
|
||||
@@ -2830,14 +2830,14 @@ def _log_empty_non_final_heal(healed: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def repair_empty_non_final_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def repair_empty_non_final_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Substitute a placeholder for empty-content non-final messages on the per-call copy.
|
||||
Anthropic/litellm/Bedrock 400 on any empty non-final message and a persisted stub poisons
|
||||
every later turn; repairing the wire copy heals the session in memory. Substitution (not
|
||||
deletion) keeps role alternation and tool-call pairing intact. The final message is untouched."""
|
||||
if not messages or len(messages) < 2:
|
||||
return messages
|
||||
repaired: List[Dict[str, Any]] = []
|
||||
repaired: list[dict[str, Any]] = []
|
||||
healed = 0
|
||||
last_idx = len(messages) - 1
|
||||
for idx, msg in enumerate(messages):
|
||||
@@ -2854,7 +2854,7 @@ def repair_empty_non_final_messages(messages: List[Dict[str, Any]]) -> List[Dict
|
||||
return messages
|
||||
|
||||
|
||||
def _classify_tool_call_orphans(messages: List[Dict[str, Any]]):
|
||||
def _classify_tool_call_orphans(messages: list[dict[str, Any]]):
|
||||
"""Classify orphaned tool-call / tool-result pairs; single source of truth for GLOBAL orphan
|
||||
detection. Returns ``(surviving_call_ids, result_call_ids, orphaned_results, missing_tool_calls)``;
|
||||
every id variant of a tool_call is registered so a result matching any alias survives, and
|
||||
@@ -2880,7 +2880,7 @@ def _classify_tool_call_orphans(messages: List[Dict[str, Any]]):
|
||||
return surviving_call_ids, result_call_ids, orphaned_results, missing_tool_calls
|
||||
|
||||
|
||||
def _drop_invalid_roles(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _drop_invalid_roles(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Drop messages whose role the API won't accept."""
|
||||
valid = _ra().AIAgent._VALID_API_ROLES
|
||||
for msg in messages:
|
||||
@@ -2889,7 +2889,7 @@ def _drop_invalid_roles(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return [m for m in messages if m.get("role") in valid]
|
||||
|
||||
|
||||
def _drop_empty_tool_calls_arrays(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _drop_empty_tool_calls_arrays(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Strict providers 400 on ``tool_calls: []``; normalize on shallow copies so history stays byte-stable."""
|
||||
# --- Drop empty / malformed tool_calls arrays on assistant messages --- An assistant message carrying
|
||||
# ``tool_calls: []`` (an empty array) — or a non-list value under the key — is semantically identical to
|
||||
@@ -2902,7 +2902,7 @@ def _drop_empty_tool_calls_arrays(messages: List[Dict[str, Any]]) -> List[Dict[s
|
||||
# per-call copy rather than in ``repair_message_sequence``, which would destructively rewrite the
|
||||
# persisted trajectory. Shallow-copy the message before dropping the key so stored history (and prompt
|
||||
# caching) stays byte-stable.
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
dropped = 0
|
||||
for msg in messages:
|
||||
if (
|
||||
@@ -2930,7 +2930,7 @@ def _drop_empty_tool_calls_arrays(messages: List[Dict[str, Any]]) -> List[Dict[s
|
||||
return normalized
|
||||
|
||||
|
||||
def _repair_invalid_tool_call_names(messages: List[Dict[str, Any]]) -> None:
|
||||
def _repair_invalid_tool_call_names(messages: list[dict[str, Any]]) -> None:
|
||||
"""Coerce every ``function.name`` to the provider-safe ``^[A-Za-z0-9_-]{1,64}$``. An empty/missing
|
||||
name becomes the ``invalid_tool_call`` sentinel (dropping would unpair the anti-priming result the
|
||||
dispatch loop keeps for it); an invalid one (``multi_tool_use.parallel``, a shell command a weak
|
||||
@@ -2970,7 +2970,7 @@ def _repair_invalid_tool_call_names(messages: List[Dict[str, Any]]) -> None:
|
||||
}
|
||||
|
||||
|
||||
def _drop_results_without_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _drop_results_without_ids(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Drop tool results with a missing/empty tool_call_id. Kept explicit (not left to the
|
||||
positional walk) for its own log line and so the final-chokepoint guarantee holds for
|
||||
callers skipping ``repair_message_sequence``."""
|
||||
@@ -2986,7 +2986,7 @@ def _drop_results_without_ids(messages: List[Dict[str, Any]]) -> List[Dict[str,
|
||||
return kept
|
||||
|
||||
|
||||
def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _pair_tool_calls_positionally(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Positional tool_call <-> tool_result pairing: strict providers (DeepSeek v4, Kimi) require
|
||||
results IMMEDIATELY after their call. Drops positional orphans, stubs unanswered declared
|
||||
ids; matching is alias-aware."""
|
||||
@@ -3006,8 +3006,8 @@ def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[s
|
||||
# (``tool_call_id_variants`` / ``tool_result_id_variants``): a result keyed on ANY alias spelling
|
||||
# (``id`` / ``call_id`` / ``response_item_id`` / composite bridge) answers the call, preserving the
|
||||
# unified alias policy from #55626/#63000/#93251.
|
||||
paired: List[Dict[str, Any]] = []
|
||||
declared_calls: Dict[str, tuple] = {}
|
||||
paired: list[dict[str, Any]] = []
|
||||
declared_calls: dict[str, tuple] = {}
|
||||
dropped = 0
|
||||
stubs = 0
|
||||
|
||||
@@ -3060,12 +3060,12 @@ def _pair_tool_calls_positionally(messages: List[Dict[str, Any]]) -> List[Dict[s
|
||||
return paired if (dropped or stubs) else messages
|
||||
|
||||
|
||||
def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _dedupe_tool_call_ids(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Deduplicate tool_call_ids (strict providers 400 on duplicates): collapse duplicates within
|
||||
an assistant message, drop results answering no OUTSTANDING call. Tracks outstanding calls
|
||||
(not ids ever seen) because llama.cpp reuses one constant id, and whole variant groups so
|
||||
alias-keyed results are not deleted."""
|
||||
outstanding: Dict[str, int] = {} # every alias of an unanswered call -> its group id
|
||||
outstanding: dict[str, int] = {} # every alias of an unanswered call -> its group id
|
||||
# 3. Deduplicate tool_call_ids. Strict providers (DeepSeek) reject a payload where the same tool_call_id
|
||||
# appears more than once with HTTP 400 "Duplicate value for 'tool_call_id'" (#58327). Duplicates can
|
||||
# arise from retries, crash/resume glitches, or a compression window that re-emits a tool result. This
|
||||
@@ -3084,9 +3084,9 @@ def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
||||
# (#55626/#58168/#63000); tracking only the coalesced id here made a result keyed on any OTHER variant
|
||||
# look like it answered no outstanding call, so this pass deleted the very result step 2's
|
||||
# variant-aware matching had just preserved (issue #93251 — whole parallel batches vanished).
|
||||
outstanding_groups: Dict[int, frozenset] = {}
|
||||
outstanding_groups: dict[int, frozenset] = {}
|
||||
next_group_id = 0
|
||||
deduped: List[Dict[str, Any]] = []
|
||||
deduped: list[dict[str, Any]] = []
|
||||
removed = 0
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
@@ -3132,7 +3132,7 @@ def _dedupe_tool_call_ids(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
||||
return deduped
|
||||
|
||||
|
||||
def _realign_tool_result_names(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _realign_tool_result_names(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Align each tool result's wire ``name`` with its call's function name (per-call copy only):
|
||||
Google 400s on a mismatch, routine when tool_search bridges via ``tool_call``."""
|
||||
# 4. Google matches functionResponse.name against functionCall.name and rejects a mismatch with HTTP 400
|
||||
@@ -3151,7 +3151,7 @@ def _realign_tool_result_names(messages: List[Dict[str, Any]]) -> List[Dict[str,
|
||||
# changes. A no-op for the native Gemini path, which already resolves the same name. A result whose
|
||||
# assistant call frame is missing entirely never reaches here — pass 1 above drops it as an orphan —
|
||||
# so the only results this pass sees are ones whose call name is knowable.
|
||||
call_names: Dict[str, str] = {}
|
||||
call_names: dict[str, str] = {}
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
@@ -3160,8 +3160,8 @@ def _realign_tool_result_names(messages: List[Dict[str, Any]]) -> List[Dict[str,
|
||||
nm = _ra().AIAgent._get_tool_call_name_static(tc)
|
||||
if cid and nm:
|
||||
call_names[cid] = nm
|
||||
realigned: List[Tuple[str, str]] = []
|
||||
aligned: List[Dict[str, Any]] = []
|
||||
realigned: list[tuple[str, str]] = []
|
||||
aligned: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
if msg.get("role") == "tool":
|
||||
expected = call_names.get((msg.get("tool_call_id") or "").strip())
|
||||
@@ -3181,7 +3181,7 @@ def _realign_tool_result_names(messages: List[Dict[str, Any]]) -> List[Dict[str,
|
||||
return aligned
|
||||
|
||||
|
||||
def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def sanitize_api_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Fix orphaned tool_call / tool_result pairs before every LLM call; runs unconditionally (not
|
||||
gated on the compressor). Order matters: empty non-final messages are healed first so the
|
||||
substituted turn participates in the pairing and dedup passes."""
|
||||
@@ -3207,7 +3207,7 @@ _ACK_WORKSPACE_MARKERS = (
|
||||
|
||||
|
||||
def looks_like_codex_intermediate_ack(
|
||||
agent, user_message: Any, assistant_content: str, messages: List[Dict[str, Any]],
|
||||
agent, user_message: Any, assistant_content: str, messages: list[dict[str, Any]],
|
||||
require_workspace: bool = True,
|
||||
) -> bool:
|
||||
"""Detect a planning/ack message that should continue instead of ending the turn.
|
||||
@@ -3398,7 +3398,7 @@ def looks_like_degenerate_final(text: str, user_message: Any = None) -> bool:
|
||||
return not any(ch.isalpha() and not ch.isascii() for ch in user_text)
|
||||
|
||||
|
||||
def tool_results_this_turn(messages: List[Dict[str, Any]]) -> int:
|
||||
def tool_results_this_turn(messages: list[dict[str, Any]]) -> int:
|
||||
"""Tool-result rows after the most recent user row — whether the turn did real tool work.
|
||||
|
||||
ANY user row ends the window, the continuation nudges included: that is what bounds the
|
||||
@@ -3651,7 +3651,7 @@ def _iter_pool_sockets(client: Any):
|
||||
yield sock
|
||||
|
||||
|
||||
def _set_reset_from_retry_after(context: Dict[str, Any], retry_after: Any) -> None:
|
||||
def _set_reset_from_retry_after(context: dict[str, Any], retry_after: Any) -> None:
|
||||
if "reset_at" in context:
|
||||
return
|
||||
seconds = parse_retry_after_seconds(retry_after)
|
||||
@@ -3685,7 +3685,7 @@ def _duration_string_seconds(text: str) -> Optional[float]:
|
||||
return sum(float(n) * _DURATION_UNIT_SECONDS[u] for n, u in parts)
|
||||
|
||||
|
||||
def _set_reset_from_vendor_headers(context: Dict[str, Any], headers: Any) -> None:
|
||||
def _set_reset_from_vendor_headers(context: dict[str, Any], headers: Any) -> None:
|
||||
for name in _VENDOR_RESET_HEADERS:
|
||||
value = headers.get(name)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
@@ -3699,9 +3699,9 @@ def _set_reset_from_vendor_headers(context: Dict[str, Any], headers: Any) -> Non
|
||||
return
|
||||
|
||||
|
||||
def extract_api_error_context(error: Exception) -> Dict[str, Any]:
|
||||
def extract_api_error_context(error: Exception) -> dict[str, Any]:
|
||||
"""Extract structured rate-limit details from provider errors."""
|
||||
context: Dict[str, Any] = {}
|
||||
context: dict[str, Any] = {}
|
||||
body = getattr(error, "body", None)
|
||||
payload = (body.get("error") if isinstance(body.get("error"), dict) else body) if isinstance(body, dict) else None
|
||||
if isinstance(payload, dict):
|
||||
|
||||
+14
-14
@@ -248,14 +248,14 @@ _CLAUDE_CODE_PREFIXES = (
|
||||
_CLAUDE_CODE_NAMES = ("claude", "claude-code")
|
||||
|
||||
|
||||
def _claude_code_candidates() -> List[str]:
|
||||
def _claude_code_candidates() -> list[str]:
|
||||
"""Executable paths to try, deduped and filtered to files that exist.
|
||||
|
||||
Two passes: every PATH hit first (what the user's shell would run), then the
|
||||
well-known install prefixes. A single nested loop would probe a stale prefix
|
||||
``claude`` before a current PATH ``claude-code``.
|
||||
"""
|
||||
seen: Dict[str, None] = {}
|
||||
seen: dict[str, None] = {}
|
||||
for name in _CLAUDE_CODE_NAMES:
|
||||
hit = shutil.which(name)
|
||||
if hit:
|
||||
@@ -351,12 +351,12 @@ def _common_betas_for_base_url(base_url: str | None, *, drop_context_1m_beta: bo
|
||||
return betas
|
||||
|
||||
|
||||
def _beta_header(betas: list) -> Dict[str, str]:
|
||||
def _beta_header(betas: list) -> dict[str, str]:
|
||||
"""``{"anthropic-beta": ...}`` when there are betas, else ``{}``."""
|
||||
return {"anthropic-beta": ",".join(betas)} if betas else {}
|
||||
|
||||
|
||||
def _attribution_headers() -> Dict[str, str]:
|
||||
def _attribution_headers() -> dict[str, str]:
|
||||
"""Same client-attribution set sent to OpenRouter / Vercel AI Gateway / Fireworks."""
|
||||
return {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent",
|
||||
@@ -371,13 +371,13 @@ def _client_timeout(timeout):
|
||||
return Timeout(timeout=float(read), connect=10.0)
|
||||
|
||||
|
||||
def _base_client_kwargs(base_url, timeout) -> tuple[str, Dict[str, Any]]:
|
||||
def _base_client_kwargs(base_url, timeout) -> tuple[str, dict[str, Any]]:
|
||||
"""Shared SDK constructor kwargs -> ``(normalized_base_url, kwargs)``. Retry is delegated to
|
||||
hermes's outer loop (``max_retries=0``): the SDK default of 2 uses its own backoff that ignores
|
||||
Retry-After and double-retries inside our loop. Any trailing ``/v1`` is stripped because the
|
||||
SDK appends ``/v1/messages``. Azure's ``api-version`` goes through ``default_query`` so the
|
||||
base_url is not corrupted into ``/anthropic?api-version=.../v1/messages``."""
|
||||
kwargs: Dict[str, Any] = {"timeout": _client_timeout(timeout), "max_retries": 0}
|
||||
kwargs: dict[str, Any] = {"timeout": _client_timeout(timeout), "max_retries": 0}
|
||||
normalized = re.sub(r"/v1/?$", "", _normalize_base_url_text(base_url).rstrip("/"))
|
||||
if normalized:
|
||||
kwargs["base_url"] = normalized
|
||||
@@ -414,7 +414,7 @@ def _build_anthropic_client_with_bearer_hook(
|
||||
return _new_sdk_client(sdk, kwargs, headers, route=base_url)
|
||||
|
||||
|
||||
def _new_sdk_client(sdk, kwargs: Dict[str, Any], headers: Dict[str, str], route: str = None):
|
||||
def _new_sdk_client(sdk, kwargs: dict[str, Any], headers: dict[str, str], route: str = None):
|
||||
"""``sdk.Anthropic(**kwargs)`` with ``headers`` attached, sending exactly ONE credential.
|
||||
``route`` is the caller's un-normalized base_url (the ``/v1`` form ``custom_providers`` entries are
|
||||
keyed by; ``kwargs["base_url"]`` has it stripped) for the per-provider ``extra_headers`` lookup.
|
||||
@@ -439,7 +439,7 @@ def _new_sdk_client(sdk, kwargs: Dict[str, Any], headers: Dict[str, str], route:
|
||||
return sdk.Anthropic(**kwargs)
|
||||
|
||||
|
||||
def _custom_provider_extra_headers(base_url) -> Dict[str, str]:
|
||||
def _custom_provider_extra_headers(base_url) -> dict[str, str]:
|
||||
"""``extra_headers`` of the ``custom_providers`` entry routed at *base_url*, else ``{}``.
|
||||
SECURITY: values routinely carry credentials (Cloudflare Access tokens) — never log them."""
|
||||
if not base_url:
|
||||
@@ -537,7 +537,7 @@ def _normalize_to_mcp_wire(name: str) -> str:
|
||||
return _MCP_TOOL_PREFIX + name.removeprefix("mcp_")
|
||||
|
||||
|
||||
def _oauth_wire_namer(anthropic_tools: List[Dict[str, Any]]):
|
||||
def _oauth_wire_namer(anthropic_tools: list[dict[str, Any]]):
|
||||
"""Return ``name -> OAuth wire name`` for this request's tool set. An alias must never collide
|
||||
with a wire name owned by a non-alias tool: two identical tool names in one request is a hard
|
||||
400, strictly worse than the bug being fixed. Mirrors normalize_response's "registered tool
|
||||
@@ -596,7 +596,7 @@ def _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_
|
||||
return system
|
||||
|
||||
|
||||
def _thinking_kwargs(reasoning_config: Dict[str, Any], model: str, effective_max_tokens: int) -> Dict[str, Any]:
|
||||
def _thinking_kwargs(reasoning_config: dict[str, Any], model: str, effective_max_tokens: int) -> dict[str, Any]:
|
||||
"""Map ``reasoning_config`` to Anthropic thinking kwargs. Adaptive models (Claude 4.6+,
|
||||
Kimi/Moonshot) get ``thinking.type=adaptive`` + ``output_config.effort``; older models and
|
||||
manual-only compat endpoints (MiniMax) get budget_tokens. Haiku has no extended thinking. On
|
||||
@@ -630,11 +630,11 @@ _TOOL_CHOICE_MAP = {None: {"type": "auto"}, "auto": {"type": "auto"}, "required"
|
||||
|
||||
|
||||
def build_anthropic_kwargs(
|
||||
model: str, messages: List[Dict], tools: Optional[List[Dict]], max_tokens: Optional[int],
|
||||
reasoning_config: Optional[Dict[str, Any]], tool_choice: Optional[str] = None,
|
||||
model: str, messages: list[dict], tools: Optional[list[dict]], max_tokens: Optional[int],
|
||||
reasoning_config: Optional[dict[str, Any]], tool_choice: Optional[str] = None,
|
||||
is_oauth: bool = False, preserve_dots: bool = False, context_length: Optional[int] = None,
|
||||
base_url: str | None = None, fast_mode: bool = False, drop_context_1m_beta: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Build kwargs for anthropic.messages.create(). ``max_tokens`` is the OUTPUT cap for one
|
||||
response; ``context_length`` is the TOTAL window (input + output). ``max_tokens=None`` uses the
|
||||
model's native output ceiling; if that exceeds ``context_length`` (small local endpoints) it is
|
||||
@@ -656,7 +656,7 @@ def build_anthropic_kwargs(
|
||||
to_wire = _oauth_wire_namer(anthropic_tools) if is_oauth else None
|
||||
if to_wire:
|
||||
system = _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_wire)
|
||||
kwargs: Dict[str, Any] = {"model": model, "messages": anthropic_messages, "max_tokens": effective_max_tokens}
|
||||
kwargs: dict[str, Any] = {"model": model, "messages": anthropic_messages, "max_tokens": effective_max_tokens}
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
if anthropic_tools:
|
||||
|
||||
@@ -85,7 +85,7 @@ def anthropic_route_is_oauth(base_url: Any, credential: Any, *, provider: Option
|
||||
if callable(credential) and not isinstance(credential, str):
|
||||
try:
|
||||
credential = credential()
|
||||
except Exception: # noqa: BLE001 — classification must never raise
|
||||
except Exception:
|
||||
return False
|
||||
return isinstance(credential, str) and _is_oauth_token(credential)
|
||||
|
||||
@@ -223,7 +223,7 @@ _SINGLETON_SOURCE_PATHS = {
|
||||
}
|
||||
|
||||
|
||||
def _claude_oauth_record(data: Any, source: str) -> Optional[Dict[str, Any]]:
|
||||
def _claude_oauth_record(data: Any, source: str) -> Optional[dict[str, Any]]:
|
||||
"""Normalise a ``{"claudeAiOauth": {...}}`` payload into our credential dict."""
|
||||
oauth_data = data.get("claudeAiOauth")
|
||||
access_token = oauth_data.get("accessToken", "") if isinstance(oauth_data, dict) else ""
|
||||
@@ -252,7 +252,7 @@ def _decode_keychain_attr(match: Optional["re.Match[str]"]) -> str:
|
||||
return match.group("text") or ""
|
||||
|
||||
|
||||
def _find_claude_code_keychain_item() -> Optional[tuple[str, Dict[str, Any]]]:
|
||||
def _find_claude_code_keychain_item() -> Optional[tuple[str, dict[str, Any]]]:
|
||||
"""``(account, payload)`` of the ``Claude Code-credentials`` login Keychain item, or None.
|
||||
|
||||
One ``find-generic-password -g`` call: attributes on stdout, ``password: …`` on stderr. The
|
||||
@@ -270,8 +270,8 @@ def _find_claude_code_keychain_item() -> Optional[tuple[str, Dict[str, Any]]]:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
account = _decode_keychain_attr(re.search(r'^\s*"acct"<blob>=' + _KEYCHAIN_ATTR + r"\s*$", result.stdout, re.M))
|
||||
raw = _decode_keychain_attr(re.search(r"^password: " + _KEYCHAIN_ATTR + r"\s*$", result.stderr, re.M))
|
||||
account = _decode_keychain_attr(re.search(r'^\s*"acct"<blob>=' + _KEYCHAIN_ATTR + r"\s*$", result.stdout, re.MULTILINE))
|
||||
raw = _decode_keychain_attr(re.search(r"^password: " + _KEYCHAIN_ATTR + r"\s*$", result.stderr, re.MULTILINE))
|
||||
if not account or not raw:
|
||||
return None
|
||||
try:
|
||||
@@ -281,7 +281,7 @@ def _find_claude_code_keychain_item() -> Optional[tuple[str, Dict[str, Any]]]:
|
||||
return (account, payload) if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def _read_claude_code_keychain_payload() -> Optional[Dict[str, Any]]:
|
||||
def _read_claude_code_keychain_payload() -> Optional[dict[str, Any]]:
|
||||
"""Raw ``{"claudeAiOauth": {...}, ...}`` payload from the macOS Keychain, or None.
|
||||
|
||||
Returns the full entry (not the normalised credential record) so a refresh
|
||||
@@ -312,7 +312,7 @@ def _read_claude_code_keychain_payload() -> Optional[Dict[str, Any]]:
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def _keychain_mirror_command(account: str, payload: Dict[str, Any]) -> tuple[list[str], str]:
|
||||
def _keychain_mirror_command(account: str, payload: dict[str, Any]) -> tuple[list[str], str]:
|
||||
"""``(argv, stdin)`` that updates the Claude Code Keychain item with ``payload``.
|
||||
|
||||
The command line goes to ``security -i`` on stdin, with the secret hex-encoded (``-X``):
|
||||
@@ -328,7 +328,7 @@ def _keychain_mirror_command(account: str, payload: Dict[str, Any]) -> tuple[lis
|
||||
return ["security", "-i"], line
|
||||
|
||||
|
||||
def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
||||
def _read_claude_code_credentials_from_keychain() -> Optional[dict[str, Any]]:
|
||||
"""Read the "Claude Code-credentials" macOS Keychain entry (Claude Code >=2.1.114)."""
|
||||
payload = _read_claude_code_keychain_payload()
|
||||
return _claude_oauth_record(payload, "macos_keychain") if payload else None
|
||||
@@ -343,12 +343,12 @@ def claude_code_credentials_path() -> Path:
|
||||
return root / ".credentials.json"
|
||||
|
||||
|
||||
def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]:
|
||||
def _read_claude_code_credentials_from_file() -> Optional[dict[str, Any]]:
|
||||
data = _load_json_if_exists(claude_code_credentials_path(), "~/.claude/.credentials.json")
|
||||
return _claude_oauth_record(data, "claude_code_credentials_file") if data is not None else None
|
||||
|
||||
|
||||
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
def read_claude_code_credentials() -> Optional[dict[str, Any]]:
|
||||
"""Read refreshable Claude Code OAuth credentials (Keychain and/or file). When both exist: prefer the only
|
||||
non-expired one (Claude Code 2.1.x refreshes one source but not the other), else the later ``expiresAt`` so a
|
||||
refresh uses the freshest refreshToken. ~/.claude.json primaryApiKey is deliberately excluded.
|
||||
@@ -368,7 +368,7 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
return kc_creds if (kc_creds.get("expiresAt", 0) or 0) >= (file_creds.get("expiresAt", 0) or 0) else file_creds
|
||||
|
||||
|
||||
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
||||
def is_claude_code_token_valid(creds: dict[str, Any]) -> bool:
|
||||
"""Non-expired access token (60s buffer); no expiresAt means managed key → valid if present."""
|
||||
expires_at = creds.get("expiresAt", 0)
|
||||
return int(time.time() * 1000) < (expires_at - 60_000) if expires_at else bool(creds.get("accessToken"))
|
||||
@@ -415,7 +415,7 @@ def _oauth_http_error(exc: Any, *, what: str) -> AnthropicOAuthError:
|
||||
|
||||
def _post_oauth_token(
|
||||
data: bytes, *, content_type: str, timeout: int, what: str, user_agent: str = _OAUTH_TOKEN_USER_AGENT
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""POST to the token endpoints in order; raise the last error if all fail."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -438,7 +438,7 @@ def _post_oauth_token(
|
||||
raise last_error or ValueError(f"Anthropic token {what} failed")
|
||||
|
||||
|
||||
def _oauth_token_state(result: Dict[str, Any], *, fallback_refresh_token: str = "") -> Dict[str, Any]:
|
||||
def _oauth_token_state(result: dict[str, Any], *, fallback_refresh_token: str = "") -> dict[str, Any]:
|
||||
"""Token-endpoint JSON -> ``{access_token, refresh_token, expires_at_ms}`` (expires_in defaults to 3600s)."""
|
||||
return {
|
||||
"access_token": result.get("access_token", ""),
|
||||
@@ -447,7 +447,7 @@ def _oauth_token_state(result: Dict[str, Any], *, fallback_refresh_token: str =
|
||||
}
|
||||
|
||||
|
||||
def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False) -> Dict[str, Any]:
|
||||
def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False) -> dict[str, Any]:
|
||||
"""Refresh an Anthropic OAuth token without mutating local credential files."""
|
||||
import urllib.parse
|
||||
if not refresh_token:
|
||||
@@ -462,7 +462,7 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
|
||||
return _oauth_token_state(result, fallback_refresh_token=refresh_token)
|
||||
|
||||
|
||||
def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
|
||||
def _refresh_oauth_token(creds: dict[str, Any]) -> Optional[str]:
|
||||
"""Refresh an expired Claude Code OAuth token, returning the new access token. Refresh tokens are single-use and
|
||||
Claude Code refreshes on its own schedule, so we first re-read the live sources and adopt an already-rotated
|
||||
token instead of racing it into ``invalid_grant``. Read, decision, POST and write-back share the pool's
|
||||
@@ -545,7 +545,7 @@ def _write_claude_code_credentials(
|
||||
except (OSError, ValueError) as e:
|
||||
logger.error("Failed to write refreshed credentials to %s: %s", cred_path, e)
|
||||
raise CredentialPersistError(cred_path, e) from e
|
||||
oauth_data: Dict[str, Any] = {"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms}
|
||||
oauth_data: dict[str, Any] = {"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms}
|
||||
if scopes is not None:
|
||||
oauth_data["scopes"] = scopes
|
||||
elif "claudeAiOauth" in existing and "scopes" in existing["claudeAiOauth"]:
|
||||
@@ -557,8 +557,8 @@ def _write_claude_code_credentials(
|
||||
|
||||
|
||||
def _merge_keychain_credential_payload(
|
||||
existing_payload: Dict[str, Any], access_token: str, refresh_token: str, expires_at_ms: int
|
||||
) -> Dict[str, Any]:
|
||||
existing_payload: dict[str, Any], access_token: str, refresh_token: str, expires_at_ms: int
|
||||
) -> dict[str, Any]:
|
||||
"""Rotate the ``claudeAiOauth`` token triple over the existing Keychain payload,
|
||||
preserving its metadata (``subscriptionType`` / ``rateLimitTier`` / ``scopes``).
|
||||
|
||||
@@ -608,7 +608,7 @@ def _mirror_claude_code_credentials_to_keychain(
|
||||
# ── Resolution ──
|
||||
|
||||
|
||||
def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
def _resolve_claude_code_token_from_credentials(creds: Optional[dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Resolve a token from Claude Code credential files, refreshing if needed."""
|
||||
creds = creds or read_claude_code_credentials()
|
||||
if not creds:
|
||||
@@ -627,7 +627,7 @@ def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]]
|
||||
return refreshed or None
|
||||
|
||||
|
||||
def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[dict[str, Any]]) -> Optional[str]:
|
||||
"""Prefer refreshable Claude Code creds over a static env OAuth token: Hermes historically persisted setup tokens
|
||||
into ANTHROPIC_TOKEN, and that static token would otherwise win before the refreshable file is inspected."""
|
||||
if not (env_token and _is_oauth_token(env_token) and isinstance(creds, dict) and creds.get("refreshToken")):
|
||||
@@ -751,7 +751,7 @@ def _generate_pkce() -> tuple:
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
def run_hermes_oauth_login_pure() -> Optional[dict[str, Any]]:
|
||||
"""Run Hermes-native OAuth PKCE flow and return credential state."""
|
||||
import webbrowser
|
||||
from urllib.parse import urlencode
|
||||
@@ -773,7 +773,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui
|
||||
except Exception:
|
||||
_can_open_gui = lambda: True # noqa: E731 — degrade to prior behavior
|
||||
_can_open_gui = lambda: True
|
||||
if _can_open_gui():
|
||||
with contextlib.suppress(Exception):
|
||||
webbrowser.open(auth_url)
|
||||
@@ -806,7 +806,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
return _oauth_token_state(result)
|
||||
|
||||
|
||||
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
|
||||
def read_hermes_oauth_credentials() -> Optional[dict[str, Any]]:
|
||||
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
|
||||
data = _load_json_if_exists(_get_hermes_oauth_file(), "Hermes OAuth credentials")
|
||||
return data if data is not None and data.get("accessToken") else None
|
||||
|
||||
@@ -27,7 +27,7 @@ def _block_type(b: Any) -> Any:
|
||||
return b.get("type") if isinstance(b, dict) else None
|
||||
|
||||
|
||||
def _has_block_type(blocks: List[Any], types) -> bool:
|
||||
def _has_block_type(blocks: list[Any], types) -> bool:
|
||||
return any(_block_type(b) in types for b in blocks)
|
||||
|
||||
|
||||
@@ -37,18 +37,18 @@ def _is_blank_text_block(b: Any) -> bool:
|
||||
return _block_type(b) == "text" and not (isinstance(b.get("text"), str) and b["text"].strip())
|
||||
|
||||
|
||||
def _cache_control_of(b: Any) -> Optional[Dict[str, Any]]:
|
||||
def _cache_control_of(b: Any) -> Optional[dict[str, Any]]:
|
||||
cc = b.get("cache_control") if isinstance(b, dict) else None
|
||||
return cc if isinstance(cc, dict) else None
|
||||
|
||||
|
||||
def _text_block(text: str) -> Dict[str, str]:
|
||||
def _text_block(text: str) -> dict[str, str]:
|
||||
return {"type": "text", "text": text}
|
||||
|
||||
|
||||
def _text_block_with_citations(text: Any, cits: Any) -> Dict[str, Any]:
|
||||
def _text_block_with_citations(text: Any, cits: Any) -> dict[str, Any]:
|
||||
"""Text block carrying ``citations`` only when it is a non-empty list (the only input-valid shape)."""
|
||||
block: Dict[str, Any] = _text_block(text)
|
||||
block: dict[str, Any] = _text_block(text)
|
||||
if isinstance(cits, list) and cits:
|
||||
block["citations"] = cits
|
||||
return block
|
||||
@@ -62,20 +62,20 @@ def _parse_tool_args(raw: Any) -> Any:
|
||||
return {}
|
||||
|
||||
|
||||
def _strip_thinking(blocks: List[Any]) -> List[Any]:
|
||||
def _strip_thinking(blocks: list[Any]) -> list[Any]:
|
||||
return [b for b in blocks if _block_type(b) not in _THINKING_TYPES]
|
||||
|
||||
|
||||
def _block_ids(blocks: List[Any], btype: str, key: str) -> set:
|
||||
def _block_ids(blocks: list[Any], btype: str, key: str) -> set:
|
||||
return {b.get(key) for b in blocks if _block_type(b) == btype}
|
||||
|
||||
|
||||
def _assistant_block_lists(result: List[Dict[str, Any]]):
|
||||
def _assistant_block_lists(result: list[dict[str, Any]]):
|
||||
"""``(index, message)`` for every assistant message whose content is a block list."""
|
||||
return ((i, m) for i, m in enumerate(result) if m.get("role") == "assistant" and isinstance(m.get("content"), list))
|
||||
|
||||
|
||||
def _carry_cache_control(out: Dict[str, Any], b: Any, *, copy: bool = False) -> Dict[str, Any]:
|
||||
def _carry_cache_control(out: dict[str, Any], b: Any, *, copy: bool = False) -> dict[str, Any]:
|
||||
"""Carry a dict-valued ``cache_control`` marker from ``b`` onto ``out`` (returned); ``copy``
|
||||
shallow-copies it so the caller's dict is never shared with the wire payload."""
|
||||
cc = _cache_control_of(b)
|
||||
@@ -84,7 +84,7 @@ def _carry_cache_control(out: Dict[str, Any], b: Any, *, copy: bool = False) ->
|
||||
return out
|
||||
|
||||
|
||||
def _split_blank_text_blocks(blocks: List[Any]) -> Tuple[List[Any], Any, List[int]]:
|
||||
def _split_blank_text_blocks(blocks: list[Any]) -> tuple[list[Any], Any, list[int]]:
|
||||
"""``(kept, relocated_cache_control, dropped_indexes)``: drop blank text blocks, remembering
|
||||
the cache_control of the last one dropped so the caller can relocate the breakpoint."""
|
||||
dropped = [i for i, blk in enumerate(blocks) if _is_blank_text_block(blk)]
|
||||
@@ -117,11 +117,11 @@ def _sanitize_tool_id(tool_id: str) -> str:
|
||||
return (re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id) if tool_id else "") or "tool_0"
|
||||
|
||||
|
||||
def _tool_use_block(tool_id: Any, name: Any, tool_input: Any) -> Dict[str, Any]:
|
||||
def _tool_use_block(tool_id: Any, name: Any, tool_input: Any) -> dict[str, Any]:
|
||||
return {"type": "tool_use", "id": _sanitize_tool_id(tool_id), "name": name, "input": tool_input}
|
||||
|
||||
|
||||
def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
|
||||
def _normalize_tool_input_schema(schema: Any) -> dict[str, Any]:
|
||||
"""Normalize a tool schema for Anthropic's validator: collapse nullable unions (``anyOf:
|
||||
[{type: string}, {type: null}]`` from Pydantic/MCP optional fields) to the non-null branch —
|
||||
optionality is already expressed by ``required``; ``keep_nullable_hint=False`` because the
|
||||
@@ -141,7 +141,7 @@ def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
|
||||
return normalized
|
||||
|
||||
|
||||
def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
|
||||
def convert_tools_to_anthropic(tools: list[dict]) -> list[dict]:
|
||||
"""Convert OpenAI tool definitions to Anthropic format. Duplicate names are dropped with a
|
||||
warning (Anthropic hard-400s on them); ``cache_control`` on the OpenAI tool dict is forwarded."""
|
||||
result = []
|
||||
@@ -156,7 +156,7 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
|
||||
continue
|
||||
if name:
|
||||
seen_names.add(name)
|
||||
anthropic_tool: Dict[str, Any] = {
|
||||
anthropic_tool: dict[str, Any] = {
|
||||
"name": name, "description": fn.get("description", ""),
|
||||
"input_schema": _normalize_tool_input_schema(fn.get("parameters") or {}),
|
||||
}
|
||||
@@ -164,7 +164,7 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
|
||||
return result
|
||||
|
||||
|
||||
def _image_block_from_openai_url(url: str) -> Dict[str, Any]:
|
||||
def _image_block_from_openai_url(url: str) -> dict[str, Any]:
|
||||
"""OpenAI image URL / data URL -> Anthropic ``image`` block. An inline subtype the API rejects
|
||||
(svg+xml, bmp, tiff) 400s every replay once in history: an SVG is rasterized to PNG when a
|
||||
rasterizer is installed, anything else unsupported becomes a text placeholder."""
|
||||
@@ -184,7 +184,7 @@ def _image_block_from_openai_url(url: str) -> Dict[str, Any]:
|
||||
return {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}}
|
||||
|
||||
|
||||
def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
|
||||
def _convert_content_part_to_anthropic(part: Any) -> Optional[dict[str, Any]]:
|
||||
"""Convert one OpenAI-style content part to an Anthropic block (None -> dropped)."""
|
||||
if part is None:
|
||||
return None
|
||||
@@ -239,7 +239,7 @@ def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_preserved_thinking_blocks(message: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
def _extract_preserved_thinking_blocks(message: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Deep-copied thinking/redacted_thinking blocks from ``reasoning_details``."""
|
||||
raw_details = message.get("reasoning_details")
|
||||
if not isinstance(raw_details, list):
|
||||
@@ -258,10 +258,10 @@ def _convert_content_to_anthropic(content: Any) -> Any:
|
||||
return [b for b in map(_convert_content_part_to_anthropic, content) if b is not None]
|
||||
|
||||
|
||||
def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
|
||||
def _content_parts_to_anthropic_blocks(parts: Any) -> list[dict[str, Any]]:
|
||||
"""Tool-message content parts -> tool_result inner blocks (text + image only, the types
|
||||
Anthropic accepts there). Used for multimodal tool results."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
out: list[dict[str, Any]] = []
|
||||
for block in map(_convert_content_part_to_anthropic, parts if isinstance(parts, list) else []):
|
||||
if not block:
|
||||
continue
|
||||
@@ -281,7 +281,7 @@ def _safe_text(text: Any) -> str:
|
||||
return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER
|
||||
|
||||
|
||||
def _replay_text(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def _replay_text(b: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
# Drop blank blocks rather than coerce in place: the caller relocates any cache_control and
|
||||
# falls back to a placeholder only when nothing survives, so "(empty)" never sits as
|
||||
# model-visible noise next to real blocks.
|
||||
@@ -290,20 +290,20 @@ def _replay_text(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return _carry_cache_control(_text_block_with_citations(b["text"], b.get("citations")), b)
|
||||
|
||||
|
||||
def _replay_thinking(b: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _replay_thinking(b: dict[str, Any]) -> dict[str, Any]:
|
||||
out = {"type": "thinking", "thinking": b.get("thinking", "")}
|
||||
return {**out, "signature": b["signature"]} if b.get("signature") else out
|
||||
|
||||
|
||||
def _replay_redacted_thinking(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def _replay_redacted_thinking(b: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None
|
||||
|
||||
|
||||
def _replay_tool_use(b: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _replay_tool_use(b: dict[str, Any]) -> dict[str, Any]:
|
||||
return _carry_cache_control(_tool_use_block(b.get("id", ""), b.get("name", ""), b.get("input", {})), b)
|
||||
|
||||
|
||||
def _replay_image(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def _replay_image(b: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
src = b.get("source")
|
||||
return {"type": "image", "source": src} if isinstance(src, dict) else None
|
||||
|
||||
@@ -314,7 +314,7 @@ _REPLAY_SANITIZERS = {
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def _sanitize_replay_block(b: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Whitelist a stored Anthropic block so it is valid as REQUEST input. SDK response blocks carry
|
||||
output-only fields the INPUT schema forbids ("Extra inputs are not permitted": ``parsed_output``,
|
||||
``caller``, ``citations=None``), and ``_to_plain_data`` captured them verbatim. Whitelist per
|
||||
@@ -326,7 +326,7 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return sanitizer(b) if sanitizer else None
|
||||
|
||||
|
||||
def _apply_assistant_cache_control_to_last_cacheable_block(blocks: List[Dict[str, Any]], cache_control: Any) -> None:
|
||||
def _apply_assistant_cache_control_to_last_cacheable_block(blocks: list[dict[str, Any]], cache_control: Any) -> None:
|
||||
if not isinstance(cache_control, dict):
|
||||
return
|
||||
for block in reversed(blocks):
|
||||
@@ -335,7 +335,7 @@ def _apply_assistant_cache_control_to_last_cacheable_block(blocks: List[Dict[str
|
||||
break
|
||||
|
||||
|
||||
def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Optional[List[Dict[str, Any]]]:
|
||||
def _replay_ordered_blocks(m: dict[str, Any], ordered_blocks: list[Any]) -> Optional[list[dict[str, Any]]]:
|
||||
"""Interleaved-thinking replay: rebuild the assistant turn from the verbatim block list
|
||||
normalize_response stored (only for turns interleaving SIGNED thinking with tool_use).
|
||||
Preserves block ORDER; returns None if nothing survives. tool_use ``input`` is re-sourced from
|
||||
@@ -346,7 +346,7 @@ def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Opti
|
||||
for tc in m.get("tool_calls", []) or []
|
||||
if isinstance(tc, dict)
|
||||
}
|
||||
replayed: List[Dict[str, Any]] = []
|
||||
replayed: list[dict[str, Any]] = []
|
||||
relocated_cc = None
|
||||
dropped_blank_text = False
|
||||
for b in ordered_blocks:
|
||||
@@ -377,7 +377,7 @@ def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Opti
|
||||
return replayed
|
||||
|
||||
|
||||
def assistant_replay_carrier(m: Dict[str, Any]) -> Tuple[bool, List[Dict[str, Any]]]:
|
||||
def assistant_replay_carrier(m: dict[str, Any]) -> tuple[bool, list[dict[str, Any]]]:
|
||||
"""``(ordered, blocks)``: the sanitized ordered sidecar when any block survives it (the whole turn
|
||||
then replays from it), else the ``reasoning_details`` thinking blocks. Single owner of carrier
|
||||
precedence for conversion, accounting and rejected-signature mirrors."""
|
||||
@@ -389,7 +389,7 @@ def assistant_replay_carrier(m: Dict[str, Any]) -> Tuple[bool, List[Dict[str, An
|
||||
return False, _extract_preserved_thinking_blocks(m)
|
||||
|
||||
|
||||
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _convert_assistant_message(m: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Assistant message -> Anthropic content blocks (thinking, text, tool_use, Kimi/DeepSeek
|
||||
reasoning_content injection)."""
|
||||
# apply_anthropic_cache_control marks an assistant turn with non-empty text by writing cache_control
|
||||
@@ -434,10 +434,10 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"role": "assistant", "content": effective}
|
||||
|
||||
|
||||
def _tool_result_content(m: Dict[str, Any]) -> Any:
|
||||
def _tool_result_content(m: dict[str, Any]) -> Any:
|
||||
"""Resolve a tool message's content into tool_result content (blocks or string)."""
|
||||
content = m.get("content", "")
|
||||
multimodal_blocks: Optional[List[Dict[str, Any]]] = None
|
||||
multimodal_blocks: Optional[list[dict[str, Any]]] = None
|
||||
if isinstance(content, dict) and content.get("_multimodal"):
|
||||
multimodal_blocks = _content_parts_to_anthropic_blocks(content.get("content") or [])
|
||||
if not multimodal_blocks and content.get("text_summary"):
|
||||
@@ -456,7 +456,7 @@ def _tool_result_content(m: Dict[str, Any]) -> Any:
|
||||
return (content if isinstance(content, str) else json.dumps(content) if content else "") or "(no output)"
|
||||
|
||||
|
||||
def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, Any]) -> None:
|
||||
def _convert_tool_message_to_result(result: list[dict[str, Any]], m: dict[str, Any]) -> None:
|
||||
"""Append a tool_result to ``result``, merging into a trailing tool_result user message when
|
||||
there is one. Mutates ``result`` in place."""
|
||||
tool_result = {
|
||||
@@ -472,7 +472,7 @@ def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, A
|
||||
result.append({"role": "user", "content": [tool_result]})
|
||||
|
||||
|
||||
def _convert_user_message(content: Any) -> Dict[str, Any]:
|
||||
def _convert_user_message(content: Any) -> dict[str, Any]:
|
||||
"""Validate and convert a user message to Anthropic format."""
|
||||
if isinstance(content, list):
|
||||
content = _fix_blank_text_blocks_in_list(
|
||||
@@ -484,7 +484,7 @@ def _convert_user_message(content: Any) -> Dict[str, Any]:
|
||||
return {"role": "user", "content": content}
|
||||
|
||||
|
||||
def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
def _strip_orphaned_tool_blocks(result: list[dict[str, Any]]) -> None:
|
||||
"""Strip tool_use blocks with no matching tool_result, and vice versa. Compression/truncation
|
||||
can remove either side of a pair or insert messages between them. Anthropic requires the
|
||||
tool_result in the IMMEDIATELY FOLLOWING user message — a global id match is not enough.
|
||||
@@ -523,19 +523,19 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
m["content"] = new_content if new_content else [_text_block("(tool result removed)")]
|
||||
|
||||
|
||||
def _concat_content(prev: Any, curr: Any) -> List[Any]:
|
||||
def _concat_content(prev: Any, curr: Any) -> list[Any]:
|
||||
"""Merge two message contents into one block list, each side's blocks kept intact (a string
|
||||
becomes its own text block). Strings are never joined: the first turn's bytes must equal what a
|
||||
later request replays as a standalone turn, or the prompt-cache prefix diverges at that block
|
||||
(MoA appends per-turn guidance after ``user(task)`` on iteration 1 and replays ``user(task)``
|
||||
alone on iteration 2 — #112358)."""
|
||||
as_blocks = lambda c: [_text_block(c)] if isinstance(c, str) else list(c) # noqa: E731
|
||||
as_blocks = lambda c: [_text_block(c)] if isinstance(c, str) else list(c)
|
||||
return as_blocks(prev) + as_blocks(curr)
|
||||
|
||||
|
||||
def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _merge_consecutive_roles(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Merge consecutive same-role messages to enforce alternation. Returns a new list."""
|
||||
fixed: List[Dict[str, Any]] = []
|
||||
fixed: list[dict[str, Any]] = []
|
||||
for m in result:
|
||||
if not (fixed and fixed[-1]["role"] == m["role"]):
|
||||
fixed.append(m)
|
||||
@@ -552,7 +552,7 @@ def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any
|
||||
return fixed
|
||||
|
||||
|
||||
def _keep_valid_thinking(content: List[Any], signature_dead: bool) -> List[Any]:
|
||||
def _keep_valid_thinking(content: list[Any], signature_dead: bool) -> list[Any]:
|
||||
"""Keep valid signed Anthropic thinking, demoting readable unsigned/invalidated thinking.
|
||||
Preserved-thinking models apply this to historical turns too; older models apply it only
|
||||
to the latest assistant turn."""
|
||||
@@ -570,7 +570,7 @@ def _keep_valid_thinking(content: List[Any], signature_dead: bool) -> List[Any]:
|
||||
return new_content
|
||||
|
||||
|
||||
def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | None, model: str | None) -> None:
|
||||
def _manage_thinking_signatures(result: list[dict[str, Any]], base_url: str | None, model: str | None) -> None:
|
||||
"""Strip or preserve thinking blocks per endpoint. Mutates ``result`` in place.
|
||||
|
||||
Anthropic signs thinking blocks against the full turn; any upstream mutation invalidates them
|
||||
@@ -606,7 +606,7 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No
|
||||
m.pop("_thinking_signature_invalidated", None) # internal flag, never on the wire
|
||||
|
||||
|
||||
def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
|
||||
def _evict_old_screenshots(result: list[dict[str, Any]]) -> None:
|
||||
"""Retire screenshot payloads once the request would cross the API's per-request image limit.
|
||||
|
||||
Mutates ``result`` in place. This wire pass has no byte sizes, so it enforces the block
|
||||
@@ -639,7 +639,7 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
|
||||
]
|
||||
|
||||
|
||||
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
|
||||
def _ensure_leading_user_turn(result: list[dict[str, Any]]) -> None:
|
||||
"""Anthropic requires messages[0].role == user; prepend a placeholder turn otherwise. A second
|
||||
auto-compaction can leave a role=assistant summary first, which the API rejects (often masked
|
||||
as a misleading tool_use/tool_result 400). The filler must be non-whitespace text or it trades
|
||||
@@ -655,8 +655,8 @@ def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
|
||||
|
||||
|
||||
def _fix_blank_text_blocks_in_list(
|
||||
blocks: List[Any], *, placeholder_text: str, msg_index: int, role: Any, location: str
|
||||
) -> List[Any]:
|
||||
blocks: list[Any], *, placeholder_text: str, msg_index: int, role: Any, location: str
|
||||
) -> list[Any]:
|
||||
"""Drop blank text blocks; relocate any cache_control they carried onto the last surviving
|
||||
cacheable block; if nothing survives, substitute one placeholder block (carrying the relocated
|
||||
marker). Non-text blocks and order are untouched. Returns a new list; logs structure only."""
|
||||
@@ -672,7 +672,7 @@ def _fix_blank_text_blocks_in_list(
|
||||
return kept
|
||||
|
||||
|
||||
def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
def _scrub_blank_text_blocks(result: list[dict[str, Any]]) -> None:
|
||||
"""Final boundary guard against blank text blocks (HTTP 400 "text content blocks must contain
|
||||
non-whitespace text"), including inside tool_result content. Runs LAST so a blank block from
|
||||
any current or future producer never reaches the wire. Mutates ``result`` in place."""
|
||||
@@ -712,15 +712,15 @@ def _convert_system_content(content: Any) -> Any:
|
||||
|
||||
|
||||
def convert_messages_to_anthropic(
|
||||
messages: List[Dict], base_url: str | None = None, model: str | None = None
|
||||
) -> Tuple[Optional[Any], List[Dict]]:
|
||||
messages: list[dict], base_url: str | None = None, model: str | None = None
|
||||
) -> tuple[Optional[Any], list[dict]]:
|
||||
"""Convert OpenAI-format messages to Anthropic format -> ``(system, messages)``. System is
|
||||
extracted into its own param (a string, or a block list when cache_control is present).
|
||||
``base_url``/``model`` drive thinking-signature policy — third-party endpoints strip signatures
|
||||
(proprietary, they 400 on them); Kimi-family endpoints/models keep unsigned
|
||||
reasoning_content-derived blocks, which Kimi requires even when empty."""
|
||||
system = None
|
||||
result: List[Dict[str, Any]] = []
|
||||
result: list[dict[str, Any]] = []
|
||||
for m in messages:
|
||||
role = m.get("role", "user")
|
||||
if role == "system":
|
||||
|
||||
@@ -78,7 +78,7 @@ class ApiErrorSummaryMixin:
|
||||
|
||||
@staticmethod
|
||||
def _is_entitlement_failure(
|
||||
error_context: Optional[Dict[str, Any]], status_code: Optional[int]
|
||||
error_context: Optional[dict[str, Any]], status_code: Optional[int]
|
||||
) -> bool:
|
||||
"""Detect subscription/entitlement 401/403s that masquerade as auth failures.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ def _model_dump(value: Any) -> Any:
|
||||
class ApiRequestHooksMixin:
|
||||
"""Hook payload sanitising + ``api_request_error`` dispatch (see module docstring)."""
|
||||
|
||||
def _usage_summary_for_api_request_hook(self, response: Any) -> Optional[Dict[str, Any]]:
|
||||
def _usage_summary_for_api_request_hook(self, response: Any) -> Optional[dict[str, Any]]:
|
||||
"""Token buckets for ``post_api_request`` plugins (no raw ``response`` object)."""
|
||||
if response is None:
|
||||
return None
|
||||
@@ -86,7 +86,7 @@ class ApiRequestHooksMixin:
|
||||
)
|
||||
|
||||
if isinstance(value, dict):
|
||||
out: Dict[str, Any] = {}
|
||||
out: dict[str, Any] = {}
|
||||
for idx, (key, item) in enumerate(value.items()):
|
||||
if idx >= max_sequence:
|
||||
out["_truncated_items"] = len(value) - max_sequence
|
||||
@@ -131,7 +131,7 @@ class ApiRequestHooksMixin:
|
||||
"_truncated": True, "original_type": type(value).__name__, "preview": encoded[:limit]
|
||||
}
|
||||
|
||||
def _api_request_payload_for_hook(self, api_kwargs: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
def _api_request_payload_for_hook(self, api_kwargs: Optional[dict[str, Any]]) -> dict[str, Any]:
|
||||
body = {
|
||||
key: value
|
||||
for key, value in (api_kwargs or {}).items()
|
||||
@@ -141,7 +141,7 @@ class ApiRequestHooksMixin:
|
||||
|
||||
def _api_response_payload_for_hook(
|
||||
self, response: Any, assistant_message: Any, *, finish_reason: Optional[str]
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
# Raw provider SDK tool_call objects are handed to the sanitizer on purpose; `_hook_jsonable` must
|
||||
# keep normalising them (model_dump / __dict__ / dataclass) or subscribers get str() blobs.
|
||||
tool_calls = getattr(assistant_message, "tool_calls", None) or []
|
||||
@@ -162,7 +162,7 @@ class ApiRequestHooksMixin:
|
||||
|
||||
def _invoke_api_request_error_hook(
|
||||
self, *, task_id: str, turn_id: str, api_request_id: str, api_call_count: int,
|
||||
api_start_time: float, api_kwargs: Optional[Dict[str, Any]], error_type: str,
|
||||
api_start_time: float, api_kwargs: Optional[dict[str, Any]], error_type: str,
|
||||
error_message: str, status_code: Optional[int] = None, retry_count: Optional[int] = None,
|
||||
max_retries: Optional[int] = None, retryable: Optional[bool] = None,
|
||||
reason: Optional[str] = None,
|
||||
|
||||
@@ -33,7 +33,7 @@ def async_api_key(sync_client: Any) -> Any:
|
||||
return _provide
|
||||
|
||||
|
||||
def configured_default_headers(sync_client: Any) -> Dict[str, str]:
|
||||
def configured_default_headers(sync_client: Any) -> dict[str, str]:
|
||||
"""The ``default_headers`` mapping the sync client was constructed with (SDK ``_custom_headers``).
|
||||
|
||||
SECURITY: values may carry credentials — never log them.
|
||||
|
||||
+211
-194
File diff suppressed because it is too large.
Load diff
@@ -124,7 +124,7 @@ def _resolve_plugin_oauth_arm(req: _ResolveRequest) -> _ResolveResult:
|
||||
# ``api_key`` / ``external_process`` arms stay on the facade (they share its credential
|
||||
# resolvers); the table maps the auth types whose arms are build-and-route one-liners
|
||||
# or live here as topical arms.
|
||||
REGISTRY_AUTHTYPE_ARMS: Dict[str, Callable[[_ResolveRequest], _ResolveResult]] = {
|
||||
REGISTRY_AUTHTYPE_ARMS: dict[str, Callable[[_ResolveRequest], _ResolveResult]] = {
|
||||
"vertex": _resolve_vertex_arm,
|
||||
"aws_sdk": _resolve_bedrock_arm,
|
||||
"oauth_minimax": _resolve_minimax_oauth_arm,
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any, List, Optional, Tuple
|
||||
|
||||
def _parse_codex_final_response(
|
||||
final: Any, *, issuer_kind: Optional[str] = None, issuer_model: Optional[str] = None,
|
||||
) -> Tuple[List[str], List[Any], Any, str]:
|
||||
) -> tuple[list[str], list[Any], Any, str]:
|
||||
"""Normalize Responses output without losing phase or completion state for aux callers."""
|
||||
from agent.codex_responses_adapter import _lower_or_none, _normalize_codex_response
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ This module runs those rungs — and only those — around a candidate's request
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
|
||||
def _parameter_ladder(first_err: Exception, client: Any, kwargs: Dict[str, Any], *,
|
||||
def _parameter_ladder(first_err: Exception, client: Any, kwargs: dict[str, Any], *,
|
||||
task: Optional[str], tag: str):
|
||||
from agent.auxiliary_client import _LadderRoute, _ladder_parameter_rungs
|
||||
# Keyword construction: the route tuple grows with every new ladder rung (a positional 13-tuple
|
||||
@@ -25,7 +25,7 @@ def _parameter_ladder(first_err: Exception, client: Any, kwargs: Dict[str, Any],
|
||||
|
||||
|
||||
def send_with_parameter_rungs(
|
||||
send: Callable[[Any, Dict[str, Any]], Any], client: Any, kwargs: Dict[str, Any], *, task: Optional[str],
|
||||
send: Callable[[Any, dict[str, Any]], Any], client: Any, kwargs: dict[str, Any], *, task: Optional[str],
|
||||
) -> Any:
|
||||
"""``send(client, kwargs)``; on a parameter 400, retry through the parameter rungs. Any other
|
||||
error (auth, payment, connection) propagates unchanged for the caller's own handling."""
|
||||
@@ -38,7 +38,7 @@ def send_with_parameter_rungs(
|
||||
|
||||
|
||||
async def send_with_parameter_rungs_async(
|
||||
send: Callable[[Any, Dict[str, Any]], Awaitable[Any]], client: Any, kwargs: Dict[str, Any], *,
|
||||
send: Callable[[Any, dict[str, Any]], Awaitable[Any]], client: Any, kwargs: dict[str, Any], *,
|
||||
task: Optional[str],
|
||||
) -> Any:
|
||||
"""Async twin of :func:`send_with_parameter_rungs`."""
|
||||
|
||||
+10
-10
@@ -22,7 +22,7 @@ PRE_AUXILIARY_CALL = "pre_auxiliary_call"
|
||||
POST_AUXILIARY_CALL = "post_auxiliary_call"
|
||||
|
||||
|
||||
def _parent_turn_identity() -> Dict[str, str]:
|
||||
def _parent_turn_identity() -> dict[str, str]:
|
||||
"""``session_id`` / ``task_id`` / ``turn_id`` / ``platform`` of the main turn this auxiliary
|
||||
call runs under, or empty strings for turn-less callers (cron, gateway idle work)."""
|
||||
ident = {"session_id": "", "task_id": "", "turn_id": "", "platform": ""}
|
||||
@@ -42,7 +42,7 @@ def _parent_turn_identity() -> Dict[str, str]:
|
||||
return ident
|
||||
|
||||
|
||||
def _system_prompt(messages: Any, kwargs: Dict[str, Any]) -> str:
|
||||
def _system_prompt(messages: Any, kwargs: dict[str, Any]) -> str:
|
||||
if isinstance(kwargs.get("system"), str): # Anthropic
|
||||
return kwargs["system"]
|
||||
if isinstance(kwargs.get("instructions"), str): # Responses
|
||||
@@ -54,7 +54,7 @@ def _system_prompt(messages: Any, kwargs: Dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _usage_summary(response: Any, *, provider: str, api_mode: str) -> Optional[Dict[str, Any]]:
|
||||
def _usage_summary(response: Any, *, provider: str, api_mode: str) -> Optional[dict[str, Any]]:
|
||||
raw_usage = getattr(response, "usage", None)
|
||||
if response is None or not raw_usage:
|
||||
return None
|
||||
@@ -90,7 +90,7 @@ class _AuxCallHooks:
|
||||
"""Fires the pre/post pair for one provider attempt; the base payload is built once."""
|
||||
|
||||
def __init__(
|
||||
self, *, aux_task: str, metadata: Dict[str, Any], client: Any, kwargs: Dict[str, Any],
|
||||
self, *, aux_task: str, metadata: dict[str, Any], client: Any, kwargs: dict[str, Any],
|
||||
provider: str, model: str, api_mode: str, streaming: bool,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
@@ -98,7 +98,7 @@ class _AuxCallHooks:
|
||||
self.streaming = streaming
|
||||
self.kwargs = kwargs
|
||||
self.started_at = time.time()
|
||||
self.base: Dict[str, Any] = dict(_parent_turn_identity())
|
||||
self.base: dict[str, Any] = dict(_parent_turn_identity())
|
||||
self.base.update(
|
||||
aux_task=aux_task,
|
||||
api_request_id=str(metadata.get("api_request_id") or ""),
|
||||
@@ -143,7 +143,7 @@ class _AuxCallHooks:
|
||||
from agent.api_request_hooks import ApiRequestHooksMixin as _Sanitize
|
||||
|
||||
ended_at = time.time()
|
||||
payload: Dict[str, Any] = dict(
|
||||
payload: dict[str, Any] = dict(
|
||||
self.base, ended_at=ended_at, api_duration=max(0.0, ended_at - self.started_at),
|
||||
error=None if error is None else f"{type(error).__name__}: {error}"[:2000],
|
||||
error_type=None if error is None else type(error).__name__,
|
||||
@@ -220,8 +220,8 @@ def _post_safely(hooks: Optional[_AuxCallHooks], response: Any = None, error: An
|
||||
|
||||
|
||||
def run_with_aux_hooks(
|
||||
call: Callable[[], Any], *, aux_task: str, metadata: Dict[str, Any], client: Any,
|
||||
kwargs: Dict[str, Any], provider: str, model: str, api_mode: str, streaming: bool = False,
|
||||
call: Callable[[], Any], *, aux_task: str, metadata: dict[str, Any], client: Any,
|
||||
kwargs: dict[str, Any], provider: str, model: str, api_mode: str, streaming: bool = False,
|
||||
) -> Any:
|
||||
"""Run one synchronous provider attempt between ``pre_auxiliary_call`` and
|
||||
``post_auxiliary_call``; the exception (if any) is reported in ``post`` and re-raised."""
|
||||
@@ -237,8 +237,8 @@ def run_with_aux_hooks(
|
||||
|
||||
|
||||
async def arun_with_aux_hooks(
|
||||
call: Callable[[], Awaitable[Any]], *, aux_task: str, metadata: Dict[str, Any], client: Any,
|
||||
kwargs: Dict[str, Any], provider: str, model: str, api_mode: str,
|
||||
call: Callable[[], Awaitable[Any]], *, aux_task: str, metadata: dict[str, Any], client: Any,
|
||||
kwargs: dict[str, Any], provider: str, model: str, api_mode: str,
|
||||
) -> Any:
|
||||
"""Async twin of :func:`run_with_aux_hooks`."""
|
||||
hooks = _hooks_or_none(aux_task=aux_task, metadata=metadata, client=client, kwargs=kwargs,
|
||||
|
||||
@@ -15,7 +15,7 @@ from hermes_cli.local_runtime.endpoint import LLAMACPP_ALIASES
|
||||
|
||||
|
||||
def bare_llamacpp_endpoint(provider: Optional[str], base_url: Optional[str],
|
||||
api_key: Any) -> Optional[Tuple[str, Any]]:
|
||||
api_key: Any) -> Optional[tuple[str, Any]]:
|
||||
"""``(base_url, api_key)`` of the profile's local llama.cpp server for a bare alias.
|
||||
|
||||
``base_url`` is ``""`` when nothing is serving. ``None`` when the request is not a bare alias:
|
||||
|
||||
@@ -40,14 +40,14 @@ def _is_disabled(reasoning_config: Any) -> bool:
|
||||
return isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False
|
||||
|
||||
|
||||
def floor_reasoning_config(reasoning_config: Any) -> Dict[str, Any]:
|
||||
def floor_reasoning_config(reasoning_config: Any) -> dict[str, Any]:
|
||||
"""The caller's disabled ``reasoning_config`` lifted to the floor; anything else returned as-is."""
|
||||
if _is_disabled(reasoning_config):
|
||||
return {"enabled": True, "effort": REASONING_FLOOR_EFFORT}
|
||||
return reasoning_config
|
||||
|
||||
|
||||
def with_reasoning_floor(kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def with_reasoning_floor(kwargs: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Copy of *kwargs* with every thinking-OFF encoding lifted to ``REASONING_FLOOR_EFFORT``:
|
||||
top-level ``reasoning_effort``, ``extra_body.reasoning`` (OpenRouter shape) and the adapter's private
|
||||
``_reasoning_config``. ``None`` when nothing was disabled, so the ladder never re-sends an unchanged
|
||||
@@ -72,7 +72,7 @@ def with_reasoning_floor(kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
def remember_reasoning_floor(
|
||||
provider: Optional[str], base_url: Optional[str], rejected_kwargs: Dict[str, Any], error: BaseException,
|
||||
provider: Optional[str], base_url: Optional[str], rejected_kwargs: dict[str, Any], error: BaseException,
|
||||
) -> None:
|
||||
"""Record that this route's ``rejected_kwargs["model"]`` refuses to disable reasoning (the ladder
|
||||
calls this after the stepped-up retry succeeded)."""
|
||||
|
||||
@@ -45,7 +45,7 @@ def _route_key(provider: Optional[str], base_url: Optional[str]) -> str:
|
||||
return (urlparse(base_url or "").netloc or "").lower() or str(provider or "").strip().lower()
|
||||
|
||||
|
||||
def _response_format_type(request_kwargs: Dict[str, Any]) -> Optional[str]:
|
||||
def _response_format_type(request_kwargs: dict[str, Any]) -> Optional[str]:
|
||||
extra_body = request_kwargs.get("extra_body")
|
||||
response_format = (extra_body or {}).get("response_format") if isinstance(extra_body, dict) else None
|
||||
if response_format is None:
|
||||
@@ -78,7 +78,7 @@ def is_capability_rejection(error: Optional[BaseException]) -> bool:
|
||||
|
||||
|
||||
def remember_structured_output_rejection(
|
||||
provider: Optional[str], base_url: Optional[str], rejected_kwargs: Dict[str, Any], error: BaseException,
|
||||
provider: Optional[str], base_url: Optional[str], rejected_kwargs: dict[str, Any], error: BaseException,
|
||||
) -> None:
|
||||
"""Record that this route's ``rejected_kwargs["model"]`` rejected the ``response_format`` type carried
|
||||
by *rejected_kwargs* — only when *error* names the capability, never for a schema-validation 400."""
|
||||
@@ -88,9 +88,9 @@ def remember_structured_output_rejection(
|
||||
|
||||
|
||||
def without_unsupported_response_format(
|
||||
extra_body: Dict[str, Any], provider: Optional[str], base_url: Optional[str], model: Optional[str],
|
||||
extra_body: dict[str, Any], provider: Optional[str], base_url: Optional[str], model: Optional[str],
|
||||
task: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""*extra_body* minus a ``response_format`` whose type this route+model is known to reject; unchanged
|
||||
otherwise."""
|
||||
response_format = extra_body.get("response_format")
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Any, Dict
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_auxiliary_task_config(task: str, _seen: frozenset = frozenset()) -> Dict[str, Any]:
|
||||
def _get_auxiliary_task_config(task: str, _seen: frozenset = frozenset()) -> dict[str, Any]:
|
||||
"""Config dict for auxiliary.<task>, or {} when unavailable. Plugin-registered tasks get their
|
||||
declared defaults layered under user config (user wins); built-in defaults live in DEFAULT_CONFIG.
|
||||
A task registered with ``inherit_from`` is resolved here, at read time, over the base task's
|
||||
@@ -57,7 +57,7 @@ _AUX_ROUTE_KEYS = frozenset({"provider", "model", "base_url", "api_key", "api_mo
|
||||
"api_key_env", "reasoning_effort"})
|
||||
|
||||
|
||||
def _layer_over_inherited(inherited: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _layer_over_inherited(inherited: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge a task's own ``auxiliary.<task>`` block over its inherited base.
|
||||
|
||||
The picker, "reset to auto" and the dashboard persist ``provider: auto`` plus ``""`` for
|
||||
|
||||
@@ -41,7 +41,7 @@ _AUTH_HEADERS = ("Authorization", "authorization", "Api-Key", "api-key", "X-Api-
|
||||
def has_azure_identity_installed() -> bool:
|
||||
"""Cheap importability check — does not walk the credential chain."""
|
||||
try:
|
||||
import azure.identity # noqa: F401
|
||||
import azure.identity
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -85,11 +85,11 @@ class EntraIdentityConfig:
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "scope", str(self.scope or "").strip() or SCOPE_AI_AZURE_DEFAULT)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"scope": self.scope, "exclude_interactive_browser": self.exclude_interactive_browser}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]], *, default_scope: Optional[str] = None) -> "EntraIdentityConfig":
|
||||
def from_dict(cls, data: Optional[dict[str, Any]], *, default_scope: Optional[str] = None) -> "EntraIdentityConfig":
|
||||
data = data or {}
|
||||
return cls(
|
||||
scope=str(data.get("scope") or "").strip() or default_scope or SCOPE_AI_AZURE_DEFAULT,
|
||||
@@ -114,7 +114,7 @@ def _default_chain_credential(config: EntraIdentityConfig) -> Any:
|
||||
# the ambient default chain is refused outright when the profile sets no credential of its own: every source it
|
||||
# could mint from (env SP, CLI/azd/PowerShell caches, host managed identity) is the launch context's identity,
|
||||
# and _inject_bearer would ship it to whatever base_url the served profile configured.
|
||||
_credentials_by_home: Dict[tuple, Any] = {}
|
||||
_credentials_by_home: dict[tuple, Any] = {}
|
||||
|
||||
_AMBIENT_CHAIN_REFUSAL = (
|
||||
"Entra ID auth is refused for this profile: it sets no AZURE_* credential of its own, and under "
|
||||
@@ -129,7 +129,7 @@ _AMBIENT_CHAIN_REFUSAL = (
|
||||
def _scoped_credential(ai: Any, config: EntraIdentityConfig) -> Any:
|
||||
from agent.secret_scope import current_secret_scope, is_multiplex_active
|
||||
scope = current_secret_scope() or {}
|
||||
read = lambda name: (scope.get(name) or "").strip() # noqa: E731
|
||||
read = lambda name: (scope.get(name) or "").strip()
|
||||
tenant, client = read("AZURE_TENANT_ID"), read("AZURE_CLIENT_ID")
|
||||
if tenant and client and read("AZURE_CLIENT_SECRET"):
|
||||
return ai.ClientSecretCredential(tenant, client, read("AZURE_CLIENT_SECRET"))
|
||||
@@ -164,7 +164,7 @@ def _resolve_config(config: Optional[EntraIdentityConfig], scope: Optional[str],
|
||||
return EntraIdentityConfig(scope=(scope or "").strip() or SCOPE_AI_AZURE_DEFAULT, **overrides)
|
||||
|
||||
|
||||
def _install_failure(allow_install: bool) -> Optional[Dict[str, Any]]:
|
||||
def _install_failure(allow_install: bool) -> Optional[dict[str, Any]]:
|
||||
"""None when ``azure.identity`` is importable (lazy-installing if allowed), else ``{"error", "hint"}``."""
|
||||
if has_azure_identity_installed():
|
||||
return None
|
||||
@@ -187,9 +187,9 @@ def build_token_provider(scope: Optional[str] = None, *, config: Optional[EntraI
|
||||
return ai.get_bearer_token_provider(build_credential(config), config.scope)
|
||||
|
||||
|
||||
def _probe_token(config: EntraIdentityConfig, timeout_seconds: float) -> Optional[Dict[str, Any]]:
|
||||
def _probe_token(config: EntraIdentityConfig, timeout_seconds: float) -> Optional[dict[str, Any]]:
|
||||
"""``get_token`` on a daemon thread under a hard deadline → ``{"token"}`` / ``{"error"}`` / None on timeout."""
|
||||
result: Dict[str, Any] = {}
|
||||
result: dict[str, Any] = {}
|
||||
ctx = contextvars.copy_context()
|
||||
|
||||
def _probe() -> None:
|
||||
@@ -251,11 +251,11 @@ _ENV_SOURCE_CHECKS = (
|
||||
|
||||
def describe_active_credential(config: Optional[EntraIdentityConfig] = None, *, scope: Optional[str] = None,
|
||||
timeout_seconds: float = 10.0, allow_install: bool = True,
|
||||
**overrides: Any) -> Dict[str, Any]:
|
||||
**overrides: Any) -> dict[str, Any]:
|
||||
"""Doctor / preflight diagnostics. Never raises; ``{"ok": False, "error": ...}`` on failure. azure-identity
|
||||
hides the winning inner credential, so this reports a coarse picture (env sources, token expiry) rather
|
||||
than a class name; ``AZURE_LOG_LEVEL=DEBUG`` shows the chain."""
|
||||
info: Dict[str, Any] = {"ok": False}
|
||||
info: dict[str, Any] = {"ok": False}
|
||||
failure = _install_failure(allow_install)
|
||||
if failure is not None:
|
||||
info["error"], info["hint"] = failure["error"], failure["hint"]
|
||||
|
||||
+44
-44
@@ -182,14 +182,14 @@ _REVIEW_INPUT_CONTEXT_FRACTION = 0.75
|
||||
_REVIEW_MAX_INPUT_TOKENS_FALLBACK = 120_000
|
||||
|
||||
|
||||
def _task_block(cfg: Any) -> Dict[str, Any]:
|
||||
def _task_block(cfg: Any) -> dict[str, Any]:
|
||||
"""``cfg["auxiliary"]["background_review"]`` as a dict (``{}`` on any shape mismatch)."""
|
||||
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
|
||||
task = aux.get("background_review", {})
|
||||
return task if isinstance(task, dict) else {}
|
||||
|
||||
|
||||
def _background_review_task_config(task_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _background_review_task_config(task_cfg: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
"""``auxiliary.background_review`` (or ``{}`` on any failure); pass a pre-loaded ``task_cfg``
|
||||
so the spawn / resolve / prompt paths do not re-read config on every turn."""
|
||||
if task_cfg is not None:
|
||||
@@ -213,7 +213,7 @@ def _context_derived_review_input_budget(review_agent: Any = None) -> int:
|
||||
|
||||
|
||||
def _review_input_token_budget(
|
||||
task_cfg: Optional[Dict[str, Any]] = None, review_agent: Any = None,
|
||||
task_cfg: Optional[dict[str, Any]] = None, review_agent: Any = None,
|
||||
) -> Optional[int]:
|
||||
"""Aggregate input-token budget for one review fork (None = unlimited; <= 0 disables). Unset
|
||||
or malformed ``max_input_tokens`` → derived from ``review_agent``'s context window."""
|
||||
@@ -225,7 +225,7 @@ def _review_input_token_budget(
|
||||
return budget if budget > 0 else None
|
||||
|
||||
|
||||
def load_background_review_settings() -> tuple[bool, Dict[str, Any]]:
|
||||
def load_background_review_settings() -> tuple[bool, dict[str, Any]]:
|
||||
"""Single config read -> ``(enabled, task_cfg)``. Fail-open (``enabled=True``) so a broken
|
||||
config never silently disables reviews — but WARN so the cost is visible."""
|
||||
try:
|
||||
@@ -242,7 +242,7 @@ def load_background_review_settings() -> tuple[bool, Dict[str, Any]]:
|
||||
return True, {}
|
||||
|
||||
|
||||
def _resolve_review_runtime(agent: Any, task_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _resolve_review_runtime(agent: Any, task_cfg: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
"""Resolve provider/model/credentials for the review fork. Default (auto / unset / same as
|
||||
parent): the parent's live runtime with ``routed=False`` (codex_app_server -> codex_responses
|
||||
downgrade applied). When ``auxiliary.background_review.{provider,model}`` names a different
|
||||
@@ -314,14 +314,14 @@ def _parent_can_emit_tool_calls(agent: Any) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _msg_text(m: Dict) -> str:
|
||||
def _msg_text(m: dict) -> str:
|
||||
c = m.get("content")
|
||||
if isinstance(c, list):
|
||||
c = " ".join(b.get("text", "") for b in c if isinstance(b, dict))
|
||||
return c.strip() if isinstance(c, str) else ""
|
||||
|
||||
|
||||
def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict]:
|
||||
def _digest_history(messages_snapshot: list[dict], tail: int = 24) -> list[dict]:
|
||||
"""Compact replay for the routed (different-model) path only: keep the recent ``tail``
|
||||
messages verbatim (extended so the kept run never starts on a tool result) and collapse older
|
||||
turns into one synthetic user-role digest, preserving role alternation."""
|
||||
@@ -333,7 +333,7 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict]
|
||||
tail += 1
|
||||
else:
|
||||
return msgs
|
||||
lines: List[str] = []
|
||||
lines: list[str] = []
|
||||
for m in msgs[:-len(keep)]:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
@@ -566,19 +566,19 @@ def _preview(text: str, limit: int) -> str:
|
||||
|
||||
|
||||
# Memory op -> (glyph, which field carries the preview, preview length).
|
||||
_MEMORY_OP_FORMATS: Dict[str, Tuple[str, str, int]] = {
|
||||
_MEMORY_OP_FORMATS: dict[str, tuple[str, str, int]] = {
|
||||
"add": ("➕", "content", 120), "replace": ("✏️", "content", 120), "remove": ("➖", "old_text", 60)
|
||||
}
|
||||
|
||||
|
||||
def _memory_op_line(label: str, action: str, fields: Dict[str, str]) -> Optional[str]:
|
||||
def _memory_op_line(label: str, action: str, fields: dict[str, str]) -> Optional[str]:
|
||||
"""Verbose line for one memory add/replace/remove, or None when no preview text."""
|
||||
glyph, field_name, limit = _MEMORY_OP_FORMATS.get(action) or (None, "", 0)
|
||||
text = fields.get(field_name) or "" if glyph else ""
|
||||
return t("display.review.memory_op_line", label=label, glyph=glyph, preview=_preview(text, limit)) if text else None
|
||||
|
||||
|
||||
def _verbose_skill_line(data: Dict, detail: Dict, message: str) -> str:
|
||||
def _verbose_skill_line(data: dict, detail: dict, message: str) -> str:
|
||||
action = detail.get("action", "")
|
||||
skill_name = detail.get("name", "")
|
||||
# ``_change`` is free-form (wrapper MCP backends return lists/scalars).
|
||||
@@ -596,7 +596,7 @@ def _verbose_skill_line(data: Dict, detail: Dict, message: str) -> str:
|
||||
return t("display.review.skill_message_verbose", message=message) if message else t("display.review.skill_action", action=action)
|
||||
|
||||
|
||||
def _verbose_memory_lines(label: str, detail: Dict) -> List[str]:
|
||||
def _verbose_memory_lines(label: str, detail: dict) -> list[str]:
|
||||
# ``operations`` may be any JSON value; only a list of dicts is usable.
|
||||
ops_raw = detail.get("operations")
|
||||
if isinstance(ops_raw, list) and ops_raw:
|
||||
@@ -612,7 +612,7 @@ _CALL_DETAIL_DEFAULTS = (
|
||||
)
|
||||
|
||||
|
||||
def _collect_review_call_details(review_messages: List[Dict]) -> Tuple[set, dict]:
|
||||
def _collect_review_call_details(review_messages: list[dict]) -> tuple[set, dict]:
|
||||
"""Map review-agent tool_call ids -> parsed call arguments for notify tools. Result JSON only
|
||||
says "Entry added"; the call arguments carry action, target and content previews. Restricting
|
||||
to notify tools keeps helper tools from surfacing as memory work just because they succeeded."""
|
||||
@@ -643,11 +643,11 @@ def _collect_review_call_details(review_messages: List[Dict]) -> Tuple[set, dict
|
||||
return all_tool_call_ids, call_details
|
||||
|
||||
|
||||
def _tool_messages(messages: List[Dict]) -> Iterator[Dict]:
|
||||
def _tool_messages(messages: list[dict]) -> Iterator[dict]:
|
||||
return (m for m in messages or [] if isinstance(m, dict) and m.get("role") == "tool")
|
||||
|
||||
|
||||
def _prior_tool_keys(prior_snapshot: List[Dict]) -> Tuple[set, set]:
|
||||
def _prior_tool_keys(prior_snapshot: list[dict]) -> tuple[set, set]:
|
||||
"""``(tool_call_ids, contents)`` of tool messages already in the parent snapshot."""
|
||||
priors = list(_tool_messages(prior_snapshot))
|
||||
ids = {m["tool_call_id"] for m in priors if m.get("tool_call_id")}
|
||||
@@ -655,7 +655,7 @@ def _prior_tool_keys(prior_snapshot: List[Dict]) -> Tuple[set, set]:
|
||||
return ids, contents
|
||||
|
||||
|
||||
def _action_lines(data: Dict, detail: Dict, verbose: bool) -> List[str]:
|
||||
def _action_lines(data: dict, detail: dict, verbose: bool) -> list[str]:
|
||||
"""Summary line(s) for one successful notify-tool result (``[]`` when nothing to report)."""
|
||||
if data.get("staged"):
|
||||
# The fork's own review summary is never published back, so an unattended-review
|
||||
@@ -697,8 +697,8 @@ def _action_lines(data: Dict, detail: Dict, verbose: bool) -> List[str]:
|
||||
|
||||
|
||||
def summarize_background_review_actions(
|
||||
review_messages: List[Dict], prior_snapshot: List[Dict], notification_mode: str = "on"
|
||||
) -> List[str]:
|
||||
review_messages: list[dict], prior_snapshot: list[dict], notification_mode: str = "on"
|
||||
) -> list[str]:
|
||||
"""Human-facing action summary for a background review pass: successful memory /
|
||||
skill-management tool results from the review agent's messages, skipping tool messages already
|
||||
present in ``prior_snapshot`` so inherited results are not re-surfaced as fresh work.
|
||||
@@ -713,7 +713,7 @@ def summarize_background_review_actions(
|
||||
verbose = mode == "verbose"
|
||||
existing_tool_call_ids, existing_tool_contents = _prior_tool_keys(prior_snapshot)
|
||||
all_tool_call_ids, call_details = _collect_review_call_details(review_messages)
|
||||
actions: List[str] = []
|
||||
actions: list[str] = []
|
||||
for msg in _tool_messages(review_messages):
|
||||
tcid = msg.get("tool_call_id")
|
||||
if tcid:
|
||||
@@ -736,9 +736,9 @@ def summarize_background_review_actions(
|
||||
def build_memory_write_metadata(
|
||||
agent: Any, *, write_origin: Optional[str] = None, execution_context: Optional[str] = None,
|
||||
task_id: Optional[str] = None, tool_call_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Build provenance metadata for external memory-provider mirrors."""
|
||||
metadata: Dict[str, Any] = {
|
||||
metadata: dict[str, Any] = {
|
||||
"write_origin": write_origin or getattr(agent, "_memory_write_origin", "assistant_tool"),
|
||||
"execution_context": execution_context or getattr(agent, "_memory_write_context", "foreground"),
|
||||
"session_id": agent.session_id or "",
|
||||
@@ -756,7 +756,7 @@ _USAGE_COUNTERS = (
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_review_usage(review_agent: Any) -> Dict[str, Any]:
|
||||
def _snapshot_review_usage(review_agent: Any) -> dict[str, Any]:
|
||||
"""Snapshot in-memory usage counters from a review fork (pre-close)."""
|
||||
return {
|
||||
**{key: getattr(review_agent, key, None) for key in ("model", "provider", "base_url")},
|
||||
@@ -765,7 +765,7 @@ def _snapshot_review_usage(review_agent: Any) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _record_review_usage_to_parent(parent_agent: Any, usage: Dict[str, Any]) -> None:
|
||||
def _record_review_usage_to_parent(parent_agent: Any, usage: dict[str, Any]) -> None:
|
||||
"""Record a fork's usage against the parent session (best-effort, never raises). The fork has
|
||||
``_session_db = None`` so conversation_loop's DB-gated accounting never sees its calls; route
|
||||
them through the aux-accounting chokepoint, which writes only ``session_model_usage`` — never
|
||||
@@ -786,7 +786,7 @@ def _record_review_usage_to_parent(parent_agent: Any, usage: Dict[str, Any]) ->
|
||||
logger.debug("Background review usage recording failed (non-fatal): %s", e)
|
||||
|
||||
|
||||
def _classify_review_result(actions: List[str]) -> str:
|
||||
def _classify_review_result(actions: list[str]) -> str:
|
||||
"""Map a review action summary to ``none`` / ``skill`` / ``memory`` / ``skill+memory``.
|
||||
Prefix-based on the formats :func:`summarize_background_review_actions` emits (``Skill …``,
|
||||
``📝 Skill …``, ``Memory …``, ``User profile …``), so a free-text line like ``Skipped: no
|
||||
@@ -801,7 +801,7 @@ def _classify_review_result(actions: List[str]) -> str:
|
||||
return "+".join(kind for kind, hit in (("skill", has_skill), ("memory", has_memory)) if hit) or "none"
|
||||
|
||||
|
||||
def _log_review_completion(usage: Dict[str, Any], result: str) -> None:
|
||||
def _log_review_completion(usage: dict[str, Any], result: str) -> None:
|
||||
"""Emit a per-fork completion line so cost is visible where it is incurred."""
|
||||
logger.info(
|
||||
"Background review complete: thread=bg-review calls=%d in=%d out=%d "
|
||||
@@ -820,12 +820,12 @@ _PROVIDER_PIN_ATTRS = (
|
||||
)
|
||||
|
||||
|
||||
def _same_model_parity_kwargs(agent: Any) -> Dict[str, Any]:
|
||||
def _same_model_parity_kwargs(agent: Any) -> dict[str, Any]:
|
||||
"""AIAgent kwargs that keep a SAME-model fork's request bytes identical to the parent's. Only
|
||||
for the un-routed path: on a different model the cache is cold anyway, and the parent's
|
||||
reasoning-effort vocabulary may be invalid for the routed provider (OpenRouter forwards
|
||||
``reasoning.effort`` unclamped; codex_responses passes ``max``/``ultra`` through unmapped)."""
|
||||
kwargs: Dict[str, Any] = {
|
||||
kwargs: dict[str, Any] = {
|
||||
# Anthropic's cache key is namespaced by ``thinking`` presence; the gateway session context
|
||||
# is appended to the cached system prompt at API-call time (without it the prompt diverges).
|
||||
"reasoning_config": getattr(agent, "reasoning_config", None),
|
||||
@@ -840,7 +840,7 @@ def _same_model_parity_kwargs(agent: Any) -> Dict[str, Any]:
|
||||
return kwargs
|
||||
|
||||
|
||||
def _warn_ignored_reasoning_effort(agent: Any, task_cfg: Optional[Dict[str, Any]] = None) -> None:
|
||||
def _warn_ignored_reasoning_effort(agent: Any, task_cfg: Optional[dict[str, Any]] = None) -> None:
|
||||
"""One-shot user-visible notice: ``auxiliary.background_review.reasoning_effort`` is IGNORED on
|
||||
the same-model path (#104116). The fork inherits the parent's ``reasoning_config`` verbatim so
|
||||
its request bytes keep the parent's prompt-cache prefix (#30532: a diverged ``thinking`` field
|
||||
@@ -890,7 +890,7 @@ def _detach_fork_compression(review_agent: Any) -> None:
|
||||
review_agent._review_defer_compaction_before_first_response = True
|
||||
|
||||
|
||||
def _routed_reasoning_config(task_cfg: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
def _routed_reasoning_config(task_cfg: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
||||
"""``reasoning_config`` for a ROUTED fork from ``auxiliary.background_review.reasoning_effort``
|
||||
(#94825). The routed branch never inherits the parent's effort (its vocabulary may be invalid for
|
||||
the routed provider), but an explicit per-task pin is the user's choice for THAT model and must
|
||||
@@ -909,14 +909,14 @@ def _routed_reasoning_config(task_cfg: Optional[Dict[str, Any]]) -> Optional[Dic
|
||||
return parsed
|
||||
|
||||
|
||||
def _fork_init_kwargs(agent: Any, rt: Dict[str, Any], routed: bool, max_iterations: int,
|
||||
task_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _fork_init_kwargs(agent: Any, rt: dict[str, Any], routed: bool, max_iterations: int,
|
||||
task_cfg: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
"""AIAgent constructor kwargs for the review fork. skip_memory=True: an external memory plugin
|
||||
scoped to the parent's session_id would leak the harness prompt into the user's real memory
|
||||
namespace; built-in MEMORY.md/USER.md state is re-bound by the caller. Toolsets match the
|
||||
parent so ``tools[]`` is byte-identical (Anthropic's cache key includes it); the runtime
|
||||
whitelist restricts dispatch."""
|
||||
kwargs: Dict[str, Any] = {
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": rt.get("model") or agent.model, "max_iterations": max_iterations, "quiet_mode": True,
|
||||
"platform": agent.platform, "provider": rt.get("provider") or agent.provider,
|
||||
"api_mode": rt.get("api_mode"), "base_url": rt.get("base_url") or None,
|
||||
@@ -954,9 +954,9 @@ def _inherit_parent_tool_surface(review_agent: Any, agent: Any) -> None:
|
||||
|
||||
|
||||
def build_cache_parity_fork(
|
||||
agent: Any, task_cfg: Optional[Dict[str, Any]] = None, *, max_iterations: int,
|
||||
agent: Any, task_cfg: Optional[dict[str, Any]] = None, *, max_iterations: int,
|
||||
write_origin: str = "background_review",
|
||||
) -> Tuple[Any, Dict[str, Any], bool]:
|
||||
) -> tuple[Any, dict[str, Any], bool]:
|
||||
"""Construct a detached AIAgent fork with warm prompt-cache parity (shared with ``/btw``): same
|
||||
runtime/credentials as the parent, byte-identical system prompt / tools[] / reasoning config on
|
||||
the same-model path, shared session_id for prefix warmth, full persistence detachment (no
|
||||
@@ -1077,8 +1077,8 @@ def _track_review_fork(agent: Any, review_agent: Any, *, register: bool) -> None
|
||||
|
||||
|
||||
def _review_tool_whitelist(
|
||||
review_agent: Any, task_cfg: Optional[Dict[str, Any]], review_memory: bool = False,
|
||||
) -> Tuple[set, set]:
|
||||
review_agent: Any, task_cfg: Optional[dict[str, Any]], review_memory: bool = False,
|
||||
) -> tuple[set, set]:
|
||||
"""``(whitelist, configured_extra_tools)`` for the review fork — DISPATCH-side only, so the
|
||||
advertised ``tools[]`` stays byte-identical to the parent's (prompt-cache parity)."""
|
||||
from model_tools import get_tool_definitions
|
||||
@@ -1124,8 +1124,8 @@ class _ReviewForkState:
|
||||
"""Mutable hand-off between the fork phase and the outer worker's error/cleanup paths."""
|
||||
|
||||
review_agent: Any = None
|
||||
review_messages: List[Dict] = field(default_factory=list)
|
||||
review_usage: Dict[str, Any] = field(default_factory=dict)
|
||||
review_messages: list[dict] = field(default_factory=list)
|
||||
review_usage: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _release_fork_clients(review_agent: Any) -> None:
|
||||
@@ -1136,7 +1136,7 @@ def _release_fork_clients(review_agent: Any) -> None:
|
||||
|
||||
|
||||
def _run_review_fork(
|
||||
agent: Any, messages_snapshot: List[Dict], prompt: str, task_cfg: Optional[Dict[str, Any]],
|
||||
agent: Any, messages_snapshot: list[dict], prompt: str, task_cfg: Optional[dict[str, Any]],
|
||||
review_run: Optional[_BackgroundReviewRun], st: _ReviewForkState, review_memory: bool = False,
|
||||
explicit: bool = False,
|
||||
) -> None:
|
||||
@@ -1199,7 +1199,7 @@ def _run_review_fork(
|
||||
st.review_agent = None
|
||||
|
||||
|
||||
def _publish_review_summary(agent: Any, actions: List[str]) -> None:
|
||||
def _publish_review_summary(agent: Any, actions: list[str]) -> None:
|
||||
summary = " · ".join(dict.fromkeys(actions))
|
||||
agent._safe_print(t("display.review.summary_cli", summary=summary))
|
||||
if agent.background_review_callback:
|
||||
@@ -1208,8 +1208,8 @@ def _publish_review_summary(agent: Any, actions: List[str]) -> None:
|
||||
|
||||
|
||||
def _run_review_in_thread(
|
||||
agent: Any, messages_snapshot: List[Dict], prompt: str,
|
||||
task_cfg: Optional[Dict[str, Any]] = None, review_run: Optional[_BackgroundReviewRun] = None,
|
||||
agent: Any, messages_snapshot: list[dict], prompt: str,
|
||||
task_cfg: Optional[dict[str, Any]] = None, review_run: Optional[_BackgroundReviewRun] = None,
|
||||
review_memory: bool = False, explicit: bool = False,
|
||||
) -> None:
|
||||
"""Daemon-thread worker: build the fork, run the prompt, surface the action summary via
|
||||
@@ -1299,9 +1299,9 @@ _PROMPT_NAME_BY_SCOPE = {
|
||||
|
||||
|
||||
def spawn_background_review_thread(
|
||||
agent: Any, messages_snapshot: List[Dict], review_memory: bool = False,
|
||||
agent: Any, messages_snapshot: list[dict], review_memory: bool = False,
|
||||
review_skills: bool = False, focus: Optional[str] = None,
|
||||
task_cfg: Optional[Dict[str, Any]] = None, review_run: Optional[_BackgroundReviewRun] = None,
|
||||
task_cfg: Optional[dict[str, Any]] = None, review_run: Optional[_BackgroundReviewRun] = None,
|
||||
explicit: bool = False,
|
||||
):
|
||||
"""Return ``(target, prompt)``; the caller builds the ``threading.Thread`` so test patches of
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ def _read_battery_uncached() -> BatteryStatus:
|
||||
import psutil
|
||||
|
||||
# ``sensors_battery`` is missing on some platforms/builds of psutil.
|
||||
batt = getattr(psutil, "sensors_battery")()
|
||||
batt = psutil.sensors_battery()
|
||||
except Exception:
|
||||
return UNAVAILABLE
|
||||
if batt is None:
|
||||
|
||||
+68
-68
@@ -25,20 +25,20 @@ from agent.errors import EmptyStreamError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_bedrock_runtime_client_cache: Dict[str, Any] = {}
|
||||
_bedrock_control_client_cache: Dict[str, Any] = {}
|
||||
_bedrock_runtime_client_cache: dict[str, Any] = {}
|
||||
_bedrock_control_client_cache: dict[str, Any] = {}
|
||||
# Routed multiplex profiles: one client per (profile home, region). boto3 freezes the credential
|
||||
# chain into the client at construction, so a region-only slot would sign profile B's calls with A's keys.
|
||||
_bedrock_clients_by_home: Dict[Tuple[str, str, str], Any] = {}
|
||||
_bedrock_clients_by_home: dict[tuple[str, str, str], Any] = {}
|
||||
|
||||
# botocore session kwarg <- profile .env variable (the explicit sources of the default chain).
|
||||
_AWS_SCOPED_CREDENTIAL_VARS: Tuple[Tuple[str, str], ...] = (
|
||||
_AWS_SCOPED_CREDENTIAL_VARS: tuple[tuple[str, str], ...] = (
|
||||
("aws_access_key_id", "AWS_ACCESS_KEY_ID"), ("aws_secret_access_key", "AWS_SECRET_ACCESS_KEY"),
|
||||
("aws_session_token", "AWS_SESSION_TOKEN"), ("profile_name", "AWS_PROFILE"),
|
||||
)
|
||||
|
||||
|
||||
def scoped_aws_session_kwargs() -> Dict[str, str]:
|
||||
def scoped_aws_session_kwargs() -> dict[str, str]:
|
||||
"""``boto3.session.Session`` kwargs from the routed profile's secret scope, ``{}`` when unscoped.
|
||||
|
||||
Under a HERMES_HOME override the process env holds the LAUNCH profile's ``AWS_*`` (or nothing), so
|
||||
@@ -73,14 +73,14 @@ def scoped_aws_session_kwargs() -> Dict[str, str]:
|
||||
return kwargs
|
||||
|
||||
# Geo/global cross-Region inference-profile prefixes Bedrock prepends to a model ID.
|
||||
_BEDROCK_PROFILE_PREFIXES: Tuple[str, ...] = ("global", "us", "eu", "apac", "ap", "au", "jp", "ca", "sa", "me", "af")
|
||||
_BEDROCK_PROFILE_PREFIXES: tuple[str, ...] = ("global", "us", "eu", "apac", "ap", "au", "jp", "ca", "sa", "me", "af")
|
||||
# The GPT-5.6 Terra/Luna cards also document India geo profiles (``in.openai.gpt-5.6-terra``).
|
||||
_BEDROCK_OPENAI_PROFILE_PREFIXES: Tuple[str, ...] = _BEDROCK_PROFILE_PREFIXES + ("in",)
|
||||
_BEDROCK_OPENAI_PROFILE_PREFIXES: tuple[str, ...] = _BEDROCK_PROFILE_PREFIXES + ("in",)
|
||||
|
||||
# Bedrock-hosted OpenAI GPT models (bare in-Region IDs). The bare ID is served by the Bedrock Mantle
|
||||
# OpenAI-compatible endpoint; its geo/global profile form is served by bedrock-runtime, where these
|
||||
# cards list Converse as supported. Narrow allowlist so GPT-OSS models stay on the native path.
|
||||
BEDROCK_OPENAI_RESPONSES_MODEL_IDS: Tuple[str, ...] = (
|
||||
BEDROCK_OPENAI_RESPONSES_MODEL_IDS: tuple[str, ...] = (
|
||||
"openai.gpt-6-astra", "openai.gpt-6.1-sol", "openai.gpt-6-sol", "openai.gpt-6-luna",
|
||||
"openai.gpt-5.5", "openai.gpt-5.6-sol", "openai.gpt-5.6-terra", "openai.gpt-5.6-luna",
|
||||
)
|
||||
@@ -123,7 +123,7 @@ def _require_boto3():
|
||||
return boto3
|
||||
|
||||
|
||||
def _cached_client(cache: Dict[str, Any], service: str, region: str):
|
||||
def _cached_client(cache: dict[str, Any], service: str, region: str):
|
||||
"""Get or create a per-region boto3 client. Unscoped: the default credential chain, one client per
|
||||
region. Routed profile: one client per (home, service, region), built from that profile's scoped
|
||||
``AWS_*`` (falling back to the default chain only for what the profile does not set)."""
|
||||
@@ -171,7 +171,7 @@ def invalidate_runtime_client(region: str) -> bool:
|
||||
|
||||
# --- Bedrock Mantle / OpenAI Responses support ---
|
||||
|
||||
def parse_bedrock_openai_model_id(model_id: str) -> Optional[Tuple[str, str]]:
|
||||
def parse_bedrock_openai_model_id(model_id: str) -> Optional[tuple[str, str]]:
|
||||
"""``(bare_id, profile_prefix)`` for a Bedrock-hosted OpenAI model, else None. ``profile_prefix``
|
||||
is ``""`` for the bare in-Region ID and e.g. ``"us"`` / ``"global"`` for a cross-Region profile."""
|
||||
normalized = str(model_id or "").strip().lower()
|
||||
@@ -195,7 +195,7 @@ def bedrock_openai_uses_mantle(model_id: str) -> bool:
|
||||
return parsed is not None and not parsed[1]
|
||||
|
||||
|
||||
def merge_bedrock_openai_model_ids(model_ids: List[str]) -> List[str]:
|
||||
def merge_bedrock_openai_model_ids(model_ids: list[str]) -> list[str]:
|
||||
"""Append Mantle-only OpenAI models, which control-plane discovery never lists."""
|
||||
merged = list(model_ids or [])
|
||||
seen = {str(m).lower() for m in merged}
|
||||
@@ -208,7 +208,7 @@ def bedrock_openai_base_url(region: str) -> str:
|
||||
return f"https://bedrock-mantle.{resolved}.api.aws/openai/v1"
|
||||
|
||||
|
||||
def _mantle_url_parts(base_url: str) -> Tuple[Optional[str], str]:
|
||||
def _mantle_url_parts(base_url: str) -> tuple[Optional[str], str]:
|
||||
"""(region or None if not a Mantle host, normalized path) for a base URL."""
|
||||
parsed = urlparse(str(base_url or ""))
|
||||
match = _BEDROCK_OPENAI_HOST_RE.match(parsed.hostname or "")
|
||||
@@ -226,7 +226,7 @@ def is_bedrock_openai_base_url(base_url: str) -> bool:
|
||||
return region is not None and path in {"", "/openai", "/openai/v1"}
|
||||
|
||||
|
||||
def resolve_bedrock_bearer_token(env: Optional[Dict[str, str]] = None) -> str:
|
||||
def resolve_bedrock_bearer_token(env: Optional[dict[str, str]] = None) -> str:
|
||||
"""Return AWS_BEARER_TOKEN_BEDROCK when Bedrock API-key auth is configured.
|
||||
|
||||
Under a HERMES_HOME override the read goes through the profile secret scope so a
|
||||
@@ -270,13 +270,13 @@ class BedrockOpenAISigV4Auth(httpx.Auth):
|
||||
|
||||
def build_bedrock_openai_http_client(region: str, *, timeout: Optional[float] = None):
|
||||
"""Build an httpx client that SigV4-signs Bedrock OpenAI requests."""
|
||||
kwargs: Dict[str, Any] = {"auth": BedrockOpenAISigV4Auth(region)}
|
||||
kwargs: dict[str, Any] = {"auth": BedrockOpenAISigV4Auth(region)}
|
||||
if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) and timeout > 0:
|
||||
kwargs["timeout"] = timeout
|
||||
return httpx.Client(**kwargs)
|
||||
|
||||
|
||||
def configure_bedrock_openai_client_kwargs(client_kwargs: Dict[str, Any], *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
||||
def configure_bedrock_openai_client_kwargs(client_kwargs: dict[str, Any], *, timeout: Optional[float] = None) -> dict[str, Any]:
|
||||
"""Install SigV4 auth on OpenAI SDK kwargs for Bedrock Mantle; a real API key keeps the SDK's
|
||||
bearer auth, the ``aws-sdk``/``no-key-required`` placeholders mean IAM chain auth."""
|
||||
base_url = str(client_kwargs.get("base_url") or "")
|
||||
@@ -338,7 +338,7 @@ def is_streaming_access_denied_error(exc: BaseException) -> bool:
|
||||
|
||||
# --- AWS credential detection ---
|
||||
# Priority order; the first group whose vars are ALL set names the auth source.
|
||||
_AWS_AUTH_ENV_CHAIN: Tuple[Tuple[str, ...], ...] = (
|
||||
_AWS_AUTH_ENV_CHAIN: tuple[tuple[str, ...], ...] = (
|
||||
("AWS_BEARER_TOKEN_BEDROCK",), # Bedrock bearer token
|
||||
("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"), # explicit IAM key pair
|
||||
("AWS_PROFILE",), # named profile (SSO, assume-role)
|
||||
@@ -357,7 +357,7 @@ def _boto3_chain_has_credentials() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_aws_auth_env_var(env: Optional[Dict[str, str]] = None) -> Optional[str]:
|
||||
def resolve_aws_auth_env_var(env: Optional[dict[str, str]] = None) -> Optional[str]:
|
||||
"""Name of the active AWS auth source: env vars first (no I/O), then ``"iam-role"`` via boto3's chain, else None."""
|
||||
env = env if env is not None else os.environ
|
||||
for group in _AWS_AUTH_ENV_CHAIN:
|
||||
@@ -366,7 +366,7 @@ def resolve_aws_auth_env_var(env: Optional[Dict[str, str]] = None) -> Optional[s
|
||||
return "iam-role" if _boto3_chain_has_credentials() else None
|
||||
|
||||
|
||||
def has_aws_credentials(env: Optional[Dict[str, str]] = None) -> bool:
|
||||
def has_aws_credentials(env: Optional[dict[str, str]] = None) -> bool:
|
||||
"""True if any AWS credential source (env vars or boto3 chain) is detected.
|
||||
|
||||
This two-tier approach mirrors the pattern from OpenClaw PR #62673: cloud environments (EC2, ECS,
|
||||
@@ -376,7 +376,7 @@ def has_aws_credentials(env: Optional[Dict[str, str]] = None) -> bool:
|
||||
return resolve_aws_auth_env_var(env) is not None or _boto3_chain_has_credentials()
|
||||
|
||||
|
||||
def resolve_bedrock_region(env: Optional[Dict[str, str]] = None) -> str:
|
||||
def resolve_bedrock_region(env: Optional[dict[str, str]] = None) -> str:
|
||||
"""AWS_REGION → AWS_DEFAULT_REGION → botocore configured region (~/.aws/config profiles) → us-east-1."""
|
||||
env = env if env is not None else os.environ
|
||||
explicit = env.get("AWS_REGION", "").strip() or env.get("AWS_DEFAULT_REGION", "").strip()
|
||||
@@ -388,7 +388,7 @@ def resolve_bedrock_region(env: Optional[Dict[str, str]] = None) -> str:
|
||||
return "us-east-1"
|
||||
|
||||
|
||||
def resolve_bedrock_runtime_region(config: Optional[Dict[str, Any]] = None) -> str:
|
||||
def resolve_bedrock_runtime_region(config: Optional[dict[str, Any]] = None) -> str:
|
||||
"""``bedrock.region`` from config.yaml, else :func:`resolve_bedrock_region`. Every non-runtime Bedrock
|
||||
endpoint must use this so auxiliary calls never leave the primary runtime's region. *config* skips disk."""
|
||||
if config is None:
|
||||
@@ -405,7 +405,7 @@ def bedrock_region_from_runtime_url(base_url: str) -> str:
|
||||
return m.group(1) if m else "us-east-1"
|
||||
|
||||
|
||||
def bedrock_guardrail_config(config: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
def bedrock_guardrail_config(config: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
|
||||
"""Converse ``guardrailConfig`` from ``bedrock.guardrail`` in config.yaml (None when unset)."""
|
||||
if config is None:
|
||||
config = {}
|
||||
@@ -422,7 +422,7 @@ def bedrock_guardrail_config(config: Optional[Dict[str, Any]] = None) -> Optiona
|
||||
return out
|
||||
|
||||
|
||||
def bedrock_guardrail_headers(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
|
||||
def bedrock_guardrail_headers(config: Optional[dict[str, Any]] = None) -> dict[str, str]:
|
||||
"""InvokeModel/Messages-wire form of the configured guardrail. The AnthropicBedrock SDK speaks
|
||||
InvokeModel, which has no ``guardrailConfig`` body field; Bedrock reads the guardrail from these
|
||||
headers instead (same enforcement, keeps prompt caching / thinking / 1M context)."""
|
||||
@@ -469,7 +469,7 @@ def bind_bedrock_runtime(agent, base_url: str, api_mode: str) -> None:
|
||||
agent._anthropic_client = None
|
||||
|
||||
|
||||
def bedrock_model_ids_or_none() -> Optional[List[str]]:
|
||||
def bedrock_model_ids_or_none() -> Optional[list[str]]:
|
||||
"""Live-discover Bedrock model IDs; None on failure/empty so callers use the static list."""
|
||||
with suppress(Exception):
|
||||
discovered = discover_bedrock_models(resolve_bedrock_runtime_region())
|
||||
@@ -509,7 +509,7 @@ def _model_supports_prompt_cache(model_id: str) -> bool:
|
||||
# record the verdict, drop the marker there for the rest of the process, and retry once without it.
|
||||
|
||||
CACHE_POINT_PLACEMENTS = ("tools", "system", "messages")
|
||||
_CACHE_POINT_REJECTIONS: Dict[str, set] = {} # model_id (lowercased) → placements Bedrock has rejected this process
|
||||
_CACHE_POINT_REJECTIONS: dict[str, set] = {} # model_id (lowercased) → placements Bedrock has rejected this process
|
||||
# e.g. "#/toolConfig/tools/18: extraneous key [cachePoint] is not permitted"
|
||||
_CACHE_POINT_PATH_PATTERN = re.compile(r"#/(?P<path>[A-Za-z0-9_./\[\]-]*)", re.IGNORECASE)
|
||||
_CACHE_POINT = {"cachePoint": {"type": "default"}}
|
||||
@@ -553,7 +553,7 @@ def _without_cache_points(blocks: Any) -> Optional[list]:
|
||||
return None if len(cleaned) == len(blocks) else cleaned
|
||||
|
||||
|
||||
def strip_cache_points(kwargs: Dict[str, Any], placement: str) -> Dict[str, Any]:
|
||||
def strip_cache_points(kwargs: dict[str, Any], placement: str) -> dict[str, Any]:
|
||||
"""Copy of Converse kwargs with ``placement``'s cachePoint removed; the SAME object
|
||||
back when nothing was stripped (callers use identity to decide a retry cannot help)."""
|
||||
if placement == "messages":
|
||||
@@ -576,7 +576,7 @@ def strip_cache_points(kwargs: Dict[str, Any], placement: str) -> Dict[str, Any]
|
||||
return kwargs
|
||||
|
||||
|
||||
def recover_from_cache_point_rejection(exc: BaseException, kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def recover_from_cache_point_rejection(exc: BaseException, kwargs: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Record Bedrock's cachePoint verdict and return retry kwargs, or None when the error
|
||||
was not a cachePoint rejection / the marker was already absent (caller re-raises)."""
|
||||
placement = cache_point_rejection_placement(exc)
|
||||
@@ -664,7 +664,7 @@ def is_anthropic_bedrock_model(model_id: str) -> bool:
|
||||
|
||||
# --- Message format conversion: OpenAI → Bedrock Converse ---
|
||||
|
||||
def convert_tools_to_converse(tools: List[Dict]) -> List[Dict]:
|
||||
def convert_tools_to_converse(tools: list[dict]) -> list[dict]:
|
||||
"""OpenAI ``{"function": {...}}`` tool defs → Converse ``{"toolSpec": {...}}``."""
|
||||
return [{"toolSpec": {
|
||||
"name": fn.get("name", ""), "description": fn.get("description", ""),
|
||||
@@ -685,7 +685,7 @@ def _safe_text(text) -> str:
|
||||
return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER
|
||||
|
||||
|
||||
def _image_block_from_data_url(url: str) -> Dict:
|
||||
def _image_block_from_data_url(url: str) -> dict:
|
||||
"""``data:<mime>;base64,...`` → Converse image block with RAW bytes (boto3 base64-encodes on the
|
||||
wire; passing the string through double-encodes and Bedrock rejects it)."""
|
||||
header, _, data = url.partition(",")
|
||||
@@ -698,7 +698,7 @@ def _image_block_from_data_url(url: str) -> Dict:
|
||||
return {"image": {"format": media_type.split("/")[-1] if "/" in media_type else "jpeg", "source": {"bytes": raw_bytes}}}
|
||||
|
||||
|
||||
def _convert_content_to_converse(content) -> List[Dict]:
|
||||
def _convert_content_to_converse(content) -> list[dict]:
|
||||
"""OpenAI content → Converse blocks; blank text → placeholder, remote image URLs → text reference."""
|
||||
if not isinstance(content, list):
|
||||
return [{"text": _safe_text(content)}]
|
||||
@@ -715,14 +715,14 @@ def _convert_content_to_converse(content) -> List[Dict]:
|
||||
return blocks or [dict(_PLACEHOLDER_BLOCK)]
|
||||
|
||||
|
||||
def _system_blocks(content) -> List[Dict]:
|
||||
def _system_blocks(content) -> list[dict]:
|
||||
"""System content → text blocks; blank parts are dropped, not placeholder-filled."""
|
||||
parts = [content] if isinstance(content, str) else content if isinstance(content, list) else []
|
||||
texts = [part.get("text", "") if isinstance(part, dict) and part.get("type") == "text" else part for part in parts]
|
||||
return [{"text": text} for text in texts if isinstance(text, str) and text.strip()]
|
||||
|
||||
|
||||
def _tool_use_block(tool_use_id, name, input_dict) -> Dict:
|
||||
def _tool_use_block(tool_use_id, name, input_dict) -> dict:
|
||||
return {"toolUse": {"toolUseId": tool_use_id, "name": name, "input": input_dict}}
|
||||
|
||||
|
||||
@@ -734,10 +734,10 @@ def _decode_redacted(encoded) -> Optional[bytes]:
|
||||
return None
|
||||
|
||||
|
||||
def _replay_ordered_blocks(ordered_blocks: List) -> List[Dict]:
|
||||
def _replay_ordered_blocks(ordered_blocks: list) -> list[dict]:
|
||||
"""Rebuild the exact Bedrock block sequence captured at normalization time; redacted reasoning is
|
||||
stored base64 (JSON-safe sidecar) and undecodable entries are skipped."""
|
||||
content_blocks: List[Dict] = []
|
||||
content_blocks: list[dict] = []
|
||||
for block in ordered_blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
@@ -750,7 +750,7 @@ def _replay_ordered_blocks(ordered_blocks: List) -> List[Dict]:
|
||||
# ReasoningContentBlock is a tagged union: reasoningText and redactedContent must go
|
||||
# out as separate blocks (#115865). Undecodable redacted entries are skipped alone.
|
||||
if isinstance(reasoning.get("text"), str):
|
||||
reasoning_text: Dict[str, str] = {"text": reasoning["text"]}
|
||||
reasoning_text: dict[str, str] = {"text": reasoning["text"]}
|
||||
if isinstance(reasoning.get("signature"), str) and reasoning["signature"]:
|
||||
reasoning_text["signature"] = reasoning["signature"] # models that sign thinking reject unsigned replay
|
||||
content_blocks.append({"reasoningContent": {"reasoningText": reasoning_text}})
|
||||
@@ -773,7 +773,7 @@ def _parse_tool_args(args) -> Any:
|
||||
return {}
|
||||
|
||||
|
||||
def _assistant_blocks(msg: Dict, content) -> List[Dict]:
|
||||
def _assistant_blocks(msg: dict, content) -> list[dict]:
|
||||
"""Assistant message → Converse blocks. An ordered ``bedrock_content_blocks`` sidecar is authoritative;
|
||||
otherwise redacted thinking from ``reasoning_details`` (byte-for-byte), then text, then tool calls."""
|
||||
ordered_blocks = msg.get("bedrock_content_blocks")
|
||||
@@ -783,7 +783,7 @@ def _assistant_blocks(msg: Dict, content) -> List[Dict]:
|
||||
_decode_redacted(d.get("data") or d.get("redactedContentBase64"))
|
||||
for d in (msg.get("reasoning_details") or []) if isinstance(d, dict) and d.get("type") == "redacted_thinking"
|
||||
]
|
||||
content_blocks: List[Dict] = [{"reasoningContent": {"redactedContent": r}} for r in redacted if r is not None]
|
||||
content_blocks: list[dict] = [{"reasoningContent": {"redactedContent": r}} for r in redacted if r is not None]
|
||||
if isinstance(content, str) and content.strip():
|
||||
content_blocks.append({"text": content})
|
||||
elif isinstance(content, list):
|
||||
@@ -794,14 +794,14 @@ def _assistant_blocks(msg: Dict, content) -> List[Dict]:
|
||||
return content_blocks
|
||||
|
||||
|
||||
def convert_messages_to_converse(messages: List[Dict]) -> Tuple[Optional[List[Dict]], List[Dict]]:
|
||||
def convert_messages_to_converse(messages: list[dict]) -> tuple[Optional[list[dict]], list[dict]]:
|
||||
"""OpenAI messages → ``(system_blocks_or_None, converse_messages)``; tool results become ``toolResult``
|
||||
user blocks. Converse needs strict user/assistant alternation with a user turn first and last:
|
||||
same-role neighbours merge, placeholder user turns pad the ends."""
|
||||
system_blocks: List[Dict] = []
|
||||
converse_msgs: List[Dict] = []
|
||||
system_blocks: list[dict] = []
|
||||
converse_msgs: list[dict] = []
|
||||
|
||||
def append_turn(role: str, blocks: List[Dict]) -> None:
|
||||
def append_turn(role: str, blocks: list[dict]) -> None:
|
||||
if converse_msgs and converse_msgs[-1]["role"] == role:
|
||||
converse_msgs[-1]["content"].extend(blocks)
|
||||
else:
|
||||
@@ -853,12 +853,12 @@ class _ResponseParts:
|
||||
"""Accumulator shared by the sync and streaming normalizers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.text_parts: List[str] = []
|
||||
self.reasoning_parts: List[str] = []
|
||||
self.reasoning_details: List[Dict[str, Any]] = []
|
||||
self.tool_calls: List[SimpleNamespace] = []
|
||||
self.text_parts: list[str] = []
|
||||
self.reasoning_parts: list[str] = []
|
||||
self.reasoning_details: list[dict[str, Any]] = []
|
||||
self.tool_calls: list[SimpleNamespace] = []
|
||||
|
||||
def absorb_reasoning(self, reasoning: Any, block: Dict[str, Any], on_text=None) -> None:
|
||||
def absorb_reasoning(self, reasoning: Any, block: dict[str, Any], on_text=None) -> None:
|
||||
"""Fold a Converse ``reasoningContent`` payload into the accumulators and ``block``. The sync response
|
||||
nests ``reasoningText: {text, signature}``; stream deltas carry ``text`` / ``signature`` flat."""
|
||||
if not isinstance(reasoning, dict):
|
||||
@@ -879,7 +879,7 @@ class _ResponseParts:
|
||||
self.reasoning_details.append({"type": "redacted_thinking", "data": encoded})
|
||||
block["redactedContentBase64"] = encoded
|
||||
|
||||
def build(self, ordered_blocks: List[Dict[str, Any]], usage_data: Dict[str, int], stop_reason: str, model: str) -> SimpleNamespace:
|
||||
def build(self, ordered_blocks: list[dict[str, Any]], usage_data: dict[str, int], stop_reason: str, model: str) -> SimpleNamespace:
|
||||
"""Assemble the OpenAI-shaped response. Converse's inputTokens EXCLUDES cache read/write tokens
|
||||
(OpenAI's prompt_tokens includes them), so they are added back."""
|
||||
msg = SimpleNamespace(
|
||||
@@ -904,7 +904,7 @@ class _ResponseParts:
|
||||
)
|
||||
|
||||
|
||||
def normalize_converse_response(response: Dict) -> SimpleNamespace:
|
||||
def normalize_converse_response(response: dict) -> SimpleNamespace:
|
||||
"""Bedrock Converse response → OpenAI ``ChatCompletion``-shaped SimpleNamespace (``.choices[0].message.
|
||||
{content,tool_calls,reasoning_content,reasoning_details,bedrock_content_blocks}``, ``finish_reason``, ``.usage``)."""
|
||||
parts = _ResponseParts()
|
||||
@@ -914,7 +914,7 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace:
|
||||
parts.text_parts.append(block["text"])
|
||||
ordered_blocks.append({"text": block["text"]})
|
||||
elif "reasoningContent" in block:
|
||||
ordered_reasoning: Dict[str, Any] = {}
|
||||
ordered_reasoning: dict[str, Any] = {}
|
||||
parts.absorb_reasoning(block["reasoningContent"], ordered_reasoning)
|
||||
if ordered_reasoning:
|
||||
ordered_blocks.append({"reasoningContent": ordered_reasoning})
|
||||
@@ -946,16 +946,16 @@ def stream_converse_with_callbacks(
|
||||
Blocks are keyed by the ``contentBlockIndex`` Bedrock stamps on every contentBlockStart/Delta/Stop:
|
||||
text blocks get NO contentBlockStart, so a counter keyed on starts shredded them (#108200)."""
|
||||
parts = _ResponseParts()
|
||||
stream_blocks: Dict[int, Dict[str, Any]] = {}
|
||||
stream_blocks: dict[int, dict[str, Any]] = {}
|
||||
current_block_index: Optional[int] = None
|
||||
current_tool: Optional[Dict] = None
|
||||
current_text_buffer: List[str] = []
|
||||
current_tool: Optional[dict] = None
|
||||
current_text_buffer: list[str] = []
|
||||
has_tool_use = False
|
||||
stop_reason = None
|
||||
interrupted = False
|
||||
usage_data: Dict[str, int] = {}
|
||||
usage_data: dict[str, int] = {}
|
||||
|
||||
def block_index(payload: Dict[str, Any], *, new_block: bool = False) -> int:
|
||||
def block_index(payload: dict[str, Any], *, new_block: bool = False) -> int:
|
||||
"""Index of the block a contentBlock* event addresses. Without ``contentBlockIndex`` (test doubles,
|
||||
proxies) a start opens a fresh slot and a delta/stop continues the current one."""
|
||||
idx = payload.get("contentBlockIndex")
|
||||
@@ -1029,18 +1029,18 @@ def stream_converse_with_callbacks(
|
||||
# --- High-level API: call Bedrock Converse ---
|
||||
|
||||
def build_converse_kwargs(
|
||||
model: str, messages: List[Dict], tools: Optional[List[Dict]] = None, max_tokens: Optional[int] = 4096,
|
||||
model: str, messages: list[dict], tools: Optional[list[dict]] = None, max_tokens: Optional[int] = 4096,
|
||||
temperature: Optional[float] = None, top_p: Optional[float] = None,
|
||||
stop_sequences: Optional[List[str]] = None, guardrail_config: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
stop_sequences: Optional[list[str]] = None, guardrail_config: Optional[dict] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build kwargs for ``bedrock-runtime.converse()`` / ``converse_stream()``. ``max_tokens=None`` omits
|
||||
``maxTokens`` (model maximum; default stays 4096). cachePoint markers go on system, tools and the
|
||||
second-newest message (survives as the tail grows — mirrors Anthropic system_and_3), each only if the
|
||||
model supports caching and Bedrock has not rejected that placement."""
|
||||
system_prompt, converse_messages = convert_messages_to_converse(messages)
|
||||
cache_at = {p for p in CACHE_POINT_PLACEMENTS if cache_point_allowed(model, p)} if _model_supports_prompt_cache(model) else set()
|
||||
inference_config: Dict[str, Any] = {} if max_tokens is None else {"maxTokens": max_tokens}
|
||||
kwargs: Dict[str, Any] = {"modelId": model, "messages": converse_messages, "inferenceConfig": inference_config}
|
||||
inference_config: dict[str, Any] = {} if max_tokens is None else {"maxTokens": max_tokens}
|
||||
kwargs: dict[str, Any] = {"modelId": model, "messages": converse_messages, "inferenceConfig": inference_config}
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt + [dict(_CACHE_POINT)] if "system" in cache_at else system_prompt
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
@@ -1069,9 +1069,9 @@ def build_converse_kwargs(
|
||||
|
||||
|
||||
def call_converse(
|
||||
region: str, model: str, messages: List[Dict], tools: Optional[List[Dict]] = None,
|
||||
region: str, model: str, messages: list[dict], tools: Optional[list[dict]] = None,
|
||||
max_tokens: Optional[int] = 4096, temperature: Optional[float] = None, top_p: Optional[float] = None,
|
||||
stop_sequences: Optional[List[str]] = None, guardrail_config: Optional[Dict] = None,
|
||||
stop_sequences: Optional[list[str]] = None, guardrail_config: Optional[dict] = None,
|
||||
) -> SimpleNamespace:
|
||||
"""Non-streaming Converse call → OpenAI-compatible response. Retries once without a rejected cachePoint
|
||||
placement; evicts the cached client on stale-connection errors."""
|
||||
@@ -1098,7 +1098,7 @@ def call_converse(
|
||||
|
||||
# --- Model discovery ---
|
||||
|
||||
_discovery_cache: Dict[str, Any] = {}
|
||||
_discovery_cache: dict[str, Any] = {}
|
||||
_DISCOVERY_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
@@ -1107,12 +1107,12 @@ def reset_discovery_cache():
|
||||
_discovery_cache.clear()
|
||||
|
||||
|
||||
def _model_entry(model_id: str, name: Any, provider: str, input_mods: list, output_mods: list) -> Dict[str, Any]:
|
||||
def _model_entry(model_id: str, name: Any, provider: str, input_mods: list, output_mods: list) -> dict[str, Any]:
|
||||
return {"id": model_id, "name": (name or model_id).strip(), "provider": provider,
|
||||
"input_modalities": input_mods, "output_modalities": output_mods, "streaming": True}
|
||||
|
||||
|
||||
def _list_foundation_models(client, filter_set: set, models: List[Dict[str, Any]]) -> None:
|
||||
def _list_foundation_models(client, filter_set: set, models: list[dict[str, Any]]) -> None:
|
||||
"""Append active, streaming-capable, text-output foundation models (optionally provider-filtered)."""
|
||||
for summary in client.list_foundation_models().get("modelSummaries", []):
|
||||
model_id = (summary.get("modelId") or "").strip()
|
||||
@@ -1131,7 +1131,7 @@ def _list_foundation_models(client, filter_set: set, models: List[Dict[str, Any]
|
||||
))
|
||||
|
||||
|
||||
def _list_inference_profiles(client, filter_set: set, models: List[Dict[str, Any]]) -> None:
|
||||
def _list_inference_profiles(client, filter_set: set, models: list[dict[str, Any]]) -> None:
|
||||
"""Append active cross-region inference profiles whose IDs are not already present (paginated)."""
|
||||
profiles, next_token = [], None
|
||||
while True:
|
||||
@@ -1152,7 +1152,7 @@ def _list_inference_profiles(client, filter_set: set, models: List[Dict[str, Any
|
||||
seen_ids.add(profile_id.lower())
|
||||
|
||||
|
||||
def discover_bedrock_models(region: str, provider_filter: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
def discover_bedrock_models(region: str, provider_filter: Optional[list[str]] = None) -> list[dict[str, Any]]:
|
||||
"""Foundation models + inference profiles (cached 1h per region/filter), ``global.`` profiles first then
|
||||
by name; [] when the client cannot be built."""
|
||||
# The list is account-scoped (whichever credentials the control client signs with), so a routed
|
||||
@@ -1169,7 +1169,7 @@ def discover_bedrock_models(region: str, provider_filter: Optional[List[str]] =
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create Bedrock client for model discovery: %s", e)
|
||||
return []
|
||||
models: List[Dict[str, Any]] = []
|
||||
models: list[dict[str, Any]] = []
|
||||
filter_set = {f.lower() for f in (provider_filter or [])}
|
||||
for step, log, message in (
|
||||
(_list_foundation_models, logger.warning, "Failed to list Bedrock foundation models: %s"),
|
||||
@@ -1194,7 +1194,7 @@ def _extract_provider_from_arn(arn: str) -> str:
|
||||
# Static fallback when the live probe is unavailable (agent/model_metadata.py). Keys match by longest
|
||||
# substring, so versioned entries win over the generic "anthropic.claude-opus-4".
|
||||
|
||||
BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = {
|
||||
BEDROCK_CONTEXT_LENGTHS: dict[str, int] = {
|
||||
# https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-xai-grok-4-6.html
|
||||
"xai.grok-4.6": 500_000,
|
||||
# Anthropic Claude: 1M GA vs 200K. The 1M entries must match agent/model_metadata.py
|
||||
@@ -1303,7 +1303,7 @@ def get_bedrock_context_length(model_id: str, region: str = "", probe: bool = Tr
|
||||
# production caller (agent/model_metadata.py::_resolve_bedrock_context_length) passes none.
|
||||
_APPLICATION_PROFILE_ARN_RE = re.compile(r":application-inference-profile/")
|
||||
_ARN_REGION_RE = re.compile(r"^arn:[^:]+:bedrock:([a-z0-9-]+):", re.IGNORECASE)
|
||||
_inference_profile_model_cache: Dict[str, str] = {}
|
||||
_inference_profile_model_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
def _resolve_inference_profile_model_id(profile_arn: str, region: str = "") -> str:
|
||||
|
||||
@@ -316,15 +316,15 @@ def _dev_fixture_billing_state() -> Optional[BillingState]:
|
||||
# Prod portal host (matches subscription_view._DEV_FIXTURE_PORTAL) + the /topup deep-link suffix.
|
||||
common: dict[str, Any] = dict(
|
||||
logged_in=True, org_id="org_acme", org_slug="acme", org_name="Acme Inc", role="OWNER",
|
||||
balance_usd=Decimal("3.40"), cli_billing_enabled=True, min_usd=Decimal("5"), max_usd=Decimal("500"),
|
||||
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")), portal_url="https://portal.nousresearch.com/billing?topup=open",
|
||||
balance_usd=Decimal("3.40"), cli_billing_enabled=True, min_usd=Decimal(5), max_usd=Decimal(500),
|
||||
charge_presets=(Decimal(10), Decimal(25), Decimal(50)), portal_url="https://portal.nousresearch.com/billing?topup=open",
|
||||
)
|
||||
card = CardInfo(brand="Visa", last4="4242")
|
||||
overrides: dict[str, dict[str, Any]] = {
|
||||
"nocard": dict(card=None),
|
||||
"card": dict(card=card),
|
||||
"card-sub": dict(card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin")),
|
||||
"card-autoreload": dict(card=card, auto_reload=AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))),
|
||||
"card-autoreload": dict(card=card, auto_reload=AutoReload(enabled=True, threshold_usd=Decimal(5), reload_to_usd=Decimal(25))),
|
||||
"notadmin": dict(card=card, role="MEMBER"),
|
||||
"billing-off": dict(card=None, cli_billing_enabled=False),
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ def read_streaming_error_body(
|
||||
stalls and oversize bodies yield best-effort partial text (or ""), so a read error can't mask the
|
||||
original failure.
|
||||
"""
|
||||
chunks: List[bytes] = []
|
||||
chunks: list[bytes] = []
|
||||
state = {"truncated": False}
|
||||
done = threading.Event()
|
||||
|
||||
@@ -55,7 +55,7 @@ def read_streaming_error_body(
|
||||
break
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
except Exception as exc: # noqa: BLE001 - error path must not raise
|
||||
except Exception as exc:
|
||||
logger.debug("bounded error-body read failed: %s", exc)
|
||||
finally:
|
||||
done.set()
|
||||
@@ -69,7 +69,7 @@ def read_streaming_error_body(
|
||||
# Closing cancels any in-flight socket read so the worker unwinds. No join (daemon, may be blocked in C).
|
||||
try:
|
||||
response.close()
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if state["truncated"]:
|
||||
|
||||
@@ -41,7 +41,7 @@ class BrowserProvider(ProviderBase):
|
||||
``hermes tools`` paint."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
def create_session(self, task_id: str) -> dict[str, object]:
|
||||
"""Create a cloud browser session and return the metadata dict from the module docstring.
|
||||
May raise ``ValueError`` (missing credentials) or ``RuntimeError`` (network / API failure);
|
||||
the dispatcher surfaces these to the user."""
|
||||
|
||||
@@ -371,7 +371,7 @@ def _provider_stream_error_from_text(text: str, finish_reason: Optional[str], *,
|
||||
_IMAGE_PART_TYPES = frozenset({"image_url", "input_image", "image"})
|
||||
|
||||
|
||||
def _image_part_chars(part: Dict[str, Any], image_cost: int) -> int:
|
||||
def _image_part_chars(part: dict[str, Any], image_cost: int) -> int:
|
||||
"""Char-equivalent of one image content part: the per-image cost learned from provider usage
|
||||
(x4 chars/token), never the base64 payload length. A single native screenshot priced as text
|
||||
read as ~100K+ tokens and selected the giant-conversation watchdog tiers (#63871, #76411)."""
|
||||
@@ -466,7 +466,7 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
|
||||
def _provider_preferences_for_agent(agent) -> dict[str, Any]:
|
||||
"""Build the validated provider-routing object shared by request paths.
|
||||
|
||||
``provider_routing.models.<id>`` overlays the flat constructor values for the CURRENT
|
||||
@@ -510,7 +510,7 @@ def _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs: dict) -> dic
|
||||
if nous_profile is not None:
|
||||
anthropic_kwargs.setdefault("extra_body", {}).update(
|
||||
nous_profile.build_extra_body(session_id=getattr(agent, "session_id", None)))
|
||||
except Exception as exc: # noqa: BLE001 — never block a turn on tagging
|
||||
except Exception as exc:
|
||||
logger.debug("Nous Portal extra_body merge failed: %s", exc)
|
||||
return anthropic_kwargs
|
||||
|
||||
@@ -861,7 +861,7 @@ def _managed_local_load_notice(agent, api_kwargs: dict) -> "Optional[str]":
|
||||
return f"⚙ processing prompt — {max(0, min(100, round(processed / total * 100)))}%"
|
||||
# Counter past the estimate (estimator undercounted): no honest denominator, label-only.
|
||||
return "⚙ processing prompt"
|
||||
except Exception: # noqa: BLE001 — a status nicety must never break a call
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -678,7 +678,7 @@ class ClientLifecycleMixin:
|
||||
return False
|
||||
try:
|
||||
return self._try_refresh_nous_client_credentials(force=False, require_account=str(account))
|
||||
except Exception: # noqa: BLE001 — a failed adoption leaves the key in hand; the 401 path still heals
|
||||
except Exception:
|
||||
logger.debug("Nous key pre-expiry adoption failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def is_official_codex_base_url(base_url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def codex_cloudflare_headers(access_token: str, *, base_url: str = CODEX_AUX_BASE_URL) -> Dict[str, str]:
|
||||
def codex_cloudflare_headers(access_token: str, *, base_url: str = CODEX_AUX_BASE_URL) -> dict[str, str]:
|
||||
"""Identity and account headers for chatgpt.com/backend-api/codex.
|
||||
|
||||
OpenAI requires third-party harnesses to identify themselves: the official
|
||||
@@ -51,7 +51,7 @@ def codex_cloudflare_headers(access_token: str, *, base_url: str = CODEX_AUX_BAS
|
||||
return headers
|
||||
|
||||
|
||||
def codex_account_headers(access_token: str) -> Dict[str, str]:
|
||||
def codex_account_headers(access_token: str) -> dict[str, str]:
|
||||
"""Workspace headers the Codex backend derives from the OAuth JWT.
|
||||
|
||||
``ChatGPT-Account-ID`` (canonical casing, from codex-rs ``auth.rs``) comes from
|
||||
@@ -61,7 +61,7 @@ def codex_account_headers(access_token: str) -> Dict[str, str]:
|
||||
this region". A malformed token drops the headers rather than raising, so it
|
||||
surfaces as a 401 instead of a crash at client construction.
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
headers: dict[str, str] = {}
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
return headers
|
||||
try:
|
||||
@@ -81,7 +81,7 @@ def codex_account_headers(access_token: str) -> Dict[str, str]:
|
||||
return headers
|
||||
|
||||
|
||||
def apply_required_codex_headers(client_kwargs: Dict[str, Any], *, access_token: str, base_url: str) -> None:
|
||||
def apply_required_codex_headers(client_kwargs: dict[str, Any], *, access_token: str, base_url: str) -> None:
|
||||
"""Keep required Codex identity after user/provider header overrides."""
|
||||
if not is_official_codex_base_url(base_url):
|
||||
return
|
||||
|
||||
@@ -144,7 +144,7 @@ def _field(obj: Any, name: str, default: Any = None) -> Any:
|
||||
return obj.get(name) if isinstance(obj, dict) else getattr(obj, name, default)
|
||||
|
||||
|
||||
def _part_type(part: Dict[str, Any]) -> str:
|
||||
def _part_type(part: dict[str, Any]) -> str:
|
||||
return str(part.get("type") or "").strip().lower()
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ def _iter_content_parts(content: list) -> Iterator[tuple[str, Any]]:
|
||||
yield "image", part
|
||||
|
||||
|
||||
def _input_image_part(part: Dict[str, Any], role: str = "user", *, keep_empty_url: bool) -> Optional[Dict[str, Any]]:
|
||||
def _input_image_part(part: dict[str, Any], role: str = "user", *, keep_empty_url: bool) -> Optional[dict[str, Any]]:
|
||||
"""Responses image part from a chat/Responses image part (``image_url`` may be a str or
|
||||
``{url, detail}``). Assistant → text placeholder (an assistant ``input_image`` 400s every
|
||||
replay); user → ``input_image``, None for an empty url unless ``keep_empty_url``; an inline
|
||||
@@ -240,13 +240,13 @@ def _input_image_part(part: Dict[str, Any], role: str = "user", *, keep_empty_ur
|
||||
url, mime = png_url, None
|
||||
if mime is not None:
|
||||
return {"type": "input_text", "text": f"[image omitted: {mime} is not a supported image format]"}
|
||||
image_part: Dict[str, Any] = {"type": "input_image", "image_url": url}
|
||||
image_part: dict[str, Any] = {"type": "input_image", "image_url": url}
|
||||
if _nonblank(detail):
|
||||
image_part["detail"] = detail.strip()
|
||||
return image_part
|
||||
|
||||
|
||||
def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> List[Dict[str, Any]]:
|
||||
def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> list[dict[str, Any]]:
|
||||
"""Chat-style multimodal content → Responses API input parts ([] if not a list). Text is
|
||||
``input_text`` (user) / ``output_text`` (assistant) — the API rejects the wrong type per role;
|
||||
``input_image`` is only legal on user messages (see :func:`_input_image_part`). Unsupported
|
||||
@@ -257,7 +257,7 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
|
||||
f"Codex Responses does not support {ptype} input; use a video-capable provider."
|
||||
)
|
||||
text_type = _text_type_for(role)
|
||||
converted: List[Dict[str, Any]] = []
|
||||
converted: list[dict[str, Any]] = []
|
||||
for kind, payload in _iter_content_parts(_as_list(content)):
|
||||
if kind == "text":
|
||||
converted.append({"type": text_type, "text": payload})
|
||||
@@ -341,7 +341,7 @@ def _derive_responses_function_call_id(call_id: str, response_item_id: Optional[
|
||||
|
||||
# --- Schema conversion --------------------------------------------------------
|
||||
|
||||
def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[List[Dict[str, Any]]]:
|
||||
def _responses_tools(tools: Optional[list[dict[str, Any]]] = None) -> Optional[list[dict[str, Any]]]:
|
||||
"""Convert chat-completions tool schemas to Responses function-tool schemas."""
|
||||
fns = [item.get("function", {}) if isinstance(item, dict) else {} for item in tools or []]
|
||||
converted = [
|
||||
@@ -365,10 +365,10 @@ def _normalize_responses_message_status(value: Any, *, default: str = "completed
|
||||
|
||||
|
||||
def _message_item(
|
||||
content: List[Dict[str, Any]], *, status: str, item_id: Optional[str] = None, phase: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
content: list[dict[str, Any]], *, status: str, item_id: Optional[str] = None, phase: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assistant ``message`` item; ``id``/``phase`` are added only when non-empty."""
|
||||
item: Dict[str, Any] = {"type": "message", "role": "assistant", "status": status, "content": content}
|
||||
item: dict[str, Any] = {"type": "message", "role": "assistant", "status": status, "content": content}
|
||||
item.update({k: v for k, v in (("id", item_id), ("phase", phase)) if v})
|
||||
return item
|
||||
|
||||
@@ -376,7 +376,7 @@ def _message_item(
|
||||
_ROLE_MESSAGE_PHASES = frozenset({"commentary", "final_answer"})
|
||||
|
||||
|
||||
def _role_message_item(role: str, content: Any, phase: Any = None) -> Dict[str, Any]:
|
||||
def _role_message_item(role: str, content: Any, phase: Any = None) -> dict[str, Any]:
|
||||
"""Plain ``message`` input item for ``role``. ``type`` is required: llama.cpp's ``/v1/responses``
|
||||
parser rejects a typeless assistant item ("Cannot determine type of 'item'"). Assistant ``phase``
|
||||
is forwarded only for values the API accepts on input messages; others would 400."""
|
||||
@@ -387,9 +387,9 @@ def _role_message_item(role: str, content: Any, phase: Any = None) -> Dict[str,
|
||||
|
||||
|
||||
def _assistant_message_item(
|
||||
raw: Dict[str, Any], content: List[Dict[str, Any]], *, is_github_responses: bool,
|
||||
raw: dict[str, Any], content: list[dict[str, Any]], *, is_github_responses: bool,
|
||||
current_issuer_kind: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Replayable assistant ``message`` item from a stored one. ``id`` is kept only when short enough and never for
|
||||
GitHub Copilot (ids bind to a backend connection; stale → 401); ``phase`` is preserved per OpenAI's cache guidance.
|
||||
The ChatGPT Codex backend additionally rejects ids that do not begin with ``msg`` (foreign Responses issuers
|
||||
@@ -405,16 +405,16 @@ def _assistant_message_item(
|
||||
|
||||
|
||||
def _replay_reasoning_items(
|
||||
msg: Dict[str, Any], *, seen_item_ids: set, current_issuer_kind: Optional[str],
|
||||
msg: dict[str, Any], *, seen_item_ids: set, current_issuer_kind: Optional[str],
|
||||
current_issuer_model: Optional[str] = None, native_compaction_eligible: bool,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Replay persisted encrypted reasoning/compaction items for one assistant turn. Skips duplicate
|
||||
ids, ``compaction`` checkpoints unless THIS request carries ``context_management`` (else a persisted
|
||||
checkpoint erases pre-checkpoint history on a model that cannot decrypt it), and items stamped by
|
||||
another issuer or model (HTTP 400). Items without a model stamp (legacy or unstamped) replay on a
|
||||
matching issuer. ``id`` (store=False lookups 404) and the Hermes provenance fields are stripped."""
|
||||
global _CROSS_ISSUER_WARN_EMITTED
|
||||
replayed: List[Dict[str, Any]] = []
|
||||
replayed: list[dict[str, Any]] = []
|
||||
for ri in _as_list(msg.get("codex_reasoning_items")):
|
||||
if not (isinstance(ri, dict) and ri.get("encrypted_content")):
|
||||
continue
|
||||
@@ -446,8 +446,8 @@ def _replay_reasoning_items(
|
||||
|
||||
|
||||
def _replay_message_items(
|
||||
msg: Dict[str, Any], *, is_github_responses: bool, current_issuer_kind: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
msg: dict[str, Any], *, is_github_responses: bool, current_issuer_kind: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Replay exact assistant message items (id/phase) for prefix-cache hits.
|
||||
|
||||
A ``msg_*`` id minted in the same response as a ``reasoning`` item is bound to that item's ``rs_*`` id,
|
||||
@@ -457,7 +457,7 @@ def _replay_message_items(
|
||||
trimmed by the transport (``codex_reasoning_trimmed``) — and the message goes out as content/status/phase
|
||||
only. Reasoning-free turns keep their id.
|
||||
"""
|
||||
replayed: List[Dict[str, Any]] = []
|
||||
replayed: list[dict[str, Any]] = []
|
||||
linked_to_reasoning = bool(msg.get("codex_reasoning_trimmed")) or any(
|
||||
isinstance(ri, dict) and ri.get("encrypted_content") for ri in _as_list(msg.get("codex_reasoning_items"))
|
||||
)
|
||||
@@ -490,8 +490,8 @@ class _WireCallIds:
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._seen: Dict[str, int] = {}
|
||||
self._queue: Dict[str, List[str]] = {}
|
||||
self._seen: dict[str, int] = {}
|
||||
self._queue: dict[str, list[str]] = {}
|
||||
|
||||
def for_call(self, call_id: str) -> str:
|
||||
base = _clamp_responses_call_id(call_id)
|
||||
@@ -508,10 +508,10 @@ class _WireCallIds:
|
||||
|
||||
|
||||
def _replay_tool_call_items(
|
||||
msg: Dict[str, Any], *, start_index: int, wire_ids: Optional[_WireCallIds] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
msg: dict[str, Any], *, start_index: int, wire_ids: Optional[_WireCallIds] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert an assistant message's ``tool_calls`` into ``function_call`` items."""
|
||||
replayed: List[Dict[str, Any]] = []
|
||||
replayed: list[dict[str, Any]] = []
|
||||
for tc in _as_list(msg.get("tool_calls")):
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
@@ -529,7 +529,7 @@ def _replay_tool_call_items(
|
||||
return replayed
|
||||
|
||||
|
||||
def _tool_output_items(msg: Dict[str, Any], *, wire_ids: Optional[_WireCallIds] = None) -> List[Dict[str, Any]]:
|
||||
def _tool_output_items(msg: dict[str, Any], *, wire_ids: Optional[_WireCallIds] = None) -> list[dict[str, Any]]:
|
||||
"""Convert a tool-role message to ``[function_call_output]`` (``[]`` if unpairable)."""
|
||||
raw_tool_call_id = msg.get("tool_call_id")
|
||||
call_id, tool_response_item_id = _split_responses_tool_id(raw_tool_call_id)
|
||||
@@ -550,10 +550,10 @@ def _tool_output_items(msg: Dict[str, Any], *, wire_ids: Optional[_WireCallIds]
|
||||
|
||||
|
||||
def _chat_messages_to_responses_input(
|
||||
messages: List[Dict[str, Any]], *, is_xai_responses: bool = False, is_github_responses: bool = False,
|
||||
messages: list[dict[str, Any]], *, is_xai_responses: bool = False, is_github_responses: bool = False,
|
||||
replay_encrypted_reasoning: bool = True, current_issuer_kind: Optional[str] = None,
|
||||
current_issuer_model: Optional[str] = None, native_compaction_eligible: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert internal chat-style messages to Responses input items.
|
||||
|
||||
``is_xai_responses``: signature compatibility only (xAI DOES replay encrypted reasoning).
|
||||
@@ -591,13 +591,13 @@ def _chat_messages_to_responses_input(
|
||||
Hermes' local history is never truncated by native compaction, so the full conversation is still on the
|
||||
wire.
|
||||
"""
|
||||
items: List[Dict[str, Any]] = []
|
||||
items: list[dict[str, Any]] = []
|
||||
# Parallel to ``items``: source chat message per item. Pruning reads a summary
|
||||
# carrier's provenance from the source; the converted item may be a lossy shape.
|
||||
# Pruning needs this to read a canonical summary carrier's up-to-date, provenance-tagged content
|
||||
# directly — the converted `item` can be a lossy shape (stale exact-replay, or a typed
|
||||
# `function_call_output` wrapper) that no longer carries it (#90976).
|
||||
item_sources: List[Optional[Dict[str, Any]]] = []
|
||||
item_sources: list[Optional[dict[str, Any]]] = []
|
||||
seen_item_ids: set = set()
|
||||
wire_ids = _WireCallIds()
|
||||
# The ChatGPT Codex backend rejects a role message whose ``content`` is a plain string with
|
||||
@@ -605,7 +605,7 @@ def _chat_messages_to_responses_input(
|
||||
# (#51512). It accepts only typed parts, so string text goes out as ``input_text``/``output_text``
|
||||
# there; other Responses routes keep the string shorthand they have always received.
|
||||
typed_text_only = current_issuer_kind == "codex_backend"
|
||||
def emit(new_items: List[Dict[str, Any]], msg: Dict[str, Any]) -> None:
|
||||
def emit(new_items: list[dict[str, Any]], msg: dict[str, Any]) -> None:
|
||||
items.extend(new_items)
|
||||
item_sources.extend([msg] * len(new_items))
|
||||
for msg in messages:
|
||||
@@ -701,8 +701,8 @@ def classify_responses_route(agent: Any) -> ResponsesRouteFlags:
|
||||
|
||||
|
||||
def _native_responses_replay_items(
|
||||
agent: Any, messages: List[Dict[str, Any]]
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
agent: Any, messages: list[dict[str, Any]]
|
||||
) -> Optional[list[dict[str, Any]]]:
|
||||
"""Build the native-compaction-eligible wire items, or ``None`` when ineligible."""
|
||||
if getattr(agent, "api_mode", None) != "codex_responses" or not isinstance(messages, list):
|
||||
return None
|
||||
@@ -731,7 +731,7 @@ def _native_responses_replay_items(
|
||||
|
||||
|
||||
def has_replayable_native_compaction_checkpoint(
|
||||
agent: Any, messages: List[Dict[str, Any]]
|
||||
agent: Any, messages: list[dict[str, Any]]
|
||||
) -> bool:
|
||||
"""Whether the current route would replay a persisted native checkpoint."""
|
||||
items = _native_responses_replay_items(agent, messages)
|
||||
@@ -742,7 +742,7 @@ def has_replayable_native_compaction_checkpoint(
|
||||
|
||||
|
||||
def estimate_native_responses_preflight_tokens(
|
||||
agent: Any, messages: List[Dict[str, Any]], *, system_prompt: str = "", tools: Optional[List[Dict[str, Any]]] = None,
|
||||
agent: Any, messages: list[dict[str, Any]], *, system_prompt: str = "", tools: Optional[list[dict[str, Any]]] = None,
|
||||
) -> Optional[int]:
|
||||
"""Estimate tokens for the checkpoint-pruned Responses payload (the full transcript overstates a natively compacted
|
||||
session and fires local compression needlessly). None when native compaction is not proven eligible or conversion fails.
|
||||
@@ -760,12 +760,14 @@ def estimate_native_responses_preflight_tokens(
|
||||
|
||||
# --- Input preflight / validation --------------------------------------------
|
||||
|
||||
_PreflightCtx = NamedTuple("_PreflightCtx", [
|
||||
("sanitize_text", Callable[[str], str]), ("sanitize_harmony_tokens", bool), ("is_github_responses", bool), ("seen_ids", set),
|
||||
])
|
||||
class _PreflightCtx(NamedTuple):
|
||||
sanitize_text: Callable[[str], str]
|
||||
sanitize_harmony_tokens: bool
|
||||
is_github_responses: bool
|
||||
seen_ids: set
|
||||
|
||||
|
||||
def _preflight_function_call(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Dict[str, Any]:
|
||||
def _preflight_function_call(item: dict[str, Any], idx: int, ctx: _PreflightCtx) -> dict[str, Any]:
|
||||
call_id, name = item.get("call_id"), item.get("name")
|
||||
if not _nonblank(call_id):
|
||||
raise ValueError(f"Codex Responses input[{idx}] function_call is missing call_id.")
|
||||
@@ -777,14 +779,14 @@ def _preflight_function_call(item: Dict[str, Any], idx: int, ctx: _PreflightCtx)
|
||||
}
|
||||
|
||||
|
||||
def _preflight_function_call_output(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Dict[str, Any]:
|
||||
def _preflight_function_call_output(item: dict[str, Any], idx: int, ctx: _PreflightCtx) -> dict[str, Any]:
|
||||
call_id = item.get("call_id")
|
||||
if not _nonblank(call_id):
|
||||
raise ValueError(f"Codex Responses input[{idx}] function_call_output is missing call_id.")
|
||||
output = item.get("output", "")
|
||||
if isinstance(output, list):
|
||||
# Multimodal tool result: keep recognised input_text/input_image parts, drop the rest (4xx otherwise).
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for part in output:
|
||||
ptype = part.get("type") if isinstance(part, dict) else None
|
||||
if ptype == "input_text" and _nonempty_str(part.get("text")):
|
||||
@@ -797,7 +799,7 @@ def _preflight_function_call_output(item: Dict[str, Any], idx: int, ctx: _Prefli
|
||||
return {"type": "function_call_output", "call_id": call_id.strip(), "output": output_value}
|
||||
|
||||
|
||||
def _preflight_encrypted(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Optional[Dict[str, Any]]:
|
||||
def _preflight_encrypted(item: dict[str, Any], idx: int, ctx: _PreflightCtx) -> Optional[dict[str, Any]]:
|
||||
"""``reasoning`` / ``compaction`` items: opaque, issuer-sealed; forward only API-defined fields."""
|
||||
encrypted = item.get("encrypted_content")
|
||||
if not _nonempty_str(encrypted):
|
||||
@@ -817,7 +819,7 @@ def _preflight_encrypted(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) ->
|
||||
}
|
||||
|
||||
|
||||
def _preflight_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Dict[str, Any]:
|
||||
def _preflight_message(item: dict[str, Any], idx: int, ctx: _PreflightCtx) -> dict[str, Any]:
|
||||
# Only replayed assistant output (a list-content item carrying id/status) takes the strict path
|
||||
# below. Phase alone is no replay marker: the converter's plain role items carry it too, and
|
||||
# preflight must not synthesize a status or reject user image parts for them.
|
||||
@@ -843,7 +845,7 @@ def _preflight_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Di
|
||||
return _assistant_message_item(item, normalized_content, is_github_responses=ctx.is_github_responses)
|
||||
|
||||
|
||||
def _preflight_role_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Dict[str, Any]:
|
||||
def _preflight_role_message(item: dict[str, Any], idx: int, ctx: _PreflightCtx) -> dict[str, Any]:
|
||||
"""``user``/``assistant`` role message, typed or untyped; string content or Responses parts."""
|
||||
role = item.get("role")
|
||||
if role not in {"user", "assistant"}:
|
||||
@@ -856,7 +858,7 @@ def _preflight_role_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx)
|
||||
# Parts are already Responses-shaped; validate and re-type text for the role.
|
||||
# Unlike history conversion, empty text / empty image urls are kept, not dropped.
|
||||
text_type = _text_type_for(role)
|
||||
validated: List[Dict[str, Any]] = []
|
||||
validated: list[dict[str, Any]] = []
|
||||
for part_idx, part in enumerate(content):
|
||||
if isinstance(part, str):
|
||||
if part:
|
||||
@@ -876,7 +878,7 @@ def _preflight_role_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx)
|
||||
return _role_message_item(role, validated, item.get("phase"))
|
||||
|
||||
|
||||
_PREFLIGHT_ITEM_HANDLERS: Dict[str, Callable[..., Optional[Dict[str, Any]]]] = {
|
||||
_PREFLIGHT_ITEM_HANDLERS: dict[str, Callable[..., Optional[dict[str, Any]]]] = {
|
||||
"function_call": _preflight_function_call, "function_call_output": _preflight_function_call_output,
|
||||
"reasoning": _preflight_encrypted, "compaction": _preflight_encrypted, "message": _preflight_message,
|
||||
}
|
||||
@@ -884,12 +886,12 @@ _PREFLIGHT_ITEM_HANDLERS: Dict[str, Callable[..., Optional[Dict[str, Any]]]] = {
|
||||
|
||||
def _preflight_codex_input_items(
|
||||
raw_items: Any, *, is_github_responses: bool = False, sanitize_harmony_tokens: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError("Codex Responses input must be a list of input items.")
|
||||
sanitize_text = _neutralize_harmony_tokens if sanitize_harmony_tokens else (lambda text: text)
|
||||
ctx = _PreflightCtx(sanitize_text, sanitize_harmony_tokens, is_github_responses, set())
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for idx, item in enumerate(raw_items):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"Codex Responses input[{idx}] must be an object.")
|
||||
@@ -901,7 +903,7 @@ def _preflight_codex_input_items(
|
||||
return normalized
|
||||
|
||||
|
||||
def _preflight_tool(tool: Any, idx: int) -> Dict[str, Any]:
|
||||
def _preflight_tool(tool: Any, idx: int) -> dict[str, Any]:
|
||||
if not isinstance(tool, dict):
|
||||
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
|
||||
tool_type = tool.get("type")
|
||||
@@ -945,7 +947,7 @@ _PREFLIGHT_ALLOWED_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _optional_dict(api_kwargs: Dict[str, Any], key: str) -> Optional[Dict[str, Any]]:
|
||||
def _optional_dict(api_kwargs: dict[str, Any], key: str) -> Optional[dict[str, Any]]:
|
||||
value = api_kwargs.get(key)
|
||||
if value is not None and not isinstance(value, dict):
|
||||
raise ValueError(f"Codex Responses request '{key}' must be an object.")
|
||||
@@ -955,7 +957,7 @@ def _optional_dict(api_kwargs: Dict[str, Any], key: str) -> Optional[Dict[str, A
|
||||
def _preflight_codex_api_kwargs(
|
||||
api_kwargs: Any, *, allow_stream: bool = False, is_github_responses: bool = False,
|
||||
sanitize_harmony_tokens: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(api_kwargs, dict):
|
||||
raise ValueError("Codex Responses request must be a dict.")
|
||||
if missing := sorted(key for key in ("model", "instructions", "input") if key not in api_kwargs):
|
||||
@@ -969,7 +971,7 @@ def _preflight_codex_api_kwargs(
|
||||
input_items = _preflight_codex_input_items(
|
||||
api_kwargs.get("input"), is_github_responses=is_github_responses, sanitize_harmony_tokens=sanitize_harmony_tokens,
|
||||
)
|
||||
normalized: Dict[str, Any] = {
|
||||
normalized: dict[str, Any] = {
|
||||
"model": model.strip(), "instructions": instructions, "input": input_items, "store": False,
|
||||
}
|
||||
tools = api_kwargs.get("tools")
|
||||
@@ -1019,7 +1021,7 @@ def _preflight_codex_api_kwargs(
|
||||
|
||||
# --- Response extraction helpers ----------------------------------------------
|
||||
|
||||
def _text_chunks(parts: Any, types: Optional[set] = None) -> List[str]:
|
||||
def _text_chunks(parts: Any, types: Optional[set] = None) -> list[str]:
|
||||
"""Non-empty ``.text`` of each part (optionally filtered by ``.type``); [] if not a list."""
|
||||
selected = [part for part in _as_list(parts) if types is None or getattr(part, "type", None) in types]
|
||||
return [text for text in (getattr(part, "text", None) for part in selected) if _nonempty_str(text)]
|
||||
@@ -1076,13 +1078,13 @@ def _response_tool_call(item: Any, item_type: str, index: int) -> SimpleNamespac
|
||||
|
||||
def _capture_encrypted_item(
|
||||
item: Any, item_type: str, issuer_kind: Optional[str], issuer_model: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""``{type, encrypted_content[, _issuer_kind, _issuer_model]}`` for replay, or None without a blob. Reasoning
|
||||
items also carry ``id`` + ``summary`` (required by the API on replay); transient ``rs_tmp_`` skip."""
|
||||
encrypted = getattr(item, "encrypted_content", None)
|
||||
if not _nonempty_str(encrypted):
|
||||
return None
|
||||
raw_item: Dict[str, Any] = {"type": item_type, "encrypted_content": encrypted}
|
||||
raw_item: dict[str, Any] = {"type": item_type, "encrypted_content": encrypted}
|
||||
if issuer_kind:
|
||||
raw_item["_issuer_kind"] = issuer_kind
|
||||
if issuer_model:
|
||||
@@ -1112,7 +1114,7 @@ class _OutputScan:
|
||||
self.saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
|
||||
self.saw_commentary_phase = self.saw_final_answer_phase = self.saw_reasoning_item = False
|
||||
|
||||
def scan(self, output: List[Any], issuer_kind: Optional[str], issuer_model: Optional[str] = None) -> None:
|
||||
def scan(self, output: list[Any], issuer_kind: Optional[str], issuer_model: Optional[str] = None) -> None:
|
||||
for item in output:
|
||||
item_type = getattr(item, "type", None)
|
||||
item_status = _lower_or_none(getattr(item, "status", None))
|
||||
@@ -1176,7 +1178,7 @@ def _normalize_codex_response(
|
||||
if out_text:
|
||||
msg = "Codex response has empty output but output_text is present (%d chars); synthesizing output item."
|
||||
logger.debug(msg, len(out_text))
|
||||
content: List[Any] = [SimpleNamespace(type="output_text", text=out_text)]
|
||||
content: list[Any] = [SimpleNamespace(type="output_text", text=out_text)]
|
||||
elif response_incomplete_content_filter:
|
||||
# Provider safety block, not a partial answer: finish content_filter, not incomplete.
|
||||
content = []
|
||||
|
||||
+12
-12
@@ -592,7 +592,7 @@ def _codex_turn_service_tier(agent) -> str | None:
|
||||
return CODEX_TIER_WORDS.get(effective_request_overrides(agent).get("service_tier"))
|
||||
|
||||
|
||||
def _ensure_codex_session(agent, messages: List[Dict[str, Any]] | None = None) -> None:
|
||||
def _ensure_codex_session(agent, messages: list[dict[str, Any]] | None = None) -> None:
|
||||
"""Lazily spawn one CodexAppServerSession per AIAgent (reused across turns, closed by the _cleanup hook).
|
||||
A live session whose thread was started with a different prompt composition (TUI/Desktop ``/personality``
|
||||
or a prompt mirror mutate the agent in place) is retired first so the new thread carries the current one.
|
||||
@@ -659,7 +659,7 @@ def _ensure_codex_session(agent, messages: List[Dict[str, Any]] | None = None) -
|
||||
)
|
||||
|
||||
|
||||
def _persist_projected_messages(agent, turn, messages: List[Dict[str, Any]]) -> bool:
|
||||
def _persist_projected_messages(agent, turn, messages: list[dict[str, Any]]) -> bool:
|
||||
"""Splice the projected messages into ``messages`` and flush them to the session DB; True when the
|
||||
rows are durable in the session DB (the codex thread binding may then be published).
|
||||
|
||||
@@ -693,7 +693,7 @@ def _persist_projected_messages(agent, turn, messages: List[Dict[str, Any]]) ->
|
||||
return flush_ok is True
|
||||
|
||||
|
||||
def _finish_codex_turn(agent, turn, messages: List[Dict[str, Any]], *, original_user_message: Any,
|
||||
def _finish_codex_turn(agent, turn, messages: list[dict[str, Any]], *, original_user_message: Any,
|
||||
should_review_memory: bool) -> dict[str, Any]:
|
||||
"""Post-turn bookkeeping mirroring the chat_completions loop; returns usage fields."""
|
||||
# run_conversation() already bumped _turns_since_memory / _user_turn_count; only _iters_since_skill is ours.
|
||||
@@ -718,8 +718,8 @@ def _finish_codex_turn(agent, turn, messages: List[Dict[str, Any]], *, original_
|
||||
return usage_result
|
||||
|
||||
|
||||
def run_codex_app_server_turn(agent, *, user_message: str, original_user_message: Any, messages: List[Dict[str, Any]],
|
||||
effective_task_id: str, should_review_memory: bool = False) -> Dict[str, Any]:
|
||||
def run_codex_app_server_turn(agent, *, user_message: str, original_user_message: Any, messages: list[dict[str, Any]],
|
||||
effective_task_id: str, should_review_memory: bool = False) -> dict[str, Any]:
|
||||
"""Hand the turn to a ``codex app-server`` subprocess and project its events into ``messages``.
|
||||
Returns the chat_completions result shape. The user message is ALREADY in ``messages`` — never append it again."""
|
||||
# Defense in depth for compression.checkpoint_required: agent init refuses the combination, but
|
||||
@@ -764,8 +764,8 @@ def run_codex_app_server_turn(agent, *, user_message: str, original_user_message
|
||||
)
|
||||
|
||||
|
||||
def _turn_result(interrupt: tuple[bool, Any], messages: List[Dict[str, Any]], *, api_calls: int, completed: bool,
|
||||
error: Any, final_response: Any, **extra: Any) -> Dict[str, Any]:
|
||||
def _turn_result(interrupt: tuple[bool, Any], messages: list[dict[str, Any]], *, api_calls: int, completed: bool,
|
||||
error: Any, final_response: Any, **extra: Any) -> dict[str, Any]:
|
||||
"""Result shape shared with the chat_completions path (``partial`` == ``not completed``)."""
|
||||
user_interrupted, interrupt_message = interrupt
|
||||
return {
|
||||
@@ -862,15 +862,15 @@ class _CodexResponseAssembler:
|
||||
def __init__(self, *, model, on_text_delta, on_reasoning_delta, on_commentary_message, on_first_delta):
|
||||
self.model, self.on_text_delta, self.on_reasoning_delta = model, on_text_delta, on_reasoning_delta
|
||||
self.on_commentary_message, self.on_first_delta = on_commentary_message, on_first_delta
|
||||
self.output_items: List[Any] = []
|
||||
self.output_items: list[Any] = []
|
||||
# output_index / first-observed sequence per output item, in lockstep, so settled pending calls merge
|
||||
# back in stream order.
|
||||
self.output_indexes, self.output_sequences = [], []
|
||||
self.text_deltas, self.commentary_text_deltas = [], []
|
||||
# pending_function_calls: announced-but-unconfirmed function calls keyed by item id. announced_output_order:
|
||||
# first-observed (sequence, output_index) per announced item id so a later .done keeps its announced position.
|
||||
self.pending_function_calls: Dict[str, Dict[str, Any]] = {}
|
||||
self.announced_output_order: Dict[str, tuple] = {}
|
||||
self.pending_function_calls: dict[str, dict[str, Any]] = {}
|
||||
self.announced_output_order: dict[str, tuple] = {}
|
||||
|
||||
def _safe(self, cb: Callable | None, label: str, *args: Any) -> None:
|
||||
_call_guarded(cb, f"Codex stream {label} raised", args=args)
|
||||
@@ -1009,7 +1009,7 @@ class _CodexResponseAssembler:
|
||||
handler = self._EXACT_HANDLERS.get(event_type) or next((h for m, h in self._FUZZY_HANDLERS if m(event_type)), None)
|
||||
return bool(handler(self, event, event_type)) if handler is not None else False
|
||||
|
||||
def _settled_output(self) -> List[Any]:
|
||||
def _settled_output(self) -> list[Any]:
|
||||
"""Merge .done items with settled pending calls, keeping stream order."""
|
||||
indexed = list(zip(self.output_indexes, self.output_sequences, self.output_items))
|
||||
for pending in self.pending_function_calls.values():
|
||||
@@ -1031,7 +1031,7 @@ class _CodexResponseAssembler:
|
||||
|
||||
def result(self) -> SimpleNamespace:
|
||||
# With only plain text deltas (no tool calls), synthesize one message item.
|
||||
output: List[Any] = list(self.output_items)
|
||||
output: list[Any] = list(self.output_items)
|
||||
if not output and self.text_deltas and not self.has_tool_calls:
|
||||
content = [SimpleNamespace(type="output_text", text="".join(self.text_deltas))]
|
||||
output = [SimpleNamespace(type="message", role="assistant", status="completed", content=content)]
|
||||
|
||||
@@ -32,7 +32,7 @@ def _text_of(content: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _render_row(msg: Dict[str, Any]) -> str:
|
||||
def _render_row(msg: dict[str, Any]) -> str:
|
||||
role = msg.get("role")
|
||||
text = _text_of(msg.get("content")).strip()
|
||||
if role == "user":
|
||||
@@ -50,7 +50,7 @@ def _render_row(msg: Dict[str, Any]) -> str:
|
||||
return "" # system rows are the prompt composition, already sent as developerInstructions
|
||||
|
||||
|
||||
def render_history_seed(messages: List[Dict[str, Any]] | None) -> str:
|
||||
def render_history_seed(messages: list[dict[str, Any]] | None) -> str:
|
||||
"""Prior turns as one text block, newest last; empty when there is nothing before the current
|
||||
user message. The trailing user row is the turn being submitted and is never included."""
|
||||
rows = list(messages or [])
|
||||
|
||||
@@ -30,7 +30,7 @@ _COMPACTION_INTERNAL_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
def project_compaction_message_for_display(message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def project_compaction_message_for_display(message: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Return authentic transcript content, or ``None`` for a pure handoff.
|
||||
|
||||
Model-facing recovery history retains the complete carrier. Display
|
||||
|
||||
+21
-21
@@ -76,10 +76,10 @@ def _skills_block(stable: str) -> str:
|
||||
return m.group(0) if m else ""
|
||||
|
||||
|
||||
def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[dict]]:
|
||||
builtin: List[dict] = []
|
||||
mcp: List[dict] = []
|
||||
subagent: List[dict] = []
|
||||
def _split_tools(tools: Sequence[dict]) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
builtin: list[dict] = []
|
||||
mcp: list[dict] = []
|
||||
subagent: list[dict] = []
|
||||
for tool in tools:
|
||||
fn = tool.get("function") if isinstance(tool, dict) else None
|
||||
name = str((fn if isinstance(fn, dict) else tool).get("name") or "")
|
||||
@@ -88,7 +88,7 @@ def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[di
|
||||
return builtin, mcp, subagent
|
||||
|
||||
|
||||
def _memory_blocks(agent: Any) -> Tuple[str, str]:
|
||||
def _memory_blocks(agent: Any) -> tuple[str, str]:
|
||||
memory_block = user_block = ""
|
||||
store = getattr(agent, "_memory_store", None)
|
||||
try:
|
||||
@@ -112,7 +112,7 @@ def _join(*parts: str) -> str:
|
||||
return "\n\n".join(part for part in parts if part).strip()
|
||||
|
||||
|
||||
def _glyph(cat: Dict[str, Any]) -> str:
|
||||
def _glyph(cat: dict[str, Any]) -> str:
|
||||
return _CATEGORIES.get(str(cat.get("id") or ""), (None, "▪"))[1]
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ def context_display_source(compressor: Any) -> str:
|
||||
return "local_estimate" if isinstance(real, (int, float)) and shown > 0 and shown != real else "provider_usage"
|
||||
|
||||
|
||||
def context_usage_fields(compressor: Any) -> Dict[str, Any]:
|
||||
def context_usage_fields(compressor: Any) -> dict[str, Any]:
|
||||
"""Current occupancy only; lifetime throughput is never a context fallback."""
|
||||
used = max(0, getattr(compressor, "last_prompt_tokens", 0) or 0)
|
||||
maximum = getattr(compressor, "context_length", 0) or 0
|
||||
@@ -140,7 +140,7 @@ def context_usage_fields(compressor: Any) -> Dict[str, Any]:
|
||||
"context_source": source, "context_estimated": source != "provider_usage"}
|
||||
|
||||
|
||||
def compute_session_context_breakdown(agent: Any, messages: Optional[List[dict]] = None) -> Dict[str, Any]:
|
||||
def compute_session_context_breakdown(agent: Any, messages: Optional[list[dict]] = None) -> dict[str, Any]:
|
||||
"""Return a Cursor-style context usage breakdown for one live agent."""
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
from agent.usage_anchor import anchored_context_tokens
|
||||
@@ -209,7 +209,7 @@ def compute_session_context_breakdown(agent: Any, messages: Optional[List[dict]]
|
||||
}
|
||||
|
||||
|
||||
def compute_context_details(agent: Any) -> Dict[str, Any]:
|
||||
def compute_context_details(agent: Any) -> dict[str, Any]:
|
||||
"""Expanded per-skill / per-toolset cost listing for ``/context all``.
|
||||
|
||||
Reuses the ``hermes prompt-size`` attribution (index-line bytes from the
|
||||
@@ -245,12 +245,12 @@ def compute_context_details(agent: Any) -> Dict[str, Any]:
|
||||
# (monospace is not guaranteed on messaging platforms).
|
||||
|
||||
|
||||
def render_context_grid(payload: Dict[str, Any]) -> List[str]:
|
||||
def render_context_grid(payload: dict[str, Any]) -> list[str]:
|
||||
"""Glyph grid: 100 cells, one per percent of the context window; categories
|
||||
fill in declaration order, the remainder is free space."""
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
total_cells = _GRID_COLUMNS * _GRID_ROWS
|
||||
cells: List[str] = []
|
||||
cells: list[str] = []
|
||||
if context_max > 0:
|
||||
for cat in payload.get("categories") or []:
|
||||
tokens = int(cat.get("tokens") or 0)
|
||||
@@ -262,7 +262,7 @@ def render_context_grid(payload: Dict[str, Any]) -> List[str]:
|
||||
return [" ".join(cells[row * _GRID_COLUMNS:(row + 1) * _GRID_COLUMNS]) for row in range(_GRID_ROWS)]
|
||||
|
||||
|
||||
def render_context_category_lines(payload: Dict[str, Any]) -> List[str]:
|
||||
def render_context_category_lines(payload: dict[str, Any]) -> list[str]:
|
||||
"""Render the 'Estimated usage by category' table as plain-text lines."""
|
||||
categories = payload.get("categories") or []
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
@@ -285,12 +285,12 @@ def render_context_category_lines(payload: Dict[str, Any]) -> List[str]:
|
||||
return lines
|
||||
|
||||
|
||||
def _toolset_row(group: Dict[str, Any]) -> str:
|
||||
def _toolset_row(group: dict[str, Any]) -> str:
|
||||
return t("gateway.context.toolset_row", toolset=f"{group['toolset']:<24}", count=f"{group['tool_count']:>3}",
|
||||
tokens=f"{group['schema_tokens']:>8,}")
|
||||
|
||||
|
||||
def _skill_row(entry: Dict[str, Any]) -> str:
|
||||
def _skill_row(entry: dict[str, Any]) -> str:
|
||||
name = str(entry.get("name") or "")
|
||||
if len(name) > 28:
|
||||
name = name[:27] + "…"
|
||||
@@ -299,7 +299,7 @@ def _skill_row(entry: Dict[str, Any]) -> str:
|
||||
return t("gateway.context.skill_row", name=f"{name:<28}", index_tokens=f"{entry['index_tokens']:>6,}", md_tokens=md_str)
|
||||
|
||||
|
||||
def _table(lines: List[str], title: str, rows: List[Dict[str, Any]], fmt) -> None:
|
||||
def _table(lines: list[str], title: str, rows: list[dict[str, Any]], fmt) -> None:
|
||||
"""Append a titled, display-capped table (blank-separated from a preceding one)."""
|
||||
if not rows:
|
||||
return
|
||||
@@ -311,23 +311,23 @@ def _table(lines: List[str], title: str, rows: List[Dict[str, Any]], fmt) -> Non
|
||||
lines.append(t("gateway.context.and_more", count=len(rows) - _DETAILS_TABLE_LIMIT))
|
||||
|
||||
|
||||
def render_context_details_lines(details: Dict[str, Any]) -> List[str]:
|
||||
def render_context_details_lines(details: dict[str, Any]) -> list[str]:
|
||||
"""Render the expanded ``/context all`` per-skill / per-toolset tables."""
|
||||
lines: List[str] = []
|
||||
lines: list[str] = []
|
||||
_table(lines, t("gateway.context.toolsets_title"), details.get("toolsets") or [], _toolset_row)
|
||||
_table(lines, t("gateway.context.skills_title"), details.get("skills") or [], _skill_row)
|
||||
return lines
|
||||
|
||||
|
||||
def render_context_breakdown_lines(
|
||||
payload: Dict[str, Any],
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
details: Optional[dict[str, Any]] = None,
|
||||
grid: bool = True,
|
||||
) -> List[str]:
|
||||
) -> list[str]:
|
||||
"""Full /context view. ``grid`` prepends the glyph grid (CLI; the gateway
|
||||
keeps its own gauge); ``details`` appends the expanded listings."""
|
||||
lines: List[str] = [*render_context_grid(payload), ""] if grid else []
|
||||
lines: list[str] = [*render_context_grid(payload), ""] if grid else []
|
||||
lines.extend(render_context_category_lines(payload))
|
||||
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
|
||||
+140
-140
@@ -73,7 +73,7 @@ def _safe_int(value: Any) -> int | None:
|
||||
# compression run (its only non-recursive call site is the compress path; the two recursive calls are the
|
||||
# deliberate main-model retry that must NOT re-issue the pin). The summary call is the ONLY auxiliary LLM
|
||||
# call a lean compaction attempt makes (#96603) — there are no sibling digest calls.
|
||||
_SUMMARY_ROUTE_PIN: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
|
||||
_SUMMARY_ROUTE_PIN: contextvars.ContextVar[Optional[dict[str, Any]]] = (
|
||||
contextvars.ContextVar("hermes_summary_route_pin", default=None)
|
||||
)
|
||||
|
||||
@@ -82,7 +82,7 @@ _PINNED_ROUTE_FIELDS: tuple[str, ...] = ("provider", "model", "base_url", "api_k
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def pin_summary_route(route: Optional[Dict[str, Any]]):
|
||||
def pin_summary_route(route: Optional[dict[str, Any]]):
|
||||
"""Pin the next summary LLM call to an explicit route; ``None`` is a no-op. Re-entrant: restores the prior pin."""
|
||||
token = _SUMMARY_ROUTE_PIN.set(route if isinstance(route, dict) else None)
|
||||
try:
|
||||
@@ -91,7 +91,7 @@ def pin_summary_route(route: Optional[Dict[str, Any]]):
|
||||
_SUMMARY_ROUTE_PIN.reset(token)
|
||||
|
||||
|
||||
def take_pinned_summary_route() -> Optional[Dict[str, Any]]:
|
||||
def take_pinned_summary_route() -> Optional[dict[str, Any]]:
|
||||
"""Read and consume the pinned summary route (single use: the main-model retry must not re-issue it)."""
|
||||
route = _SUMMARY_ROUTE_PIN.get()
|
||||
if route is not None:
|
||||
@@ -103,7 +103,7 @@ def take_pinned_summary_route() -> Optional[Dict[str, Any]]:
|
||||
# fallback summary instead (``abort_on_summary_failure`` still aborts). The host pins it when the summary
|
||||
# route stalls again after a stall-class backoff already burned one idle window (#112420), so a provably
|
||||
# unhealthy route degrades once instead of re-entering the same silent stream every turn.
|
||||
DETERMINISTIC_SUMMARY_ROUTE: Dict[str, Any] = {"label": "deterministic fallback summary", "deterministic": True}
|
||||
DETERMINISTIC_SUMMARY_ROUTE: dict[str, Any] = {"label": "deterministic fallback summary", "deterministic": True}
|
||||
|
||||
|
||||
def take_deterministic_summary_pin() -> bool:
|
||||
@@ -115,7 +115,7 @@ def take_deterministic_summary_pin() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _pinned_summary_call_kwargs() -> Dict[str, Any]:
|
||||
def _pinned_summary_call_kwargs() -> dict[str, Any]:
|
||||
"""Consume the pinned route as explicit ``call_llm`` keyword arguments."""
|
||||
route = take_pinned_summary_route() or {}
|
||||
return {field: route[field] for field in _PINNED_ROUTE_FIELDS if route.get(field) not in (None, "")}
|
||||
@@ -347,7 +347,7 @@ MAX_ITERATIONS_SUMMARY_REQUEST = (
|
||||
_BACKGROUND_PROCESS_NOTIFICATION_PREFIX = "[IMPORTANT: Background process "
|
||||
|
||||
|
||||
def _fresh_compaction_message_copy(msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _fresh_compaction_message_copy(msg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Copy a message for compaction assembly without persistence markers (``_strip_persistence_markers`` is authoritative)."""
|
||||
fresh = msg.copy()
|
||||
fresh.pop(_DB_PERSISTED_MARKER, None)
|
||||
@@ -364,7 +364,7 @@ def _template_visible_role(message: Any) -> Optional[str]:
|
||||
return None if role == "tool" or (role == "assistant" and message.get("tool_calls")) else role
|
||||
|
||||
|
||||
def _last_template_visible_role(messages: List[Dict[str, Any]]) -> Optional[str]:
|
||||
def _last_template_visible_role(messages: list[dict[str, Any]]) -> Optional[str]:
|
||||
"""Last role a strict alternation template would count in *messages*.
|
||||
|
||||
``None`` when every row is template-exempt (tool flow only).
|
||||
@@ -379,7 +379,7 @@ def _last_template_visible_role(messages: List[Dict[str, Any]]) -> Optional[str]
|
||||
)
|
||||
|
||||
|
||||
def _strip_persistence_markers(messages: List[Dict[str, Any]]) -> None:
|
||||
def _strip_persistence_markers(messages: list[dict[str, Any]]) -> None:
|
||||
"""Enforce the invariant: no assembled message carries a persistence marker.
|
||||
A leaked ``_db_persisted`` makes the child-session rotation flush skip the row, losing it from state.db.
|
||||
Per-copy-site strips are positional and re-leak when a copy site is added; this terminal sweep makes the
|
||||
@@ -399,7 +399,7 @@ class StaleHeldHistory(RuntimeError):
|
||||
"""
|
||||
|
||||
|
||||
def _archive_watermark_for(session_db: Any, session_id: str, held: List[Dict[str, Any]],
|
||||
def _archive_watermark_for(session_db: Any, session_id: str, held: list[dict[str, Any]],
|
||||
start_watermark: Optional[int] = None) -> Optional[int]:
|
||||
"""The archive watermark for a commit that rewrites the history this process holds.
|
||||
|
||||
@@ -426,7 +426,7 @@ def _archive_watermark_for(session_db: Any, session_id: str, held: List[Dict[str
|
||||
return held_archive_watermark(session_db, session_id, start_watermark, held, stale_raises=True)
|
||||
|
||||
|
||||
def stamp_db_persisted_markers(messages: List[Dict[str, Any]]) -> None:
|
||||
def stamp_db_persisted_markers(messages: list[dict[str, Any]]) -> None:
|
||||
"""Fulfil the post-commit contract of ``SessionDB.archive_and_compact()``.
|
||||
Single stamp site for all callers. Call ONLY after the commit succeeded, on the dict instances the
|
||||
caller keeps live. Needed because compress() output is marker-swept for the ROTATION flush; an
|
||||
@@ -441,7 +441,7 @@ def _is_checkpoint_item(item: Any) -> bool:
|
||||
return isinstance(item, dict) and item.get("type") == "compaction"
|
||||
|
||||
|
||||
def _newest_checkpoint_carrier(messages: List[Dict[str, Any]], key: str) -> int:
|
||||
def _newest_checkpoint_carrier(messages: list[dict[str, Any]], key: str) -> int:
|
||||
"""Index of the last assistant message carrying a ``type: "compaction"`` item under *key*, or -1.
|
||||
Transcript-side mirror of ``native_compaction.prune_pre_checkpoint_items``' newest-run-wins rule:
|
||||
the wire builder drops every checkpoint before the last one, so this is the only carrier whose
|
||||
@@ -456,7 +456,7 @@ def _newest_checkpoint_carrier(messages: List[Dict[str, Any]], key: str) -> int:
|
||||
return -1
|
||||
|
||||
|
||||
def _set_sidecar(msg: Dict[str, Any], key: str, kept: List[Any]) -> None:
|
||||
def _set_sidecar(msg: dict[str, Any], key: str, kept: list[Any]) -> None:
|
||||
"""Filter items, never leave an empty sidecar behind."""
|
||||
if kept:
|
||||
msg[key] = kept
|
||||
@@ -465,8 +465,8 @@ def _set_sidecar(msg: Dict[str, Any], key: str, kept: List[Any]) -> None:
|
||||
|
||||
|
||||
def drop_shadowed_checkpoints(
|
||||
messages: List[Dict[str, Any]], key: str = "codex_reasoning_items", *, before: Optional[int] = None,
|
||||
) -> List[int]:
|
||||
messages: list[dict[str, Any]], key: str = "codex_reasoning_items", *, before: Optional[int] = None,
|
||||
) -> list[int]:
|
||||
"""Drop ``type: "compaction"`` items from every assistant row older than the newest carrier (rows at
|
||||
index >= *before* are left alone). A checkpoint a newer carrier shadows has no reader on any wire:
|
||||
``prune_pre_checkpoint_items`` rebuilds each request around the newest checkpoint run and the replay
|
||||
@@ -474,7 +474,7 @@ def drop_shadowed_checkpoints(
|
||||
In place; returns the indices rewritten."""
|
||||
newest = _newest_checkpoint_carrier(messages, key)
|
||||
stop = newest if before is None else min(newest, before)
|
||||
rewritten: List[int] = []
|
||||
rewritten: list[int] = []
|
||||
for i in range(max(stop, 0)):
|
||||
msg = messages[i]
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
@@ -487,7 +487,7 @@ def drop_shadowed_checkpoints(
|
||||
return rewritten
|
||||
|
||||
|
||||
def _prune_stale_reasoning_replay(messages: List[Dict[str, Any]]) -> int:
|
||||
def _prune_stale_reasoning_replay(messages: list[dict[str, Any]]) -> int:
|
||||
"""Strip stale ``codex_reasoning_items`` from assistant turns older than the active one.
|
||||
Boundary is the last USER message (a turn spans several assistant rows): the Responses API replays a
|
||||
turn's bridging reasoning items together, so cutting at the last ASSISTANT would strip mid-chain.
|
||||
@@ -546,7 +546,7 @@ _SALVAGE_SUMMARY_MAX_CHARS = 8_000
|
||||
_SALVAGE_KEEP_RECENT_TOOLS = 2
|
||||
|
||||
|
||||
def _looks_like_compaction_summary(msg: Dict[str, Any], content: str) -> bool:
|
||||
def _looks_like_compaction_summary(msg: dict[str, Any], content: str) -> bool:
|
||||
# Only cap standalone handoffs; merged carriers contain live user text. Content heuristics never
|
||||
# authorize mutating a live turn: require the private compressor marker. Tool messages are
|
||||
# handled only by the stub/keep-recent pass.
|
||||
@@ -562,7 +562,7 @@ def _looks_like_compaction_summary(msg: Dict[str, Any], content: str) -> bool:
|
||||
return bool(msg.get(COMPRESSED_SUMMARY_METADATA_KEY)) or "CONTEXT COMPACTION" in head or "Conversation Summary" in head
|
||||
|
||||
|
||||
def _salvage_reduce_todo_snapshot(out: List[Dict[str, Any]]) -> None:
|
||||
def _salvage_reduce_todo_snapshot(out: list[dict[str, Any]]) -> None:
|
||||
"""Last-resort shrink: drop the synthetic todo snapshot, keeping only a pruned-skill reload notice if present."""
|
||||
from agent.conversation_compression import _PRUNED_SKILL_RELOAD_NOTICE_HEADER
|
||||
for i in range(len(out) - 1, -1, -1):
|
||||
@@ -579,8 +579,8 @@ def _salvage_reduce_todo_snapshot(out: List[Dict[str, Any]]) -> None:
|
||||
|
||||
|
||||
def salvage_grown_transcript(
|
||||
original: List[Dict[str, Any]], candidate: List[Dict[str, Any]], budget: Optional[int] = None,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
original: list[dict[str, Any]], candidate: list[dict[str, Any]], budget: Optional[int] = None,
|
||||
) -> Optional[list[dict[str, Any]]]:
|
||||
"""Mechanically shrink a compression candidate (copies, cheapest loss first); ``None`` unless strictly smaller."""
|
||||
if not candidate or not original:
|
||||
return None
|
||||
@@ -699,7 +699,7 @@ _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES = 4
|
||||
class _HandoffScan:
|
||||
"""Result of ``ContextCompressor._scan_window_handoffs``."""
|
||||
|
||||
turns_to_summarize: List[Dict[str, Any]]
|
||||
turns_to_summarize: list[dict[str, Any]]
|
||||
summary_indices: set
|
||||
tail_start: int
|
||||
previous_summary_before: Optional[str]
|
||||
@@ -899,7 +899,7 @@ def _extract_pruned_skill_names(text: str) -> list[str]:
|
||||
return list(dict.fromkeys(m.group(1) for m in _SKILL_PRUNED_MARKER_RE.finditer(text or "")))
|
||||
|
||||
|
||||
def _collect_ghosted_skill_names(turns: List[Dict[str, Any]]) -> list[str]:
|
||||
def _collect_ghosted_skill_names(turns: list[dict[str, Any]]) -> list[str]:
|
||||
"""Skill names about to be lost in compaction: demoted ``skill_view`` rows and raw, never-demoted bodies."""
|
||||
call_id_to_skill: dict[str, str] = {}
|
||||
for idx, skill in _skill_view_call_sites(turns):
|
||||
@@ -980,7 +980,7 @@ def _synthetic_user_row(content: str) -> bool:
|
||||
return content.lstrip().startswith(_SYNTHETIC_USER_ROW_PREFIXES)
|
||||
|
||||
|
||||
def _build_verbatim_user_section(turns: List[Dict[str, Any]]) -> str:
|
||||
def _build_verbatim_user_section(turns: list[dict[str, Any]]) -> str:
|
||||
"""Compacted region's REAL user messages verbatim, newest-first under a char budget (straddler truncated); "" if none."""
|
||||
collected: list[str] = []
|
||||
used = 0
|
||||
@@ -1069,7 +1069,7 @@ _ANCHOR_NOISE = frozenset({
|
||||
})
|
||||
|
||||
|
||||
def _build_anchor_index(turns: List[Dict[str, Any]]) -> str:
|
||||
def _build_anchor_index(turns: list[dict[str, Any]]) -> str:
|
||||
"""Regex-harvest exact identifiers from the compacted region (LLM-free); per-category caps, most-frequent first."""
|
||||
text = "\n".join(c for c in (msg.get("content") for msg in turns) if isinstance(c, str) and c)
|
||||
if not text:
|
||||
@@ -1110,7 +1110,7 @@ def _build_anchor_index(turns: List[Dict[str, Any]]) -> str:
|
||||
_SKILL_PRUNE_RECENT_WINDOW = 10
|
||||
|
||||
|
||||
def _skill_view_call_sites(messages: List[Dict[str, Any]]) -> list[tuple[int, str]]:
|
||||
def _skill_view_call_sites(messages: list[dict[str, Any]]) -> list[tuple[int, str]]:
|
||||
"""Yield ``(message_index, skill_name)`` for every skill_view tool call."""
|
||||
sites: list[tuple[int, str]] = []
|
||||
for i, msg in enumerate(messages):
|
||||
@@ -1127,7 +1127,7 @@ def _skill_view_call_sites(messages: List[Dict[str, Any]]) -> list[tuple[int, st
|
||||
return sites
|
||||
|
||||
|
||||
def _collect_protected_skill_names(messages: List[Dict[str, Any]], prune_boundary: int) -> set[str]:
|
||||
def _collect_protected_skill_names(messages: list[dict[str, Any]], prune_boundary: int) -> set[str]:
|
||||
"""Skill names (lower-cased) whose skill_view bodies must survive Phase-1 demotion.
|
||||
Recently loaded, loaded inside the protected tail, or named by a tail user message. Applies to
|
||||
Phase-1/2 only; the Pass-4 pressure demotion ignores it."""
|
||||
@@ -1216,9 +1216,9 @@ def _extract_tool_call_name_and_args(tool_call: Any) -> tuple[str, str]:
|
||||
return str(_tc_get(fn, "name") or "unknown"), str(_tc_get(fn, "arguments") or "")
|
||||
|
||||
|
||||
def _tool_calls_by_id(messages: List[Dict[str, Any]]) -> Dict[str, tuple]:
|
||||
def _tool_calls_by_id(messages: list[dict[str, Any]]) -> dict[str, tuple]:
|
||||
"""Map ``tool_call_id -> (tool_name, raw_arguments)`` over every assistant tool call."""
|
||||
out: Dict[str, tuple] = {}
|
||||
out: dict[str, tuple] = {}
|
||||
for msg in messages:
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
@@ -1352,17 +1352,17 @@ def _estimate_msg_budget_tokens(msg: dict, charge_stale_thinking: bool = True) -
|
||||
return tokens
|
||||
|
||||
|
||||
def _last_index_with_role(messages: "List[Dict[str, Any]]", role: str) -> int:
|
||||
def _last_index_with_role(messages: "list[dict[str, Any]]", role: str) -> int:
|
||||
"""Index of the newest dict message with ``role``, or -1."""
|
||||
return max((i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == role), default=-1)
|
||||
|
||||
|
||||
def _last_assistant_index(messages: "List[Dict[str, Any]]") -> int:
|
||||
def _last_assistant_index(messages: "list[dict[str, Any]]") -> int:
|
||||
"""Newest assistant message index, or -1 (the one turn whose thinking may replay; see ``_NEWEST_TURN_ONLY_BUDGET_KEYS``)."""
|
||||
return _last_index_with_role(messages, "assistant")
|
||||
|
||||
|
||||
def _pending_tool_round(messages: "List[Dict[str, Any]]") -> range:
|
||||
def _pending_tool_round(messages: "list[dict[str, Any]]") -> range:
|
||||
"""Indices of the tool results the transcript ends with — a round the model has not answered yet; empty when
|
||||
the transcript ends in any other row. /steer rows after the round do not answer it: a steer is delivered
|
||||
after the newest tool result before the next API call, and two can land in one iteration (one when the tool
|
||||
@@ -1452,7 +1452,7 @@ def _append_text_to_content(content: Any, text: str, *, prepend: bool = False) -
|
||||
return text + rendered if prepend else rendered + text
|
||||
|
||||
|
||||
def _replace_image_parts(parts: Any, placeholder: str) -> Optional[List[Any]]:
|
||||
def _replace_image_parts(parts: Any, placeholder: str) -> Optional[list[Any]]:
|
||||
"""New parts list with every image part replaced by a text placeholder; None if no images."""
|
||||
if not isinstance(parts, list) or not any(_is_image_part(p) for p in parts):
|
||||
return None
|
||||
@@ -1469,7 +1469,7 @@ def _tool_content_has_images(content: Any) -> bool:
|
||||
return _content_has_images(_tool_result_parts(content))
|
||||
|
||||
|
||||
def _strip_images_from_tool_msg(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def _strip_images_from_tool_msg(msg: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Copy of a tool message with image payloads replaced (stale ``api_content`` dropped); ``None`` if nothing to strip."""
|
||||
content = msg.get("content")
|
||||
if isinstance(content, dict) and content.get("_multimodal"):
|
||||
@@ -1479,7 +1479,7 @@ def _strip_images_from_tool_msg(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]
|
||||
return None if stripped is None else _rewritten(msg, stripped)
|
||||
|
||||
|
||||
def _rewritten(msg: Dict[str, Any], content: Any) -> Dict[str, Any]:
|
||||
def _rewritten(msg: dict[str, Any], content: Any) -> dict[str, Any]:
|
||||
"""Copy of ``msg`` carrying ``content``; drops the stale ``api_content`` sidecar so replay can't resend it."""
|
||||
new_msg = {**msg, "content": content}
|
||||
drop_stale_api_content(new_msg)
|
||||
@@ -1487,7 +1487,7 @@ def _rewritten(msg: Dict[str, Any], content: Any) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _retire_stale_tool_result_images(
|
||||
result: List[Dict[str, Any]], keep_newest: int = _MAX_KEEP_TOOL_IMAGES, spared: range = range(0),
|
||||
result: list[dict[str, Any]], keep_newest: int = _MAX_KEEP_TOOL_IMAGES, spared: range = range(0),
|
||||
) -> int:
|
||||
"""Replace image payloads on older tool results with text placeholders.
|
||||
Keeps the newest ``keep_newest`` image-bearing tool messages and any spared pending round;
|
||||
@@ -1511,7 +1511,7 @@ def _retire_stale_tool_result_images(
|
||||
return pruned
|
||||
|
||||
|
||||
def _image_payload(msg: Dict[str, Any]) -> Tuple[int, int]:
|
||||
def _image_payload(msg: dict[str, Any]) -> tuple[int, int]:
|
||||
"""``(blocks, bytes)`` of image payload in a message.
|
||||
|
||||
The provider counts BLOCKS: one ``tool_result`` carrying three screenshots is three against
|
||||
@@ -1537,7 +1537,7 @@ def _image_payload(msg: Dict[str, Any]) -> Tuple[int, int]:
|
||||
return blocks, payload
|
||||
|
||||
|
||||
def evict_stale_outbound_tool_images(api_messages: List[Dict[str, Any]]) -> int:
|
||||
def evict_stale_outbound_tool_images(api_messages: list[dict[str, Any]]) -> int:
|
||||
"""Drop stale screenshot/vision payloads from the per-call API copy.
|
||||
|
||||
Compression's keep-newest pass only runs when prune/compress fires, and the Anthropic
|
||||
@@ -1550,7 +1550,7 @@ def evict_stale_outbound_tool_images(api_messages: List[Dict[str, Any]]) -> int:
|
||||
reserved against the ceiling but never rewritten — policy and rationale in
|
||||
:mod:`agent.image_eviction_policy`. Returns the number of messages rewritten.
|
||||
"""
|
||||
carriers: List[Tuple[int, Tuple[int, int]]] = []
|
||||
carriers: list[tuple[int, tuple[int, int]]] = []
|
||||
reserved_blocks = reserved_bytes = 0
|
||||
for i in range(len(api_messages) - 1, -1, -1):
|
||||
msg = api_messages[i]
|
||||
@@ -1598,7 +1598,7 @@ def _strip_images_from_content(content: Any) -> Any:
|
||||
return content if stripped is None else stripped
|
||||
|
||||
|
||||
def _strip_historical_media(messages: List[Dict[str, Any]], spared: range = range(0)) -> List[Dict[str, Any]]:
|
||||
def _strip_historical_media(messages: list[dict[str, Any]], spared: range = range(0)) -> list[dict[str, Any]]:
|
||||
"""Replace image parts in older messages with placeholder text.
|
||||
Rule 1: strip everything before the newest image-bearing user message. Rule 1b: the opening
|
||||
attachment ages out once a newer tool image exists. Rule 2: keep only the newest tool-result image,
|
||||
@@ -1621,7 +1621,7 @@ def _strip_historical_media(messages: List[Dict[str, Any]], spared: range = rang
|
||||
# Nothing to strip under any rule.
|
||||
return messages
|
||||
|
||||
def _is_stale(index: int, message: Dict[str, Any]) -> bool:
|
||||
def _is_stale(index: int, message: dict[str, Any]) -> bool:
|
||||
if index in spared:
|
||||
return False
|
||||
# Rule 1: everything before the newest image-bearing user message. Rule 1b: the opening
|
||||
@@ -1638,7 +1638,7 @@ def _strip_historical_media(messages: List[Dict[str, Any]], spared: range = rang
|
||||
or (message.get("role") == "tool" and index != tool_anchor)
|
||||
)
|
||||
|
||||
def _stripped(i: int, msg: Any) -> Optional[Dict[str, Any]]:
|
||||
def _stripped(i: int, msg: Any) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(msg, dict) or not _is_stale(i, msg):
|
||||
return None
|
||||
content = msg.get("content")
|
||||
@@ -1664,7 +1664,7 @@ def _summary_part_text(part: Any) -> str:
|
||||
return _image_part_label(part) if ptype in _IMAGE_PART_TYPES else f"[{ptype or 'attachment'}]"
|
||||
|
||||
|
||||
def _image_part_label(part: Dict[str, Any]) -> str:
|
||||
def _image_part_label(part: dict[str, Any]) -> str:
|
||||
"""Short summarizer label for an image part: http(s) URLs kept as a handle, ``data:`` URLs collapse to ``[image]``."""
|
||||
url = part.get("image_url")
|
||||
if isinstance(url, dict):
|
||||
@@ -1684,7 +1684,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->
|
||||
"""1-line summary of a tool call + result. Never raises: a malformed historical call must not crash-loop compression."""
|
||||
try:
|
||||
return _summarize_tool_result_unguarded(tool_name, tool_args, tool_content)
|
||||
except Exception as exc: # noqa: BLE001 — a summary must never crash compression
|
||||
except Exception as exc:
|
||||
logger.debug("Tool-result summary failed for %s: %s", tool_name, exc)
|
||||
_len = len(tool_content) if isinstance(tool_content, str) else 0
|
||||
return f"[{tool_name}] ({_len:,} chars result)"
|
||||
@@ -2110,7 +2110,7 @@ def _today_for_prompt() -> str:
|
||||
# Per-section summarizer instructions, keyed by "the transcript has a real user turn". Wording
|
||||
# is deliberately plain: Azure/OpenAI content filters have flagged stronger "injection" /
|
||||
# "do not respond" framing. Prompt text is byte-pinned — restructure code around it only.
|
||||
_SECTION_INSTRUCTIONS: Dict[bool, Dict[str, str]] = {
|
||||
_SECTION_INSTRUCTIONS: dict[bool, dict[str, str]] = {
|
||||
True: {
|
||||
"language": (
|
||||
"Write the summary in the same language the user was using in the "
|
||||
@@ -2199,14 +2199,14 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
def _begin_compression_telemetry(
|
||||
self, *, current_tokens: int | None, attempt_id: str | None = None, session_id: str | None = None,
|
||||
trigger_source: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Initialize content-free per-attempt compression telemetry."""
|
||||
seed = getattr(self, "_compression_telemetry_seed", None)
|
||||
seed = seed if isinstance(seed, dict) else {}
|
||||
attempt_id = attempt_id or seed.get("attempt_id")
|
||||
session_id = session_id or seed.get("session_id")
|
||||
trigger_source = trigger_source or seed.get("trigger_source")
|
||||
telemetry: Dict[str, Any] = {
|
||||
telemetry: dict[str, Any] = {
|
||||
"event": "compression_attempt", "attempt_id": attempt_id or uuid.uuid4().hex,
|
||||
"session_id": session_id or "", "trigger_source": trigger_source or "unknown",
|
||||
"main_provider": self.provider or "", "main_model": self.model or "",
|
||||
@@ -2228,8 +2228,8 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
return telemetry
|
||||
|
||||
def _record_compression_regions(
|
||||
self, *, head_messages: List[Dict[str, Any]], middle_messages: List[Dict[str, Any]],
|
||||
tail_messages: List[Dict[str, Any]],
|
||||
self, *, head_messages: list[dict[str, Any]], middle_messages: list[dict[str, Any]],
|
||||
tail_messages: list[dict[str, Any]],
|
||||
) -> None:
|
||||
telemetry = getattr(self, "_active_compression_telemetry", None)
|
||||
if isinstance(telemetry, dict):
|
||||
@@ -2238,9 +2238,9 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
telemetry["protected_tail_tokens"] = estimate_messages_tokens_rough(tail_messages)
|
||||
|
||||
def _record_aux_compression_call(
|
||||
self, *, prompt_messages: List[Dict[str, Any]], max_tokens: int | None, duration_ms: int,
|
||||
self, *, prompt_messages: list[dict[str, Any]], max_tokens: int | None, duration_ms: int,
|
||||
aux_provider: str | None = None, aux_model: str | None = None,
|
||||
effective_aux_context: int | None = None, phase_timings: Dict[str, Any] | None = None,
|
||||
effective_aux_context: int | None = None, phase_timings: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
telemetry = getattr(self, "_active_compression_telemetry", None)
|
||||
if not isinstance(telemetry, dict):
|
||||
@@ -2349,7 +2349,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
def max_summary_tokens(self, value: int) -> None:
|
||||
self._max_summary_tokens = value
|
||||
|
||||
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
|
||||
def on_session_end(self, session_id: str, messages: list[dict[str, Any]]) -> None:
|
||||
"""Clear all per-session compaction state at a real session boundary.
|
||||
Session end (CLI exit, gateway expiry, id rotation) — NOT /new or /reset. Every per-session
|
||||
flag/counter can contaminate the next live session (suppressed compression, stale cooldowns,
|
||||
@@ -2637,7 +2637,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
# Any completed boundary (incl. the degraded fallback) settles the overload budget (#123167).
|
||||
self._reset_consecutive_overload_aborts()
|
||||
|
||||
def get_active_compression_failure_cooldown(self, *, refresh: bool = False) -> Optional[Dict[str, Any]]:
|
||||
def get_active_compression_failure_cooldown(self, *, refresh: bool = False) -> Optional[dict[str, Any]]:
|
||||
"""Return the live compression-failure cooldown for the bound session."""
|
||||
if refresh:
|
||||
# Rollback must distinguish an authoritative empty row from a failed read; the return value can't.
|
||||
@@ -2990,7 +2990,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
# must ABORT and preserve the session regardless of abort_on_summary_failure (see _TERMINAL_SUMMARY_FAILURES).
|
||||
self._clear_terminal_summary_failures()
|
||||
|
||||
def update_from_response(self, usage: Dict[str, Any]):
|
||||
def update_from_response(self, usage: dict[str, Any]):
|
||||
"""Update tracked token usage from API response."""
|
||||
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
self.last_completion_tokens = usage.get("completion_tokens", 0)
|
||||
@@ -3209,7 +3209,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
return False
|
||||
|
||||
def _walk_tail_budget(
|
||||
self, messages: List[Dict[str, Any]], head_end: int, ceiling: int, min_tail: int, *, cut_at_break: bool,
|
||||
self, messages: list[dict[str, Any]], head_end: int, ceiling: int, min_tail: int, *, cut_at_break: bool,
|
||||
) -> tuple[int, int]:
|
||||
"""Accumulate message tokens newest-first until ``ceiling`` (once ``min_tail`` rows are kept).
|
||||
Returns ``(cut_idx, accumulated)``; ``cut_idx`` is the first protected index. On the budget
|
||||
@@ -3228,7 +3228,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
cut = i
|
||||
return cut, accumulated
|
||||
|
||||
def _tail_row_pricer(self, messages: List[Dict[str, Any]]) -> Callable[[int], int]:
|
||||
def _tail_row_pricer(self, messages: list[dict[str, Any]]) -> Callable[[int], int]:
|
||||
"""Per-row tail price ``index -> tokens``: the walk's accounting, shared by every gate that sizes a tail region."""
|
||||
newest_asst_idx = _last_assistant_index(messages)
|
||||
charge_all_thinking = self._stale_thinking_on_wire()
|
||||
@@ -3238,7 +3238,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
return lambda i: _estimate_msg_budget_tokens(messages[i], charge_all_thinking or i == newest_asst_idx)
|
||||
|
||||
def _prune_boundary(
|
||||
self, result: List[Dict[str, Any]], protect_tail_count: int, protect_tail_tokens: int | None,
|
||||
self, result: list[dict[str, Any]], protect_tail_count: int, protect_tail_tokens: int | None,
|
||||
) -> int:
|
||||
"""First index of the protected tail; token budget (when given) beats the count floor."""
|
||||
if protect_tail_tokens is None or protect_tail_tokens <= 0:
|
||||
@@ -3250,7 +3250,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
return min(boundary, len(result) - min_protect)
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_tool_results(result: List[Dict[str, Any]]) -> int:
|
||||
def _dedupe_tool_results(result: list[dict[str, Any]]) -> int:
|
||||
"""Pass 1: keep the newest copy of identical tool results, back-reference older ones."""
|
||||
pruned = 0
|
||||
content_hashes: set = set()
|
||||
@@ -3269,7 +3269,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
|
||||
@staticmethod
|
||||
def _demote_tool_result_at(
|
||||
result: List[Dict[str, Any]], idx: int, call_id_to_tool: Dict[str, tuple[str, str]],
|
||||
result: list[dict[str, Any]], idx: int, call_id_to_tool: dict[str, tuple[str, str]],
|
||||
min_prune_chars: int, protected_skills: Optional[set[str]] = None,
|
||||
) -> bool:
|
||||
"""Replace the tool result at ``idx`` with a 1-line summary; True if modified.
|
||||
@@ -3309,8 +3309,8 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
return max(ceiling, token_budget)
|
||||
|
||||
def _pressure_demote_tail(
|
||||
self, result: List[Dict[str, Any]], prune_boundary: int, protect_tail_tokens: int,
|
||||
call_id_to_tool: Dict[str, tuple[str, str]], min_prune_chars: int, spared: range,
|
||||
self, result: list[dict[str, Any]], prune_boundary: int, protect_tail_tokens: int,
|
||||
call_id_to_tool: dict[str, tuple[str, str]], min_prune_chars: int, spared: range,
|
||||
) -> int:
|
||||
"""Pass 4: demote tool-result bodies inside the protected tail when it alone exceeds the soft
|
||||
budget (#61932). Keeps a short recent floor and the ``spared`` pending tool round verbatim; overrides
|
||||
@@ -3358,7 +3358,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
)
|
||||
return demoted
|
||||
|
||||
def _spared_pending_tool_round(self, messages: List[Dict[str, Any]]) -> range:
|
||||
def _spared_pending_tool_round(self, messages: list[dict[str, Any]]) -> range:
|
||||
"""Return the pending round, owning assistant(tool_calls) row included, only when it fits the input window's
|
||||
hard share. The call's args are part of the unread round: the model reads its result next to them."""
|
||||
pending = _pending_tool_round(messages)
|
||||
@@ -3370,9 +3370,9 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
return pending if sum(_estimate_msg_budget_tokens(messages[i]) for i in pending) <= hard_share else range(0)
|
||||
|
||||
def _prune_old_tool_results(
|
||||
self, messages: List[Dict[str, Any]], protect_tail_count: int,
|
||||
self, messages: list[dict[str, Any]], protect_tail_count: int,
|
||||
protect_tail_tokens: int | None = None, min_prune_chars: int = _PRUNE_MIN_CHARS,
|
||||
) -> tuple[List[Dict[str, Any]], int]:
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Project old tool-result bodies to bounded summaries without rewriting tool-call arguments.
|
||||
Returns ``(messages, count)``; token budget (when given) takes priority over the
|
||||
message-count floor."""
|
||||
@@ -3480,8 +3480,8 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
)
|
||||
|
||||
def prune_tool_results_only(
|
||||
self, messages: List[Dict[str, Any]], current_tokens: int | None = None,
|
||||
) -> tuple[List[Dict[str, Any]], int]:
|
||||
self, messages: list[dict[str, Any]], current_tokens: int | None = None,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Deterministic, no-LLM tool-result prune gated on ``proactive_prune_tokens``.
|
||||
Protects the tail by message COUNT only. A commit breaks the prompt cache, so it requires
|
||||
``proactive_prune_min_reclaim_tokens`` and a full regrowth runway; otherwise returns the INPUT
|
||||
@@ -3560,7 +3560,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
self._last_reclaim_block_warn = None
|
||||
return pruned_msgs, pruned_count
|
||||
|
||||
def _compute_summary_budget(self, turns_to_summarize: List[Dict[str, Any]]) -> int:
|
||||
def _compute_summary_budget(self, turns_to_summarize: list[dict[str, Any]]) -> int:
|
||||
"""Scale the summary token budget with content size and context window."""
|
||||
content_tokens = estimate_messages_tokens_rough(turns_to_summarize)
|
||||
budget = int(content_tokens * _SUMMARY_RATIO)
|
||||
@@ -3588,7 +3588,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
args = args[:self._TOOL_ARGS_HEAD] + "..."
|
||||
return f" {fn.get('name', '?')}({args})"
|
||||
|
||||
def _serialize_records_for_summary(self, turns: List[Dict[str, Any]]) -> List[str]:
|
||||
def _serialize_records_for_summary(self, turns: list[dict[str, Any]]) -> list[str]:
|
||||
"""Serialize turns into a list of labeled, redacted records for the summarizer."""
|
||||
# Lazy import: agent_runtime_helpers pulls heavy transitive imports.
|
||||
from agent.agent_runtime_helpers import strip_think_blocks
|
||||
@@ -3613,11 +3613,11 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
parts.append(f"[{role.upper()}]: {content}")
|
||||
return parts
|
||||
|
||||
def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str:
|
||||
def _serialize_for_summary(self, turns: list[dict[str, Any]]) -> str:
|
||||
"""Serialize turns into labeled, redacted text for the summarizer."""
|
||||
return "\n\n".join(self._serialize_records_for_summary(turns))
|
||||
|
||||
def _fallback_anchors(self, turns_to_summarize: List[Dict[str, Any]]) -> Dict[str, list[str]]:
|
||||
def _fallback_anchors(self, turns_to_summarize: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
"""Locally extractable anchors: user asks, actions, files, blockers, last dropped turns."""
|
||||
user_asks: list[str] = []
|
||||
assistant_actions: list[str] = []
|
||||
@@ -3667,7 +3667,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
elif role == "tool":
|
||||
tool_name, tool_args = call_id_to_tool.get(str(msg.get("tool_call_id") or ""), ("unknown", ""))
|
||||
tool_actions.append(_summarize_tool_result(tool_name, tool_args, text or ""))
|
||||
if re.search(r"\b(error|failed|exception|traceback|timeout|timed out|fatal)\b", text, re.I):
|
||||
if re.search(r"\b(error|failed|exception|traceback|timeout|timed out|fatal)\b", text, re.IGNORECASE):
|
||||
blockers.append(text[:500])
|
||||
return {
|
||||
"user_asks": user_asks,
|
||||
@@ -3678,7 +3678,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
}
|
||||
|
||||
def _build_static_fallback_summary(
|
||||
self, turns_to_summarize: List[Dict[str, Any]], reason: str | None = None,
|
||||
self, turns_to_summarize: list[dict[str, Any]], reason: str | None = None,
|
||||
) -> str:
|
||||
"""Deterministic handoff when the LLM summarizer is unavailable: locally extractable anchors (user asks,
|
||||
actions, files, errors) in the normal summary structure so downstream prompts recover gracefully."""
|
||||
@@ -3745,7 +3745,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
summary = _reinject_pruned_skill_markers(summary, _pruned_names)
|
||||
return self._augment_summary_lean(summary, turns_to_summarize)
|
||||
|
||||
def _demote_stale_tail_tools(self, messages: List[Dict[str, Any]], tail_start: int) -> List[Dict[str, Any]]:
|
||||
def _demote_stale_tail_tools(self, messages: list[dict[str, Any]], tail_start: int) -> list[dict[str, Any]]:
|
||||
"""Lean mode: demote tail tool results older than the newest ``_LEAN_TAIL_KEEP_TOOL_ROUNDS`` rounds to
|
||||
recovery stubs; skill-marker rows untouched. New list (untouched rows shared, demoted copied)."""
|
||||
session_id = getattr(self, "_session_id", "") or ""
|
||||
@@ -3773,7 +3773,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
logger.info("Lean tail: demoted %d stale tool result(s)", demoted)
|
||||
return result
|
||||
|
||||
def _augment_summary_lean(self, summary: str, turns_to_summarize: List[Dict[str, Any]]) -> str:
|
||||
def _augment_summary_lean(self, summary: str, turns_to_summarize: list[dict[str, Any]]) -> str:
|
||||
"""Append deterministic lean-mode sections to a summary; no-op in legacy mode."""
|
||||
if getattr(self, "tail_mode", "lean") != "lean":
|
||||
return summary
|
||||
@@ -3821,7 +3821,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
head_len = (limit - marker_reserve) // 2
|
||||
return elide_middle(record, head_len, limit - marker_reserve - head_len)
|
||||
|
||||
def _record_summary_input_coverage(self, coverage: Dict[str, int]) -> None:
|
||||
def _record_summary_input_coverage(self, coverage: dict[str, int]) -> None:
|
||||
"""Expose lean sampling coverage without including transcript content in telemetry."""
|
||||
telemetry = getattr(self, "_active_compression_telemetry", None)
|
||||
if not isinstance(telemetry, dict):
|
||||
@@ -3836,7 +3836,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _sample_summary_records(cls, records: Sequence[str]) -> Tuple[str, Dict[str, int]]:
|
||||
def _sample_summary_records(cls, records: Sequence[str]) -> tuple[str, dict[str, int]]:
|
||||
"""Sample complete serialized records while retaining the character bound.
|
||||
|
||||
Returns the bounded transcript and record-level coverage counters for compression
|
||||
@@ -3847,7 +3847,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
"""
|
||||
input_chars = sum(len(r) for r in records)
|
||||
|
||||
def _coverage(sampled_chars: int, sampled_record_count: int) -> Dict[str, int]:
|
||||
def _coverage(sampled_chars: int, sampled_record_count: int) -> dict[str, int]:
|
||||
return {
|
||||
"input_chars": input_chars, "sampled_chars": sampled_chars,
|
||||
"omitted_chars": input_chars - sampled_chars, "record_count": len(records),
|
||||
@@ -3987,8 +3987,8 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
Raises RuntimeError for empty content or a length-truncated (PARTIAL) summary so the failure
|
||||
routes through main-model fallback + cooldown instead of wiping the compacted turns."""
|
||||
# call_llm writes the route it actually selected; never pre-resolve a second, stale pair.
|
||||
_aux_route: Dict[str, str] = {}
|
||||
call_kwargs: Dict[str, Any] = {
|
||||
_aux_route: dict[str, str] = {}
|
||||
call_kwargs: dict[str, Any] = {
|
||||
"task": "compression",
|
||||
"main_runtime": {
|
||||
"model": self.model, "provider": self.provider, "base_url": self.base_url, "api_key": self.api_key,
|
||||
@@ -4005,7 +4005,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
# static marker, losing the real handoff (#23975). Re-entrant: a main-model retry (_generate_summary
|
||||
# recursion) re-enters harmlessly.
|
||||
_aux_call_start = time.monotonic()
|
||||
_latency_info: Dict[str, int] = {"prompt_build_ms": max(0, int((_aux_call_start - prompt_started_at) * 1000))}
|
||||
_latency_info: dict[str, int] = {"prompt_build_ms": max(0, int((_aux_call_start - prompt_started_at) * 1000))}
|
||||
call_kwargs["latency_info"] = _latency_info
|
||||
# Per-attempt observable (#114594): with this line a stalled attempt is distinguishable from a slow
|
||||
# one — silence before it is prompt build, silence after it is the summary provider.
|
||||
@@ -4070,7 +4070,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
return content
|
||||
|
||||
def _generate_summary(
|
||||
self, turns_to_summarize: List[Dict[str, Any]], focus_topic: Optional[str] = None,
|
||||
self, turns_to_summarize: list[dict[str, Any]], focus_topic: Optional[str] = None,
|
||||
memory_context: str = "", bypass_cooldown: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Structured summary of the turns (iterative update when a previous summary exists); None if all attempts fail."""
|
||||
@@ -4213,7 +4213,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _summary_template_sections(cls, _section: Dict[str, str], summary_budget: int, _session_log_section: str) -> str:
|
||||
def _summary_template_sections(cls, _section: dict[str, str], summary_budget: int, _session_log_section: str) -> str:
|
||||
"""The ``## ...`` section template shared by the fresh and iterative-update prompts."""
|
||||
_temporal_anchoring_rule = cls._temporal_anchoring_rule()
|
||||
return f"""{HISTORICAL_TASK_HEADING}
|
||||
@@ -4273,7 +4273,7 @@ Target ~{summary_budget + (_LEAN_SESSION_LOG_BUDGET_TOKENS if _session_log_secti
|
||||
Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
def _on_summary_failure(
|
||||
self, e: Exception, turns_to_summarize: List[Dict[str, Any]], focus_topic: Optional[str], memory_context: str,
|
||||
self, e: Exception, turns_to_summarize: list[dict[str, Any]], focus_topic: Optional[str], memory_context: str,
|
||||
) -> Optional[str]:
|
||||
"""Classify a summary-call failure; retry once on the main model (returning its result) or arm a cooldown (None)."""
|
||||
# A detached stale attempt must not arm a failure cooldown or stamp error state the fallback
|
||||
@@ -4419,7 +4419,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return isinstance(message, dict) and bool(message.get(COMPRESSED_SUMMARY_METADATA_KEY))
|
||||
|
||||
@classmethod
|
||||
def _transcript_has_real_user_turn(cls, messages: List[Dict[str, Any]]) -> bool:
|
||||
def _transcript_has_real_user_turn(cls, messages: list[dict[str, Any]]) -> bool:
|
||||
"""Return whether *messages* contain a user-authored (not synthetic summary) turn."""
|
||||
return any(
|
||||
isinstance(m, dict) and m.get("role") == "user" and not cls._is_synthetic_compression_user_turn(m)
|
||||
@@ -4508,7 +4508,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return not cls._is_blank_user_turn(message)
|
||||
|
||||
@classmethod
|
||||
def _blank_echo_indices_after(cls, messages: List[Dict[str, Any]], user_idx: int) -> set[int]:
|
||||
def _blank_echo_indices_after(cls, messages: list[dict[str, Any]], user_idx: int) -> set[int]:
|
||||
"""Return contiguous blank echoes after a user event; removable only if an assistant follows."""
|
||||
if user_idx < 0:
|
||||
return set()
|
||||
@@ -4520,7 +4520,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return set(range(user_idx + 1, idx))
|
||||
|
||||
@classmethod
|
||||
def _derive_auto_focus_topic(cls, messages: List[Dict[str, Any]]) -> Optional[str]:
|
||||
def _derive_auto_focus_topic(cls, messages: list[dict[str, Any]]) -> Optional[str]:
|
||||
"""Infer a compact focus hint from the most recent real user turns."""
|
||||
candidates: list[str] = []
|
||||
for msg in reversed(messages):
|
||||
@@ -4545,7 +4545,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return focus
|
||||
|
||||
@classmethod
|
||||
def _latest_user_task_snapshot(cls, messages: List[Dict[str, Any]]) -> Optional[str]:
|
||||
def _latest_user_task_snapshot(cls, messages: list[dict[str, Any]]) -> Optional[str]:
|
||||
"""Return a deterministic task-snapshot line from the newest real user turn.
|
||||
The summarizer must not invent the active-task anchor from a prompt example or a stale prior
|
||||
summary; this grounds it in the exact compacted turns."""
|
||||
@@ -4573,7 +4573,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _ground_historical_task_snapshot(cls, summary: str, messages: List[Dict[str, Any]]) -> str:
|
||||
def _ground_historical_task_snapshot(cls, summary: str, messages: list[dict[str, Any]]) -> str:
|
||||
"""Force the task snapshot section to match a real user turn when possible."""
|
||||
snapshot = cls._latest_user_task_snapshot(messages)
|
||||
if not snapshot:
|
||||
@@ -4599,7 +4599,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return f"{replacement}{body}".strip()
|
||||
|
||||
@classmethod
|
||||
def _find_context_summaries(cls, messages: List[Dict[str, Any]], start: int, end: int) -> list[tuple[int, str]]:
|
||||
def _find_context_summaries(cls, messages: list[dict[str, Any]], start: int, end: int) -> list[tuple[int, str]]:
|
||||
"""Find handoff summaries inside a compression window."""
|
||||
n = len(messages)
|
||||
# Clamp: callers may pass end = len(messages)+1.
|
||||
@@ -4614,14 +4614,14 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
@classmethod
|
||||
def _find_latest_context_summary(
|
||||
cls, messages: List[Dict[str, Any]], start: int, end: int,
|
||||
cls, messages: list[dict[str, Any]], start: int, end: int,
|
||||
) -> tuple[Optional[int], str]:
|
||||
"""Find the newest handoff summary inside a compression window."""
|
||||
summaries = cls._find_context_summaries(messages, start, end)
|
||||
return summaries[-1] if summaries else (None, "")
|
||||
|
||||
@classmethod
|
||||
def _strip_context_summary_handoff_message(cls, message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def _strip_context_summary_handoff_message(cls, message: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Drop stale handoff data while preserving merged prior-tail content.
|
||||
Returns a copy for non-handoff rows, the unwrapped prior-tail content for merged handoffs
|
||||
(delimiter form, or legacy end-marker form), and ``None`` for standalone ones."""
|
||||
@@ -4631,7 +4631,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return message.copy()
|
||||
content = message.get("content")
|
||||
|
||||
def _unwrapped(new_content: Any) -> Dict[str, Any]:
|
||||
def _unwrapped(new_content: Any) -> dict[str, Any]:
|
||||
unwrapped = {**message, "content": new_content}
|
||||
unwrapped.pop(COMPRESSED_SUMMARY_METADATA_KEY, None)
|
||||
return unwrapped
|
||||
@@ -4698,7 +4698,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
from agent.message_sanitization import tool_call_id_variants
|
||||
return set(tool_call_id_variants(tc))
|
||||
|
||||
def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _sanitize_tool_pairs(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Remove orphaned tool results and strip orphaned tool_calls after compression.
|
||||
Stubs would be dropped by repair_message_sequence when call_id != id."""
|
||||
from agent.agent_runtime_helpers import _classify_tool_call_orphans
|
||||
@@ -4738,20 +4738,20 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
logger.info("Compression sanitizer: stripped %d orphaned tool_call(s) from assistant messages", stripped_count)
|
||||
return messages
|
||||
|
||||
def _align_boundary_forward(self, messages: List[Dict[str, Any]], idx: int) -> int:
|
||||
def _align_boundary_forward(self, messages: list[dict[str, Any]], idx: int) -> int:
|
||||
"""Push a compress-start boundary forward past any orphan tool results."""
|
||||
while idx < len(messages) and messages[idx].get("role") == "tool":
|
||||
idx += 1
|
||||
return idx
|
||||
|
||||
def _restart_handoff_probe_bounds(self, messages: List[Dict[str, Any]]) -> tuple[int, int]:
|
||||
def _restart_handoff_probe_bounds(self, messages: list[dict[str, Any]]) -> tuple[int, int]:
|
||||
"""Return the bounded transcript region that can indicate restart decay."""
|
||||
if not messages or self.protect_first_n <= 0:
|
||||
return 0, 0
|
||||
first_non_system = 1 if messages[0].get("role") == "system" else 0
|
||||
return first_non_system, min(len(messages), first_non_system + self.protect_first_n + _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES)
|
||||
|
||||
def _effective_protect_first_n(self, messages: Optional[List[Dict[str, Any]]] = None) -> int:
|
||||
def _effective_protect_first_n(self, messages: Optional[list[dict[str, Any]]] = None) -> int:
|
||||
"""``protect_first_n``, decayed to 0 once the session has been compressed so early turns don't fossilize.
|
||||
After a restart the decayed state is inferred from handoff summaries in the resumed head."""
|
||||
if self.compression_count >= 1 or self._previous_summary:
|
||||
@@ -4763,7 +4763,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return 0
|
||||
return self.protect_first_n
|
||||
|
||||
def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int:
|
||||
def _protect_head_size(self, messages: list[dict[str, Any]]) -> int:
|
||||
"""Head messages to protect: the system prompt (if present) plus the decaying ``protect_first_n`` extra rows.
|
||||
|
||||
The ``protect_first_n`` portion DECAYS after the first compression (see _effective_protect_first_n)
|
||||
@@ -4772,7 +4772,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
head = 1 if messages and messages[0].get("role") == "system" else 0
|
||||
return head + self._effective_protect_first_n(messages)
|
||||
|
||||
def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) -> int:
|
||||
def _align_boundary_backward(self, messages: list[dict[str, Any]], idx: int) -> int:
|
||||
"""Pull a compress-end boundary back so a tool group is not split (orphaned tail results would be dropped)."""
|
||||
if idx <= 0 or idx >= len(messages):
|
||||
return idx
|
||||
@@ -4783,7 +4783,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return idx
|
||||
|
||||
@classmethod
|
||||
def _is_real_user_turn(cls, message: Dict[str, Any]) -> bool:
|
||||
def _is_real_user_turn(cls, message: dict[str, Any]) -> bool:
|
||||
"""Actionable user turn that is not synthetic scaffolding — the row test both index scans share.
|
||||
|
||||
Weaker than ``agent.conversation_compression._is_real_user_message``, which also rejects
|
||||
@@ -4793,14 +4793,14 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return cls._is_actionable_user_turn(message) and not cls._is_synthetic_compression_user_turn(message)
|
||||
|
||||
@classmethod
|
||||
def _real_user_indices_desc(cls, messages: List[Dict[str, Any]], head_end: int) -> list[int]:
|
||||
def _real_user_indices_desc(cls, messages: list[dict[str, Any]], head_end: int) -> list[int]:
|
||||
"""Newest-first indices of actionable, non-synthetic user turns at or after *head_end* (no handoffs/blank echoes)."""
|
||||
return [
|
||||
i for i in range(len(messages) - 1, head_end - 1, -1)
|
||||
if cls._is_real_user_turn(messages[i])
|
||||
]
|
||||
|
||||
def _find_last_user_message_idx(self, messages: List[Dict[str, Any]], head_end: int) -> int:
|
||||
def _find_last_user_message_idx(self, messages: list[dict[str, Any]], head_end: int) -> int:
|
||||
"""Return the latest actionable user turn at or after *head_end*, or -1."""
|
||||
# Early-exit generator: callers want the newest hit only, and collecting every index
|
||||
# (``_real_user_indices_desc``) costs a full backward scan per call.
|
||||
@@ -4809,7 +4809,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
-1,
|
||||
)
|
||||
|
||||
def _find_last_assistant_message_idx(self, messages: List[Dict[str, Any]], head_end: int) -> int:
|
||||
def _find_last_assistant_message_idx(self, messages: list[dict[str, Any]], head_end: int) -> int:
|
||||
"""Last text-bearing non-summary assistant reply at/after *head_end* (else last non-summary assistant), or -1."""
|
||||
last_any = -1
|
||||
for i in range(len(messages) - 1, head_end - 1, -1):
|
||||
@@ -4828,7 +4828,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return last_any
|
||||
|
||||
def _ensure_last_assistant_message_in_tail(
|
||||
self, messages: List[Dict[str, Any]], cut_idx: int, head_end: int,
|
||||
self, messages: list[dict[str, Any]], cut_idx: int, head_end: int,
|
||||
) -> int:
|
||||
"""Keep the most recent assistant reply in the protected tail, re-aligned back so a tool group is not split."""
|
||||
last_asst_idx = self._find_last_assistant_message_idx(messages, head_end)
|
||||
@@ -4843,7 +4843,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
)
|
||||
return max(new_cut, head_end + 1)
|
||||
|
||||
def _ensure_last_user_message_in_tail(self, messages: List[Dict[str, Any]], cut_idx: int, head_end: int) -> int:
|
||||
def _ensure_last_user_message_in_tail(self, messages: list[dict[str, Any]], cut_idx: int, head_end: int) -> int:
|
||||
"""Guarantee the most recent user message is in the protected tail.
|
||||
Tool-group alignment can pull the cut past the last user message; once summarized, the prefix
|
||||
tells the model to answer only messages AFTER the summary, so the active ask silently vanishes.
|
||||
@@ -4895,8 +4895,8 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
@classmethod
|
||||
def _find_inflight_user_task(
|
||||
cls, messages: List[Dict[str, Any]]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
cls, messages: list[dict[str, Any]]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Return the user turn that is still awaiting completion, or ``None``.
|
||||
|
||||
Scans the WHOLE transcript, not just the compressible region: a cron
|
||||
@@ -4959,9 +4959,9 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
def _reappend_inflight_user_task(
|
||||
self,
|
||||
compressed: List[Dict[str, Any]],
|
||||
inflight: Optional[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
compressed: list[dict[str, Any]],
|
||||
inflight: Optional[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Restate an unfinished user task after the compaction handoff.
|
||||
|
||||
``SUMMARY_PREFIX`` instructs the model to act only on a user message
|
||||
@@ -5102,7 +5102,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return compressed
|
||||
|
||||
def _ensure_last_n_user_messages_in_tail(
|
||||
self, messages: List[Dict[str, Any]], cut_idx: int, head_end: int, n: int,
|
||||
self, messages: list[dict[str, Any]], cut_idx: int, head_end: int, n: int,
|
||||
) -> int:
|
||||
"""Keep the last N actionable user messages in the tail; n <= 1 delegates to the single-message method.
|
||||
|
||||
@@ -5124,7 +5124,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return cut_idx
|
||||
return max(user_indices[min(n, len(user_indices)) - 1], head_end + 1)
|
||||
|
||||
def _find_turn_pair_end(self, messages: List[Dict[str, Any]], user_idx: int) -> int:
|
||||
def _find_turn_pair_end(self, messages: list[dict[str, Any]], user_idx: int) -> int:
|
||||
"""Index after the turn-pair (user -> assistant -> tools) at *user_idx*; ``user_idx + 1`` when no reply yet."""
|
||||
idx = user_idx + 1
|
||||
if idx >= len(messages) or messages[idx].get("role") != "assistant":
|
||||
@@ -5156,7 +5156,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
rejected = session_rejected_thinking(self, getattr(self, "_session_db", None), getattr(self, "_session_id", ""))
|
||||
|
||||
def budget(msg: Dict[str, Any]) -> int:
|
||||
def budget(msg: dict[str, Any]) -> int:
|
||||
request_copy = dict(msg)
|
||||
if rejected:
|
||||
strip_rejected_thinking(request_copy, rejected)
|
||||
@@ -5166,7 +5166,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return budget
|
||||
|
||||
def _find_tail_cut_by_tokens(
|
||||
self, messages: List[Dict[str, Any]], head_end: int, token_budget: int | None = None,
|
||||
self, messages: list[dict[str, Any]], head_end: int, token_budget: int | None = None,
|
||||
*, allow_split_turn: bool = True,
|
||||
) -> int:
|
||||
"""Walk backward accumulating tokens until the budget; return the tail start index.
|
||||
@@ -5289,15 +5289,15 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
# can't split a tool group (backward would give the floor's message back).
|
||||
return min(n, self._align_boundary_forward(messages, max(cut_idx, head_end + 1)))
|
||||
|
||||
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
def has_content_to_compress(self, messages: list[dict[str, Any]]) -> bool:
|
||||
"""True if a non-empty middle region exists (lets the gateway ``/compress`` guard skip the LLM call)."""
|
||||
compress_start = self._align_boundary_forward(messages, self._protect_head_size(messages))
|
||||
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
|
||||
return compress_start < compress_end
|
||||
|
||||
def _scan_window_handoffs(
|
||||
self, messages: List[Dict[str, Any]], compress_start: int, compress_end: int,
|
||||
turns_to_summarize: List[Dict[str, Any]],
|
||||
self, messages: list[dict[str, Any]], compress_start: int, compress_end: int,
|
||||
turns_to_summarize: list[dict[str, Any]],
|
||||
) -> "_HandoffScan":
|
||||
"""Rehydrate ``_previous_summary`` / user-turn provenance from in-transcript handoffs.
|
||||
Handoff rows are removed from the summarizer window (merged handoffs unwrap to their prior-tail
|
||||
@@ -5339,7 +5339,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
# Summary rows are excluded from summarizer input, but a merged handoff carries genuine
|
||||
# prior-tail user content — unwrap it into the window (#47274); standalone ones drop (None).
|
||||
# The newest hit (summary_idx) may itself be a merged handoff — recover its prior tail too.
|
||||
def _window_row(idx: int, msg: Dict[str, Any]):
|
||||
def _window_row(idx: int, msg: dict[str, Any]):
|
||||
if idx not in scan.summary_indices:
|
||||
return msg
|
||||
return self._strip_context_summary_handoff_message(_fresh_compaction_message_copy(msg))
|
||||
@@ -5351,7 +5351,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
scan.tail_start = summary_idx + 1
|
||||
return scan
|
||||
|
||||
def _begin_compress_attempt(self, current_tokens: Optional[int], force: bool) -> Dict[str, Any]:
|
||||
def _begin_compress_attempt(self, current_tokens: Optional[int], force: bool) -> dict[str, Any]:
|
||||
"""Reset per-call result state (callers read it after compress()) and open telemetry."""
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
@@ -5373,18 +5373,18 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
self._structural_no_op_backoff_until = 0.0
|
||||
return telemetry
|
||||
|
||||
def _structural_no_op_result(self, telemetry: Dict[str, Any], failure_class: str, reason: str) -> None:
|
||||
def _structural_no_op_result(self, telemetry: dict[str, Any], failure_class: str, reason: str) -> None:
|
||||
"""Nothing eligible to compress: transient backoff (#93022), never an ineffectiveness strike."""
|
||||
telemetry["failure_class"] = failure_class
|
||||
self._last_compression_savings_pct = 0.0
|
||||
self._record_structural_no_op(reason)
|
||||
|
||||
def _drop_blank_echoes(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _drop_blank_echoes(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Remove blank platform echoes trailing the latest actionable user turn."""
|
||||
blank = self._blank_echo_indices_after(messages, self._find_last_user_message_idx(messages, 0))
|
||||
return [m for idx, m in enumerate(messages) if idx not in blank] if blank else messages
|
||||
|
||||
def _compress_window(self, messages: List[Dict[str, Any]]) -> tuple[int, int]:
|
||||
def _compress_window(self, messages: list[dict[str, Any]]) -> tuple[int, int]:
|
||||
"""Return ``(compress_start, compress_end)`` for the summarizable middle."""
|
||||
compress_start = self._align_boundary_forward(messages, self._protect_head_size(messages))
|
||||
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
|
||||
@@ -5418,7 +5418,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
)
|
||||
|
||||
def _abort_on_summary_failure(
|
||||
self, telemetry: Dict[str, Any], n_skipped: int, previous_summary_before_scan: Optional[str],
|
||||
self, telemetry: dict[str, Any], n_skipped: int, previous_summary_before_scan: Optional[str],
|
||||
) -> bool:
|
||||
"""Abort (messages unchanged) on a terminal failure or when configured to; True when aborted.
|
||||
Access/quota, network, truncated and empty-content failures ALWAYS abort (#29559); an overload
|
||||
@@ -5452,7 +5452,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
_COMPRESSION_NOTE = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work. Your persistent memory (MEMORY.md, USER.md) remains fully authoritative regardless of compaction.]"
|
||||
|
||||
def _assemble_head(self, messages: List[Dict[str, Any]], compress_start: int) -> List[Dict[str, Any]]:
|
||||
def _assemble_head(self, messages: list[dict[str, Any]], compress_start: int) -> list[dict[str, Any]]:
|
||||
"""Protected head with the compaction note on the system prompt and stale handoffs stripped."""
|
||||
compressed = []
|
||||
for i in range(compress_start):
|
||||
@@ -5470,7 +5470,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return compressed
|
||||
|
||||
def _fallback_summary_for_window(
|
||||
self, telemetry: Dict[str, Any], turns_to_summarize: List[Dict[str, Any]],
|
||||
self, telemetry: dict[str, Any], turns_to_summarize: list[dict[str, Any]],
|
||||
n_dropped: int, feasibility_skip: bool,
|
||||
) -> str:
|
||||
"""Deterministic fallback so the model gets recoverable continuity anchors."""
|
||||
@@ -5498,10 +5498,10 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return summary
|
||||
|
||||
def _assemble_tail(
|
||||
self, messages: List[Dict[str, Any]], compress_end: int, tail_start: int, summary_indices: set,
|
||||
) -> List[Dict[str, Any]]:
|
||||
self, messages: list[dict[str, Any]], compress_end: int, tail_start: int, summary_indices: set,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Protected tail with already-folded handoff rows dropped and merged handoffs unwrapped."""
|
||||
tail_messages: List[Dict[str, Any]] = []
|
||||
tail_messages: list[dict[str, Any]] = []
|
||||
# Start at tail_start, not compress_end: the rehydration scan may have advanced it (#57835).
|
||||
for i in range(max(compress_end, tail_start), len(messages)):
|
||||
if i in summary_indices and i >= tail_start:
|
||||
@@ -5513,7 +5513,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
@staticmethod
|
||||
def _summary_placement(
|
||||
compressed: List[Dict[str, Any]], tail_messages: List[Dict[str, Any]], compress_start: int,
|
||||
compressed: list[dict[str, Any]], tail_messages: list[dict[str, Any]], compress_start: int,
|
||||
) -> tuple[str, bool, bool, Optional[int]]:
|
||||
"""Pick the summary row's role so template-visible alternation holds.
|
||||
Returns ``(summary_role, merge_into_tail, force_user_leading, first_tail_visible_idx)``. Roles
|
||||
@@ -5548,7 +5548,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return summary_role, merge_into_tail, force_user_leading, first_tail_visible_idx
|
||||
|
||||
def _merge_summary_into_tail_row(
|
||||
self, msg: Dict[str, Any], summary: str, summary_role: str, force_user_leading: bool,
|
||||
self, msg: dict[str, Any], summary: str, summary_role: str, force_user_leading: bool,
|
||||
) -> None:
|
||||
"""Fold the summary into a carried tail row (in place) when no standalone role alternates."""
|
||||
old_content = msg.get("content", "")
|
||||
@@ -5569,9 +5569,9 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
drop_stale_api_content(msg)
|
||||
|
||||
def _finalize_compressed(
|
||||
self, compressed: List[Dict[str, Any]], messages: List[Dict[str, Any]], n_messages: int,
|
||||
self, compressed: list[dict[str, Any]], messages: list[dict[str, Any]], n_messages: int,
|
||||
spare_pending_images: bool,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Post-assembly cleanup: orphan pairs, media, savings, markers, replay prune, mem trim."""
|
||||
# Single-prompt cron shape: the only live instruction sits in the protected head, BEFORE the
|
||||
# handoff, and SUMMARY_PREFIX reads that as "nothing to do" — restate it past the boundary
|
||||
@@ -5628,9 +5628,9 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return compressed
|
||||
|
||||
def compress(
|
||||
self, messages: List[Dict[str, Any]], current_tokens: Optional[int] = None, focus_topic: Optional[str] = None,
|
||||
self, messages: list[dict[str, Any]], current_tokens: Optional[int] = None, focus_topic: Optional[str] = None,
|
||||
force: bool = False, memory_context: str = "", bypass_cooldown: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Summarize the middle turns: prune tool-result bodies (tool-call args untouched), protect head and a
|
||||
token-budget tail from the pruned copy, summarize, then clean orphaned tool pairs. ``force`` clears the failure cooldown and bypasses
|
||||
the feasibility skip; ``bypass_cooldown`` runs the summary LLM without clearing the cooldown.
|
||||
@@ -5736,8 +5736,8 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return self._finalize_compressed(compressed, canonical_messages, n_messages, spare_pending_images)
|
||||
|
||||
def _assemble_compressed(
|
||||
self, messages: List[Dict[str, Any]], compress_start: int, compress_end: int, scan: "_HandoffScan", summary: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
self, messages: list[dict[str, Any]], compress_start: int, compress_end: int, scan: "_HandoffScan", summary: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Head + summary + tail from the pruned copy: its tool-result demotions are what let an oversized
|
||||
head/tail compress at all (#61932); tool-call arguments are never rewritten by pruning."""
|
||||
compressed = self._assemble_head(messages, compress_start)
|
||||
@@ -5814,13 +5814,13 @@ def _handoff_only_content(content: Any) -> Any:
|
||||
return projected
|
||||
|
||||
|
||||
def split_user_originated_turn(message: Any) -> tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
def split_user_originated_turn(message: Any) -> tuple[Optional[dict[str, Any]], Optional[dict[str, Any]]]:
|
||||
"""Split a user row into ``(handoff_only, live_view)``; either may be None; fresh dicts."""
|
||||
if not isinstance(message, dict) or message.get("role") != "user":
|
||||
return None, None
|
||||
|
||||
is_summary = is_compaction_summary_message(message)
|
||||
handoff: Optional[Dict[str, Any]] = None
|
||||
handoff: Optional[dict[str, Any]] = None
|
||||
if is_summary:
|
||||
handoff = {
|
||||
"role": "user", "content": _handoff_only_content(message.get("content")),
|
||||
@@ -5863,14 +5863,14 @@ def split_user_originated_turn(message: Any) -> tuple[Optional[Dict[str, Any]],
|
||||
return handoff, candidate
|
||||
|
||||
|
||||
def user_originated_turn_view(message: Any) -> Optional[Dict[str, Any]]:
|
||||
def user_originated_turn_view(message: Any) -> Optional[dict[str, Any]]:
|
||||
"""Return the live human-authored projection of a user row, if any."""
|
||||
return split_user_originated_turn(message)[1]
|
||||
|
||||
|
||||
def history_before_user_originated_turn(
|
||||
messages: List[Dict[str, Any]], index: int,
|
||||
) -> tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||
messages: list[dict[str, Any]], index: int,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
"""Rewind prefix and canonical live view for ``index``; a composite carrier keeps its handoff scaffold at the head."""
|
||||
if index < 0 or index >= len(messages):
|
||||
raise IndexError("user turn index is outside the transcript")
|
||||
@@ -5910,7 +5910,7 @@ def _handoff_carries_live_user_content(message: Any) -> bool:
|
||||
return isinstance(message, dict) and ContextCompressor._strip_context_summary_handoff_message(message) is not None
|
||||
|
||||
|
||||
def reference_handoff_would_drive_next_model_call(messages: Optional[List[Dict[str, Any]]]) -> bool:
|
||||
def reference_handoff_would_drive_next_model_call(messages: Optional[list[dict[str, Any]]]) -> bool:
|
||||
"""True when the next model call would be driven only by a handoff; trailing tool rows mean an in-flight exchange."""
|
||||
if not messages:
|
||||
return False
|
||||
|
||||
@@ -36,7 +36,7 @@ class PreLlmSkipMixin:
|
||||
float, 0.0, _FALLBACK_PROBE_AT_MODEL_CONFIG_KEY, 0.0,
|
||||
)
|
||||
|
||||
def _fallback_streak_skip(self, telemetry: Dict[str, Any]) -> bool:
|
||||
def _fallback_streak_skip(self, telemetry: dict[str, Any]) -> bool:
|
||||
"""Pre-LLM skip while two summaries in a row fell back: compact deterministically instead of
|
||||
paying for a summary model that keeps failing (#63008). One probe per recovery window; a
|
||||
healthy summary resets the streak, another fallback benches it again."""
|
||||
@@ -66,7 +66,7 @@ class PreLlmSkipMixin:
|
||||
return True
|
||||
|
||||
def _feasibility_skip(
|
||||
self, telemetry: Dict[str, Any], turns_to_summarize: List[Dict[str, Any]],
|
||||
self, telemetry: dict[str, Any], turns_to_summarize: list[dict[str, Any]],
|
||||
compress_start: int, compress_end: int,
|
||||
) -> bool:
|
||||
"""Pre-LLM skip after a real-usage ineffectiveness strike (reads the counter, never writes)."""
|
||||
|
||||
@@ -27,7 +27,7 @@ def _accepts_keyword_argument(callable_obj: Any, name: str) -> bool:
|
||||
|
||||
|
||||
class SummaryDispatchMixin:
|
||||
def _apply_summary_route(self, call_kwargs: dict, pinned: Optional[Dict[str, Any]] = None) -> None:
|
||||
def _apply_summary_route(self, call_kwargs: dict, pinned: Optional[dict[str, Any]] = None) -> None:
|
||||
"""Pin the summary route onto ``call_kwargs`` for both compression entry points.
|
||||
|
||||
After a fallback, an omitted route is NOT "use the main model": ``call_llm`` re-resolves
|
||||
@@ -58,7 +58,7 @@ class SummaryDispatchMixin:
|
||||
call_kwargs[key] = value
|
||||
|
||||
def _summarize_window(
|
||||
self, messages: List[Dict[str, Any]], turns_to_summarize: List[Dict[str, Any]], scan: "_HandoffScan",
|
||||
self, messages: list[dict[str, Any]], turns_to_summarize: list[dict[str, Any]], scan: "_HandoffScan",
|
||||
focus_topic: Optional[str], memory_context: str, bypass_cooldown: bool,
|
||||
) -> Optional[str]:
|
||||
"""Run the summary LLM; a cancellation rolls back the handoff scan's self-heal mutation first.
|
||||
@@ -80,7 +80,7 @@ class SummaryDispatchMixin:
|
||||
telemetry["failure_class"] = "stall_deterministic_fallback"
|
||||
return None
|
||||
# Focus-topic derivation scans user turns; only pay when a summary is generated.
|
||||
summary_kwargs: Dict[str, Any] = {
|
||||
summary_kwargs: dict[str, Any] = {
|
||||
"focus_topic": focus_topic or self._derive_auto_focus_topic(messages),
|
||||
"memory_context": memory_context,
|
||||
}
|
||||
|
||||
+16
-16
@@ -73,7 +73,7 @@ class ContextEngine(ABC):
|
||||
emit_automatic_compaction_status: bool = True
|
||||
|
||||
@abstractmethod
|
||||
def update_from_response(self, usage: Dict[str, Any]) -> None:
|
||||
def update_from_response(self, usage: dict[str, Any]) -> None:
|
||||
"""Update tracked token usage after every LLM call.
|
||||
|
||||
``prompt_tokens``/``completion_tokens``/``total_tokens`` are always present; the
|
||||
@@ -96,9 +96,9 @@ class ContextEngine(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def compress(
|
||||
self, messages: List[Dict[str, Any]], current_tokens: Optional[int] = None,
|
||||
self, messages: list[dict[str, Any]], current_tokens: Optional[int] = None,
|
||||
focus_topic: Optional[str] = None, force: bool = False, memory_context: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Compact ``messages`` into a valid OpenAI-format list that fits the budget.
|
||||
|
||||
``focus_topic`` comes from manual ``/compress <focus>`` (prioritise that topic);
|
||||
@@ -108,8 +108,8 @@ class ContextEngine(ABC):
|
||||
"""
|
||||
|
||||
def prune_tool_results_only(
|
||||
self, messages: List[Dict[str, Any]], current_tokens: int | None = None,
|
||||
) -> tuple[List[Dict[str, Any]], int]:
|
||||
self, messages: list[dict[str, Any]], current_tokens: int | None = None,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Deterministically trim old tool-result payloads without an LLM call.
|
||||
|
||||
Runs on a low, cost-oriented trigger independent of ``should_compress`` so
|
||||
@@ -119,9 +119,9 @@ class ContextEngine(ABC):
|
||||
return messages, 0
|
||||
|
||||
def select_context(
|
||||
self, request_messages: List[Dict[str, Any]], *, conversation_messages: List[Dict[str, Any]] = None,
|
||||
incoming_message: Dict[str, Any] = None, budget_tokens: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
self, request_messages: list[dict[str, Any]], *, conversation_messages: list[dict[str, Any]] = None,
|
||||
incoming_message: dict[str, Any] = None, budget_tokens: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Optionally *select* (replace) the context for THIS request, pre-generation.
|
||||
|
||||
Runs on every provider request (also retries), independent of
|
||||
@@ -140,7 +140,7 @@ class ContextEngine(ABC):
|
||||
"""
|
||||
return None
|
||||
|
||||
def on_turn_complete(self, messages: List[Dict[str, Any]], usage: Dict[str, Any] = None, **kwargs: Any) -> None:
|
||||
def on_turn_complete(self, messages: list[dict[str, Any]], usage: dict[str, Any] = None, **kwargs: Any) -> None:
|
||||
"""Observe a finished turn (complement of ``select_context()``) to index/update
|
||||
routing state for the next request.
|
||||
|
||||
@@ -151,9 +151,9 @@ class ContextEngine(ABC):
|
||||
``None`` when no provider response was reached (interrupt). ``kwargs`` may include
|
||||
``turn_id``, ``task_id``, ``api_call_count``, ``interrupted``, ``failed``, ``turn_exit_reason``.
|
||||
"""
|
||||
return None
|
||||
return
|
||||
|
||||
def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
def should_compress_preflight(self, messages: list[dict[str, Any]]) -> bool:
|
||||
"""Cheap rough check before the API call (no real token count yet); default skips."""
|
||||
return False
|
||||
|
||||
@@ -173,7 +173,7 @@ class ContextEngine(ABC):
|
||||
"""
|
||||
return default_message if self.emit_automatic_compaction_status else None
|
||||
|
||||
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
def has_content_to_compress(self, messages: list[dict[str, Any]]) -> bool:
|
||||
"""Preflight guard for gateway ``/compress``: False reports "nothing to
|
||||
compress yet" without an LLM call (e.g. transcript entirely protected)."""
|
||||
return True
|
||||
@@ -181,7 +181,7 @@ class ContextEngine(ABC):
|
||||
def on_session_start(self, session_id: str, **kwargs) -> None:
|
||||
"""Session begins: load persisted state. kwargs may include hermes_home, platform, model."""
|
||||
|
||||
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
|
||||
def on_session_end(self, session_id: str, messages: list[dict[str, Any]]) -> None:
|
||||
"""Real session boundary (CLI exit, /reset, gateway expiry) — never per-turn."""
|
||||
|
||||
def on_session_reset(self) -> None:
|
||||
@@ -201,16 +201,16 @@ class ContextEngine(ABC):
|
||||
self.last_total_tokens = 0
|
||||
self.compression_count = 0
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
||||
"""Tool schemas this engine exposes to the agent (default: none)."""
|
||||
return []
|
||||
|
||||
def handle_tool_call(self, name: str, args: Dict[str, Any], **kwargs) -> str:
|
||||
def handle_tool_call(self, name: str, args: dict[str, Any], **kwargs) -> str:
|
||||
"""Handle a call to one of this engine's tools; must return a JSON string.
|
||||
kwargs may include ``messages`` (live in-memory list)."""
|
||||
return json.dumps({"error": f"Unknown context engine tool: {name}"})
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
"""Status dict with the standard fields run_agent.py expects."""
|
||||
# Clamp the -1 "compression just ran, awaiting real usage" sentinel to 0 so no
|
||||
# reader sees a negative usage_percent on the transitional turn.
|
||||
|
||||
@@ -34,7 +34,7 @@ _STATUS_DISPLAY = {
|
||||
}
|
||||
|
||||
|
||||
def _entry(label: str, path: Path, content: str, status: str) -> Dict[str, Any]:
|
||||
def _entry(label: str, path: Path, content: str, status: str) -> dict[str, Any]:
|
||||
return {
|
||||
"label": label, "path": str(path), "chars": len(content), "est_tokens": estimate_tokens_rough(content),
|
||||
"loaded": status in ("loaded", "truncated", "flagged"), "status": status,
|
||||
@@ -60,7 +60,7 @@ def _loaded_status(content: str, rendered_len: int, max_chars: int, user_authore
|
||||
def list_context_file_sources(
|
||||
cwd: Optional[str] = None, context_length: Optional[int] = None, allow_install_tree_fallback: bool = False,
|
||||
home_override: "Path | None" = None, skip_soul: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""One dict per context file Hermes considered, in the builder's priority order.
|
||||
|
||||
Same signature semantics as ``build_context_files_prompt`` (``cwd=None`` → launch dir, install-tree guard
|
||||
@@ -70,7 +70,7 @@ def list_context_file_sources(
|
||||
cwd_path = Path(cwd if cwd is not None else os.getcwd()).resolve()
|
||||
max_chars = _pb._get_context_file_max_chars(context_length)
|
||||
suppressed = _pb._project_context_suppressed(cwd, cwd_path, allow_install_tree_fallback)
|
||||
sources: List[Dict[str, Any]] = []
|
||||
sources: list[dict[str, Any]] = []
|
||||
winner: Optional[str] = None
|
||||
for kind, label, path, content in _pb.discover_context_files(cwd_path):
|
||||
if not content:
|
||||
@@ -96,7 +96,7 @@ def list_context_file_sources(
|
||||
return sources
|
||||
|
||||
|
||||
def context_file_sources_for_agent(agent: Any) -> List[Dict[str, Any]]:
|
||||
def context_file_sources_for_agent(agent: Any) -> list[dict[str, Any]]:
|
||||
"""The manifest for a live agent, resolved exactly like ``agent.system_prompt._context_files_part``
|
||||
(session cwd, install-tree policy per platform, the agent's own profile home)."""
|
||||
if getattr(agent, "skip_context_files", False):
|
||||
@@ -112,7 +112,7 @@ def context_file_sources_for_agent(agent: Any) -> List[Dict[str, Any]]:
|
||||
)
|
||||
|
||||
|
||||
def render_context_file_lines(sources: List[Dict[str, Any]]) -> List[str]:
|
||||
def render_context_file_lines(sources: list[dict[str, Any]]) -> list[str]:
|
||||
"""Plain-text ``Context files`` block for ``/context``; [] when nothing was found."""
|
||||
if not sources:
|
||||
return []
|
||||
@@ -121,5 +121,5 @@ def render_context_file_lines(sources: List[Dict[str, Any]]) -> List[str]:
|
||||
for src in sources:
|
||||
glyph, note = _STATUS_DISPLAY.get(str(src.get("status") or ""), ("•", ""))
|
||||
suffix = f" ({note})" if note else ""
|
||||
lines.append(f"{glyph} {str(src.get('label') or ''):<{width}} ~{int(src.get('est_tokens') or 0):>9,} tokens{suffix}")
|
||||
lines.append(f"{glyph} {src.get('label') or ''!s:<{width}} ~{int(src.get('est_tokens') or 0):>9,} tokens{suffix}")
|
||||
return lines
|
||||
@@ -33,7 +33,7 @@ _context_reference_providers: dict[str, "ContextReferenceProvider"] = {}
|
||||
class ContextCompletionItem:
|
||||
"""A single autocomplete result from a context reference provider."""
|
||||
|
||||
__slots__ = ("text", "display", "meta")
|
||||
__slots__ = ("display", "meta", "text")
|
||||
|
||||
def __init__(self, text: str, display: str = "", meta: str = "") -> None:
|
||||
self.text = text
|
||||
|
||||
@@ -761,7 +761,7 @@ def _get_compress_timeout_executor():
|
||||
return _compress_timeout_executor
|
||||
|
||||
|
||||
def resolve_context_compression_timeouts(compression_cfg: Optional[dict] = None) -> Tuple[float, float]:
|
||||
def resolve_context_compression_timeouts(compression_cfg: Optional[dict] = None) -> tuple[float, float]:
|
||||
"""Return ``(idle_timeout_seconds, total_ceiling_seconds)``.
|
||||
``idle_timeout_seconds <= 0`` disables the progress-aware wrapper. The ceiling is clamped to at least one
|
||||
idle window when the idle budget is positive."""
|
||||
@@ -792,8 +792,7 @@ def resolve_context_compression_timeouts(compression_cfg: Optional[dict] = None)
|
||||
from agent.auxiliary_client import _effective_aux_timeout
|
||||
_aux_budget = float(_effective_aux_timeout("compression", None))
|
||||
if _aux_budget > 0:
|
||||
if _aux_budget > ceiling:
|
||||
ceiling = _aux_budget
|
||||
ceiling = max(ceiling, _aux_budget)
|
||||
idle = max(idle, min(_aux_budget, ceiling))
|
||||
return idle, ceiling
|
||||
|
||||
@@ -938,12 +937,12 @@ def request_exceeds_model_window(agent: Any, request_tokens: Any) -> Optional[bo
|
||||
|
||||
|
||||
def _retry_compression_on_fallback_chain(
|
||||
*, worker: Callable[[CompressionCommitFence], Tuple[list, str]], messages: list,
|
||||
*, worker: Callable[[CompressionCommitFence], tuple[list, str]], messages: list,
|
||||
system_prompt_fallback: Any, idle_timeout_seconds: float, total_ceiling_seconds: float,
|
||||
on_commit_overrun: Optional[Callable[[float, float], None]] = None,
|
||||
on_timeout_cause: Optional[Callable[[bool, bool], None]] = None, telemetry_agent: Any = None,
|
||||
new_fence: Optional[Callable[[], CompressionCommitFence]] = None, escalate_deterministic: bool = False,
|
||||
) -> Optional[Tuple[list, str]]:
|
||||
) -> Optional[tuple[list, str]]:
|
||||
"""Re-run an aborted compression with the summary route pinned: once on the configured chain entry,
|
||||
then — when ``escalate_deterministic`` (a stall backoff already burned one idle window this session,
|
||||
#112420) — once with the summary LLM skipped so compress() commits its deterministic fallback summary.
|
||||
@@ -978,11 +977,11 @@ def _retry_compression_on_fallback_chain(
|
||||
|
||||
|
||||
def _run_pinned_compression_retry(
|
||||
route: dict, *, worker: Callable[[CompressionCommitFence], Tuple[list, str]], messages: list,
|
||||
route: dict, *, worker: Callable[[CompressionCommitFence], tuple[list, str]], messages: list,
|
||||
system_prompt_fallback: Any, idle_timeout_seconds: float, total_ceiling_seconds: float,
|
||||
on_commit_overrun: Optional[Callable[[float, float], None]], on_timeout_cause: Optional[Callable[[bool, bool], None]],
|
||||
telemetry_agent: Any, new_fence: Optional[Callable[[], CompressionCommitFence]],
|
||||
) -> Optional[Tuple[list, str]]:
|
||||
) -> Optional[tuple[list, str]]:
|
||||
"""One bounded re-run of ``worker`` with ``route`` pinned; ``None`` when it produced no compression."""
|
||||
# The aborted fence refuses all commits; mint a fresh one via the host factory
|
||||
# so a /stop during the retry serializes against THIS attempt's commit boundary.
|
||||
@@ -1053,7 +1052,7 @@ def _run_pinned_compression_retry(
|
||||
|
||||
def _await_worker_within_budget(
|
||||
future: Any, fence: CompressionCommitFence, *, idle: float, ceiling: float, wait_started: float
|
||||
) -> Tuple[bool, Any]:
|
||||
) -> tuple[bool, Any]:
|
||||
"""Poll ``future`` under the idle budget + ceiling; ``(True, result)`` when it settled."""
|
||||
while True:
|
||||
waited = time.monotonic() - wait_started
|
||||
@@ -1162,16 +1161,16 @@ def _release_cancelled_worker(
|
||||
|
||||
|
||||
def run_compress_context_with_progress_timeout(
|
||||
*, worker: Callable[[CompressionCommitFence], Tuple[list, str]], messages: list,
|
||||
*, worker: Callable[[CompressionCommitFence], tuple[list, str]], messages: list,
|
||||
system_prompt_fallback: Any, idle_timeout_seconds: float, total_ceiling_seconds: float,
|
||||
on_timeout: Optional[Callable[[float, float, float], None]] = None,
|
||||
on_timeout_cause: Optional[Callable[[bool, bool], None]] = None,
|
||||
on_commit_overrun: Optional[Callable[[float, float], None]] = None,
|
||||
fence: Optional[CompressionCommitFence] = None, telemetry_agent: Any = None, stall_fallback: bool = True,
|
||||
new_fence: Optional[Callable[[], CompressionCommitFence]] = None,
|
||||
fallback_worker: Optional[Callable[[CompressionCommitFence], Tuple[list, str]]] = None,
|
||||
fallback_worker: Optional[Callable[[CompressionCommitFence], tuple[list, str]]] = None,
|
||||
request_exceeds_window: bool = False,
|
||||
) -> Tuple[list, str]:
|
||||
) -> tuple[list, str]:
|
||||
"""Run ``worker(fence)`` under a sync progress-aware (idle + ceiling) timeout.
|
||||
Budgets bound the PRE-commit phase only: an admitted commit always completes (overrun logged, surfaced
|
||||
once via ``on_commit_overrun``). A pre-commit cancel returns ``(messages, system_prompt_fallback)`` (lazy
|
||||
@@ -1498,7 +1497,7 @@ def compression_skipped_due_to_lock(agent: Any) -> bool:
|
||||
|
||||
def _get_context_compression_timeout_state(
|
||||
agent: Any, *, create: bool
|
||||
) -> Optional[Tuple[Any, Optional[threading.local]]]:
|
||||
) -> Optional[tuple[Any, Optional[threading.local]]]:
|
||||
"""Return the stable lock and thread-local timeout state for an agent."""
|
||||
try:
|
||||
attributes = vars(agent)
|
||||
@@ -1621,7 +1620,7 @@ def _rebind_session_context(session_id: str) -> None:
|
||||
|
||||
def _adopt_live_compression_child(
|
||||
agent: Any, session_db: Any, parent_session_id: str
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
) -> Optional[list[dict[str, Any]]]:
|
||||
"""Move a stale compression contender onto the live continuation tip.
|
||||
Resolve and load first, then mutate the agent, so ambiguous lineage or an unreadable handoff fails closed.
|
||||
Uses the transitive ``get_compression_tip`` walk; a tip is adopted only while its row is still live.
|
||||
@@ -1700,7 +1699,7 @@ def _reopen_orphaned_parent(session_db: Any, session_id: str) -> None:
|
||||
logger.warning("orphaned compression session reopen failed for %s: %s", session_id, exc)
|
||||
|
||||
|
||||
def recover_rotated_compression_session(agent: Any) -> Optional[List[Dict[str, Any]]]:
|
||||
def recover_rotated_compression_session(agent: Any) -> Optional[list[dict[str, Any]]]:
|
||||
"""Recover a stale live agent before a new turn writes to its old parent."""
|
||||
session_db = getattr(agent, "_session_db", None)
|
||||
session_id = getattr(agent, "session_id", None) or ""
|
||||
@@ -2242,7 +2241,7 @@ _STEER_FALLBACK_OPEN = "[OUT-OF-BAND USER MESSAGE"
|
||||
_STEER_FALLBACK_CLOSE = "[/OUT-OF-BAND USER MESSAGE]"
|
||||
|
||||
|
||||
def _steer_markers() -> Tuple[str, str]:
|
||||
def _steer_markers() -> tuple[str, str]:
|
||||
"""``(open, close)`` steer markers from prompt_builder, or the stable fallback literals."""
|
||||
try:
|
||||
from agent.prompt_builder import STEER_MARKER_CLOSE, STEER_MARKER_OPEN
|
||||
@@ -2676,7 +2675,7 @@ class _CompressionLease:
|
||||
self.finish_lock_setup()
|
||||
|
||||
|
||||
def _resolve_lock_api(lock_db: Any) -> Tuple[Any, Optional[Exception]]:
|
||||
def _resolve_lock_api(lock_db: Any) -> tuple[Any, Optional[Exception]]:
|
||||
"""Return ``(try_acquire_compression_lock, lookup_error)`` for ``lock_db``.
|
||||
``(None, None)`` = no db or legacy SessionDB without the lock API (fail open); ``(None, exc)`` = lookup
|
||||
itself failed (caller fails closed)."""
|
||||
@@ -2696,7 +2695,7 @@ def _resolve_lock_api(lock_db: Any) -> Tuple[Any, Optional[Exception]]:
|
||||
def _abort_lease(
|
||||
agent: Any, lifecycle: _CompactionLifecycle, system_message: str, attempt_started_at: float,
|
||||
failure_class: str, prompt: Optional[str] = None,
|
||||
) -> Tuple[None, str]:
|
||||
) -> tuple[None, str]:
|
||||
"""Sit-out return for lease acquisition: prompt, aborted telemetry, terminal status edge."""
|
||||
if prompt is None:
|
||||
prompt = _existing_system_prompt(agent, system_message)
|
||||
@@ -2742,7 +2741,7 @@ def _try_acquire_durable_lock(lease: _CompressionLease, try_acquire: Any, commit
|
||||
def _sit_out_lock_contention(
|
||||
agent: Any, lease: _CompressionLease, lifecycle: _CompactionLifecycle, system_message: str,
|
||||
approx_tokens: Optional[int], attempt_started_at: float,
|
||||
) -> Tuple[None, str]:
|
||||
) -> tuple[None, str]:
|
||||
"""Another path holds the lock: publish the lock-skip signal, warn once, sit out."""
|
||||
existing = None
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -2773,7 +2772,7 @@ def _sit_out_lock_contention(
|
||||
def _acquire_compression_lease(
|
||||
agent: Any, *, commit_fence: Optional[CompressionCommitFence], lifecycle: _CompactionLifecycle,
|
||||
system_message: str, approx_tokens: Optional[int], attempt_started_at: float,
|
||||
) -> Tuple[Optional[_CompressionLease], Optional[str]]:
|
||||
) -> tuple[Optional[_CompressionLease], Optional[str]]:
|
||||
"""Take the per-session compression lock; ``(None, prompt)`` means sit out.
|
||||
Two AIAgents sharing a session_id (e.g. background review fork) would both rotate and orphan a child.
|
||||
Keyed on the OLD id (what rivals read from SessionEntry). Loser sits out: messages unchanged, caller sees
|
||||
@@ -2846,7 +2845,7 @@ def _acquire_compression_lease(
|
||||
|
||||
def _adopt_if_parent_rotated(
|
||||
agent: Any, lease: _CompressionLease, messages: list, system_message: str
|
||||
) -> Optional[Tuple[list, str]]:
|
||||
) -> Optional[tuple[list, str]]:
|
||||
"""Sit out (or adopt the live child) when the parent was already rotated.
|
||||
A late contender can take the parent lock after the winner released it and rotated; holding the lock does
|
||||
not prove this agent still owns a live parent. Returns the ``compress_context`` result to hand back, or
|
||||
@@ -2970,7 +2969,7 @@ def _pre_compress_memory_context(agent: Any, messages: list, checkpoint_required
|
||||
def _resolve_compress_call(
|
||||
agent: Any, *, approx_tokens: Optional[int], focus_topic: Optional[str], force: bool, memory_context: str,
|
||||
bypass_cooldown: bool,
|
||||
) -> Tuple[Callable[..., Any], dict[str, Any]]:
|
||||
) -> tuple[Callable[..., Any], dict[str, Any]]:
|
||||
"""Bind ``compress()`` and only the kwargs its signature accepts."""
|
||||
compress_fn = agent.context_compressor.compress
|
||||
compress_kwargs = _supported_compression_kwargs(
|
||||
@@ -3145,7 +3144,7 @@ def _rebuild_system_prompt_at_boundary(agent: Any, system_message: str) -> str:
|
||||
# object (identity matters to provider-side tool-block caching on some backends).
|
||||
try:
|
||||
_refresh_agent_tool_definitions(agent)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"compaction tool-definition refresh failed; keeping the session's existing tool snapshot", exc_info=True
|
||||
)
|
||||
@@ -3182,7 +3181,7 @@ def _rebuild_system_prompt_at_boundary(agent: Any, system_message: str) -> str:
|
||||
def _salvage_or_refuse_grown_transcript(
|
||||
agent: Any, messages: list, compressed: list, *, system_message: str, attempt_started_at: float,
|
||||
attempt_snapshot: dict,
|
||||
) -> Tuple[Optional[list], Optional[str]]:
|
||||
) -> tuple[Optional[list], Optional[str]]:
|
||||
"""Anti-growth guard at the COMMIT SITE (in-place commits before the gateway can inspect).
|
||||
Compares like-for-like rough estimates; on growth tries one mechanical salvage pass, else treats the
|
||||
attempt as a refused no-op. Returns ``(compressed, None)`` to proceed or ``(None, prompt)`` when refused
|
||||
@@ -4017,7 +4016,7 @@ def _begin_compression_attempt(
|
||||
def _route_codex_compaction(
|
||||
agent: Any, messages: list, system_message: str, *, commit_fence: Optional[CompressionCommitFence],
|
||||
attempt: _Attempt, approx_tokens: Optional[int], task_id: str, force: bool,
|
||||
) -> Tuple[list, str]:
|
||||
) -> tuple[list, str]:
|
||||
"""Codex owns the real thread: run its own compact under the commit fence bracket."""
|
||||
if commit_fence is not None and not commit_fence.begin_commit(getattr(agent, "_hard_interrupt_requested", None)):
|
||||
attempt.restore_compressor(agent.context_compressor)
|
||||
@@ -4066,7 +4065,7 @@ def compress_context(
|
||||
bypass_cooldown: bool = False, defer_context_engine_notification: bool = False,
|
||||
commit_fence: Optional[CompressionCommitFence] = None, verbatim_tail: Optional[list] = None,
|
||||
trigger: Optional[str] = None, snapshot_is_current: Optional[Callable[[], bool]] = None,
|
||||
) -> Tuple[list, str]:
|
||||
) -> tuple[list, str]:
|
||||
"""Compress conversation context and split the session in SQLite.
|
||||
``force`` (manual /compress) clears the summary-failure cooldown; ``bypass_cooldown`` (provider-proven
|
||||
overflow) skips it once, breakers still apply. ``commit_fence`` stops a timed-out worker mutating session
|
||||
@@ -4315,7 +4314,7 @@ def _record_codex_compaction_failure(agent: Any, error: str) -> None:
|
||||
def _compress_context_via_codex_app_server(
|
||||
agent: Any, messages: list, system_message: Optional[str], *, approx_tokens: Optional[int] = None,
|
||||
task_id: str = "default", force: bool = False,
|
||||
) -> Tuple[list, str]:
|
||||
) -> tuple[list, str]:
|
||||
"""Route compaction to Codex app-server for Codex-owned threads.
|
||||
Rewriting the local transcript would not shrink the Codex thread, so Codex compacts its own thread and
|
||||
Hermes' transcript is left unchanged."""
|
||||
|
||||
@@ -28,9 +28,9 @@ RETIRED_ROW = "_retired_row"
|
||||
OWN_ROW = "_own_row"
|
||||
|
||||
|
||||
def retired_row_payload(message: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def retired_row_payload(message: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The fields the commit matches a retired id-less row by."""
|
||||
payload: Dict[str, Any] = {"role": message.get("role"), "content": message.get("content")}
|
||||
payload: dict[str, Any] = {"role": message.get("role"), "content": message.get("content")}
|
||||
if message.get("tool_call_id"):
|
||||
payload["tool_call_id"] = message["tool_call_id"]
|
||||
calls = [call.get("id") for call in message.get("tool_calls") or () if isinstance(call, dict)]
|
||||
@@ -47,7 +47,7 @@ def _positive_id(value: Any) -> Optional[int]:
|
||||
|
||||
def held_archive_coverage(
|
||||
messages: Sequence[Any], verbatim_tail: Optional[Sequence[Any]] = None,
|
||||
) -> Tuple[List[int], List[Dict[str, Any]]]:
|
||||
) -> tuple[list[int], list[dict[str, Any]]]:
|
||||
"""``(covered ids, unresolved dicts)`` from the history the compressor was handed.
|
||||
|
||||
A positive ``_row_id`` is covered, including ids a repair merged into that dict.
|
||||
@@ -55,8 +55,8 @@ def held_archive_coverage(
|
||||
marker-less miss is an unpersisted turn rather than a reason to archive the
|
||||
lease watermark.
|
||||
"""
|
||||
covered: List[int] = []
|
||||
unresolved: List[Dict[str, Any]] = []
|
||||
covered: list[int] = []
|
||||
unresolved: list[dict[str, Any]] = []
|
||||
for batch in (messages or (), verbatim_tail or ()):
|
||||
for message in batch:
|
||||
if not isinstance(message, dict):
|
||||
@@ -83,7 +83,7 @@ def newest_exact_held_id(
|
||||
"""
|
||||
from agent.context_compressor import _DB_PERSISTED_MARKER
|
||||
|
||||
exact: List[int] = []
|
||||
exact: list[int] = []
|
||||
for message in messages or ():
|
||||
if not isinstance(message, dict) or not message.get(_DB_PERSISTED_MARKER):
|
||||
continue
|
||||
@@ -101,7 +101,7 @@ def newest_exact_held_id(
|
||||
def coverage_for_commit(
|
||||
session_db: Any, session_id: str, messages: Sequence[Any],
|
||||
verbatim_tail: Optional[Sequence[Any]] = None,
|
||||
) -> Tuple[Optional[List[int]], Optional[List[Dict[str, Any]]]]:
|
||||
) -> tuple[Optional[list[int]], Optional[list[dict[str, Any]]]]:
|
||||
"""Coverage to pass into ``archive_and_compact``, or ``(None, None)`` to keep the watermark.
|
||||
|
||||
``None`` when the newest exact held row is already inactive (another compaction
|
||||
@@ -120,7 +120,7 @@ def coverage_for_commit(
|
||||
if newest is not None and callable(role_of) and role_of(session_id, newest) is None:
|
||||
return None, None
|
||||
covered, unresolved = held_archive_coverage(messages, verbatim_tail)
|
||||
retired: List[Dict[str, Any]] = []
|
||||
retired: list[dict[str, Any]] = []
|
||||
for message in unresolved:
|
||||
named = [row for row in message.get(RETIRED_DURABLE_ROWS) or () if isinstance(row, dict)]
|
||||
if int(message.get(UNNAMED_DURABLE_ROWS) or 0) > sum(1 for row in named if not row.get(OWN_ROW)):
|
||||
|
||||
@@ -32,14 +32,14 @@ class CompressRequest:
|
||||
@dataclass
|
||||
class CompressResult:
|
||||
status: str # "preview" | "compressed" | "lock_skipped" | "nothing_to_do"
|
||||
before_messages: List[Dict[str, Any]]
|
||||
after_messages: List[Dict[str, Any]]
|
||||
before_messages: list[dict[str, Any]]
|
||||
after_messages: list[dict[str, Any]]
|
||||
before_tokens: int
|
||||
after_tokens: int
|
||||
request: CompressRequest
|
||||
lines: List[str] = field(default_factory=list) # preview report lines (status == "preview")
|
||||
lines: list[str] = field(default_factory=list) # preview report lines (status == "preview")
|
||||
lock_holder: Any = None
|
||||
summary: Optional[Dict[str, Any]] = None # ``summarize_manual_compression`` payload when compressed
|
||||
summary: Optional[dict[str, Any]] = None # ``summarize_manual_compression`` payload when compressed
|
||||
|
||||
@property
|
||||
def removed(self) -> int:
|
||||
@@ -55,7 +55,7 @@ def parse_compress_args(raw_args: str) -> CompressRequest:
|
||||
focus_topic=focus_topic or None)
|
||||
|
||||
|
||||
def estimate_request_tokens(agent: Any, messages: Sequence[Dict[str, Any]]) -> int:
|
||||
def estimate_request_tokens(agent: Any, messages: Sequence[dict[str, Any]]) -> int:
|
||||
"""Transcript + system prompt + tool schemas: a transcript-only figure understates real request pressure
|
||||
and can even appear to grow after a dense handoff summary replaces many short turns (#6217)."""
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
@@ -67,7 +67,7 @@ def estimate_request_tokens(agent: Any, messages: Sequence[Dict[str, Any]]) -> i
|
||||
|
||||
|
||||
def compress_now(
|
||||
agent: Any, history: Sequence[Dict[str, Any]], request: CompressRequest, *,
|
||||
agent: Any, history: Sequence[dict[str, Any]], request: CompressRequest, *,
|
||||
system_message: Any = None, task_id: str = "default", skip_without_window: bool = False,
|
||||
snapshot_is_current: Optional[Callable[[], bool]] = None,
|
||||
) -> CompressResult:
|
||||
@@ -150,7 +150,7 @@ def compress_now(
|
||||
return CompressResult("compressed", before, list(compressed), before_tokens, after_tokens, request, summary=summary)
|
||||
|
||||
|
||||
def render_compress_result(result: CompressResult, *, prefix: str = "") -> List[str]:
|
||||
def render_compress_result(result: CompressResult, *, prefix: str = "") -> list[str]:
|
||||
"""Surface-neutral text lines for a result (each surface may add its own icon/prefix)."""
|
||||
if result.status == "preview":
|
||||
return [f"{prefix}{line}" for line in result.lines]
|
||||
|
||||
+38
-38
@@ -81,7 +81,7 @@ RUN_BUDGET_WRAPUP_NOTICE = (
|
||||
|
||||
|
||||
def _midturn_request_pressure_tokens(
|
||||
agent: Any, api_messages: List[Dict[str, Any]], effective_system: str, approx_tokens: int
|
||||
agent: Any, api_messages: list[dict[str, Any]], effective_system: str, approx_tokens: int
|
||||
) -> int:
|
||||
"""Token figure the mid-turn pre-API compression guard compares: the pruned
|
||||
native-Responses estimate when native compaction eligibility is proven (the generic
|
||||
@@ -121,7 +121,7 @@ def _review_input_budget_exhausted(agent: Any) -> bool:
|
||||
return isinstance(used, int) and not isinstance(used, bool) and used >= budget
|
||||
|
||||
|
||||
def _maybe_inject_run_budget_wrapup(agent: Any, messages: List[Dict[str, Any]]) -> bool:
|
||||
def _maybe_inject_run_budget_wrapup(agent: Any, messages: list[dict[str, Any]]) -> bool:
|
||||
"""Inject the one-time wall-clock wrap-up notice when past 80% of budget.
|
||||
|
||||
Appends to the NEWEST ``role:"tool"`` message (cache-safe, like /steer); latches
|
||||
@@ -157,7 +157,7 @@ def _maybe_inject_run_budget_wrapup(agent: Any, messages: List[Dict[str, Any]])
|
||||
|
||||
|
||||
def _restore_user_after_reference_handoff(
|
||||
messages: List[Dict[str, Any]], user_message: Any
|
||||
messages: list[dict[str, Any]], user_message: Any
|
||||
) -> bool:
|
||||
"""Re-append this turn's real user ask when compaction left only a handoff (#80622).
|
||||
Returns True when a restore append happened."""
|
||||
@@ -175,7 +175,7 @@ def _restore_user_after_reference_handoff(
|
||||
|
||||
|
||||
def _should_skip_model_call_for_reference_handoff(
|
||||
messages: List[Dict[str, Any]], user_message: Any
|
||||
messages: list[dict[str, Any]], user_message: Any
|
||||
) -> bool:
|
||||
"""Guard post-compaction continues against sole-handoff active turns (#80622)."""
|
||||
from agent.context_compressor import reference_handoff_would_drive_next_model_call
|
||||
@@ -291,7 +291,7 @@ def _continuation_overlap_length(previous: str, continuation: str) -> int:
|
||||
return matched if matched >= _MIN_CONTINUATION_OVERLAP else 0
|
||||
|
||||
|
||||
def _join_truncated_parts(parts: List[tuple[str, bool]]) -> str:
|
||||
def _join_truncated_parts(parts: list[tuple[str, bool]]) -> str:
|
||||
"""Join continuation fragments, deduping only interrupted-stream seams."""
|
||||
joined = ""
|
||||
previous_was_partial_stub = False
|
||||
@@ -319,7 +319,7 @@ def _moa_reference_metrics_for_hook(agent: Any) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text: str) -> None:
|
||||
def _apply_active_turn_redirect(agent: Any, messages: list[dict[str, Any]], text: str) -> None:
|
||||
"""Append a provider-safe checkpoint and correction to the live turn so role alternation
|
||||
holds and cached messages stay byte-identical. INVARIANTS: raw chain-of-thought never enters
|
||||
replayable content (inlined CoT reads as a prefill jailbreak and bricks the session with
|
||||
@@ -468,7 +468,7 @@ def _maybe_grow_local_window(agent: Any, compressor: Any,
|
||||
getattr(agent, "model", "") or "", base_url=base_url,
|
||||
session_tokens=int(request_tokens), current_window=current_window,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — growth must never break a turn
|
||||
except Exception as exc:
|
||||
logger.debug("local window growth check failed: %s", exc)
|
||||
return None
|
||||
|
||||
@@ -931,7 +931,7 @@ _LEGACY_LENGTH_CONTINUATION_NETWORK_STUB = (
|
||||
_LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX = "[System: Your previous tool call "
|
||||
|
||||
|
||||
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
|
||||
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[list[str]] = None) -> str:
|
||||
if is_partial_stub and dropped_tools:
|
||||
tool_list = ", ".join(dropped_tools[:3])
|
||||
return (
|
||||
@@ -988,7 +988,7 @@ _EMPTY_TOOL_RESPONSE_NUDGE = (
|
||||
# each iteration). Sound because canonicalization is pure; malformed strings raise before
|
||||
# being stored, so the repair fallback is never memoized. The byte budget exists because
|
||||
# argument strings can run 100KB+, so a count bound alone does not bound memory.
|
||||
_CANON_ARGS_CACHE: Dict[str, str] = {}
|
||||
_CANON_ARGS_CACHE: dict[str, str] = {}
|
||||
_CANON_ARGS_CACHE_MAX = 4096
|
||||
_CANON_ARGS_CACHE_MAX_BYTES = 32 * 1024 * 1024
|
||||
_canon_args_cache_bytes = 0
|
||||
@@ -1064,8 +1064,8 @@ def _invalid_tool_name_error_content(name: str, valid_tool_names) -> str:
|
||||
|
||||
|
||||
def _content_policy_blocked_result(
|
||||
messages: List[Dict], api_call_count: int, *, final_response: str, error_detail: str
|
||||
) -> Dict[str, Any]:
|
||||
messages: list[dict], api_call_count: int, *, final_response: str, error_detail: str
|
||||
) -> dict[str, Any]:
|
||||
"""Terminal turn result for a content-policy block (deterministic for the unchanged
|
||||
prompt, so no retry); shared by the HTTP-200 and exception paths."""
|
||||
return {
|
||||
@@ -1076,8 +1076,8 @@ def _content_policy_blocked_result(
|
||||
|
||||
|
||||
def _partial_turn_result(
|
||||
final_response: str, messages: List[Dict], api_call_count: int, **flags: Any
|
||||
) -> Dict[str, Any]:
|
||||
final_response: str, messages: list[dict], api_call_count: int, **flags: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Incomplete-turn result whose ``error`` mirrors ``final_response``; ``flags`` add the
|
||||
recovery-contract keys (``failed``, ``compression_deferred``, ...)."""
|
||||
return {
|
||||
@@ -1086,7 +1086,7 @@ def _partial_turn_result(
|
||||
}
|
||||
|
||||
|
||||
def _compression_deferred_result(agent, messages: List[Dict], api_call_count: int, reason: str = "lock") -> Dict[str, Any]:
|
||||
def _compression_deferred_result(agent, messages: list[dict], api_call_count: int, reason: str = "lock") -> dict[str, Any]:
|
||||
"""Soft turn result for a transiently-deferred compression. Both reasons must end as
|
||||
``compression_deferred``, never ``compression_exhausted`` — the gateway wipes the
|
||||
session on exhaustion (#9893/#35809). ``failed`` stays False; the turn persists."""
|
||||
@@ -1122,9 +1122,9 @@ def _compression_deferred_result(agent, messages: List[Dict], api_call_count: in
|
||||
|
||||
|
||||
def _provider_overflow_exhausted_result(
|
||||
agent, messages: List[Dict], conversation_history, api_call_count: int,
|
||||
agent, messages: list[dict], conversation_history, api_call_count: int,
|
||||
request_pressure_tokens: int, max_compression_attempts: int,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when a rebuilt request is still too large after recovery."""
|
||||
agent._flush_status_buffer()
|
||||
logger.error(
|
||||
@@ -1201,19 +1201,19 @@ def _ensure_cached_system_prompt_static(agent, system_message=None) -> None:
|
||||
reconstruct_static_prefix(agent, system_message=system_message, log_label="failover redecoration")
|
||||
|
||||
|
||||
def _peel_moa_guidance(messages: List[Dict[str, Any]], guidance: Any) -> List[Dict[str, Any]]:
|
||||
def _peel_moa_guidance(messages: list[dict[str, Any]], guidance: Any) -> list[dict[str, Any]]:
|
||||
"""Remove MoA reference guidance attached by ``_attach_reference_guidance``."""
|
||||
from agent.moa_loop import peel_reference_guidance
|
||||
return peel_reference_guidance(messages, guidance)
|
||||
|
||||
|
||||
def _redecorate_prompt_cache_for_provider(
|
||||
agent, api_messages: List[Dict[str, Any]], *, system_message=None,
|
||||
moa_prepared: Optional[Dict[str, Any]] = None, tools_for_api: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
agent, api_messages: list[dict[str, Any]], *, system_message=None,
|
||||
moa_prepared: Optional[dict[str, Any]] = None, tools_for_api: Optional[list[dict[str, Any]]] = None,
|
||||
) -> tuple[list[dict[str, Any]], Optional[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Strip and re-apply cache_control for the *current* provider policy — failover
|
||||
``continue`` paths reuse ``api_messages`` (#72626). MoA guidance is peeled and rebased."""
|
||||
messages: List[Dict[str, Any]] = [dict(m) if isinstance(m, dict) else m for m in (api_messages or [])]
|
||||
messages: list[dict[str, Any]] = [dict(m) if isinstance(m, dict) else m for m in (api_messages or [])]
|
||||
prepared = moa_prepared
|
||||
guidance = prepared.get("guidance") if isinstance(prepared, dict) else None
|
||||
if guidance:
|
||||
@@ -1275,9 +1275,9 @@ def _engine_overrides_hook(engine: Any, name: str) -> bool:
|
||||
|
||||
|
||||
def _apply_context_engine_selection(
|
||||
agent: Any, api_messages: List[Dict[str, Any]], conversation_messages: List[Dict[str, Any]],
|
||||
incoming_message: Optional[Dict[str, Any]], *, logger: Any,
|
||||
) -> List[Dict[str, Any]]:
|
||||
agent: Any, api_messages: list[dict[str, Any]], conversation_messages: list[dict[str, Any]],
|
||||
incoming_message: Optional[dict[str, Any]], *, logger: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Run the optional per-turn ``ContextEngine.select_context()`` hook, fail-open: any
|
||||
exception or invalid return yields ``api_messages`` unchanged; history is never mutated."""
|
||||
engine = getattr(agent, "context_compressor", None)
|
||||
@@ -1325,7 +1325,7 @@ def _apply_context_engine_selection(
|
||||
|
||||
|
||||
def _notify_context_engine_turn_complete(
|
||||
agent: Any, messages: List[Dict[str, Any]], *, usage: Optional[Dict[str, Any]] = None, logger: Any, **meta: Any
|
||||
agent: Any, messages: list[dict[str, Any]], *, usage: Optional[dict[str, Any]] = None, logger: Any, **meta: Any
|
||||
) -> None:
|
||||
"""Notify the active context engine that a user turn has finished (fail-open; the engine
|
||||
gets a copy so it cannot mutate the persisted transcript)."""
|
||||
@@ -1358,7 +1358,7 @@ def _decode_inline_moa_turn(user_message, persist_user_message):
|
||||
return user_message, None, persist_user_message
|
||||
|
||||
|
||||
def _preflight_timeout_result(agent, exc, conversation_history) -> Dict[str, Any]:
|
||||
def _preflight_timeout_result(agent, exc, conversation_history) -> dict[str, Any]:
|
||||
"""Typed recovery result when turn-start preflight compression timed out (#98424): no
|
||||
provider call was sent, and surfaces would otherwise hide the actionable guidance."""
|
||||
logger.warning(
|
||||
@@ -1420,7 +1420,7 @@ class _LoopState:
|
||||
restart_count: int = 0
|
||||
_outer_error_count: int = 0 # outer-loop exceptions this turn (#92450), see _MAX_OUTER_LOOP_ERRORS
|
||||
truncated_tool_call_retries: int = 0
|
||||
truncated_response_parts: List[tuple[str, bool]] = field(default_factory=list)
|
||||
truncated_response_parts: list[tuple[str, bool]] = field(default_factory=list)
|
||||
compression_attempts: int = 0
|
||||
_last_preflight_pressure: Optional[int] = None
|
||||
# A provider overflow outweighs the rough-estimate calibration that defers preflight after
|
||||
@@ -1467,7 +1467,7 @@ _CTX_FIELDS = frozenset({
|
||||
"active_system_prompt", "current_turn_user_idx", "_preflight_compression_blocked",
|
||||
})
|
||||
# Keyword names each phase helper takes (minus ``agent``), cached per function object.
|
||||
_PHASE_PARAMS: Dict[Any, tuple] = {}
|
||||
_PHASE_PARAMS: dict[Any, tuple] = {}
|
||||
# Verdict fields the loop latches (only ever sets True) instead of copying back:
|
||||
# ``handle_api_error`` reports overflow recovery per call and must not clear an earlier arm.
|
||||
_LATCHED_VERDICT_FIELDS = {"handle_api_error": frozenset({"_provider_overflow_recovery_pending"})}
|
||||
@@ -1494,7 +1494,7 @@ def _run_phase(fn, agent, state: _LoopState, **extra):
|
||||
return verdict
|
||||
|
||||
|
||||
def _run_api_retry_loop(agent, s: _LoopState) -> Optional[Dict[str, Any]]:
|
||||
def _run_api_retry_loop(agent, s: _LoopState) -> Optional[dict[str, Any]]:
|
||||
"""One API call with its retry/recovery loop (guard → build → call → check, error handlers).
|
||||
|
||||
Returns a turn result dict when a phase ends the turn, else None once the loop is left
|
||||
@@ -1526,7 +1526,7 @@ def _run_api_retry_loop(agent, s: _LoopState) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def _codex_app_server_turn(agent: Any, s: Any) -> Optional[Dict[str, Any]]:
|
||||
def _codex_app_server_turn(agent: Any, s: Any) -> Optional[dict[str, Any]]:
|
||||
"""The codex app-server's result for this turn, or None when its failure activated a fallback and the
|
||||
generic loop retries the same user turn."""
|
||||
codex_result = agent._run_codex_app_server_turn(
|
||||
@@ -1548,19 +1548,19 @@ def _run_conversation_turn(
|
||||
agent,
|
||||
user_message: Any,
|
||||
system_message: str = None,
|
||||
conversation_history: List[Dict[str, Any]] = None,
|
||||
conversation_history: list[dict[str, Any]] = None,
|
||||
task_id: str = None,
|
||||
stream_callback: Optional[callable] = None,
|
||||
persist_user_message: Optional[Any] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
persist_user_display_kind: Optional[str] = None,
|
||||
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
|
||||
persist_user_display_metadata: Optional[dict[str, Any]] = None,
|
||||
persist_user_platform_id: Optional[str] = None,
|
||||
turn_author: Optional[Dict[str, Any]] = None,
|
||||
turn_author: Optional[dict[str, Any]] = None,
|
||||
moa_config: Optional[dict[str, Any]] = None,
|
||||
title_user_message: Optional[str] = None,
|
||||
prelude: Optional[Prelude] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Run a complete conversation with tool calling until completion; returns the result dict.
|
||||
|
||||
``stream_callback``: per-text-delta callback (TTS). ``persist_user_message``: clean text to
|
||||
@@ -1710,19 +1710,19 @@ def run_conversation(
|
||||
agent,
|
||||
user_message: Any,
|
||||
system_message: str = None,
|
||||
conversation_history: List[Dict[str, Any]] = None,
|
||||
conversation_history: list[dict[str, Any]] = None,
|
||||
task_id: str = None,
|
||||
stream_callback: Optional[callable] = None,
|
||||
persist_user_message: Optional[Any] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
persist_user_display_kind: Optional[str] = None,
|
||||
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
|
||||
persist_user_display_metadata: Optional[dict[str, Any]] = None,
|
||||
persist_user_platform_id: Optional[str] = None,
|
||||
moa_config: Optional[dict[str, Any]] = None,
|
||||
turn_author: Optional[Dict[str, Any]] = None,
|
||||
turn_author: Optional[dict[str, Any]] = None,
|
||||
title_user_message: Optional[str] = None,
|
||||
prelude: Optional[Prelude] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Run one turn (see ``_run_conversation_turn``) and export the current-turn boundary.
|
||||
|
||||
Every envelope that leaves the loop — success, partial/error, interrupt, retry-exhausted,
|
||||
|
||||
@@ -99,7 +99,7 @@ def _credential_secret_fingerprint(payload: Mapping[str, Any]) -> str | None:
|
||||
def sanitize_borrowed_credential_payload(
|
||||
payload: Mapping[str, Any],
|
||||
provider_id: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Return a disk-safe credential-pool payload.
|
||||
|
||||
Owned sources pass through unchanged. Borrowed sources keep labels,
|
||||
|
||||
+72
-73
@@ -189,7 +189,7 @@ _NOUS_EXTRA_STATE_KEYS = (
|
||||
)
|
||||
|
||||
# ``replace(entry, **_CLEAR_STATUS)`` returns an entry with no error state.
|
||||
_CLEAR_STATUS: Dict[str, Any] = {
|
||||
_CLEAR_STATUS: dict[str, Any] = {
|
||||
"last_status": None,
|
||||
"last_status_at": None,
|
||||
"last_error_code": None,
|
||||
@@ -197,7 +197,7 @@ _CLEAR_STATUS: Dict[str, Any] = {
|
||||
"last_error_message": None,
|
||||
"last_error_reset_at": None,
|
||||
}
|
||||
_MARK_OK: Dict[str, Any] = {**_CLEAR_STATUS, "last_status": STATUS_OK}
|
||||
_MARK_OK: dict[str, Any] = {**_CLEAR_STATUS, "last_status": STATUS_OK}
|
||||
|
||||
|
||||
def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str:
|
||||
@@ -237,8 +237,8 @@ class PooledCredential:
|
||||
# A provider may rate-limit one model while the same credential remains
|
||||
# usable for its sibling models. Keep that observation separate from the
|
||||
# credential-wide status used for auth and billing failures.
|
||||
model_cooldowns: Optional[Dict[str, float]] = None
|
||||
extra: Dict[str, Any] = None # type: ignore[assignment]
|
||||
model_cooldowns: Optional[dict[str, float]] = None
|
||||
extra: dict[str, Any] = None # type: ignore[assignment]
|
||||
|
||||
def __post_init__(self):
|
||||
if self.extra is None:
|
||||
@@ -251,7 +251,7 @@ class PooledCredential:
|
||||
raise AttributeError(f"'{type(self).__name__}' object has no attribute {name!r}")
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, provider: str, payload: Dict[str, Any]) -> "PooledCredential":
|
||||
def from_dict(cls, provider: str, payload: dict[str, Any]) -> "PooledCredential":
|
||||
field_names = {f.name for f in fields(cls) if f.name != "provider"}
|
||||
data = {k: payload.get(k) for k in field_names if k in payload}
|
||||
# Rehydrated last_status_at may be an ISO string from to_dict() — normalize to float epoch
|
||||
@@ -273,8 +273,8 @@ class PooledCredential:
|
||||
data.setdefault("access_token", "")
|
||||
return cls(provider=provider, **data)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
result: Dict[str, Any] = {}
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for field_def in fields(self):
|
||||
if field_def.name in {"provider", "extra"}:
|
||||
continue
|
||||
@@ -327,7 +327,7 @@ def label_from_token(token: str, fallback: str) -> str:
|
||||
return fallback
|
||||
|
||||
|
||||
def _codex_principal_identity(access_token: Any) -> Optional[Tuple[str, str]]:
|
||||
def _codex_principal_identity(access_token: Any) -> Optional[tuple[str, str]]:
|
||||
"""``(chatgpt_account_id, sub)`` of a Codex access token, or None when either claim is missing.
|
||||
|
||||
Decoded without signature verification: this only decides whether two credentials Hermes
|
||||
@@ -344,7 +344,7 @@ def _codex_principal_identity(access_token: Any) -> Optional[Tuple[str, str]]:
|
||||
return account_id.strip(), subject.strip()
|
||||
|
||||
|
||||
def _codex_entry_tracks_singleton(entry: PooledCredential, singleton_tokens: Dict[str, Any]) -> bool:
|
||||
def _codex_entry_tracks_singleton(entry: PooledCredential, singleton_tokens: dict[str, Any]) -> bool:
|
||||
"""Whether a Codex pool entry may adopt the auth.json singleton's token pair.
|
||||
|
||||
``device_code`` IS the singleton. ``manual:device_code`` is ambiguous: a legacy alias of the
|
||||
@@ -361,7 +361,7 @@ def _codex_entry_tracks_singleton(entry: PooledCredential, singleton_tokens: Dic
|
||||
return entry_identity is not None and entry_identity == _codex_principal_identity(singleton_tokens.get("access_token"))
|
||||
|
||||
|
||||
def _next_priority(entries: List[PooledCredential]) -> int:
|
||||
def _next_priority(entries: list[PooledCredential]) -> int:
|
||||
return max((entry.priority for entry in entries), default=-1) + 1
|
||||
|
||||
|
||||
@@ -442,10 +442,10 @@ def _singleton_predates_entry(state: Any, entry: "PooledCredential") -> bool:
|
||||
return state_ts < entry_ts
|
||||
|
||||
|
||||
def _normalize_error_context(error_context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
def _normalize_error_context(error_context: Optional[dict[str, Any]]) -> dict[str, Any]:
|
||||
if not isinstance(error_context, dict):
|
||||
return {}
|
||||
normalized: Dict[str, Any] = {}
|
||||
normalized: dict[str, Any] = {}
|
||||
for key in ("reason", "message"):
|
||||
value = error_context.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
@@ -510,7 +510,7 @@ def _iter_custom_providers(config: Optional[dict] = None):
|
||||
yield _normalize_custom_pool_name(entry["name"]), entry
|
||||
|
||||
|
||||
def _custom_entry_name_aliases(norm_name: str, entry: Dict[str, Any]) -> set:
|
||||
def _custom_entry_name_aliases(norm_name: str, entry: dict[str, Any]) -> set:
|
||||
aliases = {norm_name}
|
||||
provider_key = _normalize_custom_pool_name(str(entry.get("provider_key") or ""))
|
||||
if provider_key:
|
||||
@@ -528,9 +528,9 @@ def _requested_custom_name_aliases(provider_name: str) -> set:
|
||||
return aliases
|
||||
|
||||
|
||||
def _pool_keys_for_custom_entry(norm_name: str, entry: Dict[str, Any]) -> List[str]:
|
||||
def _pool_keys_for_custom_entry(norm_name: str, entry: dict[str, Any]) -> list[str]:
|
||||
"""Durable ``providers.<key>`` slug first, then legacy ``custom:<name>``."""
|
||||
keys: List[str] = []
|
||||
keys: list[str] = []
|
||||
provider_key = _normalize_custom_pool_name(str(entry.get("provider_key") or ""))
|
||||
for key in (provider_key, f"{CUSTOM_POOL_PREFIX}{norm_name}" if norm_name else ""):
|
||||
normalized = key.strip().lower()
|
||||
@@ -542,7 +542,7 @@ def _pool_keys_for_custom_entry(norm_name: str, entry: Dict[str, Any]) -> List[s
|
||||
def custom_provider_pool_key_candidates(
|
||||
base_url: Optional[str],
|
||||
provider_name: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
) -> list[str]:
|
||||
"""Return pool keys to try for a custom endpoint.
|
||||
|
||||
``hermes auth add <key>`` stores ``providers.<key>`` credentials under the
|
||||
@@ -578,7 +578,7 @@ def get_custom_provider_pool_key(base_url: Optional[str], provider_name: Optiona
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def list_custom_pool_providers() -> List[str]:
|
||||
def list_custom_pool_providers() -> list[str]:
|
||||
"""Return all 'custom:*' pool keys that have entries in auth.json."""
|
||||
pool_data = read_credential_pool(None)
|
||||
return sorted(
|
||||
@@ -589,7 +589,7 @@ def list_custom_pool_providers() -> List[str]:
|
||||
)
|
||||
|
||||
|
||||
def _get_custom_provider_config(pool_key: str) -> Optional[Dict[str, Any]]:
|
||||
def _get_custom_provider_config(pool_key: str) -> Optional[dict[str, Any]]:
|
||||
"""Return the custom_providers config entry matching a pool key like 'custom:together.ai'."""
|
||||
if not pool_key.startswith(CUSTOM_POOL_PREFIX):
|
||||
return None
|
||||
@@ -788,7 +788,7 @@ def _guarded_global_root(global_path: Optional[Path]) -> Optional[Path]:
|
||||
|
||||
|
||||
def _write_through_provider_state_to_global_root(
|
||||
provider_id: str, state: Dict[str, Any]
|
||||
provider_id: str, state: dict[str, Any]
|
||||
) -> None:
|
||||
"""Persist a rotated OAuth ``state`` into the global-root auth.json.
|
||||
|
||||
@@ -830,7 +830,7 @@ def _singleton_target_for_entry(pool: "CredentialPool", entry: "PooledCredential
|
||||
return None
|
||||
|
||||
|
||||
def _store_owns_pool_provider(auth_store: Dict[str, Any], provider: str) -> bool:
|
||||
def _store_owns_pool_provider(auth_store: dict[str, Any], provider: str) -> bool:
|
||||
"""True when an already-loaded *auth_store* has its own rows for *provider*."""
|
||||
pool = auth_store.get("credential_pool")
|
||||
entries = pool.get(provider) if isinstance(pool, dict) else None
|
||||
@@ -867,10 +867,10 @@ def _borrowed_single_use_pool_root() -> Optional[Path]:
|
||||
|
||||
|
||||
def _update_root_pool_rows(
|
||||
provider: str, payloads: List[Dict[str, Any]], global_path: Path,
|
||||
provider: str, payloads: list[dict[str, Any]], global_path: Path,
|
||||
*, status_cleared_ids: Optional[Iterable[str]] = None,
|
||||
token_bases: Optional[Dict[str, Tuple[Any, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
token_bases: Optional[dict[str, tuple[Any, Any]]] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""UPDATE-ONLY merge of *payloads* into the root store's rows for *provider*.
|
||||
|
||||
A borrower may refresh the root's rows (rotation, cooldown state) but
|
||||
@@ -889,7 +889,7 @@ def _update_root_pool_rows(
|
||||
incoming_by_id = auth_mod._entry_ids(payloads)
|
||||
cleared = {cid for cid in (status_cleared_ids or ()) if cid}
|
||||
bases = token_bases or {}
|
||||
merged: List[Dict[str, Any]] = []
|
||||
merged: list[dict[str, Any]] = []
|
||||
changed = False
|
||||
for disk_entry in existing_list:
|
||||
did = disk_entry.get("id") if isinstance(disk_entry, dict) else None
|
||||
@@ -912,12 +912,12 @@ def _update_root_pool_rows(
|
||||
|
||||
def persist_pool_entries(
|
||||
provider: str,
|
||||
payloads: List[Dict[str, Any]],
|
||||
payloads: list[dict[str, Any]],
|
||||
*,
|
||||
removed_ids: Optional[Iterable[str]] = None,
|
||||
status_cleared_ids: Optional[Iterable[str]] = None,
|
||||
token_bases: Optional[Dict[str, Tuple[Any, Any]]] = None,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
token_bases: Optional[dict[str, tuple[Any, Any]]] = None,
|
||||
) -> Optional[list[dict[str, Any]]]:
|
||||
"""Persist a provider's pool rows to the store that OWNS them.
|
||||
|
||||
A named profile that sees a single-use-refresh provider (see
|
||||
@@ -958,7 +958,7 @@ def persist_pool_entries(
|
||||
# (Codex, xAI): log names (sync-message form, "<name> OAuth" form),
|
||||
# ``hermes_cli.auth`` refresh function and terminal-error predicate (looked
|
||||
# up at call time so tests can patch them).
|
||||
_TOKENS_SINGLETON_PROVIDERS: Dict[str, Tuple[str, str, str, str]] = {
|
||||
_TOKENS_SINGLETON_PROVIDERS: dict[str, tuple[str, str, str, str]] = {
|
||||
"openai-codex": ("Codex", "Codex", "refresh_codex_oauth_pure", "_is_terminal_codex_oauth_refresh_error"),
|
||||
"xai-oauth": ("xAI OAuth", "xAI", "refresh_xai_oauth_pure", "_is_terminal_xai_oauth_refresh_error"),
|
||||
}
|
||||
@@ -1005,21 +1005,21 @@ class _RefreshDone(Exception):
|
||||
|
||||
|
||||
class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin):
|
||||
def __init__(self, provider: str, entries: List[PooledCredential]):
|
||||
def __init__(self, provider: str, entries: list[PooledCredential]):
|
||||
self.provider = provider
|
||||
self._entries = sorted(entries, key=lambda entry: entry.priority)
|
||||
self._current_id: Optional[str] = None
|
||||
# Ids of rows read via the global-root fallback (single-use OAuth
|
||||
# providers only); set by load_pool(), consumed by add_entry().
|
||||
self._borrowed_root_ids: Set[str] = set()
|
||||
self._persisted_token_pairs: Dict[str, Tuple[Any, Any]] = {}
|
||||
self._borrowed_root_ids: set[str] = set()
|
||||
self._persisted_token_pairs: dict[str, tuple[Any, Any]] = {}
|
||||
self._strategy = get_pool_strategy(provider)
|
||||
# RLock: _replace_entry/_persist self-acquire it so the DEFERRED
|
||||
# single-use-token refresh path (network I/O outside the lock by
|
||||
# design) still serializes its pool mutations; in-lock callers
|
||||
# re-acquire reentrantly.
|
||||
self._lock = threading.RLock()
|
||||
self._active_leases: Dict[str, int] = {}
|
||||
self._active_leases: dict[str, int] = {}
|
||||
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
|
||||
# Monotonic timestamp of the last "no available entries" log (see
|
||||
# NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS). Re-armed to None on every
|
||||
@@ -1093,7 +1093,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
)
|
||||
return min(candidates) if candidates else None
|
||||
|
||||
def entries(self) -> List[PooledCredential]:
|
||||
def entries(self) -> list[PooledCredential]:
|
||||
with self._lock:
|
||||
return list(self._entries)
|
||||
|
||||
@@ -1146,8 +1146,8 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
def _persist(
|
||||
self,
|
||||
*,
|
||||
removed_ids: Optional[List[str]] = None,
|
||||
status_cleared_ids: Optional[List[str]] = None,
|
||||
removed_ids: Optional[list[str]] = None,
|
||||
status_cleared_ids: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
# Self-locking: snapshotting self._entries must not race a rotation.
|
||||
with self._lock:
|
||||
@@ -1186,7 +1186,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
return self._find(lambda e: e.id == updated.id) or updated
|
||||
return updated
|
||||
|
||||
def _quarantine_sources(self, entry: PooledCredential, sources: Set[str]) -> None:
|
||||
def _quarantine_sources(self, entry: PooledCredential, sources: set[str]) -> None:
|
||||
"""Drop every entry seeded from *sources* and persist the removal.
|
||||
|
||||
Atomic read-modify-write of ``self._entries``: this runs on the
|
||||
@@ -1205,7 +1205,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
def _is_terminal_auth_failure(
|
||||
self,
|
||||
status_code: Optional[int],
|
||||
normalized_error: Dict[str, Any],
|
||||
normalized_error: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Detect upstream-permanent OAuth failures that won't recover on TTL.
|
||||
|
||||
@@ -1225,7 +1225,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
self,
|
||||
entry: PooledCredential,
|
||||
status_code: Optional[int],
|
||||
error_context: Optional[Dict[str, Any]] = None,
|
||||
error_context: Optional[dict[str, Any]] = None,
|
||||
*,
|
||||
persist: bool = True,
|
||||
failure_reason: Optional[str] = None,
|
||||
@@ -1412,7 +1412,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
"Pool entry %s: syncing %s tokens from auth.json (refreshed by another process)",
|
||||
entry.id, display,
|
||||
)
|
||||
field_updates: Dict[str, Any] = {
|
||||
field_updates: dict[str, Any] = {
|
||||
"access_token": store_access or entry.access_token,
|
||||
"refresh_token": store_refresh or entry.refresh_token,
|
||||
**_CLEAR_STATUS,
|
||||
@@ -1448,7 +1448,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
if not any(v not in (None, "") and getattr(entry, k, None) != v for k, v in comparable.items()):
|
||||
return entry
|
||||
logger.debug("Pool entry %s: syncing Nous state from auth.json", entry.id)
|
||||
field_updates: Dict[str, Any] = dict(_CLEAR_STATUS)
|
||||
field_updates: dict[str, Any] = dict(_CLEAR_STATUS)
|
||||
field_updates.update({k: v for k, v in comparable.items() if v})
|
||||
extra_updates = dict(entry.extra)
|
||||
extra_updates.update(
|
||||
@@ -1502,7 +1502,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)
|
||||
|
||||
def _apply_entry_to_singleton_state(self, entry: PooledCredential, state: Dict[str, Any]) -> bool:
|
||||
def _apply_entry_to_singleton_state(self, entry: PooledCredential, state: dict[str, Any]) -> bool:
|
||||
"""Copy *entry*'s tokens into the provider's auth.json ``state`` in place."""
|
||||
if self.provider == "nous":
|
||||
state["access_token"] = entry.access_token
|
||||
@@ -1635,7 +1635,6 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
"message": f"rotated credential was not durably written to {store}: {exc}",
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
def _single_use_refresh_lock_timeout(self) -> float:
|
||||
"""Configured refresh POST timeout plus margin, so a slow token endpoint cannot starve the flock."""
|
||||
@@ -1644,7 +1643,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
return max(float(auth_mod.AUTH_LOCK_TIMEOUT_SECONDS), float(refresh_timeout_seconds) + 5.0)
|
||||
|
||||
def _commit_anthropic_rotation(
|
||||
self, entry: PooledCredential, refreshed: Dict[str, Any]
|
||||
self, entry: PooledCredential, refreshed: dict[str, Any]
|
||||
) -> None:
|
||||
"""Write a rotated Anthropic pair to its authoritative singleton, or fail closed.
|
||||
|
||||
@@ -2010,11 +2009,11 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
self._unmatched_rotation_streak = 0
|
||||
return entry
|
||||
|
||||
def _select_under_lock(self, *, model: Optional[str] = None) -> Tuple[Optional[PooledCredential], List[PooledCredential]]:
|
||||
def _select_under_lock(self, *, model: Optional[str] = None) -> tuple[Optional[PooledCredential], list[PooledCredential]]:
|
||||
with self._lock:
|
||||
return self._select_unlocked(model=model)
|
||||
|
||||
def _refresh_pending_entries(self, pending: List[PooledCredential]) -> None:
|
||||
def _refresh_pending_entries(self, pending: list[PooledCredential]) -> None:
|
||||
"""Refresh deferred single-use-token entries OUTSIDE the pool lock.
|
||||
|
||||
Each refresh takes the cross-process ``_auth_store_lock`` (20+ s
|
||||
@@ -2071,7 +2070,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
|
||||
def _available_entries(
|
||||
self, *, clear_expired: bool = False, refresh: bool = False, model: Optional[str] = None,
|
||||
) -> Tuple[List[PooledCredential], List[PooledCredential]]:
|
||||
) -> tuple[list[PooledCredential], list[PooledCredential]]:
|
||||
"""Return (available, pending_refresh) for entries not in cooldown.
|
||||
|
||||
*clear_expired* resets elapsed cooldowns to STATUS_OK and persists.
|
||||
@@ -2083,9 +2082,9 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
"""
|
||||
now = time.time()
|
||||
cleared_any = False
|
||||
entries_to_prune: List[str] = []
|
||||
available: List[PooledCredential] = []
|
||||
pending_refresh: List[PooledCredential] = []
|
||||
entries_to_prune: list[str] = []
|
||||
available: list[PooledCredential] = []
|
||||
pending_refresh: list[PooledCredential] = []
|
||||
sole_credential = self._is_sole_credential()
|
||||
for entry in self._entries:
|
||||
# Borrowed credentials persist as metadata-only references and are
|
||||
@@ -2166,7 +2165,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
|
||||
def _select_unlocked(
|
||||
self, *, refresh: bool = True, count: bool = True, model: Optional[str] = None,
|
||||
) -> Tuple[Optional[PooledCredential], List[PooledCredential]]:
|
||||
) -> tuple[Optional[PooledCredential], list[PooledCredential]]:
|
||||
"""Select the best available entry; returns ``(entry, pending_refresh)``.
|
||||
|
||||
``count=False`` skips the ``request_count`` bump for selections that are
|
||||
@@ -2298,7 +2297,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
self,
|
||||
*,
|
||||
status_code: Optional[int],
|
||||
error_context: Optional[Dict[str, Any]] = None,
|
||||
error_context: Optional[dict[str, Any]] = None,
|
||||
api_key_hint: Optional[str] = None,
|
||||
credential_id: Optional[str] = None,
|
||||
failure_reason: Optional[str] = None,
|
||||
@@ -2394,7 +2393,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
|
||||
def _acquire_lease_under_lock(
|
||||
self, credential_id: Optional[str],
|
||||
) -> Tuple[Optional[str], List[PooledCredential]]:
|
||||
) -> tuple[Optional[str], list[PooledCredential]]:
|
||||
with self._lock:
|
||||
if credential_id:
|
||||
self._active_leases[credential_id] = self._active_leases.get(credential_id, 0) + 1
|
||||
@@ -2466,7 +2465,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
|
||||
# --- Seeding --------------------------------------------------------------
|
||||
|
||||
|
||||
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
|
||||
def _upsert_entry(entries: list[PooledCredential], provider: str, source: str, payload: dict[str, Any]) -> bool:
|
||||
matching_indices = [idx for idx, entry in enumerate(entries) if entry.source == source]
|
||||
existing_idx = matching_indices[0] if matching_indices else None
|
||||
duplicate_indices = set(matching_indices[1:])
|
||||
@@ -2481,8 +2480,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
|
||||
return True
|
||||
|
||||
existing = entries[existing_idx]
|
||||
field_updates: Dict[str, Any] = {}
|
||||
extra_updates: Dict[str, Any] = {}
|
||||
field_updates: dict[str, Any] = {}
|
||||
extra_updates: dict[str, Any] = {}
|
||||
_field_names = {f.name for f in fields(existing)}
|
||||
incoming_token = payload.get("access_token")
|
||||
token_changed = incoming_token is not None and incoming_token != existing.access_token
|
||||
@@ -2527,7 +2526,7 @@ _ANTHROPIC_SOURCE_RANK = {
|
||||
}
|
||||
|
||||
|
||||
def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool:
|
||||
def _normalize_pool_priorities(provider: str, entries: list[PooledCredential]) -> bool:
|
||||
if provider != "anthropic":
|
||||
return False
|
||||
manual_entries = sorted(
|
||||
@@ -2551,7 +2550,7 @@ def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -
|
||||
return changed
|
||||
|
||||
|
||||
def _retain_sources_not_in(entries: List[PooledCredential], drop: Set[str]) -> bool:
|
||||
def _retain_sources_not_in(entries: list[PooledCredential], drop: set[str]) -> bool:
|
||||
"""Remove entries whose source is in *drop*; True if anything was removed."""
|
||||
retained = [entry for entry in entries if entry.source not in drop]
|
||||
if len(retained) == len(entries):
|
||||
@@ -2563,14 +2562,14 @@ def _retain_sources_not_in(entries: List[PooledCredential], drop: Set[str]) -> b
|
||||
class _Seeder:
|
||||
"""Accumulates ``_upsert_entry`` results for one ``load_pool`` seeding pass."""
|
||||
|
||||
def __init__(self, provider: str, entries: List[PooledCredential]):
|
||||
def __init__(self, provider: str, entries: list[PooledCredential]):
|
||||
self.provider = provider
|
||||
self.entries = entries
|
||||
self.changed = False
|
||||
self.active_sources: Set[str] = set()
|
||||
self.active_sources: set[str] = set()
|
||||
self.is_suppressed = _is_source_suppressed_fn()
|
||||
|
||||
def upsert(self, source: str, payload: Dict[str, Any]) -> bool:
|
||||
def upsert(self, source: str, payload: dict[str, Any]) -> bool:
|
||||
"""Upsert unless suppressed (``hermes auth remove`` must stay stable across loads)."""
|
||||
if self.is_suppressed(self.provider, source):
|
||||
return False
|
||||
@@ -2580,7 +2579,7 @@ class _Seeder:
|
||||
return ingested
|
||||
|
||||
@property
|
||||
def result(self) -> Tuple[bool, Set[str]]:
|
||||
def result(self) -> tuple[bool, set[str]]:
|
||||
return self.changed, self.active_sources
|
||||
|
||||
|
||||
@@ -2638,7 +2637,7 @@ def _seed_anthropic_singletons(seed: _Seeder) -> None:
|
||||
})
|
||||
|
||||
|
||||
def _seed_nous_singleton(seed: _Seeder, auth_store: Dict[str, Any]) -> None:
|
||||
def _seed_nous_singleton(seed: _Seeder, auth_store: dict[str, Any]) -> None:
|
||||
state, source_path = _load_provider_state_with_source(auth_store, "nous")
|
||||
if owned_profile_reads_root_state(auth_store, "nous", source_path):
|
||||
# e.g. an agent_key-only row surviving a fork strip/heal
|
||||
@@ -2676,7 +2675,7 @@ def _seed_nous_singleton(seed: _Seeder, auth_store: Dict[str, Any]) -> None:
|
||||
|
||||
|
||||
# Warn once per token per process when Copilot exchange degrades to raw token (#114740).
|
||||
_COPILOT_RAW_DEGRADATION_WARNED: Set[str] = set()
|
||||
_COPILOT_RAW_DEGRADATION_WARNED: set[str] = set()
|
||||
|
||||
|
||||
def _warn_copilot_raw_degradation_once(token: str) -> None:
|
||||
@@ -2795,7 +2794,7 @@ def _seed_minimax_singleton(seed: _Seeder) -> None:
|
||||
logger.debug("MiniMax OAuth token seed failed: %s", exc)
|
||||
|
||||
|
||||
def _seed_tokens_singleton(seed: _Seeder, auth_store: Dict[str, Any]) -> None:
|
||||
def _seed_tokens_singleton(seed: _Seeder, auth_store: dict[str, Any]) -> None:
|
||||
"""Codex / xAI: surface the auth.json ``providers.<id>.tokens`` singleton as ``device_code``.
|
||||
|
||||
Hermes owns its own Codex auth state and does NOT auto-import
|
||||
@@ -2825,7 +2824,7 @@ def _seed_tokens_singleton(seed: _Seeder, auth_store: Dict[str, Any]) -> None:
|
||||
})
|
||||
|
||||
|
||||
def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
|
||||
def _seed_from_singletons(provider: str, entries: list[PooledCredential]) -> tuple[bool, set[str]]:
|
||||
seed = _Seeder(provider, entries)
|
||||
auth_store = _load_auth_store()
|
||||
if provider == "anthropic":
|
||||
@@ -2867,7 +2866,7 @@ def get_env_prefer_dotenv(key: str) -> str:
|
||||
|
||||
# Providers already warned about env-key -> pool ingestion, once per process
|
||||
# (#81952 expected-behavior #3).
|
||||
_ENV_INGESTION_WARNED: Set[str] = set()
|
||||
_ENV_INGESTION_WARNED: set[str] = set()
|
||||
|
||||
|
||||
def _warn_env_ingestion_once(provider: str, env_var: str) -> None:
|
||||
@@ -2892,8 +2891,8 @@ def _warn_env_ingestion_once(provider: str, env_var: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _env_payload(*, env_var: str, token: str, base_url: str) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
def _env_payload(*, env_var: str, token: str, base_url: str) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"auth_type": AUTH_TYPE_API_KEY,
|
||||
"access_token": token,
|
||||
"base_url": base_url,
|
||||
@@ -2917,7 +2916,7 @@ _ENV_BASE_URL_RESOLVERS = {
|
||||
}
|
||||
|
||||
|
||||
def _env_key_var_candidates(env_vars: List[str], entries: List[PooledCredential]) -> List[str]:
|
||||
def _env_key_var_candidates(env_vars: list[str], entries: list[PooledCredential]) -> list[str]:
|
||||
"""*env_vars*, their numbered siblings, and the ``env:VAR`` names already persisted.
|
||||
|
||||
``VAR_2``, ``VAR_3``, ... are tried for every declared VAR until the first
|
||||
@@ -2944,7 +2943,7 @@ def _env_key_var_candidates(env_vars: List[str], entries: List[PooledCredential]
|
||||
return names
|
||||
|
||||
|
||||
def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
|
||||
def _seed_from_env(provider: str, entries: list[PooledCredential]) -> tuple[bool, set[str]]:
|
||||
seed = _Seeder(provider, entries)
|
||||
# Copilot's singleton branch exchanges the raw ghu_ OAuth token for the
|
||||
# api token via `get_copilot_api_token`; the generic loop would re-read
|
||||
@@ -2989,8 +2988,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
|
||||
|
||||
def _prune_stale_seeded_entries(
|
||||
entries: List[PooledCredential],
|
||||
active_sources: Set[str],
|
||||
entries: list[PooledCredential],
|
||||
active_sources: set[str],
|
||||
*,
|
||||
prune_env_sources: bool = True,
|
||||
) -> bool:
|
||||
@@ -3015,7 +3014,7 @@ def _prune_stale_seeded_entries(
|
||||
return True
|
||||
|
||||
|
||||
def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
|
||||
def _seed_custom_pool(pool_key: str, entries: list[PooledCredential]) -> tuple[bool, set[str]]:
|
||||
"""Seed a custom endpoint pool from custom_providers config and model config."""
|
||||
seed = _Seeder(pool_key, entries)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class CredentialPoolAdminMixin:
|
||||
self._persist()
|
||||
return self._find(lambda e: e.id == credential_id)
|
||||
|
||||
def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCredential], Optional[str]]:
|
||||
def resolve_target(self, target: Any) -> tuple[Optional[int], Optional[PooledCredential], Optional[str]]:
|
||||
raw = str(target or "").strip()
|
||||
if not raw:
|
||||
return None, None, "No credential target provided."
|
||||
|
||||
@@ -32,9 +32,9 @@ def model_cooldown_until(entry: "PooledCredential", model: Optional[str]) -> Opt
|
||||
return max(active) if active else None
|
||||
|
||||
|
||||
def merge_model_cooldowns(*maps: Any) -> Dict[str, float]:
|
||||
def merge_model_cooldowns(*maps: Any) -> dict[str, float]:
|
||||
"""Latest reset per model across snapshots — each writer only observed its own model."""
|
||||
merged: Dict[str, float] = {}
|
||||
merged: dict[str, float] = {}
|
||||
for cooldowns in maps:
|
||||
if not isinstance(cooldowns, dict):
|
||||
continue
|
||||
@@ -75,7 +75,7 @@ class CredentialPoolModelCooldownMixin:
|
||||
)
|
||||
|
||||
def _cool_down_model(
|
||||
self, entry: "PooledCredential", model: str, error_context: Optional[Dict[str, Any]],
|
||||
self, entry: "PooledCredential", model: str, error_context: Optional[dict[str, Any]],
|
||||
failure_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record a cooldown for *model* on *entry* and every sibling sharing its key.
|
||||
@@ -103,7 +103,7 @@ class CredentialPoolModelCooldownMixin:
|
||||
self._adopt(scoped, persist=False, model_cooldowns=cooldowns)
|
||||
self._persist()
|
||||
|
||||
def limit_state(self, models: Iterable[str]) -> Optional[Dict[str, Any]]:
|
||||
def limit_state(self, models: Iterable[str]) -> Optional[dict[str, Any]]:
|
||||
"""What a picker should say about this pool's rate limits, or ``None`` when nothing is limited.
|
||||
|
||||
``{"scope": "account", "resets_at": epoch}`` when every live entry is benched credential-wide
|
||||
@@ -123,7 +123,7 @@ class CredentialPoolModelCooldownMixin:
|
||||
if live and len(benched) == len(live):
|
||||
return {"scope": "account", "resets_at": min(benched.values())}
|
||||
usable = [entry for entry in live if entry.id not in benched]
|
||||
cooled: Dict[str, float] = {}
|
||||
cooled: dict[str, float] = {}
|
||||
for model in models:
|
||||
waits = [model_cooldown_until(entry, model) for entry in usable]
|
||||
if waits and all(waits) and min(waits) - now < MODEL_ENTITLEMENT_BENCH_SECONDS / 2:
|
||||
|
||||
@@ -79,7 +79,7 @@ def is_terminal_plugin_refresh_error(exc: BaseException) -> bool:
|
||||
|
||||
def recover_failed_plugin_refresh(
|
||||
pool: "CredentialPool", entry: "PooledCredential", exc: Exception,
|
||||
) -> Tuple[bool, Optional["PooledCredential"]]:
|
||||
) -> tuple[bool, Optional["PooledCredential"]]:
|
||||
"""Recovery for a plugin hook that raised: adopt a peer's rotation, or quarantine a dead grant.
|
||||
|
||||
Returns ``(handled, result)``; ``handled=False`` means the caller should bench the row as a
|
||||
|
||||
@@ -59,8 +59,8 @@ class RemovalResult:
|
||||
``load_pool`` skips the source; only ``manual`` entries legitimately use False.
|
||||
"""
|
||||
|
||||
cleaned: List[str] = field(default_factory=list)
|
||||
hints: List[str] = field(default_factory=list)
|
||||
cleaned: list[str] = field(default_factory=list)
|
||||
hints: list[str] = field(default_factory=list)
|
||||
suppress: bool = True
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ def _suppress_only(*hints: str) -> Callable[..., RemovalResult]:
|
||||
# ORDER MATTERS — ``find_removal_step`` returns the first match. Provider-
|
||||
# specific steps precede the generic ``env:*`` step so copilot's ``env:GH_TOKEN``
|
||||
# takes the copilot path (no .env edits) rather than the generic env-var removal.
|
||||
_REGISTRY: List[RemovalStep] = [
|
||||
_REGISTRY: list[RemovalStep] = [
|
||||
RemovalStep(
|
||||
provider="copilot", source_id="gh_cli",
|
||||
match_fn=lambda src: src == "gh_cli" or src.startswith("env:"),
|
||||
|
||||
+54
-54
@@ -41,8 +41,8 @@ def _state_file() -> Path:
|
||||
return get_hermes_home() / "skills" / ".curator_state"
|
||||
|
||||
|
||||
def load_state() -> Dict[str, Any]:
|
||||
base: Dict[str, Any] = {
|
||||
def load_state() -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"last_run_at": None, "last_run_duration_seconds": None, "last_run_summary": None,
|
||||
"last_run_summary_shown_at": None, "last_report_path": None, "paused": False, "run_count": 0,
|
||||
}
|
||||
@@ -57,7 +57,7 @@ def load_state() -> Dict[str, Any]:
|
||||
return base
|
||||
|
||||
|
||||
def save_state(data: Dict[str, Any]) -> None:
|
||||
def save_state(data: dict[str, Any]) -> None:
|
||||
try:
|
||||
atomic_json_write(_state_file(), data, indent=2, sort_keys=True)
|
||||
except Exception as e:
|
||||
@@ -74,14 +74,14 @@ def is_paused() -> bool:
|
||||
|
||||
# --- Config access ---
|
||||
|
||||
def _subdict(node: Any, *keys: str) -> Dict[str, Any]:
|
||||
def _subdict(node: Any, *keys: str) -> dict[str, Any]:
|
||||
"""Walk nested dict keys; {} if any level is missing or not a dict."""
|
||||
for key in keys:
|
||||
node = node.get(key) if isinstance(node, dict) else None
|
||||
return node if isinstance(node, dict) else {}
|
||||
|
||||
|
||||
def _read_config_section(*path: str, label: str, log: logging.Logger = logger) -> Dict[str, Any]:
|
||||
def _read_config_section(*path: str, label: str, log: logging.Logger = logger) -> dict[str, Any]:
|
||||
"""Read a nested section of ~/.hermes/config.yaml. Tolerates missing file."""
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
@@ -92,7 +92,7 @@ def _read_config_section(*path: str, label: str, log: logging.Logger = logger) -
|
||||
return _subdict(cfg, *path)
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
def _load_config() -> dict[str, Any]:
|
||||
return _read_config_section("curator", label="curator")
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
|
||||
|
||||
# --- Automatic state transitions (pure function, no LLM) ---
|
||||
|
||||
def _cron_referenced_skills() -> Set[str]:
|
||||
def _cron_referenced_skills() -> set[str]:
|
||||
"""Skill names referenced by any cron job (incl. paused/disabled). Best-effort: a cron import error or corrupt jobs store yields an empty set, never a crash."""
|
||||
try:
|
||||
from cron.jobs import referenced_skill_names as _refs
|
||||
@@ -206,7 +206,7 @@ def _archive_as_curator(_u, name: str) -> bool:
|
||||
reset_ledger_actor(tok)
|
||||
|
||||
|
||||
def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
|
||||
def apply_automatic_transitions(now: Optional[datetime] = None) -> dict[str, int]:
|
||||
"""Move every curator-managed skill between active/stale/archived based on its latest real activity; pinned skills are
|
||||
never touched. Built-ins are seeded with a baseline record on first sight so their inactivity clock starts NOW, not at epoch.
|
||||
Returns a counter dict."""
|
||||
@@ -487,7 +487,7 @@ def _needle_in_path_component(needle: str, path: str) -> bool:
|
||||
return any(part and part.rsplit(".", 1)[0].replace("-", "_") == norm_needle for part in path.replace("\\", "/").split("/"))
|
||||
|
||||
|
||||
def _skill_manage_args(tc: Any, *, raw_fallback: bool) -> Optional[Dict[str, Any]]:
|
||||
def _skill_manage_args(tc: Any, *, raw_fallback: bool) -> Optional[dict[str, Any]]:
|
||||
"""Parsed arguments of a ``skill_manage`` tool call (JSON string or dict), or None to skip. With *raw_fallback*,
|
||||
a malformed string yields ``{"_raw": raw}`` so substring matching still catches the common case."""
|
||||
if not isinstance(tc, dict) or tc.get("name") != "skill_manage":
|
||||
@@ -502,7 +502,7 @@ def _skill_manage_args(tc: Any, *, raw_fallback: bool) -> Optional[Dict[str, Any
|
||||
return args if isinstance(args, dict) else None
|
||||
|
||||
|
||||
def _find_reference(args: Dict[str, Any], needles: Set[str]) -> Optional[str]:
|
||||
def _find_reference(args: dict[str, Any], needles: set[str]) -> Optional[str]:
|
||||
"""First argument value (file_path, file_content, content, new_string, _raw — in that order) that references one of
|
||||
*needles*. ``file_path`` must match a whole path component; content fields match on word boundaries so "test" does not match "latest"."""
|
||||
for key in ("file_path", "file_content", "content", "new_string", "_raw"):
|
||||
@@ -515,13 +515,13 @@ def _find_reference(args: Dict[str, Any], needles: Set[str]) -> Optional[str]:
|
||||
|
||||
|
||||
def _classify_removed_skills(
|
||||
removed: List[str], added: List[str], after_names: Set[str], tool_calls: List[Dict[str, Any]],
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
removed: list[str], added: list[str], after_names: set[str], tool_calls: list[dict[str, Any]],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Split ``removed`` into consolidated vs pruned. Heuristic: a ``skill_manage`` call on a DIFFERENT, surviving-or-new
|
||||
skill whose file_path/content arguments reference the removed name is the "absorbed" signal; earliest match wins.
|
||||
Returns ``{"consolidated": [{name, into, evidence}], "pruned": [{name}]}``."""
|
||||
consolidated: List[Dict[str, Any]] = []
|
||||
pruned: List[Dict[str, Any]] = []
|
||||
consolidated: list[dict[str, Any]] = []
|
||||
pruned: list[dict[str, Any]] = []
|
||||
parsed_calls = [a for a in (_skill_manage_args(tc, raw_fallback=True) for tc in tool_calls or []) if a is not None]
|
||||
destinations = set(after_names) | set(added or [])
|
||||
for name in filter(None, removed):
|
||||
@@ -541,7 +541,7 @@ def _classify_removed_skills(
|
||||
return {"consolidated": consolidated, "pruned": pruned}
|
||||
|
||||
|
||||
def _parse_structured_summary(llm_final: str) -> Dict[str, List[Dict[str, str]]]:
|
||||
def _parse_structured_summary(llm_final: str) -> dict[str, list[dict[str, str]]]:
|
||||
"""Extract the required fenced ```yaml block (``consolidations:`` / ``prunings:`` lists) from the curator's final
|
||||
response. Tolerant: missing block or malformed YAML → empty lists (caller falls back to the tool-call heuristic); a partial
|
||||
block returns what parsed. Returns ``{"consolidations": [{from, into, reason}], "prunings": [{name, reason}]}``."""
|
||||
@@ -557,7 +557,7 @@ def _parse_structured_summary(llm_final: str) -> Dict[str, List[Dict[str, str]]]
|
||||
if not isinstance(data, dict):
|
||||
return {"consolidations": [], "prunings": []}
|
||||
|
||||
def _entries(key: str, *fields: str) -> List[Dict[str, str]]:
|
||||
def _entries(key: str, *fields: str) -> list[dict[str, str]]:
|
||||
raw = data.get(key) or []
|
||||
entries = (e for e in raw if isinstance(e, dict)) if isinstance(raw, list) else ()
|
||||
cleaned = ({f: (v.strip() if isinstance((v := e.get(f)), str) else "") for f in (*fields, "reason")} for e in entries)
|
||||
@@ -566,11 +566,11 @@ def _parse_structured_summary(llm_final: str) -> Dict[str, List[Dict[str, str]]]
|
||||
return {"consolidations": _entries("consolidations", "from", "into"), "prunings": _entries("prunings", "name")}
|
||||
|
||||
|
||||
def _extract_absorbed_into_declarations(tool_calls: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
def _extract_absorbed_into_declarations(tool_calls: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Model-declared absorption targets from ``skill_manage(action='delete')`` calls — the authoritative classification
|
||||
signal (beats YAML parsing and substring heuristics). Returns ``{name: {"into": umbrella | "", "declared": True}}``;
|
||||
``into == ""`` is an explicit prune. Deletes omitting ``absorbed_into`` are absent so the caller falls back to heuristic/YAML (older runs)."""
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for args in (_skill_manage_args(tc, raw_fallback=False) for tc in tool_calls or []):
|
||||
if args is not None and args.get("action") == "delete":
|
||||
name, target = args.get("name"), args.get("absorbed_into")
|
||||
@@ -580,9 +580,9 @@ def _extract_absorbed_into_declarations(tool_calls: List[Dict[str, Any]]) -> Dic
|
||||
|
||||
|
||||
def _reconcile_classification(
|
||||
removed: List[str], heuristic: Dict[str, List[Dict[str, Any]]], model_block: Dict[str, List[Dict[str, str]]],
|
||||
destinations: Set[str], absorbed_declarations: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
removed: list[str], heuristic: dict[str, list[dict[str, Any]]], model_block: dict[str, list[dict[str, str]]],
|
||||
destinations: set[str], absorbed_declarations: Optional[dict[str, dict[str, Any]]] = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Merge heuristic (tool-call evidence) with the model's structured block.
|
||||
First match wins; every removed skill lands in exactly one bucket:
|
||||
- ``absorbed_into`` declared at delete is authoritative: existing target → consolidated; ``""`` → pruned; missing target → fall through.
|
||||
@@ -594,8 +594,8 @@ def _reconcile_classification(
|
||||
model_cons = {e["from"]: e for e in model_block.get("consolidations", [])}
|
||||
model_pruned = {e["name"]: e for e in model_block.get("prunings", [])}
|
||||
declared = absorbed_declarations or {}
|
||||
consolidated: List[Dict[str, Any]] = []
|
||||
pruned: List[Dict[str, Any]] = []
|
||||
consolidated: list[dict[str, Any]] = []
|
||||
pruned: list[dict[str, Any]] = []
|
||||
for name in removed:
|
||||
mc, mp, hc, dec = model_cons.get(name), model_pruned.get(name), heur_cons.get(name), declared.get(name)
|
||||
mc_reason = (mc.get("reason") or "") if mc else ""
|
||||
@@ -626,14 +626,14 @@ def _reconcile_classification(
|
||||
|
||||
|
||||
class _RunDiff(NamedTuple):
|
||||
after_names: Set[str]
|
||||
removed: List[str]
|
||||
added: List[str]
|
||||
consolidated: List[Dict[str, Any]]
|
||||
pruned: List[Dict[str, Any]]
|
||||
after_names: set[str]
|
||||
removed: list[str]
|
||||
added: list[str]
|
||||
consolidated: list[dict[str, Any]]
|
||||
pruned: list[dict[str, Any]]
|
||||
|
||||
|
||||
def _diff_and_classify(before_names: Set[str], after_names: Set[str], tool_calls: List[Dict[str, Any]], model_final: str) -> _RunDiff:
|
||||
def _diff_and_classify(before_names: set[str], after_names: set[str], tool_calls: list[dict[str, Any]], model_final: str) -> _RunDiff:
|
||||
"""Diff the before/after skill sets and classify every removal: the model's YAML block carries intent + rationale,
|
||||
the tool-call heuristic audits for hallucinated umbrellas/omissions, per-delete ``absorbed_into`` beats both."""
|
||||
removed, added = sorted(before_names - after_names), sorted(after_names - before_names)
|
||||
@@ -646,11 +646,11 @@ def _diff_and_classify(before_names: Set[str], after_names: Set[str], tool_calls
|
||||
return _RunDiff(after_names, removed, added, classification["consolidated"], classification["pruned"])
|
||||
|
||||
|
||||
def _by_name(report: List[Dict[str, Any]]) -> Dict[Any, Dict[str, Any]]:
|
||||
def _by_name(report: list[dict[str, Any]]) -> dict[Any, dict[str, Any]]:
|
||||
return {r.get("name"): r for r in report if isinstance(r, dict)}
|
||||
|
||||
|
||||
def _build_rename_summary(*, before_names: Set[str], after_report: List[Dict[str, Any]], tool_calls: List[Dict[str, Any]], model_final: str) -> str:
|
||||
def _build_rename_summary(*, before_names: set[str], after_report: list[dict[str, Any]], tool_calls: list[dict[str, Any]], model_final: str) -> str:
|
||||
"""The "where did my skills go?" lines appended to the user-visible ``final_summary``; "" when nothing was archived.
|
||||
Capped at 10 entries so a big consolidation doesn't flood agent.log (full list is in REPORT.md); the pin hint
|
||||
appears only when a consolidation produced an umbrella."""
|
||||
@@ -672,7 +672,7 @@ def _build_rename_summary(*, before_names: Set[str], after_report: List[Dict[str
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _rewrite_cron_refs(consolidated: List[Dict[str, Any]], pruned: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
def _rewrite_cron_refs(consolidated: list[dict[str, Any]], pruned: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Point cron jobs at the umbrella when the curator consolidated a skill they list — otherwise the scheduler fails to
|
||||
load it and the job runs without its instructions. Best-effort: a cron-module issue never breaks the curator."""
|
||||
try:
|
||||
@@ -697,8 +697,8 @@ def _write_file(path: Path, label: str, render: Any) -> None:
|
||||
|
||||
|
||||
def _write_run_report(
|
||||
*, started_at: datetime, elapsed_seconds: float, auto_counts: Dict[str, int], auto_summary: str,
|
||||
before_report: List[Dict[str, Any]], before_names: Set[str], after_report: List[Dict[str, Any]], llm_meta: Dict[str, Any],
|
||||
*, started_at: datetime, elapsed_seconds: float, auto_counts: dict[str, int], auto_summary: str,
|
||||
before_report: list[dict[str, Any]], before_names: set[str], after_report: list[dict[str, Any]], llm_meta: dict[str, Any],
|
||||
) -> Optional[Path]:
|
||||
"""Write run.json + REPORT.md under logs/curator/{YYYYMMDD-HHMMSS}[-N]/ (N disambiguates a crash-rerun in the same
|
||||
second). Returns the report dir, or None if it couldn't be created (reporting is best-effort)."""
|
||||
@@ -717,7 +717,7 @@ def _write_run_report(
|
||||
diff = _diff_and_classify(before_names, set(after_by_name), tool_calls, llm_meta.get("final", "") or "")
|
||||
states = ((n, (before_by_name.get(n) or {}).get("state"), (after_by_name.get(n) or {}).get("state")) for n in sorted(diff.after_names & before_names))
|
||||
transitions = [{"name": n, "from": b, "to": a} for n, b, a in states if b and a and b != a]
|
||||
tc_counts: Dict[str, int] = dict(Counter(tc.get("name", "unknown") for tc in tool_calls))
|
||||
tc_counts: dict[str, int] = dict(Counter(tc.get("name", "unknown") for tc in tool_calls))
|
||||
cron_rewrites = _rewrite_cron_refs(diff.consolidated, diff.pruned)
|
||||
jobs_updated = int(cron_rewrites.get("jobs_updated", 0))
|
||||
payload = {
|
||||
@@ -741,11 +741,11 @@ def _write_run_report(
|
||||
return run_dir
|
||||
|
||||
|
||||
def _reason_suffix(entry: Dict[str, Any]) -> str:
|
||||
def _reason_suffix(entry: dict[str, Any]) -> str:
|
||||
return f" — {reason}" if (reason := (entry.get("reason") or "").strip()) else ""
|
||||
|
||||
|
||||
def _consolidated_lines(entry: Dict[str, Any]) -> List[str]:
|
||||
def _consolidated_lines(entry: dict[str, Any]) -> list[str]:
|
||||
line = f"- `{entry.get('name', '?')}` → merged into `{entry.get('into', '?')}`" + _reason_suffix(entry)
|
||||
source = entry.get("source", "")
|
||||
if source and source.startswith("tool-call audit"):
|
||||
@@ -755,12 +755,12 @@ def _consolidated_lines(entry: Dict[str, Any]) -> List[str]:
|
||||
if entry.get("model_claimed_into") else [])
|
||||
|
||||
|
||||
def _pruned_lines(entry: Any) -> List[str]:
|
||||
def _pruned_lines(entry: Any) -> list[str]:
|
||||
# Reconciler entries are dicts {name, source, reason}; tolerate bare strings (older format).
|
||||
return [f"- `{entry.get('name', '?')}`" + _reason_suffix(entry) if isinstance(entry, dict) else f"- `{entry}`"]
|
||||
|
||||
|
||||
def _cron_rewrite_lines(entry: Dict[str, Any]) -> List[str]:
|
||||
def _cron_rewrite_lines(entry: dict[str, Any]) -> list[str]:
|
||||
job_name = entry.get("job_name") or entry.get("job_id") or "?"
|
||||
head = f"- `{job_name}`: `{', '.join(entry.get('before') or [])}` → `{', '.join(entry.get('after') or []) or '(none)'}`"
|
||||
return ([head] + [f" - `{old}` → `{new}` (consolidated)" for old, new in (entry.get("mapped") or {}).items()]
|
||||
@@ -787,7 +787,7 @@ _REPORT_SECTIONS = (
|
||||
)
|
||||
|
||||
|
||||
def _render_report_markdown(p: Dict[str, Any]) -> str:
|
||||
def _render_report_markdown(p: dict[str, Any]) -> str:
|
||||
"""Render the human-readable REPORT.md."""
|
||||
mins, secs = divmod(int(p.get("duration_seconds", 0) or 0), 60)
|
||||
dur_label = f"{mins}m {secs}s" if mins else f"{secs}s"
|
||||
@@ -861,7 +861,7 @@ def _render_candidate_list() -> str:
|
||||
])
|
||||
|
||||
|
||||
def _llm_meta(summary: str, error: Optional[str] = None) -> Dict[str, Any]:
|
||||
def _llm_meta(summary: str, error: Optional[str] = None) -> dict[str, Any]:
|
||||
"""Structured result of an LLM pass that did not run (skipped or failed)."""
|
||||
return {"final": "", "summary": summary, "model": "", "provider": "", "tool_calls": [], "error": error}
|
||||
|
||||
@@ -872,13 +872,13 @@ def _notify(on_summary: Optional[Callable[[str], None]], message: str) -> None:
|
||||
on_summary(message)
|
||||
|
||||
|
||||
def _safe_curated_report() -> List[Dict[str, Any]]:
|
||||
def _safe_curated_report() -> list[dict[str, Any]]:
|
||||
with contextlib.suppress(Exception):
|
||||
return skill_usage.curated_report()
|
||||
return []
|
||||
|
||||
|
||||
def _consolidation_pass(prefix: str, auto_summary: str, dry_run: bool, before_names: Set[str]) -> tuple:
|
||||
def _consolidation_pass(prefix: str, auto_summary: str, dry_run: bool, before_names: set[str]) -> tuple:
|
||||
"""The LLM half of a run: fork (unless no candidates), then append the rename map (`old-name → umbrella`) so users
|
||||
needn't dig into REPORT.md. Returns ``(final_summary, llm_meta)``; never raises."""
|
||||
try:
|
||||
@@ -915,7 +915,7 @@ def _consolidation_pass(prefix: str, auto_summary: str, dry_run: bool, before_na
|
||||
def run_curator_review(
|
||||
on_summary: Optional[Callable[[str], None]] = None, synchronous: bool = False,
|
||||
dry_run: bool = False, consolidate: Optional[bool] = None, trigger: str = "manual",
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a single curator review pass: (1) automatic state transitions (no LLM); (2) if *consolidate* and there are
|
||||
candidates, fork an AIAgent on the review prompt; (3) update .curator_state; (4) call *on_summary*.
|
||||
*synchronous* runs the LLM review in the calling thread (default: daemon thread). *consolidate* ``None`` reads
|
||||
@@ -1005,7 +1005,7 @@ def run_curator_review(
|
||||
def _record_run_metric(trigger, dry_run, counts, before_report, after_report, llm_meta, hermes_home) -> None:
|
||||
from hermes_cli.observability.shared_metrics_loop import record_curator_run
|
||||
|
||||
def what_changed() -> Dict[str, int]:
|
||||
def what_changed() -> dict[str, int]:
|
||||
before, after = _by_name(before_report), _by_name(after_report)
|
||||
diff = _diff_and_classify(set(before), set(after), llm_meta.get("tool_calls") or [], llm_meta.get("final") or "")
|
||||
patched = sum(int(after[n].get("patch_count") or 0) > int(before[n].get("patch_count") or 0)
|
||||
@@ -1023,10 +1023,10 @@ class _ReviewRuntimeBinding(NamedTuple):
|
||||
model: str
|
||||
explicit_api_key: Optional[str]
|
||||
explicit_base_url: Optional[str]
|
||||
request_overrides: Dict[str, Any]
|
||||
request_overrides: dict[str, Any]
|
||||
|
||||
|
||||
def _merge_request_overrides(runtime_overrides: Any, slot_extra_body: Any) -> Dict[str, Any]:
|
||||
def _merge_request_overrides(runtime_overrides: Any, slot_extra_body: Any) -> dict[str, Any]:
|
||||
"""Merge resolver metadata with task-local request body fields."""
|
||||
merged = dict(runtime_overrides or {})
|
||||
if isinstance(slot_extra_body, dict) and slot_extra_body:
|
||||
@@ -1034,13 +1034,13 @@ def _merge_request_overrides(runtime_overrides: Any, slot_extra_body: Any) -> Di
|
||||
return merged
|
||||
|
||||
|
||||
def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
|
||||
def _resolve_review_runtime(cfg: dict[str, Any]) -> _ReviewRuntimeBinding:
|
||||
"""Curator is a regular auxiliary task slot (``auxiliary.curator.*``), so it rides the canonical aux-model plumbing. Precedence:
|
||||
1. ``auxiliary.curator.{provider,model}`` when both are set non-auto
|
||||
2. Legacy ``curator.auxiliary.{provider,model}`` (deprecated) when both set
|
||||
3. Main ``model.{provider,default/model}`` pair ("auto" + "" = main chat model)
|
||||
Non-empty slot ``api_key``/``base_url`` are returned as explicit overrides so ``resolve_runtime_provider`` doesn't reuse the main chat credential chain."""
|
||||
def _slot(provider: str, model: str, slot: Dict[str, Any]) -> _ReviewRuntimeBinding:
|
||||
def _slot(provider: str, model: str, slot: dict[str, Any]) -> _ReviewRuntimeBinding:
|
||||
api_key, base_url = ((str(v).strip() or None) if v is not None else None for v in (slot.get("api_key"), slot.get("base_url")))
|
||||
return _ReviewRuntimeBinding(provider, model, api_key, base_url, _merge_request_overrides({}, slot.get("extra_body")))
|
||||
|
||||
@@ -1061,7 +1061,7 @@ def _resolve_review_provider() -> tuple:
|
||||
"""``(runtime_provider, model_name, provider_name, request_overrides)`` resolved the way the CLI does: AIAgent() without
|
||||
explicit provider/model hits an auto-resolution path that fails for OAuth-only providers and pooled credentials
|
||||
(HTTP 400 "No models provided"). Never raises."""
|
||||
rp: Dict[str, Any] = {}
|
||||
rp: dict[str, Any] = {}
|
||||
overrides, provider, model_name, binding = {}, None, "", None
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
@@ -1082,10 +1082,10 @@ def _resolve_review_provider() -> tuple:
|
||||
return rp, model_name, provider, overrides
|
||||
|
||||
|
||||
def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
def _run_llm_review(prompt: str) -> dict[str, Any]:
|
||||
"""Spawn an AIAgent fork on the review prompt. Returns ``final`` (untruncated response), ``summary`` (240-char cap),
|
||||
``model``/``provider`` (what ran), ``tool_calls`` ([{name, arguments}], truncated) and ``error``. Never raises."""
|
||||
result_meta: Dict[str, Any] = _llm_meta("")
|
||||
result_meta: dict[str, Any] = _llm_meta("")
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
except Exception as e:
|
||||
@@ -1095,7 +1095,7 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
result_meta["model"], result_meta["provider"] = model_name, provider or ""
|
||||
review_agent = None
|
||||
try:
|
||||
agent_kwargs: Dict[str, Any] = {}
|
||||
agent_kwargs: dict[str, Any] = {}
|
||||
acp_command = rp.get("command")
|
||||
if isinstance(acp_command, str) and acp_command:
|
||||
agent_kwargs.update(acp_command=acp_command, acp_args=list(rp.get("args") or []))
|
||||
@@ -1195,7 +1195,7 @@ def _release_run_claim() -> None:
|
||||
_run_claim_path().unlink()
|
||||
|
||||
|
||||
def maybe_run_curator(*, idle_for_seconds: Optional[float] = None, on_summary: Optional[Callable[[str], None]] = None) -> Optional[Dict[str, Any]]:
|
||||
def maybe_run_curator(*, idle_for_seconds: Optional[float] = None, on_summary: Optional[Callable[[str], None]] = None) -> Optional[dict[str, Any]]:
|
||||
"""Best-effort: run a curator pass if all gates pass. Returns the result dict if a pass was started, else None. Never raises."""
|
||||
try:
|
||||
# Idle gating: only enforce when the caller provided a measurement.
|
||||
|
||||
+25
-25
@@ -74,11 +74,11 @@ def _jobs_list(parsed: Any) -> Optional[list]:
|
||||
return parsed if isinstance(parsed, list) else None
|
||||
|
||||
|
||||
def _backup_cron_jobs_into(dest: Path) -> Dict[str, Any]:
|
||||
def _backup_cron_jobs_into(dest: Path) -> dict[str, Any]:
|
||||
"""Copy the live ``~/.hermes/cron/jobs.json`` into ``dest`` as ``cron-jobs.json``. Never raises: a missing/unreadable
|
||||
file yields ``backed_up=False`` plus a reason, and the snapshot proceeds."""
|
||||
src = get_hermes_home() / "cron" / "jobs.json"
|
||||
info: Dict[str, Any] = {"backed_up": False, "jobs_count": 0}
|
||||
info: dict[str, Any] = {"backed_up": False, "jobs_count": 0}
|
||||
if not src.exists():
|
||||
return {**info, "reason": "no cron/jobs.json present"}
|
||||
try:
|
||||
@@ -106,7 +106,7 @@ def _utc_id(now: Optional[datetime] = None) -> str:
|
||||
return s.removesuffix("+00:00").replace(":", "-") + "Z"
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
def _load_config() -> dict[str, Any]:
|
||||
return _read_config_section("curator", "backup", label="curator backup", log=logger)
|
||||
|
||||
|
||||
@@ -130,8 +130,8 @@ def _count_skill_files(base: Path) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _write_manifest(dest: Path, reason: str, archive_path: Path, skills_counted: int, cron_info: Dict[str, Any]) -> None:
|
||||
cron_jobs: Dict[str, Any] = {"backed_up": bool(cron_info.get("backed_up", False)), "jobs_count": int(cron_info.get("jobs_count", 0))}
|
||||
def _write_manifest(dest: Path, reason: str, archive_path: Path, skills_counted: int, cron_info: dict[str, Any]) -> None:
|
||||
cron_jobs: dict[str, Any] = {"backed_up": bool(cron_info.get("backed_up", False)), "jobs_count": int(cron_info.get("jobs_count", 0))}
|
||||
if not cron_info.get("backed_up"):
|
||||
cron_jobs["reason"] = cron_info.get("reason", "not captured")
|
||||
if cron_info.get("parse_warning"):
|
||||
@@ -150,7 +150,7 @@ def _mkdir(path: Path, what: str, *, exist_ok: bool) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]:
|
||||
def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[set[str]] = None) -> Optional[Path]:
|
||||
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones. Returns the snapshot dir, or None when
|
||||
skipped (disabled, skills dir missing, IO error) — logged at debug so the curator never aborts a pass over a
|
||||
backup failure. ``protect_ids`` survive the prune step (rollback protects its target)."""
|
||||
@@ -192,12 +192,12 @@ def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] =
|
||||
return dest
|
||||
|
||||
|
||||
def prune_old_snapshots() -> List[str]:
|
||||
def prune_old_snapshots() -> list[str]:
|
||||
"""Apply ``curator.backup.keep`` without taking a new snapshot (the prune-only curator pass)."""
|
||||
return _prune_old(keep=get_keep())
|
||||
|
||||
|
||||
def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
|
||||
def _prune_old(keep: int, protect: Optional[set[str]] = None) -> list[str]:
|
||||
"""Delete regular snapshots beyond the newest *keep*; returns deleted ids. Ids in *protect* are never deleted —
|
||||
rollback() uses this so the mandatory pre-rollback safety snapshot cannot evict the snapshot being restored.
|
||||
Stale ``.rollback-staging-*`` dirs (crashed rollback) are cleaned up on every call; ``.rollback-unrestored-*`` is kept on purpose."""
|
||||
@@ -210,7 +210,7 @@ def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
|
||||
entries = sorted((c for c in dirs if _ID_RE.match(c.name)), key=lambda c: c.name, reverse=True)
|
||||
doomed = [(p, "prune") for p in entries[keep:] if p.name not in protect]
|
||||
doomed += [(p, "clean stale staging dir") for p in dirs if p.name.startswith(_STAGING_PREFIX)]
|
||||
deleted: List[str] = []
|
||||
deleted: list[str] = []
|
||||
for path, what in doomed:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
@@ -222,7 +222,7 @@ def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
|
||||
|
||||
|
||||
# --- List + rollback ---
|
||||
def _read_manifest(snap_dir: Path) -> Dict[str, Any]:
|
||||
def _read_manifest(snap_dir: Path) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads((snap_dir / "manifest.json").read_text(encoding="utf-8-sig"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
@@ -234,15 +234,15 @@ def _is_restorable(child: Path) -> bool:
|
||||
return bool(child.is_dir() and _ID_RE.match(child.name) and (child / _ARCHIVE_NAME).exists())
|
||||
|
||||
|
||||
def _restorable_snapshots() -> List[Path]:
|
||||
def _restorable_snapshots() -> list[Path]:
|
||||
"""Restorable snapshot dirs, newest first."""
|
||||
backups = _backups_dir()
|
||||
return [c for c in sorted(backups.iterdir(), reverse=True) if _is_restorable(c)] if backups.exists() else []
|
||||
|
||||
|
||||
def list_backups() -> List[Dict[str, Any]]:
|
||||
def list_backups() -> list[dict[str, Any]]:
|
||||
"""All restorable snapshots (manifest dicts), newest first."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
out: list[dict[str, Any]] = []
|
||||
for child in _restorable_snapshots():
|
||||
mf = {"id": child.name, "path": str(child), **_read_manifest(child)}
|
||||
try:
|
||||
@@ -261,12 +261,12 @@ def _resolve_backup(backup_id: Optional[str]) -> Optional[Path]:
|
||||
return next(iter(_restorable_snapshots()), None)
|
||||
|
||||
|
||||
def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
|
||||
def _restore_cron_skill_links(snapshot_dir: Path) -> dict[str, Any]:
|
||||
"""Reconcile backed-up cron skill links into the live ``cron/jobs.json``. Only ``skills``/``skill`` are restored,
|
||||
and only on jobs that still exist live (by ``id``) — everything else is live state. Backup-only jobs are skipped
|
||||
and reported; live-only jobs untouched. Never raises; writes through ``cron.jobs`` under the scheduler's lock so
|
||||
we don't race tick()."""
|
||||
report: Dict[str, Any] = {"attempted": False, "restored": [], "skipped_missing": [], "unchanged": 0, "error": None}
|
||||
report: dict[str, Any] = {"attempted": False, "restored": [], "skipped_missing": [], "unchanged": 0, "error": None}
|
||||
backup_file = snapshot_dir / CRON_JOBS_FILENAME
|
||||
if not backup_file.exists():
|
||||
return {**report, "error": f"snapshot has no {CRON_JOBS_FILENAME}"}
|
||||
@@ -278,7 +278,7 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
|
||||
return {**report, "error": "backed-up cron-jobs.json has no jobs list"}
|
||||
|
||||
# Backed-up skill state keyed by job id (legacy single + modern list field).
|
||||
backup_by_id: Dict[str, Dict[str, Any]] = {
|
||||
backup_by_id: dict[str, dict[str, Any]] = {
|
||||
job["id"]: {"skills": job.get("skills"), "skill": job.get("skill"), "name": job.get("name") or job["id"]}
|
||||
for job in backup_jobs if isinstance(job, dict) and isinstance(job.get("id"), str) and job.get("id")
|
||||
}
|
||||
@@ -320,7 +320,7 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
|
||||
for jid, b in backup_by_id.items() if jid not in live_ids]
|
||||
if changed:
|
||||
save_jobs(live_jobs)
|
||||
except Exception as e: # noqa: BLE001 — rollback must not die mid-restore
|
||||
except Exception as e:
|
||||
logger.debug("Cron skill-link restore failed: %s", e, exc_info=True)
|
||||
report["error"] = f"restore failed mid-flight: {e}"
|
||||
return report
|
||||
@@ -333,7 +333,7 @@ def _remove_entry(entry: Path) -> None:
|
||||
entry.unlink()
|
||||
|
||||
|
||||
def _restore_excluded_subtrees(staged: Path, skills: Path) -> List[str]:
|
||||
def _restore_excluded_subtrees(staged: Path, skills: Path) -> list[str]:
|
||||
"""Move excluded entries (nested ``.git``/``.hub``/...) from *staged* back under *skills* after a successful extract.
|
||||
Snapshots never contain these, so the staged copy of the live tree is the only source. ``.git`` may be a dir or a file
|
||||
(submodule / worktree ``gitdir:`` pointer) — both are moved. Best-effort and conditional: an entry is carried only when
|
||||
@@ -342,7 +342,7 @@ def _restore_excluded_subtrees(staged: Path, skills: Path) -> List[str]:
|
||||
Returns entries whose move failed, so their only surviving copies stay staged for recovery."""
|
||||
from tools.skill_ledger import TRANSIENT_DIRS
|
||||
|
||||
failed: List[str] = []
|
||||
failed: list[str] = []
|
||||
for dirpath, dirnames, filenames in os.walk(staged):
|
||||
carried = [Path(dirpath) / n for n in filenames if n in _EXCLUDE_TOP_LEVEL]
|
||||
carried += [Path(dirpath) / n for n in dirnames if n in _EXCLUDE_TOP_LEVEL or n in TRANSIENT_DIRS]
|
||||
@@ -358,7 +358,7 @@ def _restore_excluded_subtrees(staged: Path, skills: Path) -> List[str]:
|
||||
return failed
|
||||
|
||||
|
||||
def _retain_staging(staged: Path, unrestored: List[str], reason: str) -> Tuple[bool, str, None]:
|
||||
def _retain_staging(staged: Path, unrestored: list[str], reason: str) -> tuple[bool, str, None]:
|
||||
"""Keep a staging dir that still holds the only copy of *unrestored* entries and build rollback()'s failure result.
|
||||
The dir is renamed out of the prunable prefix (unique suffix on a same-second collision). If the rename fails, the
|
||||
original path is reported so recovery never raises out of rollback()."""
|
||||
@@ -375,11 +375,11 @@ def _retain_staging(staged: Path, unrestored: List[str], reason: str) -> Tuple[b
|
||||
return (False, f"{reason} - could not restore {', '.join(sorted(unrestored))}; staged copies kept at {kept}{note}", None)
|
||||
|
||||
|
||||
def _unstage(moved: List[Tuple[Path, Path]]) -> List[str]:
|
||||
def _unstage(moved: list[tuple[Path, Path]]) -> list[str]:
|
||||
"""Move staged entries back to their original paths; returns names that could not be restored. ``shutil.move``
|
||||
moves *into* an existing destination dir, so partial-extract debris would bury the real skill
|
||||
(``skills/foo/foo/``) — clear each original path first. The staged copy is authoritative."""
|
||||
failed: List[str] = []
|
||||
failed: list[str] = []
|
||||
for orig, dest in moved:
|
||||
try:
|
||||
_remove_entry(orig)
|
||||
@@ -389,7 +389,7 @@ def _unstage(moved: List[Tuple[Path, Path]]) -> List[str]:
|
||||
return failed
|
||||
|
||||
|
||||
def _cron_summary(cron_report: Dict[str, Any]) -> Optional[str]:
|
||||
def _cron_summary(cron_report: dict[str, Any]) -> Optional[str]:
|
||||
if not cron_report.get("attempted"):
|
||||
return None
|
||||
if cron_report.get("error"):
|
||||
@@ -403,7 +403,7 @@ def _cron_summary(cron_report: Dict[str, Any]) -> Optional[str]:
|
||||
return "cron links: " + ", ".join(parts) if parts else None
|
||||
|
||||
|
||||
def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]]:
|
||||
def rollback(backup_id: Optional[str] = None) -> tuple[bool, str, Optional[Path]]:
|
||||
"""Restore ``~/.hermes/skills/`` from a snapshot (explicit id or newest): safety-snapshot the CURRENT tree; stage
|
||||
current top-level entries; extract; on failure move staged entries back. Returns ``(ok, message, snapshot_path)``."""
|
||||
target = _resolve_backup(backup_id)
|
||||
@@ -433,7 +433,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
|
||||
except OSError as e:
|
||||
return (False, f"failed to create staging dir: {e}", None)
|
||||
|
||||
moved: List[Tuple[Path, Path]] = []
|
||||
moved: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
for entry in list(skills.iterdir()):
|
||||
if entry.name not in _EXCLUDE_TOP_LEVEL:
|
||||
|
||||
+2
-2
@@ -546,7 +546,7 @@ def tool_labels_for_call(tool_name: str, args: dict | None) -> list:
|
||||
try:
|
||||
from tools.tool_labels import labels_for_call
|
||||
labels = labels_for_call(tool_name, args or {})
|
||||
except Exception as exc: # noqa: BLE001 — display must never abort a turn
|
||||
except Exception as exc:
|
||||
logger.debug("bridge labels failed for %s: %s", tool_name, exc)
|
||||
return []
|
||||
skin = _get_skin()
|
||||
@@ -1247,7 +1247,7 @@ def get_cute_tool_message(tool_name: str, args: dict, duration: float, result: s
|
||||
"""Render a completion label without letting cosmetic failures escape."""
|
||||
try:
|
||||
return _get_cute_tool_message(tool_name, args, duration, result=result)
|
||||
except Exception as exc: # noqa: BLE001 — display must never abort a turn
|
||||
except Exception as exc:
|
||||
logger.debug("Tool completion label failed for %s: %s", tool_name, exc)
|
||||
safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else t("display.cute.fallback_tool_name")
|
||||
safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else t("display.cute.fallback_done")
|
||||
|
||||
@@ -81,7 +81,7 @@ class EmptyAttempt:
|
||||
return (self.model, self.provider, self.finish_reason)
|
||||
|
||||
|
||||
def resolve_guard_settings(section: Any) -> Tuple[bool, Decimal]:
|
||||
def resolve_guard_settings(section: Any) -> tuple[bool, Decimal]:
|
||||
"""Resolve ``agent.empty_response_guard`` into (enabled, threshold); malformed input → schema defaults."""
|
||||
if not isinstance(section, dict):
|
||||
return (DEFAULT_GUARD_ENABLED, DEFAULT_COST_THRESHOLD_USD)
|
||||
@@ -99,7 +99,7 @@ def resolve_guard_settings(section: Any) -> Tuple[bool, Decimal]:
|
||||
candidate = Decimal(str(threshold_raw))
|
||||
if candidate > 0:
|
||||
threshold = candidate
|
||||
except Exception: # noqa: BLE001 — malformed config must not break init
|
||||
except Exception:
|
||||
logger.debug("empty-guard: invalid cost_threshold_usd %r, using default", threshold_raw)
|
||||
return (enabled, threshold)
|
||||
|
||||
@@ -115,7 +115,7 @@ def _cost_threshold_usd(agent: Any) -> Decimal:
|
||||
return value if isinstance(value, Decimal) and value > 0 else DEFAULT_COST_THRESHOLD_USD
|
||||
|
||||
|
||||
def _attempts(agent: Any) -> List[EmptyAttempt]:
|
||||
def _attempts(agent: Any) -> list[EmptyAttempt]:
|
||||
attempts = getattr(agent, _ATTEMPTS_ATTR, None)
|
||||
if attempts is None:
|
||||
attempts = []
|
||||
@@ -132,7 +132,7 @@ def _normalized_usage(agent: Any, response: Any, what: str) -> Any:
|
||||
from agent.usage_pricing import normalize_usage
|
||||
return normalize_usage(raw_usage, provider=getattr(agent, "provider", None),
|
||||
api_mode=getattr(agent, "api_mode", None))
|
||||
except Exception: # noqa: BLE001 — pricing must never break the loop
|
||||
except Exception:
|
||||
logger.debug("empty-guard: %s failed", what, exc_info=True)
|
||||
return None
|
||||
|
||||
@@ -148,7 +148,7 @@ def _estimate_attempt_cost(agent: Any, response: Any) -> Optional[Decimal]:
|
||||
getattr(agent, "model", "") or "", canonical, provider=getattr(agent, "provider", None),
|
||||
base_url=getattr(agent, "base_url", None), api_key=getattr(agent, "api_key", None),
|
||||
)
|
||||
except Exception: # noqa: BLE001 — pricing must never break the loop
|
||||
except Exception:
|
||||
logger.debug("empty-guard: cost estimation failed", exc_info=True)
|
||||
return None
|
||||
return getattr(result, "amount_usd", None)
|
||||
@@ -184,7 +184,7 @@ def record_empty_attempt(
|
||||
attempts = _attempts(agent)
|
||||
if getattr(agent, "_empty_content_retries", 0) == 0:
|
||||
attempts.clear()
|
||||
setattr(agent, _STREAK_COST_ATTR, Decimal("0"))
|
||||
setattr(agent, _STREAK_COST_ATTR, Decimal(0))
|
||||
|
||||
usage_present, zero_output = _zero_output(agent, response)
|
||||
attempts.append(
|
||||
@@ -200,7 +200,7 @@ def record_empty_attempt(
|
||||
|
||||
cost = _estimate_attempt_cost(agent, response)
|
||||
if cost is not None and cost > 0:
|
||||
prior = getattr(agent, _STREAK_COST_ATTR, Decimal("0")) or Decimal("0")
|
||||
prior = getattr(agent, _STREAK_COST_ATTR, Decimal(0)) or Decimal(0)
|
||||
setattr(agent, _STREAK_COST_ATTR, prior + cost)
|
||||
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class ClassifiedError:
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
message: str = ""
|
||||
error_context: Dict[str, Any] = field(default_factory=dict)
|
||||
error_context: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Recovery hints — the retry loop checks these instead of re-classifying.
|
||||
retryable: bool = True
|
||||
@@ -326,7 +326,7 @@ _REQUEST_VALIDATION_PATTERNS = (
|
||||
# A rejection from any other host means the provider's gateway injected the
|
||||
# field itself: a server-side flake, not our request shape. prompt_cache_retention
|
||||
# is only sent for api.meta.ai / bedrock-mantle (agent/transports/codex.py).
|
||||
_SERVER_INJECTED_PARAM_SENDERS: Dict[str, tuple] = {
|
||||
_SERVER_INJECTED_PARAM_SENDERS: dict[str, tuple] = {
|
||||
"prompt_cache_retention": ("meta", "muse", "msl", "model-api", "bedrock", "mantle"),
|
||||
}
|
||||
_PARAM_REJECTION_WORDS = ("not supported", "unsupported", "unknown", "unrecognized")
|
||||
@@ -449,7 +449,7 @@ _UPSTREAM_BLOCKED_PATTERNS = (
|
||||
# ordered ``(patterns, verdict)`` pairs matched first-hit; ``verdict`` may be
|
||||
# a callable of the error message.
|
||||
|
||||
Verdict = Dict[str, Any]
|
||||
Verdict = dict[str, Any]
|
||||
|
||||
|
||||
def _v(reason: FailoverReason, **hints: Any) -> Verdict:
|
||||
@@ -571,7 +571,7 @@ def is_reasoning_field_rejection(error_msg: str) -> bool:
|
||||
|
||||
def _billing_hints(error_msg: str) -> Verdict:
|
||||
"""Billing verdict carrying the #82154 ambiguity marker when applicable."""
|
||||
ctx: Dict[str, Any] = {}
|
||||
ctx: dict[str, Any] = {}
|
||||
if any(p in error_msg for p in _UNVERIFIED_BILLING_PATTERNS):
|
||||
ctx = {"billing_unverified": True, "possible_content_filter": True}
|
||||
return {**_V_BILLING, "error_context": ctx}
|
||||
@@ -636,7 +636,7 @@ _MESSAGE_TAIL_RULES = (
|
||||
|
||||
# Structured error code → verdict. The error-code rate_limit verdict rotates
|
||||
# but does not set should_fallback (unlike the message/status paths).
|
||||
_ERROR_CODE_VERDICTS: Dict[str, Verdict] = {
|
||||
_ERROR_CODE_VERDICTS: dict[str, Verdict] = {
|
||||
**dict.fromkeys(("resource_exhausted", "throttled", "rate_limit_exceeded"),
|
||||
_v(_R.rate_limit, should_rotate_credential=True)),
|
||||
**dict.fromkeys(_BILLING_ERROR_CODES, _V_BILLING),
|
||||
@@ -653,7 +653,7 @@ _ERROR_CODE_VERDICTS: Dict[str, Verdict] = {
|
||||
# to the family key before lookup.
|
||||
_PROVIDER_CODE_FAMILIES = {"openai-codex": "openai", "google": "gemini", "google-gemini": "gemini",
|
||||
"google-ai-studio": "gemini", "vertex": "gemini", "google-vertex": "gemini"}
|
||||
_PROVIDER_CODE_VERDICTS: Dict[str, Dict[str, Verdict]] = {
|
||||
_PROVIDER_CODE_VERDICTS: dict[str, dict[str, Verdict]] = {
|
||||
"openai": {"server_error": _V_SERVER_ERROR},
|
||||
"gemini": {"unavailable": _V_OVERLOADED, "deadline_exceeded": _V_TIMEOUT, "internal": _V_SERVER_ERROR},
|
||||
"anthropic": {"api_error": _V_SERVER_ERROR, "rate_limit_error": _V_RATE_LIMIT},
|
||||
@@ -1222,7 +1222,7 @@ def _classify_image_tool_422(c: _Ctx) -> Verdict:
|
||||
# check, then the client-error abort path (fallback first) is correct. 408 is
|
||||
# retry-safe (RFC 9110 §15.5.9; proxies emit it when generation outruns the
|
||||
# read window). Unlisted 4xx → format_error, 5xx → server_error.
|
||||
_STATUS_HANDLERS: Dict[int, Callable[[_Ctx], Verdict]] = {
|
||||
_STATUS_HANDLERS: dict[int, Callable[[_Ctx], Verdict]] = {
|
||||
400: _classify_400, 401: lambda c: _V_AUTH_ROTATE, 402: lambda c: _classify_402(c.msg, dict),
|
||||
403: _status_403, 404: _status_404, 408: lambda c: _V_TIMEOUT, 413: lambda c: _V_PAYLOAD_TOO_LARGE,
|
||||
422: lambda c: _classify_image_tool_422(c),
|
||||
|
||||
@@ -423,7 +423,7 @@ def raise_if_read_blocked(path: str) -> None:
|
||||
"""
|
||||
try:
|
||||
blocked = get_read_block_error(path)
|
||||
except Exception: # noqa: BLE001 - guard must never break local-file loading
|
||||
except Exception:
|
||||
return
|
||||
if blocked:
|
||||
raise ValueError(blocked)
|
||||
|
||||
@@ -253,7 +253,7 @@ 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):
|
||||
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 {}
|
||||
@@ -273,7 +273,7 @@ def _coerce_content_to_text(content: Any) -> str:
|
||||
return "" if content is None else str(content)
|
||||
|
||||
|
||||
def _inline_data_part(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
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:"):
|
||||
@@ -286,21 +286,21 @@ def _inline_data_part(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def _multimodal_part(item: Any) -> Optional[Dict[str, Any]]:
|
||||
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]]:
|
||||
def _extract_multimodal_parts(content: Any) -> list[dict[str, Any]]:
|
||||
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]:
|
||||
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 {}
|
||||
sig = (extra.get("google") or extra.get("thought_signature")) if isinstance(extra, dict) else None
|
||||
@@ -309,18 +309,18 @@ def _tool_call_extra_signature(tool_call: Dict[str, Any]) -> Optional[str]:
|
||||
return sig if isinstance(sig, str) and sig else None
|
||||
|
||||
|
||||
def _tool_call_id(tool_call: Dict[str, Any]) -> str:
|
||||
def _tool_call_id(tool_call: dict[str, Any]) -> str:
|
||||
return str(tool_call.get("id") or tool_call.get("call_id") or "")
|
||||
|
||||
|
||||
def _translate_tool_call_to_gemini(tool_call: Dict[str, Any], include_ids: bool = False) -> Dict[str, Any]:
|
||||
def _translate_tool_call_to_gemini(tool_call: dict[str, Any], include_ids: bool = False) -> dict[str, Any]:
|
||||
fn = tool_call.get("function") or {}
|
||||
args_raw = fn.get("arguments", "")
|
||||
try:
|
||||
args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {}
|
||||
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}}
|
||||
call: dict[str, Any] = {"name": str(fn.get("name") or ""), "args": args if isinstance(args, dict) else {"_value": args}}
|
||||
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}
|
||||
@@ -337,8 +337,8 @@ 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,
|
||||
) -> Dict[str, Any]:
|
||||
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
|
||||
# mapping beats the result's own name (may be an unwrapped MCP name via `tool_call`).
|
||||
@@ -354,7 +354,7 @@ def _translate_tool_result_to_gemini(
|
||||
# display_name"; see vercel/ai#14369). A tool result that is itself a JSON Schema (e.g. tool_describe
|
||||
# output for an MCP tool) must therefore be forwarded as opaque text, not as a structured 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}}
|
||||
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.
|
||||
@@ -363,18 +363,18 @@ def _translate_tool_result_to_gemini(
|
||||
return {"functionResponse": function_response}
|
||||
|
||||
|
||||
def _has_function_response(content: Dict[str, Any]) -> bool:
|
||||
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]]:
|
||||
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]] = []
|
||||
merged: list[dict[str, Any]] = []
|
||||
# Compatibility contract for native Gemini generateContent: 1) Same-role adjacent contents still merge
|
||||
# in general (strict user/model alternation for ordinary text turns and parallel tool-result grouping;
|
||||
# consecutive same-role contents are rejected with HTTP 400 "Please ensure that multiturn requests
|
||||
@@ -400,11 +400,11 @@ def _merge_alternating(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
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] = {}
|
||||
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
|
||||
@@ -429,11 +429,11 @@ def _build_gemini_contents(
|
||||
return _merge_alternating(contents), ({"role": "system", "parts": [{"text": joined_system}]} if joined_system else None)
|
||||
|
||||
|
||||
def _function_declaration(tool: Any, *, json_schema: bool = False) -> Optional[Dict[str, Any]]:
|
||||
def _function_declaration(tool: Any, *, json_schema: bool = False) -> 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"]}
|
||||
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):
|
||||
@@ -446,13 +446,13 @@ def _function_declaration(tool: Any, *, json_schema: bool = False) -> Optional[D
|
||||
return decl
|
||||
|
||||
|
||||
def _translate_tools_to_gemini(tools: Any, *, json_schema: bool = False) -> List[Dict[str, Any]]:
|
||||
def _translate_tools_to_gemini(tools: Any, *, json_schema: bool = False) -> list[dict[str, Any]]:
|
||||
declarations = [d for d in (_function_declaration(t, json_schema=json_schema)
|
||||
for t in (tools if isinstance(tools, list) else [])) if d]
|
||||
return [{"functionDeclarations": declarations}] if declarations else []
|
||||
|
||||
|
||||
def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[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]}}
|
||||
name = (tool_choice.get("function") or {}).get("name") if isinstance(tool_choice, dict) else None
|
||||
@@ -467,7 +467,7 @@ _THINKING_KEYS = (
|
||||
)
|
||||
|
||||
|
||||
def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:
|
||||
def _normalize_thinking_config(config: Any) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
values = {key: config.get(key, config.get(alias)) for key, alias, _, _ in _THINKING_KEYS}
|
||||
@@ -499,7 +499,7 @@ def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_conf
|
||||
return requested
|
||||
|
||||
|
||||
def _translate_response_format(response_format: Any, *, json_schema: bool = False) -> Dict[str, Any]:
|
||||
def _translate_response_format(response_format: Any, *, json_schema: bool = False) -> dict[str, Any]:
|
||||
"""OpenAI ``response_format`` → Gemini ``generationConfig`` JSON-output keys.
|
||||
|
||||
Full-JSON-Schema ``responseJsonSchema`` exists only on the generativelanguage ``v1beta``
|
||||
@@ -519,17 +519,17 @@ def _translate_response_format(response_format: Any, *, json_schema: bool = Fals
|
||||
|
||||
|
||||
def build_gemini_request(
|
||||
*, messages: List[Dict[str, Any]], tools: Any = None, tool_choice: Any = None, temperature: Optional[float] = None,
|
||||
*, messages: list[dict[str, Any]], tools: Any = None, tool_choice: Any = None, temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None, top_p: Optional[float] = None, stop: Any = None, thinking_config: Any = None,
|
||||
response_format: Any = None, model: str = "", tools_as_json_schema: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
# 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)
|
||||
gemini_tools = _translate_tools_to_gemini(tools, json_schema=tools_as_json_schema)
|
||||
tool_config = _translate_tool_choice_to_gemini(tool_choice)
|
||||
optional = (("systemInstruction", system_instruction), ("tools", gemini_tools), ("toolConfig", tool_config))
|
||||
request: Dict[str, Any] = {"contents": contents, **{k: v for k, v in optional if v}}
|
||||
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)),
|
||||
@@ -552,29 +552,29 @@ def build_gemini_request(
|
||||
|
||||
|
||||
# ── Gemini → OpenAI response translation ─────────────────────────────────────
|
||||
def _tool_call_extra_from_part(part: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
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
|
||||
|
||||
|
||||
def _provider_call_id(fc: Dict[str, Any]) -> Optional[str]:
|
||||
def _provider_call_id(fc: dict[str, Any]) -> Optional[str]:
|
||||
fc_id = fc.get("id")
|
||||
return fc_id if isinstance(fc_id, str) and fc_id else None
|
||||
|
||||
|
||||
def _new_call_id(fc: Dict[str, Any]) -> str:
|
||||
def _new_call_id(fc: dict[str, Any]) -> str:
|
||||
"""Echo the functionCall/delta ``id`` when present, else mint an OpenAI-style one."""
|
||||
return _provider_call_id(fc) or f"call_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _dump_call_args(fc: Dict[str, Any], **kwargs: Any) -> str:
|
||||
def _dump_call_args(fc: dict[str, Any], **kwargs: Any) -> str:
|
||||
try:
|
||||
return json.dumps(fc.get("args") or {}, ensure_ascii=False, **kwargs)
|
||||
except (TypeError, ValueError):
|
||||
return "{}"
|
||||
|
||||
|
||||
def _usage_from_metadata(usage_meta: Dict[str, Any]) -> SimpleNamespace:
|
||||
def _usage_from_metadata(usage_meta: dict[str, Any]) -> SimpleNamespace:
|
||||
"""Gemini ``usageMetadata`` → OpenAI-shaped usage.
|
||||
|
||||
Hidden thinking is reported separately in ``thoughtsTokenCount``:
|
||||
@@ -584,7 +584,7 @@ def _usage_from_metadata(usage_meta: Dict[str, Any]) -> SimpleNamespace:
|
||||
real output and ``prompt + completion != total``) and also surfaced under
|
||||
``completion_tokens_details.reasoning_tokens``, where ``normalize_usage`` reads
|
||||
them. Absent on non-thinking/older responses, which keeps their numbers as-is."""
|
||||
count = lambda key: int(usage_meta.get(key) or 0) # noqa: E731
|
||||
count = lambda key: int(usage_meta.get(key) or 0)
|
||||
reasoning_tokens = count("thoughtsTokenCount")
|
||||
return SimpleNamespace(
|
||||
prompt_tokens=count("promptTokenCount"), completion_tokens=count("candidatesTokenCount") + reasoning_tokens,
|
||||
@@ -605,26 +605,26 @@ def _tool_call_ns(name: str, arguments: str, index: int, call_id: str, extra_con
|
||||
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]:
|
||||
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]]:
|
||||
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:
|
||||
def translate_gemini_response(resp: dict[str, Any], model: str) -> SimpleNamespace:
|
||||
candidates = resp.get("candidates") or []
|
||||
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 []
|
||||
pieces: Dict[bool, List[str]] = {False: [], True: []} # is_thought → text pieces
|
||||
tool_calls: List[SimpleNamespace] = []
|
||||
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
|
||||
@@ -645,7 +645,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 [
|
||||
@@ -675,7 +675,7 @@ def _parse_sse_line(line: str) -> Any:
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
|
||||
def _iter_sse_events(response: httpx.Response) -> Iterator[dict[str, Any]]:
|
||||
buffer = ""
|
||||
for chunk in response.iter_text():
|
||||
buffer += chunk or ""
|
||||
@@ -694,8 +694,8 @@ def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
|
||||
yield payload
|
||||
|
||||
|
||||
def _tool_call_slot(fc: Dict[str, Any], part: Dict[str, Any], part_index: int, args_str: str,
|
||||
tool_call_indices: Dict[str, Dict[str, Any]]) -> tuple[str, Optional[Dict[str, Any]]]:
|
||||
def _tool_call_slot(fc: dict[str, Any], part: dict[str, Any], part_index: int, args_str: str,
|
||||
tool_call_indices: dict[str, dict[str, Any]]) -> tuple[str, Optional[dict[str, Any]]]:
|
||||
"""``(key, existing slot or None)`` for a streamed functionCall.
|
||||
|
||||
Gemini 3 ids each tool call, so the id is the slot identity (``part_index`` and the thought
|
||||
@@ -719,13 +719,13 @@ def _tool_call_slot(fc: Dict[str, Any], part: Dict[str, Any], part_index: int, a
|
||||
return f"{key}#{len(tool_call_indices)}", None
|
||||
|
||||
|
||||
def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: Dict[str, Dict[str, Any]]) -> List[_GeminiStreamChunk]:
|
||||
def translate_stream_event(event: dict[str, Any], model: str, tool_call_indices: dict[str, dict[str, Any]]) -> list[_GeminiStreamChunk]:
|
||||
candidates = event.get("candidates") or []
|
||||
if not candidates:
|
||||
return []
|
||||
cand = candidates[0] if isinstance(candidates[0], dict) else {}
|
||||
parts = (cand.get("content") or {}).get("parts") or []
|
||||
chunks: List[_GeminiStreamChunk] = []
|
||||
chunks: list[_GeminiStreamChunk] = []
|
||||
for part_index, part in enumerate(parts):
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
@@ -756,7 +756,7 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices:
|
||||
return chunks
|
||||
|
||||
|
||||
def _error_info(err_obj: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
|
||||
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")
|
||||
@@ -767,7 +767,7 @@ def _error_info(err_obj: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
|
||||
return reason, metadata
|
||||
|
||||
|
||||
def _error_object(body_text: str) -> Dict[str, Any]:
|
||||
def _error_object(body_text: str) -> dict[str, Any]:
|
||||
"""The ``error`` object of a Google JSON error body, or ``{}``."""
|
||||
try:
|
||||
parsed = json.loads(body_text) if body_text else None
|
||||
@@ -820,7 +820,7 @@ class GeminiNativeClient:
|
||||
MISSING_KEY_ERROR = _MISSING_KEY_ERROR
|
||||
|
||||
def __init__(
|
||||
self, *, api_key: str, base_url: Optional[str] = None, default_headers: Optional[Dict[str, str]] = None,
|
||||
self, *, api_key: str, base_url: Optional[str] = None, default_headers: Optional[dict[str, str]] = None,
|
||||
timeout: Any = None, http_client: Optional[httpx.Client] = None, **_: Any,
|
||||
) -> None:
|
||||
if not (api_key or "").strip():
|
||||
@@ -843,10 +843,10 @@ class GeminiNativeClient:
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
def _auth_headers(self) -> Dict[str, str]:
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
return {"x-goog-api-key": self.api_key}
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"Content-Type": "application/json", "Accept": "application/json", **self._auth_headers(),
|
||||
"User-Agent": f"{_API_CLIENT} (gemini-native)", "X-Goog-Api-Client": _API_CLIENT, **self._default_headers}
|
||||
|
||||
@@ -859,9 +859,9 @@ class GeminiNativeClient:
|
||||
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,
|
||||
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, response_format: Any = None, extra_body: Optional[Dict[str, Any]] = None,
|
||||
top_p: Optional[float] = None, stop: Any = None, response_format: Any = None, extra_body: Optional[dict[str, Any]] = None,
|
||||
timeout: Any = None, **_: Any,
|
||||
) -> Any:
|
||||
extra = extra_body if isinstance(extra_body, dict) else {}
|
||||
@@ -886,13 +886,13 @@ class GeminiNativeClient:
|
||||
) from exc
|
||||
return translate_gemini_response(payload, model=model)
|
||||
|
||||
def _stream_completion(self, model: str, url: str, request: Dict[str, Any], timeout: Any) -> Iterator[_GeminiStreamChunk]:
|
||||
def _stream_completion(self, model: str, url: str, request: dict[str, Any], timeout: Any) -> Iterator[_GeminiStreamChunk]:
|
||||
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 self._http_error(response, read_streaming_error_body(response))
|
||||
tool_call_indices: Dict[str, Dict[str, Any]] = {}
|
||||
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:
|
||||
|
||||
@@ -41,9 +41,9 @@ def _stringify_enum_value(item: Any) -> Any:
|
||||
return item if isinstance(item, str) else None
|
||||
|
||||
|
||||
def _normalize_gemini_type_array(type_array: list, cleaned: Dict[str, Any]) -> None:
|
||||
def _normalize_gemini_type_array(type_array: list, cleaned: dict[str, Any]) -> None:
|
||||
"""Keep union alternatives and their branch-local structural constraints."""
|
||||
derived: Dict[str, Any] = {}
|
||||
derived: dict[str, Any] = {}
|
||||
_normalize_type_array(type_array, derived)
|
||||
if "anyOf" in derived:
|
||||
constraints = {"anyOf": cleaned["anyOf"]} if "anyOf" in cleaned else {}
|
||||
@@ -65,13 +65,13 @@ def _normalize_gemini_type_array(type_array: list, cleaned: Dict[str, Any]) -> N
|
||||
cleaned["nullable"] = True
|
||||
|
||||
|
||||
def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
|
||||
def sanitize_gemini_schema(schema: Any) -> dict[str, Any]:
|
||||
"""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] = {}
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in schema.items():
|
||||
if key not in _GEMINI_SCHEMA_ALLOWED_KEYS:
|
||||
continue
|
||||
@@ -119,7 +119,7 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
|
||||
return cleaned
|
||||
|
||||
|
||||
def sanitize_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
||||
def sanitize_gemini_tool_parameters(parameters: Any) -> dict[str, Any]:
|
||||
"""Normalize tool parameters to a valid Gemini object schema."""
|
||||
return sanitize_gemini_schema(parameters) or {"type": "object", "properties": {}}
|
||||
|
||||
@@ -132,13 +132,13 @@ def sanitize_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
||||
# schema goes as-is; only same-document $refs are inlined (MCP pydantic / zod emit
|
||||
# them and Google rejects reference indirection) and root ``$schema`` is dropped.
|
||||
|
||||
_EMPTY_OBJECT_SCHEMA: Dict[str, Any] = {"type": "object", "properties": {}}
|
||||
_EMPTY_OBJECT_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}}
|
||||
# Real tool schemas hold a handful of refs; the cap stops circular pydantic models
|
||||
# from expanding forever.
|
||||
_MAX_REF_EXPANSIONS = 256
|
||||
|
||||
|
||||
def _resolve_local_ref(root: Dict[str, Any], ref: str) -> Optional[Dict[str, Any]]:
|
||||
def _resolve_local_ref(root: dict[str, Any], ref: str) -> Optional[dict[str, Any]]:
|
||||
"""Resolve a same-document JSON pointer (``#/$defs/Foo``) against *root*."""
|
||||
if not isinstance(ref, str) or not ref.startswith("#/"):
|
||||
return None
|
||||
@@ -151,7 +151,7 @@ def _resolve_local_ref(root: Dict[str, Any], ref: str) -> Optional[Dict[str, Any
|
||||
return node if isinstance(node, dict) else None
|
||||
|
||||
|
||||
def _inline_refs(node: Any, root: Dict[str, Any], budget: List[int], stack: tuple = ()) -> Any:
|
||||
def _inline_refs(node: Any, root: dict[str, Any], budget: list[int], stack: tuple = ()) -> Any:
|
||||
"""Recursively inline same-document ``$ref`` nodes; ``ValueError`` on an unresolvable
|
||||
or circular reference or an exhausted budget (the caller then keeps the original)."""
|
||||
if isinstance(node, list):
|
||||
@@ -176,7 +176,7 @@ def _inline_refs(node: Any, root: Dict[str, Any], budget: List[int], stack: tupl
|
||||
return {**inlined, **_inline_refs(siblings, root, budget, stack)} if siblings else inlined
|
||||
|
||||
|
||||
def prepare_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
||||
def prepare_gemini_tool_parameters(parameters: Any) -> dict[str, Any]:
|
||||
"""Full JSON Schema for ``parametersJsonSchema``: deep-copied, root ``$schema`` dropped,
|
||||
same-document ``$ref`` inlined, object root guaranteed. A schema whose references
|
||||
cannot all be resolved is sent untouched so the provider names the real problem."""
|
||||
|
||||
@@ -23,7 +23,7 @@ from agent.provider_base import CatalogProviderBase
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_ASPECT_RATIOS: Tuple[str, ...] = ("landscape", "square", "portrait")
|
||||
VALID_ASPECT_RATIOS: tuple[str, ...] = ("landscape", "square", "portrait")
|
||||
DEFAULT_ASPECT_RATIO = "landscape"
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class ImageGenProvider(CatalogProviderBase):
|
||||
:attr:`name` and :meth:`generate`; ``list_models`` entries may add
|
||||
``speed`` / ``strengths`` / ``price`` for the picker."""
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
def capabilities(self) -> dict[str, Any]:
|
||||
"""``modalities`` (``"text"`` and/or ``"image"``) and ``max_reference_images``; optionally
|
||||
``supports_upscale`` (bool) and ``creative_controls`` (names from the tool's creative-control
|
||||
vocabulary: ``creativity``, ``intensity``, ``complexity``, ``movement``). Surfaced in the
|
||||
@@ -48,9 +48,9 @@ class ImageGenProvider(CatalogProviderBase):
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
*,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
reference_image_urls: Optional[list[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Generate an image, or edit ``image_url`` (``reference_image_urls`` are extra
|
||||
style/composition refs, clamped to ``max_reference_images``); any source image
|
||||
routes to the edit endpoint. Return :func:`success_response` / :func:`error_response`.
|
||||
@@ -65,7 +65,7 @@ def resolve_aspect_ratio(value: Optional[str]) -> str:
|
||||
return v if v in VALID_ASPECT_RATIOS else DEFAULT_ASPECT_RATIO
|
||||
|
||||
|
||||
def normalize_reference_images(value: Any) -> Optional[List[str]]:
|
||||
def normalize_reference_images(value: Any) -> Optional[list[str]]:
|
||||
"""Coerce a str or list into a clean list of non-blank strings; ``None`` when
|
||||
nothing usable remains so providers treat "no refs" as one sentinel."""
|
||||
if isinstance(value, str):
|
||||
@@ -109,10 +109,10 @@ def success_response(
|
||||
aspect_ratio: str,
|
||||
provider: str,
|
||||
modality: str = "text",
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
extra: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Uniform success dict; ``extra`` keys are added without overriding standard ones."""
|
||||
payload: Dict[str, Any] = {
|
||||
payload: dict[str, Any] = {
|
||||
"success": True, "image": image, "model": model, "prompt": prompt,
|
||||
"aspect_ratio": aspect_ratio, "modality": modality, "provider": provider,
|
||||
}
|
||||
@@ -129,7 +129,7 @@ def error_response(
|
||||
model: str = "",
|
||||
prompt: str = "",
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Build a uniform error response dict."""
|
||||
return {
|
||||
"success": False, "image": None, "error": error, "error_type": error_type,
|
||||
|
||||
+24
-24
@@ -66,7 +66,7 @@ def _existing_file(candidate: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def extract_image_refs(text: str) -> Tuple[List[str], List[str]]:
|
||||
def extract_image_refs(text: str) -> tuple[list[str], list[str]]:
|
||||
"""Scan free-form text for image references → ``(local_paths, urls)``, each
|
||||
ordered and deduplicated. Local paths must exist as files; URLs are not
|
||||
validated (the provider fetches them). Code spans are skipped so pasted
|
||||
@@ -99,7 +99,7 @@ def _coerce_capability_bool(raw: Any) -> Optional[bool]:
|
||||
return _BOOL_TOKENS.get(raw.strip().lower()) if isinstance(raw, str) else None
|
||||
|
||||
|
||||
def _dict_or_empty(raw: Any) -> Dict[str, Any]:
|
||||
def _dict_or_empty(raw: Any) -> dict[str, Any]:
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ def _model_supports_vision_override(models_cfg: Any, model: str) -> Optional[boo
|
||||
return _coerce_capability_bool(per_model.get("supports_vision", per_model.get("vision")))
|
||||
|
||||
|
||||
def _custom_provider_entries(cfg: Dict[str, Any], names: Iterable[str]) -> Iterable[Dict[str, Any]]:
|
||||
def _custom_provider_entries(cfg: dict[str, Any], names: Iterable[str]) -> Iterable[dict[str, Any]]:
|
||||
"""Yield legacy ``custom_providers`` entries matching ``names`` (case-insensitive);
|
||||
``names`` is the outer loop so list order cannot let a persisted default shadow the live route."""
|
||||
entries = _custom_provider_list(cfg)
|
||||
@@ -132,14 +132,14 @@ def _custom_provider_entries(cfg: Dict[str, Any], names: Iterable[str]) -> Itera
|
||||
yield from (e for e in entries if _clean_str(e.get("name")).lower() == wanted)
|
||||
|
||||
|
||||
def _custom_provider_list(cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
def _custom_provider_list(cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Dict entries of the legacy ``custom_providers`` list (empty when absent/malformed)."""
|
||||
raw = cfg.get("custom_providers")
|
||||
return [e for e in raw if isinstance(e, dict)] if isinstance(raw, list) else []
|
||||
|
||||
|
||||
def _supports_vision_override(
|
||||
cfg: Optional[Dict[str, Any]],
|
||||
cfg: Optional[dict[str, Any]],
|
||||
provider: str,
|
||||
model: str,
|
||||
*,
|
||||
@@ -160,7 +160,7 @@ def _supports_vision_override(
|
||||
if top is not None:
|
||||
return top
|
||||
|
||||
candidates: List[str] = []
|
||||
candidates: list[str] = []
|
||||
for candidate in filter(None, (requested_provider, provider, _clean_str(model_cfg.get("provider")))):
|
||||
candidates.append(candidate)
|
||||
if candidate.startswith("custom:") and candidate[len("custom:"):]:
|
||||
@@ -174,7 +174,7 @@ def _supports_vision_override(
|
||||
|
||||
|
||||
def _resolve_inference_value(
|
||||
cfg: Optional[Dict[str, Any]],
|
||||
cfg: Optional[dict[str, Any]],
|
||||
provider: str,
|
||||
key: str,
|
||||
*,
|
||||
@@ -214,7 +214,7 @@ def _resolve_inference_value(
|
||||
return next((v for v in (_clean_str(e.get(key)) for e in entries) if v), "")
|
||||
|
||||
|
||||
def _resolve_inference_base_url(cfg: Optional[Dict[str, Any]], provider: str) -> str:
|
||||
def _resolve_inference_base_url(cfg: Optional[dict[str, Any]], provider: str) -> str:
|
||||
"""Best-effort base URL for the active inference provider; the runtime value is
|
||||
only trusted when it belongs to the requested provider (or none was requested)."""
|
||||
requested = _clean_str(provider).lower()
|
||||
@@ -224,7 +224,7 @@ def _resolve_inference_base_url(cfg: Optional[Dict[str, Any]], provider: str) ->
|
||||
)
|
||||
|
||||
|
||||
def _resolve_inference_api_key(cfg: Optional[Dict[str, Any]], provider: str) -> str:
|
||||
def _resolve_inference_api_key(cfg: Optional[dict[str, Any]], provider: str) -> str:
|
||||
"""Best-effort API key, resolved like :func:`_resolve_inference_base_url` so it
|
||||
matches the base URL actually probed; otherwise the local server-type probe hits
|
||||
a keyed remote endpoint without Authorization and sprays 401s on every image turn.
|
||||
@@ -259,7 +259,7 @@ def _coerce_mode(raw: Any) -> str:
|
||||
return mode if mode in _VALID_MODES else "auto"
|
||||
|
||||
|
||||
def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool:
|
||||
def _explicit_aux_vision_override(cfg: Optional[dict[str, Any]]) -> bool:
|
||||
"""True when the user configured a specific ``auxiliary.vision`` backend — the
|
||||
de-facto image route in ``auto`` mode even when the main model has native vision.
|
||||
``auto``/empty provider with no model and no base_url is not explicit."""
|
||||
@@ -271,7 +271,7 @@ def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _probe_managed_runtime(provider: str, model: str, cfg: Optional[Dict[str, Any]]) -> Optional[bool]:
|
||||
def _probe_managed_runtime(provider: str, model: str, cfg: Optional[dict[str, Any]]) -> Optional[bool]:
|
||||
"""Managed local runtime verdict: the server receiving the image is the authority
|
||||
on whether it can see (its /props reports modalities). Cloud catalogs have never
|
||||
heard of a local GGUF, so without this every local model reads as text-only and
|
||||
@@ -282,7 +282,7 @@ def _probe_managed_runtime(provider: str, model: str, cfg: Optional[Dict[str, An
|
||||
return managed_model_supports_vision(model) if managed else None
|
||||
|
||||
|
||||
def _probe_models_dev(provider: str, model: str, cfg: Optional[Dict[str, Any]]) -> Optional[bool]:
|
||||
def _probe_models_dev(provider: str, model: str, cfg: Optional[dict[str, Any]]) -> Optional[bool]:
|
||||
"""models.dev catalog verdict. ``allow_network=True`` on purpose: this runs only
|
||||
when an image needs routing, and the text-only-main guard depends on catalog
|
||||
data — a cold cache returning "unknown" would reintroduce attempting the call.
|
||||
@@ -305,7 +305,7 @@ def _probe_models_dev(provider: str, model: str, cfg: Optional[Dict[str, Any]])
|
||||
return None if caps is None else caps.supports_vision
|
||||
|
||||
|
||||
def _probe_ollama(provider: str, model: str, cfg: Optional[Dict[str, Any]]) -> Optional[bool]:
|
||||
def _probe_ollama(provider: str, model: str, cfg: Optional[dict[str, Any]]) -> Optional[bool]:
|
||||
"""Ollama ``/api/show`` verdict for local endpoints (see :func:`_should_probe_ollama_vision`)."""
|
||||
base_url = _resolve_inference_base_url(cfg, provider)
|
||||
if not base_url and _clean_str(provider).lower() == "ollama":
|
||||
@@ -320,7 +320,7 @@ def _probe_ollama(provider: str, model: str, cfg: Optional[Dict[str, Any]]) -> O
|
||||
|
||||
# Capability probes after the config override, in priority order; each returns
|
||||
# True/False or None (unknown → next probe). Exceptions are logged and treated as None.
|
||||
_VISION_PROBES: Tuple[Tuple[str, Callable[..., Optional[bool]]], ...] = (
|
||||
_VISION_PROBES: tuple[tuple[str, Callable[..., Optional[bool]]], ...] = (
|
||||
("managed-runtime caps lookup", _probe_managed_runtime),
|
||||
("caps lookup", _probe_models_dev),
|
||||
("ollama vision probe", _probe_ollama),
|
||||
@@ -330,7 +330,7 @@ _VISION_PROBES: Tuple[Tuple[str, Callable[..., Optional[bool]]], ...] = (
|
||||
def _lookup_supports_vision(
|
||||
provider: str,
|
||||
model: str,
|
||||
cfg: Optional[Dict[str, Any]] = None,
|
||||
cfg: Optional[dict[str, Any]] = None,
|
||||
*,
|
||||
requested_provider: str = "",
|
||||
) -> Optional[bool]:
|
||||
@@ -375,7 +375,7 @@ def _lookup_supports_vision(
|
||||
def decide_image_input_mode(
|
||||
provider: str,
|
||||
model: str,
|
||||
cfg: Optional[Dict[str, Any]],
|
||||
cfg: Optional[dict[str, Any]],
|
||||
*,
|
||||
requested_provider: str = "",
|
||||
) -> str:
|
||||
@@ -404,7 +404,7 @@ _FTYP_BRANDS = {
|
||||
**dict.fromkeys((b"avif", b"avis"), "image/avif"),
|
||||
**dict.fromkeys((b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim", b"heis"), "image/heic"),
|
||||
}
|
||||
_MAGIC: Tuple[Tuple[Tuple[Tuple[int, bytes], ...], str], ...] = (
|
||||
_MAGIC: tuple[tuple[tuple[tuple[int, bytes], ...], str], ...] = (
|
||||
(((0, b"\x89PNG\r\n\x1a\n"),), "image/png"),
|
||||
(((0, b"\xff\xd8\xff"),), "image/jpeg"),
|
||||
(((0, b"GIF87a"),), "image/gif"), (((0, b"GIF89a"),), "image/gif"),
|
||||
@@ -453,7 +453,7 @@ def _transcode_to_png(raw: bytes) -> Optional[bytes]:
|
||||
|
||||
pillow_heif.register_heif_opener()
|
||||
with suppress(Exception):
|
||||
import pillow_avif # type: ignore # noqa: F401 -- registers AVIF on import
|
||||
import pillow_avif # type: ignore
|
||||
try:
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
# Normalise exotic modes to RGBA so PNG can serialise and transparency survives.
|
||||
@@ -493,7 +493,7 @@ def _accepted_mimes() -> frozenset:
|
||||
|
||||
if is_managed_provider(str(_runtime_main_value("provider") or ""), str(_runtime_main_value("base_url") or "")):
|
||||
return ACCEPTED_IMAGE_MIMES
|
||||
except Exception: # noqa: BLE001 — best-effort narrowing only
|
||||
except Exception:
|
||||
pass
|
||||
return _UNIVERSALLY_SUPPORTED_MIMES
|
||||
|
||||
@@ -532,9 +532,9 @@ def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
|
||||
def build_native_content_parts(
|
||||
user_text: str,
|
||||
image_paths: List[str],
|
||||
image_urls: Optional[List[str]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
image_paths: list[str],
|
||||
image_urls: Optional[list[str]] = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""Build an OpenAI-style ``content`` list for a user turn.
|
||||
|
||||
Local paths become base64 ``data:`` URLs; remote URLs pass through verbatim.
|
||||
@@ -544,8 +544,8 @@ def build_native_content_parts(
|
||||
``Runner._enrich_message_with_vision``. Returns ``(content_parts, skipped)``;
|
||||
``skipped`` holds unreadable local paths (URLs are never skipped).
|
||||
"""
|
||||
skipped: List[str] = []
|
||||
attached: List[Tuple[str, str]] = [] # (url, hint)
|
||||
skipped: list[str] = []
|
||||
attached: list[tuple[str, str]] = [] # (url, hint)
|
||||
for raw_path in image_paths:
|
||||
p = Path(raw_path)
|
||||
data_url = _file_to_data_url(p) if p.exists() and p.is_file() else None
|
||||
|
||||
@@ -26,11 +26,11 @@ _MIN_PLAUSIBLE, _MAX_PLAUSIBLE = 64, 32_768
|
||||
_EMA_ALPHA = 0.5
|
||||
|
||||
_image_cost_var: ContextVar[Optional[int]] = ContextVar("hermes_image_token_cost", default=None)
|
||||
_LEARNED: Dict[str, int] = {}
|
||||
_LEARNED: dict[str, int] = {}
|
||||
_LOADED = False
|
||||
# Routed profiles (multiplexed gateway) keep their own table, loaded from THEIR cache file: the
|
||||
# module slot above is the launch profile's and would otherwise be persisted into every home.
|
||||
_LEARNED_BY_HOME: Dict[str, Dict[str, int]] = {}
|
||||
_LEARNED_BY_HOME: dict[str, dict[str, int]] = {}
|
||||
|
||||
|
||||
def _cache_path():
|
||||
@@ -45,14 +45,14 @@ def _key(model: Any, base_url: Any) -> str:
|
||||
return f"{model or ''}@{base_url_hostname(base_url or '') or ''}"
|
||||
|
||||
|
||||
def _read_cache() -> Dict[str, int]:
|
||||
def _read_cache() -> dict[str, int]:
|
||||
from agent.model_metadata import _load_json_dict
|
||||
|
||||
return {k: v for k, v in _load_json_dict(_cache_path()).items()
|
||||
if isinstance(v, int) and _MIN_PLAUSIBLE <= v <= _MAX_PLAUSIBLE}
|
||||
|
||||
|
||||
def _table() -> Dict[str, int]:
|
||||
def _table() -> dict[str, int]:
|
||||
"""The active profile's learned table, loaded lazily from its cache file."""
|
||||
global _LOADED
|
||||
from hermes_constants import get_hermes_home_override, hermes_home_key
|
||||
@@ -94,13 +94,13 @@ def bind_image_token_cost(agent: Any) -> None:
|
||||
_image_cost_var.set(learned_image_token_cost(getattr(agent, "model", None), getattr(agent, "base_url", None)))
|
||||
|
||||
|
||||
def count_images(messages: List[Dict[str, Any]]) -> int:
|
||||
def count_images(messages: list[dict[str, Any]]) -> int:
|
||||
from agent.model_metadata import _count_image_tokens
|
||||
|
||||
return sum(_count_image_tokens(m, 1) for m in messages if isinstance(m, dict))
|
||||
|
||||
|
||||
def calibrate_from_usage(agent: Any, messages: List[Dict[str, Any]], prompt_tokens: Any) -> Optional[int]:
|
||||
def calibrate_from_usage(agent: Any, messages: list[dict[str, Any]], prompt_tokens: Any) -> Optional[int]:
|
||||
"""Learn the per-image cost from the response that just priced ``messages``.
|
||||
|
||||
Requires the PREVIOUS anchor (real count of the prior request) to still match: the residual
|
||||
|
||||
@@ -24,7 +24,7 @@ _NON_NAMES = frozenset({
|
||||
"admin", "administrator", "default", "guest", "me", "owner", "root", "test", "user",
|
||||
})
|
||||
_HANDLE_CHARS = re.compile(r"[\d_@/\\]")
|
||||
_SPARK_MODEL = re.compile(r"\b(dgx|spark|gb10)\b", re.I)
|
||||
_SPARK_MODEL = re.compile(r"\b(dgx|spark|gb10)\b", re.IGNORECASE)
|
||||
|
||||
FORK_QUESTION = "Know what you'd like it to make?"
|
||||
# Reading the Blender plugin's app declaration takes the network.
|
||||
@@ -294,7 +294,7 @@ def _handoff(kind: str, description: str) -> dict:
|
||||
"""The handoff message's parts and its two plans from ``templates/handoff.md``, the machine plan naming this
|
||||
computer, so the model reads them only when it reaches the handoff."""
|
||||
text = (skill_dir() / "templates" / "handoff.md").read_text(encoding="utf-8-sig")
|
||||
sections = dict(re.findall(r"^## (\S+)\n\n(.*?)\n*(?=^## |\Z)", text, re.M | re.S))
|
||||
sections = dict(re.findall(r"^## (\S+)\n\n(.*?)\n*(?=^## |\Z)", text, re.MULTILINE | re.DOTALL))
|
||||
machine = sections["machine"].replace("<machine_kind>", kind).replace("<description>", description)
|
||||
return {"message": sections["message"], "build": sections["build"], "machine": machine}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Any, Callable, Dict, Optional, Tuple
|
||||
from tools.arg_coercion import coerce_tool_args
|
||||
|
||||
|
||||
def tool_hook_ids(agent, effective_task_id: str, tool_call_id: Optional[str]) -> Dict[str, str]:
|
||||
def tool_hook_ids(agent, effective_task_id: str, tool_call_id: Optional[str]) -> dict[str, str]:
|
||||
"""Identity kwargs every tool hook/middleware call carries (all coerced to ``""``)."""
|
||||
return {
|
||||
"task_id": effective_task_id or "",
|
||||
@@ -97,10 +97,10 @@ class InlineToolContext:
|
||||
InlineToolExecutor = Callable[[Any, dict, InlineToolContext], Any]
|
||||
|
||||
# ``(kwarg, args_key)`` → ``args.get(key)``; ``(kwarg, args_key, default)`` → ``args.get(key, default)``.
|
||||
_ArgSpec = Tuple[Any, ...]
|
||||
_ArgSpec = tuple[Any, ...]
|
||||
|
||||
|
||||
def _call_tool(module: str, func: str, args: dict, arg_specs: Tuple[_ArgSpec, ...], **fixed: Any) -> Any:
|
||||
def _call_tool(module: str, func: str, args: dict, arg_specs: tuple[_ArgSpec, ...], **fixed: Any) -> Any:
|
||||
"""Import ``module.func`` lazily and call it with args mapped per ``arg_specs`` plus ``fixed``."""
|
||||
fn = getattr(import_module(module), func)
|
||||
return fn(**{spec[0]: args.get(*spec[1:]) for spec in arg_specs}, **fixed)
|
||||
@@ -234,7 +234,7 @@ def _setup_mcp_shim(agent, args: dict, ctx: InlineToolContext) -> Any:
|
||||
|
||||
|
||||
# Order is the historical if/elif order of ``execute_tool_calls_sequential``.
|
||||
_RAW_INLINE_TOOL_EXECUTORS: Dict[str, InlineToolExecutor] = {
|
||||
_RAW_INLINE_TOOL_EXECUTORS: dict[str, InlineToolExecutor] = {
|
||||
"todo_list": _tool(
|
||||
"tools.todo_tool", "todo_tool", ("todos", "todos"), ("merge", "merge", False),
|
||||
store=lambda agent, ctx: agent._todo_store,
|
||||
@@ -295,7 +295,7 @@ def _coerced(name: str, executor: InlineToolExecutor) -> InlineToolExecutor:
|
||||
return _exec
|
||||
|
||||
|
||||
INLINE_TOOL_EXECUTORS: Dict[str, InlineToolExecutor] = {
|
||||
INLINE_TOOL_EXECUTORS: dict[str, InlineToolExecutor] = {
|
||||
name: _coerced(name, executor) for name, executor in _RAW_INLINE_TOOL_EXECUTORS.items()
|
||||
}
|
||||
|
||||
|
||||
+24
-24
@@ -26,7 +26,7 @@ def _fmt_est_cost(est_cost: float) -> str:
|
||||
return format_cost_label(Decimal(str(est_cost)))
|
||||
|
||||
|
||||
def _estimate_cost(session_or_model: Dict[str, Any] | str, input_tokens: int = 0, output_tokens: int = 0, *, cache_read_tokens: int = 0,
|
||||
def _estimate_cost(session_or_model: dict[str, Any] | str, input_tokens: int = 0, output_tokens: int = 0, *, cache_read_tokens: int = 0,
|
||||
cache_write_tokens: int = 0, provider: Optional[str] = None, base_url: Optional[str] = None) -> tuple[float, str]:
|
||||
"""Estimate the USD cost for a session row or a model/token tuple."""
|
||||
if isinstance(session_or_model, dict):
|
||||
@@ -41,7 +41,7 @@ def _estimate_cost(session_or_model: Dict[str, Any] | str, input_tokens: int = 0
|
||||
return float(result.amount_usd or 0.0), result.status
|
||||
|
||||
|
||||
def _bar_chart(values: List[int], max_width: int = 20) -> List[str]:
|
||||
def _bar_chart(values: list[int], max_width: int = 20) -> list[str]:
|
||||
peak = max(values) if values else 1
|
||||
return ["" for _ in values] if peak == 0 else ["█" * max(1, int(v / peak * max_width)) if v > 0 else "" for v in values]
|
||||
|
||||
@@ -170,7 +170,7 @@ class InsightsEngine:
|
||||
sql, params = (getattr(self, base + "_WITH_SOURCE"), (cutoff, source)) if source else (getattr(self, base + "_ALL"), (cutoff,))
|
||||
return self._conn.execute(sql, params).fetchall()
|
||||
|
||||
def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]:
|
||||
def generate(self, days: int = 30, source: str = None) -> dict[str, Any]:
|
||||
"""Generate a complete insights report for the last ``days`` days, optionally filtered by source platform."""
|
||||
cutoff = time.time() - (days * 86400)
|
||||
# Drain the SessionDB's async accounting queue so counters are exact
|
||||
@@ -197,7 +197,7 @@ class InsightsEngine:
|
||||
"top_sessions": self._compute_top_sessions(sessions),
|
||||
}
|
||||
|
||||
def get_usage_breakdown(self, days: int = 30, source: str = None) -> Dict[str, Any]:
|
||||
def get_usage_breakdown(self, days: int = 30, source: str = None) -> dict[str, Any]:
|
||||
"""Analytics-usage payload (tools + skills) without a full generate(); the
|
||||
instr()-prefiltered skill query loads only skill_view/skill_manage messages."""
|
||||
cutoff = time.time() - (days * 86400)
|
||||
@@ -206,7 +206,7 @@ class InsightsEngine:
|
||||
|
||||
# ------------------------------------------------------------------ SQL
|
||||
|
||||
def _get_sessions(self, cutoff: float, source: str = None) -> List[Dict]:
|
||||
def _get_sessions(self, cutoff: float, source: str = None) -> list[dict]:
|
||||
# Coerce the two epoch columns once at load: one corrupt/TEXT cell must degrade to "unknown"
|
||||
# for that session, never abort the whole report (#99959).
|
||||
rows = [dict(row) for row in self._query("_GET_SESSIONS", cutoff, source)]
|
||||
@@ -215,7 +215,7 @@ class InsightsEngine:
|
||||
row[col] = coerce_epoch(row.get(col), session_id=row.get("id"), field=col)
|
||||
return rows
|
||||
|
||||
def _get_tool_usage(self, cutoff: float, source: str = None) -> List[Dict]:
|
||||
def _get_tool_usage(self, cutoff: float, source: str = None) -> list[dict]:
|
||||
"""Tool call counts from two sources: ``tool_name`` on 'tool' rows (set
|
||||
by the gateway) and ``tool_calls`` JSON on assistant rows (covers CLI,
|
||||
where tool_name is not populated). The two views are reconciled PER
|
||||
@@ -237,9 +237,9 @@ class InsightsEngine:
|
||||
tool_counts[key[1]] += max(by_session_tool.get(key, 0), calls_by_session_tool.get(key, 0))
|
||||
return [{"tool_name": name, "count": count} for name, count in tool_counts.most_common()]
|
||||
|
||||
def _get_skill_usage(self, cutoff: float, source: str = None) -> List[Dict]:
|
||||
def _get_skill_usage(self, cutoff: float, source: str = None) -> list[dict]:
|
||||
"""Extract per-skill usage from assistant tool calls."""
|
||||
skill_counts: Dict[str, Dict[str, Any]] = {}
|
||||
skill_counts: dict[str, dict[str, Any]] = {}
|
||||
for row in self._query("_GET_SKILL_CALLS", cutoff, source):
|
||||
timestamp = row["timestamp"]
|
||||
for func in _iter_functions(row["tool_calls"]):
|
||||
@@ -255,11 +255,11 @@ class InsightsEngine:
|
||||
entry["last_used_at"] = timestamp
|
||||
return list(skill_counts.values())
|
||||
|
||||
def _get_message_stats(self, cutoff: float, source: str = None) -> Dict:
|
||||
def _get_message_stats(self, cutoff: float, source: str = None) -> dict:
|
||||
rows = self._query("_GET_MESSAGE_STATS", cutoff, source)
|
||||
return dict(rows[0]) if rows else {"total_messages": 0, "user_messages": 0, "assistant_messages": 0, "tool_messages": 0}
|
||||
|
||||
def _get_model_usage(self, cutoff: float, source: str = None) -> List[Dict]:
|
||||
def _get_model_usage(self, cutoff: float, source: str = None) -> list[dict]:
|
||||
"""Per-model usage rows; [] when the table is missing (older DB) so the caller falls back to the per-session aggregate."""
|
||||
try:
|
||||
return [dict(row) for row in self._query("_GET_MODEL_USAGE", cutoff, source)]
|
||||
@@ -268,7 +268,7 @@ class InsightsEngine:
|
||||
|
||||
# -------------------------------------------------------------- Compute
|
||||
|
||||
def _compute_overview(self, sessions: List[Dict], message_stats: Dict, models: Optional[List[Dict]] = None) -> Dict:
|
||||
def _compute_overview(self, sessions: list[dict], message_stats: dict, models: Optional[list[dict]] = None) -> dict:
|
||||
# Per-model breakdown includes auxiliary usage rows (vision/compression/
|
||||
# titles) plus reconciled residuals, while session counters carry
|
||||
# main-loop usage only — sum the breakdown when available so overview
|
||||
@@ -315,7 +315,7 @@ class InsightsEngine:
|
||||
"included_cost_sessions": status_counts["included"],
|
||||
}
|
||||
|
||||
def _compute_model_breakdown(self, sessions: List[Dict], cutoff: float, source: str = None) -> List[Dict]:
|
||||
def _compute_model_breakdown(self, sessions: list[dict], cutoff: float, source: str = None) -> list[dict]:
|
||||
"""Tokens/cost per model from session_model_usage, so a session that
|
||||
switched models via ``/model`` splits across every model it used.
|
||||
Sessions without per-model rows (pre-table data) fall back to their
|
||||
@@ -325,10 +325,10 @@ class InsightsEngine:
|
||||
model_data = defaultdict(lambda: {"sessions": set(), **dict.fromkeys(_TOKEN_KEYS, 0), "reasoning_tokens": 0, "total_tokens": 0,
|
||||
"api_calls": 0, "tool_calls": 0, "cost": 0.0, "actual_cost": 0.0})
|
||||
|
||||
def _accumulate(model, provider, base_url, session_id, counts: Dict[str, int], *,
|
||||
def _accumulate(model, provider, base_url, session_id, counts: dict[str, int], *,
|
||||
stored_cost=None, actual_cost=None, cost_status=None):
|
||||
model = model or "unknown"
|
||||
d: Dict[str, Any] = model_data[_short_model(model)]
|
||||
d: dict[str, Any] = model_data[_short_model(model)]
|
||||
d["sessions"].add(session_id)
|
||||
for key in _TOKEN_KEYS + ("reasoning_tokens",):
|
||||
d[key] += counts[key]
|
||||
@@ -345,7 +345,7 @@ class InsightsEngine:
|
||||
d["has_pricing"] = has_known_pricing(model, provider or None, base_url) or d.get("has_pricing", False)
|
||||
usage_totals = defaultdict(lambda: dict.fromkeys(count_keys, 0) | {"estimated_cost_usd": 0.0, "actual_cost_usd": 0.0})
|
||||
for r in self._get_model_usage(cutoff, source):
|
||||
totals: Dict[str, Any] = usage_totals[r["session_id"]]
|
||||
totals: dict[str, Any] = usage_totals[r["session_id"]]
|
||||
counts = {key: r[key] or 0 for key in count_keys}
|
||||
for key in count_keys:
|
||||
totals[key] += counts[key]
|
||||
@@ -376,7 +376,7 @@ class InsightsEngine:
|
||||
for model, data in model_data.items()]
|
||||
return sorted(result, key=lambda x: (x["total_tokens"], x["sessions"]), reverse=True)
|
||||
|
||||
def _compute_platform_breakdown(self, sessions: List[Dict]) -> List[Dict]:
|
||||
def _compute_platform_breakdown(self, sessions: list[dict]) -> list[dict]:
|
||||
platform_data = defaultdict(lambda: {"sessions": 0, "messages": 0, **dict.fromkeys(_TOKEN_KEYS, 0), "total_tokens": 0, "tool_calls": 0})
|
||||
for s in sessions:
|
||||
d = platform_data[s.get("source") or "unknown"]
|
||||
@@ -388,12 +388,12 @@ class InsightsEngine:
|
||||
d["tool_calls"] += s.get("tool_call_count") or 0
|
||||
return sorted(({"platform": platform, **data} for platform, data in platform_data.items()), key=lambda x: x["sessions"], reverse=True)
|
||||
|
||||
def _compute_tool_breakdown(self, tool_usage: List[Dict]) -> List[Dict]:
|
||||
def _compute_tool_breakdown(self, tool_usage: list[dict]) -> list[dict]:
|
||||
"""Ranked tool list with percentages."""
|
||||
total_calls = sum(t["count"] for t in tool_usage)
|
||||
return [{"tool": t["tool_name"], "count": t["count"], "percentage": (t["count"] / total_calls * 100) if total_calls else 0} for t in tool_usage]
|
||||
|
||||
def _compute_skill_breakdown(self, skill_usage: List[Dict]) -> Dict[str, Any]:
|
||||
def _compute_skill_breakdown(self, skill_usage: list[dict]) -> dict[str, Any]:
|
||||
"""Per-skill usage → summary + ranked list."""
|
||||
total_skill_loads = sum(s["view_count"] for s in skill_usage)
|
||||
total_skill_edits = sum(s["manage_count"] for s in skill_usage)
|
||||
@@ -409,7 +409,7 @@ class InsightsEngine:
|
||||
"top_skills": top_skills,
|
||||
}
|
||||
|
||||
def _compute_activity_patterns(self, sessions: List[Dict]) -> Dict:
|
||||
def _compute_activity_patterns(self, sessions: list[dict]) -> dict:
|
||||
"""Activity by day of week, hour, and active-day streak."""
|
||||
day_counts, hour_counts, daily_counts = Counter(), Counter(), Counter() # weekday (0=Monday), hour, "YYYY-MM-DD"
|
||||
for s in sessions:
|
||||
@@ -439,7 +439,7 @@ class InsightsEngine:
|
||||
("Most tool calls", lambda s: s.get("tool_call_count") or 0, "{} calls"),
|
||||
)
|
||||
|
||||
def _compute_top_sessions(self, sessions: List[Dict]) -> List[Dict]:
|
||||
def _compute_top_sessions(self, sessions: list[dict]) -> list[dict]:
|
||||
"""Notable sessions (longest, most messages, most tokens, most tool calls)."""
|
||||
top = []
|
||||
timed = [s for s in sessions if s.get("started_at") and s.get("ended_at")]
|
||||
@@ -457,11 +457,11 @@ class InsightsEngine:
|
||||
# ------------------------------------------------------------- Formatting
|
||||
|
||||
@staticmethod
|
||||
def _section(title: str) -> List[str]:
|
||||
def _section(title: str) -> list[str]:
|
||||
return [f" {title}", " " + "─" * 56]
|
||||
|
||||
@staticmethod
|
||||
def _cost_lines(o: Dict, templates: tuple) -> List[str]:
|
||||
def _cost_lines(o: dict, templates: tuple) -> list[str]:
|
||||
"""One formatted line per non-zero cost bucket (estimated, included, unknown)."""
|
||||
# Cost breakdown — surface the three buckets so subscription-included and unknown-cost sessions are
|
||||
# visible instead of silently collapsing to $0. See #77223.
|
||||
@@ -469,7 +469,7 @@ class InsightsEngine:
|
||||
values = (_fmt_est_cost(est_cost) if est_cost > 0 else "", o.get("included_cost_sessions", 0), o.get("unknown_cost_sessions", 0))
|
||||
return [tpl.format(v) for tpl, v in zip(templates, values) if v]
|
||||
|
||||
def format_terminal(self, report: Dict) -> str:
|
||||
def format_terminal(self, report: dict) -> str:
|
||||
"""Format the insights report for terminal display (CLI)."""
|
||||
if report.get("empty"):
|
||||
src = f" (source: {report['source_filter']})" if report.get("source_filter") else ""
|
||||
@@ -548,7 +548,7 @@ class InsightsEngine:
|
||||
lines += [f" {ts['label']:<20} {ts['value']:<18} ({ts['date']}, {ts['session_id']})" for ts in report["top_sessions"]] + [""]
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_gateway(self, report: Dict) -> str:
|
||||
def format_gateway(self, report: dict) -> str:
|
||||
"""Format the insights report for gateway/messaging (shorter)."""
|
||||
if report.get("empty"):
|
||||
return f"No sessions found in the last {report.get('days', 30)} days."
|
||||
|
||||
@@ -18,7 +18,7 @@ def preload_jiter_native_extension() -> bool:
|
||||
return True
|
||||
try:
|
||||
importlib.import_module("jiter.jiter")
|
||||
from jiter import from_json as _from_json # noqa: F401
|
||||
from jiter import from_json as _from_json
|
||||
except Exception as exc:
|
||||
_JITER_PRELOAD_ERROR = exc
|
||||
return False
|
||||
|
||||
@@ -120,8 +120,7 @@ def max_recovery_attempts() -> int:
|
||||
|
||||
def recovery_delay_seconds(attempt: int) -> float:
|
||||
"""Backoff before recovery attempt ``attempt`` (1-based); last entry repeats."""
|
||||
if attempt < 1:
|
||||
attempt = 1
|
||||
attempt = max(attempt, 1)
|
||||
index = min(attempt - 1, len(RECOVERY_DELAYS_SECONDS) - 1)
|
||||
return RECOVERY_DELAYS_SECONDS[index]
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Row = list # of runs ``[text, style, alpha, hex?]``; a grid is a list of rows
|
||||
|
||||
|
||||
def _clamp(v: float, lo: float, hi: float) -> float:
|
||||
return lo if v < lo else hi if v > hi else v
|
||||
return lo if v < lo else min(v, hi)
|
||||
|
||||
|
||||
def _lerp(a: float, b: float, t: float) -> float:
|
||||
@@ -191,7 +191,7 @@ def _skill_category_counts(nodes: Iterable[dict[str, Any]]) -> Counter:
|
||||
# ── Timeline chart frame ─────────────────────────────────────────────────────
|
||||
|
||||
class _ChartBucket:
|
||||
__slots__ = ("label", "ts", "nodes", "rec")
|
||||
__slots__ = ("label", "nodes", "rec", "ts")
|
||||
|
||||
def __init__(self, label: str, ts: float):
|
||||
self.label, self.ts, self.rec = label, ts, 1.0
|
||||
|
||||
+4
-4
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
# hermes_bootstrap first (UTF-8 stdio on Windows; no-op on POSIX), like every other entry point.
|
||||
try:
|
||||
import hermes_bootstrap # noqa: F401
|
||||
import hermes_bootstrap
|
||||
except ModuleNotFoundError:
|
||||
pass # partial `hermes update` — only skips the Windows UTF-8 stdio setup
|
||||
|
||||
@@ -22,8 +22,8 @@ from hermes_cli import _early_recovery
|
||||
if _early_recovery.restore_interrupted_pull():
|
||||
_early_recovery.relaunch_after_restore()
|
||||
|
||||
import argparse # noqa: E402
|
||||
from typing import Callable, List, Optional # noqa: E402
|
||||
import argparse
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
@@ -57,7 +57,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None, *, run: Optional[Callable[..., object]] = None) -> int:
|
||||
def main(argv: Optional[list[str]] = None, *, run: Optional[Callable[..., object]] = None) -> int:
|
||||
"""Parse ``argv`` (default ``sys.argv[1:]``) and run one query through ``run_agent.main``.
|
||||
|
||||
Metadata flags and a bare invocation never reach the runner; ``run`` lets
|
||||
|
||||
@@ -20,7 +20,7 @@ _LM_EFFORT_ALIASES = {"off": "none", "on": "medium"}
|
||||
_LM_EFFORT_CLAMP = {"max": "xhigh", "ultra": "xhigh"}
|
||||
|
||||
|
||||
def resolve_lmstudio_effort(reasoning_config: Optional[dict], allowed_options: Optional[List[str]]) -> Optional[str]:
|
||||
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).
|
||||
|
||||
@@ -72,7 +72,7 @@ def release_workspace(path: str) -> int:
|
||||
for svc in services:
|
||||
try:
|
||||
released += svc.release_workspace(path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
logger.debug("LSP workspace release failed for %s: %s", path, e)
|
||||
return released
|
||||
|
||||
@@ -88,7 +88,7 @@ def shutdown_service() -> None:
|
||||
if svc is not None:
|
||||
try:
|
||||
svc.shutdown()
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
logger.debug("LSP shutdown error: %s", e)
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ def _atexit_shutdown() -> None:
|
||||
"""atexit wrapper; logs at debug since the user has already seen the final output."""
|
||||
try:
|
||||
shutdown_service()
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
logger.debug("atexit LSP shutdown failed: %s", e)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ def _all_servers() -> list:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
try:
|
||||
lsp_cfg = load_config_readonly().get("lsp") or {}
|
||||
except Exception: # noqa: BLE001 — a broken config still lists the built-ins
|
||||
except Exception:
|
||||
lsp_cfg = {}
|
||||
return [*custom_servers(lsp_cfg.get("servers") if isinstance(lsp_cfg, dict) else None), *SERVERS]
|
||||
|
||||
|
||||
+25
-25
@@ -55,7 +55,7 @@ MAX_TRACKED_FILES = 64
|
||||
_WRITE_ERRORS = (BrokenPipeError, ConnectionResetError, OSError)
|
||||
_LIVE_STATES = {"starting", "running"}
|
||||
|
||||
_CLIENT_CAPABILITIES: Dict[str, Any] = {
|
||||
_CLIENT_CAPABILITIES: dict[str, Any] = {
|
||||
"window": {"workDoneProgress": True},
|
||||
"workspace": {"configuration": True, "workspaceFolders": True,
|
||||
"didChangeWatchedFiles": {"dynamicRegistration": True}, "diagnostics": {"refreshSupport": False}},
|
||||
@@ -84,7 +84,7 @@ def file_uri(path: str) -> str:
|
||||
return "file://" + quote(abs_path, safe="/:")
|
||||
|
||||
|
||||
def _folder(root: str) -> Dict[str, str]:
|
||||
def _folder(root: str) -> dict[str, str]:
|
||||
"""Build an LSP ``WorkspaceFolder`` for ``root``."""
|
||||
return {"name": os.path.basename(root.rstrip(os.sep)) or root, "uri": file_uri(root)}
|
||||
|
||||
@@ -99,7 +99,7 @@ def uri_to_path(uri: str) -> str:
|
||||
return os.path.normpath(unquote(raw))
|
||||
|
||||
|
||||
def _end_position(text: str) -> Dict[str, int]:
|
||||
def _end_position(text: str) -> dict[str, int]:
|
||||
"""LSP Position at the end of ``text`` (for a whole-document replace range)."""
|
||||
if not text:
|
||||
return {"line": 0, "character": 0}
|
||||
@@ -119,8 +119,8 @@ class _DocState:
|
||||
publishDiagnostics get exact tagging; others are credited with the current version at receipt."""
|
||||
version: int = 0
|
||||
text: str = ""
|
||||
push: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pull: List[Dict[str, Any]] = field(default_factory=list)
|
||||
push: list[dict[str, Any]] = field(default_factory=list)
|
||||
pull: list[dict[str, Any]] = field(default_factory=list)
|
||||
push_version: int = -1
|
||||
pull_version: int = -1
|
||||
seed_seen: bool = False
|
||||
@@ -139,15 +139,15 @@ class LSPClient:
|
||||
"""One server process + one workspace root. ``start()`` → ``open_file()`` → ``wait_for_diagnostics()`` →
|
||||
``diagnostics_for()`` → ``shutdown()``."""
|
||||
|
||||
def __init__(self, *, server_id: str, workspace_root: str, command: List[str],
|
||||
env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None,
|
||||
initialization_options: Optional[Dict[str, Any]] = None,
|
||||
def __init__(self, *, server_id: str, workspace_root: str, command: list[str],
|
||||
env: Optional[dict[str, str]] = None, cwd: Optional[str] = None,
|
||||
initialization_options: Optional[dict[str, Any]] = None,
|
||||
seed_diagnostics_on_first_push: bool = False) -> None:
|
||||
self.server_id = server_id
|
||||
self.workspace_root = workspace_root
|
||||
# Roots this server serves. Single-root servers only ever hold ``workspace_root``;
|
||||
# multi-root servers (pyright) grow this via ``add_workspace_folder`` instead of a second process.
|
||||
self.workspace_folders: List[str] = [workspace_root]
|
||||
self.workspace_folders: list[str] = [workspace_root]
|
||||
self._command = list(command)
|
||||
self._env = env
|
||||
self._cwd = cwd or workspace_root
|
||||
@@ -157,16 +157,16 @@ class LSPClient:
|
||||
self._proc: Optional[asyncio.subprocess.Process] = None
|
||||
self._stderr_task: Optional[asyncio.Task] = None
|
||||
# Ring buffer of the most recent server stderr lines, surfaced when spawn/initialize fails.
|
||||
self._stderr_tail: List[str] = []
|
||||
self._stderr_tail: list[str] = []
|
||||
self._exit_code: Optional[int] = None
|
||||
self._reader_task: Optional[asyncio.Task] = None
|
||||
self._cleanup_lock = asyncio.Lock()
|
||||
self._next_id: int = 0
|
||||
self._pending: Dict[int, asyncio.Future] = {}
|
||||
self._pending: dict[int, asyncio.Future] = {}
|
||||
|
||||
# Server → client requests; anything else gets method-not-found. Capability (un)registration
|
||||
# and diagnostic refresh are acknowledged but not acted on: we re-pull on every touch anyway.
|
||||
self._request_handlers: Dict[str, Callable[[Any], Awaitable[Any]]] = {
|
||||
self._request_handlers: dict[str, Callable[[Any], Awaitable[Any]]] = {
|
||||
"window/workDoneProgress/create": self._handle_null,
|
||||
"workspace/configuration": self._handle_workspace_configuration,
|
||||
"client/registerCapability": self._handle_null,
|
||||
@@ -175,11 +175,11 @@ class LSPClient:
|
||||
"workspace/diagnostic/refresh": self._handle_null,
|
||||
}
|
||||
# Server → client notifications; others (showMessage, $/progress) are dropped.
|
||||
self._notification_handlers: Dict[str, Callable[[Any], None]] = {
|
||||
self._notification_handlers: dict[str, Callable[[Any], None]] = {
|
||||
"textDocument/publishDiagnostics": self._handle_publish_diagnostics,
|
||||
}
|
||||
|
||||
self._docs: Dict[str, _DocState] = {} # keyed by absolute path (NOT URI)
|
||||
self._docs: dict[str, _DocState] = {} # keyed by absolute path (NOT URI)
|
||||
self._state: str = "stopped"
|
||||
self._sync_kind: int = 1 # 1=Full, 2=Incremental
|
||||
self._stopping: bool = False
|
||||
@@ -313,7 +313,7 @@ class LSPClient:
|
||||
|
||||
def failure_details(self) -> str:
|
||||
"""Exit status + last stderr lines for a failed spawn/initialize; empty for a live server."""
|
||||
parts: List[str] = []
|
||||
parts: list[str] = []
|
||||
if exit_desc := self._describe_exit():
|
||||
parts.append(exit_desc)
|
||||
if self._stderr_tail:
|
||||
@@ -353,7 +353,7 @@ class LSPClient:
|
||||
if unexpected_close:
|
||||
await self._cleanup_process()
|
||||
|
||||
def _workspace_folders(self) -> List[Dict[str, str]]:
|
||||
def _workspace_folders(self) -> list[dict[str, str]]:
|
||||
return [_folder(r) for r in self.workspace_folders]
|
||||
|
||||
async def add_workspace_folder(self, root: str) -> None:
|
||||
@@ -405,7 +405,7 @@ class LSPClient:
|
||||
pass
|
||||
try:
|
||||
await self._send_notification("exit", None)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
# Signalling right after ``exit`` races the server's own exit: needless SIGTERM
|
||||
# noise for well-behaved servers and, on Darwin, a reaped-and-reused PID target.
|
||||
@@ -517,7 +517,7 @@ class LSPClient:
|
||||
else:
|
||||
try:
|
||||
reply = make_response(req_id, await handler(msg.get("params")))
|
||||
except Exception as e: # noqa: BLE001 — protocol must not blow up
|
||||
except Exception as e:
|
||||
logger.warning("[%s] request handler %s failed: %s", self.server_id, method, e)
|
||||
reply = make_error_response(req_id, -32000, f"handler failed: {e}")
|
||||
await self._send_reply(reply)
|
||||
@@ -528,7 +528,7 @@ class LSPClient:
|
||||
return
|
||||
try:
|
||||
handler(msg.get("params"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
logger.debug("[%s] notification handler %s failed: %s", self.server_id, method, e)
|
||||
|
||||
# ---- built-in server-→-client request handlers ----
|
||||
@@ -611,7 +611,7 @@ class LSPClient:
|
||||
return 0
|
||||
# pop + reinsert refreshes LRU recency (dicts are insertion-ordered).
|
||||
self._docs[abs_path] = self._docs.pop(abs_path)
|
||||
change: Dict[str, Any] = {"text": text}
|
||||
change: dict[str, Any] = {"text": text}
|
||||
if self._sync_kind == 2:
|
||||
change["range"] = {"start": {"line": 0, "character": 0}, "end": _end_position(doc.text)}
|
||||
new_version = doc.version + 1
|
||||
@@ -741,7 +741,7 @@ class LSPClient:
|
||||
continue
|
||||
await self._await_push(min(remaining, 0.5))
|
||||
|
||||
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]:
|
||||
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> list[dict[str, Any]]:
|
||||
"""Merged + deduped push/pull diagnostics for one file. With ``fresh_only=True`` a store only
|
||||
contributes once its version tag has caught up to the document's — report paths must use this
|
||||
so "stale" and "clean" aren't conflated."""
|
||||
@@ -753,9 +753,9 @@ class LSPClient:
|
||||
return _dedupe(push, pull)
|
||||
|
||||
|
||||
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
seen: Set[str] = set()
|
||||
out: List[Dict[str, Any]] = []
|
||||
def _dedupe(*lists: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
seen: set[str] = set()
|
||||
out: list[dict[str, Any]] = []
|
||||
for d in (d for lst in lists for d in lst if isinstance(d, dict)):
|
||||
if (key := _diagnostic_key(d)) not in seen:
|
||||
seen.add(key)
|
||||
@@ -763,7 +763,7 @@ def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _diagnostic_key(d: Dict[str, Any]) -> str:
|
||||
def _diagnostic_key(d: dict[str, Any]) -> str:
|
||||
"""Content-equality key: severity + code + source + message + range. Shared with the manager's
|
||||
cross-edit delta filter (``_diag_key``) so both layers agree on identity. Range is included so an
|
||||
identical error at a second site still surfaces as new (the manager line-shifts its baseline first)."""
|
||||
|
||||
@@ -50,7 +50,7 @@ def _emit(server_id: str, level: int, message: str) -> None:
|
||||
event_log.log(level, "lsp[%s] %s", server_id, message)
|
||||
|
||||
|
||||
def _emit_once(bucket: set, key: Tuple, server_id: str, level: int, first: str, repeat: str) -> None:
|
||||
def _emit_once(bucket: set, key: tuple, server_id: str, level: int, first: str, repeat: str) -> None:
|
||||
"""Log *first* at *level* the first time *key* is seen, *repeat* at DEBUG thereafter."""
|
||||
with _announce_lock:
|
||||
is_first = key not in bucket
|
||||
@@ -144,7 +144,7 @@ def log_untrusted_skipped(server_id: str, workspace_root: str, file_path: str) -
|
||||
f"skipped: untrusted workspace {workspace_root}")
|
||||
|
||||
|
||||
def log_reaped(keys: List[Tuple[str, str]], idle_timeout: float) -> None:
|
||||
def log_reaped(keys: list[tuple[str, str]], idle_timeout: float) -> None:
|
||||
"""Idle clients were reaped. INFO, one line per sweep.
|
||||
|
||||
Also forgets the ``log_active`` announcement for those keys so a respawn
|
||||
@@ -156,7 +156,7 @@ def log_reaped(keys: List[Tuple[str, str]], idle_timeout: float) -> None:
|
||||
_emit("reaper", logging.INFO, f"reaped {len(keys)} idle client(s) after {idle_timeout:.0f}s: {summary}")
|
||||
|
||||
|
||||
def log_released(keys: List[Tuple[str, str]], reason: str) -> None:
|
||||
def log_released(keys: list[tuple[str, str]], reason: str) -> None:
|
||||
"""Clients were shut down because their workspace went away (worktree released or root deleted).
|
||||
INFO, one line per event; forgets the ``log_active`` announcement like :func:`log_reaped`."""
|
||||
with _announce_lock:
|
||||
|
||||
+11
-11
@@ -24,15 +24,15 @@ from hermes_constants import find_node_executable, with_hermes_node_path
|
||||
logger = logging.getLogger("agent.lsp.install")
|
||||
|
||||
|
||||
def _recipe(strategy: str, pkg: str, bin_name: str, **extra: Any) -> Dict[str, Any]:
|
||||
def _recipe(strategy: str, pkg: str, bin_name: str, **extra: Any) -> dict[str, Any]:
|
||||
return {"strategy": strategy, "pkg": pkg, "bin": bin_name, **extra}
|
||||
|
||||
|
||||
def _npm(pkg: str, bin_name: str, **extra: Any) -> Dict[str, Any]:
|
||||
def _npm(pkg: str, bin_name: str, **extra: Any) -> dict[str, Any]:
|
||||
return _recipe("npm", pkg, bin_name, **extra)
|
||||
|
||||
|
||||
def _manual(bin_name: str) -> Dict[str, Any]:
|
||||
def _manual(bin_name: str) -> dict[str, Any]:
|
||||
return _recipe("manual", "", bin_name)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ TYPESCRIPT_SDK_PKG = "typescript@6"
|
||||
# Recipe key → {strategy, pkg, bin[, extra_pkgs]}. After install we look for
|
||||
# ``bin`` in ``<HERMES_HOME>/lsp/bin/`` first, then on PATH. ``extra_pkgs``
|
||||
# are sibling npm packages a server needs in the same node_modules tree.
|
||||
INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
|
||||
INSTALL_RECIPES: dict[str, dict[str, Any]] = {
|
||||
"pyright": _npm("pyright", "pyright-langserver"),
|
||||
# tsserver must be importable from the same node_modules tree or
|
||||
# initialize() fails with "Could not find a valid TypeScript installation".
|
||||
@@ -72,8 +72,8 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
|
||||
"powershell": _manual("pwsh"),
|
||||
}
|
||||
|
||||
_install_locks: Dict[str, threading.Lock] = {}
|
||||
_install_results: Dict[str, Optional[str]] = {}
|
||||
_install_locks: dict[str, threading.Lock] = {}
|
||||
_install_results: dict[str, Optional[str]] = {}
|
||||
_install_lock_meta = threading.Lock()
|
||||
_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat")
|
||||
|
||||
@@ -101,7 +101,7 @@ def _native_binary_candidates(base: Path, *, is_windows: Optional[bool] = None)
|
||||
"""
|
||||
if not (_is_windows() if is_windows is None else is_windows):
|
||||
return [base]
|
||||
cands: Dict[str, Path] = {}
|
||||
cands: dict[str, Path] = {}
|
||||
for c in (*(Path(str(base) + s) for s in _WINDOWS_WRAPPER_SUFFIXES), base):
|
||||
cands.setdefault(str(c).lower(), c)
|
||||
return list(cands.values())
|
||||
@@ -212,7 +212,7 @@ def _link_into_bin(target: Path) -> str:
|
||||
|
||||
# Node package manager → argv that installs into ``<staging>/node_modules`` (``lsp.package_manager``).
|
||||
# Every manager keeps the staging-dir semantics: nothing touches the user's project or global tree.
|
||||
_NODE_PM_ARGV: Dict[str, Callable[[str], list]] = {
|
||||
_NODE_PM_ARGV: dict[str, Callable[[str], list]] = {
|
||||
"npm": lambda staging: ["install", "--prefix", staging, "--silent", "--no-fund", "--no-audit"],
|
||||
"pnpm": lambda staging: ["add", "--dir", staging],
|
||||
# Global ``--cwd`` (before the command) is accepted by both Yarn Classic and Yarn Berry; Berry's
|
||||
@@ -226,7 +226,7 @@ def _node_package_manager() -> Optional[str]:
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
lsp_cfg = load_config_readonly().get("lsp") or {}
|
||||
except Exception: # noqa: BLE001 — installer must not die on a broken config; npm is the historical default
|
||||
except Exception:
|
||||
return "npm"
|
||||
pm = str(lsp_cfg.get("package_manager") or "npm").strip().lower() if isinstance(lsp_cfg, dict) else "npm"
|
||||
if pm not in _NODE_PM_ARGV:
|
||||
@@ -250,7 +250,7 @@ def _install_npm(pkg: str, bin_name: str, extra_pkgs: Optional[list] = None) ->
|
||||
|
||||
pm_store.ensure("npm")
|
||||
pm_bin = find_node_executable("npm")
|
||||
except Exception as exc: # noqa: BLE001 — a refused/failed PM install skips this server, never the session
|
||||
except Exception as exc:
|
||||
logger.warning("[install] cannot install %s: managed npm unavailable (%s)", pkg, exc)
|
||||
return None
|
||||
if pm_bin is None:
|
||||
@@ -306,7 +306,7 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
|
||||
|
||||
# strategy → installer(recipe, bin_name). ``manual`` is handled before dispatch.
|
||||
_INSTALLERS: Dict[str, Callable[[Dict[str, Any], str], Optional[str]]] = {
|
||||
_INSTALLERS: dict[str, Callable[[dict[str, Any], str], Optional[str]]] = {
|
||||
"npm": lambda r, b: _install_npm(r["pkg"], b, extra_pkgs=r.get("extra_pkgs") or []),
|
||||
"go": lambda r, b: _install_go(r["pkg"], b),
|
||||
"pip": lambda r, b: _install_pip(r["pkg"], b),
|
||||
|
||||
+33
-33
@@ -33,8 +33,8 @@ DEFAULT_IDLE_TIMEOUT = 600 # seconds; servers idle for >10min get reaped
|
||||
_DELTA_BASELINE_CAP = 256 # per-file pre-write snapshots; paths never written again would otherwise live forever (#62950)
|
||||
MIN_IDLE_TIMEOUT = 30 # floor for config values; must exceed any per-op wait budget
|
||||
|
||||
_Key = Tuple[str, str]
|
||||
_Diags = List[Dict[str, Any]]
|
||||
_Key = tuple[str, str]
|
||||
_Diags = list[dict[str, Any]]
|
||||
|
||||
|
||||
def _float_or(value: Any, default: float) -> float:
|
||||
@@ -44,7 +44,7 @@ def _float_or(value: Any, default: float) -> float:
|
||||
return default
|
||||
|
||||
|
||||
def _path_list(value: Any) -> Optional[List[str]]:
|
||||
def _path_list(value: Any) -> Optional[list[str]]:
|
||||
"""A config list of paths, ``~``-expanded; ``None`` when the value is not a list of strings."""
|
||||
if value is None:
|
||||
return []
|
||||
@@ -53,7 +53,7 @@ def _path_list(value: Any) -> Optional[List[str]]:
|
||||
return [os.path.expanduser(p) for p in value if p]
|
||||
|
||||
|
||||
def _parse_exclude_roots(value: Any) -> Optional[List[str]]:
|
||||
def _parse_exclude_roots(value: Any) -> Optional[list[str]]:
|
||||
"""``lsp.exclude_roots``; ``None`` (fail closed: every root excluded) for a malformed value."""
|
||||
if (roots := _path_list(value)) is None:
|
||||
eventlog.event_log.warning(
|
||||
@@ -62,7 +62,7 @@ def _parse_exclude_roots(value: Any) -> Optional[List[str]]:
|
||||
return roots
|
||||
|
||||
|
||||
def parse_trusted_workspaces(value: Any) -> List[str]:
|
||||
def parse_trusted_workspaces(value: Any) -> list[str]:
|
||||
"""``lsp.trusted_workspaces``; a malformed value trusts nothing extra."""
|
||||
if (roots := _path_list(value)) is None:
|
||||
eventlog.event_log.warning(
|
||||
@@ -103,7 +103,7 @@ class _BackgroundLoop:
|
||||
finally:
|
||||
try:
|
||||
loop.close()
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self, coro, *, timeout: Optional[float] = None) -> Any:
|
||||
@@ -139,12 +139,12 @@ class LSPService:
|
||||
|
||||
def __init__(
|
||||
self, *, enabled: bool, wait_mode: str, wait_timeout: float, install_strategy: str,
|
||||
binary_overrides: Optional[Dict[str, List[str]]] = None,
|
||||
env_overrides: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
init_overrides: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
disabled_servers: Optional[List[str]] = None,
|
||||
binary_overrides: Optional[dict[str, list[str]]] = None,
|
||||
env_overrides: Optional[dict[str, dict[str, str]]] = None,
|
||||
init_overrides: Optional[dict[str, dict[str, Any]]] = None,
|
||||
disabled_servers: Optional[list[str]] = None,
|
||||
idle_timeout: float = DEFAULT_IDLE_TIMEOUT,
|
||||
extra_servers: Optional[List[ServerDef]] = None,
|
||||
extra_servers: Optional[list[ServerDef]] = None,
|
||||
broken_retry_seconds: float = 0.0,
|
||||
warmup_timeout: float = 0.0,
|
||||
exclude_roots: Any = None,
|
||||
@@ -159,14 +159,14 @@ class LSPService:
|
||||
self._init_overrides = init_overrides or {}
|
||||
self._disabled_servers = set(disabled_servers or [])
|
||||
self._idle_timeout = idle_timeout
|
||||
self._extra_servers: List[ServerDef] = list(extra_servers or [])
|
||||
self._extra_servers: list[ServerDef] = list(extra_servers or [])
|
||||
self._broken_retry = max(0.0, broken_retry_seconds)
|
||||
self._warmup_timeout = max(0.0, warmup_timeout)
|
||||
# ``None`` = misconfigured → fail closed (every root excluded) until the user fixes the key: a
|
||||
# bare string here means "exclude that workspace", and excluding nothing would re-pay the stall it
|
||||
# was meant to avoid.
|
||||
self._exclude_roots: Optional[List[str]] = _parse_exclude_roots(exclude_roots)
|
||||
self._trusted_workspaces: List[str] = parse_trusted_workspaces(trusted_workspaces)
|
||||
self._exclude_roots: Optional[list[str]] = _parse_exclude_roots(exclude_roots)
|
||||
self._trusted_workspaces: list[str] = parse_trusted_workspaces(trusted_workspaces)
|
||||
self._untrusted_skipped: set = set() # (server_id, root) pairs denied by workspace trust
|
||||
self._operator_roots = frozenset(operator_workspace_roots()) # see _note_operator_roots
|
||||
|
||||
@@ -175,15 +175,15 @@ class LSPService:
|
||||
self._loop.start()
|
||||
|
||||
# Per-(server_id, workspace_root) state
|
||||
self._clients: Dict[_Key, LSPClient] = {}
|
||||
self._clients: dict[_Key, LSPClient] = {}
|
||||
# (server_id, root) → monotonic deadline after which the pair may be retried (inf = lifetime).
|
||||
self._broken: Dict[_Key, float] = {}
|
||||
self._spawning: Dict[_Key, asyncio.Future] = {}
|
||||
self._last_used: Dict[_Key, float] = {}
|
||||
self._broken: dict[_Key, float] = {}
|
||||
self._spawning: dict[_Key, asyncio.Future] = {}
|
||||
self._last_used: dict[_Key, float] = {}
|
||||
self._state_lock = threading.Lock()
|
||||
self._idle_reaper_task: Optional[asyncio.Task] = None
|
||||
# abs file path → diagnostics snapshot taken immediately before a write.
|
||||
self._delta_baseline: Dict[str, _Diags] = {}
|
||||
self._delta_baseline: dict[str, _Diags] = {}
|
||||
|
||||
if self._enabled and self._idle_timeout > 0:
|
||||
self._loop.run(self._start_idle_reaper(), timeout=2.0)
|
||||
@@ -194,7 +194,7 @@ class LSPService:
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
cfg = load_config_readonly()
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
logger.debug("LSP config load failed: %s", e)
|
||||
return None
|
||||
lsp_cfg = cfg.get("lsp") if isinstance(cfg, dict) else None
|
||||
@@ -255,7 +255,7 @@ class LSPService:
|
||||
return None
|
||||
try:
|
||||
return (srv.server_id, srv.resolve_root(file_path, ws_root) or ws_root)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
return (srv.server_id, ws_root)
|
||||
|
||||
def enabled_for(self, file_path: str) -> bool:
|
||||
@@ -354,7 +354,7 @@ class LSPService:
|
||||
budget = self._wait_budget(file_path)
|
||||
t = max(DIAGNOSTICS_DOCUMENT_WAIT + 3.0, budget + 3.0)
|
||||
diags = self._loop.run(self._snapshot_async(file_path, budget), timeout=t)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
|
||||
self._mark_broken_for_file(file_path, e)
|
||||
diags = []
|
||||
@@ -387,7 +387,7 @@ class LSPService:
|
||||
budget = self._wait_budget(file_path)
|
||||
t = timeout if timeout is not None else budget + 2.0
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path, budget=budget), timeout=t)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
if isinstance(e, asyncio.TimeoutError):
|
||||
eventlog.log_timeout(server_id, file_path)
|
||||
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
|
||||
@@ -424,7 +424,7 @@ class LSPService:
|
||||
# Roll the baseline forward so the next call is a delta against this state.
|
||||
try:
|
||||
fresh = self._loop.run(self._current_diags_async(file_path), timeout=2.0) or []
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
fresh = []
|
||||
if fresh:
|
||||
self._set_delta_baseline(abs_path, fresh)
|
||||
@@ -449,7 +449,7 @@ class LSPService:
|
||||
try:
|
||||
# Fire-and-forget shutdown — we're already on a slow path.
|
||||
self._loop.run(client.shutdown(), timeout=1.0)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
if not already_broken:
|
||||
eventlog.log_spawn_failed(key[0], key[1], exc)
|
||||
@@ -460,12 +460,12 @@ class LSPService:
|
||||
return
|
||||
try:
|
||||
self._loop.run(self._shutdown_async(), timeout=10.0)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
logger.debug("LSP shutdown error: %s", e)
|
||||
self._loop.stop()
|
||||
clear_cache()
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
"""Return a snapshot of the service for ``hermes lsp status``."""
|
||||
with self._state_lock:
|
||||
clients = [
|
||||
@@ -512,7 +512,7 @@ class LSPService:
|
||||
file_path, version, mode=self._wait_mode,
|
||||
timeout=budget if budget is not None else self._wait_timeout,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
if snapshot:
|
||||
logger.debug("snapshot open/wait failed: %s", e)
|
||||
else:
|
||||
@@ -574,7 +574,7 @@ class LSPService:
|
||||
if not owner:
|
||||
try:
|
||||
client = await spawning
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
return None
|
||||
return await self._attach_root(srv, client, root) if client is not None else None
|
||||
try:
|
||||
@@ -618,7 +618,7 @@ class LSPService:
|
||||
)
|
||||
try:
|
||||
await client.start()
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
eventlog.log_spawn_failed(srv.server_id, root, e)
|
||||
return None
|
||||
return client
|
||||
@@ -641,7 +641,7 @@ class LSPService:
|
||||
await self._reap_idle_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
# A transient sweep error must not kill the reaper, or the accumulation leak it fixes comes back.
|
||||
logger.debug("LSP idle reaper sweep error: %s", e)
|
||||
|
||||
@@ -671,7 +671,7 @@ class LSPService:
|
||||
root = os.path.abspath(workspace_root)
|
||||
try:
|
||||
return self._loop.run(self._release_async(root), timeout=15.0)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
logger.debug("LSP release of %s failed: %s", root, e)
|
||||
return 0
|
||||
|
||||
@@ -709,7 +709,7 @@ class LSPService:
|
||||
for folder in folders:
|
||||
try:
|
||||
await client.remove_workspace_folder(folder)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
logger.debug("LSP folder removal for %s failed: %s", folder, e)
|
||||
if clients:
|
||||
eventlog.log_released([(c.server_id, c.workspace_root) for c in clients], reason)
|
||||
|
||||
@@ -119,7 +119,7 @@ def make_error_response(req_id: Any, code: int, message: str, data: Any = None)
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": err}
|
||||
|
||||
|
||||
def classify_message(msg: dict) -> Tuple[str, Any]:
|
||||
def classify_message(msg: dict) -> tuple[str, Any]:
|
||||
"""Return ``(kind, key)``: kind ∈ request/response/notification/invalid; key is the id (request/response),
|
||||
the method (notification) or ``None`` (invalid)."""
|
||||
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
|
||||
|
||||
@@ -42,7 +42,7 @@ def build_line_shift(pre_text: str, post_text: str) -> _Shift:
|
||||
return shift
|
||||
|
||||
|
||||
def shift_diagnostic_range(diag: Dict[str, Any], shift: _Shift) -> Optional[Dict[str, Any]]:
|
||||
def shift_diagnostic_range(diag: dict[str, Any], shift: _Shift) -> Optional[dict[str, Any]]:
|
||||
"""Copy of ``diag`` with its line range remapped; ``None`` if the start line was deleted.
|
||||
|
||||
A multi-line diagnostic whose end straddles the deletion collapses to a
|
||||
@@ -67,7 +67,7 @@ def shift_diagnostic_range(diag: Dict[str, Any], shift: _Shift) -> Optional[Dict
|
||||
}
|
||||
|
||||
|
||||
def shift_baseline(baseline: List[Dict[str, Any]], shift: _Shift) -> List[Dict[str, Any]]:
|
||||
def shift_baseline(baseline: list[dict[str, Any]], shift: _Shift) -> list[dict[str, Any]]:
|
||||
"""Apply ``shift`` to every diagnostic in ``baseline``, dropping deleted entries."""
|
||||
shifted = (shift_diagnostic_range(d, shift) for d in baseline if isinstance(d, dict))
|
||||
return [s for s in shifted if s is not None]
|
||||
|
||||
@@ -40,7 +40,7 @@ def _sanitize_field(value: Any, *, limit: int) -> str:
|
||||
return html.escape(raw.strip()[:limit], quote=False)
|
||||
|
||||
|
||||
def format_diagnostic(d: Dict[str, Any]) -> str:
|
||||
def format_diagnostic(d: dict[str, Any]) -> str:
|
||||
"""One-line representation of a single diagnostic (fields sanitized)."""
|
||||
sev = SEVERITY_NAMES.get(d.get("severity") or 1, "ERROR")
|
||||
start = (d.get("range") or {}).get("start") or {}
|
||||
@@ -54,7 +54,7 @@ def format_diagnostic(d: Dict[str, Any]) -> str:
|
||||
|
||||
def report_for_file(
|
||||
file_path: str,
|
||||
diagnostics: List[Dict[str, Any]],
|
||||
diagnostics: list[dict[str, Any]],
|
||||
*,
|
||||
severities: frozenset = DEFAULT_SEVERITIES,
|
||||
max_per_file: int = MAX_PER_FILE,
|
||||
|
||||
+21
-21
@@ -20,7 +20,7 @@ logger = logging.getLogger("agent.lsp.servers")
|
||||
|
||||
# LSP languageId for ``textDocument/didOpen``, as language → extensions. A few
|
||||
# servers (typescript-language-server, vue-language-server) refuse wrong IDs.
|
||||
_EXTS_BY_LANGUAGE: Dict[str, Sequence[str]] = {
|
||||
_EXTS_BY_LANGUAGE: dict[str, Sequence[str]] = {
|
||||
"python": (".py", ".pyi"),
|
||||
"typescript": (".ts", ".mts", ".cts"),
|
||||
"typescriptreact": (".tsx",),
|
||||
@@ -45,7 +45,7 @@ _EXTS_BY_LANGUAGE: Dict[str, Sequence[str]] = {
|
||||
"dockerfile": (".dockerfile",),
|
||||
"powershell": (".ps1", ".psm1", ".psd1"),
|
||||
}
|
||||
LANGUAGE_BY_EXT: Dict[str, str] = {ext: lang for lang, exts in _EXTS_BY_LANGUAGE.items() for ext in exts}
|
||||
LANGUAGE_BY_EXT: dict[str, str] = {ext: lang for lang, exts in _EXTS_BY_LANGUAGE.items() for ext in exts}
|
||||
|
||||
_SpawnFn = Callable[[str, "ServerContext"], Optional["SpawnSpec"]]
|
||||
_RootFn = Callable[[str, str], Optional[str]]
|
||||
@@ -54,11 +54,11 @@ _RootFn = Callable[[str, str], Optional[str]]
|
||||
@dataclass
|
||||
class SpawnSpec:
|
||||
"""Result of resolving a server for a file (``None`` means skip)."""
|
||||
command: List[str]
|
||||
command: list[str]
|
||||
workspace_root: str
|
||||
cwd: str
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
initialization_options: Dict[str, Any] = field(default_factory=dict)
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
initialization_options: dict[str, Any] = field(default_factory=dict)
|
||||
seed_diagnostics_on_first_push: bool = False
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class ServerDef:
|
||||
"""One language server: ``resolve_root(file, ws)`` → per-server root or ``None`` to skip;
|
||||
``build_spawn(root, ctx)`` → :class:`SpawnSpec` or ``None`` when the binary can't be found."""
|
||||
server_id: str
|
||||
extensions: Tuple[str, ...]
|
||||
extensions: tuple[str, ...]
|
||||
resolve_root: _RootFn
|
||||
build_spawn: _SpawnFn
|
||||
seed_first_push: bool = False
|
||||
@@ -87,9 +87,9 @@ class ServerContext:
|
||||
"""User policy passed into :meth:`ServerDef.build_spawn` (install strategy, overrides)."""
|
||||
workspace_root: str
|
||||
install_strategy: str = "auto" # "auto" | "manual" | "off"
|
||||
binary_overrides: Dict[str, List[str]] = field(default_factory=dict)
|
||||
env_overrides: Dict[str, Dict[str, str]] = field(default_factory=dict)
|
||||
init_overrides: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
binary_overrides: dict[str, list[str]] = field(default_factory=dict)
|
||||
env_overrides: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
init_overrides: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# Whether the server may load code the project ships (see workspace.is_trusted_workspace).
|
||||
# Defaults closed: a context built without a trust decision must never run the repo's code.
|
||||
trusted: bool = False
|
||||
@@ -142,8 +142,8 @@ def _find_binary(ctx: ServerContext, server_id: str, which: Sequence[str], insta
|
||||
return bin_path
|
||||
|
||||
|
||||
def _make_spec(root: str, ctx: ServerContext, server_id: str, command: List[str],
|
||||
base_init: Optional[Dict[str, Any]] = None, seed: bool = False) -> SpawnSpec:
|
||||
def _make_spec(root: str, ctx: ServerContext, server_id: str, command: list[str],
|
||||
base_init: Optional[dict[str, Any]] = None, seed: bool = False) -> SpawnSpec:
|
||||
from pm import env_for
|
||||
|
||||
init = ctx.init_overrides.get(server_id, {}) if base_init is None else {**base_init, **ctx.init_overrides.get(server_id, {})}
|
||||
@@ -154,8 +154,8 @@ def _make_spec(root: str, ctx: ServerContext, server_id: str, command: List[str]
|
||||
|
||||
|
||||
def _simple_spawn(server_id: str, which: Sequence[str], args: Sequence[str] = (),
|
||||
install_pkg: Optional[str] = None, base_init: Optional[Dict[str, Any]] = None,
|
||||
seed: bool = False, untrusted_init: Optional[Dict[str, Any]] = None) -> _SpawnFn:
|
||||
install_pkg: Optional[str] = None, base_init: Optional[dict[str, Any]] = None,
|
||||
seed: bool = False, untrusted_init: Optional[dict[str, Any]] = None) -> _SpawnFn:
|
||||
"""Build a spawn function for the common single-binary server shape; ``untrusted_init`` replaces
|
||||
``base_init`` in an untrusted workspace (the server's own switch for not loading project code)."""
|
||||
def build(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
|
||||
@@ -235,7 +235,7 @@ _VUE_TSDK_MSG = (
|
||||
)
|
||||
|
||||
|
||||
def _node_modules_trees(bin_path: str, root: Optional[str]) -> List[str]:
|
||||
def _node_modules_trees(bin_path: str, root: Optional[str]) -> list[str]:
|
||||
"""``node_modules`` trees that may hold a server and its TypeScript SDK: the launcher's own tree
|
||||
(symlinks resolved), Hermes staging, then the project's (``root`` None: Hermes's trees only, for
|
||||
TypeScript's SDK pin in an untrusted workspace, whose own JavaScript must not load)."""
|
||||
@@ -296,7 +296,7 @@ def _spawn_typescript(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
|
||||
bin_path = _find_binary(ctx, "typescript", ("typescript-language-server",), "typescript-language-server")
|
||||
if bin_path is None:
|
||||
return None
|
||||
base: Dict[str, Any] = {}
|
||||
base: dict[str, Any] = {}
|
||||
if not ctx.trusted:
|
||||
sdk = _typescript_sdk_dir(_node_modules_trees(bin_path, None))
|
||||
if sdk is None:
|
||||
@@ -396,12 +396,12 @@ _DENO_EXCLUDES = ["deno.json", "deno.jsonc"]
|
||||
_root_typescript = _markers_root(_JS_MARKERS, _DENO_EXCLUDES)
|
||||
|
||||
|
||||
def _server(server_id: str, extensions: Tuple[str, ...], description: str, *,
|
||||
def _server(server_id: str, extensions: tuple[str, ...], description: str, *,
|
||||
markers: Optional[Sequence[str]] = None, excludes: Sequence[str] = (),
|
||||
resolve_root: Optional[_RootFn] = None, build_spawn: Optional[_SpawnFn] = None,
|
||||
which: Sequence[str] = (), args: Sequence[str] = (), install_pkg: Optional[str] = None,
|
||||
base_init: Optional[Dict[str, Any]] = None, seed: bool = False,
|
||||
untrusted_init: Optional[Dict[str, Any]] = None, multi_root: bool = False) -> ServerDef:
|
||||
base_init: Optional[dict[str, Any]] = None, seed: bool = False,
|
||||
untrusted_init: Optional[dict[str, Any]] = None, multi_root: bool = False) -> ServerDef:
|
||||
"""Registry entry factory: defaults to marker-based root + single-binary spawn."""
|
||||
return ServerDef(
|
||||
server_id, extensions,
|
||||
@@ -411,7 +411,7 @@ def _server(server_id: str, extensions: Tuple[str, ...], description: str, *,
|
||||
)
|
||||
|
||||
|
||||
SERVERS: List[ServerDef] = [
|
||||
SERVERS: list[ServerDef] = [
|
||||
_server("pyright", (".py", ".pyi"), "Python — Microsoft pyright",
|
||||
markers=["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"],
|
||||
build_spawn=_spawn_pyright, multi_root=True),
|
||||
@@ -498,14 +498,14 @@ def _custom_spawn(server_id: str, command: Sequence[str]) -> _SpawnFn:
|
||||
return build
|
||||
|
||||
|
||||
def custom_servers(servers_cfg: Any) -> List[ServerDef]:
|
||||
def custom_servers(servers_cfg: Any) -> list[ServerDef]:
|
||||
"""``lsp.servers`` entries that declare ``extensions`` and name no built-in server are user-declared
|
||||
servers (issue #100257). They go AHEAD of the built-ins so a custom entry can claim an extension;
|
||||
malformed entries are logged and skipped so one typo never disables the rest of the subsystem."""
|
||||
if not isinstance(servers_cfg, dict):
|
||||
return []
|
||||
builtin = {s.server_id for s in SERVERS}
|
||||
out: List[ServerDef] = []
|
||||
out: list[ServerDef] = []
|
||||
for server_id, cfg in servers_cfg.items():
|
||||
if server_id in builtin or not isinstance(cfg, dict) or "extensions" not in cfg:
|
||||
continue
|
||||
|
||||
@@ -134,7 +134,7 @@ def nearest_root(
|
||||
return None
|
||||
|
||||
|
||||
def resolve_workspace_for_file(file_path: str, *, cwd: Optional[str] = None) -> Tuple[Optional[str], bool]:
|
||||
def resolve_workspace_for_file(file_path: str, *, cwd: Optional[str] = None) -> tuple[Optional[str], bool]:
|
||||
"""Return ``(workspace_root, gated_in)`` for a file. The cwd's worktree wins when the file is
|
||||
inside it; otherwise the file's own worktree is the fallback anchor (monorepos / unrelated
|
||||
checkouts). ``(None, False)`` when neither is in a git worktree."""
|
||||
@@ -154,7 +154,7 @@ def resolve_workspace_for_file(file_path: str, *, cwd: Optional[str] = None) ->
|
||||
return None, False
|
||||
|
||||
|
||||
def operator_workspace_roots() -> Set[str]:
|
||||
def operator_workspace_roots() -> set[str]:
|
||||
"""Git worktrees the operator pointed Hermes at: the launch dir and the surface-set workspace
|
||||
(``resolve_agent_cwd``: the Desktop/TUI session cwd, ``hermes -w``'s worktree, a gateway's
|
||||
``terminal.cwd``). The agent's ``cd`` moves neither (it only moves the terminal's cwd). A repo at
|
||||
@@ -172,7 +172,7 @@ def operator_workspace_roots() -> Set[str]:
|
||||
if not is_truthy_value(get_session_env("HERMES_CRON_SESSION", "")):
|
||||
anchors.append(resolve_agent_cwd)
|
||||
home = normalize_path("~")
|
||||
roots: Set[str] = set()
|
||||
roots: set[str] = set()
|
||||
for anchor in anchors:
|
||||
try:
|
||||
root = find_git_worktree(str(anchor()))
|
||||
|
||||
+11
-11
@@ -28,7 +28,7 @@ def _disp_width(s: str) -> int:
|
||||
return max(wcswidth(s), 0)
|
||||
|
||||
|
||||
def split_table_row(row: str) -> List[str]:
|
||||
def split_table_row(row: str) -> list[str]:
|
||||
"""Split ``| a | b | c |`` into ``["a", "b", "c"]`` with trims."""
|
||||
return [c.strip() for c in row.strip().removeprefix("|").removesuffix("|").split("|")]
|
||||
|
||||
@@ -51,7 +51,7 @@ def looks_like_table_row(row: str) -> bool:
|
||||
return bool(stripped) and (stripped.startswith("|") or stripped.count("|") >= 2)
|
||||
|
||||
|
||||
def _render_block(rows: List[List[str]], available_width: int | None = None) -> List[str]:
|
||||
def _render_block(rows: list[list[str]], available_width: int | None = None) -> list[str]:
|
||||
"""Render ``rows`` (header + body, divider implied) at uniform widths.
|
||||
|
||||
When the horizontal table would exceed ``available_width`` fall back to a
|
||||
@@ -65,7 +65,7 @@ def _render_block(rows: List[List[str]], available_width: int | None = None) ->
|
||||
if available_width is not None and sum(widths) + 3 * ncols + 1 > max(available_width, 20):
|
||||
return _render_vertical(rows, ncols, available_width)
|
||||
|
||||
def _row(cells: List[str]) -> str:
|
||||
def _row(cells: list[str]) -> str:
|
||||
return "| " + " | ".join(c + " " * max(0, widths[k] - _disp_width(c)) for k, c in enumerate(cells)) + " |"
|
||||
|
||||
out = [_row(rows[0]), "|" + "|".join("-" * (w + 2) for w in widths) + "|"]
|
||||
@@ -73,9 +73,9 @@ def _render_block(rows: List[List[str]], available_width: int | None = None) ->
|
||||
return out
|
||||
|
||||
|
||||
def _hard_break(word: str, w: int) -> List[str]:
|
||||
def _hard_break(word: str, w: int) -> list[str]:
|
||||
"""Split a single over-wide word into display-width-``w`` chunks."""
|
||||
out: List[str] = []
|
||||
out: list[str] = []
|
||||
buf, bw = "", 0
|
||||
for ch in word:
|
||||
cw = _disp_width(ch) or 1
|
||||
@@ -87,7 +87,7 @@ def _hard_break(word: str, w: int) -> List[str]:
|
||||
return out + [buf] if buf else out
|
||||
|
||||
|
||||
def _wrap_to_width(text: str, width: int) -> List[str]:
|
||||
def _wrap_to_width(text: str, width: int) -> list[str]:
|
||||
"""Soft-wrap ``text`` at word boundaries to ``width`` display cells.
|
||||
|
||||
Words wider than ``width`` are hard-broken. Empty input yields a single
|
||||
@@ -98,7 +98,7 @@ def _wrap_to_width(text: str, width: int) -> List[str]:
|
||||
words = text.split()
|
||||
if not words:
|
||||
return [""]
|
||||
lines: List[str] = []
|
||||
lines: list[str] = []
|
||||
current, current_w = "", 0
|
||||
|
||||
def _start(word: str, ww: int) -> None:
|
||||
@@ -126,7 +126,7 @@ def _wrap_to_width(text: str, width: int) -> List[str]:
|
||||
return lines or [""]
|
||||
|
||||
|
||||
def _render_vertical(rows: List[List[str]], ncols: int, available_width: int) -> List[str]:
|
||||
def _render_vertical(rows: list[list[str]], ncols: int, available_width: int) -> list[str]:
|
||||
"""Render a too-wide table as ``Header: value`` blocks (Claude Code's narrow fallback).
|
||||
|
||||
Each body row becomes one block with continuation lines indented two spaces,
|
||||
@@ -137,7 +137,7 @@ def _render_vertical(rows: List[List[str]], ncols: int, available_width: int) ->
|
||||
labels = [h or f"Column {i + 1}" for i, h in enumerate(rows[0] + [""] * (ncols - len(rows[0])))]
|
||||
separator = "─" * (max(20, min(40, available_width - 2)) if available_width else 30)
|
||||
cont_budget = max(10, available_width - 2) # continuation lines are indented two spaces
|
||||
out: List[str] = []
|
||||
out: list[str] = []
|
||||
for ri, row in enumerate(rows[1:]):
|
||||
if ri > 0:
|
||||
out.append(separator)
|
||||
@@ -165,14 +165,14 @@ def realign_markdown_tables(text: str, available_width: int | None = None) -> st
|
||||
if "|" not in text:
|
||||
return text
|
||||
lines = text.split("\n")
|
||||
out: List[str] = []
|
||||
out: list[str] = []
|
||||
i, n = 0, len(lines)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
# A table starts with a header row whose next line is a divider.
|
||||
if "|" in line and i + 1 < n and is_table_divider(lines[i + 1]):
|
||||
header = split_table_row(line)
|
||||
body: List[List[str]] = []
|
||||
body: list[list[str]] = []
|
||||
j = i + 2
|
||||
while j < n and "|" in lines[j] and lines[j].strip():
|
||||
if not is_table_divider(lines[j]):
|
||||
|
||||
+29
-29
@@ -63,7 +63,7 @@ def _accepts_require_checkpoint(fn: Callable[..., Any]) -> bool:
|
||||
|
||||
# -- Tool-schema plumbing -----------------------------------------------------
|
||||
|
||||
def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
|
||||
def normalize_tool_schema(schema: Any) -> Optional[dict[str, Any]]:
|
||||
"""Return a bare function-tool dict with a resolvable top-level ``name``, else None.
|
||||
|
||||
Providers should return ``{"name", "description", "parameters"}`` but some return the
|
||||
@@ -78,7 +78,7 @@ def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
|
||||
return schema if name and isinstance(name, str) else None
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]], disabled_toolsets: Optional[List[str]] = None,
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[list[str]], disabled_toolsets: Optional[list[str]] = None,
|
||||
*, memory_tool_present: bool = False) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if disabled_toolsets and "memory" in disabled_toolsets:
|
||||
@@ -226,7 +226,7 @@ def _redact_for_provider(*values: Any) -> Any:
|
||||
return scrubbed[0] if len(scrubbed) == 1 else tuple(scrubbed)
|
||||
|
||||
|
||||
def _redact_messages_for_egress(messages: Optional[List[Any]]) -> Optional[List[Any]]:
|
||||
def _redact_messages_for_egress(messages: Optional[list[Any]]) -> Optional[list[Any]]:
|
||||
return None if messages is None else list(_redact_for_provider(messages))
|
||||
|
||||
|
||||
@@ -396,16 +396,16 @@ class MemoryManager:
|
||||
"""
|
||||
|
||||
def __init__(self, *, external_prefetch_timeout: Optional[float] = None) -> None:
|
||||
self._providers: List[MemoryProvider] = []
|
||||
self._tool_to_provider: Dict[str, MemoryProvider] = {}
|
||||
self._external_prefetch_spill_config: Optional[Dict[str, Any]] = None
|
||||
self._providers: list[MemoryProvider] = []
|
||||
self._tool_to_provider: dict[str, MemoryProvider] = {}
|
||||
self._external_prefetch_spill_config: Optional[dict[str, Any]] = None
|
||||
self._has_external: bool = False
|
||||
timeout = external_prefetch_timeout
|
||||
timeout = _EXTERNAL_PREFETCH_TIMEOUT_S if timeout is None else float(timeout)
|
||||
if timeout <= 0:
|
||||
raise ValueError("external_prefetch_timeout must be positive")
|
||||
self._external_prefetch_timeout = timeout
|
||||
self._external_prefetch_threads: Dict[str, threading.Thread] = {}
|
||||
self._external_prefetch_threads: dict[str, threading.Thread] = {}
|
||||
self._external_prefetch_lock = threading.Lock()
|
||||
# Single-worker background executor for end-of-turn sync/prefetch, created lazily so
|
||||
# the builtin-only path spawns no threads; one worker serializes a provider's writes.
|
||||
@@ -413,17 +413,17 @@ class MemoryManager:
|
||||
self._sync_executor_lock = threading.Lock()
|
||||
# Futures by durability class ("write" / "prefetch") so shutdown can drain FIFO
|
||||
# within a bound, then report exactly what it abandoned.
|
||||
self._background_futures: Dict[Future, str] = {}
|
||||
self._background_futures: dict[Future, str] = {}
|
||||
self._shutting_down = False
|
||||
self._shutdown_drain_state: Dict[str, Any] = {
|
||||
self._shutdown_drain_state: dict[str, Any] = {
|
||||
"status": "not_started", "abandoned_writes": 0, "abandoned_prefetches": 0, "active_tasks": 0,
|
||||
}
|
||||
|
||||
def _each_provider(self, label: str, call: Callable[[MemoryProvider], Any], *, level: int = logging.DEBUG,
|
||||
providers: Optional[List[MemoryProvider]] = None, exc_info: bool = False) -> List[Any]:
|
||||
providers: Optional[list[MemoryProvider]] = None, exc_info: bool = False) -> list[Any]:
|
||||
"""Call ``call(provider)`` per provider, logging+swallowing failures; returns successes in order.
|
||||
``label`` completes the log line ``Memory provider '<name>' <label>: <exc>``."""
|
||||
results: List[Any] = []
|
||||
results: list[Any] = []
|
||||
for provider in self._providers if providers is None else providers:
|
||||
try:
|
||||
results.append(call(provider))
|
||||
@@ -483,7 +483,7 @@ class MemoryManager:
|
||||
logger.info("Memory provider '%s' registered (%d tools)", provider.name, len(schemas))
|
||||
|
||||
@property
|
||||
def providers(self) -> List[MemoryProvider]:
|
||||
def providers(self) -> list[MemoryProvider]:
|
||||
return list(self._providers)
|
||||
|
||||
def get_provider(self, name: str) -> Optional[MemoryProvider]:
|
||||
@@ -516,7 +516,7 @@ class MemoryManager:
|
||||
if provider.name == "builtin":
|
||||
return provider.prefetch(query, session_id=session_id)
|
||||
|
||||
result_box: Dict[str, Any] = {}
|
||||
result_box: dict[str, Any] = {}
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
@@ -559,7 +559,7 @@ class MemoryManager:
|
||||
def describe_recall(self) -> str:
|
||||
"""Deterministic recall indicator line (e.g. ``"🧠 Provider — recalled 3 memories"``); ``""`` if none.
|
||||
Call right after :meth:`prefetch_all` so the user SEES memory was used even if the model is silent."""
|
||||
segments: List[str] = []
|
||||
segments: list[str] = []
|
||||
for status in self._each_provider("recall_status failed (non-fatal)", lambda p: p.recall_status()):
|
||||
if status is None:
|
||||
continue
|
||||
@@ -588,8 +588,8 @@ class MemoryManager:
|
||||
return params is None or _has_var_kwargs(params) or keyword in params
|
||||
|
||||
def sync_all(self, user_content: str, assistant_content: str, *, session_id: str = "",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
turn_author: Optional[Dict[str, Any]] = None) -> None:
|
||||
messages: Optional[list[dict[str, Any]]] = None,
|
||||
turn_author: Optional[dict[str, Any]] = None) -> None:
|
||||
"""Sync a completed turn to all providers on the background worker.
|
||||
|
||||
Never inline: a provider's ``sync_turn`` may block for minutes, which kept ``run_conversation``
|
||||
@@ -609,7 +609,7 @@ class MemoryManager:
|
||||
optional_kwargs = {"messages": redacted_messages, "turn_author": turn_author}
|
||||
|
||||
def _sync(provider: MemoryProvider) -> None:
|
||||
kwargs: Dict[str, Any] = {"session_id": session_id}
|
||||
kwargs: dict[str, Any] = {"session_id": session_id}
|
||||
for keyword, value in optional_kwargs.items():
|
||||
if value is not None and self._provider_sync_accepts(provider, keyword):
|
||||
kwargs[keyword] = value
|
||||
@@ -674,12 +674,12 @@ class MemoryManager:
|
||||
return isinstance(e, RuntimeError) # executor already shut down — nothing pending
|
||||
return True
|
||||
|
||||
def get_all_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
def get_all_tool_schemas(self) -> list[dict[str, Any]]:
|
||||
"""Collect deduplicated tool schemas from all providers; reserved core tool names are
|
||||
skipped because :meth:`add_provider` refuses to route them."""
|
||||
from toolsets import _HERMES_CORE_TOOLS
|
||||
|
||||
schemas: List[Dict[str, Any]] = []
|
||||
schemas: list[dict[str, Any]] = []
|
||||
seen = set()
|
||||
|
||||
def _collect(provider: MemoryProvider) -> None:
|
||||
@@ -703,7 +703,7 @@ class MemoryManager:
|
||||
def has_tool(self, tool_name: str) -> bool:
|
||||
return tool_name in self._tool_to_provider
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
||||
def handle_tool_call(self, tool_name: str, args: dict[str, Any], **kwargs) -> str:
|
||||
"""Route a tool call to its provider; returns a JSON string (tool_error on failure)."""
|
||||
provider = self._tool_to_provider.get(tool_name)
|
||||
if provider is None:
|
||||
@@ -730,12 +730,12 @@ class MemoryManager:
|
||||
|
||||
self._each_provider("on_turn_start failed", _tick)
|
||||
|
||||
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
||||
def on_session_end(self, messages: list[dict[str, Any]]) -> None:
|
||||
messages = _redact_messages_for_egress(messages or [])
|
||||
self._each_provider("on_session_end failed", lambda p: p.on_session_end(messages), level=logging.WARNING,
|
||||
exc_info=True)
|
||||
|
||||
def commit_session_boundary_async(self, messages: List[Dict[str, Any]], *, new_session_id: str,
|
||||
def commit_session_boundary_async(self, messages: list[dict[str, Any]], *, new_session_id: str,
|
||||
parent_session_id: str = "", reason: str = "new_session") -> None:
|
||||
"""Queue old-session extraction + provider rebinding as ONE serialized task.
|
||||
|
||||
@@ -795,8 +795,8 @@ class MemoryManager:
|
||||
versions = (self._checkpoint_api_version(p) for p in self._providers)
|
||||
return any(v is not None and v >= api_version for v in versions)
|
||||
|
||||
def on_pre_compress(self, messages: List[Dict[str, Any]], *,
|
||||
evidence_messages: Optional[List[Dict[str, Any]]] = None, require_checkpoint: bool = False,
|
||||
def on_pre_compress(self, messages: list[dict[str, Any]], *,
|
||||
evidence_messages: Optional[list[dict[str, Any]]] = None, require_checkpoint: bool = False,
|
||||
checkpoint_api_version: int = PRE_COMPRESS_CHECKPOINT_API_VERSION) -> str:
|
||||
"""Notify providers before compression; return their combined summary-prompt text.
|
||||
|
||||
@@ -814,7 +814,7 @@ class MemoryManager:
|
||||
is_checkpoint_provider = version >= checkpoint_api_version
|
||||
use_evidence = is_checkpoint_provider and evidence_messages is not None
|
||||
provider_messages = evidence_messages if use_evidence else messages
|
||||
kwargs: Dict[str, Any] = {}
|
||||
kwargs: dict[str, Any] = {}
|
||||
# v1 providers and bare-shape v2 providers never see the signal.
|
||||
if is_checkpoint_provider and _accepts_require_checkpoint(provider.on_pre_compress):
|
||||
kwargs["require_checkpoint"] = require_checkpoint
|
||||
@@ -843,7 +843,7 @@ class MemoryManager:
|
||||
return "positional" if accepted >= 4 else "legacy"
|
||||
|
||||
def on_memory_write(self, action: str, target: str, content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
metadata: Optional[dict[str, Any]] = None) -> None:
|
||||
"""Notify external providers when the built-in memory tool writes (skips builtin, the source)."""
|
||||
content, metadata = _redact_for_provider(content, dict(metadata or {}))
|
||||
|
||||
@@ -874,8 +874,8 @@ class MemoryManager:
|
||||
return False
|
||||
return isinstance(result, dict) and result.get("success") is True and result.get("staged") is not True
|
||||
|
||||
def notify_memory_tool_write(self, tool_result: Any, tool_args: Dict[str, Any], *,
|
||||
build_metadata: Optional[Callable[[], Dict[str, Any]]] = None) -> None:
|
||||
def notify_memory_tool_write(self, tool_result: Any, tool_args: dict[str, Any], *,
|
||||
build_metadata: Optional[Callable[[], dict[str, Any]]] = None) -> None:
|
||||
"""Mirror a built-in memory tool call to external providers.
|
||||
|
||||
Gates on a committed write, expands single-op and batched ``operations`` shapes, keeps only
|
||||
@@ -926,7 +926,7 @@ class MemoryManager:
|
||||
providers=self._providers[::-1])
|
||||
|
||||
@property
|
||||
def shutdown_drain_state(self) -> Dict[str, Any]:
|
||||
def shutdown_drain_state(self) -> dict[str, Any]:
|
||||
"""Snapshot of the most recent bounded shutdown drain outcome."""
|
||||
with self._sync_executor_lock:
|
||||
return dict(self._shutdown_drain_state)
|
||||
|
||||
+12
-12
@@ -27,7 +27,7 @@ def ctx_bound(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
|
||||
|
||||
def spawn_context_thread(target: Callable[..., Any], *, name: str, daemon: bool = True,
|
||||
args: tuple = (), kwargs: Optional[Dict[str, Any]] = None) -> threading.Thread:
|
||||
args: tuple = (), kwargs: Optional[dict[str, Any]] = None) -> threading.Thread:
|
||||
"""Unstarted thread running *target* under the spawner's contextvars (see :func:`ctx_bound`).
|
||||
Every memory-provider background job (prefetch, sync, writer loops) must go through this."""
|
||||
return threading.Thread(target=ctx_bound(target), args=args, kwargs=kwargs, name=name, daemon=daemon)
|
||||
@@ -132,17 +132,17 @@ class MemoryProvider(ABC):
|
||||
|
||||
def sync_turn(
|
||||
self, user_content: str, assistant_content: str, *,
|
||||
session_id: str = "", messages: Optional[List[Dict[str, Any]]] = None,
|
||||
turn_author: Optional[Dict[str, Any]] = None,
|
||||
session_id: str = "", messages: Optional[list[dict[str, Any]]] = None,
|
||||
turn_author: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Persist a completed turn (non-blocking). ``messages`` is the OpenAI-style list so far.
|
||||
``turn_author`` (``{"id", "name", "is_bot"}``) is who wrote the user side; the manager sends it only to signatures that accept it."""
|
||||
|
||||
@abstractmethod
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
||||
"""OpenAI function-calling schemas ({"name", "description", "parameters"}); [] if none."""
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
||||
def handle_tool_call(self, tool_name: str, args: dict[str, Any], **kwargs) -> str:
|
||||
"""Handle one of this provider's tools; must return a JSON string."""
|
||||
raise NotImplementedError(f"Provider {self.name} does not handle tool {tool_name}")
|
||||
|
||||
@@ -156,13 +156,13 @@ class MemoryProvider(ABC):
|
||||
author_is_bot. The author trio names who wrote THIS turn (None, None, False without one): a shared session
|
||||
carries several participants, so a provider keying durable state on identity must read it per turn."""
|
||||
|
||||
def identity_signature(self) -> Dict[str, Any]:
|
||||
def identity_signature(self) -> dict[str, Any]:
|
||||
"""Identity-mapping values that must bust a cached gateway agent when they change (writer identity, alias
|
||||
tables, session-name prefixing). Provider-namespaced keys, JSON-serializable values. The gateway calls this
|
||||
on an uninitialized instance on every inbound message, so keep it cheap and read-only."""
|
||||
return {}
|
||||
|
||||
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
||||
def on_session_end(self, messages: list[dict[str, Any]]) -> None:
|
||||
"""End-of-session extraction; fires only at real session boundaries, never per-turn."""
|
||||
|
||||
def on_session_switch(
|
||||
@@ -173,25 +173,25 @@ class MemoryProvider(ABC):
|
||||
``reset`` is True only for a genuinely new conversation (flush buffers); ``rewound``:
|
||||
same id but the transcript was truncated."""
|
||||
|
||||
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
|
||||
def on_pre_compress(self, messages: list[dict[str, Any]]) -> str:
|
||||
"""Extract insights from ``messages`` about to be compressed, fed into the summary prompt."""
|
||||
return ""
|
||||
|
||||
def on_delegation(self, task: str, result: str, *, child_session_id: str = "", **kwargs) -> None:
|
||||
"""PARENT-side observation of a completed delegation (the subagent has no provider session)."""
|
||||
|
||||
def get_config_schema(self) -> List[Dict[str, Any]]:
|
||||
def get_config_schema(self) -> list[dict[str, Any]]:
|
||||
"""Setup fields for ``hermes memory setup`` ([] if none): ``key``, ``description``,
|
||||
optional ``secret`` (goes to .env), ``required``, ``default``, ``choices``, ``type``
|
||||
(text | integer | number | boolean), ``minimum``/``maximum``/``step``, ``url``,
|
||||
``env_var`` (explicit secret env var; default auto-generated)."""
|
||||
return []
|
||||
|
||||
def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
|
||||
def save_config(self, values: dict[str, Any], hermes_home: str) -> None:
|
||||
"""Write non-secret setup ``values`` to the provider's native config. Plugins MUST either
|
||||
override this or use only env vars (every schema field carrying ``env_var``)."""
|
||||
|
||||
def on_memory_write(self, action: str, target: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
def on_memory_write(self, action: str, target: str, content: str, metadata: Optional[dict[str, Any]] = None) -> None:
|
||||
"""Mirror a built-in memory-tool write (``action``: add | replace | remove; ``target``:
|
||||
memory | user; ``metadata``: provenance such as write_origin, session_id, tool_name).
|
||||
For replace/remove, ``metadata["previous_content"]`` is the full entry selected
|
||||
@@ -200,7 +200,7 @@ class MemoryProvider(ABC):
|
||||
callers may omit this field: ``old_text`` alone is not authoritative identity.
|
||||
"""
|
||||
|
||||
def backup_paths(self) -> List[str]:
|
||||
def backup_paths(self) -> list[str]:
|
||||
"""Absolute paths of provider state OUTSIDE HERMES_HOME for ``hermes backup``/``import``
|
||||
(paths outside the home dir are skipped). MUST work without ``initialize()`` or network."""
|
||||
return []
|
||||
@@ -62,7 +62,7 @@ def mint_uid() -> str:
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
def uid_list(value: Any) -> List[str]:
|
||||
def uid_list(value: Any) -> list[str]:
|
||||
"""The unique non-empty string uids of a live list, in order; anything else is ``[]``."""
|
||||
return list(dict.fromkeys(u for u in (value if isinstance(value, list) else ()) if isinstance(u, str) and u))
|
||||
|
||||
@@ -152,7 +152,7 @@ def _uid_occurrences(value: Any) -> list:
|
||||
return list(value) if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def per_occurrence_tool_call_uids(uids: Mapping[str, Any], tool_calls: List[Mapping[str, Any]]) -> dict:
|
||||
def per_occurrence_tool_call_uids(uids: Mapping[str, Any], tool_calls: list[Mapping[str, Any]]) -> dict:
|
||||
"""*uids* with every provider id this row names more than once spelled out as one uid per occurrence. A
|
||||
single response that repeats an id shares ONE uid (its results carry it, so every call stays paired);
|
||||
before a fold appends another turn's occurrences, the shared uid must fill each of this row's slots or
|
||||
@@ -209,7 +209,7 @@ def resolve_tool_call_uid(index: MutableMapping[str, str], tool_call_id: Any) ->
|
||||
return None
|
||||
|
||||
|
||||
def tool_call_uid_from_history(messages: List[dict], tool_index: int, owners: dict) -> Optional[str]:
|
||||
def tool_call_uid_from_history(messages: list[dict], tool_index: int, owners: dict) -> Optional[str]:
|
||||
"""Resolve a tool-result dict's uid from the nearest preceding assistant dict in ``messages`` that
|
||||
named its ``tool_call_id`` (the cross-flush case: the assistant row landed in an earlier batch).
|
||||
``owners`` memoizes each assistant's (named variants, uid index) across one flush's results, so K
|
||||
|
||||
+14
-14
@@ -37,7 +37,7 @@ def _is_micro_marker(entry: Any) -> bool:
|
||||
class MicroCompactionMixin:
|
||||
"""Rolling micro-compaction; host must be a ``ContextCompressor``."""
|
||||
|
||||
def _resolve_compact_cursor(self, messages: List[Dict[str, Any]], head_end: int, tail_start: int) -> int:
|
||||
def _resolve_compact_cursor(self, messages: list[dict[str, Any]], head_end: int, tail_start: int) -> int:
|
||||
"""Index of the first message not yet absorbed into the rolling summary: the in-memory
|
||||
cursor when valid, else just past the last summary marker."""
|
||||
if head_end < self._micro_compact_cursor < tail_start:
|
||||
@@ -61,7 +61,7 @@ class MicroCompactionMixin:
|
||||
return cursor
|
||||
|
||||
def _find_one_exchange(
|
||||
self, messages: List[Dict[str, Any]], start: int, tail_start: int,
|
||||
self, messages: list[dict[str, Any]], start: int, tail_start: int,
|
||||
) -> Optional[tuple[int, int]]:
|
||||
"""Find the next complete exchange (full agent turn) starting at *start*; returns
|
||||
``(exchange_start, exchange_end)`` or ``None``. Spans assistant+tool rows up to the next
|
||||
@@ -90,7 +90,7 @@ class MicroCompactionMixin:
|
||||
return None
|
||||
return (exchange_start, idx)
|
||||
|
||||
def _build_micro_summary_prompt(self, existing_summary: str, exchange_text: str) -> List[Dict[str, str]]:
|
||||
def _build_micro_summary_prompt(self, existing_summary: str, exchange_text: str) -> list[dict[str, str]]:
|
||||
"""Build the prompt messages for a single-exchange micro-summary."""
|
||||
summary_block = existing_summary if existing_summary.strip() else "(No previous summary yet.)"
|
||||
user_prompt = (
|
||||
@@ -163,7 +163,7 @@ class MicroCompactionMixin:
|
||||
"""Return True when the rolling summary is large enough to defrag."""
|
||||
return estimate_tokens_rough(self._micro_compact_rolling_summary) >= self._micro_compact_defrag_threshold_tokens
|
||||
|
||||
def _defrag_rolling_summary(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
def _defrag_rolling_summary(self, messages: list[dict[str, Any]]) -> bool:
|
||||
"""Re-summarize the rolling summary text and rewrite the marker in place.
|
||||
Transcript-shape-neutral (no splice, no cursor move). Returns True when it rewrote."""
|
||||
old_summary = self._micro_compact_rolling_summary
|
||||
@@ -200,7 +200,7 @@ class MicroCompactionMixin:
|
||||
self._micro_compact_consecutive_failures = 0
|
||||
self._micro_compact_last_failure_cursor = -1
|
||||
|
||||
def _micro_compact(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _micro_compact(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Run one round of micro-compaction (entry point from ``finalize_turn()``). Returns the
|
||||
(possibly modified) list and syncs the session DB via ``archive_and_compact`` (the
|
||||
append-only flush alone would double-load on resume)."""
|
||||
@@ -231,7 +231,7 @@ class MicroCompactionMixin:
|
||||
_held = list(messages)
|
||||
_skip, _start_watermark = self._micro_start_watermark(_held)
|
||||
|
||||
def _telemetry(outcome: str, result: List[Dict[str, Any]], **extra: Any) -> None:
|
||||
def _telemetry(outcome: str, result: list[dict[str, Any]], **extra: Any) -> None:
|
||||
self._emit_micro_compaction_telemetry(
|
||||
outcome=outcome, messages_before=n_messages, messages_after=len(result),
|
||||
tokens_before=_tokens_before, duration_ms=int((time.monotonic() - _started_at) * 1000), **extra,
|
||||
@@ -310,7 +310,7 @@ class MicroCompactionMixin:
|
||||
self._reset_micro_failure_tracking()
|
||||
return "exchange_skipped"
|
||||
|
||||
def _next_exchange(self, messages: List[Dict[str, Any]]) -> Optional[tuple[int, int]]:
|
||||
def _next_exchange(self, messages: list[dict[str, Any]]) -> Optional[tuple[int, int]]:
|
||||
"""The next un-absorbed exchange inside the compressible window, or None."""
|
||||
compress_start = self._align_boundary_forward(messages, self._protect_head_size(messages))
|
||||
compress_end = self._find_tail_cut_by_tokens(messages, compress_start, allow_split_turn=False)
|
||||
@@ -331,7 +331,7 @@ class MicroCompactionMixin:
|
||||
end = body.find(cc._SUMMARY_END_MARKER)
|
||||
return (body[:end] if end != -1 else body).strip()
|
||||
|
||||
def _cursor_after_splice(self, result: List[Dict[str, Any]], fallback: int) -> int:
|
||||
def _cursor_after_splice(self, result: list[dict[str, Any]], fallback: int) -> int:
|
||||
"""Cursor position just past the summary marker in *result*. Must derive from the SPLICED
|
||||
list: a splice collapses several rows into one marker (and may drop a superseded one), so
|
||||
pre-splice indices land inside a later exchange and silently skip it."""
|
||||
@@ -371,7 +371,7 @@ class MicroCompactionMixin:
|
||||
except Exception as exc:
|
||||
logger.debug("failed to emit micro-compaction telemetry: %s", exc)
|
||||
|
||||
def _micro_start_watermark(self, held: List[Dict[str, Any]]) -> "tuple[Optional[str], Optional[int]]":
|
||||
def _micro_start_watermark(self, held: list[dict[str, Any]]) -> "tuple[Optional[str], Optional[int]]":
|
||||
"""``(skip_outcome, watermark)`` taken before a pass's slow step; *skip_outcome* is the telemetry
|
||||
label of a pass that must not run, or None.
|
||||
|
||||
@@ -399,7 +399,7 @@ class MicroCompactionMixin:
|
||||
return None, watermark
|
||||
|
||||
def _sync_micro_compact_to_db(
|
||||
self, compacted_messages: List[Dict[str, Any]], *, held: Optional[List[Dict[str, Any]]] = None,
|
||||
self, compacted_messages: list[dict[str, Any]], *, held: Optional[list[dict[str, Any]]] = None,
|
||||
start_watermark: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Persist the micro-compacted set to the session DB atomically and stamp rows persisted.
|
||||
@@ -445,8 +445,8 @@ class MicroCompactionMixin:
|
||||
return True
|
||||
|
||||
def _splice_micro_compact_result(
|
||||
self, messages: List[Dict[str, Any]], splice_start: int, splice_end: int, supersede: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
self, messages: list[dict[str, Any]], splice_start: int, splice_end: int, supersede: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Replace *messages[splice_start:splice_end]* with an assistant-role summary marker.
|
||||
Merges user turns left adjacent by a superseded marker so the result is alternation-valid."""
|
||||
cc = _cc()
|
||||
@@ -480,7 +480,7 @@ class MicroCompactionMixin:
|
||||
cc = _cc()
|
||||
return f"{cc.SUMMARY_PREFIX}\n\n{cc.HISTORICAL_TASK_HEADING}\n{summary_text.strip()}\n\n{cc._SUMMARY_END_MARKER}"
|
||||
|
||||
def _merge_adjacent_user_turns(self, result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _merge_adjacent_user_turns(self, result: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Merge consecutive plain-text real user turns left by a supersede. Same ``\\n\\n`` join as
|
||||
``repair_message_sequence`` pass 2, done here so the marker and cursor are never collateral
|
||||
damage of the downstream repair. Lists untouched."""
|
||||
@@ -492,7 +492,7 @@ class MicroCompactionMixin:
|
||||
and isinstance(m.get("content"), str)
|
||||
)
|
||||
|
||||
merged: List[Dict[str, Any]] = []
|
||||
merged: list[dict[str, Any]] = []
|
||||
for msg in result:
|
||||
prev = merged[-1] if merged else None
|
||||
if _plain_user(msg) and _plain_user(prev):
|
||||
|
||||
+69
-69
@@ -60,13 +60,13 @@ def _strip_provider_prefix(model: str) -> str:
|
||||
return model
|
||||
|
||||
|
||||
_model_metadata_cache: Dict[str, Dict[str, Any]] = {}
|
||||
_model_metadata_cache: dict[str, dict[str, Any]] = {}
|
||||
_model_metadata_cache_time: float = 0
|
||||
_MODEL_CACHE_TTL = 3600
|
||||
# In-memory memo keyed by (base_url, api-key fingerprint): per-key gateways return a per-key catalog, and
|
||||
# in a multiplexed process two profiles may share a URL with different keys. The disk memo stays per URL.
|
||||
_endpoint_model_metadata_cache: Dict[Tuple[str, str], Dict[str, Dict[str, Any]]] = {}
|
||||
_endpoint_model_metadata_cache_time: Dict[Tuple[str, str], float] = {}
|
||||
_endpoint_model_metadata_cache: dict[tuple[str, str], dict[str, dict[str, Any]]] = {}
|
||||
_endpoint_model_metadata_cache_time: dict[tuple[str, str], float] = {}
|
||||
_ENDPOINT_MODEL_CACHE_TTL = 300
|
||||
# Server-type verdicts (server_type, monotonic_ts): positive ones live an hour so a
|
||||
# server swap on the same port is re-detected; None gets the short TTL so a
|
||||
@@ -78,11 +78,11 @@ _ENDPOINT_PROBE_TTL_SECONDS = 3600.0
|
||||
# verdict was never cached, so every turn re-probed). Short TTL keeps a transient failure (server starting
|
||||
# up, key being fixed) recoverable within minutes instead of pinning "undetected" for an hour.
|
||||
_ENDPOINT_PROBE_FAILURE_TTL_SECONDS = 300.0
|
||||
_endpoint_probe_path_cache: Dict[str, tuple] = {}
|
||||
_endpoint_probe_path_cache: dict[str, tuple] = {}
|
||||
# Routable-but-dead endpoints (corp LAN off-VPN) blackhole TCP: once ANY probe paid
|
||||
# a full connect timeout, later probes short-circuit for a while.
|
||||
_ENDPOINT_BLACKHOLE_TTL_SECONDS = 30.0
|
||||
_endpoint_blackhole_cache: Dict[str, float] = {} # host:port -> monotonic ts
|
||||
_endpoint_blackhole_cache: dict[str, float] = {} # host:port -> monotonic ts
|
||||
|
||||
|
||||
def _parse_base_url(base_url: str, scheme: str = "http"):
|
||||
@@ -149,7 +149,7 @@ def _cache_file(name: str) -> Path:
|
||||
return get_hermes_home() / "cache" / name
|
||||
|
||||
|
||||
def _load_json_dict(path: Path) -> Dict[str, Any]:
|
||||
def _load_json_dict(path: Path) -> dict[str, Any]:
|
||||
"""JSON object at ``path``, or {} when missing/invalid."""
|
||||
try:
|
||||
with path.open("r", encoding="utf-8-sig") as f:
|
||||
@@ -211,7 +211,7 @@ def _model_metadata_disk_cache_age_seconds() -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]:
|
||||
def _load_model_metadata_disk_cache() -> dict[str, dict[str, Any]]:
|
||||
"""Processed OpenRouter metadata cache from disk ({} on any failure)."""
|
||||
try:
|
||||
with _get_model_metadata_cache_path().open("r", encoding="utf-8-sig") as f:
|
||||
@@ -222,7 +222,7 @@ def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None:
|
||||
def _save_model_metadata_disk_cache(data: dict[str, dict[str, Any]]) -> None:
|
||||
try:
|
||||
atomic_json_write(_get_model_metadata_cache_path(), data, indent=0, separators=(",", ":"))
|
||||
except Exception as e:
|
||||
@@ -234,7 +234,7 @@ def _get_endpoint_metadata_cache_path() -> Path:
|
||||
return _cache_file("endpoint_model_metadata.json")
|
||||
|
||||
|
||||
def _endpoint_disk_cache_get(normalized: str) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||
def _endpoint_disk_cache_get(normalized: str) -> Optional[dict[str, dict[str, Any]]]:
|
||||
"""Fresh cross-process memo of a remote ``/models`` probe (same TTL as in-memory): one-shot
|
||||
runs (``hermes -q``, cron) start cold and Nous bypasses the persistent context cache, so
|
||||
without this every launch paid the live probe. Local endpoints are never memoized."""
|
||||
@@ -242,7 +242,7 @@ def _endpoint_disk_cache_get(normalized: str) -> Optional[Dict[str, Dict[str, An
|
||||
return models if isinstance(models, dict) else None
|
||||
|
||||
|
||||
def _endpoint_disk_cache_put(normalized: str, cache: Dict[str, Dict[str, Any]]) -> None:
|
||||
def _endpoint_disk_cache_put(normalized: str, cache: dict[str, dict[str, Any]]) -> None:
|
||||
"""Memoize a successful remote ``/models`` probe; expired siblings are dropped."""
|
||||
_ttl_memo_put(
|
||||
_get_endpoint_metadata_cache_path(), normalized, cache, _ENDPOINT_MODEL_CACHE_TTL,
|
||||
@@ -273,7 +273,7 @@ MINIMUM_CONTEXT_LENGTH = 64_000
|
||||
# In-process (model, base_url) -> (result, monotonic_ts) memo for local probes: one
|
||||
# startup resolves the same model several times (banner, /model, compressor). Never persisted.
|
||||
_LOCAL_CTX_PROBE_TTL_SECONDS = 30.0
|
||||
_LOCAL_CTX_PROBE_CACHE: Dict[tuple, tuple] = {}
|
||||
_LOCAL_CTX_PROBE_CACHE: dict[tuple, tuple] = {}
|
||||
# In-process (model, region) -> monotonic_ts memo of a FAILED Bedrock context probe. That probe pads
|
||||
# prompts of 1.3M/2.2M tokens and attempts up to two converse calls (agent/bedrock_adapter.py
|
||||
# _BEDROCK_PROBE_TIERS; the second tier is sent only when the first yields no parseable limit, i.e. on
|
||||
@@ -283,7 +283,7 @@ _LOCAL_CTX_PROBE_CACHE: Dict[tuple, tuple] = {}
|
||||
# reasoning as _ENDPOINT_PROBE_FAILURE_TTL_SECONDS: a failure is usually transient (expired SSO
|
||||
# session, offline box), so it must expire rather than stick.
|
||||
_BEDROCK_PROBE_FAILURE_TTL_SECONDS = 300.0
|
||||
_BEDROCK_PROBE_FAILURE_CACHE: Dict[tuple, float] = {}
|
||||
_BEDROCK_PROBE_FAILURE_CACHE: dict[tuple, float] = {}
|
||||
# Family-pattern fallbacks, used only when provider-aware sources all miss.
|
||||
# Lookups are longest-key-first substring matches, so dict order is cosmetic
|
||||
# and a specific key must be STRICTLY longer than its catch-all.
|
||||
@@ -425,7 +425,7 @@ def _normalize_base_url(base_url: str) -> str:
|
||||
return (base_url or "").strip().rstrip("/")
|
||||
|
||||
|
||||
def _auth_headers(api_key: object = "") -> Dict[str, str]:
|
||||
def _auth_headers(api_key: object = "") -> dict[str, str]:
|
||||
from agent.command_token_source import materialize_probe_api_key
|
||||
token = materialize_probe_api_key(api_key)
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
@@ -439,7 +439,7 @@ def _is_custom_endpoint(base_url: str) -> bool:
|
||||
# Host substring -> provider. ".githubcopilot.com" covers api.enterprise./api.business.
|
||||
# hosts; models.inference.ai.azure.com (GitHub Models free tier, ~8K per-request cap)
|
||||
# is mapped so a targeted hint fires instead of the custom-endpoint path.
|
||||
_URL_TO_PROVIDER: Dict[str, str] = {
|
||||
_URL_TO_PROVIDER: dict[str, str] = {
|
||||
"api.openai.com": "openai", "chatgpt.com": "openai", "api.anthropic.com": "anthropic",
|
||||
"api.z.ai": "zai", "open.bigmodel.cn": "zai",
|
||||
"api.moonshot.ai": "kimi-coding", "api.moonshot.cn": "kimi-coding-cn", "api.kimi.com": "kimi-coding",
|
||||
@@ -556,7 +556,7 @@ def _catalog_key_matches(key: str, model_lower: str) -> bool:
|
||||
return key in model_lower or _normalize_model_version(key) in _normalize_model_version(model_lower)
|
||||
|
||||
|
||||
def _longest_key_match(table: Dict[str, int], model_lower: str) -> Optional[Tuple[str, int]]:
|
||||
def _longest_key_match(table: dict[str, int], model_lower: str) -> Optional[tuple[str, int]]:
|
||||
"""First ``(key, value)`` whose key is a substring of ``model_lower``, longest key first so
|
||||
specific entries (``gpt-5.4-mini``) beat their family catch-all (``gpt-5``); ties keep table order."""
|
||||
for key, value in sorted(table.items(), key=lambda x: len(x[0]), reverse=True):
|
||||
@@ -565,7 +565,7 @@ def _longest_key_match(table: Dict[str, int], model_lower: str) -> Optional[Tupl
|
||||
return None
|
||||
|
||||
|
||||
def _ollama_show_context(data: Dict[str, Any], *, gguf_first: bool, minimum: Optional[int] = None) -> Optional[int]:
|
||||
def _ollama_show_context(data: dict[str, Any], *, gguf_first: bool, minimum: Optional[int] = None) -> Optional[int]:
|
||||
"""Context length from an Ollama ``/api/show`` payload. ``parameters.num_ctx`` is the RUNTIME
|
||||
window (Modelfile override), ``model_info.*.context_length`` the GGUF training max. Local users
|
||||
control num_ctx (prefer it); hosted operators may cap it arbitrarily (``gguf_first``)."""
|
||||
@@ -795,7 +795,7 @@ def _coerce_reasonable_int(value: Any, minimum: int = 1024, maximum: int = 10_00
|
||||
return result if minimum <= result <= maximum else None
|
||||
|
||||
|
||||
def _extract_first_int(payload: Dict[str, Any], keys: tuple[str, ...]) -> Optional[int]:
|
||||
def _extract_first_int(payload: dict[str, Any], keys: tuple[str, ...]) -> Optional[int]:
|
||||
keyset = {key.lower() for key in keys}
|
||||
for mapping in _iter_nested_dicts(payload):
|
||||
for key, value in mapping.items():
|
||||
@@ -806,13 +806,13 @@ def _extract_first_int(payload: Dict[str, Any], keys: tuple[str, ...]) -> Option
|
||||
return None
|
||||
|
||||
|
||||
def _extract_flat_context_length(payload: Dict[str, Any]) -> Optional[int]:
|
||||
def _extract_flat_context_length(payload: dict[str, Any]) -> Optional[int]:
|
||||
"""Top-level-only context WINDOW read (no nested walk, so an unrelated nested section can't leak
|
||||
a same-named key). ``max_tokens`` is NOT a window key: on OpenAI-compatible passthroughs it is the max OUTPUT."""
|
||||
return next((c for c in map(_coerce_reasonable_int, (payload.get(k) for k in _CONTEXT_LENGTH_KEYS)) if c is not None), None)
|
||||
|
||||
|
||||
def _context_length_from_model_payload(payload: Dict[str, Any]) -> Optional[int]:
|
||||
def _context_length_from_model_payload(payload: dict[str, Any]) -> Optional[int]:
|
||||
"""Context window from a ``/v1/models`` object: window keys first, ``max_tokens`` last (Anthropic
|
||||
payloads carry ``max_input_tokens`` = 1M window AND ``max_tokens`` = 128k OUTPUT cap)."""
|
||||
if not isinstance(payload, dict):
|
||||
@@ -834,10 +834,10 @@ _PER_MILLION_QUOTE_MIN = 0.001
|
||||
_TOKEN_RATE_FIELDS = ("prompt", "completion", "cache_read", "cache_write")
|
||||
|
||||
|
||||
def _normalize_token_rates(pricing: Dict[str, Any], unit: Any) -> Dict[str, Any]:
|
||||
def _normalize_token_rates(pricing: dict[str, Any], unit: Any) -> dict[str, Any]:
|
||||
"""Rescale the generic path's token rates to per-token strings (the contract usage_pricing
|
||||
multiplies by 1e6), the way the Novita/DeepInfra branches already do for their known units."""
|
||||
rates: Dict[str, float] = {}
|
||||
rates: dict[str, float] = {}
|
||||
for key in _TOKEN_RATE_FIELDS:
|
||||
try:
|
||||
rates[key] = float(pricing[key])
|
||||
@@ -851,8 +851,8 @@ def _normalize_token_rates(pricing: Dict[str, Any], unit: Any) -> Dict[str, Any]
|
||||
return pricing
|
||||
|
||||
|
||||
def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _per_token(source: Dict[str, Any], fields: Dict[str, str], scale) -> Dict[str, Any]:
|
||||
def _extract_pricing(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def _per_token(source: dict[str, Any], fields: dict[str, str], scale) -> dict[str, Any]:
|
||||
# Provider $/MTok (or Novita's 1/10_000-$ per M) -> per-token strings, the same path usage_pricing uses for OpenRouter.
|
||||
return {target: str(scale(float(source[key]))) for target, key in fields.items() if source.get(key) is not None}
|
||||
novita_fields = {"prompt": "input_token_price_per_m", "completion": "output_token_price_per_m"}
|
||||
@@ -873,7 +873,7 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
for mapping in _iter_nested_dicts(payload):
|
||||
normalized = {str(key).lower(): value for key, value in mapping.items()}
|
||||
pricing: Dict[str, Any] = {}
|
||||
pricing: dict[str, Any] = {}
|
||||
for target, aliases in alias_map.items():
|
||||
for alias in aliases:
|
||||
if alias in normalized and normalized[alias] not in {None, ""}:
|
||||
@@ -884,13 +884,13 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _add_model_aliases(cache: Dict[str, Dict[str, Any]], model_id: str, entry: Dict[str, Any]) -> None:
|
||||
def _add_model_aliases(cache: dict[str, dict[str, Any]], model_id: str, entry: dict[str, Any]) -> None:
|
||||
cache[model_id] = entry
|
||||
if "/" in model_id:
|
||||
cache.setdefault(model_id.split("/", 1)[1], entry)
|
||||
|
||||
|
||||
def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
||||
def fetch_model_metadata(force_refresh: bool = False) -> dict[str, dict[str, Any]]:
|
||||
"""Fetch model metadata from OpenRouter (cached for 1 hour)."""
|
||||
global _model_metadata_cache, _model_metadata_cache_time
|
||||
if not force_refresh:
|
||||
@@ -938,13 +938,13 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
||||
return {}
|
||||
|
||||
|
||||
def _endpoint_model_entry(model: Dict[str, Any], model_id: str, context_length: Optional[int]) -> Dict[str, Any]:
|
||||
def _endpoint_model_entry(model: dict[str, Any], model_id: str, context_length: Optional[int]) -> dict[str, Any]:
|
||||
"""Cache entry for one ``/models`` item; optional keys are set only when known."""
|
||||
optional = (("context_length", context_length), ("max_completion_tokens", _extract_first_int(model, _MAX_COMPLETION_KEYS)), ("pricing", _extract_pricing(model) or None))
|
||||
return {"name": model.get("name", model_id), **{k: v for k, v in optional if v is not None}}
|
||||
|
||||
|
||||
def _lmstudio_loaded_context(model: Dict[str, Any]) -> Optional[int]:
|
||||
def _lmstudio_loaded_context(model: dict[str, Any]) -> Optional[int]:
|
||||
"""Context of the first loaded LM Studio instance (the runtime value), else None."""
|
||||
for inst in model.get("loaded_instances", []) or []:
|
||||
cfg = inst.get("config", {}) if isinstance(inst, dict) else None
|
||||
@@ -954,11 +954,11 @@ def _lmstudio_loaded_context(model: Dict[str, Any]) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _lmstudio_native_models(normalized: str, headers: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
|
||||
def _lmstudio_native_models(normalized: str, headers: dict[str, str]) -> dict[str, dict[str, Any]]:
|
||||
"""LM Studio ``/api/v1/models`` → cache; context comes from the first loaded instance."""
|
||||
response = model_metadata_http.get(_lmstudio_server_root(normalized).rstrip("/") + "/api/v1/models", headers=headers, timeout=(5, 10), verify=model_metadata_http.resolve_verify(normalized))
|
||||
response.raise_for_status()
|
||||
cache: Dict[str, Dict[str, Any]] = {}
|
||||
cache: dict[str, dict[str, Any]] = {}
|
||||
for model in response.json().get("models", []):
|
||||
model_id = (model.get("key") or model.get("id")) if isinstance(model, dict) else None
|
||||
if not model_id:
|
||||
@@ -971,7 +971,7 @@ def _lmstudio_native_models(normalized: str, headers: Dict[str, str]) -> Dict[st
|
||||
return cache
|
||||
|
||||
|
||||
def _apply_llamacpp_props(cache: Dict[str, Dict[str, Any]], request_candidate: str, headers: Dict[str, str], verify) -> None:
|
||||
def _apply_llamacpp_props(cache: dict[str, dict[str, Any]], request_candidate: str, headers: dict[str, str], verify) -> None:
|
||||
"""Overwrite ``context_length`` with llama.cpp's allocated ``n_ctx`` from /props (``/v1/props``, then
|
||||
``/props`` for older builds). In router mode the bare endpoint 400s, so each LOADED child is read
|
||||
via ``/props?model=``; unloaded children are skipped — probing could autoload them."""
|
||||
@@ -981,7 +981,7 @@ def _apply_llamacpp_props(cache: Dict[str, Dict[str, Any]], request_candidate: s
|
||||
if resp.is_error:
|
||||
resp = model_metadata_http.get(base + "/props", params=params, headers=headers, timeout=5, verify=verify)
|
||||
return resp
|
||||
def _n_ctx(props: Dict[str, Any]) -> Any:
|
||||
def _n_ctx(props: dict[str, Any]) -> Any:
|
||||
return (props.get("default_generation_settings") or {}).get("n_ctx")
|
||||
props_resp = _props()
|
||||
if not props_resp.is_error:
|
||||
@@ -1003,20 +1003,20 @@ def _apply_llamacpp_props(cache: Dict[str, Dict[str, Any]], request_candidate: s
|
||||
cache[child_id]["context_length"] = child_ctx
|
||||
|
||||
|
||||
def _endpoint_memo_key(normalized: str, api_key: object) -> Tuple[str, str]:
|
||||
def _endpoint_memo_key(normalized: str, api_key: object) -> tuple[str, str]:
|
||||
from agent.credential_persistence import fingerprint_secret_value
|
||||
# Callable (minted) keys are not fingerprinted here: doing so would mint on every cache hit.
|
||||
return normalized, (fingerprint_secret_value(api_key) or "") if isinstance(api_key, str) else ""
|
||||
|
||||
|
||||
def _remember_endpoint_models(memo_key: Tuple[str, str], cache: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
def _remember_endpoint_models(memo_key: tuple[str, str], cache: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
_endpoint_model_metadata_cache[memo_key] = cache
|
||||
_endpoint_model_metadata_cache_time[memo_key] = time.time()
|
||||
return cache
|
||||
|
||||
|
||||
def _parse_models_payload(payload: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
cache: Dict[str, Dict[str, Any]] = {}
|
||||
def _parse_models_payload(payload: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
cache: dict[str, dict[str, Any]] = {}
|
||||
for model in payload.get("data", []):
|
||||
model_id = model.get("id") if isinstance(model, dict) else None
|
||||
if model_id:
|
||||
@@ -1024,7 +1024,7 @@ def _parse_models_payload(payload: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
return cache
|
||||
|
||||
|
||||
def fetch_endpoint_model_metadata(base_url: str, api_key: str = "", force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
||||
def fetch_endpoint_model_metadata(base_url: str, api_key: str = "", force_refresh: bool = False) -> dict[str, dict[str, Any]]:
|
||||
"""Model metadata from an OpenAI-compatible ``/models`` endpoint (cached per base URL)."""
|
||||
normalized = _normalize_base_url(base_url)
|
||||
if not normalized or base_url_host_matches(normalized, "openrouter.ai"):
|
||||
@@ -1121,12 +1121,12 @@ def _load_context_cache_document() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_context_cache() -> Dict[str, int]:
|
||||
def _load_context_cache() -> dict[str, int]:
|
||||
"""Load scalar lengths, preserving the legacy reader contract."""
|
||||
return _load_context_cache_document().get("context_lengths") or {}
|
||||
|
||||
|
||||
def _write_context_cache(cache: Dict[str, int], bedrock_confirmed: dict | None = None) -> None:
|
||||
def _write_context_cache(cache: dict[str, int], bedrock_confirmed: dict | None = None) -> None:
|
||||
"""Atomic write (a truncating write killed mid-dump leaves a partial file that
|
||||
_load_context_cache() swallows as {}, wiping EVERY cached length). Raises on failure."""
|
||||
document = {"context_lengths": cache}
|
||||
@@ -1390,7 +1390,7 @@ _PARSEABLE_OUTPUT_CAP_SIGNALS = (
|
||||
)
|
||||
|
||||
|
||||
def _sglang_window_and_input(error_lower: str) -> Optional[Tuple[int, int]]:
|
||||
def _sglang_window_and_input(error_lower: str) -> Optional[tuple[int, int]]:
|
||||
"""``(window, input_tokens)`` from SGLang's wording, else None; both figures are explicit there."""
|
||||
_m_ctx = re.search(r'maximum context length of (\d+)\s*token', error_lower)
|
||||
_m_in = re.search(r'(\d+)\s*tokens from the input messages', error_lower)
|
||||
@@ -1426,7 +1426,7 @@ def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:
|
||||
return candidate_id == lookup_model or ("/" in candidate_id and candidate_id.rsplit("/", 1)[1] == lookup_model)
|
||||
|
||||
|
||||
def _ollama_show(server_url: str, api_key: str, bare_model: str, timeout: float = 3.0, *, note_blackhole: bool = False) -> Optional[Dict[str, Any]]:
|
||||
def _ollama_show(server_url: str, api_key: str, bare_model: str, timeout: float = 3.0, *, note_blackhole: bool = False) -> Optional[dict[str, Any]]:
|
||||
"""Ollama ``/api/show`` JSON for ``bare_model``, or None on any failure (``note_blackhole``: connect timeouts condemn the host)."""
|
||||
import httpx
|
||||
try:
|
||||
@@ -1692,7 +1692,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: Any) ->
|
||||
|
||||
# Codex OAuth `context_window` values (what Codex enforces — lower than the direct API for the same
|
||||
# slugs). Fallback when the live probe fails; longest-key-first. gpt-5.3-codex-spark is listed so "gpt-5.3-codex" doesn't win.
|
||||
_CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
|
||||
_CODEX_OAUTH_CONTEXT_FALLBACK: dict[str, int] = {
|
||||
"gpt-6-astra": 272_000, "gpt-6.1-sol": 272_000, "gpt-6-sol": 272_000, "gpt-6-luna": 272_000,
|
||||
"gpt-5.1-codex-max": 272_000, "gpt-5.1-codex-mini": 272_000, "gpt-5.3-codex": 272_000,
|
||||
"gpt-5.3-codex-spark": 128_000, "gpt-5.2-codex": 272_000, "gpt-5.4-mini": 272_000,
|
||||
@@ -1707,10 +1707,10 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
|
||||
# aren't routable on Codex); ``gpt-5.4`` is EXACT because gpt-5.4-mini enforces 272K. The gpt-6 tiers
|
||||
# carry the 5.6 verdict forward: they replace Sol/Terra/Luna on the same Codex route, and the live
|
||||
# catalog's ``max_context_window`` still caps the bump (#105443) if it publishes a lower ceiling.
|
||||
_CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES: Dict[str, int] = {
|
||||
_CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES: dict[str, int] = {
|
||||
"gpt-5.6": 900_000, "gpt-6-sol": 900_000, "gpt-6-luna": 900_000,
|
||||
}
|
||||
_CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT: Dict[str, int] = {
|
||||
_CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT: dict[str, int] = {
|
||||
"gpt-5.4": 900_000, "gpt-daybreak-blue-latest": 900_000,
|
||||
"gpt-6-astra": 900_000, # advertised 272K; 920,043 input OK, 1,000,043 rejected (live 2026-09-04)
|
||||
# advertised 272K; 918,137 input OK, ~931K rejected (live 2026-09-29), consistent with the 922K left by
|
||||
@@ -1782,10 +1782,10 @@ def _verified_codex_ctx_for_slug(model_bare: str) -> Optional[int]:
|
||||
return next((ctx for key, ctx in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES.items() if base == key or base.startswith((key + "-", key + "."))), None)
|
||||
|
||||
|
||||
_codex_oauth_context_cache: Dict[str, Tuple[Dict[str, int], float]] = {}
|
||||
_codex_oauth_context_cache: dict[str, tuple[dict[str, int], float]] = {}
|
||||
# ``{slug: max_context_window}`` from the same fetch, keyed by the same token fingerprint. Only the
|
||||
# opted-in ``-900k`` bump reads it (#105443); a catalog without the field leaves the entry empty.
|
||||
_codex_oauth_max_context_cache: Dict[str, Dict[str, int]] = {}
|
||||
_codex_oauth_max_context_cache: dict[str, dict[str, int]] = {}
|
||||
_CODEX_OAUTH_CONTEXT_CACHE_TTL = 3600 # 1 hour
|
||||
_CODEX_OAUTH_CONTEXT_NEGATIVE_TTL = 300 # a probe that found no catalog is retried after 5 minutes; must stay < TTL
|
||||
# The Codex models endpoint reads ``client_version`` as a Codex CLI compatibility version and
|
||||
@@ -1801,7 +1801,7 @@ CODEX_NEWEST_CLIENT_VERSION = "99.0.0"
|
||||
CODEX_UNGATED_CLIENT_VERSION = "0.0.0"
|
||||
|
||||
|
||||
def _codex_catalog_urls(base_url: str = "") -> Tuple[str, ...]:
|
||||
def _codex_catalog_urls(base_url: str = "") -> tuple[str, ...]:
|
||||
"""Catalog URLs against ``base_url`` when it names a Codex-compatible gateway, else the
|
||||
hard-coded endpoint. A custom base's credential belongs to that service — sending it to
|
||||
chatgpt.com (or fetching the direct catalog behind a gateway's back) is wrong (#121486)."""
|
||||
@@ -1833,7 +1833,7 @@ def _codex_catalog_probe_allowed(access_token: str, base_url: str = "") -> bool:
|
||||
return bool(_decode_jwt_claims(access_token))
|
||||
|
||||
|
||||
def fetch_codex_catalog_entries(get: Callable[[str], Any], base_url: str = "") -> Tuple[List[Any], Optional[int]]:
|
||||
def fetch_codex_catalog_entries(get: Callable[[str], Any], base_url: str = "") -> tuple[list[Any], Optional[int]]:
|
||||
"""``(models, last_status)`` from the first catalog URL that answers HTTP 200 with a non-empty
|
||||
``models`` list; ``get(url)`` is any client returning an object with ``status_code``/``json()``.
|
||||
An empty or non-200 answer on the newest-client URL falls through to the ``0.0.0`` sentinel."""
|
||||
@@ -1853,7 +1853,7 @@ def fetch_codex_catalog_entries(get: Callable[[str], Any], base_url: str = "") -
|
||||
def _codex_oauth_token_fingerprint(access_token: str, base_url: str = "") -> str:
|
||||
"""Non-secret cache key for a Codex OAuth access token (plus the base it was probed against —
|
||||
a gateway's catalog can differ from chatgpt.com's for the same forwarded token)."""
|
||||
return hashlib.sha256(f"{access_token}\n{(base_url or '').strip().rstrip('/')}".encode("utf-8")).hexdigest()[:16]
|
||||
return hashlib.sha256(f"{access_token}\n{(base_url or '').strip().rstrip('/')}".encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _remember_no_codex_catalog(cache_key: str) -> None:
|
||||
@@ -1869,7 +1869,7 @@ def _remember_no_codex_catalog(cache_key: str) -> None:
|
||||
_codex_oauth_context_cache[cache_key] = ({}, now - _CODEX_OAUTH_CONTEXT_CACHE_TTL + _CODEX_OAUTH_CONTEXT_NEGATIVE_TTL)
|
||||
|
||||
|
||||
def _fetch_codex_oauth_context_lengths_with_source(access_token: str, base_url: str = "") -> Tuple[Dict[str, int], bool]:
|
||||
def _fetch_codex_oauth_context_lengths_with_source(access_token: str, base_url: str = "") -> tuple[dict[str, int], bool]:
|
||||
"""Codex catalogue ``{slug: context_window}`` plus whether it came from HTTP. Cached per token
|
||||
fingerprint (windows vary by entitlement); ``max_context_window`` lands in
|
||||
``_codex_oauth_max_context_cache`` under the same key. An in-process hit reports False: not a
|
||||
@@ -1898,8 +1898,8 @@ def _fetch_codex_oauth_context_lengths_with_source(access_token: str, base_url:
|
||||
logger.debug("Codex /models probe failed: %s", exc)
|
||||
_remember_no_codex_catalog(cache_key)
|
||||
return {}, False
|
||||
result: Dict[str, int] = {}
|
||||
max_result: Dict[str, int] = {}
|
||||
result: dict[str, int] = {}
|
||||
max_result: dict[str, int] = {}
|
||||
for item in entries:
|
||||
slug, ctx, max_ctx = (item.get("slug"), item.get("context_window"), item.get("max_context_window")) if isinstance(item, dict) else (None, None, None)
|
||||
if isinstance(slug, str) and isinstance(ctx, int) and ctx > 0:
|
||||
@@ -1926,14 +1926,14 @@ def _cached_codex_catalog_max(access_token: str, base_url: str, slug: str) -> Op
|
||||
return published.get(_codex_catalog_key(published, slug))
|
||||
|
||||
|
||||
def _resolve_codex_oauth_context_length_with_source(model: str, access_token: str = "", base_url: str = "") -> Tuple[Optional[int], str]:
|
||||
def _resolve_codex_oauth_context_length_with_source(model: str, access_token: str = "", base_url: str = "") -> tuple[Optional[int], str]:
|
||||
"""``(context_length, source)`` for a Codex OAuth slug. source: "live" (fresh authenticated probe —
|
||||
the only one eligible for persistent writes), "memory" (same-token in-process hit), "fallback"
|
||||
(static table), or "" when unresolved."""
|
||||
model_bare = _strip_provider_prefix(model).strip()
|
||||
if not model_bare:
|
||||
return None, ""
|
||||
def _apply_verified_bump(ctx: int, source: str, catalog_max: Optional[int] = None) -> Tuple[int, str]:
|
||||
def _apply_verified_bump(ctx: int, source: str, catalog_max: Optional[int] = None) -> tuple[int, str]:
|
||||
"""Lift an EXACT stale 272K advertisement to the verified cap for opted-in ``-900k`` variants only,
|
||||
never above the catalog's own ``max_context_window`` when it publishes one (#105443)."""
|
||||
bumped = _verified_codex_ctx_for_slug(model_bare)
|
||||
@@ -1960,7 +1960,7 @@ def _resolve_codex_oauth_context_length_with_source(model: str, access_token: st
|
||||
return _apply_verified_bump(hit[1], "fallback", catalog_max) if hit else (None, "")
|
||||
|
||||
|
||||
def _resolve_nous_context_length(model: str, base_url: str = "", api_key: str = "") -> Tuple[Optional[int], str]:
|
||||
def _resolve_nous_context_length(model: str, base_url: str = "", api_key: str = "") -> tuple[Optional[int], str]:
|
||||
"""``(context_length, source)`` for a Nous Portal model: portal /v1/models is authoritative
|
||||
("portal"). Fallback matches OR's prefixed ids against the bare Nous id with dot/dash
|
||||
normalisation ("openrouter" — callers must NOT persist it, or a portal blip freezes the wrong value)."""
|
||||
@@ -2400,7 +2400,7 @@ def estimate_tokens_rough(text: str) -> int:
|
||||
return dense + ((len(stripped.encode("utf-8", "replace")) + 3) // CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def estimate_messages_tokens_rough(messages: List[Dict[str, Any]], *, charge_stale_thinking: bool = True) -> int:
|
||||
def estimate_messages_tokens_rough(messages: list[dict[str, Any]], *, charge_stale_thinking: bool = True) -> int:
|
||||
"""Rough token estimate for a message list (pre-flight only). Images cost the per-image price
|
||||
learned from provider usage (``agent.image_token_cost``; flat default before calibration)
|
||||
rather than their base64 length. ``charge_stale_thinking=False`` mirrors the tail-budget
|
||||
@@ -2415,7 +2415,7 @@ def estimate_messages_tokens_rough(messages: List[Dict[str, Any]], *, charge_sta
|
||||
return sum(_estimate_message_tokens_cached(msg, image_cost) for msg in messages)
|
||||
|
||||
|
||||
def estimate_native_anthropic_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
|
||||
def estimate_native_anthropic_messages_tokens_rough(messages: list[dict[str, Any]]) -> int:
|
||||
"""Estimate native Anthropic messages with replayed readable thinking charged exactly once."""
|
||||
from agent.message_sanitization import native_anthropic_accounting_projection
|
||||
|
||||
@@ -2426,8 +2426,8 @@ def estimate_native_anthropic_messages_tokens_rough(messages: List[Dict[str, Any
|
||||
|
||||
|
||||
def estimate_native_anthropic_request_tokens_rough(
|
||||
messages: List[Dict[str, Any]], *, system_prompt: str = "",
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
messages: list[dict[str, Any]], *, system_prompt: str = "",
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
) -> int:
|
||||
"""Request estimate for native Anthropic; opaque replay bytes never enter text accounting."""
|
||||
from agent.message_sanitization import native_anthropic_accounting_projection
|
||||
@@ -2443,7 +2443,7 @@ def estimate_native_anthropic_request_tokens_rough(
|
||||
_STALE_THINKING_ESTIMATE_KEYS = ("reasoning", "reasoning_content")
|
||||
|
||||
|
||||
def _strip_stale_thinking_for_estimate(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _strip_stale_thinking_for_estimate(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Copy of ``messages`` with stale thinking keys removed (newest kept). Shallow stripped copies
|
||||
share the original value objects, so the per-message memo still hits for the stripped shape."""
|
||||
def _is_assistant(m: Any) -> bool:
|
||||
@@ -2470,7 +2470,7 @@ def _strip_stale_thinking_for_estimate(messages: List[Dict[str, Any]]) -> List[D
|
||||
# estimate. Because the api_messages build shallow-copies history dicts each iteration, the copies share the
|
||||
# same content strings — so unchanged history messages hit the memo even though the outer dicts are fresh
|
||||
# objects every turn.
|
||||
_MSG_TOKENS_CACHE: Dict[Any, Tuple[list, int, int]] = {} # pins, text tokens, image count
|
||||
_MSG_TOKENS_CACHE: dict[Any, tuple[list, int, int]] = {} # pins, text tokens, image count
|
||||
_MSG_TOKENS_CACHE_MAX = 4096
|
||||
|
||||
|
||||
@@ -2493,7 +2493,7 @@ def _msg_fingerprint(value: Any, pins: list) -> Any:
|
||||
def _estimate_message_tokens_cached(msg: Any, image_cost: int) -> int:
|
||||
"""Text tokens + images x ``image_cost``; the memo holds text and image COUNT so a recalibrated
|
||||
per-image price re-prices cached rows without invalidating them."""
|
||||
def _compute() -> Tuple[int, int]:
|
||||
def _compute() -> tuple[int, int]:
|
||||
return _estimate_message_tokens_without_images(msg), _count_image_tokens(msg, 1)
|
||||
try:
|
||||
pins: list = []
|
||||
@@ -2526,7 +2526,7 @@ def _count_parts(parts: Any, types: set) -> int:
|
||||
_IMAGE_PART_TYPES = frozenset({"image", "image_url", "input_image"})
|
||||
|
||||
|
||||
def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int:
|
||||
def _count_image_tokens(msg: dict[str, Any], cost_per_image: int) -> int:
|
||||
"""Count image-like content parts in a message; return their token cost."""
|
||||
if not isinstance(msg, dict):
|
||||
return 0
|
||||
@@ -2554,7 +2554,7 @@ def strip_opaque_replay_items(items: Any) -> Any:
|
||||
]
|
||||
|
||||
|
||||
def _wire_message_shadow(msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _wire_message_shadow(msg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Shadow of a message holding only what the provider actually receives.
|
||||
* ``api_content`` SUBSTITUTES ``content`` (mirrors ``turn_context.substitute_api_content`` exactly):
|
||||
only a non-empty STRING sidecar on a user/assistant row displaces content; substituting any
|
||||
@@ -2570,7 +2570,7 @@ def _wire_message_shadow(msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
sidecar_wins = isinstance(sidecar, str) and bool(sidecar) and msg.get("role") in ("user", "assistant")
|
||||
_rc = msg.get("reasoning_content")
|
||||
drop_reasoning_dup = isinstance(_rc, str) and bool(_rc.strip())
|
||||
shadow: Dict[str, Any] = {}
|
||||
shadow: dict[str, Any] = {}
|
||||
for k, v in msg.items():
|
||||
if k in ("_anthropic_content_blocks", "reasoning_details") or k in PERSISTENCE_ONLY_MESSAGE_FIELDS or (k == "reasoning" and drop_reasoning_dup):
|
||||
continue
|
||||
@@ -2607,13 +2607,13 @@ def _wire_message_shadow(msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return shadow
|
||||
|
||||
|
||||
def _estimate_message_tokens_without_images(msg: Dict[str, Any]) -> int:
|
||||
def _estimate_message_tokens_without_images(msg: dict[str, Any]) -> int:
|
||||
"""Token estimate for a message shadow with image payloads stripped."""
|
||||
return estimate_tokens_rough(str(_wire_message_shadow(msg) if isinstance(msg, dict) else msg))
|
||||
|
||||
|
||||
def estimate_request_tokens_rough(
|
||||
messages: List[Dict[str, Any]], *, system_prompt: str = "", tools: Optional[List[Dict[str, Any]]] = None, charge_stale_thinking: bool = True,
|
||||
messages: list[dict[str, Any]], *, system_prompt: str = "", tools: Optional[list[dict[str, Any]]] = None, charge_stale_thinking: bool = True,
|
||||
) -> int:
|
||||
"""Rough token estimate for a full request: system prompt + messages + tool schemas (50+ tools
|
||||
add 20-30K on their own). ``charge_stale_thinking`` is forwarded — pass False when the route
|
||||
@@ -2629,7 +2629,7 @@ def estimate_request_tokens_rough(
|
||||
|
||||
# Keyed by ``id(tools)``; bounded, oldest-first eviction. Repeated ``str(tools)`` on
|
||||
# large schemas stalls GUI event loops under GIL pressure.
|
||||
_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {}
|
||||
_TOOLS_TOKENS_CACHE: dict[int, tuple[int, str, str, int]] = {}
|
||||
_TOOLS_TOKENS_CACHE_MAX = 256
|
||||
|
||||
|
||||
@@ -2642,7 +2642,7 @@ def _tool_name_for_cache(tool: Any) -> str:
|
||||
return name if isinstance(name, str) else ""
|
||||
|
||||
|
||||
def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int:
|
||||
def _estimate_tools_tokens_rough(tools: list[dict[str, Any]]) -> int:
|
||||
if not tools:
|
||||
return 0
|
||||
key = id(tools)
|
||||
|
||||
+55
-55
@@ -28,7 +28,7 @@ MODELS_DEV_URL = "https://models.dev/api.json"
|
||||
_MODELS_DEV_CACHE_TTL = 4 * 3600 # 4 hours — ETag conditional GET makes refresh cheap
|
||||
_MODELS_DEV_RETRY_DELAY = 300 # 5 minutes after a failed refresh
|
||||
# In-memory cache
|
||||
_models_dev_cache: Dict[str, Any] = {}
|
||||
_models_dev_cache: dict[str, Any] = {}
|
||||
_models_dev_cache_time: float = 0
|
||||
_models_dev_retry_after: float = 0
|
||||
_models_dev_fetch_lock = threading.Lock()
|
||||
@@ -51,8 +51,8 @@ class ModelInfo:
|
||||
structured_output: bool = False
|
||||
open_weights: bool = False
|
||||
# Modalities
|
||||
input_modalities: Tuple[str, ...] = () # ("text", "image", "pdf", ...)
|
||||
output_modalities: Tuple[str, ...] = ()
|
||||
input_modalities: tuple[str, ...] = () # ("text", "image", "pdf", ...)
|
||||
output_modalities: tuple[str, ...] = ()
|
||||
# Limits
|
||||
context_window: int = 0
|
||||
max_output: int = 0
|
||||
@@ -89,7 +89,7 @@ class ProviderInfo:
|
||||
"""Full metadata for a provider from models.dev."""
|
||||
id: str # models.dev provider ID
|
||||
name: str # display name
|
||||
env: Tuple[str, ...] # env var names for API key
|
||||
env: tuple[str, ...] # env var names for API key
|
||||
api: str # base URL
|
||||
doc: str = "" # documentation URL
|
||||
model_count: int = 0
|
||||
@@ -107,7 +107,7 @@ class ModelCapabilities:
|
||||
|
||||
|
||||
# Hermes provider names → models.dev provider IDs
|
||||
PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
PROVIDER_TO_MODELS_DEV: dict[str, str] = {
|
||||
"openrouter": "openrouter", "novita": "novita-ai", "anthropic": "anthropic",
|
||||
"openai": "openai", "openai-api": "openai", "openai-codex": "openai", "zai": "zai",
|
||||
"kimi": "kimi-for-coding", "kimi-coding": "kimi-for-coding",
|
||||
@@ -130,10 +130,10 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
"ollama-cloud": "ollama-cloud",
|
||||
}
|
||||
# Reverse mapping: models.dev id → Hermes ids (built lazily; many-to-one).
|
||||
_MODELS_DEV_TO_PROVIDER: Optional[Dict[str, List[str]]] = None
|
||||
_MODELS_DEV_TO_PROVIDER: Optional[dict[str, list[str]]] = None
|
||||
|
||||
|
||||
def _models_dev_to_hermes_ids(mdev_id: str) -> List[str]:
|
||||
def _models_dev_to_hermes_ids(mdev_id: str) -> list[str]:
|
||||
"""Return the Hermes provider ids that map to *mdev_id* (may be [])."""
|
||||
global _MODELS_DEV_TO_PROVIDER
|
||||
if _MODELS_DEV_TO_PROVIDER is None:
|
||||
@@ -143,12 +143,12 @@ def _models_dev_to_hermes_ids(mdev_id: str) -> List[str]:
|
||||
return _MODELS_DEV_TO_PROVIDER.get(mdev_id, [])
|
||||
|
||||
|
||||
def _dict_or_empty(value: Any) -> Dict[str, Any]:
|
||||
def _dict_or_empty(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _configured_catalog_provider(
|
||||
provider: str, *, config: Optional[Dict[str, Any]] = None,
|
||||
provider: str, *, config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[str]:
|
||||
"""``catalog_provider`` declared on a custom provider's ``providers.<name>`` row (or legacy
|
||||
``custom_providers[]`` entry): the catalogued vendor whose models it resells. None when unset."""
|
||||
@@ -176,7 +176,7 @@ def _configured_catalog_provider(
|
||||
|
||||
|
||||
def _models_dev_id(
|
||||
provider: str, *, config: Optional[Dict[str, Any]] = None,
|
||||
provider: str, *, config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[str]:
|
||||
"""models.dev provider id for a Hermes provider id, or None. A custom provider reaches the
|
||||
catalog only through its configured ``catalog_provider`` alias (#112649)."""
|
||||
@@ -199,7 +199,7 @@ def _models_dev_id(
|
||||
_UNKNOWN_CATALOG_PROVIDER_WARNED: set = set() # (provider, alias) warned once per process
|
||||
|
||||
|
||||
def _cfg_get(*keys: str, default: Any, config: Optional[Dict[str, Any]] = None) -> Any:
|
||||
def _cfg_get(*keys: str, default: Any, config: Optional[dict[str, Any]] = None) -> Any:
|
||||
"""``cfg_get`` over the read-only config; *default* on any failure."""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config_readonly
|
||||
@@ -263,7 +263,7 @@ def _validate_registry(data: Any) -> bool:
|
||||
return isinstance(data, dict) and len(data) > 0
|
||||
|
||||
|
||||
def _load_disk_cache() -> Dict[str, Any]:
|
||||
def _load_disk_cache() -> dict[str, Any]:
|
||||
"""Load the disk cache; a corrupt/empty one is quarantined with a warning so it never
|
||||
masquerades as ``{}`` and breaks provider/model resolution."""
|
||||
try:
|
||||
@@ -303,7 +303,7 @@ def _disk_cache_age_seconds() -> Optional[float]:
|
||||
return _quietly("stat models.dev disk cache", stat)
|
||||
|
||||
|
||||
def _save_disk_cache(data: Dict[str, Any], etag: str = "") -> None:
|
||||
def _save_disk_cache(data: dict[str, Any], etag: str = "") -> None:
|
||||
"""Save the registry atomically, plus the ETag sidecar when non-empty."""
|
||||
_quietly("save models.dev disk cache", lambda: atomic_json_write(_get_cache_path(), data, indent=None, separators=(",", ":")))
|
||||
if etag:
|
||||
@@ -315,12 +315,12 @@ class _NotModified(Exception):
|
||||
"""Server returned 304 Not Modified — existing cache is still valid."""
|
||||
|
||||
|
||||
def _fetch_models_dev_from_network(*, conditional: bool = False) -> Tuple[Dict[str, Any], str]:
|
||||
def _fetch_models_dev_from_network(*, conditional: bool = False) -> tuple[dict[str, Any], str]:
|
||||
"""Fetch the live registry; returns ``(registry, etag)`` (etag "" if none). Raises on network
|
||||
errors and on an empty/invalid payload. ``conditional`` sends ``If-None-Match`` with the sidecar's
|
||||
ETag and raises ``_NotModified`` on 304 — pass True ONLY while holding ``_models_dev_fetch_lock``
|
||||
AND a servable registry, or a 304 leaves the process with no data."""
|
||||
headers: Dict[str, str] = {}
|
||||
headers: dict[str, str] = {}
|
||||
if conditional and (etag := _load_etag()):
|
||||
headers["If-None-Match"] = etag
|
||||
# (connect, read): 5 s connect fails fast on blackholed hosts; 10 s read tolerates a slow registry.
|
||||
@@ -341,7 +341,7 @@ def _mark_stale_cache_grace() -> None:
|
||||
_models_dev_cache_time = max(_models_dev_cache_time, time.time() - _MODELS_DEV_CACHE_TTL + _MODELS_DEV_RETRY_DELAY)
|
||||
|
||||
|
||||
def _serve_stale(msg: str, *args: Any) -> Dict[str, Any]:
|
||||
def _serve_stale(msg: str, *args: Any) -> dict[str, Any]:
|
||||
"""Arm the grace window, kick off a background refresh, return the held cache."""
|
||||
_mark_stale_cache_grace()
|
||||
_start_background_refresh_models_dev()
|
||||
@@ -349,7 +349,7 @@ def _serve_stale(msg: str, *args: Any) -> Dict[str, Any]:
|
||||
return _models_dev_cache
|
||||
|
||||
|
||||
def _commit_registry(data: Dict[str, Any], *, etag: str = "", where: str) -> None:
|
||||
def _commit_registry(data: dict[str, Any], *, etag: str = "", where: str) -> None:
|
||||
"""Persist a fetched registry: disk + in-mem + clear backoff. Callers hold ``_models_dev_fetch_lock``
|
||||
so a failing refresh on one path can never stomp state a succeeding refresh just committed."""
|
||||
global _models_dev_cache, _models_dev_cache_time, _models_dev_retry_after
|
||||
@@ -387,7 +387,7 @@ def _note_refresh_failure(exc: Exception, *, where: str) -> None:
|
||||
logger.debug("models.dev refresh failed (%s); retry suppressed for %ds: %s", where, _MODELS_DEV_RETRY_DELAY, exc)
|
||||
|
||||
|
||||
def _refresh_locked(where: str) -> Optional[Dict[str, Any]]:
|
||||
def _refresh_locked(where: str) -> Optional[dict[str, Any]]:
|
||||
"""One conditional fetch + state update; caller holds ``_models_dev_fetch_lock``. Returns the
|
||||
registry to serve, or None when the fetch failed (backoff armed)."""
|
||||
try:
|
||||
@@ -435,7 +435,7 @@ def _start_background_refresh_models_dev() -> None:
|
||||
logger.debug("Failed to start models.dev refresh thread: %s", e)
|
||||
|
||||
|
||||
def fetch_models_dev(force_refresh: bool = False, *, allow_network: bool = True) -> Dict[str, Any]:
|
||||
def fetch_models_dev(force_refresh: bool = False, *, allow_network: bool = True) -> dict[str, Any]:
|
||||
"""Fetch the models.dev registry (dict keyed by provider ID; {} on failure). Cache hierarchy:
|
||||
fresh in-memory → stale in-memory (served now, refreshed in one background daemon thread — stale
|
||||
beats a foreground timeout) → disk of any age (stale triggers the same background refresh) →
|
||||
@@ -493,7 +493,7 @@ def fetch_models_dev(force_refresh: bool = False, *, allow_network: bool = True)
|
||||
return _models_dev_cache
|
||||
|
||||
|
||||
def _registry_provider(mdev_id: str, allow_network: bool) -> Optional[Dict[str, Any]]:
|
||||
def _registry_provider(mdev_id: str, allow_network: bool) -> Optional[dict[str, Any]]:
|
||||
"""The raw models.dev provider entry, or None."""
|
||||
# Keep the zero-argument call on the allow_network path: dozens of test sites monkeypatch fetch_models_dev with zero-arg lambdas.
|
||||
registry = fetch_models_dev() if allow_network else fetch_models_dev(allow_network=False)
|
||||
@@ -501,7 +501,7 @@ def _registry_provider(mdev_id: str, allow_network: bool) -> Optional[Dict[str,
|
||||
return provider_data if isinstance(provider_data, dict) else None
|
||||
|
||||
|
||||
def _registry_models(mdev_id: str, *, allow_network: bool) -> Optional[Dict[str, Any]]:
|
||||
def _registry_models(mdev_id: str, *, allow_network: bool) -> Optional[dict[str, Any]]:
|
||||
"""The ``models`` dict of a models.dev provider entry, or None."""
|
||||
provider_data = _registry_provider(mdev_id, allow_network)
|
||||
models = provider_data.get("models", {}) if provider_data is not None else None
|
||||
@@ -509,8 +509,8 @@ def _registry_models(mdev_id: str, *, allow_network: bool) -> Optional[Dict[str,
|
||||
|
||||
|
||||
def _get_provider_models(
|
||||
provider: str, *, allow_network: bool = False, config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
provider: str, *, allow_network: bool = False, config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Resolve a Hermes provider ID to its models dict, or None if unknown.
|
||||
``allow_network`` defaults to False — hot-path callers must never block."""
|
||||
mdev_id = _models_dev_id(provider, config=config)
|
||||
@@ -545,7 +545,7 @@ def _openrouter_catalog_lookup_base(provider: str, model: str) -> Optional[str]:
|
||||
|
||||
|
||||
def _iter_model_entries(
|
||||
models: Dict[str, Any], model: str, *, suffix_fallback: bool = True, provider: str = ""
|
||||
models: dict[str, Any], model: str, *, suffix_fallback: bool = True, provider: str = ""
|
||||
):
|
||||
"""Yield ``(model_id, entry)`` candidates: exact, case-insensitive, then (optionally)
|
||||
``:cloud``/``-cloud`` suffixed forms. Suffix fallback: some providers (ollama-cloud) store
|
||||
@@ -575,8 +575,8 @@ def _iter_model_entries(
|
||||
|
||||
|
||||
def _find_model_entry(
|
||||
models: Dict[str, Any], model: str, provider: str = ""
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
models: dict[str, Any], model: str, provider: str = ""
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""First catalog entry for *model* (exact, case-insensitive, suffix), or None."""
|
||||
return next((entry for _mid, entry in _iter_model_entries(models, model, provider=provider)), None)
|
||||
|
||||
@@ -587,7 +587,7 @@ def _extract_limit(entry: Any, key: str) -> Optional[int]:
|
||||
return int(value) if isinstance(value, (int, float)) and value > 0 else None
|
||||
|
||||
|
||||
def _extract_context(entry: Dict[str, Any]) -> Optional[int]:
|
||||
def _extract_context(entry: dict[str, Any]) -> Optional[int]:
|
||||
"""Context length from a models.dev model entry, or None if invalid/zero."""
|
||||
return _extract_limit(entry, "context")
|
||||
|
||||
@@ -621,12 +621,12 @@ def lookup_models_dev_context(provider: str, model: str, *, allow_network: bool
|
||||
_OVERRIDE_WARNED_KEYS: set = set()
|
||||
# Safe defaults for models absent from the catalog (tools on, 200K context). Capability fields and the
|
||||
# output limit stay absent so consumers see them as unknown instead of a synthesized value.
|
||||
_UNKNOWN_MODEL_BASE: Dict[str, Any] = {"limit": {"context": 200000}, "tool_call": True}
|
||||
_UNKNOWN_MODEL_BASE: dict[str, Any] = {"limit": {"context": 200000}, "tool_call": True}
|
||||
|
||||
# Account-gated models may be usable before models.dev has indexed them. Keep
|
||||
# their capabilities available for an explicitly selected/discovered model
|
||||
# without adding them to any picker catalog.
|
||||
_DEEPSEEK_FLASH_VISION: Dict[str, Any] = {
|
||||
_DEEPSEEK_FLASH_VISION: dict[str, Any] = {
|
||||
"limit": {"context": 1_000_000, "output": 384_000},
|
||||
"modalities": {"input": ["text", "image"], "output": ["text"]},
|
||||
"tool_call": True,
|
||||
@@ -634,7 +634,7 @@ _DEEPSEEK_FLASH_VISION: Dict[str, Any] = {
|
||||
"family": "deepseek-flash",
|
||||
}
|
||||
|
||||
_BUILTIN_MODEL_METADATA: Dict[Tuple[str, str], Dict[str, Any]] = {
|
||||
_BUILTIN_MODEL_METADATA: dict[tuple[str, str], dict[str, Any]] = {
|
||||
("openai", "gpt-6-astra"): {
|
||||
"limit": {"context": 1_050_000, "output": 128_000},
|
||||
"modalities": {"input": ["text", "image"], "output": ["text"]},
|
||||
@@ -653,7 +653,7 @@ _BUILTIN_MODEL_METADATA: Dict[Tuple[str, str], Dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
def _load_model_overrides(*, config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _load_model_overrides(*, config: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
"""The ``model_overrides`` config section ({} on any failure). Deliberately not memoized:
|
||||
``load_config_readonly()`` is already (mtime, size)-cached upstream, and an ``id(cfg)``-keyed
|
||||
layer can serve stale overrides after a reload when CPython reuses the dict address."""
|
||||
@@ -665,7 +665,7 @@ def _load_model_overrides(*, config: Optional[Dict[str, Any]] = None) -> Dict[st
|
||||
return _dict_or_empty(overrides)
|
||||
|
||||
|
||||
def _provider_override_section(provider: str, *, config: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
def _provider_override_section(provider: str, *, config: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
|
||||
"""Override section for *provider* (keyed by Hermes OR models.dev id), or None."""
|
||||
overrides = (
|
||||
_load_model_overrides(config=config)
|
||||
@@ -680,7 +680,7 @@ def _provider_override_section(provider: str, *, config: Optional[Dict[str, Any]
|
||||
return next((section for section in (overrides.get(key) if key else None for key in candidates) if isinstance(section, dict)), None)
|
||||
|
||||
|
||||
def _explicit_model_override(provider: str, model: str, *, config: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
def _explicit_model_override(provider: str, model: str, *, config: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
|
||||
"""Explicit per-provider+model override dict (exact, then case-insensitive skipping the ``_default`` sentinel), or None."""
|
||||
model_key = (model or "").strip()
|
||||
section = _provider_override_section(provider, config=config) if model_key else None
|
||||
@@ -693,7 +693,7 @@ def _explicit_model_override(provider: str, model: str, *, config: Optional[Dict
|
||||
return next((mdata for mid, mdata in section.items() if mid != "_default" and mid.lower() == model_lower and isinstance(mdata, dict)), None)
|
||||
|
||||
|
||||
def _default_model_override(provider: str, *, config: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
def _default_model_override(provider: str, *, config: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
|
||||
"""Fill-gap ``_default`` override: per-provider first, then global; or None."""
|
||||
section = _provider_override_section(provider, config=config)
|
||||
if section is not None and isinstance(section.get("_default"), dict):
|
||||
@@ -708,14 +708,14 @@ def _default_model_override(provider: str, *, config: Optional[Dict[str, Any]] =
|
||||
|
||||
|
||||
def _override_for(
|
||||
provider: str, model: str, *, catalog_hit: bool, config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
provider: str, model: str, *, catalog_hit: bool, config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Explicit override if any; else the ``_default`` only on a catalog miss."""
|
||||
explicit = _explicit_model_override(provider, model, config=config)
|
||||
return explicit if explicit is not None or catalog_hit else _default_model_override(provider, config=config)
|
||||
|
||||
|
||||
def _override_int(override: Dict[str, Any], key: str) -> Optional[int]:
|
||||
def _override_int(override: dict[str, Any], key: str) -> Optional[int]:
|
||||
"""Coerce an override field to a positive int, warning once on garbage."""
|
||||
raw = override.get(key)
|
||||
if raw is None:
|
||||
@@ -746,10 +746,10 @@ def _default_override_context(provider: str) -> Optional[int]:
|
||||
return _override_int(default, "context_window") if default is not None else None
|
||||
|
||||
|
||||
def _override_to_catalog_shape(override: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[bool]]:
|
||||
def _override_to_catalog_shape(override: dict[str, Any]) -> tuple[dict[str, Any], Optional[bool]]:
|
||||
"""Translate canonical override keys into a models.dev-shaped patch. Returns ``(patch, vision)`` —
|
||||
vision is out-of-band because it maps onto the ``modalities.input`` list rather than a scalar field."""
|
||||
patch: Dict[str, Any] = {}
|
||||
patch: dict[str, Any] = {}
|
||||
limit = {
|
||||
catalog_key: value for catalog_key, override_key in (("context", "context_window"),)
|
||||
if (value := _override_int(override, override_key)) is not None
|
||||
@@ -767,7 +767,7 @@ def _override_to_catalog_shape(override: Dict[str, Any]) -> Tuple[Dict[str, Any]
|
||||
return patch, vision
|
||||
|
||||
|
||||
def _merge_catalog_entry_with_override(raw: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _merge_catalog_entry_with_override(raw: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Patch a catalog entry with a canonical-schema override. Sub-dicts (``limit``, ``modalities``) are
|
||||
merged, not clobbered — setting only ``context_window`` must not wipe the catalog's ``limit.output``."""
|
||||
shaped, vision_override = _override_to_catalog_shape(override)
|
||||
@@ -790,14 +790,14 @@ def _merge_catalog_entry_with_override(raw: Dict[str, Any], override: Dict[str,
|
||||
|
||||
|
||||
def _builtin_model_metadata(
|
||||
provider: str, model: str, *, config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
provider: str, model: str, *, config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Built-in metadata for a provider/model pair, if Hermes has a vendor-specific entry."""
|
||||
provider_key = _models_dev_id(provider, config=config) or (provider or "").strip()
|
||||
return _BUILTIN_MODEL_METADATA.get((provider_key, (model or "").strip().lower()))
|
||||
|
||||
|
||||
def _relay_vision_marker_metadata(provider: str, model: str) -> Optional[Dict[str, Any]]:
|
||||
def _relay_vision_marker_metadata(provider: str, model: str) -> Optional[dict[str, Any]]:
|
||||
"""Fill-gap base for an OpenCode Zen/Go ``*-vision*`` model id the catalog does not know. The relays
|
||||
resell vendor previews (``deepseek-v4-flash-vision-exp``) before models.dev indexes them, and the id's
|
||||
``-vision`` token is the vendor's own capability marker; without it ``image_input_mode: auto`` treats
|
||||
@@ -810,7 +810,7 @@ def _relay_vision_marker_metadata(provider: str, model: str) -> Optional[Dict[st
|
||||
return {**_UNKNOWN_MODEL_BASE, "modalities": {"input": ["text", "image"], "output": ["text"]}}
|
||||
|
||||
|
||||
def _provider_model_capabilities(provider: str, model: str) -> Dict[str, Any]:
|
||||
def _provider_model_capabilities(provider: str, model: str) -> dict[str, Any]:
|
||||
"""Exact-model declaration from the registered ``ProviderProfile.model_capabilities`` (canonical
|
||||
``model_overrides`` schema). The ONE plugin seam: every consumer that reads models.dev through this
|
||||
module (picker badges, image routing, ``/api/model/info``, context lookup) sees it (#102115)."""
|
||||
@@ -821,8 +821,8 @@ def _provider_model_capabilities(provider: str, model: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _apply_overrides(
|
||||
provider: str, model: str, entry: Optional[Dict[str, Any]], *, config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
provider: str, model: str, entry: Optional[dict[str, Any]], *, config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Catalog/builtin metadata, patched by the plugin's declaration, then by the explicit user override.
|
||||
``_UNKNOWN_MODEL_BASE`` is the base on a catalog miss; a fill-gap ``_default`` applies only when no
|
||||
source knows the model. None when nothing knows it."""
|
||||
@@ -837,7 +837,7 @@ def _apply_overrides(
|
||||
return base if override is None else _merge_catalog_entry_with_override(base if base is not None else _UNKNOWN_MODEL_BASE, override)
|
||||
|
||||
|
||||
def _entry_supports_vision(entry: Dict[str, Any]) -> bool:
|
||||
def _entry_supports_vision(entry: dict[str, Any]) -> bool:
|
||||
"""Prefer explicit ``modalities.input`` (the older ``attachment`` flag can be stale or too broad
|
||||
for image routing); fall back to it only when the input modalities are absent/invalid."""
|
||||
input_mods = _dict_or_empty(entry.get("modalities", {})).get("input")
|
||||
@@ -845,7 +845,7 @@ def _entry_supports_vision(entry: Dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def get_model_capabilities(
|
||||
provider: str, model: str, *, allow_network: bool = False, config: Optional[Dict[str, Any]] = None,
|
||||
provider: str, model: str, *, allow_network: bool = False, config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[ModelCapabilities]:
|
||||
"""Capability metadata from the models.dev cache, or None if unresolvable. EXPLICIT ``model_overrides``
|
||||
patch catalog fields; ``_default`` fills the gap only for models the catalog does not know. Unspecified
|
||||
@@ -879,7 +879,7 @@ def get_model_capabilities(
|
||||
)
|
||||
|
||||
|
||||
def list_provider_models(provider: str, *, allow_network: bool = True) -> List[str]:
|
||||
def list_provider_models(provider: str, *, allow_network: bool = True) -> list[str]:
|
||||
"""All model IDs for a provider ([] if unknown). ``allow_network`` defaults to True: the model
|
||||
picker is interactive and a fresh catalog is worth a short wait."""
|
||||
from hermes_cli.models import normalize_provider
|
||||
@@ -908,7 +908,7 @@ def _should_hide_from_provider_catalog(provider: str, model_id: str) -> bool:
|
||||
return (provider or "").strip().lower() in {"gemini", "google"} and (model_id or "").strip().lower() in _GOOGLE_HIDDEN_MODELS
|
||||
|
||||
|
||||
def list_agentic_models(provider: str, *, allow_network: bool = True) -> List[str]:
|
||||
def list_agentic_models(provider: str, *, allow_network: bool = True) -> list[str]:
|
||||
"""Model IDs suitable for agentic use: tool_call=True, minus hidden and noise models. [] on any
|
||||
failure. ``allow_network`` defaults to True (called from interactive model selection)."""
|
||||
models = _get_provider_models(provider, allow_network=allow_network)
|
||||
@@ -918,11 +918,11 @@ def list_agentic_models(provider: str, *, allow_network: bool = True) -> List[st
|
||||
] if models is not None else []
|
||||
|
||||
|
||||
def _parse_model_info(model_id: str, raw: Dict[str, Any], provider_id: str) -> ModelInfo:
|
||||
def _parse_model_info(model_id: str, raw: dict[str, Any], provider_id: str) -> ModelInfo:
|
||||
"""Convert a raw models.dev model entry dict into a ModelInfo dataclass."""
|
||||
cost = _dict_or_empty(raw.get("cost"))
|
||||
modalities = _dict_or_empty(raw.get("modalities"))
|
||||
def _mods(key: str) -> Tuple[str, ...]:
|
||||
def _mods(key: str) -> tuple[str, ...]:
|
||||
mods = modalities.get(key) or []
|
||||
return tuple(mods) if isinstance(mods, list) else ()
|
||||
def _cost(key: str) -> Optional[float]:
|
||||
@@ -939,7 +939,7 @@ def _parse_model_info(model_id: str, raw: Dict[str, Any], provider_id: str) -> M
|
||||
)
|
||||
|
||||
|
||||
def _parse_provider_info(provider_id: str, raw: Dict[str, Any]) -> ProviderInfo:
|
||||
def _parse_provider_info(provider_id: str, raw: dict[str, Any]) -> ProviderInfo:
|
||||
"""Convert a raw models.dev provider entry dict into a ProviderInfo."""
|
||||
env = raw.get("env") or []
|
||||
models = raw.get("models") or {}
|
||||
@@ -950,7 +950,7 @@ def _parse_provider_info(provider_id: str, raw: Dict[str, Any]) -> ProviderInfo:
|
||||
|
||||
|
||||
def get_provider_info(
|
||||
provider_id: str, *, allow_network: bool = True, config: Optional[Dict[str, Any]] = None,
|
||||
provider_id: str, *, allow_network: bool = True, config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[ProviderInfo]:
|
||||
"""Provider metadata by Hermes or models.dev ID, or None if not cataloged. ``allow_network`` defaults to True (interactive setup)."""
|
||||
mdev_id = _models_dev_id(provider_id, config=config) or provider_id
|
||||
@@ -959,7 +959,7 @@ def get_provider_info(
|
||||
|
||||
|
||||
def get_model_info(
|
||||
provider_id: str, model_id: str, *, allow_network: bool = False, config: Optional[Dict[str, Any]] = None,
|
||||
provider_id: str, model_id: str, *, allow_network: bool = False, config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[ModelInfo]:
|
||||
"""Full model metadata by Hermes or models.dev provider ID (exact match, then case-insensitive), or
|
||||
None if not found. EXPLICIT ``model_overrides`` patch known catalog models; ``_default`` fills the gap
|
||||
|
||||
@@ -26,7 +26,7 @@ class MonitoringEmitter:
|
||||
|
||||
def __init__(self, *, enabled: bool = True) -> None:
|
||||
self._enabled = enabled
|
||||
self._q: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=_MAX_QUEUE)
|
||||
self._q: "queue.Queue[dict[str, Any]]" = queue.Queue(maxsize=_MAX_QUEUE)
|
||||
self._dropped = 0
|
||||
self._dispatched = 0
|
||||
self._stop = threading.Event()
|
||||
@@ -122,7 +122,7 @@ class MonitoringEmitter:
|
||||
threading.Thread(target=_wait_for_completion, name="hermes-monitoring-flush", daemon=True).start()
|
||||
finished.wait(timeout=timeout)
|
||||
|
||||
def stats(self) -> Dict[str, int]:
|
||||
def stats(self) -> dict[str, int]:
|
||||
return {"queued": self._q.qsize(), "dispatched": self._dispatched, "dropped": self._dropped, "subscribers": len(self._subscribers)}
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -17,7 +17,7 @@ class _MonitoringEvent:
|
||||
__slots__ = ()
|
||||
EVENT: ClassVar[str]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"event": self.EVENT, **asdict(self)}
|
||||
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ logger = logging.getLogger(__name__)
|
||||
class GatewayMetric:
|
||||
name: str
|
||||
value: int | float
|
||||
attributes: Dict[str, str]
|
||||
attributes: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GatewayHealthSnapshot:
|
||||
metrics: List[GatewayMetric]
|
||||
events: List[GatewayHealthEvent | GatewayDiagnosticEvent]
|
||||
metrics: list[GatewayMetric]
|
||||
events: list[GatewayHealthEvent | GatewayDiagnosticEvent]
|
||||
|
||||
|
||||
_RUNNING_PLATFORM_STATES = {"running", "connected", "ok", "ready"}
|
||||
@@ -131,7 +131,7 @@ def _coerce_pid(raw: Any) -> Optional[int]:
|
||||
def _gateway_status(name: str, fallback: Callable[[], Any], /, **kwargs: Any) -> Any:
|
||||
"""Prefer ``gateway.status.<name>`` (the runtime-status contract); fall back to the local approximation."""
|
||||
try:
|
||||
import gateway.status as status
|
||||
from gateway import status
|
||||
return getattr(status, name)(**kwargs)
|
||||
except Exception:
|
||||
return fallback()
|
||||
@@ -179,7 +179,7 @@ def build_gateway_health_snapshot(
|
||||
"hermes.supervision_mode": mode if mode in _SUPERVISION_MODES else "unknown",
|
||||
}
|
||||
|
||||
def metric(name: str, value: int | float, **extra: str) -> GatewayMetric:
|
||||
def metric(name: str, value: float, **extra: str) -> GatewayMetric:
|
||||
attrs = dict(base)
|
||||
for key, val in extra.items():
|
||||
if val is not None:
|
||||
|
||||
@@ -54,7 +54,7 @@ _OBSERVABLE_METRIC_NAMES = (
|
||||
)
|
||||
|
||||
|
||||
def _diagnostic_log_attributes(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _diagnostic_log_attributes(event: dict[str, Any]) -> dict[str, Any]:
|
||||
# Same allowlist as the span mapping: profile/install_id/ts never egress as attributes.
|
||||
return _allowlisted_attrs(event, otlp_exporter._KEEP_BY_KIND["gateway_diagnostic"])
|
||||
|
||||
@@ -102,15 +102,15 @@ class GatewayHealthExportRuntime:
|
||||
self.streamer = self.log_streamer = self.metric_provider = self.thread = self.stop_event = None
|
||||
|
||||
|
||||
def _gateway_health_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _gateway_health_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return _monitoring_section(config, "gateway_health_export")
|
||||
|
||||
|
||||
def _enabled(config: Dict[str, Any]) -> bool:
|
||||
def _enabled(config: dict[str, Any]) -> bool:
|
||||
return bool(_gateway_health_config(config).get("enabled") and otlp_exporter.is_enabled(config))
|
||||
|
||||
|
||||
def _exporter_kwargs(config: Dict[str, Any], signal: str) -> Dict[str, Any]:
|
||||
def _exporter_kwargs(config: dict[str, Any], signal: str) -> dict[str, Any]:
|
||||
otlp = _otlp_config(config)
|
||||
return {"endpoint": _signal_endpoint(str(otlp.get("endpoint")), signal), "headers": _resolve_headers(otlp.get("headers_env")) or None}
|
||||
|
||||
@@ -128,7 +128,7 @@ def _supervision_mode() -> str:
|
||||
return next((mode for mode, detect in _SUPERVISION_DETECTORS if detect()), "manual")
|
||||
|
||||
|
||||
def _read_gateway_snapshot(config: Dict[str, Any]):
|
||||
def _read_gateway_snapshot(config: dict[str, Any]):
|
||||
try:
|
||||
from gateway.status import read_runtime_status
|
||||
runtime = read_runtime_status() or {}
|
||||
@@ -170,7 +170,7 @@ def _read_background_delegations_count() -> int:
|
||||
return _count("background-delegations count failed", "tools.async_delegation", lambda m: m.active_count())
|
||||
|
||||
|
||||
def _read_runtime_snapshot(config: Dict[str, Any]):
|
||||
def _read_runtime_snapshot(config: dict[str, Any]):
|
||||
gateway_snapshot = _read_gateway_snapshot(config)
|
||||
# Background/subagent work is appended to the gateway snapshot so it rides the same base
|
||||
# resource attributes (service.instance.id etc.).
|
||||
@@ -194,7 +194,7 @@ def _read_runtime_snapshot(config: Dict[str, Any]):
|
||||
return gateway_snapshot
|
||||
|
||||
|
||||
def _emit_snapshot_events(config: Dict[str, Any]) -> None:
|
||||
def _emit_snapshot_events(config: dict[str, Any]) -> None:
|
||||
if not _gateway_health_config(config).get("diagnostic_events_enabled", True):
|
||||
return
|
||||
try:
|
||||
@@ -204,7 +204,7 @@ def _emit_snapshot_events(config: Dict[str, Any]) -> None:
|
||||
logger.debug("gateway health snapshot emit failed", exc_info=True)
|
||||
|
||||
|
||||
def _start_metric_provider(config: Dict[str, Any], sdk: Dict[str, Any]) -> Any:
|
||||
def _start_metric_provider(config: dict[str, Any], sdk: dict[str, Any]) -> Any:
|
||||
exporter = sdk["OTLPMetricExporter"](**_exporter_kwargs(config, "metrics"))
|
||||
interval_ms = max(5, int(_gateway_health_config(config).get("export_interval_seconds", 60))) * 1000
|
||||
reader = sdk["PeriodicExportingMetricReader"](exporter, export_interval_millis=interval_ms)
|
||||
@@ -231,14 +231,14 @@ def _start_metric_provider(config: Dict[str, Any], sdk: Dict[str, Any]) -> Any:
|
||||
_SEVERITY_NAMES = {"critical": "FATAL", "fatal": "FATAL", "error": "ERROR", "info": "INFO", "information": "INFO", "debug": "DEBUG"}
|
||||
|
||||
|
||||
def _severity_number(sdk: Dict[str, Any], severity: Any) -> Any:
|
||||
def _severity_number(sdk: dict[str, Any], severity: Any) -> Any:
|
||||
return getattr(sdk["SeverityNumber"], _SEVERITY_NAMES.get(str(severity or "warning").lower(), "WARN"))
|
||||
|
||||
|
||||
class GatewayDiagnosticLogStreamer(EmitterStreamer):
|
||||
"""Emitter subscriber that sends gateway diagnostic events as OTLP logs."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], sdk: Dict[str, Any]):
|
||||
def __init__(self, config: dict[str, Any], sdk: dict[str, Any]):
|
||||
resource = sdk["Resource"].create(_runtime_resource_attributes(config, telemetry_scope="gateway_diagnostics"))
|
||||
self._provider = sdk["LoggerProvider"](resource=resource)
|
||||
self._processor = sdk["BatchLogRecordProcessor"](sdk["OTLPLogExporter"](**_exporter_kwargs(config, "logs")))
|
||||
@@ -247,7 +247,7 @@ class GatewayDiagnosticLogStreamer(EmitterStreamer):
|
||||
self._sdk = sdk
|
||||
self.exported = 0
|
||||
|
||||
def __call__(self, batch: list[Dict[str, Any]]) -> None:
|
||||
def __call__(self, batch: list[dict[str, Any]]) -> None:
|
||||
sdk = self._sdk
|
||||
for ev in batch:
|
||||
if ev.get("event") != "gateway_diagnostic":
|
||||
@@ -266,7 +266,7 @@ class GatewayDiagnosticLogStreamer(EmitterStreamer):
|
||||
self.exported += 1
|
||||
|
||||
|
||||
def _gateway_health_event(ev: Dict[str, Any]) -> bool:
|
||||
def _gateway_health_event(ev: dict[str, Any]) -> bool:
|
||||
return ev.get("event") in {"gateway_health", "cron_execution"}
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ def _fail(runtime: GatewayHealthExportRuntime, log: Callable[..., None], msg: st
|
||||
return GatewayHealthExportRuntime(enabled=False, reason=reason)
|
||||
|
||||
|
||||
def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRuntime:
|
||||
def start_gateway_health_export(config: dict[str, Any]) -> GatewayHealthExportRuntime:
|
||||
"""Start P0 gateway health export if configured. Never raises."""
|
||||
if not _enabled(config):
|
||||
return GatewayHealthExportRuntime(enabled=False, reason="disabled")
|
||||
@@ -284,7 +284,7 @@ def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRu
|
||||
metrics_on = gh.get("metrics_enabled", True)
|
||||
diagnostics_on = gh.get("diagnostic_events_enabled", True)
|
||||
runtime = GatewayHealthExportRuntime(enabled=True, reason="enabled")
|
||||
sdk: Optional[Dict[str, Any]] = None
|
||||
sdk: Optional[dict[str, Any]] = None
|
||||
if metrics_on or diagnostics_on:
|
||||
try:
|
||||
sdk = otlp_exporter._require_sdk(_METRICS_SDK, auto_install=True)
|
||||
|
||||
@@ -28,7 +28,7 @@ class OTLPUnavailable(RuntimeError):
|
||||
|
||||
|
||||
# ── SDK loading ──────────────────────────────────────────────────────────────
|
||||
_SDK_MODULES: Dict[str, tuple[str, ...]] = {
|
||||
_SDK_MODULES: dict[str, tuple[str, ...]] = {
|
||||
"opentelemetry.sdk.trace": ("TracerProvider",),
|
||||
"opentelemetry.sdk.trace.export": ("BatchSpanProcessor",),
|
||||
"opentelemetry.sdk.resources": ("Resource",),
|
||||
@@ -44,11 +44,11 @@ _SDK_MODULES: Dict[str, tuple[str, ...]] = {
|
||||
"opentelemetry.sdk.metrics": ("MeterProvider",),
|
||||
"opentelemetry.sdk.metrics.export": ("PeriodicExportingMetricReader",),
|
||||
}
|
||||
_SDK_SYMBOLS: Dict[str, str] = {name: module for module, names in _SDK_MODULES.items() for name in names}
|
||||
_SDK_SYMBOLS: dict[str, str] = {name: module for module, names in _SDK_MODULES.items() for name in names}
|
||||
_SPAN_SDK = ("TracerProvider", "BatchSpanProcessor", "Resource", "OTLPSpanExporter", "SpanKind")
|
||||
|
||||
|
||||
def _require_sdk(names: Iterable[str] = _SPAN_SDK, *, auto_install: bool = True) -> Dict[str, Any]:
|
||||
def _require_sdk(names: Iterable[str] = _SPAN_SDK, *, auto_install: bool = True) -> dict[str, Any]:
|
||||
"""Import the named OTel SDK symbols, lazily installing the extra on first use.
|
||||
|
||||
Routes through pm.ensure_import('otlp') so a missing SDK triggers the
|
||||
@@ -73,20 +73,20 @@ def _require_sdk(names: Iterable[str] = _SPAN_SDK, *, auto_install: bool = True)
|
||||
|
||||
|
||||
# ── config + connection plumbing ─────────────────────────────────────────────
|
||||
def _monitoring_section(config: Dict[str, Any], *path: str) -> Dict[str, Any]:
|
||||
def _monitoring_section(config: dict[str, Any], *path: str) -> dict[str, Any]:
|
||||
node: Any = (config or {}).get("monitoring") or {}
|
||||
for key in path:
|
||||
node = node.get(key) or {}
|
||||
return node
|
||||
|
||||
|
||||
def _otlp_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _otlp_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return _monitoring_section(config, "export", "otlp")
|
||||
|
||||
|
||||
def _resolve_headers(headers_env: Optional[Dict[str, str]]) -> Dict[str, str]:
|
||||
def _resolve_headers(headers_env: Optional[dict[str, str]]) -> dict[str, str]:
|
||||
"""Resolve {header_name: ENV_VAR_NAME} -> {header_name: value}; missing vars skipped."""
|
||||
resolved: Dict[str, str] = {}
|
||||
resolved: dict[str, str] = {}
|
||||
for header_name, env_name in (headers_env or {}).items():
|
||||
val = os.environ.get(str(env_name))
|
||||
if val:
|
||||
@@ -115,7 +115,7 @@ _RESOURCE_ATTRIBUTE_KEYS = frozenset({
|
||||
_SAFE_RESOURCE_VALUE = re.compile(r"^[A-Za-z0-9._:/-]{1,128}$")
|
||||
|
||||
|
||||
def _install_id(config: Dict[str, Any]) -> str:
|
||||
def _install_id(config: dict[str, Any]) -> str:
|
||||
try:
|
||||
from agent.monitoring.policy import ensure_install_id
|
||||
return str(ensure_install_id(config))
|
||||
@@ -123,9 +123,9 @@ def _install_id(config: Dict[str, Any]) -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _safe_resource_attributes(raw: Any) -> Dict[str, str]:
|
||||
def _safe_resource_attributes(raw: Any) -> dict[str, str]:
|
||||
"""Allowlist bounded resource labels and reject values changed by redaction."""
|
||||
attrs: Dict[str, str] = {}
|
||||
attrs: dict[str, str] = {}
|
||||
for key, value in (raw.items() if isinstance(raw, dict) else ()):
|
||||
key = str(key)
|
||||
if key not in _RESOURCE_ATTRIBUTE_KEYS or value is None:
|
||||
@@ -138,7 +138,7 @@ def _safe_resource_attributes(raw: Any) -> Dict[str, str]:
|
||||
return attrs
|
||||
|
||||
|
||||
def _runtime_resource_attributes(config: Dict[str, Any], *, telemetry_scope: str) -> Dict[str, str]:
|
||||
def _runtime_resource_attributes(config: dict[str, Any], *, telemetry_scope: str) -> dict[str, str]:
|
||||
"""Build the safe OTLP resource shared by spans, metrics and diagnostic logs."""
|
||||
attrs = _safe_resource_attributes(_monitoring_section(config, "gateway_health_export").get("resource_attributes"))
|
||||
attrs["service.name"] = "hermes-gateway"
|
||||
@@ -147,7 +147,7 @@ def _runtime_resource_attributes(config: Dict[str, Any], *, telemetry_scope: str
|
||||
return attrs
|
||||
|
||||
|
||||
def build_exporter(config: Dict[str, Any]):
|
||||
def build_exporter(config: dict[str, Any]):
|
||||
"""Construct an OTLP span exporter from config. Raises OTLPUnavailable if no SDK."""
|
||||
sdk = _require_sdk()
|
||||
otlp = _otlp_config(config)
|
||||
@@ -157,11 +157,11 @@ def build_exporter(config: Dict[str, Any]):
|
||||
return sdk["OTLPSpanExporter"](endpoint=endpoint, headers=_resolve_headers(otlp.get("headers_env")) or None)
|
||||
|
||||
|
||||
def _resource_attributes(config: Dict[str, Any]) -> Dict[str, str]:
|
||||
def _resource_attributes(config: dict[str, Any]) -> dict[str, str]:
|
||||
return _runtime_resource_attributes(config, telemetry_scope="gateway_monitoring")
|
||||
|
||||
|
||||
def _make_provider(config: Dict[str, Any]):
|
||||
def _make_provider(config: dict[str, Any]):
|
||||
sdk = _require_sdk()
|
||||
provider = sdk["TracerProvider"](resource=sdk["Resource"].create(_resource_attributes(config)))
|
||||
processor = sdk["BatchSpanProcessor"](build_exporter(config))
|
||||
@@ -171,7 +171,7 @@ def _make_provider(config: Dict[str, Any]):
|
||||
|
||||
# ── event -> span attribute mapping ──────────────────────────────────────────
|
||||
# Per-kind attribute allowlists: everything else (profile, install_id, ...) never egresses.
|
||||
_KEEP_BY_KIND: Dict[str, tuple[str, ...]] = {
|
||||
_KEEP_BY_KIND: dict[str, tuple[str, ...]] = {
|
||||
"gateway_health": (
|
||||
"name", "gateway_state", "old_state", "new_state", "exit_reason", "restart_requested", "active_agents",
|
||||
"gateway_busy", "gateway_drainable", "platform_count", "fatal_platform_count", "version", "supervision_mode", "pid",
|
||||
@@ -181,9 +181,9 @@ _KEEP_BY_KIND: Dict[str, tuple[str, ...]] = {
|
||||
}
|
||||
|
||||
|
||||
def _allowlisted_attrs(ev: Dict[str, Any], keys: Iterable[str]) -> Dict[str, Any]:
|
||||
def _allowlisted_attrs(ev: dict[str, Any], keys: Iterable[str]) -> dict[str, Any]:
|
||||
"""``hermes.<key>`` attributes for present keys; string values are redacted and bounded."""
|
||||
attrs: Dict[str, Any] = {}
|
||||
attrs: dict[str, Any] = {}
|
||||
for col in keys:
|
||||
v = ev.get(col)
|
||||
if v is not None:
|
||||
@@ -191,15 +191,15 @@ def _allowlisted_attrs(ev: Dict[str, Any], keys: Iterable[str]) -> Dict[str, Any
|
||||
return attrs
|
||||
|
||||
|
||||
def _span_attrs(ev: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _span_attrs(ev: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Span attributes for a monitoring event (content-free by construction)."""
|
||||
kind = ev.get("event")
|
||||
attrs: Dict[str, Any] = {"hermes.event": kind or "unknown"}
|
||||
attrs: dict[str, Any] = {"hermes.event": kind or "unknown"}
|
||||
attrs.update(_allowlisted_attrs(ev, _KEEP_BY_KIND.get(kind, ()))) # type: ignore[arg-type]
|
||||
return attrs
|
||||
|
||||
|
||||
def export_batch(provider, batch: List[Dict[str, Any]]) -> int:
|
||||
def export_batch(provider, batch: list[dict[str, Any]]) -> int:
|
||||
"""Map a batch of events to OTel spans. Returns spans created."""
|
||||
tracer = provider.get_tracer("hermes.monitoring")
|
||||
n = 0
|
||||
@@ -232,12 +232,12 @@ class EmitterStreamer:
|
||||
class OTLPStreamer(EmitterStreamer):
|
||||
"""A live subscriber that pushes each emitter batch to OTLP as spans."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], *, event_filter: Optional[Callable[[Dict[str, Any]], bool]] = None):
|
||||
def __init__(self, config: dict[str, Any], *, event_filter: Optional[Callable[[dict[str, Any]], bool]] = None):
|
||||
self._provider, self._processor = _make_provider(config)
|
||||
self._event_filter = event_filter
|
||||
self.exported = 0
|
||||
|
||||
def __call__(self, batch: List[Dict[str, Any]]) -> None:
|
||||
def __call__(self, batch: list[dict[str, Any]]) -> None:
|
||||
if self._event_filter is not None:
|
||||
batch = [ev for ev in batch if self._event_filter(ev)]
|
||||
if not batch:
|
||||
@@ -254,13 +254,13 @@ def is_available() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_enabled(config: Dict[str, Any]) -> bool:
|
||||
def is_enabled(config: dict[str, Any]) -> bool:
|
||||
otlp = _otlp_config(config)
|
||||
return bool(otlp.get("enabled") and otlp.get("endpoint"))
|
||||
|
||||
|
||||
def start_streaming(
|
||||
config: Dict[str, Any], *, event_filter: Optional[Callable[[Dict[str, Any]], bool]] = None,
|
||||
config: dict[str, Any], *, event_filter: Optional[Callable[[dict[str, Any]], bool]] = None,
|
||||
) -> Optional[OTLPStreamer]:
|
||||
"""If OTLP is enabled, attach a streamer to the singleton emitter.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Any, Dict
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_install_id(config: Dict[str, Any]) -> str:
|
||||
def ensure_install_id(config: dict[str, Any]) -> str:
|
||||
"""Return a stable install id, minting and persisting one when empty.
|
||||
|
||||
The id becomes ``service.instance.id`` and must survive restarts, so a fresh
|
||||
|
||||
Loaded 100 of 2366 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user