Skip to content

Commit e215b83

Browse files
solomonneascodex
andauthored
feat(roster): add capability suggestions and receipt stats (#463)
* feat(roster): add capability suggestions and receipt stats Co-Authored-By: Codex <codex@openai.com> * fix(ci): stabilize Ruff and command inventory checks Co-Authored-By: Codex <codex@openai.com> * fix(ci): support Python 3.10 in Ruff config test Co-Authored-By: Codex <codex@openai.com> --------- Co-authored-by: Codex <codex@openai.com>
1 parent 22a8e01 commit e215b83

8 files changed

Lines changed: 721 additions & 5 deletions

File tree

docs/command-inventory.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ enabled: run `brigade extras on` once, or set `BRIGADE_EXTRAS=1`.
5252
- `brigade repos` (extras): 74 command path(s)
5353
- `brigade research` (extras): 11 command path(s)
5454
- `brigade roadmap` (extras): 4 command path(s)
55-
- `brigade roster`: 2 command path(s)
55+
- `brigade roster`: 4 command path(s)
5656
- `brigade route`: 1 command path(s)
5757
- `brigade run`: 1 command path(s)
5858
- `brigade runbook` (extras): 5 command path(s)
@@ -428,6 +428,8 @@ enabled: run `brigade extras on` once, or set `BRIGADE_EXTRAS=1`.
428428
- `brigade roadmap patterns` (extras)
429429
- `brigade roster doctor`
430430
- `brigade roster init`
431+
- `brigade roster stats`
432+
- `brigade roster suggest`
431433
- `brigade route`
432434
- `brigade run`
433435
- `brigade runbook closeout` (extras)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ addopts = "-ra"
6363
[tool.ruff]
6464
line-length = 120
6565
src = ["src", "tests"]
66-
extend-exclude = ["engines"]
66+
extend-exclude = ["engines", "*.md"]
6767
force-exclude = true
6868

6969
[tool.ruff.lint]

src/brigade/cli/roster.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ def register(sub: argparse._SubParsersAction) -> None:
3535
default=None,
3636
help="Path to roster.toml. Defaults to .brigade/roster.toml under --target.",
3737
)
38+
p_roster_suggest = roster_sub.add_parser(
39+
"suggest",
40+
help="Assemble a preset roster from installed host capabilities.",
41+
)
42+
p_roster_suggest.add_argument(
43+
"--preset",
44+
required=True,
45+
help="Packaged preset name or path (with or without .toml).",
46+
)
47+
p_roster_suggest.add_argument("--target", "-t", type=Path, default=Path("."))
48+
p_roster_stats = roster_sub.add_parser("stats", help="Summarize per-seat stats from local worker receipts.")
49+
p_roster_stats.add_argument("--target", "-t", type=Path, default=Path("."))
3850
p_roster.set_defaults(func=dispatch)
3951

4052

@@ -51,5 +63,9 @@ def dispatch(args) -> int:
5163
)
5264
if args.roster_command == "doctor":
5365
return roster_cmd.doctor(target=args.target, roster_path=args.roster)
66+
if args.roster_command == "suggest":
67+
return roster_cmd.suggest(target=args.target, preset=args.preset)
68+
if args.roster_command == "stats":
69+
return roster_cmd.stats(target=args.target)
5470
args._brigade_parser.error(f"unknown roster command: {args.roster_command}")
5571
return 2

src/brigade/roster.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from __future__ import annotations
44

55
import fnmatch
6+
import json
67
import re
8+
import statistics
79
from dataclasses import dataclass, replace
810
from pathlib import Path
911
from typing import Literal, Protocol
@@ -92,6 +94,13 @@ def usable(self) -> bool:
9294
return self.roster.orchestrator in self.roster.agents
9395

9496

