fix(agent-team): wire the DeepSeek mechanical-edit builder as the live diff_builder (#60 root fix)

The real #60 root cause: failsafe_production_p3_wiring never bound a diff_builder,
so the build node fell back to builders.default_diff_builder (Claude). The Claude
agentic builder (max_turns + read-only tools) then exhausted its turn cap reading
files before it could emit a diff — 'Reached maximum number of turns (8)' live.

The intended builder already exists: builders_llm.as_diff_builder() routes the
build through DeepSeek fast_coder as a SINGLE model completion (with the
orchestrator's retrieval context + defensive _extract_diff + fail-safe no-op).
A single completion has no agent turn loop, so it CANNOT exhaust turns. Confirmed
get_fast_coder() works in the daemon environment.

- coordinator.py: failsafe_production_p3_wiring (P3-configured branch) now binds
  builders_llm.as_diff_builder() as the diff_builder. Without it the build node
  silently used the turn-exhausting Claude fallback.
- builders.py: default_diff_builder reverted to a safe SINGLE-SHOT, TOOL-LESS
  fallback (max_turns=4, no allowed_tools/budget) — tools are what consumed the
  turns; this fallback is no longer the live builder. Keeps _extract_unified_diff.
- tests: failsafe binds a real (callable) diff_builder when P3 is configured;
  default_diff_builder is single-shot + tool-less.

Full suite 1504 passed; ruff clean.
This commit is contained in:
Adam Moussa 2026-06-24 14:58:48 -04:00
parent 694161a52c
commit ad6f31c115
4 changed files with 80 additions and 39 deletions

View file

@ -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,
)

View file

@ -172,41 +172,40 @@ class DiffBuilder(Protocol):
...
# Unlike the single-shot reasoning→JSON nodes (clarifier/planner/fixer/verifier),
# the builder is GENUINELY AGENTIC: synthesizing a unified diff needs to inspect
# the repo and iterate, so it must override the invoker's single-shot defaults
# (max_turns=1, allowed_tools=[]) — otherwise the call dies with "Reached maximum
# number of turns (1)" (issue #60). Tools are READ-ONLY: the builder returns the
# diff as DATA and performs no repo writes (D2/D11), so no Edit/Write/Bash.
_BUILDER_MAX_TURNS = 8
_BUILDER_ALLOWED_TOOLS = ["Read", "Grep", "Glob"]
# A tool-using diff session runs longer than a one-shot completion; give it more
# headroom than the $2 reasoning-node default.
_BUILDER_BUDGET_USD = 4.0
# 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.
Diff synthesis is agentic, so this passes an explicit non-single-shot config
(turn/budget headroom + a read-only tool allowlist); see ``_BUILDER_*``.
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,
max_turns=_BUILDER_MAX_TURNS,
allowed_tools=_BUILDER_ALLOWED_TOOLS,
budget_usd=_BUILDER_BUDGET_USD,
)
return _extract_unified_diff(result.text)
@ -274,12 +273,10 @@ def _render_build_prompt(plan: Mapping[str, Any]) -> str:
f"Title: {title}\n"
f"Declared scope (paths you may edit):\n{scope_lines}\n"
f"Phases:\n{phase_lines}\n\n"
"You may use the read-only tools (Read/Grep/Glob) to inspect the repo "
"before writing the patch. Your FINAL message 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"
"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"
)

View file

@ -105,11 +105,12 @@ def test_default_diff_builder_calls_claude_invoke() -> None:
assert "src" in seen["prompt"]
def test_default_diff_builder_passes_agentic_config_to_invoke_seam() -> None:
# Issue #60: the builder must NOT inherit the invoker's single-shot default
# (max_turns=1, allowed_tools=[]) — diff synthesis is agentic and dies with
# "Reached maximum number of turns (1)" otherwise. It opts in to turn/budget
# headroom plus a READ-ONLY tool allowlist (no repo writes, D2/D11).
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):
@ -121,12 +122,10 @@ def test_default_diff_builder_passes_agentic_config_to_invoke_seam() -> None:
kw = seen["kw"]
assert kw.get("max_turns") == builders._BUILDER_MAX_TURNS
assert kw["max_turns"] > 1
allowed = kw.get("allowed_tools")
assert allowed and {"Read", "Grep", "Glob"} <= set(allowed)
# Read-only only: never hand the diff-as-data builder write/exec tools.
assert not ({"Edit", "Write", "Bash"} & set(allowed))
assert kw.get("budget_usd") == builders._BUILDER_BUDGET_USD
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:

View file

@ -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