Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ PERFORMANCE_API_KEY=
# MODEL_ALIASES='{"requested-name": "served-name"}' # audited equivalences only
CLAUDE_CODE_ATTREBUTION_HEADER=0

# Strict execution backend (doc/RFC-provider-registry.md). REQUIRED for Strict
# runs — they refuse to start while this is unset (CLI runs default to api).
# api = the raw-API keys above
# cursor = Cursor subscription via the cursor-agent CLI (no API key;
# the CLI must be installed and logged in)
# claude-code / codex = declared, ship in M2/M3
STRICT_BACKEND=api
# STRICT_BACKEND_MODEL= # model id inside the harness (optional)
# STRICT_BACKEND_CONCURRENCY=2 # concurrent harness sessions
# STRICT_BACKEND_CLI= # binary path override (else PATH lookup)
# STRICT_BACKEND_TIMEOUT_S=1800 # per-session wall-clock ceiling

# Repos: JSON map of repo name -> local path (machine-specific; .env is gitignored,
# which is why absolute paths belong HERE and never in a committed file).
REPO_PATHS={"vllm-omni": "/path/to/vllm-omni", "afd-plugin": "/path/to/afd-plugin"}
Expand Down
343 changes: 343 additions & 0 deletions doc/RFC-provider-registry.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/infermatrix_copilot/cli/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ def __init__(self, settings: Settings | None = None):
configured dir, and the planner over both. `last_run_dir` starts unset
and is filled by the first execution."""
self.settings = settings or Settings()
self.llm = LLM(self.settings)
# provider-registry seam: a real llm.LLM under backend "api"
# (byte-identical), a HarnessLLM adapter under a harness backend
from ..providers import llm_for

self.llm = llm_for(self.settings)
self.registry = register_builtin_steps(StepRegistry())
self.store = PlaybookStore(self.settings.playbooks_dir, self.registry)
self.planner = Planner(self.store, self.registry)
Expand Down
33 changes: 33 additions & 0 deletions src/infermatrix_copilot/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,45 @@ def _check_deps() -> tuple[bool, str]:

def _check_env(settings) -> tuple[bool, str]:
if not settings.shared_api_key:
if settings.strict_backend not in ("", "api"):
# harness backends carry subscription auth in the vendor CLI —
# a missing API key is a valid state, not a failure
return True, (f"no API key (valid: STRICT_BACKEND="
f"{settings.strict_backend} uses the vendor CLI's "
"subscription auth)")
return False, (
"LLM credential is empty — set ANTHROPIC_API_KEY or "
"OPENAI_API_KEY in ~/.infermatrix-copilot/.env "
"(key names checked only; values never printed)")
return True, f"{settings.resolved_llm_provider} credentials configured"


def _check_strict_backend(settings) -> tuple[bool, str]:
"""Provider-registry selection (doc/RFC-provider-registry.md). Unset is
reported but not failed here — CLI runs default to api; Strict runs
refuse at strict_readiness with the same fix line."""
backend = settings.strict_backend
if not backend:
return True, ("STRICT_BACKEND unset — CLI runs default to api; "
"Strict runs REFUSE until it is set (fix: add "
"STRICT_BACKEND=api or cursor to "
"~/.infermatrix-copilot/.env)")
if backend == "api":
return True, "backend api (raw-API path; credentials checked above)"
from ..providers import transport_for

try:
transport = transport_for(settings)
except NotImplementedError as exc:
return False, str(exc)
cli = transport.cli_path()
if not cli:
return False, (f"backend {backend} selected but its CLI is missing — "
"fix: install it or set STRICT_BACKEND_CLI=/path/to/"
"cli in ~/.infermatrix-copilot/.env")
return True, f"backend {backend} via {cli}"


def _check_gh() -> tuple[bool, str]:
if shutil.which("gh") is None:
return False, ("GitHub CLI not installed — fix: "
Expand Down Expand Up @@ -218,6 +250,7 @@ def run_doctor(as_json: bool = False, probe: bool = False) -> int:
checks = [
("deps", _check_deps()),
("env", _check_env(settings)),
("backend", _check_strict_backend(settings)),
("gh", _check_gh()),
("repos", _check_repos(settings)),
("backends", _check_model_backends(settings)),
Expand Down
45 changes: 44 additions & 1 deletion src/infermatrix_copilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,19 @@ class ResolvedTarget:
api_key: str
source: str
provider: Literal["anthropic", "openai"] = "anthropic"
# Provider registry (doc/RFC-provider-registry.md): which registry entry
# serves this target and how. `api` keeps today's exact semantics;
# `harness` targets carry NO endpoint/credential here — subscription auth
# lives inside the vendor CLI and is never resolved by Settings.
provider_id: str = "api"
kind: Literal["api", "harness"] = "api"

@property
def host(self) -> str:
"""Endpoint hostname for traces/echo lines."""
"""Endpoint hostname for traces/echo lines (`cli:<provider>` for
harness targets, which have no HTTP endpoint of ours)."""
if self.kind == "harness":
return f"cli:{self.provider_id}"
if self.base_url:
return urlparse(self.base_url).netloc
return ("api.openai.com" if self.provider == "openai"
Expand Down Expand Up @@ -141,6 +150,18 @@ class Settings(BaseSettings):
mcp_repo_allowlist: list[str] = []
mcp_report_max_bytes: int = 65536

# Strict execution backend (doc/RFC-provider-registry.md): which provider
# powers runs. REQUIRED for Strict — `strict_readiness` names the exact
# fix when empty (decision: explicit selection, never a silent fallback);
# the CLI path treats empty as "api" so maintainer setups keep working.
# Harness ids (cursor / claude-code / codex) need no API key: the vendor
# CLI holds the subscription auth.
strict_backend: str = "" # "" | api | cursor | claude-code | codex
strict_backend_model: str = "" # model id INSIDE the harness (optional)
strict_backend_concurrency: int = 2 # concurrent harness sessions
strict_backend_cli: str = "" # binary path override (else PATH)
strict_backend_timeout_s: float = 1800.0 # per-session wall-clock ceiling

# Shared, human-curated knowledge base — vendored from the community docs
# (see doc/KNOWLEDGE.md), organized as general/ (cross-repo experience) +
# repos/<repo>/ (repo-specific). Adapters reference only their repos/<repo>/
Expand Down Expand Up @@ -278,6 +299,18 @@ def _parse_aliases(cls, v):
"string->string equivalences")
return obj

@field_validator("strict_backend")
@classmethod
def _validate_strict_backend(cls, v):
"""Unknown backend ids fail at startup, not mid-run: the selection is
a routing decision and a typo must not silently mean 'api'."""
allowed = {"", "api", "cursor", "claude-code", "codex"}
if v not in allowed:
raise ValueError(
f"STRICT_BACKEND must be one of {sorted(allowed - {''})} "
f"(or unset), got {v!r}")
return v

@model_validator(mode="after")
def _validate_tier_atomicity(self):
"""Reject partial tier backends at startup: URL or key alone could pair
Expand Down Expand Up @@ -386,6 +419,16 @@ def tier_target(self, mode: str, role: str = "agent") -> ResolvedTarget:
shared one); `performance` with no `performance_model` raises
`TierNotConfiguredError` — failing upfront replaced the silent
agent_model fallback that once mislabeled a whole run."""
backend = self.strict_backend
if backend not in ("", "api"):
# Harness target (doc/RFC-provider-registry.md): the vendor CLI
# holds the subscription credential, so base_url/api_key stay
# empty and per-tier API backends do not apply — the harness
# serves both modes (its model comes from STRICT_BACKEND_MODEL).
return ResolvedTarget(role, self.strict_backend_model, "", "",
f"backend:{backend}",
self.resolved_llm_provider,
provider_id=backend, kind="harness")
if mode == "performance":
if not self.performance_model:
raise TierNotConfiguredError(
Expand Down
24 changes: 18 additions & 6 deletions src/infermatrix_copilot/engine/agent_runtime/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,24 @@ async def run_agent_step(
prompt = dispatch_ctx.render()
if guidance:
prompt += f"\n\n## STEP GUIDANCE\n{guidance}"
outcome = await asyncio.to_thread(
run_agent,
step_llm, system=system, prompt=prompt, scope=scope,
trace=ctx.trace, extra_tools=all_extra, max_iters=budget,
model=tier_model,
)
if target.kind == "harness" and llm_override is None:
# Harness backend (doc/RFC-provider-registry.md): the vendor CLI owns
# the tool loop for this whole step — same prompt bundle and output
# contract, builtin tools served back through the MCP bridge under
# this scope. MoA members (llm_override) are raw-API endpoints and
# keep the in-process loop even under a harness backend.
from ...providers import run_harness_step

outcome = await asyncio.to_thread(
run_harness_step, ctx, target, step_name=step_name,
system=system, prompt=prompt, scope=scope, max_iters=budget)
else:
outcome = await asyncio.to_thread(
run_agent,
step_llm, system=system, prompt=prompt, scope=scope,
trace=ctx.trace, extra_tools=all_extra, max_iters=budget,
model=tier_model,
)
output = _coerce_output(outcome.text, ctx, contract)
if output is not None:
output.setdefault("_tools_used", outcome.tools_used[:40])
Expand Down
31 changes: 26 additions & 5 deletions src/infermatrix_copilot/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,33 @@ def configure_strict_repo(self, repo: str, repo_path: str = "") -> None:
def strict_readiness(self, repo: str) -> list[str]:
"""Return actionable setup gaps before reserving a Strict run."""
missing = []
if not self.settings.shared_api_key:
# Backend selection is EXPLICIT for Strict (doc/RFC-provider-registry
# .md): unset refuses with the exact fix, never falls back silently.
backend = self.settings.strict_backend
if not backend:
missing.append(
"model credential missing; set ANTHROPIC_API_KEY or "
"OPENAI_API_KEY in "
"~/.infermatrix-copilot/.env"
)
"STRICT_BACKEND not set; add STRICT_BACKEND=api (or cursor / "
"claude-code / codex) to ~/.infermatrix-copilot/.env")
elif backend == "api":
if not self.settings.shared_api_key:
missing.append(
"model credential missing; set ANTHROPIC_API_KEY or "
"OPENAI_API_KEY in "
"~/.infermatrix-copilot/.env"
)
else:
from .providers import transport_for

try:
transport = transport_for(self.settings)
except NotImplementedError as exc:
missing.append(str(exc))
else:
if not transport.cli_path():
missing.append(
f"STRICT_BACKEND={backend} selected but its CLI is "
"not installed; install it or set STRICT_BACKEND_CLI "
"in ~/.infermatrix-copilot/.env")
repo_path = self.copilot._resolve_repo_path(repo)
if not repo_path or not Path(repo_path).is_dir():
missing.append(
Expand Down
76 changes: 76 additions & 0 deletions src/infermatrix_copilot/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Provider registry — one table of ways to reach a model
(doc/RFC-provider-registry.md).

Public surface: the registry (`PROVIDERS`, `resolve_provider`,
`transport_for`), the contracts (`ProviderSpec`, `AgentSessionRequest`,
`HarnessTransport`), and the two integration factories the core calls:
`llm_for` (LLM construction under the selected backend) and
`run_harness_step` (the agent-runtime branch for harness targets).
"""

