From 415669c2e4c09a3dce1112864ed97c490988a7ef Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Fri, 17 Jul 2026 23:07:57 +0200 Subject: [PATCH] Keep Leo candidate reasoning grounded and stop automatic chat reinforcement (#180) * Preserve candidate reasoning and disable automatic chat reinforcement * Retain exact-row guards for combined claim audits --- deploy/auto-deploy.sh | 49 ++++++-- deploy/deploy.sh | 15 ++- hermes-agent/install-hermes.sh | 4 + hermes-agent/leoclean-bin/kb_tool.py | 24 +++- .../enforce_leoclean_learning_policy.py | 114 ++++++++++++++++++ scripts/leo_behavior_manifest.py | 14 ++- .../test_enforce_leoclean_learning_policy.py | 76 ++++++++++++ .../test_hermes_leoclean_kb_bridge_source.py | 33 +++++ tests/test_leo_behavior_manifest.py | 5 + tests/test_teleo_agent_systemd.py | 6 + 10 files changed, 326 insertions(+), 14 deletions(-) create mode 100644 hermes-agent/patches/enforce_leoclean_learning_policy.py create mode 100644 tests/test_enforce_leoclean_learning_policy.py diff --git a/deploy/auto-deploy.sh b/deploy/auto-deploy.sh index 1b7d517..cc2da8c 100755 --- a/deploy/auto-deploy.sh +++ b/deploy/auto-deploy.sh @@ -19,8 +19,10 @@ AGENT_STATE_DIR="/opt/teleo-eval/ops/agent-state" LEOCLEAN_BIN_DIR="/home/teleo/.hermes/profiles/leoclean/bin" LEOCLEAN_SKILLS_DIR="/home/teleo/.hermes/profiles/leoclean/skills" LEOCLEAN_PLUGINS_DIR="/home/teleo/.hermes/profiles/leoclean/plugins" +LEOCLEAN_CONFIG="/home/teleo/.hermes/profiles/leoclean/config.yaml" HERMES_AGENT_DIR="/home/teleo/.hermes/hermes-agent" HERMES_PATCH_DIR="/home/teleo/.hermes/teleo-runtime-patches" +LEOCLEAN_POLICY_PATCH="$HERMES_PATCH_DIR/enforce_leoclean_learning_policy.py" SYSTEMD_DIR="/etc/systemd/system" STAMP_FILE="/opt/teleo-eval/.last-deploy-sha" LOG_TAG="auto-deploy" @@ -55,6 +57,7 @@ NEW_SHA=$(git rev-parse "$DEPLOY_REMOTE/main") OLD_SHA=$(cat "$STAMP_FILE" 2>/dev/null || echo "none") if [ "$NEW_SHA" = "$OLD_SHA" ]; then + LEOCLEAN_DRIFT_REPAIRED=false # Hermes can be upgraded independently of this repository. Repair an # overwritten response-transform hook even when no infrastructure commit # changed, and restart only when the patch was actually reapplied. @@ -65,14 +68,25 @@ if [ "$NEW_SHA" = "$OLD_SHA" ]; then fi if echo "$PATCH_RESULT" | grep -q '"status": "installed_now"'; then log "Hermes response-transform drift repaired: $PATCH_RESULT" - if systemctl is-active --quiet leoclean-gateway.service; then - sudo systemctl restart leoclean-gateway - sleep 10 - if ! systemctl is-active --quiet leoclean-gateway.service; then - log "ERROR: leoclean-gateway failed after response-transform drift repair" - exit 1 - fi - fi + LEOCLEAN_DRIFT_REPAIRED=true + fi + fi + if [ -f "$LEOCLEAN_POLICY_PATCH" ] && [ -f "$LEOCLEAN_CONFIG" ]; then + if ! POLICY_RESULT=$(python3 "$LEOCLEAN_POLICY_PATCH" "$LEOCLEAN_CONFIG"); then + log "ERROR: leoclean learning-policy drift could not be repaired" + exit 1 + fi + if echo "$POLICY_RESULT" | grep -q '"status": "updated"'; then + log "Leoclean learning-policy drift repaired: $POLICY_RESULT" + LEOCLEAN_DRIFT_REPAIRED=true + fi + fi + if $LEOCLEAN_DRIFT_REPAIRED && systemctl is-active --quiet leoclean-gateway.service; then + sudo systemctl restart leoclean-gateway + sleep 10 + if ! systemctl is-active --quiet leoclean-gateway.service; then + log "ERROR: leoclean-gateway failed after runtime-policy drift repair" + exit 1 fi fi exit 0 @@ -152,6 +166,14 @@ if [ -f "$HERMES_PATCH_DIR/apply_response_transform_hook.py" ]; then PATCH_RESULT=$(python3 "$HERMES_PATCH_DIR/apply_response_transform_hook.py" "$HERMES_AGENT_DIR/run_agent.py") log "Hermes response transform: $PATCH_RESULT" fi +LEOCLEAN_POLICY_CHANGED=false +if [ -f "$LEOCLEAN_POLICY_PATCH" ] && [ -f "$LEOCLEAN_CONFIG" ]; then + POLICY_RESULT=$(python3 "$LEOCLEAN_POLICY_PATCH" "$LEOCLEAN_CONFIG") + log "Leoclean learning policy: $POLICY_RESULT" + if echo "$POLICY_RESULT" | grep -q '"status": "updated"'; then + LEOCLEAN_POLICY_CHANGED=true + fi +fi # Safety net: ensure synced .sh files are executable after rsync. # Keep this bounded to deploy-owned paths: /opt/teleo-eval also contains @@ -216,6 +238,9 @@ if [ "$OLD_SHA" != "none" ]; then | bash deploy/leoclean-gateway-restart-required.sh; then add_restart_if_unit_active leoclean-gateway fi + if $LEOCLEAN_POLICY_CHANGED; then + add_restart_if_unit_active leoclean-gateway + fi else RESTART="teleo-pipeline teleo-diagnostics" add_restart_if_unit_active teleo-agent@leo @@ -247,6 +272,14 @@ if [ -n "$RESTART" ]; then FAIL=1 fi fi + if [ "$svc" = "leoclean-gateway" ] && [ -f "$LEOCLEAN_POLICY_PATCH" ]; then + if POLICY_CHECK=$(python3 "$LEOCLEAN_POLICY_PATCH" --check "$LEOCLEAN_CONFIG"); then + log "leoclean-gateway learning policy: $POLICY_CHECK" + else + log "ERROR: leoclean-gateway learning policy check failed: $POLICY_CHECK" + FAIL=1 + fi + fi done if echo "$RESTART" | grep -q "teleo-pipeline"; then diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 5d0950a..9035e59 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -13,8 +13,10 @@ VPS_AGENT_STATE="/opt/teleo-eval/ops/agent-state" VPS_LEOCLEAN_BIN="/home/teleo/.hermes/profiles/leoclean/bin" VPS_LEOCLEAN_SKILLS="/home/teleo/.hermes/profiles/leoclean/skills" VPS_LEOCLEAN_PLUGINS="/home/teleo/.hermes/profiles/leoclean/plugins" +VPS_LEOCLEAN_CONFIG="/home/teleo/.hermes/profiles/leoclean/config.yaml" VPS_HERMES_AGENT="/home/teleo/.hermes/hermes-agent" VPS_HERMES_PATCHES="/home/teleo/.hermes/teleo-runtime-patches" +VPS_LEOCLEAN_POLICY_PATCH="$VPS_HERMES_PATCHES/enforce_leoclean_learning_policy.py" VPS_SYSTEMD="/etc/systemd/system" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -109,12 +111,18 @@ rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/hermes-agent/leoclean-plugins/vps/" "$VPS_H echo "" echo "=== Hermes runtime patches ===" +LEOCLEAN_POLICY_CHANGED=false if $DRY_RUN; then - echo "Would sync hermes-agent/patches/ and verify the installed response-transform hook." + echo "Would sync hermes-agent/patches/, verify the response-transform hook, and enforce the leoclean learning policy." else ssh "$VPS_HOST" "mkdir -p '$VPS_HERMES_PATCHES'" rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/hermes-agent/patches/" "$VPS_HOST:$VPS_HERMES_PATCHES/" ssh "$VPS_HOST" "python3 '$VPS_HERMES_PATCHES/apply_response_transform_hook.py' '$VPS_HERMES_AGENT/run_agent.py'" + POLICY_RESULT=$(ssh "$VPS_HOST" "python3 '$VPS_LEOCLEAN_POLICY_PATCH' '$VPS_LEOCLEAN_CONFIG'") + echo "$POLICY_RESULT" + if echo "$POLICY_RESULT" | grep -q '"status": "updated"'; then + LEOCLEAN_POLICY_CHANGED=true + fi fi echo "" @@ -142,4 +150,9 @@ if $RESTART; then echo "=== Restarting services ===" ssh "$VPS_HOST" "sudo systemctl restart teleo-pipeline teleo-diagnostics; if systemctl is-active --quiet teleo-agent@leo.service; then sudo systemctl restart teleo-agent@leo; fi; if systemctl list-units --all --full teleo-agent@leo-wallet-test.service --no-legend | grep -q .; then sudo systemctl restart teleo-agent@leo-wallet-test; fi; if systemctl is-active --quiet leoclean-gateway.service; then sudo systemctl restart leoclean-gateway; fi" echo "Services restarted." +elif $LEOCLEAN_POLICY_CHANGED; then + echo "" + echo "=== Restarting leoclean for learning-policy change ===" + ssh "$VPS_HOST" "if systemctl is-active --quiet leoclean-gateway.service; then sudo systemctl restart leoclean-gateway; fi" + echo "Leoclean gateway restarted." fi diff --git a/hermes-agent/install-hermes.sh b/hermes-agent/install-hermes.sh index ededeaf..8822e69 100755 --- a/hermes-agent/install-hermes.sh +++ b/hermes-agent/install-hermes.sh @@ -59,6 +59,10 @@ terminal: memory: enabled: true search: sqlite_fts5 + nudge_interval: 0 + +skills: + creation_nudge_interval: 0 tools: web_search: true diff --git a/hermes-agent/leoclean-bin/kb_tool.py b/hermes-agent/leoclean-bin/kb_tool.py index 0557d1d..7c431f7 100755 --- a/hermes-agent/leoclean-bin/kb_tool.py +++ b/hermes-agent/leoclean-bin/kb_tool.py @@ -587,9 +587,7 @@ def operational_contracts( apply_receipt_question = any( term in lowered for term in ("canonical receipt", "postflight", "closure proof", "before/after") - ) or bool( - re.search(r"\bapplied_at\b\s+(?:readback|proof|receipt|postflight)\b", lowered) - ) + ) or bool(re.search(r"\bapplied_at\b\s+(?:readback|proof|receipt|postflight)\b", lowered)) if apply_receipt_question: contracts.append( { @@ -685,8 +683,26 @@ def operational_contracts( ) ) ) + existing_claim_audit_question = any( + term in lowered + for term in ( + "inspect one exact claim", + "exact claim body and its evidence", + "one retrieved canonical claim", + "linked source or evidence id", + ) + ) + candidate_claim_composition_question = bool( + any( + term in lowered + for term in ("candidate claim", "reviewable candidate", "candidate only", "revised exact claim body") + ) + and any(term in lowered for term in ("draft", "propose", "revise", "rewrite", "narrow", "return")) + and not existing_claim_audit_question + ) claim_evidence_challenge_question = bool( - any(term in lowered for term in ("claim", "exact body", "claim body")) + not candidate_claim_composition_question + and any(term in lowered for term in ("claim", "exact body", "claim body")) and any(term in lowered for term in ("evidence", "source")) and any( term in lowered diff --git a/hermes-agent/patches/enforce_leoclean_learning_policy.py b/hermes-agent/patches/enforce_leoclean_learning_policy.py new file mode 100644 index 0000000..df8165a --- /dev/null +++ b/hermes-agent/patches/enforce_leoclean_learning_policy.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Disable automatic chat-to-memory and chat-to-skill reinforcement for leoclean.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import tempfile +from pathlib import Path +from typing import Any + +import yaml + +POLICY_VERSION = "teleo-leoclean-learning-policy-v1" +BACKUP_SUFFIX = ".before-teleo-learning-policy" +MEMORY_NUDGE_INTERVAL = 0 +SKILL_CREATION_NUDGE_INTERVAL = 0 + + +def _load_config(path: Path) -> dict[str, Any]: + if path.is_symlink(): + raise ValueError("refusing symlinked profile config") + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + raise ValueError("profile config must be a YAML mapping") + return raw + + +def policy_projection(config: dict[str, Any]) -> dict[str, int | None]: + memory = config.get("memory") if isinstance(config.get("memory"), dict) else {} + skills = config.get("skills") if isinstance(config.get("skills"), dict) else {} + return { + "memory_nudge_interval": memory.get("nudge_interval"), + "skill_creation_nudge_interval": skills.get("creation_nudge_interval"), + } + + +def enforce_policy(path: Path, *, check: bool = False) -> tuple[dict[str, Any], int]: + config = _load_config(path) + before = policy_projection(config) + expected = { + "memory_nudge_interval": MEMORY_NUDGE_INTERVAL, + "skill_creation_nudge_interval": SKILL_CREATION_NUDGE_INTERVAL, + } + changed = before != expected + + if check: + return { + "policy_version": POLICY_VERSION, + "status": "change_required" if changed else "compliant", + "target": str(path), + "policy": before, + }, 1 if changed else 0 + + backup_path = path.with_name(path.name + BACKUP_SUFFIX) + if changed: + memory = config.setdefault("memory", {}) + skills = config.setdefault("skills", {}) + if not isinstance(memory, dict) or not isinstance(skills, dict): + raise ValueError("memory and skills config sections must be YAML mappings") + memory["nudge_interval"] = MEMORY_NUDGE_INTERVAL + skills["creation_nudge_interval"] = SKILL_CREATION_NUDGE_INTERVAL + + if not backup_path.exists(): + shutil.copy2(path, backup_path) + + mode = path.stat().st_mode & 0o777 + rendered = yaml.safe_dump(config, sort_keys=False, allow_unicode=False) + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False + ) as handle: + handle.write(rendered) + temp_path = Path(handle.name) + try: + temp_path.chmod(mode) + os.replace(temp_path, path) + finally: + temp_path.unlink(missing_ok=True) + + after = policy_projection(_load_config(path)) + if after != expected: + raise ValueError("profile config did not retain the required learning policy") + return { + "policy_version": POLICY_VERSION, + "status": "updated" if changed else "already_compliant", + "target": str(path), + "backup": str(backup_path) if changed else None, + "policy": after, + }, 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("target", type=Path) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + try: + result, code = enforce_policy(args.target, check=args.check) + except (OSError, ValueError, yaml.YAMLError) as exc: + result = { + "policy_version": POLICY_VERSION, + "status": "error", + "target": str(args.target), + "error": type(exc).__name__, + } + code = 2 + print(json.dumps(result, sort_keys=True)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/leo_behavior_manifest.py b/scripts/leo_behavior_manifest.py index 39755a6..65238f2 100644 --- a/scripts/leo_behavior_manifest.py +++ b/scripts/leo_behavior_manifest.py @@ -235,7 +235,15 @@ def identity_config_projection(raw: dict[str, Any]) -> dict[str, Any]: section_fields = { "model": ("provider", "default", "smart_routing", "smart_routing_model", "max_tokens"), "agent": ("reasoning_effort",), - "memory": ("enabled", "search"), + "memory": ( + "enabled", + "memory_enabled", + "user_profile_enabled", + "search", + "nudge_interval", + "flush_min_turns", + ), + "skills": ("creation_nudge_interval",), "tools": ("allowed", "write_enabled"), "identity_runtime": ("network_send_enabled", "database_write_enabled", "allowed_tools"), } @@ -328,6 +336,8 @@ def safe_model_config(config_path: Path) -> dict[str, Any]: "reasoning_effort": _nested(identity_config, "agent", "reasoning_effort"), "memory_enabled": _nested(identity_config, "memory", "enabled"), "memory_search": _nested(identity_config, "memory", "search"), + "automatic_memory_review_interval": _nested(identity_config, "memory", "nudge_interval"), + "automatic_skill_review_interval": _nested(identity_config, "skills", "creation_nudge_interval"), } @@ -495,6 +505,8 @@ def identity_runtime_payload(manifest: dict[str, Any]) -> dict[str, Any]: "reasoning_effort", "memory_enabled", "memory_search", + "automatic_memory_review_interval", + "automatic_skill_review_interval", "base_model_weights", "actual_per_turn_model_must_be_recorded_by_the_call_receipt", ) diff --git a/tests/test_enforce_leoclean_learning_policy.py b/tests/test_enforce_leoclean_learning_policy.py new file mode 100644 index 0000000..c04b3f5 --- /dev/null +++ b/tests/test_enforce_leoclean_learning_policy.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +PATCH_PATH = ROOT / "hermes-agent" / "patches" / "enforce_leoclean_learning_policy.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("enforce_leoclean_learning_policy", PATCH_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_learning_policy_disables_automatic_reviews_without_removing_explicit_tools(tmp_path: Path) -> None: + module = _load_module() + config_path = tmp_path / "config.yaml" + original = """model: + provider: openrouter +gateway: + telegram: + bot_token: private-test-token +memory: + memory_enabled: true + user_profile_enabled: true + nudge_interval: 10 +skills: + external_dirs: [] + creation_nudge_interval: 10 +""" + config_path.write_text(original, encoding="utf-8") + config_path.chmod(0o600) + + result, code = module.enforce_policy(config_path) + + assert code == 0 + assert result["status"] == "updated" + assert result["policy"] == { + "memory_nudge_interval": 0, + "skill_creation_nudge_interval": 0, + } + assert "private-test-token" not in json.dumps(result) + updated = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert updated["memory"] == { + "memory_enabled": True, + "user_profile_enabled": True, + "nudge_interval": 0, + } + assert updated["skills"] == {"external_dirs": [], "creation_nudge_interval": 0} + assert updated["gateway"]["telegram"]["bot_token"] == "private-test-token" + assert config_path.stat().st_mode & 0o777 == 0o600 + assert config_path.with_name(config_path.name + module.BACKUP_SUFFIX).read_text(encoding="utf-8") == original + + check_result, check_code = module.enforce_policy(config_path, check=True) + assert check_code == 0 + assert check_result["status"] == "compliant" + + +def test_learning_policy_check_reports_drift_without_writing(tmp_path: Path) -> None: + module = _load_module() + config_path = tmp_path / "config.yaml" + original = "memory:\n memory_enabled: true\n" + config_path.write_text(original, encoding="utf-8") + + result, code = module.enforce_policy(config_path, check=True) + + assert code == 1 + assert result["status"] == "change_required" + assert config_path.read_text(encoding="utf-8") == original + assert not config_path.with_name(config_path.name + module.BACKUP_SUFFIX).exists() diff --git a/tests/test_hermes_leoclean_kb_bridge_source.py b/tests/test_hermes_leoclean_kb_bridge_source.py index f8d5ad3..76bfa56 100644 --- a/tests/test_hermes_leoclean_kb_bridge_source.py +++ b/tests/test_hermes_leoclean_kb_bridge_source.py @@ -1669,6 +1669,39 @@ def test_vps_bridge_binds_claim_challenge_to_exact_retrieved_rows() -> None: assert "no database write was made" in unbound_response +def test_vps_bridge_does_not_replace_candidate_composition_with_retrieved_claim() -> None: + module = _load_module(BRIDGE_DIR / "kb_tool.py") + candidate_queries = [ + ( + "Draft the strongest reviewable candidate claim this discussion supports. Give the exact claim body, " + "initial confidence, falsifier, minimum source and evidence requirements, and relations to existing " + "canonical rows. Do not write or stage it yet." + ), + ( + "That candidate still smuggles in unsupported assumptions. Narrow the claim. Return the revised exact " + "claim body, revised initial confidence, a concrete falsifier, minimum source and evidence requirements, " + "and relations to existing rows. Do not write or stage anything." + ), + ] + + for query in candidate_queries: + contracts = module.operational_contracts(query) + contract_ids = {contract["id"] for contract in contracts} + + assert "claim_evidence_challenge" not in contract_ids + assert module.compile_operational_response(contracts) is None + + +def test_vps_bridge_keeps_exact_row_guard_for_combined_audit_and_candidate_request() -> None: + module = _load_module(BRIDGE_DIR / "kb_tool.py") + contracts = module.operational_contracts( + "Inspect one exact claim body and its evidence, challenge its unsupported assumption, and then draft a " + "narrower candidate claim." + ) + + assert "claim_evidence_challenge" in {contract["id"] for contract in contracts} + + def test_vps_prepare_source_retrieves_canonical_candidates_before_model_extraction( tmp_path: Path, monkeypatch, capsys ) -> None: diff --git a/tests/test_leo_behavior_manifest.py b/tests/test_leo_behavior_manifest.py index f8959c2..010adf0 100644 --- a/tests/test_leo_behavior_manifest.py +++ b/tests/test_leo_behavior_manifest.py @@ -30,6 +30,9 @@ def test_behavior_manifest_is_deterministic_and_never_emits_config_secrets(tmp_p memory: enabled: true search: sqlite_fts5 + nudge_interval: 0 +skills: + creation_nudge_interval: 0 gateway: telegram: bot_token: super-secret-token @@ -52,6 +55,8 @@ gateway: assert first["behavior_sha256"] == second["behavior_sha256"] assert first["model_runtime"]["default_model"] == "anthropic/claude-sonnet-4-6" + assert first["model_runtime"]["automatic_memory_review_interval"] == 0 + assert first["model_runtime"]["automatic_skill_review_interval"] == 0 assert first["teleo_infrastructure_runtime"]["source_tree"]["file_count"] == 2 assert first["components"]["persistent_memory"]["content"]["file_count"] == 1 encoded = json.dumps(first) diff --git a/tests/test_teleo_agent_systemd.py b/tests/test_teleo_agent_systemd.py index 1156c9e..2d2f643 100644 --- a/tests/test_teleo_agent_systemd.py +++ b/tests/test_teleo_agent_systemd.py @@ -128,6 +128,10 @@ def test_deploy_scripts_sync_leoclean_skills_and_restart_gateway_for_runtime_cha assert "hermes-agent/leoclean-skills/vps/" in auto_deploy assert "hermes-agent/leoclean-plugins/vps/" in auto_deploy assert 'HERMES_PATCH_DIR="/home/teleo/.hermes/teleo-runtime-patches"' in auto_deploy + assert 'LEOCLEAN_CONFIG="/home/teleo/.hermes/profiles/leoclean/config.yaml"' in auto_deploy + assert 'LEOCLEAN_POLICY_PATCH="$HERMES_PATCH_DIR/enforce_leoclean_learning_policy.py"' in auto_deploy + assert 'python3 "$LEOCLEAN_POLICY_PATCH" "$LEOCLEAN_CONFIG"' in auto_deploy + assert "Leoclean learning-policy drift repaired" in auto_deploy assert '"status": "installed_now"' in auto_deploy assert "Hermes response-transform drift repaired" in auto_deploy assert "deploy/leoclean-gateway-restart-required.sh" in auto_deploy @@ -145,6 +149,8 @@ def test_deploy_scripts_sync_leoclean_skills_and_restart_gateway_for_runtime_cha assert "hermes-agent/leoclean-skills/vps/" in manual_deploy assert "hermes-agent/leoclean-plugins/vps/" in manual_deploy assert 'VPS_HERMES_PATCHES="/home/teleo/.hermes/teleo-runtime-patches"' in manual_deploy + assert 'VPS_LEOCLEAN_POLICY_PATCH="$VPS_HERMES_PATCHES/enforce_leoclean_learning_policy.py"' in manual_deploy + assert "Leoclean gateway restarted." in manual_deploy assert "systemctl is-active --quiet leoclean-gateway.service" in manual_deploy