feat(agent-team): P3-inert build/verify subgraph topology (opt-in, no live CI)

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).
This commit is contained in:
Adam Moussa 2026-06-18 13:23:03 -04:00
parent 0842ff778b
commit e1208ee563
5 changed files with 957 additions and 7 deletions

View file

@ -66,6 +66,7 @@ if TYPE_CHECKING: # pragma: no cover - typing only
__all__ = [
"Coordinator",
"build_verify_wiring",
"default_clarify_node_factory",
]
@ -96,6 +97,19 @@ ReviewWiring = Callable[
[],
"tuple[Callable[[PipelineState], PipelineState], Callable[[PipelineState], str]]",
]
# Composes the opt-in P3 build->verify subgraph and yields the
# ``(build_node, verify_node, route_after_verify)`` tuple
# :func:`agent_team.graph.build_graph` wires off the review loop's "build" route.
# None -> no P3 subgraph (P2: the review's "build" route is the terminus). This
# stays OPT-IN and INERT: the production run-team path never injects it (it is
# held until the §3.3.2 CI trust-boundary security gate clears).
BuildVerifyWiring = Callable[
[],
"tuple["
"Callable[[PipelineState], dict[str, Any]], "
"Callable[[PipelineState], PipelineState], "
"Callable[[PipelineState], str]]",
]
# A checkpointer factory over the db path: returns the BaseCheckpointSaver the
# graph is compiled with. Defaults to the production SQLite checkpointer
@ -189,6 +203,57 @@ def default_review_wiring() -> tuple[
return review_loop.bind_review_node(), review_loop.route_after_review
def build_verify_wiring() -> tuple[
Callable[[PipelineState], "dict[str, Any]"],
Callable[[PipelineState], PipelineState],
Callable[[PipelineState], str],
]:
"""Compose the OPT-IN, INERT P3 build->verify subgraph (held for the gate).
Returns the ``(build_node, verify_node, route_after_verify)`` tuple
:func:`agent_team.graph.build_graph` hangs off the review loop's "build"
route, composed from :mod:`agent_team.nodes.build_verify_subgraph` with its
INERT defaults:
* the BUILD node is built with NO injected diff builder
(:func:`~agent_team.nodes.build_verify_subgraph.make_build_node` with
``diff_builder=None``), so it falls back to the committed default builder
that fails loudly in an un-wired environment rather than emitting an empty
diff; and
* the VERIFY node is built with NO ``ci_result`` fetcher
(:func:`~agent_team.nodes.build_verify_subgraph.make_verify_node` with the
default ``_no_ci_result`` -> ``None``), so the pure-code gate has no
authenticated pass to read, returns ``BLOCK``, and the task parks.
This is the FAIL-SAFE composition: with no authenticated CI result the
pipeline can NEVER fabricate a pass, so wiring this subgraph in is inert
until a leaf binds the real diff-builder + read-only-PAT CI fetcher via
:func:`~agent_team.nodes.build_verify_subgraph.bind_diff_builder` /
:func:`~agent_team.nodes.build_verify_subgraph.bind_ci_result_fetcher` AFTER
the §3.3.2 CI trust-boundary clears ``/sh-security-review`` + the GPT-4.1
cross-review. The production ``run-team.py`` path deliberately does NOT inject
this factory; it stays opt-in/off.
Lazy-imported for the same import-hygiene reason as the clarifier / planner /
review factories (the subgraph pulls in the builders + verifier leaves).
"""
from agent_team.nodes.build_verify_subgraph import (
make_build_node,
make_verify_node,
route_after_verify,
)
from agent_team.nodes.verifier import VerifierConfig
# INERT VerifierConfig: ``expected_run_id`` is required by the dataclass but
# is moot while the default fetcher yields no authenticated CI result (the
# gate BLOCKs and the task parks regardless). A real leaf supplies the task's
# actual expected run id once the gate clears.
config = VerifierConfig(expected_run_id="")
build_node = make_build_node(diff_builder=None)
verify_node = make_verify_node(config, ci_result_fetcher=None)
return build_node, verify_node, route_after_verify
class Coordinator:
"""Owns the live Plane-2 runtime: graph + resume worker + transport (§3.3).
@ -211,6 +276,7 @@ class Coordinator:
build_clarify_node: ClarifyNodeFactory | None = None,
build_plan_node: PlanNodeFactory | None = None,
review_wiring: ReviewWiring | None = None,
build_verify_wiring: BuildVerifyWiring | None = None,
build_checkpointer: CheckpointerFactory | None = None,
resume_queue: "queue.Queue[Any] | None" = None,
deadline_window: timedelta | None = None,
@ -224,6 +290,10 @@ class Coordinator:
# path injects default_plan_node_factory + default_review_wiring.
self._build_plan_node = build_plan_node
self._review_wiring = review_wiring
# P3 (build->verify) is OPT-IN and INERT: left None, the graph stops at
# the P2 review terminus. The production run-team path never injects it;
# it is held until the §3.3.2 CI trust-boundary security gate clears.
self._build_verify_wiring = build_verify_wiring
self._build_checkpointer = (
build_checkpointer or graph_mod.build_sqlite_checkpointer
)
@ -294,12 +364,20 @@ class Coordinator:
if self._review_wiring is not None:
review_node, route_review = self._review_wiring()
# P3 (opt-in/INERT): the build->verify subgraph tuple, only wired when
# the review loop is also wired (it hangs off the review's "build"
# route). None in the production path.
build_verify: Any = None
if self._build_verify_wiring is not None:
build_verify = self._build_verify_wiring()
self._graph = graph_mod.build_graph(
checkpointer,
live_clarify_node=clarify_node,
live_plan_node=plan_node,
review_node=review_node,
route_review=route_review,
build_verify=build_verify,
)
# The ResumeWorker is satisfied directly by the compiled LangGraph app
@ -354,7 +432,14 @@ class Coordinator:
if self._graph is None:
raise RuntimeError("Coordinator.start_task called before setup()")
_LOG.info("start_task intake (transport=%s): %s", transport_name, task_text)
# Intake text is untrusted (a GitHub issue body, etc.); truncate and
# escape newlines so a forged multi-line body cannot spoof operator log
# lines (log-injection hygiene).
_LOG.info(
"start_task intake (transport=%s): %s",
transport_name,
task_text[:200].replace("\n", "\\n").replace("\r", "\\r"),
)
thread_id, _state = graph_mod.start_task(self._graph, transport=transport_name)
question = graph_mod.pending_question(self._graph, thread_id=thread_id)

View file

@ -62,6 +62,8 @@ if TYPE_CHECKING: # pragma: no cover - typing only
from langgraph.graph.state import CompiledStateGraph
__all__ = [
"APPROVED_ROUTE",
"BUILD_NODE",
"BUILD_ROUTE",
"CLARIFY",
"DEFAULT_CLARIFY_DEADLINE",
@ -70,6 +72,7 @@ __all__ = [
"PARKED_ROUTE",
"PLAN",
"REVIEW",
"VERIFY_NODE",
"build_graph",
"build_sqlite_checkpointer",
"clarify_node",
@ -99,6 +102,25 @@ REVIEW = "review"
BUILD_ROUTE = "build"
PARKED_ROUTE = "parked"
# P3 (build -> verify subgraph) vertex ids. These are the GRAPH VERTEX names the
# opt-in P3 subgraph hangs off the review loop's "build" route; they are kept
# distinct from the route-id constants above (BUILD_ROUTE / PARKED_ROUTE) and
# from PLAN/REVIEW so the conditional-edge maps never collide a route key with a
# vertex id. The subgraph itself is supplied wholesale by the Integrate-phase
# caller (the ``build_verify`` tuple), so graph.py does not import
# agent_team.nodes.build_verify_subgraph (no wiring import cycle); it only owns
# the topology that connects the injected nodes.
BUILD_NODE = "build_node"
VERIFY_NODE = "verify_node"
# P3 route ids returned by the injected ``route_after_verify`` function. They
# mirror agent_team.nodes.build_verify_subgraph.APPROVED_ROUTE / BUILD_ROUTE /
# PARKED_ROUTE by VALUE so this module wires the VERIFY conditional-edge map
# without importing that module. APPROVED_ROUTE is the build->verify-specific
# PASS terminus (the draft-PR endpoint); BUILD_ROUTE loops back to the builders
# under the build-loop budget; PARKED_ROUTE is the fail-safe escalation.
APPROVED_ROUTE = "approved"
# The P1 stage order (§7.1): intake -> clarify -> plan, then stop. Builders and
# verifiers (BUILD/VERIFY) are deliberately NOT wired here — P1 ends at an
# approved plan with no build (§7.1 "Stops at an approved plan, no build yet").
@ -266,6 +288,12 @@ def build_graph(
live_plan_node: Callable[[PipelineState], PipelineState] | None = None,
review_node: Callable[[PipelineState], PipelineState] | None = None,
route_review: Callable[[PipelineState], str] | None = None,
build_verify: tuple[
Callable[[PipelineState], dict[str, Any]],
Callable[[PipelineState], PipelineState],
Callable[[PipelineState], str],
]
| None = None,
) -> CompiledStateGraph:
"""Assemble + compile the P1 pipeline ``StateGraph`` (§3.3, §7.1).
@ -311,6 +339,27 @@ def build_graph(
``review_node`` requires ``route_review`` (and a real ``live_plan_node`` that
advances to REVIEW); passing one without the other is a wiring error.
``build_verify`` wires the **OPT-IN P3** build -> verify subgraph and is
supplied wholesale as the tuple
``(build_node, verify_node, route_after_verify)`` the coordinator composes
from :mod:`agent_team.nodes.build_verify_subgraph` (passed in so this module
never imports that module — no wiring import cycle). It only takes effect
when the review loop is also wired (it hangs off the review's ``"build"``
route):
* **P2 (default):** ``build_verify`` is ``None`` -> the review's ``"build"``
route terminates at ``END`` (the approved-plan terminus), exactly as
before. Production stays P2 (clarify -> plan -> review).
* **P3:** ``build_verify`` is given (with ``review_node``) -> the review's
``"build"`` route is REPOINTED at the BUILD node, ``BUILD -> VERIFY`` is
wired, and ``route_after_verify`` maps ``{approved -> END (PR terminus),
build -> BUILD (bounded build<->verify loop), parked -> END (escalation)}``.
The subgraph stays INERT unless the caller binds real diff-builder / CI
seams (held for the §3.3.2 security gate); with the default INERT seams the
verifier gate has no authenticated pass and parks. Passing
``build_verify`` without ``review_node`` is a wiring error (there is no
``"build"`` route to repoint).
"""
clarify = live_clarify_node if live_clarify_node is not None else clarify_node
plan = live_plan_node if live_plan_node is not None else plan_node
@ -321,6 +370,13 @@ def build_graph(
"function, e.g. review_loop.route_after_review)."
)
if build_verify is not None and review_node is None:
raise ValueError(
"build_graph: build_verify (the P3 build->verify subgraph) requires "
"review_node — it hangs off the review loop's 'build' route, so there "
"is nothing to repoint without a review loop."
)
builder: StateGraph = StateGraph(PipelineState)
builder.add_node(INTAKE, intake_node)
builder.add_node(CLARIFY, clarify)
@ -334,14 +390,38 @@ def build_graph(
# P1: the plan stage is the terminus.
builder.add_edge(PLAN, END)
else:
# P2: plan -> review -> {loop-back to plan | END}.
# P2/P3: plan -> review -> {loop-back to plan | build | END}.
builder.add_node(REVIEW, review_node)
builder.add_edge(PLAN, REVIEW)
builder.add_conditional_edges(
REVIEW,
route_review,
{BUILD_ROUTE: END, PLAN: PLAN, PARKED_ROUTE: END},
)
if build_verify is None:
# P2: the review's "build" route is the approved-plan terminus.
builder.add_conditional_edges(
REVIEW,
route_review,
{BUILD_ROUTE: END, PLAN: PLAN, PARKED_ROUTE: END},
)
else:
# P3 (opt-in): repoint the review's "build" route at the BUILD node,
# wire BUILD -> VERIFY, and route the verifier verdict to
# {approved -> END (PR terminus), build -> BUILD (loop), parked ->
# END (escalation)}. The subgraph nodes + router are injected (the
# ``build_verify`` tuple) so this module imports no P3 code.
build_node, verify_node, route_after_verify = build_verify
builder.add_node(BUILD_NODE, build_node)
builder.add_node(VERIFY_NODE, verify_node)
builder.add_conditional_edges(
REVIEW,
route_review,
{BUILD_ROUTE: BUILD_NODE, PLAN: PLAN, PARKED_ROUTE: END},
)
builder.add_edge(BUILD_NODE, VERIFY_NODE)
builder.add_conditional_edges(
VERIFY_NODE,
route_after_verify,
{APPROVED_ROUTE: END, BUILD_ROUTE: BUILD_NODE, PARKED_ROUTE: END},
)
if checkpointer is None:
return builder.compile()

View file

@ -0,0 +1,286 @@
"""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}"
)

