#!/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 ""`, `hermes chat -q ""`). 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)