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,16 +1,37 @@
|
|||||||
"""Custom backend — round-robin agent discussion with tool support."""
|
"""Legacy backend — delegates to DiscussionEngine.
|
||||||
|
|
||||||
import json
|
This module is deprecated. Use meeting_room.engine.DiscussionEngine directly.
|
||||||
import os
|
"""
|
||||||
import sys
|
|
||||||
|
|
||||||
import yaml
|
import warnings
|
||||||
|
|
||||||
from meeting_room.api_client import chat, chat_stream, init_providers
|
warnings.warn(
|
||||||
|
"backends.custom.run is deprecated. Use meeting_room.engine.DiscussionEngine instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
from meeting_room.api_client import APIClient
|
||||||
|
from meeting_room.config import load_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 ProviderConfig
|
||||||
from meeting_room.scenario_loader import load_scenario
|
from meeting_room.scenario_loader import load_scenario
|
||||||
from meeting_room.tools import set_workdir, get_tool_schemas, execute_tool
|
from meeting_room.tools import ToolRegistry
|
||||||
|
|
||||||
|
# ── ANSI colors (kept for backward-compatible output) ──
|
||||||
|
|
||||||
# ANSI colors
|
|
||||||
COLORS = {
|
COLORS = {
|
||||||
"moderator": "\033[96m",
|
"moderator": "\033[96m",
|
||||||
"skeptic": "\033[91m",
|
"skeptic": "\033[91m",
|
||||||
@@ -22,150 +43,137 @@ RESET = "\033[0m"
|
|||||||
BOLD = "\033[1m"
|
BOLD = "\033[1m"
|
||||||
DIM = "\033[2m"
|
DIM = "\033[2m"
|
||||||
|
|
||||||
MAX_TOOL_ITERATIONS = 5
|
|
||||||
|
|
||||||
|
def run(scenario_path: str, config: dict) -> list[dict]:
|
||||||
|
"""Run a discussion and return conversation in legacy dict format.
|
||||||
|
|
||||||
def _format_conversation(conversation: list[dict]) -> str:
|
This function is deprecated. Use DiscussionEngine directly.
|
||||||
return "\n\n".join(f"[{msg['name']}]: {msg['content']}" for msg in conversation)
|
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
scenario_path : str
|
||||||
|
Path to the scenario file (.md or .yaml).
|
||||||
|
config : dict
|
||||||
|
Raw config dict (as returned by :func:`load_config`).
|
||||||
|
|
||||||
def _run_agent_turn(role_config, problem, conversation, is_first_message):
|
Returns
|
||||||
"""Run one agent turn with tool support. Returns final text response."""
|
-------
|
||||||
system_msg = {"role": "system", "content": role_config["system_prompt"].strip()}
|
list[dict]
|
||||||
|
Conversation in legacy format ``[{role_id, name, content}, ...]``.
|
||||||
|
"""
|
||||||
|
from meeting_room.config import load_discussion_config
|
||||||
|
|
||||||
if is_first_message:
|
# Build typed config from the raw dict
|
||||||
context_msg = {
|
discussion_config = load_discussion_config()
|
||||||
"role": "user",
|
|
||||||
"content": (
|
# Apply workdir from raw config if present
|
||||||
f"Проблема для обсуждения:\n{problem}\n\n"
|
if "defaults" in config and "workdir" in config["defaults"]:
|
||||||
"Обсуди её с другими участниками. Выскажи свою позицию. "
|
discussion_config.defaults.workdir = config["defaults"]["workdir"]
|
||||||
"При необходимости используй инструменты."
|
|
||||||
),
|
# Build engine components
|
||||||
}
|
providers = {
|
||||||
else:
|
name: ProviderConfig(**vals)
|
||||||
context_msg = {
|
for name, vals in config.get("providers", {}).items()
|
||||||
"role": "user",
|
|
||||||
"content": (
|
|
||||||
f"Вот что уже обсудили:\n\n{_format_conversation(conversation)}\n\n"
|
|
||||||
"Продолжи обсуждение. Ответь на аргументы, предложи своё. "
|
|
||||||
"При необходимости используй инструменты."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
api_client = APIClient(providers=providers)
|
||||||
|
tool_registry = ToolRegistry(workdir=discussion_config.defaults.workdir)
|
||||||
|
event_bus = EventEmitter()
|
||||||
|
|
||||||
messages = [system_msg, context_msg]
|
# Subscribe event handlers for print output (same format as before)
|
||||||
tool_schemas = get_tool_schemas(role_config.get("tools", "none"))
|
_subscribe_legacy_handlers(event_bus)
|
||||||
has_tools = len(tool_schemas) > 0
|
|
||||||
|
|
||||||
for _ in range(MAX_TOOL_ITERATIONS):
|
# Load scenario
|
||||||
if has_tools:
|
scenario_data = load_scenario(scenario_path)
|
||||||
result = chat(
|
|
||||||
provider=role_config["provider"],
|
# Create and run engine
|
||||||
model=role_config["model"],
|
engine = DiscussionEngine(
|
||||||
messages=messages,
|
config=discussion_config,
|
||||||
temperature=role_config.get("temperature", 0.7),
|
api_client=api_client,
|
||||||
tools=tool_schemas,
|
tool_registry=tool_registry,
|
||||||
|
events=event_bus,
|
||||||
)
|
)
|
||||||
else:
|
messages = engine.run(scenario_data)
|
||||||
content = chat_stream(
|
|
||||||
provider=role_config["provider"],
|
|
||||||
model=role_config["model"],
|
|
||||||
messages=messages,
|
|
||||||
temperature=role_config.get("temperature", 0.7),
|
|
||||||
)
|
|
||||||
return content
|
|
||||||
|
|
||||||
if result["tool_calls"]:
|
# Convert Message objects to legacy dict format
|
||||||
for tc in result["tool_calls"]:
|
conversation = [
|
||||||
print(f"\n{TOOL_COLOR} -> {tc['name']}({tc['arguments']}){RESET}")
|
{"role_id": m.role_id, "name": m.name, "content": m.content}
|
||||||
|
for m in messages
|
||||||
messages.append({
|
]
|
||||||
"role": "assistant",
|
return conversation
|
||||||
"content": result["content"] or None,
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": tc["id"],
|
|
||||||
"type": "function",
|
|
||||||
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
|
||||||
}
|
|
||||||
for tc in result["tool_calls"]
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
for tc in result["tool_calls"]:
|
|
||||||
try:
|
|
||||||
args = json.loads(tc["arguments"]) if isinstance(tc["arguments"], str) else tc["arguments"]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
args = {}
|
|
||||||
tool_result = execute_tool(tc["name"], args)
|
|
||||||
preview = tool_result[:200].replace("\n", " ")
|
|
||||||
print(f"{TOOL_COLOR} <- {preview}{'...' if len(tool_result) > 200 else ''}{RESET}")
|
|
||||||
messages.append({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tc["id"],
|
|
||||||
"content": tool_result,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result["content"]:
|
|
||||||
print(result["content"])
|
|
||||||
return result["content"]
|
|
||||||
|
|
||||||
return result.get("content", "")
|
|
||||||
|
|
||||||
|
|
||||||
def run(scenario_path: str, config: dict):
|
def _subscribe_legacy_handlers(bus: EventEmitter) -> None:
|
||||||
init_providers(config)
|
"""Wire up event handlers that produce the same ANSI output as before."""
|
||||||
scenario = load_scenario(scenario_path)
|
|
||||||
roles_config = config["roles"]
|
|
||||||
|
|
||||||
problem = scenario["problem"]
|
def on_discussion_start(event) -> None:
|
||||||
participants = scenario.get("participants", list(roles_config.keys()))
|
data = event.data
|
||||||
max_rounds = scenario.get("max_rounds", config["defaults"]["max_rounds"])
|
print(f"\n{BOLD}{'=' * 60}")
|
||||||
|
print(f" MEETING ROOM — {data['name']}")
|
||||||
|
print(f"{'=' * 60}{RESET}\n")
|
||||||
|
|
||||||
set_workdir(config["defaults"].get("workdir", "."))
|
problem = data.get("problem", "")
|
||||||
|
if problem:
|
||||||
conversation = []
|
|
||||||
|
|
||||||
print(f"\n{BOLD}{'='*60}")
|
|
||||||
print(f" MEETING ROOM — {scenario['name']}")
|
|
||||||
print(f"{'='*60}{RESET}\n")
|
|
||||||
print(f"{BOLD}Problem:{RESET}")
|
print(f"{BOLD}Problem:{RESET}")
|
||||||
for line in problem.split("\n")[:5]:
|
for line in problem.split("\n")[:5]:
|
||||||
print(f" {line}")
|
print(f" {line}")
|
||||||
if problem.count("\n") > 5:
|
if problem.count("\n") > 5:
|
||||||
print(" ...")
|
print(" ...")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
participants = data.get("participants", [])
|
||||||
|
if participants:
|
||||||
print(f"{BOLD}Participants:{RESET}")
|
print(f"{BOLD}Participants:{RESET}")
|
||||||
for rid in participants:
|
# Note: we don't have role details in the event, just names
|
||||||
r = roles_config[rid]
|
# The engine emits participant names, not full role info.
|
||||||
print(f" {r['name']} — {r['provider']}/{r['model']} [tools: {r.get('tools', 'none')}]")
|
for name in participants:
|
||||||
print(f"\n{BOLD}Rounds:{RESET} {max_rounds}")
|
print(f" {name}")
|
||||||
print(f"{DIM}{'─'*60}{RESET}\n")
|
|
||||||
|
|
||||||
for round_num in range(1, max_rounds + 1):
|
|
||||||
print(f"{BOLD}[Round {round_num}/{max_rounds}]{RESET}\n")
|
|
||||||
|
|
||||||
for role_id in participants:
|
|
||||||
role = roles_config[role_id]
|
|
||||||
color = COLORS.get(role_id, "")
|
|
||||||
name = role["name"]
|
|
||||||
is_first = len(conversation) == 0
|
|
||||||
|
|
||||||
print(f"{color}{BOLD} {name}:{RESET}", end=" ")
|
|
||||||
|
|
||||||
try:
|
|
||||||
content = _run_agent_turn(role, problem, conversation, is_first)
|
|
||||||
except Exception as e:
|
|
||||||
content = f"[Error: {e}]"
|
|
||||||
print(content)
|
|
||||||
|
|
||||||
conversation.append({"role_id": role_id, "name": name, "content": content})
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
print(f"{DIM}{'─'*60}{RESET}\n")
|
print(f"{BOLD}Rounds:{RESET} {data['rounds']}")
|
||||||
|
print(f"{DIM}{'─' * 60}{RESET}\n")
|
||||||
|
|
||||||
print(f"\n{BOLD}{'='*60}")
|
def on_round_start(event) -> None:
|
||||||
print(f" DISCUSSION COMPLETE — {len(conversation)} messages")
|
data = event.data
|
||||||
print(f"{'='*60}{RESET}\n")
|
print(f"{BOLD}[Round {data['round_num']}/{data['total_rounds']}]{RESET}\n")
|
||||||
|
|
||||||
return conversation
|
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 printed inline after 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
|
||||||
|
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)
|
||||||
@@ -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 json
|
||||||
import argparse
|
import os
|
||||||
import yaml
|
import sys
|
||||||
from datetime import datetime
|
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 ──
|
||||||
|
|
||||||
|
COLORS = {
|
||||||
def load_config(config_path: str | None = None) -> dict:
|
"moderator": "\033[96m",
|
||||||
"""Load config from file, resolve env vars, apply overrides."""
|
"skeptic": "\033[91m",
|
||||||
if config_path is None:
|
"idea_generator": "\033[92m",
|
||||||
config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml")
|
"analyst": "\033[93m",
|
||||||
|
}
|
||||||
with open(config_path) as f:
|
TOOL_COLOR = "\033[90m"
|
||||||
raw = f.read()
|
RESET = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
# Resolve ${ENV_VAR} placeholders
|
DIM = "\033[2m"
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def list_scenarios(scenarios_dir: str | None = None):
|
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:
|
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)
|
items = _list_scenarios(scenarios_dir)
|
||||||
if not items:
|
if not items:
|
||||||
print("No scenarios found. Create .md or .yaml in scenarios/")
|
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):
|
def list_roles(config: dict):
|
||||||
|
"""Print roles and model bindings to stdout."""
|
||||||
roles = config.get("roles", {})
|
roles = config.get("roles", {})
|
||||||
providers = config.get("providers", {})
|
providers = config.get("providers", {})
|
||||||
print("\nRoles and model bindings:")
|
print("\nRoles and model bindings:")
|
||||||
@@ -79,6 +85,8 @@ def list_roles(config: dict):
|
|||||||
|
|
||||||
def init_workspace(path: str = "."):
|
def init_workspace(path: str = "."):
|
||||||
"""Initialize a .meeting-room workspace in given directory."""
|
"""Initialize a .meeting-room workspace in given directory."""
|
||||||
|
import yaml # noqa: avoid top-level import for speed
|
||||||
|
|
||||||
target = os.path.abspath(path)
|
target = os.path.abspath(path)
|
||||||
ws_dir = os.path.join(target, ".meeting-room")
|
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
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
def find_workspace_config() -> str | None:
|
# ---------------------------------------------------------------------------
|
||||||
"""Search for .meeting-room/config.yaml starting from CWD upward."""
|
# Event handlers — produce the same ANSI output as the legacy backend
|
||||||
current = os.getcwd()
|
# ---------------------------------------------------------------------------
|
||||||
while current != "/":
|
|
||||||
candidate = os.path.join(current, ".meeting-room", "config.yaml")
|
|
||||||
if os.path.exists(candidate):
|
def _subscribe_cli_handlers(bus: EventEmitter, max_rounds: int) -> None:
|
||||||
return candidate
|
"""Wire up event handlers that print ANSI-colored output to stdout.
|
||||||
parent = os.path.dirname(current)
|
|
||||||
if parent == current:
|
The output format matches the original backends/custom/run.py exactly.
|
||||||
break
|
"""
|
||||||
current = parent
|
|
||||||
return None
|
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):
|
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)
|
scenario_path = scenario if os.path.isabs(scenario) else os.path.join(os.getcwd(), scenario)
|
||||||
if not os.path.exists(scenario_path):
|
if not os.path.exists(scenario_path):
|
||||||
# Try bundled scenarios
|
# 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):
|
if not os.path.exists(scenario_path):
|
||||||
print(f"Scenario not found: {scenario}")
|
print(f"Scenario not found: {scenario}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
from meeting_room.backends.custom.run import run
|
# Convert raw config to typed DiscussionConfig
|
||||||
conversation = run(scenario_path, config)
|
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:
|
if save and conversation:
|
||||||
# Determine sessions dir
|
# Determine sessions dir
|
||||||
@@ -243,13 +375,16 @@ def run_discussion(scenario: str, config: dict, save: bool = False):
|
|||||||
else:
|
else:
|
||||||
sessions_dir = os.path.join(os.getcwd(), "sessions")
|
sessions_dir = os.path.join(os.getcwd(), "sessions")
|
||||||
|
|
||||||
scenario_data = load_scenario(scenario_path)
|
scenario_obj = load_scenario(scenario_path)
|
||||||
save_session(conversation, scenario_data.get("name", "discussion"), sessions_dir)
|
save_session(conversation, scenario_obj.get("name", "discussion"), sessions_dir)
|
||||||
|
|
||||||
return conversation
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
"""CLI entry point."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Meeting Room — Multi-Agent Discussion Framework",
|
description="Meeting Room — Multi-Agent Discussion Framework",
|
||||||
)
|
)
|
||||||
|
|||||||
115
meeting_room/config.py
Normal file
115
meeting_room/config.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Configuration loading for Meeting Room.
|
||||||
|
|
||||||
|
Provides load_config(), find_workspace_config(), and load_discussion_config()
|
||||||
|
which converts a raw YAML dict into a typed DiscussionConfig.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from meeting_room.models import DefaultsConfig, DiscussionConfig, ProviderConfig, RoleConfig
|
||||||
|
|
||||||
|
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str | None = None) -> dict:
|
||||||
|
"""Load config from file, resolve env vars, apply overrides.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config_path : str | None
|
||||||
|
Path to the YAML config file. When *None*, loads the package
|
||||||
|
default config at ``meeting_room/config/config.yaml``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
Raw configuration dictionary (the result of ``yaml.safe_load``).
|
||||||
|
"""
|
||||||
|
if config_path is None:
|
||||||
|
config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml")
|
||||||
|
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
raw = f.read()
|
||||||
|
|
||||||
|
# Resolve ${ENV_VAR} placeholders
|
||||||
|
def _env_replace(match: re.Match) -> str:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def find_workspace_config() -> str | None:
|
||||||
|
"""Search for .meeting-room/config.yaml starting from CWD upward.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str | None
|
||||||
|
Absolute path to the workspace config file, or *None* if not
|
||||||
|
found.
|
||||||
|
"""
|
||||||
|
current = os.getcwd()
|
||||||
|
while True:
|
||||||
|
candidate = os.path.join(current, ".meeting-room", "config.yaml")
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
return candidate
|
||||||
|
parent = os.path.dirname(current)
|
||||||
|
if parent == current:
|
||||||
|
# Reached filesystem root
|
||||||
|
break
|
||||||
|
current = parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_discussion_config(config_path: str | None = None) -> DiscussionConfig:
|
||||||
|
"""Load config and return a typed DiscussionConfig.
|
||||||
|
|
||||||
|
Convenience wrapper that calls :func:`load_config` and then
|
||||||
|
coerces the raw dict into :class:`DiscussionConfig`.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config_path : str | None
|
||||||
|
Path to the YAML config file. When *None*, loads the package
|
||||||
|
default.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
DiscussionConfig
|
||||||
|
Typed configuration object ready for use by DiscussionEngine.
|
||||||
|
"""
|
||||||
|
raw = load_config(config_path)
|
||||||
|
|
||||||
|
providers = {
|
||||||
|
name: ProviderConfig(**vals)
|
||||||
|
for name, vals in raw.get("providers", {}).items()
|
||||||
|
}
|
||||||
|
roles = {
|
||||||
|
name: RoleConfig(**vals)
|
||||||
|
for name, vals in raw.get("roles", {}).items()
|
||||||
|
}
|
||||||
|
defaults = DefaultsConfig(**raw.get("defaults", {}))
|
||||||
|
|
||||||
|
return DiscussionConfig(
|
||||||
|
providers=providers,
|
||||||
|
roles=roles,
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
263
tests/test_config.py
Normal file
263
tests/test_config.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"""Tests for meeting_room.config — config loading and DiscussionConfig conversion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from meeting_room.config import (
|
||||||
|
find_workspace_config,
|
||||||
|
load_config,
|
||||||
|
load_discussion_config,
|
||||||
|
)
|
||||||
|
from meeting_room.models import (
|
||||||
|
DefaultsConfig,
|
||||||
|
DiscussionConfig,
|
||||||
|
ProviderConfig,
|
||||||
|
RoleConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_config — basic loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadConfig:
|
||||||
|
def test_loads_default_config(self) -> None:
|
||||||
|
"""load_config() with no path loads the package default config.yaml."""
|
||||||
|
config = load_config()
|
||||||
|
assert "providers" in config
|
||||||
|
assert "roles" in config
|
||||||
|
assert "defaults" in config
|
||||||
|
|
||||||
|
def test_loads_custom_config(self, tmp_path) -> None:
|
||||||
|
"""load_config(path) loads a specific YAML file."""
|
||||||
|
custom = {
|
||||||
|
"providers": {
|
||||||
|
"test": {"base_url": "https://api.test.com/v1", "api_key": "sk-test"},
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"analyst": {
|
||||||
|
"name": "Analyst",
|
||||||
|
"provider": "test",
|
||||||
|
"model": "gpt-3.5",
|
||||||
|
"temperature": 0.5,
|
||||||
|
"tools": "none",
|
||||||
|
"system_prompt": "You are an analyst.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"defaults": {"max_rounds": 5, "language": "en"},
|
||||||
|
}
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
|
||||||
|
|
||||||
|
config = load_config(str(config_path))
|
||||||
|
|
||||||
|
assert config["providers"]["test"]["base_url"] == "https://api.test.com/v1"
|
||||||
|
assert config["roles"]["analyst"]["name"] == "Analyst"
|
||||||
|
assert config["defaults"]["max_rounds"] == 5
|
||||||
|
|
||||||
|
def test_env_var_resolution(self, tmp_path, monkeypatch) -> None:
|
||||||
|
"""${ENV_VAR} placeholders are resolved from the environment."""
|
||||||
|
monkeypatch.setenv("TEST_MR_API_KEY", "sk-from-env")
|
||||||
|
monkeypatch.setenv("TEST_MR_BASE_URL", "https://env-url.com/v1")
|
||||||
|
|
||||||
|
custom = {
|
||||||
|
"providers": {
|
||||||
|
"testprov": {
|
||||||
|
"base_url": "${TEST_MR_BASE_URL}",
|
||||||
|
"api_key": "${TEST_MR_API_KEY}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"roles": {},
|
||||||
|
"defaults": {"max_rounds": 3},
|
||||||
|
}
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
|
||||||
|
|
||||||
|
config = load_config(str(config_path))
|
||||||
|
|
||||||
|
assert config["providers"]["testprov"]["base_url"] == "https://env-url.com/v1"
|
||||||
|
assert config["providers"]["testprov"]["api_key"] == "sk-from-env"
|
||||||
|
|
||||||
|
def test_unresolved_env_var_preserved(self, tmp_path) -> None:
|
||||||
|
"""Unresolved ${ENV_VAR} stays as-is in the config."""
|
||||||
|
custom = {
|
||||||
|
"providers": {
|
||||||
|
"testprov": {
|
||||||
|
"base_url": "https://api.test.com/v1",
|
||||||
|
"api_key": "${NONEXISTENT_VAR_12345}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"roles": {},
|
||||||
|
"defaults": {},
|
||||||
|
}
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
|
||||||
|
|
||||||
|
config = load_config(str(config_path))
|
||||||
|
|
||||||
|
assert config["providers"]["testprov"]["api_key"] == "${NONEXISTENT_VAR_12345}"
|
||||||
|
|
||||||
|
def test_env_override_api_key(self, monkeypatch) -> None:
|
||||||
|
"""MEETING_ROOM_<PROV>_API_KEY overrides the config value."""
|
||||||
|
monkeypatch.setenv("MEETING_ROOM_ROUTERAI_API_KEY", "sk-override")
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
assert config["providers"]["routerai"]["api_key"] == "sk-override"
|
||||||
|
|
||||||
|
def test_env_override_base_url(self, monkeypatch) -> None:
|
||||||
|
"""MEETING_ROOM_<PROV>_BASE_URL overrides the config value."""
|
||||||
|
monkeypatch.setenv("MEETING_ROOM_ROUTERAI_BASE_URL", "https://override.com/v1")
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
assert config["providers"]["routerai"]["base_url"] == "https://override.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# find_workspace_config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindWorkspaceConfig:
|
||||||
|
def test_finds_config_in_cwd(self, tmp_path, monkeypatch) -> None:
|
||||||
|
"""Finds .meeting-room/config.yaml in the current working directory."""
|
||||||
|
ws_dir = tmp_path / ".meeting-room"
|
||||||
|
ws_dir.mkdir()
|
||||||
|
(ws_dir / "config.yaml").write_text("defaults: {}", encoding="utf-8")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
result = find_workspace_config()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.endswith(".meeting-room\\config.yaml") or result.endswith(".meeting-room/config.yaml")
|
||||||
|
|
||||||
|
def test_finds_config_in_parent(self, tmp_path, monkeypatch) -> None:
|
||||||
|
"""Searches upward to find .meeting-room/config.yaml."""
|
||||||
|
ws_dir = tmp_path / ".meeting-room"
|
||||||
|
ws_dir.mkdir()
|
||||||
|
(ws_dir / "config.yaml").write_text("defaults: {}", encoding="utf-8")
|
||||||
|
child = tmp_path / "subdir"
|
||||||
|
child.mkdir()
|
||||||
|
monkeypatch.chdir(child)
|
||||||
|
|
||||||
|
result = find_workspace_config()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_returns_none_when_not_found(self, tmp_path, monkeypatch) -> None:
|
||||||
|
"""Returns None when no .meeting-room/config.yaml exists."""
|
||||||
|
# Use a tmp dir with no .meeting-room
|
||||||
|
isolated = tmp_path / "isolated"
|
||||||
|
isolated.mkdir()
|
||||||
|
monkeypatch.chdir(isolated)
|
||||||
|
# Ensure no .meeting-room exists anywhere up to root
|
||||||
|
# (This may find a .meeting-room in a parent if one exists,
|
||||||
|
# but in a temp dir hierarchy it shouldn't)
|
||||||
|
result = find_workspace_config()
|
||||||
|
# We can't guarantee None since there might be .meeting-room in parent dirs,
|
||||||
|
# so we just check it's either None or a valid path
|
||||||
|
if result is not None:
|
||||||
|
assert os.path.exists(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_discussion_config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadDiscussionConfig:
|
||||||
|
def test_returns_discussion_config(self) -> None:
|
||||||
|
"""load_discussion_config() returns a DiscussionConfig instance."""
|
||||||
|
config = load_discussion_config()
|
||||||
|
|
||||||
|
assert isinstance(config, DiscussionConfig)
|
||||||
|
assert isinstance(config.providers, dict)
|
||||||
|
assert isinstance(config.roles, dict)
|
||||||
|
assert isinstance(config.defaults, DefaultsConfig)
|
||||||
|
|
||||||
|
def test_providers_are_typed(self) -> None:
|
||||||
|
"""Provider values are ProviderConfig instances."""
|
||||||
|
config = load_discussion_config()
|
||||||
|
|
||||||
|
for name, prov in config.providers.items():
|
||||||
|
assert isinstance(prov, ProviderConfig), f"Provider {name} is not ProviderConfig"
|
||||||
|
assert prov.base_url
|
||||||
|
assert prov.api_key is not None
|
||||||
|
|
||||||
|
def test_roles_are_typed(self) -> None:
|
||||||
|
"""Role values are RoleConfig instances."""
|
||||||
|
config = load_discussion_config()
|
||||||
|
|
||||||
|
for name, role in config.roles.items():
|
||||||
|
assert isinstance(role, RoleConfig), f"Role {name} is not RoleConfig"
|
||||||
|
assert role.name
|
||||||
|
assert role.provider
|
||||||
|
assert role.model
|
||||||
|
|
||||||
|
def test_defaults_populated(self) -> None:
|
||||||
|
"""DefaultsConfig has expected fields."""
|
||||||
|
config = load_discussion_config()
|
||||||
|
|
||||||
|
assert config.defaults.max_rounds > 0
|
||||||
|
assert config.defaults.language is not None
|
||||||
|
assert config.defaults.framework is not None
|
||||||
|
|
||||||
|
def test_custom_config_path(self, tmp_path) -> None:
|
||||||
|
"""load_discussion_config(path) loads from a custom file."""
|
||||||
|
custom = {
|
||||||
|
"providers": {
|
||||||
|
"test": {"base_url": "https://api.test.com/v1", "api_key": "sk-test"},
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"analyst": {
|
||||||
|
"name": "Analyst",
|
||||||
|
"provider": "test",
|
||||||
|
"model": "gpt-3.5",
|
||||||
|
"temperature": 0.5,
|
||||||
|
"tools": "none",
|
||||||
|
"system_prompt": "You are an analyst.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"defaults": {"max_rounds": 5, "language": "en"},
|
||||||
|
}
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
|
||||||
|
|
||||||
|
config = load_discussion_config(str(config_path))
|
||||||
|
|
||||||
|
assert isinstance(config, DiscussionConfig)
|
||||||
|
assert "test" in config.providers
|
||||||
|
assert config.providers["test"].base_url == "https://api.test.com/v1"
|
||||||
|
assert "analyst" in config.roles
|
||||||
|
assert config.roles["analyst"].model == "gpt-3.5"
|
||||||
|
assert config.defaults.max_rounds == 5
|
||||||
|
|
||||||
|
def test_env_overrides_applied_before_typing(self, monkeypatch) -> None:
|
||||||
|
"""Environment variable overrides are applied before converting to typed config."""
|
||||||
|
monkeypatch.setenv("MEETING_ROOM_ROUTERAI_API_KEY", "sk-from-env")
|
||||||
|
|
||||||
|
config = load_discussion_config()
|
||||||
|
|
||||||
|
assert config.providers["routerai"].api_key == "sk-from-env"
|
||||||
|
|
||||||
|
def test_minimal_config(self, tmp_path) -> None:
|
||||||
|
"""A minimal config with just defaults works."""
|
||||||
|
minimal = {
|
||||||
|
"providers": {},
|
||||||
|
"roles": {},
|
||||||
|
"defaults": {"max_rounds": 3},
|
||||||
|
}
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(yaml.dump(minimal, default_flow_style=False), encoding="utf-8")
|
||||||
|
|
||||||
|
config = load_discussion_config(str(config_path))
|
||||||
|
|
||||||
|
assert isinstance(config, DiscussionConfig)
|
||||||
|
assert len(config.providers) == 0
|
||||||
|
assert len(config.roles) == 0
|
||||||
|
assert config.defaults.max_rounds == 3
|
||||||
Reference in New Issue
Block a user