97+
@dataclass(frozen=True)
98+
class SeatReceiptStats:
99+
sample_count: int
100+
median_duration_seconds: float
101+
failure_rate: float
102+
103+
95104
@dataclass(frozen=True)
96105
class HostCapabilityProbe:
97106
def lookup(self, cli_ref: str) -> Capability:
@@ -695,3 +704,76 @@ def resolve_capabilities(
695704
roster=replace(roster, agents=resolved_agents),
696705
report=tuple(report),
697706
)
707+
708+
709+
def _worker_result_failed(row: dict[str, object]) -> bool:
710+
ok = row.get("ok")
711+
if ok is False:
712+
return True
713+
if ok is True:
714+
return False
715+
status = row.get("status")
716+
if isinstance(status, str):
717+
normalized = status.strip().lower()
718+
if normalized in {"failed", "error", "fail", "failure"}:
719+
return True
720+
if normalized in {"ok", "success", "passed", "complete", "completed"}:
721+
return False
722+
exit_code = row.get("exit_code")
723+
if isinstance(exit_code, int) and not isinstance(exit_code, bool) and exit_code != 0:
724+
return True
725+
return False
726+
727+
728+
def collect_seat_receipt_stats(runs_root: Path) -> dict[str, SeatReceiptStats]:
729+
"""Aggregate per-seat worker receipt durations and failure rates from local runs."""
730+
731+
runs_root = runs_root.expanduser()
732+
if not runs_root.is_dir():
733+
return {}
734+
735+
durations: dict[str, list[float]] = {}
736+
samples: dict[str, int] = {}
737+
failures: dict[str, int] = {}
738+
739+
for run_dir in sorted(runs_root.iterdir()):
740+
if not run_dir.is_dir():
741+
continue
742+
path = run_dir / "worker-results.json"
743+
if not path.is_file():
744+
continue
745+
try:
746+
payload = json.loads(path.read_text())
747+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
748+
continue
749+
if not isinstance(payload, dict):
750+
continue
751+
results = payload.get("results")
752+
if not isinstance(results, list):
753+
continue
754+
for row in results:
755+
if not isinstance(row, dict):
756+
continue
757+
seat = row.get("worker")
758+
if not isinstance(seat, str) or not seat.strip():
759+
continue
760+
seat_name = seat.strip()
761+
samples[seat_name] = samples.get(seat_name, 0) + 1
762+
if _worker_result_failed(row):
763+
failures[seat_name] = failures.get(seat_name, 0) + 1
764+
duration = row.get("duration_seconds")
765+
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
766+
continue
767+
durations.setdefault(seat_name, []).append(float(duration))
768+
769+
stats: dict[str, SeatReceiptStats] = {}
770+
for seat_name, seat_durations in durations.items():
771+
sample_count = samples[seat_name]
772+
if sample_count <= 0:
773+
continue
774+
stats[seat_name] = SeatReceiptStats(
775+
sample_count=sample_count,
776+
median_duration_seconds=float(statistics.median(seat_durations)),
777+
failure_rate=failures.get(seat_name, 0) / sample_count,
778+
)
779+
return stats

src/brigade/roster_cmd.py

Lines changed: 206 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from . import model_inventory
1111
from . import roster as roster_mod
1212
from . import templates
13+
from . import toml_compat
1314

1415
DEFAULT_ROSTER_REL = ".brigade/roster.toml"
1516

@@ -94,6 +95,193 @@ def preset_roster_paths() -> tuple[Path, ...]:
9495
return tuple(sorted(rosters_dir.glob("*.toml")))
9596

9697

