feat(agent): per-model system-prompt prelude (operation mode override) - #48101
feat(agent): per-model system-prompt prelude (operation mode override)#48101arminanton wants to merge 1 commit into
Conversation
Adds a system-prompt override mechanism (like the --system-prompt-file flags coding-agent CLIs expose): inject verbatim Markdown files as the FIRST system content, ahead of Hermes' own identity/tools/memory layers, resolved per model via a glob map. The resolved text is plain system content, so each provider routes it to its own system channel (Anthropic system=, OpenAI messages[0], Gemini systemInstruction). Disabled by default; fully fail-soft. Re-resolves whenever the cached system prompt is rebuilt: new session, model switch mid-session (switch_model nulls the cache, agent_runtime_helpers.py:1656), or context compression. Continuing sessions reuse the prior prompt verbatim to keep the prefix cache warm. Optional operating-mode marker (off unless a rule names operating_mode) prepends a short <policy_spec>+<system-reminder> block naming the mode and telling the model to treat it as authoritative over later conflicting framing, so a loaded operating prompt sticks. Overridable via operating_mode_marker (set to "" to disable). Non-contradictory: never tells the model to deny its underlying model. Complementary to NousResearch#44610 (which reshapes existing stable-tier fragments); this prepends a new per-model tier ahead of everything. 19 tests; documented config block in cli-config.yaml.example.
…file-residual analysis - external_cherrypick_all_prs.sh: real git cherry-pick of all 40 feature PR REMOTE heads onto fresh v0.17.0 = 37 CLEAN + 2 documented drifts (NousResearch#48069, NousResearch#50056); NousResearch#48101 conflict is a transient (clean in isolation). - INDEPENDENT-VERIFICATION-ROUND3.md: confirms NousResearch#50033 remote head carries the utf-8 fix; explains all 94 whole-file diffs (upstream drift / PR-adds-new-file / private-deferred); per-file content-containment vs remote heads = 0 uncovered.
…usResearch#4) Full stack on v0.17.0: build OK (0 compile errors), test slice 672 passed / 0 failed vs src baseline 604 passed / 0 failed (delta = PR-added new tests; both 100% pass, no regressions). Cherry-pick 37 clean + 3 documented-drift-resolved. Operator apply-time conflict notes added to NousResearch#48069/NousResearch#50056/NousResearch#48101 descriptions.
- OVERLAP-DISJOINTNESS-TABLE.txt: 14/14 overlap files have pairwise-disjoint hunks (empirical clean sequential apply onto v0.17.0). Corrects a v0.16-coordinate false-positive. - fresh_clone_repro.sh + .out: fresh fork clone, cherry-pick 40 PRs onto v0.17.0, diff vs src. 5 apparent-residuals all classified (4 deferred-by-design in NousResearch#50111 + 1 NousResearch#48101 bulk-stack artifact, correct standalone); 0 real residual. - pristine-baseline-COMMAND.txt: exact repro command + output; NousResearch#50066/NousResearch#50086 failure sets byte-identical to pristine v0.17.0 (comm -23 empty). - NousResearch#50111 confirmed isolated (0 importable src), not required for src re-application.
…olution semantic review Council: (3) per-hunk justification — every unmapped hunk blamed via git log -S to its origin commit, mapped to a standing user instruction (exclusion) OR a shipped PR. 216/216 accounted, 0 uncovered. Found+resolved 11 initially-uncovered (all in shipped test-cluster PRs NousResearch#48065/NousResearch#48101/NousResearch#49644/NousResearch#50032/NousResearch#50080/NousResearch#50078). (4) semantic-equivalence review of the resolution patches: all 6 active ones re-anchor their PR's exact intent onto v0.17.0, no silent behavior change; removed the DEAD agent_gemini_cloudcode_adapter patch (never invoked — withdrawn file — and imported withdrawn google_user_agent).
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused, cache-aware implementation. The feature premise remains valid: current agent/system_prompt.py:497-520 exposes and joins only stable, context, and volatile; there is no equivalent per-model leading prelude on main.
Problems
agent/system_prompt_prelude.py:232ignores theproviderargument accepted at line 214, although the caller supplies it atagent/system_prompt.py:101-103. Provider-qualified glob rules therefore cannot work whenagent.modelis bare.agent/system_prompt_prelude.py:228-230hardcodes~/.hermes; use profile-awareget_hermes_home()instead. The canonical resolver is platform-aware athermes_constants.py:46-77.- The new configuration is absent from
hermes_cli/config.py:976DEFAULT_CONFIG, andHERMES_PRELUDE_CONFIGatagent/system_prompt_prelude.py:143adds a non-secret configuration environment override contrary toAGENTS.md:62-64. website/docs/developer-guide/prompt-assembly.md:29-35still documents three tiers, while this PR adds a fourth.
Suggested changes
- Match both
provider/modeland bare model IDs; add an end-to-end temp-HERMES_HOMEtest through prompt assembly. - Derive the default directory from
get_hermes_home(), add it toDEFAULT_CONFIG, remove the environment override, and update the prompt-assembly documentation.
Automated hermes-sweeper review.
| str(cfg.get("base_dir") or "~/.hermes/system-prompts").strip() | ||
| ) | ||
| first_match = cfg.get("first_match", True) | ||
| ids = _candidate_ids(model) |
There was a problem hiding this comment.
provider is accepted by resolve_prelude() and passed from the prompt builder, but this candidate list ignores it. A rule such as anthropic/* cannot match when agent.model is claude-opus-4-6; construct a provider/model candidate as well as the bare-model candidate and add coverage for that runtime shape.
| return PreludeResolution("", [], None) | ||
|
|
||
| base_dir = os.path.expanduser( | ||
| str(cfg.get("base_dir") or "~/.hermes/system-prompts").strip() |
There was a problem hiding this comment.
This default hardcodes the default-profile POSIX path. It bypasses HERMES_HOME for named profiles and is incorrect on native Windows, where get_hermes_home() resolves under LOCALAPPDATA. Derive the implicit directory from get_hermes_home() / "system-prompts" instead.
| # Env override lets a test or a sandboxed run point at a different config | ||
| # without editing config.yaml. Value is a path to a YAML file with the same | ||
| # top-level ``system_prompt_prelude`` block. | ||
| override = (os.getenv("HERMES_PRELUDE_CONFIG") or "").strip() |
There was a problem hiding this comment.
Please remove this user-facing non-secret configuration environment override. The project configuration policy requires behavioral settings to live in config.yaml; use a temp HERMES_HOME and config fixture for tests instead.
What does this PR do?
Adds a system-prompt override mechanism, the kind coding-agent CLIs (Claude Code and others) expose via
--system-prompt-file/--append-system-prompt-file. It lets an operator program a model to behave or operate to a specific standard at the system level: one or more verbatim Markdown files are injected as the first system content, ahead of Hermes' own identity/tools/memory layers, resolved per model via a glob map.Because the resolved text is plain system content, each provider routes it to its own system channel with no special-casing (Anthropic
system=, OpenAImessages[0] {role:"system"}, GeminisystemInstruction). It's disabled by default and entirely fail-soft (any missing file / malformed config yields an empty prelude and never breaks prompt build).Why
Today the built-in guidance blocks are hardcoded; there's no clean way to hand a model a full, model-appropriate operating prompt without forking. This is useful when you want a specific model family to operate to a known standard. For example, you can run an Opus-class model to a named operating standard (e.g. operate as
F@bl3 5) by pointing a*opus*rule at that prompt file — the model adopts the standard while Hermes' own layers still ride on top.When is the prelude (re)loaded?
The system prompt is built once per session and cached, so the upstream prefix cache stays warm across turns. The prelude re-resolves whenever that cached prompt is rebuilt:
conversation_loop.pybuild path);switch_modelnullsagent._cached_system_prompt(agent_runtime_helpers.py:1656), so the next turn rebuilds and re-resolves to the new model's prelude;conversation_compression.py:507).A continuing session reuses the previous turn's prompt verbatim (to keep the cache prefix matching) until one of the above fires.
Changes Made
agent/system_prompt_prelude.py(new) — fail-soft resolver. Reads asystem_prompt_preludeconfig block (or a standalone YAML viaHERMES_PRELUDE_CONFIG), matches the runtime model id against fnmatch globs (both the fullprovider/modeland the bare tail, case-insensitive), and stacks the matched files verbatim. First-match or layered (first_match: false). Returns aPreludeResolutioncarrying the text + provenance.agent/system_prompt.py— resolve the prelude at the top ofbuild_system_prompt_parts, expose it as a newpreludekey, and join it ahead ofstable/context/volatileinbuild_system_prompt.hermes_cli/prompt_size.py— report thepreludetier in the prompt-size breakdown.cli-config.yaml.example— documented (commented-out)system_prompt_preludeblock including the optional operating-mode marker and its toggles.tests/agent/test_system_prompt_prelude.py(new) — 19 tests: single match, stacking order, most-specific-first, bare-tail + case-insensitive matching, no-match/disabled/missing-file/all-missing fail-soft, layered mode, dedupe, absolute paths, empty/None model, and the operating-mode marker (prepended when set,profilealias, absent, custom template, empty-disables).Optional operating-mode marker
A rule may name an
operating_mode(e.g.operating_mode: "House"). When set, a short marker is prepended ahead of the prelude files that (a) names the active mode so the model can transparently report it ("running as House"), and (b) tells the model to treat the mode as authoritative over later conflicting framing in the prompt. That second part is what makes a loaded operating prompt actually stick instead of being second-guessed by the trailingModel:/Provider:metadata line. It's built from two framing tags aligned models weight as authoritative system content —<policy_spec>(the non-negotiable rule) and<system-reminder>(descriptive system info). It is off unless a rule names a mode, the template is overridable via the top-leveloperating_mode_markerconfig key, and setting that key to""disables the marker entirely while keeping the mode name on the resolution. The marker is deliberately non-contradictory: it never tells the model to deny its underlying model, only to operate to the named standard.Relation to prior work
Complementary to #44610 (
agent.prompt_overrides): that reshapes Hermes' existing stable-tier fragments (replace/append/prepend/remove a named block); this prepends a new per-model operator tier ahead of everything. Different mechanism, different use case — they compose cleanly.Type of Change
How to Test
~/.hermes/system-prompts/opus-operating.mdwith some operating text.config.yaml:hermes prompt-sizeshows the newpreludetier;HERMES_DUMP_REQUESTS=1shows the prelude leading the system content).enabled: false(default) or no matching rule, the prompt is byte-identical to before.Checklist
Code
feat(agent):)pytest tests/ -qand all tests pass (19 new + the existingtests/agent/test_system_prompt.py+test_system_prompt_restore.py, no regression)Documentation & Housekeeping
cli-config.yaml.exampleif I added/changed config keys — done (newsystem_prompt_preludeblock)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Aℹ️ Apply-time note (operator)
This PR cherry-picks CLEAN onto v0.17.0 (
2bd1977d8), both standalone and after the other system-prompt-touching PR (#49917). A "conflict" seen in one bulk-stack experiment was a transient working-tree state from an earlier PR's conflict resolution in the same run, NOT a real conflict in this PR. Apply order: after #49917 is fine; either order resolves cleanly.