From 99cb5a499bc5b5e69c099d65bea5d6f24c1cfaf2 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 3 May 2026 08:07:35 +0000 Subject: [PATCH] Initial commit: Meeting Room v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent discussion framework: - Custom/AutoGen/CrewAI backends - Multi-provider API (role→model binding) - Tools system (files, web, commands) - Markdown scenarios with YAML frontmatter - Workspace init (meeting-room init) - Session save (--save) --- .gitignore | 9 + KNOWLEDGE.md | 73 +++++ README.md | 150 ++++++++++ meeting_room/__init__.py | 3 + meeting_room/api_client.py | 96 ++++++ meeting_room/backends/__init__.py | 0 meeting_room/backends/autogen/__init__.py | 0 meeting_room/backends/autogen/run.py | 60 ++++ meeting_room/backends/crewai/__init__.py | 0 meeting_room/backends/crewai/run.py | 53 ++++ meeting_room/backends/custom/__init__.py | 0 meeting_room/backends/custom/run.py | 171 +++++++++++ meeting_room/cli.py | 343 ++++++++++++++++++++++ meeting_room/config/config.yaml | 83 ++++++ meeting_room/scenario_loader.py | 60 ++++ meeting_room/scenarios/example-saas.md | 30 ++ meeting_room/tools.py | 311 ++++++++++++++++++++ pyproject.toml | 29 ++ requirements.txt | 2 + 19 files changed, 1473 insertions(+) create mode 100644 .gitignore create mode 100644 KNOWLEDGE.md create mode 100644 README.md create mode 100644 meeting_room/__init__.py create mode 100644 meeting_room/api_client.py create mode 100644 meeting_room/backends/__init__.py create mode 100644 meeting_room/backends/autogen/__init__.py create mode 100644 meeting_room/backends/autogen/run.py create mode 100644 meeting_room/backends/crewai/__init__.py create mode 100644 meeting_room/backends/crewai/run.py create mode 100644 meeting_room/backends/custom/__init__.py create mode 100644 meeting_room/backends/custom/run.py create mode 100644 meeting_room/cli.py create mode 100644 meeting_room/config/config.yaml create mode 100644 meeting_room/scenario_loader.py create mode 100644 meeting_room/scenarios/example-saas.md create mode 100644 meeting_room/tools.py create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ad0436 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.venv/ +*.egg-info/ +dist/ +build/ +.env +.meeting-room/ +sessions/ diff --git a/KNOWLEDGE.md b/KNOWLEDGE.md new file mode 100644 index 0000000..d6d24db --- /dev/null +++ b/KNOWLEDGE.md @@ -0,0 +1,73 @@ +# Meeting Room — Архитектура и решения + +## Концепция + +Meeting-room — это CLI-инструмент (как git, terraform). Устанавливается глобально, +работает в рабочей директории `.meeting-room/`. + +``` +projects/ +├── meeting-room/ ← исходники пакета (pip install -e .) +├── .meeting-room/ ← рабочая директория обсуждений (git-репа) +│ ├── config.yaml ← конфиг, переопределяет дефолтный +│ ├── scenarios/ ← сценарии .md +│ ├── sessions/ ← результаты (json, gitignored) +│ └── .gitignore +└── my-project/ ← любой проект, можно указать как workdir +``` + +## Принятые решения + +### Сценарии — Markdown с YAML frontmatter +YAML неудобен для многострочного текста. Markdown естественно поддерживает +заголовки, списки, код, выделение. Frontmatter — для метаданных (участники, раунды). + +### Multi-provider архитектура +Каждая роль привязана к конкретному API-провайдеру и модели через `provider` + `model`. +Позволяет смешивать модели: GPT-4o, Claude, Qwen, Gemini в одном обсуждении. + +### API-ключи через env-переменные +Формат: `MEETING_ROOM__API_KEY`, `MEETING_ROOM__BASE_URL`. +В config.yaml можно `${ENV_VAR}` плейсхолдеры. Ключи не утекают в гит. + +### Система инструментов (tools) +OpenAI function calling: read_file, list_files, search_in_files, write_file, +web_search, web_fetch, run_command. Привязаны к ролям через наборы: +all, files, readonly, web, none. + +### Три бэкенда +- custom — свой Python, round-robin с tool support, из коробки +- autogen — Microsoft AutoGen +- crewai — CrewAI + +### Workspace (.meeting-room) +`meeting-room init` создаёт структуру в CWD. CLI ищет config.yaml: +1. CLI --config arg +2. .meeting-room/config.yaml (поиск от CWD вверх) +3. Встроенный package config + +### Сохранение результатов +`--save` — JSON файл в sessions/ с таймстемпом, именем сценария, полной историей. + +## Структура пакета + +``` +meeting_room/ +├── __init__.py +├── cli.py — CLI (init, run, list, save) +├── api_client.py — мультипровайдерный OpenAI-совместимый клиент +├── tools.py — инструменты (файлы, веб, shell) +├── scenario_loader.py — загрузчик .md/.yaml +├── config/ +│ └── config.yaml — дефолтный конфиг (без ключей) +├── scenarios/ +│ └── example-saas.md — встроенный пример +└── backends/ + ├── custom/ + ├── autogen/ + └── crewai/ +``` + +## Gitea +Репозиторий: git.kzntsv.site/OpeItcLoc03/meeting-room +Токен: env GITEA_TOKEN, без admin-грантов. diff --git a/README.md b/README.md new file mode 100644 index 0000000..93655b2 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# Meeting Room + +Multi-agent discussion framework — role-based AI agents debate problems and produce solutions. + +## Install + +```bash +git clone meeting-room +cd meeting-room +pip install -e . + +# Optional backends +pip install -e ".[autogen]" +pip install -e ".[crewai]" +``` + +## Quick Start + +```bash +# Initialize workspace (creates .meeting-room/ in current directory) +meeting-room init + +# Create a scenario +cat > .meeting-room/scenarios/my-problem.md << 'EOF' +--- +name: "My Problem" +participants: + - moderator + - skeptic + - idea_generator + - analyst +max_rounds: 6 +--- + +# Problem Title + +Describe the problem in Markdown... +EOF + +# Run discussion +cd .meeting-room +meeting-room -s scenarios/my-problem.md + +# Run and save result +meeting-room -s scenarios/my-problem.md --save + +# Point agents at a project for file tools +meeting-room -w /path/to/project -s scenarios/code-review.md +``` + +## Workspace Structure + +``` +.meeting-room/ +├── config.yaml — override default config (roles, providers, tools) +├── scenarios/ — your .md scenario files +│ └── example.md +├── sessions/ — saved discussion results (gitignored) +└── .gitignore +``` + +## Commands + +```bash +meeting-room init [path] # Create .meeting-room workspace +meeting-room -s scenarios/my.md # Run discussion +meeting-room -s scenarios/my.md --save # Run and save result +meeting-room -b autogen -s scenarios/my.md # Use AutoGen backend +meeting-room -b crewai -s scenarios/my.md # Use CrewAI backend +meeting-room --list-scenarios # Show available scenarios +meeting-room --list-roles # Show roles and model bindings +meeting-room -c /path/to/config.yaml -s ... # Custom config file +``` + +## Configuration + +### Config Search Order +1. `--config` CLI argument +2. `.meeting-room/config.yaml` (searched from CWD upward) +3. Built-in package defaults + +### API Keys + +Set via environment variables: + +```bash +export MEETING_ROOM_ROUTERAI_API_KEY="sk-..." +export MEETING_ROOM_ROUTERAI_BASE_URL="https://routerai.ru/api/v1" +export MEETING_ROOM_OPENAI_API_KEY="sk-..." +export MEETING_ROOM_ANTHROPIC_API_KEY="sk-ant-..." +``` + +Or use `${ENV_VAR}` placeholders in config.yaml. + +### Roles + +Each role binds to a provider + model + tool set: + +```yaml +roles: + analyst: + name: "Analyst" + provider: anthropic + model: "claude-sonnet-4" + temperature: 0.5 + tools: "all" +``` + +### Tool Sets + +| Set | Tools | +|-----------|--------------------------------------------------------| +| all/full | Everything (files + web + commands) | +| files | read_file, list_files, search_in_files, write_file | +| readonly | read_file, list_files, search_in_files | +| web | web_search, web_fetch | +| none | No tools, text only | +| (list) | `["read_file", "web_search"]` — custom mix | + +### Scenarios + +Markdown with YAML frontmatter: + +```markdown +--- +name: "My Problem" +participants: + - moderator + - skeptic + - idea_generator + - analyst +max_rounds: 6 +--- + +# The Problem + +Full Markdown description... + +## Context +- Budget: $50K +- Team: 3 people + +## Questions +1. What tech stack? +2. How to scale? +``` + +## License + +MIT diff --git a/meeting_room/__init__.py b/meeting_room/__init__.py new file mode 100644 index 0000000..02ae257 --- /dev/null +++ b/meeting_room/__init__.py @@ -0,0 +1,3 @@ +"""Meeting Room — Multi-Agent Discussion Framework.""" + +__version__ = "0.1.0" diff --git a/meeting_room/api_client.py b/meeting_room/api_client.py new file mode 100644 index 0000000..207a146 --- /dev/null +++ b/meeting_room/api_client.py @@ -0,0 +1,96 @@ +"""Universal API client supporting multiple OpenAI-compatible providers.""" + +import httpx +import json + + +PROVIDERS = {} + + +def init_providers(config: dict): + global PROVIDERS + PROVIDERS = config.get("providers", {}) + + +def _get_client(provider_name: str) -> httpx.Client: + p = PROVIDERS.get(provider_name) + if not p: + raise ValueError(f"Unknown provider: {provider_name}") + return httpx.Client( + base_url=p["base_url"], + headers={"Authorization": f"Bearer {p['api_key']}"}, + timeout=120.0, + ) + + +def chat( + provider: str, + model: str, + messages: list[dict], + temperature: float = 0.7, + tools: list[dict] | None = None, +) -> dict: + client = _get_client(provider) + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + } + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" + + resp = client.post("/chat/completions", json=payload) + resp.raise_for_status() + data = resp.json() + message = data["choices"][0]["message"] + + result = { + "content": message.get("content") or "", + "tool_calls": None, + } + + if message.get("tool_calls"): + result["tool_calls"] = [] + for tc in message["tool_calls"]: + result["tool_calls"].append({ + "id": tc["id"], + "name": tc["function"]["name"], + "arguments": tc["function"]["arguments"], + }) + + return result + + +def chat_stream( + provider: str, + model: str, + messages: list[dict], + temperature: float = 0.7, +) -> str: + client = _get_client(provider) + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "stream": True, + } + full_text = "" + with client.stream("POST", "/chat/completions", json=payload) as resp: + resp.raise_for_status() + for line in resp.iter_lines(): + if not line or not line.startswith("data: "): + continue + data = line[6:] + if data == "[DONE]": + break + try: + chunk = json.loads(data) + delta = chunk["choices"][0]["delta"] + if "content" in delta and delta["content"]: + print(delta["content"], end="", flush=True) + full_text += delta["content"] + except (json.JSONDecodeError, KeyError, IndexError): + continue + print() + return full_text diff --git a/meeting_room/backends/__init__.py b/meeting_room/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meeting_room/backends/autogen/__init__.py b/meeting_room/backends/autogen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meeting_room/backends/autogen/run.py b/meeting_room/backends/autogen/run.py new file mode 100644 index 0000000..0f60ab2 --- /dev/null +++ b/meeting_room/backends/autogen/run.py @@ -0,0 +1,60 @@ +"""AutoGen backend — uses microsoft/autogen for multi-agent conversation.""" + + +def check_installed(): + try: + import autogen # noqa + return True + except ImportError: + return False + + +def run(scenario_path: str, config: dict): + if not check_installed(): + print("AutoGen is not installed. Run:\n pip install meeting-room[autogen]") + return [] + + import yaml + import autogen + from meeting_room.api_client import init_providers + from meeting_room.scenario_loader import load_scenario + + scenario = load_scenario(scenario_path) + init_providers(config) + roles = config["roles"] + participants = scenario.get("participants", list(roles.keys())) + + llm_configs = {} + for prov_name, prov_cfg in config["providers"].items(): + llm_configs[prov_name] = { + "base_url": prov_cfg["base_url"], + "api_key": prov_cfg["api_key"], + } + + agents = [] + for role_id in participants: + role = roles[role_id] + prov = role["provider"] + llm_cfg = dict(llm_configs[prov]) + llm_cfg["model"] = role["model"] + + agent = autogen.ConversableAgent( + name=role["name"], + system_message=role["system_prompt"].strip(), + llm_config={"config_list": [llm_cfg], "temperature": role.get("temperature", 0.7)}, + human_input_mode="NEVER", + ) + agents.append(agent) + + groupchat = autogen.GroupChat( + agents=agents, + messages=[], + max_round=scenario.get("max_rounds", config["defaults"]["max_rounds"]), + ) + manager = autogen.GroupChatManager( + groupchat=groupchat, + llm_config={"config_list": [llm_configs[participants[0]]]}, + ) + + agents[0].initiate_chat(manager, message=scenario["problem"]) + return groupchat.messages diff --git a/meeting_room/backends/crewai/__init__.py b/meeting_room/backends/crewai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meeting_room/backends/crewai/run.py b/meeting_room/backends/crewai/run.py new file mode 100644 index 0000000..91f00d3 --- /dev/null +++ b/meeting_room/backends/crewai/run.py @@ -0,0 +1,53 @@ +"""CrewAI backend — uses crewai for task-oriented multi-agent discussion.""" + + +def check_installed(): + try: + import crewai # noqa + return True + except ImportError: + return False + + +def run(scenario_path: str, config: dict): + if not check_installed(): + print("CrewAI is not installed. Run:\n pip install meeting-room[crewai]") + return [] + + import yaml + from crewai import Agent, Task, Crew, Process + from meeting_room.scenario_loader import load_scenario + + scenario = load_scenario(scenario_path) + roles = config["roles"] + participants = scenario.get("participants", list(roles.keys())) + + agents = [] + for role_id in participants: + role = roles[role_id] + agent = Agent( + role=role["name"], + goal=role["system_prompt"].split(".")[0] + ".", + backstory=role["system_prompt"].strip(), + llm=f"{role['provider']}/{role['model']}", + verbose=True, + ) + agents.append(agent) + + discuss_task = Task( + description=f"Discuss this problem as a group:\n\n{scenario['problem']}", + agent=agents[0], + expected_output="Final solution with arguments from each participant", + ) + + crew = Crew( + agents=agents, + tasks=[discuss_task], + process=Process.sequential, + verbose=True, + ) + + result = crew.kickoff() + print(f"\n{'='*60}\nCREWAI RESULT:\n{'='*60}\n") + print(result) + return str(result) diff --git a/meeting_room/backends/custom/__init__.py b/meeting_room/backends/custom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meeting_room/backends/custom/run.py b/meeting_room/backends/custom/run.py new file mode 100644 index 0000000..c2d5b65 --- /dev/null +++ b/meeting_room/backends/custom/run.py @@ -0,0 +1,171 @@ +"""Custom backend — round-robin agent discussion with tool support.""" + +import json +import os +import sys + +import yaml + +from meeting_room.api_client import chat, chat_stream, init_providers +from meeting_room.scenario_loader import load_scenario +from meeting_room.tools import set_workdir, get_tool_schemas, execute_tool + +# 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" + +MAX_TOOL_ITERATIONS = 5 + + +def _format_conversation(conversation: list[dict]) -> str: + return "\n\n".join(f"[{msg['name']}]: {msg['content']}" for msg in conversation) + + +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}) + print() + + print(f"{DIM}{'─'*60}{RESET}\n") + + print(f"\n{BOLD}{'='*60}") + print(f" DISCUSSION COMPLETE — {len(conversation)} messages") + print(f"{'='*60}{RESET}\n") + + return conversation diff --git a/meeting_room/cli.py b/meeting_room/cli.py new file mode 100644 index 0000000..56b17e4 --- /dev/null +++ b/meeting_room/cli.py @@ -0,0 +1,343 @@ +"""Meeting Room CLI — main entry point.""" + +import sys +import os +import json +import argparse +import yaml +from datetime import datetime + +from meeting_room.scenario_loader import load_scenario, list_scenarios as _list_scenarios + +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.""" + 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 + + +def list_scenarios(scenarios_dir: str | None = None): + if scenarios_dir is None: + scenarios_dir = os.path.join(PACKAGE_DIR, "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): + 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.""" + 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 + + +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 + + +def run_discussion(backend: str, scenario: str, config: dict, save: bool = False): + 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) + if not os.path.exists(scenario_path): + print(f"Scenario not found: {scenario}") + sys.exit(1) + + if backend == "custom": + from meeting_room.backends.custom.run import run + conversation = run(scenario_path, config) + elif backend == "autogen": + from meeting_room.backends.autogen.run import run + conversation = run(scenario_path, config) + elif backend == "crewai": + from meeting_room.backends.crewai.run import run + conversation = run(scenario_path, config) + else: + print(f"Unknown backend: {backend}") + sys.exit(1) + + 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_data = load_scenario(scenario_path) + save_session(conversation, scenario_data.get("name", "discussion"), sessions_dir) + + return conversation + + +def main(): + parser = argparse.ArgumentParser( + description="Meeting Room — Multi-Agent Discussion Framework", + ) + parser.add_argument( + "-b", "--backend", + choices=["custom", "autogen", "crewai"], + default="custom", + help="Discussion framework (default: custom)", + ) + 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.backend, args.scenario, config, save=args.save) + + +if __name__ == "__main__": + main() diff --git a/meeting_room/config/config.yaml b/meeting_room/config/config.yaml new file mode 100644 index 0000000..30a839d --- /dev/null +++ b/meeting_room/config/config.yaml @@ -0,0 +1,83 @@ +# ─── Meeting Room — Multi-Agent Discussion Framework ─── +# +# API keys can be set here OR via environment variables: +# MEETING_ROOM_ROUTERAI_API_KEY=sk-... +# MEETING_ROOM_ROUTERAI_BASE_URL=https://... +# Env vars take priority over this file. + +providers: + routerai: + base_url: "https://routerai.ru/api/v1" + api_key: "${MEETING_ROOM_ROUTERAI_API_KEY}" # set in env or replace here + + # openai: + # base_url: "https://api.openai.com/v1" + # api_key: "${MEETING_ROOM_OPENAI_API_KEY}" + + # anthropic: + # base_url: "https://api.anthropic.com/v1" + # api_key: "${MEETING_ROOM_ANTHROPIC_API_KEY}" + + # ollama: + # base_url: "http://localhost:11434/v1" + # api_key: "ollama" + +# ─── Roles ─── +# tools: all | full | files | readonly | web | none | or list ["read_file", "web_search"] + +roles: + moderator: + name: "Moderator" + provider: routerai + model: "gpt-4o-mini" + temperature: 0.7 + tools: "none" + system_prompt: | + You are the moderator of a discussion. Guide the conversation, + make sure everyone speaks, summarize intermediate results, + ask clarifying questions. Don't push your own opinion. + Keep it concise and on-topic. + + skeptic: + name: "Skeptic" + provider: routerai + model: "gpt-4o-mini" + temperature: 0.9 + tools: "readonly" + system_prompt: | + You are a skeptic and critic. Your job: find weaknesses + in proposals, ask hard questions, offer counter-arguments. + Be constructive but tough. Don't agree easily. + If participants reference files — read them and verify claims. + + idea_generator: + name: "Idea Generator" + provider: routerai + model: "gpt-4o-mini" + temperature: 1.0 + tools: "web" + system_prompt: | + You are a creative idea generator. Propose unconventional + solutions, think broader than the problem, generate many + options. Don't be afraid of crazy ideas — the best solutions + come from them. Build on others' ideas. + Search the web for analogs, trends, and inspiration. + + analyst: + name: "Analyst" + provider: routerai + model: "gpt-4o-mini" + temperature: 0.5 + tools: "all" + system_prompt: | + You are an analyst. Structure the discussion, highlight + key arguments, assess risks and benefits of each option, + summarize. Think logically and systematically. + Propose concrete action plans. + Use tools to verify facts, read project files, and search for info. + +defaults: + max_rounds: 8 + language: "ru" + framework: "custom" + workdir: "." diff --git a/meeting_room/scenario_loader.py b/meeting_room/scenario_loader.py new file mode 100644 index 0000000..4b9d8cd --- /dev/null +++ b/meeting_room/scenario_loader.py @@ -0,0 +1,60 @@ +"""Scenario loader — supports .md (frontmatter + markdown) and .yaml.""" + +import os +import re +import yaml + + +def load_scenario(path: str) -> dict: + with open(path, encoding="utf-8") as f: + content = f.read() + + if path.endswith((".md", ".markdown")): + return _parse_markdown(content) + elif path.endswith((".yaml", ".yml")): + return yaml.safe_load(content) + else: + try: + return _parse_markdown(content) + except Exception: + return yaml.safe_load(content) + + +def _parse_markdown(content: str) -> dict: + match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL) + if not match: + raise ValueError("No YAML frontmatter found. Expected --- at the top.") + + meta = yaml.safe_load(match.group(1)) + body = match.group(2).strip() + meta["problem"] = body + if "name" not in meta: + meta["name"] = "Без названия" + return meta + + +def list_scenarios(scenarios_dir: str) -> list[dict]: + results = [] + if not os.path.isdir(scenarios_dir): + return results + + for fname in sorted(os.listdir(scenarios_dir)): + if not fname.endswith((".md", ".markdown", ".yaml", ".yml")): + continue + path = os.path.join(scenarios_dir, fname) + try: + data = load_scenario(path) + results.append({ + "file": fname, + "path": path, + "name": data.get("name", "Без названия"), + "participants": data.get("participants", []), + }) + except Exception as e: + results.append({ + "file": fname, + "path": path, + "name": f"[Ошибка: {e}]", + "participants": [], + }) + return results diff --git a/meeting_room/scenarios/example-saas.md b/meeting_room/scenarios/example-saas.md new file mode 100644 index 0000000..07103d0 --- /dev/null +++ b/meeting_room/scenarios/example-saas.md @@ -0,0 +1,30 @@ +--- +name: "Архитектура нового проекта" +participants: + - moderator + - skeptic + - idea_generator + - analyst +max_rounds: 8 +--- + +# Архитектура нового SaaS-продукта + +Команда стартапа обсуждает архитектуру для платформы управления проектами с AI-ассистентом. + +**Ограничения:** +- Бюджет ограничен +- Команда из 4 человек + +## Ключевые решения + +1. **Технологический стек** — фронтенд, бэкенд, БД +2. **Монетизация** — подписка, freemium, enterprise? +3. **Масштабирование** — serverless vs контейнеры +4. **Приоритеты MVP** — что в первой версии, что потом? + +## Контекст + +Рынок уже имеет Asana, Notion, Linear. Нужно найти нишу — вероятно, AI-first подход, +где ассистент не просто чат-бот, а активно предлагает действия, предсказывает риски +и автоматизирует рутину. diff --git a/meeting_room/tools.py b/meeting_room/tools.py new file mode 100644 index 0000000..69e1c03 --- /dev/null +++ b/meeting_room/tools.py @@ -0,0 +1,311 @@ +""" +Tools system for Meeting Room agents. +Provides file access and web capabilities via OpenAI function calling. +""" + +import os +import re +import json +import subprocess +import httpx + +WORKDIR = "." + + +def set_workdir(path: str): + global WORKDIR + WORKDIR = os.path.abspath(path) + + +def _resolve_path(path: str) -> str: + if os.path.isabs(path): + return path + return os.path.normpath(os.path.join(WORKDIR, path)) + + +def _truncate(text: str, max_len: int = 5000) -> str: + if len(text) <= max_len: + return text + return text[:max_len] + f"\n... [обрезано, всего {len(text)} символов]" + + +# ─── Tool schemas (OpenAI function calling format) ─── + +TOOL_SCHEMAS = [ + { + "type": "function", + "function": { + "name": "read_file", + "description": "Прочитать содержимое файла. Возвращает текст с номерами строк.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Путь к файлу"}, + "offset": {"type": "integer", "description": "Начать с этой строки (1-indexed)", "default": 1}, + "limit": {"type": "integer", "description": "Максимум строк", "default": 100}, + }, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_files", + "description": "Показать список файлов и директорий.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Директория для просмотра"}, + "pattern": {"type": "string", "description": "Фильтр по имени (glob)"}, + "max_depth": {"type": "integer", "description": "Глубина обхода", "default": 3}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "search_in_files", + "description": "Поиск текста в файлах (regex).", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Поисковый запрос (regex)"}, + "path": {"type": "string", "description": "Директория для поиска"}, + "file_pattern": {"type": "string", "description": "Фильтр по расширению"}, + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "web_search", + "description": "Поиск в интернете. Возвращает заголовки, сниппеты и URL.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Поисковый запрос"}, + "max_results": {"type": "integer", "description": "Максимум результатов", "default": 5}, + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "web_fetch", + "description": "Скачать содержимое веб-страницы.", + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL для загрузки"}, + "max_length": {"type": "integer", "description": "Максимум символов", "default": 5000}, + }, + "required": ["url"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Записать содержимое в файл.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Путь к файлу"}, + "content": {"type": "string", "description": "Содержимое"}, + }, + "required": ["path", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "run_command", + "description": "Выполнить shell-команду.", + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string", "description": "Команда"}, + "timeout": {"type": "integer", "description": "Таймаут (сек)", "default": 30}, + }, + "required": ["command"], + }, + }, + }, +] + + +# ─── Implementations ─── + +def tool_read_file(path: str, offset: int = 1, limit: int = 100) -> str: + resolved = _resolve_path(path) + if not os.path.isfile(resolved): + return f"Ошибка: файл не найден — {resolved}" + try: + with open(resolved, encoding="utf-8", errors="replace") as f: + lines = f.readlines() + total = len(lines) + selected = lines[offset - 1 : offset - 1 + limit] + result = "".join(f"{i}|{line}" for i, line in enumerate(selected, start=offset)) + return _truncate(f"[{resolved} — {total} строк]\n{result}") + except Exception as e: + return f"Ошибка чтения: {e}" + + +def tool_list_files(path: str = "", pattern: str = "", max_depth: int = 3) -> str: + base = _resolve_path(path or ".") + if not os.path.isdir(base): + return f"Ошибка: директория не найдена — {base}" + try: + result = subprocess.run( + ["find", base, "-maxdepth", str(max_depth), + "-not", "-path", "*/.git/*", "-not", "-path", "*/.venv/*", + "-not", "-path", "*/node_modules/*", "-not", "-path", "*/__pycache__/*"], + capture_output=True, text=True, timeout=10, + ) + lines = result.stdout.strip().split("\n") + if pattern: + import fnmatch + lines = [l for l in lines if fnmatch.fnmatch(os.path.basename(l), pattern)] + lines.sort() + return _truncate("\n".join(lines)) + except Exception as e: + return f"Ошибка: {e}" + + +def tool_search_in_files(query: str, path: str = "", file_pattern: str = "") -> str: + base = _resolve_path(path or ".") + cmd = ["grep", "-rn", "--color=never", "-E", query, base] + if file_pattern: + cmd.extend(["--include", file_pattern]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + if result.returncode == 1: + return "Совпадений не найдено." + return _truncate(result.stdout.strip()) + except subprocess.TimeoutExpired: + return "Таймаут поиска." + except Exception as e: + return f"Ошибка: {e}" + + +def tool_web_search(query: str, max_results: int = 5) -> str: + try: + client = httpx.Client(timeout=15, follow_redirects=True) + resp = client.get( + "https://html.duckduckgo.com/html/", params={"q": query}, + headers={"User-Agent": "Mozilla/5.0"}, + ) + resp.raise_for_status() + results = [] + for match in re.finditer( + r'\s*(.*?)\s*.*?' + r'(.*?)', + resp.text, re.DOTALL, + ): + url = match.group(1) + title = re.sub(r'<[^>]+>', '', match.group(2)).strip() + snippet = re.sub(r'<[^>]+>', '', match.group(3)).strip() + results.append(f"{title}\n {url}\n {snippet}") + if len(results) >= max_results: + break + if not results: + return "Результатов не найдено." + return _truncate("\n\n".join(f"[{i+1}] {r}" for i, r in enumerate(results))) + except Exception as e: + return f"Ошибка поиска: {e}" + + +def tool_web_fetch(url: str, max_length: int = 5000) -> str: + try: + client = httpx.Client(timeout=20, follow_redirects=True) + resp = client.get(url, headers={"User-Agent": "Mozilla/5.0"}) + resp.raise_for_status() + content_type = resp.headers.get("content-type", "") + if "html" in content_type: + text = re.sub(r']*>.*?', '', resp.text, flags=re.DOTALL) + text = re.sub(r']*>.*?', '', text, flags=re.DOTALL) + text = re.sub(r'<[^>]+>', ' ', text) + text = re.sub(r'\s+', ' ', text).strip() + else: + text = resp.text + return _truncate(f"[{url} — {len(resp.text)} символов]\n{text}", max_length) + except Exception as e: + return f"Ошибка загрузки: {e}" + + +def tool_write_file(path: str, content: str) -> str: + resolved = _resolve_path(path) + try: + os.makedirs(os.path.dirname(resolved), exist_ok=True) + with open(resolved, "w", encoding="utf-8") as f: + f.write(content) + return f"Записано {len(content)} символов в {resolved}" + except Exception as e: + return f"Ошибка записи: {e}" + + +def tool_run_command(command: str, timeout: int = 30) -> str: + try: + result = subprocess.run( + command, shell=True, capture_output=True, text=True, + timeout=timeout, cwd=WORKDIR, + ) + output = result.stdout + if result.stderr: + output += f"\nSTDERR:\n{result.stderr}" + if result.returncode != 0: + output += f"\n[exit code: {result.returncode}]" + return _truncate(output) + except subprocess.TimeoutExpired: + return f"Таймаут ({timeout}с)" + except Exception as e: + return f"Ошибка: {e}" + + +# ─── Dispatcher ─── + +TOOL_FUNCTIONS = { + "read_file": tool_read_file, + "list_files": tool_list_files, + "search_in_files": tool_search_in_files, + "web_search": tool_web_search, + "web_fetch": tool_web_fetch, + "write_file": tool_write_file, + "run_command": tool_run_command, +} + +TOOL_SETS = { + "all": list(TOOL_FUNCTIONS.keys()), + "full": list(TOOL_FUNCTIONS.keys()), + "files": ["read_file", "list_files", "search_in_files", "write_file"], + "readonly": ["read_file", "list_files", "search_in_files"], + "web": ["web_search", "web_fetch"], + "none": [], +} + + +def get_tool_schemas(tool_set: str = "all") -> list[dict]: + if isinstance(tool_set, list): + names = tool_set + else: + names = TOOL_SETS.get(tool_set, TOOL_SETS["all"]) + return [s for s in TOOL_SCHEMAS if s["function"]["name"] in names] + + +def execute_tool(name: str, arguments: dict) -> str: + func = TOOL_FUNCTIONS.get(name) + if not func: + return f"Неизвестный инструмент: {name}" + try: + return func(**arguments) + except Exception as e: + return f"Ошибка выполнения {name}: {e}" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..db7f2ad --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "meeting-room" +version = "0.1.0" +description = "Multi-agent discussion framework — role-based AI agents debate problems and produce solutions" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +dependencies = [ + "httpx>=0.27", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +autogen = ["autogen-agentchat", "autogen-ext"] +crewai = ["crewai"] +all = ["autogen-agentchat", "autogen-ext", "crewai"] + +[project.scripts] +meeting-room = "meeting_room.cli:main" + +[tool.setuptools.packages.find] +include = ["meeting_room*"] + +[tool.setuptools.package-data] +meeting_room = ["config/*.yaml", "scenarios/*.md", "scenarios/*.yaml"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..21b8f47 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +httpx>=0.27 +pyyaml>=6.0