Files
TheRock/rocm-systems/experimental/python/perfxpert/docs/architecture/backend-adapter.md
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

11 KiB
Raw Blame History

BackendAdapter Protocol (multi-backend contract)

The BackendAdapter Protocol is the contract every perfxpert-code <backend> adapter satisfies. Claude Code, Gemini CLI, and Codex CLI each implement this Protocol; the dispatcher in perfxpert/cli/_backend_dispatch.py routes subcommands into the registered adapter by name.

This doc is aimed at contributors adding a fifth backend (or updating an existing one). The user-facing install/uninstall surface is documented in ../guides/backends.md.

Source of truth: perfxpert/cli/_backend/protocol.py.

Protocol interface

Every adapter declares six class attributes and implements six methods. All signatures are locked from day one (plan cycle-2 I2) so downstream consumers never need to mutate them.

# SKIP-SAMPLE — Protocol summary; see perfxpert/cli/_backend/protocol.py for the real source
from typing import Literal, Protocol

class BackendAdapter(Protocol):
    # Class attributes
    name: str                         # routing key: "claude", "gemini", ...
    binary_name: str                  # shutil.which target
    install_hint: str                 # one-liner shown on check_available=False
    min_version: str | None           # SemVer floor; None disables the check
    known_schema_versions: tuple[str, ...]  # parseable config-schema versions
    tool_name_template: str           # e.g. "mcp__perfxpert__{tool}"
    spawn_strategy: Literal["execvpe", "subprocess"]

    # Lifecycle methods
    def check_available(self) -> tuple[bool, str]: ...
    def plan(self, cwd, scope="project", dry_run=True) -> Plan: ...
    def install(self, cwd, scope="project", allow_agents_md_append=False,
                dry_run=False, quiet=False) -> InstallReport: ...
    def verify_mcp_live(self, cwd, telemetry=False) -> LiveCheckReport: ...
    def uninstall(self, cwd, scope="project") -> UninstallReport: ...
    def spawn(self, argv, env, cwd) -> int: ...

The four report dataclasses (Plan, InstallReport, UninstallReport, LiveCheckReport) are frozen — an adapter's return value cannot be mutated by the dispatcher after it has been logged.

Lifecycle

                check_available()
                        │
                        ▼
                     plan()  ◀──── (dry-run path exits here)
                        │
                        ▼
                    install()
                        │
                        ├─ 1. Consent (per backend × cwd × file-set hash)
                        ├─ 2. Gate-hook install   (BEFORE MCP — I-N1)
                        ├─ 3. MCP registration    (backend-native config)
                        ├─ 4. Stage AGENTS.md cache
                        └─ 5. verify_mcp_live()   (skippable via env)
                        │
                        ▼
                     spawn()   ◀──── execvpe (default) or subprocess
                        │
                        ▼
                  [backend TUI runs — control never returns on execvpe]

       uninstall()   ◀──── symmetric reverse of install()

The ordering constraint "gate-hook BEFORE MCP" (plan cycle-2 I-N1) is load-bearing: if the gate-hook installer raises GateHookUnsupported mid-install, there is no partial MCP registration to clean up.

Per-backend state model

Backend MCP registration target Prompt cache Gate hook surface Session state
opencode (bundled) ~/.cache/perfxpert/opencode/opencode.json Patched system prompt (AMD fork patches 0010+0020) Fork patches 0010, 0012-0017 + STRICT-TOOL-DISCIPLINE stanza N/A — state carried in opencode session
Claude Code <cwd>/.mcp.json (project scope) <cwd>/.perfxpert/AGENTS.md + <cwd>/CLAUDE.local.md pointer Native PreToolUse hook in <cwd>/.claude/settings.json <cwd>/.claude/.perfxpert-gate-state.<session_id>.json
Gemini CLI <cwd>/.gemini/settings.json (project scope) <cwd>/.perfxpert/AGENTS.md referenced via context.fileName list-append Native BeforeTool + AfterTool command hooks in <cwd>/.gemini/settings.json <cwd>/.gemini/runtime/perfxpert-gate-<session_id>.json
Codex CLI <cwd>/.codex/config.toml when the project is trusted; otherwise fallback user-scope ~/.codex/config.toml. Trust itself always lives in ~/.codex/config.toml under [projects."<abs-cwd>"] <cwd>/AGENTS.override.md (perfxpert-managed compatibility override; if <cwd>/AGENTS.md exists the adapter shadow-copies it into the override and appends a perfxpert-managed block) Prompt-layer-only — rejection-language stanza in the perfxpert-managed override file. CodexGateHook.install() always raises GateHookUnsupported because Codex's native PreToolUse intercepts Bash only (not MCP/Write/etc.) as of April 2026 N/A (no persistent server-side session state; stanza re-emitted every session)

A NEW session (different session_id) always starts with the gate engaged — even in the same cwd. The sidecar file is keyed on session_id to enforce this naturally.

Gate-hook contract

