refactor(memory,skills): replace tri-state write_mode with boolean write_approval (default off) (#43354)

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.
This commit is contained in:
Teknium
2026-06-09 23:21:14 -07:00
committed by GitHub
parent 9ca9697342
commit 095f526b11
11 changed files with 296 additions and 187 deletions
+8 -8
View File
@@ -1306,12 +1306,12 @@ class CLICommandsMixin:
parts = cmd.strip().split()
args = parts[1:] if len(parts) > 1 else []
if args and args[0].lower() in {"pending", "approve", "apply", "reject",
"deny", "drop", "diff", "mode"}:
"deny", "drop", "diff", "approval", "mode"}:
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
out = handle_pending_subcommand(
wa.SKILLS, args,
set_mode_fn=lambda m: self._save_write_mode("skills", m),
set_mode_fn=lambda enabled: self._save_write_approval("skills", enabled),
)
if out is not None:
print(out)
@@ -1320,7 +1320,7 @@ class CLICommandsMixin:
handle_skills_slash(cmd, ChatConsole())
def _handle_memory_command(self, cmd: str):
"""Handle /memory slash command — pending review + write-mode control."""
"""Handle /memory slash command — pending review + approval-gate toggle."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
parts = cmd.strip().split()
@@ -1329,17 +1329,17 @@ class CLICommandsMixin:
out = handle_pending_subcommand(
wa.MEMORY, args,
memory_store=store,
set_mode_fn=lambda m: self._save_write_mode("memory", m),
set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled),
)
if out is None:
out = ("Unknown /memory subcommand. "
"Use: pending, approve <id>, reject <id>, mode <on|off|approve>.")
"Use: pending, approve <id>, reject <id>, approval <on|off>.")
print(out)
def _save_write_mode(self, subsystem: str, mode: str):
"""Persist <subsystem>.write_mode to config (for /memory|/skills mode)."""
def _save_write_approval(self, subsystem: str, enabled: bool):
"""Persist <subsystem>.write_approval to config (for /memory|/skills approval)."""
from cli import save_config_value
save_config_value(f"{subsystem}.write_mode", mode)
save_config_value(f"{subsystem}.write_approval", bool(enabled))
def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session.
+4 -4
View File
@@ -168,11 +168,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True,
subcommands=("search", "browse", "inspect", "install", "audit",
"pending", "approve", "reject", "diff", "mode")),
CommandDef("memory", "Review pending memory writes / set write mode",
"pending", "approve", "reject", "diff", "approval")),
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
"Tools & Skills",
args_hint="[pending|approve|reject|mode] [id|on|off|approve]",
subcommands=("pending", "approve", "reject", "mode")),
args_hint="[pending|approve|reject|approval] [id|on|off]",
subcommands=("pending", "approve", "reject", "approval")),
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
"Tools & Skills"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
+52 -22
View File
@@ -1653,18 +1653,19 @@ DEFAULT_CONFIG = {
"memory": {
"memory_enabled": True,
"user_profile_enabled": True,
# Write gate for the memory tool (add/replace/remove), applied to BOTH
# Approval gate for memory writes (add/replace/remove), applied to BOTH
# foreground agent turns and the background self-improvement review fork
# (the source of unprompted "wrong assumption" saves users reported):
# on — write freely (default, current behaviour)
# off — never write; the memory tool returns a clean disabled result
# approve — foreground writes block on an inline approve/deny prompt
# (entries are small enough to review in a chat bubble);
# background-review writes are staged for review instead of
# committed (a daemon thread cannot block on a prompt).
# Pending entries: /memory pending, /memory approve <id>,
# /memory reject <id>.
"write_mode": "on",
# (the source of unprompted "wrong assumption" saves users reported).
# false (default) — write freely; the gate is off (pre-gate behaviour)
# true — require approval: foreground writes prompt inline
# (entries are small enough to review in a chat
# bubble); background-review writes are staged
# instead of committed (a daemon thread cannot block
# on a prompt). Review staged entries with
# /memory pending, /memory approve <id>,
# /memory reject <id>.
# To disable memory entirely, use memory_enabled: false instead.
"write_approval": False,
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
# External memory provider plugin (empty = built-in only).
@@ -1769,17 +1770,18 @@ DEFAULT_CONFIG = {
# External hub installs (trusted/community sources) are always
# scanned regardless of this setting.
"guard_agent_created": False,
# Write gate for skill_manage (create/edit/patch/write_file/delete/
# Approval gate for skill_manage (create/edit/patch/write_file/delete/
# remove_file), applied to BOTH foreground agent turns and the
# background self-improvement review fork:
# on — write freely (default, current behaviour)
# off — never write; skill_manage returns a clean disabled result
# approve — stage the write for review instead of committing.
# Pending skills are listed with /skills pending, reviewed
# with /skills diff <id> (full diff — CLI/dashboard/file,
# never crammed into a chat bubble), and applied with
# /skills approve <id> or dropped with /skills reject <id>.
"write_mode": "on",
# background self-improvement review fork.
# false (default) — write freely; the gate is off (pre-gate behaviour)
# true — require approval: stage the write for review
# instead of committing (a SKILL.md is too large to
# review inline, so skills always stage rather than
# prompt). List with /skills pending, inspect with
# /skills diff <id> (full diff — CLI/dashboard/file,
# never crammed into a chat bubble), apply with
# /skills approve <id> or drop with /skills reject <id>.
"write_approval": False,
},
# Curator — background skill maintenance.
@@ -2463,7 +2465,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 28,
"_config_version": 29,
}
# =============================================================================
@@ -4734,6 +4736,34 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
if not quiet:
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
# ── Version 28 → 29: rename memory/skills write_mode → write_approval ──
# The tri-state write_mode (on|off|approve) was replaced by a clear boolean
# write_approval (default false = gate off, writes flow freely; true =
# require approval). Only an explicit "approve" carried gating intent, so
# it maps to true; everything else (on/off/unset) → false. The old
# "off = block all writes" mode is dropped — memory_enabled: false disables
# memory entirely. Only rewrite a key the user actually persisted; never
# invent one.
if current_ver < 29:
config = read_raw_config()
touched = False
for subsystem in ("memory", "skills"):
sub = config.get(subsystem)
if not isinstance(sub, dict) or "write_mode" not in sub:
continue
old = sub.pop("write_mode")
old_norm = old.strip().lower() if isinstance(old, str) else old
sub["write_approval"] = (old_norm == "approve")
config[subsystem] = sub
touched = True
results["config_added"].append(
f"{subsystem}.write_mode → write_approval={sub['write_approval']}"
)
if touched:
save_config(config)
if not quiet:
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
+33 -21
View File
@@ -20,7 +20,10 @@ from typing import List, Optional
from tools import write_approval as wa
_VALID_MODES = (wa.MODE_ON, wa.MODE_OFF, wa.MODE_APPROVE)
def _fmt_state(subsystem: str) -> str:
on = wa.write_approval_enabled(subsystem)
return f"{subsystem}.write_approval = {'on' if on else 'off'}"
# ---------------------------------------------------------------------------
@@ -63,18 +66,17 @@ def handle_pending_subcommand(
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 ``(mode: str) -> None`` that persists the
new write_mode to config (gateway provides this; CLI uses its own
``save_config_value`` and passes a closure).
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 + current mode.
mode = wa.get_write_mode(subsystem)
return f"{subsystem}.write_mode = {mode}\n\n" + _fmt_pending_list(subsystem)
# 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:]
@@ -91,8 +93,8 @@ def handle_pending_subcommand(
if sub == "diff" and subsystem == wa.SKILLS:
return _diff(rest)
if sub == "mode":
return _set_mode(subsystem, rest, set_mode_fn)
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
@@ -179,19 +181,29 @@ def _diff(rest: List[str]) -> str:
return header + "\n" + diff
def _set_mode(subsystem: str, rest: List[str], set_mode_fn) -> str:
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:
cur = wa.get_write_mode(subsystem)
return (f"{subsystem}.write_mode = {cur}\n"
f"Set with: /{subsystem} mode <on|off|approve>")
mode = rest[0].lower()
if mode not in _VALID_MODES:
return f"Invalid mode '{mode}'. Use: on, off, approve."
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:
return (f"To change {subsystem} write mode, run:\n"
f" hermes config set {subsystem}.write_mode {mode}")
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(mode)
set_mode_fn(enabled)
except Exception as e:
return f"Failed to set {subsystem}.write_mode: {e}"
return f"{subsystem}.write_mode set to '{mode}'."
return f"Failed to set {subsystem}.write_approval: {e}"
return f"{subsystem}.write_approval set to '{'on' if enabled else 'off'}'."