fix(agent-team): align Confluence node with merged #61 lessons + post-review hardening

Apply review findings from mining PR #61/#63/#64 and the recent merged PRs:

- conf_draft_node: drop the agentic invoker config (max_turns=8 + Read/Grep/Glob
  + budget) that merged #61 reverted live (#60); use max_turns=4 tools-off like
  the planner. The draft is a reasoning->JSON node — repo context is folded into
  the prompt, never fetched via Claude tools (feedback_claude_sdk_single_shot).
  Invert the test that pinned the old tools-on contract.
- confluence_writer: record the dedicated Phase.CONF_DRAFT/CONF_GATE/CONF_WRITE
  values instead of borrowing VERIFY/REVIEW/BUILD, so the ledger/transitions/
  dashboard show the real lane.
- invoker: note the allowed_tools seam is replicated from merged #61 (drop on a
  future rebase; byte-equivalent default None->[]).
- dashboard: document that conf_* vertices need no _NODE_TO_STAGE remap (node id
  == stage key, identity fallback resolves them).

Verified (no change needed): coordinator confluence gate shares the plan gate's
crash isolation (_emit never raises, ledger write fail-soft); confluence_* state
channels persist via declared PipelineState channels + checkpointer; Flow B edge
hooks VERIFY's approved terminus, orthogonal to #61's plan-gate-approve routing.

Full suite 1564 passed; ruff + format clean.
This commit is contained in:
Adam Moussa 2026-06-24 19:14:31 -04:00
parent c7f9c1bac2
commit e9bf016035
3 changed files with 52 additions and 46 deletions

View file

@ -55,7 +55,11 @@ _WAITING_STATUSES = frozenset({"waiting_human"})
_PARKED_STATUSES = frozenset({"parked", "failed"}) _PARKED_STATUSES = frozenset({"parked", "failed"})
# Map a topology node id back to the budget_ledger ``stage`` key for cost join. # Map a topology node id back to the budget_ledger ``stage`` key for cost join.
# Most node ids equal their stage; the P3 build/verify vertices differ. # Most node ids equal their stage; the P3 build/verify vertices differ. The
# Confluence vertices (conf_draft / conf_gate / conf_write) need NO entry — their
# node id already equals both their Phase value and their ledger stage key, so the
# identity fallback (``_NODE_TO_STAGE.get(node_id, node_id)``) resolves them and
# they render on the timeline like any other stage.
_NODE_TO_STAGE = {"build_node": "build", "verify_node": "verify"} _NODE_TO_STAGE = {"build_node": "build", "verify_node": "verify"}

View file

