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
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 31 additions & 10 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 17 additions & 4 deletions examples/team-template/langgraph-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

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: 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
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 = "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"

Expand Down
58 changes: 57 additions & 1 deletion tests/test_team_spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"
Expand All @@ -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"]
Expand Down
Loading