Initial commit: Meeting Room v0.1.0

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)
This commit is contained in:
Hermes
2026-05-03 08:07:35 +00:00
commit 99cb5a499b
19 changed files with 1473 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.env
.meeting-room/
sessions/

73
KNOWLEDGE.md Normal file
View File

@@ -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_<PROVIDER>_API_KEY`, `MEETING_ROOM_<PROVIDER>_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-грантов.

150
README.md Normal file
View File

@@ -0,0 +1,150 @@
# Meeting Room
Multi-agent discussion framework — role-based AI agents debate problems and produce solutions.
## Install
```bash
git clone <repo-url> 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

3
meeting_room/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Meeting Room — Multi-Agent Discussion Framework."""
__version__ = "0.1.0"

View File

@@ -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

View File

View File

@@ -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

View File

View File

@@ -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)

View File

View File

@@ -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

343
meeting_room/cli.py Normal file
View File

@@ -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()

View File

@@ -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: "."

View File

@@ -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

View File

@@ -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 подход,
где ассистент не просто чат-бот, а активно предлагает действия, предсказывает риски
и автоматизирует рутину.

311
meeting_room/tools.py Normal file
View File

@@ -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'<a rel="nofollow" class="result__a" href="([^"]+)">\s*(.*?)\s*</a>.*?'
r'<a class="result__snippet".*?>(.*?)</a>',
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'<script[^>]*>.*?</script>', '', resp.text, flags=re.DOTALL)
text = re.sub(r'<style[^>]*>.*?</style>', '', 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}"

29
pyproject.toml Normal file
View File

@@ -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"]

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
httpx>=0.27
pyyaml>=6.0