View file

@ -0,0 +1,375 @@
"""Unit tests for agent_team.nodes.build_verify_subgraph (P3-INERT topology).
These tests prove the build -> verify subgraph TOPOLOGY is correctly inert:
* the BUILD node proposes a candidate diff via an INJECTED fake builder and
advances to VERIFY;
* the VERIFY node, fed a fake authenticated-pass ``ci_result``, routes to the
approved / PR terminus;
* the VERIFY node with the DEFAULT (None) fetcher — and with a failing fetcher —
BLOCKs and routes to PARKED, never fabricating a pass;
* an LLM fix-proposal can NEVER flip a failing verdict to pass (the gate is the
sole pass authority).
Everything is fully mocked; no SDK, no network, no live CI.
"""
from __future__ import annotations
import pytest
from agent_team.nodes import build_verify_subgraph as bvs
from agent_team.nodes import verifier as verifier_mod
from agent_team.nodes.build_verify_subgraph import (
APPROVED_ROUTE,
BUILD_ROUTE,
PARKED_ROUTE,
bind_ci_result_fetcher,
make_build_node,
make_verify_node,
route_after_verify,
)
from agent_team.nodes.verifier import VerifierConfig, set_fix_advisor
from agent_team.state_store import compute_content_hash
from agent_team.task_model import Phase, PipelineState, TaskStatus
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _diff_for(*paths: str) -> str:
"""Build a minimal in-scope unified diff touching ``paths``."""
chunks = []
for p in paths:
chunks.append(f"diff --git a/{p} b/{p}\n@@ -1 +1 @@\n-old\n+new\n")
return "".join(chunks)
def _hash(diff: str) -> str:
return compute_content_hash(diff.encode("utf-8"))
def _plan(scope: list[str]) -> dict:
return {
"title": "do the thing",
"scope": scope,
"phases": ["P1: edit", "P2: test"],
"approved": True,
}
def _build_state(plan: dict) -> PipelineState:
return {
"thread_id": "t1",
"status": TaskStatus.ACTIVE.value,
"current_phase": Phase.BUILD.value,
"plan": plan,
}
def _verify_state(diff: str) -> PipelineState:
return {
"thread_id": "t1",
"status": TaskStatus.ACTIVE.value,
"current_phase": Phase.VERIFY.value,
"candidate_diff": diff,
"diff_hash": _hash(diff),
"ci_results": None,
}
@pytest.fixture(autouse=True)
def _reset_advisor():
"""Restore the default null fix-advisor after each test."""
yield
set_fix_advisor(verifier_mod._null_advisor)
# --------------------------------------------------------------------------- #
# Route id parity with graph.py (topology contract)
# --------------------------------------------------------------------------- #
def test_route_ids_mirror_graph_by_value() -> None:
"""BUILD_ROUTE / PARKED_ROUTE must match graph.py by value (no import cycle)."""
from agent_team import graph
assert bvs.BUILD_ROUTE == graph.BUILD_ROUTE
assert bvs.PARKED_ROUTE == graph.PARKED_ROUTE
# APPROVED_ROUTE is the build->verify-specific PASS terminus.
assert APPROVED_ROUTE == "approved"
# --------------------------------------------------------------------------- #
# BUILD node: proposes a diff via an injected fake builder
# --------------------------------------------------------------------------- #
def test_build_node_proposes_diff_with_injected_builder() -> None:
diff = _diff_for("src/foo.py")
calls: list[dict] = []
def fake_builder(*, plan, config):
calls.append({"plan": plan, "config": config})
return diff
node = make_build_node(diff_builder=fake_builder)
plan = _plan(scope=["src"])
out = node(_build_state(plan))
# The injected builder was consulted with the approved plan.
assert len(calls) == 1
assert calls[0]["plan"] == plan
# Clean in-scope diff -> advance to VERIFY with the diff + integrity hash.
assert out["candidate_diff"] == diff
assert out["diff_hash"] == _hash(diff)
assert out["current_phase"] == Phase.VERIFY.value
assert out["status"] == TaskStatus.ACTIVE.value
assert "park_reason" not in out
def test_build_node_parks_on_trust_control_surface_violation() -> None:
"""A diff touching the denylist parks for human + GPT cross-review."""
diff = _diff_for(".github/workflows/ci.yml")
def fake_builder(*, plan, config):
return diff
node = make_build_node(diff_builder=fake_builder)
out = node(_build_state(_plan(scope=[".github"])))
assert out["current_phase"] == Phase.PARKED.value
assert out["status"] == TaskStatus.PARKED.value
assert "park_reason" in out
# --------------------------------------------------------------------------- #
# VERIFY node: authenticated pass -> approved/PR terminus
# --------------------------------------------------------------------------- #
def test_verify_pass_routes_to_approved() -> None:
diff = _diff_for("src/foo.py")
def pass_fetcher(state):
# A fake authenticated-pass CI result keyed to the expected run + hash.
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=pass_fetcher,
)
out = node(_verify_state(diff))
# Pure-code gate passed -> DONE / draft-PR terminus.
assert out["status"] == TaskStatus.DONE.value
assert out["current_phase"] == Phase.DONE.value
assert out["ci_results"]["gate_decision"] == "pass"
assert route_after_verify(out) == APPROVED_ROUTE
# --------------------------------------------------------------------------- #
# VERIFY node: INERT default (None) + failing fetcher -> BLOCK -> PARKED
# --------------------------------------------------------------------------- #
def test_verify_default_fetcher_blocks_and_parks() -> None:
"""No ci_result (the INERT default) -> BLOCK -> PARKED. Never a pass."""
diff = _diff_for("src/foo.py")
# No fetcher injected: the default returns None (pre-live-CI reality).
node = make_verify_node(VerifierConfig(expected_run_id="r1", allowed_scope=["src"]))
out = node(_verify_state(diff))
assert out["status"] == TaskStatus.PARKED.value
assert out["current_phase"] == Phase.PARKED.value
assert out["ci_results"]["gate_decision"] == "block"
assert route_after_verify(out) == PARKED_ROUTE
def test_verify_none_fetcher_explicit_blocks_and_parks() -> None:
"""An explicit fetcher returning None also fails safe to PARKED."""
diff = _diff_for("src/foo.py")
def none_fetcher(state):
return None
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=none_fetcher,
)
out = node(_verify_state(diff))
assert out["current_phase"] == Phase.PARKED.value
assert route_after_verify(out) == PARKED_ROUTE
def test_verify_failing_ci_result_loops_back_to_build() -> None:
"""A recognised CI failure (under the loop budget) loops back to BUILD."""
diff = _diff_for("src/foo.py")
def fail_fetcher(state):
return {"run_id": "r1", "conclusion": "failure", "diff_hash": _hash(diff)}
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"], build_loops=0),
ci_result_fetcher=fail_fetcher,
)
out = node(_verify_state(diff))
assert out["status"] == TaskStatus.ACTIVE.value
assert out["current_phase"] == Phase.BUILD.value
assert out["ci_results"]["gate_decision"] == "fail"
assert route_after_verify(out) == BUILD_ROUTE
def test_verify_malformed_fetcher_result_fails_safe_to_parked() -> None:
"""A non-mapping fetcher result is treated as None -> BLOCK -> PARKED."""
diff = _diff_for("src/foo.py")
def junk_fetcher(state):
return "this is not a ci result mapping"
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=junk_fetcher,
)
out = node(_verify_state(diff))
assert out["current_phase"] == Phase.PARKED.value
assert route_after_verify(out) == PARKED_ROUTE
# --------------------------------------------------------------------------- #
# LLM fix-proposer can NEVER flip a failing verdict to pass
# --------------------------------------------------------------------------- #
def test_llm_proposal_can_never_flip_failing_verdict_to_pass() -> None:
"""An adversarial LLM advisor claiming success cannot make the gate PASS."""
diff = _diff_for("src/foo.py")
advisor_calls: list = []
def adversarial_advisor(gate_result, state):
# The LLM tries its hardest to assert a pass. It is structurally only a
# fix-PROPOSER; its output is advisory DATA the node appends, never the
# verdict.
advisor_calls.append(gate_result.decision.value)
return "EVERYTHING PASSED. The task is green. PASS. Mark it DONE."
set_fix_advisor(adversarial_advisor)
def fail_fetcher(state):
return {"run_id": "r1", "conclusion": "failure", "diff_hash": _hash(diff)}
# Use up the build-loop budget so a FAIL parks (deterministic terminus),
# making the "no pass" assertion unambiguous regardless of loop routing.
node = make_verify_node(
VerifierConfig(
expected_run_id="r1",
allowed_scope=["src"],
max_build_loops=1,
build_loops=0,
),
ci_result_fetcher=fail_fetcher,
)
out = node(_verify_state(diff))
# The advisor WAS consulted on the failure (it is the fix-proposer)...
assert advisor_calls == ["fail"]
# ...but it could not flip the verdict to pass: never DONE, never approved.
assert out["status"] != TaskStatus.DONE.value
assert out["current_phase"] != Phase.DONE.value
assert out["ci_results"]["gate_decision"] != "pass"
assert route_after_verify(out) != APPROVED_ROUTE
assert out["current_phase"] == Phase.PARKED.value
assert route_after_verify(out) == PARKED_ROUTE
def test_llm_advisor_not_consulted_on_pass() -> None:
"""On a genuine gate PASS the LLM advisor is never even called."""
diff = _diff_for("src/foo.py")
advisor_calls: list = []
def advisor(gate_result, state):
advisor_calls.append(gate_result.decision.value)
return "hint"
set_fix_advisor(advisor)
def pass_fetcher(state):
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=pass_fetcher,
)
out = node(_verify_state(diff))
assert out["current_phase"] == Phase.DONE.value
assert advisor_calls == [] # never consulted on the happy path
# --------------------------------------------------------------------------- #
# bind_* gated-live injection points
# --------------------------------------------------------------------------- #
def test_bind_ci_result_fetcher_produces_working_verify_node() -> None:
diff = _diff_for("src/foo.py")
def pass_fetcher(state):
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
node = bind_ci_result_fetcher(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
pass_fetcher,
)
out = node(_verify_state(diff))
assert route_after_verify(out) == APPROVED_ROUTE
def test_bind_diff_builder_produces_working_build_node() -> None:
diff = _diff_for("src/foo.py")
def fake_builder(*, plan, config):
return diff
node = bvs.bind_diff_builder(fake_builder)
out = node(_build_state(_plan(scope=["src"])))
assert out["candidate_diff"] == diff
assert out["current_phase"] == Phase.VERIFY.value
# --------------------------------------------------------------------------- #
# route_after_verify fail-safe on a missing / unknown phase
# --------------------------------------------------------------------------- #
def test_route_after_verify_parks_on_missing_phase() -> None:
assert route_after_verify({}) == PARKED_ROUTE
assert route_after_verify({"current_phase": "intake"}) == PARKED_ROUTE
def test_verify_node_does_not_mutate_caller_state() -> None:
"""The wrapper merges ci_result into a COPY, never the caller's state."""
diff = _diff_for("src/foo.py")
state = _verify_state(diff)
state["ci_results"] = None
sentinel = state["ci_results"]
def pass_fetcher(s):
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=pass_fetcher,
)
node(state)
# Caller's state is untouched (the node wrote into a dict copy).
assert state["ci_results"] is sentinel

