Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,23 @@ board to a PR — or fork it as a starting point.
requires a spec, EARS acceptance criteria, and explicit `files_to_modify`.
- **Escalation (opt-in)** — with a `coders` map of >1 distinct delegate, a capability
failure climbs a model tier (`fast→smart→reasoning`) and blocks at the top.
- **coder.solve() board seam (ADR 0064 P2)** — on a fresh build, when the
- **coder.solve() board seam (ADR 0064 P2/P3)** — on a fresh build, when the
[`coder`](https://github.com/protoLabsAI/protoAgent/tree/main/plugins/coder) plugin
is enabled AND the feature has acceptance criteria AND `coder_solve_test_cmd` (or
`local_gate_cmd`) is set, the loop dispatches through `coder.solve()`'s
execution-grounded ladder (greedy → best-of-k → tree-search) instead of a single
`delegate_to(acp)` shot — gated on the feature's acceptance tests actually PASSING
in a real candidate worktree, never an LLM judge. Composes WITH the tier ladder
above (solve() searches *within* a tier; a search that never passes escalates a
tier, or blocks, exactly like a no-diff dispatch). Missing coder/acceptance/test
command ⇒ honest degrade to the single shot — see `coder_seam.py`.
execution-grounded ladder — greedy → best-of-k → tree-search → **fusion** — instead
of a single `delegate_to(acp)` shot, gated on the feature's acceptance tests
actually PASSING in a real candidate worktree, never an LLM judge. **Fusion** (rung
4, opt-in via `coder_solve_fusion_delegate`) is a richer *generator* for the
hardest features the cheaper rungs couldn't pass — it can't tool-call (a plain
completion, e.g. `protolabs/fusion`, not an ACP session), so `coder_seam.py` hands
it the current content of the feature's declared files and writes its reply's
files into a fresh worktree itself; the SAME `verify()` oracle judges it. Composes
WITH the tier ladder above (solve() searches *within* a tier; a search that never
passes escalates a tier, or blocks, exactly like a no-diff dispatch). Missing
coder/acceptance/test command ⇒ honest degrade to the single shot; missing
`coder_solve_fusion_delegate` ⇒ the ladder simply stops at tree-search — see
`coder_seam.py`.
- **Planning layer** — two reasoning subagents (`decompose` + `antagonist`) driven by
the `decompose-project` skill: idea → outline → MADR ADRs → epics › milestones ›
features, hardened by an adversary, with a per-epic human gate.
Expand Down Expand Up @@ -114,6 +121,10 @@ project_board:
# acceptance criteria + a test command — see "What it does").
coder_solve_test_cmd: "pytest tests/ -q" # solve()'s verify() oracle; falls back to
# local_gate_cmd if blank, else the seam honest-degrades.
coder_solve_fusion_delegate: "" # rung 4 (ADR 0064 P3), opt-in: an `openai`-type
# delegate name (e.g. protolabs/fusion) for the hardest
# features. Blank (default) = ladder stops at tree-search.
coder_solve_fusion_k: 2 # candidates fusion generates when reached
# webhook_secret: "..." # set before exposing /webhook/pr publicly
```

Expand Down
154 changes: 150 additions & 4 deletions coder_seam.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""The P2 board seam (ADR 0064): dispatch a feature's build through the `coder`
plugin's execution-grounded ``solve()`` ladder instead of a single
``delegate_to(acp)`` shot — greedy → best-of-k → tree-search, gated on the
feature's acceptance tests actually PASSING in a real worktree, never an LLM judge.
``delegate_to(acp)`` shot — greedy → best-of-k → tree-search → fusion, gated on
the feature's acceptance tests actually PASSING in a real worktree, never an LLM
judge.

**Composes** `plugins.coder.solve` (a separate, git-URL-installed plugin — imported
lazily/best-effort so this repo carries no hard dependency on it and no import-time
Expand All @@ -28,19 +29,96 @@
the acceptance criteria as part of its definition of done; this module's ``verify``
just RUNS whatever tests exist in a candidate's worktree via the configured command
and gates on its exit code — real execution, no fabricated grounding.
"""

**Rung 4 — fusion (ADR 0064 P3).** Fusion (e.g. ``protolabs/fusion``) is a strong
*generator* but, per the ADR, it **can't tool-call** — unlike the ``acp`` coder
(a real edit/verify session in the worktree), it can only return a plain chat
completion. So its candidate generation is a DIFFERENT shape from the ACP rungs:
``_fusion_prompt`` hands it the task + the CURRENT content of the feature's
declared ``files_to_modify`` (read from the base repo — fusion has no tool access
to look these up itself) and asks for the complete, final content of every file it
creates or changes; ``_parse_fusion_files`` extracts ``{path: content}`` from the
reply; ``_WorktreeSolveAdapter.generate_fusion`` writes those files into a fresh
worktree (the same throwaway-per-candidate discipline as the ACP rungs) and hands
the path to the SAME ``verify()`` — real acceptance tests, same oracle, no separate
judge. Wholesale file replacement (not a unified diff) is deliberate: an LLM
completion reliably reproduces a full file; a hand-rolled patch with drifted
context lines is a common failure mode `git apply` doesn't forgive. Only reached
when a ``fusion_delegate`` is configured (an ``openai``-type Delegate, already
resolved by the caller) — absent that, ``solve()`` gets ``fusion_generate=None``
and stops at tree-search exactly as before (honest degrade, unchanged)."""

from __future__ import annotations

import asyncio
import importlib
import logging
import re
from pathlib import Path
from typing import Callable

from . import worktree

log = logging.getLogger("protoagent.plugins.project_board")

# ``### path/to/file.py`` header, then a fenced block (any/no language hint) holding
# that file's COMPLETE new content. Deliberately simple/strict: a fusion completion
# that doesn't follow the format parses to no files, which just fails verify() like
# any other empty candidate — never a silent partial/mangled write.
_FUSION_FILE_RE = re.compile(r"^###\s+(\S.+?)\s*$\n```[^\n]*\n(.*?)```", re.MULTILINE | re.DOTALL)

_FUSION_READ_MAX_CHARS = 20_000 # per-file context cap — fusion sees enough to edit, not a dump


def _parse_fusion_files(reply: str) -> dict[str, str]:
"""Extract ``{relative path: full file content}`` from a fusion completion. No
match ⇒ empty dict — the caller writes nothing, and the untouched worktree just
fails ``verify()`` like any other candidate that didn't address the task."""
return {path.strip(): content for path, content in _FUSION_FILE_RE.findall(reply or "")}


def _fusion_prompt(task: str, *, feedback: str | None, repo: str, files_to_modify: list[str]) -> str:
"""Build fusion's prompt. Fusion can't read the repo itself (no tool-calling), so
this hands it the CURRENT content of every file the feature declares — read from
the base repo, best-effort (a listed-but-not-yet-created file is noted as new)."""
file_blocks = []
for rel in files_to_modify:
p = Path(repo) / rel
try:
text = p.read_text(errors="replace")[:_FUSION_READ_MAX_CHARS]
file_blocks.append(f"### {rel} (current content)\n```\n{text}\n```")
except OSError:
file_blocks.append(f"### {rel} (does not exist yet — you are creating it)")
files_section = (
"\n\n".join(file_blocks) if file_blocks else "(no existing files listed — create what the task needs)"
)
parts = [
"Implement the task below. You have NO tool access — you cannot read or run "
"anything else, so work only from what's given here.",
"",
"## Task",
task.strip(),
"",
"## Current file contents",
files_section,
"",
"## Your reply format — REQUIRED, exactly this shape per file",
"For every file you create or change, return its COMPLETE, FINAL content "
"(never a partial diff or `...` elisions) as:",
"### relative/path/to/file.py",
"```",
"<the file's entire new content>",
"```",
"Only include files you're actually creating or changing. No prose outside the file blocks.",
]
if feedback:
parts += [
"",
"## Your previous attempt FAILED the acceptance tests — fix exactly this",
feedback.strip(),
]
return "\n".join(parts)


