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:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.meeting-room/
|
||||||
|
sessions/
|
||||||
73
KNOWLEDGE.md
Normal file
73
KNOWLEDGE.md
Normal 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
150
README.md
Normal 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
3
meeting_room/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Meeting Room — Multi-Agent Discussion Framework."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
96
meeting_room/api_client.py
Normal file
96
meeting_room/api_client.py
Normal 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
|
||||||
0
meeting_room/backends/__init__.py
Normal file
0
meeting_room/backends/__init__.py
Normal file
0
meeting_room/backends/autogen/__init__.py
Normal file
0
meeting_room/backends/autogen/__init__.py
Normal file
60
meeting_room/backends/autogen/run.py
Normal file
60
meeting_room/backends/autogen/run.py
Normal 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
|
||||||
0
meeting_room/backends/crewai/__init__.py
Normal file
0
meeting_room/backends/crewai/__init__.py
Normal file
53
meeting_room/backends/crewai/run.py
Normal file
53
meeting_room/backends/crewai/run.py
Normal 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)
|
||||||
0
meeting_room/backends/custom/__init__.py
Normal file
0
meeting_room/backends/custom/__init__.py
Normal file
171
meeting_room/backends/custom/run.py
Normal file
171
meeting_room/backends/custom/run.py
Normal 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
343
meeting_room/cli.py
Normal 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()
|
||||||
83
meeting_room/config/config.yaml
Normal file
83
meeting_room/config/config.yaml
Normal 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: "."
|
||||||
60
meeting_room/scenario_loader.py
Normal file
60
meeting_room/scenario_loader.py
Normal 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
|
||||||
30
meeting_room/scenarios/example-saas.md
Normal file
30
meeting_room/scenarios/example-saas.md
Normal 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
311
meeting_room/tools.py
Normal 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
29
pyproject.toml
Normal 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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
httpx>=0.27
|
||||||
|
pyyaml>=6.0
|
||||||
Reference in New Issue
Block a user