From 214ba31bfe9e0d079db30103321dd04d2ab5b260 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Fri, 3 Jul 2026 13:50:40 -0700 Subject: [PATCH] feat: declare the fusion delegate + gap-fill its gateway (ADR 0064 P3, v0.16.0) Companion to projectBoard-plugin's fusion-rung wiring: the shipped team template now declares a real fusion delegate (openai-type, protolabs/fusion) and wires it into project_board.coder_solve_fusion_delegate, so a team spun up from this template can actually reach rung 4 on the hardest features -- not just have the board-seam machinery support it in principle. url/api_key ship blank on the fusion delegate, same as the team's own model block -- so _inherit_host_gateway needed extending: it now ALSO gap-fills any delegates: entry of type openai with a blank url from the same host gateway the model block inherits (a per-delegate url is left alone, same discipline). No per-team creds prep needed for fusion any more than for the team's brain. README's coder-ladder section gets a new "fusion" subsection documenting the config and the can't-tool-call design constraint. Shipped-template content contract extended (fusion delegate present + wired); new _inherit_host_gateway tests for the delegate gap-fill (fills blank, respects a per-delegate url, leaves non-openai delegates alone). 144 passed (was 127; +17 new/updated across the shipped-template contract and gateway-inherit coverage). Co-Authored-By: Claude Opus 4.8 --- README.md | 28 +++++++++- __init__.py | 41 ++++++++++---- examples/team-template/langgraph-config.yaml | 21 +++++-- protoagent.plugin.yaml | 2 +- pyproject.toml | 2 +- tests/test_team_spawn.py | 58 +++++++++++++++++++- 6 files changed, 132 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 15181d0..1ceb7c0 100644 --- a/README.md +++ b/README.md @@ -190,15 +190,37 @@ Where `project_board.coders` escalates by **model tier**, the `coder` plugin esc criteria compile to tests and the coders above still can't pass them, the board can reach for `coder`'s difficulty-gated ladder: **greedy** (one shot) → **best-of-k** (k candidates, execution-selected) → **tree-search** (refine on the *failing* tests, bounded depth) → -**fusion** (opt-in, richest generator). Every rung is gated on tests actually passing, never -an LLM judge — it's the missing execution-verification rung in the board loop, reserved for -genuinely **hard, verifiable** features the cheaper coders above failed. +**fusion** (opt-in, richest generator, wired via `project_board.coder_solve_fusion_delegate` +below). Every rung is gated on tests actually passing, never an LLM judge — it's the missing +execution-verification rung in the board loop, reserved for genuinely **hard, verifiable** +features the cheaper coders above failed. `coder` runs **model-authored code in a subprocess** — isolation, **not** a true sandbox (the same caveat as `execute_code`) — so only enable it for a trusted model/host. See [ADR 0064](https://github.com/protoLabsAI/protoAgent/blob/main/docs/adr/0064-coder-execution-grounded-code-solve.md) for the full ladder, the verifier contract, and the board-seam design. +#### `fusion` — rung 4, a richer generator that can't tool-call + +```yaml +delegates: + - { name: fusion, type: openai, model: protolabs/fusion, url: "", api_key: "" } +project_board: + coder_solve_fusion_delegate: fusion + coder_solve_fusion_k: 2 +``` + +Reached only after greedy **and** best-of-k **and** tree-search all fail a feature's +acceptance tests. Fusion is a plain chat completion, not an ACP coding session — it **can't +tool-call**, so it can't read the repo or edit files itself. The board seam works around this: +it hands fusion the CURRENT content of the feature's declared `files_to_modify`, asks for the +complete replacement content of every file it changes, and writes the reply's files into a +fresh worktree for the same acceptance-test oracle to judge — no separate LLM judge, ever. +`url`/`api_key` blank on the `fusion` delegate → gap-filled from the same host gateway the +team's own brain model inherits (no per-team creds prep). Blank +`coder_solve_fusion_delegate` (an empty string, not the shipped default) → the ladder simply +stops at tree-search. + ### `filesystem` — fenced to the repo ```yaml diff --git a/__init__.py b/__init__.py index 1e16f1d..24fd3e4 100644 --- a/__init__.py +++ b/__init__.py @@ -888,23 +888,44 @@ def _inherit_host_gateway(cfg_path: Path) -> None: and a template that already set its own ``api_base`` (a per-team gateway) is left alone. (The key ALSO rides ``os.environ`` into the child via supervisor.start, so an env-only key still reaches the team; this carries the ``api_base``, which has no env fallback in - graph.llm.) Best-effort + comment-preserving (ruamel).""" + graph.llm.) + + Also gap-fills any ``delegates:`` entry of ``type: openai`` with a blank ``url`` (e.g. the + shipped template's ``fusion`` delegate, ADR 0064 P3) — same discipline, same host gateway, + so an ``openai``-type delegate needs no per-team creds prep either. A delegate that already + sets its own ``url`` (a per-delegate gateway) is left alone, same as the model block. + + Best-effort + comment-preserving (ruamel).""" from graph.config_io import load_yaml_doc, save_yaml_doc, secrets_yaml_path doc = load_yaml_doc(cfg_path) if not isinstance(doc, dict): return - model = doc.setdefault("model", {}) - if not isinstance(model, dict) or str(model.get("api_base") or "").strip(): - return # the template carries its own gateway — respect it (per-team gateway) host_base, host_key = _host_gateway() - if not host_base: - return # host itself has no resolvable gateway — nothing to inherit (preflight will flag) - model["api_base"] = host_base - if host_key and not str(model.get("api_key") or "").strip(): - model["api_key"] = host_key - save_yaml_doc(doc, cfg_path) + changed = False + + model = doc.setdefault("model", {}) + if isinstance(model, dict) and not str(model.get("api_base") or "").strip() and host_base: + model["api_base"] = host_base + if host_key and not str(model.get("api_key") or "").strip(): + model["api_key"] = host_key + changed = True + + delegates = doc.get("delegates") + if isinstance(delegates, list) and host_base: + for d in delegates: + if not isinstance(d, dict) or str(d.get("type") or "") != "openai": + continue + if str(d.get("url") or "").strip(): + continue # a per-delegate gateway is already set — respect it + d["url"] = host_base + if host_key and not str(d.get("api_key") or "").strip(): + d["api_key"] = host_key + changed = True + + if changed: + save_yaml_doc(doc, cfg_path) # Carry the host's secrets overlay (the gateway key) onto the team when it has none of its # own — belt-and-suspenders with the env-inherited key. Never clobber a real team secrets. diff --git a/examples/team-template/langgraph-config.yaml b/examples/team-template/langgraph-config.yaml index e3feb22..d67ba37 100644 --- a/examples/team-template/langgraph-config.yaml +++ b/examples/team-template/langgraph-config.yaml @@ -44,6 +44,11 @@ delegates: # args, unlike proto) — the same binary a Claude-brained team already runs, so it's # on PATH for free. Needs `npm i -g @agentclientprotocol/claude-agent-acp` otherwise. - { name: claude, type: acp, command: claude-code, workdir: "{{REPO}}", permissions: auto } + # fusion (ADR 0064 P3) — the coder ladder's rung 4, an `openai`-type delegate (a plain + # completion, NOT an ACP session — fusion can't tool-call). `url`/`api_key` blank → + # INHERITS the same host gateway the team's own `model:` block does (portfolio_spinup_team + # gap-fills any blank-url openai delegate here, same discipline as the brain model above). + - { name: fusion, type: openai, model: protolabs/fusion, url: "", api_key: "" } project_board: repo: "{{REPO}}" # the repo the board manages; worktrees branch off it @@ -60,14 +65,22 @@ project_board: opus: claude loop_enabled: true # auto-dispatch ready features (an ephemeral team should just run) local_gate_cmd: "{{GATE}}" # pre-PR gate — the repo's real check command (empty = none) + # rung 4 (ADR 0064 P3): reached only after greedy AND best-of-k AND tree-search all + # fail a feature's acceptance tests — a richer generator for the hardest work. fusion + # can't tool-call (a plain completion), so the board seam hands it the current file + # content itself and writes its reply's files into a fresh worktree for the SAME + # verify() to judge — no separate LLM judge. Blank delegate = ladder stops at tree-search. + coder_solve_fusion_delegate: fusion + coder_solve_fusion_k: 2 # coder (ADR 0064) — the execution-grounded escalation: when a feature's EARS acceptance # compiles to tests and the coders above still can't pass them, the board can search # HARDER on a FIXED model — greedy → best-of-k → tree-search (refine on the failing -# tests) — gated on the tests actually passing, not an LLM judge. This is a different -# axis than the coders ladder above (which climbs model tier); the two compose. It runs -# MODEL-AUTHORED CODE in a subprocess (isolation, NOT a true sandbox, like execute_code) -# — enable only for a trusted model/host. +# tests) → fusion (a richer, oracle-selected generator for what even tree-search +# couldn't pass) — gated on the tests actually passing, not an LLM judge. This is a +# different axis than the coders ladder above (which climbs model tier); the two +# compose. It runs MODEL-AUTHORED CODE in a subprocess (isolation, NOT a true sandbox, +# like execute_code) — enable only for a trusted model/host. coder: delegate: proto # candidate generator for the search ladder (reuse a delegate declared above) diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 703efb8..4f70ede 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: portfolio name: Portfolio (multi-team orchestration) -version: 0.15.1 +version: 0.16.0 description: >- The PM / program orchestration layer (ADR 0055 P1). One agent orchestrates work across MANY team-agents, each running its own project board for its own repo diff --git a/pyproject.toml b/pyproject.toml index ad215ac..5ea92fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portfolio" -version = "0.15.1" +version = "0.16.0" description = "Multi-team orchestration plugin for protoAgent — one PM agent dispatches to, rolls up, watches, and sequences MANY team-agents' project boards over A2A (ADR 0055)." requires-python = ">=3.11" diff --git a/tests/test_team_spawn.py b/tests/test_team_spawn.py index 8eafda2..a8aacb4 100644 --- a/tests/test_team_spawn.py +++ b/tests/test_team_spawn.py @@ -826,6 +826,42 @@ def test_inherit_host_gateway_respects_template_gateway(tmp_path, monkeypatch): assert yaml.safe_load(cfg.read_text())["model"]["api_base"] == "https://team.gw/v1" # untouched +def test_inherit_host_gateway_fills_a_blank_openai_delegate_url(tmp_path, monkeypatch): + """The shipped template's `fusion` delegate (ADR 0064 P3) ships url/api_key blank — + same gap-fill discipline as the team's own model block, so it needs no per-team + creds prep either.""" + import yaml + + _host(tmp_path, monkeypatch, api_base="https://gw.host/v1") + cfg = _team_cfg( + tmp_path, + "model:\n api_base: https://team.gw/v1\n" # per-team model gateway — untouched below + "delegates:\n - {name: fusion, type: openai, model: protolabs/fusion, url: ''}\n" + " - {name: proto, type: acp, command: proto}\n", + ) + portfolio._inherit_host_gateway(cfg) + doc = yaml.safe_load(cfg.read_text()) + by_name = {d["name"]: d for d in doc["delegates"]} + assert by_name["fusion"]["url"] == "https://gw.host/v1" # gap-filled + assert by_name["proto"].get("url", "") == "" # a non-openai delegate is untouched + assert doc["model"]["api_base"] == "https://team.gw/v1" # the model's OWN gateway, untouched + + +def test_inherit_host_gateway_respects_a_delegates_own_url(tmp_path, monkeypatch): + import yaml + + _host(tmp_path, monkeypatch, api_base="https://gw.host/v1") + cfg = _team_cfg( + tmp_path, + "model:\n api_base: ''\n" + "delegates:\n - {name: fusion, type: openai, model: protolabs/fusion, url: https://fusion.gw/v1}\n", + ) + portfolio._inherit_host_gateway(cfg) + doc = yaml.safe_load(cfg.read_text()) + assert doc["delegates"][0]["url"] == "https://fusion.gw/v1" # per-delegate gateway respected + assert doc["model"]["api_base"] == "https://gw.host/v1" # the model block still gets filled + + def test_inherit_host_gateway_copies_host_secrets_when_team_has_none(tmp_path, monkeypatch): _host(tmp_path, monkeypatch, api_base="https://gw.host/v1", secrets="OPENAI_API_KEY: sk-host\n") cfg = _team_cfg(tmp_path, "model:\n api_base: ''\n") @@ -977,7 +1013,7 @@ def test_shipped_template_enables_delegates_project_board_and_coder(): def test_shipped_template_declares_both_acp_delegates(): doc = _shipped_template_doc() by_name = {d["name"]: d for d in doc["delegates"]} - assert set(by_name) == {"proto", "claude"} + assert {"proto", "claude"} <= set(by_name) # plus fusion (openai) — see the next test proto = by_name["proto"] assert proto["type"] == "acp" @@ -994,6 +1030,26 @@ def test_shipped_template_declares_both_acp_delegates(): assert claude["permissions"] == "auto" +def test_shipped_template_declares_the_fusion_delegate_gateway_blank(): + """fusion (ADR 0064 P3, rung 4) is an `openai`-type delegate — url/api_key blank + so portfolio_spinup_team's gateway-inherit fills them from the host, same as the + team's own model block (no per-team creds prep).""" + doc = _shipped_template_doc() + by_name = {d["name"]: d for d in doc["delegates"]} + fusion = by_name["fusion"] + assert fusion["type"] == "openai" + assert fusion["model"] == "protolabs/fusion" + assert fusion.get("url", "") == "" + assert fusion.get("api_key", "") == "" + + +def test_shipped_template_wires_fusion_into_the_coder_solve_ladder(): + doc = _shipped_template_doc() + pb = doc["project_board"] + assert pb["coder_solve_fusion_delegate"] == "fusion" + assert pb["coder_solve_fusion_k"] == 2 + + def test_shipped_template_coders_ladder_escalates_smart_to_reasoning_to_opus(): doc = _shipped_template_doc() pb = doc["project_board"]