98+
def _resolve_preset_path(preset: Path | str) -> Path:
99+
if isinstance(preset, Path):
100+
path = preset.expanduser().resolve()
101+
else:
102+
name = str(preset).strip()
103+
if not name:
104+
raise ValueError("preset name must be non-empty")
105+
if not name.endswith(".toml"):
106+
name = f"{name}.toml"
107+
path = (templates.template_root() / "rosters" / name).resolve()
108+
if not path.is_file():
109+
raise FileNotFoundError(f"preset not found: {path}")
110+
return path
111+
112+
113+
def _local_receipt_stats(target: Path) -> dict[str, roster_mod.SeatReceiptStats]:
114+
return roster_mod.collect_seat_receipt_stats(target / ".brigade" / "runs")
115+
116+
117+
def _format_resolved_seat(resolved: str | None) -> str:
118+
return "-" if resolved is None else resolved
119+
120+
121+
def _print_seat_resolutions(report: tuple[roster_mod.SeatResolution, ...]) -> None:
122+
for entry in report:
123+
print(
124+
f"requested={entry.requested} outcome={entry.outcome} "
125+
f"resolved={_format_resolved_seat(entry.resolved)} reason={entry.reason}"
126+
)
127+
128+
129+
def _stats_detail(
130+
agent_name: str,
131+
agent: roster_mod.Agent,
132+
local_stats: dict[str, roster_mod.SeatReceiptStats],
133+
) -> str:
134+
receipt = local_stats.get(agent_name)
135+
if receipt is not None:
136+
return (
137+
f"source=local-receipts sample_count={receipt.sample_count} "
138+
f"median_duration={receipt.median_duration_seconds:g} "
139+
f"failure_rate={receipt.failure_rate:.3f}"
140+
)
141+
parts = ["source=author-default"]
142+
if agent.stats:
143+
for key, value in sorted(agent.stats.items()):
144+
if key == "source":
145+
continue
146+
parts.append(f"{key}={value}")
147+
return " ".join(parts)
148+
149+
150+
def _format_inline_table(values: dict[str, str]) -> str:
151+
inner = ", ".join(f"{key} = {toml_compat.format_toml_value(value)}" for key, value in values.items())
152+
return "{" + inner + "}"
153+
154+
155+
def _format_string_list(values: tuple[str, ...]) -> str:
156+
return "[" + ", ".join(toml_compat.format_toml_value(item) for item in values) + "]"
157+
158+
159+
def _render_agent_stats(
160+
agent: roster_mod.Agent,
161+
agent_name: str,
162+
local_stats: dict[str, roster_mod.SeatReceiptStats],
163+
) -> dict[str, str]:
164+
receipt = local_stats.get(agent_name)
165+
if receipt is not None:
166+
rendered: dict[str, str] = {}
167+
rendered["source"] = "local-receipts"
168+
rendered["median_duration_seconds"] = f"{receipt.median_duration_seconds:g}"
169+
rendered["failure_rate"] = f"{receipt.failure_rate:.3f}"
170+
rendered["sample_count"] = str(receipt.sample_count)
171+
return rendered
172+
rendered = dict(agent.stats or {})
173+
rendered["source"] = "author-default"
174+
return rendered
175+
176+
177+
def _render_roster_toml(
178+
roster: roster_mod.Roster,
179+
local_stats: dict[str, roster_mod.SeatReceiptStats],
180+
) -> str:
181+
lines: list[str] = [f"orchestrator = {toml_compat.format_toml_value(roster.orchestrator)}"]
182+
if roster.codex_transport != "exec":
183+
lines.append(f"codex_transport = {toml_compat.format_toml_value(roster.codex_transport)}")
184+
lines.append("")
185+
186+
agent_names = [roster.orchestrator] + sorted(name for name in roster.agents if name != roster.orchestrator)
187+
for name in agent_names:
188+
agent = roster.agents[name]
189+
lines.append(f"[agents.{name}]")
190+
if agent.cli is not None:
191+
lines.append(f"cli = {toml_compat.format_toml_value(agent.cli)}")
192+
if agent.endpoint is not None:
193+
lines.append(f"endpoint = {toml_compat.format_toml_value(agent.endpoint)}")
194+
if agent.model is not None:
195+
lines.append(f"model = {toml_compat.format_toml_value(agent.model)}")
196+
if agent.reasoning is not None:
197+
lines.append(f"reasoning = {toml_compat.format_toml_value(agent.reasoning)}")
198+
lines.append(f"role = {toml_compat.format_toml_value(agent.role)}")
199+
if agent.purpose is not None:
200+
lines.append(f"purpose = {toml_compat.format_toml_value(agent.purpose)}")
201+
if agent.requires is not None:
202+
lines.append(f"requires = {_format_inline_table(agent.requires)}")
203+
if agent.fallback:
204+
lines.append(f"fallback = {_format_string_list(agent.fallback)}")
205+
stats = _render_agent_stats(agent, name, local_stats)
206+
if stats:
207+
lines.append(f"stats = {_format_inline_table(stats)}")
208+
if agent.caveats:
209+
lines.append(f"caveats = {_format_string_list(agent.caveats)}")
210+
if agent.transport != "direct":
211+
lines.append(f"transport = {toml_compat.format_toml_value(agent.transport)}")
212+
if agent.transport_version is not None:
213+
lines.append(f"transport_version = {toml_compat.format_toml_value(agent.transport_version)}")
214+
if agent.timeout_seconds is not None:
215+
lines.append(f"timeout_seconds = {toml_compat.format_toml_value(agent.timeout_seconds)}")
216+
if not agent.read_only_capable:
217+
lines.append("read_only_capable = false")
218+
if agent.invalid_final_fallback is not None:
219+
lines.append(f"invalid_final_fallback = {toml_compat.format_toml_value(agent.invalid_final_fallback)}")
220+
if agent.env is not None:
221+
lines.append(f"env = {_format_inline_table(agent.env)}")
222+
lines.append("")
223+
224+
lines.append("[limits]")
225+
lines.append(f"max_workers = {toml_compat.format_toml_value(roster.max_workers)}")
226+
lines.append(f"timeout_seconds = {toml_compat.format_toml_value(roster.timeout_seconds)}")
227+
if roster.allow_models:
228+
lines.append(f"allow_models = {_format_string_list(roster.allow_models)}")
229+
if roster.sandbox is not None:
230+
lines.append(f"sandbox = {toml_compat.format_toml_value(roster.sandbox)}")
231+
return "\n".join(lines) + "\n"
232+
233+
234+
def suggest(
235+
target: Path,
236+
*,
237+
preset: Path | str,
238+
probe: roster_mod.CapabilityProbe | None = None,
239+
) -> int:
240+
target = target.expanduser()
241+
try:
242+
preset_path = _resolve_preset_path(preset)
243+
except (FileNotFoundError, ValueError) as exc:
244+
print(f"error: {exc}", file=sys.stderr)
245+
return 2
246+
try:
247+
loaded = roster_mod.load_roster(preset_path)
248+
except ValueError as exc:
249+
print(f"error: invalid preset {preset_path}: {exc}", file=sys.stderr)
250+
return 2
251+
252+
active_probe = probe if probe is not None else roster_mod.HostCapabilityProbe()
253+
result = roster_mod.resolve_capabilities(loaded, active_probe)
254+
local_stats = _local_receipt_stats(target)
255+
256+
_print_seat_resolutions(result.report)
257+
for name, agent in result.roster.agents.items():
258+
print(f"stats seat={name} {_stats_detail(name, agent, local_stats)}")
259+
260+
if not result.usable:
261+
print("roster is not adoptable: orchestrator seat is unavailable")
262+
return 1
263+
264+
print("\n# Adoptable roster")
265+
print(_render_roster_toml(result.roster, local_stats), end="")
266+
return 0
267+
268+
269+
def stats(target: Path) -> int:
270+
target = target.expanduser()
271+
local_stats = _local_receipt_stats(target)
272+
if not local_stats:
273+
print("no local worker receipt stats found")
274+
return 0
275+
for seat_name in sorted(local_stats):
276+
receipt = local_stats[seat_name]
277+
print(
278+
f"seat={seat_name} source=local-receipts sample_count={receipt.sample_count} "
279+
f"median_duration={receipt.median_duration_seconds:g} "
280+
f"failure_rate={receipt.failure_rate:.3f}"
281+
)
282+
return 0
283+
284+
97285
def init(
98286
target: Path,
99287
*,
@@ -130,7 +318,12 @@ def init(
130318
return 0
131319

132320

133-
def doctor(target: Path, *, roster_path: Path | None = None) -> int:
321+
def doctor(
322+
target: Path,
323+
*,
324+
roster_path: Path | None = None,
325+
probe: roster_mod.CapabilityProbe | None = None,
326+
) -> int:
134327
target = target.expanduser()
135328

136329
checks: list[doctor_mod.CheckResult] = []
@@ -144,6 +337,14 @@ def doctor(target: Path, *, roster_path: Path | None = None) -> int:
144337
checks.append((doctor_mod.FAIL, "roster: file", f"invalid {path}: {exc}"))
145338
return doctor_mod._report(checks)
146339

340+
local_stats = _local_receipt_stats(target)
341+
active_probe = probe if probe is not None else roster_mod.HostCapabilityProbe()
342+
capability = roster_mod.resolve_capabilities(loaded, active_probe)
343+
for entry in capability.report:
344+
if entry.outcome == "self":
345+
continue
346+
checks.append((doctor_mod.WARN, f"roster: capability {entry.requested}", entry.reason))
347+
147348
checks.append((doctor_mod.OK, "roster: file", str(path)))
148349
checks.append((doctor_mod.OK, "roster: orchestrator", loaded.orchestrator))
149350
checks.append((doctor_mod.OK, "roster: max_workers", str(loaded.max_workers)))
@@ -155,6 +356,10 @@ def doctor(target: Path, *, roster_path: Path | None = None) -> int:
155356
else:
156357
checks.append((doctor_mod.WARN, "roster: allow_models", "not set; explicit model allow-list recommended"))
157358

359+
for name, agent in loaded.agents.items():
360+
if agent.stats is not None or name in local_stats:
361+
checks.append((doctor_mod.INFO, f"roster: stats {name}", _stats_detail(name, agent, local_stats)))
362+
158363
inventory_inspector = model_inventory.ModelInventoryInspector()
159364
for name, agent in loaded.agents.items():
160365
timeout = roster_mod.timeout_for(agent, loaded)

0 commit comments

Comments
 (0)