class SolveExhausted(worktree.WorktreeError):
"""``coder.solve()`` spent its whole generation budget against this feature's
Expand Down Expand Up @@ -118,6 +196,9 @@ def __init__(
test_cmd: str,
test_timeout: float,
verdict_cls,
fusion_delegate=None,
files_to_modify: list[str] | None = None,
_fusion_dispatch=None,
):
self.repo = repo
self.base = base
Expand All @@ -128,22 +209,70 @@ def __init__(
self.test_cmd = test_cmd
self.test_timeout = test_timeout
self.verdict_cls = verdict_cls # `plugins.coder.solve.Verdict` — passed in, never imported here
self.fusion_delegate = fusion_delegate # a resolved `openai`-type Delegate, or None
self.files_to_modify = files_to_modify or []
# Test-injection seam (mirrors `_solve`/`_budget_cls`/`_verdict_cls` on
# `dispatch()`): production never passes this — the real lazy
# `ADAPTERS["openai"].dispatch` import happens in `generate_fusion` below.
self._fusion_dispatch = _fusion_dispatch
self.candidates: list[tuple[str, str]] = [] # (worktree_path, branch)
# `git worktree add` against the SAME repo must not run concurrently (best-
# of-k dispatches `generate()` via asyncio.gather) — serialize just that
# step; the slow part (the coder dispatch) still runs in parallel.
self._wt_lock = asyncio.Lock()
self._n = 0

async def generate(self, task: str, *, feedback: str | None = None) -> str:
async def _new_candidate_worktree(self) -> tuple[str, str]:
self._n += 1
cid = f"{self.fid}.g{self._n}"
async with self._wt_lock:
wt, branch = await worktree.create_worktree(self.repo, self.base, cid, self.root)
self.candidates.append((wt, branch))
return wt, branch

async def generate(self, task: str, *, feedback: str | None = None) -> str:
wt, _branch = await self._new_candidate_worktree()
await worktree.dispatch_coder(self.coder, wt, _augment_prompt(task, feedback), timeout=self.dispatch_timeout)
return wt

async def generate_fusion(self, task: str, *, feedback: str | None = None) -> str:
"""Rung 4 (ADR 0064 P3): fusion can't tool-call, so instead of dispatching an
ACP session into the worktree, get a plain completion and write its files into
one. Same candidate bookkeeping (``candidates``/``_wt_lock``) as ``generate``,
so promote/reap treats a fusion winner identically to an ACP one."""
if self._fusion_dispatch is not None:
openai_dispatch = self._fusion_dispatch
else:
from plugins.delegates.adapters import ADAPTERS

openai_dispatch = ADAPTERS["openai"].dispatch

prompt = _fusion_prompt(task, feedback=feedback, repo=self.repo, files_to_modify=self.files_to_modify)
reply = await openai_dispatch(self.fusion_delegate, prompt, timeout=self.dispatch_timeout)
files = _parse_fusion_files(reply)
wt, _branch = await self._new_candidate_worktree()
wt_root = Path(wt).resolve()
written = 0
for rel, content in files.items():
# `rel` comes from a model completion — an absolute path or a `../` climb
# would otherwise write outside the worktree (Path.__truediv__ with an
# absolute right-hand side even discards the left side entirely). Resolve
# and require containment; skip (don't crash the candidate) on a miss.
dest = (wt_root / rel).resolve()
if wt_root not in dest.parents and dest != wt_root:
log.warning(
"[project_board] %s fusion tried to write outside its worktree: %r — skipped", self.fid, rel
)
continue
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content)
written += 1
if not written:
log.warning(
"[project_board] %s fusion reply parsed to 0 writable files — candidate is unchanged base", self.fid
)
return wt

async def verify(self, candidate_wt: str):
Verdict = self.verdict_cls
try:
Expand Down Expand Up @@ -215,9 +344,13 @@ async def dispatch(
k: int,
tree_depth: int,
record_gens: RecordGens | None = None,
fusion_delegate=None,
fusion_k: int = 2,
files_to_modify: list[str] | None = None,
_solve=None,
_budget_cls=None,
_verdict_cls=None,
_fusion_dispatch=None,
) -> tuple[str, str, str]:
"""Run the execution-grounded ladder for one feature build.

