feat(ops): מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר (any→any, שתי החברות) — סוגר FU-8a
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s

הקשר: החלפת adapter_type ב-dropdown הגולמי של Paperclip מקריסה את הסוכן מיד —
content_arg adapters (gemini_local/deepseek_local) שוברים את ה-frontmatter המוביל `---`
(yargs/arg-parser קורא אותו כדגל → "Not enough arguments following: prompt"), המודל
חייב להתאים ל-provider, ו-excludeTools של gemini גלובלי לכל הסוכנים. בנוסף — שינוי
חברה-אחת מפר את INV-MC1 (סנכרון מדלג על adapter mismatch → drift שקט).

הכלי מיישב את 3 צירי-הכשל data-driven, מעביר בשתי החברות יחד, הפיך מדויק, ומסרב
מעבר לא-בטוח ב-preflight (סוגר את פער FU-8a "אין שער-ולידציה למעבר-אדפטר").

- scripts/adapter_profiles.py — רישום-פרופילי-אדפטר (מקור-אמת יחיד; אדפטר חדש = רשומה אחת)
- scripts/migrate_agent_adapter.py — --check/--apply/--revert/--verify; frontmatter→.nofm.md
  נגזר, sidecar לשחזור, --relax-tools ל-excludeTools, PATCH דרך /api/agents/{id} (לא DB)
- .gitignore + scripts/SCRIPTS.md

נבדק: --verify (9 סוכנים תקינים), --check (CEO→gemini מציג nofm+model+tool-conflict;
provider-guard תופס מודל לא-תואם, exit≠0), ובדיקות-לוגיקה טהורות (strip/canonical/recompute).

Invariants: מקיים G2 (רישום+קובץ-קנוני יחיד, גרסת-gemini נגזרת), G12 (PATCH-API דרך
המעטפת, לא DB), INV-MC1 (שתי חברות יחד). סוגר FU-8a.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 11:28:16 +00:00
parent 4dce06c04a
commit f7a8ad48ac
4 changed files with 641 additions and 0 deletions

View File

