6.4 KiB
Contributor Quick Reference
For occasional contributors and PR authors. Full developer docs: https://hermes-agent.nousresearch.com/docs/developer-guide/
Project Layout
hermes-agent/
├── run_agent.py # AIAgent — core conversation loop
├── model_tools.py # Tool discovery and dispatch
├── toolsets.py # Toolset definitions
├── cli.py # Interactive CLI (HermesCLI)
├── hermes_state.py # SQLite session store
├── agent/ # Prompt builder, context compression, memory, model routing, credential pooling, skill dispatch
├── hermes_cli/ # CLI subcommands, config, setup, commands
│ ├── commands.py # Slash command registry (CommandDef)
│ ├── config.py # DEFAULT_CONFIG, env var definitions
│ └── main.py # CLI entry point and argparse
├── tools/ # One file per tool
│ └── registry.py # Central tool registry
├── gateway/ # Messaging gateway
│ └── platforms/ # Platform adapters (telegram, discord, etc.)
├── cron/ # Job scheduler
├── tests/ # Extensive pytest suite (run via scripts/run_tests.sh)
└── website/ # Docusaurus docs site
Config: ~/.hermes/config.yaml (settings), ~/.hermes/.env (API keys) — both under $HERMES_HOME when it is set.
Adding a Tool
Two files. Auto-discovery imports any tools/*.py with a top-level
registry.register() call, but a tool is only exposed to an agent once
its name appears in a toolset.
1. Create tools/your_tool.py:
import json, os
from tools.registry import registry
def check_requirements() -> bool:
return bool(os.getenv("EXAMPLE_API_KEY"))
def example_tool(param: str, task_id: str = None) -> str:
return json.dumps({"success": True, "data": "..."})
registry.register(
name="example_tool",
toolset="example",
schema={"name": "example_tool", "description": "...", "parameters": {...}},
handler=lambda args, **kw: example_tool(
param=args.get("param", ""), task_id=kw.get("task_id")),
check_fn=check_requirements,
requires_env=["EXAMPLE_API_KEY"],
)
2. Wire it into a toolset in toolsets.py — add the name to
_HERMES_CORE_TOOLS (every platform) or to a specific toolset.
All handlers must return JSON strings. Use get_hermes_home() for paths,
never hardcode ~/.hermes. For custom/local-only tools, write a plugin in
~/.hermes/plugins/ instead of editing core — see the developer docs.
Adding a Slash Command
- Add
CommandDeftoCOMMAND_REGISTRYinhermes_cli/commands.py - Add handler in
cli.py→process_command() - (Optional) Add gateway handler in
gateway/run.py
All consumers (help text, autocomplete, Telegram menu, Slack mapping) derive from the central registry automatically.
Agent Loop (High Level)
run_conversation():
1. Build system prompt
2. Loop while iterations < max:
a. Call LLM (OpenAI-format messages + tool schemas)
b. If tool_calls → dispatch each via handle_function_call() → append results → continue
c. If text response → return
3. Context compression triggers automatically near token limit
Testing
Use the canonical runner — it enforces CI-parity (hermetic env -i, unset
credentials, TZ=UTC, per-file subprocess isolation via
scripts/run_tests_parallel.py — no xdist, worker count auto-scaled):
scripts/run_tests.sh # full suite
scripts/run_tests.sh tests/tools/ # one directory
scripts/run_tests.sh tests/tools/test_x.py # one file
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
- Tests auto-redirect
HERMES_HOMEto temp dirs — never touch real~/.hermes/. - The script probes
.venv, thenvenv, then the shared worktree venv. - Windows: the wrapper is POSIX-only; see
references/windows-quirks.mdfor the direct-pytest workaround.
Cross-platform test guards: tests using POSIX-only syscalls need a skip marker. Common ones already in the codebase:
- Symlink creation →
@pytest.mark.skipif(sys.platform == "win32", reason="Symlinks require elevated privileges on Windows")(seetests/cron/test_cron_script.py) - POSIX file modes (0o600, etc.) →
@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX mode bits not enforced on Windows")(seetests/hermes_cli/test_auth_toctou_file_modes.py) signal.SIGALRM→ Unix-only (per-test timeouts no longer use it directly; see the win32 timeout-method shim intests/conftest.py::pytest_configure)- Live Winsock / Windows-specific regression tests →
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")
Monkeypatching sys.platform is not enough when the code under test also calls platform.system() / platform.release() / platform.mac_ver(). Those functions re-read the real OS independently, so a test that sets sys.platform = "linux" on a Windows runner will still see platform.system() == "Windows" and route through the Windows branch. Patch all three together:
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform, "release", lambda: "6.8.0-generic")
See tests/agent/test_prompt_builder.py::TestEnvironmentHints for a worked example.
System prompt's execution-environment block
Factual host/backend guidance (OS, $HOME, cwd, terminal backend, shell)
is emitted by agent/prompt_builder.py::build_environment_hints(). The key
invariant for prompt authors: with a remote terminal backend
(docker, singularity, modal, daytona, ssh, managed_modal), host info is
suppressed and every file tool runs inside the backend container — the
prompt must never describe the host the agent can't touch.
Commit Conventions
type: concise subject line
Optional body.
Types: fix:, feat:, refactor:, docs:, chore:
Key Rules
- Never break prompt caching — don't change context, tools, or system prompt mid-conversation
- Message role alternation — never two assistant or two user messages in a row
- Use
get_hermes_home()fromhermes_constantsfor all paths (profile-safe) - Config values go in
config.yaml, secrets go in.env - New tools need a
check_fnso they only appear when requirements are met