mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
The shipped tri-state write_mode (on|off|approve) conflated two concepts —
whether writes are enabled and whether they're gated — so 'on' (writes flow
freely, gate inactive) read like 'gating is on'. Replace it with a single
clear boolean gate that defaults off.
memory.write_approval / skills.write_approval:
false (default) — write freely; the approval gate is off (pre-gate behaviour)
true — require approval: memory foreground prompts inline, memory
background-review + all skill writes stage for review
The old 'off = block all writes' mode is dropped; memory_enabled: false already
disables memory entirely, so a third 'block' state was redundant.
- tools/write_approval.py: get_write_mode/MODE_* → write_approval_enabled() bool;
evaluate_gate() loses the config-driven 'blocked' path (blocked now only comes
from an interactive user denial).
- tools/memory_tool.py, tools/skill_manager_tool.py: comment + behaviour follow.
- hermes_cli/config.py: memory/skills write_mode → write_approval (False);
_config_version 28→29 with a 28→29 migration that renames any persisted
write_mode (approve→true, on/off/unset→false) and drops the old key.
- slash commands: '/memory|/skills mode <on|off|approve>' → 'approval <on|off>'
('mode' kept as a back-compat alias); set_mode_fn callback now takes a bool.
- write_approval_commands.py, cli_commands_mixin.py, gateway/slash_commands.py,
commands.py: handlers + registry args/subcommands updated.
- docs + tests rewritten for the boolean model; added migration tests.
209 lines
7.5 KiB
Python
209 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared handlers for the /memory and /skills write-approval subcommands.
|
|
|
|
Both the interactive CLI (``cli.py``) and the gateway (``gateway/run.py``) call
|
|
into this module so the pending-review UX (list / approve / reject / diff /
|
|
mode) lives in one place. Each caller owns only its surface concerns:
|
|
formatting the returned text and, for the gateway, persisting config + evicting
|
|
the cached agent on a mode change.
|
|
|
|
Every public handler returns a plain text string suitable for both a terminal
|
|
and a chat message. Skill diffs are intentionally NOT inlined here — the
|
|
``diff`` handler returns the full diff for the CLI pager, but on a messaging
|
|
platform the gateway truncates it and points the user at the dashboard / file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import List, Optional
|
|
|
|
from tools import write_approval as wa
|
|
|
|
|
|
def _fmt_state(subsystem: str) -> str:
|
|
on = wa.write_approval_enabled(subsystem)
|
|
return f"{subsystem}.write_approval = {'on' if on else 'off'}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Formatting helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _fmt_pending_list(subsystem: str) -> str:
|
|
records = wa.list_pending(subsystem)
|
|
if not records:
|
|
return f"No pending {subsystem} writes."
|
|
lines = [f"Pending {subsystem} writes ({len(records)}):"]
|
|
for r in records:
|
|
origin = r.get("origin", "foreground")
|
|
tag = " [auto]" if origin == "background_review" else ""
|
|
lines.append(f" {r['id']}{tag} {r.get('summary', '')}")
|
|
where = "/{s} approve <id>".format(s=subsystem)
|
|
lines.append("")
|
|
lines.append(f"Apply: {where} Reject: /{subsystem} reject <id>")
|
|
if subsystem == wa.SKILLS:
|
|
lines.append("Review full diff: /skills diff <id>")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subcommand dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def handle_pending_subcommand(
|
|
subsystem: str,
|
|
args: List[str],
|
|
*,
|
|
memory_store=None,
|
|
set_mode_fn=None,
|
|
) -> Optional[str]:
|
|
"""Dispatch a /memory or /skills subcommand.
|
|
|
|
Args:
|
|
subsystem: ``memory`` or ``skills``.
|
|
args: tokens after the slash command (e.g. ``["approve", "a1b2"]``).
|
|
memory_store: live MemoryStore for applying approved memory writes
|
|
(CLI passes ``self.agent._memory_store``; gateway applies against a
|
|
freshly loaded store).
|
|
set_mode_fn: optional callable ``(enabled: bool) -> None`` that
|
|
persists the new write_approval boolean to config (gateway provides
|
|
this; CLI uses its own ``save_config_value`` and passes a closure).
|
|
|
|
Returns a text string to show the user. Returns None when the args are not
|
|
a write-approval subcommand (caller falls through to its other handling,
|
|
e.g. /skills search).
|
|
"""
|
|
if not args:
|
|
# Bare /memory or /skills with no sub → show pending + gate state.
|
|
return f"{_fmt_state(subsystem)}\n\n" + _fmt_pending_list(subsystem)
|
|
|
|
sub = args[0].lower()
|
|
rest = args[1:]
|
|
|
|
if sub == "pending":
|
|
return _fmt_pending_list(subsystem)
|
|
|
|
if sub in {"approve", "apply"}:
|
|
return _approve(subsystem, rest, memory_store)
|
|
|
|
if sub in {"reject", "deny", "drop"}:
|
|
return _reject(subsystem, rest)
|
|
|
|
if sub == "diff" and subsystem == wa.SKILLS:
|
|
return _diff(rest)
|
|
|
|
if sub in {"approval", "mode"}: # 'mode' kept as a back-compat alias
|
|
return _set_approval(subsystem, rest, set_mode_fn)
|
|
|
|
return None # not ours — caller handles
|
|
|
|
|
|
def _resolve_one(subsystem: str, rest: List[str]):
|
|
if not rest:
|
|
return None, f"Usage: /{subsystem} approve|reject <id> (or 'all')"
|
|
return rest[0], None
|
|
|
|
|
|
def _approve(subsystem: str, rest: List[str], memory_store) -> str:
|
|
target, err = _resolve_one(subsystem, rest)
|
|
if err or target is None:
|
|
return err or f"Usage: /{subsystem} approve <id>"
|
|
|
|
records = wa.list_pending(subsystem)
|
|
if not records:
|
|
return f"No pending {subsystem} writes."
|
|
|
|
if target.lower() == "all":
|
|
targets = list(records)
|
|
else:
|
|
rec = wa.get_pending(subsystem, target)
|
|
if not rec:
|
|
return f"No pending {subsystem} write with id '{target}'."
|
|
targets = [rec]
|
|
|
|
applied, failed = 0, []
|
|
for rec in targets:
|
|
ok, msg = _apply_one(subsystem, rec, memory_store)
|
|
if ok:
|
|
wa.discard_pending(subsystem, rec["id"])
|
|
applied += 1
|
|
else:
|
|
failed.append(f"{rec['id']}: {msg}")
|
|
|
|
out = [f"Approved {applied} {subsystem} write(s)."]
|
|
if failed:
|
|
out.append("Failed:")
|
|
out.extend(f" {f}" for f in failed)
|
|
return "\n".join(out)
|
|
|
|
|
|
def _apply_one(subsystem: str, rec, memory_store):
|
|
payload = rec.get("payload", {})
|
|
try:
|
|
if subsystem == wa.MEMORY:
|
|
if memory_store is None:
|
|
return False, "memory store unavailable"
|
|
from tools.memory_tool import apply_memory_pending
|
|
result = apply_memory_pending(payload, memory_store)
|
|
return bool(result.get("success")), result.get("error", "")
|
|
else:
|
|
from tools.skill_manager_tool import apply_skill_pending
|
|
result = json.loads(apply_skill_pending(payload))
|
|
return bool(result.get("success")), result.get("error", "")
|
|
except Exception as e:
|
|
return False, str(e)
|
|
|
|
|
|
def _reject(subsystem: str, rest: List[str]) -> str:
|
|
target, err = _resolve_one(subsystem, rest)
|
|
if err or target is None:
|
|
return err or f"Usage: /{subsystem} reject <id>"
|
|
if target.lower() == "all":
|
|
n = 0
|
|
for rec in wa.list_pending(subsystem):
|
|
if wa.discard_pending(subsystem, rec["id"]):
|
|
n += 1
|
|
return f"Rejected {n} pending {subsystem} write(s)."
|
|
if wa.discard_pending(subsystem, target):
|
|
return f"Rejected pending {subsystem} write '{target}'."
|
|
return f"No pending {subsystem} write with id '{target}'."
|
|
|
|
|
|
def _diff(rest: List[str]) -> str:
|
|
if not rest:
|
|
return "Usage: /skills diff <id>"
|
|
rec = wa.get_pending(wa.SKILLS, rest[0])
|
|
if not rec:
|
|
return f"No pending skill write with id '{rest[0]}'."
|
|
diff = wa.skill_pending_diff(rec)
|
|
header = f"# Pending skill write {rec['id']}: {rec.get('summary', '')}\n"
|
|
return header + "\n" + diff
|
|
|
|
|
|
def _set_approval(subsystem: str, rest: List[str], set_mode_fn) -> str:
|
|
"""Turn the approval gate on/off for a subsystem.
|
|
|
|
``set_mode_fn`` (when provided) persists the new boolean to config.
|
|
"""
|
|
if not rest:
|
|
return (f"{_fmt_state(subsystem)}\n"
|
|
f"Set with: /{subsystem} approval <on|off>")
|
|
arg = rest[0].strip().lower()
|
|
truthy = {"on", "true", "yes", "1", "enable", "enabled"}
|
|
falsey = {"off", "false", "no", "0", "disable", "disabled"}
|
|
if arg in truthy:
|
|
enabled = True
|
|
elif arg in falsey:
|
|
enabled = False
|
|
else:
|
|
return f"Invalid value '{arg}'. Use: on or off."
|
|
if set_mode_fn is None:
|
|
val = "true" if enabled else "false"
|
|
return (f"To change the {subsystem} approval gate, run:\n"
|
|
f" hermes config set {subsystem}.write_approval {val}")
|
|
try:
|
|
set_mode_fn(enabled)
|
|
except Exception as e:
|
|
return f"Failed to set {subsystem}.write_approval: {e}"
|
|
return f"{subsystem}.write_approval set to '{'on' if enabled else 'off'}'."
|