"""In-process invokers for non-Claude models (GPT-4.1, DeepSeek, Gemini) — WS1. Mirrors the pattern of :mod:`agent_team.invoker` (which supplies Claude invokers for the billing seam) but targets the **non-Claude** model seams: * :func:`agent_team.nodes.review_loop.set_review_invoker` — GPT-4.1 (``cross_reviewer``) * :func:`agent_team.nodes.builders_llm.default_build` is replaced in-process by binding :func:`make_fast_coder_invoker` as the builder Each factory lazily imports the root orchestrator's ``models`` module (never at module load) so importing this file on a machine where ``models.py`` is absent (CI, Mac dev, tests) does not raise :class:`ModuleNotFoundError`. The ``models`` module itself is kept out of the agent-team package graph; we reach it at runtime through the orchestrator root's ``sys.path`` entry that ``run-team.py`` and the graph module already bootstrap. Model keys accepted by :func:`multi_invoke`: ``cross_reviewer`` GPT-4.1 via ``models.get_cross_reviewer()``. The §3.2 cross-family reviewer. ``fast_coder`` DeepSeek via ``models.get_fast_coder()``. The P3 mechanical-edit builder. ``scanner`` Gemini via ``models.get_scanner()``. The P3 security-scan verifier. Security -------- All model outputs returned by :func:`multi_invoke` are UNTRUSTED. Callers are responsible for defensive parsing before acting on the result. This module performs no parsing — it returns the raw model response text and leaves the fail-safe discipline to the caller (review_loop_llm.parse_verdict, builders_llm._extract_diff, etc.). """ from __future__ import annotations import sys from collections.abc import Callable from pathlib import Path from typing import Any __all__ = [ "MODELS", "MultiInvoker", "bind_multi_invoker", "make_fast_coder_invoker", "make_scanner_invoker", "multi_invoke", ] # The set of model keys this module can dispatch to. MODELS: frozenset[str] = frozenset({"cross_reviewer", "fast_coder", "scanner"}) # Callable type: (prompt, **kw) -> str — same shape as ReviewInvoker and BuildCallable. MultiInvoker = Callable[..., str] def _ensure_orchestrator_on_path() -> None: """Add the orchestrator root to sys.path if absent. ``models.py`` lives at the orchestrator root (not inside agent-team). This file is at ``/agent-team/agent_team/invoker_multi.py``, so the root is ``parents[2]``. Mirroring the bootstrap in ``run-team.py`` ensures the deferred import in :func:`_load_model` succeeds when ``multi_invoke`` is called in a process that has NOT already bootstrapped ``sys.path``. """ root = str(Path(__file__).resolve().parents[2]) if root not in sys.path: sys.path.insert(0, root) def _load_model(key: str) -> Any: """Lazily import the orchestrator ``models`` module and return the model. ``key`` must be one of :data:`MODELS`. Raises :class:`ValueError` on an unknown key and :class:`RuntimeError` when the ``models`` module is unavailable (the orchestrator is not on ``sys.path``). Deferred import keeps this module importable everywhere the orchestrator root is absent (CI, tests, Mac dev). """ _ensure_orchestrator_on_path() try: import models # noqa: PLC0415 - intentional deferred import except ImportError as exc: raise RuntimeError( "Cannot import orchestrator 'models' module — ensure the orchestrator " "root is on sys.path (run-team.py bootstraps this automatically). " "Tests should mock multi_invoke rather than call it." ) from exc factories = { "cross_reviewer": models.get_cross_reviewer, "fast_coder": models.get_fast_coder, "scanner": models.get_scanner, } if key not in factories: raise ValueError( f"unknown model key {key!r}; must be one of: " + ", ".join(sorted(MODELS)) ) return factories[key]() def multi_invoke(prompt: str, *, model: str, **kw: Any) -> str: """Invoke a non-Claude model in-process and return its response text. ``model`` must be one of :data:`MODELS`. The underlying model is loaded lazily via :func:`_load_model` so the orchestrator's ``models`` module is not imported until first call. Any call error propagates to the caller, which is responsible for fail-safe handling (e.g. :func:`review_plan` catches all exceptions and returns REQUEST_CHANGES). The response text is returned raw (UNTRUSTED); callers must parse/validate before acting on it. """ model_obj = _load_model(model) result = model_obj.invoke(prompt) text = getattr(result, "content", result) return text if isinstance(text, str) else str(text) def make_fast_coder_invoker() -> MultiInvoker: """Return an in-process callable that routes prompts to DeepSeek ``fast_coder``. Mirrors :func:`agent_team.nodes.review_loop_llm.make_cross_reviewer_invoker` for the builder seam. The callable matches :data:`~agent_team.nodes.builders_llm.BuildCallable` ``(instruction: str) -> str`` and is suitable for passing as the ``build`` kwarg to :func:`agent_team.nodes.builders_llm.build_candidate_diff`. """ def _invoke(instruction: str, **_kw: Any) -> str: return multi_invoke(instruction, model="fast_coder") return _invoke def make_scanner_invoker() -> MultiInvoker: """Return an in-process callable that routes prompts to Gemini ``scanner``. The callable is suitable for injection into the verifier's scan seam. """ def _invoke(prompt: str, **_kw: Any) -> str: return multi_invoke(prompt, model="scanner") return _invoke def bind_multi_invoker() -> None: """Bind in-process non-Claude invokers into the review and builder seams. Call once at startup (alongside :func:`agent_team.invoker.bind_subscription_invoker`) to replace the subprocess-based defaults with in-process model calls: * ``review_loop.set_review_invoker`` → in-process GPT-4.1 via :func:`~agent_team.nodes.review_loop_llm.make_cross_reviewer_invoker` * Builder default (``builders_llm.default_build``) is already replaced at module load in this build; :func:`make_fast_coder_invoker` is the callable for explicit injection into :func:`~agent_team.nodes.builders_llm.build_candidate_diff` when needed. Lazy-imported so importing ``invoker_multi`` has no global side effects and tests that never call ``bind_multi_invoker`` remain model-free. """ from agent_team.nodes import review_loop # noqa: PLC0415 from agent_team.nodes.review_loop_llm import ( # noqa: PLC0415 make_cross_reviewer_invoker, ) review_loop.set_review_invoker(make_cross_reviewer_invoker())