resolve_party.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.11"
  4. # ///
  5. """Resolve the party-mode roster, lazily.
  6. Merges the installed BMAD agents with the user's custom `party_members`
  7. into one collective, then projects only what the moment needs:
  8. * default (no flag) — the active roster to load on entry: the
  9. `default_party` group if one is configured, else the whole collective.
  10. Other groups come back as names only, so nothing you aren't using is
  11. loaded into the party.
  12. * --list-groups — just id + name + size for every configured group. The
  13. cheap menu for "which room?", with no member detail.
  14. * --party <id> — full member detail for one chosen group, on demand
  15. (e.g. when the user switches rooms). Unknown id returns the available
  16. names instead of an error wall.
  17. The merge is deterministic (a keyed union; a custom member whose code
  18. matches an installed agent overrides it), so the orchestrator consumes a
  19. resolved roster instead of re-deriving it every session.
  20. Stdlib only (Python 3.11+ for tomllib). Shells out to the project's
  21. resolve_config.py and resolve_customization.py; falls back to reading
  22. customize.toml directly if the customization resolver is unavailable.
  23. resolve_party.py --project-root P --skill S
  24. resolve_party.py --project-root P --skill S --list-groups
  25. resolve_party.py --project-root P --skill S --party writers-room
  26. """
  27. import argparse
  28. import json
  29. import subprocess
  30. import sys
  31. from pathlib import Path
  32. try:
  33. import tomllib
  34. except ImportError: # pragma: no cover - guarded for <3.11
  35. sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib`).\n")
  36. sys.exit(3)
  37. def _run_json(cmd):
  38. """Run a resolver script and parse its JSON stdout. None on any failure."""
  39. try:
  40. out = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
  41. except (OSError, subprocess.SubprocessError):
  42. return None
  43. if out.returncode != 0 or not out.stdout.strip():
  44. return None
  45. try:
  46. return json.loads(out.stdout)
  47. except json.JSONDecodeError:
  48. return None
  49. def load_agents(project_root: Path):
  50. """Installed agents as {code: entry}. Empty dict (with a flag) on failure."""
  51. script = project_root / "_bmad" / "scripts" / "resolve_config.py"
  52. data = _run_json([sys.executable, str(script), "--project-root", str(project_root), "--key", "agents"])
  53. if data is None:
  54. return {}, False
  55. return data.get("agents", {}) or {}, True
  56. def load_workflow(project_root: Path, skill_root: Path):
  57. """Merged [workflow] table. Falls back to the skill's base customize.toml."""
  58. script = project_root / "_bmad" / "scripts" / "resolve_customization.py"
  59. data = _run_json([sys.executable, str(script), "--skill", str(skill_root), "--key", "workflow"])
  60. if data is not None and "workflow" in data:
  61. return data["workflow"]
  62. # Fallback: read the skill's base customize.toml directly (no override merge).
  63. toml_path = skill_root / "customize.toml"
  64. if toml_path.exists():
  65. try:
  66. with toml_path.open("rb") as f:
  67. return tomllib.load(f).get("workflow", {})
  68. except (OSError, tomllib.TOMLDecodeError):
  69. pass
  70. return {}
  71. def _alias(code: str) -> str:
  72. """Short alias for an installed agent code: bmad-agent-analyst -> analyst."""
  73. for prefix in ("bmad-agent-", "bmad-"):
  74. if code.startswith(prefix):
  75. return code[len(prefix):]
  76. return code
  77. def build_collective(agents: dict, party_members: list):
  78. """One pool keyed by code. Custom members override matching installed agents.
  79. Returns (collective, index, installed_codes):
  80. * collective — every member (installed + custom), the pool groups draw
  81. from and the orchestrator can summon by name.
  82. * index — maps every resolvable token (code, prefix-stripped alias,
  83. lower-cased name) to a canonical code.
  84. * installed_codes — the codes occupying an installed-agent slot, in
  85. order. This is the DEFAULT room: installed agents (with any custom
  86. override applied in place), and NOT the pure-custom additions. So
  87. shipping or defining custom members grows the pool without crowding
  88. the default party.
  89. """
  90. collective = {}
  91. index = {}
  92. installed_codes = []
  93. def register(code, entry):
  94. collective[code] = entry
  95. index[code] = code
  96. index[code.lower()] = code
  97. index[_alias(code).lower()] = code
  98. name = entry.get("name")
  99. if name:
  100. index[name.lower()] = code
  101. for code, info in agents.items():
  102. register(code, {
  103. "code": code,
  104. "name": info.get("name", code),
  105. "icon": info.get("icon", ""),
  106. "title": info.get("title", ""),
  107. "description": info.get("description", ""),
  108. "module": info.get("module", ""),
  109. "team": info.get("team", ""),
  110. "source": "installed",
  111. })
  112. installed_codes.append(code)
  113. for m in party_members or []:
  114. code = m.get("code")
  115. if not code:
  116. continue
  117. # A custom member overrides an installed agent it matches by code/alias/name.
  118. canonical = index.get(code) or index.get(code.lower()) or code
  119. entry = {"code": canonical, "source": "custom"}
  120. for field in ("name", "icon", "title", "persona", "capabilities", "model"):
  121. if m.get(field) is not None:
  122. entry[field] = m[field]
  123. entry.setdefault("name", canonical)
  124. register(canonical, entry)
  125. # An override keeps the installed slot; a brand-new custom does not join it.
  126. return collective, index, installed_codes
  127. def resolve_members(member_tokens, collective, index):
  128. """(resolved entries in listed order, unresolved tokens)."""
  129. resolved, unresolved = [], []
  130. for token in member_tokens or []:
  131. code = index.get(token) or index.get(str(token).lower())
  132. if code and code in collective:
  133. resolved.append(collective[code])
  134. else:
  135. unresolved.append(token)
  136. return resolved, unresolved
  137. def group_menu(groups):
  138. """Names only — the cheap menu. Open-cast groups (no roster) are flagged."""
  139. out = []
  140. for g in groups or []:
  141. if not isinstance(g, dict) or not g.get("id"):
  142. continue
  143. members = g.get("members", []) or []
  144. entry = {"id": g["id"], "name": g.get("name", g["id"]),
  145. "member_count": len(members)}
  146. if not members:
  147. entry["open_cast"] = True
  148. out.append(entry)
  149. return out
  150. def find_group(groups, group_id):
  151. for g in groups or []:
  152. if isinstance(g, dict) and g.get("id") == group_id:
  153. return g
  154. return None
  155. def group_detail(g, collective, index):
  156. """Full detail for one group: resolved members + the optional scene.
  157. `scene` is a freeform line the orchestrator plays — setting, what's
  158. happening, room dynamics, in-the-moment character notes. Surfaced only
  159. here (when a group is the active/chosen roster), never in the menu.
  160. `members` is optional. With none, the group is open-cast: `open_cast`
  161. is flagged and the scene describes the pool the orchestrator casts from
  162. on the fly (e.g. "figures from the Star Wars Rebels universe"). A few
  163. listed members anchor the room; the scene can still invite more.
  164. """
  165. raw_members = g.get("members", []) or []
  166. members, unresolved = resolve_members(raw_members, collective, index)
  167. detail = {"active": g["id"], "name": g.get("name", g["id"]),
  168. "members": members, "unresolved": unresolved,
  169. "memory_enabled": bool(g.get("memory", False))}
  170. if g.get("scene"):
  171. detail["scene"] = g["scene"]
  172. if not raw_members:
  173. detail["open_cast"] = True
  174. return detail
  175. def main():
  176. ap = argparse.ArgumentParser(description="Resolve the party-mode roster, lazily.")
  177. ap.add_argument("--project-root", required=True)
  178. ap.add_argument("--skill", required=True, help="Path to the bmad-party-mode skill dir")
  179. ap.add_argument("--party", help="Resolve full detail for this group id")
  180. ap.add_argument("--list-groups", action="store_true", help="Group names only")
  181. args = ap.parse_args()
  182. project_root = Path(args.project_root).resolve()
  183. skill_root = Path(args.skill).resolve()
  184. workflow = load_workflow(project_root, skill_root)
  185. groups = workflow.get("party_groups", []) or []
  186. default_party = workflow.get("default_party", "") or ""
  187. party_mode = workflow.get("party_mode", "session") or "session"
  188. # The global party_memory flag governs only the DEFAULT installed-agent room;
  189. # a named group carries its own `memory` flag (resolved in group_detail).
  190. party_memory = bool(workflow.get("party_memory", True))
  191. # Group menu never needs the (more expensive) installed-agent resolve.
  192. if args.list_groups:
  193. _emit({
  194. "party_mode": party_mode,
  195. "default_party": default_party,
  196. "groups": group_menu(groups),
  197. })
  198. return
  199. agents, agents_ok = load_agents(project_root)
  200. collective, index, installed_codes = build_collective(agents, workflow.get("party_members", []))
  201. if args.party:
  202. g = find_group(groups, args.party)
  203. if g is None:
  204. _emit({"error": "unknown_group", "requested": args.party,
  205. "available": group_menu(groups)})
  206. return
  207. _emit({**group_detail(g, collective, index), "party_mode": party_mode})
  208. return
  209. # Default: the active roster to load on entry.
  210. result = {"party_mode": party_mode, "groups": group_menu(groups),
  211. "installed_agents_resolved": agents_ok}
  212. g = find_group(groups, default_party) if default_party else None
  213. if g is not None:
  214. result.update(group_detail(g, collective, index))
  215. else:
  216. # No default group: the installed agents (custom additions stay in the
  217. # pool but don't crowd the default room), exactly like a plain install.
  218. result.update({"active": "installed",
  219. "members": [collective[c] for c in installed_codes],
  220. "memory_enabled": party_memory})
  221. _emit(result)
  222. def _emit(obj):
  223. reconfigure = getattr(sys.stdout, "reconfigure", None)
  224. if reconfigure is not None:
  225. reconfigure(encoding="utf-8")
  226. sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
  227. if __name__ == "__main__":
  228. main()