"""P3-INERT build -> verify subgraph TOPOLOGY (design §3.3, §7.1 P3). This module is the **wiring topology** for the Plane-2 build -> verify stage:: ... -> REVIEW (route "build") -> BUILD -> VERIFY -> {approved | build | parked} It produces the BUILD node, the VERIFY node, and the :func:`route_after_verify` conditional-edge function so the Integrate phase can hang them off :func:`agent_team.graph.build_graph` as a subgraph reachable from the review loop's ``"build"`` route. It assembles NOTHING by itself: it does not call :func:`agent_team.graph.build_graph`, and the production default pipeline stays P2 (clarify -> plan -> review). Hooking this subgraph in is a deliberate, opt-in Integrate-phase edit. ============================== INERT / HARD-GATE ========================== P3 (builders + verifier) is HARD-GATED behind ``/sh-security-review`` + a GPT-4.1 cross-review of the §3.3.2 CI apply/verify trust boundary BEFORE it goes live. This module is TOPOLOGY + SEAMS ONLY and MUST stay INERT: * **No live CI.** The VERIFY node consumes an INJECTED ``ci_result`` seam — a fetcher callable that, given the task state, returns the authenticated CI conclusion as DATA (exactly what :func:`agent_team.ci_gate.evaluate_ci_gate` expects). The DEFAULT fetcher returns ``None`` (the current pre-live-CI reality). It performs NO live CI dispatch, NO OIDC, NO network to GitHub Actions, NO ``git``/patch apply, and NO filesystem mutation. * **Fail-safe verdict.** With no ``ci_result`` (the default), the pure-code gate (:mod:`agent_team.ci_gate`) returns ``BLOCK`` — there is no authenticated pass to be had — and :func:`route_after_verify` routes the task to PARKED. The pipeline NEVER fabricates a pass; the gate is the sole pass authority. * **LLM stays a fix-proposer.** The verifier's LLM seam (:data:`agent_team.nodes.verifier.FixAdvisor`) is consulted ONLY on a failure to author a fix hint. It is structurally incapable of flipping the verdict to pass (the verdict is computed first, by the gate, and is never read back from the proposer — see :mod:`agent_team.nodes.verifier_llm`). The live apply/verify path (the gated wiring of a real diff builder + a real CI result fetcher) is held for the separate security-review + cross-review gate and is NOT shipped or enabled here. :func:`bind_diff_builder` and :func:`bind_ci_result_fetcher` are the injection points a leaf will use to bind those real seams once the gate clears. ============================================================================ What this module owns (topology + seams only): * :data:`APPROVED_ROUTE` / :data:`BUILD_ROUTE` / :data:`PARKED_ROUTE` — the route ids :func:`route_after_verify` returns. ``BUILD_ROUTE`` / ``PARKED_ROUTE`` mirror :data:`agent_team.graph.BUILD_ROUTE` / :data:`agent_team.graph.PARKED_ROUTE` by VALUE so the conditional-edge map the Integrate phase builds matches without this module importing ``graph`` (which would be a wiring import cycle). * :func:`make_build_node` — factory producing the single-argument BUILD node, threading an injectable :class:`~agent_team.nodes.builders.DiffBuilder` into :func:`agent_team.nodes.builders.builders_node`. * :func:`make_verify_node` — factory producing the single-argument VERIFY node, threading an injectable ``ci_result`` fetcher into :func:`agent_team.nodes.verifier.verifier_node` (default fetcher -> ``None``). * :func:`route_after_verify` — the LangGraph conditional-edge function that reads the verdict the VERIFY node recorded and returns the next route id. * :func:`bind_diff_builder` / :func:`bind_ci_result_fetcher` — the gated-live injection points (held for the security gate). It imports the committed node + foundation contracts verbatim and redefines none of them. No SDK is imported at module top (deferred discipline mirroring :func:`agent_team.graph.build_sqlite_checkpointer`); it is fully unit-testable with no network. """ from __future__ import annotations from collections.abc import Callable, Mapping from typing import Any from agent_team.nodes.builders import DiffBuilder, builders_node from agent_team.nodes.verifier import VerifierConfig, verifier_node from agent_team.task_model import Phase, PipelineState __all__ = [ "APPROVED_ROUTE", "BUILD_NODE", "BUILD_ROUTE", "CiResultFetcher", "PARKED_ROUTE", "VERIFY_NODE", "bind_ci_result_fetcher", "bind_diff_builder", "make_build_node", "make_verify_node", "route_after_verify", ] # --- Node names (graph vertices). ------------------------------------------ # Kept as constants so the Integrate-phase wiring references the subgraph # vertices by name rather than by string literal. BUILD_NODE = "build" VERIFY_NODE = "verify" # --- Route ids returned by route_after_verify. ----------------------------- # These mirror agent_team.graph.BUILD_ROUTE / PARKED_ROUTE by VALUE so the # conditional-edge map the Integrate phase builds lines up without importing # graph here (that would be a wiring import cycle). APPROVED_ROUTE is the # build->verify-specific PASS terminus (the draft-PR endpoint); BUILD_ROUTE is # the loop-back to the builders on a recoverable failure; PARKED_ROUTE is the # fail-safe escalation (the only reachable route while INERT, since the default # fetcher yields no authenticated pass). APPROVED_ROUTE = "approved" BUILD_ROUTE = "build" PARKED_ROUTE = "parked" # The injectable CI-result seam: given the task state, return the authenticated, # patch-independent CI conclusion as a mapping (run_id / conclusion / diff_hash), # or ``None`` when there is no authenticated result. The DEFAULT # (:func:`_no_ci_result`) always returns ``None`` (the INERT pre-live-CI # reality), so the gate BLOCKs and the task parks — never a fabricated pass. The # real fetcher (read-only PAT against the GitHub Checks/Actions API) is bound via # :func:`bind_ci_result_fetcher` only after the §3.3.2 trust boundary clears its # security gate. CiResultFetcher = Callable[[PipelineState], Mapping[str, Any] | None] def _no_ci_result(state: PipelineState) -> None: """Default :data:`CiResultFetcher`: there is NO authenticated CI result. This is the INERT, pre-live-CI reality. Returning ``None`` means the pure-code gate (:func:`agent_team.ci_gate.evaluate_ci_gate`) has no authenticated conclusion to read and therefore returns ``BLOCK`` — never a pass. The subgraph thus fails SAFE to PARKED until a real fetcher is bound via :func:`bind_ci_result_fetcher` (which is held for the security gate). """ return None def make_build_node( *, diff_builder: DiffBuilder | None = None, config: Mapping[str, Any] | None = None, ) -> Callable[[PipelineState], dict[str, Any]]: """Produce the single-argument BUILD node (approved plan -> candidate diff). Wraps :func:`agent_team.nodes.builders.builders_node` as a one-argument ``PipelineState -> partial PipelineState`` closure so LangGraph can add it as a vertex without seeing the node's ``builder`` / ``config`` keyword params (LangGraph would otherwise try to inject its own ``RunnableConfig`` there — the same hazard :func:`agent_team.nodes.review_loop.bind_review_node` guards against). The injected ``diff_builder`` is threaded straight to the node's :class:`~agent_team.nodes.builders.DiffBuilder` seam. INERT: when ``diff_builder`` is ``None`` the node falls back to its committed default (:func:`agent_team.nodes.builders.default_diff_builder`), which fails LOUDLY in an un-wired environment (the billing seam raises until configured) rather than emitting an empty diff. The real DeepSeek path is bound via :func:`bind_diff_builder` once the §3.3.2 gate clears. The node itself never applies a patch — it emits the diff as DATA plus the box-side trust-control-surface scan + integrity hash. """ def node(state: PipelineState) -> dict[str, Any]: return builders_node(state, builder=diff_builder, config=config) return node def make_verify_node( config: VerifierConfig, *, ci_result_fetcher: CiResultFetcher | None = None, ) -> Callable[[PipelineState], PipelineState]: """Produce the single-argument VERIFY node (gate the CI result, decide phase). Wraps :func:`agent_team.nodes.verifier.verifier_node` as a one-argument closure over ``config`` (a :class:`~agent_team.nodes.verifier.VerifierConfig`) so it wires straight into LangGraph without a manage-injected ``config`` param. The INERT ``ci_result`` seam is the key here: the node reads its CI conclusion from ``state["ci_results"]``, so this wrapper FETCHES that result via the injected ``ci_result_fetcher`` and merges it into the state BEFORE delegating to the node. The default fetcher (:func:`_no_ci_result`) returns ``None`` (the pre-live-CI reality). With no authenticated CI result the pure-code gate returns ``BLOCK`` and the node parks the task — it can NEVER fabricate a pass. The LLM verifier seam stays a fix-PROPOSER only (the gate is the sole pass authority); see :mod:`agent_team.nodes.verifier_llm`. The fetcher is called defensively: it receives the task state and returns the authenticated CI conclusion mapping (``run_id`` / ``conclusion`` / ``diff_hash``) or ``None``. Any value other than a mapping is treated as "no result" (``None``), so a malformed fetcher fails SAFE to BLOCK rather than smuggling something past the gate. The real read-only-PAT fetcher is bound via :func:`bind_ci_result_fetcher` only after the §3.3.2 trust boundary clears its security gate. """ fetcher: CiResultFetcher = ( ci_result_fetcher if ci_result_fetcher is not None else _no_ci_result ) def node(state: PipelineState) -> PipelineState: fetched = fetcher(state) ci_result = fetched if isinstance(fetched, Mapping) else None # Merge the (possibly None) fetched CI result into the state the node # reads from, WITHOUT mutating the caller's state object. The node reads # ``ci_results``; a None result leaves the gate with nothing to pass on. scoped_state: dict[str, Any] = dict(state) scoped_state["ci_results"] = ci_result return verifier_node(scoped_state, config) return node def route_after_verify(state: PipelineState) -> str: """LangGraph conditional-edge: the next route id after the VERIFY node. Reads the phase the VERIFY node recorded (the pure-code gate's verdict, already merged into ``current_phase`` / ``status``) and maps it to a route id the Integrate-phase conditional-edge map keys against: * gate PASS -> phase ``DONE`` -> :data:`APPROVED_ROUTE` (the draft-PR terminus). UNREACHABLE while INERT — the default fetcher yields no authenticated pass, so the gate never returns PASS. * gate FAIL under the build-loop budget -> phase ``BUILD`` -> :data:`BUILD_ROUTE` (loop back to the builders with the fix hint). * gate BLOCK, or FAIL at/over the budget -> phase ``PARKED`` -> :data:`PARKED_ROUTE` (escalate to human + GPT cross-review; ALARM). FAILS SAFE: any unexpected / missing phase routes to :data:`PARKED_ROUTE` rather than advancing, so an ambiguous state parks for a human instead of shipping. The verdict is owned entirely by the gate (the node already applied it); this function only reads the recorded phase. """ phase = state.get("current_phase") if phase == Phase.DONE.value: return APPROVED_ROUTE if phase == Phase.BUILD.value: return BUILD_ROUTE if phase == Phase.PARKED.value: return PARKED_ROUTE # Unknown / missing phase (the node always sets one of the above) -> park # fail-closed rather than advancing an ambiguous state. return PARKED_ROUTE def bind_diff_builder( diff_builder: DiffBuilder, ) -> Callable[[PipelineState], dict[str, Any]]: """Bind the REAL diff builder into a BUILD node (GATED-LIVE injection point). Thin convenience over :func:`make_build_node` for the leaf that, once the §3.3.2 trust boundary clears ``/sh-security-review`` + the GPT-4.1 cross-review, binds the real DeepSeek ``fast_coder`` path (adapt :func:`agent_team.nodes.builders_llm.as_diff_builder` into a :class:`~agent_team.nodes.builders.DiffBuilder`). Binding it does NOT enable any apply/verify behaviour — the BUILD node still only EMITS a diff as DATA plus the box-side scan + hash. Held for the gate; not wired here. """ return make_build_node(diff_builder=diff_builder) def bind_ci_result_fetcher( config: VerifierConfig, ci_result_fetcher: CiResultFetcher, ) -> Callable[[PipelineState], PipelineState]: """Bind the REAL CI-result fetcher into a VERIFY node (GATED-LIVE injection). Thin convenience over :func:`make_verify_node` for the leaf that, once the §3.3.2 trust boundary clears its security gate, binds the real authenticated CI-result fetcher (read-only PAT against the GitHub Checks/Actions API, NOT enabled here). The fetcher returns the authenticated conclusion as DATA; pass/fail remains owned by the pure-code gate, so binding a fetcher only GIVES the gate a result to read — it can never make the LLM the pass authority. Held for the gate; not wired here. """ return make_verify_node(config, ci_result_fetcher=ci_result_fetcher) # A module-level note for the Integrate phase (no execution): the build->verify # subgraph is hung off the review loop's "build" route. The conditional-edge map # from VERIFY should send APPROVED_ROUTE to the PR/draft terminus, BUILD_ROUTE # back to the BUILD node (the bounded build<->verify loop, capped by # VerifierConfig.max_build_loops), and PARKED_ROUTE to the escalation terminus. # build_graph wires this in opt-in; this module never assembles it itself. _INTEGRATE_NOTE = ( "review('build') -> BUILD -> VERIFY -> route_after_verify -> " "{approved: PR terminus, build: BUILD (loop), parked: escalation}" )