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
|
||||
import os
|
||||
import sys
|
||||
This module is deprecated. Use meeting_room.engine.DiscussionEngine directly.
|
||||
"""
|
||||
|
||||
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.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 = {
|
||||
"moderator": "\033[96m",
|
||||
"skeptic": "\033[91m",
|
||||
@@ -22,150 +43,137 @@ RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
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.
|
||||
|
||||
This function is deprecated. Use DiscussionEngine directly.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scenario_path : str
|
||||
Path to the scenario file (.md or .yaml).
|
||||
config : dict
|
||||
Raw config dict (as returned by :func:`load_config`).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict]
|
||||
Conversation in legacy format ``[{role_id, name, content}, ...]``.
|
||||
"""
|
||||
from meeting_room.config import load_discussion_config
|
||||
|
||||
# Build typed config from the raw dict
|
||||
discussion_config = load_discussion_config()
|
||||
|
||||
# Apply workdir from raw config if present
|
||||
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 event handlers for print output (same format as before)
|
||||
_subscribe_legacy_handlers(event_bus)
|
||||
|
||||
# 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
|
||||
]
|
||||
return conversation
|
||||
|
||||
|
||||
def _format_conversation(conversation: list[dict]) -> str:
|
||||
return "\n\n".join(f"[{msg['name']}]: {msg['content']}" for msg in conversation)
|
||||
def _subscribe_legacy_handlers(bus: EventEmitter) -> None:
|
||||
"""Wire up event handlers that produce the same ANSI output as before."""
|
||||
|
||||
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")
|
||||
|
||||
def _run_agent_turn(role_config, problem, conversation, is_first_message):
|
||||
"""Run one agent turn with tool support. Returns final text response."""
|
||||
system_msg = {"role": "system", "content": role_config["system_prompt"].strip()}
|
||||
|
||||
if is_first_message:
|
||||
context_msg = {
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Проблема для обсуждения:\n{problem}\n\n"
|
||||
"Обсуди её с другими участниками. Выскажи свою позицию. "
|
||||
"При необходимости используй инструменты."
|
||||
),
|
||||
}
|
||||
else:
|
||||
context_msg = {
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Вот что уже обсудили:\n\n{_format_conversation(conversation)}\n\n"
|
||||
"Продолжи обсуждение. Ответь на аргументы, предложи своё. "
|
||||
"При необходимости используй инструменты."
|
||||
),
|
||||
}
|
||||
|
||||
messages = [system_msg, context_msg]
|
||||
tool_schemas = get_tool_schemas(role_config.get("tools", "none"))
|
||||
has_tools = len(tool_schemas) > 0
|
||||
|
||||
for _ in range(MAX_TOOL_ITERATIONS):
|
||||
if has_tools:
|
||||
result = chat(
|
||||
provider=role_config["provider"],
|
||||
model=role_config["model"],
|
||||
messages=messages,
|
||||
temperature=role_config.get("temperature", 0.7),
|
||||
tools=tool_schemas,
|
||||
)
|
||||
else:
|
||||
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"]:
|
||||
for tc in result["tool_calls"]:
|
||||
print(f"\n{TOOL_COLOR} -> {tc['name']}({tc['arguments']}){RESET}")
|
||||
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"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):
|
||||
init_providers(config)
|
||||
scenario = load_scenario(scenario_path)
|
||||
roles_config = config["roles"]
|
||||
|
||||
problem = scenario["problem"]
|
||||
participants = scenario.get("participants", list(roles_config.keys()))
|
||||
max_rounds = scenario.get("max_rounds", config["defaults"]["max_rounds"])
|
||||
|
||||
set_workdir(config["defaults"].get("workdir", "."))
|
||||
|
||||
conversation = []
|
||||
|
||||
print(f"\n{BOLD}{'='*60}")
|
||||
print(f" MEETING ROOM — {scenario['name']}")
|
||||
print(f"{'='*60}{RESET}\n")
|
||||
print(f"{BOLD}Problem:{RESET}")
|
||||
for line in problem.split("\n")[:5]:
|
||||
print(f" {line}")
|
||||
if problem.count("\n") > 5:
|
||||
print(" ...")
|
||||
print()
|
||||
print(f"{BOLD}Participants:{RESET}")
|
||||
for rid in participants:
|
||||
r = roles_config[rid]
|
||||
print(f" {r['name']} — {r['provider']}/{r['model']} [tools: {r.get('tools', 'none')}]")
|
||||
print(f"\n{BOLD}Rounds:{RESET} {max_rounds}")
|
||||
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})
|
||||
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(f"{DIM}{'─'*60}{RESET}\n")
|
||||
participants = data.get("participants", [])
|
||||
if participants:
|
||||
print(f"{BOLD}Participants:{RESET}")
|
||||
# Note: we don't have role details in the event, just names
|
||||
# The engine emits participant names, not full role info.
|
||||
for name in participants:
|
||||
print(f" {name}")
|
||||
print()
|
||||
|
||||
print(f"\n{BOLD}{'='*60}")
|
||||
print(f" DISCUSSION COMPLETE — {len(conversation)} messages")
|
||||
print(f"{'='*60}{RESET}\n")
|
||||
print(f"{BOLD}Rounds:{RESET} {data['rounds']}")
|
||||
print(f"{DIM}{'─' * 60}{RESET}\n")
|
||||
|
||||
return conversation
|
||||
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 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)
|
||||
Reference in New Issue
Block a user