feat: extract config.py, refactor CLI to use event-driven engine, add deprecation shim
- 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>
This commit is contained in:
@@ -1,49 +1,54 @@
|
||||
"""Meeting Room CLI — main entry point."""
|
||||
"""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 sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import yaml
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from meeting_room.scenario_loader import load_scenario, list_scenarios as _list_scenarios
|
||||
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
|
||||
|
||||
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
# ── ANSI colors ──
|
||||
|
||||
|
||||
def load_config(config_path: str | None = None) -> dict:
|
||||
"""Load config from file, resolve env vars, apply overrides."""
|
||||
if config_path is None:
|
||||
config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml")
|
||||
|
||||
with open(config_path) as f:
|
||||
raw = f.read()
|
||||
|
||||
# Resolve ${ENV_VAR} placeholders
|
||||
import re
|
||||
def _env_replace(match):
|
||||
var = match.group(1)
|
||||
return os.environ.get(var, match.group(0))
|
||||
raw = re.sub(r'\$\{(\w+)\}', _env_replace, raw)
|
||||
|
||||
config = yaml.safe_load(raw)
|
||||
|
||||
# Override API keys from environment if set
|
||||
for prov_name, prov_cfg in config.get("providers", {}).items():
|
||||
env_key = f"MEETING_ROOM_{prov_name.upper()}_API_KEY"
|
||||
if os.environ.get(env_key):
|
||||
prov_cfg["api_key"] = os.environ[env_key]
|
||||
env_url = f"MEETING_ROOM_{prov_name.upper()}_BASE_URL"
|
||||
if os.environ.get(env_url):
|
||||
prov_cfg["base_url"] = os.environ[env_url]
|
||||
|
||||
return config
|
||||
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(PACKAGE_DIR, "scenarios")
|
||||
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/")
|
||||
@@ -59,6 +64,7 @@ def list_scenarios(scenarios_dir: str | None = None):
|
||||
|
||||
|
||||
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:")
|
||||
@@ -79,6 +85,8 @@ def list_roles(config: dict):
|
||||
|
||||
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")
|
||||
|
||||
@@ -209,31 +217,155 @@ def save_session(conversation: list[dict], scenario_name: str, sessions_dir: str
|
||||
return filepath
|
||||
|
||||
|
||||
def find_workspace_config() -> str | None:
|
||||
"""Search for .meeting-room/config.yaml starting from CWD upward."""
|
||||
current = os.getcwd()
|
||||
while current != "/":
|
||||
candidate = os.path.join(current, ".meeting-room", "config.yaml")
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
return None
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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(PACKAGE_DIR, "scenarios", scenario)
|
||||
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)
|
||||
|
||||
from meeting_room.backends.custom.run import run
|
||||
conversation = run(scenario_path, config)
|
||||
# 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
|
||||
@@ -243,13 +375,16 @@ def run_discussion(scenario: str, config: dict, save: bool = False):
|
||||
else:
|
||||
sessions_dir = os.path.join(os.getcwd(), "sessions")
|
||||
|
||||
scenario_data = load_scenario(scenario_path)
|
||||
save_session(conversation, scenario_data.get("name", "discussion"), sessions_dir)
|
||||
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",
|
||||
)
|
||||
@@ -324,4 +459,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
Reference in New Issue
Block a user