Inspired by Perplexity Computer: Model Council mode for Mixture of Agents
Adds a 'council' synthesis style to MoA (per preset via synthesis_style, one-shot via the new /council command on CLI + gateway). Reference models answer independently; the aggregator chairs the deliberation and produces a user-facing report of consensus, per-model disagreements (with the differing assumptions behind them), unique contributions, and a recommendation with an explicit confidence level. Inspired by Perplexity's Model Council rollout to Perplexity Computer (changelog 08/04/26): pick a board of 2-8 models, run them independently, synthesize where they agree/disagree and what each uniquely surfaces.
This commit is contained in:
parent
1965fde394
commit
8d9e18d40b
|
|
@ -2001,6 +2001,9 @@ def run_conversation(
|
|||
degraded_reference_policy=str(
|
||||
moa_config.get("degraded_reference_policy") or "loud"
|
||||
),
|
||||
synthesis_style=str(
|
||||
moa_config.get("synthesis_style") or "guidance"
|
||||
),
|
||||
agent=agent,
|
||||
)
|
||||
if _moa_context:
|
||||
|
|
|
|||
|
|
@ -1217,6 +1217,7 @@ def aggregate_moa_context(
|
|||
reference_max_tokens: int | None = None,
|
||||
reference_timeout: float | None = None,
|
||||
degraded_reference_policy: str = "loud",
|
||||
synthesis_style: str = "guidance",
|
||||
agent: Any = None,
|
||||
) -> str:
|
||||
"""Run configured reference models and synthesize their advice.
|
||||
|
|
@ -1240,6 +1241,14 @@ def aggregate_moa_context(
|
|||
|
||||
``agent``, when passed, lets the reference fan-out be aborted early on a
|
||||
user interrupt — see ``_run_references_parallel``'s docstring.
|
||||
|
||||
``synthesis_style`` selects how advisor output is synthesized (see
|
||||
``hermes_cli.moa_config.coerce_synthesis_style``): ``"guidance"`` (the
|
||||
default) produces private context for the acting model; ``"council"``
|
||||
(inspired by Perplexity Computer's Model Council, Aug 2026) makes the
|
||||
aggregator act as a council *chair*, producing a user-facing deliberation
|
||||
report that surfaces agreement, disagreement, per-model unique
|
||||
contributions, and a recommendation with confidence.
|
||||
"""
|
||||
reference_models = [slot for slot in reference_models if slot.get("enabled", True)]
|
||||
reference_outputs: list[tuple[str, str, Any]] = []
|
||||
|
|
@ -1297,15 +1306,36 @@ def aggregate_moa_context(
|
|||
f"{notice}"
|
||||
)
|
||||
|
||||
synth_prompt = (
|
||||
"You are the aggregator in a Mixture of Agents process. Synthesize the "
|
||||
"reference responses into concise, actionable guidance for the main "
|
||||
"Hermes agent. Focus on next steps, tool-use strategy, risks, and any "
|
||||
"disagreements. Do not answer the user directly unless that is all that "
|
||||
"is needed; produce context the main agent should use in its normal loop.\n\n"
|
||||
f"Original user prompt:\n{user_prompt}\n\n"
|
||||
f"Reference responses:\n{joined}"
|
||||
)
|
||||
council = str(synthesis_style or "guidance").strip().lower() == "council"
|
||||
if council:
|
||||
synth_prompt = (
|
||||
"You are the CHAIR of a model council. Several independent frontier "
|
||||
"models were each asked the same question and answered without seeing "
|
||||
"each other's responses. Chair the deliberation: produce a council "
|
||||
"report with these sections —\n"
|
||||
"1. Consensus: where the models agree (consensus supports acting with "
|
||||
"confidence).\n"
|
||||
"2. Disagreements: where they diverge, attributing each position to "
|
||||
"its model by label, and identifying the differing starting "
|
||||
"assumptions behind each divergence.\n"
|
||||
"3. Unique contributions: what each model uniquely surfaced that the "
|
||||
"others missed.\n"
|
||||
"4. Chair's recommendation: your synthesized position, with an "
|
||||
"explicit confidence level and what evidence would change it.\n"
|
||||
"Be concrete and attribute positions to models by name.\n\n"
|
||||
f"Original user prompt:\n{user_prompt}\n\n"
|
||||
f"Council member responses:\n{joined}"
|
||||
)
|
||||
else:
|
||||
synth_prompt = (
|
||||
"You are the aggregator in a Mixture of Agents process. Synthesize the "
|
||||
"reference responses into concise, actionable guidance for the main "
|
||||
"Hermes agent. Focus on next steps, tool-use strategy, risks, and any "
|
||||
"disagreements. Do not answer the user directly unless that is all that "
|
||||
"is needed; produce context the main agent should use in its normal loop.\n\n"
|
||||
f"Original user prompt:\n{user_prompt}\n\n"
|
||||
f"Reference responses:\n{joined}"
|
||||
)
|
||||
|
||||
agg_label = _slot_label(aggregator)
|
||||
agg_runtime = _slot_runtime(aggregator)
|
||||
|
|
@ -1351,6 +1381,18 @@ def aggregate_moa_context(
|
|||
if not synthesis:
|
||||
synthesis = joined
|
||||
|
||||
if council:
|
||||
return (
|
||||
"[Model Council report — a board of independent models deliberated "
|
||||
"on the user's question and the chair synthesized their positions. "
|
||||
"Present this deliberation to the user: preserve the "
|
||||
"consensus/disagreement structure and the per-model attributions, "
|
||||
"and add your own judgement where useful.]\n"
|
||||
f"Chair: {agg_label}\n"
|
||||
f"Council members: {', '.join(_slot_label(slot) for slot in reference_models)}\n\n"
|
||||
f"{synthesis.strip()}"
|
||||
)
|
||||
|
||||
return (
|
||||
"[Mixture of Agents context — use this as private guidance for the "
|
||||
"normal Hermes agent loop. You may call tools, continue reasoning, or "
|
||||
|
|
@ -2227,15 +2269,39 @@ class MoAChatCompletions:
|
|||
elif joined or degraded:
|
||||
if degraded:
|
||||
joined = f"{joined}\n\n{degraded}" if joined else degraded
|
||||
guidance = (
|
||||
"[Mixture of Agents reference context]\n"
|
||||
f"Preset: {self.preset_name}\n"
|
||||
f"Aggregator/acting model: {_slot_label(aggregator)}\n"
|
||||
f"References: {', '.join(label for label, _, _ in _agg_refs)}\n\n"
|
||||
"Use the reference responses below as private context. You are the aggregator and acting model: "
|
||||
"answer the user directly or call tools as needed.\n\n"
|
||||
f"{joined}"
|
||||
)
|
||||
if str(preset.get("synthesis_style") or "guidance").strip().lower() == "council":
|
||||
# Council style (inspired by Perplexity Computer's Model
|
||||
# Council, Aug 2026): the acting model chairs a deliberation
|
||||
# instead of consuming private advice. Its user-facing answer
|
||||
# must surface consensus, disagreements (with per-model
|
||||
# attribution and the differing assumptions behind them),
|
||||
# unique contributions, and a recommendation with confidence.
|
||||
guidance = (
|
||||
"[Model Council deliberation]\n"
|
||||
f"Preset: {self.preset_name}\n"
|
||||
f"Chair (you): {_slot_label(aggregator)}\n"
|
||||
f"Council members: {', '.join(label for label, _, _ in _agg_refs)}\n\n"
|
||||
"You are the chair of a model council. The council member "
|
||||
"responses below are independent answers to the current "
|
||||
"state of the task. Chair the deliberation in your reply: "
|
||||
"surface where the members agree (consensus), where they "
|
||||
"disagree (attribute positions to members by name and name "
|
||||
"the differing assumptions), what each uniquely surfaced, "
|
||||
"and close with your own recommendation and an explicit "
|
||||
"confidence level. You may still call tools when the task "
|
||||
"requires acting rather than deliberating.\n\n"
|
||||
f"{joined}"
|
||||
)
|
||||
else:
|
||||
guidance = (
|
||||
"[Mixture of Agents reference context]\n"
|
||||
f"Preset: {self.preset_name}\n"
|
||||
f"Aggregator/acting model: {_slot_label(aggregator)}\n"
|
||||
f"References: {', '.join(label for label, _, _ in _agg_refs)}\n\n"
|
||||
"Use the reference responses below as private context. You are the aggregator and acting model: "
|
||||
"answer the user directly or call tools as needed.\n\n"
|
||||
f"{joined}"
|
||||
)
|
||||
_attach_reference_guidance(agg_messages, guidance)
|
||||
|
||||
prepared_request = {
|
||||
|
|
|
|||
25
cli.py
25
cli.py
|
|
@ -10678,6 +10678,31 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
self._pending_moa_disable_after_turn = True
|
||||
self._pending_agent_seed = payload
|
||||
_cprint(f" MoA one-shot queued with preset {preset}; previous model will be restored after this turn.")
|
||||
elif canonical == "council":
|
||||
# /council <question> — one-shot Model Council (inspired by
|
||||
# Perplexity Computer's Model Council, Aug 2026): the default MoA
|
||||
# preset's reference models answer the question independently, the
|
||||
# preset's aggregator chairs the deliberation, and the council
|
||||
# report (consensus / disagreements / unique contributions /
|
||||
# recommendation with confidence) is handed to the current session
|
||||
# model to present. The session model is never switched.
|
||||
from hermes_cli.moa_config import build_moa_turn_prompt
|
||||
|
||||
parts = cmd_original.split(None, 1)
|
||||
payload = parts[1].strip() if len(parts) > 1 else ""
|
||||
if not payload:
|
||||
_cprint(" Usage: /council <question> (runs the default MoA preset's models as an independent council and reports consensus vs disagreement)")
|
||||
return True
|
||||
try:
|
||||
moa_cfg = self.config.get("moa") if isinstance(self.config, dict) else {}
|
||||
encoded = build_moa_turn_prompt(
|
||||
payload, moa_cfg, synthesis_style="council"
|
||||
)
|
||||
except Exception as exc:
|
||||
_cprint(f" Council setup failed: {exc}")
|
||||
return True
|
||||
self._pending_input.put(encoded)
|
||||
_cprint(" ⚖ Council convened — reference models are deliberating.")
|
||||
elif canonical == "subgoal":
|
||||
self._handle_subgoal_command(cmd_original)
|
||||
elif canonical == "skin":
|
||||
|
|
|
|||
|
|
@ -16132,6 +16132,35 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if canonical == "refine":
|
||||
return await self._handle_refine_command(event)
|
||||
|
||||
if canonical == "council":
|
||||
# /council <question> — one-shot Model Council (inspired by
|
||||
# Perplexity Computer's Model Council, Aug 2026). Encodes the
|
||||
# question as a hidden MoA one-shot marker with the council
|
||||
# synthesis style forced on; run_conversation decodes it, the
|
||||
# default preset's reference models answer independently, and
|
||||
# the chair's consensus/disagreement report reaches the session
|
||||
# model as context. No model switch, no agent eviction.
|
||||
from hermes_cli.moa_config import build_moa_turn_prompt
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
council_payload = event.get_command_args().strip()
|
||||
if not council_payload:
|
||||
return (
|
||||
"Usage: /council <question> (runs the default MoA preset's "
|
||||
"models as an independent council and reports consensus vs "
|
||||
"disagreement)"
|
||||
)
|
||||
try:
|
||||
cfg = load_config()
|
||||
event.text = build_moa_turn_prompt(
|
||||
council_payload,
|
||||
cfg.get("moa") if isinstance(cfg, dict) else {},
|
||||
synthesis_style="council",
|
||||
)
|
||||
except Exception:
|
||||
return "Failed to prepare council turn."
|
||||
# Fall through to _handle_message_with_agent with the rewritten text.
|
||||
|
||||
if canonical == "moa":
|
||||
# /moa is one-shot sugar only: run a single prompt through the
|
||||
# default MoA preset, then restore the prior model. To *switch* to a
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
args_hint="[focus instructions]"),
|
||||
CommandDef("moa", "Run one prompt through the default Mixture of Agents preset, then restore your model", "Session",
|
||||
args_hint="<prompt>", busy_policy="reject", busy_handler="moa"),
|
||||
CommandDef("council", "Convene a model council: reference models answer independently, a chair synthesizes consensus and disagreements", "Session",
|
||||
args_hint="<question>", busy_policy="reject"),
|
||||
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
|
||||
args_hint="[text | remove N | clear]", busy_policy="dispatch"),
|
||||
CommandDef("status", "Show session, model, token, and context info", "Session",
|
||||
|
|
@ -1277,7 +1279,7 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
|
|||
# native slash.
|
||||
# - pause: global emergency stop; reached via /hermes pause [off] on
|
||||
# Slack. Added at the 50-cap — a native slot would clamp /platform.
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "pause"})
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "council", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "pause"})
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@ def _print_config(config: dict[str, Any]) -> None:
|
|||
print(f" {idx}. {_format_slot(slot)}")
|
||||
agg = preset["aggregator"]
|
||||
print(f" Aggregator: {_format_slot(agg)}")
|
||||
style = str(preset.get("synthesis_style") or "guidance")
|
||||
if style != "guidance":
|
||||
print(f" Synthesis style: {style}")
|
||||
|
||||
|
||||
def cmd_moa(args) -> None:
|
||||
|
|
|
|||
|
|
@ -71,6 +71,26 @@ def _coerce_degraded_reference_policy(value: Any) -> str:
|
|||
return policy if policy in {"loud", "silent"} else "loud"
|
||||
|
||||
|
||||
def coerce_synthesis_style(value: Any) -> str:
|
||||
"""Normalize the MoA synthesis style; unknown values degrade to 'guidance'.
|
||||
|
||||
- ``'guidance'`` (default): the classic MoA shape — advisor responses are
|
||||
synthesized into PRIVATE context for the acting model, which then
|
||||
answers the user normally. Advisor perspectives never surface as a
|
||||
deliverable.
|
||||
- ``'council'``: multi-model deliberation shape (inspired by Perplexity
|
||||
Computer's Model Council, Aug 2026): each reference model weighs in
|
||||
independently and the acting model becomes the council *chair*,
|
||||
producing a USER-FACING report that explicitly surfaces where the
|
||||
models agree, where they disagree (and why — differing starting
|
||||
assumptions), what each uniquely contributes, and a recommended
|
||||
position with confidence. Built for ambiguous judgment calls where
|
||||
disagreement between frontier models is itself the signal.
|
||||
"""
|
||||
style = str(value or "guidance").strip().lower()
|
||||
return style if style in {"guidance", "council"} else "guidance"
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: int) -> int:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
|
|
@ -306,6 +326,7 @@ def _default_preset() -> dict[str, Any]:
|
|||
"max_tokens": 4096,
|
||||
"reference_max_tokens": None,
|
||||
"fanout": "user_turn",
|
||||
"synthesis_style": "guidance",
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
|
|
@ -366,6 +387,11 @@ def _normalize_preset(raw: Any) -> dict[str, Any]:
|
|||
# last advisor run. Also accepts the mapping form
|
||||
# {mode: every_n, n: N}, normalized to the canonical string.
|
||||
"fanout": _coerce_fanout(raw.get("fanout")),
|
||||
# How advisor output is synthesized for the acting model. "guidance"
|
||||
# (default) keeps advisor perspectives as private context; "council"
|
||||
# turns the acting model into a council chair producing a user-facing
|
||||
# agree/disagree report (see coerce_synthesis_style).
|
||||
"synthesis_style": coerce_synthesis_style(raw.get("synthesis_style")),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -415,6 +441,7 @@ def normalize_moa_config(raw: Any) -> dict[str, Any]:
|
|||
"max_tokens": active["max_tokens"],
|
||||
"reference_max_tokens": active.get("reference_max_tokens"),
|
||||
"fanout": active.get("fanout", "user_turn"),
|
||||
"synthesis_style": active.get("synthesis_style", "guidance"),
|
||||
"enabled": active["enabled"],
|
||||
# MoA-level (not per-preset) toggles ride at the top level alongside
|
||||
# save_traces. privacy_filter: '' (off, default) | 'display' | 'full'
|
||||
|
|
@ -475,11 +502,24 @@ def set_active_moa_preset(config: Any, name: str | None) -> dict[str, Any]:
|
|||
return cfg
|
||||
|
||||
|
||||
def encode_moa_turn(prompt: str, config: Any = None, preset: str | None = None) -> str:
|
||||
"""Encode a /moa one-shot turn for frontends that can only send text."""
|
||||
def encode_moa_turn(
|
||||
prompt: str,
|
||||
config: Any = None,
|
||||
preset: str | None = None,
|
||||
synthesis_style: str | None = None,
|
||||
) -> str:
|
||||
"""Encode a /moa one-shot turn for frontends that can only send text.
|
||||
|
||||
``synthesis_style``, when given, overrides the resolved preset's style for
|
||||
this one turn (used by ``/council`` to force a council deliberation over
|
||||
the default preset without touching config).
|
||||
"""
|
||||
turn_config = resolve_moa_preset(config or {}, preset)
|
||||
if synthesis_style is not None:
|
||||
turn_config["synthesis_style"] = coerce_synthesis_style(synthesis_style)
|
||||
payload = {
|
||||
"prompt": str(prompt or ""),
|
||||
"config": resolve_moa_preset(config or {}, preset),
|
||||
"config": turn_config,
|
||||
}
|
||||
encoded = base64.urlsafe_b64encode(
|
||||
json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
|
@ -500,9 +540,16 @@ def decode_moa_turn(message: Any) -> tuple[str, dict[str, Any] | None]:
|
|||
return prompt, _normalize_preset(payload.get("config") or {})
|
||||
|
||||
|
||||
def build_moa_turn_prompt(user_prompt: str, config: Any = None, preset: str | None = None) -> str:
|
||||
def build_moa_turn_prompt(
|
||||
user_prompt: str,
|
||||
config: Any = None,
|
||||
preset: str | None = None,
|
||||
synthesis_style: str | None = None,
|
||||
) -> str:
|
||||
"""Build the hidden one-shot payload used by TUI/gateway routing."""
|
||||
return encode_moa_turn(user_prompt, config, preset=preset)
|
||||
return encode_moa_turn(
|
||||
user_prompt, config, preset=preset, synthesis_style=synthesis_style
|
||||
)
|
||||
|
||||
|
||||
def moa_usage() -> str:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
"""Tests for the MoA 'council' synthesis style (Model Council).
|
||||
|
||||
Inspired by Perplexity Computer's Model Council (Aug 2026): reference models
|
||||
answer independently, and the aggregator acts as a council chair producing a
|
||||
user-facing consensus/disagreement report instead of private guidance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.moa_config import (
|
||||
build_moa_turn_prompt,
|
||||
coerce_synthesis_style,
|
||||
decode_moa_turn,
|
||||
normalize_moa_config,
|
||||
)
|
||||
|
||||
|
||||
# ── coerce_synthesis_style normalization ────────────────────────────────────
|
||||
|
||||
|
||||
def test_coerce_synthesis_style_defaults_and_tolerance():
|
||||
assert coerce_synthesis_style(None) == "guidance"
|
||||
assert coerce_synthesis_style("") == "guidance"
|
||||
assert coerce_synthesis_style("guidance") == "guidance"
|
||||
assert coerce_synthesis_style("council") == "council"
|
||||
assert coerce_synthesis_style("COUNCIL ") == "council"
|
||||
# Unknown / bad types degrade to the default (tolerant-read contract).
|
||||
assert coerce_synthesis_style("debate") == "guidance"
|
||||
assert coerce_synthesis_style(42) == "guidance"
|
||||
|
||||
|
||||
def test_normalize_preset_carries_synthesis_style():
|
||||
cfg = normalize_moa_config(
|
||||
{"presets": {"p": {
|
||||
"reference_models": [{"provider": "openrouter", "model": "openai/gpt-5.5"}],
|
||||
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
|
||||
"synthesis_style": "council",
|
||||
}}}
|
||||
)
|
||||
assert cfg["presets"]["p"]["synthesis_style"] == "council"
|
||||
# Flattened compatibility view mirrors the default preset.
|
||||
assert cfg["synthesis_style"] == "council"
|
||||
|
||||
|
||||
def test_normalize_preset_synthesis_style_defaults_to_guidance():
|
||||
cfg = normalize_moa_config({})
|
||||
assert cfg["presets"]["default"]["synthesis_style"] == "guidance"
|
||||
assert cfg["synthesis_style"] == "guidance"
|
||||
|
||||
|
||||
# ── one-shot marker round-trip (/council) ───────────────────────────────────
|
||||
|
||||
|
||||
def test_council_one_shot_marker_round_trip():
|
||||
encoded = build_moa_turn_prompt(
|
||||
"should we launch in Q4 or Q1?", {}, synthesis_style="council"
|
||||
)
|
||||
prompt, config = decode_moa_turn(encoded)
|
||||
assert prompt == "should we launch in Q4 or Q1?"
|
||||
assert config is not None
|
||||
assert config["synthesis_style"] == "council"
|
||||
|
||||
|
||||
def test_moa_one_shot_marker_keeps_guidance_default():
|
||||
encoded = build_moa_turn_prompt("hello", {})
|
||||
_prompt, config = decode_moa_turn(encoded)
|
||||
assert config is not None
|
||||
assert config["synthesis_style"] == "guidance"
|
||||
|
||||
|
||||
# ── aggregate_moa_context council path ──────────────────────────────────────
|
||||
|
||||
|
||||
def _response(content: str = "ok"):
|
||||
message = SimpleNamespace(content=content, tool_calls=[])
|
||||
choice = SimpleNamespace(message=message, finish_reason="stop")
|
||||
return SimpleNamespace(choices=[choice], usage=None, model="fake")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
|
||||
|
||||
def _run_aggregate(monkeypatch, synthesis_style):
|
||||
from agent.moa_loop import aggregate_moa_context
|
||||
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_call_llm(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return _response(
|
||||
"advice" if kwargs.get("task") == "moa_reference" else "synthesis"
|
||||
)
|
||||
|
||||
monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm)
|
||||
|
||||
result = aggregate_moa_context(
|
||||
user_prompt="buy or lease?",
|
||||
api_messages=[{"role": "user", "content": "buy or lease?"}],
|
||||
reference_models=[{"provider": "openrouter", "model": "openai/gpt-5.5"}],
|
||||
aggregator={"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
|
||||
synthesis_style=synthesis_style,
|
||||
)
|
||||
return result, calls
|
||||
|
||||
|
||||
def _flatten(content) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return "".join(
|
||||
part.get("text", "") for part in content if isinstance(part, dict)
|
||||
)
|
||||
|
||||
|
||||
def test_council_style_uses_chair_prompt_and_report_framing(hermes_home, monkeypatch):
|
||||
result, calls = _run_aggregate(monkeypatch, "council")
|
||||
|
||||
agg_calls = [c for c in calls if c.get("task") == "moa_aggregator"]
|
||||
assert len(agg_calls) == 1
|
||||
synth_prompt = _flatten(agg_calls[0]["messages"][-1]["content"])
|
||||
assert "CHAIR of a model council" in synth_prompt
|
||||
assert "Council member responses" in synth_prompt
|
||||
|
||||
# The returned context is a user-facing council report, not private
|
||||
# guidance.
|
||||
assert result.startswith("[Model Council report")
|
||||
assert "Chair: openrouter:anthropic/claude-opus-4.8" in result
|
||||
assert "Council members: openrouter:openai/gpt-5.5" in result
|
||||
|
||||
|
||||
def test_guidance_style_unchanged(hermes_home, monkeypatch):
|
||||
"""Default style must be byte-compatible with the classic MoA framing."""
|
||||
result, calls = _run_aggregate(monkeypatch, "guidance")
|
||||
|
||||
agg_calls = [c for c in calls if c.get("task") == "moa_aggregator"]
|
||||
synth_prompt = _flatten(agg_calls[0]["messages"][-1]["content"])
|
||||
assert "aggregator in a Mixture of Agents process" in synth_prompt
|
||||
assert result.startswith("[Mixture of Agents context")
|
||||
assert "CHAIR" not in synth_prompt
|
||||
|
|
@ -56,6 +56,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
|||
| `/heartbeat every <interval> <prompt>` (alias: `/hb`) | Set a recurring prompt that re-enters **this session** as a normal user turn whenever it's idle and the interval has elapsed (min 60s; missed ticks coalesce). Subcommands: `/heartbeat status`, `/heartbeat pause`, `/heartbeat resume`, `/heartbeat clear`. Session-scoped and in-process — use `hermes cron` for durable isolated schedules. See [Session Heartbeats](/user-guide/features/heartbeat). |
|
||||
| `/refine [focus]` | Run the background memory/skill self-improvement review **now** instead of waiting for the automatic post-turn trigger. Optional focus text steers the review (e.g. `/refine save the deploy workflow as a skill`). Runs in a background fork against a conversation snapshot — the live session and prompt cache are untouched; results are reported when done. |
|
||||
| `/moa <prompt>` | Run a single prompt through the default [Mixture of Agents](/user-guide/features/mixture-of-agents) preset, then restore your current model. One-shot — does not change your session model. |
|
||||
| `/council <question>` | Convene a **model council**: the default MoA preset's reference models answer the question independently, and the aggregator chairs the deliberation — reporting where the models agree, where they disagree (with per-model attribution and the differing assumptions behind each divergence), what each uniquely surfaced, and a recommendation with an explicit confidence level. Built for ambiguous judgment calls where model disagreement is itself the signal. One-shot — does not change your session model. See [Council mode](/user-guide/features/mixture-of-agents#council-mode). |
|
||||
| `/resume [name]` | Resume a previously-named session |
|
||||
| `/sessions` (TUI alias: `/switch`) | Classic CLI: browse and resume previous sessions in an interactive picker. TUI: open the live session switcher for currently open TUI sessions. Use `/sessions new` in the TUI to start another live session immediately. |
|
||||
| `/egress [status]` | Show Docker egress proxy status — enabled/configured/running state, credential source, token mappings, uncovered providers, and next remediation step. Works in CLI, TUI, Desktop chat, and messaging gateway. |
|
||||
|
|
@ -256,6 +257,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
|||
| `/heartbeat every <interval> <prompt>` (alias: `/hb`) | Set a recurring prompt that re-enters this session when idle. Subcommands: `status`, `pause`, `resume`, `clear`. On Slack use `/hermes heartbeat …`. |
|
||||
| `/refine [focus]` | Run the memory/skill self-improvement review now, optionally with focus instructions. On Slack use `/hermes refine …`. |
|
||||
| `/moa <prompt>` | Run one prompt through the default [Mixture of Agents](/user-guide/features/mixture-of-agents) preset, then restore the session model. |
|
||||
| `/council <question>` | One-shot [model council](/user-guide/features/mixture-of-agents#council-mode): reference models answer independently; the chair reports consensus, disagreements, and a recommendation with confidence. |
|
||||
| `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path). |
|
||||
| `/agents` (alias: `/tasks`) | Show active agents and running tasks. |
|
||||
| `/sessions` | Browse and resume previous sessions. |
|
||||
|
|
|
|||
|
|
@ -230,6 +230,65 @@ moa:
|
|||
|
||||
Omit `reasoning_effort` to use the provider/Hermes default for that slot.
|
||||
|
||||
## Council mode
|
||||
|
||||
*Inspired by Perplexity Computer's Model Council (Aug 2026).*
|
||||
|
||||
By default, MoA advisor perspectives are **private context** — the acting model
|
||||
consumes them silently and answers normally. Council mode flips that: the
|
||||
reference models become an explicit **board of independent models**, and the
|
||||
acting model becomes the council **chair**, producing a user-facing
|
||||
deliberation report that surfaces:
|
||||
|
||||
1. **Consensus** — where the models agree (consensus supports acting with
|
||||
confidence).
|
||||
2. **Disagreements** — where they diverge, attributed per model, with the
|
||||
differing starting assumptions behind each divergence.
|
||||
3. **Unique contributions** — what each model surfaced that the others missed.
|
||||
4. **Chair's recommendation** — a synthesized position with an explicit
|
||||
confidence level and what evidence would change it.
|
||||
|
||||
Council mode is built for ambiguous questions: judgment calls, trade-offs,
|
||||
risk assessments — situations where there is no single right answer and
|
||||
disagreement between frontier models is itself the signal.
|
||||
|
||||
### One-shot: `/council`
|
||||
|
||||
```
|
||||
/council should we launch this feature in Q4 or Q1?
|
||||
```
|
||||
|
||||
Runs the default MoA preset's reference models independently against your
|
||||
question, chairs the deliberation, and reports back — then your session
|
||||
continues on your normal model. Works on the CLI, TUI, and every messaging
|
||||
platform (on Slack: `/hermes council …`).
|
||||
|
||||
### Persistent: `synthesis_style: council`
|
||||
|
||||
Set it per preset to make every turn of a MoA session deliberate as a council:
|
||||
|
||||
```yaml
|
||||
moa:
|
||||
presets:
|
||||
board:
|
||||
reference_models:
|
||||
- provider: openrouter
|
||||
model: openai/gpt-5.5
|
||||
- provider: openrouter
|
||||
model: google/gemini-3.1-pro
|
||||
aggregator:
|
||||
provider: openrouter
|
||||
model: anthropic/claude-opus-4.8
|
||||
synthesis_style: council # default: guidance
|
||||
```
|
||||
|
||||
`guidance` (the default) is the classic MoA shape — advisor output stays
|
||||
private. Unknown values degrade to `guidance`.
|
||||
|
||||
Tips (they sharpen council output noticeably): ask for decisions rather than
|
||||
summaries ("what would you bet on and why"), include numbers/timelines/budget,
|
||||
and ask each model to state the assumptions behind its conclusion.
|
||||
|
||||
## Terminal preset management
|
||||
|
||||
```bash
|
||||
|
|
|
|||
Loading…
Reference in New Issue