diff --git a/AGENTS.md b/AGENTS.md index 32d03139d7..aecd44860c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,1833 +1,424 @@ # Hermes Agent - Development Guide Instructions for AI coding assistants and developers working on the hermes-agent codebase. +This root file holds only what applies everywhere. Each area has its own `AGENTS.md` (aim for +~8k chars; `agent/subdirectory_hints.py` delivers up to 32k and truncates head/tail with a warning +past that); see the **routing table** at the end and read the area file before editing in that area. **Never give up on the right solution.** ## What Hermes Is -Hermes is a personal AI agent that runs the same agent core across a CLI, a -messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI, -and an Electron desktop app. It learns across sessions (memory + skills), -delegates to subagents, runs scheduled jobs, and drives a real terminal and -browser. It is extended primarily through **plugins and skills**, not by -growing the core. +Hermes is a personal AI agent that runs the same agent core across a CLI, a messaging +gateway (Telegram, Discord, Slack, ~20 platforms), a TUI, and an Electron desktop app. It +learns across sessions (memory + skills), delegates to subagents, runs scheduled jobs, and +drives a real terminal and browser. It is extended primarily through **plugins and skills**, +not by growing the core. -Two properties shape almost every design decision and are the lens for -reviewing any change: +Two invariants shape almost every design decision and are the lens for reviewing any change: -- **Per-conversation prompt caching is sacred.** A long-lived conversation - reuses a cached prefix every turn. Anything that mutates past context, - swaps toolsets, or rebuilds the system prompt mid-conversation invalidates - that cache and multiplies the user's cost. We do not do it (the one - exception is context compression). -- **The core is a narrow waist; capability lives at the edges.** Every model - tool we add is sent on every API call, so the bar for a new *core* tool is - high. Most new capability should arrive as a CLI command + skill, a - service-gated tool, or a plugin — not as core surface. +- **Per-conversation prompt caching is sacred.** A long-lived conversation reuses a cached + prefix every turn. Anything that mutates past context, swaps toolsets, reloads memories, or + rebuilds the system prompt mid-conversation invalidates that cache and multiplies the user's + cost. We do not do it; the ONE exception is context compression. Slash commands that mutate + system-prompt state (skills, tools, memory) must be **cache-aware**: default to deferred + invalidation (takes effect next session) with an opt-in `--now` flag (`/skills install --now` + is the canonical pattern). +- **The core is a narrow waist; capability lives at the edges.** Every model tool is sent on + every API call, so the bar for a new *core* tool is high. New capability should arrive as a + CLI command + skill, a service-gated tool, or a plugin — not as core surface. ## Contribution Rubric — What We Want / What We Don't -This is the project's intent layer. Use it two ways: +The project's intent layer. It serves humans aiming a contribution AND the automated triage +sweeper, which may only close on `implemented_on_main`, `cannot_reproduce`, or `incoherent`. +Taste-based "out of scope" closes are a human maintainer's call; the sweeper's job is to +recognize design intent and *avoid wrongly closing a legitimate contribution*. -1. **For humans and for your own work** — what gets merged and what gets - rejected, so a contribution aims at the target. -2. **For automated review (the triage sweeper)** — guidance on when a PR is - safe to close on the three allowed reasons (`implemented_on_main`, - `cannot_reproduce`, `incoherent`) and, just as important, **when NOT to - close** one. Taste-based "we don't want this / out of scope" closes are NOT - an automated decision — those stay with a human maintainer. The sweeper's - job here is to recognize design intent and *avoid wrongly closing a - legitimate contribution*, not to make the won't-implement call itself. - -Read the balance right: Hermes ships a **lot** — most merges are bug fixes to -real reported behavior, and the product surface (platforms, channels, -providers, models, desktop/TUI features) expands aggressively and on purpose. -The restraint below is aimed squarely at the **core agent + the model tool -schema**, the one place where every addition is paid for on every API call. -"Smallest footprint" governs *how a capability is wired into the core*, NOT -whether the product is allowed to grow. We are expansive at the edges and -conservative at the waist. +Read the balance right: Hermes ships a **lot**. Most merges are bug fixes to reported +behavior, and the product surface (platforms, providers, models, desktop/TUI features) +expands aggressively on purpose. The restraint below targets the **core agent + model tool +schema**, the one place where every addition is paid for on every API call. "Smallest +footprint" governs *how a capability is wired into the core*, not whether the product may +grow: expansive at the edges, conservative at the waist. ### What we want -- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an - actual reported symptom. A good fix reproduces the symptom on current - `main`, points to the exact line where it manifests, and fixes the whole bug - class — sibling call paths included — not just the one site the reporter hit. -- **Expand reach at the edges.** New platform adapters, channels, providers, - models, and desktop/TUI/dashboard features are welcome and land routinely, - including large ones (a new messaging channel, a session-cap feature, a - Windows PTY bridge). Breadth in the product is a goal, not a footprint - concern — as long as it integrates with the existing setup/config UX - (`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw - env var. -- **Refactor god-files into clean modules.** Extracting a multi-thousand-line - cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused - mixin or module is wanted work, even when the diff is huge and mechanical - (large `+N/-N` refactors merge regularly). The "every line traces to the - request" test applies to *feature* PRs; a declared refactor's request IS the - extraction. -- **Keep the core narrow.** New *model tools* are the expensive exception — - every tool ships on every API call. Prefer, in order: extend existing code → - CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server - in the catalog → new core tool (last resort). See "The Footprint Ladder." -- **Extend, don't duplicate.** Before adding a module/manager/hook, check - whether existing infrastructure already covers the use case. When several PRs - integrate the same *category*, design one shared interface instead of merging - them one at a time (see the ABC + orchestrator note under the Footprint - Ladder). -- **Behavior contracts over snapshots.** Tests should assert how two pieces of - data must relate (invariants), not freeze a current value (model lists, - config version literals, enumeration counts). See "Don't write - change-detector tests." -- **E2E validation, not just green unit mocks.** For anything touching - resolution chains, config propagation, security boundaries, remote - backends, or file/network I/O, exercise the real path with real imports - against a temp `HERMES_HOME`. Mocks hide integration bugs. -- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict - message role alternation (never two same-role messages in a row; never a - synthetic user message injected mid-loop), and a system prompt that is - byte-stable for the life of a conversation. -- **Contributor credit preserved.** Salvage external work by cherry-picking - (rebase-merge) so authorship survives in git history; don't reimplement from - scratch when you can build on top. +- **Fix real bugs, well.** Reproduce the symptom on current `main`, point to the exact line + where it manifests, and fix the whole bug class — sibling call paths included. +- **Expand reach at the edges.** New adapters, channels, providers, models, desktop/TUI/ + dashboard features land routinely, including large ones — as long as they integrate with + the existing setup/config UX (`hermes tools`, `hermes setup`, auto-install) rather than + bolting on a raw env var. +- **Refactor god-files into clean modules.** Huge mechanical `+N/-N` extraction PRs are + wanted work. "Every line traces to the request" applies to *feature* PRs; a declared + refactor's request IS the extraction. +- **Keep the core narrow.** Prefer, in order: extend existing code → CLI command + skill → + service-gated tool (`check_fn`) → plugin → MCP server in the catalog → new core tool (last + resort). See the Footprint Ladder. +- **Extend, don't duplicate.** Check whether existing infrastructure covers the use case + before adding a module/manager/hook. When 3+ open PRs integrate the same *category* + (memory backends, providers, notifiers), design an ABC + orchestrator, wrap the existing + built-in as the first provider, and turn the competing PRs into plugins against it. +- **Behavior contracts over snapshots.** Tests assert how two pieces of data relate, never + freeze a current value (see Testing). +- **E2E validation, not just green unit mocks.** Anything touching resolution chains, config + propagation, security boundaries, remote backends, or file/network I/O must exercise the + real path with real imports against a temp `HERMES_HOME`. Mocks hide integration bugs. +- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict role + alternation (never two same-role messages in a row; never a synthetic user message injected + mid-loop), and a system prompt byte-stable for the life of a conversation. +- **Contributor credit preserved.** Salvage external work by cherry-picking (rebase-merge) so + authorship survives; build on top rather than reimplementing. ### What we don't want (rejected even when well-built) -- **Speculative infrastructure.** Hooks, callbacks, or extension points with no - concrete consumer. Adding a hook is easy; removing one after plugins depend - on it is hard. A hook is NOT speculative if a contributor has a real, stated - use case — even if the consumer ships separately. -- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets - only (API keys, tokens, passwords). All behavioral settings — timeouts, - thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an - internal env var if the mechanism needs one, but user-facing docs point to - `config.yaml`. Reject PRs that tell users to "set X in your .env" unless X - is a credential. -- **A new core tool when terminal + file already do the job, or when a skill - would.** If the only barrier is file visibility on a remote backend, fix the - mount, not the toolset. -- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit` - pagination on tools that load content the agent must read fully (skills, - prompts, playbooks). Models will read page 1 and skip the rest. -- **"Fixes" that destroy the feature they secure.** A mitigation that kills the - feature's purpose is the wrong mitigation. Read the original commit's intent - (`git log -p -S`) before restricting behavior; find a fix that preserves the - feature. -- **Outbound telemetry / usage attribution without opt-in gating.** No new - analytics, third-party identifier tagging, or attribution tags until a - generic user-facing opt-in (config gate + setup prompt + `hermes tools` - toggle) exists. Park behind a label, do not merge. -- **Change-detector tests, cache-breaking mid-conversation, dead code wired in - without E2E proof, and plugins that touch core files.** Plugins live in their - own directory and work within the ABCs/hooks we provide; if a plugin needs - more, widen the generic plugin surface, don't special-case it in core. -- **Third-party products / other people's projects integrated into the core - tree.** Observability backends, vendor SaaS integrations, analytics dashboards, - and similar "someone else's product" plugins do NOT land under `plugins/` in - this repo. They place an ongoing maintenance burden on us to keep them working - against a fast-moving core, for a backend we don't own. Ship them as a - **standalone plugin repo** users install into `~/.hermes/plugins/` (or via a - pip entry point), and promote them in the Nous Research Discord - (`#plugins-skills-and-skins`). This is a coupling-and-maintenance decision, not - a quality bar — the plugin can be excellent and still be a close. PRs that add - such a directory to the tree are closed with a pointer to publish it as its own - repo. +- **Speculative infrastructure.** Hooks/callbacks/extension points with no concrete consumer. + Adding a hook is easy; removing one after plugins depend on it is hard. A hook with a real, + stated use case is NOT speculative even if the consumer ships separately. +- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets only. Behavioral + settings (timeouts, thresholds, flags, display prefs) go in `config.yaml`; bridge to an + internal env var in code if the mechanism needs one. Reject "set X in your .env" docs + unless X is a credential. +- **A new core tool when terminal + file (or a skill) already do the job.** If the only + barrier is file visibility on a remote backend, fix the mount, not the toolset. +- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit` pagination on + tools that load content the agent must read fully (skills, prompts, playbooks) — models + read page 1 and skip the rest. +- **"Fixes" that destroy the feature they secure.** Read the original intent + (`git log -p -S`) before restricting behavior; find a fix that preserves the feature. +- **Outbound telemetry / usage attribution without opt-in gating.** No analytics, + third-party identifier tagging, or attribution tags until a generic user-facing opt-in + (config gate + setup prompt + `hermes tools` toggle) exists. Park behind a label. +- **Change-detector tests, cache-breaking mid-conversation, dead code wired in without E2E + proof, plugins that touch core files.** Plugins work within the ABCs/hooks we provide; if + one needs more, widen the generic plugin surface, never special-case it in core. +- **Third-party products integrated into the core tree.** Observability backends, vendor + SaaS connectors, analytics dashboards, and other "someone else's product" plugins do NOT + land under `plugins/` — every one becomes our burden against a fast-moving core for a + backend we don't own. Ship as a **standalone plugin repo** (`~/.hermes/plugins/` or pip + entry point), promoted in the Nous Research Discord `#plugins-skills-and-skins`. This is a + coupling decision, not a quality bar; such PRs are closed with a pointer to publish. ### Before you call it a bug — verify the premise (and when NOT to close) -The most common reason a well-written PR gets closed is not code quality — it -is that the change is built on a **wrong premise**, or it treats an -**intentional design as a gap**. These patterns cut both ways: they tell a -human reviewer what to scrutinize, and they tell the automated sweeper when a -PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in -doubt, leave it open for a human). They are distilled from real closes. +The most common reason a well-written PR is closed is a **wrong premise** or treating an +**intentional design as a gap**. These patterns tell a reviewer what to scrutinize and tell +the sweeper when a PR is NOT safe to close (when in doubt, leave it open for a human): -- **"Intentional design, not a gap."** A limitation that looks like an - oversight is often deliberate. Before "fixing" a missing link or a - restriction, ask whether the isolation IS the design. Example: profiles are - independent islands on purpose — a PR adding live config inheritance from the - default profile was closed because coupling profiles together is exactly what - the design prevents (the copy-at-creation `--clone` path already covers the - legitimate "start from my default" case). Read the original commit's intent - (`git log -p -S ""`) before assuming something is unfinished. -- **"The premise doesn't hold against how X actually works."** A PR's - justification frequently rests on a wrong mental model of an existing - mechanism. Trace the real code/runtime before accepting the rationale. Two - real closes: a rate-limit "re-probe during cooldown" PR (the breaker only - trips on a *confirmed-empty* account bucket, so re-probing just hammers a - bucket we've already proven empty); a usage-accumulation fix whose new branch - **never executes at runtime** because an earlier guard already popped the - state it depended on. If you can't point to the exact line where the bug - manifests AND show the fix changes that line's behavior, you haven't verified - the premise. -- **"This fix was wrong — the absence/omission was deliberate."** Adding the - obvious-looking missing piece can break things the omission was protecting. - Example: restoring "missing" `__init__.py` files made a test tree importable - as a dotted package that shadowed the real plugin, deleting its `register()` - at import time. The absence was load-bearing. -- **"Overreached / resurrected an approach we'd moved past."** Scope creep that - supersedes an agreed-on base, or revives a direction the maintainers - deliberately closed, gets rejected even when the code works. Keep the change - to the narrow piece that was actually agreed; offer the rest as a focused - follow-up. +- **"Intentional design, not a gap."** Ask whether the isolation IS the design. Profiles are + independent islands on purpose: a PR adding live config inheritance from the default + profile was closed because coupling profiles is exactly what the design prevents (`--clone` + already covers "start from my default"). Read `git log -p -S ""` before assuming + something is unfinished. +- **"The premise doesn't hold against how X actually works."** Trace the real runtime before + accepting a rationale. Real closes: a rate-limit "re-probe during cooldown" PR (the breaker + trips only on a *confirmed-empty* bucket, so re-probing hammers a bucket proven empty); a + usage fix whose new branch **never executes** because an earlier guard already popped the + state. If you can't point to the exact line where the bug manifests AND show the fix changes + that line's behavior, the premise is unverified. +- **"The absence was deliberate."** Restoring "missing" `__init__.py` files made a test tree + importable as a dotted package that shadowed the real plugin and deleted its `register()` + at import time. The omission was load-bearing. +- **"Overreached / resurrected an approach we moved past."** Scope creep beyond the agreed + base, or reviving a direction maintainers closed, is rejected even when it works. Offer the + rest as a focused follow-up. -The throughline: **verify the claim AND the intent against the codebase before -writing or merging a fix.** A confirmed reproduction on current `main` plus a -line-level account of where the fix acts beats a plausible-sounding rationale -every time. When in doubt about intent, it is cheaper to ask than to ship a -fix that fights the design. +Throughline: **verify the claim AND the intent against the codebase before writing or merging +a fix.** A reproduction on current `main` plus a line-level account beats a plausible +rationale. When unsure about intent, asking is cheaper than shipping a fix that fights the +design. ### The Footprint Ladder (new capability decision) -Each rung adds more permanent surface than the one above. Choose the highest -(least-footprint) rung that correctly solves the problem: +Choose the highest (least-footprint) rung that correctly solves the problem: -1. **Extend existing code** — the capability is a variation of something that - already exists. Zero new surface. -2. **CLI command + skill** — manages config/state/infra expressible as shell - commands. The agent runs `hermes ` guided by a skill. Zero - model-tool footprint. Default choice for subscriptions, scheduled tasks, - service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`. -3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND - only appears when a prerequisite is configured. Zero footprint otherwise. - Examples: Home Assistant tools (gated on token), memory-provider tools. -4. **Plugin** — third-party/niche/user-specific capability that doesn't ship in - core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime. -5. **MCP server (in the catalog)** — if the capability genuinely needs to be a - tool (structured I/O the agent invokes) but isn't core-fundamental, prefer - building it as an MCP server and adding it to the MCP catalog over growing - the core toolset. The agent connects to it through the built-in MCP client; - zero permanent core-schema footprint, and it's reusable by any MCP host. -6. **New core tool** — only when the capability is fundamental, broadly useful - to nearly every user, and unreachable via terminal + file (or an MCP server). - Examples of correct core tools: terminal, read_file, web_search, - browser_navigate. - -When 3+ open PRs try to integrate the same *category* of thing (memory -backends, providers, notifiers), don't merge them one at a time — design an -ABC + orchestrator, wrap the existing built-in as the first provider, and turn -the competing PRs into plugins against that interface. +1. **Extend existing code** — a variation of something that exists. Zero new surface. +2. **CLI command + skill** — config/state/infra expressible as shell commands; the agent runs + `hermes ` guided by a skill. Default for subscriptions, scheduled tasks, + service setup (`hermes webhook`, `hermes cron`, `hermes tools`). +3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND only appears when + a prerequisite is configured (Home Assistant tools, memory-provider tools). +4. **Plugin** — third-party/niche/user-specific; lives in `~/.hermes/plugins/` or a pip + package, discovered at runtime. +5. **MCP server (in the catalog)** — genuinely a tool but not core-fundamental. Zero permanent + core-schema footprint, reusable by any MCP host, reached via the built-in MCP client. +6. **New core tool** — only when fundamental, broadly useful to nearly every user, and + unreachable via terminal + file or an MCP server (terminal, read_file, web_search, + browser_navigate). ### Surface capability is a property of the SESSION, never of the process env -A tool that only works because of *who is on the other end of the connection* — -the desktop app's panes, the in-app browser, message reactions, Projects — must -resolve its availability from the **session's own source**, not from an env var -on the backend process. +A tool that works only because of *who is on the other end* (desktop panes, in-app browser, +message reactions, Projects) must resolve availability from the **session's own source**, not +from an env var on the backend. Client and backend are separate machines: the desktop app may +drive a locally spawned backend, one over SSH, one behind URL + token, or Hermes Cloud, and +only the first two carry `HERMES_DESKTOP=1`. An env-keyed gate is a silent no-op on the other +topologies — the tool is stripped from the schema while the platform hint tells the model it +is "inside the Hermes desktop app". The pattern: -The client and the backend are separate machines on separate clocks. The -desktop app can be driving a backend Electron spawned locally, one over SSH, -one behind a plain URL + token, or Hermes Cloud. Only the first two are spawned -by us and carry `HERMES_DESKTOP=1`. Every env-keyed GUI gate is therefore a -silent no-op on the other half of the topologies, and the failure is invisible: -the tool is stripped from the schema before the model ever sees it, on the same -backend whose platform hint is telling the model it's *"chatting inside the -Hermes desktop app."* +- **The toolset is the surface gate.** Keep such tools off `_HERMES_CORE_TOOLS` and in a named + toolset (`desktop_ui`, `project`); the GUI gateway's `_load_enabled_toolsets(platform)` + folds it in when the session's platform says GUI. One resolver, every topology. +- **`check_fn` answers reachability or opt-in, not surface.** "Is the bridge wired?" — fine. + "Was I spawned by Electron?" — not. `check_fn` results are TTL-cached process-wide + (`tools/registry.py`); a per-session answer does not belong there. +- **Ask which identity you mean.** `HERMES_DESKTOP=1` legitimately means "this backend was + spawned by the app" (cron ticker, web-dist handling). It does NOT mean "a GUI is watching"; + the embedded terminal pane (`hermes --tui` against that backend) is the counterexample. -The pattern that works: - -- **The toolset is the surface gate.** Keep the tools off `_HERMES_CORE_TOOLS` - (nobody else should pay their schema) and put them in a named toolset — - `desktop_ui`, `project`. The GUI gateway's `_load_enabled_toolsets(platform)` - folds that toolset in when the session's platform says GUI. One resolver, - every topology. -- **`check_fn` answers reachability or user opt-in, not surface.** "Is the - renderer bridge wired?", "did the user enable reactions?" — fine. "Was I - spawned by Electron?" — not fine. `check_fn` results are also TTL-cached - process-wide (`tools/registry.py`), so a per-session answer does not belong - there at all: one process serves many sessions. -- **Ask which identity you actually mean.** `HERMES_DESKTOP=1` legitimately - marks *"this backend process was spawned by the app"* — it gates the cron - ticker and web-dist handling correctly. It does NOT mean "a GUI is watching", - and the embedded terminal pane (`hermes --tui` against that same backend) is - the standing counterexample. - -Same test both ways: if the capability would still make sense with the client -on another machine, it is session-scoped. Cover it with a test that asserts the -GUI session gets the tool **with the env var absent** — that's the assertion -the original gate could never have passed. +Test: if the capability still makes sense with the client on another machine, it is +session-scoped. Assert the GUI session gets the tool **with the env var absent**. ## Development Environment ```bash -# Prefer .venv; fall back to venv if that's what your checkout has. source .venv/bin/activate # or: source venv/bin/activate ``` - -`scripts/run_tests.sh` probes `.venv` first, then `venv`, then -`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the -main checkout). +`scripts/run_tests.sh` probes `.venv`, then `venv`, then `$HOME/.hermes/hermes-agent/venv` +(worktrees sharing the main checkout's venv). ## Project Structure -File counts shift constantly — don't treat the tree below as exhaustive. -The canonical source is the filesystem. The notes call out the load-bearing -entry points you'll actually edit. +Counts shift constantly; the filesystem is canonical. Load-bearing entry points: ``` hermes-agent/ -├── run_agent.py # AIAgent class — public entry (~1.5k LOC); the turn loop lives in agent/turn_*.py +├── run_agent.py # AIAgent facade; the turn loop lives in agent/turn_*.py ├── 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 (~4.6k LOC + hermes_cli/cli_*_mixin.py) -├── hermes_state.py # SessionDB facade (~1.4k LOC); implementation in hermes_state_*.py (21 siblings) +├── toolsets.py # TOOLSETS dict, _HERMES_CORE_TOOLS +├── cli.py # HermesCLI (REPL, slash dispatch) + hermes_cli/cli_*_mixin.py +├── hermes_state.py # SessionDB facade; hermes_state_*.py siblings ├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths -├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware) +├── hermes_logging.py # agent.log / errors.log / gateway.log (profile-aware) ├── batch_runner.py # Parallel batch processing -├── agent/ # Agent internals: turn_*.py (loop phases), provider adapters, memory, compression, ... -├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine -│ └── web_routers/ # Dashboard FastAPI routers (one file per surface); web_server.py mounts them -├── tools/ # Tool implementations — auto-discovered via tools/registry.py +├── agent/ # turn_*.py loop phases, providers, memory, compression, prompt builder +├── hermes_cli/ # CLI subcommands, setup, config, plugins loader, skins, updater +│ └── web_routers/ # Dashboard FastAPI routers (one per surface); web_server.py mounts them +├── tools/ # Tool implementations, auto-discovered via tools/registry.py │ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity) -├── gateway/ # Messaging gateway — run.py facade + run_*.py phases + session*.py + platforms/ -│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp, -│ │ # homeassistant, signal, matrix, mattermost, email, sms, -│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles, -│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md. -│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped) -├── plugins/ # Plugin system (see "Plugins" section below) -│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...) -│ ├── context_engine/ # Context-engine plugins -│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...) -│ ├── kanban/ # Multi-agent board dispatcher + worker plugin -│ ├── hermes-achievements/ # Gamified achievement tracking -│ ├── observability/ # Metrics / traces / logs plugin -│ ├── image_gen/ # Image-generation providers -│ └── / # disk-cleanup, google_meet, platforms, spotify, -│ # strike-freedom-cockpit, ... -├── optional-skills/ # Heavier/niche skills shipped but NOT active by default -├── skills/ # Built-in skills bundled with the repo +├── gateway/ # run.py facade + run_*.py phases + session*.py + platforms/ +│ ├── platforms/ # One adapter per platform; see platforms/ADDING_A_PLATFORM.md +│ └── builtin_hooks/ # Always-registered gateway hooks (extension point; none shipped) +├── plugins/ # memory/, context_engine/, model-providers/, kanban/, image_gen/, ... +├── skills/ # Built-in skills (by category) optional-skills/: shipped, not active ├── ui-tui/ # Ink (React) terminal UI — `hermes --tui` -│ └── src/ # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib -├── tui_gateway/ # Python JSON-RPC backend for the TUI — server.py + methods_*.py -├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration) -├── cron/ # Scheduler — jobs.py, scheduler.py (+ scheduler_*.py) -├── evals/ # Offline benchmarks (codebase_navigability/, compaction/, core_tool_deferral/, ...) -├── scripts/ # run_tests.sh, release.py, check_compat_pointers.py, auxiliary scripts -├── website/ # Docusaurus docs site -└── tests/ # Pytest suite (~39k test functions across ~3.7k files as of Sep 2026) +├── tui_gateway/ # Python JSON-RPC backend for TUI + Desktop — server.py + methods_*.py +├── apps/desktop/ # Electron desktop app (+ apps/shared JSON-RPC client) web/: dashboard SPA +├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains) +├── cron/ # jobs.py + scheduler.py (+ scheduler_*.py) +├── evals/ # Offline benchmarks (codebase_navigability/, compaction/, ...) +├── scripts/ # run_tests.sh, release.py, check_compat_pointers.py, ci/ +├── website/ # Docusaurus docs (developer-guide/ holds the long-form area docs) +└── tests/ # Pytest suite (~39k tests / ~3.7k files, Sep 2026) ``` +**User state:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (secrets only), +`~/.hermes/logs/` (`agent.log` INFO+, `errors.log` WARNING+, `gateway.log`); all +profile-aware via `get_hermes_home()`. Browse logs with `hermes logs [--follow] [--level] [--session]`. + +**Dependency chain:** `tools/registry.py` (no deps) ← `tools/*.py` (register at import) ← +`model_tools.py` (discovery) ← `run_agent.py`, `cli.py`, `batch_runner.py`, `environments/`. + ### Facade + siblings layout (Sep 2026 decomposition) -Every former god file is now a **facade** plus a family of **siblings** named `_.py` -in the same directory. The facade keeps the public entry points and the names other packages -import; each sibling owns one topic and is where the code actually lives. Families with the most -siblings: - -| facade | siblings | topics | -|---|---|---| -| `hermes_state.py` | 21 `hermes_state_*.py` | schema, fts, search, compression, portability, gateway, errors, ... | -| `gateway/run.py` | 15 `gateway/run_*.py` | startup, adapters, inbound, turn, busy, goals, notifications, shutdown, ... | -| `tools/mcp_tool.py` | 15 `tools/mcp_tool_*.py` | config, discovery, transport, registration, content, errors, ... | -| `hermes_cli/kanban.py` | 14 `hermes_cli/kanban_*.py` | boards, db, db_connect, db_dispatch, db_notify, workspace, ... | -| `hermes_cli/web_server.py` | 13 `web_server_*.py` + 24 `web_routers/*.py` | one router file per dashboard surface | -| `hermes_cli/auth.py` | 12 `auth_*.py` | device_flow, codex, minimax, model_picker, commands, ... | -| `tools/browser_tool.py` | 11 `browser_tool_*.py` | cdp, cloud, install, lifecycle, session, real_profile, vision, ... | -| `cli.py` | 12 `hermes_cli/cli_*_mixin.py` | commands, stream, status_bar, billing, ... — mixed into `HermesCLI` | -| `run_agent.py` | `agent/turn_*.py`, `agent/agent_init.py`, `agent/conversation_loop.py` | turn phases: iteration_prep, api_call, api_error, overflow, truncation, recovery | - -Rules that follow from this layout: - -- **Find code by topic, not by facade.** `grep -rn "def name" /_*.py` lands on the - definition; reading the facade first is the expensive way (see `evals/codebase_navigability/`). -- **Siblings may import each other and late-import the facade** (inside functions) to avoid cycles. - A facade never imports a sibling at module level *and* gets imported by that sibling at module level. -- **Patch where production reads.** Many siblings deliberately do `from import name` inside - the function so tests can `monkeypatch.setattr(facade, "name", ...)`. Before writing a patch target, - check which binding the call site actually reads; a patch on the wrong module passes silently. -- **`scripts/check_compat_pointers.py` runs in CI.** Old import paths kept alive for external plugins - (`PLUGIN-COMPAT` blocks, `COMPAT_MANIFEST.md`, `compat_manifest.json`) are OFF LIMITS to in-tree code - and tests; they are removed on schedule by reverting one commit. Import from the defining module. -- **Don't recreate god files.** A sibling passing ~2,000 lines or a function passing ~300 is a signal - to split again along the same `_` convention. - -**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only). -**Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+), -`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`. -Browse with `hermes logs [--follow] [--level ...] [--session ...]`. - -## TypeScript Style - -Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages. - -- Prefer small nanostores over component state when state is shared, reused, or read by distant UI. -- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`. -- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`. -- Do not pass state through three components when the leaf can subscribe to the atom. -- Keep persistence beside the atom that owns it. -- Keep route roots thin. They compose routes and shell; they should not become controllers. -- No monolithic hooks. A hook should own one narrow job. -- Prefer colocated action modules over hidden god hooks. -- If a callback is pure side effect, use the terse void form: - `onState={st => void setGatewayState(st)}`. -- Async UI handlers should make intent explicit: - `onClick={() => void save()}`. -- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props. -- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps`, `Omit<...>`, `Pick<...>`. -- Table-driven beats condition ladders when mapping ids, routes, or views. -- `src/app` owns routes, pages, and page-specific components. -- `src/store` owns shared atoms. -- `src/lib` owns shared pure helpers. - -## File Dependency Chain - -``` -tools/registry.py (no deps — imported by all tool files) - ↑ -tools/*.py (each calls registry.register() at import time) - ↑ -model_tools.py (imports tools/registry + triggers tool discovery) - ↑ -run_agent.py, cli.py, batch_runner.py, environments/ -``` - ---- - -## AIAgent Class (run_agent.py + agent/) - -`run_agent.py` is the public entry: the `AIAgent` class assembled from mixins -(`agent/turn_facade.py`, `agent/client_lifecycle.py`, `agent/stream_delivery.py`, -`agent/session_persistence.py`, `agent/compression_facade.py`, ...). Construction runs -`agent/agent_init.py::init_agent`; the turn itself is `agent/conversation_loop.py::run_conversation`, -which `AIAgent.run_conversation` forwards to after taking the session turn lease. - -The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks, -session context, budget, credential pool, etc.). The signature below is the -minimum subset you'll usually touch — read `run_agent.py` for the full list. - -```python -class AIAgent: - def __init__(self, - base_url: str = None, - api_key: str = None, - provider: str = None, - api_mode: str = None, # "chat_completions" | "codex_responses" | ... - model: str = "", # empty → resolved from config/provider later - max_iterations: int = 500, # tool-calling iterations (shared with subagents) - enabled_toolsets: list = None, - disabled_toolsets: list = None, - quiet_mode: bool = False, - save_trajectories: bool = False, - platform: str = None, # "cli", "telegram", etc. - session_id: str = None, - skip_context_files: bool = False, - skip_memory: bool = False, - credential_pool=None, - # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model, - # checkpoints config, prefill_messages, service_tier, reasoning_config, etc. - ): ... - - def chat(self, message: str) -> str: - """Simple interface — returns final response string.""" - - def run_conversation(self, user_message: str, system_message: str = None, - conversation_history: list = None, task_id: str = None) -> dict: - """Full interface — returns dict with final_response + messages.""" -``` - -### Agent Loop - -The core loop is `agent/conversation_loop.py::run_conversation()` — entirely synchronous, with -interrupt checks, budget tracking, and a one-turn grace call. Each phase of an iteration is its own -module under `agent/turn_*.py` (`turn_iteration_prep`, `turn_request_assembly`, `turn_api_call`, -`turn_api_error`, `turn_overflow`, `turn_truncation`, `turn_recovery`, `turn_usage`, ...), so a change -to, say, overflow handling touches one ~600-line file. Schematically: - -```python -while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \ - or self._budget_grace_call: - if self._interrupt_requested: break - response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas) - if response.tool_calls: - for tool_call in response.tool_calls: - result = handle_function_call(tool_call.name, tool_call.args, task_id) - messages.append(tool_result_message(result)) - api_call_count += 1 - else: - return response.content -``` - -Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`. -Reasoning content is stored in `assistant_msg["reasoning"]`. - ---- - -## CLI Architecture (cli.py + hermes_cli/cli_*_mixin.py) - -- `cli.py` holds `HermesCLI` (REPL loop, config, slash dispatch); behaviour is split into mixins in - `hermes_cli/cli_commands_mixin.py`, `cli_stream_mixin.py`, `cli_status_bar_mixin.py`, `cli_billing_mixin.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 -- `process_command()` on `HermesCLI` resolves the canonical name via `resolve_command()` from the central - registry, then dispatches through the `_SLASH_DISPATCH` table (`canonical -> (method name, pass_arg)`), - falling back to a `_handle__command` method by naming convention. There is no `elif` ladder. -- Skill slash commands: `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`) - -All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically: - -- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name -- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch -- **Gateway help** — `gateway_help_lines()` generates `/help` output -- **Telegram** — `telegram_bot_commands()` generates the BotCommand menu -- **Slack** — `slack_subcommand_map()` generates `/hermes` subcommand routing -- **Autocomplete** — `COMMANDS` flat dict feeds `SlashCommandCompleter` -- **CLI help** — `COMMANDS_BY_CATEGORY` dict feeds `show_help()` - -### Adding a Slash Command - -1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`: -```python -CommandDef("mycommand", "Description of what it does", "Session", - aliases=("mc",), args_hint="[arg]"), -``` -2. Add the handler on the CLI. Name it `_handle_mycommand_command(self, cmd_original)` on the - relevant `hermes_cli/cli_*_mixin.py` and it is picked up by convention; or add an explicit entry to - `HermesCLI._SLASH_DISPATCH` in `cli.py` when the method name or arg-passing differs: -```python -"mycommand": ("_handle_mycommand", True), # (method name, pass cmd_original?) -``` -3. If the command is available in the gateway, add `_handle_mycommand_command(self, event)` on the - matching `gateway/slash_commands_*.py` mixin and list `"mycommand"` in `_IDLE_COMMANDS` (or - `_PLAIN_COMMANDS` if it must also work mid-run) in `gateway/run_busy.py`. Handlers are looked up - by name through `_command_handler_table`; there is no `if canonical == ...` chain to extend. -4. For persistent settings, use `save_config_value()` in `cli.py` - -**CommandDef fields:** -- `name` — canonical name without slash (e.g. `"background"`) -- `description` — human-readable description -- `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"` -- `aliases` — tuple of alternative names (e.g. `("bg",)`) -- `args_hint` — argument placeholder shown in help (e.g. `""`, `"[name]"`) -- `cli_only` — only available in the interactive CLI -- `gateway_only` — only available in messaging platforms -- `gateway_config_gate` — config dotpath (e.g. `"display.tool_progress_command"`); when set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. `GATEWAY_KNOWN_COMMANDS` always includes config-gated commands so the gateway can dispatch them; help/menus only show them when the gate is open. - -**Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically. - ---- - -## TUI Architecture (ui-tui + tui_gateway) - -The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`. - -### Process Model - -``` -hermes --tui - └─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway) - │ └─ AIAgent + tools + sessions - └─ renders transcript, composer, prompts, activity -``` - -TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic. - -### Transport - -Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog. - -### Key Surfaces - -| Surface | Ink component | Gateway method | -|---------|---------------|----------------| -| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` | -| Tool activity | `thinking.tsx` | `tool.start/progress/complete` | -| Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` | -| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` | -| Session picker | `sessionPicker.tsx` | `session.list/resume` | -| Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` | -| Completions | `useCompletion` hook | `complete.slash`, `complete.path` | -| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data | - -### Slash Command Flow - -1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx` -2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback - -### Dev Commands - -```bash -cd ui-tui -npm install # first time -npm run dev # watch mode (rebuilds hermes-ink + tsx --watch) -npm start # production -npm run build # full build (hermes-ink + tsc) -npm run typecheck # typecheck only (tsc --noEmit) -npm run lint # eslint -npm run fmt # prettier -npm test # vitest -``` - -### TUI in the Dashboard (`hermes dashboard` → `/chat`) - -The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`. - -- Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths. -- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade). -- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not). -- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:;]` intercepted on the server and applied with `TIOCSWINSZ`. - -**Do not re-implement the primary chat experience in React.** The main transcript, composer/input flow (including slash-command behavior), and PTY-backed terminal belong to the embedded `hermes --tui` — anything new you add to Ink shows up in the dashboard automatically. If you find yourself rebuilding the transcript or composer for the dashboard, stop and extend Ink instead. - -**Structured React UI around the TUI is allowed when it is not a second chat surface.** Sidebar widgets, inspectors, summaries, status panels, and similar supporting views (e.g. `ChatSidebar`, `ModelPickerDialog`, `ToolCall`) are fine when they complement the embedded TUI rather than replacing the transcript / composer / terminal. Keep their state independent of the PTY child's session and surface their failures non-destructively so the terminal pane keeps working unimpaired. - -### Electron Desktop Chat App (`apps/desktop/`) - -A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`. - -**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline: - -- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills. -- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover. - - `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run. - - `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`. - - `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.) -- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt. - -**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root). - ---- - -## Adding New Tools - -Before adding any tool, settle the footprint question first (see "The -Footprint Ladder" in the Contribution Rubric): most capabilities should NOT -be core tools. For custom or local-only tools, do **not** edit Hermes core. -Use the plugin route instead: create `~/.hermes/plugins//plugin.yaml` -and `~/.hermes/plugins//__init__.py`, then register tools with -`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be -enabled or disabled without touching `tools/` or `toolsets.py`. - -Use the built-in route below only when the user is explicitly contributing a new -core Hermes tool that should ship in the base system. - -Built-in/core tools require changes in **2 files**: - -**1. Create `tools/your_tool.py`:** -```python -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. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from. - -Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step. - -The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string. - -**Path references in tool schemas**: If the schema description mentions file paths (e.g. default output directories), use `display_hermes_home()` to make them profile-aware. The schema is generated at import time, which is after `_apply_profile_override()` sets `HERMES_HOME`. - -**State files**: If a tool stores persistent state (caches, logs, checkpoints), use `get_hermes_home()` for the base directory — never `Path.home() / ".hermes"`. This ensures each profile gets its own state. - -**Agent-level tools** (todo, memory): intercepted by `agent/tool_executor.py` (via the `INLINE_TOOL_EXECUTORS` table in `agent/inline_tool_executors.py`) before `handle_function_call()`. See `tools/todo_tool.py` for the pattern. - ---- +Every former god file is a **facade** (public entry points + the names other packages import) +plus **siblings** `_.py` in the same directory, each owning one topic. Largest +families: `hermes_state.py` (21), `gateway/run.py` (15), `tools/mcp_tool.py` (15), +`hermes_cli/kanban.py` (14), `hermes_cli/web_server.py` (13 + 24 routers), `hermes_cli/auth.py` +(12), `tools/browser_tool.py` (11), `cli.py` (12 `hermes_cli/cli_*_mixin.py`), `run_agent.py` +(`agent/turn_*.py`, `agent_init.py`, `conversation_loop.py`). + +- **Find code by topic, not by facade:** `grep -rn "def name" /_*.py`. Reading the + facade first is the expensive way (`evals/codebase_navigability/`). +- **Siblings may import each other and late-import the facade** inside functions. A facade + never imports a sibling at module level *and* gets imported by that sibling at module level. +- **Patch where production reads.** Siblings often do `from import name` inside the + function so `monkeypatch.setattr(facade, "name", ...)` is the seam; a patch on the defining + module passes silently. Check the call site's binding before writing a patch target + (blind repointing to defining modules broke 130+ tests). +- **Compat pointers are OFF LIMITS in-tree.** Old import paths kept alive for external plugins + (`PLUGIN-COMPAT` blocks, `COMPAT_MANIFEST.md`, `compat_manifest.json`) must not be used by + in-tree code or tests; `scripts/check_compat_pointers.py` runs in CI, and + `-W error::hermes_cli.plugin_compat.HermesPluginCompatWarning` catches them in the suite. + They are removed 2026-09-14 by reverting one commit. Import from the defining module. +- **Don't recreate god files.** A file passing ~2,000 lines or a function passing ~300 lines / + cyclomatic complexity 30 is the signal to split along `_` FIRST, in its own + commit. New behaviour goes in a new or topical sibling — never appended to a facade. +- **No `if/elif` ladders ≥ 4 branches keyed on a name/kind** — use a dict/table → handler + (`_SLASH_DISPATCH` in `cli.py`, `_command_handler_table` in the gateway are the shape). +- **No re-export shims for internal moves** ("keep the old name importable"). Internal paths + are not API; external compat is handled ONCE by the compat layer, not per PR. +- **Moving a symbol means fixing its docs in the same PR:** grep `website/docs`, `docs/`, + `skills/`, and every `AGENTS.md` for the old `path.py` + symbol (23 doc files went stale + after the refactor). `evals/codebase_navigability/static_metrics.py