@@ -0,0 +1,544 @@
#!/usr/bin/env python3
"""migrate_agent_adapter.py — safely migrate ANY Paperclip agent to ANY adapter.
Why: flipping an agent's adapter in the raw Paperclip dropdown crashes it. The
three failure axes (model↔provider, instructions transport, tool gating) are
described in adapter_profiles.py. On top of those, INV-MC1 requires the two
companies (CMP 1xxx / CMPA 8xxx) to stay symmetric — a manual flip touches one
and the cross-company sync then *skips* the agent, leaving silent drift.
This tool reconciles all three axes, applies to BOTH company records together,
snapshots the prior state for an exact revert, and refuses unsafe migrations
in preflight (closing the FU-8a "no adapter-switch validation gate" gap).
It is an operations tool in the declared SHELL layer (like
sync_agents_across_companies.py) — it talks Paperclip via the documented
PATCH /api/agents/{id} path, never direct DB writes (G12).
Runtime artifacts (frontmatter-stripped instruction copies, the revert
snapshot) are written to the MAIN working tree absolute paths, because that is
where Paperclip actually launches the agents (shared cwd /home/chaim/legal-ai).
Usage:
# preflight only — reports what would change + warnings, no mutation
python migrate_agent_adapter.py --check --agent "עוזר משפטי" --to gemini_local
# migrate one agent (both companies) — degraded-fallback warning printed
python migrate_agent_adapter.py --apply --agent "עוזר משפטי" --to gemini_local [--model X] [--relax-tools]
# migrate the whole team
python migrate_agent_adapter.py --apply --agent all --to gemini_local --relax-tools
# undo: restore each migrated agent to its pre-migration state
python migrate_agent_adapter.py --revert --agent "עוזר משפטי" # or --agent all
# health gate: flag any agent in an incompatible / asymmetric state
python migrate_agent_adapter.py --verify
Requires: PAPERCLIP_BOARD_API_KEY (Infisical: /paperclip @ nautilus).
Run with the interpreter that has asyncpg+httpx:
/home/chaim/legal-ai/mcp-server/.venv/bin/python
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import asyncpg
sys.path.insert(0, str(Path(__file__).resolve().parent))
from adapter_profiles import ( # noqa: E402
ADAPTER_PROFILES,
UnknownAdapter,
get_profile,
model_matches_provider,
)
from sync_agents_across_companies import ( # noqa: E402 (reuse SHELL helpers)
CMP_COMPANY_ID,
CMPA_COMPANY_ID,
PAPERCLIP_DB_URL,
call_patch,
fetch_agents,
)
# Absolute MAIN-tree paths — agents run there, not in any worktree.
AGENTS_DIR = Path("/home/chaim/legal-ai/.claude/agents")
GENERATED_DIR = AGENTS_DIR / ".generated"
SIDECAR = Path("/home/chaim/legal-ai/data/adapter-migration-state.json")
GEMINI_SETTINGS = Path.home() / ".gemini" / "settings.json"
NOFM_SUFFIX = ".nofm.md"
COMPANY_IDS = [CMP_COMPANY_ID, CMPA_COMPANY_ID]
COMPANY_LABELS = {
CMP_COMPANY_ID: "CMP (רישוי ובניה)",
CMPA_COMPANY_ID: "CMPA (היטלי השבחה)",
}
def fail(msg: str) -> None:
print(f"{msg}", file=sys.stderr)
sys.exit(1)
# ─────────────────────────── sidecar (revert state) ───────────────────────────
def load_sidecar() -> dict:
if SIDECAR.exists():
try:
return json.loads(SIDECAR.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
fail(f"sidecar פגום ({SIDECAR}): {e}")
return {"agents": {}, "gemini_excludetools_baseline": None}
def save_sidecar(data: dict) -> None:
SIDECAR.parent.mkdir(parents=True, exist_ok=True)
SIDECAR.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
# ─────────────────────────── instructions handling ───────────────────────────
def canonical_instructions_path(current_path: str) -> str:
"""Resolve the frontmatter-bearing canonical .md, even if currently pointed
at a generated .nofm.md copy."""
if not current_path:
return ""
p = Path(current_path)
if p.parent == GENERATED_DIR and p.name.endswith(NOFM_SUFFIX):
base = p.name[: -len(NOFM_SUFFIX)]
return str(AGENTS_DIR / f"{base}.md")
return current_path
def strip_frontmatter(text: str) -> str:
"""Remove a leading YAML `---...---` block so the content never starts with
`--` (which content_arg adapters parse as a CLI flag)."""
m = re.match(r"^---\n.*?\n---\n", text, re.DOTALL)
if m:
text = text[m.end():]
text = text.lstrip("\n")
if not text or text.lstrip().startswith("--"):
text = "# הוראות סוכן\n\n" + text
return text
def render_nofm_text(canonical_path: str) -> str:
return strip_frontmatter(Path(canonical_path).read_text(encoding="utf-8"))
def write_nofm(canonical_path: str) -> str:
"""Generate the frontmatter-free copy and return its absolute path."""
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
base = Path(canonical_path).stem
out = GENERATED_DIR / f"{base}{NOFM_SUFFIX}"
out.write_text(render_nofm_text(canonical_path), encoding="utf-8")
return str(out)
def frontmatter_tools(canonical_path: str) -> list[str]:
"""Parse the `tools:` list from an agent .md frontmatter (bare tool names,
`mcp__legal-ai__` prefix stripped)."""
try:
lines = Path(canonical_path).read_text(encoding="utf-8").splitlines()
except OSError:
return []
tools: list[str] = []
in_tools = False
for line in lines:
if not in_tools:
if re.match(r"^tools:\s*$", line):
in_tools = True
continue
m = re.match(r"^\s+-\s+(.+?)\s*$", line)
if m:
tools.append(m.group(1).strip())
elif line.strip() and not line.startswith(" "):
break # left the tools: block
return [t.replace("mcp__legal-ai__", "") for t in tools]
# ─────────────────────────── gemini excludeTools ───────────────────────────
def read_gemini_excludetools() -> list[str]:
if not GEMINI_SETTINGS.exists():
return []
try:
cfg = json.loads(GEMINI_SETTINGS.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
return list((((cfg.get("mcpServers") or {}).get("legal-ai") or {}).get("excludeTools")) or [])
def write_gemini_excludetools(tools: list[str]) -> None:
cfg = json.loads(GEMINI_SETTINGS.read_text(encoding="utf-8"))
cfg.setdefault("mcpServers", {}).setdefault("legal-ai", {})["excludeTools"] = sorted(set(tools))
GEMINI_SETTINGS.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
def recompute_gemini_excludetools(sidecar: dict) -> list[str] | None:
"""excludeTools = baseline minus the union of tools relaxed for agents still
targeted at gemini_local. Returns the list to write, or None if no baseline
was ever captured (nothing to recompute)."""
baseline = sidecar.get("gemini_excludetools_baseline")
if baseline is None:
return None
relaxed: set[str] = set()
for entry in sidecar["agents"].values():
if entry.get("current_target") == "gemini_local":
relaxed.update(entry.get("relaxed_tools") or [])
return [t for t in baseline if t not in relaxed]
# ─────────────────────────── agent loading ───────────────────────────
async def load_pairs() -> dict[str, dict[str, dict]]:
"""Return {agent_name: {company_id: agent_row}}."""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = []
for cid in COMPANY_IDS:
for a in await fetch_agents(conn, cid):
a["_company_id"] = cid
rows.append(a)
finally:
await conn.close()
pairs: dict[str, dict[str, dict]] = {}
for a in rows:
pairs.setdefault(a["name"], {})[a["_company_id"]] = a
return pairs
def adapter_of(agent: dict) -> str:
return agent.get("adapter_type") or ""
def model_of(agent: dict) -> str:
ac = agent.get("adapter_config") or {}
return ac.get("model") or ""
def instr_of(agent: dict) -> str:
ac = agent.get("adapter_config") or {}
return ac.get("instructionsFilePath") or ""
# ─────────────────────────── planning / preflight ───────────────────────────
def plan_migration(name: str, records: dict[str, dict], target: str,
model_override: str | None) -> dict:
"""Compute the safe migration for one agent (both companies). Returns a dict
with target_model, target_instr, nofm_needed, tool_conflicts, errors[],
warnings[]."""
errors: list[str] = []
warnings: list[str] = []
try:
profile = get_profile(target)
except UnknownAdapter as e:
return {"errors": [str(e)], "warnings": []}
# INV-MC1: both company records must exist to flip symmetrically.
missing = [COMPANY_LABELS[c] for c in COMPANY_IDS if c not in records]
if missing:
errors.append(f"חסרה רשומת-סוכן בחברות: {', '.join(missing)} — אי-אפשר להפוך סימטרית.")
return {"errors": errors, "warnings": warnings}
target_model = (model_override or profile["default_model"] or "").strip()
if not model_matches_provider(target_model, target):
errors.append(
f"מודל {target_model!r} אינו תואם ל-provider {profile['provider']!r} של {target}. "
f"השתמש ב---model או הסר אותו לקבלת ברירת-המחדל ({profile['default_model']})."
)
# instructions transport
canonical = canonical_instructions_path(instr_of(records[CMP_COMPANY_ID]))
if not canonical or not os.path.exists(canonical):
errors.append(f"קובץ-הוראות קנוני חסר: {canonical or '(לא מוגדר)'}")
nofm_needed = not profile["frontmatter_safe"]
target_instr = canonical
elif profile["frontmatter_safe"]:
nofm_needed = False
target_instr = canonical
else:
nofm_needed = True
target_instr = str(GENERATED_DIR / f"{Path(canonical).stem}{NOFM_SUFFIX}")
# validate the (in-memory) stripped content really is flag-safe
stripped = render_nofm_text(canonical)
if stripped.lstrip().startswith("--"):
errors.append("גריסת-ה-frontmatter עדיין פותחת ב-'--' — לא בטוח ל-content_arg adapter.")
# tool gating (gemini global excludeTools)
tool_conflicts: list[str] = []
if profile["tool_config"] == "gemini_global" and canonical and os.path.exists(canonical):
required = set(frontmatter_tools(canonical))
excluded = set(read_gemini_excludetools())
tool_conflicts = sorted(required & excluded)
if tool_conflicts:
warnings.append(
"כלי-כתיבה שייחסמו תחת gemini (excludeTools גלובלי): "
f"{', '.join(tool_conflicts)} — הוסף --relax-tools כדי לשחרר אותם."
)
return {
"target_model": target_model,
"target_instr": target_instr,
"nofm_needed": nofm_needed,
"canonical": canonical,
"tool_conflicts": tool_conflicts,
"errors": errors,
"warnings": warnings,
}
def print_plan(name: str, records: dict, target: str, plan: dict) -> None:
cur = adapter_of(records[CMP_COMPANY_ID])
print(f"\n{name}{cur or '(none)'}{target}")
if plan.get("errors"):
for e in plan["errors"]:
print(f"{e}")
return
print(f" model: {model_of(records[CMP_COMPANY_ID]) or '(default)'}{plan['target_model']}")
print(f" instructions: {plan['target_instr']}"
f"{' (frontmatter-stripped copy)' if plan['nofm_needed'] else ''}")
for w in plan["warnings"]:
print(f"{w}")
# ─────────────────────────── apply / revert ───────────────────────────
async def apply_one(name: str, records: dict, target: str, plan: dict,
sidecar: dict, relax_tools: bool) -> list[str]:
errors: list[str] = []
# 1. snapshot ORIGINAL state once (first time we ever touch this agent),
# so --revert always returns to the true pre-migration config.
agents_state = sidecar["agents"]
if name not in agents_state:
agents_state[name] = {
"original": {
cid: {
"adapter_type": adapter_of(records[cid]),
"model": model_of(records[cid]),
"instructionsFilePath": instr_of(records[cid]),
}
for cid in COMPANY_IDS
},
"relaxed_tools": [],
}
agents_state[name]["current_target"] = target
# 2. generate the frontmatter-free instructions copy if needed
if plan["nofm_needed"]:
write_nofm(plan["canonical"])
# 3. PATCH both company records (adapterType + adapterConfig merge)
for cid in COMPANY_IDS:
body = {
"adapterType": target,
"adapterConfig": {
"model": plan["target_model"],
"instructionsFilePath": plan["target_instr"],
},
}
status, data = await call_patch(records[cid]["id"], body)
if status >= 400:
errors.append(f"{COMPANY_LABELS[cid]}: PATCH HTTP {status}: {json.dumps(data)[:200]}")
else:
print(f"{COMPANY_LABELS[cid]}{target} / {plan['target_model']}")
# 4. relax global gemini excludeTools if requested
if relax_tools and plan.get("tool_conflicts"):
if sidecar.get("gemini_excludetools_baseline") is None:
sidecar["gemini_excludetools_baseline"] = read_gemini_excludetools()
agents_state[name]["relaxed_tools"] = plan["tool_conflicts"]
new_excl = recompute_gemini_excludetools(sidecar)
if new_excl is not None:
write_gemini_excludetools(new_excl)
print(f" ✓ excludeTools שוחרר: {', '.join(plan['tool_conflicts'])} (גלובלי לכל gemini)")
return errors
async def revert_one(name: str, records: dict, sidecar: dict) -> list[str]:
errors: list[str] = []
entry = sidecar["agents"].get(name)
if not entry:
print(f"{name} — אין snapshot לשחזור (לא הועבר דרך הכלי).")
return errors
for cid in COMPANY_IDS:
if cid not in records:
errors.append(f"{name}: חסרה רשומה ב-{COMPANY_LABELS[cid]} — דלג.")
continue
orig = entry["original"][cid]
body = {
"adapterType": orig["adapter_type"],
"adapterConfig": {
"model": orig["model"],
"instructionsFilePath": orig["instructionsFilePath"],
},
}
status, data = await call_patch(records[cid]["id"], body)
if status >= 400:
errors.append(f"{COMPANY_LABELS[cid]}: PATCH HTTP {status}: {json.dumps(data)[:200]}")
else:
print(f"{COMPANY_LABELS[cid]}{orig['adapter_type']} / {orig['model'] or '(default)'}")
if not errors:
sidecar["agents"].pop(name, None)
# recompute global excludeTools now that this agent no longer needs relaxing
new_excl = recompute_gemini_excludetools(sidecar)
if new_excl is not None:
write_gemini_excludetools(new_excl)
return errors
# ─────────────────────────── verify ───────────────────────────
def verify_pairs(pairs: dict[str, dict]) -> int:
"""Flag any agent whose live state is incompatible or asymmetric. Returns
the number of flagged agents (0 = healthy)."""
flagged = 0
print("\n=== verify — תאימות מצב חי ===")
for name in sorted(pairs):
records = pairs[name]
problems: list[str] = []
# INV-MC1 — same adapter_type in both companies
adapters = {adapter_of(records[c]) for c in records}
if len(records) == 2 and len(adapters) > 1:
problems.append(f"adapter_type א-סימטרי בין החברות: {sorted(adapters)}")
for cid, agent in records.items():
at = adapter_of(agent)
if at not in ADAPTER_PROFILES:
problems.append(f"{COMPANY_LABELS.get(cid, cid)}: adapter לא מוכר {at!r}")
continue
profile = ADAPTER_PROFILES[at]
if not model_matches_provider(model_of(agent), at):
problems.append(
f"{COMPANY_LABELS.get(cid, cid)}: מודל {model_of(agent)!r} לא תואם ל-{at}")
if not profile["frontmatter_safe"]:
instr = instr_of(agent)
if instr and os.path.exists(instr):
head = Path(instr).read_text(encoding="utf-8", errors="ignore").lstrip()
if head.startswith("--"):
problems.append(
f"{COMPANY_LABELS.get(cid, cid)}: קובץ-הוראות פותח ב-'--' → יקרוס תחת {at}")
elif instr:
problems.append(f"{COMPANY_LABELS.get(cid, cid)}: קובץ-הוראות חסר: {instr}")
if problems:
flagged += 1
print(f"{name}")
for p in problems:
print(f" {p}")
else:
print(f"{name}{sorted(adapters)[0] if adapters else ''}")
print(f"\nSummary: {flagged} סוכנים בעייתיים → {'DRIFT' if flagged else 'תקין'}")
return flagged
# ─────────────────────────── main ───────────────────────────
async def main() -> None:
p = argparse.ArgumentParser(description="Migrate any Paperclip agent to any adapter (both companies).")
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--check", action="store_true", help="Preflight only — no mutation")
g.add_argument("--apply", action="store_true", help="Perform the migration")
g.add_argument("--revert", action="store_true", help="Restore pre-migration state")
g.add_argument("--verify", action="store_true", help="Flag incompatible/asymmetric agents")
p.add_argument("--agent", help="Agent name (e.g. 'עוזר משפטי') or 'all'")
p.add_argument("--to", dest="target", help="Target adapter (for --check/--apply)")
p.add_argument("--model", help="Override target model (else adapter default)")
p.add_argument("--relax-tools", action="store_true",
help="Remove conflicting write tools from the global gemini excludeTools")
args = p.parse_args()
pairs = await load_pairs()
if args.verify:
sys.exit(1 if verify_pairs(pairs) else 0)
if not args.agent:
fail("--agent נדרש (שם-סוכן או 'all').")
if args.agent == "all":
names = sorted(pairs)
elif args.agent in pairs:
names = [args.agent]
else:
fail(f"סוכן לא נמצא: {args.agent!r}. קיימים: {', '.join(sorted(pairs))}")
# ── revert ──
if args.revert:
sidecar = load_sidecar()
all_errors: list[str] = []
print(f"=== revert ({len(names)} סוכנים) ===")
for name in names:
print(f"\n{name}")
errs = await revert_one(name, pairs[name], sidecar)
all_errors += [f"{name}: {e}" for e in errs]
save_sidecar(sidecar)
if all_errors:
print(f"\n⚠️ {len(all_errors)} שגיאות:")
for e in all_errors:
print(f" {e}")
sys.exit(1)
print("\n✓ revert הושלם.")
return
# ── check / apply ──
if not args.target:
fail("--to <adapter> נדרש עבור --check/--apply.")
if args.target not in ADAPTER_PROFILES:
fail(f"אדפטר לא מוכר: {args.target}. רשומים: {', '.join(sorted(ADAPTER_PROFILES))}")
plans = {n: plan_migration(n, pairs[n], args.target, args.model) for n in names}
print(f"=== {'check' if args.check else 'apply'}{args.target} ({len(names)} סוכנים) ===")
for n in names:
print_plan(n, pairs[n], args.target, plans[n])
hard_errors = {n: pl["errors"] for n, pl in plans.items() if pl.get("errors")}
if hard_errors:
print(f"\n{len(hard_errors)} סוכנים עם שגיאות-preflight — לא בטוח להעביר.")
if args.check:
sys.exit(1)
fail("תקן את השגיאות לפני --apply.")
if args.check:
print("\n✓ preflight עבר. הרץ עם --apply לביצוע.")
return
# degraded-fallback warning
profile = ADAPTER_PROFILES[args.target]
if profile["provider"] != "claude":
print(f"\n⚠️ מעבר ל-{args.target} הוא fallback-חירום (degraded), לא שקול-איכות ל-Claude.")
print(" החזר ל-claude_local (--revert) כשטוקני-Claude חוזרים.")
sidecar = load_sidecar()
all_errors: list[str] = []
print(f"\n=== applying ===")
for n in names:
print(f"\n{n}")
errs = await apply_one(n, pairs[n], args.target, plans[n], sidecar, args.relax_tools)
all_errors += [f"{n}: {e}" for e in errs]
save_sidecar(sidecar)
if all_errors:
print(f"\n⚠️ {len(all_errors)} שגיאות:")
for e in all_errors:
print(f" {e}")
sys.exit(1)
print(f"\n✓ הועברו {len(names)} סוכנים ל-{args.target}. הרץ --verify לאישור, ו-sync_agents_across_companies.py --verify לוודא אפס drift.")
if __name__ == "__main__":
asyncio.run(main())