Expand All @@ -234,6 +367,14 @@ async def dispatch(
import happens here, deferred so this module carries no hard dependency on the
`coder` plugin).

``fusion_delegate`` (a resolved ``openai``-type Delegate, or ``None``) gates rung
4 (ADR 0064 P3) — the caller resolves it (mirroring how ``coder`` itself is
resolved), so this module never does delegate lookup. ``None`` (unconfigured) ⇒
``solve()`` gets ``fusion_generate=None`` and stops at tree-search, unchanged from
before this rung existed. ``files_to_modify`` feeds fusion's prompt (it can't read
the repo itself, unlike the ACP rungs) — the same list the feature's Ready gate
already required.

**``solve()`` itself can raise.** The ladder (`coder`'s own ``solve.py``) has no
try/except around ``generate``/``verify`` — it assumes a candidate attempt never
errors, only that it might fail its tests. A REAL dispatch can still raise
Expand Down Expand Up @@ -263,6 +404,9 @@ async def dispatch(
test_cmd=test_cmd,
test_timeout=test_timeout,
verdict_cls=Verdict,
fusion_delegate=fusion_delegate,
files_to_modify=files_to_modify,
_fusion_dispatch=_fusion_dispatch,
)
try:
result = await solve(
Expand All @@ -272,6 +416,8 @@ async def dispatch(
budget=Budget(budget),
k=k,
tree_depth=tree_depth,
fusion_generate=adapter.generate_fusion if fusion_delegate is not None else None,
fusion_k=fusion_k,
)
except Exception as exc:
for wt, branch in adapter.candidates:
Expand Down
16 changes: 16 additions & 0 deletions loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,14 @@ def __init__(self, cfg: dict):
self.coder_solve_budget = max(1, int(self.cfg.get("coder_solve_budget", 6)))
self.coder_solve_k = max(1, int(self.cfg.get("coder_solve_k", 3)))
self.coder_solve_tree_depth = max(0, int(self.cfg.get("coder_solve_tree_depth", 2)))
# Rung 4 (ADR 0064 P3): a richer generator for the HARDEST features — reached
# only after greedy AND best-of-k AND tree-search all fail their tests. Fusion
# can't tool-call (it's a plain completion, not an ACP session), so it's an
# `openai`-type delegate name, resolved per-dispatch in `_drive` (mirroring how
# `coder`/`reviewer` are resolved) — never here, this is just config plumbing.
# Blank ⇒ no fusion rung; the ladder stops at tree-search exactly as before.
self.coder_solve_fusion_delegate = str(self.cfg.get("coder_solve_fusion_delegate", "")).strip()
self.coder_solve_fusion_k = max(1, int(self.cfg.get("coder_solve_fusion_k", 2)))
# KG lessons (the flywheel READ half): before dispatching a coder, query the
# knowledge graph (via graph.sdk) for distilled lessons relevant to THIS feature
# and inject them into the prompt — so the coder heeds this area's known failure
Expand Down Expand Up @@ -673,6 +681,11 @@ async def _drive(self, feature: dict):
self._inflight[fid] = (repo, wt, branch)
result = await worktree.dispatch_coder(coder, wt, prompt, timeout=self.coder_timeout or None)
elif self._use_coder_solve(feature) and not self._ci_feedback.get(fid):
fusion = (
self._resolve_delegate(self.coder_solve_fusion_delegate, "openai")
if self.coder_solve_fusion_delegate
else None
)
wt, branch, result = await coder_seam.dispatch(
task=prompt,
coder=coder,
Expand All @@ -687,6 +700,9 @@ async def _drive(self, feature: dict):
k=self.coder_solve_k,
tree_depth=self.coder_solve_tree_depth,
record_gens=lambda n: store.record_gens_spent(fid, n),
fusion_delegate=fusion,
fusion_k=self.coder_solve_fusion_k,
files_to_modify=feature.get("files_to_modify") or [],
)
self._inflight[fid] = (repo, wt, branch)
elif self.max_mode_n > 1 and not self._ci_feedback.get(fid):
Expand Down
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id: project_board
name: Project Board (coding orchestration)
version: 0.27.0
version: 0.28.0
description: >-
A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready
→ in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "project-board"
version = "0.27.0"
version = "0.28.0"
description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)."
requires-python = ">=3.11"

Expand Down
Loading
Loading