The gate-hook layer lives in perfxpert/cli/_gate_hooks/ — one module per backend. Every gate-hook installer satisfies three rules:

  1. Event-based lift. The gate lifts once perfxpert_intent_classify has returned in the current session. The lift signal is a sidecar state file written by a native post-tool hook (Claude and Gemini). Static deny-lists alone are insufficient — they cannot lift mid-session.

  2. Raise GateHookUnsupported before MCP on mechanical-hook backends. If the gate cannot be installed cleanly on a backend that promises a native hook (today: Claude and Gemini), the installer MUST raise GateHookUnsupported BEFORE any MCP or prompt-cache write. Codex is the explicit exception: it does not satisfy the mechanical hook contract and instead relies on prompt-layer guidance only.

  3. Reject non-perfxpert tool calls until lift. Until perfxpert_intent_classify has been invoked in the current session, every non-PerfXpert MCP tool call is denied with a user-visible reason matching the opencode fork patch 0020 retryWith message ("call intent_classify first"). This keeps the UX identical across backends while allowing each backend to use its own MCP wire-name format.

The three LiveCheckReport.gate_hook_installed states encode these outcomes: None = probe skipped, False = surface unsupported (known limit), True = installed AND effective.

Codex-specific notes (prompt-layer-only + TOML state)

The CodexAdapter is the first adapter in-tree that relies on the rule-2 "raise GateHookUnsupported" path as its normal install outcome, not an error path. CodexGateHook.install() unconditionally raises GateHookUnsupported because Codex's native PreToolUse hook currently intercepts Bash only (not MCP/Write/WebSearch/etc.) and therefore cannot satisfy rule-3. The adapter catches the exception before MCP registration runs (I-N1), records gate_hook_installed=False, and leans on the rejection-language stanza inside the discovered AGENTS.override.md for enforcement. Full rationale + the Codex re-visit checklist are captured in the local Codex hook-surface decision record.

Two Codex-specific state-handling conventions worth naming here:

  • TOML, not JSON. Codex trust state always lives in ~/.codex/config.toml, while MCP registration lands in <cwd>/.codex/config.toml for trusted projects and falls back to ~/.codex/config.toml only when project scope is unavailable. CodexAdapter reads TOML with stdlib tomllib on the hot path; writes go through a lazy-imported tomlkit (comment/key-order preserving). The tomlkit import lives inside the write branch only — the read path never pays the cost.
  • Trust gate before MCP. Codex refuses to run agents in a project directory that isn't marked [projects."<abs-cwd>"].trust_level = "trusted". The adapter's install() handles this as an explicit step before MCP registration: prompts interactively, honors PERFXPERT_AUTO_TRUST=1 (with an always-on stderr warning — see the user guide), and raises TrustRequired in non-interactive contexts without the env var. ConfigClobber is raised if ~/.codex/config.toml is git-tracked or parse-fails (symmetric with the Claude .mcp.json refuse-if-tracked rule).

Adding a new backend

Steps to add a fifth backend (example: aider):

  1. Write the adapter. Create perfxpert/cli/_backend/aider.py with a class AiderAdapter satisfying the BackendAdapter Protocol. Declare all six class attributes + implement all six methods. Borrow the structure of perfxpert/cli/_backend/claude.py or gemini.py — both are self-contained and under 800 lines.

  2. Write the gate hook. Create perfxpert/cli/_gate_hooks/aider.py with a class AiderGateHook that implements install(cwd) per the gate-hook contract above. If the backend exposes no suitable pre-tool-call surface, raise GateHookUnsupported from install() — the adapter treats this cleanly.

  3. Register with the dispatcher. Edit perfxpert/cli/_backend_dispatch.py and add a _aider_runner function + entry in BACKEND_REGISTRY. Follow the existing pattern; the dispatcher's Task 6 install-then-spawn flow picks up the new entry without further changes.

  4. Wire up uninstall. Edit perfxpert/cli/opencode_launcher.py::_run_uninstall to lazy-import AiderAdapter under the aider branch (mirrors the existing claude / gemini branches).

  5. Tests + docs. Add tests/test_cli/test_aider_adapter.py mirroring the existing adapter tests. Update the backend comparison table in ../guides/backends.md and the per-backend state model table above in this file.

References

  • ../guides/backends.md — user-facing install/uninstall recipes
  • ../integration/mcp-server.md — the 56 READ_ONLY tools every adapter exposes to its backend (8 agent-hierarchy entry points + 47 classifier/knowledge tools
    • 1 trace_diff.diff_runs)
  • Local Claude hook-surface decision record — why the Claude adapter uses the native PreToolUse hook instead of permissions.deny.
  • Local multi-backend implementation plan — the 14-task PR 1 + PR 2 breakdown.
  • Local Codex hook-surface decision record — why the Codex adapter degrades to prompt-layer-only (native PreToolUse is Bash-only).
  • Source: perfxpert/cli/_backend/protocol.py, perfxpert/cli/_backend/{claude,gemini,codex}.py, perfxpert/cli/_gate_hooks/{claude,gemini,codex,opencode}.py, perfxpert/cli/_backend_dispatch.py