View file

@ -366,6 +366,130 @@ def test_p2_graph_loops_then_escalates_on_persistent_changes(
assert len(final["review_verdicts"]) == 3 # looped to the cap, then escalated
# --- P3 build -> verify subgraph wiring (opt-in). ---------------------------
def _p3_plan_stub(state: PipelineState) -> PipelineState:
"""P2/P3 planner stub: emit an APPROVED, scoped plan and advance to REVIEW.
Like ``_p2_plan_stub`` but carries a ``scope`` so the P3 BUILD node's
trust-control-surface scan accepts the candidate diff, letting the
build -> verify topology be driven end to end.
"""
revisions = len(state.get("review_verdicts") or [])
return PipelineState(
plan={
"title": "do it",
"scope": ["src"],
"phases": ["P1"],
"revision": revisions,
},
current_phase=Phase.REVIEW.value,
status=TaskStatus.ACTIVE.value,
)
def _p3_diff() -> str:
"""A minimal in-scope unified diff the fake builder returns."""
return "diff --git a/src/foo.py b/src/foo.py\n@@ -1 +1 @@\n-old\n+new\n"
def _p3_graph(review_text: str, *, ci_result_fetcher):
"""Compile a P3 graph: review -> build -> verify with injected seams.
The diff builder is a fixed in-scope diff; the CI-result fetcher is injected
so the test drives the verifier verdict (pass / fail / none) deterministically
with no live CI.
"""
from agent_team.nodes import review_loop
from agent_team.nodes.build_verify_subgraph import (
make_build_node,
make_verify_node,
route_after_verify,
)
from agent_team.nodes.verifier import VerifierConfig
review_loop.set_review_invoker(lambda prompt, **kw: review_text)
def fake_builder(*, plan, config):
return _p3_diff()
build_node = make_build_node(diff_builder=fake_builder)
verify_node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=ci_result_fetcher,
)
return build_graph(
checkpointer=_Saver(),
live_plan_node=_p3_plan_stub,
review_node=review_loop.bind_review_node(),
route_review=review_loop.route_after_review,
build_verify=(build_node, verify_node, route_after_verify),
)
def test_build_graph_build_verify_requires_review_node() -> None:
"""build_verify without review_node is a wiring error (no 'build' route)."""
from agent_team.nodes.build_verify_subgraph import (
make_build_node,
make_verify_node,
route_after_verify,
)
from agent_team.nodes.verifier import VerifierConfig
tuple_ = (
make_build_node(diff_builder=None),
make_verify_node(VerifierConfig(expected_run_id="")),
route_after_verify,
)
with pytest.raises(ValueError, match="build_verify"):
build_graph(build_verify=tuple_)
def test_p3_graph_authenticated_pass_routes_to_done(restore_review_invoker) -> None:
"""review(APPROVE) -> build -> verify(PASS via fake CI) -> DONE (PR terminus)."""
def pass_fetcher(state):
from agent_team.state_store import compute_content_hash
diff_hash = compute_content_hash(_p3_diff().encode("utf-8"))
return {"run_id": "r1", "conclusion": "success", "diff_hash": diff_hash}
graph = _p3_graph("VERDICT: APPROVE\nlooks solid", ci_result_fetcher=pass_fetcher)
thread_id, _ = start_task(graph, transport="slack")
final = resume_task(graph, thread_id=thread_id, answer="scope is X")
# The authenticated CI pass cleared the gate -> DONE terminus.
assert final["current_phase"] == Phase.DONE.value
assert final["status"] == TaskStatus.DONE.value
def test_p3_graph_inert_default_parks_at_verify(restore_review_invoker) -> None:
"""review(APPROVE) -> build -> verify(no CI result) -> BLOCK -> PARKED.
With the INERT default (no authenticated CI result) the gate can never
fabricate a pass, so an approved plan still parks at VERIFY. This is the
production-safe behavior the opt-in subgraph ships with.
"""
graph = _p3_graph(
"VERDICT: APPROVE\nlooks solid", ci_result_fetcher=lambda state: None
)
thread_id, _ = start_task(graph, transport="slack")
final = resume_task(graph, thread_id=thread_id, answer="scope is X")
assert final["current_phase"] == Phase.PARKED.value
assert final["status"] == TaskStatus.PARKED.value
def test_p3_graph_route_constants_mirror_subgraph_by_value() -> None:
"""graph.py's P3 route ids match the subgraph module by value (no cycle)."""
from agent_team.nodes import build_verify_subgraph as bvs
assert graph_mod.APPROVED_ROUTE == bvs.APPROVED_ROUTE
assert graph_mod.BUILD_ROUTE == bvs.BUILD_ROUTE
assert graph_mod.PARKED_ROUTE == bvs.PARKED_ROUTE
# --- Module import hygiene. -------------------------------------------------