from __future__ import annotations

from .base import AgentSessionRequest, HarnessTransport, ProviderSpec
from .harness_llm import HarnessLLM
from .registry import PROVIDERS, resolve_provider, transport_for

__all__ = [
"PROVIDERS", "AgentSessionRequest", "HarnessLLM", "HarnessTransport",
"ProviderSpec", "llm_for", "resolve_provider", "run_harness_step",
"transport_for",
]


class _UnshippedTransport(HarnessTransport):
"""Placeholder for declared-but-unshipped backends (claude-code/codex):
construction must degrade — `available` False, the milestone message on
any use — while `strict_readiness`/doctor report the gap with its fix."""

def __init__(self, settings, reason: str):
super().__init__(settings)
self._reason = reason

def cli_path(self):
return None

def run_session(self, req):
raise RuntimeError(self._reason)

def complete(self, **kwargs):
raise RuntimeError(self._reason)


def llm_for(settings):
"""The LLM-shaped client for the selected backend: the real `llm.LLM`
under `api` (parity: byte-identical construction), a `HarnessLLM`
adapter under a harness. Construction never requires the CLI (or the
transport) to exist — absence surfaces as `available == False`, the same
degrade signal an unset API key produces today."""
if resolve_provider(settings).kind == "harness":
try:
return HarnessLLM(settings, transport_for(settings))
except NotImplementedError as exc:
return HarnessLLM(settings, _UnshippedTransport(settings, str(exc)))
from ..llm import LLM

return LLM(settings)


def run_harness_step(ctx, target, *, step_name: str, system: str, prompt: str,
scope, max_iters: int):
"""Run one agent step through the selected harness: write the bridge spec
for this scope, then delegate the whole step to the transport. Returns
`agent_loop.AgentOutcome` so the runner's downstream (output coercion,
traces, salvage) is shared with the in-process loop."""
from ..tool_bridge import write_bridge_spec

transport = transport_for(ctx.settings)
spec = ctx.state.get("task_spec") or {}
bridge_spec = write_bridge_spec(
run_dir=ctx.run_dir, step_name=step_name, scope=scope,
repo=str(spec.get("repo") or ctx.settings.default_repo))
return transport.run_session(AgentSessionRequest(
system=system, prompt=prompt, scope=scope, model=target.model,
max_iters=max_iters, timeout_s=ctx.settings.strict_backend_timeout_s,
run_dir=ctx.run_dir, step_name=step_name,
bridge_spec_path=bridge_spec, trace=ctx.trace))
Loading
Loading