From 21f2fe54c16f3a37d753cdbd2a25677331b6091a Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 13:13:28 -0400 Subject: [PATCH 1/6] fix(agent-team): builder uses agentic invoker config (read-only tools + turn headroom) The Plane-2 builder (default_diff_builder) called claude_invoke with no overrides, inheriting the subscription invoker's single-shot defaults (max_turns=1, allowed_tools=[]). Diff synthesis is agentic, so the call died with 'Reached maximum number of turns (1)' and every task failed at phase=build. - invoker.py: thread allowed_tools through subscription_invoker and _collect_subscription_text (default None -> []), so callers can opt in; single-shot reasoning nodes are unchanged. - builders.py: default_diff_builder now passes max_turns=8, a read-only tool allowlist (Read/Grep/Glob), and budget_usd=4.0. No write tools -- the builder returns the diff as data and performs no repo writes (D2/D11). - Tests: builder agentic-config passthrough; invoker allowed_tools thread + tool-less default guard (so future nodes must opt in explicitly). Closes #60 --- agent-team/agent_team/invoker.py | 14 +++++--- agent-team/agent_team/nodes/builders.py | 24 +++++++++++++- agent-team/tests/test_builders.py | 24 ++++++++++++++ agent-team/tests/test_invoker.py | 44 +++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/agent-team/agent_team/invoker.py b/agent-team/agent_team/invoker.py index f52dadc..18155b3 100644 --- a/agent-team/agent_team/invoker.py +++ b/agent-team/agent_team/invoker.py @@ -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, ) diff --git a/agent-team/agent_team/nodes/builders.py b/agent-team/agent_team/nodes/builders.py index 9920954..3daba97 100644 --- a/agent-team/agent_team/nodes/builders.py +++ b/agent-team/agent_team/nodes/builders.py @@ -172,6 +172,19 @@ 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 + + def default_diff_builder( *, plan: Mapping[str, Any], config: Mapping[str, Any] | None ) -> str: @@ -183,9 +196,18 @@ def default_diff_builder( :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. + + Diff synthesis is agentic, so this passes an explicit non-single-shot config + (turn/budget headroom + a read-only tool allowlist); see ``_BUILDER_*``. """ prompt = _render_build_prompt(plan) - result: ClaudeResult = claude_invoke(prompt, config=config) + result: ClaudeResult = claude_invoke( + prompt, + config=config, + max_turns=_BUILDER_MAX_TURNS, + allowed_tools=_BUILDER_ALLOWED_TOOLS, + budget_usd=_BUILDER_BUDGET_USD, + ) return result.text diff --git a/agent-team/tests/test_builders.py b/agent-team/tests/test_builders.py index 23a820e..c14f123 100644 --- a/agent-team/tests/test_builders.py +++ b/agent-team/tests/test_builders.py @@ -105,6 +105,30 @@ 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). + 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 + 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 + + 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 diff --git a/agent-team/tests/test_invoker.py b/agent-team/tests/test_invoker.py index 00bece5..382e5a5 100644 --- a/agent-team/tests/test_invoker.py +++ b/agent-team/tests/test_invoker.py @@ -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: -- 2.50.1 From e42b910a80f073c2839e3be9962eb5071bb401da Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 14:13:11 -0400 Subject: [PATCH 2/6] fix(agent-team): plan-gate approve routes into the build subgraph (not END) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered while smoke-testing the #60 builder fix on the R720: a task that reaches the human plan-gate and is APPROVED never built. The gate's GATE_APPROVE_ROUTE was hard-wired to END in build_graph, so plan_gate_node set phase=BUILD/status=ACTIVE and the graph terminated WITHOUT entering the build subgraph — the task wedged at phase=build with no build, no error. Only the reviewer's auto-approve path (review -> build_node) reached the builder; every human-gate-approved plan silently dead-ended. The P3 splice only repoints the REVIEW node's build route to BUILD_NODE; the plan_gate edges are independent and were never updated, so even with P3 fully wired the gate approve went to END. _apply_plan_decision's 'settle exactly as an auto-approved plan' intent was broken by the edge map. - graph.py: when build_verify (P3) is wired, point GATE_APPROVE_ROUTE at BUILD_NODE (mirroring REVIEW's BUILD_ROUTE: BUILD_NODE); keep END when P3 is inert (P2 approved-plan terminus). LangGraph resolves the forward reference to BUILD_NODE at compile(). - tests: gate-approve with P3 wired traverses BUILD -> VERIFY -> DONE on an authenticated CI pass, and parks at VERIFY (never fabricates a pass) when the CI fetcher is inert. Existing P2 gate-approve -> END behavior unchanged. --- agent-team/agent_team/graph.py | 15 +++++-- agent-team/tests/test_graph.py | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/agent-team/agent_team/graph.py b/agent-team/agent_team/graph.py index 3615030..f9affdc 100644 --- a/agent-team/agent_team/graph.py +++ b/agent-team/agent_team/graph.py @@ -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, }, diff --git a/agent-team/tests/test_graph.py b/agent-team/tests/test_graph.py index d7a13ae..7e86e0f 100644 --- a/agent-team/tests/test_graph.py +++ b/agent-team/tests/test_graph.py @@ -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: -- 2.50.1 From d12880ed09e2844c8c01c5e8b68471468989da99 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 14:36:00 -0400 Subject: [PATCH 3/6] fix(agent-team): accurate Slack status for build/verify states (no false 'plan could not be auto-approved') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resume-followup notifier mislabeled build/verify-stage tasks. Two cases, both surfaced live while smoke-testing the #60 builder fix: 1. AWAITING CI (in-progress): the VERIFY node suspends via interrupt() for the async CI-wait. Its interrupt payload is not question-shaped, so pending_question() returns None and the notifier treated the still-suspended task as a settled PARK -> posted '⚠️ PARKED ... the plan could not be auto-approved ... re-assign' for a task that had actually approved the plan, built a diff, and dispatched it to CI. Now detect the suspend (graph interrupted + dispatched run_id + a built diff) and post an honest 'diff built and dispatched to CI (run X); awaiting verification' notice. 2. SETTLED build/verify PARK: phase inference keyed off review_verdicts and reported 'review' for a task that reached verify; the copy hardcoded 'the plan could not be auto-approved'. Now a built diff (candidate_diff/diff_hash) makes the inferred phase 'verify' and the copy reflect a verification/CI-gate failure, surfacing the CI conclusion via _summarize_build_blocker. Plan/review escalation parks (no candidate_diff) are unchanged. - coordinator.py: _post_resume_followups awaiting-CI branch + build-aware phase inference + build-aware PARKED copy; add _summarize_build_blocker. - tests: built-park reports verify + CI conclusion (not auto-approve copy); awaiting-CI suspend reports in-progress, not parked. --- agent-team/agent_team/coordinator.py | 78 ++++++++++++++++++++++++-- agent-team/tests/test_coordinator.py | 82 ++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/agent-team/agent_team/coordinator.py b/agent-team/agent_team/coordinator.py index 48abc23..e4399df 100644 --- a/agent-team/agent_team/coordinator.py +++ b/agent-team/agent_team/coordinator.py @@ -1151,15 +1151,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 +1213,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 +1568,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. diff --git a/agent-team/tests/test_coordinator.py b/agent-team/tests/test_coordinator.py index 07b7d2a..341e1cd 100644 --- a/agent-team/tests/test_coordinator.py +++ b/agent-team/tests/test_coordinator.py @@ -1499,6 +1499,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 # --------------------------------------------------------------------------- # -- 2.50.1 From 694161a52c40bc8edbe4e81625206adf72fa68e3 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 14:39:50 -0400 Subject: [PATCH 4/6] fix(agent-team): builder returns a real unified diff, not tool narration (#60 output contract) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #60 max_turns/tools fix stopped the build-node crash but exposed the next gap: with tools enabled the agentic builder reads files and its final text is NARRATION (observed live: candidate_diff = 'Let me read the key source files to get exact signatures bef…'), not a unified diff. That non-diff dispatched to CI, could not be applied, and the task parked at verify. - builders.py: default_diff_builder now extracts the unified diff from the response via _extract_unified_diff — prefers a fenced ```diff block, else slices from the first 'diff --git' header, dropping surrounding prose. A reply with NO diff header raises BuildError so narration fails closed (the node fails the task with a clear reason) instead of dispatching a bogus diff. - builders.py: _render_build_prompt now instructs the model that its FINAL message must be ONLY the unified diff in a single ```diff fenced block, no narration before/after. - tests: extract from fenced/bare diff with narration; reject narration-only (BuildError); default_diff_builder returns the clean diff from a narrated response and raises on a prose-only reply. Full suite 1503 passed; ruff clean. --- agent-team/agent_team/nodes/builders.py | 51 ++++++++++++++++++++++++- agent-team/tests/test_builders.py | 51 +++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/agent-team/agent_team/nodes/builders.py b/agent-team/agent_team/nodes/builders.py index 3daba97..079f4dd 100644 --- a/agent-team/agent_team/nodes/builders.py +++ b/agent-team/agent_team/nodes/builders.py @@ -208,7 +208,48 @@ def default_diff_builder( allowed_tools=_BUILDER_ALLOWED_TOOLS, budget_usd=_BUILDER_BUDGET_USD, ) - return result.text + 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: @@ -232,7 +273,13 @@ 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" + "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" ) diff --git a/agent-team/tests/test_builders.py b/agent-team/tests/test_builders.py index c14f123..21a6cb6 100644 --- a/agent-team/tests/test_builders.py +++ b/agent-team/tests/test_builders.py @@ -136,6 +136,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 # --------------------------------------------------------------------------- # -- 2.50.1 From ad6f31c115bfbb89d519536115bef241c2de3610 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 14:58:48 -0400 Subject: [PATCH 5/6] fix(agent-team): wire the DeepSeek mechanical-edit builder as the live diff_builder (#60 root fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent-team/agent_team/coordinator.py | 16 +++++++- agent-team/agent_team/nodes/builders.py | 51 ++++++++++++------------- agent-team/tests/test_builders.py | 21 +++++----- agent-team/tests/test_coordinator.py | 31 +++++++++++++++ 4 files changed, 80 insertions(+), 39 deletions(-) diff --git a/agent-team/agent_team/coordinator.py b/agent-team/agent_team/coordinator.py index e4399df..12b7b94 100644 --- a/agent-team/agent_team/coordinator.py +++ b/agent-team/agent_team/coordinator.py @@ -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, ) diff --git a/agent-team/agent_team/nodes/builders.py b/agent-team/agent_team/nodes/builders.py index 079f4dd..d69cfdc 100644 --- a/agent-team/agent_team/nodes/builders.py +++ b/agent-team/agent_team/nodes/builders.py @@ -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" ) diff --git a/agent-team/tests/test_builders.py b/agent-team/tests/test_builders.py index 21a6cb6..072ce76 100644 --- a/agent-team/tests/test_builders.py +++ b/agent-team/tests/test_builders.py @@ -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: diff --git a/agent-team/tests/test_coordinator.py b/agent-team/tests/test_coordinator.py index 341e1cd..6b2c1d1 100644 --- a/agent-team/tests/test_coordinator.py +++ b/agent-team/tests/test_coordinator.py @@ -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 -- 2.50.1 From 3ff9a43ca3ff2ea90f9415647b5a8158cc8dbe7c Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 15:18:28 -0400 Subject: [PATCH 6/6] fix(agent-team): clarifier turn headroom (max_turns=4) so it can finish its JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clarifier (ClaudeClarifier._turn) called claude_invoke with no max_turns, inheriting the single-shot default (1). When the model's one turn did not terminate in a final result the SDK raised 'Reached maximum number of turns (1)' and, with no salvageable text, the call failed and crashed the clarify node — leaving the task wedged at clarify with NO question posted to Slack (the human never sees a clarifier prompt). Observed live on the R720. Same single-shot flake the planner hit and fixed in PR #58 (_PLANNER_MAX_TURNS=4); the clarifier never got the headroom. Give it the same: pass max_turns=4 (tools stay off — still a fast reasoning->JSON completion). - clarifier_llm.py: _turn passes max_turns=_CLARIFIER_MAX_TURNS (=4). - tests: clarifier passes max_turns headroom to the invoke seam. Full suite 1505 passed; ruff clean. --- agent-team/agent_team/nodes/clarifier_llm.py | 17 ++++++++++++++++- agent-team/tests/test_clarifier_llm.py | 12 ++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/agent-team/agent_team/nodes/clarifier_llm.py b/agent-team/agent_team/nodes/clarifier_llm.py index 68b5fd4..e5440ab 100644 --- a/agent-team/agent_team/nodes/clarifier_llm.py +++ b/agent-team/agent_team/nodes/clarifier_llm.py @@ -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 diff --git a/agent-team/tests/test_clarifier_llm.py b/agent-team/tests/test_clarifier_llm.py index 1448709..1379d77 100644 --- a/agent-team/tests/test_clarifier_llm.py +++ b/agent-team/tests/test_clarifier_llm.py @@ -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) -- 2.50.1