@ -94,24 +94,30 @@ except Exception: # noqa: BLE001 - graph may not define it yet on this branch
# always terminates. # always terminates.
MAX_CONFLUENCE_GATE_VISITS = 3 MAX_CONFLUENCE_GATE_VISITS = 3
# Per-call turn headroom + budget for the AGENTIC draft call. The subscription # Per-call turn headroom for the draft call. This is a reasoning->JSON node (it
# invoker defaults to single-shot (max_turns=1), which an agentic repo-inspecting # turns the task/plan/diff already in state into a documentation draft), so it
# call exhausts before it can finish ("Reached maximum number of turns (1)"; PR # follows the SAME shape as the planner/clarifier: a few turns, NO tools. The
# #61 / issue #60). This call READS the repo to write accurate docs, so it needs # subscription invoker's single-shot default (max_turns=1) crashes the reasoning
# real turns and a READ-ONLY toolset — it emits the draft as DATA and writes # nodes ("Reached maximum number of turns (1)"), so the planner uses max_turns=4;
# nothing, so no Edit/Write/Bash. # we match it.
_DRAFT_MAX_TURNS = 8 #
_DRAFT_ALLOWED_TOOLS: tuple[str, ...] = ("Read", "Grep", "Glob") # Deliberately NOT agentic + NOT tool-using: the merged #60 fix (PR #61) proved
_DRAFT_BUDGET_USD = 4.0 # live that giving a generative node read-only tools + a higher turn cap just
# moves the failure — the tool session exhausts the cap reading files before it
# emits output, or returns narration instead of the artifact (observed at
# max_turns=8). The lesson (memory feedback_claude_sdk_single_shot): reasoning
# nodes run tools-off; any repo context the draft needs is gathered
# deterministically INTO the prompt (build_confluence_prompt folds in plan +
# candidate_diff), never via Claude tool calls.
_DRAFT_MAX_TURNS = 4
# Phase .value strings this stage routes on. The graph wires vertices that read # Phase .value strings this stage routes on. Now that task_model defines dedicated
# these; kept as Phase members so a future dedicated Phase enum value is a # CONF_* phases, the stage records them directly (durable current_phase reflects
# one-line swap. Until the foundation adds CONF_* members the stage reuses the # the real lane in the ledger / transitions / dashboard instead of borrowing
# closest existing phases (VERIFY/BUILD/DONE/PARKED) for routing — the graph-agent # VERIFY/REVIEW/BUILD).
# repoints these if dedicated phases are introduced. CONF_DRAFT_PHASE = Phase.CONF_DRAFT.value
CONF_DRAFT_PHASE = Phase.VERIFY.value CONF_GATE_PHASE = Phase.CONF_GATE.value
CONF_GATE_PHASE = Phase.REVIEW.value CONF_WRITE_PHASE = Phase.CONF_WRITE.value
CONF_WRITE_PHASE = Phase.BUILD.value
CONF_DONE_PHASE = Phase.DONE.value CONF_DONE_PHASE = Phase.DONE.value
# Disjoint namespace base for the Confluence gate's stable ``turn`` derivation. # Disjoint namespace base for the Confluence gate's stable ``turn`` derivation.
@ -235,17 +241,17 @@ def _conf_approval_question_set(
def conf_draft_node( def conf_draft_node(
state: PipelineState, config: dict[str, Any] | None = None state: PipelineState, config: dict[str, Any] | None = None
) -> PipelineState: ) -> PipelineState:
"""CONF_DRAFT stage: agentic repo inspection -> Confluence draft (as DATA). """CONF_DRAFT stage: reasoning over in-state context -> Confluence draft (as DATA).
Builds the draft prompt (Flow A from ``state['task']``; Flow B folding in Builds the draft prompt (Flow A from ``state['task']``; Flow B folding in
``plan`` + ``candidate_diff`` + repo name; ``confluence_feedback`` folded in ``plan`` + ``candidate_diff`` + repo name; ``confluence_feedback`` folded in
on a ``request_changes`` loop-back), then makes ONE **agentic** Claude call on a ``request_changes`` loop-back), then makes ONE single-shot, **tool-less**
that may READ the repository to write accurate docs. The call is pinned to a Claude call (``max_turns=4``) that turns that context into a documentation
READ-ONLY toolset (``Read``/``Grep``/``Glob``), ``max_turns=8``, and a draft. This is a reasoning->JSON node like the planner: it does NOT read the
``budget_usd`` cap — the agentic-config rationale builders use (PR #61 / repository agentically — the merged #60 fix (PR #61) proved live that handing
issue #60: single-shot defaults die with "Reached maximum number of turns a generative node read-only tools just exhausts the turn cap or returns
(1)"). The node emits the draft as DATA and writes NOTHING to the repo, so no narration. Any repo context the draft needs is folded into the prompt by
Edit/Write/Bash tool is granted. :func:`build_confluence_prompt`, never fetched via tools.
Returns a **partial** :class:`PipelineState`: the parsed ``confluence_draft`` Returns a **partial** :class:`PipelineState`: the parsed ``confluence_draft``
plus the phase advanced to the Confluence gate and ``status`` ACTIVE. plus the phase advanced to the Confluence gate and ``status`` ACTIVE.
@ -259,8 +265,6 @@ def conf_draft_node(
prompt, prompt,
config=config, config=config,
max_turns=_DRAFT_MAX_TURNS, max_turns=_DRAFT_MAX_TURNS,
allowed_tools=list(_DRAFT_ALLOWED_TOOLS),
budget_usd=_DRAFT_BUDGET_USD,
) )
draft = parse_confluence_reply(result.text) draft = parse_confluence_reply(result.text)

View file

@ -2,8 +2,10 @@
Covers the three LangGraph nodes plus the conditional-edge router: Covers the three LangGraph nodes plus the conditional-edge router:
* :func:`conf_draft_node` — the AGENTIC draft call. Pins the PR #61 contract: * :func:`conf_draft_node` — the reasoning->JSON draft call. Pins the post-#60
``max_turns > 1`` and a READ-ONLY toolset (Read/Grep/Glob, NO Edit/Write/Bash). contract: a small turn headroom (``max_turns == 4``, like the planner) and
TOOLS-OFF (no allowed_tools / budget) — the high-cap + read-only-tools approach
was reverted live in PR #61 (feedback_claude_sdk_single_shot).
* :func:`conf_gate_node` — the resumable human gate. Driven through a real * :func:`conf_gate_node` — the resumable human gate. Driven through a real
in-memory LangGraph (interrupt + ``Command(resume=...)``) to assert the in-memory LangGraph (interrupt + ``Command(resume=...)``) to assert the
interrupt payload ``kind`` and the approve / request_changes / abandon / interrupt payload ``kind`` and the approve / request_changes / abandon /
@ -204,28 +206,24 @@ def test_conf_draft_node_builds_draft_and_advances_to_gate() -> None:
assert out["confluence_draft"]["page_id"] == "1540098" assert out["confluence_draft"]["page_id"] == "1540098"
def test_conf_draft_node_passes_agentic_max_turns() -> None: def test_conf_draft_node_passes_reasoning_turn_headroom() -> None:
# PR #61: single-shot defaults die with "Reached maximum number of turns (1)"; # The single-shot default (max_turns=1) crashes reasoning nodes; the draft
# the agentic draft call must request real turn headroom (> 1). # asks for the same small headroom the planner uses (4) — NOT a high agentic
# cap (the merged #60 fix / PR #61 reverted the high-cap + tools approach).
calls = _bind_invoker(json.dumps(_VALID_DRAFT)) calls = _bind_invoker(json.dumps(_VALID_DRAFT))
conf_draft_node(_state(task="x")) conf_draft_node(_state(task="x"))
assert calls[0]["kw"].get("max_turns", 1) > 1 assert calls[0]["kw"].get("max_turns", 1) == 4
def test_conf_draft_node_grants_only_read_only_tools() -> None: def test_conf_draft_node_runs_tools_off() -> None:
# The draft emits DATA and writes nothing, so it gets a READ-ONLY toolset: # Load-bearing pin (memory feedback_claude_sdk_single_shot): the draft is a
# Read/Grep/Glob present, and NO Edit/Write/Bash (the load-bearing pin). # reasoning->JSON node and must run TOOLS-OFF. Giving it repo tools was proven
# live to exhaust the turn cap / return narration (#60). Any repo context goes
# INTO the prompt, never via Claude tools — so no allowed_tools, no budget.
calls = _bind_invoker(json.dumps(_VALID_DRAFT)) calls = _bind_invoker(json.dumps(_VALID_DRAFT))
conf_draft_node(_state(task="x")) conf_draft_node(_state(task="x"))
tools = set(calls[0]["kw"].get("allowed_tools") or []) assert not calls[0]["kw"].get("allowed_tools")
assert {"Read", "Grep", "Glob"} <= tools assert "budget_usd" not in calls[0]["kw"]
assert tools.isdisjoint({"Edit", "Write", "Bash", "MultiEdit", "NotebookEdit"})
def test_conf_draft_node_passes_budget_cap() -> None:
calls = _bind_invoker(json.dumps(_VALID_DRAFT))
conf_draft_node(_state(task="x"))
assert isinstance(calls[0]["kw"].get("budget_usd"), (int, float))
def test_conf_draft_node_forwards_config_to_billing_seam() -> None: def test_conf_draft_node_forwards_config_to_billing_seam() -> None: