CI has no fastapi (box-only dependency); api.py imports it lazily. Guard the
7 TestClient smoke tests with skipif(find_spec('fastapi') is None) so they
skip in CI instead of failing collection, leaving the 14 invoker_multi tests
running. Also apply ruff format to the 5 WS1 files CI flagged.
170 lines
6.6 KiB
Python
170 lines
6.6 KiB
Python
"""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 ``<root>/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())
|