Merge pull request 'feat(ops): מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר (any→any) — סוגר FU-8a' (#247) from worktree-adapter-migrate into main
This commit was merged in pull request #247.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,4 +18,6 @@ kiryat-yearim/
|
||||
continuation-prompt.md
|
||||
node_modules/
|
||||
data/eval/eval-report-*
|
||||
data/adapter-migration-state.json # revert snapshot for migrate_agent_adapter.py (runtime state)
|
||||
.claude/agents/.generated/ # frontmatter-stripped instruction copies for content_arg adapters (generated)
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
| `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס |
|
||||
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי |
|
||||
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
|
||||
| `adapter_profiles.py` | python (module) | **רישום-פרופילי-אדפטר** — מקור-אמת יחיד ל-3 צירי-הכשל של מעבר-אדפטר: provider/default_model, instructions_mode (`file_path` בטוח-frontmatter מול `content_arg` ששובר `---`), ו-tool_config (`gemini_global` excludeTools / `frontmatter` / `hermes`). מיובא ע"י `migrate_agent_adapter.py`. הוספת אדפטר עתידי = רשומה אחת. לא מורץ ישירות. | תשתית |
|
||||
| `migrate_agent_adapter.py` | python | **מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר, בשתי החברות יחד (INV-MC1)**. מיישב model↔provider, גורס frontmatter לעותק `.generated/<name>.nofm.md` ל-content_arg adapters (אחרת קריסת `gemini --prompt`/`hermes -q` על `---`), ומשחרר excludeTools גלובלי של gemini (`--relax-tools`). `--check` (preflight בלבד, exit≠0 על שגיאה — שער FU-8a) / `--apply` / `--revert` (שחזור מדויק מ-sidecar `data/adapter-migration-state.json`) / `--verify` (מסמן מצב לא-תואם/א-סימטרי, exit≠0). `--agent "<שם>"\|all --to <adapter> [--model X] [--relax-tools]`. PATCH דרך `/api/agents/{id}` (לא DB). דורש `PAPERCLIP_BOARD_API_KEY`. הרץ עם `mcp-server/.venv/bin/python`. **fallback-חירום כשנגמרים טוקני-Claude; החזר ל-claude_local כשחוזרים.** | ידני לפי צורך |
|
||||
| `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש |
|
||||
| `fu2b_reconcile_internal_case_numbers.py` | python | **FU-2b (GAP-07/08) — תיאום `case_number` של `internal_committee`** מציטוט-מלא למספר-בסיס קנוני (X1: trim·prefix-strip·`/`→`-`, חודש נשמר). דטרמיניסטי (token יחיד; 0/>1 → flag). `--dry-run` (ברירת-מחדל) מפיק טבלת-תיאום ל-`data/audit/fu2b-reconciliation-*.{csv,md}` עם flags (DUP_CHECK / PROC_MISMATCH / MISMATCH). `--apply --approved <csv>` מגבה ואז מעדכן רק שורות שאושרו ע"י היו"ר. scope: internal בלבד (external → #68). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
|
||||
| `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
|
||||
|
||||
93
scripts/adapter_profiles.py
Normal file
93
scripts/adapter_profiles.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""adapter_profiles.py — single source of truth for Paperclip adapter capabilities.
|
||||
|
||||
Why this exists: switching a Paperclip agent's adapter is NOT a free dropdown
|
||||
flip. Each adapter family differs on three axes that, if not reconciled, crash
|
||||
the agent immediately or degrade it silently:
|
||||
|
||||
1. model id ↔ provider — each adapter expects a model from its own provider
|
||||
(claude-* / gemini-* / deepseek-*). Wrong/foreign id fails or silently
|
||||
falls back to the adapter default.
|
||||
2. instructions transport —
|
||||
• file_path adapters pass the instructions file as a *path*
|
||||
(`claude --append-system-prompt-file`); the CLI parses it,
|
||||
so a leading `---` YAML frontmatter is fine.
|
||||
• content_arg adapters read the file and splice its *content* into the
|
||||
CLI argument (`gemini --prompt "<content>"`,
|
||||
`hermes chat -q "<content>"`). yargs/arg-parsers read a
|
||||
value starting with `--` as a flag → the run dies within
|
||||
~2s with "Not enough arguments following: prompt". These
|
||||
adapters therefore require a frontmatter-FREE instructions
|
||||
file (must not start with `--`).
|
||||
3. tool availability — claude uses the frontmatter `tools:`/`--allowedTools`;
|
||||
gemini reads a GLOBAL `~/.gemini/settings.json` `excludeTools` list (one
|
||||
list for ALL gemini agents); deepseek uses `~/.hermes`.
|
||||
|
||||
Keeping these facts in one declarative registry makes "migrate any agent to any
|
||||
adapter" data-driven: adding a future adapter (codex_local, grok_local, ...) is
|
||||
one new entry here. Consumed by scripts/migrate_agent_adapter.py.
|
||||
|
||||
Verified 2026-06-13 against the installed adapter packages
|
||||
(`@paperclipai/adapter-{claude,gemini}-local`, in-repo
|
||||
`adapters/deepseek-paperclip-adapter`) and the live Paperclip agents table.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# tool_config values:
|
||||
# "frontmatter" — tools come from the agent .md frontmatter / --allowedTools
|
||||
# "gemini_global" — tools gated by the global ~/.gemini/settings.json excludeTools
|
||||
# "hermes" — tools configured under ~/.hermes (deepseek runtime)
|
||||
ADAPTER_PROFILES: dict[str, dict] = {
|
||||
"claude_local": {
|
||||
"provider": "claude",
|
||||
"default_model": "claude-opus-4-8",
|
||||
"instructions_mode": "file_path",
|
||||
"frontmatter_safe": True,
|
||||
"tool_config": "frontmatter",
|
||||
},
|
||||
"gemini_local": {
|
||||
"provider": "gemini",
|
||||
"default_model": "gemini-3.1-pro-preview",
|
||||
"instructions_mode": "content_arg",
|
||||
"frontmatter_safe": False,
|
||||
"tool_config": "gemini_global",
|
||||
},
|
||||
"deepseek_local": {
|
||||
"provider": "deepseek",
|
||||
"default_model": "deepseek-v4-pro",
|
||||
"instructions_mode": "content_arg",
|
||||
"frontmatter_safe": False,
|
||||
"tool_config": "hermes",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class UnknownAdapter(ValueError):
|
||||
"""Raised when an adapter type has no registered profile."""
|
||||
|
||||
|
||||
def get_profile(adapter_type: str) -> dict:
|
||||
"""Return the capability profile for an adapter type, or raise UnknownAdapter.
|
||||
|
||||
Fail loud: a typo'd or unregistered adapter must not silently pass through
|
||||
migration (that is exactly how the original crash happened)."""
|
||||
profile = ADAPTER_PROFILES.get(adapter_type)
|
||||
if profile is None:
|
||||
known = ", ".join(sorted(ADAPTER_PROFILES))
|
||||
raise UnknownAdapter(
|
||||
f"אדפטר לא מוכר: {adapter_type!r}. רשומים: {known}. "
|
||||
f"הוסף פרופיל ל-adapter_profiles.py לפני מעבר אליו."
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
def model_matches_provider(model: str, adapter_type: str) -> bool:
|
||||
"""True iff `model` belongs to the adapter's provider family.
|
||||
|
||||
Empty model is treated as matching — the adapter falls back to its own
|
||||
default model, which is by definition in-family."""
|
||||
if not model:
|
||||
return True
|
||||
provider = get_profile(adapter_type)["provider"]
|
||||
return model.startswith(provider)
|
||||
544
scripts/migrate_agent_adapter.py
Normal file
544
scripts/migrate_agent_adapter.py
Normal 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())
|
||||
Reference in New Issue
Block a user