fix(agent-team): wire DeepSeek builder (#60) + plan-gate→build routing + accurate build/verify Slack status #61
10 changed files with 523 additions and 22 deletions
|
|
@ -545,8 +545,22 @@ def failsafe_production_p3_wiring(
|
|||
if _p3_env_is_configured():
|
||||
owner = os.environ.get("AGENT_TEAM_REPO_OWNER", "").strip()
|
||||
repo = os.environ.get("AGENT_TEAM_REPO_NAME", "").strip()
|
||||
# Bind the INTENDED diff builder: the DeepSeek ``fast_coder`` mechanical
|
||||
# -edit path (:func:`agent_team.nodes.builders_llm.as_diff_builder`). It
|
||||
# produces the diff as a SINGLE model completion, so — unlike the Claude
|
||||
# agentic fallback (``builders.default_diff_builder``) — it cannot exhaust
|
||||
# the agent turn cap mid-exploration (issue #60: a tool-using Claude
|
||||
# session burned all its turns reading files and never emitted a diff).
|
||||
# Without this bind the build node falls back to the Claude single-shot
|
||||
# path, which is exactly the #60 failure. Lazy import keeps the
|
||||
# orchestrator models stack off this module's import surface.
|
||||
from agent_team.nodes.builders_llm import as_diff_builder
|
||||
|
||||
diff_builder = as_diff_builder()
|
||||
return (
|
||||
lambda: gated_build_verify_wiring(owner=owner, repo=repo),
|
||||
lambda: gated_build_verify_wiring(
|
||||
owner=owner, repo=repo, diff_builder=diff_builder
|
||||
),
|
||||
default_dispatch_node_factory,
|
||||
)
|
||||
|
||||
|
|
@ -1151,15 +1165,47 @@ class Coordinator:
|
|||
)
|
||||
continue
|
||||
|
||||
# No pending question: the task settled. Distinguish parked vs done,
|
||||
# and say WHERE it got to and WHAT is blocking it.
|
||||
# ``built`` == the task reached the P3 build subgraph (it produced a
|
||||
# candidate diff). This distinguishes a build/verify-stage state from a
|
||||
# plan/review escalation, which the messages below key off so a
|
||||
# successfully-built task is never reported as "the plan could not be
|
||||
# auto-approved".
|
||||
status = values.get("status")
|
||||
run_id = str(values.get("run_id") or "").strip()
|
||||
built = bool(values.get("candidate_diff") or values.get("diff_hash"))
|
||||
|
||||
# IN-PROGRESS, not settled: the VERIFY node SUSPENDS (async CI-wait)
|
||||
# awaiting a dispatched run's terminal conclusion. Its interrupt
|
||||
# payload is NOT question-shaped, so ``pending_question`` returns None
|
||||
# even though the graph is still suspended — without this branch the
|
||||
# task is misread as a settled PARK and gets the "could not be
|
||||
# auto-approved / re-assign" escalation copy. Detect the suspend (graph
|
||||
# still interrupted + a dispatched run + a built diff) and report
|
||||
# honest progress instead.
|
||||
suspended = bool(getattr(snap, "interrupts", None)) or bool(
|
||||
getattr(snap, "next", None)
|
||||
)
|
||||
if question is None and suspended and built and run_id:
|
||||
self._emit(
|
||||
f"🛠️ {label} — plan approved; diff built and dispatched to CI "
|
||||
f"(run `{run_id}`). Awaiting verification — I'll post the "
|
||||
"result when the run finishes.",
|
||||
thread_ts=root_ts,
|
||||
)
|
||||
continue
|
||||
|
||||
# No pending question and not awaiting CI: the task settled. Distinguish
|
||||
# parked vs done, and say WHERE it got to and WHAT is blocking it.
|
||||
phase = str(values.get("current_phase") or "unknown")
|
||||
# When a task parks, current_phase is the terminal "parked" — not
|
||||
# useful. Infer the phase it was IN when it escalated, so the human
|
||||
# sees WHERE it died (review > plan > clarify by what state exists).
|
||||
# sees WHERE it stopped (verify > review > plan > clarify by what state
|
||||
# exists). A built diff means it reached the build/verify subgraph, so
|
||||
# that takes precedence over the plan/review escalation phases.
|
||||
if phase == "parked":
|
||||
if values.get("review_verdicts"):
|
||||
if built:
|
||||
phase = "verify"
|
||||
elif values.get("review_verdicts"):
|
||||
phase = "review"
|
||||
elif values.get("plan"):
|
||||
phase = "plan"
|
||||
|
|
@ -1181,6 +1227,20 @@ class Coordinator:
|
|||
"Re-assign it to try again.",
|
||||
thread_ts=root_ts,
|
||||
)
|
||||
elif status == TaskStatus.PARKED.value and built:
|
||||
# The diff WAS built and dispatched, but the build/verify gate did
|
||||
# not pass (CI BLOCKed / no authenticated pass). This is NOT a
|
||||
# "plan could not be auto-approved" escalation — say what actually
|
||||
# happened so the human looks at the CI run, not the plan.
|
||||
blocker = self._summarize_build_blocker(values)
|
||||
self._emit(
|
||||
f"⚠️ PARKED — {label}\n"
|
||||
f"• Reached phase: {phase}\n"
|
||||
f"• What's blocking it: {blocker}\n"
|
||||
"• The diff was built and dispatched, but the verification "
|
||||
"gate did not pass. Review the CI run, then re-assign to retry.",
|
||||
thread_ts=root_ts,
|
||||
)
|
||||
elif status == TaskStatus.PARKED.value:
|
||||
blocker = self._summarize_blocker(values)
|
||||
self._emit(
|
||||
|
|
@ -1522,6 +1582,30 @@ class Coordinator:
|
|||
"requesting changes without converging)."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_build_blocker(values: "dict[str, Any]") -> str:
|
||||
"""Human-readable reason a BUILT task parked at the build/verify gate.
|
||||
|
||||
Surfaces the CI conclusion the verifier gated on (``ci_results``) so the
|
||||
human sees WHY verification failed — a failing/no-pass run, or no
|
||||
authenticated result at all — rather than the plan-review escalation text
|
||||
:meth:`_summarize_blocker` produces (which is wrong for a built diff).
|
||||
"""
|
||||
ci = values.get("ci_results")
|
||||
if isinstance(ci, dict):
|
||||
conclusion = str(ci.get("conclusion") or "").strip()
|
||||
if conclusion:
|
||||
run_id = str(ci.get("run_id") or values.get("run_id") or "").strip()
|
||||
where = f" (run {run_id})" if run_id else ""
|
||||
return (
|
||||
f"the CI verify run concluded '{conclusion}'{where}; the "
|
||||
"pure-code gate did not pass."
|
||||
)
|
||||
return (
|
||||
"the verification gate did not pass (no authenticated CI pass was "
|
||||
"available for the dispatched diff)."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _verify_pass_verdict(values: "dict[str, Any]") -> "dict[str, Any] | None":
|
||||
"""Return the verify-stage PASS verdict if the task reached the P3 PASS terminus.
|
||||
|
|
|
|||
|
|
@ -526,7 +526,8 @@ def route_after_plan_gate(state: PipelineState) -> str:
|
|||
Reads the routing state :func:`plan_gate_node` wrote on resume (or on the
|
||||
ceiling-reached terminal park) and maps it to a route id:
|
||||
|
||||
* status ACTIVE + phase BUILD -> :data:`GATE_APPROVE_ROUTE` (END/approved);
|
||||
* status ACTIVE + phase BUILD -> :data:`GATE_APPROVE_ROUTE` (BUILD_NODE when
|
||||
the P3 build subgraph is wired, else the approved-plan END terminus);
|
||||
* status ACTIVE + phase PLAN -> :data:`GATE_REVISE_ROUTE` (loop to planner);
|
||||
* anything else (FAILED, or PARKED ceiling) -> :data:`GATE_TERMINAL_ROUTE`.
|
||||
"""
|
||||
|
|
@ -770,9 +771,17 @@ def build_graph(
|
|||
# the plan gate is wired it is REPOINTED at the PLAN_GATE vertex instead:
|
||||
# the cap dead-end suspends on a resumable human decision rather than
|
||||
# terminally parking. The gate's own conditional edges then route
|
||||
# approve -> END, request_changes -> PLAN (loop back), terminal -> END.
|
||||
# request_changes -> PLAN (loop back), terminal -> END, and approve ->
|
||||
# the SAME target the reviewer's auto-approve "build" route reaches:
|
||||
# BUILD_NODE when the P3 build subgraph is wired, else END (the P2
|
||||
# approved-plan terminus). Pointing approve at END unconditionally was a
|
||||
# bug — a human-approved-at-gate plan settled at phase BUILD without ever
|
||||
# entering the builder, so it never built (issue #60 sibling). BUILD_NODE
|
||||
# is added below in the build_verify branch; LangGraph resolves the
|
||||
# forward reference at compile().
|
||||
if plan_gate:
|
||||
parked_target = PLAN_GATE
|
||||
gate_approve_target = BUILD_NODE if build_verify is not None else END
|
||||
builder.add_node(
|
||||
PLAN_GATE,
|
||||
_instrument(PLAN_GATE, plan_gate_node, transition_recorder),
|
||||
|
|
@ -781,7 +790,7 @@ def build_graph(
|
|||
PLAN_GATE,
|
||||
route_after_plan_gate,
|
||||
{
|
||||
GATE_APPROVE_ROUTE: END,
|
||||
GATE_APPROVE_ROUTE: gate_approve_target,
|
||||
GATE_REVISE_ROUTE: PLAN,
|
||||
GATE_TERMINAL_ROUTE: END,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ async def _collect_subscription_text(
|
|||
max_turns: int,
|
||||
budget_usd: float,
|
||||
model: str | None,
|
||||
allowed_tools: list[str] | None = None,
|
||||
_query: Callable[..., Any] | None = None,
|
||||
_options_cls: Callable[..., Any] | None = None,
|
||||
) -> tuple[str, dict[str, Any], list[Any]]:
|
||||
|
|
@ -113,10 +114,13 @@ async def _collect_subscription_text(
|
|||
model=model,
|
||||
max_turns=max_turns,
|
||||
max_budget_usd=budget_usd,
|
||||
# No tools: these are pure reasoning→JSON completions. Disallowing tools
|
||||
# keeps the call a single deterministic turn (no repo exploration / tool
|
||||
# loops that produce slow, unparseable output).
|
||||
allowed_tools=[],
|
||||
# Tools are OPT-IN and default to none. The single-shot reasoning→JSON
|
||||
# callers (clarifier/planner/fixer/verifier) leave this empty so the call
|
||||
# stays a fast deterministic turn (no repo exploration / tool loops that
|
||||
# produce slow, unparseable output). A genuinely agentic caller — e.g. the
|
||||
# builder synthesizing a unified diff — passes a read-only allowlist
|
||||
# (Read/Grep/Glob) so it can inspect the repo and finish its diff.
|
||||
allowed_tools=list(allowed_tools or []),
|
||||
)
|
||||
|
||||
texts: list[str] = []
|
||||
|
|
@ -175,6 +179,7 @@ def subscription_invoker(
|
|||
max_turns: int = _DEFAULT_MAX_TURNS,
|
||||
budget_usd: float = _DEFAULT_BUDGET_USD,
|
||||
model: str | None = None,
|
||||
allowed_tools: list[str] | None = None,
|
||||
_query: Callable[..., Any] | None = None,
|
||||
_options_cls: Callable[..., Any] | None = None,
|
||||
**kw: Any,
|
||||
|
|
@ -205,6 +210,7 @@ def subscription_invoker(
|
|||
max_turns=max_turns,
|
||||
budget_usd=budget_usd,
|
||||
model=model,
|
||||
allowed_tools=allowed_tools,
|
||||
_query=_query,
|
||||
_options_cls=_options_cls,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -172,21 +172,83 @@ class DiffBuilder(Protocol):
|
|||
...
|
||||
|
||||
|
||||
# default_diff_builder is the FALLBACK builder — used only when no real
|
||||
# diff_builder is bound (the live coordinator binds the DeepSeek mechanical-edit
|
||||
# builder, builders_llm.as_diff_builder, which is the intended P3 path). It is
|
||||
# deliberately SINGLE-SHOT and TOOL-LESS: enabling agentic tools makes every tool
|
||||
# call consume an agent turn, and the session exhausts the turn cap mid-
|
||||
# exploration before it ever emits a diff (issue #60 — observed live at both
|
||||
# max_turns=1 and =8). A few tool-less turns of headroom let the model FINISH the
|
||||
# diff text in one completion, the planner pattern. Tool-driven diff accuracy is
|
||||
# the DeepSeek builder's job, not this fallback's.
|
||||
_BUILDER_MAX_TURNS = 4
|
||||
|
||||
|
||||
def default_diff_builder(
|
||||
*, plan: Mapping[str, Any], config: Mapping[str, Any] | None
|
||||
) -> str:
|
||||
"""Default :class:`DiffBuilder`: author the diff via the Claude billing seam.
|
||||
"""Fallback :class:`DiffBuilder`: author the diff via the Claude billing seam.
|
||||
|
||||
Renders the approved plan into an instruction and calls
|
||||
:func:`agent_team.billing.claude_invoke` (the §3.1 seam) to produce the
|
||||
unified diff. Because the seam's default invoker raises until
|
||||
:func:`agent_team.billing.set_invoker` is called, an un-wired environment
|
||||
fails loudly here rather than emitting an empty diff. The coordinator binds
|
||||
the real Claude-spec + DeepSeek-edit path at startup.
|
||||
:func:`agent_team.billing.claude_invoke` (the §3.1 seam) as a single-shot,
|
||||
tool-less completion to produce the unified diff, then extracts the diff from
|
||||
the response (:func:`_extract_unified_diff`). Because the seam's default
|
||||
invoker raises until :func:`agent_team.billing.set_invoker` is called, an
|
||||
un-wired environment fails loudly here rather than emitting an empty diff.
|
||||
|
||||
This is the INERT fallback only: the live coordinator binds the DeepSeek
|
||||
mechanical-edit builder (:func:`agent_team.nodes.builders_llm.as_diff_builder`)
|
||||
as the real ``diff_builder``, so this Claude path is not the production
|
||||
builder. It stays tool-less on purpose — see ``_BUILDER_MAX_TURNS``.
|
||||
"""
|
||||
prompt = _render_build_prompt(plan)
|
||||
result: ClaudeResult = claude_invoke(prompt, config=config)
|
||||
return result.text
|
||||
result: ClaudeResult = claude_invoke(
|
||||
prompt,
|
||||
config=config,
|
||||
max_turns=_BUILDER_MAX_TURNS,
|
||||
)
|
||||
return _extract_unified_diff(result.text)
|
||||
|
||||
|
||||
# The agentic builder uses read-only tools, so its response can wrap the diff in
|
||||
# a fenced code block and/or surround it with narration ("Let me read the source
|
||||
# files first…"). Recover the unified diff from that response: a ```diff fence is
|
||||
# preferred, else the slice from the first ``diff --git`` header. A response with
|
||||
# NO diff header at all is a hard BuildError — narration must never dispatch as a
|
||||
# candidate diff (it would fail closed at CI in a confusing way).
|
||||
_DIFF_FENCE_RE = re.compile(r"```(?:diff|patch)?[ \t]*\n(.*?)```", re.DOTALL)
|
||||
_DIFF_GIT_MARKER = "diff --git "
|
||||
|
||||
|
||||
def _extract_unified_diff(text: str) -> str:
|
||||
"""Recover the unified diff from the (tool-using) builder response.
|
||||
|
||||
Prefers the last fenced ```diff block containing a ``diff --git`` header;
|
||||
otherwise slices from the first ``diff --git`` marker to the end, dropping any
|
||||
surrounding narration. Raises :class:`BuildError` when the response carries no
|
||||
diff header at all (e.g. tool narration with no patch) so a prose-only reply
|
||||
fails loudly here rather than dispatching as a bogus candidate diff.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
raise BuildError("builder returned a non-string response")
|
||||
|
||||
diff = ""
|
||||
for block in reversed(_DIFF_FENCE_RE.findall(text)):
|
||||
if _DIFF_GIT_MARKER in block:
|
||||
diff = block
|
||||
break
|
||||
if not diff:
|
||||
marker = text.find(_DIFF_GIT_MARKER)
|
||||
if marker != -1:
|
||||
diff = text[marker:]
|
||||
|
||||
diff = diff.strip()
|
||||
if _DIFF_GIT_MARKER not in diff:
|
||||
raise BuildError(
|
||||
"builder response contained no unified diff (no 'diff --git' header "
|
||||
"— the model returned narration / tool output instead of a patch)"
|
||||
)
|
||||
return diff + "\n"
|
||||
|
||||
|
||||
def _render_build_prompt(plan: Mapping[str, Any]) -> str:
|
||||
|
|
@ -210,7 +272,11 @@ def _render_build_prompt(plan: Mapping[str, Any]) -> str:
|
|||
"Dependabot config.\n\n"
|
||||
f"Title: {title}\n"
|
||||
f"Declared scope (paths you may edit):\n{scope_lines}\n"
|
||||
f"Phases:\n{phase_lines}\n"
|
||||
f"Phases:\n{phase_lines}\n\n"
|
||||
"Your response MUST be ONLY the unified diff in `git diff` format (each "
|
||||
"file section beginning with a `diff --git a/… b/…` header), wrapped in a "
|
||||
"single ```diff fenced code block. Do NOT include any narration, "
|
||||
"explanation, or text before or after the diff.\n"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,16 @@ __all__ = [
|
|||
# clarifier loop's injected callables, etc.).
|
||||
ClaudeInvoke = Callable[..., ClaudeResult]
|
||||
|
||||
# The clarifier is a single-shot reasoning→JSON completion (confidence +
|
||||
# question-set), but the invoker's single-shot default (max_turns=1) is flaky:
|
||||
# when the model's one turn does not terminate in a final result it raises
|
||||
# "Reached maximum number of turns (1)", and with no salvageable text the call
|
||||
# fails and crashes the clarify node (leaving the task wedged at clarify with no
|
||||
# question posted). The planner hit the same flake and was given headroom in PR
|
||||
# #58; the clarifier needs the same. A few turns let the model FINISH its JSON;
|
||||
# tools stay OFF so it remains a fast, deterministic completion.
|
||||
_CLARIFIER_MAX_TURNS = 4
|
||||
|
||||
# Used when the model is below the confidence bar but supplied no usable
|
||||
# question-set. The loop must always have something to ask rather than spin or
|
||||
# falsely advance, so we substitute a generic clarifier prompt.
|
||||
|
|
@ -202,7 +212,12 @@ class ClaudeClarifier:
|
|||
return self._cache
|
||||
|
||||
prompt = self._build_prompt(qa_history, state)
|
||||
result = self._invoke(prompt, model=self._model, config=self._config)
|
||||
result = self._invoke(
|
||||
prompt,
|
||||
model=self._model,
|
||||
config=self._config,
|
||||
max_turns=_CLARIFIER_MAX_TURNS,
|
||||
)
|
||||
parsed = self._parse(getattr(result, "text", ""))
|
||||
|
||||
self._cache_key = key
|
||||
|
|
|
|||
|
|
@ -105,6 +105,29 @@ def test_default_diff_builder_calls_claude_invoke() -> None:
|
|||
assert "src" in seen["prompt"]
|
||||
|
||||
|
||||
def test_default_diff_builder_is_single_shot_and_tool_less() -> None:
|
||||
# Issue #60: the FALLBACK Claude builder must NOT run agentic with tools.
|
||||
# Tools make each call consume a turn and the session exhausts the cap before
|
||||
# emitting a diff (observed live at max_turns=1 and =8). It runs tool-less
|
||||
# with a few turns of headroom (planner pattern); the live builder is the
|
||||
# DeepSeek single-completion path, not this one.
|
||||
seen: dict = {}
|
||||
|
||||
def fake(prompt: str, *, mode: BillingMode, **kw):
|
||||
seen["kw"] = kw
|
||||
return ClaudeResult(text=CLEAN_DIFF, mode=mode)
|
||||
|
||||
billing.set_invoker(fake)
|
||||
default_diff_builder(plan={"title": "x", "scope": ["src"]}, config=None)
|
||||
|
||||
kw = seen["kw"]
|
||||
assert kw.get("max_turns") == builders._BUILDER_MAX_TURNS
|
||||
assert kw["max_turns"] > 1 # headroom so the completion can finish the diff
|
||||
# Tool-less: no agentic tools are requested (that is what caused #60's
|
||||
# turn-exhaustion). allowed_tools is left unset → the invoker default [].
|
||||
assert "allowed_tools" not in kw or not kw["allowed_tools"]
|
||||
|
||||
|
||||
def test_default_diff_builder_fails_loud_when_unwired() -> None:
|
||||
# Foundation contract: the seam raises until an invoker is bound.
|
||||
billing._invoker = billing._unconfigured_invoker
|
||||
|
|
@ -112,6 +135,57 @@ def test_default_diff_builder_fails_loud_when_unwired() -> None:
|
|||
default_diff_builder(plan={"title": "x"}, config=None)
|
||||
|
||||
|
||||
def test_extract_unified_diff_from_fenced_block_with_narration() -> None:
|
||||
# The agentic builder may narrate around a fenced ```diff block; recover only
|
||||
# the diff, dropping the prose.
|
||||
text = (
|
||||
"Let me read the key source files to get exact signatures first.\n"
|
||||
"Here is the patch:\n\n"
|
||||
"```diff\n" + CLEAN_DIFF + "```\n"
|
||||
"That implements the plan."
|
||||
)
|
||||
assert builders._extract_unified_diff(text) == CLEAN_DIFF
|
||||
|
||||
|
||||
def test_extract_unified_diff_from_bare_diff_with_leading_narration() -> None:
|
||||
# No fence: slice from the first ``diff --git`` header, dropping the prose.
|
||||
text = "I inspected the repo. Applying this change:\n\n" + CLEAN_DIFF
|
||||
assert builders._extract_unified_diff(text) == CLEAN_DIFF
|
||||
|
||||
|
||||
def test_extract_unified_diff_rejects_narration_only() -> None:
|
||||
# The exact failure observed live: tool narration with no patch. It MUST fail
|
||||
# closed (BuildError), never pass narration through as a candidate diff.
|
||||
text = "Let me read the key source files to get exact signatures before writing the diff."
|
||||
with pytest.raises(BuildError, match="no unified diff"):
|
||||
builders._extract_unified_diff(text)
|
||||
|
||||
|
||||
def test_default_diff_builder_extracts_diff_from_narrated_response() -> None:
|
||||
# End to end: the invoker returns narration + a fenced diff; the builder
|
||||
# returns the clean unified diff, not the prose.
|
||||
def fake(prompt: str, *, mode: BillingMode, **kw):
|
||||
return ClaudeResult(
|
||||
text="Sure — let me inspect the files.\n```diff\n" + CLEAN_DIFF + "```",
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
billing.set_invoker(fake)
|
||||
out = default_diff_builder(plan={"title": "x", "scope": ["src"]}, config=None)
|
||||
assert out == CLEAN_DIFF
|
||||
|
||||
|
||||
def test_default_diff_builder_raises_on_prose_only_response() -> None:
|
||||
# A prose-only builder reply parks/fails the task rather than dispatching
|
||||
# garbage to CI (issue #60 output-contract hardening).
|
||||
def fake(prompt: str, *, mode: BillingMode, **kw):
|
||||
return ClaudeResult(text="Let me read the source files first.", mode=mode)
|
||||
|
||||
billing.set_invoker(fake)
|
||||
with pytest.raises(BuildError, match="no unified diff"):
|
||||
default_diff_builder(plan={"title": "x", "scope": ["src"]}, config=None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Diff parsing / canonicalization
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
|
|
@ -89,6 +89,18 @@ def test_high_confidence_parsed() -> None:
|
|||
assert clar.assess_confidence([], _state()) == 0.99
|
||||
|
||||
|
||||
def test_clarifier_passes_max_turns_headroom_to_invoke_seam() -> None:
|
||||
# The single-shot Claude default (1 turn) is flaky: it crashes the clarify
|
||||
# node with "Reached maximum number of turns (1)" and leaves the task wedged
|
||||
# with no question posted. The clarifier asks for headroom so the model can
|
||||
# FINISH its JSON (mirrors the planner fix, PR #58).
|
||||
fake = _FakeInvoke(_json(0.99, []))
|
||||
clar = ClaudeClarifier(invoke=fake)
|
||||
clar.assess_confidence([], _state())
|
||||
assert fake.calls[0]["kw"].get("max_turns") == 4
|
||||
assert fake.calls[0]["kw"]["max_turns"] > 1
|
||||
|
||||
|
||||
def test_single_call_per_turn_memoized() -> None:
|
||||
fake = _FakeInvoke(_json(0.99, []))
|
||||
clar = ClaudeClarifier(invoke=fake)
|
||||
|
|
|
|||
|
|
@ -953,6 +953,37 @@ def test_default_review_wiring_binds_and_returns_node_and_router() -> None:
|
|||
review_loop._review_invoker = saved
|
||||
|
||||
|
||||
def test_failsafe_p3_wiring_binds_the_deepseek_diff_builder(monkeypatch: Any) -> None:
|
||||
# Issue #60 root fix: when the P3 env is configured the live failsafe MUST
|
||||
# bind a real diff_builder (the DeepSeek mechanical-edit path) onto the build
|
||||
# node, not leave it None — None falls back to the Claude single-shot builder
|
||||
# that exhausts its turn cap and fails every build.
|
||||
from agent_team import coordinator as coord_mod
|
||||
|
||||
monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "Sea-Haven-Industries")
|
||||
monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "orchestrator")
|
||||
monkeypatch.setenv(
|
||||
"GITHUB_TOKEN", "ci-read-token"
|
||||
) # _p3_env_is_configured CI token
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def spy_gated(*, owner: str, repo: str, **kw: Any) -> tuple:
|
||||
captured["owner"] = owner
|
||||
captured["diff_builder"] = kw.get("diff_builder")
|
||||
return ("build", "verify", "route")
|
||||
|
||||
monkeypatch.setattr(coord_mod, "gated_build_verify_wiring", spy_gated)
|
||||
|
||||
build_verify_thunk, dispatch_factory = coord_mod.failsafe_production_p3_wiring()
|
||||
assert build_verify_thunk is not None and dispatch_factory is not None
|
||||
|
||||
# The diff_builder is bound when the thunk is invoked at graph-build.
|
||||
build_verify_thunk()
|
||||
assert captured["owner"] == "Sea-Haven-Industries"
|
||||
assert callable(captured["diff_builder"]) # a REAL builder, not None (the bug)
|
||||
|
||||
|
||||
def test_setup_with_p2_factories_builds_a_review_node(db_path: Path) -> None:
|
||||
"""Injecting the P2 factories compiles a graph that includes the review vertex."""
|
||||
from agent_team.nodes import review_loop
|
||||
|
|
@ -1499,6 +1530,88 @@ def test_parked_message_infers_phase_when_current_phase_is_parked(
|
|||
assert "Reached phase: parked" not in msgs[0]
|
||||
|
||||
|
||||
def test_followups_built_park_reports_verify_not_plan_escalation(
|
||||
db_path: Path, monkeypatch: Any
|
||||
) -> None:
|
||||
# A task that BUILT a diff and parked at the verify/CI gate must report the
|
||||
# build/verify stage + the CI conclusion — NOT the plan-review escalation
|
||||
# copy ("the plan could not be auto-approved"), which is wrong once a diff
|
||||
# exists. Regression for the issue-#60 sibling notification bug.
|
||||
from agent_team import coordinator as coord_mod
|
||||
from agent_team.task_model import TaskStatus
|
||||
|
||||
msgs: list[str] = []
|
||||
coord = _make_coordinator(db_path)
|
||||
coord._notify = msgs.append
|
||||
coord.setup()
|
||||
monkeypatch.setattr(
|
||||
coord_mod.graph_mod, "pending_question", lambda _g, *, thread_id: None
|
||||
)
|
||||
|
||||
class _Snap:
|
||||
values = {
|
||||
"status": TaskStatus.PARKED.value,
|
||||
"current_phase": "parked",
|
||||
"task": "add a smoke-test file in agent-team/tests",
|
||||
"candidate_diff": "diff --git a/x b/x\n",
|
||||
"diff_hash": "abc",
|
||||
"run_id": "r1",
|
||||
"ci_results": {"run_id": "r1", "conclusion": "failure"},
|
||||
# Stale review verdicts must NOT win the phase inference for a built task.
|
||||
"review_verdicts": [{"verdict": "request_changes", "findings": "old"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(coord._graph, "get_state", lambda _cfg: _Snap())
|
||||
coord._post_resume_followups([_resume_result("beef0003cafe")])
|
||||
assert len(msgs) == 1
|
||||
m = msgs[0]
|
||||
assert "Reached phase: verify" in m # build/verify, not "review"
|
||||
assert "could not be auto-approved" not in m # the misleading copy is gone
|
||||
assert "verification gate did not pass" in m
|
||||
assert "failure" in m # the CI conclusion is surfaced
|
||||
|
||||
|
||||
def test_followups_awaiting_ci_reports_in_progress_not_parked(
|
||||
db_path: Path, monkeypatch: Any
|
||||
) -> None:
|
||||
# The VERIFY node SUSPENDS (async CI-wait) with a non-question interrupt, so
|
||||
# pending_question is None even though the graph is still suspended. The
|
||||
# notifier must report in-progress ("awaiting verification"), NOT a settled
|
||||
# PARK — this is the exact mislabel that posted "could not be auto-approved"
|
||||
# to Slack for a healthy, building task.
|
||||
from agent_team import coordinator as coord_mod
|
||||
from agent_team.task_model import TaskStatus
|
||||
|
||||
msgs: list[str] = []
|
||||
coord = _make_coordinator(db_path)
|
||||
coord._notify = msgs.append
|
||||
coord.setup()
|
||||
monkeypatch.setattr(
|
||||
coord_mod.graph_mod, "pending_question", lambda _g, *, thread_id: None
|
||||
)
|
||||
|
||||
class _Snap:
|
||||
interrupts = ("await-ci",) # graph still suspended on the CI-wait interrupt
|
||||
next = ("verify_node",)
|
||||
values = {
|
||||
"status": TaskStatus.PARKED.value, # stale carried-over while suspended
|
||||
"current_phase": "parked",
|
||||
"task": "add a smoke-test file in agent-team/tests",
|
||||
"candidate_diff": "diff --git a/x b/x\n",
|
||||
"diff_hash": "abc",
|
||||
"run_id": "run-42",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(coord._graph, "get_state", lambda _cfg: _Snap())
|
||||
coord._post_resume_followups([_resume_result("d00d0004beef")])
|
||||
assert len(msgs) == 1
|
||||
m = msgs[0]
|
||||
assert "awaiting verification" in m.lower()
|
||||
assert "run-42" in m
|
||||
assert "could not be auto-approved" not in m
|
||||
assert "parked" not in m.lower()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CI-watcher sweep in tick (§3.3.2 Decision 2) — async resume-on-CI-complete
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
|
|
@ -587,6 +587,84 @@ def test_plan_gate_approve_settles_as_approved_plan(restore_review_invoker) -> N
|
|||
assert final["status"] == TaskStatus.ACTIVE.value
|
||||
|
||||
|
||||
def _p3_plan_gate_graph(review_text: str, *, ci_result_fetcher):
|
||||
"""Compile a graph with BOTH the plan gate AND the P3 build->verify subgraph.
|
||||
|
||||
The reviewer (``review_text``) never approves, so the plan<->review loop hits
|
||||
the cap and suspends on the human PLAN_GATE; a human ``approve`` must then
|
||||
route into the build subgraph (BUILD -> VERIFY), exactly as the reviewer's
|
||||
own auto-approve "build" route does.
|
||||
"""
|
||||
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,
|
||||
plan_gate=True,
|
||||
build_verify=(build_node, verify_node, route_after_verify),
|
||||
)
|
||||
|
||||
|
||||
def test_plan_gate_approve_enters_build_subgraph_when_p3_wired(
|
||||
restore_review_invoker,
|
||||
) -> None:
|
||||
# Regression for the gate sibling of issue #60: a human approve at the plan
|
||||
# gate MUST route into the P3 build subgraph (BUILD -> VERIFY), not dead-end
|
||||
# at END leaving the task stuck at phase=build. The reviewer never approves,
|
||||
# so the loop caps to the human gate; an authenticated CI pass then carries
|
||||
# build -> verify -> DONE — proving the approve edge reached BUILD_NODE.
|
||||
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_plan_gate_graph(
|
||||
"VERDICT: REQUEST CHANGES\nnot yet", ci_result_fetcher=pass_fetcher
|
||||
)
|
||||
thread_id, _ = _drive_to_plan_gate(graph)
|
||||
final = resume_task(graph, thread_id=thread_id, answer={"decision": "approve"})
|
||||
|
||||
# Reached the build->verify PASS terminus, NOT a phase=build dead-end.
|
||||
assert final["current_phase"] == Phase.DONE.value
|
||||
assert final["status"] == TaskStatus.DONE.value
|
||||
|
||||
|
||||
def test_plan_gate_approve_inert_p3_still_settles_at_approved_terminus(
|
||||
restore_review_invoker,
|
||||
) -> None:
|
||||
# With the P3 build subgraph wired but its CI fetcher INERT (no authenticated
|
||||
# result), a gate approve enters build->verify and parks at VERIFY (the
|
||||
# production-safe default) — it must NOT fabricate a pass. Confirms the
|
||||
# approve edge routes through the subgraph, not to END.
|
||||
graph = _p3_plan_gate_graph(
|
||||
"VERDICT: REQUEST CHANGES\nnot yet", ci_result_fetcher=lambda state: None
|
||||
)
|
||||
thread_id, _ = _drive_to_plan_gate(graph)
|
||||
final = resume_task(graph, thread_id=thread_id, answer={"decision": "approve"})
|
||||
|
||||
assert final["current_phase"] == Phase.PARKED.value
|
||||
assert final["status"] == TaskStatus.PARKED.value
|
||||
|
||||
|
||||
def test_plan_gate_request_changes_loops_back_with_notes(
|
||||
restore_review_invoker,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -151,6 +151,50 @@ def test_subscription_invoker_returns_result(
|
|||
assert result.raw == messages
|
||||
|
||||
|
||||
def _make_capturing_query(messages, captured: dict):
|
||||
"""Async ``query`` that records the ``options`` it was handed, then yields."""
|
||||
|
||||
async def _query(*, prompt, options):
|
||||
captured["options"] = options
|
||||
for msg in messages:
|
||||
yield msg
|
||||
|
||||
return _query
|
||||
|
||||
|
||||
def test_subscription_invoker_threads_allowed_tools(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Issue #60: an agentic caller (the builder) opts in to tools; the allowlist
|
||||
# must reach the Agent SDK options, not be swallowed by **kw.
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok")
|
||||
captured: dict = {}
|
||||
invoker.subscription_invoker(
|
||||
"build it",
|
||||
mode=BillingMode.SUBSCRIPTION,
|
||||
allowed_tools=["Read", "Grep"],
|
||||
_query=_make_capturing_query([ResultMessage("ok")], captured),
|
||||
_options_cls=_fake_options,
|
||||
)
|
||||
assert captured["options"]["allowed_tools"] == ["Read", "Grep"]
|
||||
|
||||
|
||||
def test_subscription_invoker_defaults_to_no_tools(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# The single-shot reasoning nodes (clarifier/planner/fixer/verifier) must
|
||||
# keep their tool-less default — this is the opt-in guard for future nodes.
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok")
|
||||
captured: dict = {}
|
||||
invoker.subscription_invoker(
|
||||
"reason",
|
||||
mode=BillingMode.SUBSCRIPTION,
|
||||
_query=_make_capturing_query([ResultMessage("ok")], captured),
|
||||
_options_cls=_fake_options,
|
||||
)
|
||||
assert captured["options"]["allowed_tools"] == []
|
||||
|
||||
|
||||
def test_subscription_invoker_falls_back_to_assistant_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
Reference in a new issue