- config.py: extracted load_config(), find_workspace_config(), load_discussion_config() from cli.py; fixed Windows root detection - cli.py: run_discussion() now creates DiscussionEngine + EventEmitter, subscribes to events with ANSI-color output (identical to before), delegates to engine.run() - backends/custom/run.py: deprecation shim with DeprecationWarning, delegates to DiscussionEngine - 16 new config tests, all 184 tests passing Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
462 lines
14 KiB
Python
462 lines
14 KiB
Python
"""Meeting Room CLI — main entry point.
|
|
|
|
Refactored (v2): run_discussion() now creates a DiscussionEngine and
|
|
subscribes to events with ANSI-color output instead of calling the
|
|
legacy backend directly.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
from meeting_room.api_client import APIClient
|
|
from meeting_room.config import find_workspace_config, load_config, load_discussion_config
|
|
from meeting_room.engine import DiscussionEngine
|
|
from meeting_room.events import (
|
|
AGENT_ERROR,
|
|
AGENT_MESSAGE,
|
|
AGENT_TURN_START,
|
|
DISCUSSION_END,
|
|
DISCUSSION_START,
|
|
ROUND_END,
|
|
ROUND_START,
|
|
TOOL_CALL,
|
|
TOOL_RESULT,
|
|
EventEmitter,
|
|
)
|
|
from meeting_room.models import DiscussionConfig, ProviderConfig
|
|
from meeting_room.scenario_loader import load_scenario
|
|
from meeting_room.tools import ToolRegistry
|
|
|
|
# ── ANSI colors ──
|
|
|
|
COLORS = {
|
|
"moderator": "\033[96m",
|
|
"skeptic": "\033[91m",
|
|
"idea_generator": "\033[92m",
|
|
"analyst": "\033[93m",
|
|
}
|
|
TOOL_COLOR = "\033[90m"
|
|
RESET = "\033[0m"
|
|
BOLD = "\033[1m"
|
|
DIM = "\033[2m"
|
|
|
|
|
|
def list_scenarios(scenarios_dir: str | None = None):
|
|
"""Print available scenarios to stdout."""
|
|
from meeting_room.scenario_loader import list_scenarios as _list_scenarios
|
|
|
|
if scenarios_dir is None:
|
|
scenarios_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenarios")
|
|
items = _list_scenarios(scenarios_dir)
|
|
if not items:
|
|
print("No scenarios found. Create .md or .yaml in scenarios/")
|
|
return
|
|
print("\nAvailable scenarios:")
|
|
print("-" * 40)
|
|
for s in items:
|
|
participants = ", ".join(s["participants"]) if s["participants"] else "-"
|
|
print(f" {s['file']}")
|
|
print(f" {s['name']}")
|
|
print(f" Participants: {participants}")
|
|
print()
|
|
|
|
|
|
def list_roles(config: dict):
|
|
"""Print roles and model bindings to stdout."""
|
|
roles = config.get("roles", {})
|
|
providers = config.get("providers", {})
|
|
print("\nRoles and model bindings:")
|
|
print("-" * 60)
|
|
for role_id, role in roles.items():
|
|
prov = role.get("provider", "?")
|
|
model = role.get("model", "?")
|
|
temp = role.get("temperature", 0.7)
|
|
tools = role.get("tools", "none")
|
|
prov_url = providers.get(prov, {}).get("base_url", "?")
|
|
print(f" {role['name']} ({role_id})")
|
|
print(f" API: {prov} — {prov_url}")
|
|
print(f" Model: {model}")
|
|
print(f" Temperature: {temp}")
|
|
print(f" Tools: {tools}")
|
|
print()
|
|
|
|
|
|
def init_workspace(path: str = "."):
|
|
"""Initialize a .meeting-room workspace in given directory."""
|
|
import yaml # noqa: avoid top-level import for speed
|
|
|
|
target = os.path.abspath(path)
|
|
ws_dir = os.path.join(target, ".meeting-room")
|
|
|
|
if os.path.exists(ws_dir):
|
|
print(f"Workspace already exists: {ws_dir}")
|
|
return
|
|
|
|
os.makedirs(os.path.join(ws_dir, "scenarios"), exist_ok=True)
|
|
os.makedirs(os.path.join(ws_dir, "sessions"), exist_ok=True)
|
|
|
|
# Write default config (overrides package defaults)
|
|
default_config = {
|
|
"providers": {
|
|
"routerai": {
|
|
"base_url": "${MEETING_ROOM_ROUTERAI_BASE_URL}",
|
|
"api_key": "${MEETING_ROOM_ROUTERAI_API_KEY}",
|
|
}
|
|
},
|
|
"roles": {
|
|
"moderator": {
|
|
"name": "Moderator",
|
|
"provider": "routerai",
|
|
"model": "gpt-4o-mini",
|
|
"temperature": 0.7,
|
|
"tools": "none",
|
|
"system_prompt": "You are the moderator of a discussion.",
|
|
},
|
|
"skeptic": {
|
|
"name": "Skeptic",
|
|
"provider": "routerai",
|
|
"model": "gpt-4o-mini",
|
|
"temperature": 0.9,
|
|
"tools": "readonly",
|
|
"system_prompt": "You are a skeptic and critic.",
|
|
},
|
|
"idea_generator": {
|
|
"name": "Idea Generator",
|
|
"provider": "routerai",
|
|
"model": "gpt-4o-mini",
|
|
"temperature": 1.0,
|
|
"tools": "web",
|
|
"system_prompt": "You are a creative idea generator.",
|
|
},
|
|
"analyst": {
|
|
"name": "Analyst",
|
|
"provider": "routerai",
|
|
"model": "gpt-4o-mini",
|
|
"temperature": 0.5,
|
|
"tools": "all",
|
|
"system_prompt": "You are an analyst.",
|
|
},
|
|
},
|
|
"defaults": {
|
|
"max_rounds": 8,
|
|
"framework": "custom",
|
|
"workdir": "..", # parent of .meeting-room
|
|
},
|
|
}
|
|
|
|
config_path = os.path.join(ws_dir, "config.yaml")
|
|
with open(config_path, "w") as f:
|
|
yaml.dump(default_config, f, default_flow_style=False, allow_unicode=True)
|
|
|
|
# Write example scenario
|
|
example = """---
|
|
name: "Example Discussion"
|
|
participants:
|
|
- moderator
|
|
- skeptic
|
|
- idea_generator
|
|
- analyst
|
|
max_rounds: 6
|
|
---
|
|
|
|
# Problem Title
|
|
|
|
Describe the problem here in Markdown...
|
|
|
|
## Context
|
|
- Factor 1
|
|
- Factor 2
|
|
|
|
## Questions
|
|
1. Question 1?
|
|
2. Question 2?
|
|
"""
|
|
with open(os.path.join(ws_dir, "scenarios", "example.md"), "w") as f:
|
|
f.write(example)
|
|
|
|
# Write .gitignore
|
|
with open(os.path.join(ws_dir, ".gitignore"), "w") as f:
|
|
f.write("sessions/\n")
|
|
|
|
print(f"Workspace initialized: {ws_dir}")
|
|
print(f"""
|
|
Structure:
|
|
{ws_dir}/
|
|
├── config.yaml — override default config
|
|
├── scenarios/ — your .md scenario files
|
|
│ └── example.md
|
|
├── sessions/ — saved discussion results (gitignored)
|
|
└── .gitignore
|
|
|
|
Usage:
|
|
cd {ws_dir}
|
|
meeting-room -s scenarios/example.md
|
|
meeting-room -s scenarios/example.md --save
|
|
""")
|
|
|
|
|
|
def save_session(conversation: list[dict], scenario_name: str, sessions_dir: str = "sessions"):
|
|
"""Save discussion result to a JSON file."""
|
|
os.makedirs(sessions_dir, exist_ok=True)
|
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
safe_name = scenario_name.replace(" ", "-").lower()
|
|
filename = f"{timestamp}_{safe_name}.json"
|
|
filepath = os.path.join(sessions_dir, filename)
|
|
|
|
session_data = {
|
|
"timestamp": datetime.now().isoformat(),
|
|
"scenario": scenario_name,
|
|
"messages": conversation,
|
|
}
|
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
json.dump(session_data, f, ensure_ascii=False, indent=2)
|
|
|
|
print(f"Session saved: {filepath}")
|
|
return filepath
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event handlers — produce the same ANSI output as the legacy backend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _subscribe_cli_handlers(bus: EventEmitter, max_rounds: int) -> None:
|
|
"""Wire up event handlers that print ANSI-colored output to stdout.
|
|
|
|
The output format matches the original backends/custom/run.py exactly.
|
|
"""
|
|
|
|
def on_discussion_start(event) -> None:
|
|
data = event.data
|
|
print(f"\n{BOLD}{'=' * 60}")
|
|
print(f" MEETING ROOM — {data['name']}")
|
|
print(f"{'=' * 60}{RESET}\n")
|
|
|
|
# Print problem preview
|
|
problem = data.get("problem", "")
|
|
if problem:
|
|
print(f"{BOLD}Problem:{RESET}")
|
|
for line in problem.split("\n")[:5]:
|
|
print(f" {line}")
|
|
if problem.count("\n") > 5:
|
|
print(" ...")
|
|
print()
|
|
|
|
# Print participants
|
|
participants = data.get("participants", [])
|
|
if participants:
|
|
print(f"{BOLD}Participants:{RESET}")
|
|
for name in participants:
|
|
print(f" {name}")
|
|
print()
|
|
|
|
print(f"{BOLD}Rounds:{RESET} {data['rounds']}")
|
|
print(f"{DIM}{'─' * 60}{RESET}\n")
|
|
|
|
def on_round_start(event) -> None:
|
|
data = event.data
|
|
print(f"{BOLD}[Round {data['round_num']}/{data['total_rounds']}]{RESET}\n")
|
|
|
|
def on_agent_turn_start(event) -> None:
|
|
data = event.data
|
|
color = COLORS.get(data["role_id"], "")
|
|
print(f"{color}{BOLD} {data['name']}:{RESET}", end=" ")
|
|
|
|
def on_agent_message(event) -> None:
|
|
# Content is printed inline after the name; just end the line.
|
|
print()
|
|
|
|
def on_tool_call(event) -> None:
|
|
data = event.data
|
|
print(f"\n{TOOL_COLOR} -> {data['tool_name']}({data['arguments']}){RESET}")
|
|
|
|
def on_tool_result(event) -> None:
|
|
data = event.data
|
|
preview = data["result_preview"]
|
|
suffix = "..." if len(data.get("full_result", "")) > 200 else ""
|
|
print(f"{TOOL_COLOR} <- {preview}{suffix}{RESET}")
|
|
|
|
def on_agent_error(event) -> None:
|
|
data = event.data
|
|
# Error content is already printed inline by the name header;
|
|
# just note the error type.
|
|
print(f" [Error: {data['error']}]")
|
|
|
|
def on_round_end(event) -> None:
|
|
print(f"{DIM}{'─' * 60}{RESET}\n")
|
|
|
|
def on_discussion_end(event) -> None:
|
|
data = event.data
|
|
print(f"\n{BOLD}{'=' * 60}")
|
|
print(f" DISCUSSION COMPLETE — {data['total_messages']} messages")
|
|
print(f"{'=' * 60}{RESET}\n")
|
|
|
|
bus.on(DISCUSSION_START, on_discussion_start)
|
|
bus.on(ROUND_START, on_round_start)
|
|
bus.on(AGENT_TURN_START, on_agent_turn_start)
|
|
bus.on(AGENT_MESSAGE, on_agent_message)
|
|
bus.on(TOOL_CALL, on_tool_call)
|
|
bus.on(TOOL_RESULT, on_tool_result)
|
|
bus.on(AGENT_ERROR, on_agent_error)
|
|
bus.on(ROUND_END, on_round_end)
|
|
bus.on(DISCUSSION_END, on_discussion_end)
|
|
|
|
|
|
def run_discussion(scenario: str, config: dict, save: bool = False):
|
|
"""Run a discussion through DiscussionEngine with ANSI event output.
|
|
|
|
Parameters
|
|
----------
|
|
scenario : str
|
|
Path or name of the scenario file.
|
|
config : dict
|
|
Raw config dict (as returned by :func:`load_config`).
|
|
save : bool
|
|
Whether to save the session to a JSON file.
|
|
|
|
Returns
|
|
-------
|
|
list[dict]
|
|
Conversation in the legacy dict format ``[{role_id, name, content}, ...]``.
|
|
"""
|
|
scenario_path = scenario if os.path.isabs(scenario) else os.path.join(os.getcwd(), scenario)
|
|
if not os.path.exists(scenario_path):
|
|
# Try bundled scenarios
|
|
scenario_path = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "scenarios", scenario
|
|
)
|
|
if not os.path.exists(scenario_path):
|
|
print(f"Scenario not found: {scenario}")
|
|
sys.exit(1)
|
|
|
|
# Convert raw config to typed DiscussionConfig
|
|
discussion_config = load_discussion_config()
|
|
# Apply any overrides from the raw config (e.g. workdir from CLI)
|
|
if "defaults" in config and "workdir" in config["defaults"]:
|
|
discussion_config.defaults.workdir = config["defaults"]["workdir"]
|
|
|
|
# Build engine components
|
|
providers = {
|
|
name: ProviderConfig(**vals)
|
|
for name, vals in config.get("providers", {}).items()
|
|
}
|
|
api_client = APIClient(providers=providers)
|
|
tool_registry = ToolRegistry(workdir=discussion_config.defaults.workdir)
|
|
event_bus = EventEmitter()
|
|
|
|
# Subscribe CLI output handlers
|
|
_subscribe_cli_handlers(event_bus, max_rounds=discussion_config.defaults.max_rounds)
|
|
|
|
# Load scenario
|
|
scenario_data = load_scenario(scenario_path)
|
|
|
|
# Create and run engine
|
|
engine = DiscussionEngine(
|
|
config=discussion_config,
|
|
api_client=api_client,
|
|
tool_registry=tool_registry,
|
|
events=event_bus,
|
|
)
|
|
messages = engine.run(scenario_data)
|
|
|
|
# Convert Message objects to legacy dict format
|
|
conversation = [
|
|
{"role_id": m.role_id, "name": m.name, "content": m.content}
|
|
for m in messages
|
|
]
|
|
|
|
if save and conversation:
|
|
# Determine sessions dir
|
|
ws_config = find_workspace_config()
|
|
if ws_config:
|
|
sessions_dir = os.path.join(os.path.dirname(ws_config), "sessions")
|
|
else:
|
|
sessions_dir = os.path.join(os.getcwd(), "sessions")
|
|
|
|
scenario_obj = load_scenario(scenario_path)
|
|
save_session(conversation, scenario_obj.get("name", "discussion"), sessions_dir)
|
|
|
|
return conversation
|
|
|
|
|
|
def main():
|
|
"""CLI entry point."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Meeting Room — Multi-Agent Discussion Framework",
|
|
)
|
|
parser.add_argument(
|
|
"-s", "--scenario",
|
|
default="example-saas.md",
|
|
help="Scenario file .md or .yaml",
|
|
)
|
|
parser.add_argument(
|
|
"-c", "--config",
|
|
default=None,
|
|
help="Path to custom config.yaml",
|
|
)
|
|
parser.add_argument(
|
|
"-w", "--workdir",
|
|
default=None,
|
|
help="Working directory for file tools",
|
|
)
|
|
parser.add_argument(
|
|
"--save", action="store_true",
|
|
help="Save discussion result to sessions/",
|
|
)
|
|
parser.add_argument(
|
|
"--list-scenarios", action="store_true",
|
|
help="List available scenarios",
|
|
)
|
|
parser.add_argument(
|
|
"--list-roles", action="store_true",
|
|
help="List roles and model bindings",
|
|
)
|
|
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
init_parser = subparsers.add_parser("init", help="Initialize .meeting-room workspace")
|
|
init_parser.add_argument("path", nargs="?", default=".", help="Where to create .meeting-room")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Handle init command
|
|
if args.command == "init":
|
|
init_workspace(args.path)
|
|
return
|
|
|
|
# Find config: CLI arg > .meeting-room/config.yaml > package default
|
|
if args.config:
|
|
config = load_config(args.config)
|
|
else:
|
|
ws_config = find_workspace_config()
|
|
if ws_config:
|
|
config = load_config(ws_config)
|
|
else:
|
|
config = load_config()
|
|
|
|
if args.list_scenarios:
|
|
list_scenarios()
|
|
cwd_scenarios = os.path.join(os.getcwd(), "scenarios")
|
|
if os.path.isdir(cwd_scenarios):
|
|
list_scenarios(cwd_scenarios)
|
|
# Also check .meeting-room
|
|
ws_scenarios = os.path.join(os.getcwd(), ".meeting-room", "scenarios")
|
|
if os.path.isdir(ws_scenarios):
|
|
list_scenarios(ws_scenarios)
|
|
return
|
|
|
|
if args.list_roles:
|
|
list_roles(config)
|
|
return
|
|
|
|
if args.workdir:
|
|
config["defaults"]["workdir"] = args.workdir
|
|
|
|
run_discussion(args.scenario, config, save=args.save)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |