All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
paperclip-dev is for maintaining the Paperclip codebase itself — not relevant to legal work. Removed from all 14 agents (was on CMPA mirror). paperclip-converting-plans-to-tasks helps decompose a plan into assigned issues. Useful for the planning-heavy agents (CEO, analyst). Now scoped to those two — removed from the other 5 in CMPA where it had crept in. Net effect: zero drift on paperclipai/* skills across all 7 master+mirror pairs. Verified via the new Agents tab dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
4.9 KiB
Python
135 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Fix paperclipai/* skill drift across CMP+CMPA agents.
|
|
|
|
Goal: zero drift on paperclipai/* skills between master(CMP) and mirror(CMPA).
|
|
|
|
Rules:
|
|
* Remove ``paperclipai/paperclip/paperclip-dev`` from all 14 agents (not relevant
|
|
for legal work — it's for maintaining Paperclip itself).
|
|
* Ensure ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` exists
|
|
on CEO + analyst agents in both companies (planning skill).
|
|
* Remove ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` from any
|
|
other agent in either company that currently has it.
|
|
|
|
Local/* and company/* skills are not touched — they're scoped to a company
|
|
by design and drift is expected.
|
|
|
|
Usage::
|
|
|
|
PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py # dry-run
|
|
PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py --apply # commit
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
|
|
import httpx
|
|
|
|
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY")
|
|
|
|
COMPANIES = {
|
|
"licensing": ("CMP ", "42a7acd0-30c5-4cbd-ac97-7424f65df294"),
|
|
"betterment": ("CMPA", "8639e837-4c9d-47fa-a76b-95788d651896"),
|
|
}
|
|
|
|
DEV_SKILL = "paperclipai/paperclip/paperclip-dev"
|
|
CONVERTING_SKILL = "paperclipai/paperclip/paperclip-converting-plans-to-tasks"
|
|
|
|
# Hebrew names of the agents that should retain converting-plans-to-tasks.
|
|
CONVERTING_TARGETS = {"עוזר משפטי", "מנתח משפטי"}
|
|
|
|
|
|
def headers() -> dict[str, str]:
|
|
if not PAPERCLIP_BOARD_API_KEY:
|
|
sys.exit("PAPERCLIP_BOARD_API_KEY not set — fetch from Infisical first.")
|
|
return {
|
|
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
async def fetch_company_agents(client: httpx.AsyncClient, company_id: str) -> list[dict]:
|
|
r = await client.get(f"{PAPERCLIP_API_URL}/api/companies/{company_id}/agents", headers=headers())
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def compute_changes(agent: dict) -> tuple[bool, list[str], list[str]]:
|
|
skill_sync = (agent.get("adapterConfig") or {}).get("paperclipSkillSync") or {}
|
|
old = list(skill_sync.get("desiredSkills") or [])
|
|
new = [s for s in old if s != DEV_SKILL]
|
|
if agent["name"] in CONVERTING_TARGETS:
|
|
if CONVERTING_SKILL not in new:
|
|
new.append(CONVERTING_SKILL)
|
|
else:
|
|
new = [s for s in new if s != CONVERTING_SKILL]
|
|
return (sorted(old) != sorted(new), old, new)
|
|
|
|
|
|
async def patch_agent(
|
|
client: httpx.AsyncClient, agent_id: str, current_skill_sync: dict, new_skills: list[str]
|
|
) -> None:
|
|
body = {
|
|
"adapterConfig": {
|
|
"paperclipSkillSync": {**current_skill_sync, "desiredSkills": new_skills},
|
|
}
|
|
}
|
|
r = await client.patch(
|
|
f"{PAPERCLIP_API_URL}/api/agents/{agent_id}", headers=headers(), json=body, timeout=15
|
|
)
|
|
r.raise_for_status()
|
|
|
|
|
|
async def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--apply", action="store_true", help="commit changes (default: dry-run)")
|
|
args = parser.parse_args()
|
|
|
|
mode = "APPLY" if args.apply else "DRY-RUN"
|
|
print(f"=== {mode}: fixing paperclipai/* skill drift ===\n")
|
|
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
all_agents: list[dict] = []
|
|
for label, (_, cid) in COMPANIES.items():
|
|
agents = await fetch_company_agents(client, cid)
|
|
for a in agents:
|
|
a["_company_label"] = COMPANIES[label][0]
|
|
all_agents.extend(agents)
|
|
|
|
changes_planned = 0
|
|
for a in sorted(all_agents, key=lambda x: (x["_company_label"], x["name"])):
|
|
changed, old, new = compute_changes(a)
|
|
label = a["_company_label"]
|
|
if not changed:
|
|
print(f" {label} {a['name']:20} no change")
|
|
continue
|
|
changes_planned += 1
|
|
removed = sorted(set(old) - set(new))
|
|
added = sorted(set(new) - set(old))
|
|
print(f" {label} {a['name']:20} -{len(removed)} +{len(added)}")
|
|
for s in removed:
|
|
print(f" - {s}")
|
|
for s in added:
|
|
print(f" + {s}")
|
|
if args.apply:
|
|
skill_sync = (a.get("adapterConfig") or {}).get("paperclipSkillSync") or {}
|
|
try:
|
|
await patch_agent(client, a["id"], skill_sync, new)
|
|
print(" ✓ patched")
|
|
except httpx.HTTPStatusError as e:
|
|
print(f" ✗ failed: {e.response.status_code} {e.response.text[:200]}")
|
|
raise
|
|
|
|
print(f"\n{mode}: {changes_planned} agents would change")
|
|
if not args.apply and changes_planned > 0:
|
|
print("Run with --apply to commit.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|