1010from . import model_inventory
1111from . import roster as roster_mod
1212from . import templates
13+ from . import toml_compat
1314
1415DEFAULT_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+
97285def 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