Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 261 additions & 15 deletions agent/moa_loop.py

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2431,6 +2431,16 @@ def _ensure_hermes_home_managed(home: Path):
# override the output directory.
"save_traces": False,
"trace_dir": "",
# Privacy redaction filter for advisor (reference) outputs. Advisors
# can echo PII from the conversation (emails, formatted phone numbers)
# and credential shapes into reference blocks, traces, and the
# aggregator prompt. Modes ('' = off, the default):
# "display" — redact user-visible surfaces only (reference blocks
# shown in the UI + saved MoA trace records); the
# aggregator still sees raw advisor text.
# "full" — additionally redact the advisor text injected into
# the aggregator prompt (issue #59959).
"privacy_filter": "",
"presets": {
"default": {
"reference_models": [
Expand Down
64 changes: 61 additions & 3 deletions hermes_cli/moa_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,59 @@ def _coerce_int_or_none(value: Any) -> int | None:


def _coerce_fanout(value: Any) -> str:
"""Normalize the fan-out cadence; unknown values fall back to default."""
"""Normalize the fan-out cadence; unknown values fall back to default.

Canonical values are the strings ``per_iteration``, ``user_turn``, and
``every_n:<N>`` (N >= 2). The ``every_n`` cadence also accepts the mapping
form ``{mode: every_n, n: N}`` from hand-edited YAML and normalizes it to
the canonical string, so the rest of the pipeline (presets, flattened
view, runtime) only ever sees one shape. ``every_n:1`` means "run every
iteration" and collapses to ``per_iteration``; anything unparseable falls
back to ``per_iteration`` (the tolerant-read contract of this module).
"""
if isinstance(value, dict):
# Mapping form: {mode: every_n, n: 3}. Non-every_n mapping modes fall
# through to the string path below (e.g. {mode: user_turn}).
mode = str(value.get("mode") or "").strip().lower()
if mode == "every_n":
n = _coerce_int(value.get("n"), 0)
return f"every_n:{n}" if n >= 2 else "per_iteration"
value = mode
mode = str(value or "").strip().lower()
return mode if mode in {"per_iteration", "user_turn"} else "per_iteration"
if mode in {"per_iteration", "user_turn"}:
return mode
if mode.startswith("every_n"):
_, sep, rest = mode.partition(":")
n = _coerce_int(rest.strip(), 0) if sep else 0
if n >= 2:
return f"every_n:{n}"
return "per_iteration"


def coerce_privacy_filter(value: Any) -> str:
"""Normalize ``moa.privacy_filter`` to '' (off), 'display', or 'full'.

- ``''`` (empty string): filter off — the default. ``false``/``None``/
unknown values land here so a hand-edited config degrades to prior
behavior (tolerant-read contract).
- ``'display'``: redact user-visible surfaces only — the reference blocks
shown in the UI and the saved MoA trace records. The aggregator still
sees raw advisor text, so answer quality is unaffected.
- ``'full'``: additionally redact the advisor text injected into the
aggregator prompt (issue #59959's literal ask). A hand-edited boolean
``true`` maps here because the issue framed the toggle as "redact
before passing to the aggregator".
"""
if value is True:
return "full"
if value is None or value is False:
return ""
mode = str(value).strip().lower()
if mode in {"display", "full"}:
return mode
if mode in {"true", "on", "yes", "1"}:
return "full"
return ""


def _clean_reasoning_effort(value: Any) -> str | None:
Expand Down Expand Up @@ -246,7 +296,11 @@ def _normalize_preset(raw: Any) -> dict[str, Any]:
# iteration, so advice tracks live task state. "user_turn" runs the
# advisors ONCE per user turn (the original MoA shape): the
# aggregator gets their upfront plan-level advice, then acts alone
# for the rest of the tool loop.
# for the rest of the tool loop. "every_n:<N>" (N >= 2) is the middle
# ground: advisors run on the first iteration of each user turn and
# every Nth tool iteration after it; in-between iterations reuse the
# cached guidance from the last advisor run. Also accepts the mapping
# form {mode: every_n, n: N}, normalized to the canonical string.
"fanout": _coerce_fanout(raw.get("fanout")),
}

Expand Down Expand Up @@ -296,6 +350,10 @@ def normalize_moa_config(raw: Any) -> dict[str, Any]:
"reference_max_tokens": active.get("reference_max_tokens"),
"fanout": active.get("fanout", "per_iteration"),
"enabled": active["enabled"],
# MoA-level (not per-preset) toggles ride at the top level alongside
# save_traces. privacy_filter: '' (off, default) | 'display' | 'full'
# — see coerce_privacy_filter for the semantics of each mode.
"privacy_filter": coerce_privacy_filter(raw.get("privacy_filter")),
}


Expand Down
71 changes: 71 additions & 0 deletions tests/hermes_cli/test_moa_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,3 +583,74 @@ def test_slot_max_tokens_absent_by_default():
)
ref = cfg["presets"]["p"]["reference_models"][0]
assert "max_tokens" not in ref


# --- fanout cadence normalization (every_n) ---


def test_fanout_defaults_to_per_iteration():
cfg = normalize_moa_config({})
assert cfg["fanout"] == "per_iteration"


def test_fanout_every_n_string_form_normalized():
cfg = normalize_moa_config({"fanout": "every_n:3"})
assert cfg["fanout"] == "every_n:3"
assert cfg["presets"][DEFAULT_MOA_PRESET_NAME]["fanout"] == "every_n:3"


def test_fanout_every_n_mapping_form_normalized_to_string():
cfg = normalize_moa_config({"fanout": {"mode": "every_n", "n": 4}})
assert cfg["fanout"] == "every_n:4"


def test_fanout_every_n_degenerate_n_falls_back():
# n=1 means "every iteration" — that IS per_iteration; n=0 / negative /
# garbage must never produce a broken cadence string.
assert normalize_moa_config({"fanout": "every_n:1"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": "every_n:0"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": "every_n:-2"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": "every_n:x"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": "every_n"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": {"mode": "every_n"}})["fanout"] == "per_iteration"


def test_fanout_every_n_round_trips_through_normalize():
once = normalize_moa_config({"fanout": "every_n:3"})
twice = normalize_moa_config(once)
assert twice["fanout"] == "every_n:3"
assert twice["presets"][DEFAULT_MOA_PRESET_NAME]["fanout"] == "every_n:3"


def test_fanout_mapping_user_turn_mode_accepted():
cfg = normalize_moa_config({"fanout": {"mode": "user_turn"}})
assert cfg["fanout"] == "user_turn"


# --- privacy_filter normalization ---


def test_privacy_filter_defaults_off():
cfg = normalize_moa_config({})
assert cfg["privacy_filter"] == ""


def test_privacy_filter_modes_normalized():
from hermes_cli.moa_config import coerce_privacy_filter

assert coerce_privacy_filter("display") == "display"
assert coerce_privacy_filter("FULL") == "full"
assert coerce_privacy_filter(True) == "full" # legacy boolean → issue #59959 ask
assert coerce_privacy_filter("true") == "full"
assert coerce_privacy_filter(False) == ""
assert coerce_privacy_filter(None) == ""
assert coerce_privacy_filter("bogus") == ""
assert coerce_privacy_filter("off") == ""


def test_privacy_filter_round_trips_through_normalize():
once = normalize_moa_config({"privacy_filter": "display"})
assert once["privacy_filter"] == "display"
assert normalize_moa_config(once)["privacy_filter"] == "display"
full = normalize_moa_config({"privacy_filter": "full"})
assert normalize_moa_config(full)["privacy_filter"] == "full"
Loading
Loading