Compare commits
22 Commits
sid/restru
...
sid/founda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
987962f453 | ||
|
|
25072fe690 | ||
|
|
ff99611e16 | ||
|
|
76aebd73c3 | ||
|
|
a1e667b9f2 | ||
|
|
4b16341975 | ||
|
|
65ca3ba93b | ||
|
|
8bff8bf2c0 | ||
|
|
acd1f17b88 | ||
|
|
850973295e | ||
|
|
847ffca715 | ||
|
|
67bc441099 | ||
|
|
a9ed7cb3b4 | ||
|
|
15ac253b11 | ||
|
|
fb6d37495b | ||
|
|
72e7c0ce34 | ||
|
|
f8d2365795 | ||
|
|
ca2b6a529e | ||
|
|
224e6d46d9 | ||
|
|
d3dde0b459 | ||
|
|
3f4c5ac71e | ||
|
|
08c378356d |
@@ -14,6 +14,3 @@ node_modules
|
||||
.env
|
||||
|
||||
*.md
|
||||
|
||||
# Runtime data (bind-mounted at /opt/data; must not leak into build context)
|
||||
data/
|
||||
|
||||
5
.git-blame-ignore-revs
Normal file
5
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,5 @@
|
||||
# hermes_agent package restructure (PR 1/3)
|
||||
# Commit 2: pure git mv — all source files into hermes_agent/
|
||||
65ca3ba93b3fa7fd2b15af5b62d54020061f3672
|
||||
# Commit 3: rewrite all imports for hermes_agent package
|
||||
4b16341975a1217588054f567d0f76dc5a3cc481
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
.DS_Store
|
||||
/venv/
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
|
||||
137
AGENTS.md
137
AGENTS.md
@@ -12,68 +12,59 @@ source venv/bin/activate # ALWAYS activate before running Python
|
||||
|
||||
```
|
||||
hermes-agent/
|
||||
├── run_agent.py # AIAgent class — core conversation loop
|
||||
├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call()
|
||||
├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list
|
||||
├── cli.py # HermesCLI class — interactive CLI orchestrator
|
||||
├── hermes_state.py # SessionDB — SQLite session store (FTS5 search)
|
||||
├── agent/ # Agent internals
|
||||
│ ├── prompt_builder.py # System prompt assembly
|
||||
│ ├── context_compressor.py # Auto context compression
|
||||
│ ├── prompt_caching.py # Anthropic prompt caching
|
||||
│ ├── auxiliary_client.py # Auxiliary LLM client (vision, summarization)
|
||||
│ ├── model_metadata.py # Model context lengths, token estimation
|
||||
│ ├── models_dev.py # models.dev registry integration (provider-aware context)
|
||||
│ ├── display.py # KawaiiSpinner, tool preview formatting
|
||||
│ ├── skill_commands.py # Skill slash commands (shared CLI/gateway)
|
||||
│ └── trajectory.py # Trajectory saving helpers
|
||||
├── hermes_cli/ # CLI subcommands and setup
|
||||
│ ├── main.py # Entry point — all `hermes` subcommands
|
||||
│ ├── config.py # DEFAULT_CONFIG, OPTIONAL_ENV_VARS, migration
|
||||
│ ├── commands.py # Slash command definitions + SlashCommandCompleter
|
||||
│ ├── callbacks.py # Terminal callbacks (clarify, sudo, approval)
|
||||
│ ├── setup.py # Interactive setup wizard
|
||||
│ ├── skin_engine.py # Skin/theme engine — CLI visual customization
|
||||
│ ├── skills_config.py # `hermes skills` — enable/disable skills per platform
|
||||
│ ├── tools_config.py # `hermes tools` — enable/disable tools per platform
|
||||
│ ├── skills_hub.py # `/skills` slash command (search, browse, install)
|
||||
│ ├── models.py # Model catalog, provider model lists
|
||||
│ ├── model_switch.py # Shared /model switch pipeline (CLI + gateway)
|
||||
│ └── auth.py # Provider credential resolution
|
||||
├── tools/ # Tool implementations (one file per tool)
|
||||
│ ├── registry.py # Central tool registry (schemas, handlers, dispatch)
|
||||
│ ├── approval.py # Dangerous command detection
|
||||
│ ├── terminal_tool.py # Terminal orchestration
|
||||
│ ├── process_registry.py # Background process management
|
||||
│ ├── file_tools.py # File read/write/search/patch
|
||||
│ ├── web_tools.py # Web search/extract (Parallel + Firecrawl)
|
||||
│ ├── browser_tool.py # Browserbase browser automation
|
||||
│ ├── code_execution_tool.py # execute_code sandbox
|
||||
│ ├── delegate_tool.py # Subagent delegation
|
||||
│ ├── mcp_tool.py # MCP client (~1050 lines)
|
||||
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
|
||||
├── gateway/ # Messaging platform gateway
|
||||
│ ├── run.py # Main loop, slash commands, message dispatch
|
||||
│ ├── session.py # SessionStore — conversation persistence
|
||||
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal, qqbot
|
||||
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||
│ ├── src/entry.tsx # TTY gate + render()
|
||||
│ ├── src/app.tsx # Main state machine and UI
|
||||
│ ├── src/gatewayClient.ts # Child process + JSON-RPC bridge
|
||||
│ ├── src/app/ # Decomposed app logic (event handler, slash handler, stores, hooks)
|
||||
│ ├── src/components/ # Ink components (branding, markdown, prompts, pickers, etc.)
|
||||
│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory
|
||||
│ └── src/lib/ # Pure helpers (history, osc52, text, rpc, messages)
|
||||
├── hermes_agent/ # Single installable package
|
||||
│ ├── agent/ # Core conversation loop and agent internals
|
||||
│ │ ├── loop.py # AIAgent class — core conversation loop
|
||||
│ │ ├── prompt_builder.py # System prompt assembly
|
||||
│ │ ├── context/ # Context management (engine, compressor, references)
|
||||
│ │ ├── memory/ # Memory management (manager, provider)
|
||||
│ │ ├── image_gen/ # Image generation (provider, registry)
|
||||
│ │ ├── display.py # KawaiiSpinner, tool preview formatting
|
||||
│ │ ├── skill_commands.py # Skill slash commands (shared CLI/gateway)
|
||||
│ │ └── trajectory.py # Trajectory saving helpers
|
||||
│ ├── providers/ # LLM provider adapters and transports
|
||||
│ │ ├── anthropic_adapter.py # Anthropic adapter
|
||||
│ │ ├── anthropic_transport.py # Anthropic transport
|
||||
│ │ ├── metadata.py # Model context lengths, token estimation
|
||||
│ │ ├── auxiliary.py # Auxiliary LLM client (vision, summarization)
|
||||
│ │ ├── caching.py # Anthropic prompt caching
|
||||
│ │ └── credential_pool.py # Credential management
|
||||
│ ├── tools/ # Tool implementations
|
||||
│ │ ├── dispatch.py # Tool orchestration, discover_builtin_tools()
|
||||
│ │ ├── toolsets.py # Toolset definitions
|
||||
│ │ ├── registry.py # Central tool registry
|
||||
│ │ ├── terminal.py # Terminal orchestration
|
||||
│ │ ├── browser/ # Browser tools (tool, cdp, camofox, providers/)
|
||||
│ │ ├── mcp/ # MCP client and server
|
||||
│ │ ├── skills/ # Skill management (manager, tool, hub, guard, sync)
|
||||
│ │ ├── media/ # Voice, TTS, transcription, image gen
|
||||
│ │ ├── files/ # File operations (tools, operations, state)
|
||||
│ │ └── security/ # Path security, URL safety, approval
|
||||
│ ├── backends/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
|
||||
│ ├── cli/ # CLI subcommands and setup
|
||||
│ │ ├── main.py # Entry point — all `hermes` subcommands
|
||||
│ │ ├── repl.py # HermesCLI class — interactive CLI orchestrator
|
||||
│ │ ├── config.py # DEFAULT_CONFIG, OPTIONAL_ENV_VARS, migration
|
||||
│ │ ├── commands.py # Slash command definitions
|
||||
│ │ ├── auth/ # Provider credential resolution
|
||||
│ │ ├── models/ # Model catalog, provider lists, switching
|
||||
│ │ └── ui/ # Banner, colors, skin engine, callbacks, tips
|
||||
│ ├── gateway/ # Messaging platform gateway
|
||||
│ │ ├── run.py # Main loop, slash commands, message dispatch
|
||||
│ │ ├── session.py # SessionStore — conversation persistence
|
||||
│ │ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, etc.
|
||||
│ ├── acp/ # ACP server (VS Code / Zed / JetBrains integration)
|
||||
│ ├── cron/ # Scheduler (jobs.py, scheduler.py)
|
||||
│ ├── plugins/ # Plugin system (memory providers, context engines)
|
||||
│ ├── constants.py # Shared constants
|
||||
│ ├── state.py # SessionDB — SQLite session store
|
||||
│ ├── logging.py # Logging configuration
|
||||
│ └── utils.py # Shared utilities
|
||||
├── tui_gateway/ # Python JSON-RPC backend for the TUI
|
||||
│ ├── entry.py # stdio entrypoint
|
||||
│ ├── server.py # RPC handlers and session logic
|
||||
│ ├── render.py # Optional rich/ANSI bridge
|
||||
│ └── slash_worker.py # Persistent HermesCLI subprocess for slash commands
|
||||
├── hermes_agent/acp/ # ACP server (VS Code / Zed / JetBrains integration)
|
||||
├── cron/ # Scheduler (jobs.py, scheduler.py)
|
||||
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||
├── environments/ # RL training environments (Atropos)
|
||||
├── tests/ # Pytest suite (~3000 tests)
|
||||
└── batch_runner.py # Parallel batch processing
|
||||
├── tests/ # Pytest suite
|
||||
└── web/ # Vite + React web dashboard
|
||||
```
|
||||
|
||||
**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys)
|
||||
@@ -81,18 +72,18 @@ hermes-agent/
|
||||
## File Dependency Chain
|
||||
|
||||
```
|
||||
tools/registry.py (no deps — imported by all tool files)
|
||||
hermes_agent/tools/registry.py (no deps — imported by all tool files)
|
||||
↑
|
||||
tools/*.py (each calls registry.register() at import time)
|
||||
hermes_agent/tools/*.py (each calls registry.register() at import time)
|
||||
↑
|
||||
model_tools.py (imports tools/registry + triggers tool discovery)
|
||||
hermes_agent/tools/dispatch.py (imports registry + triggers tool discovery)
|
||||
↑
|
||||
run_agent.py, cli.py, batch_runner.py, environments/
|
||||
hermes_agent/agent/loop.py, hermes_agent/cli/repl.py, environments/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AIAgent Class (run_agent.py)
|
||||
## AIAgent Class (hermes_agent/agent/loop.py)
|
||||
|
||||
```python
|
||||
class AIAgent:
|
||||
@@ -138,14 +129,14 @@ Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`. Re
|
||||
|
||||
---
|
||||
|
||||
## CLI Architecture (cli.py)
|
||||
## CLI Architecture (hermes_agent/cli/repl.py)
|
||||
|
||||
- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
|
||||
- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
|
||||
- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
|
||||
- **Skin engine** (`hermes_cli/skin_engine.py`) — data-driven CLI theming; initialized from `display.skin` config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding text
|
||||
- **KawaiiSpinner** (`hermes_agent/agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
|
||||
- `load_cli_config()` in repl.py merges hardcoded defaults + user config YAML
|
||||
- **Skin engine** (`hermes_agent/cli/ui/skin_engine.py`) — data-driven CLI theming; initialized from `display.skin` config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding text
|
||||
- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
|
||||
- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
|
||||
- Skill slash commands: `hermes_agent/agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
|
||||
|
||||
### Slash Command Registry (`hermes_cli/commands.py`)
|
||||
|
||||
@@ -272,7 +263,7 @@ registry.register(
|
||||
|
||||
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
|
||||
|
||||
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain.
|
||||
Auto-discovery: any `hermes_agent/tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain.
|
||||
|
||||
The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
|
||||
|
||||
@@ -498,11 +489,11 @@ Rendering bugs in tmux/iTerm2 — ghosting on scroll. Use `curses` (stdlib) inst
|
||||
### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
|
||||
Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
|
||||
|
||||
### `_last_resolved_tool_names` is a process-global in `model_tools.py`
|
||||
### `_last_resolved_tool_names` is a process-global in `hermes_agent/tools/dispatch.py`
|
||||
`_run_single_child()` in `delegate_tool.py` saves and restores this global around subagent execution. If you add new code that reads this global, be aware it may be temporarily stale during child agent runs.
|
||||
|
||||
### DO NOT hardcode cross-tool references in schema descriptions
|
||||
Tool schema descriptions must not mention tools from other toolsets by name (e.g., `browser_navigate` saying "prefer web_search"). Those tools may be unavailable (missing API keys, disabled toolset), causing the model to hallucinate calls to non-existent tools. If a cross-reference is needed, add it dynamically in `get_tool_definitions()` in `model_tools.py` — see the `browser_navigate` / `execute_code` post-processing blocks for the pattern.
|
||||
Tool schema descriptions must not mention tools from other toolsets by name (e.g., `browser_navigate` saying "prefer web_search"). Those tools may be unavailable (missing API keys, disabled toolset), causing the model to hallucinate calls to non-existent tools. If a cross-reference is needed, add it dynamically in `get_tool_definitions()` in `hermes_agent/tools/dispatch.py` — see the `browser_navigate` / `execute_code` post-processing blocks for the pattern.
|
||||
|
||||
### Tests must not write to `~/.hermes/`
|
||||
The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
|
||||
|
||||
@@ -55,10 +55,10 @@ If your skill is specialized, community-contributed, or niche, it's better suite
|
||||
|
||||
| Requirement | Notes |
|
||||
|-------------|-------|
|
||||
| **Git** | With `--recurse-submodules` support, and the `git-lfs` extension installed |
|
||||
| **Git** | With `--recurse-submodules` support |
|
||||
| **Python 3.11+** | uv will install it if missing |
|
||||
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
|
||||
| **Node.js 18+** | Optional — needed for browser tools and WhatsApp bridge |
|
||||
|
||||
### Clone and install
|
||||
|
||||
@@ -88,7 +88,7 @@ cp cli-config.yaml.example ~/.hermes/config.yaml
|
||||
touch ~/.hermes/.env
|
||||
|
||||
# Add at minimum an LLM provider key:
|
||||
echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
|
||||
echo 'OPENROUTER_API_KEY=sk-or-v1-your-key' >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
@@ -12,7 +12,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# Install system dependencies in one layer, clear APT cache
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli && \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
@@ -38,7 +38,7 @@ RUN npm install --prefer-offline --no-audit && \
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
COPY --chown=hermes:hermes . .
|
||||
|
||||
# Build web dashboard (Vite outputs to hermes_cli/web_dist/)
|
||||
# Build web dashboard (Vite outputs to hermes_agent/cli/web_dist/)
|
||||
RUN cd web && npm run build
|
||||
|
||||
# ---------- Python virtualenv ----------
|
||||
@@ -48,8 +48,7 @@ RUN uv venv && \
|
||||
uv pip install --no-cache-dir -e ".[all]"
|
||||
|
||||
# ---------- Runtime ----------
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_agent/cli/web_dist
|
||||
ENV HERMES_HOME=/opt/data
|
||||
ENV PATH="/opt/data/.local/bin:${PATH}"
|
||||
VOLUME [ "/opt/data" ]
|
||||
ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
graft hermes_agent
|
||||
graft skills
|
||||
graft optional-skills
|
||||
global-exclude __pycache__
|
||||
|
||||
@@ -173,6 +173,7 @@ python -m pytest tests/ -q
|
||||
- 💬 [Discord](https://discord.gg/NousResearch)
|
||||
- 📚 [Skills Hub](https://agentskills.io)
|
||||
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
|
||||
- 💡 [Discussions](https://github.com/NousResearch/hermes-agent/discussions)
|
||||
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account.
|
||||
|
||||
---
|
||||
|
||||
@@ -776,7 +776,6 @@ delegation:
|
||||
# max_concurrent_children: 3 # Max parallel child agents (default: 3)
|
||||
# max_spawn_depth: 1 # Tree depth cap (1-3, default: 1 = flat). Raise to 2 or 3 to allow orchestrator children to spawn their own workers.
|
||||
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
|
||||
# inherit_mcp_toolsets: true # When explicit child toolsets are narrowed, also keep the parent's MCP toolsets (default: true). Set false for strict intersection.
|
||||
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)
|
||||
# provider: "openrouter" # Override provider for subagents (empty = inherit parent)
|
||||
# # Resolves full credentials (base_url, api_key) automatically.
|
||||
|
||||
@@ -29,7 +29,7 @@ echo "📝 Logging to: $LOG_FILE"
|
||||
# Point to the example dataset in this directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
python batch_runner.py \
|
||||
python scripts/batch_runner.py \
|
||||
--dataset_file="$SCRIPT_DIR/example_browser_tasks.jsonl" \
|
||||
--batch_size=5 \
|
||||
--run_name="browser_tasks_example" \
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Generates tool-calling trajectories for multi-step web research tasks.
|
||||
#
|
||||
# Usage:
|
||||
# python batch_runner.py \
|
||||
# python scripts/batch_runner.py \
|
||||
# --config datagen-config-examples/web_research.yaml \
|
||||
# --run_name web_research_v1
|
||||
|
||||
|
||||
@@ -58,13 +58,6 @@ if [ ! -f "$HERMES_HOME/config.yaml" ]; then
|
||||
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
|
||||
fi
|
||||
|
||||
# Ensure the main config file remains accessible to the hermes runtime user
|
||||
# even if it was edited on the host after initial ownership setup.
|
||||
if [ -f "$HERMES_HOME/config.yaml" ]; then
|
||||
chown hermes:hermes "$HERMES_HOME/config.yaml"
|
||||
chmod 640 "$HERMES_HOME/config.yaml"
|
||||
fi
|
||||
|
||||
# SOUL.md
|
||||
if [ ! -f "$HERMES_HOME/SOUL.md" ]; then
|
||||
cp "$INSTALL_DIR/docker/SOUL.md" "$HERMES_HOME/SOUL.md"
|
||||
@@ -72,22 +65,7 @@ fi
|
||||
|
||||
# Sync bundled skills (manifest-based so user edits are preserved)
|
||||
if [ -d "$INSTALL_DIR/skills" ]; then
|
||||
python3 "$INSTALL_DIR/tools/skills_sync.py"
|
||||
hermes-skills-sync
|
||||
fi
|
||||
|
||||
# Final exec: two supported invocation patterns.
|
||||
#
|
||||
# docker run <image> -> exec `hermes` with no args (legacy default)
|
||||
# docker run <image> chat -q "..." -> exec `hermes chat -q "..."` (legacy wrap)
|
||||
# docker run <image> sleep infinity -> exec `sleep infinity` directly
|
||||
# docker run <image> bash -> exec `bash` directly
|
||||
#
|
||||
# If the first positional arg resolves to an executable on PATH, we assume the
|
||||
# caller wants to run it directly (needed by the launcher which runs long-lived
|
||||
# `sleep infinity` sandbox containers — see tools/environments/docker.py).
|
||||
# Otherwise we treat the args as a hermes subcommand and wrap with `hermes`,
|
||||
# preserving the documented `docker run <image> <subcommand>` behavior.
|
||||
if [ $# -gt 0 ] && command -v "$1" >/dev/null 2>&1; then
|
||||
exec "$@"
|
||||
fi
|
||||
exec hermes "$@"
|
||||
|
||||
@@ -18,11 +18,14 @@ import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING
|
||||
|
||||
from model_tools import handle_function_call
|
||||
from tools.terminal_tool import get_active_env
|
||||
from tools.tool_result_storage import maybe_persist_tool_result, enforce_turn_budget
|
||||
if TYPE_CHECKING:
|
||||
from hermes_agent.tools.budget_config import BudgetConfig
|
||||
|
||||
from hermes_agent.tools.dispatch import handle_function_call
|
||||
from hermes_agent.tools.terminal import get_active_env
|
||||
from hermes_agent.tools.result_storage import maybe_persist_tool_result, enforce_turn_budget
|
||||
|
||||
# Thread pool for running sync tool calls that internally use asyncio.run()
|
||||
# (e.g., the Modal/Docker/Daytona terminal backends). Running them in a separate
|
||||
@@ -161,7 +164,7 @@ class HermesAgentLoop:
|
||||
thresholds, per-turn aggregate budget, and preview size.
|
||||
If None, uses DEFAULT_BUDGET (current hardcoded values).
|
||||
"""
|
||||
from tools.budget_config import DEFAULT_BUDGET
|
||||
from hermes_agent.tools.budget_config import DEFAULT_BUDGET
|
||||
self.server = server
|
||||
self.tool_schemas = tool_schemas
|
||||
self.valid_tool_names = valid_tool_names
|
||||
@@ -187,7 +190,7 @@ class HermesAgentLoop:
|
||||
tool_errors: List[ToolError] = []
|
||||
|
||||
# Per-loop TodoStore for the todo tool (ephemeral, dies with the loop)
|
||||
from tools.todo_tool import TodoStore, todo_tool as _todo_tool
|
||||
from hermes_agent.tools.todo import TodoStore, todo_tool as _todo_tool
|
||||
_todo_store = TodoStore()
|
||||
|
||||
# Extract user task from first user message for browser_snapshot context
|
||||
|
||||
@@ -60,7 +60,7 @@ from atroposlib.envs.server_handling.server_manager import APIServerConfig
|
||||
from environments.agent_loop import AgentResult, HermesAgentLoop
|
||||
from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig
|
||||
from environments.tool_context import ToolContext
|
||||
from tools.terminal_tool import (
|
||||
from hermes_agent.tools.terminal import (
|
||||
register_task_env_overrides,
|
||||
clear_task_env_overrides,
|
||||
cleanup_vm,
|
||||
@@ -876,7 +876,7 @@ class TerminalBench2EvalEnv(HermesAgentBaseEnv):
|
||||
# Let cancellations propagate (finally blocks run cleanup_vm)
|
||||
await asyncio.gather(*eval_tasks, return_exceptions=True)
|
||||
# Belt-and-suspenders: clean up any remaining sandboxes
|
||||
from tools.terminal_tool import cleanup_all_environments
|
||||
from hermes_agent.tools.terminal import cleanup_all_environments
|
||||
cleanup_all_environments()
|
||||
print("All sandboxes cleaned up.")
|
||||
return
|
||||
@@ -984,7 +984,7 @@ class TerminalBench2EvalEnv(HermesAgentBaseEnv):
|
||||
|
||||
# Kill all remaining sandboxes. Timed-out tasks leave orphaned thread
|
||||
# pool workers still executing commands -- cleanup_all stops them.
|
||||
from tools.terminal_tool import cleanup_all_environments
|
||||
from hermes_agent.tools.terminal import cleanup_all_environments
|
||||
print("\nCleaning up all sandboxes...")
|
||||
cleanup_all_environments()
|
||||
|
||||
|
||||
@@ -709,7 +709,7 @@ class YCBenchEvalEnv(HermesAgentBaseEnv):
|
||||
tqdm.write("\n[INTERRUPTED] Stopping evaluation...")
|
||||
pbar.close()
|
||||
try:
|
||||
from tools.terminal_tool import cleanup_all_environments
|
||||
from hermes_agent.tools.terminal import cleanup_all_environments
|
||||
cleanup_all_environments()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -819,7 +819,7 @@ class YCBenchEvalEnv(HermesAgentBaseEnv):
|
||||
print(f"Results saved to: {self._streaming_path}")
|
||||
|
||||
try:
|
||||
from tools.terminal_tool import cleanup_all_environments
|
||||
from hermes_agent.tools.terminal import cleanup_all_environments
|
||||
cleanup_all_environments()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -62,15 +62,15 @@ from atroposlib.type_definitions import Item
|
||||
|
||||
from environments.agent_loop import AgentResult, HermesAgentLoop
|
||||
from environments.tool_context import ToolContext
|
||||
from tools.budget_config import (
|
||||
from hermes_agent.tools.budget_config import (
|
||||
DEFAULT_RESULT_SIZE_CHARS,
|
||||
DEFAULT_TURN_BUDGET_CHARS,
|
||||
DEFAULT_PREVIEW_SIZE_CHARS,
|
||||
)
|
||||
|
||||
# Import hermes-agent toolset infrastructure
|
||||
from model_tools import get_tool_definitions
|
||||
from toolset_distributions import sample_toolsets_from_distribution
|
||||
from hermes_agent.tools.dispatch import get_tool_definitions
|
||||
from hermes_agent.tools.distributions import sample_toolsets_from_distribution
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -209,7 +209,7 @@ class HermesAgentEnvConfig(BaseEnvConfig):
|
||||
|
||||
def build_budget_config(self):
|
||||
"""Build a BudgetConfig from env config fields."""
|
||||
from tools.budget_config import BudgetConfig
|
||||
from hermes_agent.tools.budget_config import BudgetConfig
|
||||
return BudgetConfig(
|
||||
default_result_size=self.default_result_size_chars,
|
||||
turn_budget=self.turn_budget_chars,
|
||||
|
||||
@@ -31,9 +31,9 @@ from typing import Any, Dict, List, Optional
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
|
||||
from model_tools import handle_function_call
|
||||
from tools.terminal_tool import cleanup_vm
|
||||
from tools.browser_tool import cleanup_browser
|
||||
from hermes_agent.tools.dispatch import handle_function_call
|
||||
from hermes_agent.tools.terminal import cleanup_vm
|
||||
from hermes_agent.tools.browser.tool import cleanup_browser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -446,7 +446,7 @@ class ToolContext:
|
||||
"""
|
||||
# Kill any background processes from this rollout (safety net)
|
||||
try:
|
||||
from tools.process_registry import process_registry
|
||||
from hermes_agent.tools.process_registry import process_registry
|
||||
killed = process_registry.kill_all(task_id=self.task_id)
|
||||
if killed:
|
||||
logger.debug("Process cleanup for task %s: killed %d process(es)", self.task_id, killed)
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
"""
|
||||
QQBot scan-to-configure (QR code onboard) module.
|
||||
|
||||
Mirrors the Feishu onboarding pattern: synchronous HTTP + a single public
|
||||
entry-point ``qr_register()`` that handles the full flow (create task →
|
||||
display QR code → poll → decrypt credentials).
|
||||
|
||||
Calls the ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result`` APIs to
|
||||
generate a QR-code URL and poll for scan completion. On success the caller
|
||||
receives the bot's *app_id*, *client_secret* (decrypted locally), and the
|
||||
scanner's *user_openid* — enough to fully configure the QQBot gateway.
|
||||
|
||||
Reference: https://bot.q.qq.com/wiki/develop/api-v2/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from enum import IntEnum
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
from .constants import (
|
||||
ONBOARD_API_TIMEOUT,
|
||||
ONBOARD_CREATE_PATH,
|
||||
ONBOARD_POLL_INTERVAL,
|
||||
ONBOARD_POLL_PATH,
|
||||
PORTAL_HOST,
|
||||
QR_URL_TEMPLATE,
|
||||
)
|
||||
from .crypto import decrypt_secret, generate_bind_key
|
||||
from .utils import get_api_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bind status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BindStatus(IntEnum):
|
||||
"""Status codes returned by ``_poll_bind_result``."""
|
||||
|
||||
NONE = 0
|
||||
PENDING = 1
|
||||
COMPLETED = 2
|
||||
EXPIRED = 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
import qrcode as _qrcode_mod
|
||||
except (ImportError, TypeError):
|
||||
_qrcode_mod = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _render_qr(url: str) -> bool:
|
||||
"""Try to render a QR code in the terminal. Returns True if successful."""
|
||||
if _qrcode_mod is None:
|
||||
return False
|
||||
try:
|
||||
qr = _qrcode_mod.QRCode(
|
||||
error_correction=_qrcode_mod.constants.ERROR_CORRECT_M,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synchronous HTTP helpers (mirrors Feishu _post_registration pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_bind_task(timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[str, str]:
|
||||
"""Create a bind task and return *(task_id, aes_key_base64)*.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the API returns a non-zero ``retcode``.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
url = f"https://{PORTAL_HOST}{ONBOARD_CREATE_PATH}"
|
||||
key = generate_bind_key()
|
||||
|
||||
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = client.post(url, json={"key": key}, headers=get_api_headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if data.get("retcode") != 0:
|
||||
raise RuntimeError(data.get("msg", "create_bind_task failed"))
|
||||
|
||||
task_id = data.get("data", {}).get("task_id")
|
||||
if not task_id:
|
||||
raise RuntimeError("create_bind_task: missing task_id in response")
|
||||
|
||||
logger.debug("create_bind_task ok: task_id=%s", task_id)
|
||||
return task_id, key
|
||||
|
||||
|
||||
def _poll_bind_result(
|
||||
task_id: str,
|
||||
timeout: float = ONBOARD_API_TIMEOUT,
|
||||
) -> Tuple[BindStatus, str, str, str]:
|
||||
"""Poll the bind result for *task_id*.
|
||||
|
||||
Returns:
|
||||
A 4-tuple of ``(status, bot_appid, bot_encrypt_secret, user_openid)``.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the API returns a non-zero ``retcode``.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
url = f"https://{PORTAL_HOST}{ONBOARD_POLL_PATH}"
|
||||
|
||||
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = client.post(url, json={"task_id": task_id}, headers=get_api_headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if data.get("retcode") != 0:
|
||||
raise RuntimeError(data.get("msg", "poll_bind_result failed"))
|
||||
|
||||
d = data.get("data", {})
|
||||
return (
|
||||
BindStatus(d.get("status", 0)),
|
||||
str(d.get("bot_appid", "")),
|
||||
d.get("bot_encrypt_secret", ""),
|
||||
d.get("user_openid", ""),
|
||||
)
|
||||
|
||||
|
||||
def build_connect_url(task_id: str) -> str:
|
||||
"""Build the QR-code target URL for a given *task_id*."""
|
||||
return QR_URL_TEMPLATE.format(task_id=quote(task_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry-point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_REFRESHES = 3
|
||||
|
||||
|
||||
def qr_register(timeout_seconds: int = 600) -> Optional[dict]:
|
||||
"""Run the QQBot scan-to-configure QR registration flow.
|
||||
|
||||
Mirrors ``feishu.qr_register()``: handles create → display → poll →
|
||||
decrypt in one call. Unexpected errors propagate to the caller.
|
||||
|
||||
:returns:
|
||||
``{"app_id": ..., "client_secret": ..., "user_openid": ...}`` on
|
||||
success, or ``None`` on failure / expiry / cancellation.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
for refresh_count in range(_MAX_REFRESHES + 1):
|
||||
# ── Create bind task ──
|
||||
try:
|
||||
task_id, aes_key = _create_bind_task()
|
||||
except Exception as exc:
|
||||
logger.warning("[QQBot onboard] Failed to create bind task: %s", exc)
|
||||
return None
|
||||
|
||||
url = build_connect_url(task_id)
|
||||
|
||||
# ── Display QR code + URL ──
|
||||
print()
|
||||
if _render_qr(url):
|
||||
print(f" Scan the QR code above, or open this URL directly:\n {url}")
|
||||
else:
|
||||
print(f" Open this URL in QQ on your phone:\n {url}")
|
||||
print(" Tip: pip install qrcode to display a scannable QR code here")
|
||||
print()
|
||||
|
||||
# ── Poll loop ──
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status, app_id, encrypted_secret, user_openid = _poll_bind_result(task_id)
|
||||
except Exception:
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
if status == BindStatus.COMPLETED:
|
||||
client_secret = decrypt_secret(encrypted_secret, aes_key)
|
||||
print()
|
||||
print(f" QR scan complete! (App ID: {app_id})")
|
||||
if user_openid:
|
||||
print(f" Scanner's OpenID: {user_openid}")
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"client_secret": client_secret,
|
||||
"user_openid": user_openid,
|
||||
}
|
||||
|
||||
if status == BindStatus.EXPIRED:
|
||||
if refresh_count >= _MAX_REFRESHES:
|
||||
logger.warning("[QQBot onboard] QR code expired %d times — giving up", _MAX_REFRESHES)
|
||||
return None
|
||||
print(f"\n QR code expired, refreshing... ({refresh_count + 1}/{_MAX_REFRESHES})")
|
||||
break # next for-loop iteration creates a new task
|
||||
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
else:
|
||||
# deadline reached without completing
|
||||
logger.warning("[QQBot onboard] Poll timed out after %ds", timeout_seconds)
|
||||
return None
|
||||
|
||||
return None
|
||||
2
hermes
2
hermes
@@ -7,5 +7,5 @@ subcommands such as `gateway`, `cron`, and `doctor`.
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from hermes_cli.main import main
|
||||
from hermes_agent.cli.main import main
|
||||
main()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Hermes Agent — The self-improving AI agent."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Allow running the ACP adapter as ``python -m hermes_agent.acp``."""
|
||||
"""Allow running the ACP adapter as ``python -m acp_adapter``."""
|
||||
|
||||
from hermes_agent.acp.entry import main
|
||||
from .entry import main
|
||||
|
||||
main()
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional
|
||||
def detect_provider() -> Optional[str]:
|
||||
"""Resolve the active Hermes runtime provider, or None if unavailable."""
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
from hermes_agent.cli.runtime_provider import resolve_runtime_provider
|
||||
runtime = resolve_runtime_provider()
|
||||
api_key = runtime.get("api_key")
|
||||
provider = runtime.get("provider")
|
||||
|
||||
@@ -6,7 +6,7 @@ and starts the ACP agent server.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m hermes_agent.acp.entry
|
||||
python -m acp_adapter.entry
|
||||
# or
|
||||
hermes acp
|
||||
# or
|
||||
@@ -16,7 +16,8 @@ Usage::
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from hermes_constants import get_hermes_home
|
||||
from pathlib import Path
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
|
||||
|
||||
# Methods clients send as periodic liveness probes. They are not part of the
|
||||
@@ -82,7 +83,7 @@ def _setup_logging() -> None:
|
||||
|
||||
def _load_env() -> None:
|
||||
"""Load .env from HERMES_HOME (default ``~/.hermes``)."""
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from hermes_agent.cli.env_loader import load_hermes_dotenv
|
||||
|
||||
hermes_home = get_hermes_home()
|
||||
loaded = load_hermes_dotenv(hermes_home=hermes_home)
|
||||
@@ -104,7 +105,7 @@ def main() -> None:
|
||||
logger.info("Starting hermes-agent ACP adapter")
|
||||
|
||||
import acp
|
||||
from hermes_agent.acp.server import HermesACPAgent
|
||||
from .server import HermesACPAgent
|
||||
|
||||
agent = HermesACPAgent()
|
||||
try:
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import Any, Callable, Deque, Dict
|
||||
|
||||
import acp
|
||||
|
||||
from hermes_agent.acp.tools import (
|
||||
from .tools import (
|
||||
build_tool_complete,
|
||||
build_tool_start,
|
||||
make_tool_call_id,
|
||||
@@ -88,7 +88,7 @@ def make_tool_progress_cb(
|
||||
snapshot = None
|
||||
if name in {"write_file", "patch", "skill_manage"}:
|
||||
try:
|
||||
from agent.display import capture_local_edit_snapshot
|
||||
from hermes_agent.agent.display import capture_local_edit_snapshot
|
||||
|
||||
snapshot = capture_local_edit_snapshot(name, args)
|
||||
except Exception:
|
||||
|
||||
@@ -65,7 +65,7 @@ from hermes_agent.acp.session import SessionManager, SessionState
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from hermes_cli import __version__ as HERMES_VERSION
|
||||
from hermes_agent.cli import __version__ as HERMES_VERSION
|
||||
except Exception:
|
||||
HERMES_VERSION = "0.0.0"
|
||||
|
||||
@@ -172,7 +172,7 @@ class HermesACPAgent(acp.Agent):
|
||||
provider = getattr(state.agent, "provider", None) or detect_provider() or "openrouter"
|
||||
|
||||
try:
|
||||
from hermes_cli.models import curated_models_for_provider, normalize_provider, provider_label
|
||||
from hermes_agent.cli.models.models import curated_models_for_provider, normalize_provider, provider_label
|
||||
|
||||
normalized_provider = normalize_provider(provider)
|
||||
provider_name = provider_label(normalized_provider)
|
||||
@@ -235,7 +235,7 @@ class HermesACPAgent(acp.Agent):
|
||||
new_model = raw_model.strip()
|
||||
|
||||
try:
|
||||
from hermes_cli.models import detect_provider_for_model, parse_model_input
|
||||
from hermes_agent.cli.models.models import detect_provider_for_model, parse_model_input
|
||||
|
||||
target_provider, new_model = parse_model_input(new_model, current_provider)
|
||||
if target_provider == current_provider:
|
||||
@@ -257,7 +257,7 @@ class HermesACPAgent(acp.Agent):
|
||||
return
|
||||
|
||||
try:
|
||||
from tools.mcp_tool import register_mcp_servers
|
||||
from hermes_agent.tools.mcp.tool import register_mcp_servers
|
||||
|
||||
config_map: dict[str, dict] = {}
|
||||
for server in mcp_servers:
|
||||
@@ -285,7 +285,7 @@ class HermesACPAgent(acp.Agent):
|
||||
return
|
||||
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from hermes_agent.tools.dispatch import get_tool_definitions
|
||||
|
||||
enabled_toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
|
||||
disabled_toolsets = getattr(state.agent, "disabled_toolsets", None)
|
||||
@@ -572,7 +572,7 @@ class HermesACPAgent(acp.Agent):
|
||||
nonlocal previous_approval_cb, previous_interactive
|
||||
if approval_cb:
|
||||
try:
|
||||
from tools import terminal_tool as _terminal_tool
|
||||
from hermes_agent.tools import terminal as _terminal_tool
|
||||
previous_approval_cb = _terminal_tool._get_approval_callback()
|
||||
_terminal_tool.set_approval_callback(approval_cb)
|
||||
except Exception:
|
||||
@@ -599,7 +599,7 @@ class HermesACPAgent(acp.Agent):
|
||||
os.environ["HERMES_INTERACTIVE"] = previous_interactive
|
||||
if approval_cb:
|
||||
try:
|
||||
from tools import terminal_tool as _terminal_tool
|
||||
from hermes_agent.tools import terminal as _terminal_tool
|
||||
_terminal_tool.set_approval_callback(previous_approval_cb)
|
||||
except Exception:
|
||||
logger.debug("Could not restore approval callback", exc_info=True)
|
||||
@@ -618,7 +618,7 @@ class HermesACPAgent(acp.Agent):
|
||||
final_response = result.get("final_response", "")
|
||||
if final_response:
|
||||
try:
|
||||
from agent.title_generator import maybe_auto_title
|
||||
from hermes_agent.agent.title_generator import maybe_auto_title
|
||||
|
||||
maybe_auto_title(
|
||||
self.session_manager._get_db(),
|
||||
@@ -753,7 +753,7 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
def _cmd_tools(self, args: str, state: SessionState) -> str:
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from hermes_agent.tools.dispatch import get_tool_definitions
|
||||
toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
|
||||
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
|
||||
if not tools:
|
||||
@@ -804,7 +804,7 @@ class HermesACPAgent(acp.Agent):
|
||||
if not hasattr(agent, "_compress_context"):
|
||||
return "Context compression not available for this agent."
|
||||
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
from hermes_agent.providers.metadata import estimate_messages_tokens_rough
|
||||
|
||||
original_count = len(state.history)
|
||||
approx_tokens = estimate_messages_tokens_rough(state.history)
|
||||
|
||||
@@ -8,7 +8,7 @@ history.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
|
||||
import copy
|
||||
import json
|
||||
@@ -100,7 +100,7 @@ def _register_task_cwd(task_id: str, cwd: str) -> None:
|
||||
if not task_id:
|
||||
return
|
||||
try:
|
||||
from tools.terminal_tool import register_task_env_overrides
|
||||
from hermes_agent.tools.terminal import register_task_env_overrides
|
||||
register_task_env_overrides(task_id, {"cwd": cwd})
|
||||
except Exception:
|
||||
logger.debug("Failed to register ACP task cwd override", exc_info=True)
|
||||
@@ -111,7 +111,7 @@ def _clear_task_cwd(task_id: str) -> None:
|
||||
if not task_id:
|
||||
return
|
||||
try:
|
||||
from tools.terminal_tool import clear_task_env_overrides
|
||||
from hermes_agent.tools.terminal import clear_task_env_overrides
|
||||
clear_task_env_overrides(task_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to clear ACP task cwd override", exc_info=True)
|
||||
@@ -355,7 +355,7 @@ class SessionManager:
|
||||
if self._db_instance is not None:
|
||||
return self._db_instance
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
from hermes_agent.state import SessionDB
|
||||
hermes_home = get_hermes_home()
|
||||
self._db_instance = SessionDB(db_path=hermes_home / "state.db")
|
||||
return self._db_instance
|
||||
@@ -523,9 +523,9 @@ class SessionManager:
|
||||
if self._agent_factory is not None:
|
||||
return self._agent_factory()
|
||||
|
||||
from run_agent import AIAgent
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
from hermes_agent.agent.loop import AIAgent
|
||||
from hermes_agent.cli.config import load_config
|
||||
from hermes_agent.cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
config = load_config()
|
||||
model_cfg = config.get("model")
|
||||
|
||||
@@ -103,7 +103,7 @@ def _build_patch_mode_content(patch_text: str) -> List[Any]:
|
||||
return [acp.tool_content(acp.text_block(""))]
|
||||
|
||||
try:
|
||||
from tools.patch_parser import OperationType, parse_v4a_patch
|
||||
from hermes_agent.tools.patch_parser import OperationType, parse_v4a_patch
|
||||
|
||||
operations, error = parse_v4a_patch(patch_text)
|
||||
if error or not operations:
|
||||
@@ -243,7 +243,7 @@ def _build_tool_complete_content(
|
||||
|
||||
if tool_name in {"write_file", "patch", "skill_manage"}:
|
||||
try:
|
||||
from agent.display import extract_edit_diff
|
||||
from hermes_agent.agent.display import extract_edit_diff
|
||||
|
||||
diff_text = extract_edit_diff(
|
||||
tool_name,
|
||||
|
||||
0
hermes_agent/agent/context/__init__.py
Normal file
0
hermes_agent/agent/context/__init__.py
Normal file
@@ -24,14 +24,14 @@ import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.auxiliary_client import call_llm
|
||||
from agent.context_engine import ContextEngine
|
||||
from agent.model_metadata import (
|
||||
from hermes_agent.providers.auxiliary import call_llm
|
||||
from hermes_agent.agent.context.engine import ContextEngine
|
||||
from hermes_agent.providers.metadata import (
|
||||
MINIMUM_CONTEXT_LENGTH,
|
||||
get_model_context_length,
|
||||
estimate_messages_tokens_rough,
|
||||
)
|
||||
from agent.redact import redact_sensitive_text
|
||||
from hermes_agent.agent.redact import redact_sensitive_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,47 +64,6 @@ _CHARS_PER_TOKEN = 4
|
||||
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
|
||||
|
||||
def _content_text_for_contains(content: Any) -> str:
|
||||
"""Return a best-effort text view of message content.
|
||||
|
||||
Used only for substring checks when we need to know whether we've already
|
||||
appended a note to a message. Keeps multimodal lists intact elsewhere.
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(part for part in parts if part)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _append_text_to_content(content: Any, text: str, *, prepend: bool = False) -> Any:
|
||||
"""Append or prepend plain text to message content safely.
|
||||
|
||||
Compression sometimes needs to add a note or merge a summary into an
|
||||
existing message. Message content may be plain text or a multimodal list of
|
||||
blocks, so direct string concatenation is not always safe.
|
||||
"""
|
||||
if content is None:
|
||||
return text
|
||||
if isinstance(content, str):
|
||||
return text + content if prepend else content + text
|
||||
if isinstance(content, list):
|
||||
text_block = {"type": "text", "text": text}
|
||||
return [text_block, *content] if prepend else [*content, text_block]
|
||||
rendered = str(content)
|
||||
return text + rendered if prepend else rendered + text
|
||||
|
||||
|
||||
def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str:
|
||||
"""Shrink long string values inside a tool-call arguments JSON blob while
|
||||
preserving JSON validity.
|
||||
@@ -848,7 +807,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
)
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic) # retry immediately
|
||||
return self._generate_summary(turns_to_summarize) # retry immediately
|
||||
|
||||
# Transient errors (timeout, rate limit, network) — shorter cooldown
|
||||
_transient_cooldown = 60
|
||||
@@ -1185,13 +1144,10 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
for i in range(compress_start):
|
||||
msg = messages[i].copy()
|
||||
if i == 0 and msg.get("role") == "system":
|
||||
existing = msg.get("content")
|
||||
existing = msg.get("content") or ""
|
||||
_compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
|
||||
if _compression_note not in _content_text_for_contains(existing):
|
||||
msg["content"] = _append_text_to_content(
|
||||
existing,
|
||||
"\n\n" + _compression_note if isinstance(existing, str) and existing else _compression_note,
|
||||
)
|
||||
if _compression_note not in existing:
|
||||
msg["content"] = existing + "\n\n" + _compression_note
|
||||
compressed.append(msg)
|
||||
|
||||
# If LLM summary failed, insert a static fallback so the model
|
||||
@@ -1235,15 +1191,12 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
for i in range(compress_end, n_messages):
|
||||
msg = messages[i].copy()
|
||||
if _merge_summary_into_tail and i == compress_end:
|
||||
merged_prefix = (
|
||||
original = msg.get("content") or ""
|
||||
msg["content"] = (
|
||||
summary
|
||||
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---\n\n"
|
||||
)
|
||||
msg["content"] = _append_text_to_content(
|
||||
msg.get("content"),
|
||||
merged_prefix,
|
||||
prepend=True,
|
||||
+ original
|
||||
)
|
||||
_merge_summary_into_tail = False
|
||||
compressed.append(msg)
|
||||
@@ -11,7 +11,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from agent.model_metadata import estimate_tokens_rough
|
||||
from hermes_agent.providers.metadata import estimate_tokens_rough
|
||||
|
||||
_QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')'
|
||||
REFERENCE_PATTERN = re.compile(
|
||||
@@ -315,7 +315,7 @@ async def _fetch_url_content(
|
||||
|
||||
|
||||
async def _default_url_fetcher(url: str) -> str:
|
||||
from tools.web_tools import web_extract_tool
|
||||
from hermes_agent.tools.web import web_extract_tool
|
||||
|
||||
raw = await web_extract_tool([url], format="markdown", use_llm_processing=True)
|
||||
payload = json.loads(raw)
|
||||
@@ -340,7 +340,7 @@ def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) -
|
||||
|
||||
|
||||
def _ensure_reference_path_allowed(path: Path) -> None:
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
home = Path(os.path.expanduser("~")).resolve()
|
||||
hermes_home = get_hermes_home().resolve()
|
||||
|
||||
@@ -21,8 +21,8 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from agent.file_safety import get_read_block_error, is_write_denied
|
||||
from agent.redact import redact_sensitive_text
|
||||
from hermes_agent.agent.file_safety import get_read_block_error, is_write_denied
|
||||
from hermes_agent.agent.redact import redact_sensitive_text
|
||||
|
||||
ACP_MARKER_BASE_URL = "acp://copilot"
|
||||
_DEFAULT_TIMEOUT_SECONDS = 900.0
|
||||
@@ -13,7 +13,7 @@ from dataclasses import dataclass, field
|
||||
from difflib import unified_diff
|
||||
from pathlib import Path
|
||||
|
||||
from utils import safe_json_loads
|
||||
from hermes_agent.utils import safe_json_loads
|
||||
|
||||
# ANSI escape codes for coloring tool failure indicators
|
||||
_RED = "\033[31m"
|
||||
@@ -43,7 +43,7 @@ def _diff_ansi() -> dict[str, str]:
|
||||
plus = "\033[38;2;255;255;255;48;2;20;90;20m"
|
||||
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
from hermes_agent.cli.ui.skin_engine import get_active_skin
|
||||
skin = get_active_skin()
|
||||
|
||||
def _hex_fg(key: str, fallback_rgb: tuple[int, int, int]) -> str:
|
||||
@@ -118,7 +118,7 @@ def get_tool_preview_max_len() -> int:
|
||||
def _get_skin():
|
||||
"""Get the active skin config, or None if not available."""
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
from hermes_agent.cli.ui.skin_engine import get_active_skin
|
||||
return get_active_skin()
|
||||
except Exception:
|
||||
return None
|
||||
@@ -148,7 +148,7 @@ def get_tool_emoji(tool_name: str, default: str = "⚡") -> str:
|
||||
return override
|
||||
# 2. Registry default
|
||||
try:
|
||||
from tools.registry import registry
|
||||
from hermes_agent.tools.registry import registry
|
||||
emoji = registry.get_emoji(tool_name, default="")
|
||||
if emoji:
|
||||
return emoji
|
||||
@@ -311,7 +311,7 @@ def _resolve_skill_manage_paths(args: dict) -> list[Path]:
|
||||
if not action or not name:
|
||||
return []
|
||||
|
||||
from tools.skill_manager_tool import _find_skill, _resolve_skill_dir
|
||||
from hermes_agent.tools.skills.manager import _find_skill, _resolve_skill_dir
|
||||
|
||||
if action == "create":
|
||||
skill_dir = _resolve_skill_dir(name, args.get("category"))
|
||||
@@ -729,6 +729,7 @@ class KawaiiSpinner:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
frame = self.spinner_frames[self.frame_idx % len(self.spinner_frames)]
|
||||
assert self.start_time is not None # start() sets it before thread starts
|
||||
elapsed = time.time() - self.start_time
|
||||
if wings:
|
||||
left, right = wings[self.frame_idx % len(wings)]
|
||||
@@ -10,7 +10,7 @@ from typing import Optional
|
||||
def _hermes_home_path() -> Path:
|
||||
"""Resolve the active HERMES_HOME (profile-aware) without circular imports."""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home # local import to avoid cycles
|
||||
from hermes_agent.constants import get_hermes_home # local import to avoid cycles
|
||||
return get_hermes_home()
|
||||
except Exception:
|
||||
return Path(os.path.expanduser("~/.hermes"))
|
||||
0
hermes_agent/agent/image_gen/__init__.py
Normal file
0
hermes_agent/agent/image_gen/__init__.py
Normal file
@@ -164,7 +164,7 @@ def resolve_aspect_ratio(value: Optional[str]) -> str:
|
||||
|
||||
def _images_cache_dir() -> Path:
|
||||
"""Return ``$HERMES_HOME/cache/images/``, creating parents as needed."""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
|
||||
path = get_hermes_home() / "cache" / "images"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -24,7 +24,7 @@ import logging
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from agent.image_gen_provider import ImageGenProvider
|
||||
from hermes_agent.agent.image_gen.provider import ImageGenProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,7 +80,7 @@ def get_active_provider() -> Optional[ImageGenProvider]:
|
||||
"""
|
||||
configured: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
@@ -10,7 +10,7 @@ multi-platform architecture with additional cost estimation and platform
|
||||
breakdown capabilities.
|
||||
|
||||
Usage:
|
||||
from agent.insights import InsightsEngine
|
||||
from hermes_agent.agent.insights import InsightsEngine
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=30)
|
||||
print(engine.format_terminal(report))
|
||||
@@ -22,7 +22,7 @@ from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.usage_pricing import (
|
||||
from hermes_agent.providers.pricing import (
|
||||
CanonicalUsage,
|
||||
DEFAULT_PRICING,
|
||||
estimate_usage_cost,
|
||||
File diff suppressed because it is too large
Load Diff
0
hermes_agent/agent/memory/__init__.py
Normal file
0
hermes_agent/agent/memory/__init__.py
Normal file
@@ -33,8 +33,8 @@ import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from tools.registry import tool_error
|
||||
from hermes_agent.agent.memory.provider import MemoryProvider
|
||||
from hermes_agent.tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -361,7 +361,7 @@ class MemoryManager:
|
||||
``get_hermes_home()`` themselves.
|
||||
"""
|
||||
if "hermes_home" not in kwargs:
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
kwargs["hermes_home"] = str(get_hermes_home())
|
||||
for provider in self._providers:
|
||||
try:
|
||||
@@ -12,10 +12,10 @@ import threading
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
|
||||
from hermes_agent.constants import get_hermes_home, get_skills_dir, is_wsl
|
||||
from typing import Optional
|
||||
|
||||
from agent.skill_utils import (
|
||||
from hermes_agent.agent.skill_utils import (
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
get_all_skills_dirs,
|
||||
@@ -24,7 +24,7 @@ from agent.skill_utils import (
|
||||
parse_frontmatter,
|
||||
skill_matches_platform,
|
||||
)
|
||||
from utils import atomic_json_write
|
||||
from hermes_agent.utils import atomic_json_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -370,32 +370,6 @@ PLATFORM_HINTS = {
|
||||
"MEDIA:/absolute/path/to/file in your response. Images (.jpg, .png, "
|
||||
".heic) appear as photos and other files arrive as attachments."
|
||||
),
|
||||
"mattermost": (
|
||||
"You are in a Mattermost workspace communicating with your user. "
|
||||
"Mattermost renders standard Markdown — headings, bold, italic, code "
|
||||
"blocks, and tables all work. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png, .webp) are uploaded as photo "
|
||||
"attachments, audio and video as file attachments. "
|
||||
"Image URLs in markdown format  are rendered as inline previews automatically."
|
||||
),
|
||||
"matrix": (
|
||||
"You are in a Matrix room communicating with your user. "
|
||||
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
|
||||
"the adapter converts your Markdown to HTML for rich display. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
|
||||
"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
|
||||
"and other files as downloadable attachments."
|
||||
),
|
||||
"feishu": (
|
||||
"You are in a Feishu (Lark) workspace communicating with your user. "
|
||||
"Feishu renders Markdown in messages — bold, italic, code blocks, and "
|
||||
"links are supported. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png, .webp) are uploaded and displayed "
|
||||
"inline, audio files as voice messages, and other files as attachments."
|
||||
),
|
||||
"weixin": (
|
||||
"You are on Weixin/WeChat. Markdown formatting is supported, so you may use it when "
|
||||
"it improves readability, but keep the message compact and chat-friendly. You can send media files natively: "
|
||||
@@ -645,7 +619,7 @@ def build_skills_system_prompt(
|
||||
# ── Layer 1: in-process LRU cache ─────────────────────────────────
|
||||
# Include the resolved platform so per-platform disabled-skill lists
|
||||
# produce distinct cache entries (gateway serves multiple platforms).
|
||||
from gateway.session_context import get_session_env
|
||||
from hermes_agent.gateway.session_context import get_session_env
|
||||
_platform_hint = (
|
||||
os.environ.get("HERMES_PLATFORM")
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
@@ -850,8 +824,8 @@ def build_skills_system_prompt(
|
||||
def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -> str:
|
||||
"""Build a compact Nous subscription capability block for the system prompt."""
|
||||
try:
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
from hermes_agent.cli.nous_subscription import get_nous_subscription_features
|
||||
from hermes_agent.tools.backend_helpers import managed_nous_tools_enabled
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to import Nous subscription helper: %s", exc)
|
||||
return ""
|
||||
@@ -937,7 +911,7 @@ def load_soul_md() -> Optional[str]:
|
||||
``skip_soul=True`` so SOUL.md isn't injected twice.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import ensure_hermes_home
|
||||
from hermes_agent.cli.config import ensure_hermes_home
|
||||
ensure_hermes_home()
|
||||
except Exception as e:
|
||||
logger.debug("Could not ensure HERMES_HOME before loading SOUL.md: %s", e)
|
||||
@@ -75,7 +75,7 @@ try:
|
||||
except ImportError: # pragma: no cover
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -177,7 +177,7 @@ def register_from_config(
|
||||
registered: List[ShellHookSpec] = []
|
||||
|
||||
# Import lazily — avoids circular imports at module-load time.
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
from hermes_agent.cli.plugins import get_plugin_manager
|
||||
|
||||
manager = get_plugin_manager()
|
||||
|
||||
@@ -243,7 +243,7 @@ def _parse_hooks_block(hooks_cfg: Any) -> List[ShellHookSpec]:
|
||||
Malformed entries warn-and-skip — we never raise from config parsing
|
||||
because a broken hook must not crash the agent.
|
||||
"""
|
||||
from hermes_cli.plugins import VALID_HOOKS
|
||||
from hermes_agent.cli.plugins import VALID_HOOKS
|
||||
|
||||
if not isinstance(hooks_cfg, dict):
|
||||
return []
|
||||
@@ -13,7 +13,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_agent.constants import display_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +39,7 @@ _INLINE_SHELL_MAX_OUTPUT = 4000
|
||||
def _load_skills_config() -> dict:
|
||||
"""Load the ``skills`` section of config.yaml (best-effort)."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
|
||||
cfg = load_config() or {}
|
||||
skills_cfg = cfg.get("skills")
|
||||
@@ -156,7 +156,7 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
|
||||
return None
|
||||
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, skill_view
|
||||
from hermes_agent.tools.skills.tool import SKILLS_DIR, skill_view
|
||||
|
||||
identifier_path = Path(raw_identifier).expanduser()
|
||||
if identifier_path.is_absolute():
|
||||
@@ -202,7 +202,7 @@ def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None
|
||||
without needing to read config.yaml itself.
|
||||
"""
|
||||
try:
|
||||
from agent.skill_utils import (
|
||||
from hermes_agent.agent.skill_utils import (
|
||||
extract_skill_config_vars,
|
||||
parse_frontmatter,
|
||||
resolve_skill_config_values,
|
||||
@@ -241,7 +241,7 @@ def _build_skill_message(
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""Format a loaded skill into a user/system message payload."""
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
from hermes_agent.tools.skills.tool import SKILLS_DIR
|
||||
|
||||
content = str(loaded_skill.get("content") or "")
|
||||
|
||||
@@ -344,8 +344,8 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
global _skill_commands
|
||||
_skill_commands = {}
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
from hermes_agent.tools.skills.tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
|
||||
from hermes_agent.agent.skill_utils import get_external_skills_dirs
|
||||
disabled = _get_disabled_skill_names()
|
||||
seen_names: set = set()
|
||||
|
||||
@@ -12,7 +12,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import get_config_path, get_skills_dir
|
||||
from hermes_agent.constants import get_config_path, get_skills_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -145,7 +145,7 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
|
||||
if not isinstance(skills_cfg, dict):
|
||||
return set()
|
||||
|
||||
from gateway.session_context import get_session_env
|
||||
from hermes_agent.gateway.session_context import get_session_env
|
||||
resolved_platform = (
|
||||
platform
|
||||
or os.getenv("HERMES_PLATFORM")
|
||||
@@ -435,7 +435,7 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
Excludes ``.git``, ``.github``, ``.hub`` directories.
|
||||
"""
|
||||
matches = []
|
||||
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
||||
for root, dirs, files in os.walk(skills_dir):
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
|
||||
if filename in files:
|
||||
matches.append(Path(root) / filename)
|
||||
@@ -455,7 +455,8 @@ def parse_qualified_name(name: str) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
if ":" not in name:
|
||||
return None, name
|
||||
return tuple(name.split(":", 1)) # type: ignore[return-value]
|
||||
ns, bare = name.split(":", 1)
|
||||
return ns, bare
|
||||
|
||||
|
||||
def is_valid_namespace(candidate: Optional[str]) -> bool:
|
||||
@@ -19,7 +19,7 @@ import shlex
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Set
|
||||
|
||||
from agent.prompt_builder import _scan_context_content
|
||||
from hermes_agent.agent.prompt_builder import _scan_context_content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from agent.auxiliary_client import call_llm
|
||||
from hermes_agent.providers.auxiliary import call_llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,7 +38,7 @@ def generate_title(user_message: str, assistant_response: str, timeout: float =
|
||||
response = call_llm(
|
||||
task="title_generation",
|
||||
messages=messages,
|
||||
max_tokens=500,
|
||||
max_tokens=30,
|
||||
temperature=0.3,
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -8,6 +8,6 @@ The terminal_tool.py factory (_create_environment) selects the backend
|
||||
based on the TERMINAL_ENV configuration.
|
||||
"""
|
||||
|
||||
from tools.environments.base import BaseEnvironment
|
||||
from hermes_agent.backends.base import BaseEnvironment
|
||||
|
||||
__all__ = ["BaseEnvironment"]
|
||||
@@ -20,8 +20,8 @@ from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import IO, Callable, Protocol
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.interrupt import is_interrupted
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
from hermes_agent.tools.interrupt import is_interrupted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -245,7 +245,7 @@ class _ThreadedProcessHandle:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
def wait(self, timeout: float | None = None) -> int | None:
|
||||
self._done.wait(timeout=timeout)
|
||||
return self._returncode
|
||||
|
||||
@@ -383,7 +383,7 @@ class BaseEnvironment(ABC):
|
||||
quoted_cwd = (
|
||||
shlex.quote(cwd) if cwd != "~" and not cwd.startswith("~/") else cwd
|
||||
)
|
||||
parts.append(f"builtin cd {quoted_cwd} || exit 126")
|
||||
parts.append(f"cd {quoted_cwd} || exit 126")
|
||||
|
||||
# Run the actual command
|
||||
parts.append(f"eval '{escaped}'")
|
||||
@@ -710,7 +710,7 @@ class BaseEnvironment(ABC):
|
||||
# server, `yes > /dev/null`, etc.), leaking the subshell forever.
|
||||
# Rewriting to `A && { B & }` runs B as a plain background in the
|
||||
# current shell — no subshell wait.
|
||||
from tools.terminal_tool import _rewrite_compound_background
|
||||
from hermes_agent.tools.terminal import _rewrite_compound_background
|
||||
exec_command = _rewrite_compound_background(exec_command)
|
||||
effective_timeout = timeout or self.timeout
|
||||
effective_cwd = cwd or self.cwd
|
||||
@@ -755,9 +755,9 @@ class BaseEnvironment(ABC):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _prepare_command(self, command: str) -> tuple[str, str | None]:
|
||||
def _prepare_command(self, command: str) -> tuple[str | None, str | None]:
|
||||
"""Transform sudo commands if SUDO_PASSWORD is available."""
|
||||
from tools.terminal_tool import _transform_sudo_command
|
||||
from hermes_agent.tools.terminal import _transform_sudo_command
|
||||
|
||||
return _transform_sudo_command(command)
|
||||
|
||||
@@ -12,11 +12,11 @@ import shlex
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from tools.environments.base import (
|
||||
from hermes_agent.backends.base import (
|
||||
BaseEnvironment,
|
||||
_ThreadedProcessHandle,
|
||||
)
|
||||
from tools.environments.file_sync import (
|
||||
from hermes_agent.backends.file_sync import (
|
||||
FileSyncManager,
|
||||
iter_sync_files,
|
||||
quoted_mkdir_command,
|
||||
@@ -14,8 +14,8 @@ import sys
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from tools.environments.base import BaseEnvironment, _popen_bash
|
||||
from tools.environments.local import _HERMES_PROVIDER_ENV_BLOCKLIST
|
||||
from hermes_agent.backends.base import BaseEnvironment, _popen_bash
|
||||
from hermes_agent.backends.local import _HERMES_PROVIDER_ENV_BLOCKLIST
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,7 +91,7 @@ def _normalize_env_dict(env: dict | None) -> dict[str, str]:
|
||||
def _load_hermes_env_vars() -> dict[str, str]:
|
||||
"""Load ~/.hermes/.env values without failing Docker command execution."""
|
||||
try:
|
||||
from hermes_cli.config import load_env
|
||||
from hermes_agent.cli.config import load_env
|
||||
|
||||
return load_env() or {}
|
||||
except Exception:
|
||||
@@ -148,10 +148,6 @@ def find_docker() -> Optional[str]:
|
||||
# We drop all capabilities then add back the minimum needed:
|
||||
# DAC_OVERRIDE - root can write to bind-mounted dirs owned by host user
|
||||
# CHOWN/FOWNER - package managers (pip, npm, apt) need to set file ownership
|
||||
# SETUID/SETGID - the image entrypoint drops from root to the 'hermes'
|
||||
# user via `gosu`, which requires these caps. Combined with
|
||||
# `no-new-privileges`, gosu still cannot escalate back to root after
|
||||
# the drop, so the security posture is preserved.
|
||||
# Block privilege escalation and limit PIDs.
|
||||
# /tmp is size-limited and nosuid but allows exec (needed by pip/npm builds).
|
||||
_SECURITY_ARGS = [
|
||||
@@ -159,8 +155,6 @@ _SECURITY_ARGS = [
|
||||
"--cap-add", "DAC_OVERRIDE",
|
||||
"--cap-add", "CHOWN",
|
||||
"--cap-add", "FOWNER",
|
||||
"--cap-add", "SETUID",
|
||||
"--cap-add", "SETGID",
|
||||
"--security-opt", "no-new-privileges",
|
||||
"--pids-limit", "256",
|
||||
"--tmpfs", "/tmp:rw,nosuid,size=512m",
|
||||
@@ -304,7 +298,7 @@ class DockerEnvironment(BaseEnvironment):
|
||||
# Persistent workspace via bind mounts from a configurable host directory
|
||||
# (TERMINAL_SANDBOX_DIR, default ~/.hermes/sandboxes/). Non-persistent
|
||||
# mode uses tmpfs (ephemeral, fast, gone on cleanup).
|
||||
from tools.environments.base import get_sandbox_dir
|
||||
from hermes_agent.backends.base import get_sandbox_dir
|
||||
|
||||
# User-configured volume mounts (from config.yaml docker_volumes)
|
||||
volume_args = []
|
||||
@@ -368,7 +362,7 @@ class DockerEnvironment(BaseEnvironment):
|
||||
# Mount credential files (OAuth tokens, etc.) declared by skills.
|
||||
# Read-only so the container can authenticate but not modify host creds.
|
||||
try:
|
||||
from tools.credential_files import (
|
||||
from hermes_agent.tools.credential_files import (
|
||||
get_credential_file_mounts,
|
||||
get_skills_directory_mount,
|
||||
get_cache_directory_mounts,
|
||||
@@ -470,7 +464,7 @@ class DockerEnvironment(BaseEnvironment):
|
||||
explicit_forward_keys = set(self._forward_env)
|
||||
passthrough_keys: set[str] = set()
|
||||
try:
|
||||
from tools.env_passthrough import get_all_passthrough
|
||||
from hermes_agent.tools.env_passthrough import get_all_passthrough
|
||||
passthrough_keys = set(get_all_passthrough())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -24,8 +24,8 @@ except ImportError:
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.environments.base import _file_mtime_key
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
from hermes_agent.backends.base import _file_mtime_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,7 +50,7 @@ def iter_sync_files(container_base: str = "/root/.hermes") -> list[tuple[str, st
|
||||
"""
|
||||
# Late import: credential_files imports agent modules that create
|
||||
# circular dependencies if loaded at file_sync module level.
|
||||
from tools.credential_files import (
|
||||
from hermes_agent.tools.credential_files import (
|
||||
get_credential_file_mounts,
|
||||
iter_cache_files,
|
||||
iter_skills_files,
|
||||
@@ -7,7 +7,7 @@ import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from tools.environments.base import BaseEnvironment, _pipe_stdin
|
||||
from hermes_agent.backends.base import BaseEnvironment, _pipe_stdin
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
@@ -21,7 +21,7 @@ def _build_provider_env_blocklist() -> frozenset:
|
||||
blocked: set[str] = set()
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_agent.cli.auth.auth import PROVIDER_REGISTRY
|
||||
for pconfig in PROVIDER_REGISTRY.values():
|
||||
blocked.update(pconfig.api_key_env_vars)
|
||||
if pconfig.base_url_env_var:
|
||||
@@ -30,7 +30,7 @@ def _build_provider_env_blocklist() -> frozenset:
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.config import OPTIONAL_ENV_VARS
|
||||
from hermes_agent.cli.config import OPTIONAL_ENV_VARS
|
||||
for name, metadata in OPTIONAL_ENV_VARS.items():
|
||||
category = metadata.get("category")
|
||||
if category in {"tool", "messaging"}:
|
||||
@@ -110,7 +110,7 @@ _HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist()
|
||||
def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = None) -> dict:
|
||||
"""Filter Hermes-managed secrets from a subprocess environment."""
|
||||
try:
|
||||
from tools.env_passthrough import is_env_passthrough as _is_passthrough
|
||||
from hermes_agent.tools.env_passthrough import is_env_passthrough as _is_passthrough
|
||||
except Exception:
|
||||
_is_passthrough = lambda _: False # noqa: E731
|
||||
|
||||
@@ -130,7 +130,7 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non
|
||||
sanitized[key] = value
|
||||
|
||||
# Per-profile HOME isolation for background processes (same as _make_run_env).
|
||||
from hermes_constants import get_subprocess_home
|
||||
from hermes_agent.constants import get_subprocess_home
|
||||
_profile_home = get_subprocess_home()
|
||||
if _profile_home:
|
||||
sanitized["HOME"] = _profile_home
|
||||
@@ -186,7 +186,7 @@ _SANE_PATH = (
|
||||
def _make_run_env(env: dict) -> dict:
|
||||
"""Build a run environment with a sane PATH and provider-var stripping."""
|
||||
try:
|
||||
from tools.env_passthrough import is_env_passthrough as _is_passthrough
|
||||
from hermes_agent.tools.env_passthrough import is_env_passthrough as _is_passthrough
|
||||
except Exception:
|
||||
_is_passthrough = lambda _: False # noqa: E731
|
||||
|
||||
@@ -205,7 +205,7 @@ def _make_run_env(env: dict) -> dict:
|
||||
# Per-profile HOME isolation: redirect system tool configs (git, ssh, gh,
|
||||
# npm …) into {HERMES_HOME}/home/ when that directory exists. Only the
|
||||
# subprocess sees the override — the Python process keeps the real HOME.
|
||||
from hermes_constants import get_subprocess_home
|
||||
from hermes_agent.constants import get_subprocess_home
|
||||
_profile_home = get_subprocess_home()
|
||||
if _profile_home:
|
||||
run_env["HOME"] = _profile_home
|
||||
@@ -220,7 +220,7 @@ def _read_terminal_shell_init_config() -> tuple[list[str], bool]:
|
||||
execution never breaks because the config file is unreadable.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
|
||||
cfg = load_config() or {}
|
||||
terminal_cfg = cfg.get("terminal") or {}
|
||||
@@ -247,22 +247,10 @@ def _resolve_shell_init_files() -> list[str]:
|
||||
if explicit:
|
||||
candidates.extend(explicit)
|
||||
elif auto_bashrc and not _IS_WINDOWS:
|
||||
# Build a login-shell-ish source list so tools like n / nvm / asdf /
|
||||
# pyenv that self-install into the user's shell rc land on PATH in
|
||||
# the captured snapshot.
|
||||
#
|
||||
# ~/.profile and ~/.bash_profile run first because they have no
|
||||
# interactivity guard — installers like ``n`` and ``nvm`` append
|
||||
# their PATH export there on most distros, and a non-interactive
|
||||
# ``. ~/.profile`` picks that up.
|
||||
#
|
||||
# ~/.bashrc runs last. On Debian/Ubuntu the default bashrc starts
|
||||
# with ``case $- in *i*) ;; *) return;; esac`` and exits early
|
||||
# when sourced non-interactively, which is why sourcing bashrc
|
||||
# alone misses nvm/n PATH additions placed below that guard. We
|
||||
# still include it so users who put PATH logic in bashrc (and
|
||||
# stripped the guard, or never had one) keep working.
|
||||
candidates.extend(["~/.profile", "~/.bash_profile", "~/.bashrc"])
|
||||
# Bash's login-shell invocation does NOT source ~/.bashrc by default,
|
||||
# so tools like nvm / asdf / pyenv that self-install there stay
|
||||
# invisible to the snapshot without this nudge.
|
||||
candidates.append("~/.bashrc")
|
||||
|
||||
resolved: list[str] = []
|
||||
for raw in candidates:
|
||||
@@ -361,7 +349,6 @@ class LocalEnvironment(BaseEnvironment):
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
cwd=self.cwd,
|
||||
)
|
||||
|
||||
if stdin_data is not None:
|
||||
@@ -10,12 +10,12 @@ import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tools.environments.modal_utils import (
|
||||
from hermes_agent.backends.modal_utils import (
|
||||
BaseModalExecutionEnvironment,
|
||||
ModalExecStart,
|
||||
PreparedModalExec,
|
||||
)
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from hermes_agent.tools.managed_gateway import resolve_managed_tool_gateway
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -214,7 +214,7 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
||||
def _guard_unsupported_credential_passthrough(self) -> None:
|
||||
"""Managed Modal does not sync or mount host credential files."""
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts
|
||||
from hermes_agent.tools.credential_files import get_credential_file_mounts
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@@ -14,14 +14,14 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.environments.base import (
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
from hermes_agent.backends.base import (
|
||||
BaseEnvironment,
|
||||
_ThreadedProcessHandle,
|
||||
_load_json_store,
|
||||
_save_json_store,
|
||||
)
|
||||
from tools.environments.file_sync import (
|
||||
from hermes_agent.backends.file_sync import (
|
||||
FileSyncManager,
|
||||
iter_sync_files,
|
||||
quoted_mkdir_command,
|
||||
@@ -187,7 +187,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||
|
||||
cred_mounts = []
|
||||
try:
|
||||
from tools.credential_files import (
|
||||
from hermes_agent.tools.credential_files import (
|
||||
get_credential_file_mounts,
|
||||
iter_skills_files,
|
||||
iter_cache_files,
|
||||
@@ -20,8 +20,8 @@ from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from tools.environments.base import BaseEnvironment
|
||||
from tools.interrupt import is_interrupted
|
||||
from hermes_agent.backends.base import BaseEnvironment
|
||||
from hermes_agent.tools.interrupt import is_interrupted
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -136,7 +136,7 @@ class BaseModalExecutionEnvironment(BaseEnvironment):
|
||||
|
||||
# Periodic activity touch so the gateway knows we're alive
|
||||
try:
|
||||
from tools.environments.base import touch_activity_if_due
|
||||
from hermes_agent.backends.base import touch_activity_if_due
|
||||
touch_activity_if_due(_activity_state, "modal command running")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -14,8 +14,8 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.environments.base import (
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
from hermes_agent.backends.base import (
|
||||
BaseEnvironment,
|
||||
_load_json_store,
|
||||
_popen_bash,
|
||||
@@ -75,7 +75,7 @@ def _get_scratch_dir() -> Path:
|
||||
scratch_path.mkdir(parents=True, exist_ok=True)
|
||||
return scratch_path
|
||||
|
||||
from tools.environments.base import get_sandbox_dir
|
||||
from hermes_agent.backends.base import get_sandbox_dir
|
||||
sandbox = get_sandbox_dir() / "singularity"
|
||||
|
||||
scratch = Path("/scratch")
|
||||
@@ -202,7 +202,7 @@ class SingularityEnvironment(BaseEnvironment):
|
||||
cmd.append("--writable-tmpfs")
|
||||
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
|
||||
from hermes_agent.tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
|
||||
for mount_entry in get_credential_file_mounts():
|
||||
cmd.extend(["--bind", f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro"])
|
||||
for skills_mount in get_skills_directory_mount():
|
||||
@@ -9,8 +9,8 @@ import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from tools.environments.base import BaseEnvironment, _popen_bash
|
||||
from tools.environments.file_sync import (
|
||||
from hermes_agent.backends.base import BaseEnvironment, _popen_bash
|
||||
from hermes_agent.backends.file_sync import (
|
||||
FileSyncManager,
|
||||
iter_sync_files,
|
||||
quoted_mkdir_command,
|
||||
0
hermes_agent/cli/auth/__init__.py
Normal file
0
hermes_agent/cli/auth/__init__.py
Normal file
@@ -38,8 +38,8 @@ from typing import Any, Dict, List, Optional
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_agent.cli.config import get_hermes_home, get_config_path, read_raw_config
|
||||
from hermes_agent.constants import OPENROUTER_BASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -72,8 +72,6 @@ DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
|
||||
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
|
||||
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
|
||||
DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1"
|
||||
STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1"
|
||||
STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1"
|
||||
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
@@ -184,14 +182,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
inference_base_url="https://api.moonshot.cn/v1",
|
||||
api_key_env_vars=("KIMI_CN_API_KEY",),
|
||||
),
|
||||
"stepfun": ProviderConfig(
|
||||
id="stepfun",
|
||||
name="StepFun Step Plan",
|
||||
auth_type="api_key",
|
||||
inference_base_url=STEPFUN_STEP_PLAN_INTL_BASE_URL,
|
||||
api_key_env_vars=("STEPFUN_API_KEY",),
|
||||
base_url_env_var="STEPFUN_BASE_URL",
|
||||
),
|
||||
"arcee": ProviderConfig(
|
||||
id="arcee",
|
||||
name="Arcee AI",
|
||||
@@ -214,7 +204,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://api.anthropic.com",
|
||||
api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
|
||||
base_url_env_var="ANTHROPIC_BASE_URL",
|
||||
),
|
||||
"alibaba": ProviderConfig(
|
||||
id="alibaba",
|
||||
@@ -340,7 +329,7 @@ def get_anthropic_key() -> str:
|
||||
|
||||
ANTHROPIC_API_KEY -> ANTHROPIC_TOKEN -> CLAUDE_CODE_OAUTH_TOKEN
|
||||
"""
|
||||
from hermes_cli.config import get_env_value
|
||||
from hermes_agent.cli.config import get_env_value
|
||||
|
||||
for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars:
|
||||
value = get_env_value(var) or os.getenv(var, "")
|
||||
@@ -417,7 +406,7 @@ def _resolve_api_key_provider_secret(
|
||||
if provider_id == "copilot":
|
||||
# Use the dedicated copilot auth module for proper token validation
|
||||
try:
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||
from hermes_agent.cli.auth.copilot import resolve_copilot_token
|
||||
token, source = resolve_copilot_token()
|
||||
if token:
|
||||
return token, source
|
||||
@@ -768,16 +757,20 @@ def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Di
|
||||
auth_store["active_provider"] = provider_id
|
||||
|
||||
|
||||
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Return the persisted credential pool, or one provider slice."""
|
||||
def read_credential_pool() -> Dict[str, Any]:
|
||||
"""Return the entire persisted credential pool."""
|
||||
auth_store = _load_auth_store()
|
||||
pool = auth_store.get("credential_pool")
|
||||
if not isinstance(pool, dict):
|
||||
pool = {}
|
||||
if provider_id is None:
|
||||
return dict(pool)
|
||||
provider_entries = pool.get(provider_id)
|
||||
return list(provider_entries) if isinstance(provider_entries, list) else []
|
||||
return dict(pool)
|
||||
|
||||
|
||||
def read_provider_credentials(provider_id: str) -> List[Dict[str, Any]]:
|
||||
"""Return credential entries for a single provider."""
|
||||
pool = read_credential_pool()
|
||||
entries = pool.get(provider_id)
|
||||
return list(entries) if isinstance(entries, list) else []
|
||||
|
||||
|
||||
def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path:
|
||||
@@ -873,7 +866,7 @@ def is_provider_explicitly_configured(provider_id: str) -> bool:
|
||||
|
||||
# 2. Check config.yaml model.provider
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
@@ -960,7 +953,7 @@ def _get_config_hint_for_unknown_provider(provider_name: str) -> str:
|
||||
and returns a human-readable diagnostic, or empty string if nothing found.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import validate_config_structure
|
||||
from hermes_agent.cli.config import validate_config_structure
|
||||
issues = validate_config_structure()
|
||||
if not issues:
|
||||
return ""
|
||||
@@ -1003,7 +996,6 @@ def resolve_provider(
|
||||
"x-ai": "xai", "x.ai": "xai", "grok": "xai",
|
||||
"kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
|
||||
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
|
||||
"step": "stepfun", "stepfun-coding-plan": "stepfun",
|
||||
"arcee-ai": "arcee", "arceeai": "arcee",
|
||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic", "claude-code": "anthropic",
|
||||
@@ -1076,7 +1068,7 @@ def resolve_provider(
|
||||
# AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).
|
||||
# This runs after API-key providers so explicit keys always win.
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
from hermes_agent.providers.bedrock_adapter import has_aws_credentials
|
||||
if has_aws_credentials():
|
||||
return "bedrock"
|
||||
except ImportError:
|
||||
@@ -1339,7 +1331,7 @@ def resolve_gemini_oauth_runtime_credentials(
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve runtime OAuth creds for google-gemini-cli."""
|
||||
try:
|
||||
from agent.google_oauth import (
|
||||
from hermes_agent.providers.google_oauth import (
|
||||
GoogleOAuthError,
|
||||
_credentials_path,
|
||||
get_valid_access_token,
|
||||
@@ -1378,7 +1370,7 @@ def resolve_gemini_oauth_runtime_credentials(
|
||||
def get_gemini_oauth_auth_status() -> Dict[str, Any]:
|
||||
"""Return a status dict for `hermes auth list` / `hermes status`."""
|
||||
try:
|
||||
from agent.google_oauth import _credentials_path, load_credentials
|
||||
from hermes_agent.providers.google_oauth import _credentials_path, load_credentials
|
||||
except ImportError:
|
||||
return {"logged_in": False, "error": "agent.google_oauth unavailable"}
|
||||
auth_path = _credentials_path()
|
||||
@@ -2167,7 +2159,7 @@ def persist_nous_credentials(
|
||||
Returns the upserted :class:`PooledCredential` entry (or ``None`` if
|
||||
seeding somehow produced no match — shouldn't happen).
|
||||
"""
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_agent.providers.credential_pool import load_pool
|
||||
|
||||
state = dict(creds)
|
||||
if label and str(label).strip():
|
||||
@@ -2448,7 +2440,7 @@ def get_nous_auth_status() -> Dict[str, Any]:
|
||||
# Check credential pool first — the dashboard device-code flow saves
|
||||
# here but may not have written to the auth store yet.
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_agent.providers.credential_pool import load_pool
|
||||
pool = load_pool("nous")
|
||||
if pool and pool.has_credentials():
|
||||
entry = pool.select()
|
||||
@@ -2502,7 +2494,7 @@ def get_codex_auth_status() -> Dict[str, Any]:
|
||||
# Check credential pool first — this is where `hermes auth` and
|
||||
# `hermes model` store device_code tokens.
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_agent.providers.credential_pool import load_pool
|
||||
pool = load_pool("openai-codex")
|
||||
if pool and pool.has_credentials():
|
||||
entry = pool.select()
|
||||
@@ -2623,7 +2615,7 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
# AWS SDK providers (Bedrock) — check via boto3 credential chain
|
||||
if pconfig and pconfig.auth_type == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
from hermes_agent.providers.bedrock_adapter import has_aws_credentials
|
||||
return {"logged_in": has_aws_credentials(), "provider": target}
|
||||
except ImportError:
|
||||
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
|
||||
@@ -2812,7 +2804,7 @@ def _prompt_model_selection(
|
||||
If *unavailable_models* is provided, those models are shown grayed out
|
||||
and unselectable, with an upgrade link to *portal_url*.
|
||||
"""
|
||||
from hermes_cli.models import _format_price_per_mtok
|
||||
from hermes_agent.cli.models.models import _format_price_per_mtok
|
||||
|
||||
_unavailable = unavailable_models or []
|
||||
|
||||
@@ -2922,7 +2914,7 @@ def _prompt_model_selection(
|
||||
title=effective_title,
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
from hermes_agent.cli.ui.curses import flush_stdin
|
||||
flush_stdin()
|
||||
if idx is None:
|
||||
return None
|
||||
@@ -2979,7 +2971,7 @@ def _save_model_choice(model_id: str) -> None:
|
||||
The model is stored in config.yaml only — NOT in .env. This avoids
|
||||
conflicts in multi-agent setups where env vars would stomp each other.
|
||||
"""
|
||||
from hermes_cli.config import save_config, load_config
|
||||
from hermes_agent.cli.config import save_config, load_config
|
||||
|
||||
config = load_config()
|
||||
# Always use dict format so provider/base_url can be stored alongside
|
||||
@@ -3058,7 +3050,7 @@ def _login_openai_codex(args, pconfig: ProviderConfig) -> None:
|
||||
config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL))
|
||||
print()
|
||||
print("Login successful!")
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
from hermes_agent.constants import display_hermes_home as _dhh
|
||||
print(f" Auth state: {_dhh()}/auth.json")
|
||||
print(f" Config updated: {config_path} (model.provider=openai-codex)")
|
||||
|
||||
@@ -3395,7 +3387,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
code="invalid_token",
|
||||
)
|
||||
|
||||
from hermes_cli.models import (
|
||||
from hermes_agent.cli.models.models import (
|
||||
_PROVIDER_MODELS, get_pricing_for_provider,
|
||||
check_nous_free_tier, partition_nous_models_by_tier,
|
||||
)
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
|
||||
from agent.credential_pool import (
|
||||
from hermes_agent.providers.credential_pool import (
|
||||
AUTH_TYPE_API_KEY,
|
||||
AUTH_TYPE_OAUTH,
|
||||
CUSTOM_POOL_PREFIX,
|
||||
@@ -27,9 +27,9 @@ from agent.credential_pool import (
|
||||
list_custom_pool_providers,
|
||||
load_pool,
|
||||
)
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
import hermes_agent.cli.auth.auth as auth_mod
|
||||
from hermes_agent.cli.auth.auth import PROVIDER_REGISTRY
|
||||
from hermes_agent.constants import OPENROUTER_BASE_URL
|
||||
|
||||
|
||||
# Providers that support OAuth login in addition to API keys.
|
||||
@@ -39,7 +39,7 @@ _OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "
|
||||
def _get_custom_provider_names() -> list:
|
||||
"""Return list of (display_name, pool_key, provider_key) tuples."""
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
from hermes_agent.cli.config import get_compatible_custom_providers, load_config
|
||||
|
||||
config = load_config()
|
||||
except Exception:
|
||||
@@ -88,7 +88,7 @@ def _provider_base_url(provider: str) -> str:
|
||||
if provider == "openrouter":
|
||||
return OPENROUTER_BASE_URL
|
||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
from agent.credential_pool import _get_custom_provider_config
|
||||
from hermes_agent.providers.credential_pool import _get_custom_provider_config
|
||||
|
||||
cp_config = _get_custom_provider_config(provider)
|
||||
if cp_config:
|
||||
@@ -159,7 +159,7 @@ def auth_add_command(args) -> None:
|
||||
# Matches the Codex device_code re-link pattern that predates this.
|
||||
if not provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
from hermes_agent.cli.auth.auth import (
|
||||
_load_auth_store,
|
||||
unsuppress_credential_source,
|
||||
)
|
||||
@@ -197,7 +197,7 @@ def auth_add_command(args) -> None:
|
||||
return
|
||||
|
||||
if provider == "anthropic":
|
||||
from agent import anthropic_adapter as anthropic_mod
|
||||
from hermes_agent.providers import anthropic_adapter as anthropic_mod
|
||||
|
||||
creds = anthropic_mod.run_hermes_oauth_login_pure()
|
||||
if not creds:
|
||||
@@ -271,7 +271,7 @@ def auth_add_command(args) -> None:
|
||||
return
|
||||
|
||||
if provider == "google-gemini-cli":
|
||||
from agent.google_oauth import run_gemini_oauth_login_pure
|
||||
from hermes_agent.providers.google_oauth import run_gemini_oauth_login_pure
|
||||
|
||||
creds = run_gemini_oauth_login_pure()
|
||||
label = (getattr(args, "label", None) or "").strip() or (
|
||||
@@ -361,8 +361,8 @@ def auth_remove_command(args) -> None:
|
||||
# handles its source-specific cleanup and we centralise suppression +
|
||||
# user-facing output here so every source behaves identically from
|
||||
# the user's perspective.
|
||||
from agent.credential_sources import find_removal_step
|
||||
from hermes_cli.auth import suppress_credential_source
|
||||
from hermes_agent.providers.credential_sources import find_removal_step
|
||||
from hermes_agent.cli.auth.auth import suppress_credential_source
|
||||
|
||||
step = find_removal_step(provider, removed.source)
|
||||
if step is None:
|
||||
@@ -396,7 +396,7 @@ def _interactive_auth() -> None:
|
||||
|
||||
# Show AWS Bedrock credential status (not in the pool — uses boto3 chain)
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
|
||||
from hermes_agent.providers.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
|
||||
if has_aws_credentials():
|
||||
auth_source = resolve_aws_auth_env_var() or "unknown"
|
||||
region = resolve_bedrock_region()
|
||||
@@ -558,7 +558,7 @@ def _interactive_strategy() -> None:
|
||||
print("Invalid choice.")
|
||||
return
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_agent.cli.config import load_config, save_config
|
||||
cfg = load_config()
|
||||
pool_strategies = cfg.get("credential_pool_strategies") or {}
|
||||
if not isinstance(pool_strategies, dict):
|
||||
@@ -18,7 +18,7 @@ import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
@@ -108,7 +108,7 @@ def wait_for_registration_success(
|
||||
device_code: str,
|
||||
interval: int = 3,
|
||||
expires_in: int = 7200,
|
||||
on_waiting: Optional[callable] = None,
|
||||
on_waiting: Optional[Callable[..., Any]] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Block until the registration succeeds or times out.
|
||||
|
||||
@@ -234,7 +234,7 @@ def dingtalk_qr_auth() -> Optional[Tuple[str, str]]:
|
||||
Returns (client_id, client_secret) on success, or None if the user
|
||||
cancelled or the flow failed.
|
||||
"""
|
||||
from hermes_cli.setup import print_info, print_success, print_warning, print_error
|
||||
from hermes_agent.cli.setup_wizard import print_info, print_success, print_warning, print_error
|
||||
|
||||
print()
|
||||
print_info(" Initializing DingTalk device authorization...")
|
||||
@@ -21,7 +21,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_constants import get_default_hermes_root, get_hermes_home, display_hermes_home
|
||||
from hermes_agent.constants import get_default_hermes_root, get_hermes_home, display_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -396,7 +396,7 @@ def run_import(args) -> None:
|
||||
restored_profiles = []
|
||||
if profiles_dir.is_dir():
|
||||
try:
|
||||
from hermes_cli.profiles import (
|
||||
from hermes_agent.cli.profiles import (
|
||||
create_wrapper_script, check_alias_collision,
|
||||
_is_wrapper_dir_in_path, _get_wrapper_dir,
|
||||
)
|
||||
@@ -16,9 +16,9 @@ import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
|
||||
from hermes_constants import get_optional_skills_dir
|
||||
from hermes_cli.setup import (
|
||||
from hermes_agent.cli.config import get_hermes_home, get_config_path, load_config, save_config
|
||||
from hermes_agent.constants import get_optional_skills_dir
|
||||
from hermes_agent.cli.setup_wizard import (
|
||||
Colors,
|
||||
color,
|
||||
print_header,
|
||||
@@ -30,7 +30,7 @@ from hermes_cli.setup import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2].resolve()
|
||||
|
||||
_OPENCLAW_SCRIPT = (
|
||||
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
|
||||
@@ -153,7 +153,7 @@ def _warn_if_gateway_running(auto_yes: bool) -> None:
|
||||
(e.g. Telegram 409 "terminated by other getUpdates request"). Warn the
|
||||
user and let them decide whether to continue.
|
||||
"""
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
from hermes_agent.gateway.status import get_running_pid, read_runtime_status
|
||||
|
||||
if not get_running_pid():
|
||||
return
|
||||
@@ -249,7 +249,7 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
||||
state_path = child / state_name
|
||||
if state_path.exists():
|
||||
kind = "directory" if state_path.is_dir() else "file"
|
||||
rel = state_path.relative_to(source_dir).as_posix()
|
||||
rel = state_path.relative_to(source_dir)
|
||||
findings.append((state_path, f"Workspace {kind}: {rel}"))
|
||||
|
||||
return findings
|
||||
@@ -19,7 +19,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import is_wsl as _is_wsl
|
||||
from hermes_agent.constants import is_wsl as _is_wsl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -276,7 +276,7 @@ def _get_ps_exe() -> str | None:
|
||||
global _ps_exe
|
||||
if _ps_exe is False:
|
||||
_ps_exe = _find_powershell()
|
||||
return _ps_exe
|
||||
return _ps_exe if isinstance(_ps_exe, str) else None
|
||||
|
||||
|
||||
def _windows_has_image() -> bool:
|
||||
@@ -395,14 +395,17 @@ def _wayland_save(dest: Path) -> bool:
|
||||
|
||||
def _convert_to_png(path: Path) -> bool:
|
||||
"""Convert an image file to PNG in-place (requires Pillow or ImageMagick)."""
|
||||
# Try Pillow first (likely installed in the venv)
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Pillow is required for clipboard image conversion. "
|
||||
"Install with: pip install hermes-agent[cli]"
|
||||
) from None
|
||||
try:
|
||||
img = Image.open(path)
|
||||
img.save(path, "PNG")
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Pillow BMP→PNG conversion failed: %s", e)
|
||||
|
||||
@@ -260,26 +260,6 @@ GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def is_gateway_known_command(name: str | None) -> bool:
|
||||
"""Return True if ``name`` resolves to a gateway-dispatchable slash command.
|
||||
|
||||
This covers both built-in commands (``GATEWAY_KNOWN_COMMANDS`` derived
|
||||
from ``COMMAND_REGISTRY``) and plugin-registered commands, which are
|
||||
looked up lazily so importing this module never forces plugin
|
||||
discovery. Gateway code uses this to decide whether to emit
|
||||
``command:<name>`` hooks — plugin commands get the same lifecycle
|
||||
events as built-ins.
|
||||
"""
|
||||
if not name:
|
||||
return False
|
||||
if name in GATEWAY_KNOWN_COMMANDS:
|
||||
return True
|
||||
for plugin_name, _description, _args_hint in _iter_plugin_command_entries():
|
||||
if plugin_name == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Commands with explicit Level-2 running-agent handlers in gateway/run.py.
|
||||
# Listed here for introspection / tests; semantically a subset of
|
||||
# "all resolvable commands" — which is the real bypass set (see
|
||||
@@ -338,7 +318,7 @@ def _resolve_config_gates() -> set[str]:
|
||||
if not gated:
|
||||
return set()
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
from hermes_agent.cli.config import read_raw_config
|
||||
cfg = read_raw_config()
|
||||
except Exception:
|
||||
return set()
|
||||
@@ -391,47 +371,12 @@ def gateway_help_lines() -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
|
||||
"""Yield (name, description, args_hint) tuples for all plugin slash commands.
|
||||
|
||||
Plugin commands are registered via
|
||||
:func:`hermes_cli.plugins.PluginContext.register_command`. They behave
|
||||
like ``CommandDef`` entries for gateway surfacing: they appear in the
|
||||
Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and
|
||||
(via :func:`gateway.platforms.discord._register_slash_commands`) in
|
||||
Discord's native slash command picker.
|
||||
|
||||
Lookup is lazy so importing this module never forces plugin discovery
|
||||
(which can trigger filesystem scans and environment-dependent
|
||||
behavior).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_commands
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
commands = get_plugin_commands() or {}
|
||||
except Exception:
|
||||
return []
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
for name, meta in commands.items():
|
||||
if not isinstance(name, str) or not isinstance(meta, dict):
|
||||
continue
|
||||
description = str(meta.get("description") or f"Run /{name}")
|
||||
args_hint = str(meta.get("args_hint") or "").strip()
|
||||
entries.append((name, description, args_hint))
|
||||
return entries
|
||||
|
||||
|
||||
def telegram_bot_commands() -> list[tuple[str, str]]:
|
||||
"""Return (command_name, description) pairs for Telegram setMyCommands.
|
||||
|
||||
Telegram command names cannot contain hyphens, so they are replaced with
|
||||
underscores. Aliases are skipped -- Telegram shows one menu entry per
|
||||
canonical command.
|
||||
|
||||
Plugin-registered slash commands are included so plugins get native
|
||||
autocomplete in Telegram without touching core code.
|
||||
"""
|
||||
overrides = _resolve_config_gates()
|
||||
result: list[tuple[str, str]] = []
|
||||
@@ -441,10 +386,6 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
|
||||
tg_name = _sanitize_telegram_name(cmd.name)
|
||||
if tg_name:
|
||||
result.append((tg_name, cmd.description))
|
||||
for name, description, _args_hint in _iter_plugin_command_entries():
|
||||
tg_name = _sanitize_telegram_name(name)
|
||||
if tg_name:
|
||||
result.append((tg_name, description))
|
||||
return result
|
||||
|
||||
|
||||
@@ -556,7 +497,7 @@ def _collect_gateway_skill_entries(
|
||||
# --- Tier 1: Plugin slash commands (never trimmed) ---------------------
|
||||
plugin_pairs: list[tuple[str, str]] = []
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_commands
|
||||
from hermes_agent.cli.plugins import get_plugin_commands
|
||||
plugin_cmds = get_plugin_commands()
|
||||
for cmd_name in sorted(plugin_cmds):
|
||||
name = sanitize_name(cmd_name) if sanitize_name else cmd_name
|
||||
@@ -578,15 +519,15 @@ def _collect_gateway_skill_entries(
|
||||
# --- Tier 2: Built-in skill commands (trimmed at cap) -----------------
|
||||
_platform_disabled: set[str] = set()
|
||||
try:
|
||||
from agent.skill_utils import get_disabled_skill_names
|
||||
from hermes_agent.agent.skill_utils import get_disabled_skill_names
|
||||
_platform_disabled = get_disabled_skill_names(platform=platform)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
skill_triples: list[tuple[str, str, str]] = []
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
from hermes_agent.agent.skill_commands import get_skill_commands
|
||||
from hermes_agent.tools.skills.tool import SKILLS_DIR
|
||||
_skills_dir = str(SKILLS_DIR.resolve())
|
||||
_hub_dir = str((SKILLS_DIR / ".hub").resolve())
|
||||
skill_cmds = get_skill_commands()
|
||||
@@ -720,7 +661,7 @@ def discord_skill_commands_by_category(
|
||||
|
||||
_platform_disabled: set[str] = set()
|
||||
try:
|
||||
from agent.skill_utils import get_disabled_skill_names
|
||||
from hermes_agent.agent.skill_utils import get_disabled_skill_names
|
||||
_platform_disabled = get_disabled_skill_names(platform="discord")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -732,8 +673,8 @@ def discord_skill_commands_by_category(
|
||||
hidden = 0
|
||||
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
from hermes_agent.agent.skill_commands import get_skill_commands
|
||||
from hermes_agent.tools.skills.tool import SKILLS_DIR
|
||||
_skills_dir = SKILLS_DIR.resolve()
|
||||
_hub_dir = (SKILLS_DIR / ".hub").resolve()
|
||||
skill_cmds = get_skill_commands()
|
||||
@@ -809,9 +750,6 @@ def slack_subcommand_map() -> dict[str, str]:
|
||||
|
||||
Maps both canonical names and aliases so /hermes bg do stuff works
|
||||
the same as /hermes background do stuff.
|
||||
|
||||
Plugin-registered slash commands are included so ``/hermes <plugin-cmd>``
|
||||
routes through the plugin handler.
|
||||
"""
|
||||
overrides = _resolve_config_gates()
|
||||
mapping: dict[str, str] = {}
|
||||
@@ -821,9 +759,6 @@ def slack_subcommand_map() -> dict[str, str]:
|
||||
mapping[cmd.name] = f"/{cmd.name}"
|
||||
for alias in cmd.aliases:
|
||||
mapping[alias] = f"/{alias}"
|
||||
for name, _description, _args_hint in _iter_plugin_command_entries():
|
||||
if name not in mapping:
|
||||
mapping[name] = f"/{name}"
|
||||
return mapping
|
||||
|
||||
|
||||
@@ -1181,7 +1116,7 @@ class SlashCommandCompleter(Completer):
|
||||
def _skin_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /skin from available skins."""
|
||||
try:
|
||||
from hermes_cli.skin_engine import list_skins
|
||||
from hermes_agent.cli.ui.skin_engine import list_skins
|
||||
for s in list_skins():
|
||||
name = s["name"]
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
@@ -1198,7 +1133,7 @@ class SlashCommandCompleter(Completer):
|
||||
def _personality_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /personality from configured personalities."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
personalities = load_config().get("agent", {}).get("personalities", {})
|
||||
if "none".startswith(sub_lower) and "none" != sub_lower:
|
||||
yield Completion(
|
||||
@@ -1227,7 +1162,7 @@ class SlashCommandCompleter(Completer):
|
||||
seen = set()
|
||||
# Config-based direct aliases (preferred — include provider info)
|
||||
try:
|
||||
from hermes_cli.model_switch import (
|
||||
from hermes_agent.cli.models.switch import (
|
||||
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
|
||||
)
|
||||
_ensure_direct_aliases()
|
||||
@@ -1327,7 +1262,7 @@ class SlashCommandCompleter(Completer):
|
||||
|
||||
# Plugin-registered slash commands
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_commands
|
||||
from hermes_agent.cli.plugins import get_plugin_commands
|
||||
for cmd_name, cmd_info in get_plugin_commands().items():
|
||||
if cmd_name.startswith(word):
|
||||
desc = str(cmd_info.get("description", "Plugin command"))
|
||||
@@ -23,7 +23,7 @@ import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from typing import Dict, Any, Optional, List, Tuple, TypedDict, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,8 +61,8 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
})
|
||||
import yaml
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
||||
from hermes_agent.cli.ui.colors import Colors, color
|
||||
from hermes_agent.cli.default_soul import DEFAULT_SOUL_MD
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -169,7 +169,7 @@ def get_container_exec_info() -> Optional[dict]:
|
||||
if os.environ.get("HERMES_DEV") == "1":
|
||||
return None
|
||||
|
||||
from hermes_constants import is_container
|
||||
from hermes_agent.constants import is_container
|
||||
if is_container():
|
||||
return None
|
||||
|
||||
@@ -205,7 +205,7 @@ def get_container_exec_info() -> Optional[dict]:
|
||||
# =============================================================================
|
||||
|
||||
# Re-export from hermes_constants — canonical definition lives there.
|
||||
from hermes_constants import get_hermes_home # noqa: F811,E402
|
||||
from hermes_agent.constants import get_hermes_home # noqa: F811,E402
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Get the main config file path."""
|
||||
@@ -217,7 +217,7 @@ def get_env_path() -> Path:
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project installation directory."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
return Path(__file__).resolve().parents[2].resolve()
|
||||
|
||||
def _secure_dir(path):
|
||||
"""Set directory to owner-only access (0700 by default). No-op on Windows.
|
||||
@@ -343,12 +343,363 @@ def _ensure_hermes_home_managed(home: Path):
|
||||
# Config loading/saving
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
class _AgentConfig(TypedDict):
|
||||
max_turns: int
|
||||
gateway_timeout: int
|
||||
restart_drain_timeout: int
|
||||
service_tier: str
|
||||
tool_use_enforcement: str
|
||||
gateway_timeout_warning: int
|
||||
gateway_notify_interval: int
|
||||
|
||||
class _TerminalConfig(TypedDict):
|
||||
backend: str
|
||||
modal_mode: str
|
||||
cwd: str
|
||||
timeout: int
|
||||
env_passthrough: List[str]
|
||||
docker_image: str
|
||||
docker_forward_env: List[str]
|
||||
docker_env: Dict[str, str]
|
||||
singularity_image: str
|
||||
modal_image: str
|
||||
daytona_image: str
|
||||
container_cpu: int
|
||||
container_memory: int
|
||||
container_disk: int
|
||||
container_persistent: bool
|
||||
docker_volumes: List[str]
|
||||
docker_mount_cwd_to_workspace: bool
|
||||
persistent_shell: bool
|
||||
|
||||
|
||||
class _CamofoxConfig(TypedDict, total=False):
|
||||
managed_persistence: bool
|
||||
|
||||
|
||||
class _BrowserConfig(TypedDict):
|
||||
inactivity_timeout: int
|
||||
command_timeout: int
|
||||
record_sessions: bool
|
||||
allow_private_urls: bool
|
||||
cdp_url: str
|
||||
camofox: _CamofoxConfig
|
||||
|
||||
|
||||
class _CheckpointsConfig(TypedDict):
|
||||
enabled: bool
|
||||
max_snapshots: int
|
||||
|
||||
|
||||
class _CompressionConfig(TypedDict):
|
||||
enabled: bool
|
||||
threshold: float
|
||||
target_ratio: float
|
||||
protect_last_n: int
|
||||
|
||||
|
||||
class _BedrockDiscoveryConfig(TypedDict):
|
||||
enabled: bool
|
||||
provider_filter: List[str]
|
||||
refresh_interval: int
|
||||
|
||||
|
||||
class _BedrockGuardrailConfig(TypedDict):
|
||||
guardrail_identifier: str
|
||||
guardrail_version: str
|
||||
stream_processing_mode: str
|
||||
trace: str
|
||||
|
||||
|
||||
class _BedrockConfig(TypedDict):
|
||||
region: str
|
||||
discovery: _BedrockDiscoveryConfig
|
||||
guardrail: _BedrockGuardrailConfig
|
||||
|
||||
|
||||
class _AuxiliaryTaskConfig(TypedDict, total=False):
|
||||
provider: str
|
||||
model: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
timeout: int
|
||||
extra_body: Dict[str, Any]
|
||||
max_concurrency: int
|
||||
download_timeout: int
|
||||
|
||||
|
||||
class _AuxiliaryConfig(TypedDict):
|
||||
vision: _AuxiliaryTaskConfig
|
||||
web_extract: _AuxiliaryTaskConfig
|
||||
compression: _AuxiliaryTaskConfig
|
||||
session_search: _AuxiliaryTaskConfig
|
||||
skills_hub: _AuxiliaryTaskConfig
|
||||
approval: _AuxiliaryTaskConfig
|
||||
mcp: _AuxiliaryTaskConfig
|
||||
flush_memories: _AuxiliaryTaskConfig
|
||||
title_generation: _AuxiliaryTaskConfig
|
||||
|
||||
|
||||
class _UserMessagePreviewConfig(TypedDict):
|
||||
first_lines: int
|
||||
last_lines: int
|
||||
|
||||
|
||||
class _DisplayConfig(TypedDict):
|
||||
compact: bool
|
||||
personality: str
|
||||
resume_display: str
|
||||
busy_input_mode: str
|
||||
bell_on_complete: bool
|
||||
show_reasoning: bool
|
||||
streaming: bool
|
||||
final_response_markdown: str
|
||||
inline_diffs: bool
|
||||
show_cost: bool
|
||||
skin: str
|
||||
user_message_preview: _UserMessagePreviewConfig
|
||||
interim_assistant_messages: bool
|
||||
tool_progress_command: bool
|
||||
tool_progress_overrides: Dict[str, Any]
|
||||
tool_preview_length: int
|
||||
platforms: Dict[str, Any]
|
||||
|
||||
|
||||
class _DashboardConfig(TypedDict):
|
||||
theme: str
|
||||
|
||||
|
||||
class _PrivacyConfig(TypedDict):
|
||||
redact_pii: bool
|
||||
|
||||
|
||||
class _EdgeTtsConfig(TypedDict):
|
||||
voice: str
|
||||
|
||||
|
||||
class _ElevenlabsTtsConfig(TypedDict):
|
||||
voice_id: str
|
||||
model_id: str
|
||||
|
||||
|
||||
class _OpenaiTtsConfig(TypedDict):
|
||||
model: str
|
||||
voice: str
|
||||
|
||||
|
||||
class _XaiTtsConfig(TypedDict):
|
||||
voice_id: str
|
||||
language: str
|
||||
sample_rate: int
|
||||
bit_rate: int
|
||||
|
||||
|
||||
class _MistralTtsConfig(TypedDict):
|
||||
model: str
|
||||
voice_id: str
|
||||
|
||||
|
||||
class _NeuttsConfig(TypedDict):
|
||||
ref_audio: str
|
||||
ref_text: str
|
||||
model: str
|
||||
device: str
|
||||
|
||||
|
||||
class _TtsConfig(TypedDict):
|
||||
provider: str
|
||||
edge: _EdgeTtsConfig
|
||||
elevenlabs: _ElevenlabsTtsConfig
|
||||
openai: _OpenaiTtsConfig
|
||||
xai: _XaiTtsConfig
|
||||
mistral: _MistralTtsConfig
|
||||
neutts: _NeuttsConfig
|
||||
|
||||
|
||||
class _LocalSttConfig(TypedDict):
|
||||
model: str
|
||||
language: str
|
||||
|
||||
|
||||
class _OpenaiSttConfig(TypedDict):
|
||||
model: str
|
||||
|
||||
|
||||
class _MistralSttConfig(TypedDict):
|
||||
model: str
|
||||
|
||||
|
||||
class _SttConfig(TypedDict):
|
||||
enabled: bool
|
||||
provider: str
|
||||
local: _LocalSttConfig
|
||||
openai: _OpenaiSttConfig
|
||||
mistral: _MistralSttConfig
|
||||
|
||||
|
||||
class _VoiceConfig(TypedDict):
|
||||
record_key: str
|
||||
max_recording_seconds: int
|
||||
auto_tts: bool
|
||||
silence_threshold: int
|
||||
silence_duration: float
|
||||
|
||||
|
||||
class _HumanDelayConfig(TypedDict):
|
||||
mode: str
|
||||
min_ms: int
|
||||
max_ms: int
|
||||
|
||||
|
||||
class _ContextConfig(TypedDict):
|
||||
engine: str
|
||||
|
||||
|
||||
class _MemoryConfig(TypedDict):
|
||||
memory_enabled: bool
|
||||
user_profile_enabled: bool
|
||||
memory_char_limit: int
|
||||
user_char_limit: int
|
||||
provider: str
|
||||
|
||||
|
||||
class _DelegationConfig(TypedDict):
|
||||
model: str
|
||||
provider: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
max_iterations: int
|
||||
reasoning_effort: str
|
||||
|
||||
|
||||
class _SkillsConfig(TypedDict):
|
||||
external_dirs: List[str]
|
||||
|
||||
|
||||
class _ChannelPromptsConfig(TypedDict):
|
||||
channel_prompts: Dict[str, str]
|
||||
|
||||
|
||||
class _DiscordConfig(TypedDict):
|
||||
require_mention: bool
|
||||
free_response_channels: str
|
||||
allowed_channels: str
|
||||
auto_thread: bool
|
||||
reactions: bool
|
||||
channel_prompts: Dict[str, str]
|
||||
server_actions: str
|
||||
|
||||
|
||||
class _ApprovalsConfig(TypedDict):
|
||||
mode: str
|
||||
timeout: int
|
||||
cron_mode: str
|
||||
|
||||
|
||||
class _WebsiteBlocklistConfig(TypedDict):
|
||||
enabled: bool
|
||||
domains: List[str]
|
||||
shared_files: List[str]
|
||||
|
||||
|
||||
class _SecurityConfig(TypedDict):
|
||||
redact_secrets: bool
|
||||
tirith_enabled: bool
|
||||
tirith_path: str
|
||||
tirith_timeout: int
|
||||
tirith_fail_open: bool
|
||||
website_blocklist: _WebsiteBlocklistConfig
|
||||
|
||||
|
||||
class _CronConfig(TypedDict):
|
||||
wrap_response: bool
|
||||
max_parallel_jobs: Optional[int]
|
||||
|
||||
|
||||
class _CodeExecutionConfig(TypedDict):
|
||||
mode: str
|
||||
|
||||
|
||||
class _LoggingConfig(TypedDict):
|
||||
level: str
|
||||
max_size_mb: int
|
||||
backup_count: int
|
||||
|
||||
|
||||
class _NetworkConfig(TypedDict):
|
||||
force_ipv4: bool
|
||||
|
||||
|
||||
class _DefaultConfig(TypedDict):
|
||||
model: str
|
||||
providers: Dict[str, Any]
|
||||
fallback_providers: List[Any]
|
||||
credential_pool_strategies: Dict[str, Any]
|
||||
toolsets: List[str]
|
||||
agent: _AgentConfig
|
||||
terminal: _TerminalConfig
|
||||
browser: _BrowserConfig
|
||||
checkpoints: _CheckpointsConfig
|
||||
file_read_max_chars: int
|
||||
compression: _CompressionConfig
|
||||
bedrock: _BedrockConfig
|
||||
auxiliary: _AuxiliaryConfig
|
||||
display: _DisplayConfig
|
||||
dashboard: _DashboardConfig
|
||||
privacy: _PrivacyConfig
|
||||
tts: _TtsConfig
|
||||
stt: _SttConfig
|
||||
voice: _VoiceConfig
|
||||
human_delay: _HumanDelayConfig
|
||||
context: _ContextConfig
|
||||
memory: _MemoryConfig
|
||||
delegation: _DelegationConfig
|
||||
prefill_messages_file: str
|
||||
skills: _SkillsConfig
|
||||
honcho: Dict[str, Any]
|
||||
timezone: str
|
||||
discord: _DiscordConfig
|
||||
whatsapp: Dict[str, Any]
|
||||
telegram: _ChannelPromptsConfig
|
||||
slack: _ChannelPromptsConfig
|
||||
mattermost: _ChannelPromptsConfig
|
||||
approvals: _ApprovalsConfig
|
||||
command_allowlist: List[str]
|
||||
quick_commands: Dict[str, Any]
|
||||
hooks: Dict[str, Any]
|
||||
hooks_auto_accept: bool
|
||||
personalities: Dict[str, Any]
|
||||
security: _SecurityConfig
|
||||
cron: _CronConfig
|
||||
code_execution: _CodeExecutionConfig
|
||||
logging: _LoggingConfig
|
||||
network: _NetworkConfig
|
||||
_config_version: int
|
||||
|
||||
|
||||
class _EnvVarRequired(TypedDict):
|
||||
description: str
|
||||
prompt: str
|
||||
category: str
|
||||
|
||||
|
||||
class _EnvVarOptional(TypedDict, total=False):
|
||||
url: Optional[str]
|
||||
password: bool
|
||||
tools: List[str]
|
||||
advanced: bool
|
||||
|
||||
|
||||
class _EnvVarInfo(_EnvVarRequired, _EnvVarOptional):
|
||||
pass
|
||||
|
||||
|
||||
DEFAULT_CONFIG: _DefaultConfig = {
|
||||
"model": "",
|
||||
"providers": {},
|
||||
"fallback_providers": [],
|
||||
"credential_pool_strategies": {},
|
||||
"toolsets": ["hermes-cli"],
|
||||
"hermes_agent.tools.toolsets": ["hermes-cli"],
|
||||
"agent": {
|
||||
"max_turns": 90,
|
||||
# Inactivity timeout for gateway agent execution (seconds).
|
||||
@@ -394,23 +745,17 @@ DEFAULT_CONFIG = {
|
||||
# (bash doesn't source bashrc in non-interactive login mode) or
|
||||
# zsh-specific files like ``~/.zshrc`` / ``~/.zprofile``.
|
||||
# Paths support ``~`` / ``${VAR}``. Missing files are silently
|
||||
# skipped. When empty, Hermes auto-sources ``~/.profile``,
|
||||
# ``~/.bash_profile``, and ``~/.bashrc`` (in that order) if the
|
||||
# skipped. When empty, Hermes auto-appends ``~/.bashrc`` if the
|
||||
# snapshot shell is bash (this is the ``auto_source_bashrc``
|
||||
# behaviour — disable with that key if you want strict login-only
|
||||
# semantics).
|
||||
"shell_init_files": [],
|
||||
# When true (default), Hermes sources the user's shell rc files
|
||||
# (``~/.profile``, ``~/.bash_profile``, ``~/.bashrc``) in the
|
||||
# login shell used to build the environment snapshot. This
|
||||
# captures PATH additions, shell functions, and aliases — which a
|
||||
# plain ``bash -l -c`` would otherwise miss because bash skips
|
||||
# bashrc in non-interactive login mode, and because a default
|
||||
# Debian/Ubuntu ``~/.bashrc`` short-circuits on non-interactive
|
||||
# sources. ``~/.profile`` and ``~/.bash_profile`` are tried first
|
||||
# because ``n`` / ``nvm`` / ``asdf`` installers typically write
|
||||
# their PATH exports there without an interactivity guard. Turn
|
||||
# this off if your rc files misbehave when sourced
|
||||
# When true (default), Hermes sources ``~/.bashrc`` in the login
|
||||
# shell used to build the environment snapshot. This captures
|
||||
# PATH additions, shell functions, and aliases defined in the
|
||||
# user's bashrc — which a plain ``bash -l -c`` would otherwise
|
||||
# miss because bash skips bashrc in non-interactive login mode.
|
||||
# Turn this off if you have a bashrc that misbehaves when sourced
|
||||
# non-interactively (e.g. one that hard-exits on TTY checks).
|
||||
"auto_source_bashrc": True,
|
||||
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
@@ -718,12 +1063,6 @@ DEFAULT_CONFIG = {
|
||||
"provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials)
|
||||
"base_url": "", # direct OpenAI-compatible endpoint for subagents
|
||||
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
|
||||
# When delegate_task narrows child toolsets explicitly, preserve any
|
||||
# MCP toolsets the parent already has enabled. On by default so
|
||||
# narrowing (e.g. toolsets=["web","browser"]) expresses "I want these
|
||||
# extras" without silently stripping MCP tools the parent already has.
|
||||
# Set to false for strict intersection.
|
||||
"inherit_mcp_toolsets": True,
|
||||
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
|
||||
# independent of the parent's max_iterations)
|
||||
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
|
||||
@@ -760,17 +1099,6 @@ DEFAULT_CONFIG = {
|
||||
"inline_shell": False,
|
||||
# Timeout (seconds) for each !`cmd` snippet when inline_shell is on.
|
||||
"inline_shell_timeout": 10,
|
||||
# Run the keyword/pattern security scanner on skills the agent
|
||||
# writes via skill_manage (create/edit/patch). Off by default
|
||||
# because the agent can already execute the same code paths via
|
||||
# terminal() with no gate, so the scan adds friction (blocks
|
||||
# skills that mention risky keywords in prose) without meaningful
|
||||
# security. Turn on if you want the belt-and-suspenders — a
|
||||
# dangerous verdict will then surface as a tool error to the
|
||||
# agent, which can retry with the flagged content removed.
|
||||
# External hub installs (trusted/community sources) are always
|
||||
# scanned regardless of this setting.
|
||||
"guard_agent_created": False,
|
||||
},
|
||||
|
||||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||||
@@ -863,7 +1191,6 @@ DEFAULT_CONFIG = {
|
||||
|
||||
# Pre-exec security scanning via tirith
|
||||
"security": {
|
||||
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
|
||||
"redact_secrets": True,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
@@ -917,34 +1244,6 @@ DEFAULT_CONFIG = {
|
||||
"force_ipv4": False,
|
||||
},
|
||||
|
||||
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
|
||||
# state.db accumulates every session, message, tool call, and FTS5 index
|
||||
# entry forever. Without auto-pruning, a heavy user (gateway + cron)
|
||||
# reports 384MB+ databases with 68K+ messages, which slows down FTS5
|
||||
# inserts, /resume listing, and insights queries.
|
||||
"sessions": {
|
||||
# When true, prune ended sessions older than retention_days once
|
||||
# per (roughly) min_interval_hours at CLI/gateway/cron startup.
|
||||
# Only touches ended sessions — active sessions are always preserved.
|
||||
# Default false: session history is valuable for search recall, and
|
||||
# silently deleting it could surprise users. Opt in explicitly.
|
||||
"auto_prune": False,
|
||||
# How many days of ended-session history to keep. Matches the
|
||||
# default of ``hermes sessions prune``.
|
||||
"retention_days": 90,
|
||||
# VACUUM after a prune that actually deleted rows. SQLite does not
|
||||
# reclaim disk space on DELETE — freed pages are just reused on
|
||||
# subsequent INSERTs — so without VACUUM the file stays bloated
|
||||
# even after pruning. VACUUM blocks writes for a few seconds per
|
||||
# 100MB, so it only runs at startup, and only when prune deleted
|
||||
# ≥1 session.
|
||||
"vacuum_after_prune": True,
|
||||
# Minimum hours between auto-maintenance runs (avoids repeating
|
||||
# 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,
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 22,
|
||||
}
|
||||
@@ -971,7 +1270,7 @@ ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
|
||||
REQUIRED_ENV_VARS = {}
|
||||
|
||||
# Optional environment variables that enhance functionality
|
||||
OPTIONAL_ENV_VARS = {
|
||||
OPTIONAL_ENV_VARS: Dict[str, _EnvVarInfo] = {
|
||||
# ── Provider (handled in provider selection, not shown in checklists) ──
|
||||
"NOUS_BASE_URL": {
|
||||
"description": "Nous Portal base URL override",
|
||||
@@ -1102,22 +1401,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"STEPFUN_API_KEY": {
|
||||
"description": "StepFun Step Plan API key",
|
||||
"prompt": "StepFun Step Plan API key",
|
||||
"url": "https://platform.stepfun.com/",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"STEPFUN_BASE_URL": {
|
||||
"description": "StepFun Step Plan base URL override",
|
||||
"prompt": "StepFun Step Plan base URL (leave empty for default)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"ARCEEAI_API_KEY": {
|
||||
"description": "Arcee AI API key",
|
||||
"prompt": "Arcee AI API key",
|
||||
@@ -1921,7 +2204,7 @@ def get_missing_config_fields() -> List[Dict[str, Any]]:
|
||||
config = load_config()
|
||||
missing = []
|
||||
|
||||
def _check(defaults: dict, current: dict, prefix: str = ""):
|
||||
def _check(defaults: Dict[str, Any], current: Dict[str, Any], prefix: str = ""):
|
||||
for key, default_value in defaults.items():
|
||||
if key.startswith('_'):
|
||||
continue
|
||||
@@ -1935,7 +2218,7 @@ def get_missing_config_fields() -> List[Dict[str, Any]]:
|
||||
elif isinstance(default_value, dict) and isinstance(current.get(key), dict):
|
||||
_check(default_value, current[key], full_key)
|
||||
|
||||
_check(DEFAULT_CONFIG, config)
|
||||
_check(dict(DEFAULT_CONFIG), config)
|
||||
return missing
|
||||
|
||||
|
||||
@@ -1947,7 +2230,7 @@ def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
|
||||
config.yaml. Returns a list of dicts suitable for prompting.
|
||||
"""
|
||||
try:
|
||||
from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX
|
||||
from hermes_agent.agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -2072,14 +2355,6 @@ def _normalize_custom_provider_entry(
|
||||
models = entry.get("models")
|
||||
if isinstance(models, dict) and models:
|
||||
normalized["models"] = models
|
||||
elif isinstance(models, list) and models:
|
||||
# Hand-edited configs (and older Hermes versions) write ``models`` as
|
||||
# a plain list of model ids. Preserve them by converting to the dict
|
||||
# shape downstream code expects; otherwise normalize silently drops
|
||||
# the list and /model shows the provider with (0) models.
|
||||
normalized["models"] = {
|
||||
str(m): {} for m in models if isinstance(m, str) and m.strip()
|
||||
}
|
||||
|
||||
context_length = entry.get("context_length")
|
||||
if isinstance(context_length, int) and context_length > 0:
|
||||
@@ -2163,8 +2438,8 @@ def check_config_version() -> Tuple[int, int]:
|
||||
Returns (current_version, latest_version).
|
||||
"""
|
||||
config = load_config()
|
||||
current = config.get("_config_version", 0)
|
||||
latest = DEFAULT_CONFIG.get("_config_version", 1)
|
||||
current = int(config.get("_config_version", 0))
|
||||
latest = int(DEFAULT_CONFIG.get("_config_version", 1))
|
||||
return current, latest
|
||||
|
||||
|
||||
@@ -2175,10 +2450,9 @@ def check_config_version() -> Tuple[int, int]:
|
||||
# Fields that are valid at root level of config.yaml
|
||||
_KNOWN_ROOT_KEYS = {
|
||||
"_config_version", "model", "providers", "fallback_model",
|
||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||
"fallback_providers", "credential_pool_strategies", "hermes_agent.tools.toolsets",
|
||||
"agent", "terminal", "display", "compression", "delegation",
|
||||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||||
"sessions",
|
||||
}
|
||||
|
||||
# Valid fields inside a custom_providers list entry
|
||||
@@ -2858,7 +3132,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
print()
|
||||
config = load_config()
|
||||
try:
|
||||
from agent.skill_utils import SKILL_CONFIG_PREFIX
|
||||
from hermes_agent.agent.skill_utils import SKILL_CONFIG_PREFIX
|
||||
except Exception:
|
||||
SKILL_CONFIG_PREFIX = "skills.config"
|
||||
for var in missing_skill_config:
|
||||
@@ -2884,7 +3158,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
return results
|
||||
|
||||
|
||||
def _deep_merge(base: dict, override: dict) -> dict:
|
||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Recursively merge *override* into *base*, preserving nested defaults.
|
||||
|
||||
Keys in *override* take precedence. If both values are dicts the merge
|
||||
@@ -3073,7 +3347,7 @@ def load_config() -> Dict[str, Any]:
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
config: Dict[str, Any] = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
@@ -3173,7 +3447,7 @@ def save_config(config: Dict[str, Any]):
|
||||
if is_managed():
|
||||
managed_error("save configuration")
|
||||
return
|
||||
from utils import atomic_yaml_write
|
||||
from hermes_agent.utils import atomic_yaml_write
|
||||
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
@@ -3194,7 +3468,7 @@ def save_config(config: Dict[str, Any]):
|
||||
if not sec or sec.get("redact_secrets") is None:
|
||||
parts.append(_SECURITY_COMMENT)
|
||||
fb = normalized.get("fallback_model", {})
|
||||
if not fb or not isinstance(fb, dict) or not (fb.get("provider") and fb.get("model")):
|
||||
if not fb or not (fb.get("provider") and fb.get("model")):
|
||||
parts.append(_FALLBACK_COMMENT)
|
||||
|
||||
atomic_yaml_write(
|
||||
@@ -3609,7 +3883,7 @@ def show_config():
|
||||
for env_key, name in keys:
|
||||
value = get_env_value(env_key)
|
||||
print(f" {name:<14} {redact_key(value)}")
|
||||
from hermes_cli.auth import get_anthropic_key
|
||||
from hermes_agent.cli.auth.auth import get_anthropic_key
|
||||
anthropic_value = get_anthropic_key()
|
||||
print(f" {'Anthropic':<14} {redact_key(anthropic_value)}")
|
||||
|
||||
@@ -3717,7 +3991,7 @@ def show_config():
|
||||
|
||||
# Skill config
|
||||
try:
|
||||
from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values
|
||||
from hermes_agent.agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values
|
||||
skill_vars = discover_all_skill_config_vars()
|
||||
if skill_vars:
|
||||
resolved = resolve_skill_config_values(skill_vars)
|
||||
@@ -3749,7 +4023,7 @@ def edit_config():
|
||||
|
||||
# Ensure config exists
|
||||
if not config_path.exists():
|
||||
save_config(DEFAULT_CONFIG)
|
||||
save_config(dict(DEFAULT_CONFIG))
|
||||
print(f"Created {config_path}")
|
||||
|
||||
# Find editor
|
||||
@@ -3831,7 +4105,7 @@ def set_config_value(key: str, value: str):
|
||||
|
||||
# Write only user config back (not the full merged defaults)
|
||||
ensure_hermes_home()
|
||||
from utils import atomic_yaml_write
|
||||
from hermes_agent.utils import atomic_yaml_write
|
||||
atomic_yaml_write(config_path, user_config, sort_keys=False)
|
||||
|
||||
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
|
||||
@@ -10,10 +10,9 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2].resolve()
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_agent.cli.ui.colors import Colors, color
|
||||
|
||||
|
||||
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
|
||||
@@ -33,14 +32,14 @@ def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None)
|
||||
|
||||
|
||||
def _cron_api(**kwargs):
|
||||
from tools.cronjob_tools import cronjob as cronjob_tool
|
||||
from hermes_agent.tools.cronjob import cronjob as cronjob_tool
|
||||
|
||||
return json.loads(cronjob_tool(**kwargs))
|
||||
|
||||
|
||||
def cron_list(show_all: bool = False):
|
||||
"""List all scheduled jobs."""
|
||||
from cron.jobs import list_jobs
|
||||
from hermes_agent.cron.jobs import list_jobs
|
||||
|
||||
jobs = list_jobs(include_disabled=show_all)
|
||||
|
||||
@@ -110,7 +109,7 @@ def cron_list(show_all: bool = False):
|
||||
|
||||
print()
|
||||
|
||||
from hermes_cli.gateway import find_gateway_pids
|
||||
from hermes_agent.cli.gateway import find_gateway_pids
|
||||
if not find_gateway_pids():
|
||||
print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW))
|
||||
print(color(" Start it with: hermes gateway install", Colors.DIM))
|
||||
@@ -120,14 +119,14 @@ def cron_list(show_all: bool = False):
|
||||
|
||||
def cron_tick():
|
||||
"""Run due jobs once and exit."""
|
||||
from cron.scheduler import tick
|
||||
from hermes_agent.cron.scheduler import tick
|
||||
tick(verbose=True)
|
||||
|
||||
|
||||
def cron_status():
|
||||
"""Show cron execution status."""
|
||||
from cron.jobs import list_jobs
|
||||
from hermes_cli.gateway import find_gateway_pids
|
||||
from hermes_agent.cron.jobs import list_jobs
|
||||
from hermes_agent.cli.gateway import find_gateway_pids
|
||||
|
||||
print()
|
||||
|
||||
@@ -185,7 +184,7 @@ def cron_create(args):
|
||||
|
||||
|
||||
def cron_edit(args):
|
||||
from cron.jobs import get_job
|
||||
from hermes_agent.cron.jobs import get_job
|
||||
|
||||
job = get_job(args.job_id)
|
||||
if not job:
|
||||
@@ -13,11 +13,10 @@ import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -148,14 +147,6 @@ def _sweep_expired_pastes(now: Optional[float] = None) -> tuple[int, int]:
|
||||
return (deleted, len(remaining))
|
||||
|
||||
|
||||
def _best_effort_sweep_expired_pastes() -> None:
|
||||
"""Attempt pending-paste cleanup without letting /debug fail offline."""
|
||||
try:
|
||||
_sweep_expired_pastes()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Privacy / delete helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -323,128 +314,72 @@ def upload_to_pastebin(content: str, expiry_days: int = 7) -> str:
|
||||
# Log file reading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogSnapshot:
|
||||
"""Single-read snapshot of a log file used by debug-share."""
|
||||
|
||||
path: Optional[Path]
|
||||
tail_text: str
|
||||
full_text: Optional[str]
|
||||
|
||||
|
||||
def _primary_log_path(log_name: str) -> Optional[Path]:
|
||||
"""Where *log_name* would live if present. Doesn't check existence."""
|
||||
from hermes_cli.logs import LOG_FILES
|
||||
|
||||
filename = LOG_FILES.get(log_name)
|
||||
return (get_hermes_home() / "logs" / filename) if filename else None
|
||||
|
||||
|
||||
def _resolve_log_path(log_name: str) -> Optional[Path]:
|
||||
"""Find the log file for *log_name*, falling back to the .1 rotation.
|
||||
|
||||
Returns the first non-empty candidate (primary, then .1), or None.
|
||||
Callers distinguish 'empty primary' from 'truly missing' via
|
||||
:func:`_primary_log_path`.
|
||||
Returns the path if found, or None.
|
||||
"""
|
||||
primary = _primary_log_path(log_name)
|
||||
if primary is None:
|
||||
from hermes_agent.cli.logs import LOG_FILES
|
||||
|
||||
filename = LOG_FILES.get(log_name)
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
primary = log_dir / filename
|
||||
if primary.exists() and primary.stat().st_size > 0:
|
||||
return primary
|
||||
|
||||
rotated = primary.parent / f"{primary.name}.1"
|
||||
# Fall back to the most recent rotated file (.1).
|
||||
rotated = log_dir / f"{filename}.1"
|
||||
if rotated.exists() and rotated.stat().st_size > 0:
|
||||
return rotated
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _capture_log_snapshot(
|
||||
log_name: str,
|
||||
*,
|
||||
tail_lines: int,
|
||||
max_bytes: int = _MAX_LOG_BYTES,
|
||||
) -> LogSnapshot:
|
||||
"""Capture a log once and derive summary/full-log views from it.
|
||||
def _read_log_tail(log_name: str, num_lines: int) -> str:
|
||||
"""Read the last *num_lines* from a log file, or return a placeholder."""
|
||||
from hermes_agent.cli.logs import _read_last_n_lines
|
||||
|
||||
The report tail and standalone log upload must come from the same file
|
||||
snapshot. Otherwise a rotation/truncate between reads can make the report
|
||||
look newer than the uploaded ``agent.log`` paste.
|
||||
log_path = _resolve_log_path(log_name)
|
||||
if log_path is None:
|
||||
return "(file not found)"
|
||||
|
||||
try:
|
||||
lines = _read_last_n_lines(log_path, num_lines)
|
||||
return "".join(lines).rstrip("\n")
|
||||
except Exception as exc:
|
||||
return f"(error reading: {exc})"
|
||||
|
||||
|
||||
def _read_full_log(log_name: str, max_bytes: int = _MAX_LOG_BYTES) -> Optional[str]:
|
||||
"""Read a log file for standalone upload.
|
||||
|
||||
Returns the file content (last *max_bytes* if truncated), or None if the
|
||||
file doesn't exist or is empty.
|
||||
"""
|
||||
log_path = _resolve_log_path(log_name)
|
||||
if log_path is None:
|
||||
primary = _primary_log_path(log_name)
|
||||
tail = "(file empty)" if primary and primary.exists() else "(file not found)"
|
||||
return LogSnapshot(path=None, tail_text=tail, full_text=None)
|
||||
return None
|
||||
|
||||
try:
|
||||
size = log_path.stat().st_size
|
||||
if size == 0:
|
||||
# race: file was truncated between _resolve_log_path and stat
|
||||
return LogSnapshot(path=log_path, tail_text="(file empty)", full_text=None)
|
||||
return None
|
||||
|
||||
if size <= max_bytes:
|
||||
return log_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# File is larger than max_bytes — read the tail.
|
||||
with open(log_path, "rb") as f:
|
||||
if size <= max_bytes:
|
||||
raw = f.read()
|
||||
truncated = False
|
||||
else:
|
||||
# Read from the end until we have enough bytes for the
|
||||
# standalone upload and enough newline context to render the
|
||||
# summary tail from the same snapshot.
|
||||
chunk_size = 8192
|
||||
pos = size
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
newline_count = 0
|
||||
|
||||
while pos > 0 and (total < max_bytes or newline_count <= tail_lines + 1) and total < max_bytes * 2:
|
||||
read_size = min(chunk_size, pos)
|
||||
pos -= read_size
|
||||
f.seek(pos)
|
||||
chunk = f.read(read_size)
|
||||
chunks.insert(0, chunk)
|
||||
total += len(chunk)
|
||||
newline_count += chunk.count(b"\n")
|
||||
chunk_size = min(chunk_size * 2, 65536)
|
||||
|
||||
raw = b"".join(chunks)
|
||||
truncated = pos > 0
|
||||
|
||||
full_raw = raw
|
||||
if truncated and len(full_raw) > max_bytes:
|
||||
cut = len(full_raw) - max_bytes
|
||||
# Check whether the cut lands exactly on a line boundary. If the
|
||||
# byte just before the cut position is a newline the first retained
|
||||
# byte starts a complete line and we should keep it. Only drop a
|
||||
# partial first line when we're genuinely mid-line.
|
||||
on_boundary = cut > 0 and full_raw[cut - 1 : cut] == b"\n"
|
||||
full_raw = full_raw[cut:]
|
||||
if not on_boundary and b"\n" in full_raw:
|
||||
full_raw = full_raw.split(b"\n", 1)[1]
|
||||
|
||||
all_text = raw.decode("utf-8", errors="replace")
|
||||
tail_text = "".join(all_text.splitlines(keepends=True)[-tail_lines:]).rstrip("\n")
|
||||
|
||||
full_text = full_raw.decode("utf-8", errors="replace")
|
||||
if truncated:
|
||||
full_text = f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{full_text}"
|
||||
|
||||
return LogSnapshot(path=log_path, tail_text=tail_text, full_text=full_text)
|
||||
except Exception as exc:
|
||||
return LogSnapshot(path=log_path, tail_text=f"(error reading: {exc})", full_text=None)
|
||||
|
||||
|
||||
def _capture_default_log_snapshots(log_lines: int) -> dict[str, LogSnapshot]:
|
||||
"""Capture all logs used by debug-share exactly once."""
|
||||
errors_lines = min(log_lines, 100)
|
||||
return {
|
||||
"agent": _capture_log_snapshot("agent", tail_lines=log_lines),
|
||||
"errors": _capture_log_snapshot("errors", tail_lines=errors_lines),
|
||||
"gateway": _capture_log_snapshot("gateway", tail_lines=errors_lines),
|
||||
}
|
||||
f.seek(size - max_bytes)
|
||||
# Skip partial line at the seek point.
|
||||
f.readline()
|
||||
content = f.read().decode("utf-8", errors="replace")
|
||||
return f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{content}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -453,7 +388,7 @@ def _capture_default_log_snapshots(log_lines: int) -> dict[str, LogSnapshot]:
|
||||
|
||||
def _capture_dump() -> str:
|
||||
"""Run ``hermes dump`` and return its stdout as a string."""
|
||||
from hermes_cli.dump import run_dump
|
||||
from hermes_agent.cli.dump import run_dump
|
||||
|
||||
class _FakeArgs:
|
||||
show_keys = False
|
||||
@@ -470,12 +405,7 @@ def _capture_dump() -> str:
|
||||
return capture.getvalue()
|
||||
|
||||
|
||||
def collect_debug_report(
|
||||
*,
|
||||
log_lines: int = 200,
|
||||
dump_text: str = "",
|
||||
log_snapshots: Optional[dict[str, LogSnapshot]] = None,
|
||||
) -> str:
|
||||
def collect_debug_report(*, log_lines: int = 200, dump_text: str = "") -> str:
|
||||
"""Build the summary debug report: system dump + log tails.
|
||||
|
||||
Parameters
|
||||
@@ -494,22 +424,19 @@ def collect_debug_report(
|
||||
dump_text = _capture_dump()
|
||||
buf.write(dump_text)
|
||||
|
||||
if log_snapshots is None:
|
||||
log_snapshots = _capture_default_log_snapshots(log_lines)
|
||||
|
||||
# ── Recent log tails (summary only) ──────────────────────────────────
|
||||
buf.write("\n\n")
|
||||
buf.write(f"--- agent.log (last {log_lines} lines) ---\n")
|
||||
buf.write(log_snapshots["agent"].tail_text)
|
||||
buf.write(_read_log_tail("agent", log_lines))
|
||||
buf.write("\n\n")
|
||||
|
||||
errors_lines = min(log_lines, 100)
|
||||
buf.write(f"--- errors.log (last {errors_lines} lines) ---\n")
|
||||
buf.write(log_snapshots["errors"].tail_text)
|
||||
buf.write(_read_log_tail("errors", errors_lines))
|
||||
buf.write("\n\n")
|
||||
|
||||
buf.write(f"--- gateway.log (last {errors_lines} lines) ---\n")
|
||||
buf.write(log_snapshots["gateway"].tail_text)
|
||||
buf.write(_read_log_tail("gateway", errors_lines))
|
||||
buf.write("\n")
|
||||
|
||||
return buf.getvalue()
|
||||
@@ -521,8 +448,6 @@ def collect_debug_report(
|
||||
|
||||
def run_debug_share(args):
|
||||
"""Collect debug report + full logs, upload each, print URLs."""
|
||||
_best_effort_sweep_expired_pastes()
|
||||
|
||||
log_lines = getattr(args, "lines", 200)
|
||||
expiry = getattr(args, "expire", 7)
|
||||
local_only = getattr(args, "local", False)
|
||||
@@ -534,15 +459,10 @@ def run_debug_share(args):
|
||||
|
||||
# Capture dump once — prepended to every paste for context.
|
||||
dump_text = _capture_dump()
|
||||
log_snapshots = _capture_default_log_snapshots(log_lines)
|
||||
|
||||
report = collect_debug_report(
|
||||
log_lines=log_lines,
|
||||
dump_text=dump_text,
|
||||
log_snapshots=log_snapshots,
|
||||
)
|
||||
agent_log = log_snapshots["agent"].full_text
|
||||
gateway_log = log_snapshots["gateway"].full_text
|
||||
report = collect_debug_report(log_lines=log_lines, dump_text=dump_text)
|
||||
agent_log = _read_full_log("agent")
|
||||
gateway_log = _read_full_log("gateway")
|
||||
|
||||
# Prepend dump header to each full log so every paste is self-contained.
|
||||
if agent_log:
|
||||
@@ -10,8 +10,8 @@ import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_agent.cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
from hermes_agent.constants import display_hermes_home
|
||||
|
||||
PROJECT_ROOT = get_project_root()
|
||||
HERMES_HOME = get_hermes_home()
|
||||
@@ -28,9 +28,9 @@ if _env_path.exists():
|
||||
# Also try project .env as dev fallback
|
||||
load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from utils import base_url_host_matches
|
||||
from hermes_agent.cli.ui.colors import Colors, color
|
||||
from hermes_agent.constants import OPENROUTER_MODELS_URL
|
||||
from hermes_agent.utils import base_url_host_matches
|
||||
|
||||
|
||||
_PROVIDER_ENV_HINTS = (
|
||||
@@ -58,7 +58,7 @@ _PROVIDER_ENV_HINTS = (
|
||||
)
|
||||
|
||||
|
||||
from hermes_constants import is_termux as _is_termux
|
||||
from hermes_agent.constants import is_termux as _is_termux
|
||||
|
||||
|
||||
def _python_install_cmd() -> str:
|
||||
@@ -92,7 +92,7 @@ def _has_provider_env_config(content: str) -> bool:
|
||||
def _honcho_is_configured_for_doctor() -> bool:
|
||||
"""Return True when Honcho is configured, even if this process has no active session."""
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
from hermes_agent.plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
cfg = HonchoClientConfig.from_global_config()
|
||||
return bool(cfg.enabled and (cfg.api_key or cfg.base_url))
|
||||
@@ -132,7 +132,7 @@ def check_info(text: str):
|
||||
def _check_gateway_service_linger(issues: list[str]) -> None:
|
||||
"""Warn when a systemd user gateway service will stop after logout."""
|
||||
try:
|
||||
from hermes_cli.gateway import (
|
||||
from hermes_agent.cli.gateway import (
|
||||
get_systemd_linger_status,
|
||||
get_systemd_unit_path,
|
||||
is_linux,
|
||||
@@ -290,12 +290,12 @@ def run_doctor(args):
|
||||
|
||||
known_providers: set = set()
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_agent.cli.auth.auth import PROVIDER_REGISTRY
|
||||
known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from hermes_cli.auth import resolve_provider as _resolve_provider
|
||||
from hermes_agent.cli.auth.auth import resolve_provider as _resolve_provider
|
||||
except Exception:
|
||||
_resolve_provider = None
|
||||
|
||||
@@ -338,7 +338,7 @@ def run_doctor(args):
|
||||
# explicitly dispatch, which would produce false positives.
|
||||
if canonical_provider and canonical_provider not in ("auto", "custom", "openrouter"):
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
|
||||
from hermes_agent.cli.auth.auth import PROVIDER_REGISTRY, get_auth_status
|
||||
pconfig = PROVIDER_REGISTRY.get(canonical_provider)
|
||||
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
|
||||
status = get_auth_status(canonical_provider) or {}
|
||||
@@ -379,7 +379,7 @@ def run_doctor(args):
|
||||
config_path = HERMES_HOME / 'config.yaml'
|
||||
if config_path.exists():
|
||||
try:
|
||||
from hermes_cli.config import check_config_version, migrate_config
|
||||
from hermes_agent.cli.config import check_config_version, migrate_config
|
||||
current_ver, latest_ver = check_config_version()
|
||||
if current_ver < latest_ver:
|
||||
check_warn(
|
||||
@@ -419,7 +419,7 @@ def run_doctor(args):
|
||||
model_section[k] = raw_config.pop(k)
|
||||
else:
|
||||
raw_config.pop(k)
|
||||
from utils import atomic_yaml_write
|
||||
from hermes_agent.utils import atomic_yaml_write
|
||||
atomic_yaml_write(config_path, raw_config)
|
||||
check_ok("Migrated stale root-level keys into model section")
|
||||
fixed_count += 1
|
||||
@@ -430,7 +430,7 @@ def run_doctor(args):
|
||||
|
||||
# Validate config structure (catches malformed custom_providers, etc.)
|
||||
try:
|
||||
from hermes_cli.config import validate_config_structure
|
||||
from hermes_agent.cli.config import validate_config_structure
|
||||
config_issues = validate_config_structure()
|
||||
if config_issues:
|
||||
print()
|
||||
@@ -454,7 +454,7 @@ def run_doctor(args):
|
||||
print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
from hermes_agent.cli.auth.auth import (
|
||||
get_nous_auth_status,
|
||||
get_codex_auth_status,
|
||||
get_gemini_oauth_auth_status,
|
||||
@@ -877,13 +877,13 @@ def run_doctor(args):
|
||||
else:
|
||||
check_warn("OpenRouter API", "(not configured)")
|
||||
|
||||
from hermes_cli.auth import get_anthropic_key
|
||||
from hermes_agent.cli.auth.auth import get_anthropic_key
|
||||
anthropic_key = get_anthropic_key()
|
||||
if anthropic_key:
|
||||
print(" Checking Anthropic API...", end="", flush=True)
|
||||
try:
|
||||
import httpx
|
||||
from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
from hermes_agent.providers.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
|
||||
headers = {"anthropic-version": "2023-06-01"}
|
||||
if _is_oauth_token(anthropic_key):
|
||||
@@ -912,7 +912,6 @@ def run_doctor(args):
|
||||
_apikey_providers = [
|
||||
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
|
||||
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
|
||||
("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
|
||||
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
|
||||
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
|
||||
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
|
||||
@@ -952,7 +951,7 @@ def run_doctor(args):
|
||||
# with no /v1) don't support /models. Rewrite to the OpenAI-compat
|
||||
# /v1 surface for health checks.
|
||||
if _base and _base.rstrip("/").endswith("/anthropic"):
|
||||
from agent.auxiliary_client import _to_openai_base_url
|
||||
from hermes_agent.providers.auxiliary import _to_openai_base_url
|
||||
_base = _to_openai_base_url(_base)
|
||||
if base_url_host_matches(_base, "api.kimi.com") and _base.rstrip("/").endswith("/coding"):
|
||||
_base = _base.rstrip("/") + "/v1"
|
||||
@@ -978,7 +977,7 @@ def run_doctor(args):
|
||||
# -- AWS Bedrock --
|
||||
# Bedrock uses the AWS SDK credential chain, not API keys.
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
|
||||
from hermes_agent.providers.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
|
||||
if has_aws_credentials():
|
||||
_auth_var = resolve_aws_auth_env_var()
|
||||
_region = resolve_bedrock_region()
|
||||
@@ -1029,9 +1028,7 @@ def run_doctor(args):
|
||||
print(color("◆ Tool Availability", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
try:
|
||||
# Add project root to path for imports
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
|
||||
from hermes_agent.tools.dispatch import check_tool_availability, TOOLSET_REQUIREMENTS
|
||||
|
||||
available, unavailable = check_tool_availability()
|
||||
available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)
|
||||
@@ -1080,7 +1077,7 @@ def run_doctor(args):
|
||||
else:
|
||||
check_warn("Skills Hub directory not initialized", "(run: hermes skills list)")
|
||||
|
||||
from hermes_cli.config import get_env_value
|
||||
from hermes_agent.cli.config import get_env_value
|
||||
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
|
||||
if github_token:
|
||||
check_ok("GitHub token configured (authenticated API access)")
|
||||
@@ -1108,7 +1105,7 @@ def run_doctor(args):
|
||||
check_ok("Built-in memory active", "(no external provider configured — this is fine)")
|
||||
elif _active_memory_provider == "honcho":
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
|
||||
from hermes_agent.plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
|
||||
hcfg = HonchoClientConfig.from_global_config()
|
||||
_honcho_cfg_path = resolve_config_path()
|
||||
|
||||
@@ -1120,7 +1117,7 @@ def run_doctor(args):
|
||||
check_fail("Honcho API key or base URL not set", "run: hermes memory setup")
|
||||
issues.append("No Honcho API key — run 'hermes memory setup'")
|
||||
else:
|
||||
from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
|
||||
from hermes_agent.plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
|
||||
reset_honcho_client()
|
||||
try:
|
||||
get_honcho_client(hcfg)
|
||||
@@ -1138,7 +1135,7 @@ def run_doctor(args):
|
||||
check_warn("Honcho check failed", str(_e))
|
||||
elif _active_memory_provider == "mem0":
|
||||
try:
|
||||
from plugins.memory.mem0 import _load_config as _load_mem0_config
|
||||
from hermes_agent.plugins.memory.mem0 import _load_config as _load_mem0_config
|
||||
mem0_cfg = _load_mem0_config()
|
||||
mem0_key = mem0_cfg.get("api_key", "")
|
||||
if mem0_key:
|
||||
@@ -1155,7 +1152,7 @@ def run_doctor(args):
|
||||
else:
|
||||
# Generic check for other memory providers (openviking, hindsight, etc.)
|
||||
try:
|
||||
from plugins.memory import load_memory_provider
|
||||
from hermes_agent.plugins.memory import load_memory_provider
|
||||
_provider = load_memory_provider(_active_memory_provider)
|
||||
if _provider and _provider.is_available():
|
||||
check_ok(f"{_active_memory_provider} provider active")
|
||||
@@ -1170,7 +1167,7 @@ def run_doctor(args):
|
||||
# Profiles
|
||||
# =========================================================================
|
||||
try:
|
||||
from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists
|
||||
from hermes_agent.cli.profiles import list_profiles, _get_wrapper_dir, profile_exists
|
||||
import re as _re
|
||||
|
||||
named_profiles = [p for p in list_profiles() if not p.is_default]
|
||||
@@ -13,8 +13,8 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_agent.cli.config import get_hermes_home, get_env_path, get_project_root, load_config
|
||||
from hermes_agent.constants import display_hermes_home
|
||||
|
||||
|
||||
def _get_git_commit(project_root: Path) -> str:
|
||||
@@ -44,7 +44,7 @@ def _redact(value: str) -> str:
|
||||
def _gateway_status() -> str:
|
||||
"""Return a short gateway status string."""
|
||||
try:
|
||||
from hermes_cli.gateway import get_gateway_runtime_snapshot
|
||||
from hermes_agent.cli.gateway import get_gateway_runtime_snapshot
|
||||
|
||||
snapshot = get_gateway_runtime_snapshot()
|
||||
if snapshot.running:
|
||||
@@ -142,7 +142,7 @@ def _config_overrides(config: dict) -> dict[str, str]:
|
||||
|
||||
Returns a flat dict of dotpath -> value for interesting overrides.
|
||||
"""
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
from hermes_agent.cli.config import DEFAULT_CONFIG
|
||||
|
||||
overrides = {}
|
||||
|
||||
@@ -178,7 +178,7 @@ def _config_overrides(config: dict) -> dict[str, str]:
|
||||
default_toolsets = DEFAULT_CONFIG.get("toolsets", [])
|
||||
user_toolsets = config.get("toolsets", [])
|
||||
if user_toolsets != default_toolsets:
|
||||
overrides["toolsets"] = str(user_toolsets)
|
||||
overrides["hermes_agent.tools.toolsets"] = str(user_toolsets)
|
||||
|
||||
# Fallback providers
|
||||
fallbacks = config.get("fallback_providers", [])
|
||||
@@ -207,7 +207,7 @@ def run_dump(args):
|
||||
hermes_home = get_hermes_home()
|
||||
|
||||
try:
|
||||
from hermes_cli import __version__, __release_date__
|
||||
from hermes_agent.cli import __version__, __release_date__
|
||||
except ImportError:
|
||||
__version__ = "(unknown)"
|
||||
__release_date__ = ""
|
||||
@@ -223,7 +223,7 @@ def run_dump(args):
|
||||
|
||||
# Profile
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
from hermes_agent.cli.profiles import get_active_profile_name
|
||||
profile = get_active_profile_name() or "(default)"
|
||||
except Exception:
|
||||
profile = "(default)"
|
||||
@@ -108,7 +108,7 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
try:
|
||||
from hermes_cli.config import _sanitize_env_lines
|
||||
from hermes_agent.cli.config import _sanitize_env_lines
|
||||
except ImportError:
|
||||
return # early bootstrap — config module not available yet
|
||||
|
||||
@@ -160,8 +160,6 @@ def load_hermes_dotenv(
|
||||
# Fix corrupted .env files before python-dotenv parses them (#8908).
|
||||
if user_env.exists():
|
||||
_sanitize_env_file_if_needed(user_env)
|
||||
if project_env_path and project_env_path.exists():
|
||||
_sanitize_env_file_if_needed(project_env_path)
|
||||
|
||||
if user_env.exists():
|
||||
_load_dotenv_with_fallback(user_env, override=True)
|
||||
@@ -13,15 +13,15 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2].resolve()
|
||||
|
||||
from gateway.status import terminate_pid
|
||||
from gateway.restart import (
|
||||
from hermes_agent.gateway.status import terminate_pid
|
||||
from hermes_agent.gateway.restart import (
|
||||
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,
|
||||
GATEWAY_SERVICE_RESTART_EXIT_CODE,
|
||||
parse_restart_drain_timeout,
|
||||
)
|
||||
from hermes_cli.config import (
|
||||
from hermes_agent.cli.config import (
|
||||
get_env_value,
|
||||
get_hermes_home,
|
||||
is_managed,
|
||||
@@ -31,11 +31,11 @@ from hermes_cli.config import (
|
||||
)
|
||||
# display_hermes_home is imported lazily at call sites to avoid ImportError
|
||||
# when hermes_constants is cached from a pre-update version during `hermes update`.
|
||||
from hermes_cli.setup import (
|
||||
from hermes_agent.cli.setup_wizard import (
|
||||
print_header, print_info, print_success, print_warning, print_error,
|
||||
prompt, prompt_choice, prompt_yes_no,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_agent.cli.ui.colors import Colors, color
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -192,6 +192,12 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
|
||||
"""
|
||||
pids: list[int] = []
|
||||
patterns = [
|
||||
"hermes_agent.cli.main gateway",
|
||||
"hermes_agent.cli.main --profile",
|
||||
"hermes_agent.cli.main -p",
|
||||
"hermes_agent/cli/main.py gateway",
|
||||
"hermes_agent/cli/main.py --profile",
|
||||
"hermes_agent/cli/main.py -p",
|
||||
"hermes_cli.main gateway",
|
||||
"hermes_cli.main --profile",
|
||||
"hermes_cli.main -p",
|
||||
@@ -303,7 +309,7 @@ def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = Fals
|
||||
pids: list[int] = []
|
||||
if not all_profiles:
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
from hermes_agent.gateway.status import get_running_pid
|
||||
|
||||
_append_unique_pid(pids, get_running_pid(), _exclude)
|
||||
except Exception:
|
||||
@@ -333,147 +339,6 @@ def _probe_systemd_service_running(system: bool = False) -> tuple[bool, bool]:
|
||||
return selected_system, result.stdout.strip() == "active"
|
||||
|
||||
|
||||
def _read_systemd_unit_properties(
|
||||
system: bool = False,
|
||||
properties: tuple[str, ...] = (
|
||||
"ActiveState",
|
||||
"SubState",
|
||||
"Result",
|
||||
"ExecMainStatus",
|
||||
),
|
||||
) -> dict[str, str]:
|
||||
"""Return selected ``systemctl show`` properties for the gateway unit."""
|
||||
selected_system = _select_systemd_scope(system)
|
||||
try:
|
||||
result = _run_systemctl(
|
||||
[
|
||||
"show",
|
||||
get_service_name(),
|
||||
"--no-pager",
|
||||
"--property",
|
||||
",".join(properties),
|
||||
],
|
||||
system=selected_system,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (RuntimeError, subprocess.TimeoutExpired, OSError):
|
||||
return {}
|
||||
|
||||
if result.returncode != 0:
|
||||
return {}
|
||||
|
||||
parsed: dict[str, str] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
parsed[key] = value.strip()
|
||||
return parsed
|
||||
|
||||
|
||||
def _wait_for_systemd_service_restart(
|
||||
*,
|
||||
system: bool = False,
|
||||
previous_pid: int | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> bool:
|
||||
"""Wait for the gateway service to become active after a restart handoff."""
|
||||
import time
|
||||
|
||||
svc = get_service_name()
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
props = _read_systemd_unit_properties(system=system)
|
||||
active_state = props.get("ActiveState", "")
|
||||
sub_state = props.get("SubState", "")
|
||||
new_pid = None
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
new_pid = get_running_pid()
|
||||
except Exception:
|
||||
new_pid = None
|
||||
|
||||
if active_state == "active":
|
||||
if new_pid and (previous_pid is None or new_pid != previous_pid):
|
||||
print(f"✓ {scope_label} service restarted (PID {new_pid})")
|
||||
return True
|
||||
if previous_pid is None:
|
||||
print(f"✓ {scope_label} service restarted")
|
||||
return True
|
||||
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
print(
|
||||
f"⚠ {scope_label} service did not become active within {int(timeout)}s.\n"
|
||||
f" Check status: {'sudo ' if system else ''}hermes gateway status\n"
|
||||
f" Check logs: journalctl {'--user ' if not system else ''}-u {svc} -l --since '2 min ago'"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _recover_pending_systemd_restart(system: bool = False, previous_pid: int | None = None) -> bool:
|
||||
"""Recover a planned service restart that is stuck in systemd state."""
|
||||
props = _read_systemd_unit_properties(system=system)
|
||||
if not props:
|
||||
return False
|
||||
|
||||
try:
|
||||
from gateway.status import read_runtime_status
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
runtime_state = read_runtime_status() or {}
|
||||
if not runtime_state.get("restart_requested"):
|
||||
return False
|
||||
|
||||
active_state = props.get("ActiveState", "")
|
||||
sub_state = props.get("SubState", "")
|
||||
exec_main_status = props.get("ExecMainStatus", "")
|
||||
result = props.get("Result", "")
|
||||
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
print("⏳ Service restart already pending — waiting for systemd relaunch...")
|
||||
return _wait_for_systemd_service_restart(
|
||||
system=system,
|
||||
previous_pid=previous_pid,
|
||||
)
|
||||
|
||||
if active_state == "failed" and (
|
||||
exec_main_status == str(GATEWAY_SERVICE_RESTART_EXIT_CODE)
|
||||
or result == "exit-code"
|
||||
):
|
||||
svc = get_service_name()
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
print(f"↻ Clearing failed state for pending {scope_label.lower()} service restart...")
|
||||
_run_systemctl(
|
||||
["reset-failed", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(
|
||||
["start", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=90,
|
||||
)
|
||||
return _wait_for_systemd_service_restart(
|
||||
system=system,
|
||||
previous_pid=previous_pid,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _probe_launchd_service_running() -> bool:
|
||||
if not get_launchd_plist_path().exists():
|
||||
return False
|
||||
@@ -498,7 +363,7 @@ def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot
|
||||
gateway_pids=gateway_pids,
|
||||
)
|
||||
|
||||
from hermes_constants import is_container
|
||||
from hermes_agent.constants import is_container
|
||||
|
||||
if is_linux() and is_container():
|
||||
return GatewayRuntimeSnapshot(
|
||||
@@ -586,7 +451,7 @@ def stop_profile_gateway() -> bool:
|
||||
Returns True if a process was stopped, False if none was found.
|
||||
"""
|
||||
try:
|
||||
from gateway.status import get_running_pid, remove_pid_file
|
||||
from hermes_agent.gateway.status import get_running_pid, remove_pid_file
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
@@ -611,8 +476,7 @@ def stop_profile_gateway() -> bool:
|
||||
except (ProcessLookupError, PermissionError):
|
||||
break
|
||||
|
||||
if get_running_pid() is None:
|
||||
remove_pid_file()
|
||||
remove_pid_file()
|
||||
return True
|
||||
|
||||
|
||||
@@ -620,7 +484,7 @@ def is_linux() -> bool:
|
||||
return sys.platform.startswith('linux')
|
||||
|
||||
|
||||
from hermes_constants import is_container, is_termux, is_wsl
|
||||
from hermes_agent.constants import is_container, is_termux, is_wsl
|
||||
|
||||
|
||||
def _wsl_systemd_operational() -> bool:
|
||||
@@ -694,7 +558,7 @@ def _profile_suffix() -> str:
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
from hermes_constants import get_default_hermes_root
|
||||
from hermes_agent.constants import get_default_hermes_root
|
||||
home = get_hermes_home().resolve()
|
||||
default = get_default_hermes_root().resolve()
|
||||
if home == default:
|
||||
@@ -724,7 +588,7 @@ def _profile_arg(hermes_home: str | None = None) -> str:
|
||||
service definition for a different user (e.g. system service).
|
||||
"""
|
||||
import re
|
||||
from hermes_constants import get_default_hermes_root
|
||||
from hermes_agent.constants import get_default_hermes_root
|
||||
home = Path(hermes_home or str(get_hermes_home())).resolve()
|
||||
default = get_default_hermes_root().resolve()
|
||||
if home == default:
|
||||
@@ -761,21 +625,6 @@ def get_systemd_unit_path(system: bool = False) -> Path:
|
||||
return Path.home() / ".config" / "systemd" / "user" / f"{name}.service"
|
||||
|
||||
|
||||
class UserSystemdUnavailableError(RuntimeError):
|
||||
"""Raised when ``systemctl --user`` cannot reach the user D-Bus session.
|
||||
|
||||
Typically hit on fresh RHEL/Debian SSH sessions where linger is disabled
|
||||
and no user@.service is running, so ``/run/user/$UID/bus`` never exists.
|
||||
Carries a user-facing remediation message in ``args[0]``.
|
||||
"""
|
||||
|
||||
|
||||
def _user_dbus_socket_path() -> Path:
|
||||
"""Return the expected per-user D-Bus socket path (regardless of existence)."""
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
return Path(xdg) / "bus"
|
||||
|
||||
|
||||
def _ensure_user_systemd_env() -> None:
|
||||
"""Ensure DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR are set for systemctl --user.
|
||||
|
||||
@@ -798,126 +647,6 @@ def _ensure_user_systemd_env() -> None:
|
||||
os.environ["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={bus_path}"
|
||||
|
||||
|
||||
def _wait_for_user_dbus_socket(timeout: float = 3.0) -> bool:
|
||||
"""Poll for the user D-Bus socket to appear, up to ``timeout`` seconds.
|
||||
|
||||
Linger-enabled user@.service can take a second or two to spawn the socket
|
||||
after ``loginctl enable-linger`` runs. Returns True once the socket exists.
|
||||
"""
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if _user_dbus_socket_path().exists():
|
||||
_ensure_user_systemd_env()
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return _user_dbus_socket_path().exists()
|
||||
|
||||
|
||||
def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None:
|
||||
"""Ensure ``systemctl --user`` will reach the user D-Bus session bus.
|
||||
|
||||
No-op when the bus socket is already there (the common case on desktops
|
||||
and linger-enabled servers). On fresh SSH sessions where the socket is
|
||||
missing:
|
||||
|
||||
* If linger is already enabled, wait briefly for user@.service to spawn
|
||||
the socket.
|
||||
* If linger is disabled and ``auto_enable_linger`` is True, try
|
||||
``loginctl enable-linger $USER`` (works as non-root when polkit permits
|
||||
it, otherwise needs sudo).
|
||||
* If the socket is still missing afterwards, raise
|
||||
:class:`UserSystemdUnavailableError` with a precise remediation message.
|
||||
|
||||
Callers should treat the exception as a terminal condition for user-scope
|
||||
systemd operations and surface the message to the user.
|
||||
"""
|
||||
_ensure_user_systemd_env()
|
||||
bus_path = _user_dbus_socket_path()
|
||||
if bus_path.exists():
|
||||
return
|
||||
|
||||
import getpass
|
||||
|
||||
username = getpass.getuser()
|
||||
linger_enabled, linger_detail = get_systemd_linger_status()
|
||||
|
||||
if linger_enabled is True:
|
||||
if _wait_for_user_dbus_socket(timeout=3.0):
|
||||
return
|
||||
# Linger is on but socket still missing — unusual; fall through to error.
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason="User D-Bus socket is missing even though linger is enabled.",
|
||||
fix_hint=(
|
||||
f" systemctl start user@{os.getuid()}.service\n"
|
||||
" (may require sudo; try again after the command succeeds)"
|
||||
),
|
||||
)
|
||||
|
||||
if auto_enable_linger and shutil.which("loginctl"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["loginctl", "enable-linger", username],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason=f"loginctl enable-linger failed ({exc}).",
|
||||
fix_hint=f" sudo loginctl enable-linger {username}",
|
||||
)
|
||||
else:
|
||||
if result.returncode == 0:
|
||||
if _wait_for_user_dbus_socket(timeout=5.0):
|
||||
print(f"✓ Enabled linger for {username} — user D-Bus now available")
|
||||
return
|
||||
# enable-linger succeeded but the socket never appeared.
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason="Linger was enabled, but the user D-Bus socket did not appear.",
|
||||
fix_hint=(
|
||||
" Log out and log back in, then re-run the command.\n"
|
||||
f" Or reboot and run: systemctl --user start {get_service_name()}"
|
||||
),
|
||||
)
|
||||
detail = (result.stderr or result.stdout or f"exit {result.returncode}").strip()
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason=f"loginctl enable-linger was denied: {detail}",
|
||||
fix_hint=f" sudo loginctl enable-linger {username}",
|
||||
)
|
||||
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason=(
|
||||
"User D-Bus session is not available "
|
||||
f"({linger_detail or 'linger disabled'})."
|
||||
),
|
||||
fix_hint=f" sudo loginctl enable-linger {username}",
|
||||
)
|
||||
|
||||
|
||||
def _raise_user_systemd_unavailable(username: str, *, reason: str, fix_hint: str) -> None:
|
||||
"""Build a user-facing error message and raise UserSystemdUnavailableError."""
|
||||
msg = (
|
||||
f"{reason}\n"
|
||||
" systemctl --user cannot reach the user D-Bus session in this shell.\n"
|
||||
"\n"
|
||||
" To fix:\n"
|
||||
f"{fix_hint}\n"
|
||||
"\n"
|
||||
" Alternative: run the gateway in the foreground (stays up until\n"
|
||||
" you exit / close the terminal):\n"
|
||||
" hermes gateway run"
|
||||
)
|
||||
raise UserSystemdUnavailableError(msg)
|
||||
|
||||
|
||||
def _systemctl_cmd(system: bool = False) -> list[str]:
|
||||
if not system:
|
||||
_ensure_user_systemd_env()
|
||||
@@ -973,6 +702,8 @@ _LEGACY_SERVICE_NAMES: tuple[str, ...] = ("hermes.service",)
|
||||
# ExecStart content markers that identify a unit as running our gateway.
|
||||
# A legacy unit is only flagged when its file contains one of these.
|
||||
_LEGACY_UNIT_EXECSTART_MARKERS: tuple[str, ...] = (
|
||||
"hermes_agent.cli.main gateway",
|
||||
"hermes_agent/cli/main.py gateway",
|
||||
"hermes_cli.main gateway",
|
||||
"hermes_cli/main.py gateway",
|
||||
"gateway/run.py",
|
||||
@@ -1498,7 +1229,7 @@ StartLimitBurst=5
|
||||
Type=simple
|
||||
User={username}
|
||||
Group={group_name}
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
ExecStart={python_path} -m hermes_agent.cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="HOME={home_dir}"
|
||||
Environment="USER={username}"
|
||||
@@ -1533,7 +1264,7 @@ StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
ExecStart={python_path} -m hermes_agent.cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
@@ -1758,11 +1489,6 @@ def systemd_start(system: bool = False):
|
||||
system = _select_systemd_scope(system)
|
||||
if system:
|
||||
_require_root_for_system_service("start")
|
||||
else:
|
||||
# Fail fast with actionable guidance if the user D-Bus session is not
|
||||
# reachable (common on fresh RHEL/Debian SSH sessions without linger).
|
||||
# Raises UserSystemdUnavailableError with a remediation message.
|
||||
_preflight_user_systemd()
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service started")
|
||||
@@ -1782,16 +1508,19 @@ def systemd_restart(system: bool = False):
|
||||
system = _select_systemd_scope(system)
|
||||
if system:
|
||||
_require_root_for_system_service("restart")
|
||||
else:
|
||||
_preflight_user_systemd()
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
from gateway.status import get_running_pid
|
||||
from hermes_agent.gateway.status import get_running_pid
|
||||
|
||||
pid = get_running_pid()
|
||||
if pid is not None and _request_gateway_self_restart(pid):
|
||||
# SIGUSR1 sent — the gateway will drain active agents, exit with
|
||||
# code 75, and systemd will restart it after RestartSec (30s).
|
||||
# Wait for the old process to die and the new one to become active
|
||||
# so the CLI doesn't return while the service is still restarting.
|
||||
import time
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
svc = get_service_name()
|
||||
scope_cmd = _systemctl_cmd(system)
|
||||
|
||||
# Phase 1: wait for old process to exit (drain + shutdown)
|
||||
print(f"⏳ {scope_label} service draining active work...")
|
||||
@@ -1805,41 +1534,48 @@ def systemd_restart(system: bool = False):
|
||||
else:
|
||||
print(f"⚠ Old process (PID {pid}) still alive after 90s")
|
||||
|
||||
# The gateway exits with code 75 for a planned service restart.
|
||||
# systemd can sit in the RestartSec window or even wedge itself into a
|
||||
# failed/rate-limited state if the operator asks for another restart in
|
||||
# the middle of that handoff. Clear any stale failed state and kick the
|
||||
# unit immediately so `hermes gateway restart` behaves idempotently.
|
||||
_run_systemctl(
|
||||
["reset-failed", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(
|
||||
["start", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=90,
|
||||
)
|
||||
_wait_for_systemd_service_restart(system=system, previous_pid=pid)
|
||||
return
|
||||
# Phase 2: wait for systemd to start the new process
|
||||
print(f"⏳ Waiting for {svc} to restart...")
|
||||
deadline = time.time() + 60
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
scope_cmd + ["is-active", svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
# Verify it's a NEW process, not the old one somehow
|
||||
new_pid = get_running_pid()
|
||||
if new_pid and new_pid != pid:
|
||||
print(f"✓ {scope_label} service restarted (PID {new_pid})")
|
||||
return
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
time.sleep(2)
|
||||
|
||||
if _recover_pending_systemd_restart(system=system, previous_pid=pid):
|
||||
# Timed out — check final state
|
||||
try:
|
||||
result = subprocess.run(
|
||||
scope_cmd + ["is-active", svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
print(f"✓ {scope_label} service restarted")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
f"⚠ {scope_label} service did not become active within 60s.\n"
|
||||
f" Check status: {'sudo ' if system else ''}hermes gateway status\n"
|
||||
f" Check logs: journalctl {'--user ' if not system else ''}-u {svc} --since '2 min ago'"
|
||||
)
|
||||
return
|
||||
|
||||
_run_systemctl(
|
||||
["reset-failed", get_service_name()],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service restarted")
|
||||
|
||||
|
||||
|
||||
def systemd_status(deep: bool = False, system: bool = False, full: bool = False):
|
||||
def systemd_status(deep: bool = False, system: bool = False):
|
||||
system = _select_systemd_scope(system)
|
||||
unit_path = get_systemd_unit_path(system=system)
|
||||
scope_flag = " --system" if system else ""
|
||||
@@ -1862,12 +1598,8 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False)
|
||||
print(f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit")
|
||||
print()
|
||||
|
||||
status_cmd = ["status", get_service_name(), "--no-pager"]
|
||||
if full:
|
||||
status_cmd.append("-l")
|
||||
|
||||
_run_systemctl(
|
||||
status_cmd,
|
||||
["status", get_service_name(), "--no-pager"],
|
||||
system=system,
|
||||
capture_output=False,
|
||||
timeout=10,
|
||||
@@ -1900,19 +1632,6 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False)
|
||||
for line in runtime_lines:
|
||||
print(f" {line}")
|
||||
|
||||
unit_props = _read_systemd_unit_properties(system=system)
|
||||
active_state = unit_props.get("ActiveState", "")
|
||||
sub_state = unit_props.get("SubState", "")
|
||||
exec_main_status = unit_props.get("ExecMainStatus", "")
|
||||
result_code = unit_props.get("Result", "")
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
print(" ⏳ Restart pending: systemd is waiting to relaunch the gateway")
|
||||
elif active_state == "failed" and exec_main_status == str(GATEWAY_SERVICE_RESTART_EXIT_CODE):
|
||||
print(" ⚠ Planned restart is stuck in systemd failed state (exit 75)")
|
||||
print(f" Run: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()} && {'sudo ' if system else ''}hermes gateway start{scope_flag}")
|
||||
elif active_state == "failed" and result_code:
|
||||
print(f" ⚠ Systemd unit result: {result_code}")
|
||||
|
||||
if system:
|
||||
print("✓ System service starts at boot without requiring systemd linger")
|
||||
elif deep:
|
||||
@@ -1928,10 +1647,7 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False)
|
||||
if deep:
|
||||
print()
|
||||
print("Recent logs:")
|
||||
log_cmd = _journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"]
|
||||
if full:
|
||||
log_cmd.append("-l")
|
||||
subprocess.run(log_cmd, timeout=10)
|
||||
subprocess.run(_journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"], timeout=10)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1981,7 +1697,7 @@ def generate_launchd_plist() -> str:
|
||||
prog_args = [
|
||||
f"<string>{python_path}</string>",
|
||||
"<string>-m</string>",
|
||||
"<string>hermes_cli.main</string>",
|
||||
"<string>hermes_agent.cli.main</string>",
|
||||
]
|
||||
if profile_arg:
|
||||
for part in profile_arg.split():
|
||||
@@ -2091,7 +1807,7 @@ def launchd_install(force: bool = False):
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(" hermes gateway status # Check status")
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
from hermes_agent.constants import display_hermes_home as _dhh
|
||||
print(f" tail -f {_dhh()}/logs/gateway.log # View logs")
|
||||
|
||||
def launchd_uninstall():
|
||||
@@ -2159,7 +1875,7 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5.
|
||||
force_after: Seconds of graceful waiting before escalating to force-kill.
|
||||
"""
|
||||
import time
|
||||
from gateway.status import get_running_pid
|
||||
from hermes_agent.gateway.status import get_running_pid
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
force_deadline = (time.monotonic() + force_after) if force_after is not None else None
|
||||
@@ -2193,7 +1909,7 @@ def launchd_restart():
|
||||
label = get_launchd_label()
|
||||
target = f"{_launchd_domain()}/{label}"
|
||||
drain_timeout = _get_restart_drain_timeout()
|
||||
from gateway.status import get_running_pid
|
||||
from hermes_agent.gateway.status import get_running_pid
|
||||
|
||||
try:
|
||||
pid = get_running_pid()
|
||||
@@ -2274,9 +1990,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
This prevents systemd restart loops when the old process
|
||||
hasn't fully exited yet.
|
||||
"""
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from gateway.run import start_gateway
|
||||
from hermes_agent.gateway.run import start_gateway
|
||||
|
||||
print("┌─────────────────────────────────────────────────────────┐")
|
||||
print("│ ⚕ Hermes Gateway Starting... │")
|
||||
@@ -2722,7 +2436,7 @@ def _platform_status(platform: dict) -> str:
|
||||
def _runtime_health_lines() -> list[str]:
|
||||
"""Summarize the latest persisted gateway runtime health state."""
|
||||
try:
|
||||
from gateway.status import read_runtime_status
|
||||
from hermes_agent.gateway.status import read_runtime_status
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -2854,7 +2568,7 @@ def _setup_standard_platform(platform: dict):
|
||||
|
||||
def _setup_whatsapp():
|
||||
"""Delegate to the existing WhatsApp setup flow."""
|
||||
from hermes_cli.main import cmd_whatsapp
|
||||
from hermes_agent.cli.main import cmd_whatsapp
|
||||
import argparse
|
||||
cmd_whatsapp(argparse.Namespace())
|
||||
|
||||
@@ -2873,7 +2587,7 @@ def _setup_sms():
|
||||
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk — QR scan (recommended) or manual credential entry."""
|
||||
from hermes_cli.setup import (
|
||||
from hermes_agent.cli.setup_wizard import (
|
||||
prompt_choice, prompt_yes_no, print_info, print_success, print_warning,
|
||||
)
|
||||
|
||||
@@ -2904,7 +2618,7 @@ def _setup_dingtalk():
|
||||
if method == 0:
|
||||
# ── QR-code device-flow authorization ──
|
||||
try:
|
||||
from hermes_cli.dingtalk_auth import dingtalk_qr_auth
|
||||
from hermes_agent.cli.auth.dingtalk import dingtalk_qr_auth
|
||||
except ImportError as exc:
|
||||
print_warning(f" QR auth module failed to load ({exc}), falling back to manual input.")
|
||||
_setup_standard_platform(dingtalk_platform)
|
||||
@@ -2931,120 +2645,15 @@ def _setup_dingtalk():
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Interactive setup for WeCom — scan QR code or manual credential input."""
|
||||
print()
|
||||
print(color(" ─── 💬 WeCom (Enterprise WeChat) Setup ───", Colors.CYAN))
|
||||
"""Configure WeCom (Enterprise WeChat) via the standard platform setup."""
|
||||
wecom_platform = next(p for p in _PLATFORMS if p["key"] == "wecom")
|
||||
_setup_standard_platform(wecom_platform)
|
||||
|
||||
existing_bot_id = get_env_value("WECOM_BOT_ID")
|
||||
existing_secret = get_env_value("WECOM_SECRET")
|
||||
if existing_bot_id and existing_secret:
|
||||
print()
|
||||
print_success("WeCom is already configured.")
|
||||
if not prompt_yes_no(" Reconfigure WeCom?", False):
|
||||
return
|
||||
|
||||
# ── Choose setup method ──
|
||||
print()
|
||||
method_choices = [
|
||||
"Scan QR code to obtain Bot ID and Secret automatically (recommended)",
|
||||
"Enter existing Bot ID and Secret manually",
|
||||
]
|
||||
method_idx = prompt_choice(" How would you like to set up WeCom?", method_choices, 0)
|
||||
|
||||
bot_id = None
|
||||
secret = None
|
||||
|
||||
if method_idx == 0:
|
||||
# ── QR scan flow ──
|
||||
try:
|
||||
from gateway.platforms.wecom import qr_scan_for_bot_info
|
||||
except Exception as exc:
|
||||
print_error(f" WeCom QR scan import failed: {exc}")
|
||||
qr_scan_for_bot_info = None
|
||||
|
||||
if qr_scan_for_bot_info is not None:
|
||||
try:
|
||||
credentials = qr_scan_for_bot_info()
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_warning(" WeCom setup cancelled.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print_warning(f" QR scan failed: {exc}")
|
||||
credentials = None
|
||||
if credentials:
|
||||
bot_id = credentials.get("bot_id", "")
|
||||
secret = credentials.get("secret", "")
|
||||
print_success(" ✔ QR scan successful! Bot ID and Secret obtained.")
|
||||
|
||||
if not bot_id or not secret:
|
||||
print_info(" QR scan did not complete. Continuing with manual input.")
|
||||
bot_id = None
|
||||
secret = None
|
||||
|
||||
# ── Manual credential input ──
|
||||
if not bot_id or not secret:
|
||||
print()
|
||||
print_info(" 1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots")
|
||||
print_info(" 2. Select API Mode")
|
||||
print_info(" 3. Copy the Bot ID and Secret from the bot's credentials info")
|
||||
print_info(" 4. The bot connects via WebSocket — no public endpoint needed")
|
||||
print()
|
||||
bot_id = prompt(" Bot ID", password=False)
|
||||
if not bot_id:
|
||||
print_warning(" Skipped — WeCom won't work without a Bot ID.")
|
||||
return
|
||||
secret = prompt(" Secret", password=True)
|
||||
if not secret:
|
||||
print_warning(" Skipped — WeCom won't work without a Secret.")
|
||||
return
|
||||
|
||||
# ── Save core credentials ──
|
||||
save_env_value("WECOM_BOT_ID", bot_id)
|
||||
save_env_value("WECOM_SECRET", secret)
|
||||
|
||||
# ── Allowed users (deny-by-default security) ──
|
||||
print()
|
||||
print_info(" The gateway DENIES all users by default for security.")
|
||||
print_info(" Enter user IDs to create an allowlist, or leave empty.")
|
||||
allowed = prompt(" Allowed user IDs (comma-separated, or empty)", password=False)
|
||||
if allowed:
|
||||
cleaned = allowed.replace(" ", "")
|
||||
save_env_value("WECOM_ALLOWED_USERS", cleaned)
|
||||
print_success(" Saved — only these users can interact with the bot.")
|
||||
else:
|
||||
print()
|
||||
access_choices = [
|
||||
"Enable open access (anyone can message the bot)",
|
||||
"Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')",
|
||||
"Disable direct messages",
|
||||
"Skip for now (bot will deny all users until configured)",
|
||||
]
|
||||
access_idx = prompt_choice(" How should unauthorized users be handled?", access_choices, 1)
|
||||
if access_idx == 0:
|
||||
save_env_value("WECOM_DM_POLICY", "open")
|
||||
save_env_value("GATEWAY_ALLOW_ALL_USERS", "true")
|
||||
print_warning(" Open access enabled — anyone can use your bot!")
|
||||
elif access_idx == 1:
|
||||
save_env_value("WECOM_DM_POLICY", "pairing")
|
||||
print_success(" DM pairing mode — users will receive a code to request access.")
|
||||
print_info(" Approve with: hermes pairing approve <platform> <code>")
|
||||
elif access_idx == 2:
|
||||
save_env_value("WECOM_DM_POLICY", "disabled")
|
||||
print_warning(" Direct messages disabled.")
|
||||
else:
|
||||
print_info(" Skipped — configure later with 'hermes gateway setup'")
|
||||
|
||||
# ── Home channel (optional) ──
|
||||
print()
|
||||
print_info(" Chat ID for scheduled results and notifications.")
|
||||
home = prompt(" Home chat ID (optional, for cron/notifications)", password=False)
|
||||
if home:
|
||||
save_env_value("WECOM_HOME_CHANNEL", home)
|
||||
print_success(f" Home channel set to {home}")
|
||||
|
||||
print()
|
||||
print_success("💬 WeCom configured!")
|
||||
def _setup_wecom_callback():
|
||||
"""Configure WeCom Callback (self-built app) via the standard platform setup."""
|
||||
wecom_platform = next(p for p in _PLATFORMS if p["key"] == "wecom_callback")
|
||||
_setup_standard_platform(wecom_platform)
|
||||
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
@@ -3117,7 +2726,7 @@ def _setup_weixin():
|
||||
return
|
||||
|
||||
try:
|
||||
from gateway.platforms.weixin import check_weixin_requirements, qr_login
|
||||
from hermes_agent.gateway.platforms.weixin import check_weixin_requirements, qr_login
|
||||
except Exception as exc:
|
||||
print_error(f" Weixin adapter import failed: {exc}")
|
||||
print_info(" Install gateway dependencies first, then retry.")
|
||||
@@ -3252,7 +2861,7 @@ def _setup_feishu():
|
||||
if method_idx == 0:
|
||||
# ── QR scan-to-create ──
|
||||
try:
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from hermes_agent.gateway.platforms.feishu import qr_register
|
||||
except Exception as exc:
|
||||
print_error(f" Feishu / Lark onboard import failed: {exc}")
|
||||
qr_register = None
|
||||
@@ -3293,7 +2902,7 @@ def _setup_feishu():
|
||||
# Try to probe the bot with manual credentials
|
||||
bot_name = None
|
||||
try:
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
from hermes_agent.gateway.platforms.feishu import probe_bot
|
||||
bot_info = probe_bot(app_id, app_secret, domain)
|
||||
if bot_info:
|
||||
bot_name = bot_info.get("bot_name")
|
||||
@@ -3424,8 +3033,7 @@ def _setup_qqbot():
|
||||
if method_idx == 0:
|
||||
# ── QR scan-to-configure ──
|
||||
try:
|
||||
from gateway.platforms.qqbot import qr_register
|
||||
credentials = qr_register()
|
||||
credentials = _qqbot_qr_flow()
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_warning(" QQ Bot setup cancelled.")
|
||||
@@ -3507,6 +3115,106 @@ def _setup_qqbot():
|
||||
print_info(f" App ID: {credentials['app_id']}")
|
||||
|
||||
|
||||
def _qqbot_render_qr(url: str) -> bool:
|
||||
"""Try to render a QR code in the terminal. Returns True if successful."""
|
||||
try:
|
||||
import qrcode as _qr
|
||||
qr = _qr.QRCode(border=1,error_correction=_qr.constants.ERROR_CORRECT_L)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _qqbot_qr_flow():
|
||||
"""Run the QR-code scan-to-configure flow.
|
||||
|
||||
Returns a dict with app_id, client_secret, user_openid on success,
|
||||
or None on failure/cancel.
|
||||
"""
|
||||
try:
|
||||
from hermes_agent.gateway.platforms.qqbot import (
|
||||
create_bind_task, poll_bind_result, build_connect_url,
|
||||
decrypt_secret, BindStatus,
|
||||
)
|
||||
from hermes_agent.gateway.platforms.qqbot.constants import ONBOARD_POLL_INTERVAL
|
||||
except Exception as exc:
|
||||
print_error(f" QQBot onboard import failed: {exc}")
|
||||
return None
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
MAX_REFRESHES = 3
|
||||
refresh_count = 0
|
||||
|
||||
while refresh_count <= MAX_REFRESHES:
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
# ── Create bind task ──
|
||||
try:
|
||||
task_id, aes_key = loop.run_until_complete(create_bind_task())
|
||||
except Exception as e:
|
||||
print_warning(f" Failed to create bind task: {e}")
|
||||
loop.close()
|
||||
return None
|
||||
|
||||
url = build_connect_url(task_id)
|
||||
|
||||
# ── Display QR code + URL ──
|
||||
print()
|
||||
if _qqbot_render_qr(url):
|
||||
print(f" Scan the QR code above, or open this URL directly:\n {url}")
|
||||
else:
|
||||
print(f" Open this URL in QQ on your phone:\n {url}")
|
||||
print_info(" Tip: pip install qrcode to show a scannable QR code here")
|
||||
|
||||
# ── Poll loop (silent — keep QR visible at bottom) ──
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
status, app_id, encrypted_secret, user_openid = loop.run_until_complete(
|
||||
poll_bind_result(task_id)
|
||||
)
|
||||
except Exception:
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
if status == BindStatus.COMPLETED:
|
||||
client_secret = decrypt_secret(encrypted_secret, aes_key)
|
||||
print()
|
||||
print_success(f" QR scan complete! (App ID: {app_id})")
|
||||
if user_openid:
|
||||
print_info(f" Scanner's OpenID: {user_openid}")
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"client_secret": client_secret,
|
||||
"user_openid": user_openid,
|
||||
}
|
||||
|
||||
if status == BindStatus.EXPIRED:
|
||||
refresh_count += 1
|
||||
if refresh_count > MAX_REFRESHES:
|
||||
print()
|
||||
print_warning(f" QR code expired {MAX_REFRESHES} times — giving up.")
|
||||
return None
|
||||
print()
|
||||
print_warning(f" QR code expired, refreshing... ({refresh_count}/{MAX_REFRESHES})")
|
||||
loop.close()
|
||||
break # outer while creates a new task
|
||||
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
except KeyboardInterrupt:
|
||||
loop.close()
|
||||
raise
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _setup_signal():
|
||||
"""Interactive setup for Signal messenger."""
|
||||
import shutil
|
||||
@@ -3658,10 +3366,6 @@ def gateway_setup():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Failed to start — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Failed to start: {e}")
|
||||
else:
|
||||
@@ -3698,8 +3402,6 @@ def gateway_setup():
|
||||
_setup_feishu()
|
||||
elif platform["key"] == "qqbot":
|
||||
_setup_qqbot()
|
||||
elif platform["key"] == "wecom":
|
||||
_setup_wecom()
|
||||
else:
|
||||
_setup_standard_platform(platform)
|
||||
|
||||
@@ -3726,10 +3428,6 @@ def gateway_setup():
|
||||
else:
|
||||
stop_profile_gateway()
|
||||
print_info("Start manually: hermes gateway")
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Restart failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
elif service_installed:
|
||||
@@ -3739,10 +3437,6 @@ def gateway_setup():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
else:
|
||||
@@ -3766,10 +3460,6 @@ def gateway_setup():
|
||||
systemd_start(system=installed_scope == "system")
|
||||
else:
|
||||
launchd_start()
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -3787,7 +3477,7 @@ def gateway_setup():
|
||||
print_info(" To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'")
|
||||
else:
|
||||
if is_termux():
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
from hermes_agent.constants import display_hermes_home as _dhh
|
||||
print_info(" Termux does not use systemd/launchd services.")
|
||||
print_info(" Run in foreground: hermes gateway run")
|
||||
print_info(f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &")
|
||||
@@ -3807,18 +3497,6 @@ def gateway_setup():
|
||||
|
||||
def gateway_command(args):
|
||||
"""Handle gateway subcommands."""
|
||||
try:
|
||||
return _gateway_command_inner(args)
|
||||
except UserSystemdUnavailableError as e:
|
||||
# Clean, actionable message instead of a traceback when the user D-Bus
|
||||
# session is unreachable (fresh SSH shell, no linger, container, etc.).
|
||||
print_error("User systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _gateway_command_inner(args):
|
||||
subcmd = getattr(args, 'gateway_command', None)
|
||||
|
||||
# Default to run if no subcommand
|
||||
@@ -4082,13 +3760,12 @@ def _gateway_command_inner(args):
|
||||
|
||||
elif subcmd == "status":
|
||||
deep = getattr(args, 'deep', False)
|
||||
full = getattr(args, 'full', False)
|
||||
system = getattr(args, 'system', False)
|
||||
snapshot = get_gateway_runtime_snapshot(system=system)
|
||||
|
||||
# Check for service first
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
systemd_status(deep, system=system, full=full)
|
||||
systemd_status(deep, system=system)
|
||||
_print_gateway_process_mismatch(snapshot)
|
||||
elif is_macos() and get_launchd_plist_path().exists():
|
||||
launchd_status(deep)
|
||||
@@ -50,8 +50,8 @@ def hooks_command(args) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cmd_list(_args) -> None:
|
||||
from hermes_cli.config import load_config
|
||||
from agent import shell_hooks
|
||||
from hermes_agent.cli.config import load_config
|
||||
from hermes_agent.agent import shell_hooks
|
||||
|
||||
specs = shell_hooks.iter_configured_hooks(load_config())
|
||||
|
||||
@@ -186,9 +186,9 @@ _DEFAULT_PAYLOADS = {
|
||||
|
||||
|
||||
def _cmd_test(args) -> None:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.plugins import VALID_HOOKS
|
||||
from agent import shell_hooks
|
||||
from hermes_agent.cli.config import load_config
|
||||
from hermes_agent.cli.plugins import VALID_HOOKS
|
||||
from hermes_agent.agent import shell_hooks
|
||||
|
||||
event = args.event
|
||||
if event not in VALID_HOOKS:
|
||||
@@ -273,7 +273,7 @@ def _truncate(s: str, n: int) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cmd_revoke(args) -> None:
|
||||
from agent import shell_hooks
|
||||
from hermes_agent.agent import shell_hooks
|
||||
|
||||
removed = shell_hooks.revoke(args.command)
|
||||
if removed == 0:
|
||||
@@ -291,8 +291,8 @@ def _cmd_revoke(args) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cmd_doctor(_args) -> None:
|
||||
from hermes_cli.config import load_config
|
||||
from agent import shell_hooks
|
||||
from hermes_agent.cli.config import load_config
|
||||
from hermes_agent.agent import shell_hooks
|
||||
|
||||
specs = shell_hooks.iter_configured_hooks(load_config())
|
||||
|
||||
@@ -24,7 +24,7 @@ from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home, display_hermes_home
|
||||
|
||||
# Known log files (name → filename)
|
||||
LOG_FILES = {
|
||||
@@ -191,7 +191,7 @@ def tail_log(
|
||||
# Resolve component to logger name prefixes
|
||||
component_prefixes = None
|
||||
if component:
|
||||
from hermes_logging import COMPONENT_PREFIXES
|
||||
from hermes_agent.logging import COMPONENT_PREFIXES
|
||||
component_lower = component.lower()
|
||||
if component_lower not in COMPONENT_PREFIXES:
|
||||
available = ", ".join(sorted(COMPONENT_PREFIXES))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,15 @@ import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_cli.config import (
|
||||
from hermes_agent.cli.config import (
|
||||
load_config,
|
||||
save_config,
|
||||
get_env_value,
|
||||
save_env_value,
|
||||
get_hermes_home, # noqa: F401 — used by test mocks
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_agent.cli.ui.colors import Colors, color
|
||||
from hermes_agent.constants import display_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,7 +61,7 @@ def _confirm(question: str, default: bool = True) -> bool:
|
||||
|
||||
|
||||
def _prompt(question: str, *, password: bool = False, default: str = "") -> str:
|
||||
from hermes_cli.cli_output import prompt as _shared_prompt
|
||||
from hermes_agent.cli.ui.output import prompt as _shared_prompt
|
||||
return _shared_prompt(question, default=default, password=password)
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ def _probe_single_server(
|
||||
Returns list of ``(tool_name, description)`` tuples.
|
||||
Raises on connection failure.
|
||||
"""
|
||||
from tools.mcp_tool import (
|
||||
from hermes_agent.tools.mcp.tool import (
|
||||
_ensure_mcp_loop,
|
||||
_run_on_mcp_loop,
|
||||
_connect_server,
|
||||
@@ -279,7 +279,7 @@ def cmd_mcp_add(args):
|
||||
_info(f"Starting OAuth flow for '{name}'...")
|
||||
oauth_ok = False
|
||||
try:
|
||||
from tools.mcp_oauth_manager import get_manager
|
||||
from hermes_agent.tools.mcp.oauth_manager import get_manager
|
||||
oauth_auth = get_manager().get_or_build_provider(name, url, None)
|
||||
if oauth_auth:
|
||||
server_config["auth"] = "oauth"
|
||||
@@ -372,7 +372,7 @@ def cmd_mcp_add(args):
|
||||
|
||||
if choice in ("s", "select"):
|
||||
# Interactive tool selection
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
from hermes_agent.cli.ui.curses import curses_checklist
|
||||
|
||||
labels = [f"{t[0]} — {t[1]}" for t in tools]
|
||||
pre_selected = set(range(len(tools)))
|
||||
@@ -432,7 +432,7 @@ def cmd_mcp_remove(args):
|
||||
# any provider instance cached in the current process (e.g. from an
|
||||
# earlier `hermes mcp test` in the same session) is evicted too.
|
||||
try:
|
||||
from tools.mcp_oauth_manager import get_manager
|
||||
from hermes_agent.tools.mcp.oauth_manager import get_manager
|
||||
get_manager().remove(name)
|
||||
_success("Cleaned up OAuth tokens")
|
||||
except Exception:
|
||||
@@ -616,7 +616,7 @@ def cmd_mcp_login(args):
|
||||
# Wipe both disk and in-memory cache so the next probe forces a fresh
|
||||
# OAuth flow.
|
||||
try:
|
||||
from tools.mcp_oauth_manager import get_manager
|
||||
from hermes_agent.tools.mcp.oauth_manager import get_manager
|
||||
mgr = get_manager()
|
||||
mgr.remove(name)
|
||||
except Exception as exc:
|
||||
@@ -700,7 +700,7 @@ def cmd_mcp_configure(args):
|
||||
print()
|
||||
|
||||
# Interactive checklist
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
from hermes_agent.cli.ui.curses import curses_checklist
|
||||
|
||||
labels = [f"{t[0]} — {t[1]}" for t in all_tools]
|
||||
|
||||
@@ -742,7 +742,7 @@ def mcp_command(args):
|
||||
action = getattr(args, "mcp_action", None)
|
||||
|
||||
if action == "serve":
|
||||
from mcp_serve import run_mcp_server
|
||||
from hermes_agent.tools.mcp.serve import run_mcp_server
|
||||
run_mcp_server(verbose=getattr(args, "verbose", False))
|
||||
return
|
||||
|
||||
@@ -12,7 +12,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -25,7 +25,7 @@ def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -
|
||||
items: list of (label, description) tuples.
|
||||
Returns selected index, or default on escape/quit.
|
||||
"""
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
from hermes_agent.cli.ui.curses import curses_radiolist
|
||||
# Format (label, desc) tuples into display strings
|
||||
display_items = [
|
||||
f"{label} {desc}" if desc else label
|
||||
@@ -58,7 +58,7 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str
|
||||
def _install_dependencies(provider_name: str) -> None:
|
||||
"""Install pip dependencies declared in plugin.yaml."""
|
||||
import subprocess
|
||||
from plugins.memory import find_provider_dir
|
||||
from hermes_agent.plugins.memory import find_provider_dir
|
||||
|
||||
plugin_dir = find_provider_dir(provider_name)
|
||||
if not plugin_dir:
|
||||
@@ -148,7 +148,7 @@ def _get_available_providers() -> list:
|
||||
Returns list of (name, description, provider_instance) tuples.
|
||||
"""
|
||||
try:
|
||||
from plugins.memory import discover_memory_providers, load_memory_provider
|
||||
from hermes_agent.plugins.memory import discover_memory_providers, load_memory_provider
|
||||
raw = discover_memory_providers()
|
||||
except Exception:
|
||||
raw = []
|
||||
@@ -184,7 +184,7 @@ def _get_available_providers() -> list:
|
||||
|
||||
def cmd_setup_provider(provider_name: str) -> None:
|
||||
"""Run memory setup for a specific provider, skipping the picker."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_agent.cli.config import load_config, save_config
|
||||
|
||||
providers = _get_available_providers()
|
||||
match = None
|
||||
@@ -220,7 +220,7 @@ def cmd_setup_provider(provider_name: str) -> None:
|
||||
|
||||
def cmd_setup(args) -> None:
|
||||
"""Interactive memory provider setup wizard."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_agent.cli.config import load_config, save_config
|
||||
|
||||
providers = _get_available_providers()
|
||||
|
||||
@@ -386,7 +386,7 @@ def _write_env_vars(env_path: Path, env_writes: dict) -> None:
|
||||
|
||||
def cmd_status(args) -> None:
|
||||
"""Show current memory provider config."""
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
mem_config = config.get("memory", {})
|
||||
0
hermes_agent/cli/models/__init__.py
Normal file
0
hermes_agent/cli/models/__init__.py
Normal file
@@ -16,7 +16,7 @@ from difflib import get_close_matches
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple, Optional
|
||||
|
||||
from hermes_cli import __version__ as _HERMES_VERSION
|
||||
from hermes_agent.cli import __version__ as _HERMES_VERSION
|
||||
|
||||
# Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity
|
||||
# Check (error 1010) don't reject the default ``Python-urllib/*`` signature.
|
||||
@@ -42,8 +42,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("openrouter/elephant-alpha", "free"),
|
||||
("openai/gpt-5.4", ""),
|
||||
("openai/gpt-5.4-mini", ""),
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
("xiaomi/mimo-v2.5", ""),
|
||||
("xiaomi/mimo-v2-pro", ""),
|
||||
("openai/gpt-5.3-codex", ""),
|
||||
("google/gemini-3-pro-image-preview", ""),
|
||||
("google/gemini-3-flash-preview", ""),
|
||||
@@ -102,15 +101,14 @@ def _codex_curated_models() -> list[str]:
|
||||
This keeps the gateway /model picker in sync with the CLI `hermes model`
|
||||
flow without maintaining a separate static list.
|
||||
"""
|
||||
from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, _add_forward_compat_models
|
||||
from hermes_agent.cli.models.codex import DEFAULT_CODEX_MODELS, _add_forward_compat_models
|
||||
return _add_forward_compat_models(list(DEFAULT_CODEX_MODELS))
|
||||
|
||||
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
"moonshotai/kimi-k2.6",
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
"xiaomi/mimo-v2.5",
|
||||
"xiaomi/mimo-v2-pro",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
@@ -212,10 +210,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"kimi-k2-turbo-preview",
|
||||
"kimi-k2-0905-preview",
|
||||
],
|
||||
"stepfun": [
|
||||
"step-3.5-flash",
|
||||
"step-3.5-flash-2603",
|
||||
],
|
||||
"moonshot": [
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
@@ -494,7 +488,7 @@ def check_nous_free_tier() -> bool:
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state, resolve_nous_runtime_credentials
|
||||
from hermes_agent.cli.auth.auth import get_provider_auth_state, resolve_nous_runtime_credentials
|
||||
|
||||
# Ensure we have a fresh token (triggers refresh if needed)
|
||||
resolve_nous_runtime_credentials(min_key_ttl_seconds=60)
|
||||
@@ -589,7 +583,7 @@ def fetch_nous_recommended_models(
|
||||
def _resolve_nous_portal_url() -> str:
|
||||
"""Best-effort lookup of the Portal base URL the user is authed against."""
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
from hermes_agent.cli.auth.auth import (
|
||||
DEFAULT_NOUS_PORTAL_URL,
|
||||
get_provider_auth_state,
|
||||
)
|
||||
@@ -705,7 +699,6 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"),
|
||||
ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com) & Moonshot API"),
|
||||
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"),
|
||||
ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (agent/coding models via Step Plan API)"),
|
||||
ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"),
|
||||
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"),
|
||||
ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
@@ -740,8 +733,6 @@ _PROVIDER_ALIASES = {
|
||||
"moonshot": "kimi-coding",
|
||||
"kimi-cn": "kimi-coding-cn",
|
||||
"moonshot-cn": "kimi-coding-cn",
|
||||
"step": "stepfun",
|
||||
"stepfun-coding-plan": "stepfun",
|
||||
"arcee-ai": "arcee",
|
||||
"arceeai": "arcee",
|
||||
"minimax-china": "minimax-cn",
|
||||
@@ -921,7 +912,7 @@ def fetch_ai_gateway_models(
|
||||
if _ai_gateway_catalog_cache is not None and not force_refresh:
|
||||
return list(_ai_gateway_catalog_cache)
|
||||
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL
|
||||
from hermes_agent.constants import AI_GATEWAY_BASE_URL
|
||||
|
||||
fallback = list(VERCEL_AI_GATEWAY_MODELS)
|
||||
preferred_ids = [mid for mid, _ in fallback]
|
||||
@@ -1142,7 +1133,7 @@ def fetch_ai_gateway_pricing(
|
||||
``prompt`` / ``completion``. This translates. Cache read/write field names
|
||||
already match.
|
||||
"""
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL
|
||||
from hermes_agent.constants import AI_GATEWAY_BASE_URL
|
||||
|
||||
cache_key = AI_GATEWAY_BASE_URL.rstrip("/")
|
||||
if not force_refresh and cache_key in _pricing_cache:
|
||||
@@ -1189,7 +1180,7 @@ def _resolve_openrouter_api_key() -> str:
|
||||
def _resolve_nous_pricing_credentials() -> tuple[str, str]:
|
||||
"""Return ``(api_key, base_url)`` for Nous Portal pricing, or empty strings."""
|
||||
try:
|
||||
from hermes_cli.auth import resolve_nous_runtime_credentials
|
||||
from hermes_agent.cli.auth.auth import resolve_nous_runtime_credentials
|
||||
creds = resolve_nous_runtime_credentials()
|
||||
if creds:
|
||||
return (creds.get("api_key", ""), creds.get("base_url", ""))
|
||||
@@ -1257,7 +1248,7 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||
# Check if this provider has credentials available
|
||||
has_creds = False
|
||||
try:
|
||||
from hermes_cli.auth import get_auth_status, has_usable_secret
|
||||
from hermes_agent.cli.auth.auth import get_auth_status, has_usable_secret
|
||||
if pid == "custom":
|
||||
custom_base_url = _get_custom_base_url() or ""
|
||||
has_creds = bool(custom_base_url.strip())
|
||||
@@ -1316,7 +1307,7 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
|
||||
def _get_custom_base_url() -> str:
|
||||
"""Get the custom endpoint base_url from config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
config = load_config()
|
||||
model_cfg = config.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
@@ -1410,7 +1401,7 @@ def detect_provider_for_model(
|
||||
# credential pool, or auth store entries.
|
||||
has_creds = False
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_agent.cli.auth.auth import PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY.get(direct_match)
|
||||
if pconfig:
|
||||
for env_var in pconfig.api_key_env_vars:
|
||||
@@ -1423,7 +1414,7 @@ def detect_provider_for_model(
|
||||
# Claude Code tokens, and other non-env-var credentials (#10300).
|
||||
if not has_creds:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_agent.providers.credential_pool import load_pool
|
||||
pool = load_pool(direct_match)
|
||||
if pool.has_credentials():
|
||||
has_creds = True
|
||||
@@ -1431,7 +1422,7 @@ def detect_provider_for_model(
|
||||
pass
|
||||
if not has_creds:
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
from hermes_agent.cli.auth.auth import _load_auth_store
|
||||
store = _load_auth_store()
|
||||
if direct_match in store.get("providers", {}) or direct_match in store.get("credential_pool", {}):
|
||||
has_creds = True
|
||||
@@ -1581,7 +1572,7 @@ def resolve_fast_mode_overrides(model_id: Optional[str]) -> dict[str, Any] | Non
|
||||
def _resolve_copilot_catalog_api_key() -> str:
|
||||
"""Best-effort GitHub token for fetching the Copilot model catalog."""
|
||||
try:
|
||||
from hermes_cli.auth import resolve_api_key_provider_credentials
|
||||
from hermes_agent.cli.auth.auth import resolve_api_key_provider_credentials
|
||||
|
||||
creds = resolve_api_key_provider_credentials("copilot")
|
||||
return str(creds.get("api_key") or "").strip()
|
||||
@@ -1589,90 +1580,17 @@ def _resolve_copilot_catalog_api_key() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# Providers where models.dev is treated as authoritative: curated static
|
||||
# lists are kept only as an offline fallback and to capture custom additions
|
||||
# the registry doesn't publish yet. Adding a provider here causes its
|
||||
# curated list to be merged with fresh models.dev entries (fresh first, any
|
||||
# curated-only names appended) for both the CLI and the gateway /model picker.
|
||||
#
|
||||
# DELIBERATELY EXCLUDED:
|
||||
# - "openrouter": curated list is already a hand-picked agentic subset of
|
||||
# OpenRouter's 400+ catalog. Blindly merging would dump everything.
|
||||
# - "nous": curated list and Portal /models endpoint are the source of
|
||||
# truth for the subscription tier.
|
||||
# Also excluded: providers that already have dedicated live-endpoint
|
||||
# branches below (copilot, anthropic, ai-gateway, ollama-cloud, custom,
|
||||
# stepfun, openai-codex) — those paths handle freshness themselves.
|
||||
_MODELS_DEV_PREFERRED: frozenset[str] = frozenset({
|
||||
"opencode-go",
|
||||
"opencode-zen",
|
||||
"deepseek",
|
||||
"kilocode",
|
||||
"fireworks",
|
||||
"mistral",
|
||||
"togetherai",
|
||||
"cohere",
|
||||
"perplexity",
|
||||
"groq",
|
||||
"nvidia",
|
||||
"huggingface",
|
||||
"zai",
|
||||
"gemini",
|
||||
"google",
|
||||
})
|
||||
|
||||
|
||||
def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:
|
||||
"""Merge curated list with fresh models.dev entries for a preferred provider.
|
||||
|
||||
Returns models.dev entries first (in models.dev order), then any
|
||||
curated-only entries appended. Preserves case for curated fallbacks
|
||||
(e.g. ``MiniMax-M2.7``) while trusting models.dev for newer variants.
|
||||
|
||||
If models.dev is unreachable or returns nothing, the curated list is
|
||||
returned unchanged — this is the offline/CI fallback path.
|
||||
"""
|
||||
try:
|
||||
from agent.models_dev import list_agentic_models
|
||||
mdev = list_agentic_models(provider)
|
||||
except Exception:
|
||||
mdev = []
|
||||
|
||||
if not mdev:
|
||||
return list(curated)
|
||||
|
||||
# Case-insensitive dedup while preserving order and curated casing.
|
||||
seen_lower: set[str] = set()
|
||||
merged: list[str] = []
|
||||
for mid in mdev:
|
||||
key = str(mid).lower()
|
||||
if key in seen_lower:
|
||||
continue
|
||||
seen_lower.add(key)
|
||||
merged.append(mid)
|
||||
for mid in curated:
|
||||
key = str(mid).lower()
|
||||
if key in seen_lower:
|
||||
continue
|
||||
seen_lower.add(key)
|
||||
merged.append(mid)
|
||||
return merged
|
||||
|
||||
|
||||
def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
|
||||
"""Return the best known model catalog for a provider.
|
||||
|
||||
Tries live API endpoints for providers that support them (Codex, Nous),
|
||||
falling back to static lists. For providers in ``_MODELS_DEV_PREFERRED``
|
||||
(opencode-go/zen, xiaomi, deepseek, smaller inference providers, etc.),
|
||||
models.dev entries are merged on top of curated so new models released
|
||||
on the platform appear in ``/model`` without a Hermes release.
|
||||
falling back to static lists.
|
||||
"""
|
||||
normalized = normalize_provider(provider)
|
||||
if normalized == "openrouter":
|
||||
return model_ids(force_refresh=force_refresh)
|
||||
if normalized == "openai-codex":
|
||||
from hermes_cli.codex_models import get_codex_model_ids
|
||||
from hermes_agent.cli.models.codex import get_codex_model_ids
|
||||
|
||||
return get_codex_model_ids()
|
||||
if normalized in {"copilot", "copilot-acp"}:
|
||||
@@ -1687,7 +1605,7 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
if normalized == "nous":
|
||||
# Try live Nous Portal /models endpoint
|
||||
try:
|
||||
from hermes_cli.auth import fetch_nous_models, resolve_nous_runtime_credentials
|
||||
from hermes_agent.cli.auth.auth import fetch_nous_models, resolve_nous_runtime_credentials
|
||||
creds = resolve_nous_runtime_credentials()
|
||||
if creds:
|
||||
live = fetch_nous_models(api_key=creds.get("api_key", ""), inference_base_url=creds.get("base_url", ""))
|
||||
@@ -1695,19 +1613,6 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
return live
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "stepfun":
|
||||
try:
|
||||
from hermes_cli.auth import resolve_api_key_provider_credentials
|
||||
|
||||
creds = resolve_api_key_provider_credentials("stepfun")
|
||||
api_key = str(creds.get("api_key") or "").strip()
|
||||
base_url = str(creds.get("base_url") or "").strip()
|
||||
if api_key and base_url:
|
||||
live = fetch_api_models(api_key, base_url)
|
||||
if live:
|
||||
return live
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "anthropic":
|
||||
live = _fetch_anthropic_models()
|
||||
if live:
|
||||
@@ -1732,10 +1637,7 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
live = fetch_api_models(api_key, base_url)
|
||||
if live:
|
||||
return live
|
||||
curated_static = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
if normalized in _MODELS_DEV_PREFERRED:
|
||||
return _merge_with_models_dev(normalized, curated_static)
|
||||
return curated_static
|
||||
return list(_PROVIDER_MODELS.get(normalized, []))
|
||||
|
||||
|
||||
def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
@@ -1745,7 +1647,7 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
Claude Code auto-discovery). Returns sorted model IDs or None.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
|
||||
from hermes_agent.providers.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
@@ -1756,7 +1658,7 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
|
||||
if _is_oauth_token(token):
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
from hermes_agent.providers.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
||||
else:
|
||||
headers["x-api-key"] = token
|
||||
@@ -1799,7 +1701,7 @@ def copilot_default_headers() -> dict[str, str]:
|
||||
Copilot CLI send on every request.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.copilot_auth import copilot_request_headers
|
||||
from hermes_agent.cli.auth.copilot import copilot_request_headers
|
||||
return copilot_request_headers(is_agent_turn=True)
|
||||
except ImportError:
|
||||
return {
|
||||
@@ -2215,7 +2117,7 @@ def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
return None
|
||||
base_url = os.getenv("AI_GATEWAY_BASE_URL", "").strip()
|
||||
if not base_url:
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL
|
||||
from hermes_agent.constants import AI_GATEWAY_BASE_URL
|
||||
base_url = AI_GATEWAY_BASE_URL
|
||||
|
||||
url = base_url.rstrip("/") + "/models"
|
||||
@@ -2259,7 +2161,7 @@ _OLLAMA_CLOUD_CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
def _ollama_cloud_cache_path() -> Path:
|
||||
"""Return the path for the Ollama Cloud model cache."""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
return get_hermes_home() / "ollama_cloud_models_cache.json"
|
||||
|
||||
|
||||
@@ -2293,7 +2195,7 @@ def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]:
|
||||
def _save_ollama_cloud_cache(models: list[str]) -> None:
|
||||
"""Persist the merged Ollama Cloud model list to disk."""
|
||||
try:
|
||||
from utils import atomic_json_write
|
||||
from hermes_agent.utils import atomic_json_write
|
||||
cache_path = _ollama_cloud_cache_path()
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_json_write(cache_path, {"models": models, "cached_at": time.time()}, indent=None)
|
||||
@@ -2338,7 +2240,7 @@ def fetch_ollama_cloud_models(
|
||||
# 3. models.dev registry
|
||||
mdev_models: list[str] = []
|
||||
try:
|
||||
from agent.models_dev import list_agentic_models
|
||||
from hermes_agent.providers.metadata_dev import list_agentic_models
|
||||
mdev_models = list_agentic_models("ollama-cloud")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -2608,7 +2510,7 @@ def validate_requested_model(
|
||||
# AWS SDK control plane (ListFoundationModels + ListInferenceProfiles).
|
||||
if normalized == "bedrock":
|
||||
try:
|
||||
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region
|
||||
from hermes_agent.providers.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region
|
||||
region = resolve_bedrock_region()
|
||||
discovered = discover_bedrock_models(region)
|
||||
discovered_ids = {m["id"] for m in discovered}
|
||||
@@ -184,7 +184,7 @@ def _normalize_provider_alias(provider_name: str) -> str:
|
||||
if not raw:
|
||||
return raw
|
||||
try:
|
||||
from hermes_cli.models import normalize_provider
|
||||
from hermes_agent.cli.models.models import normalize_provider
|
||||
|
||||
return normalize_provider(raw)
|
||||
except Exception:
|
||||
@@ -382,7 +382,7 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
|
||||
# HTTP 400 "model_not_supported". See issue #6879.
|
||||
if provider in {"copilot", "copilot-acp"}:
|
||||
try:
|
||||
from hermes_cli.models import normalize_copilot_model_id
|
||||
from hermes_agent.cli.models.models import normalize_copilot_model_id
|
||||
|
||||
normalized = normalize_copilot_model_id(name)
|
||||
if normalized:
|
||||
@@ -25,17 +25,17 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
from hermes_cli.providers import (
|
||||
from hermes_agent.cli.providers import (
|
||||
custom_provider_slug,
|
||||
determine_api_mode,
|
||||
get_label,
|
||||
is_aggregator,
|
||||
resolve_provider_full,
|
||||
)
|
||||
from hermes_cli.model_normalize import (
|
||||
from hermes_agent.cli.models.normalize import (
|
||||
normalize_model_for_provider,
|
||||
)
|
||||
from agent.models_dev import (
|
||||
from hermes_agent.providers.metadata_dev import (
|
||||
ModelCapabilities,
|
||||
ModelInfo,
|
||||
get_model_capabilities,
|
||||
@@ -143,7 +143,7 @@ MODEL_ALIASES: dict[str, ModelIdentity] = {
|
||||
# Z.AI / GLM
|
||||
"glm": ModelIdentity("z-ai", "glm"),
|
||||
|
||||
# Step Plan (StepFun)
|
||||
# StepFun
|
||||
"step": ModelIdentity("stepfun", "step"),
|
||||
|
||||
# Xiaomi
|
||||
@@ -193,7 +193,7 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:
|
||||
"""
|
||||
merged = dict(_BUILTIN_DIRECT_ALIASES)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
cfg = load_config()
|
||||
user_aliases = cfg.get("model_aliases")
|
||||
if isinstance(user_aliases, dict):
|
||||
@@ -456,13 +456,13 @@ def switch_model(
|
||||
Returns:
|
||||
ModelSwitchResult with all information the caller needs.
|
||||
"""
|
||||
from hermes_cli.models import (
|
||||
from hermes_agent.cli.models.models import (
|
||||
copilot_model_api_mode,
|
||||
detect_provider_for_model,
|
||||
validate_requested_model,
|
||||
opencode_model_api_mode,
|
||||
)
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
from hermes_agent.cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
resolved_alias = ""
|
||||
new_model = raw_input.strip()
|
||||
@@ -486,7 +486,7 @@ def switch_model(
|
||||
)
|
||||
# Check for common config issues that cause provider resolution failures
|
||||
try:
|
||||
from hermes_cli.config import validate_config_structure
|
||||
from hermes_agent.cli.config import validate_config_structure
|
||||
_cfg_issues = validate_config_structure()
|
||||
if _cfg_issues:
|
||||
_switch_err += "\n\nRun 'hermes doctor' — config issues detected:"
|
||||
@@ -505,7 +505,7 @@ def switch_model(
|
||||
# If no model specified, try auto-detect from endpoint
|
||||
if not new_model:
|
||||
if pdef.base_url:
|
||||
from hermes_cli.runtime_provider import _auto_detect_local_model
|
||||
from hermes_agent.cli.runtime_provider import _auto_detect_local_model
|
||||
detected = _auto_detect_local_model(pdef.base_url)
|
||||
if detected:
|
||||
new_model = detected
|
||||
@@ -782,7 +782,6 @@ def switch_model(
|
||||
|
||||
def list_authenticated_providers(
|
||||
current_provider: str = "",
|
||||
current_base_url: str = "",
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
max_models: int = 8,
|
||||
@@ -805,16 +804,13 @@ def list_authenticated_providers(
|
||||
Only includes providers that have API keys set or are user-defined endpoints.
|
||||
"""
|
||||
import os
|
||||
from agent.models_dev import (
|
||||
from hermes_agent.providers.metadata_dev import (
|
||||
PROVIDER_TO_MODELS_DEV,
|
||||
fetch_models_dev,
|
||||
get_provider_info as _mdev_pinfo,
|
||||
)
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_cli.models import (
|
||||
OPENROUTER_MODELS, _PROVIDER_MODELS,
|
||||
_MODELS_DEV_PREFERRED, _merge_with_models_dev,
|
||||
)
|
||||
from hermes_agent.cli.auth.auth import PROVIDER_REGISTRY
|
||||
from hermes_agent.cli.models.models import OPENROUTER_MODELS, _PROVIDER_MODELS
|
||||
|
||||
results: List[dict] = []
|
||||
seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545)
|
||||
@@ -830,7 +826,7 @@ def list_authenticated_providers(
|
||||
curated["nous"] = curated["openrouter"]
|
||||
# Ollama Cloud uses dynamic discovery (no static curated list)
|
||||
if "ollama-cloud" not in curated:
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
from hermes_agent.cli.models.models import fetch_ollama_cloud_models
|
||||
curated["ollama-cloud"] = fetch_ollama_cloud_models()
|
||||
|
||||
# --- 1. Check Hermes-mapped providers ---
|
||||
@@ -848,10 +844,6 @@ def list_authenticated_providers(
|
||||
# source of truth. models.dev can have wrong mappings (e.g.
|
||||
# minimax-cn → MINIMAX_API_KEY instead of MINIMAX_CN_API_KEY).
|
||||
pconfig = PROVIDER_REGISTRY.get(hermes_id)
|
||||
# Skip non-API-key auth providers here — they are handled in
|
||||
# section 2 (HERMES_OVERLAYS) with proper auth store checking.
|
||||
if pconfig and pconfig.auth_type != "api_key":
|
||||
continue
|
||||
if pconfig and pconfig.api_key_env_vars:
|
||||
env_vars = list(pconfig.api_key_env_vars)
|
||||
else:
|
||||
@@ -864,13 +856,8 @@ def list_authenticated_providers(
|
||||
if not has_creds:
|
||||
continue
|
||||
|
||||
# Use curated list, falling back to models.dev if no curated list.
|
||||
# For preferred providers, merge models.dev entries into the curated
|
||||
# catalog so newly released models (e.g. mimo-v2.5-pro on opencode-go)
|
||||
# show up in the picker without requiring a Hermes release.
|
||||
# Use curated list, falling back to models.dev if no curated list
|
||||
model_ids = curated.get(hermes_id, [])
|
||||
if hermes_id in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_id, model_ids)
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
@@ -891,8 +878,8 @@ def list_authenticated_providers(
|
||||
seen_mdev_ids.add(mdev_id)
|
||||
|
||||
# --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) ---
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY as _auth_registry
|
||||
from hermes_agent.cli.providers import HERMES_OVERLAYS
|
||||
from hermes_agent.cli.auth.auth import PROVIDER_REGISTRY as _auth_registry
|
||||
|
||||
# Build reverse mapping: models.dev ID → Hermes provider ID.
|
||||
# HERMES_OVERLAYS keys may be models.dev IDs (e.g. "github-copilot")
|
||||
@@ -926,7 +913,7 @@ def list_authenticated_providers(
|
||||
# OAuth via external credential files).
|
||||
if not has_creds:
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
from hermes_agent.cli.auth.auth import _load_auth_store
|
||||
store = _load_auth_store()
|
||||
providers_store = store.get("providers", {})
|
||||
pool_store = store.get("credential_pool", {})
|
||||
@@ -943,7 +930,7 @@ def list_authenticated_providers(
|
||||
# imports on demand but aren't in the raw auth.json yet.
|
||||
if not has_creds:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_agent.providers.credential_pool import load_pool
|
||||
pool = load_pool(hermes_slug)
|
||||
if pool.has_credentials():
|
||||
has_creds = True
|
||||
@@ -958,7 +945,7 @@ def list_authenticated_providers(
|
||||
# configured.
|
||||
if not has_creds and hermes_slug == "anthropic":
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
from hermes_agent.providers.anthropic_adapter import (
|
||||
read_claude_code_credentials,
|
||||
read_hermes_oauth_credentials,
|
||||
)
|
||||
@@ -974,9 +961,6 @@ def list_authenticated_providers(
|
||||
|
||||
# Use curated list — look up by Hermes slug, fall back to overlay key
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
# Merge with models.dev for preferred providers (same rationale as above).
|
||||
if hermes_slug in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
@@ -997,7 +981,7 @@ def list_authenticated_providers(
|
||||
# in PROVIDER_TO_MODELS_DEV or HERMES_OVERLAYS (keeps /model in sync
|
||||
# with `hermes model`).
|
||||
try:
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS as _canon_provs
|
||||
from hermes_agent.cli.models.models import CANONICAL_PROVIDERS as _canon_provs
|
||||
except ImportError:
|
||||
_canon_provs = []
|
||||
|
||||
@@ -1013,7 +997,7 @@ def list_authenticated_providers(
|
||||
# Also check auth store and credential pool
|
||||
if not _cp_has_creds:
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
from hermes_agent.cli.auth.auth import _load_auth_store
|
||||
_cp_store = _load_auth_store()
|
||||
_cp_providers_store = _cp_store.get("providers", {})
|
||||
_cp_pool_store = _cp_store.get("credential_pool", {})
|
||||
@@ -1026,7 +1010,7 @@ def list_authenticated_providers(
|
||||
pass
|
||||
if not _cp_has_creds:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_agent.providers.credential_pool import load_pool
|
||||
_cp_pool = load_pool(_cp.slug)
|
||||
if _cp_pool.has_credentials():
|
||||
_cp_has_creds = True
|
||||
@@ -1122,113 +1106,66 @@ def list_authenticated_providers(
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
# provider. Entries sharing the same endpoint (``base_url`` + ``api_key``)
|
||||
# are grouped into a single picker row, so e.g. four Ollama entries
|
||||
# pointing at ``http://localhost:11434/v1`` with per-model display names
|
||||
# ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# "Ollama" row with four models inside instead of four near-duplicates
|
||||
# that differ only by suffix. Entries with distinct endpoints still
|
||||
# produce separate rows.
|
||||
#
|
||||
# When the grouped endpoint matches ``current_base_url`` the group's
|
||||
# slug becomes ``current_provider`` so that selecting a model from the
|
||||
# picker flows back through the runtime provider that already holds
|
||||
# valid credentials — no re-resolution needed.
|
||||
# provider. Entries sharing the same provider name are grouped into a
|
||||
# single picker row so that e.g. four Ollama Cloud entries
|
||||
# (qwen3-coder, glm-5.1, kimi-k2, minimax-m2.7) appear as one
|
||||
# "Ollama Cloud" row with four models inside instead of four
|
||||
# duplicate "Ollama Cloud" rows. Entries with distinct provider names
|
||||
# still produce separate rows (e.g. Ollama Cloud vs Moonshot).
|
||||
if custom_providers and isinstance(custom_providers, list):
|
||||
from collections import OrderedDict
|
||||
|
||||
# Key by (base_url, api_key) instead of slug: names frequently
|
||||
# differ per model ("Ollama — X") while the endpoint stays the
|
||||
# same. Slug-based grouping left them as separate rows.
|
||||
groups: "OrderedDict[tuple, dict]" = OrderedDict()
|
||||
groups: "OrderedDict[str, dict]" = OrderedDict()
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
raw_name = (entry.get("name") or "").strip()
|
||||
display_name = (entry.get("name") or "").strip()
|
||||
api_url = (
|
||||
entry.get("base_url", "")
|
||||
or entry.get("url", "")
|
||||
or entry.get("api", "")
|
||||
or ""
|
||||
).strip().rstrip("/")
|
||||
if not raw_name or not api_url:
|
||||
).strip()
|
||||
if not display_name or not api_url:
|
||||
continue
|
||||
api_key = (entry.get("api_key") or "").strip()
|
||||
|
||||
group_key = (api_url, api_key)
|
||||
if group_key not in groups:
|
||||
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
|
||||
# "Ollama" for the grouped row. Em dash is the convention
|
||||
# Hermes's own writer uses; a hyphen variant is accepted
|
||||
# for hand-edited configs.
|
||||
display_name = raw_name
|
||||
for sep in ("—", " - "):
|
||||
if sep in display_name:
|
||||
display_name = display_name.split(sep)[0].strip()
|
||||
break
|
||||
if not display_name:
|
||||
display_name = raw_name
|
||||
# If this endpoint matches the currently active one, use
|
||||
# ``current_provider`` as the slug so picker-driven switches
|
||||
# route through the live credential pipeline.
|
||||
if (
|
||||
current_base_url
|
||||
and api_url == current_base_url.strip().rstrip("/")
|
||||
):
|
||||
slug = current_provider or custom_provider_slug(display_name)
|
||||
else:
|
||||
slug = custom_provider_slug(display_name)
|
||||
groups[group_key] = {
|
||||
"slug": slug,
|
||||
slug = custom_provider_slug(display_name)
|
||||
if slug not in groups:
|
||||
groups[slug] = {
|
||||
"name": display_name,
|
||||
"api_url": api_url,
|
||||
"models": [],
|
||||
}
|
||||
|
||||
# The singular ``model:`` field only holds the currently
|
||||
# active model. Hermes's own writer (main.py::_save_custom_provider)
|
||||
# stores every configured model as a dict under ``models:``;
|
||||
# downstream readers (agent/models_dev.py, gateway/run.py,
|
||||
# run_agent.py, hermes_cli/config.py) already consume that dict.
|
||||
# The /model picker previously ignored it, so multi-model
|
||||
# custom providers appeared to have only the active model.
|
||||
default_model = (entry.get("model") or "").strip()
|
||||
if default_model and default_model not in groups[group_key]["models"]:
|
||||
groups[group_key]["models"].append(default_model)
|
||||
if default_model and default_model not in groups[slug]["models"]:
|
||||
groups[slug]["models"].append(default_model)
|
||||
|
||||
cfg_models = entry.get("models", {})
|
||||
if isinstance(cfg_models, dict):
|
||||
for m in cfg_models:
|
||||
if m and m not in groups[group_key]["models"]:
|
||||
groups[group_key]["models"].append(m)
|
||||
if m and m not in groups[slug]["models"]:
|
||||
groups[slug]["models"].append(m)
|
||||
elif isinstance(cfg_models, list):
|
||||
for m in cfg_models:
|
||||
if m and m not in groups[group_key]["models"]:
|
||||
groups[group_key]["models"].append(m)
|
||||
if m and m not in groups[slug]["models"]:
|
||||
groups[slug]["models"].append(m)
|
||||
|
||||
_section4_emitted_slugs: set = set()
|
||||
for grp in groups.values():
|
||||
slug = grp["slug"]
|
||||
# If the slug is already claimed by a built-in / overlay /
|
||||
# user-provider row (sections 1-3), skip this custom group
|
||||
# to avoid shadowing a real provider.
|
||||
if slug.lower() in seen_slugs and slug.lower() not in _section4_emitted_slugs:
|
||||
for slug, grp in groups.items():
|
||||
if slug.lower() in seen_slugs:
|
||||
continue
|
||||
# If a prior section-4 group already used this slug (two custom
|
||||
# endpoints with the same cleaned name — e.g. two OpenAI-
|
||||
# compatible gateways named identically with different keys),
|
||||
# append a counter so both rows stay visible in the picker.
|
||||
if slug.lower() in _section4_emitted_slugs:
|
||||
base_slug = slug
|
||||
n = 2
|
||||
while f"{base_slug}-{n}".lower() in seen_slugs:
|
||||
n += 1
|
||||
slug = f"{base_slug}-{n}"
|
||||
grp["slug"] = slug
|
||||
# Skip if section 3 already emitted this endpoint under its
|
||||
# ``providers:`` dict key — matches on (display_name, base_url).
|
||||
# Prevents two picker rows labelled identically when callers
|
||||
# pass both ``user_providers`` and a compatibility-merged
|
||||
# ``custom_providers`` list.
|
||||
# ``providers:`` dict key — matches on (display_name, base_url),
|
||||
# the tuple section 4 groups by. Prevents two picker rows
|
||||
# labelled identically when callers pass both ``user_providers``
|
||||
# and a compatibility-merged ``custom_providers`` list.
|
||||
_pair_key = (
|
||||
str(grp["name"]).strip().lower(),
|
||||
str(grp["api_url"]).strip().rstrip("/").lower(),
|
||||
@@ -1246,7 +1183,6 @@ def list_authenticated_providers(
|
||||
"api_url": grp["api_url"],
|
||||
})
|
||||
seen_slugs.add(slug.lower())
|
||||
_section4_emitted_slugs.add(slug.lower())
|
||||
|
||||
# Sort: current provider first, then by model count descending
|
||||
results.sort(key=lambda r: (not r["is_current"], -r["total_models"]))
|
||||
@@ -6,10 +6,10 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Optional, Set
|
||||
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.config import get_env_value, load_config
|
||||
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||
from tools.tool_backend_helpers import (
|
||||
from hermes_agent.cli.auth.auth import get_nous_auth_status
|
||||
from hermes_agent.cli.config import get_env_value, load_config
|
||||
from hermes_agent.tools.managed_gateway import is_managed_tool_gateway_ready
|
||||
from hermes_agent.tools.backend_helpers import (
|
||||
fal_key_is_configured,
|
||||
has_direct_modal_credentials,
|
||||
managed_nous_tools_enabled,
|
||||
@@ -82,7 +82,7 @@ def _model_config_dict(config: Dict[str, object]) -> Dict[str, object]:
|
||||
|
||||
|
||||
def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool:
|
||||
from toolsets import resolve_toolset
|
||||
from hermes_agent.tools.toolsets import resolve_toolset
|
||||
|
||||
platform_toolsets = config.get("platform_toolsets")
|
||||
if not isinstance(platform_toolsets, dict) or not platform_toolsets:
|
||||
@@ -123,7 +123,7 @@ def _has_agent_browser() -> bool:
|
||||
|
||||
agent_browser_bin = shutil.which("agent-browser")
|
||||
local_bin = (
|
||||
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
|
||||
Path(__file__).resolve().parents[2] / "node_modules" / ".bin" / "agent-browser"
|
||||
)
|
||||
return bool(agent_browser_bin or local_bin.exists())
|
||||
|
||||
@@ -688,7 +688,7 @@ def prompt_enable_tool_gateway(config: Dict[str, object]) -> set[str]:
|
||||
return set()
|
||||
|
||||
try:
|
||||
from hermes_cli.setup import prompt_choice
|
||||
from hermes_agent.cli.setup_wizard import prompt_choice
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
@@ -766,7 +766,7 @@ def prompt_enable_tool_gateway(config: Dict[str, object]) -> set[str]:
|
||||
|
||||
changed = apply_gateway_defaults(config, to_apply)
|
||||
if changed:
|
||||
from hermes_cli.config import save_config
|
||||
from hermes_agent.cli.config import save_config
|
||||
save_config(config)
|
||||
# Only report the tools that actually switched (not already-managed ones)
|
||||
newly_switched = changed - set(already_managed)
|
||||
@@ -10,7 +10,7 @@ Usage:
|
||||
|
||||
def pairing_command(args):
|
||||
"""Handle hermes pairing subcommands."""
|
||||
from gateway.pairing import PairingStore
|
||||
from hermes_agent.gateway.pairing import PairingStore
|
||||
|
||||
store = PairingStore()
|
||||
action = getattr(args, "pairing_action", None)
|
||||
@@ -44,7 +44,7 @@ def _cmd_list(store):
|
||||
for p in pending:
|
||||
print(
|
||||
f" {p['platform']:<12} {p['code']:<10} {p['user_id']:<20} "
|
||||
f"{(p.get('user_name') or ''):<20} {p['age_minutes']}m ago"
|
||||
f"{p.get('user_name', ''):<20} {p['age_minutes']}m ago"
|
||||
)
|
||||
else:
|
||||
print("\n No pending pairing requests.")
|
||||
@@ -54,7 +54,7 @@ def _cmd_list(store):
|
||||
print(f" {'Platform':<12} {'User ID':<20} {'Name':<20}")
|
||||
print(f" {'--------':<12} {'-------':<20} {'----':<20}")
|
||||
for a in approved:
|
||||
print(f" {a['platform']:<12} {a['user_id']:<20} {(a.get('user_name') or ''):<20}")
|
||||
print(f" {a['platform']:<12} {a['user_id']:<20} {a.get('user_name', ''):<20}")
|
||||
else:
|
||||
print("\n No approved users.")
|
||||
|
||||
@@ -69,7 +69,7 @@ def _cmd_approve(store, platform: str, code: str):
|
||||
result = store.approve_code(platform, code)
|
||||
if result:
|
||||
uid = result["user_id"]
|
||||
name = result.get("user_name") or ""
|
||||
name = result.get("user_name", "")
|
||||
display = f"{name} ({uid})" if name else uid
|
||||
print(f"\n Approved! User {display} on {platform} can now use the bot~")
|
||||
print(" They'll be recognized automatically on their next message.\n")
|
||||
@@ -43,8 +43,8 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import env_var_enabled
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
from hermes_agent.utils import env_var_enabled
|
||||
|
||||
try:
|
||||
import yaml
|
||||
@@ -73,7 +73,7 @@ VALID_HOOKS: Set[str] = {
|
||||
"subagent_stop",
|
||||
}
|
||||
|
||||
ENTRY_POINTS_GROUP = "hermes_agent.plugins"
|
||||
ENTRY_POINTS_GROUP = "plugins"
|
||||
|
||||
_NS_PARENT = "hermes_plugins"
|
||||
|
||||
@@ -91,7 +91,7 @@ def _get_disabled_plugins() -> set:
|
||||
``plugins.enabled``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
@@ -114,7 +114,7 @@ def _get_enabled_plugins() -> Optional[set]:
|
||||
* ``set(...)`` — the concrete allow-list.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
config = load_config()
|
||||
plugins_cfg = config.get("plugins")
|
||||
if not isinstance(plugins_cfg, dict):
|
||||
@@ -207,7 +207,7 @@ class PluginContext:
|
||||
emoji: str = "",
|
||||
) -> None:
|
||||
"""Register a tool in the global registry **and** track it as plugin-provided."""
|
||||
from tools.registry import registry
|
||||
from hermes_agent.tools.registry import registry
|
||||
|
||||
registry.register(
|
||||
name=name,
|
||||
@@ -283,7 +283,6 @@ class PluginContext:
|
||||
name: str,
|
||||
handler: Callable,
|
||||
description: str = "",
|
||||
args_hint: str = "",
|
||||
) -> None:
|
||||
"""Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions.
|
||||
|
||||
@@ -294,13 +293,6 @@ class PluginContext:
|
||||
terminal commands), this registers in-session slash commands that users
|
||||
invoke during a conversation.
|
||||
|
||||
``args_hint`` is an optional short string (e.g. ``"<file>"`` or
|
||||
``"dias:7 formato:json"``) used by gateway adapters to surface the
|
||||
command with an argument field — for example Discord's native slash
|
||||
command picker. Plugin commands without ``args_hint`` register as
|
||||
parameterless in Discord and still accept trailing text when invoked
|
||||
as free-form chat.
|
||||
|
||||
Names conflicting with built-in commands are rejected with a warning.
|
||||
"""
|
||||
clean = name.lower().strip().lstrip("/").replace(" ", "-")
|
||||
@@ -313,7 +305,7 @@ class PluginContext:
|
||||
|
||||
# Reject if it conflicts with a built-in command
|
||||
try:
|
||||
from hermes_cli.commands import resolve_command
|
||||
from hermes_agent.cli.commands import resolve_command
|
||||
if resolve_command(clean) is not None:
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register command '/%s' which conflicts "
|
||||
@@ -328,7 +320,6 @@ class PluginContext:
|
||||
"handler": handler,
|
||||
"description": description or "Plugin command",
|
||||
"plugin": self.manifest.name,
|
||||
"args_hint": (args_hint or "").strip(),
|
||||
}
|
||||
logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean)
|
||||
|
||||
@@ -350,7 +341,7 @@ class PluginContext:
|
||||
Returns:
|
||||
JSON string from the tool handler (same format as model tool calls).
|
||||
"""
|
||||
from tools.registry import registry
|
||||
from hermes_agent.tools.registry import registry
|
||||
|
||||
# Wire up parent agent context when available (CLI mode).
|
||||
# In gateway mode _cli_ref is None — tools degrade gracefully
|
||||
@@ -381,7 +372,7 @@ class PluginContext:
|
||||
)
|
||||
return
|
||||
# Defer the import to avoid circular deps at module level
|
||||
from agent.context_engine import ContextEngine
|
||||
from hermes_agent.agent.context.engine import ContextEngine
|
||||
if not isinstance(engine, ContextEngine):
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register a context engine that does not "
|
||||
@@ -406,8 +397,8 @@ class PluginContext:
|
||||
``config.yaml`` matches against when routing ``image_generate``
|
||||
tool calls.
|
||||
"""
|
||||
from agent.image_gen_provider import ImageGenProvider
|
||||
from agent.image_gen_registry import register_provider
|
||||
from hermes_agent.agent.image_gen.provider import ImageGenProvider
|
||||
from hermes_agent.agent.image_gen.registry import register_provider
|
||||
|
||||
if not isinstance(provider, ImageGenProvider):
|
||||
logger.warning(
|
||||
@@ -461,7 +452,7 @@ class PluginContext:
|
||||
ValueError: if *name* contains ``':'`` or invalid characters.
|
||||
FileNotFoundError: if *path* does not exist.
|
||||
"""
|
||||
from agent.skill_utils import _NAMESPACE_RE
|
||||
from hermes_agent.agent.skill_utils import _NAMESPACE_RE
|
||||
|
||||
if ":" in name:
|
||||
raise ValueError(
|
||||
@@ -512,23 +503,10 @@ class PluginManager:
|
||||
# Public
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def discover_and_load(self, force: bool = False) -> None:
|
||||
"""Scan all plugin sources and load each plugin found.
|
||||
|
||||
When ``force`` is true, clear cached discovery state first so config
|
||||
changes or newly-added bundled backends become visible in long-lived
|
||||
sessions without requiring a full agent restart.
|
||||
"""
|
||||
if self._discovered and not force:
|
||||
def discover_and_load(self) -> None:
|
||||
"""Scan all plugin sources and load each plugin found."""
|
||||
if self._discovered:
|
||||
return
|
||||
if force:
|
||||
self._plugins.clear()
|
||||
self._hooks.clear()
|
||||
self._plugin_tool_names.clear()
|
||||
self._cli_commands.clear()
|
||||
self._plugin_commands.clear()
|
||||
self._plugin_skills.clear()
|
||||
self._context_engine = None
|
||||
self._discovered = True
|
||||
|
||||
manifests: List[PluginManifest] = []
|
||||
@@ -756,30 +734,6 @@ class PluginManager:
|
||||
)
|
||||
kind = "standalone"
|
||||
|
||||
# Auto-coerce user-installed memory providers to kind="exclusive"
|
||||
# so they're routed to plugins/memory discovery instead of being
|
||||
# loaded by the general PluginManager (which has no
|
||||
# register_memory_provider on PluginContext). Mirrors the
|
||||
# heuristic in plugins/memory/__init__.py:_is_memory_provider_dir.
|
||||
# Bundled memory providers are already skipped via skip_names.
|
||||
if kind == "standalone" and "kind" not in data:
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if init_file.exists():
|
||||
try:
|
||||
source_text = init_file.read_text(errors="replace")[:8192]
|
||||
if (
|
||||
"register_memory_provider" in source_text
|
||||
or "MemoryProvider" in source_text
|
||||
):
|
||||
kind = "exclusive"
|
||||
logger.debug(
|
||||
"Plugin %s: detected memory provider, "
|
||||
"treating as kind='exclusive'",
|
||||
key,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PluginManifest(
|
||||
name=name,
|
||||
version=str(data.get("version", "")),
|
||||
@@ -1042,13 +996,9 @@ def get_plugin_manager() -> PluginManager:
|
||||
return _plugin_manager
|
||||
|
||||
|
||||
def discover_plugins(force: bool = False) -> None:
|
||||
"""Discover and load all plugins.
|
||||
|
||||
Default behavior is idempotent. Pass ``force=True`` to rescan plugin
|
||||
manifests and reload state in the current process.
|
||||
"""
|
||||
get_plugin_manager().discover_and_load(force=force)
|
||||
def discover_plugins() -> None:
|
||||
"""Discover and load all plugins (idempotent)."""
|
||||
get_plugin_manager().discover_and_load()
|
||||
|
||||
|
||||
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
|
||||
@@ -1099,13 +1049,10 @@ def get_pre_tool_call_block_message(
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_plugins_discovered(force: bool = False) -> PluginManager:
|
||||
"""Return the global manager after ensuring plugin discovery has run.
|
||||
|
||||
Pass ``force=True`` to rescan in the current process.
|
||||
"""
|
||||
def _ensure_plugins_discovered() -> PluginManager:
|
||||
"""Return the global manager after running idempotent plugin discovery."""
|
||||
manager = get_plugin_manager()
|
||||
manager.discover_and_load(force=force)
|
||||
manager.discover_and_load()
|
||||
return manager
|
||||
|
||||
|
||||
@@ -1140,7 +1087,7 @@ def get_plugin_toolsets() -> List[tuple]:
|
||||
return []
|
||||
|
||||
try:
|
||||
from tools.registry import registry
|
||||
from hermes_agent.tools.registry import registry
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -17,7 +17,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -173,8 +173,8 @@ def _prompt_plugin_env_vars(manifest: dict, console) -> None:
|
||||
if not requires_env:
|
||||
return
|
||||
|
||||
from hermes_cli.config import get_env_value, save_env_value # noqa: F811
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_agent.cli.config import get_env_value, save_env_value # noqa: F811
|
||||
from hermes_agent.constants import display_hermes_home
|
||||
|
||||
# Normalise to list-of-dicts
|
||||
env_specs: list[dict] = []
|
||||
@@ -360,7 +360,7 @@ def cmd_install(
|
||||
)
|
||||
sys.exit(1)
|
||||
if mv_int > _SUPPORTED_MANIFEST_VERSION:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
from hermes_agent.cli.config import recommended_update_command
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version "
|
||||
f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n"
|
||||
@@ -517,7 +517,7 @@ def _get_disabled_set() -> set:
|
||||
listed in ``plugins.enabled``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
@@ -527,7 +527,7 @@ def _get_disabled_set() -> set:
|
||||
|
||||
def _save_disabled_set(disabled: set) -> None:
|
||||
"""Write the disabled plugins list to config.yaml."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_agent.cli.config import load_config, save_config
|
||||
config = load_config()
|
||||
if "plugins" not in config:
|
||||
config["plugins"] = {}
|
||||
@@ -542,7 +542,7 @@ def _get_enabled_set() -> set:
|
||||
the key is missing (same behaviour as "nothing enabled yet").
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
config = load_config()
|
||||
plugins_cfg = config.get("plugins", {})
|
||||
if not isinstance(plugins_cfg, dict):
|
||||
@@ -555,7 +555,7 @@ def _get_enabled_set() -> set:
|
||||
|
||||
def _save_enabled_set(enabled: set) -> None:
|
||||
"""Write the enabled plugins list to config.yaml."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_agent.cli.config import load_config, save_config
|
||||
config = load_config()
|
||||
if "plugins" not in config:
|
||||
config["plugins"] = {}
|
||||
@@ -631,8 +631,8 @@ def _plugin_exists(name: str) -> bool:
|
||||
return True
|
||||
# Bundled: <repo>/plugins/<name>/
|
||||
from pathlib import Path as _P
|
||||
import hermes_cli
|
||||
repo_plugins = _P(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
import hermes_agent.cli as _cli_pkg
|
||||
repo_plugins = _P(_cli_pkg.__file__).resolve().parent.parent / "plugins"
|
||||
if repo_plugins.is_dir():
|
||||
candidate = repo_plugins / name
|
||||
if candidate.is_dir() and (
|
||||
@@ -659,8 +659,8 @@ def _discover_all_plugins() -> list:
|
||||
seen: dict = {} # name -> (name, version, description, source, path)
|
||||
|
||||
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
|
||||
import hermes_cli
|
||||
repo_plugins = Path(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
import hermes_agent.cli as _cli_pkg
|
||||
repo_plugins = Path(_cli_pkg.__file__).resolve().parent.parent / "plugins"
|
||||
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
@@ -743,7 +743,7 @@ def cmd_list() -> None:
|
||||
def _discover_memory_providers() -> list[tuple[str, str]]:
|
||||
"""Return [(name, description), ...] for available memory providers."""
|
||||
try:
|
||||
from plugins.memory import discover_memory_providers
|
||||
from hermes_agent.plugins.memory import discover_memory_providers
|
||||
return [(name, desc) for name, desc, _avail in discover_memory_providers()]
|
||||
except Exception:
|
||||
return []
|
||||
@@ -752,7 +752,7 @@ def _discover_memory_providers() -> list[tuple[str, str]]:
|
||||
def _discover_context_engines() -> list[tuple[str, str]]:
|
||||
"""Return [(name, description), ...] for available context engines."""
|
||||
try:
|
||||
from plugins.context_engine import discover_context_engines
|
||||
from hermes_agent.plugins.context_engine import discover_context_engines
|
||||
return [(name, desc) for name, desc, _avail in discover_context_engines()]
|
||||
except Exception:
|
||||
return []
|
||||
@@ -761,7 +761,7 @@ def _discover_context_engines() -> list[tuple[str, str]]:
|
||||
def _get_current_memory_provider() -> str:
|
||||
"""Return the current memory.provider from config (empty = built-in)."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
config = load_config()
|
||||
return config.get("memory", {}).get("provider", "") or ""
|
||||
except Exception:
|
||||
@@ -771,7 +771,7 @@ def _get_current_memory_provider() -> str:
|
||||
def _get_current_context_engine() -> str:
|
||||
"""Return the current context.engine from config."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_agent.cli.config import load_config
|
||||
config = load_config()
|
||||
return config.get("context", {}).get("engine", "compressor") or "compressor"
|
||||
except Exception:
|
||||
@@ -780,7 +780,7 @@ def _get_current_context_engine() -> str:
|
||||
|
||||
def _save_memory_provider(name: str) -> None:
|
||||
"""Persist memory.provider to config.yaml."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_agent.cli.config import load_config, save_config
|
||||
config = load_config()
|
||||
if "memory" not in config:
|
||||
config["memory"] = {}
|
||||
@@ -790,7 +790,7 @@ def _save_memory_provider(name: str) -> None:
|
||||
|
||||
def _save_context_engine(name: str) -> None:
|
||||
"""Persist context.engine to config.yaml."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_agent.cli.config import load_config, save_config
|
||||
config = load_config()
|
||||
if "context" not in config:
|
||||
config["context"] = {}
|
||||
@@ -800,7 +800,7 @@ def _save_context_engine(name: str) -> None:
|
||||
|
||||
def _configure_memory_provider() -> bool:
|
||||
"""Launch a radio picker for memory providers. Returns True if changed."""
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
from hermes_agent.cli.ui.curses import curses_radiolist
|
||||
|
||||
current = _get_current_memory_provider()
|
||||
providers = _discover_memory_providers()
|
||||
@@ -838,7 +838,7 @@ def _configure_memory_provider() -> bool:
|
||||
|
||||
def _configure_context_engine() -> bool:
|
||||
"""Launch a radio picker for context engines. Returns True if changed."""
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
from hermes_agent.cli.ui.curses import curses_radiolist
|
||||
|
||||
current = _get_current_context_engine()
|
||||
engines = _discover_context_engines()
|
||||
@@ -938,7 +938,7 @@ def cmd_toggle() -> None:
|
||||
def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
|
||||
disabled, categories, console):
|
||||
"""Custom curses screen with checkboxes + category action rows."""
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
from hermes_agent.cli.ui.curses import flush_stdin
|
||||
|
||||
chosen = set(plugin_selected)
|
||||
n_plugins = len(plugin_names)
|
||||
@@ -1188,7 +1188,7 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
|
||||
def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
|
||||
disabled, categories, console):
|
||||
"""Text-based fallback for the composite plugins UI."""
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_agent.cli.ui.colors import Colors, color
|
||||
|
||||
print(color("\n Plugins", Colors.YELLOW))
|
||||
|
||||
@@ -84,7 +84,7 @@ _DEFAULT_EXPORT_EXCLUDE_ROOT = frozenset({
|
||||
"node_modules", # npm packages
|
||||
# Databases & runtime state
|
||||
"state.db", "state.db-shm", "state.db-wal",
|
||||
"hermes_state.db",
|
||||
"state.db",
|
||||
"response_store.db", "response_store.db-shm", "response_store.db-wal",
|
||||
"gateway.pid", "gateway_state.json", "processes.json",
|
||||
"auth.json", # API keys, OAuth tokens, credential pools
|
||||
@@ -138,7 +138,7 @@ def _get_default_hermes_home() -> Path:
|
||||
In Docker/custom deployments where HERMES_HOME is outside ``~/.hermes``
|
||||
(e.g. ``/opt/data``), returns HERMES_HOME directly.
|
||||
"""
|
||||
from hermes_constants import get_default_hermes_root
|
||||
from hermes_agent.constants import get_default_hermes_root
|
||||
return get_default_hermes_root()
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ def _read_config_model(profile_dir: Path) -> tuple:
|
||||
def _check_gateway_running(profile_dir: Path) -> bool:
|
||||
"""Check if a gateway is running for a given profile directory."""
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
from hermes_agent.gateway.status import get_running_pid
|
||||
return get_running_pid(profile_dir / "gateway.pid", cleanup_stale=False) is not None
|
||||
except Exception:
|
||||
return False
|
||||
@@ -413,7 +413,7 @@ def create_profile(
|
||||
if clone_from is not None or clone_all or clone_config:
|
||||
if clone_from is None:
|
||||
# Default: clone from active profile
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
source_dir = get_hermes_home()
|
||||
else:
|
||||
validate_profile_name(clone_from)
|
||||
@@ -455,7 +455,7 @@ def create_profile(
|
||||
soul_path = profile_dir / "SOUL.md"
|
||||
if not soul_path.exists():
|
||||
try:
|
||||
from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
||||
from hermes_agent.cli.default_soul import DEFAULT_SOUL_MD
|
||||
soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8")
|
||||
except Exception:
|
||||
pass # best-effort — don't fail profile creation over this
|
||||
@@ -469,11 +469,11 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
|
||||
Uses subprocess because sync_skills() caches HERMES_HOME at module level.
|
||||
Returns the sync result dict, or None on failure.
|
||||
"""
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
project_root = Path(__file__).resolve().parents[2].resolve()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
"import json; from tools.skills_sync import sync_skills; "
|
||||
"import json; from hermes_agent.tools.skills.sync import sync_skills; "
|
||||
"r = sync_skills(quiet=True); print(json.dumps(r))"],
|
||||
env={**os.environ, "HERMES_HOME": str(profile_dir)},
|
||||
cwd=str(project_root),
|
||||
@@ -597,7 +597,7 @@ def _cleanup_gateway_service(name: str, profile_dir: Path) -> None:
|
||||
old_home = os.environ.get("HERMES_HOME")
|
||||
try:
|
||||
os.environ["HERMES_HOME"] = str(profile_dir)
|
||||
from hermes_cli.gateway import get_service_name, get_launchd_plist_path
|
||||
from hermes_agent.cli.gateway import get_service_name, get_launchd_plist_path
|
||||
|
||||
if _platform.system() == "Linux":
|
||||
svc_name = get_service_name()
|
||||
@@ -720,7 +720,7 @@ def get_active_profile_name() -> str:
|
||||
Returns the profile name if HERMES_HOME points into ``~/.hermes/profiles/<name>``.
|
||||
Returns ``"custom"`` if HERMES_HOME is set to an unrecognized path.
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_agent.constants import get_hermes_home
|
||||
hermes_home = get_hermes_home()
|
||||
resolved = hermes_home.resolve()
|
||||
|
||||
@@ -863,15 +863,19 @@ def _safe_extract_profile_archive(archive: Path, destination: Path) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _inspect_profile_archive_roots(archive: Path) -> set[str]:
|
||||
"""Return the archive's top-level directory names.
|
||||
def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
"""Import a profile from a tar.gz archive.
|
||||
|
||||
Profile imports expect exactly one root directory. Inspecting the archive
|
||||
before extraction lets us stage the import safely instead of mutating a
|
||||
live profile tree first and reconciling names later.
|
||||
If *name* is not given, infers it from the archive's top-level directory.
|
||||
Returns the imported profile directory.
|
||||
"""
|
||||
import tarfile
|
||||
|
||||
archive = Path(archive_path)
|
||||
if not archive.exists():
|
||||
raise FileNotFoundError(f"Archive not found: {archive}")
|
||||
|
||||
# Peek at the archive to find the top-level directory name
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
top_dirs = {
|
||||
parts[0]
|
||||
@@ -885,33 +889,13 @@ def _inspect_profile_archive_roots(archive: Path) -> set[str]:
|
||||
for member in tf.getmembers()
|
||||
if member.isdir()
|
||||
}
|
||||
return top_dirs
|
||||
|
||||
|
||||
def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
"""Import a profile from a tar.gz archive.
|
||||
|
||||
If *name* is not given, infers it from the archive's top-level directory.
|
||||
Returns the imported profile directory.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
archive = Path(archive_path)
|
||||
if not archive.exists():
|
||||
raise FileNotFoundError(f"Archive not found: {archive}")
|
||||
|
||||
top_dirs = _inspect_profile_archive_roots(archive)
|
||||
archive_root = top_dirs.pop() if len(top_dirs) == 1 else None
|
||||
inferred_name = name or archive_root
|
||||
inferred_name = name or (top_dirs.pop() if len(top_dirs) == 1 else None)
|
||||
if not inferred_name:
|
||||
raise ValueError(
|
||||
"Cannot determine profile name from archive. "
|
||||
"Specify it explicitly: hermes profile import <archive> --name <name>"
|
||||
)
|
||||
if archive_root is None:
|
||||
raise ValueError(
|
||||
"Profile archive must contain exactly one top-level directory."
|
||||
)
|
||||
|
||||
# Archives exported from the default profile have "default/" as top-level
|
||||
# dir. Importing as "default" would target ~/.hermes itself — disallow
|
||||
@@ -930,22 +914,12 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
profiles_root = _get_profiles_root()
|
||||
profiles_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="hermes_profile_import_") as tmpdir:
|
||||
staging_root = Path(tmpdir)
|
||||
_safe_extract_profile_archive(archive, staging_root)
|
||||
_safe_extract_profile_archive(archive, profiles_root)
|
||||
|
||||
extracted = staging_root / archive_root
|
||||
if not extracted.is_dir():
|
||||
raise ValueError(
|
||||
f"Profile archive root is missing or invalid: {archive_root}"
|
||||
)
|
||||
|
||||
final_source = extracted
|
||||
if archive_root != inferred_name:
|
||||
final_source = staging_root / inferred_name
|
||||
extracted.rename(final_source)
|
||||
|
||||
shutil.move(str(final_source), str(profile_dir))
|
||||
# If the archive extracted under a different name, rename
|
||||
extracted = profiles_root / (top_dirs.pop() if top_dirs else inferred_name)
|
||||
if extracted != profile_dir and extracted.exists():
|
||||
extracted.rename(profile_dir)
|
||||
|
||||
return profile_dir
|
||||
|
||||
@@ -23,7 +23,7 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
from hermes_agent.utils import base_url_host_matches, base_url_hostname
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,12 +94,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
transport="openai_chat",
|
||||
base_url_env_var="KIMI_BASE_URL",
|
||||
),
|
||||
"stepfun": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("STEPFUN_API_KEY",),
|
||||
base_url_override="https://api.stepfun.ai/step_plan/v1",
|
||||
base_url_env_var="STEPFUN_BASE_URL",
|
||||
),
|
||||
"minimax": HermesOverlay(
|
||||
transport="anthropic_messages",
|
||||
base_url_env_var="MINIMAX_BASE_URL",
|
||||
@@ -216,10 +210,6 @@ ALIASES: Dict[str, str] = {
|
||||
"kimi-coding-cn": "kimi-for-coding",
|
||||
"moonshot": "kimi-for-coding",
|
||||
|
||||
# stepfun
|
||||
"step": "stepfun",
|
||||
"stepfun-coding-plan": "stepfun",
|
||||
|
||||
# minimax-cn
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
@@ -304,7 +294,6 @@ _LABEL_OVERRIDES: Dict[str, str] = {
|
||||
"nous": "Nous Portal",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"stepfun": "StepFun Step Plan",
|
||||
"xiaomi": "Xiaomi MiMo",
|
||||
"local": "Local endpoint",
|
||||
"bedrock": "AWS Bedrock",
|
||||
@@ -352,7 +341,7 @@ def get_provider(name: str) -> Optional[ProviderDef]:
|
||||
|
||||
# Try to get models.dev data
|
||||
try:
|
||||
from agent.models_dev import get_provider_info as _mdev_provider
|
||||
from hermes_agent.providers.metadata_dev import get_provider_info as _mdev_provider
|
||||
mdev_info = _mdev_provider(canonical)
|
||||
except Exception:
|
||||
mdev_info = None
|
||||
@@ -607,7 +596,7 @@ def resolve_provider_full(
|
||||
|
||||
# 3. Try models.dev directly (for providers not in our ALIASES)
|
||||
try:
|
||||
from agent.models_dev import get_provider_info as _mdev_provider
|
||||
from hermes_agent.providers.metadata_dev import get_provider_info as _mdev_provider
|
||||
mdev_info = _mdev_provider(canonical)
|
||||
if mdev_info is not None:
|
||||
return ProviderDef(
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
from agent.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool
|
||||
from hermes_cli.auth import (
|
||||
from hermes_agent.cli.auth import auth as auth_mod
|
||||
from hermes_agent.providers.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool
|
||||
from hermes_agent.cli.auth.auth import (
|
||||
AuthError,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
DEFAULT_QWEN_BASE_URL,
|
||||
@@ -27,9 +27,9 @@ from hermes_cli.auth import (
|
||||
resolve_external_process_provider_credentials,
|
||||
has_usable_secret,
|
||||
)
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
from hermes_agent.cli.config import get_compatible_custom_providers, load_config
|
||||
from hermes_agent.constants import OPENROUTER_BASE_URL
|
||||
from hermes_agent.utils import base_url_host_matches, base_url_hostname
|
||||
|
||||
|
||||
def _normalize_custom_provider_name(value: str) -> str:
|
||||
@@ -134,7 +134,7 @@ def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str:
|
||||
return "chat_completions"
|
||||
|
||||
try:
|
||||
from hermes_cli.models import copilot_model_api_mode
|
||||
from hermes_agent.cli.models.models import copilot_model_api_mode
|
||||
|
||||
return copilot_model_api_mode(model_name, api_key=api_key)
|
||||
except Exception:
|
||||
@@ -206,7 +206,7 @@ def _resolve_runtime_from_pool_entry(
|
||||
if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
|
||||
api_mode = configured_mode
|
||||
elif provider in ("opencode-zen", "opencode-go"):
|
||||
from hermes_cli.models import opencode_model_api_mode
|
||||
from hermes_agent.cli.models.models import opencode_model_api_mode
|
||||
api_mode = opencode_model_api_mode(provider, model_cfg.get("default", ""))
|
||||
else:
|
||||
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix,
|
||||
@@ -567,7 +567,7 @@ def _resolve_explicit_runtime(
|
||||
base_url = explicit_base_url or cfg_base_url or "https://api.anthropic.com"
|
||||
api_key = explicit_api_key
|
||||
if not api_key:
|
||||
from agent.anthropic_adapter import resolve_anthropic_token
|
||||
from hermes_agent.providers.anthropic_adapter import resolve_anthropic_token
|
||||
|
||||
api_key = resolve_anthropic_token()
|
||||
if not api_key:
|
||||
@@ -870,7 +870,7 @@ def resolve_runtime_provider(
|
||||
|
||||
# Anthropic (native Messages API)
|
||||
if provider == "anthropic":
|
||||
from agent.anthropic_adapter import resolve_anthropic_token
|
||||
from hermes_agent.providers.anthropic_adapter import resolve_anthropic_token
|
||||
token = resolve_anthropic_token()
|
||||
if not token:
|
||||
raise AuthError(
|
||||
@@ -896,7 +896,7 @@ def resolve_runtime_provider(
|
||||
|
||||
# AWS Bedrock (native Converse API via boto3)
|
||||
if provider == "bedrock":
|
||||
from agent.bedrock_adapter import (
|
||||
from hermes_agent.providers.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
resolve_aws_auth_env_var,
|
||||
resolve_bedrock_region,
|
||||
@@ -989,7 +989,7 @@ def resolve_runtime_provider(
|
||||
if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
|
||||
api_mode = configured_mode
|
||||
elif provider in ("opencode-zen", "opencode-go"):
|
||||
from hermes_cli.models import opencode_model_api_mode
|
||||
from hermes_agent.cli.models.models import opencode_model_api_mode
|
||||
api_mode = opencode_model_api_mode(provider, model_cfg.get("default", ""))
|
||||
else:
|
||||
# Auto-detect Anthropic-compatible endpoints by URL convention
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user