build_verify_subgraph: BUILD->VERIFY nodes + route_after_verify. build_graph gains an opt-in build_verify param that repoints the review 'build' route at the subgraph (BUILD->VERIFY->{approved->END | loop->PLAN | parked->END}); default unchanged (P2). Coordinator build_verify_wiring composes it INERT (no ci_result -> ci_gate BLOCK -> PARKED; LLM is fix-proposer only, never declares green). NOT enabled in production: the live CI apply/verify + OIDC stays held for its /sh-security-review + GPT-4.1 cross-review gate. Also escapes untrusted intake text in logs (log-injection hygiene).
286 lines
14 KiB
Python
286 lines
14 KiB
Python
"""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}"
|
|
)
|