Compare commits
2 Commits
main
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51dc40208a | ||
|
|
2e10051178 |
@ -1239,6 +1239,23 @@ def init_agent(
|
|||||||
# are noisy.
|
# are noisy.
|
||||||
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
|
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
|
||||||
|
|
||||||
|
# Per-platform prompt-hint overrides (config.yaml → platform_hints).
|
||||||
|
# Lets an enterprise admin append to or replace Hermes' built-in
|
||||||
|
# platform hint for a single messaging platform (e.g. WhatsApp) without
|
||||||
|
# affecting other platforms. Shape:
|
||||||
|
# platform_hints:
|
||||||
|
# whatsapp:
|
||||||
|
# append: "When tabular output would help, invoke the ... skill."
|
||||||
|
# slack:
|
||||||
|
# replace: "Custom Slack hint that fully replaces the default."
|
||||||
|
# Stored verbatim; resolution happens in agent/system_prompt.py against
|
||||||
|
# the active platform. Invalid shapes are ignored defensively so a bad
|
||||||
|
# config entry can never break prompt assembly.
|
||||||
|
_platform_hints_cfg = _agent_cfg.get("platform_hints", {})
|
||||||
|
if not isinstance(_platform_hints_cfg, dict):
|
||||||
|
_platform_hints_cfg = {}
|
||||||
|
agent._platform_hint_overrides = _platform_hints_cfg
|
||||||
|
|
||||||
# App-level API retry count (wraps each model API call). Default 3,
|
# App-level API retry count (wraps each model API call). Default 3,
|
||||||
# overridable via agent.api_max_retries in config.yaml. See #11616.
|
# overridable via agent.api_max_retries in config.yaml. See #11616.
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -61,6 +61,55 @@ def _ra():
|
|||||||
return run_agent
|
return run_agent
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) -> str:
|
||||||
|
"""Apply a per-platform prompt-hint override to the default hint.
|
||||||
|
|
||||||
|
Reads ``agent._platform_hint_overrides`` (populated from
|
||||||
|
``config.yaml`` ``platform_hints`` by ``agent_init``) and resolves the
|
||||||
|
effective hint for *platform_key*:
|
||||||
|
|
||||||
|
* ``replace`` — substitute the default hint entirely.
|
||||||
|
* ``append`` — keep the default and append the extra text.
|
||||||
|
* a bare string value — treated as ``append`` (convenience shorthand).
|
||||||
|
|
||||||
|
Precedence: ``replace`` wins over ``append`` if both are present.
|
||||||
|
Override text is added on top of (not instead of) the SOUL/context/
|
||||||
|
memory tiers — it only affects the platform-hint segment, so other
|
||||||
|
platforms are unaffected and general system instructions still apply.
|
||||||
|
|
||||||
|
Defensive: any malformed entry falls back to the unmodified default so
|
||||||
|
a bad config value can never break prompt assembly or leak across
|
||||||
|
platforms.
|
||||||
|
"""
|
||||||
|
if not platform_key:
|
||||||
|
return default_hint
|
||||||
|
overrides = getattr(agent, "_platform_hint_overrides", None)
|
||||||
|
if not isinstance(overrides, dict) or not overrides:
|
||||||
|
return default_hint
|
||||||
|
spec = overrides.get(platform_key)
|
||||||
|
if spec is None:
|
||||||
|
return default_hint
|
||||||
|
|
||||||
|
# Shorthand: a bare string is treated as append text.
|
||||||
|
if isinstance(spec, str):
|
||||||
|
extra = spec.strip()
|
||||||
|
return f"{default_hint}\n\n{extra}".strip() if extra else default_hint
|
||||||
|
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
return default_hint
|
||||||
|
|
||||||
|
replace_text = spec.get("replace")
|
||||||
|
if isinstance(replace_text, str) and replace_text.strip():
|
||||||
|
base = replace_text.strip()
|
||||||
|
else:
|
||||||
|
base = default_hint
|
||||||
|
|
||||||
|
append_text = spec.get("append")
|
||||||
|
if isinstance(append_text, str) and append_text.strip():
|
||||||
|
return f"{base}\n\n{append_text.strip()}".strip()
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
|
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
|
||||||
"""Assemble the system prompt as three ordered parts.
|
"""Assemble the system prompt as three ordered parts.
|
||||||
|
|
||||||
@ -331,18 +380,25 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||||||
)
|
)
|
||||||
|
|
||||||
platform_key = (agent.platform or "").lower().strip()
|
platform_key = (agent.platform or "").lower().strip()
|
||||||
|
# Resolve the built-in/plugin default hint for this platform, then apply
|
||||||
|
# any per-platform override from config (platform_hints.<platform>).
|
||||||
|
_default_hint = ""
|
||||||
if platform_key in PLATFORM_HINTS:
|
if platform_key in PLATFORM_HINTS:
|
||||||
stable_parts.append(PLATFORM_HINTS[platform_key])
|
_default_hint = PLATFORM_HINTS[platform_key]
|
||||||
elif platform_key:
|
elif platform_key:
|
||||||
# Check plugin registry for platform-specific LLM guidance
|
# Check plugin registry for platform-specific LLM guidance
|
||||||
try:
|
try:
|
||||||
from gateway.platform_registry import platform_registry
|
from gateway.platform_registry import platform_registry
|
||||||
_entry = platform_registry.get(platform_key)
|
_entry = platform_registry.get(platform_key)
|
||||||
if _entry and _entry.platform_hint:
|
if _entry and _entry.platform_hint:
|
||||||
stable_parts.append(_entry.platform_hint)
|
_default_hint = _entry.platform_hint
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
|
||||||
|
if _effective_hint:
|
||||||
|
stable_parts.append(_effective_hint)
|
||||||
|
|
||||||
# ── Context tier (cwd-dependent, may change between sessions) ─
|
# ── Context tier (cwd-dependent, may change between sessions) ─
|
||||||
context_parts: List[str] = []
|
context_parts: List[str] = []
|
||||||
|
|
||||||
|
|||||||
@ -2155,6 +2155,22 @@ DEFAULT_CONFIG = {
|
|||||||
# User-defined quick commands that bypass the agent loop (type: exec only)
|
# User-defined quick commands that bypass the agent loop (type: exec only)
|
||||||
"quick_commands": {},
|
"quick_commands": {},
|
||||||
|
|
||||||
|
# Per-platform system-prompt hint overrides. Lets an admin append to or
|
||||||
|
# replace Hermes' built-in platform hint for a single messaging platform
|
||||||
|
# (WhatsApp, Slack, Telegram, ...) without affecting other platforms.
|
||||||
|
# Useful for enterprise/managed profiles that ship platform-aware skills.
|
||||||
|
# Each key is a platform name; the value is either:
|
||||||
|
# { "append": "extra text" } — keep the default hint, append text
|
||||||
|
# { "replace": "full text" } — substitute the default hint entirely
|
||||||
|
# "extra text" — shorthand for { "append": ... }
|
||||||
|
# `replace` wins over `append` if both are given. Example:
|
||||||
|
# platform_hints:
|
||||||
|
# whatsapp:
|
||||||
|
# append: >
|
||||||
|
# When tabular output would be useful, invoke the
|
||||||
|
# table_formatting skill instead of emitting a Markdown table.
|
||||||
|
"platform_hints": {},
|
||||||
|
|
||||||
# Shell-script hooks — declarative bridge that invokes shell scripts
|
# Shell-script hooks — declarative bridge that invokes shell scripts
|
||||||
# on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call,
|
# on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call,
|
||||||
# subagent_stop, etc.). Each entry maps an event name to a list of
|
# subagent_stop, etc.). Each entry maps an event name to a list of
|
||||||
|
|||||||
@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
|||||||
|
|
||||||
# Auto-extracted from noreply emails + manual overrides
|
# Auto-extracted from noreply emails + manual overrides
|
||||||
AUTHOR_MAP = {
|
AUTHOR_MAP = {
|
||||||
|
"victor@rocketfueldev.com": "victor-kyriazakos",
|
||||||
"286497132+srojk34@users.noreply.github.com": "srojk34",
|
"286497132+srojk34@users.noreply.github.com": "srojk34",
|
||||||
"59806492+sitkarev@users.noreply.github.com": "sitkarev",
|
"59806492+sitkarev@users.noreply.github.com": "sitkarev",
|
||||||
"zheng@omegasys.eu": "omegazheng",
|
"zheng@omegasys.eu": "omegazheng",
|
||||||
|
|||||||
101
tests/agent/test_platform_hint_overrides.py
Normal file
101
tests/agent/test_platform_hint_overrides.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""Tests for per-platform prompt-hint overrides (config.yaml → platform_hints).
|
||||||
|
|
||||||
|
Covers agent/system_prompt.py::_resolve_platform_hint — the resolver that
|
||||||
|
applies append/replace overrides to a platform's default hint. Feature added
|
||||||
|
for enterprise managed profiles (per-platform behavior without affecting other
|
||||||
|
platforms). See HA Core ticket: configurable per-platform prompt hints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
from agent.system_prompt import _resolve_platform_hint
|
||||||
|
|
||||||
|
|
||||||
|
def _agent(overrides):
|
||||||
|
"""Minimal stand-in carrying just the override attribute the resolver reads."""
|
||||||
|
a = types.SimpleNamespace()
|
||||||
|
a._platform_hint_overrides = overrides
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT = "You are on WhatsApp. Do not use markdown."
|
||||||
|
EXTRA = "When tabular output would help, invoke the table_formatting skill."
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolvePlatformHint:
|
||||||
|
def test_no_overrides_returns_default(self):
|
||||||
|
assert _resolve_platform_hint(_agent({}), "whatsapp", DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
def test_missing_attr_returns_default(self):
|
||||||
|
a = types.SimpleNamespace() # no _platform_hint_overrides at all
|
||||||
|
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
def test_platform_not_in_overrides_returns_default(self):
|
||||||
|
a = _agent({"slack": {"append": "x"}})
|
||||||
|
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
def test_append_dict(self):
|
||||||
|
a = _agent({"whatsapp": {"append": EXTRA}})
|
||||||
|
out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
|
||||||
|
assert out == f"{DEFAULT}\n\n{EXTRA}"
|
||||||
|
assert DEFAULT in out and EXTRA in out
|
||||||
|
|
||||||
|
def test_replace_dict(self):
|
||||||
|
a = _agent({"whatsapp": {"replace": EXTRA}})
|
||||||
|
out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
|
||||||
|
assert out == EXTRA
|
||||||
|
assert DEFAULT not in out
|
||||||
|
|
||||||
|
def test_replace_wins_over_append_but_both_applied(self):
|
||||||
|
a = _agent({"whatsapp": {"replace": "BASE", "append": "TAIL"}})
|
||||||
|
out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
|
||||||
|
# replace substitutes the base, append still tacks on
|
||||||
|
assert out == "BASE\n\nTAIL"
|
||||||
|
assert DEFAULT not in out
|
||||||
|
|
||||||
|
def test_bare_string_is_append_shorthand(self):
|
||||||
|
a = _agent({"whatsapp": EXTRA})
|
||||||
|
out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
|
||||||
|
assert out == f"{DEFAULT}\n\n{EXTRA}"
|
||||||
|
|
||||||
|
def test_other_platform_unaffected(self):
|
||||||
|
"""An override for whatsapp must not change telegram's hint."""
|
||||||
|
a = _agent({"whatsapp": {"append": EXTRA}})
|
||||||
|
tg_default = "You are on Telegram. Markdown works."
|
||||||
|
assert _resolve_platform_hint(a, "telegram", tg_default) == tg_default
|
||||||
|
|
||||||
|
def test_empty_platform_key_returns_default(self):
|
||||||
|
a = _agent({"whatsapp": {"append": EXTRA}})
|
||||||
|
assert _resolve_platform_hint(a, "", DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
# --- defensive / malformed input: never break prompt assembly ---
|
||||||
|
|
||||||
|
def test_malformed_spec_list_returns_default(self):
|
||||||
|
a = _agent({"whatsapp": ["not", "valid"]})
|
||||||
|
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
def test_overrides_not_a_dict_returns_default(self):
|
||||||
|
a = _agent(["nope"])
|
||||||
|
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
def test_empty_append_string_returns_default(self):
|
||||||
|
a = _agent({"whatsapp": {"append": " "}})
|
||||||
|
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
def test_empty_replace_falls_back_to_default_base(self):
|
||||||
|
a = _agent({"whatsapp": {"replace": " "}})
|
||||||
|
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
def test_non_string_append_ignored(self):
|
||||||
|
a = _agent({"whatsapp": {"append": 123}})
|
||||||
|
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
def test_replace_with_empty_default_hint(self):
|
||||||
|
"""replace works even when the platform had no built-in default."""
|
||||||
|
a = _agent({"customplat": {"replace": "Custom hint."}})
|
||||||
|
assert _resolve_platform_hint(a, "customplat", "") == "Custom hint."
|
||||||
|
|
||||||
|
def test_append_with_empty_default_hint(self):
|
||||||
|
"""append on a platform with no default just yields the extra text."""
|
||||||
|
a = _agent({"customplat": {"append": "Only this."}})
|
||||||
|
assert _resolve_platform_hint(a, "customplat", "") == "Only this."
|
||||||
@ -116,6 +116,43 @@ You are a CLI AI Agent. Try not to use markdown but simple text
|
|||||||
renderable inside a terminal.
|
renderable inside a terminal.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Customizing platform hints
|
||||||
|
|
||||||
|
The platform hint (Layer 10 above) is the per-surface guidance Hermes
|
||||||
|
injects for Telegram, WhatsApp, Slack, CLI, and other platforms — for
|
||||||
|
example "you are on a terminal, avoid Markdown." The built-in defaults
|
||||||
|
live in `PLATFORM_HINTS` (`agent/system_prompt.py`); plugin-provided
|
||||||
|
platforms supply theirs through the platform registry.
|
||||||
|
|
||||||
|
An administrator can append to or replace a single platform's hint from
|
||||||
|
`config.yaml` via the top-level `platform_hints` key, without touching
|
||||||
|
any other platform:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
platform_hints:
|
||||||
|
whatsapp:
|
||||||
|
append: >
|
||||||
|
When tabular output would be useful, invoke the table_formatting
|
||||||
|
skill instead of emitting a Markdown table.
|
||||||
|
slack:
|
||||||
|
replace: "You are on Slack. Keep responses tight and avoid wide tables."
|
||||||
|
telegram: "Prefer short messages; split long answers." # shorthand = append
|
||||||
|
```
|
||||||
|
|
||||||
|
- `append` — keep the built-in hint and add the extra text after it.
|
||||||
|
- `replace` — substitute the built-in hint entirely.
|
||||||
|
- A bare string — shorthand for `append`.
|
||||||
|
- `replace` wins over `append` when both are present.
|
||||||
|
- A malformed entry is ignored defensively and falls back to the
|
||||||
|
unmodified default, so a bad config value can never break prompt
|
||||||
|
assembly or leak across platforms.
|
||||||
|
|
||||||
|
The override is resolved when the system prompt is built (session start,
|
||||||
|
and again on compaction since that rebuilds the prompt). It produces a
|
||||||
|
byte-stable hint for a fixed config, so it lives in the **stable** tier
|
||||||
|
alongside the built-in hint and does not break prompt caching — it is
|
||||||
|
not a live mid-session mutation of a frozen prompt.
|
||||||
|
|
||||||
## How SOUL.md appears in the prompt
|
## How SOUL.md appears in the prompt
|
||||||
|
|
||||||
`SOUL.md` lives at `~/.hermes/SOUL.md` and serves as the agent's identity — the very first section of the system prompt. The loading logic in `prompt_builder.py` works as follows:
|
`SOUL.md` lives at `~/.hermes/SOUL.md` and serves as the agent's identity — the very first section of the system prompt. The loading logic in `prompt_builder.py` works as follows:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user