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