Keep Leo candidate reasoning grounded and stop automatic chat reinforcement (#180)
Some checks are pending
CI / lint-and-test (push) Waiting to run

* Preserve candidate reasoning and disable automatic chat reinforcement

* Retain exact-row guards for combined claim audits
This commit is contained in:
twentyOne2x 2026-07-17 23:07:57 +02:00 committed by GitHub
parent 77e1336c0f
commit 415669c2e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 326 additions and 14 deletions

View file

@ -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

View file

@ -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

View file

@ -59,6 +59,10 @@ terminal:
memory:
enabled: true
search: sqlite_fts5
nudge_interval: 0
skills:
creation_nudge_interval: 0
tools:
web_search: true

View file

@ -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

View file

@ -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())

View file

@ -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",
)

View file

@ -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()

View file

@ -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:

View file

@ -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)

View file

@ -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