mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
Merge branch 'main' into bb/gui
This commit is contained in:
commit
fa48c2501f
72 changed files with 2726 additions and 742 deletions
|
|
@ -210,7 +210,7 @@ hermes-agent/
|
|||
| `~/.hermes/skills/` | All active skills (bundled + hub-installed + agent-created) |
|
||||
| `~/.hermes/memories/` | Persistent memory (MEMORY.md, USER.md) |
|
||||
| `~/.hermes/state.db` | SQLite session database |
|
||||
| `~/.hermes/sessions/` | JSON session logs |
|
||||
| `~/.hermes/sessions/` | Gateway routing index (`sessions.json`), request-dump breadcrumbs, gateway `*.jsonl` transcripts, and (optionally) per-session JSON snapshots when `sessions.write_json_snapshots: true` is set. The per-session snapshots are off by default; state.db is canonical. |
|
||||
| `~/.hermes/cron/` | Scheduled job data |
|
||||
| `~/.hermes/whatsapp/session/` | WhatsApp bridge credentials |
|
||||
|
||||
|
|
@ -239,7 +239,7 @@ User message → AIAgent._run_agent_loop()
|
|||
|
||||
- **Self-registering tools**: Each tool file calls `registry.register()` at import time. `model_tools.py` triggers discovery by importing all tool modules.
|
||||
- **Toolset grouping**: Tools are grouped into toolsets (`web`, `terminal`, `file`, `browser`, etc.) that can be enabled/disabled per platform.
|
||||
- **Session persistence**: All conversations are stored in SQLite (`hermes_state.py`) with full-text search and unique session titles. JSON logs go to `~/.hermes/sessions/`.
|
||||
- **Session persistence**: All conversations are stored in SQLite (`hermes_state.py`) with full-text search and unique session titles. Per-session JSON snapshots in `~/.hermes/sessions/` were superseded by the SQLite store and are off by default; opt back in with `sessions.write_json_snapshots: true` if you have external tooling that consumes the JSON files directly.
|
||||
- **Ephemeral injection**: System prompts and prefill messages are injected at API call time, never persisted to the database or logs.
|
||||
- **Provider abstraction**: The agent works with any OpenAI-compatible API. Provider resolution happens at init time (Nous Portal OAuth, OpenRouter API key, or custom endpoint).
|
||||
- **Provider routing**: When using OpenRouter, `provider_routing` in config.yaml controls provider selection (sort by throughput/latency/price, allow/ignore specific providers, data retention policies). These are injected as `extra_body.provider` in API requests.
|
||||
|
|
|
|||
|
|
@ -901,7 +901,19 @@ def init_agent(
|
|||
hermes_home = get_hermes_home()
|
||||
agent.logs_dir = hermes_home / "sessions"
|
||||
agent.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
agent.session_log_file = agent.logs_dir / f"session_{agent.session_id}.json"
|
||||
# Per-session JSON snapshot writer (~/.hermes/sessions/session_{sid}.json)
|
||||
# is opt-in via sessions.write_json_snapshots (default False). state.db
|
||||
# is canonical — the snapshot is only useful for external tooling that
|
||||
# reads the JSON files directly. See run_agent._save_session_log.
|
||||
agent._session_json_enabled = False
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_sess_cfg
|
||||
_sess_cfg = (_load_sess_cfg().get("sessions") or {})
|
||||
agent._session_json_enabled = bool(_sess_cfg.get("write_json_snapshots", False))
|
||||
except Exception:
|
||||
pass
|
||||
# logs_dir is retained unconditionally for request_dump_*.json (debug
|
||||
# breadcrumb path written by agent_runtime_helpers.dump_api_request_debug).
|
||||
|
||||
# Track conversation messages for session logging
|
||||
agent._session_messages: List[Dict[str, Any]] = []
|
||||
|
|
|
|||
|
|
@ -387,8 +387,6 @@ def compress_context(
|
|||
_SESSION_ID.set(agent.session_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Update session_log_file to point to the new session's JSON file
|
||||
agent.session_log_file = agent.logs_dir / f"session_{agent.session_id}.json"
|
||||
agent._session_db_created = False
|
||||
agent._session_db.create_session(
|
||||
session_id=agent.session_id,
|
||||
|
|
|
|||
|
|
@ -1454,7 +1454,6 @@ def run_conversation(
|
|||
}
|
||||
messages.append(continue_msg)
|
||||
agent._session_messages = messages
|
||||
agent._save_session_log(messages)
|
||||
restart_with_length_continuation = True
|
||||
break
|
||||
|
||||
|
|
@ -3086,7 +3085,6 @@ def run_conversation(
|
|||
if not agent.quiet_mode:
|
||||
agent._vprint(f"{agent.log_prefix}↻ Codex response incomplete; continuing turn ({agent._codex_incomplete_retries}/3)")
|
||||
agent._session_messages = messages
|
||||
agent._save_session_log(messages)
|
||||
continue
|
||||
|
||||
agent._codex_incomplete_retries = 0
|
||||
|
|
@ -3411,7 +3409,6 @@ def run_conversation(
|
|||
|
||||
# Save session log incrementally (so progress is visible even if interrupted)
|
||||
agent._session_messages = messages
|
||||
agent._save_session_log(messages)
|
||||
|
||||
# Continue loop for next response
|
||||
continue
|
||||
|
|
@ -3578,7 +3575,6 @@ def run_conversation(
|
|||
interim_msg["_thinking_prefill"] = True
|
||||
messages.append(interim_msg)
|
||||
agent._session_messages = messages
|
||||
agent._save_session_log(messages)
|
||||
continue
|
||||
|
||||
# ── Empty response retry ──────────────────────
|
||||
|
|
@ -3712,7 +3708,6 @@ def run_conversation(
|
|||
}
|
||||
messages.append(continue_msg)
|
||||
agent._session_messages = messages
|
||||
agent._save_session_log(messages)
|
||||
continue
|
||||
|
||||
codex_ack_continuations = 0
|
||||
|
|
|
|||
|
|
@ -112,17 +112,31 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
def convert_messages(
|
||||
self, messages: list[dict[str, Any]], **kwargs
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Messages are already in OpenAI format — sanitize Codex leaks only.
|
||||
"""Messages are already in OpenAI format — strip internal fields
|
||||
that strict chat-completions providers reject with HTTP 400/422.
|
||||
|
||||
Strips Codex Responses API fields (``codex_reasoning_items`` /
|
||||
``codex_message_items`` on the message, ``call_id``/``response_item_id``
|
||||
on tool_calls) that strict chat-completions providers reject with 400/422.
|
||||
Strips:
|
||||
|
||||
- Codex Responses API fields: ``codex_reasoning_items`` /
|
||||
``codex_message_items`` on the message, ``call_id`` /
|
||||
``response_item_id`` on ``tool_calls`` entries.
|
||||
- ``tool_name`` on tool-result messages — written by
|
||||
``make_tool_result_message()`` for the SQLite FTS index, but not
|
||||
part of the Chat Completions schema. Strict providers (Fireworks,
|
||||
Moonshot/Kimi) reject any payload containing it with
|
||||
``Extra inputs are not permitted, field: 'messages[N].tool_name'``.
|
||||
Permissive providers (OpenRouter, MiniMax) silently ignore the
|
||||
field, which masked the bug for months.
|
||||
"""
|
||||
needs_sanitize = False
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if "codex_reasoning_items" in msg or "codex_message_items" in msg:
|
||||
if (
|
||||
"codex_reasoning_items" in msg
|
||||
or "codex_message_items" in msg
|
||||
or "tool_name" in msg
|
||||
):
|
||||
needs_sanitize = True
|
||||
break
|
||||
tool_calls = msg.get("tool_calls")
|
||||
|
|
@ -145,6 +159,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
continue
|
||||
msg.pop("codex_reasoning_items", None)
|
||||
msg.pop("codex_message_items", None)
|
||||
msg.pop("tool_name", None)
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
for tc in tool_calls:
|
||||
|
|
|
|||
284
apps/dashboard/package-lock.json
generated
284
apps/dashboard/package-lock.json
generated
|
|
@ -9,7 +9,7 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nous-research/ui": "0.14.0",
|
||||
"@nous-research/ui": "^0.14.2",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
|
@ -1092,12 +1092,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@nous-research/ui": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.14.0.tgz",
|
||||
"integrity": "sha512-tfpE6jGOxE5oVBab/dTSepOudy/+Xep3gJ6NCFriYJvdtQBGXcqsi4mCaVPiNNaS/ZFf4/10dnl/oJTb6DtLKg==",
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.14.2.tgz",
|
||||
"integrity": "sha512-H3cMt2e0IpmcTNOmR6zVX+8ja48w4X4F/IFXhWCpaoVs8zKVRN12Ryb4RnX/ac8IrbUu6UsIds7ZtmXxPHcfdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"nanostores": "^1.3.0",
|
||||
|
|
@ -1177,13 +1178,20 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"node_modules/@radix-ui/react-checkbox": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
|
|
@ -1200,24 +1208,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
|
|
@ -1275,47 +1265,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
|
|
@ -1366,47 +1315,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.10.tgz",
|
||||
|
|
@ -1431,6 +1339,47 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
|
|
@ -1456,12 +1405,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
|
|
@ -1479,9 +1428,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
|
|
@ -1554,47 +1503,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
|
|
@ -1680,6 +1588,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
|
|
@ -1739,47 +1662,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
|
|
@ -3904,9 +3786,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.5",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz",
|
||||
"integrity": "sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==",
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nous-research/ui": "0.14.0",
|
||||
"@nous-research/ui": "^0.14.2",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
|
|
@ -283,15 +285,22 @@ export function ModelPickerDialog(props: Props) {
|
|||
Saves to config.yaml — applies to new sessions.
|
||||
</span>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={persistGlobal}
|
||||
onChange={(e) => setPersistGlobal(e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
id="model-picker-persist-global"
|
||||
onCheckedChange={(checked) =>
|
||||
setPersistGlobal(checked === true)
|
||||
}
|
||||
/>
|
||||
Persist globally (otherwise this session only)
|
||||
</label>
|
||||
|
||||
<Label
|
||||
className="font-sans normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
|
||||
htmlFor="model-picker-persist-global"
|
||||
>
|
||||
Persist globally (otherwise this session only)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface CheckboxProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
className,
|
||||
label,
|
||||
id,
|
||||
checked,
|
||||
defaultChecked,
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
// Support both controlled (checked prop) and uncontrolled (defaultChecked) usage.
|
||||
// For visual rendering, prefer `checked` if provided; otherwise fall back to defaultChecked.
|
||||
const isChecked = checked ?? defaultChecked ?? false;
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 cursor-pointer select-none",
|
||||
props.disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center transition-all",
|
||||
"border bg-background/40",
|
||||
// Focus-visible ring for keyboard accessibility
|
||||
"group-has-[:focus-visible]:ring-2 group-has-[:focus-visible]:ring-ring group-has-[:focus-visible]:ring-offset-1",
|
||||
isChecked
|
||||
? "border-foreground bg-foreground/20"
|
||||
: "border-border group-hover:border-foreground/40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3 transition-opacity",
|
||||
isChecked
|
||||
? "text-foreground opacity-100"
|
||||
: "text-foreground opacity-0",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
defaultChecked={checked === undefined ? defaultChecked : undefined}
|
||||
className="sr-only"
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="text-sm">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
@import 'tailwindcss';
|
||||
/* `fonts.css` must come BEFORE `globals.css`: as of @nous-research/ui 0.14.x,
|
||||
`globals.css` only declares the `--font-*` CSS variables (Collapse, Rules
|
||||
Compressed/Expanded, Mondwest). The `@font-face` registrations live in
|
||||
`fonts.css`, so without this import the DS variables resolve to font
|
||||
families the browser never loads and components fall back to a system
|
||||
stack (Tabs, Segmented, Typography, Buttons, etc. all look unstyled). */
|
||||
@import '@nous-research/ui/styles/fonts.css';
|
||||
@import '@nous-research/ui/styles/globals.css';
|
||||
|
||||
/* Scan the published design-system bundle so its utility classes survive
|
||||
|
|
|
|||
|
|
@ -439,7 +439,7 @@ export default function AnalyticsPage() {
|
|||
);
|
||||
setEnd(
|
||||
showTokens === false ? null : (
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export default function LogsPage() {
|
|||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-3">
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
|
|
|
|||
|
|
@ -827,7 +827,7 @@ export default function ModelsPage() {
|
|||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export default function PluginsPage() {
|
|||
|
||||
useEffect(() => {
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 justify-start">
|
||||
<div className="flex w-full min-w-0 justify-start sm:justify-end">
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Plus,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import spinners from "unicode-animations";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { api } from "@/lib/api";
|
||||
|
|
@ -14,7 +28,7 @@ import { Badge } from "@nous-research/ui/ui/components/badge";
|
|||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
|
|
@ -131,7 +145,10 @@ export default function ProfilesPage() {
|
|||
}
|
||||
try {
|
||||
await api.renameProfile(renamingFrom, target);
|
||||
showToast(`${t.profiles.renamed}: ${renamingFrom} → ${target}`, "success");
|
||||
showToast(
|
||||
`${t.profiles.renamed}: ${renamingFrom} → ${target}`,
|
||||
"success",
|
||||
);
|
||||
setRenamingFrom(null);
|
||||
setRenameTo("");
|
||||
load();
|
||||
|
|
@ -214,10 +231,7 @@ export default function ProfilesPage() {
|
|||
// Put "Create" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Button size="sm" onClick={() => setCreateModalOpen(true)}>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
|
|
@ -266,7 +280,9 @@ export default function ProfilesPage() {
|
|||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
|
||||
onClick={(e) =>
|
||||
e.target === e.currentTarget && setCreateModalOpen(false)
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-profile-title"
|
||||
|
|
@ -313,12 +329,22 @@ export default function ProfilesPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
id="clone-from-default"
|
||||
checked={cloneFromDefault}
|
||||
onChange={(e) => setCloneFromDefault(e.target.checked)}
|
||||
label={t.profiles.cloneFromDefault}
|
||||
/>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
checked={cloneFromDefault}
|
||||
id="clone-from-default"
|
||||
onCheckedChange={(checked) =>
|
||||
setCloneFromDefault(checked === true)
|
||||
}
|
||||
/>
|
||||
|
||||
<Label
|
||||
className="font-sans normal-case tracking-normal text-sm cursor-pointer"
|
||||
htmlFor="clone-from-default"
|
||||
>
|
||||
{t.profiles.cloneFromDefault}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleCreate} disabled={creating}>
|
||||
|
|
@ -426,10 +452,7 @@ export default function ProfilesPage() {
|
|||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRenameSubmit}
|
||||
>
|
||||
<Button size="sm" onClick={handleRenameSubmit}>
|
||||
{t.common.save}
|
||||
</Button>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { api, fetchJSON } from "@/lib/api";
|
|||
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -121,7 +122,7 @@ export function exposePluginSDK() {
|
|||
// Raw fetchJSON for plugin-specific endpoints
|
||||
fetchJSON,
|
||||
|
||||
// UI components (shadcn/ui primitives)
|
||||
// UI components — Nous DS where available, shadcn/ui primitives elsewhere.
|
||||
components: {
|
||||
Card,
|
||||
CardHeader,
|
||||
|
|
@ -129,6 +130,7 @@ export function exposePluginSDK() {
|
|||
CardContent,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
|
|
|
|||
94
cli.py
94
cli.py
|
|
@ -105,6 +105,7 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧
|
|||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
from hermes_cli.browser_connect import (
|
||||
DEFAULT_BROWSER_CDP_URL,
|
||||
is_browser_debug_ready,
|
||||
manual_chrome_debug_command,
|
||||
try_launch_chrome_debug,
|
||||
)
|
||||
|
|
@ -6457,12 +6458,6 @@ class HermesCLI:
|
|||
if self.agent:
|
||||
self.agent.session_id = new_session_id
|
||||
self.agent.session_start = now
|
||||
# Redirect the JSON session log to the new branch session file so
|
||||
# messages written after branching land in the correct file.
|
||||
if hasattr(self.agent, "session_log_file") and hasattr(self.agent, "logs_dir"):
|
||||
self.agent.session_log_file = (
|
||||
self.agent.logs_dir / f"session_{new_session_id}.json"
|
||||
)
|
||||
self.agent.reset_session_state()
|
||||
if hasattr(self.agent, "_last_flushed_db_idx"):
|
||||
self.agent._last_flushed_db_idx = len(self.conversation_history)
|
||||
|
|
@ -8411,10 +8406,10 @@ class HermesCLI:
|
|||
|
||||
@staticmethod
|
||||
def _try_launch_chrome_debug(port: int, system: str) -> bool:
|
||||
"""Try to launch Chrome/Chromium with remote debugging enabled.
|
||||
"""Try to launch a Chromium-family browser with remote debugging enabled.
|
||||
|
||||
Uses a dedicated user-data-dir so the debug instance doesn't conflict
|
||||
with an already-running Chrome using the default profile.
|
||||
with an already-running browser using the default profile.
|
||||
|
||||
Returns True if a launch command was executed (doesn't guarantee success).
|
||||
"""
|
||||
|
|
@ -8459,7 +8454,7 @@ class HermesCLI:
|
|||
)
|
||||
|
||||
def _handle_browser_command(self, cmd: str):
|
||||
"""Handle /browser connect|disconnect|status — manage live Chrome CDP connection."""
|
||||
"""Handle /browser connect|disconnect|status — manage live Chromium-family CDP connection."""
|
||||
import platform as _plat
|
||||
|
||||
parts = cmd.strip().split(None, 1)
|
||||
|
|
@ -8513,56 +8508,42 @@ class HermesCLI:
|
|||
|
||||
print()
|
||||
|
||||
# Check if Chrome is already listening on the debug port
|
||||
import socket
|
||||
_already_open = False
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(1)
|
||||
s.connect((_host, _port))
|
||||
s.close()
|
||||
_already_open = True
|
||||
except (OSError, socket.timeout):
|
||||
pass
|
||||
# Check if a Chromium-family browser is already serving CDP on the debug port
|
||||
_already_open = is_browser_debug_ready(cdp_url, timeout=1.0)
|
||||
|
||||
if _already_open:
|
||||
print(f" ✓ Chrome is already listening on port {_port}")
|
||||
print(f" ✓ Chromium-family browser is already listening on port {_port}")
|
||||
elif cdp_url == _DEFAULT_CDP:
|
||||
# Try to auto-launch Chrome with remote debugging
|
||||
print(" Chrome isn't running with remote debugging — attempting to launch...")
|
||||
# Try to auto-launch a Chromium-family browser with remote debugging
|
||||
print(" Chromium-family browser isn't running with remote debugging — attempting to launch...")
|
||||
_launched = self._try_launch_chrome_debug(_port, _plat.system())
|
||||
if _launched:
|
||||
# Wait for the port to come up
|
||||
# Wait for the DevTools discovery endpoint to come up
|
||||
for _wait in range(10):
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(1)
|
||||
s.connect((_host, _port))
|
||||
s.close()
|
||||
if is_browser_debug_ready(cdp_url, timeout=1.0):
|
||||
_already_open = True
|
||||
break
|
||||
except (OSError, socket.timeout):
|
||||
time.sleep(0.5)
|
||||
time.sleep(0.5)
|
||||
if _already_open:
|
||||
print(f" ✓ Chrome launched and listening on port {_port}")
|
||||
print(f" ✓ Chromium-family browser launched and listening on port {_port}")
|
||||
else:
|
||||
print(f" ⚠ Chrome launched but port {_port} isn't responding yet")
|
||||
print(f" ⚠ Browser launched but port {_port} isn't responding yet")
|
||||
print(" Try again in a few seconds — the debug instance may still be starting")
|
||||
else:
|
||||
print(" ⚠ Could not auto-launch Chrome")
|
||||
print(" ⚠ Could not auto-launch a Chromium-family browser")
|
||||
sys_name = _plat.system()
|
||||
chrome_cmd = manual_chrome_debug_command(_port, sys_name)
|
||||
if chrome_cmd:
|
||||
print(f" Launch Chrome manually:")
|
||||
print(f" Launch a Chromium-family browser manually:")
|
||||
print(f" {chrome_cmd}")
|
||||
else:
|
||||
print(" No Chrome/Chromium executable found in this environment")
|
||||
print(" No supported Chromium-family browser executable found in this environment")
|
||||
else:
|
||||
print(f" ⚠ Port {_port} is not reachable at {cdp_url}")
|
||||
|
||||
if not _already_open:
|
||||
print()
|
||||
print("Browser not connected — start Chrome with remote debugging and retry /browser connect")
|
||||
print("Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect")
|
||||
print()
|
||||
return
|
||||
|
||||
|
|
@ -8575,20 +8556,23 @@ class HermesCLI:
|
|||
except Exception:
|
||||
pass
|
||||
print()
|
||||
print("🌐 Browser connected to live Chrome via CDP")
|
||||
print("🌐 Browser connected to live Chromium-family browser via CDP")
|
||||
print(f" Endpoint: {cdp_url}")
|
||||
print()
|
||||
|
||||
# Inject context message so the model knows
|
||||
# Inject context message so the model knows this slash command
|
||||
# intentionally makes the dev/debug CDP browser available for use.
|
||||
if hasattr(self, '_pending_input'):
|
||||
self._pending_input.put(
|
||||
"[System note: The user has connected your browser tools to their live Chrome browser "
|
||||
"via Chrome DevTools Protocol. Your browser_navigate, browser_snapshot, browser_click, "
|
||||
"and other browser tools now control their real browser — including any pages they have "
|
||||
"open, logged-in sessions, and cookies. They likely opened specific sites or logged into "
|
||||
"services before connecting. Please await their instruction before attempting to operate "
|
||||
"the browser. When you do act, be mindful that your actions affect their real browser — "
|
||||
"don't close tabs or navigate away from pages without asking.]"
|
||||
"[System note: The user invoked /browser connect and connected your browser tools to "
|
||||
"a Chromium-family dev/debug browser via Chrome DevTools Protocol. "
|
||||
"Your browser_navigate, browser_snapshot, browser_click, and other browser tools now "
|
||||
"control that CDP browser. The command itself is a signal that using browser tools for "
|
||||
"their current browser-related request is expected; do not wait for separate permission "
|
||||
"just because CDP is connected. This is typically a Hermes-managed isolated debug "
|
||||
"profile, not the user's main everyday browser. It is still user-visible and may contain "
|
||||
"pages, logged-in sessions, or cookies in that debug profile, so avoid destructive actions, "
|
||||
"closing tabs, or navigating away unless the user's task calls for it.]"
|
||||
)
|
||||
|
||||
elif sub == "disconnect":
|
||||
|
|
@ -8601,24 +8585,24 @@ class HermesCLI:
|
|||
except Exception:
|
||||
pass
|
||||
print()
|
||||
print("🌐 Browser disconnected from live Chrome")
|
||||
print("🌐 Browser disconnected from live Chromium-family browser")
|
||||
print(" Browser tools reverted to default mode (local headless or cloud provider)")
|
||||
print()
|
||||
|
||||
if hasattr(self, '_pending_input'):
|
||||
self._pending_input.put(
|
||||
"[System note: The user has disconnected the browser tools from their live Chrome. "
|
||||
"[System note: The user has disconnected the browser tools from their live Chromium-family browser. "
|
||||
"Browser tools are back to default mode (headless local browser or cloud provider).]"
|
||||
)
|
||||
else:
|
||||
print()
|
||||
print("Browser is not connected to live Chrome (already using default mode)")
|
||||
print("Browser is not connected to a live Chromium-family browser (already using default mode)")
|
||||
print()
|
||||
|
||||
elif sub == "status":
|
||||
print()
|
||||
if current:
|
||||
print("🌐 Browser: connected to live Chrome via CDP")
|
||||
print("🌐 Browser: connected to live Chromium-family browser via CDP")
|
||||
print(f" Endpoint: {current}")
|
||||
|
||||
_port = 9222
|
||||
|
|
@ -8634,7 +8618,7 @@ class HermesCLI:
|
|||
s.close()
|
||||
print(" Status: ✓ reachable")
|
||||
except (OSError, Exception):
|
||||
print(" Status: ⚠ not reachable (Chrome may not be running)")
|
||||
print(" Status: ⚠ not reachable (browser may not be running)")
|
||||
else:
|
||||
try:
|
||||
from tools.browser_tool import _get_cloud_provider
|
||||
|
|
@ -8654,13 +8638,13 @@ class HermesCLI:
|
|||
if engine == "lightpanda":
|
||||
print("🌐 Browser: local Lightpanda (agent-browser --engine lightpanda)")
|
||||
print(" ⚡ Lightpanda: faster navigation, no screenshot support")
|
||||
print(" Automatic Chrome fallback for screenshots and failed commands")
|
||||
print(" Automatic Chromium fallback for screenshots and failed commands")
|
||||
elif engine == "chrome":
|
||||
print("🌐 Browser: local headless Chrome (agent-browser --engine chrome)")
|
||||
print("🌐 Browser: local headless Chromium (agent-browser --engine chrome)")
|
||||
else:
|
||||
print("🌐 Browser: local headless Chromium (agent-browser)")
|
||||
print()
|
||||
print(" /browser connect — connect to your live Chrome")
|
||||
print(" /browser connect — connect to your live Chromium-family browser")
|
||||
print(" /browser disconnect — revert to default")
|
||||
print()
|
||||
|
||||
|
|
@ -8668,7 +8652,7 @@ class HermesCLI:
|
|||
print()
|
||||
print("Usage: /browser connect|disconnect|status")
|
||||
print()
|
||||
print(" connect Connect browser tools to your live Chrome session")
|
||||
print(" connect Connect browser tools to your live Chromium-family browser session")
|
||||
print(" disconnect Revert to default browser backend")
|
||||
print(" status Show current browser mode")
|
||||
print()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Shared helpers for attaching Hermes to a local Chrome CDP port."""
|
||||
"""Shared helpers for attaching Hermes to a local Chromium-family CDP port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -21,23 +21,53 @@ _DARWIN_APPS = (
|
|||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
)
|
||||
|
||||
_WINDOWS_INSTALL_PARTS = (
|
||||
("Google", "Chrome", "Application", "chrome.exe"),
|
||||
("Chromium", "Application", "chrome.exe"),
|
||||
("Chromium", "Application", "chromium.exe"),
|
||||
("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
("Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
_WINDOWS_BROWSER_GROUPS = (
|
||||
(("chrome.exe", "chrome"), (("Google", "Chrome", "Application", "chrome.exe"),)),
|
||||
(
|
||||
("chromium.exe", "chromium"),
|
||||
(("Chromium", "Application", "chrome.exe"), ("Chromium", "Application", "chromium.exe")),
|
||||
),
|
||||
(("brave.exe", "brave"), (("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),)),
|
||||
(("msedge.exe", "msedge"), (("Microsoft", "Edge", "Application", "msedge.exe"),)),
|
||||
)
|
||||
|
||||
_LINUX_BIN_NAMES = (
|
||||
"google-chrome", "google-chrome-stable", "chromium-browser",
|
||||
"chromium", "brave-browser", "microsoft-edge",
|
||||
_WINDOWS_BIN_NAMES = tuple(name for names, _ in _WINDOWS_BROWSER_GROUPS for name in names)
|
||||
_WINDOWS_INSTALL_PARTS = tuple(parts for _, group in _WINDOWS_BROWSER_GROUPS for parts in group)
|
||||
|
||||
_LINUX_BROWSER_GROUPS = (
|
||||
(
|
||||
("google-chrome", "google-chrome-stable"),
|
||||
("/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"),
|
||||
),
|
||||
(
|
||||
("chromium-browser", "chromium"),
|
||||
("/usr/bin/chromium-browser", "/usr/bin/chromium"),
|
||||
),
|
||||
(
|
||||
("brave-browser", "brave-browser-stable", "brave"),
|
||||
(
|
||||
"/usr/bin/brave-browser",
|
||||
"/usr/bin/brave-browser-stable",
|
||||
"/usr/bin/brave",
|
||||
"/snap/bin/brave",
|
||||
"/opt/brave.com/brave/brave-browser",
|
||||
"/opt/brave.com/brave/brave",
|
||||
"/opt/brave-bin/brave",
|
||||
),
|
||||
),
|
||||
(
|
||||
("microsoft-edge", "microsoft-edge-stable", "msedge"),
|
||||
(
|
||||
"/usr/bin/microsoft-edge",
|
||||
"/usr/bin/microsoft-edge-stable",
|
||||
"/opt/microsoft/msedge/microsoft-edge",
|
||||
"/opt/microsoft/msedge/msedge",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_WINDOWS_BIN_NAMES = (
|
||||
"chrome.exe", "msedge.exe", "brave.exe", "chromium.exe",
|
||||
"chrome", "msedge", "brave", "chromium",
|
||||
)
|
||||
_LINUX_BIN_NAMES = tuple(name for names, _ in _LINUX_BROWSER_GROUPS for name in names)
|
||||
_LINUX_INSTALL_PATHS = tuple(path for _, paths in _LINUX_BROWSER_GROUPS for path in paths)
|
||||
|
||||
|
||||
def get_chrome_debug_candidates(system: str) -> list[str]:
|
||||
|
|
@ -53,10 +83,14 @@ def get_chrome_debug_candidates(system: str) -> list[str]:
|
|||
candidates.append(path)
|
||||
seen.add(normalized)
|
||||
|
||||
def add_install_paths(bases: tuple[str | None, ...]) -> None:
|
||||
for base in filter(None, bases):
|
||||
for parts in _WINDOWS_INSTALL_PARTS:
|
||||
add(os.path.join(base, *parts))
|
||||
def add_windows_install_paths(
|
||||
bases: tuple[str | None, ...],
|
||||
install_groups: tuple[tuple[tuple[str, ...], tuple[tuple[str, ...], ...]], ...],
|
||||
) -> None:
|
||||
for _, group in install_groups:
|
||||
for base in filter(None, bases):
|
||||
for parts in group:
|
||||
add(os.path.join(base, *parts))
|
||||
|
||||
if system == "Darwin":
|
||||
for app in _DARWIN_APPS:
|
||||
|
|
@ -64,18 +98,25 @@ def get_chrome_debug_candidates(system: str) -> list[str]:
|
|||
return candidates
|
||||
|
||||
if system == "Windows":
|
||||
for name in _WINDOWS_BIN_NAMES:
|
||||
add(shutil.which(name))
|
||||
add_install_paths((
|
||||
install_bases = (
|
||||
os.environ.get("ProgramFiles"),
|
||||
os.environ.get("ProgramFiles(x86)"),
|
||||
os.environ.get("LOCALAPPDATA"),
|
||||
))
|
||||
)
|
||||
for names, install_parts in _WINDOWS_BROWSER_GROUPS:
|
||||
for name in names:
|
||||
add(shutil.which(name))
|
||||
for base in filter(None, install_bases):
|
||||
for parts in install_parts:
|
||||
add(os.path.join(base, *parts))
|
||||
return candidates
|
||||
|
||||
for name in _LINUX_BIN_NAMES:
|
||||
add(shutil.which(name))
|
||||
add_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"))
|
||||
for names, paths in _LINUX_BROWSER_GROUPS:
|
||||
for name in names:
|
||||
add(shutil.which(name))
|
||||
for path in paths:
|
||||
add(path)
|
||||
add_windows_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"), _WINDOWS_BROWSER_GROUPS)
|
||||
return candidates
|
||||
|
||||
|
||||
|
|
@ -92,6 +133,42 @@ def _chrome_debug_args(port: int) -> list[str]:
|
|||
]
|
||||
|
||||
|
||||
def is_browser_debug_ready(url: str, timeout: float = 1.0) -> bool:
|
||||
"""Return True when ``url`` exposes a reachable Chrome DevTools endpoint."""
|
||||
import socket
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url if "://" in url else f"http://{url}")
|
||||
try:
|
||||
port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if parsed.scheme in {"ws", "wss"} and parsed.path.startswith("/devtools/browser/"):
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
try:
|
||||
with socket.create_connection((parsed.hostname, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
scheme = {"ws": "http", "wss": "https"}.get(parsed.scheme, parsed.scheme)
|
||||
if scheme not in {"http", "https"} or not parsed.netloc:
|
||||
return False
|
||||
|
||||
root = f"{scheme}://{parsed.netloc}".rstrip("/")
|
||||
for probe in (f"{root}/json/version", f"{root}/json"):
|
||||
try:
|
||||
with urllib.request.urlopen(probe, timeout=timeout) as resp:
|
||||
if 200 <= getattr(resp, "status", 200) < 300:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def manual_chrome_debug_command(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> str | None:
|
||||
system = system or platform.system()
|
||||
candidates = get_chrome_debug_candidates(system)
|
||||
|
|
@ -126,13 +203,15 @@ def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str |
|
|||
return False
|
||||
|
||||
os.makedirs(chrome_debug_data_dir(), exist_ok=True)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[candidates[0], *_chrome_debug_args(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**_detach_kwargs(system),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
for candidate in candidates:
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[candidate, *_chrome_debug_args(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**_detach_kwargs(system),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
aliases=("reload_mcp",)),
|
||||
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
|
||||
"Tools & Skills", aliases=("reload_skills",)),
|
||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||
CommandDef("browser", "Connect browser tools to your live Chromium-family browser via CDP", "Tools & Skills",
|
||||
cli_only=True, args_hint="[connect|disconnect|status]",
|
||||
subcommands=("connect", "disconnect", "status")),
|
||||
CommandDef("plugins", "List installed plugins and their status",
|
||||
|
|
|
|||
|
|
@ -1646,6 +1646,15 @@ DEFAULT_CONFIG = {
|
|||
# the sweep on every CLI invocation). Tracked via state_meta in
|
||||
# state.db itself, so it's shared across all processes.
|
||||
"min_interval_hours": 24,
|
||||
# Legacy per-session JSON snapshot writer. When true, the agent
|
||||
# rewrites ``~/.hermes/sessions/session_{sid}.json`` on every turn
|
||||
# boundary with the full message list. state.db is canonical and
|
||||
# has every field the snapshot stored (plus per-message timestamps
|
||||
# and token counts), so this is off by default — the snapshots had
|
||||
# no consumer outside their own overwrite guard and accumulated
|
||||
# GBs of disk on heavy users. Opt in only if you have an external
|
||||
# tool that consumes the JSON files directly.
|
||||
"write_json_snapshots": False,
|
||||
},
|
||||
|
||||
# Contextual first-touch onboarding hints (see agent/onboarding.py).
|
||||
|
|
|
|||
|
|
@ -777,7 +777,33 @@ def run_doctor(args):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
_section("xAI Model Retirement (May 15, 2026)")
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.xai_retirement import (
|
||||
MIGRATION_GUIDE_URL,
|
||||
find_retired_xai_refs,
|
||||
format_issue,
|
||||
)
|
||||
|
||||
_xai_cfg = load_config()
|
||||
retired_refs = find_retired_xai_refs(_xai_cfg)
|
||||
if not retired_refs:
|
||||
check_ok("No retired xAI models in config")
|
||||
else:
|
||||
for ref in retired_refs:
|
||||
check_warn(format_issue(ref))
|
||||
check_info(f"Migration guide: {MIGRATION_GUIDE_URL}")
|
||||
manual_issues.append(
|
||||
f"Update {len(retired_refs)} retired xAI model reference(s) "
|
||||
f"in config.yaml — see {MIGRATION_GUIDE_URL}"
|
||||
)
|
||||
except Exception as _xai_check_err:
|
||||
check_warn("xAI retirement check skipped", f"({_xai_check_err})")
|
||||
|
||||
_section("Auth Providers")
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
get_nous_auth_status,
|
||||
|
|
|
|||
|
|
@ -270,11 +270,20 @@ import time as _time
|
|||
from datetime import datetime
|
||||
|
||||
from hermes_cli import __version__, __release_date__
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL, OPENROUTER_BASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_termux_startup_environment(env: dict[str, str] | None = None) -> bool:
|
||||
"""Import-safe Termux check for cold-start-sensitive CLI paths."""
|
||||
check = env or os.environ
|
||||
prefix = str(check.get("PREFIX", ""))
|
||||
return bool(
|
||||
check.get("TERMUX_VERSION")
|
||||
or "com.termux/files/usr" in prefix
|
||||
or prefix.startswith("/data/data/com.termux/")
|
||||
)
|
||||
|
||||
|
||||
def _relative_time(ts) -> str:
|
||||
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
|
||||
if not ts:
|
||||
|
|
@ -976,6 +985,72 @@ def _tui_need_npm_install(root: Path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
_TUI_BUILD_INPUT_DIRS = (
|
||||
"src",
|
||||
"packages/hermes-ink/src",
|
||||
)
|
||||
|
||||
_TUI_BUILD_INPUT_FILES = (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.build.json",
|
||||
"babel.compiler.config.cjs",
|
||||
"scripts/build.mjs",
|
||||
"packages/hermes-ink/package.json",
|
||||
"packages/hermes-ink/package-lock.json",
|
||||
"packages/hermes-ink/index.js",
|
||||
"packages/hermes-ink/text-input.js",
|
||||
)
|
||||
|
||||
_TUI_BUILD_INPUT_SUFFIXES = frozenset(
|
||||
{".cjs", ".js", ".jsx", ".json", ".mjs", ".ts", ".tsx"}
|
||||
)
|
||||
|
||||
|
||||
def _iter_tui_build_inputs(root: Path):
|
||||
"""Yield source/config files that affect ``ui-tui/dist/entry.js``."""
|
||||
for rel in _TUI_BUILD_INPUT_FILES:
|
||||
path = root / rel
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
for rel in _TUI_BUILD_INPUT_DIRS:
|
||||
base = root / rel
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for path in base.rglob("*"):
|
||||
if path.is_file() and path.suffix in _TUI_BUILD_INPUT_SUFFIXES:
|
||||
yield path
|
||||
|
||||
|
||||
def _tui_need_rebuild(root: Path) -> bool:
|
||||
"""True when ``dist/entry.js`` is missing or older than TUI inputs.
|
||||
|
||||
The TUI bundle is self-contained. Rebuilding it on every launch adds a
|
||||
visible cold-start tax on slow Termux CPUs, while a simple mtime freshness
|
||||
check still rebuilds immediately after source updates, dependency updates,
|
||||
or local edits. Set ``HERMES_TUI_FORCE_BUILD=1`` to force the old behaviour.
|
||||
"""
|
||||
force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower()
|
||||
if force in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
|
||||
entry = root / "dist" / "entry.js"
|
||||
try:
|
||||
output_mtime = entry.stat().st_mtime
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
for path in _iter_tui_build_inputs(root):
|
||||
try:
|
||||
if path.stat().st_mtime > output_mtime:
|
||||
return True
|
||||
except OSError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_tui_node() -> None:
|
||||
"""Make sure `node` + `npm` are on PATH for the TUI.
|
||||
|
||||
|
|
@ -1090,6 +1165,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
|||
|
||||
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx.
|
||||
did_install = False
|
||||
if _tui_need_npm_install(tui_dir):
|
||||
npm = _node_bin("npm")
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
|
|
@ -1109,6 +1185,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
|||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
did_install = True
|
||||
|
||||
if tui_dev:
|
||||
# Keep the local @hermes/ink package exports in sync with source.
|
||||
|
|
@ -1137,21 +1214,28 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
|||
return [str(tsx), "src/entry.tsx"], tui_dir
|
||||
return [npm, "start"], tui_dir
|
||||
|
||||
# Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs.
|
||||
npm = _node_bin("npm")
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("TUI build failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
# Desktop/dev launches retain the historical "always rebuild" behaviour.
|
||||
# Termux cold starts use the freshness check because esbuild startup is
|
||||
# expensive on old mobile CPUs.
|
||||
should_build = True
|
||||
if _is_termux_startup_environment():
|
||||
should_build = did_install or _tui_need_rebuild(tui_dir)
|
||||
|
||||
if should_build:
|
||||
npm = _node_bin("npm")
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("TUI build failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
|
||||
node = _node_bin("node")
|
||||
return [node, str(tui_dir / "dist" / "entry.js")], tui_dir
|
||||
|
|
@ -1413,6 +1497,29 @@ def cmd_chat(args):
|
|||
# If resolution fails, keep the original value — _init_agent will
|
||||
# report "Session not found" with the original input
|
||||
|
||||
# xAI retirement warning — one-shot, non-blocking, never fails startup
|
||||
try:
|
||||
from hermes_cli.xai_retirement import (
|
||||
MIGRATION_GUIDE_URL,
|
||||
RETIREMENT_DATE,
|
||||
find_retired_xai_refs,
|
||||
format_issue,
|
||||
)
|
||||
from hermes_cli.config import load_config as _load_config_for_xai_check
|
||||
|
||||
_retired_xai_refs = find_retired_xai_refs(_load_config_for_xai_check())
|
||||
if _retired_xai_refs:
|
||||
sys.stderr.write(
|
||||
f"\033[33m⚠ xAI retires {len(_retired_xai_refs)} model(s) "
|
||||
f"in your config on {RETIREMENT_DATE}:\033[0m\n"
|
||||
)
|
||||
for _ref in _retired_xai_refs:
|
||||
sys.stderr.write(f" \033[33m⚠\033[0m {format_issue(_ref)}\n")
|
||||
sys.stderr.write(f" \033[2mMigration guide: {MIGRATION_GUIDE_URL}\033[0m\n")
|
||||
sys.stderr.write(" \033[2mRun 'hermes doctor' for details.\033[0m\n\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First-run guard: check if any provider is configured before launching
|
||||
if not _has_any_provider_configured():
|
||||
print()
|
||||
|
|
@ -2592,6 +2699,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
|||
|
||||
def _model_flow_openrouter(config, current_model=""):
|
||||
"""OpenRouter provider: ensure API key, then pick model."""
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.auth import (
|
||||
ProviderConfig,
|
||||
_prompt_model_selection,
|
||||
|
|
@ -2652,6 +2760,7 @@ def _model_flow_openrouter(config, current_model=""):
|
|||
|
||||
def _model_flow_ai_gateway(config, current_model=""):
|
||||
"""Vercel AI Gateway provider: ensure API key, then pick model with pricing."""
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
_prompt_model_selection,
|
||||
|
|
@ -4245,8 +4354,11 @@ def _model_flow_named_custom(config, provider_info):
|
|||
print(f" Provider: {name} ({base_url})")
|
||||
|
||||
|
||||
# Curated model lists for direct API-key providers — single source in models.py
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
# Keep the historical eager model catalog import on desktop/CI. Termux defers
|
||||
# it to the model-selection handlers so plain `hermes --tui` does not pay for
|
||||
# requests/models.dev catalog imports before the Node TUI starts.
|
||||
if not _is_termux_startup_environment():
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
|
||||
def _current_reasoning_effort(config) -> str:
|
||||
|
|
@ -4363,6 +4475,7 @@ def _model_flow_copilot(config, current_model=""):
|
|||
)
|
||||
from hermes_cli.config import save_env_value, load_config, save_config
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
fetch_api_models,
|
||||
fetch_github_model_catalog,
|
||||
github_model_reasoning_efforts,
|
||||
|
|
@ -4555,6 +4668,7 @@ def _model_flow_copilot_acp(config, current_model=""):
|
|||
resolve_external_process_provider_credentials,
|
||||
)
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
fetch_github_model_catalog,
|
||||
normalize_copilot_model_id,
|
||||
)
|
||||
|
|
@ -4758,6 +4872,7 @@ def _model_flow_kimi(config, current_model=""):
|
|||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
provider_id = "kimi-coding"
|
||||
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||
|
|
@ -4868,7 +4983,7 @@ def _model_flow_stepfun(config, current_model=""):
|
|||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from hermes_cli.models import fetch_api_models
|
||||
from hermes_cli.models import _PROVIDER_MODELS, fetch_api_models
|
||||
|
||||
provider_id = "stepfun"
|
||||
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||
|
|
@ -5248,6 +5363,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
|||
save_config,
|
||||
)
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
fetch_api_models,
|
||||
opencode_model_api_mode,
|
||||
normalize_opencode_model_id,
|
||||
|
|
@ -7669,9 +7785,7 @@ def _install_python_dependencies_with_optional_fallback(
|
|||
|
||||
|
||||
def _is_termux_env(env: dict[str, str] | None = None) -> bool:
|
||||
check = env or os.environ
|
||||
prefix = str(check.get("PREFIX", ""))
|
||||
return "com.termux" in prefix or prefix.startswith("/data/data/com.termux/")
|
||||
return _is_termux_startup_environment(env)
|
||||
|
||||
|
||||
def _is_android_python() -> bool:
|
||||
|
|
@ -10348,7 +10462,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
|||
"computer-use",
|
||||
"config", "cron", "curator", "dashboard", "debug", "doctor",
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory",
|
||||
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
|
||||
"model", "pairing", "plugins", "postinstall", "profile", "proxy",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
|
|
@ -10442,6 +10556,47 @@ def _plugin_cli_discovery_needed() -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _try_termux_fast_tui_launch() -> bool:
|
||||
"""Launch obvious Termux TUI invocations before building every subparser.
|
||||
|
||||
`hermes --tui` is the hot path on phones. The full parser setup imports
|
||||
command modules for model, fallback, migrate, kanban, bundles, plugins,
|
||||
etc. even though the TUI immediately execs Node. On Termux only, parse the
|
||||
lightweight top-level/chat parser and hand off to ``cmd_chat`` when the
|
||||
invocation is unambiguously the built-in TUI/chat path.
|
||||
"""
|
||||
if not _is_termux_startup_environment():
|
||||
return False
|
||||
|
||||
if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]:
|
||||
return False
|
||||
|
||||
wants_tui = os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]
|
||||
if not wants_tui:
|
||||
return False
|
||||
|
||||
first = _first_positional_argv()
|
||||
if first not in {None, "chat"}:
|
||||
return False
|
||||
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
args = parser.parse_args(_coalesce_session_name_args(sys.argv[1:]))
|
||||
|
||||
# Preserve top-level behaviours whose semantics are not "launch chat/TUI".
|
||||
if getattr(args, "version", False) or getattr(args, "oneshot", None):
|
||||
return False
|
||||
if getattr(args, "command", None) not in {None, "chat"}:
|
||||
return False
|
||||
if not (getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"):
|
||||
return False
|
||||
|
||||
cmd_chat(args)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
|
||||
|
|
@ -10459,6 +10614,9 @@ def main():
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if _try_termux_fast_tui_launch():
|
||||
return
|
||||
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, subparsers, chat_parser = build_top_level_parser()
|
||||
|
|
@ -10555,6 +10713,44 @@ def main():
|
|||
)
|
||||
fallback_parser.set_defaults(func=cmd_fallback)
|
||||
|
||||
# =========================================================================
|
||||
# migrate command
|
||||
# =========================================================================
|
||||
from hermes_cli.migrate import cmd_migrate, cmd_migrate_xai
|
||||
|
||||
migrate_parser = subparsers.add_parser(
|
||||
"migrate",
|
||||
help="Migrate configuration for retired models or deprecated settings",
|
||||
description=(
|
||||
"Diagnose and (optionally) rewrite the active config.yaml to "
|
||||
"replace references to retired models or deprecated settings."
|
||||
),
|
||||
)
|
||||
migrate_subparsers = migrate_parser.add_subparsers(dest="migrate_type")
|
||||
|
||||
migrate_xai = migrate_subparsers.add_parser(
|
||||
"xai",
|
||||
help="Migrate xAI models scheduled for retirement on May 15, 2026",
|
||||
description=(
|
||||
"Scan config.yaml for references to xAI models retiring on "
|
||||
"May 15, 2026 and, with --apply, rewrite them in-place to the "
|
||||
"official replacements per the xAI migration guide. The original "
|
||||
"config.yaml is backed up before any rewrite."
|
||||
),
|
||||
)
|
||||
migrate_xai.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Rewrite config.yaml in-place (default: dry-run, no writes)",
|
||||
)
|
||||
migrate_xai.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
help="Skip the timestamped backup of config.yaml when applying",
|
||||
)
|
||||
migrate_xai.set_defaults(func=cmd_migrate_xai)
|
||||
migrate_parser.set_defaults(func=cmd_migrate)
|
||||
|
||||
# =========================================================================
|
||||
# gateway command
|
||||
# =========================================================================
|
||||
|
|
|
|||
115
hermes_cli/migrate.py
Normal file
115
hermes_cli/migrate.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""CLI handlers for ``hermes migrate ...``.
|
||||
|
||||
Currently exposes only ``hermes migrate xai`` — diagnoses and (with --apply)
|
||||
rewrites references to xAI models retired on May 15, 2026.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
|
||||
def cmd_migrate(args: Any) -> int:
|
||||
"""Dispatcher for ``hermes migrate <subtype>``."""
|
||||
sub = getattr(args, "migrate_type", None)
|
||||
if sub == "xai":
|
||||
return cmd_migrate_xai(args)
|
||||
|
||||
print("usage: hermes migrate xai [--apply] [--no-backup]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def cmd_migrate_xai(args: Any) -> int:
|
||||
"""Run xAI May-15 model migration in dry-run or apply mode."""
|
||||
from hermes_cli.xai_retirement import (
|
||||
MIGRATION_GUIDE_URL,
|
||||
RETIREMENT_DATE,
|
||||
apply_migration,
|
||||
find_retired_xai_refs,
|
||||
format_issue,
|
||||
)
|
||||
|
||||
apply = bool(getattr(args, "apply", False))
|
||||
no_backup = bool(getattr(args, "no_backup", False))
|
||||
|
||||
config = load_config()
|
||||
issues = find_retired_xai_refs(config)
|
||||
|
||||
print()
|
||||
print(color(
|
||||
f"◆ xAI Model Retirement Migration ({RETIREMENT_DATE})",
|
||||
Colors.CYAN, Colors.BOLD,
|
||||
))
|
||||
print()
|
||||
|
||||
if not issues:
|
||||
print(f" {color('✓', Colors.GREEN)} No retired xAI models in config — nothing to migrate.")
|
||||
return 0
|
||||
|
||||
print(f" Found {len(issues)} retired xAI model reference(s):")
|
||||
print()
|
||||
for issue in issues:
|
||||
print(f" {color('⚠', Colors.YELLOW)} {format_issue(issue)}")
|
||||
print()
|
||||
print(f" {color('→', Colors.CYAN)} Migration guide: {MIGRATION_GUIDE_URL}")
|
||||
print()
|
||||
|
||||
config_path = _resolve_config_path()
|
||||
|
||||
if not apply:
|
||||
print(color("Dry-run mode — no changes written.", Colors.DIM))
|
||||
print(color(
|
||||
"Re-run with `hermes migrate xai --apply` to rewrite "
|
||||
f"{config_path} in-place (backup created automatically).",
|
||||
Colors.DIM,
|
||||
))
|
||||
return 0
|
||||
|
||||
if not config_path or not config_path.exists():
|
||||
print(
|
||||
f" {color('✗', Colors.RED)} Could not locate config.yaml "
|
||||
f"(looked at: {config_path})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
result = apply_migration(
|
||||
config_path=config_path,
|
||||
issues=issues,
|
||||
backup=not no_backup,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f" {color('✗', Colors.RED)} Migration failed: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not result.config_changed:
|
||||
print(f" {color('⚠', Colors.YELLOW)} No changes written.")
|
||||
return 0
|
||||
|
||||
if result.backup_path is not None:
|
||||
print(f" {color('✓', Colors.GREEN)} Backup: {result.backup_path}")
|
||||
print(
|
||||
f" {color('✓', Colors.GREEN)} Updated {len(result.issues_resolved)} "
|
||||
f"slot(s) in {result.file_path}"
|
||||
)
|
||||
print()
|
||||
print(color(
|
||||
"Run `hermes doctor` to confirm no retired xAI models remain.",
|
||||
Colors.DIM,
|
||||
))
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_config_path() -> Path:
|
||||
"""Best-effort: locate the active config.yaml on disk."""
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
return get_hermes_home() / "config.yaml"
|
||||
|
|
@ -31,7 +31,7 @@ TIPS = [
|
|||
"/skin changes the CLI theme — try ares, mono, slate, poseidon, or charizard.",
|
||||
"/statusbar toggles a persistent bar showing model, tokens, context fill %, cost, and duration.",
|
||||
"/tools disable browser temporarily removes browser tools for the current session.",
|
||||
"/browser connect attaches browser tools to your running Chrome instance via CDP.",
|
||||
"/browser connect attaches browser tools to your running Chromium-family browser via CDP.",
|
||||
"/plugins lists installed plugins and their status.",
|
||||
"/cron manages scheduled tasks — set up recurring prompts with delivery to any platform.",
|
||||
"/reload-mcp hot-reloads MCP server configuration without restarting.",
|
||||
|
|
@ -300,7 +300,7 @@ TIPS = [
|
|||
"Container mode: place .container-mode in HERMES_HOME and the host CLI auto-execs into the container.",
|
||||
"Ctrl+C has 5 priority tiers: cancel recording → cancel prompts → cancel picker → interrupt agent → exit.",
|
||||
"Every interrupt during an agent run is logged to ~/.hermes/interrupt_debug.log with timestamps.",
|
||||
"BROWSER_CDP_URL connects browser tools to any running Chrome — accepts WebSocket, HTTP, or host:port.",
|
||||
"BROWSER_CDP_URL connects browser tools to any running Chromium-family browser — accepts WebSocket, HTTP, or host:port.",
|
||||
"BROWSERBASE_ADVANCED_STEALTH=true enables advanced anti-detection with custom Chromium (Scale Plan).",
|
||||
"The CLI auto-switches to compact mode in terminals narrower than 80 columns.",
|
||||
"Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).",
|
||||
|
|
|
|||
253
hermes_cli/xai_retirement.py
Normal file
253
hermes_cli/xai_retirement.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"""Detect xAI models retired on May 15, 2026.
|
||||
|
||||
Source: https://docs.x.ai/developers/migration/may-15-retirement
|
||||
|
||||
Pure logic: walks a Hermes config dict, returns issues for any reference
|
||||
to a retired xAI model. No I/O, no CLI dependencies — testable in isolation
|
||||
and reusable from both `hermes doctor` and a future `hermes migrate xai`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
MIGRATION_GUIDE_URL = "https://docs.x.ai/developers/migration/may-15-retirement"
|
||||
RETIREMENT_DATE = "May 15, 2026"
|
||||
|
||||
|
||||
# Official mapping per xAI migration guide.
|
||||
# Some entries set ``reasoning_effort`` because non-reasoning variants don't
|
||||
# have a one-to-one replacement: ``grok-4.3`` reasons by default, so emulating
|
||||
# ``*-non-reasoning`` behavior on it requires ``reasoning_effort="none"``.
|
||||
_RETIRED_MODELS: Dict[str, Dict[str, Optional[str]]] = {
|
||||
"grok-4-0709": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-4-fast-reasoning": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-4-fast-non-reasoning": {"replacement": "grok-4.3", "reasoning_effort": "none", "note": None},
|
||||
"grok-4-1-fast-reasoning": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-4-1-fast-non-reasoning": {"replacement": "grok-4.3", "reasoning_effort": "none", "note": None},
|
||||
"grok-code-fast-1": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-3": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-imagine-image-pro": {"replacement": "grok-imagine-image-quality", "reasoning_effort": None, "note": None},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetirementIssue:
|
||||
"""A reference to a retired xAI model found in a Hermes config."""
|
||||
|
||||
config_path: str # e.g. "principal.model" or "auxiliary.vision.model"
|
||||
current_model: str # exact value found in config (preserves casing/prefix)
|
||||
replacement: str # recommended xAI replacement
|
||||
reasoning_effort: Optional[str] = None # set if non-reasoning variant migration
|
||||
note: Optional[str] = None # disambiguation note when applicable
|
||||
|
||||
|
||||
def _normalize(model_id: str) -> str:
|
||||
"""Strip provider prefix (``x-ai/grok-4`` → ``grok-4``) and lowercase."""
|
||||
m = model_id.strip().lower()
|
||||
for prefix in ("x-ai/", "xai/"):
|
||||
if m.startswith(prefix):
|
||||
m = m[len(prefix):]
|
||||
break
|
||||
return m
|
||||
|
||||
|
||||
def _looks_like_xai(model_id: Optional[str]) -> bool:
|
||||
if not isinstance(model_id, str) or not model_id.strip():
|
||||
return False
|
||||
return _normalize(model_id).startswith("grok-")
|
||||
|
||||
|
||||
def find_retired_xai_refs(config: Dict[str, Any]) -> List[RetirementIssue]:
|
||||
"""Walk all model slots in a Hermes config and return retirement issues.
|
||||
|
||||
Slots scanned:
|
||||
- ``principal.model``
|
||||
- ``auxiliary.<any>.model`` (introspective — covers future aux slots)
|
||||
- ``delegation.model``
|
||||
- ``tts.xai.model``
|
||||
- ``plugins.image_gen.xai.model``
|
||||
"""
|
||||
issues: List[RetirementIssue] = []
|
||||
|
||||
def _check(path: str, model: Any) -> None:
|
||||
if not _looks_like_xai(model):
|
||||
return
|
||||
norm = _normalize(model)
|
||||
entry = _RETIRED_MODELS.get(norm)
|
||||
if entry is None:
|
||||
return
|
||||
issues.append(RetirementIssue(
|
||||
config_path=path,
|
||||
current_model=model,
|
||||
replacement=entry["replacement"],
|
||||
reasoning_effort=entry.get("reasoning_effort"),
|
||||
note=entry.get("note"),
|
||||
))
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return issues
|
||||
|
||||
principal = config.get("principal")
|
||||
if isinstance(principal, dict):
|
||||
_check("principal.model", principal.get("model"))
|
||||
|
||||
aux = config.get("auxiliary")
|
||||
if isinstance(aux, dict):
|
||||
for slot_name, slot_cfg in aux.items():
|
||||
if isinstance(slot_cfg, dict):
|
||||
_check(f"auxiliary.{slot_name}.model", slot_cfg.get("model"))
|
||||
|
||||
delegation = config.get("delegation")
|
||||
if isinstance(delegation, dict):
|
||||
_check("delegation.model", delegation.get("model"))
|
||||
|
||||
tts = config.get("tts")
|
||||
if isinstance(tts, dict):
|
||||
tts_xai = tts.get("xai")
|
||||
if isinstance(tts_xai, dict):
|
||||
_check("tts.xai.model", tts_xai.get("model"))
|
||||
|
||||
plugins = config.get("plugins")
|
||||
if isinstance(plugins, dict):
|
||||
image_gen = plugins.get("image_gen")
|
||||
if isinstance(image_gen, dict):
|
||||
ig_xai = image_gen.get("xai")
|
||||
if isinstance(ig_xai, dict):
|
||||
_check("plugins.image_gen.xai.model", ig_xai.get("model"))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def format_issue(issue: RetirementIssue) -> str:
|
||||
"""One-line human-readable rendering of a retirement issue."""
|
||||
parts = [
|
||||
f"{issue.config_path}: {issue.current_model!r} → use {issue.replacement!r}"
|
||||
]
|
||||
if issue.reasoning_effort:
|
||||
parts.append(f'(set reasoning_effort: "{issue.reasoning_effort}")')
|
||||
if issue.note:
|
||||
parts.append(f"[note: {issue.note}]")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply migration to config.yaml (round-trip preserves comments/order/types)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyResult:
|
||||
"""Outcome of an apply_migration call."""
|
||||
|
||||
file_path: Path
|
||||
backup_path: Optional[Path]
|
||||
issues_resolved: List[RetirementIssue]
|
||||
config_changed: bool
|
||||
|
||||
|
||||
def _walk_to_parent(yaml_doc: Any, dotted_path: str) -> "tuple[Any, str]":
|
||||
"""Resolve a dotted slot path to (parent_mapping, leaf_key).
|
||||
|
||||
Example: "auxiliary.vision.model" -> (yaml_doc["auxiliary"]["vision"], "model").
|
||||
Raises KeyError if any intermediate node is missing or not a mapping.
|
||||
"""
|
||||
parts = dotted_path.split(".")
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Path must have at least one parent: {dotted_path!r}")
|
||||
node = yaml_doc
|
||||
for segment in parts[:-1]:
|
||||
if not isinstance(node, dict) or segment not in node:
|
||||
raise KeyError(f"Path segment {segment!r} missing in {dotted_path!r}")
|
||||
node = node[segment]
|
||||
return node, parts[-1]
|
||||
|
||||
|
||||
def apply_migration(
|
||||
config_path: Path,
|
||||
issues: List[RetirementIssue],
|
||||
backup: bool = True,
|
||||
) -> ApplyResult:
|
||||
"""Rewrite ``config_path`` in-place so each issue is resolved.
|
||||
|
||||
For every issue, the model name is replaced by ``issue.replacement``. If the
|
||||
issue has ``reasoning_effort`` set (i.e. the migration is from a
|
||||
``*-non-reasoning`` variant), a sibling ``reasoning_effort`` key is added
|
||||
or updated alongside the model.
|
||||
|
||||
Uses ``ruamel.yaml`` round-trip mode so comments, key order, indentation,
|
||||
and type literals (booleans, ints) are preserved.
|
||||
|
||||
A backup copy is written to
|
||||
``<config_path>.bak-pre-migrate-xai-YYYYMMDD-HHMMSS`` before rewriting,
|
||||
unless ``backup=False``.
|
||||
"""
|
||||
from ruamel.yaml import YAML # local import — avoid hard dep at module load
|
||||
|
||||
config_path = Path(config_path)
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(config_path)
|
||||
|
||||
if not issues:
|
||||
return ApplyResult(
|
||||
file_path=config_path,
|
||||
backup_path=None,
|
||||
issues_resolved=[],
|
||||
config_changed=False,
|
||||
)
|
||||
|
||||
yaml = YAML(typ="rt")
|
||||
yaml.preserve_quotes = True
|
||||
with config_path.open("r", encoding="utf-8") as fh:
|
||||
doc = yaml.load(fh)
|
||||
|
||||
if doc is None:
|
||||
return ApplyResult(
|
||||
file_path=config_path,
|
||||
backup_path=None,
|
||||
issues_resolved=[],
|
||||
config_changed=False,
|
||||
)
|
||||
|
||||
resolved: List[RetirementIssue] = []
|
||||
for issue in issues:
|
||||
try:
|
||||
parent, leaf = _walk_to_parent(doc, issue.config_path)
|
||||
except KeyError:
|
||||
# Slot vanished between scan and apply — skip silently
|
||||
continue
|
||||
parent[leaf] = issue.replacement
|
||||
if issue.reasoning_effort:
|
||||
parent["reasoning_effort"] = issue.reasoning_effort
|
||||
resolved.append(issue)
|
||||
|
||||
if not resolved:
|
||||
return ApplyResult(
|
||||
file_path=config_path,
|
||||
backup_path=None,
|
||||
issues_resolved=[],
|
||||
config_changed=False,
|
||||
)
|
||||
|
||||
backup_path: Optional[Path] = None
|
||||
if backup:
|
||||
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = config_path.with_name(
|
||||
f"{config_path.name}.bak-pre-migrate-xai-{ts}"
|
||||
)
|
||||
shutil.copy2(config_path, backup_path)
|
||||
|
||||
with config_path.open("w", encoding="utf-8") as fh:
|
||||
yaml.dump(doc, fh)
|
||||
|
||||
return ApplyResult(
|
||||
file_path=config_path,
|
||||
backup_path=backup_path,
|
||||
issues_resolved=resolved,
|
||||
config_changed=True,
|
||||
)
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
let
|
||||
src = ../apps;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
src = ../apps/dashboard;
|
||||
hash = "sha256-jJsVp3Dz+6/GaruxcUSby+G1vVB+nHHlu1tFWE9wQZQ=";
|
||||
inherit src;
|
||||
hash = "sha256-GxSmEpclOwmv94KmGMediPITxqXAsxqTEQOoDIbYkUw=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "apps/dashboard"; attr = "web"; pname = "hermes-web"; };
|
||||
|
|
|
|||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -25,7 +25,7 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nous-research/ui": "0.14.0",
|
||||
"@nous-research/ui": "^0.14.2",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
|
@ -2913,12 +2913,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@nous-research/ui": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.14.0.tgz",
|
||||
"integrity": "sha512-tfpE6jGOxE5oVBab/dTSepOudy/+Xep3gJ6NCFriYJvdtQBGXcqsi4mCaVPiNNaS/ZFf4/10dnl/oJTb6DtLKg==",
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.14.2.tgz",
|
||||
"integrity": "sha512-H3cMt2e0IpmcTNOmR6zVX+8ja48w4X4F/IFXhWCpaoVs8zKVRN12Ryb4RnX/ac8IrbUu6UsIds7ZtmXxPHcfdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"nanostores": "^1.3.0",
|
||||
|
|
|
|||
58
plugins/kanban/dashboard/dist/index.js
vendored
58
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -24,6 +24,23 @@
|
|||
const { useState, useEffect, useCallback, useMemo, useRef } = SDK.hooks;
|
||||
const { cn, timeAgo } = SDK.utils;
|
||||
|
||||
// Newer host dashboards expose a DS-styled Checkbox on the plugin SDK.
|
||||
// Fall back to a native <input type="checkbox"> shim so older hosts that
|
||||
// predate the design-system rollout still render. The shim normalises
|
||||
// Radix's onCheckedChange(checked) signature to native onChange(event).
|
||||
const Checkbox = SDK.components.Checkbox || function (props) {
|
||||
const { checked, onCheckedChange, className, onClick, ...rest } = props;
|
||||
return h("input", Object.assign({
|
||||
type: "checkbox",
|
||||
checked: !!checked,
|
||||
className: className,
|
||||
onClick: onClick,
|
||||
onChange: function (e) {
|
||||
if (onCheckedChange) onCheckedChange(e.target.checked);
|
||||
},
|
||||
}, rest));
|
||||
};
|
||||
|
||||
// useI18n is a hook each component calls locally. Older host dashboards
|
||||
// may not expose it yet; fall back to a shim so the bundle still renders
|
||||
// English against an older host SDK. English fallback strings live
|
||||
|
|
@ -1648,11 +1665,10 @@
|
|||
h(Label, { className: "text-xs text-muted-foreground" },
|
||||
"Orchestration mode"),
|
||||
h("label", { className: "flex items-center gap-2 text-xs h-8" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
h(Checkbox, {
|
||||
checked: !!settings.auto_decompose,
|
||||
onChange: function (e) {
|
||||
saveSettings({ auto_decompose: !!e.target.checked });
|
||||
onCheckedChange: function (checked) {
|
||||
saveSettings({ auto_decompose: checked === true });
|
||||
},
|
||||
}),
|
||||
"Auto-decompose triage tasks",
|
||||
|
|
@ -1908,10 +1924,9 @@
|
|||
}),
|
||||
),
|
||||
h("label", { className: "flex items-center gap-2 text-xs" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
h(Checkbox, {
|
||||
checked: switchTo,
|
||||
onChange: function (e) { setSwitchTo(e.target.checked); },
|
||||
onCheckedChange: function (checked) { setSwitchTo(checked === true); },
|
||||
}),
|
||||
tx(t, "switchAfterCreate", "Switch to this board after creating it"),
|
||||
),
|
||||
|
|
@ -1981,19 +1996,17 @@
|
|||
),
|
||||
h("label", { className: "flex items-center gap-2 text-xs",
|
||||
title: "Include archived tasks in the board view. Archived tasks are hidden by default." },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
h(Checkbox, {
|
||||
checked: props.includeArchived,
|
||||
onChange: function (e) { props.setIncludeArchived(e.target.checked); },
|
||||
onCheckedChange: function (checked) { props.setIncludeArchived(checked === true); },
|
||||
}),
|
||||
tx(t, "showArchived", "Show archived"),
|
||||
),
|
||||
h("label", { className: "flex items-center gap-2 text-xs",
|
||||
title: "Group the Running column by assigned profile" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
h(Checkbox, {
|
||||
checked: props.laneByProfile,
|
||||
onChange: function (e) { props.setLaneByProfile(e.target.checked); },
|
||||
onCheckedChange: function (checked) { props.setLaneByProfile(checked === true); },
|
||||
}),
|
||||
tx(t, "lanesByProfile", "Lanes by profile"),
|
||||
),
|
||||
|
|
@ -2122,10 +2135,9 @@
|
|||
}, tx(t, "apply", "Apply")),
|
||||
),
|
||||
h("label", { className: "hermes-kanban-bulk-reclaim-first", title: "Reclaim any active claims before reassigning" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
h(Checkbox, {
|
||||
checked: reclaimFirst,
|
||||
onChange: function (e) { setReclaimFirst(e.target.checked); },
|
||||
onCheckedChange: function (checked) { setReclaimFirst(checked === true); },
|
||||
}),
|
||||
"Reclaim first",
|
||||
),
|
||||
|
|
@ -2313,14 +2325,12 @@
|
|||
},
|
||||
h("div", { className: "hermes-kanban-column-header",
|
||||
title: colHelp || "" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
h(Checkbox, {
|
||||
className: "hermes-kanban-col-check",
|
||||
title: "Select all tasks in this column",
|
||||
"aria-label": `Select all tasks in ${colLabel || props.column.name}`,
|
||||
checked: props.column.tasks.length > 0 && props.column.tasks.every(function (t) { return props.selectedIds.has(t.id); }),
|
||||
onChange: function (e) {
|
||||
e.stopPropagation();
|
||||
onCheckedChange: function () {
|
||||
if (props.selectAllInColumn) props.selectAllInColumn(props.column.name);
|
||||
},
|
||||
onClick: function (e) { e.stopPropagation(); },
|
||||
|
|
@ -2461,8 +2471,7 @@
|
|||
if (props.toggleSelected) props.toggleSelected(t.id, false);
|
||||
}
|
||||
};
|
||||
const handleCheckbox = function (e) {
|
||||
e.stopPropagation();
|
||||
const handleCheckedChange = function () {
|
||||
props.toggleSelected(t.id, true);
|
||||
};
|
||||
|
||||
|
|
@ -2495,11 +2504,10 @@
|
|||
title: tx(i18n, "selectForBulk", "Select for bulk actions"),
|
||||
onClick: function (e) { e.stopPropagation(); },
|
||||
},
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
h(Checkbox, {
|
||||
className: "hermes-kanban-card-check",
|
||||
checked: props.selected,
|
||||
onChange: handleCheckbox,
|
||||
onCheckedChange: handleCheckedChange,
|
||||
onClick: function (e) { e.stopPropagation(); },
|
||||
"aria-label": `Select task ${t.id}`,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -41,7 +41,11 @@ dependencies = [
|
|||
"ruamel.yaml==0.18.17",
|
||||
"requests==2.33.0", # CVE-2026-25645
|
||||
"jinja2==3.1.6",
|
||||
"pydantic==2.12.5",
|
||||
# Bumped from 2.12.5 to 2.13.4 to pull in pydantic-core 2.46.4.
|
||||
# pydantic-core 2.41.5 (pulled by 2.12.5) segfaults when the OpenAI SDK's
|
||||
# Responses API resource is exercised from a non-main thread, which is the
|
||||
# codex_responses dispatch in agent/chat_completion_helpers.py:_call.
|
||||
"pydantic==2.13.4",
|
||||
# Interactive CLI (prompt_toolkit is used directly by cli.py)
|
||||
"prompt_toolkit==3.0.52",
|
||||
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
|
||||
|
|
|
|||
44
run_agent.py
44
run_agent.py
|
|
@ -168,7 +168,7 @@ from agent.tool_result_classification import (
|
|||
file_mutation_result_landed,
|
||||
)
|
||||
from agent.trajectory import (
|
||||
convert_scratchpad_to_think, has_incomplete_scratchpad,
|
||||
convert_scratchpad_to_think,
|
||||
save_trajectory as _save_trajectory_to_file,
|
||||
)
|
||||
from agent.message_sanitization import (
|
||||
|
|
@ -1535,23 +1535,35 @@ class AIAgent:
|
|||
return content.strip()
|
||||
|
||||
def _save_session_log(self, messages: List[Dict[str, Any]] = None):
|
||||
"""
|
||||
Save the full raw session to a JSON file.
|
||||
"""Optional per-session JSON snapshot writer.
|
||||
|
||||
Stores every message exactly as the agent sees it: user messages,
|
||||
assistant messages (with reasoning, finish_reason, tool_calls),
|
||||
tool responses (with tool_call_id, tool_name), and injected system
|
||||
messages (compression summaries, todo snapshots, etc.).
|
||||
Gated by ``sessions.write_json_snapshots`` (default False). state.db
|
||||
is the canonical message store; this writer exists only for users
|
||||
whose external tooling consumes ``~/.hermes/sessions/session_{sid}.json``
|
||||
directly. When the flag is off this is a fast no-op.
|
||||
|
||||
REASONING_SCRATCHPAD tags are converted to <think> blocks for consistency.
|
||||
Overwritten after each turn so it always reflects the latest state.
|
||||
When enabled, rewrites the snapshot after every persistence point with
|
||||
the full message list (assistant content normalized via
|
||||
``_clean_session_content`` to convert REASONING_SCRATCHPAD to think
|
||||
tags). The truncation guard ("don't overwrite a larger log with
|
||||
fewer messages") is preserved so resume + branch don't clobber a
|
||||
fuller existing snapshot.
|
||||
"""
|
||||
if not getattr(self, "_session_json_enabled", False):
|
||||
return
|
||||
messages = messages or self._session_messages
|
||||
if not messages:
|
||||
return
|
||||
|
||||
# Re-derive the target path each call so /branch and /compress
|
||||
# session-id changes land in the right file without any re-point
|
||||
# bookkeeping at the call sites.
|
||||
try:
|
||||
log_file = self.logs_dir / f"session_{self.session_id}.json"
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
# Clean assistant content for session logs
|
||||
cleaned = []
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant" and msg.get("content"):
|
||||
|
|
@ -1560,12 +1572,11 @@ class AIAgent:
|
|||
cleaned.append(msg)
|
||||
|
||||
# Guard: never overwrite a larger session log with fewer messages.
|
||||
# This protects against data loss when --resume loads a session whose
|
||||
# messages weren't fully written to SQLite — the resumed agent starts
|
||||
# with partial history and would otherwise clobber the full JSON log.
|
||||
if self.session_log_file.exists():
|
||||
# Protects against data loss when a resumed agent starts with
|
||||
# partial history and would otherwise clobber the full JSON log.
|
||||
if log_file.exists():
|
||||
try:
|
||||
existing = json.loads(self.session_log_file.read_text(encoding="utf-8"))
|
||||
existing = json.loads(log_file.read_text(encoding="utf-8"))
|
||||
existing_count = existing.get("message_count", len(existing.get("messages", [])))
|
||||
if existing_count > len(cleaned):
|
||||
logging.debug(
|
||||
|
|
@ -1590,7 +1601,7 @@ class AIAgent:
|
|||
}
|
||||
|
||||
atomic_json_write(
|
||||
self.session_log_file,
|
||||
log_file,
|
||||
entry,
|
||||
indent=2,
|
||||
default=str,
|
||||
|
|
@ -1600,6 +1611,7 @@ class AIAgent:
|
|||
if self.verbose_logging:
|
||||
logging.warning(f"Failed to save session log: {e}")
|
||||
|
||||
|
||||
def interrupt(self, message: str = None) -> None:
|
||||
"""
|
||||
Request the agent to interrupt its current tool-calling loop.
|
||||
|
|
|
|||
|
|
@ -372,6 +372,7 @@ AUTHOR_MAP = {
|
|||
"bloodcarter@gmail.com": "bloodcarter",
|
||||
"scott@scotttrinh.com": "scotttrinh",
|
||||
"quocanh261997@gmail.com": "quocanh261997",
|
||||
"savanne.kham@protonmail.com": "savanne-kham", # PR #28958 salvage (strip tool_name for strict providers)
|
||||
# contributors (from noreply pattern)
|
||||
"david.vv@icloud.com": "davidvv",
|
||||
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
|
||||
|
|
@ -680,7 +681,7 @@ AUTHOR_MAP = {
|
|||
"hmbown@gmail.com": "Hmbown",
|
||||
"iacobs@m0n5t3r.info": "m0n5t3r",
|
||||
"jiayuw794@gmail.com": "JiayuuWang",
|
||||
"jonny@nousresearch.com": "jquesnelle",
|
||||
"jonny@nousresearch.com": "yoniebans",
|
||||
"jake@nousresearch.com": "simpolism",
|
||||
"juan.ovalle@mistral.ai": "jjovalle99",
|
||||
"julien.talbot@ergonomia.re": "Julientalbot",
|
||||
|
|
|
|||
|
|
@ -336,7 +336,8 @@ The registry of record is `hermes_cli/commands.py` — every consumer
|
|||
~/.hermes/config.yaml Main configuration
|
||||
~/.hermes/.env API keys and secrets
|
||||
$HERMES_HOME/skills/ Installed skills
|
||||
~/.hermes/sessions/ Session transcripts
|
||||
~/.hermes/sessions/ Gateway routing index, request dumps, *.jsonl transcripts (and optional per-session JSON snapshots when sessions.write_json_snapshots: true)
|
||||
~/.hermes/state.db Canonical session store (SQLite + FTS5)
|
||||
~/.hermes/logs/ Gateway and error logs
|
||||
~/.hermes/auth.json OAuth tokens and credential pools
|
||||
~/.hermes/hermes-agent/ Source code (if git-installed)
|
||||
|
|
@ -867,7 +868,7 @@ hermes config set auxiliary.vision.model <model_name>
|
|||
| Env variables | `hermes config env-path` or [Env vars reference](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) |
|
||||
| CLI commands | `hermes --help` or [CLI reference](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) |
|
||||
| Gateway logs | `~/.hermes/logs/gateway.log` |
|
||||
| Session files | `~/.hermes/sessions/` or `hermes sessions browse` |
|
||||
| Session files | `hermes sessions browse` (reads state.db) |
|
||||
| Source code | `~/.hermes/hermes-agent/` |
|
||||
|
||||
---
|
||||
|
|
|
|||
210
tests/agent/lsp/test_shell_linter_lsp_skip.py
Normal file
210
tests/agent/lsp/test_shell_linter_lsp_skip.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""Skip the per-file shell linter when LSP will handle the same file.
|
||||
|
||||
The per-file ``npx tsc --noEmit FILE.ts`` shell linter cannot see
|
||||
``tsconfig.json`` (a documented ``tsc`` quirk: explicit file args bypass
|
||||
the project config), so it defaults to no-lib / ES5 and floods the
|
||||
agent's lint field with phantom "Cannot find 'Promise' / 'Map' / 'Set' /
|
||||
'ReadonlySet' / 'Iterable' / 'imul' / …" errors on every edit — up to
|
||||
25K tokens per patch. The LSP tier (``tsserver`` via
|
||||
typescript-language-server) reads tsconfig correctly and surfaces real
|
||||
diagnostics in the ``lsp_diagnostics`` field of the WriteResult /
|
||||
PatchResult.
|
||||
|
||||
These tests pin the contract:
|
||||
|
||||
- When LSP is active AND ``enabled_for(path)`` for a ``.ts`` / ``.go``
|
||||
/ ``.rs`` file, ``_check_lint`` returns ``skipped`` without invoking
|
||||
the shell linter at all.
|
||||
- When LSP is inactive or disabled-for-path, the shell linter runs
|
||||
exactly as before (regression guard for the default config).
|
||||
- The skip only applies to extensions in
|
||||
``_SHELL_LINTER_LSP_REDUNDANT`` — Python ``py_compile`` and
|
||||
``node --check`` keep running unconditionally because they're fast,
|
||||
file-local, and correct.
|
||||
- ``.tsx`` is intentionally NOT in either ``LINTERS`` or
|
||||
``_SHELL_LINTER_LSP_REDUNDANT``: it had no ``LINTERS`` entry
|
||||
pre-PR (so it was already implicitly ``skipped`` via the
|
||||
``ext not in LINTERS`` branch) and adding one would have inherited
|
||||
``.ts``'s broken ``tsc --noEmit FILE`` invocation for LSP-disabled
|
||||
users. When LSP IS enabled, ``.tsx`` is still covered by
|
||||
typescript-language-server via ``_maybe_lsp_diagnostics`` — the
|
||||
diagnostics show up on ``lsp_diagnostics``, not ``lint``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_fops():
|
||||
from tools.environments.local import LocalEnvironment
|
||||
from tools.file_operations import ShellFileOperations
|
||||
return ShellFileOperations(LocalEnvironment())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ext", [".ts", ".go", ".rs"])
|
||||
def test_shell_linter_skipped_when_lsp_will_handle(ext, tmp_path):
|
||||
"""When LSP is active and enabled_for(path), shell linter is skipped.
|
||||
|
||||
The shell linter's _exec must NOT be called — that's the whole
|
||||
point. We assert by patching ``_exec`` to raise, so any accidental
|
||||
invocation surfaces as a test failure.
|
||||
"""
|
||||
fops = _make_fops()
|
||||
src = tmp_path / f"bad{ext}"
|
||||
src.write_text("intentionally invalid content\n")
|
||||
|
||||
def _exec_must_not_run(*args, **kwargs): # pragma: no cover
|
||||
raise AssertionError(
|
||||
"shell linter was invoked despite LSP claiming the file"
|
||||
)
|
||||
|
||||
with patch.object(fops, "_lsp_will_handle", return_value=True), \
|
||||
patch.object(fops, "_exec", side_effect=_exec_must_not_run), \
|
||||
patch.object(fops, "_has_command", return_value=True):
|
||||
result = fops._check_lint(str(src))
|
||||
|
||||
assert result.skipped is True
|
||||
assert "LSP" in (result.message or "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ext", [".ts", ".go", ".rs"])
|
||||
def test_shell_linter_runs_when_lsp_inactive(ext, tmp_path):
|
||||
"""When LSP is inactive (default config, no service, remote backend, ...),
|
||||
the shell linter runs as before — no behavior change."""
|
||||
fops = _make_fops()
|
||||
src = tmp_path / f"clean{ext}"
|
||||
src.write_text("// content\n")
|
||||
|
||||
fake_result = MagicMock()
|
||||
fake_result.exit_code = 0
|
||||
fake_result.stdout = ""
|
||||
|
||||
with patch.object(fops, "_lsp_will_handle", return_value=False), \
|
||||
patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
|
||||
patch.object(fops, "_has_command", return_value=True):
|
||||
result = fops._check_lint(str(src))
|
||||
|
||||
# _exec must have been called — proving the shell linter ran.
|
||||
assert exec_mock.called, "shell linter did NOT run when LSP was inactive"
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ext", [".py", ".js"])
|
||||
def test_lsp_does_not_skip_non_redundant_extensions(ext, tmp_path):
|
||||
"""``py_compile`` and ``node --check`` keep running even when an LSP
|
||||
server (pyright/pylsp/typescript-language-server-for-JS) is active —
|
||||
they're fast, file-local, and correct, so there's no upside to
|
||||
suppressing them.
|
||||
"""
|
||||
fops = _make_fops()
|
||||
src = tmp_path / f"clean{ext}"
|
||||
src.write_text("# valid\n" if ext == ".py" else "// valid\n")
|
||||
|
||||
fake_result = MagicMock()
|
||||
fake_result.exit_code = 0
|
||||
fake_result.stdout = ""
|
||||
|
||||
# Even with LSP claiming the file, the shell linter must still run
|
||||
# for these extensions.
|
||||
with patch.object(fops, "_lsp_will_handle", return_value=True), \
|
||||
patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
|
||||
patch.object(fops, "_has_command", return_value=True):
|
||||
fops._check_lint(str(src))
|
||||
|
||||
assert exec_mock.called, (
|
||||
f"shell linter for {ext} did not run despite being in the "
|
||||
"'always-run' set (py_compile / node --check)"
|
||||
)
|
||||
|
||||
|
||||
def test_lsp_will_handle_returns_false_when_service_is_none(tmp_path):
|
||||
"""``_lsp_will_handle`` must return False when the LSP service hasn't
|
||||
been initialized — otherwise we'd accidentally skip the shell linter
|
||||
on systems where LSP isn't configured at all."""
|
||||
fops = _make_fops()
|
||||
src = tmp_path / "foo.ts"
|
||||
src.write_text("const x = 1\n")
|
||||
|
||||
with patch.object(fops, "_lsp_local_only", return_value=True), \
|
||||
patch("agent.lsp.get_service", return_value=None):
|
||||
assert fops._lsp_will_handle(str(src)) is False
|
||||
|
||||
|
||||
def test_lsp_will_handle_returns_false_on_remote_backend(tmp_path):
|
||||
"""LSP servers run on the host process — remote backends (Docker,
|
||||
SSH, Modal, …) keep files inside the sandbox where the host LSP
|
||||
can't reach them. ``_lsp_will_handle`` must short-circuit before
|
||||
calling into the service in that case."""
|
||||
fops = _make_fops()
|
||||
src = tmp_path / "foo.ts"
|
||||
src.write_text("const x = 1\n")
|
||||
|
||||
with patch.object(fops, "_lsp_local_only", return_value=False), \
|
||||
patch("agent.lsp.get_service") as get_service_mock:
|
||||
result = fops._lsp_will_handle(str(src))
|
||||
|
||||
assert result is False
|
||||
# Importantly: we never even consulted the service.
|
||||
assert not get_service_mock.called
|
||||
|
||||
|
||||
def test_lsp_will_handle_swallows_enabled_for_exception(tmp_path):
|
||||
"""A flaky LSP service must never break the shell-linter fallback —
|
||||
if ``enabled_for`` raises, we treat the file as "not handled" so the
|
||||
shell linter still runs."""
|
||||
fops = _make_fops()
|
||||
src = tmp_path / "foo.ts"
|
||||
src.write_text("const x = 1\n")
|
||||
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.enabled_for.side_effect = RuntimeError("server crashed")
|
||||
|
||||
with patch.object(fops, "_lsp_local_only", return_value=True), \
|
||||
patch("agent.lsp.get_service", return_value=fake_svc):
|
||||
assert fops._lsp_will_handle(str(src)) is False
|
||||
|
||||
|
||||
def test_tsx_stays_out_of_linters_table_for_default_compatibility():
|
||||
"""Regression: keep ``.tsx`` out of ``LINTERS`` so users with LSP
|
||||
DISABLED don't suddenly get the broken ``npx tsc --noEmit FILE.tsx``
|
||||
invocation that ``.ts`` historically used to get.
|
||||
|
||||
Pre-PR behavior: ``.tsx`` had no entry in ``LINTERS``, so it fell
|
||||
through to ``ext not in LINTERS`` → ``LintResult(skipped=True,
|
||||
message="No linter for .tsx files")``. This PR preserves that for
|
||||
the default config.
|
||||
|
||||
When LSP IS enabled, ``.tsx`` is still covered by the LSP tier via
|
||||
``_maybe_lsp_diagnostics`` (typescript-language-server claims
|
||||
``.tsx`` in its extensions list) — the diagnostics show up in the
|
||||
``lsp_diagnostics`` field, not the ``lint`` field.
|
||||
"""
|
||||
from tools.file_operations import LINTERS, _SHELL_LINTER_LSP_REDUNDANT
|
||||
|
||||
assert ".tsx" not in LINTERS
|
||||
assert ".tsx" not in _SHELL_LINTER_LSP_REDUNDANT
|
||||
|
||||
|
||||
def test_tsx_default_check_lint_returns_skipped(tmp_path):
|
||||
"""End-to-end: ``.tsx`` files get ``LintResult(skipped=True)`` from
|
||||
``_check_lint`` regardless of LSP status — this is the no-regression
|
||||
contract that addresses Copilot review #3271017282."""
|
||||
fops = _make_fops()
|
||||
src = tmp_path / "foo.tsx"
|
||||
src.write_text("export const X = () => <div/>\n")
|
||||
|
||||
# Even with LSP claiming the file, no shell linter runs for .tsx
|
||||
# because there's no LINTERS entry — the ``ext not in LINTERS``
|
||||
# branch fires before the LSP short-circuit is consulted.
|
||||
with patch.object(fops, "_lsp_will_handle", return_value=True), \
|
||||
patch.object(fops, "_exec") as exec_mock:
|
||||
result = fops._check_lint(str(src))
|
||||
|
||||
assert result.skipped is True
|
||||
assert not exec_mock.called, "no shell linter should run for .tsx"
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
@ -46,6 +46,26 @@ class TestChatCompletionsBasic:
|
|||
assert "codex_reasoning_items" in msgs[0]
|
||||
assert "codex_message_items" in msgs[0]
|
||||
|
||||
def test_convert_messages_strips_tool_name(self, transport):
|
||||
"""Internal `tool_name` (used for FTS indexing in the SQLite store) is
|
||||
not part of the OpenAI Chat Completions schema. Strict providers like
|
||||
Moonshot/Kimi reject it with HTTP 400 'Extra inputs are not permitted'.
|
||||
"""
|
||||
msgs = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"id": "call_1", "type": "function",
|
||||
"function": {"name": "execute_code", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "tool_name": "execute_code",
|
||||
"content": "result"},
|
||||
]
|
||||
result = transport.convert_messages(msgs)
|
||||
assert "tool_name" not in result[2]
|
||||
assert result[2]["content"] == "result"
|
||||
assert result[2]["tool_call_id"] == "call_1"
|
||||
# Original list untouched (deepcopy-on-demand)
|
||||
assert msgs[2]["tool_name"] == "execute_code"
|
||||
|
||||
|
||||
class TestChatCompletionsBuildKwargs:
|
||||
|
||||
|
|
|
|||
|
|
@ -160,30 +160,6 @@ class TestBranchCommandCLI:
|
|||
assert agent.reset_session_state.called
|
||||
assert agent._last_flushed_db_idx == 4 # len(conversation_history)
|
||||
|
||||
def test_branch_updates_agent_session_log_file(self, cli_instance, session_db, tmp_path):
|
||||
"""Branching must redirect the agent's session_log_file to the new session's path."""
|
||||
from cli import HermesCLI
|
||||
from pathlib import Path
|
||||
|
||||
logs_dir = tmp_path / "sessions"
|
||||
logs_dir.mkdir()
|
||||
|
||||
agent = MagicMock()
|
||||
agent._last_flushed_db_idx = 0
|
||||
agent.logs_dir = logs_dir
|
||||
agent.session_log_file = logs_dir / f"session_{cli_instance.session_id}.json"
|
||||
cli_instance.agent = agent
|
||||
|
||||
old_log_file = agent.session_log_file
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
new_session_id = cli_instance.session_id
|
||||
expected_log = logs_dir / f"session_{new_session_id}.json"
|
||||
assert agent.session_log_file == expected_log, (
|
||||
"session_log_file must point to the branch session, not the original"
|
||||
)
|
||||
assert agent.session_log_file != old_log_file
|
||||
|
||||
def test_branch_sets_resumed_flag(self, cli_instance, session_db):
|
||||
"""Branch should set _resumed=True to prevent auto-title generation."""
|
||||
from cli import HermesCLI
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
"""Tests for CLI browser CDP auto-launch helpers."""
|
||||
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
import os
|
||||
from queue import Queue
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.browser_connect import manual_chrome_debug_command
|
||||
from hermes_cli.browser_connect import (
|
||||
get_chrome_debug_candidates,
|
||||
is_browser_debug_ready,
|
||||
manual_chrome_debug_command,
|
||||
)
|
||||
|
||||
|
||||
def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port):
|
||||
|
|
@ -19,7 +26,35 @@ def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port):
|
|||
assert "chrome-debug" in user_data_args[0]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class TestChromeDebugLaunch:
|
||||
def test_browser_debug_ready_requires_http_cdp_endpoint(self):
|
||||
requested = []
|
||||
|
||||
def fake_urlopen(url, timeout):
|
||||
requested.append(url)
|
||||
if url.endswith("/json/version"):
|
||||
return _FakeResponse()
|
||||
raise OSError("unexpected probe")
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is True
|
||||
|
||||
assert requested == ["http://127.0.0.1:9222/json/version"]
|
||||
|
||||
def test_browser_debug_ready_rejects_non_cdp_listener(self):
|
||||
with patch("urllib.request.urlopen", side_effect=OSError("not cdp")):
|
||||
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is False
|
||||
|
||||
def test_windows_launch_uses_browser_found_on_path(self):
|
||||
captured = {}
|
||||
|
||||
|
|
@ -72,6 +107,98 @@ class TestChromeDebugLaunch:
|
|||
assert command is not None
|
||||
assert command.startswith("/usr/bin/chromium --remote-debugging-port=9222")
|
||||
|
||||
def test_linux_candidates_prefer_chrome_before_brave_when_both_exist(self):
|
||||
chrome = "/usr/bin/google-chrome"
|
||||
brave = "/usr/bin/brave-browser"
|
||||
|
||||
def fake_which(name):
|
||||
return {"google-chrome": chrome, "brave-browser": brave}.get(name)
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=fake_which), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
command = manual_chrome_debug_command(9222, "Linux")
|
||||
|
||||
assert candidates[:2] == [chrome, brave]
|
||||
assert command is not None
|
||||
assert command.startswith(f"{chrome} --remote-debugging-port=9222")
|
||||
|
||||
def test_linux_candidates_prefer_chrome_install_path_before_brave_on_path(self):
|
||||
chrome = "/opt/google/chrome/chrome"
|
||||
brave = "/usr/bin/brave-browser"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave-browser" else None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
|
||||
assert candidates[:2] == [chrome, brave]
|
||||
|
||||
def test_windows_candidates_prefer_chrome_install_path_before_brave_on_path(self, monkeypatch):
|
||||
program_files = r"C:\Program Files"
|
||||
chrome = os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe")
|
||||
brave = r"C:\Brave\brave.exe"
|
||||
|
||||
monkeypatch.setenv("ProgramFiles", program_files)
|
||||
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
|
||||
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave.exe" else None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
|
||||
candidates = get_chrome_debug_candidates("Windows")
|
||||
|
||||
assert candidates[:2] == [chrome, brave]
|
||||
|
||||
def test_linux_candidates_include_arch_brave_install_path(self):
|
||||
brave = "/opt/brave-bin/brave"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == brave):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
command = manual_chrome_debug_command(9222, "Linux")
|
||||
|
||||
assert candidates == [brave]
|
||||
assert command is not None
|
||||
assert command.startswith(f"{brave} --remote-debugging-port=9222")
|
||||
|
||||
def test_linux_candidates_include_brave_binary_name(self):
|
||||
brave = "/usr/bin/brave"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave" else None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == brave):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
command = manual_chrome_debug_command(9222, "Linux")
|
||||
|
||||
assert candidates == [brave]
|
||||
assert command is not None
|
||||
assert command.startswith(f"{brave} --remote-debugging-port=9222")
|
||||
|
||||
def test_linux_candidates_include_official_brave_and_edge_stable_paths(self):
|
||||
brave = "/usr/bin/brave-browser-stable"
|
||||
edge = "/usr/bin/microsoft-edge-stable"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {brave, edge}):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
|
||||
assert candidates == [brave, edge]
|
||||
|
||||
def test_launch_tries_next_browser_when_first_candidate_fails(self):
|
||||
brave = "/usr/bin/brave-browser"
|
||||
chrome = "/usr/bin/google-chrome"
|
||||
attempts = []
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
attempts.append(cmd[0])
|
||||
if cmd[0] == brave:
|
||||
raise OSError("broken brave install")
|
||||
return object()
|
||||
|
||||
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[brave, chrome]), \
|
||||
patch("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9222, "Linux") is True
|
||||
|
||||
assert attempts == [brave, chrome]
|
||||
|
||||
def test_manual_command_uses_wsl_windows_chrome_when_available(self):
|
||||
chrome = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
|
||||
|
||||
|
|
@ -99,3 +226,28 @@ class TestChromeDebugLaunch:
|
|||
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", return_value=False):
|
||||
assert manual_chrome_debug_command(9222, "Linux") is None
|
||||
|
||||
def test_connect_context_note_allows_expected_browser_use(self, monkeypatch):
|
||||
"""`/browser connect` is an instruction to use the CDP browser.
|
||||
|
||||
The queued context note must not tell the model to wait for a second
|
||||
permission step or imply that the attached browser is the user's main
|
||||
everyday Chrome profile.
|
||||
"""
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._pending_input = Queue()
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
|
||||
with patch("cli.is_browser_debug_ready", return_value=True), \
|
||||
patch("tools.browser_tool.cleanup_all_browsers"), \
|
||||
patch("tools.browser_tool._ensure_cdp_supervisor"), \
|
||||
redirect_stdout(StringIO()):
|
||||
cli._handle_browser_command("/browser connect")
|
||||
|
||||
note = cli._pending_input.get_nowait()
|
||||
assert "Chromium-family" in note
|
||||
assert "dev/debug" in note
|
||||
assert "using browser tools for their current browser-related request is expected" in note
|
||||
assert "live Chrome browser" not in note
|
||||
assert "real browser" not in note
|
||||
assert "Please await their instruction" not in note
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ class _Codex401ThenSuccessAgent(run_agent.AIAgent):
|
|||
self._cleanup_task_resources = lambda task_id: None
|
||||
self._persist_session = lambda messages, history=None: None
|
||||
self._save_trajectory = lambda messages, user_message, completed: None
|
||||
self._save_session_log = lambda messages: None
|
||||
|
||||
def _try_refresh_codex_client_credentials(self, *, force: bool = True) -> bool:
|
||||
type(self).refresh_attempts += 1
|
||||
|
|
|
|||
223
tests/hermes_cli/test_migrate_xai.py
Normal file
223
tests/hermes_cli/test_migrate_xai.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"""Tests for ``hermes migrate xai`` — apply path with ruamel round-trip."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.xai_retirement import (
|
||||
RetirementIssue,
|
||||
apply_migration,
|
||||
find_retired_xai_refs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def trap_config(tmp_path: Path) -> Path:
|
||||
"""A config.yaml with retired models AND comments to verify round-trip."""
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"# Hermes config (sample)\n"
|
||||
"principal:\n"
|
||||
" provider: xai # the main model\n"
|
||||
" model: grok-4-1-fast-non-reasoning # retiring May 15\n"
|
||||
" temperature: 0.5\n"
|
||||
"auxiliary:\n"
|
||||
" vision:\n"
|
||||
" provider: xai\n"
|
||||
" model: grok-4-fast-reasoning # retiring\n"
|
||||
" compression:\n"
|
||||
" provider: openai # not affected\n"
|
||||
" model: gpt-4o-mini\n"
|
||||
"delegation:\n"
|
||||
" model: grok-code-fast-1 # retiring\n"
|
||||
"plugins:\n"
|
||||
" image_gen:\n"
|
||||
" xai:\n"
|
||||
" model: grok-imagine-image-pro # retiring\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_config(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"principal:\n"
|
||||
" provider: xai\n"
|
||||
" model: grok-4.3\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def _parse(path: Path) -> dict:
|
||||
"""Load with ruamel for assertion convenience."""
|
||||
from ruamel.yaml import YAML
|
||||
yaml = YAML(typ="rt")
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
return yaml.load(fh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry-run / no-op
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNoOpPaths:
|
||||
def test_clean_config_returns_unchanged_result(self, clean_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(clean_config))
|
||||
assert issues == []
|
||||
result = apply_migration(clean_config, issues)
|
||||
assert result.config_changed is False
|
||||
assert result.backup_path is None
|
||||
# File untouched
|
||||
assert "grok-4.3" in clean_config.read_text(encoding="utf-8")
|
||||
|
||||
def test_empty_issues_list_is_noop(self, trap_config: Path):
|
||||
original = trap_config.read_text(encoding="utf-8")
|
||||
result = apply_migration(trap_config, issues=[])
|
||||
assert result.config_changed is False
|
||||
assert trap_config.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
apply_migration(tmp_path / "absent.yaml", issues=[
|
||||
RetirementIssue(
|
||||
config_path="principal.model",
|
||||
current_model="grok-3",
|
||||
replacement="grok-4.3",
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply: surgical replacement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApplyReplacement:
|
||||
def test_replaces_principal_model(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
result = apply_migration(trap_config, issues)
|
||||
assert result.config_changed is True
|
||||
cfg = _parse(trap_config)
|
||||
assert cfg["principal"]["model"] == "grok-4.3"
|
||||
|
||||
def test_adds_reasoning_effort_for_non_reasoning_variant(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
cfg = _parse(trap_config)
|
||||
# Principal was grok-4-1-fast-non-reasoning → reasoning_effort: "none"
|
||||
assert cfg["principal"]["reasoning_effort"] == "none"
|
||||
|
||||
def test_replaces_auxiliary_vision(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
cfg = _parse(trap_config)
|
||||
assert cfg["auxiliary"]["vision"]["model"] == "grok-4.3"
|
||||
|
||||
def test_replaces_delegation(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
cfg = _parse(trap_config)
|
||||
assert cfg["delegation"]["model"] == "grok-4.3"
|
||||
|
||||
def test_replaces_image_gen_plugin(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
cfg = _parse(trap_config)
|
||||
assert cfg["plugins"]["image_gen"]["xai"]["model"] == "grok-imagine-image-quality"
|
||||
|
||||
def test_does_not_touch_unrelated_slots(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
cfg = _parse(trap_config)
|
||||
# auxiliary.compression was never xAI, must remain untouched
|
||||
assert cfg["auxiliary"]["compression"]["model"] == "gpt-4o-mini"
|
||||
assert cfg["auxiliary"]["compression"]["provider"] == "openai"
|
||||
# principal.temperature must survive
|
||||
assert cfg["principal"]["temperature"] == 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip preservation (the hard part)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRoundTripPreservation:
|
||||
def test_preserves_top_of_file_comment(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
text = trap_config.read_text(encoding="utf-8")
|
||||
assert "# Hermes config (sample)" in text
|
||||
|
||||
def test_preserves_inline_comments_on_unmodified_lines(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
text = trap_config.read_text(encoding="utf-8")
|
||||
assert "# the main model" in text
|
||||
assert "# not affected" in text
|
||||
|
||||
def test_preserves_top_level_key_order(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
text = trap_config.read_text(encoding="utf-8")
|
||||
order = [
|
||||
text.index("principal:"),
|
||||
text.index("auxiliary:"),
|
||||
text.index("delegation:"),
|
||||
text.index("plugins:"),
|
||||
]
|
||||
assert order == sorted(order)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBackup:
|
||||
def test_backup_is_written_by_default(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
original = trap_config.read_text(encoding="utf-8")
|
||||
result = apply_migration(trap_config, issues)
|
||||
assert result.backup_path is not None
|
||||
assert result.backup_path.exists()
|
||||
assert result.backup_path.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_backup_filename_prefixed(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
result = apply_migration(trap_config, issues)
|
||||
assert result.backup_path is not None
|
||||
assert result.backup_path.name.startswith("config.yaml.bak-pre-migrate-xai-")
|
||||
|
||||
def test_no_backup_when_disabled(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
result = apply_migration(trap_config, issues, backup=False)
|
||||
assert result.backup_path is None
|
||||
# No bak file in the directory
|
||||
assert not list(trap_config.parent.glob("*.bak-pre-migrate-xai-*"))
|
||||
|
||||
def test_no_backup_when_no_changes(self, clean_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(clean_config))
|
||||
result = apply_migration(clean_config, issues, backup=True)
|
||||
assert result.backup_path is None # nothing to back up
|
||||
assert not list(clean_config.parent.glob("*.bak-pre-migrate-xai-*"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIdempotence:
|
||||
def test_apply_twice_is_safe(self, trap_config: Path):
|
||||
# First pass: replace
|
||||
issues_1 = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues_1)
|
||||
# Second pass: nothing to do
|
||||
issues_2 = find_retired_xai_refs(_parse(trap_config))
|
||||
assert issues_2 == []
|
||||
result_2 = apply_migration(trap_config, issues_2)
|
||||
assert result_2.config_changed is False
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"""_tui_need_npm_install: auto npm when node_modules is behind the lockfile."""
|
||||
|
||||
import os
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -120,3 +121,75 @@ def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
|
|||
"""dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install."""
|
||||
_touch_tui_entry(tmp_path)
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
|
||||
|
||||
def test_need_rebuild_when_tui_bundle_missing(tmp_path: Path, main_mod) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "entry.tsx").write_text("console.log('src')")
|
||||
|
||||
assert main_mod._tui_need_rebuild(tmp_path) is True
|
||||
|
||||
|
||||
def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path, main_mod) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
(src / "entry.tsx").write_text("console.log('src')")
|
||||
os.utime(src / "entry.tsx", (100, 100))
|
||||
os.utime(tmp_path / "dist" / "entry.js", (200, 200))
|
||||
|
||||
assert main_mod._tui_need_rebuild(tmp_path) is False
|
||||
|
||||
|
||||
def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path, main_mod) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
(src / "entry.tsx").write_text("console.log('src')")
|
||||
os.utime(tmp_path / "dist" / "entry.js", (100, 100))
|
||||
os.utime(src / "entry.tsx", (200, 200))
|
||||
|
||||
assert main_mod._tui_need_rebuild(tmp_path) is True
|
||||
|
||||
|
||||
def test_make_tui_argv_skips_build_only_on_termux_when_fresh(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
|
||||
def fail_run(*_args, **_kwargs):
|
||||
raise AssertionError("fresh Termux TUI launch must not rebuild")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
|
||||
|
||||
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
|
||||
|
||||
assert argv == ["/bin/node", str(tmp_path / "dist" / "entry.js")]
|
||||
assert cwd == tmp_path
|
||||
|
||||
|
||||
def test_make_tui_argv_keeps_desktop_always_build_behaviour(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
main_mod._make_tui_argv(tmp_path, tui_dev=False)
|
||||
|
||||
assert calls
|
||||
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
|
||||
|
|
|
|||
|
|
@ -251,6 +251,38 @@ def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod):
|
|||
assert captured == {"toolsets": "web,terminal", "tui": True}
|
||||
|
||||
|
||||
def test_termux_fast_tui_launch_uses_light_parser(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(
|
||||
sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_mod,
|
||||
"cmd_chat",
|
||||
lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}),
|
||||
)
|
||||
|
||||
assert main_mod._try_termux_fast_tui_launch() is True
|
||||
assert captured == {"toolsets": "web,terminal", "tui": True}
|
||||
|
||||
|
||||
def test_termux_fast_tui_launch_skips_help(monkeypatch, main_mod):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--help"])
|
||||
|
||||
assert main_mod._try_termux_fast_tui_launch() is False
|
||||
|
||||
|
||||
def test_fast_tui_launch_is_termux_only(monkeypatch, main_mod):
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "--tui"])
|
||||
|
||||
assert main_mod._try_termux_fast_tui_launch() is False
|
||||
|
||||
|
||||
def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
|
|
|
|||
275
tests/hermes_cli/test_xai_retirement.py
Normal file
275
tests/hermes_cli/test_xai_retirement.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"""Unit tests for hermes_cli.xai_retirement (May 15, 2026 model retirement)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.xai_retirement import (
|
||||
MIGRATION_GUIDE_URL,
|
||||
RETIREMENT_DATE,
|
||||
RetirementIssue,
|
||||
_RETIRED_MODELS,
|
||||
_looks_like_xai,
|
||||
_normalize,
|
||||
find_retired_xai_refs,
|
||||
format_issue,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _paths(issues):
|
||||
return [i.config_path for i in issues]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _normalize / _looks_like_xai
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalize:
|
||||
def test_strips_x_ai_prefix(self):
|
||||
assert _normalize("x-ai/grok-4") == "grok-4"
|
||||
|
||||
def test_strips_xai_prefix(self):
|
||||
assert _normalize("xai/grok-4-fast") == "grok-4-fast"
|
||||
|
||||
def test_lowercases(self):
|
||||
assert _normalize("Grok-Code-Fast-1") == "grok-code-fast-1"
|
||||
|
||||
def test_no_prefix_passthrough(self):
|
||||
assert _normalize("grok-4.3") == "grok-4.3"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _normalize(" grok-4 ") == "grok-4"
|
||||
|
||||
|
||||
class TestLooksLikeXai:
|
||||
def test_grok_prefix(self):
|
||||
assert _looks_like_xai("grok-4")
|
||||
assert _looks_like_xai("x-ai/grok-4-1-fast")
|
||||
|
||||
def test_non_grok_returns_false(self):
|
||||
assert not _looks_like_xai("gpt-4")
|
||||
assert not _looks_like_xai("claude-sonnet-4-6")
|
||||
assert not _looks_like_xai("openrouter/openai/gpt-4")
|
||||
|
||||
def test_none_or_empty(self):
|
||||
assert not _looks_like_xai(None)
|
||||
assert not _looks_like_xai("")
|
||||
assert not _looks_like_xai(" ")
|
||||
|
||||
def test_non_string(self):
|
||||
assert not _looks_like_xai(42)
|
||||
assert not _looks_like_xai({"model": "grok-4"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_retired_xai_refs — config scanning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindRetiredEdgeCases:
|
||||
def test_empty_config_no_issues(self):
|
||||
assert find_retired_xai_refs({}) == []
|
||||
|
||||
def test_non_dict_config_returns_empty(self):
|
||||
assert find_retired_xai_refs(None) == [] # type: ignore[arg-type]
|
||||
assert find_retired_xai_refs("nope") == [] # type: ignore[arg-type]
|
||||
|
||||
def test_no_xai_models_no_issues(self):
|
||||
cfg = {
|
||||
"principal": {"provider": "openai", "model": "gpt-4o"},
|
||||
"auxiliary": {"vision": {"model": "claude-sonnet-4-6"}},
|
||||
"delegation": {"model": "openai/o3"},
|
||||
}
|
||||
assert find_retired_xai_refs(cfg) == []
|
||||
|
||||
def test_xai_valid_model_not_flagged(self):
|
||||
cfg = {
|
||||
"principal": {"model": "grok-4.3"},
|
||||
"auxiliary": {
|
||||
"vision": {"model": "grok-4.20-0309-reasoning"},
|
||||
"fast": {"model": "grok-4-fast"},
|
||||
"fast_1": {"model": "grok-4-1-fast"},
|
||||
"bare": {"model": "grok-4"},
|
||||
},
|
||||
}
|
||||
assert find_retired_xai_refs(cfg) == []
|
||||
|
||||
|
||||
class TestFindRetiredPerSlot:
|
||||
def test_principal_retired(self):
|
||||
cfg = {"principal": {"model": "grok-code-fast-1"}}
|
||||
issues = find_retired_xai_refs(cfg)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].config_path == "principal.model"
|
||||
assert issues[0].current_model == "grok-code-fast-1"
|
||||
assert issues[0].replacement == "grok-4.3"
|
||||
assert issues[0].reasoning_effort is None
|
||||
|
||||
def test_principal_with_x_ai_prefix(self):
|
||||
cfg = {"principal": {"model": "x-ai/grok-4-1-fast-non-reasoning"}}
|
||||
issues = find_retired_xai_refs(cfg)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].current_model == "x-ai/grok-4-1-fast-non-reasoning"
|
||||
assert issues[0].replacement == "grok-4.3"
|
||||
assert issues[0].reasoning_effort == "none"
|
||||
|
||||
def test_auxiliary_multiple_slots(self):
|
||||
cfg = {
|
||||
"auxiliary": {
|
||||
"vision": {"model": "grok-4-fast-reasoning"},
|
||||
"compression": {"model": "grok-code-fast-1"},
|
||||
"curator": {"model": "grok-4.3"}, # not retired
|
||||
"approval": {"model": "gpt-4o-mini"}, # not xAI
|
||||
}
|
||||
}
|
||||
issues = find_retired_xai_refs(cfg)
|
||||
assert sorted(_paths(issues)) == [
|
||||
"auxiliary.compression.model",
|
||||
"auxiliary.vision.model",
|
||||
]
|
||||
|
||||
def test_auxiliary_unknown_slot_still_scanned(self):
|
||||
cfg = {"auxiliary": {"future_slot_xyz": {"model": "grok-3"}}}
|
||||
issues = find_retired_xai_refs(cfg)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].config_path == "auxiliary.future_slot_xyz.model"
|
||||
|
||||
def test_delegation_retired(self):
|
||||
cfg = {"delegation": {"model": "grok-4-fast-reasoning"}}
|
||||
issues = find_retired_xai_refs(cfg)
|
||||
assert _paths(issues) == ["delegation.model"]
|
||||
|
||||
def test_tts_xai_retired(self):
|
||||
cfg = {"tts": {"xai": {"model": "grok-imagine-image-pro"}}}
|
||||
issues = find_retired_xai_refs(cfg)
|
||||
assert _paths(issues) == ["tts.xai.model"]
|
||||
assert issues[0].replacement == "grok-imagine-image-quality"
|
||||
|
||||
def test_image_gen_plugin_retired(self):
|
||||
cfg = {
|
||||
"plugins": {
|
||||
"image_gen": {
|
||||
"xai": {"model": "grok-imagine-image-pro"}
|
||||
}
|
||||
}
|
||||
}
|
||||
issues = find_retired_xai_refs(cfg)
|
||||
assert _paths(issues) == ["plugins.image_gen.xai.model"]
|
||||
assert issues[0].replacement == "grok-imagine-image-quality"
|
||||
|
||||
def test_full_trap_config(self):
|
||||
cfg = {
|
||||
"principal": {"model": "grok-4-1-fast-non-reasoning"},
|
||||
"auxiliary": {"vision": {"model": "grok-4-fast-reasoning"}},
|
||||
"delegation": {"model": "grok-code-fast-1"},
|
||||
"tts": {"xai": {"model": "grok-3"}}, # text model in TTS slot, but valid path
|
||||
"plugins": {"image_gen": {"xai": {"model": "grok-imagine-image-pro"}}},
|
||||
}
|
||||
issues = find_retired_xai_refs(cfg)
|
||||
assert len(issues) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMigrationSemantics:
|
||||
def test_non_reasoning_variant_recommends_reasoning_effort_none(self):
|
||||
cfg = {"principal": {"model": "grok-4-fast-non-reasoning"}}
|
||||
issue = find_retired_xai_refs(cfg)[0]
|
||||
assert issue.reasoning_effort == "none"
|
||||
|
||||
def test_reasoning_variant_no_extra_param(self):
|
||||
cfg = {"principal": {"model": "grok-4-1-fast-reasoning"}}
|
||||
issue = find_retired_xai_refs(cfg)[0]
|
||||
assert issue.reasoning_effort is None
|
||||
|
||||
def test_grok_3_maps_to_grok_4_3(self):
|
||||
cfg = {"principal": {"model": "grok-3"}}
|
||||
issue = find_retired_xai_refs(cfg)[0]
|
||||
assert issue.replacement == "grok-4.3"
|
||||
|
||||
def test_imagine_pro_maps_to_imagine_quality(self):
|
||||
cfg = {"plugins": {"image_gen": {"xai": {"model": "grok-imagine-image-pro"}}}}
|
||||
issue = find_retired_xai_refs(cfg)[0]
|
||||
assert issue.replacement == "grok-imagine-image-quality"
|
||||
|
||||
def test_all_retired_have_replacement(self):
|
||||
for name, entry in _RETIRED_MODELS.items():
|
||||
assert entry.get("replacement"), f"{name} has no replacement"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_issue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatIssue:
|
||||
def test_basic_format(self):
|
||||
issue = RetirementIssue(
|
||||
config_path="principal.model",
|
||||
current_model="grok-3",
|
||||
replacement="grok-4.3",
|
||||
)
|
||||
s = format_issue(issue)
|
||||
assert "principal.model" in s
|
||||
assert "'grok-3'" in s
|
||||
assert "'grok-4.3'" in s
|
||||
|
||||
def test_includes_reasoning_effort_when_set(self):
|
||||
issue = RetirementIssue(
|
||||
config_path="principal.model",
|
||||
current_model="grok-4-fast-non-reasoning",
|
||||
replacement="grok-4.3",
|
||||
reasoning_effort="none",
|
||||
)
|
||||
s = format_issue(issue)
|
||||
assert 'reasoning_effort: "none"' in s
|
||||
|
||||
def test_omits_reasoning_effort_when_none(self):
|
||||
issue = RetirementIssue(
|
||||
config_path="principal.model",
|
||||
current_model="grok-code-fast-1",
|
||||
replacement="grok-4.3",
|
||||
reasoning_effort=None,
|
||||
)
|
||||
s = format_issue(issue)
|
||||
assert "reasoning_effort" not in s
|
||||
|
||||
def test_includes_note_when_set(self):
|
||||
issue = RetirementIssue(
|
||||
config_path="principal.model",
|
||||
current_model="grok-3",
|
||||
replacement="grok-4.3",
|
||||
note="ambiguous variant",
|
||||
)
|
||||
s = format_issue(issue)
|
||||
assert "[note: ambiguous variant]" in s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level constants sanity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestModuleConstants:
|
||||
def test_retirement_date_is_may_15(self):
|
||||
assert "May 15, 2026" == RETIREMENT_DATE
|
||||
|
||||
def test_migration_guide_url_points_to_xai(self):
|
||||
assert MIGRATION_GUIDE_URL.startswith("https://docs.x.ai/")
|
||||
assert "may-15" in MIGRATION_GUIDE_URL.lower()
|
||||
|
||||
def test_retired_models_keyset_matches_doc(self):
|
||||
# Snapshot test: if xAI's list changes we want CI to flag it.
|
||||
expected = {
|
||||
"grok-4-0709",
|
||||
"grok-4-fast-reasoning",
|
||||
"grok-4-fast-non-reasoning",
|
||||
"grok-4-1-fast-reasoning",
|
||||
"grok-4-1-fast-non-reasoning",
|
||||
"grok-code-fast-1",
|
||||
"grok-3",
|
||||
"grok-imagine-image-pro",
|
||||
}
|
||||
assert set(_RETIRED_MODELS.keys()) == expected
|
||||
|
|
@ -110,8 +110,6 @@ class TestFlushDeduplication:
|
|||
db = SessionDB(db_path=db_path)
|
||||
|
||||
agent = self._make_agent(db)
|
||||
# Stub out _save_session_log to avoid file I/O
|
||||
agent._save_session_log = MagicMock()
|
||||
|
||||
conversation_history = [{"role": "user", "content": "old"}]
|
||||
messages = list(conversation_history) + [
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def _make_agent(monkeypatch, api_mode, provider, response_fn):
|
|||
kw.update(skip_context_files=True, skip_memory=True, max_iterations=4)
|
||||
super().__init__(*a, **kw)
|
||||
self._cleanup_task_resources = self._persist_session = lambda *a, **k: None
|
||||
self._save_trajectory = self._save_session_log = lambda *a, **k: None
|
||||
self._save_trajectory = lambda *a, **k: None
|
||||
|
||||
def run_conversation(self, msg, conversation_history=None, task_id=None):
|
||||
self._interruptible_api_call = lambda kw: response_fn()
|
||||
|
|
|
|||
|
|
@ -9,11 +9,7 @@ def _agent_with_stubbed_persistence():
|
|||
agent._persist_user_message_override = None
|
||||
agent._session_db = None
|
||||
agent._session_messages = []
|
||||
agent.saved_session_logs = []
|
||||
agent.flushed_session_db_messages = []
|
||||
agent._save_session_log = lambda messages: agent.saved_session_logs.append(
|
||||
[m.copy() for m in messages]
|
||||
)
|
||||
agent._flush_messages_to_session_db = lambda messages, conversation_history=None: (
|
||||
agent.flushed_session_db_messages.append([m.copy() for m in messages])
|
||||
)
|
||||
|
|
@ -60,7 +56,7 @@ def test_persist_session_strips_trailing_empty_recovery_scaffolding():
|
|||
assert messages == [
|
||||
{"role": "user", "content": "run the task"},
|
||||
]
|
||||
assert agent.saved_session_logs[-1] == messages
|
||||
assert agent.flushed_session_db_messages[-1] == messages
|
||||
assert all(not msg.get("_empty_recovery_synthetic") for msg in messages)
|
||||
|
||||
|
||||
|
|
@ -77,7 +73,7 @@ def test_persist_session_keeps_unmarked_terminal_empty_response():
|
|||
{"role": "user", "content": "run the task"},
|
||||
{"role": "assistant", "content": "(empty)"},
|
||||
]
|
||||
assert agent.saved_session_logs[-1] == messages
|
||||
assert agent.flushed_session_db_messages[-1] == messages
|
||||
|
||||
|
||||
def test_persist_session_strips_marked_terminal_empty_sentinel():
|
||||
|
|
@ -94,5 +90,5 @@ def test_persist_session_strips_marked_terminal_empty_sentinel():
|
|||
AIAgent._persist_session(agent, messages, conversation_history=[])
|
||||
|
||||
assert messages == [{"role": "user", "content": "continue"}]
|
||||
assert agent.saved_session_logs[-1] == messages
|
||||
assert agent.flushed_session_db_messages[-1] == messages
|
||||
assert all(not msg.get("_empty_terminal_sentinel") for msg in messages)
|
||||
|
|
|
|||
|
|
@ -554,23 +554,50 @@ class TestExtractReasoning:
|
|||
assert result == "from structured field"
|
||||
|
||||
|
||||
class TestCleanSessionContent:
|
||||
def test_none_passthrough(self):
|
||||
assert AIAgent._clean_session_content(None) is None
|
||||
class TestSessionJsonSnapshotOptIn:
|
||||
"""Regression: per-session JSON snapshot writer is opt-in via config.
|
||||
|
||||
def test_scratchpad_converted(self):
|
||||
text = "<REASONING_SCRATCHPAD>think</REASONING_SCRATCHPAD> answer"
|
||||
result = AIAgent._clean_session_content(text)
|
||||
assert "<REASONING_SCRATCHPAD>" not in result
|
||||
assert "<think>" in result
|
||||
state.db is canonical (PR #29182). ``sessions.write_json_snapshots``
|
||||
defaults to False, so the agent must NOT write ``session_{sid}.json``
|
||||
files by default — that behavior caused multi-GB sessions directories
|
||||
on heavy users. Users can opt back in for external tooling that reads
|
||||
the JSON files directly.
|
||||
"""
|
||||
|
||||
def test_extra_newlines_cleaned(self):
|
||||
text = "\n\n\n<think>x</think>\n\n\nafter"
|
||||
result = AIAgent._clean_session_content(text)
|
||||
# Should not have excessive newlines around think block
|
||||
assert "\n\n\n" not in result
|
||||
# Content after think block must be preserved
|
||||
assert "after" in result
|
||||
def test_session_json_disabled_by_default(self, agent):
|
||||
# Default config: writer is gated off.
|
||||
assert getattr(agent, "_session_json_enabled", False) is False, (
|
||||
"sessions.write_json_snapshots must default to False"
|
||||
)
|
||||
|
||||
def test_save_session_log_noops_when_disabled(self, agent, tmp_path):
|
||||
# When disabled, calling the method must not write any file even
|
||||
# if logs_dir is writable and messages are non-empty.
|
||||
agent._session_json_enabled = False
|
||||
agent.logs_dir = tmp_path
|
||||
agent._session_messages = [{"role": "user", "content": "hello"}]
|
||||
agent._save_session_log()
|
||||
# No session_*.json must appear under logs_dir.
|
||||
assert list(tmp_path.glob("session_*.json")) == []
|
||||
|
||||
def test_save_session_log_writes_when_enabled(self, agent, tmp_path):
|
||||
# Opt-in path: with the flag on and a session_id, the writer must
|
||||
# produce ``session_{sid}.json`` under logs_dir.
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
agent._save_session_log(messages)
|
||||
expected = tmp_path / f"session_{agent.session_id}.json"
|
||||
assert expected.exists(), (
|
||||
"Opt-in writer must produce session_{sid}.json under logs_dir"
|
||||
)
|
||||
|
||||
def test_logs_dir_retained_for_request_dumps(self, agent):
|
||||
# logs_dir is kept unconditionally because
|
||||
# agent_runtime_helpers.dump_api_request_debug still writes
|
||||
# request_dump_*.json there (debug breadcrumb path), independent of
|
||||
# the session JSON opt-in.
|
||||
assert hasattr(agent, "logs_dir")
|
||||
|
||||
|
||||
class TestGetMessagesUpToLastAssistant:
|
||||
|
|
@ -1901,7 +1928,6 @@ class TestExecuteToolCalls:
|
|||
agent._interruptible_api_call = _fake_api_call
|
||||
agent._persist_session = lambda *args, **kwargs: None
|
||||
agent._save_trajectory = lambda *args, **kwargs: None
|
||||
agent._save_session_log = lambda *args, **kwargs: None
|
||||
|
||||
captured = io.StringIO()
|
||||
agent._print_fn = lambda *args, **kw: print(*args, file=captured, **kw)
|
||||
|
|
@ -4253,22 +4279,6 @@ class TestSafeWriter:
|
|||
assert inner.getvalue() == "test"
|
||||
|
||||
|
||||
class TestSaveSessionLogAtomicWrite:
|
||||
def test_uses_shared_atomic_json_helper(self, agent, tmp_path):
|
||||
agent.session_log_file = tmp_path / "session.json"
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
|
||||
with patch("run_agent.atomic_json_write", create=True) as mock_atomic_write:
|
||||
agent._save_session_log(messages)
|
||||
|
||||
mock_atomic_write.assert_called_once()
|
||||
call_args = mock_atomic_write.call_args
|
||||
assert call_args.args[0] == agent.session_log_file
|
||||
payload = call_args.args[1]
|
||||
assert payload["session_id"] == agent.session_id
|
||||
assert payload["messages"] == messages
|
||||
assert call_args.kwargs["indent"] == 2
|
||||
assert call_args.kwargs["default"] is str
|
||||
|
||||
|
||||
# ===================================================================
|
||||
|
|
@ -5056,12 +5066,9 @@ class TestPersistUserMessageOverride:
|
|||
{"role": "assistant", "content": "Hi!"},
|
||||
]
|
||||
|
||||
with patch.object(agent, "_save_session_log") as mock_save:
|
||||
agent._persist_session(messages, [])
|
||||
agent._persist_session(messages, [])
|
||||
|
||||
assert messages[0]["content"] == "Hello there"
|
||||
saved_messages = mock_save.call_args.args[0]
|
||||
assert saved_messages[0]["content"] == "Hello there"
|
||||
first_db_write = agent._session_db.append_message.call_args_list[0].kwargs
|
||||
assert first_db_write["content"] == "Hello there"
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ def _build_agent(monkeypatch):
|
|||
agent._cleanup_task_resources = lambda task_id: None
|
||||
agent._persist_session = lambda messages, history=None: None
|
||||
agent._save_trajectory = lambda messages, user_message, completed: None
|
||||
agent._save_session_log = lambda messages: None
|
||||
return agent
|
||||
|
||||
|
||||
|
|
@ -75,7 +74,6 @@ def _build_copilot_agent(monkeypatch, *, model="gpt-5.4"):
|
|||
agent._cleanup_task_resources = lambda task_id: None
|
||||
agent._persist_session = lambda messages, history=None: None
|
||||
agent._save_trajectory = lambda messages, user_message, completed: None
|
||||
agent._save_session_log = lambda messages: None
|
||||
return agent
|
||||
|
||||
|
||||
|
|
@ -335,7 +333,6 @@ def test_build_api_kwargs_codex_clamps_minimal_effort(monkeypatch):
|
|||
agent._cleanup_task_resources = lambda task_id: None
|
||||
agent._persist_session = lambda messages, history=None: None
|
||||
agent._save_trajectory = lambda messages, user_message, completed: None
|
||||
agent._save_session_log = lambda messages: None
|
||||
|
||||
kwargs = agent._build_api_kwargs(
|
||||
[
|
||||
|
|
@ -365,7 +362,6 @@ def test_build_api_kwargs_codex_preserves_supported_efforts(monkeypatch):
|
|||
agent._cleanup_task_resources = lambda task_id: None
|
||||
agent._persist_session = lambda messages, history=None: None
|
||||
agent._save_trajectory = lambda messages, user_message, completed: None
|
||||
agent._save_session_log = lambda messages: None
|
||||
|
||||
kwargs = agent._build_api_kwargs(
|
||||
[
|
||||
|
|
@ -594,7 +590,6 @@ def _build_xai_oauth_agent(monkeypatch):
|
|||
agent._cleanup_task_resources = lambda task_id: None
|
||||
agent._persist_session = lambda messages, history=None: None
|
||||
agent._save_trajectory = lambda messages, user_message, completed: None
|
||||
agent._save_session_log = lambda messages: None
|
||||
return agent
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3995,7 +3995,7 @@ def test_browser_manage_connect_sets_env_and_cleans_twice(monkeypatch):
|
|||
|
||||
assert resp["result"]["connected"] is True
|
||||
assert resp["result"]["url"] == "http://127.0.0.1:9222"
|
||||
assert resp["result"]["messages"] == ["Chrome is already listening on port 9222"]
|
||||
assert resp["result"]["messages"] == ["Chromium-family browser is already listening on port 9222"]
|
||||
assert os.environ.get("BROWSER_CDP_URL") == "http://127.0.0.1:9222"
|
||||
# First cleanup runs against the OLD env (none here), second against the NEW.
|
||||
assert cleanup_calls == ["", "http://127.0.0.1:9222"]
|
||||
|
|
@ -4015,7 +4015,7 @@ def test_browser_manage_connect_defaults_to_loopback(monkeypatch):
|
|||
|
||||
assert resp["result"]["connected"] is True
|
||||
assert resp["result"]["url"] == "http://127.0.0.1:9222"
|
||||
assert resp["result"]["messages"] == ["Chrome is already listening on port 9222"]
|
||||
assert resp["result"]["messages"] == ["Chromium-family browser is already listening on port 9222"]
|
||||
assert urls[0] == "http://127.0.0.1:9222/json/version"
|
||||
|
||||
|
||||
|
|
@ -4058,10 +4058,10 @@ def test_browser_manage_connect_default_local_reports_launch_hint(monkeypatch):
|
|||
assert resp["result"]["url"] == "http://127.0.0.1:9222"
|
||||
assert (
|
||||
resp["result"]["messages"][0]
|
||||
== "Chrome isn't running with remote debugging — attempting to launch..."
|
||||
== "Chromium-family browser isn't running with remote debugging — attempting to launch..."
|
||||
)
|
||||
assert any(
|
||||
"No Chrome/Chromium executable was found" in line
|
||||
"No supported Chromium-family browser executable was found" in line
|
||||
for line in resp["result"]["messages"]
|
||||
)
|
||||
assert any(
|
||||
|
|
@ -4188,8 +4188,8 @@ def test_browser_manage_connect_default_local_retries_after_launch(monkeypatch):
|
|||
assert resp["result"]["connected"] is True
|
||||
assert resp["result"]["url"] == "http://127.0.0.1:9222"
|
||||
assert resp["result"]["messages"] == [
|
||||
"Chrome isn't running with remote debugging — attempting to launch...",
|
||||
"Chrome launched and listening on port 9222",
|
||||
"Chromium-family browser isn't running with remote debugging — attempting to launch...",
|
||||
"Chromium-family browser launched and listening on port 9222",
|
||||
]
|
||||
assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222"
|
||||
|
||||
|
|
|
|||
|
|
@ -509,3 +509,141 @@ class TestParseErrorSignalling:
|
|||
ops, err = parse_v4a_patch(patch)
|
||||
assert err is None
|
||||
assert len(ops) == 1
|
||||
|
||||
|
||||
class TestV4ALspDiagnosticsPropagation:
|
||||
"""V4A patches must surface ``WriteResult.lsp_diagnostics`` from the
|
||||
underlying ``write_file`` calls on ``PatchResult.lsp_diagnostics``.
|
||||
|
||||
Without explicit propagation the LSP tier's output gets silently
|
||||
dropped on the V4A code path — see Copilot review #3271017295 on
|
||||
PR #29054. The shell-linter LSP skip introduced by that PR makes
|
||||
this gap visible: a ``.ts`` / ``.go`` / ``.rs`` V4A patch with LSP
|
||||
active would otherwise return ``lint = {f: {skipped: True, ...}}``
|
||||
and zero diagnostics from any channel.
|
||||
"""
|
||||
|
||||
def _build_ops_writing(self, path: str, content: str):
|
||||
"""Build a single ADD operation that writes ``content`` to ``path``."""
|
||||
# Use the V4A parser so we don't have to construct PatchOperation
|
||||
# / Hunk / Line objects by hand.
|
||||
lines = "\n".join(f"+{line}" for line in content.splitlines())
|
||||
patch_text = (
|
||||
"*** Begin Patch\n"
|
||||
f"*** Add File: {path}\n"
|
||||
f"{lines}\n"
|
||||
"*** End Patch"
|
||||
)
|
||||
ops, err = parse_v4a_patch(patch_text)
|
||||
assert err is None, err
|
||||
return ops
|
||||
|
||||
def test_lsp_diagnostics_propagated_from_write_file_on_add(self):
|
||||
"""ADD op: ``WriteResult.lsp_diagnostics`` flows through to
|
||||
``PatchResult.lsp_diagnostics``."""
|
||||
ops = self._build_ops_writing("foo.ts", "const x: number = 1\n")
|
||||
|
||||
diag_block = (
|
||||
"<diagnostics file=\"foo.ts\">\n"
|
||||
"ERROR [1:7] some diagnostic\n"
|
||||
"</diagnostics>"
|
||||
)
|
||||
|
||||
class FakeFileOps:
|
||||
def write_file(self, path, content):
|
||||
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
|
||||
|
||||
def _check_lint(self, path):
|
||||
return SimpleNamespace(to_dict=lambda: {"skipped": True})
|
||||
|
||||
result = apply_v4a_operations(ops, FakeFileOps())
|
||||
|
||||
assert result.success is True
|
||||
assert result.lsp_diagnostics == diag_block
|
||||
|
||||
def test_lsp_diagnostics_propagated_from_write_file_on_update(self):
|
||||
"""UPDATE op: ``WriteResult.lsp_diagnostics`` flows through to
|
||||
``PatchResult.lsp_diagnostics``."""
|
||||
patch_text = (
|
||||
"*** Begin Patch\n"
|
||||
"*** Update File: bar.ts\n"
|
||||
"-old\n"
|
||||
"+new\n"
|
||||
"*** End Patch"
|
||||
)
|
||||
ops, err = parse_v4a_patch(patch_text)
|
||||
assert err is None
|
||||
|
||||
diag_block = (
|
||||
"<diagnostics file=\"bar.ts\">\n"
|
||||
"ERROR [3:1] something\n"
|
||||
"</diagnostics>"
|
||||
)
|
||||
|
||||
class FakeFileOps:
|
||||
def read_file_raw(self, path):
|
||||
return SimpleNamespace(content="ctx\nold\nctx\n", error=None)
|
||||
|
||||
def write_file(self, path, content):
|
||||
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
|
||||
|
||||
def _check_lint(self, path):
|
||||
return SimpleNamespace(to_dict=lambda: {"skipped": True})
|
||||
|
||||
result = apply_v4a_operations(ops, FakeFileOps())
|
||||
|
||||
assert result.success is True
|
||||
assert result.lsp_diagnostics == diag_block
|
||||
|
||||
def test_lsp_diagnostics_none_when_no_blocks_emitted(self):
|
||||
"""When no underlying ``write_file`` produced diagnostics, the
|
||||
aggregated field stays ``None`` (so it doesn't get serialized
|
||||
as an empty string in ``PatchResult.to_dict``)."""
|
||||
ops = self._build_ops_writing("foo.py", "x = 1\n")
|
||||
|
||||
class FakeFileOps:
|
||||
def write_file(self, path, content):
|
||||
# lsp_diagnostics omitted entirely (older WriteResult shape).
|
||||
return SimpleNamespace(error=None)
|
||||
|
||||
def _check_lint(self, path):
|
||||
return SimpleNamespace(to_dict=lambda: {"success": True})
|
||||
|
||||
result = apply_v4a_operations(ops, FakeFileOps())
|
||||
|
||||
assert result.success is True
|
||||
assert result.lsp_diagnostics is None
|
||||
|
||||
def test_lsp_diagnostics_combined_across_multiple_files(self):
|
||||
"""When several files in one V4A patch produce diagnostics,
|
||||
each block appears in the combined output so per-file attribution
|
||||
is preserved."""
|
||||
patch_text = (
|
||||
"*** Begin Patch\n"
|
||||
"*** Add File: a.ts\n"
|
||||
"+const a = 1\n"
|
||||
"*** Add File: b.ts\n"
|
||||
"+const b = 2\n"
|
||||
"*** End Patch"
|
||||
)
|
||||
ops, err = parse_v4a_patch(patch_text)
|
||||
assert err is None
|
||||
|
||||
per_file = {
|
||||
"a.ts": "<diagnostics file=\"a.ts\">\nERR a\n</diagnostics>",
|
||||
"b.ts": "<diagnostics file=\"b.ts\">\nERR b\n</diagnostics>",
|
||||
}
|
||||
|
||||
class FakeFileOps:
|
||||
def write_file(self, path, content):
|
||||
return SimpleNamespace(error=None, lsp_diagnostics=per_file[path])
|
||||
|
||||
def _check_lint(self, path):
|
||||
return SimpleNamespace(to_dict=lambda: {"skipped": True})
|
||||
|
||||
result = apply_v4a_operations(ops, FakeFileOps())
|
||||
|
||||
assert result.success is True
|
||||
assert result.lsp_diagnostics is not None
|
||||
assert per_file["a.ts"] in result.lsp_diagnostics
|
||||
assert per_file["b.ts"] in result.lsp_diagnostics
|
||||
|
|
|
|||
81
tests/tools/test_tts_xai_speech_tags.py
Normal file
81
tests/tools/test_tts_xai_speech_tags.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Tests for xAI TTS speech-tag handling."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from tools.tts_tool import _apply_xai_auto_speech_tags, _generate_xai_tts
|
||||
|
||||
|
||||
def test_apply_xai_auto_speech_tags_adds_light_pause_after_first_sentence():
|
||||
text = "Bonjour Monsieur Talbot. Ceci est un test de réponse vocale."
|
||||
|
||||
assert _apply_xai_auto_speech_tags(text) == (
|
||||
"Bonjour Monsieur Talbot. [pause] Ceci est un test de réponse vocale."
|
||||
)
|
||||
|
||||
|
||||
def test_apply_xai_auto_speech_tags_preserves_explicit_tags():
|
||||
text = "Bonjour. [pause] <whisper>Déjà balisé.</whisper>"
|
||||
|
||||
assert _apply_xai_auto_speech_tags(text) == text
|
||||
|
||||
|
||||
def test_apply_xai_auto_speech_tags_preserves_all_documented_xai_tags():
|
||||
text = "Bonjour Monsieur Talbot. [sigh] <slow>Je parle lentement.</slow> <emphasis>Important.</emphasis>"
|
||||
|
||||
assert _apply_xai_auto_speech_tags(text) == text
|
||||
|
||||
|
||||
def test_generate_xai_tts_sends_auto_speech_tags_when_enabled(tmp_path, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeResponse:
|
||||
content = b"mp3"
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def fake_post(url, headers, json, timeout):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
captured["json"] = json
|
||||
captured["timeout"] = timeout
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
out = tmp_path / "out.mp3"
|
||||
_generate_xai_tts(
|
||||
"Bonjour Monsieur Talbot. Ceci est un test.",
|
||||
str(out),
|
||||
{"xai": {"voice_id": "ara", "language": "fr", "auto_speech_tags": True}},
|
||||
)
|
||||
|
||||
assert out.read_bytes() == b"mp3"
|
||||
assert captured["url"] == "https://api.x.ai/v1/tts"
|
||||
assert captured["json"]["voice_id"] == "ara"
|
||||
assert captured["json"]["language"] == "fr"
|
||||
assert captured["json"]["text"] == "Bonjour Monsieur Talbot. [pause] Ceci est un test."
|
||||
|
||||
|
||||
def test_generate_xai_tts_leaves_text_plain_by_default(tmp_path, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
fake_response = Mock()
|
||||
fake_response.content = b"mp3"
|
||||
fake_response.raise_for_status.return_value = None
|
||||
|
||||
def fake_post(url, headers, json, timeout):
|
||||
captured["json"] = json
|
||||
return fake_response
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
_generate_xai_tts(
|
||||
"Bonjour Monsieur Talbot. Ceci est un test.",
|
||||
str(tmp_path / "out.mp3"),
|
||||
{"xai": {"voice_id": "ara", "language": "fr"}},
|
||||
)
|
||||
|
||||
assert captured["json"]["text"] == "Bonjour Monsieur Talbot. Ceci est un test."
|
||||
|
|
@ -56,7 +56,7 @@ def get_camofox_url() -> str:
|
|||
def is_camofox_mode() -> bool:
|
||||
"""True when Camofox backend is configured and no CDP override is active.
|
||||
|
||||
When the user has explicitly connected to a live Chrome instance via
|
||||
When the user has explicitly connected to a live Chromium-family browser via
|
||||
``/browser connect`` (which sets ``BROWSER_CDP_URL``), the CDP connection
|
||||
takes priority over Camofox so the browser tools operate on the real
|
||||
browser instead of being silently routed to the Camofox backend.
|
||||
|
|
|
|||
|
|
@ -358,8 +358,9 @@ def browser_cdp(
|
|||
if not endpoint:
|
||||
return tool_error(
|
||||
"No CDP endpoint is available. Run '/browser connect' to attach "
|
||||
"to a running Chrome, or set 'browser.cdp_url' in config.yaml. "
|
||||
"The Camofox backend is REST-only and does not expose CDP.",
|
||||
"to a running Chrome, Brave, Chromium, or Edge browser, or set "
|
||||
"'browser.cdp_url' in config.yaml. The Camofox backend is REST-only "
|
||||
"and does not expose CDP.",
|
||||
cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
|
||||
|
|
@ -367,8 +368,8 @@ def browser_cdp(
|
|||
return tool_error(
|
||||
f"CDP endpoint is not a WebSocket URL: {endpoint!r}. "
|
||||
"Expected ws://... or wss://... — the /browser connect "
|
||||
"resolver should have rewritten this. Check that Chrome is "
|
||||
"actually listening on the debug port."
|
||||
"resolver should have rewritten this. Check that a Chromium-family "
|
||||
"browser is actually listening on the debug port."
|
||||
)
|
||||
|
||||
call_params: Dict[str, Any] = params or {}
|
||||
|
|
@ -431,12 +432,12 @@ BROWSER_CDP_SCHEMA: Dict[str, Any] = {
|
|||
"browser operations not covered by browser_navigate, browser_click, "
|
||||
"browser_console, etc.\n\n"
|
||||
"**Requires a reachable CDP endpoint.** Available when the user has "
|
||||
"run '/browser connect' to attach to a running Chrome, or when "
|
||||
"'browser.cdp_url' is set in config.yaml. Not currently wired up for "
|
||||
"cloud backends (Browserbase, Browser Use, Firecrawl) — those expose "
|
||||
"CDP per session but live-session routing is a follow-up. Camofox is "
|
||||
"REST-only and will never support CDP. If the tool is in your toolset "
|
||||
"at all, a CDP endpoint is already reachable.\n\n"
|
||||
"run '/browser connect' to attach to a running Chrome, Brave, Chromium, "
|
||||
"or Edge browser, or when 'browser.cdp_url' is set in config.yaml. "
|
||||
"Not currently wired up for cloud backends (Browserbase, Browser Use, "
|
||||
"Firecrawl) — those expose CDP per session but live-session routing is "
|
||||
"a follow-up. Camofox is REST-only and will never support CDP. If the "
|
||||
"tool is in your toolset at all, a CDP endpoint is already reachable.\n\n"
|
||||
f"**CDP method reference:** {CDP_DOCS_URL} — use web_extract on a "
|
||||
"method's URL (e.g. '/tot/Page/#method-handleJavaScriptDialog') "
|
||||
"to look up parameters and return shape.\n\n"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ accept or dismiss.
|
|||
|
||||
Gated on the same ``_browser_cdp_check`` as ``browser_cdp`` so it only
|
||||
appears when a CDP endpoint is reachable (Browserbase with a
|
||||
``connectUrl``, local Chrome via ``/browser connect``, or
|
||||
``connectUrl``, local Chromium-family browser via ``/browser connect``, or
|
||||
``browser.cdp_url`` set in config).
|
||||
|
||||
See ``website/docs/developer-guide/browser-supervisor.md`` for the full
|
||||
|
|
@ -40,7 +40,7 @@ BROWSER_DIALOG_SCHEMA: Dict[str, Any] = {
|
|||
"happens when a second dialog fires while the first is still open), "
|
||||
"pass ``dialog_id`` from the snapshot to disambiguate.\n\n"
|
||||
"**Availability:** only present when a CDP-capable backend is "
|
||||
"attached — Browserbase sessions, local Chrome via "
|
||||
"attached — Browserbase sessions, local Chromium-family browser via "
|
||||
"``/browser connect``, or ``browser.cdp_url`` in config.yaml. "
|
||||
"Not available on Camofox (REST-only) or the default Playwright "
|
||||
"local browser (CDP port is hidden)."
|
||||
|
|
|
|||
|
|
@ -326,6 +326,44 @@ LINTERS = {
|
|||
'.rs': 'rustfmt --check {file} 2>&1',
|
||||
}
|
||||
|
||||
# Extensions where the per-file shell linter is structurally weaker than
|
||||
# a real LSP server AND produces phantom errors on real-world projects:
|
||||
#
|
||||
# - ``.ts``: ``tsc --noEmit FILE.ts`` ignores ``tsconfig.json`` and
|
||||
# defaults to no-lib / ES5, so every ES2015+ stdlib reference
|
||||
# (``Promise``, ``Map``, ``Set``, ``ReadonlySet``, ``Iterable``,
|
||||
# ``Math.imul``, ``Number.isFinite``, etc.) reports as missing. This
|
||||
# floods the agent's lint field with 20K+ tokens of false positives on
|
||||
# every edit. No supported tsc flag fixes the single-file invocation;
|
||||
# the canonical replacement is ``tsserver`` via LSP, which respects
|
||||
# tsconfig and gives true diagnostics.
|
||||
#
|
||||
# ``.tsx`` is intentionally NOT in ``LINTERS`` (and therefore not
|
||||
# here): it has no shell linter entry, so it falls through to the
|
||||
# ``ext not in LINTERS`` skip case unchanged. Pre-PR behavior:
|
||||
# ``.tsx`` was implicitly ``skipped``. Keeping it that way means
|
||||
# ``.tsx`` edits with LSP disabled get no per-file syntax check
|
||||
# (same as before this PR) instead of the broken ``tsc`` invocation
|
||||
# that ``.ts`` used to get. When LSP is enabled, ``.tsx`` is covered
|
||||
# by the LSP tier via ``_maybe_lsp_diagnostics`` exactly as ``.ts``.
|
||||
#
|
||||
# - ``.go``: ``go vet FILE.go`` fails outside a module / GOPATH with
|
||||
# "cannot find package" — already partially handled by
|
||||
# ``_LINTER_UNUSABLE_PATTERNS`` but only when the package error is the
|
||||
# ONLY output; mixed real+phantom output still leaks through.
|
||||
# ``gopls`` is the canonical replacement.
|
||||
#
|
||||
# - ``.rs``: ``rustfmt --check FILE.rs`` is style, not type-checking, and
|
||||
# rejects non-Cargo project files. ``rust-analyzer`` is the canonical
|
||||
# replacement.
|
||||
#
|
||||
# When the LSP service is configured AND ``enabled_for(path)`` for this
|
||||
# extension's file, ``_check_lint`` skips the shell linter for these
|
||||
# extensions — the ``lsp_diagnostics`` channel carries the real signal.
|
||||
# Everything else in ``LINTERS`` (Python ``py_compile``, ``node --check``)
|
||||
# is fast, file-local, and correct, so it runs unconditionally.
|
||||
_SHELL_LINTER_LSP_REDUNDANT = frozenset({'.ts', '.go', '.rs'})
|
||||
|
||||
|
||||
# Patterns that indicate the linter base command exists on PATH but
|
||||
# couldn't actually run — e.g. ``npx tsc`` when tsc isn't installed in
|
||||
|
|
@ -1169,6 +1207,19 @@ class ShellFileOperations(FileOperations):
|
|||
if ext not in LINTERS:
|
||||
return LintResult(skipped=True, message=f"No linter for {ext} files")
|
||||
|
||||
# If a real LSP server is active and claims this file, skip the
|
||||
# shell linter for extensions whose per-file shell invocation is
|
||||
# structurally weaker / floods phantom errors. See
|
||||
# ``_SHELL_LINTER_LSP_REDUNDANT`` above for the rationale per ext.
|
||||
# The LSP tier runs separately via ``_maybe_lsp_diagnostics`` and
|
||||
# carries the real diagnostics in ``lsp_diagnostics`` on the
|
||||
# WriteResult / PatchResult.
|
||||
if ext in _SHELL_LINTER_LSP_REDUNDANT and self._lsp_will_handle(path):
|
||||
return LintResult(
|
||||
skipped=True,
|
||||
message=f"LSP server handles {ext} — shell linter skipped",
|
||||
)
|
||||
|
||||
linter_cmd = LINTERS[ext]
|
||||
# Extract the base command (first word)
|
||||
base_cmd = linter_cmd.split()[0]
|
||||
|
|
@ -1332,6 +1383,40 @@ class ShellFileOperations(FileOperations):
|
|||
return True
|
||||
return False
|
||||
|
||||
def _lsp_will_handle(self, path: str) -> bool:
|
||||
"""Return True iff the LSP service is active AND will lint this file.
|
||||
|
||||
Stronger than :meth:`_lsp_handles_extension` — that one only checks
|
||||
the static server registry. This one additionally requires the
|
||||
LSP service to be configured/enabled and the file to pass
|
||||
:meth:`agent.lsp.manager.LSPService.enabled_for` (which gates on
|
||||
workspace detection, disabled-server set, and the broken-pair
|
||||
short-circuit).
|
||||
|
||||
Used by :meth:`_check_lint` to decide whether to skip the per-file
|
||||
shell linter for extensions in ``_SHELL_LINTER_LSP_REDUNDANT``.
|
||||
|
||||
Best-effort: any failure path returns False so the shell linter
|
||||
runs as before — never suppress lint based on an LSP probe that
|
||||
couldn't actually answer the question.
|
||||
"""
|
||||
if not self._lsp_local_only():
|
||||
return False
|
||||
try:
|
||||
from agent.lsp import get_service
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
try:
|
||||
svc = get_service()
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
if svc is None:
|
||||
return False
|
||||
try:
|
||||
return bool(svc.enabled_for(path))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def _snapshot_lsp_baseline(self, path: str) -> None:
|
||||
"""Capture pre-edit LSP diagnostics so the post-write delta is correct.
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,12 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
|||
files_created = []
|
||||
files_deleted = []
|
||||
all_diffs = []
|
||||
# Per-file LSP diagnostics blocks captured from underlying write_file
|
||||
# calls. V4A bypasses the WriteResult / PatchResult plumbing that
|
||||
# write_file and patch_replace use, so without explicit propagation
|
||||
# the LSP tier's output gets silently dropped — see
|
||||
# ``PatchResult.lsp_diagnostics`` aggregation below.
|
||||
lsp_blocks: List[str] = []
|
||||
errors = []
|
||||
|
||||
for op in operations:
|
||||
|
|
@ -372,6 +378,8 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
|||
if result[0]:
|
||||
files_created.append(op.file_path)
|
||||
all_diffs.append(result[1])
|
||||
if result[2]:
|
||||
lsp_blocks.append(result[2])
|
||||
else:
|
||||
errors.append(f"Failed to add {op.file_path}: {result[1]}")
|
||||
|
||||
|
|
@ -396,6 +404,8 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
|||
if result[0]:
|
||||
files_modified.append(op.file_path)
|
||||
all_diffs.append(result[1])
|
||||
if result[2]:
|
||||
lsp_blocks.append(result[2])
|
||||
else:
|
||||
errors.append(f"Failed to update {op.file_path}: {result[1]}")
|
||||
|
||||
|
|
@ -411,6 +421,13 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
|||
|
||||
combined_diff = '\n'.join(all_diffs)
|
||||
|
||||
# Combine per-file LSP diagnostics blocks. Each block already has
|
||||
# the ``<diagnostics file="...">`` header from
|
||||
# ``LSPService.report_for_file`` so concatenation is safe — the
|
||||
# agent (and any downstream parsers) can still attribute each
|
||||
# diagnostic to its file.
|
||||
combined_lsp = "\n\n".join(lsp_blocks) if lsp_blocks else None
|
||||
|
||||
if errors:
|
||||
return PatchResult(
|
||||
success=False,
|
||||
|
|
@ -419,6 +436,7 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
|||
files_created=files_created,
|
||||
files_deleted=files_deleted,
|
||||
lint=lint_results if lint_results else None,
|
||||
lsp_diagnostics=combined_lsp,
|
||||
error="Apply phase failed (state may be inconsistent — run `git diff` to assess):\n"
|
||||
+ "\n".join(f" • {e}" for e in errors),
|
||||
)
|
||||
|
|
@ -430,11 +448,19 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
|||
files_created=files_created,
|
||||
files_deleted=files_deleted,
|
||||
lint=lint_results if lint_results else None,
|
||||
lsp_diagnostics=combined_lsp,
|
||||
)
|
||||
|
||||
|
||||
def _apply_add(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
"""Apply an add file operation."""
|
||||
def _apply_add(op: PatchOperation, file_ops: Any) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Apply an add file operation.
|
||||
|
||||
Returns ``(success, diff_or_error, lsp_diagnostics)``. The third
|
||||
element carries the formatted ``<diagnostics>`` block from
|
||||
:class:`WriteResult.lsp_diagnostics` so V4A patches can surface
|
||||
semantic diagnostics from the LSP layer — without this, the LSP
|
||||
tier would silently swallow them on the V4A code path.
|
||||
"""
|
||||
# Extract content from hunks (all + lines)
|
||||
content_lines = []
|
||||
for hunk in op.hunks:
|
||||
|
|
@ -446,12 +472,12 @@ def _apply_add(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
|||
|
||||
result = file_ops.write_file(op.file_path, content)
|
||||
if result.error:
|
||||
return False, result.error
|
||||
return False, result.error, None
|
||||
|
||||
diff = f"--- /dev/null\n+++ b/{op.file_path}\n"
|
||||
diff += '\n'.join(f"+{line}" for line in content_lines)
|
||||
|
||||
return True, diff
|
||||
return True, diff, getattr(result, "lsp_diagnostics", None)
|
||||
|
||||
|
||||
def _apply_delete(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
|
|
@ -485,8 +511,12 @@ def _apply_move(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
|||
return True, diff
|
||||
|
||||
|
||||
def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
"""Apply an update file operation."""
|
||||
def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Apply an update file operation.
|
||||
|
||||
Returns ``(success, diff_or_error, lsp_diagnostics)`` — see
|
||||
:func:`_apply_add` for the rationale on the third element.
|
||||
"""
|
||||
# Deferred import: breaks the patch_parser ↔ fuzzy_match circular dependency
|
||||
from tools.fuzzy_match import fuzzy_find_and_replace
|
||||
|
||||
|
|
@ -494,7 +524,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
|||
read_result = file_ops.read_file_raw(op.file_path)
|
||||
|
||||
if read_result.error:
|
||||
return False, f"Cannot read file: {read_result.error}"
|
||||
return False, f"Cannot read file: {read_result.error}", None
|
||||
|
||||
current_content = read_result.content
|
||||
|
||||
|
|
@ -549,7 +579,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
|||
err_msg += format_no_match_hint(error, 0, search_pattern, new_content)
|
||||
except Exception:
|
||||
pass
|
||||
return False, err_msg
|
||||
return False, err_msg, None
|
||||
else:
|
||||
# Addition-only hunk (no context or removed lines).
|
||||
# Insert at the location indicated by the context hint, or at end of file.
|
||||
|
|
@ -563,7 +593,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
|||
return False, (
|
||||
f"Addition-only hunk: context hint '{hunk.context_hint}' is ambiguous "
|
||||
f"({occurrences} occurrences) — provide a more unique hint"
|
||||
)
|
||||
), None
|
||||
else:
|
||||
hint_pos = new_content.find(hunk.context_hint)
|
||||
# Insert after the line containing the context hint
|
||||
|
|
@ -578,7 +608,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
|||
# Write new content
|
||||
write_result = file_ops.write_file(op.file_path, new_content)
|
||||
if write_result.error:
|
||||
return False, write_result.error
|
||||
return False, write_result.error, None
|
||||
|
||||
# Generate diff
|
||||
diff_lines = difflib.unified_diff(
|
||||
|
|
@ -589,4 +619,4 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
|||
)
|
||||
diff = ''.join(diff_lines)
|
||||
|
||||
return True, diff
|
||||
return True, diff, getattr(write_result, "lsp_diagnostics", None)
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ DEFAULT_XAI_VOICE_ID = "eve"
|
|||
DEFAULT_XAI_LANGUAGE = "en"
|
||||
DEFAULT_XAI_SAMPLE_RATE = 24000
|
||||
DEFAULT_XAI_BIT_RATE = 128000
|
||||
DEFAULT_XAI_AUTO_SPEECH_TAGS = False
|
||||
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
DEFAULT_GEMINI_TTS_MODEL = "gemini-2.5-flash-preview-tts"
|
||||
DEFAULT_GEMINI_TTS_VOICE = "Kore"
|
||||
|
|
@ -892,6 +893,79 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
|||
# ===========================================================================
|
||||
# Provider: xAI TTS
|
||||
# ===========================================================================
|
||||
_XAI_INLINE_SPEECH_TAGS = (
|
||||
"pause",
|
||||
"long-pause",
|
||||
"hum-tune",
|
||||
"laugh",
|
||||
"chuckle",
|
||||
"giggle",
|
||||
"cry",
|
||||
"tsk",
|
||||
"tongue-click",
|
||||
"lip-smack",
|
||||
"breath",
|
||||
"inhale",
|
||||
"exhale",
|
||||
"sigh",
|
||||
)
|
||||
_XAI_WRAPPING_SPEECH_TAGS = (
|
||||
"soft",
|
||||
"whisper",
|
||||
"loud",
|
||||
"build-intensity",
|
||||
"decrease-intensity",
|
||||
"higher-pitch",
|
||||
"lower-pitch",
|
||||
"slow",
|
||||
"fast",
|
||||
"sing-song",
|
||||
"singing",
|
||||
"laugh-speak",
|
||||
"emphasis",
|
||||
)
|
||||
_XAI_SPEECH_TAG_RE = re.compile(
|
||||
r"(\[(?:" + "|".join(_XAI_INLINE_SPEECH_TAGS) + r")\]|</?(?:" + "|".join(_XAI_WRAPPING_SPEECH_TAGS) + r")>)",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_XAI_FIRST_SENTENCE_RE = re.compile(r"^(.{12,120}?[.!?…])\s+(?=\S)", flags=re.DOTALL)
|
||||
|
||||
|
||||
def _xai_bool_config(value: Any, default: bool = False) -> bool:
|
||||
"""Coerce common YAML/env bool spellings without treating random strings as true."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _apply_xai_auto_speech_tags(text: str) -> str:
|
||||
"""Add light xAI speech tags for more natural voice-mode replies.
|
||||
|
||||
The transform is intentionally conservative: it only inserts pauses. It
|
||||
never fabricates laughter or whispering, and it leaves explicit user/model
|
||||
speech tags untouched.
|
||||
"""
|
||||
clean = text.strip()
|
||||
if not clean or _XAI_SPEECH_TAG_RE.search(clean):
|
||||
return text
|
||||
|
||||
clean = re.sub(r"\n\s*\n+", " [pause] ", clean)
|
||||
clean = re.sub(r"\s*\n\s*", " ", clean)
|
||||
clean = _XAI_FIRST_SENTENCE_RE.sub(r"\1 [pause] ", clean, count=1)
|
||||
clean = re.sub(r"\s{2,}", " ", clean).strip()
|
||||
return clean
|
||||
|
||||
|
||||
def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Generate audio using xAI TTS.
|
||||
|
|
@ -913,6 +987,12 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
|
|||
language = str(xai_config.get("language", DEFAULT_XAI_LANGUAGE)).strip() or DEFAULT_XAI_LANGUAGE
|
||||
sample_rate = int(xai_config.get("sample_rate", DEFAULT_XAI_SAMPLE_RATE))
|
||||
bit_rate = int(xai_config.get("bit_rate", DEFAULT_XAI_BIT_RATE))
|
||||
auto_speech_tags = _xai_bool_config(
|
||||
xai_config.get("auto_speech_tags", xai_config.get("speech_tags")),
|
||||
DEFAULT_XAI_AUTO_SPEECH_TAGS,
|
||||
)
|
||||
if auto_speech_tags:
|
||||
text = _apply_xai_auto_speech_tags(text)
|
||||
base_url = str(
|
||||
xai_config.get("base_url")
|
||||
or creds.get("base_url")
|
||||
|
|
|
|||
|
|
@ -6690,17 +6690,17 @@ def _failure_messages(url: str, port: int, system: str) -> list[str]:
|
|||
|
||||
command = manual_chrome_debug_command(port, system)
|
||||
hint = (
|
||||
["Start Chrome with remote debugging, then retry /browser connect:", command]
|
||||
["Start a Chromium-family browser with remote debugging, then retry /browser connect:", command]
|
||||
if command
|
||||
else [
|
||||
"No Chrome/Chromium executable was found in this environment.",
|
||||
f"Install one or start Chrome with --remote-debugging-port={port}, then retry /browser connect.",
|
||||
"No supported Chromium-family browser executable was found in this environment.",
|
||||
f"Install one or start a Chromium-family browser with --remote-debugging-port={port}, then retry /browser connect.",
|
||||
]
|
||||
)
|
||||
return [
|
||||
f"Chrome is not reachable at {url}.",
|
||||
f"Browser CDP is not reachable at {url}.",
|
||||
*hint,
|
||||
"Browser not connected — start Chrome with remote debugging and retry /browser connect",
|
||||
"Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -6786,7 +6786,7 @@ def _browser_connect(rid, params: dict) -> dict:
|
|||
from hermes_cli.browser_connect import try_launch_chrome_debug
|
||||
|
||||
announce(
|
||||
"Chrome isn't running with remote debugging — attempting to launch..."
|
||||
"Chromium-family browser isn't running with remote debugging — attempting to launch..."
|
||||
)
|
||||
|
||||
if try_launch_chrome_debug(port, system):
|
||||
|
|
@ -6797,7 +6797,7 @@ def _browser_connect(rid, params: dict) -> dict:
|
|||
break
|
||||
|
||||
if ok:
|
||||
announce(f"Chrome launched and listening on port {port}")
|
||||
announce(f"Chromium-family browser launched and listening on port {port}")
|
||||
else:
|
||||
for line in _failure_messages(url, port, system)[1:]:
|
||||
announce(line, level="error")
|
||||
|
|
@ -6807,7 +6807,7 @@ def _browser_connect(rid, params: dict) -> dict:
|
|||
elif not ok:
|
||||
return _err(rid, 5031, f"could not reach browser CDP at {url}")
|
||||
elif _is_default_local_cdp(parsed):
|
||||
announce(f"Chrome is already listening on port {port}")
|
||||
announce(f"Chromium-family browser is already listening on port {port}")
|
||||
|
||||
normalized = _normalize_cdp_url(parsed)
|
||||
|
||||
|
|
|
|||
|
|
@ -379,11 +379,11 @@ describe('createGatewayEventHandler', () => {
|
|||
const handler = createGatewayEventHandler(ctx)
|
||||
|
||||
handler({
|
||||
payload: { message: 'Chrome launched and listening on port 9222' },
|
||||
payload: { message: 'Chromium-family browser launched and listening on port 9222' },
|
||||
type: 'browser.progress'
|
||||
} as any)
|
||||
|
||||
expect(ctx.system.sys).toHaveBeenCalledWith('Chrome launched and listening on port 9222')
|
||||
expect(ctx.system.sys).toHaveBeenCalledWith('Chromium-family browser launched and listening on port 9222')
|
||||
})
|
||||
|
||||
it('annotates gateway.start_timeout with stderr tail lines so users can diagnose without /logs', () => {
|
||||
|
|
|
|||
|
|
@ -387,8 +387,8 @@ describe('createSlashHandler', () => {
|
|||
Promise.resolve({
|
||||
connected: false,
|
||||
messages: [
|
||||
"Chrome isn't running with remote debugging — attempting to launch...",
|
||||
'Browser not connected — start Chrome with remote debugging and retry /browser connect'
|
||||
"Chromium-family browser isn't running with remote debugging — attempting to launch...",
|
||||
'Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect'
|
||||
],
|
||||
url: 'http://127.0.0.1:9222'
|
||||
})
|
||||
|
|
@ -397,14 +397,14 @@ describe('createSlashHandler', () => {
|
|||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/browser connect')).toBe(true)
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('checking Chrome remote debugging at http://127.0.0.1:9222...')
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('checking Chromium-family browser remote debugging at http://127.0.0.1:9222...')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith(
|
||||
"Chrome isn't running with remote debugging — attempting to launch..."
|
||||
"Chromium-family browser isn't running with remote debugging — attempting to launch..."
|
||||
)
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith(
|
||||
'Browser not connected — start Chrome with remote debugging and retry /browser connect'
|
||||
'Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect'
|
||||
)
|
||||
expect(ctx.transcript.sys).not.toHaveBeenCalledWith('browser connect failed')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,11 +21,26 @@ describe('splitReasoning', () => {
|
|||
expect(text).toBe('body')
|
||||
})
|
||||
|
||||
it('treats unclosed trailing <think>… as reasoning', () => {
|
||||
const { reasoning, text } = splitReasoning('answer start <think>still deciding')
|
||||
it('treats unclosed leading <think>… as reasoning (real reasoning-model stream)', () => {
|
||||
const { reasoning, text } = splitReasoning('<think>still deciding')
|
||||
|
||||
expect(reasoning).toBe('still deciding')
|
||||
expect(text).toBe('answer start')
|
||||
expect(text).toBe('')
|
||||
})
|
||||
|
||||
it('does not strip trailing prose after a stray mid-text <think> mention', () => {
|
||||
// Regression for "TUI eats last paragraph of output": when the model
|
||||
// emits a literal `<think>` somewhere in prose (quoted explanation, code
|
||||
// example, partial stream-mid-tag), the trailing greedy unclosed-tag
|
||||
// regex used to consume every paragraph after it. Real unclosed
|
||||
// reasoning blocks always lead the message — anchor to ^ so prose
|
||||
// mentions are preserved.
|
||||
const { reasoning, text } = splitReasoning(
|
||||
'final answer paragraph one.\n\n<think>internal note never closed\n\nfinal answer paragraph two.'
|
||||
)
|
||||
|
||||
expect(reasoning).toBe('')
|
||||
expect(text).toBe('final answer paragraph one.\n\n<think>internal note never closed\n\nfinal answer paragraph two.')
|
||||
})
|
||||
|
||||
it('returns empty reasoning and untouched text when no tags present', () => {
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export const opsCommands: SlashCommand[] = [
|
|||
const url = action === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
|
||||
|
||||
if (url) {
|
||||
ctx.transcript.sys(`checking Chrome remote debugging at ${url}...`)
|
||||
ctx.transcript.sys(`checking Chromium-family browser remote debugging at ${url}...`)
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
|
|
@ -181,7 +181,7 @@ export const opsCommands: SlashCommand[] = [
|
|||
}
|
||||
|
||||
if (r.connected) {
|
||||
ctx.transcript.sys('Browser connected to live Chrome via CDP')
|
||||
ctx.transcript.sys('Browser connected to live Chromium-family browser via CDP')
|
||||
ctx.transcript.sys(`Endpoint: ${r.url || '(url unavailable)'}`)
|
||||
ctx.transcript.sys('next browser tool call will use this CDP endpoint')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ export function splitReasoning(input: string): SplitReasoning {
|
|||
return ''
|
||||
})
|
||||
|
||||
const unclosed = new RegExp(`<${tag}>([\\s\\S]*)$`, 'i')
|
||||
// Anchor to start-of-input so a literal `<think>` mid-prose (model quoting
|
||||
// the word, code blocks containing the tag, etc.) doesn't eat every
|
||||
// paragraph after it. Real unclosed reasoning blocks always lead the
|
||||
// message — that's how reasoning models stream. See test
|
||||
// "does not strip trailing prose after a stray mid-text <think> mention".
|
||||
const unclosed = new RegExp(`^\\s*<${tag}>([\\s\\S]*)$`, 'i')
|
||||
text = text.replace(unclosed, (_m, inner: string) => {
|
||||
const trimmed = inner.trim()
|
||||
|
||||
|
|
|
|||
195
uv.lock
generated
195
uv.lock
generated
|
|
@ -1859,7 +1859,7 @@ requires-dist = [
|
|||
{ name = "prompt-toolkit", specifier = "==3.0.52" },
|
||||
{ name = "psutil", specifier = "==7.2.2" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" },
|
||||
{ name = "pydantic", specifier = "==2.12.5" },
|
||||
{ name = "pydantic", specifier = "==2.13.4" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
|
||||
|
|
@ -2087,11 +2087,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3263,7 +3263,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
|
|
@ -3271,106 +3271,111 @@ dependencies = [
|
|||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -217,6 +217,6 @@ Issue planned against `jo-inc/camofox-browser` adding:
|
|||
Unit tests use an asyncio mock CDP server that speaks enough of the protocol
|
||||
to exercise all state transitions: attach, enable, navigate, dialog fire,
|
||||
dialog dismiss, frame attach/detach, child target attach, session teardown.
|
||||
Real-backend E2E (Browserbase + local Chrome) is manual — exercise via
|
||||
`/browser connect` to a live Chrome and run the dialog/frame test cases
|
||||
described above.
|
||||
Real-backend E2E (Browserbase + local Chromium-family browser) is manual — exercise via
|
||||
`/browser connect` to a live Chromium-family browser and run the dialog/frame
|
||||
test cases described above.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
---
|
||||
sidebar_position: 16
|
||||
title: "xAI Grok OAuth (SuperGrok Subscription)"
|
||||
description: "Sign in with your SuperGrok subscription to use Grok models in Hermes Agent — no API key required"
|
||||
title: "xAI Grok OAuth (SuperGrok / X Premium+)"
|
||||
description: "Sign in with your SuperGrok or X Premium+ subscription to use Grok models in Hermes Agent — no API key required"
|
||||
---
|
||||
|
||||
# xAI Grok OAuth (SuperGrok Subscription)
|
||||
# xAI Grok OAuth (SuperGrok / X Premium+)
|
||||
|
||||
Hermes Agent supports xAI Grok through a browser-based OAuth login flow against [accounts.x.ai](https://accounts.x.ai), using your existing **SuperGrok subscription**. No `XAI_API_KEY` is required — log in once and Hermes automatically refreshes your session in the background.
|
||||
Hermes Agent supports xAI Grok through a browser-based OAuth login flow against [accounts.x.ai](https://accounts.x.ai), using either a **SuperGrok subscription** ([grok.com](https://x.ai/grok)) or an **X Premium+ subscription** (linked X account). No `XAI_API_KEY` is required — log in once and Hermes automatically refreshes your session in the background.
|
||||
|
||||
When you sign in with an X account that has Premium+, xAI automatically links the subscription status to your xAI session, so the OAuth flow works the same as it does for direct SuperGrok subscribers.
|
||||
|
||||
The transport reuses the `codex_responses` adapter (xAI exposes a Responses-style endpoint), so reasoning, tool-calling, streaming, and prompt caching work without any adapter changes.
|
||||
|
||||
|
|
@ -17,20 +19,20 @@ The same OAuth bearer token is also reused by every direct-to-xAI surface in Her
|
|||
| Item | Value |
|
||||
|------|-------|
|
||||
| Provider ID | `xai-oauth` |
|
||||
| Display name | xAI Grok OAuth (SuperGrok Subscription) |
|
||||
| Display name | xAI Grok OAuth (SuperGrok / X Premium+) |
|
||||
| Auth type | Browser OAuth 2.0 PKCE (loopback callback) |
|
||||
| Transport | xAI Responses API (`codex_responses`) |
|
||||
| Default model | `grok-4.3` |
|
||||
| Endpoint | `https://api.x.ai/v1` |
|
||||
| Auth server | `https://accounts.x.ai` |
|
||||
| Requires env var | No (`XAI_API_KEY` is **not** used for this provider) |
|
||||
| Subscription | [SuperGrok](https://x.ai/grok) — see note below |
|
||||
| Subscription | [SuperGrok](https://x.ai/grok) or [X Premium+](https://x.com/i/premium_sign_up) — see note below |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
- Hermes Agent installed
|
||||
- An active SuperGrok subscription on your xAI account
|
||||
- An active **SuperGrok** subscription on your xAI account, **or** an **X Premium+** subscription on the X account you sign in with (xAI links the subscription automatically)
|
||||
- A browser available on the local machine (or use `--no-browser` for remote sessions)
|
||||
|
||||
:::warning xAI may restrict OAuth API access by tier
|
||||
|
|
@ -42,7 +44,7 @@ xAI's backend enforces its own allowlist on the OAuth API surface and has been s
|
|||
```bash
|
||||
# Launch the provider and model picker
|
||||
hermes model
|
||||
# → Select "xAI Grok OAuth (SuperGrok Subscription)" from the provider list
|
||||
# → Select "xAI Grok OAuth (SuperGrok / X Premium+)" from the provider list
|
||||
# → Hermes opens your browser to accounts.x.ai
|
||||
# → Approve access in the browser
|
||||
# → Pick a model (grok-4.3 is at the top)
|
||||
|
|
@ -111,7 +113,7 @@ The `◆ Auth Providers` section will show the current state of every provider,
|
|||
|
||||
```bash
|
||||
hermes model
|
||||
# → Select "xAI Grok OAuth (SuperGrok Subscription)"
|
||||
# → Select "xAI Grok OAuth (SuperGrok / X Premium+)"
|
||||
# → Pick from the model list (grok-4.3 is pinned to the top)
|
||||
```
|
||||
|
||||
|
|
@ -155,7 +157,7 @@ hermes tools
|
|||
# → Text-to-Speech → "xAI TTS"
|
||||
# → Image Generation → "xAI Grok Imagine (image)"
|
||||
# → Video Generation → "xAI Grok Imagine"
|
||||
# → X (Twitter) Search → "xAI Grok OAuth (SuperGrok Subscription)"
|
||||
# → X (Twitter) Search → "xAI Grok OAuth (SuperGrok / X Premium+)"
|
||||
```
|
||||
|
||||
If OAuth tokens are already stored, the picker confirms it and skips the credential prompt. If neither OAuth nor `XAI_API_KEY` is set, the picker offers a 3-choice menu: OAuth login, paste API key, or skip.
|
||||
|
|
@ -165,7 +167,7 @@ The `video_gen` toolset is disabled by default. Enable it in `hermes tools` →
|
|||
:::
|
||||
|
||||
:::note X search auto-enables when xAI credentials are present
|
||||
The `x_search` toolset auto-enables whenever xAI credentials (a SuperGrok OAuth token or `XAI_API_KEY`) are configured. Disable explicitly via `hermes tools` → `🐦 X (Twitter) Search` (press space) if you don't want this. The tool routes through xAI's built-in `x_search` Responses API — it works with **either** your SuperGrok OAuth login or a paid `XAI_API_KEY`, and prefers OAuth when both are configured (uses your subscription quota instead of API spend). The tool schema is hidden from the model when no xAI credentials are configured, regardless of whether the toolset is enabled.
|
||||
The `x_search` toolset auto-enables whenever xAI credentials (a SuperGrok / X Premium+ OAuth token or `XAI_API_KEY`) are configured. Disable explicitly via `hermes tools` → `🐦 X (Twitter) Search` (press space) if you don't want this. The tool routes through xAI's built-in `x_search` Responses API — it works with **either** your SuperGrok / X Premium+ OAuth login or a paid `XAI_API_KEY`, and prefers OAuth when both are configured (uses your subscription quota instead of API spend). The tool schema is hidden from the model when no xAI credentials are configured, regardless of whether the toolset is enabled.
|
||||
:::
|
||||
|
||||
### Models
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Hermes includes full browser automation with multiple backend options for naviga
|
|||
|
||||
- **Browserbase** — Managed cloud browsers with anti-bot tooling, CAPTCHA solving, and residential proxies
|
||||
- **Browser Use** — Alternative cloud browser provider
|
||||
- **Local Chrome via CDP** — Connect to your running Chrome instance using `/browser connect`
|
||||
- **Local Chromium-family CDP** — Connect to your running Chrome, Brave, Chromium, or Edge browser using `/browser connect`
|
||||
- **Local Chromium** — Headless local browser via the `agent-browser` CLI
|
||||
|
||||
See [Browser Automation](/docs/user-guide/features/browser) for setup and usage.
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ When using the Z.AI / GLM provider, Hermes automatically probes multiple endpoin
|
|||
|
||||
xAI is wired through the Responses API (`codex_responses` transport) for automatic reasoning support on Grok 4 models — no `reasoning_effort` parameter needed, the server reasons by default. Set `XAI_API_KEY` in `~/.hermes/.env` and pick xAI in `hermes model`, or drop `grok` as a shortcut into `/model grok-4-1-fast-reasoning`.
|
||||
|
||||
SuperGrok subscribers can sign in with browser OAuth instead of using an API key — pick **xAI Grok OAuth (SuperGrok Subscription)** in `hermes model`, or run `hermes auth add xai-oauth`. The same OAuth bearer token is automatically reused by direct-to-xAI tools (TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md) for the full flow — and if Hermes runs on a remote host, also see [OAuth over SSH / Remote Hosts](../guides/oauth-over-ssh.md) for the required `ssh -L` tunnel.
|
||||
SuperGrok and X Premium+ subscribers can sign in with browser OAuth instead of using an API key — pick **xAI Grok OAuth (SuperGrok Subscription)** in `hermes model`, or run `hermes auth add xai-oauth`. The same OAuth bearer token is automatically reused by direct-to-xAI tools (TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md) for the full flow — and if Hermes runs on a remote host, also see [OAuth over SSH / Remote Hosts](../guides/oauth-over-ssh.md) for the required `ssh -L` tunnel.
|
||||
|
||||
When using xAI as a provider (any base URL containing `x.ai`), Hermes automatically enables prompt caching by sending the `x-grok-conv-id` header with every API request. This routes requests to the same server within a conversation session, allowing xAI's infrastructure to reuse cached system prompts and conversation history.
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
|||
|---------|-------------|
|
||||
| `/tools [list\|disable\|enable] [name...]` | Manage tools: list available tools, or disable/enable specific tools for the current session. Disabling a tool removes it from the agent's toolset and triggers a session reset. |
|
||||
| `/toolsets` | List available toolsets |
|
||||
| `/browser [connect\|disconnect\|status]` | Manage local Chrome CDP connection. `connect` attaches browser tools to a running Chrome instance (default: `ws://localhost:9222`). `disconnect` detaches. `status` shows current connection. Auto-launches Chrome if no debugger is detected. |
|
||||
| `/browser [connect\|disconnect\|status]` | Manage a local Chromium-family CDP connection. `connect` attaches browser tools to a running Chrome, Brave, Chromium, or Edge instance (default: `http://127.0.0.1:9222`). `disconnect` detaches. `status` shows current connection. Auto-launches a supported Chromium-family browser if no debugger is detected. |
|
||||
| `/skills` | Search, install, inspect, or manage skills from online registries |
|
||||
| `/cron` | Manage scheduled tasks (list, add/create, edit, pause, resume, run, remove) |
|
||||
| `/curator` | Background skill maintenance — `status`, `run`, `pin`, `archive`. See [Curator](/docs/user-guide/features/curator). |
|
||||
|
|
|
|||
|
|
@ -836,7 +836,7 @@ Available providers for auxiliary tasks: `auto`, `main`, plus any provider in th
|
|||
:::
|
||||
|
||||
:::tip xAI Grok OAuth
|
||||
`xai-oauth` logs in via browser OAuth for SuperGrok subscribers (no API key needed). Run `hermes model` and select **xAI Grok OAuth (SuperGrok Subscription)** to authenticate. The same OAuth token is reused for every direct-to-xAI surface (chat, auxiliary tasks, TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md), and if Hermes is on a remote host see [OAuth over SSH / Remote Hosts](../guides/oauth-over-ssh.md).
|
||||
`xai-oauth` logs in via browser OAuth for SuperGrok and X Premium+ subscribers (no API key needed). Run `hermes model` and select **xAI Grok OAuth (SuperGrok Subscription)** to authenticate. The same OAuth token is reused for every direct-to-xAI surface (chat, auxiliary tasks, TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md), and if Hermes is on a remote host see [OAuth over SSH / Remote Hosts](../guides/oauth-over-ssh.md).
|
||||
:::
|
||||
|
||||
:::warning `"main"` is for auxiliary tasks only
|
||||
|
|
@ -962,7 +962,7 @@ These options apply to **auxiliary task configs** (`auxiliary:`, `compression:`,
|
|||
| `"nous"` | Force Nous Portal | `hermes auth` |
|
||||
| `"codex"` | Force Codex OAuth (ChatGPT account). Supports vision (gpt-5.3-codex). | `hermes model` → Codex |
|
||||
| `"minimax-oauth"` | Force MiniMax OAuth (browser login, no API key). Uses MiniMax-M2.7-highspeed for auxiliary tasks. | `hermes model` → MiniMax (OAuth) |
|
||||
| `"xai-oauth"` | Force xAI Grok OAuth (browser login for SuperGrok subscribers, no API key). Same OAuth token covers chat, TTS, image, video, and transcription. | `hermes model` → xAI Grok OAuth (SuperGrok Subscription) |
|
||||
| `"xai-oauth"` | Force xAI Grok OAuth (browser login for SuperGrok or X Premium+ subscribers, no API key). Same OAuth token covers chat, TTS, image, video, and transcription. | `hermes model` → xAI Grok OAuth (SuperGrok Subscription) |
|
||||
| `"main"` | Use your active custom/main endpoint. This can come from `OPENAI_BASE_URL` + `OPENAI_API_KEY` or from a custom endpoint saved via `hermes model` / `config.yaml`. Works with OpenAI, local models, or any OpenAI-compatible API. **Auxiliary tasks only — not valid for `model.provider`.** | Custom endpoint credentials + base URL |
|
||||
|
||||
Direct API-key providers from the main provider catalog also work here when you want side tasks to bypass your default router. `gmi` is valid once `GMI_API_KEY` is configured:
|
||||
|
|
@ -1505,11 +1505,11 @@ browser:
|
|||
command_timeout: 30 # Timeout in seconds for browser commands (screenshot, navigate, etc.)
|
||||
record_sessions: false # Auto-record browser sessions as WebM videos to ~/.hermes/browser_recordings/
|
||||
# Optional CDP override — when set, Hermes attaches directly to your own
|
||||
# Chrome (via /browser connect) rather than starting a headless browser.
|
||||
# Chromium-family browser (via /browser connect) rather than starting a headless browser.
|
||||
cdp_url: ""
|
||||
# Dialog supervisor — controls how native JS dialogs (alert / confirm / prompt)
|
||||
# are handled when a CDP backend is attached (Browserbase, local Chrome via
|
||||
# /browser connect). Ignored on Camofox and default local agent-browser mode.
|
||||
# are handled when a CDP backend is attached (Browserbase, local Chromium-family
|
||||
# browser via /browser connect). Ignored on Camofox and default local agent-browser mode.
|
||||
dialog_policy: must_respond # must_respond | auto_dismiss | auto_accept
|
||||
dialog_timeout_s: 300 # Safety auto-dismiss under must_respond (seconds)
|
||||
camofox:
|
||||
|
|
@ -1527,7 +1527,7 @@ browser:
|
|||
|
||||
See the [browser feature page](./features/browser.md#browser_dialog) for the full dialog workflow.
|
||||
|
||||
The browser toolset supports multiple providers. See the [Browser feature page](/docs/user-guide/features/browser) for details on Browserbase, Browser Use, and local Chrome CDP setup.
|
||||
The browser toolset supports multiple providers. See the [Browser feature page](/docs/user-guide/features/browser) for details on Browserbase, Browser Use, and local Chromium-family CDP setup.
|
||||
|
||||
## Timezone
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Browser Automation
|
||||
description: Control browsers with multiple providers, local Chrome via CDP, or cloud browsers for web interaction, form filling, scraping, and more.
|
||||
description: Control browsers with multiple providers, local Chromium-family browsers via CDP, or cloud browsers for web interaction, form filling, scraping, and more.
|
||||
sidebar_label: Browser
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
|
@ -13,7 +13,7 @@ Hermes Agent includes a full browser automation toolset with multiple backend op
|
|||
- **Browser Use cloud mode** via [Browser Use](https://browser-use.com) as an alternative cloud browser provider
|
||||
- **Firecrawl cloud mode** via [Firecrawl](https://firecrawl.dev) for cloud browsers with built-in scraping
|
||||
- **Camofox local mode** via [Camofox](https://github.com/jo-inc/camofox-browser) for local anti-detection browsing (Firefox-based fingerprint spoofing)
|
||||
- **Local Chrome via CDP** — connect browser tools to your own Chrome instance using `/browser connect`
|
||||
- **Local Chromium-family CDP** — connect browser tools to your own Chrome, Brave, Chromium, or Edge instance using `/browser connect`
|
||||
- **Local browser mode** via the `agent-browser` CLI and a local Chromium installation
|
||||
|
||||
In all modes, the agent can navigate websites, interact with page elements, fill forms, and extract information.
|
||||
|
|
@ -25,7 +25,7 @@ Pages are represented as **accessibility trees** (text-based snapshots), making
|
|||
Key capabilities:
|
||||
|
||||
- **Multi-provider cloud execution** — Browserbase, Browser Use, or Firecrawl — no local browser needed
|
||||
- **Local Chrome integration** — attach to your running Chrome via CDP for hands-on browsing
|
||||
- **Local Chromium-family integration** — attach to your running Chrome, Brave, Chromium, or Edge browser via CDP for hands-on browsing
|
||||
- **Built-in stealth** — random fingerprints, CAPTCHA solving, residential proxies (Browserbase)
|
||||
- **Session isolation** — each task gets its own browser session
|
||||
- **Automatic cleanup** — inactive sessions are closed after a timeout
|
||||
|
|
@ -285,9 +285,9 @@ Adoption only fires until `tab_id` is populated for the session. If the external
|
|||
|
||||
When Camofox runs in headed mode (with a visible browser window), it exposes a VNC port in its health check response. Hermes automatically discovers this and includes the VNC URL in navigation responses, so the agent can share a link for you to watch the browser live.
|
||||
|
||||
### Local Chrome via CDP (`/browser connect`)
|
||||
### Local Chromium-family browser via CDP (`/browser connect`)
|
||||
|
||||
Instead of a cloud provider, you can attach Hermes browser tools to your own running Chrome instance via the Chrome DevTools Protocol (CDP). This is useful when you want to see what the agent is doing in real-time, interact with pages that require your own cookies/sessions, or avoid cloud browser costs.
|
||||
Instead of a cloud provider, you can attach Hermes browser tools to your own running Chrome, Brave, Chromium, or Edge instance via the Chrome DevTools Protocol (CDP). This is useful when you want to see what the agent is doing in real-time, interact with pages that require your own cookies/sessions, or avoid cloud browser costs.
|
||||
|
||||
:::note
|
||||
`/browser connect` is an **interactive-CLI slash command** — it is not dispatched by the gateway. If you try to run it inside a WebUI, Telegram, Discord, or other gateway chat, the message will be sent to the agent as plain text and the command will not execute. Start Hermes from the terminal (`hermes` or `hermes chat`) and issue `/browser connect` there.
|
||||
|
|
@ -296,26 +296,40 @@ Instead of a cloud provider, you can attach Hermes browser tools to your own run
|
|||
In the CLI, use:
|
||||
|
||||
```
|
||||
/browser connect # Connect to Chrome at ws://localhost:9222
|
||||
/browser connect # Auto-launch/connect to a local Chromium-family browser at http://127.0.0.1:9222
|
||||
/browser connect ws://host:port # Connect to a specific CDP endpoint
|
||||
/browser status # Check current connection
|
||||
/browser disconnect # Detach and return to cloud/local mode
|
||||
/browser status # Check current connection
|
||||
/browser disconnect # Detach and return to cloud/local mode
|
||||
```
|
||||
|
||||
If Chrome isn't already running with remote debugging, Hermes will attempt to auto-launch it with `--remote-debugging-port=9222`.
|
||||
If a browser isn't already running with remote debugging, Hermes will attempt to auto-launch a supported Chromium-family browser with `--remote-debugging-port=9222`. Detection includes Brave, Google Chrome, Chromium, and Microsoft Edge, with common Linux install paths such as `/opt/brave-bin/brave` and `/snap/bin/brave`.
|
||||
|
||||
:::tip
|
||||
To start Chrome manually with CDP enabled, use a dedicated user-data-dir so the debug port actually comes up even if Chrome is already running with your normal profile:
|
||||
To start a Chromium-family browser manually with CDP enabled, use a dedicated user-data-dir so the debug port actually comes up even if the browser is already running with your normal profile:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
# Linux — Brave
|
||||
brave-browser \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=$HOME/.hermes/chrome-debug \
|
||||
--no-first-run \
|
||||
--no-default-browser-check &
|
||||
|
||||
# Linux — Google Chrome
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=$HOME/.hermes/chrome-debug \
|
||||
--no-first-run \
|
||||
--no-default-browser-check &
|
||||
|
||||
# macOS
|
||||
# macOS — Brave
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/.hermes/chrome-debug" \
|
||||
--no-first-run \
|
||||
--no-default-browser-check &
|
||||
|
||||
# macOS — Google Chrome
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/.hermes/chrome-debug" \
|
||||
|
|
@ -325,10 +339,10 @@ google-chrome \
|
|||
|
||||
Then launch the Hermes CLI and run `/browser connect`.
|
||||
|
||||
**Why `--user-data-dir`?** Without it, launching Chrome while a regular Chrome instance is already running typically opens a new window on the existing process — and that existing process was not started with `--remote-debugging-port`, so port 9222 never opens. A dedicated user-data-dir forces a fresh Chrome process where the debug port actually listens. `--no-first-run --no-default-browser-check` skips the first-launch wizard for the fresh profile.
|
||||
**Why `--user-data-dir`?** Without it, launching a Chromium-family browser while a regular instance is already running typically opens a new window on the existing process — and that existing process was not started with `--remote-debugging-port`, so port 9222 never opens. A dedicated user-data-dir forces a fresh browser process where the debug port actually listens. `--no-first-run --no-default-browser-check` skips the first-launch wizard for the fresh profile.
|
||||
:::
|
||||
|
||||
When connected via CDP, all browser tools (`browser_navigate`, `browser_click`, etc.) operate on your live Chrome instance instead of spinning up a cloud session.
|
||||
When connected via CDP, all browser tools (`browser_navigate`, `browser_click`, etc.) operate on your live browser instance instead of spinning up a cloud session.
|
||||
|
||||
### WSL2 + Windows Chrome: prefer MCP over `/browser connect`
|
||||
|
||||
|
|
@ -489,7 +503,7 @@ When a CDP supervisor is active for the current session (typical for any session
|
|||
|
||||
Raw Chrome DevTools Protocol passthrough — the escape hatch for browser operations not covered by the other tools. Use for native dialog handling, iframe-scoped evaluation, cookie/network control, or any CDP verb the agent needs.
|
||||
|
||||
**Only available when a CDP endpoint is reachable at session start** — meaning `/browser connect` has attached to a running Chrome, or `browser.cdp_url` is set in `config.yaml`. The default local agent-browser mode, Camofox, and cloud providers (Browserbase, Browser Use, Firecrawl) do not currently expose CDP to this tool — cloud providers have per-session CDP URLs but live-session routing is a follow-up.
|
||||
**Only available when a CDP endpoint is reachable at session start** — meaning `/browser connect` has attached to a running Chrome, Brave, Chromium, or Edge browser, or `browser.cdp_url` is set in `config.yaml`. The default local agent-browser mode, Camofox, and cloud providers (Browserbase, Browser Use, Firecrawl) do not currently expose CDP to this tool — cloud providers have per-session CDP URLs but live-session routing is a follow-up.
|
||||
|
||||
**CDP method reference:** https://chromedevtools.github.io/devtools-protocol/ — the agent can `web_extract` a specific method's page to look up parameters and return shape.
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch
|
|||
## Media & Web
|
||||
|
||||
- **[Voice Mode](voice-mode.md)** — Full voice interaction across CLI and messaging platforms. Talk to the agent using your microphone, hear spoken replies, and have live voice conversations in Discord voice channels.
|
||||
- **[Browser Automation](browser.md)** — Full browser automation with multiple backends: Browserbase cloud, Browser Use cloud, local Chrome via CDP, or local Chromium. Navigate websites, fill forms, and extract information.
|
||||
- **[Browser Automation](browser.md)** — Full browser automation with multiple backends: Browserbase cloud, Browser Use cloud, local Chrome/Brave/Chromium/Edge via CDP, or local Chromium. Navigate websites, fill forms, and extract information.
|
||||
- **[Vision & Image Paste](vision.md)** — Multimodal vision support. Paste images from your clipboard into the CLI and ask the agent to analyze, describe, or work with them using any vision-capable model.
|
||||
- **[Image Generation](image-generation.md)** — Generate images from text prompts using FAL.ai. Nine models supported (FLUX 2 Klein/Pro, GPT-Image 1.5/2, Nano Banana Pro, Ideogram V3, Recraft V4 Pro, Qwen, Z-Image Turbo); pick one via `hermes tools`.
|
||||
- **[Voice & TTS](tts.md)** — Text-to-speech output and voice message transcription across all messaging platforms, with ten native provider options: Edge TTS (free), ElevenLabs, OpenAI TTS, MiniMax, Mistral Voxtral, Google Gemini, xAI, NeuTTS, KittenTTS, and Piper — plus custom command providers for any local TTS CLI.
|
||||
|
|
|
|||
|
|
@ -236,7 +236,8 @@ Paths support `~` expansion and `${VAR}` environment variable substitution.
|
|||
|
||||
### How it works
|
||||
|
||||
- **Read-only**: External dirs are only scanned for skill discovery. When the agent creates or edits a skill, it always writes to `~/.hermes/skills/`.
|
||||
- **Create locally, update in place**: New agent-created skills are written to `~/.hermes/skills/`. Existing skills are modified where they are found, including skills under `external_dirs`, when the agent uses `skill_manage` actions such as `patch`, `edit`, `write_file`, `remove_file`, or `delete`.
|
||||
- **External dirs are not a write-protection boundary**: If an external skill directory is writable by the Hermes process, agent-managed skill updates can change files in that directory. Use filesystem permissions or a separate profile/toolset setup if shared external skills must stay read-only.
|
||||
- **Local precedence**: If the same skill name exists in both the local dir and an external dir, the local version wins.
|
||||
- **Full integration**: External skills appear in the system prompt index, `skills_list`, `skill_view`, and as `/skill-name` slash commands — no different from local skills.
|
||||
- **Non-existent paths are silently skipped**: If a configured directory doesn't exist, Hermes ignores it without errors. Useful for optional shared directories that may not be present on every machine.
|
||||
|
|
@ -250,7 +251,7 @@ Paths support `~` expansion and `${VAR}` environment variable substitution.
|
|||
└── mlops/axolotl/
|
||||
└── SKILL.md
|
||||
|
||||
~/.agents/skills/ # External (read-only, shared)
|
||||
~/.agents/skills/ # External (shared, mutable if writable)
|
||||
├── my-custom-workflow/
|
||||
│ └── SKILL.md
|
||||
└── team-conventions/
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ The `x_search` tool lets the agent search X (Twitter) posts, profiles, and threa
|
|||
|
||||
| Credential | Source | Setup |
|
||||
|------------|--------|-------|
|
||||
| **SuperGrok OAuth** (preferred) | Browser login at `accounts.x.ai`, refreshed automatically | `hermes auth add xai-oauth` — see [xAI Grok OAuth (SuperGrok Subscription)](../../guides/xai-grok-oauth.md) |
|
||||
| **SuperGrok / X Premium+ OAuth** (preferred) | Browser login at `accounts.x.ai`, refreshed automatically | `hermes auth add xai-oauth` — see [xAI Grok OAuth (SuperGrok / X Premium+)](../../guides/xai-grok-oauth.md) |
|
||||
| **`XAI_API_KEY`** | Paid xAI API key | Set in `~/.hermes/.env` |
|
||||
|
||||
Both hit the same endpoint with the same payload — the only difference is the bearer token. **When both are configured, SuperGrok OAuth wins** so x_search runs against your subscription quota instead of paid API spend.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue