Compare commits

...

22 Commits

Author SHA1 Message Date
49e0ce5290 meta(tasks): update [secrets-path-off-by-one] in OpeItcLoc03/meeting-room 2026-06-11 09:58:19 +00:00
1aa70a00d3 meta(tasks): claim [secrets-path-off-by-one] in OpeItcLoc03/meeting-room by DESKTOP-NSEF0UK:claude-sonnet:26920 2026-06-11 09:58:18 +00:00
8215d8af6e meta(tasks): update [bootstrap-upgrade-canonical-triggers] in meeting-room 2026-05-07 07:45:17 +00:00
cc91de04a0 meta(tasks): update [bootstrap-upgrade-canonical-triggers] in meeting-room 2026-05-07 07:32:39 +00:00
ccc170735c meta(tasks): create [bootstrap-upgrade-canonical-triggers] in meeting-room 2026-05-07 06:57:22 +00:00
b57a1529e8 meta(tasks): create [secrets-path-off-by-one] in meeting-room 2026-05-06 22:11:54 +00:00
afc43668fe chore: update task board — Phase 2 + code review complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 09:16:10 +03:00
f0500502a7 fix: code review — critical and important fixes [v0.2.1]
- inject_message now uses session's event_bus (BOSS_TURN reaches WebSocket/SSE)
- asyncio.get_running_loop() replaces deprecated get_event_loop()
- API keys masked in /api/providers response
- ServerSession (renamed from Session) with cleanup() for handler teardown
- Path traversal protection in _resolve_scenario_path
- XSS fix: escapeHtml(d.name) in web UI
- Duplicate imports removed, os/json moved to module level
- WebSocket handler restructured for reliable cleanup
- New tests: cleanup, provider masking, path traversal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 09:15:24 +03:00
ff5ee2879a docs: update overview — Phase 2 complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 08:51:16 +03:00
1dffaae822 feat: Phase 2 — web server, REST API, WebSocket/SSE, UI [v0.2.0]
AsyncEventBridge bridges sync EventEmitter to asyncio consumers.
FastAPI app with session management, discussion control, roles/providers.
WebSocket for real-time events + inject, SSE for read-only streaming.
Minimal dark-theme HTML/JS UI. `meeting-room serve` CLI subcommand.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 08:50:04 +03:00
069462953b chore: add tests/__init__.py for pytest discovery
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 08:13:22 +03:00
ae7fb35d4b fix: Windows unicode output and config model names
- Force UTF-8 stdout/stderr on Windows to handle LLM unicode output
- Replace ─ (U+2500) with - for Windows cp1251 compatibility
- Sanitize em-dash in session filenames
- Update default model to glm-5.1 in all roles

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 07:14:34 +03:00
962b0beddd docs: update README, wiki overview, index, log; add roles/code-review/pydantic wiki pages
- README: remove autogen/crewai references, add v2 architecture diagram,
  code-review scenario, cross-LLM config examples, roadmap
- wiki/overview: event-driven architecture, Pydantic v2, roadmap
- wiki/index: add entities/roles, concepts/code-review, packages/pydantic
- wiki/log: Phase 1 completion, code-review decision
- New: entities/roles.md, concepts/code-review.md, packages/pydantic.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 06:30:33 +03:00
2887b2309a feat: add code-review scenario and cross-LLM review roles
- scenarios/code-review.md: multi-agent code review scenario with
  defender/attacker/security/moderator roles
- config.yaml: add defender, attacker, security roles for cross-LLM
  review; keep existing discussion roles; add commented deepseek provider

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 00:42:04 +03:00
96b6f061f3 chore: update task board — Phase 1 complete
All 7 Phase 1 tasks done:
- deps: pydantic, pytest, [web] optional deps
- models: Pydantic v2 data models
- events: EventEmitter + event types
- api-client: APIClient class refactor
- tools: ToolRegistry class + Windows compat
- engine: DiscussionEngine with event-driven architecture
- config-cli: config.py extraction + CLI refactor

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 00:34:20 +03:00
d0507b3b0c feat: extract config.py, refactor CLI to use event-driven engine, add deprecation shim
- config.py: extracted load_config(), find_workspace_config(),
  load_discussion_config() from cli.py; fixed Windows root detection
- cli.py: run_discussion() now creates DiscussionEngine + EventEmitter,
  subscribes to events with ANSI-color output (identical to before),
  delegates to engine.run()
- backends/custom/run.py: deprecation shim with DeprecationWarning,
  delegates to DiscussionEngine
- 16 new config tests, all 184 tests passing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 00:33:07 +03:00
858618b326 feat: add DiscussionEngine with event-driven architecture
- engine.py: DiscussionEngine class with run() and inject_message()
- All print() calls replaced with EventEmitter.emit()
- Event mapping: DISCUSSION_START, ROUND_START, AGENT_TURN_START,
  AGENT_MESSAGE, TOOL_CALL, TOOL_RESULT, AGENT_ERROR, ROUND_END,
  DISCUSSION_END, BOSS_TURN
- Accepts typed dependencies (APIClient, ToolRegistry, EventEmitter)
- 27 engine tests, all 168 total tests passing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 00:19:32 +03:00
6bbbefaa6f feat: refactor APIClient and ToolRegistry to classes
- api_client.py: APIClient class with ProviderConfig types,
  chat_stream() returns str instead of printing,
  module-level wrappers for backward compat
- tools.py: ToolRegistry class with instance methods,
  pure Python list_files (os.walk) and search_in_files (re.search)
  replacing find/grep for Windows compat,
  English tool descriptions and error messages,
  module-level wrappers for backward compat
- 74 new tests (21 api_client + 53 tools)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 00:09:41 +03:00
d292d32afa feat: add Pydantic models and EventEmitter for v2 core
- models.py: ProviderConfig, RoleConfig, DiscussionConfig, ScenarioConfig,
  Message, ToolCallRecord, Session — compatible with config.yaml format
- events.py: Event constants, Event dataclass, EventEmitter with on/off/emit
- 67 tests passing (37 models + 30 events)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 00:00:21 +03:00
1064276e81 feat: add pydantic, pytest, and optional web dependencies
Add pydantic>=2.0 to main dependencies for typed models.
Add [dev] optional deps (pytest, pytest-mock) for testing.
Add [web] optional deps (fastapi, uvicorn, websockets) for Phase 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 23:52:55 +03:00
52e77bb50c feat: v2 plan — remove autogen/crewai, add tasks and wiki
- Remove autogen and crewai backends (focus on custom only)
- Simplify CLI: remove --backend flag
- Remove optional deps from pyproject.toml
- Add .tasks/ with 7 tasks for Phase 1 refactor
- Add .wiki/concepts/meeting-room-v2.md with architecture plan
- Update .wiki/index.md and .tasks/STATUS.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 23:42:39 +03:00
5ed79a2e95 chore: bootstrap project structure
- .wiki/ with canonical Karpathy layout (CLAUDE.md, index, log, overview, raw)
- .tasks/ with canonical STATUS.md board
- CLAUDE.md with skill triggers
- Bootstrap manifest in .wiki/concepts/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 23:12:18 +03:00
54 changed files with 6049 additions and 653 deletions

102
.tasks/STATUS.md Normal file
View File

@@ -0,0 +1,102 @@
# Task Board
_Updated: 2026-05-04_
## 🟢 v2-p2-async-bridge — AsyncEventBridge: sync EventEmitter → asyncio.Queue
**Status:** done
**Where I stopped:** Complete
---
## 🟢 v2-p2-fastapi-app — FastAPI app + REST API (sessions, start, inject, roles, providers)
**Status:** done
**Where I stopped:** Complete
---
## 🟢 v2-p2-websocket-sse — WebSocket + SSE streaming endpoints
**Status:** done
**Where I stopped:** Complete
---
## 🟢 v2-p2-web-ui — Minimal HTML/CSS/JS frontend
**Status:** done
**Where I stopped:** Complete
---
## 🟢 v2-p2-serve-command — `meeting-room serve` CLI subcommand
**Status:** done
**Where I stopped:** Complete
---
## 🟢 code-review-p2 — Code review fixes after Phase 2
**Status:** done
**Where I stopped:** All critical/important issues fixed
**Next action:** N/A
---
## 🟢 v2-p1-deps — Обновить зависимости (pydantic, pytest, [web])
**Status:** done
---
## 🟢 v2-p1-models — Pydantic модели данных
**Status:** done
---
## 🟢 v2-p1-events — EventEmitter и типы событий
**Status:** done
---
## 🟢 v2-p1-api-client — Рефактор APIClient
**Status:** done
---
## 🟢 v2-p1-tools — Рефактор ToolRegistry
**Status:** done
---
## 🟢 v2-p1-engine — DiscussionEngine
**Status:** done
---
## 🟢 v2-p1-config-cli — config.py + CLI рефактор
**Status:** done
---
<!--
Фаза 2 завершена + код-ревью пройден. Все таски выполнены.
-->
## 🔵 [secrets-path-off-by-one] — Bug в `meeting_room/config.py:35` — off-by-one в количестве вызовов `os.path.dirname()`. `PACKAGE_DIR` = `~\projects\meeting-room\meeting_room` (resolved через `os.path.abspath(__file__)` в строке 17). Текущий код: `_common_root = os.path.dirname(os.path.dirname(os.path.dirname(PACKAGE_DIR)))` — 3 вызова — резолвит в `~\` (user home), а должно быть в `~\projects\`. Соответственно `_secrets_path = os.path.join(_common_root, ".common", "secrets", "interns.env")` указывает на `~\.common\secrets\interns.env`, который **не существует**. Реальный файл: `~\projects\.common\secrets\interns.env` (создан в рамках `[unify-llm-secrets]`, закрыт 2026-05-05). Эффект: `load_dotenv(_secrets_path)` молча возвращает False, `OLLAMA_CLOUD_API_KEY` env-var НЕ загружается, regex-substitution в `_env_replace` (config.py:46-50) оставляет литеральную строку `${OLLAMA_CLOUD_API_KEY}` в `providers.ollama_cloud.api_key`, OpenAI-client дёргает Ollama с этой строкой как Bearer-токеном → 401. Тесты не ловят: `tests/test_config.py:107,114,242` используют `monkeypatch.setenv()` — путь до secrets-файла не задействуется. Surfaced код-ревью 2026-05-07. Fix: либо `dirname` дважды (`os.path.dirname(os.path.dirname(PACKAGE_DIR))` → `~\projects\`), либо паритет с `interns-mcp/server.py:17`: `Path(__file__).resolve().parents[2]`. Acceptance: убрать env-var из shell, прогнать `python -c "from meeting_room.config import load_config; print(load_config()['providers']['ollama_cloud']['api_key'][:10])"` — должен вернуть начало реального ключа `02a8f6e9f9...`, а не `${OLLAMA_CLOU`. Добавить regression-тест: монкипатч-PACKAGE_DIR и ассерт что resolved path попадает в `<projects>/.common/secrets/interns.env`. Регрессия impacted closure: [unify-llm-secrets] в `common/.tasks/STATUS.md` (closed 2026-05-05) — claim'ил «meeting-room config.py → load_dotenv()» но fixture-only-tested.
**Status:** blocked
**Where I stopped:** workspace: working tree dirty
**Next action:** Открыть `~\projects\meeting-room\meeting_room\config.py:35`. Заменить тройной `os.path.dirname(...)` на двойной (или `Path(__file__).resolve().parents[2]`). Добавить regression-тест в `tests/test_config.py` который реально читает env-var из временного `.common/secrets/interns.env` через `monkeypatch.chdir` + временный layout. Пуш PATCH bump в `meeting-room/pyproject.toml`.
**Branch:** n/a
**Owner:** DESKTOP-NSEF0UK:claude-sonnet:26920
**Claim token:** 3d037bf6-2627-4231-94b5-40cea04abae4
**Claim expires at:** 2026-06-11T10:08:16.554Z
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .meeting-room / 2026-05-06T22:11:53.904Z -->
---
## ⚪ [bootstrap-upgrade-canonical-triggers] — Прогнать `project-bootstrap` v1.10.0 в upgrade-режиме на этом проекте. Цель — дописать в `CLAUDE.md` 3 недостающих канонических триггера: `follow tdd-criteria`, `delegate to interns when allowed`, `recommend, don't menu`. Уже присутствуют: `pull remote before work`, `follow project discipline`, `we're on Windows`. ВНИМАНИЕ: это `meeting-room` (без точки) — обычный код-проект, не путать с `.meeting-room` workspace-комнатой (которая в раскатку не входит). Аудит 2026-05-07 через `bulk_text_read`. Контекст — координатор `[bootstrap-rollout-canonical-triggers]` в `_meta` (commit dd933fd). Пилот на `books` подтвердил идемпотентность (158fc95).
**Status:** ready
**Where I stopped:** Unblocked 2026-05-07 после успешного прогона `pilonuxt` (commit `e5d45f9`: полный bootstrap — CLAUDE.md + `.wiki/concepts/bootstrap-manifest.md`, скил отработал корректно). 3-строчная группа разблокирована.
**Next action:** 1) `upgrade project` в `~/projects/meeting-room/` (без точки — обычная репа, не путать с `.meeting-room`). **Запустить скил `project-bootstrap` v1.10.0** — не делать ручной Edit (incident на `pilonuxt` 2026-05-07). 2) Подтвердить план: 2 файла — `CLAUDE.md` (+3: `follow tdd-criteria`, `delegate to interns when allowed`, `recommend, don't menu`) + `.wiki/concepts/bootstrap-manifest.md` (manifest 1.x → 1.10.0). 3) Прогнать. 4) **Verify:** `git diff --stat` показывает 2 файла. Если 1 — переоткрыть. Reference: `books@5ca16d9`. 5) Коммит `chore(bootstrap): upgrade CLAUDE.md — add tdd-criteria, interns, recommend-dont-menu triggers` + body. 6) Повторный — no-op. 7) `tasks_close`.
**Blocker:** bootstrap-rollout-canonical-triggers
**Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: meeting-room / 2026-05-07T06:57:21.673Z -->
---

View File

@@ -0,0 +1,25 @@
# v2-p1-api-client
## Goal
Рефактор api_client.py: класс APIClient вместо модульных глобалов, chat_stream() возвращает текст вместо печати, обратная совместимость через обёртки.
## Key files
- `meeting_room/api_client.py` — изменить
- `tests/test_api_client.py` — создать
## Decisions log
- 2026-05-03: Обратная совместимость через module-level функции-делегаты
## Open questions
- [ ]
## Completed steps
- [ ] Создать класс APIClient с __init__(providers: dict[str, ProviderConfig])
- [ ] Перенести chat() и chat_stream() как instance methods
- [ ] chat_stream() возвращает str, не печатает
- [ ] Добавить module-level init_providers(), chat(), chat_stream() как обёртки над default instance
- [ ] Написать тесты с mocked httpx
- [ ] Запустить тесты
## Notes
Критическое изменение: chat_stream() должен возвращать текст, а не печатать в stdout. Печать — ответственность вызывающего (CLI или WebSocket).

View File

@@ -0,0 +1,26 @@
# v2-p1-config-cli
## Goal
Извлечь config.py из cli.py, реорганизовать CLI как подписчик событий, добавить deprecation shim в backends/custom/.
## Key files
- `meeting_room/config.py` — создать (извлечь из cli.py)
- `meeting_room/cli.py` — изменить (тонкая обёртка, подписка на события)
- `meeting_room/backends/custom/run.py` — изменить (deprecation shim)
## Decisions log
- 2026-05-03: CLI не меняет пользовательский интерфейс — те же флаги, тот же вывод
## Open questions
- [ ]
## Completed steps
- [ ] Создать config.py: load_config(), find_workspace_config(), load_discussion_config()
- [ ] Рефакторить cli.py: создать DiscussionEngine, подписаться на события с ANSI-цветами
- [ ] Сохранить init_workspace, save_session, list_scenarios, list_roles в cli.py
- [ ] backends/custom/run.py → deprecation warning + legacy wrapper
- [ ] Написать integration test (CLI с mock API, тот же формат вывода)
- [ ] Запустить все тесты
## Notes
CLI должен работать идентично текущему. Это главное требование Фазы 1 — бесшовная совместимость.

25
.tasks/v2-p1-deps.md Normal file
View File

@@ -0,0 +1,25 @@
# v2-p1-deps
## Goal
Обновить зависимости: добавить pydantic, pytest, web-опциональные.
## Key files
- `pyproject.toml` — изменить
- `requirements.txt` — изменить
## Decisions log
- 2026-05-03: pydantic в основных зависимостях, fastapi/uvicorn — опционально
## Open questions
- [ ]
## Completed steps
- [ ] Добавить pydantic>=2.0 в dependencies
- [ ] Добавить [web] optional deps: fastapi, uvicorn, websockets
- [ ] Добавить [dev] optional deps: pytest, pytest-mock
- [ ] Обновить requirements.txt
- [ ] Запустить pip install -e ".[dev]" и проверить импорт
- [ ] Закоммитить
## Notes
FastAPI добавляется в Фазе 2, но опциональная группа [web] создаётся уже сейчас.

25
.tasks/v2-p1-engine.md Normal file
View File

@@ -0,0 +1,25 @@
# v2-p1-engine
## Goal
Извлечь DiscussionEngine из backends/custom/run.py. Заменить print() на events.emit(). Добавить inject_message() для Boss/CSO.
## Key files
- `meeting_room/engine.py` — создать
- `tests/test_engine.py` — создать
## Decisions log
- 2026-05-03: Движок emit'ит события, потребитель подписывается
## Open questions
- [ ]
## Completed steps
- [ ] Создать DiscussionEngine с __init__(config, api_client, tool_registry, event_emitter)
- [ ] Перенести _run_agent_turn() из backends/custom/run.py
- [ ] Заменить все print() на self.events.emit()
- [ ] Добавить inject_message(role_id, content) для Boss/CSO
- [ ] Написать тесты с mock APIClient и ToolRegistry
- [ ] Запустить тесты
## Notes
Движок полностью отделён от I/O. CLI подписывается на события и печатает, WebSocket подписывается и стримит.

24
.tasks/v2-p1-events.md Normal file
View File

@@ -0,0 +1,24 @@
# v2-p1-events
## Goal
Создать EventEmitter и типы событий для событийной архитектуры. Заменяет прямые print() вызовы в движке.
## Key files
- `meeting_room/events.py` — создать
- `tests/test_events.py` — создать
## Decisions log
- 2026-05-03: EventEmitter синхронный в Фазе 1, AsyncEventBridge в Фазе 2
## Open questions
- [ ]
## Completed steps
- [ ] Создать events.py с константами событий (DISCUSSION_START, ROUND_START, AGENT_TURN_START, AGENT_MESSAGE, TOOL_CALL, TOOL_RESULT, AGENT_ERROR, ROUND_END, DISCUSSION_END, BOSS_TURN)
- [ ] Создать Event dataclass с type, data, timestamp
- [ ] Создать EventEmitter с методами on(), off(), emit()
- [ ] Написать тесты: подписка, отписка, эмиссия, множественные обработчики
- [ ] Запустить тесты
## Notes
EventEmitter простой — dict of lists, без asyncio. Асинхронная версия появится в Фазе 2 (ws.py).

22
.tasks/v2-p1-models.md Normal file
View File

@@ -0,0 +1,22 @@
# v2-p1-models
## Goal
Создать Pydantic v2 модели данных для meeting-room: ProviderConfig, RoleConfig, ScenarioConfig, DiscussionConfig, Message, Session. Заменить сырые dict'ы на типизированные модели.
## Key files
- `meeting_room/models.py` — создать
- `tests/test_models.py` — создать
## Decisions log
- 2026-05-03: выбрали Pydantic v2 для валидации и сериализации
## Open questions
- [ ] Стоит ли использовать model_config для разрешения extra fields в config?
## Completed steps
- [ ] Создать models.py с ProviderConfig, RoleConfig, ScenarioConfig, DiscussionConfig, Message, ToolCallRecord, Session
- [ ] Написать тесты: валидация, дефолты, невалидные данные
- [ ] Запустить тесты, убедиться что проходят
## Notes
Модели должны быть совместимы с текущим форматом config.yaml — load_config() возвращает dict, который должен без проблем конвертироваться в DiscussionConfig через DiscussionConfig(**config).

26
.tasks/v2-p1-tools.md Normal file
View File

@@ -0,0 +1,26 @@
# v2-p1-tools
## Goal
Рефактор tools.py: класс ToolRegistry вместо модульных глобалов, замена find/grep на чистый Python, описания инструментов на английском.
## Key files
- `meeting_room/tools.py` — изменить
- `tests/test_tools.py` — создать
## Decisions log
- 2026-05-03: Чистый Python вместо shell-команд для Windows-совместимости
## Open questions
- [ ]
## Completed steps
- [ ] Создать класс ToolRegistry с __init__(workdir: str)
- [ ] Перенести get_tool_schemas() и execute_tool() как instance methods
- [ ] Заменить tool_list_files: subprocess.run(["find",...]) → os.walk() + fnmatch
- [ ] Заменить tool_search_in_files: subprocess.run(["grep",...]) → re.search() по файлам
- [ ] Перевести описания инструментов и ошибки на английский
- [ ] Написать тесты (особенно list_files и search_in_files на Windows)
- [ ] Запустить тесты
## Notes
Это критическое исправление — текущие find/grep не работают на Windows.

View File

@@ -0,0 +1,26 @@
# v2-p2-async-bridge
## Goal
Bridge synchronous EventEmitter to asyncio so DiscussionEngine can run in a thread while WebSocket/SSE consumers receive events asynchronously.
## Key files
- `meeting_room/events.py` — add AsyncEventBridge class
- `meeting_room/engine.py` — no changes (runs in thread as-is)
## Decisions log
- 2026-05-04: AsyncEventBridge + asyncio.to_thread() chosen over async engine rewrite. Minimizes changes, engine stays sync.
## Open questions
- [ ] None
## Completed steps
- [x] AsyncEventBridge class in events.py
- [x] Thread-safe bridge with call_soon_threadsafe
- [x] subscribe/unsubscribe consumer queues
- [x] Drop policy on full queues
- [x] Tests (10/10 passing)
## Notes
- events.py already has comment: "In Phase 2, an AsyncEventBridge will forward events to WebSocket clients"
- Bridge subscribes to sync EventEmitter, puts events into asyncio.Queue
- Consumers (WebSocket handlers, SSE handlers) read from the queue

View File

@@ -0,0 +1,22 @@
# v2-p2-fastapi-app
## Goal
Create FastAPI application with REST API endpoints for session management and discussion control.
## Key files
- `meeting_room/server.py` — FastAPI app, routes, session store
## Decisions log
- 2026-05-04: In-memory session store (dict). Persistent storage deferred.
- 2026-05-04: REST routes under /api prefix, WebSocket/SSE on root
## Open questions
- [ ] None
## Completed steps
- [x] FastAPI app with lifespan handler
- [x] Session + SessionStore models
- [x] REST endpoints: POST /api/sessions, GET /api/sessions, GET /api/sessions/{id}, POST /api/sessions/{id}/start, POST /api/sessions/{id}/inject, GET /api/sessions/{id}/messages
- [x] GET /api/roles, GET /api/providers
- [x] Session start runs engine in asyncio.to_thread
- [x] Tests (12 passing)

View File

@@ -0,0 +1,17 @@
# v2-p2-serve-command
## Goal
Add `meeting-room serve` CLI subcommand that starts the FastAPI web server.
## Key files
- `meeting_room/cli.py` — serve subcommand added
## Decisions log
- 2026-05-04: Default port 8000, configurable via --port. --host and --reload flags.
## Open questions
- [ ] None
## Completed steps
- [x] `meeting-room serve` subcommand with --host, --port, --reload
- [x] Starts uvicorn with the FastAPI app

18
.tasks/v2-p2-web-ui.md Normal file
View File

@@ -0,0 +1,18 @@
# v2-p2-web-ui
## Goal
Minimal HTML/CSS/JS frontend for viewing and interacting with discussions.
## Key files
- `meeting_room/static/index.html` — single-page UI
## Decisions log
- 2026-05-04: Vanilla JS + WebSocket API. No framework.
## Open questions
- [ ] None
## Completed steps
- [x] Dark theme UI with WebSocket event handling
- [x] Start discussion, view messages, inject messages
- [x] Served from /static/index.html, / redirects

View File

@@ -0,0 +1,17 @@
# v2-p2-websocket-sse
## Goal
Stream discussion events to browser clients via WebSocket and SSE endpoints.
## Key files
- `meeting_room/server.py` — WebSocket and SSE route handlers
## Decisions log
- 2026-05-04: WebSocket for bidirectional (events + inject), SSE for read-only
## Open questions
- [ ] None
## Completed steps
- [x] WebSocket endpoint: /ws/{session_id} — bidirectional events + inject
- [x] SSE endpoint: /events/{session_id} — read-only event stream

35
.wiki/CLAUDE.md Normal file
View File

@@ -0,0 +1,35 @@
# Wiki Schema — meeting-room
Project-specific wiki conventions. Read this before any wiki operation.
This wiki follows Karpathy's LLM Wiki pattern:
**https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f**
The `using-wiki` skill enforces the workflow and file formats. This file overrides the skill where they conflict.
## Scope
This wiki is **project-level** — for knowledge specific to the meeting-room project:
- architecture decisions, backend design, UI patterns
- entities like roles, providers, scenario format
- concepts like brainstorm mode, roundtable mode, artifact generation
For cross-project knowledge (org structure, shared conventions) use the global `~/projects/.wiki/`.
## Page types
- `entities/` — discrete things: roles, providers, config schema.
- `concepts/` — recurring ideas, design decisions: brainstorm, roundtable, artifacts.
- `packages/` — code packages this project produces or consumes.
- `sources/` — one summary page per ingested external doc; carries `ingested:` and `raw_path:`.
- `overview.md` — single project-wide overview.
## Naming
- `kebab-case.md`, **Latin only**. Transliterate Cyrillic in filenames; keep the original title in the H1 + frontmatter.
## Domain conventions
- Entities: roles (moderator, skeptic, idea_generator, analyst, boss, cso), providers (routerai, ollama_cloud)
- Concepts: brainstorm (1:1 with CSO), roundtable (multi-agent discussion), setup (pre-discussion config), artifacts (tasks, wiki, decisions)
- Packages: httpx, pyyaml (runtime); textual/fastapi (planned)

View File

@@ -0,0 +1,19 @@
---
title: Bootstrap Manifest
type: concept
updated: 2026-05-03
generator: project-bootstrap@1.5.0
---
# Bootstrap Manifest
Skills used to initialize this project's `.wiki/` and `.tasks/` layout, with their versions at install time.
| Skill | Version | Role |
|---|---|---|
| `project-bootstrap` | 1.5.0 | orchestrator |
| `setup-wiki` | 1.0.0 | wiki canonical layout |
| `setup-tasks` | 1.0.0 | tasks canonical layout |
| `project-discipline` | 0.1.0 | cross-project policy |
This file is overwritten if `project-bootstrap` is re-run on the same project. For history, use `git log .wiki/concepts/bootstrap-manifest.md`.

View File

@@ -0,0 +1,37 @@
---
title: Cross-LLM Code Review
type: concept
updated: 2026-05-04
---
# Cross-LLM Code Review
Use meeting-room to run multi-agent code review where different LLM providers cross-check each other's work.
## How it works
1. Configure multiple providers (OpenAI, Anthropic, DeepSeek, local Ollama)
2. Assign different providers to review roles (defender, attacker, security)
3. Point meeting-room at a project directory with `-w /path/to/project`
4. Agents read code files, debate quality, and produce structured findings
## Scenario
```bash
meeting-room -s code-review.md -w /path/to/project
```
## Why cross-LLM?
Different models have different blind spots. GPT-4o may catch type errors that Claude misses, while DeepSeek may spot performance issues neither other model flagged. Cross-LLM review is the "second pair of eyes" principle applied to AI code review.
## Role separation
- **defender** — explains design decisions, pushes back on unjustified criticism
- **attacker** — hunts bugs, SOLID violations, dead code, edge cases
- **security** — checks injection, auth gaps, CVEs in dependencies
- **moderator** — categorizes findings (CRITICAL / IMPORTANT / MINOR / APPROVED)
## Future (Phase 3)
Boss role will allow injecting questions mid-review. CSO (Claude Code) will be able to join the review via REST API.

View File

@@ -0,0 +1,57 @@
---
title: "Meeting-Room v2: Интерактивный офис"
type: concept
domain: meeting-room
tags: [architecture, refactor, web-ui, artifacts]
updated: 2026-05-03
---
# Meeting-Room v2: Интерактивный офис
> Эволюция CLI-тула в интерактивный многоагентный офис с Web UI, ролями Boss/CSO и генерацией артефактов.
## Проблема
Текущий meeting-room — CLI-утилита с одним режимом: `meeting-room -s scenario.md`. Глобальное состояние, движок смешан с I/O, нет событийной системы, несовместимость с Windows. Не поддерживает интерактивность.
## Решение — 4 фазы
### Фаза 1: Рефактор ядра
- Pydantic модели данных (ProviderConfig, RoleConfig, Session)
- EventEmitter + типы событий (вместо print())
- APIClient класс (вместо модульных глобалов)
- ToolRegistry класс (вместо модульных глобалов, чистый Python вместо find/grep)
- DiscussionEngine (извлечён из backends/custom/run.py, emit вместо print)
- Обратная совместимость CLI
### Фаза 2: Web-сервер
- FastAPI + WebSocket + SSE
- REST: sessions, start, inject, roles, providers
- Минимальный HTML/CSS/JS UI (без фреймворка)
- `meeting-room serve` команда
### Фаза 3: Интерактивность
- Boss-роль (пауза + inject через API)
- CSO-инъекция (Claude Code через REST)
- Setup Wizard (выбор участников, моделей, повестки)
- BrainstormEngine (1:1 с CSO)
### Фаза 4: Артефакты
- LLM-экстрактор (задачи, знания, решения из дискуссии)
- TaskIntegrator → .tasks/ проекта (через projects-meta MCP)
- WikiIntegrator → .wiki/ проекта (через projects-meta MCP)
- ProjectCreator → Gitea API → clone → bootstrap
- SessionArchive (индекс, поиск, архивация)
## Ключевые принципы
- Событийная архитектура: движок emit'ит, потребитель подписывается
- Каждая фаза даёт работающий продукт
- CLI работает идентично на протяжении всех фаз
- Артефакты идут в проектные .tasks/ и .wiki/, не в глобальные
- Глобальные .tasks/ — только агрегация
## See Also
- [sources/organization-architecture-meeting](../../.wiki/sources/organization-architecture-meeting.md) — оргструктура
- [entities/cso](../../.wiki/entities/cso.md) — роль CSO

45
.wiki/entities/roles.md Normal file
View File

@@ -0,0 +1,45 @@
---
title: Discussion Roles
type: entity
updated: 2026-05-04
---
# Discussion Roles
Roles define participants in a meeting-room discussion. Each role binds to a provider, model, temperature, tool set, and system prompt.
## General discussion roles
| Role | Purpose | Default tools |
|------|---------|-------------|
| moderator | Guide conversation, summarize, keep on-topic | none |
| skeptic | Find weaknesses, ask hard questions, counter-argue | readonly |
| idea_generator | Propose unconventional solutions, search web for inspiration | web |
| analyst | Structure arguments, assess risks, propose action plans | all |
## Code review roles
| Role | Purpose | Default tools |
|------|---------|-------------|
| defender | Defend code decisions, explain rationale | readonly |
| attacker | Ruthlessly find bugs, style violations, dead code | readonly |
| security | Check vulnerabilities, injection, CVEs | web |
| moderator | Structure review findings into CRITICAL/IMPORTANT/MINOR | none |
## Role configuration
```yaml
roles:
my_role:
name: "Display Name"
provider: openai # provider key from providers section
model: "gpt-4o"
temperature: 0.7 # 0.0 = deterministic, 1.0 = creative
tools: "readonly" # tool set name or list ["read_file", "web_search"]
system_prompt: |
Your instructions here...
```
## Cross-LLM review
Assign different `provider` values to roles so separate LLMs cross-check each other. The defender uses one model, the attacker uses another — each catches what the other misses.

25
.wiki/index.md Normal file
View File

@@ -0,0 +1,25 @@
# Wiki Index
Catalog of all wiki pages. One line per page, organized by type. Updated on every ingest / new page.
## Overview
- [overview.md](overview.md) — project overview, tech stack, roadmap
## Entities
- [roles.md](entities/roles.md) — discussion roles: moderator, skeptic, idea_generator, analyst, defender, attacker, security
## Concepts
- [bootstrap-manifest.md](concepts/bootstrap-manifest.md) — skills used to initialize project structure
- [meeting-room-v2.md](concepts/meeting-room-v2.md) — v2 architecture: 4-phase plan, interactive office
- [code-review.md](concepts/code-review.md) — cross-LLM code review pattern
## Packages
- [pydantic.md](packages/pydantic.md) — Pydantic v2 models for config, messages, sessions
## Sources
<!-- (none yet) -->

19
.wiki/log.md Normal file
View File

@@ -0,0 +1,19 @@
# Wiki Log
Append-only operation log. Format:
```
## [YYYY-MM-DD] <op> | <one-line description>
```
Operations: `init`, `ingest`, `query`, `lint`, `refactor`, `decision`.
Parseable: `grep "^## \[" .wiki/log.md | tail -20`.
---
## [2026-05-03] init | wiki bootstrapped via setup-wiki@1.0.0
## [2026-05-04] refactor | v2 Phase 1 complete: Pydantic models, EventEmitter, APIClient class, ToolRegistry class (pure Python/Windows), DiscussionEngine (event-driven), config.py, CLI refactor, deprecation shim. 184 tests passing.
## [2026-05-04] decision | code-review scenario added: cross-LLM review with defender/attacker/security/moderator roles

43
.wiki/overview.md Normal file
View File

@@ -0,0 +1,43 @@
---
title: meeting-room overview
type: overview
updated: 2026-05-04
---
# meeting-room — overview
Multi-agent discussion framework — an interactive workspace where AI agents debate problems and produce structured artifacts.
## What it does
Meeting-room runs multi-agent roundtable discussions with configurable participants, models, and tools. Each role can use a different LLM provider, enabling cross-model review and debate. The event-driven architecture decouples the engine from I/O — CLI subscribes to events for ANSI output, WebSocket/SSE streams them to browsers.
## Key components
- **DiscussionEngine** — event-driven core: orchestrates round-robin discussion, emits events instead of printing
- **APIClient** — class-based multi-provider client (OpenAI-compatible APIs)
- **ToolRegistry** — class-based, pure Python (Windows compatible, no find/grep)
- **EventEmitter** — synchronous pub/sub bus for decoupling engine from I/O
- **AsyncEventBridge** — bridges sync EventEmitter to asyncio consumers (WebSocket/SSE)
- **FastAPI server** — REST API + WebSocket + SSE for web UI
- **Pydantic v2 models** — typed config (DiscussionConfig, ProviderConfig, RoleConfig), messages, sessions
- **Scenarios** — Markdown + YAML frontmatter for defining discussion topics
## Built-in scenarios
- **Roundtable** — moderator/skeptic/idea_generator/analyst discuss a problem
- **Code Review** — defender/attacker/security/moderator cross-LLM code review
## Tech stack
- Python 3.11+, pydantic v2, httpx, pyyaml
- FastAPI + uvicorn + websockets (web server)
- Event-driven architecture (EventEmitter + AsyncEventBridge)
- OpenAI-compatible API (multi-provider: RouterAI, Ollama Cloud, OpenAI, Anthropic, DeepSeek, local Ollama)
## Roadmap
- [x] Phase 1: Core refactor — Pydantic models, EventEmitter, class-based APIClient/ToolRegistry, DiscussionEngine
- [x] Phase 2: Web server — FastAPI + WebSocket + SSE, REST API, HTML UI
- [ ] Phase 3: Interactivity — Boss role, CSO injection, Setup Wizard, BrainstormEngine
- [ ] Phase 4: Artifacts — LLM extraction to .tasks/.wiki, SessionArchive

0
.wiki/packages/.gitkeep Normal file
View File

View File

@@ -0,0 +1,38 @@
---
title: Pydantic v2 Models
type: package
updated: 2026-05-04
---
# Pydantic v2 Models
meeting-room uses Pydantic v2 for typed configuration, messages, and session data. All models are in `meeting_room/models.py`.
## Config models
| Model | Purpose | extra |
|-------|---------|-------|
| `ProviderConfig` | API provider (base_url, api_key) | forbid |
| `RoleConfig` | Discussion role (name, provider, model, temperature, tools, system_prompt) | forbid |
| `DefaultsConfig` | Default settings (max_rounds, language, framework, workdir) | ignore |
| `DiscussionConfig` | Top-level config — direct counterpart of config.yaml | ignore |
Key: `DiscussionConfig(**yaml_config)` works unchanged — feeds parsed YAML directly into the model.
## Scenario model
| Model | Purpose | extra |
|-------|---------|-------|
| `ScenarioConfig` | Loaded from .md/.yaml (name, participants, max_rounds, problem) | ignore |
## Runtime models
| Model | Purpose | extra |
|-------|---------|-------|
| `Message` | Single discussion message (role_id, name, content) | forbid |
| `ToolCallRecord` | Tool call (id, name, arguments, result) | forbid |
| `Session` | Full session (scenario, messages, tool_calls, created_at, updated_at) | ignore |
## Tools field
`RoleConfig.tools` accepts both `str` ("all", "readonly", etc.) and `list[str]` (["read_file", "web_search"]). A field validator coerces list items to strings.

11
.wiki/raw/README.md Normal file
View File

@@ -0,0 +1,11 @@
# Raw Sources
**Immutable.** Read, never edit. The only allowed modification is appending a `> Status:` blockquote when the user explicitly asks for a status audit.
Place raw inputs here — articles, transcripts, PDFs, screenshots — exactly as they came in. The agent reads from `raw/`, writes summaries into `../sources/`, and never modifies raw files.
For large or path-sensitive sources outside the repo, register them here:
```
- short-name → /absolute/path/to/source
```

0
.wiki/sources/.gitkeep Normal file
View File

11
CLAUDE.md Normal file
View File

@@ -0,0 +1,11 @@
# CLAUDE.md
# Agent instructions. Each line is a trigger for an installed skill.
talk like a caveman
use superpowers
use project wiki
use task management system
check across all projects
pull remote before work
follow project discipline
we're on Windows

105
README.md
View File

@@ -9,9 +9,11 @@ git clone <repo-url> meeting-room
cd meeting-room
pip install -e .
# Optional backends
pip install -e ".[autogen]"
pip install -e ".[crewai]"
# With dev dependencies (pytest, pytest-mock)
pip install -e ".[dev]"
# With web server dependencies (Phase 2)
pip install -e ".[web]"
```
## Quick Start
@@ -20,32 +22,18 @@ pip install -e ".[crewai]"
# 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
---
# Run a discussion with default scenario
meeting-room -s example-saas.md
# Problem Title
Describe the problem in Markdown...
EOF
# Run discussion
cd .meeting-room
meeting-room -s scenarios/my-problem.md
# Run and save result
# Run and save result to sessions/
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
# Cross-LLM code review — point agents at a project
meeting-room -s code-review.md -w /path/to/project
# List available scenarios and roles
meeting-room --list-scenarios
meeting-room --list-roles
```
## Workspace Structure
@@ -65,13 +53,41 @@ meeting-room -w /path/to/project -s scenarios/code-review.md
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 -s code-review.md -w /path # Code review on a project
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
meeting-room -w /path/to/project -s ... # Set workdir for file tools
```
## Built-in Scenarios
| Scenario | Description |
|----------|-------------|
| `example-saas.md` | General discussion: SaaS architecture with moderator/skeptic/idea_generator/analyst |
| `code-review.md` | Cross-LLM code review with defender/attacker/security/moderator |
## Architecture (v2)
```
┌──────────┐ events ┌───────────────┐
│ CLI │◄────────────│ DiscussionEngine │
│ (ANSI) │ │ (event-driven) │
└──────────┘ └───────┬───────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ APIClient│ │ToolRegistry│ │EventEmitter│
└──────────┘ └──────────┘ └──────────┘
```
- **DiscussionEngine** — orchestrates round-robin discussion, emits events (no print calls)
- **APIClient** — class-based, multi-provider (OpenAI-compatible APIs)
- **ToolRegistry** — class-based, pure Python (Windows compatible)
- **EventEmitter** — synchronous pub/sub bus (Phase 2: AsyncEventBridge for WebSocket)
- **Pydantic v2 models** — typed config, messages, sessions
## Configuration
### Config Search Order
@@ -88,6 +104,7 @@ 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-..."
export MEETING_ROOM_DEEPSEEK_API_KEY="sk-..."
```
Or use `${ENV_VAR}` placeholders in config.yaml.
@@ -98,12 +115,23 @@ 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"
defender:
name: "Defender"
provider: anthropic # one LLM defends
model: "claude-sonnet-4-6"
tools: "readonly"
attacker:
name: "Attacker"
provider: openai # another LLM attacks
model: "gpt-4o"
tools: "readonly"
security:
name: "Security"
provider: deepseek # third LLM checks security
model: "deepseek-chat"
tools: "web"
```
### Tool Sets
@@ -145,6 +173,13 @@ Full Markdown description...
2. How to scale?
```
## Roadmap
- [x] **Phase 1: Core refactor** — Pydantic models, EventEmitter, class-based APIClient/ToolRegistry, DiscussionEngine, backward-compatible CLI
- [ ] **Phase 2: Web server** — FastAPI + WebSocket + SSE, REST API, minimal HTML UI, `meeting-room serve`
- [ ] **Phase 3: Interactivity** — Boss role, CSO injection, Setup Wizard, BrainstormEngine
- [ ] **Phase 4: Artifacts** — LLM extraction → .tasks/.wiki, SessionArchive
## License
MIT
MIT

View File

@@ -1,26 +1,157 @@
"""Universal API client supporting multiple OpenAI-compatible providers."""
import httpx
from __future__ import annotations
import json
from typing import Iterator
import httpx
from meeting_room.models import ProviderConfig
PROVIDERS = {}
class APIClient:
"""Stateful client that holds provider configs and makes LLM calls.
Parameters
----------
providers:
Mapping of provider name to ``ProviderConfig`` (base_url + api_key).
"""
def __init__(self, providers: dict[str, ProviderConfig]) -> None:
self.providers: dict[str, ProviderConfig] = providers
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_client(self, provider_name: str) -> httpx.Client:
"""Create a short-lived ``httpx.Client`` for *provider_name*."""
p = self.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,
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def chat(
self,
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
tools: list[dict] | None = None,
) -> dict:
"""Send a non-streaming chat completion request.
Returns a dict with keys ``content`` (str) and ``tool_calls``
(list | None).
"""
client = self._get_client(provider)
payload: dict = {
"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: dict = {
"content": message.get("content") or "",
"tool_calls": None,
}
if message.get("tool_calls"):
result["tool_calls"] = [
{
"id": tc["id"],
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
}
for tc in message["tool_calls"]
]
return result
def chat_stream(
self,
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
) -> str:
"""Send a streaming chat completion request.
Accumulates all content deltas and **returns** the full text.
Does NOT print to stdout.
"""
client = self._get_client(provider)
payload: dict = {
"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"]:
full_text += delta["content"]
except (json.JSONDecodeError, KeyError, IndexError):
continue
return full_text
def init_providers(config: dict):
global PROVIDERS
PROVIDERS = config.get("providers", {})
# ======================================================================
# Module-level backward-compatible wrappers
# ======================================================================
_default_client: APIClient | None = None
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 init_providers(config: dict) -> None:
"""Initialise the module-level default ``APIClient``.
*config* is the raw dict produced by ``yaml.safe_load()`` — it must
contain a ``providers`` key whose value maps provider names to
``{base_url, api_key}`` dicts.
"""
global _default_client
raw: dict = config.get("providers", {})
providers: dict[str, ProviderConfig] = {
name: ProviderConfig(**vals) for name, vals in raw.items()
}
_default_client = APIClient(providers)
def _require_default() -> APIClient:
if _default_client is None:
raise RuntimeError(
"init_providers() must be called before using module-level chat/chat_stream"
)
return _default_client
def chat(
@@ -30,36 +161,8 @@ def chat(
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
"""Module-level wrapper that delegates to the default ``APIClient``."""
return _require_default().chat(provider, model, messages, temperature, tools)
def chat_stream(
@@ -68,29 +171,8 @@ def chat_stream(
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
"""Module-level wrapper that delegates to the default ``APIClient``.
Returns the accumulated text — does NOT print to stdout.
"""
return _require_default().chat_stream(provider, model, messages, temperature)

View File

@@ -1,60 +0,0 @@
"""AutoGen backend — uses microsoft/autogen for multi-agent conversation."""
def check_installed():
try:
import autogen # noqa
return True
except ImportError:
return False
def run(scenario_path: str, config: dict):
if not check_installed():
print("AutoGen is not installed. Run:\n pip install meeting-room[autogen]")
return []
import yaml
import autogen
from meeting_room.api_client import init_providers
from meeting_room.scenario_loader import load_scenario
scenario = load_scenario(scenario_path)
init_providers(config)
roles = config["roles"]
participants = scenario.get("participants", list(roles.keys()))
llm_configs = {}
for prov_name, prov_cfg in config["providers"].items():
llm_configs[prov_name] = {
"base_url": prov_cfg["base_url"],
"api_key": prov_cfg["api_key"],
}
agents = []
for role_id in participants:
role = roles[role_id]
prov = role["provider"]
llm_cfg = dict(llm_configs[prov])
llm_cfg["model"] = role["model"]
agent = autogen.ConversableAgent(
name=role["name"],
system_message=role["system_prompt"].strip(),
llm_config={"config_list": [llm_cfg], "temperature": role.get("temperature", 0.7)},
human_input_mode="NEVER",
)
agents.append(agent)
groupchat = autogen.GroupChat(
agents=agents,
messages=[],
max_round=scenario.get("max_rounds", config["defaults"]["max_rounds"]),
)
manager = autogen.GroupChatManager(
groupchat=groupchat,
llm_config={"config_list": [llm_configs[participants[0]]]},
)
agents[0].initiate_chat(manager, message=scenario["problem"])
return groupchat.messages

View File

@@ -1,53 +0,0 @@
"""CrewAI backend — uses crewai for task-oriented multi-agent discussion."""
def check_installed():
try:
import crewai # noqa
return True
except ImportError:
return False
def run(scenario_path: str, config: dict):
if not check_installed():
print("CrewAI is not installed. Run:\n pip install meeting-room[crewai]")
return []
import yaml
from crewai import Agent, Task, Crew, Process
from meeting_room.scenario_loader import load_scenario
scenario = load_scenario(scenario_path)
roles = config["roles"]
participants = scenario.get("participants", list(roles.keys()))
agents = []
for role_id in participants:
role = roles[role_id]
agent = Agent(
role=role["name"],
goal=role["system_prompt"].split(".")[0] + ".",
backstory=role["system_prompt"].strip(),
llm=f"{role['provider']}/{role['model']}",
verbose=True,
)
agents.append(agent)
discuss_task = Task(
description=f"Discuss this problem as a group:\n\n{scenario['problem']}",
agent=agents[0],
expected_output="Final solution with arguments from each participant",
)
crew = Crew(
agents=agents,
tasks=[discuss_task],
process=Process.sequential,
verbose=True,
)
result = crew.kickoff()
print(f"\n{'='*60}\nCREWAI RESULT:\n{'='*60}\n")
print(result)
return str(result)

View File

@@ -1,16 +1,37 @@
"""Custom backend — round-robin agent discussion with tool support."""
"""Legacy backend — delegates to DiscussionEngine.
import json
import os
import sys
This module is deprecated. Use meeting_room.engine.DiscussionEngine directly.
"""
import yaml
import warnings
from meeting_room.api_client import chat, chat_stream, init_providers
warnings.warn(
"backends.custom.run is deprecated. Use meeting_room.engine.DiscussionEngine instead.",
DeprecationWarning,
stacklevel=2,
)
from meeting_room.api_client import APIClient
from meeting_room.config import load_config
from meeting_room.engine import DiscussionEngine
from meeting_room.events import (
AGENT_ERROR,
AGENT_MESSAGE,
AGENT_TURN_START,
DISCUSSION_END,
DISCUSSION_START,
ROUND_END,
ROUND_START,
TOOL_CALL,
TOOL_RESULT,
EventEmitter,
)
from meeting_room.models import ProviderConfig
from meeting_room.scenario_loader import load_scenario
from meeting_room.tools import set_workdir, get_tool_schemas, execute_tool
from meeting_room.tools import ToolRegistry
# ── ANSI colors (kept for backward-compatible output) ──
# ANSI colors
COLORS = {
"moderator": "\033[96m",
"skeptic": "\033[91m",
@@ -22,150 +43,137 @@ RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
MAX_TOOL_ITERATIONS = 5
def run(scenario_path: str, config: dict) -> list[dict]:
"""Run a discussion and return conversation in legacy dict format.
This function is deprecated. Use DiscussionEngine directly.
Parameters
----------
scenario_path : str
Path to the scenario file (.md or .yaml).
config : dict
Raw config dict (as returned by :func:`load_config`).
Returns
-------
list[dict]
Conversation in legacy format ``[{role_id, name, content}, ...]``.
"""
from meeting_room.config import load_discussion_config
# Build typed config from the raw dict
discussion_config = load_discussion_config()
# Apply workdir from raw config if present
if "defaults" in config and "workdir" in config["defaults"]:
discussion_config.defaults.workdir = config["defaults"]["workdir"]
# Build engine components
providers = {
name: ProviderConfig(**vals)
for name, vals in config.get("providers", {}).items()
}
api_client = APIClient(providers=providers)
tool_registry = ToolRegistry(workdir=discussion_config.defaults.workdir)
event_bus = EventEmitter()
# Subscribe event handlers for print output (same format as before)
_subscribe_legacy_handlers(event_bus)
# Load scenario
scenario_data = load_scenario(scenario_path)
# Create and run engine
engine = DiscussionEngine(
config=discussion_config,
api_client=api_client,
tool_registry=tool_registry,
events=event_bus,
)
messages = engine.run(scenario_data)
# Convert Message objects to legacy dict format
conversation = [
{"role_id": m.role_id, "name": m.name, "content": m.content}
for m in messages
]
return conversation
def _format_conversation(conversation: list[dict]) -> str:
return "\n\n".join(f"[{msg['name']}]: {msg['content']}" for msg in conversation)
def _subscribe_legacy_handlers(bus: EventEmitter) -> None:
"""Wire up event handlers that produce the same ANSI output as before."""
def on_discussion_start(event) -> None:
data = event.data
print(f"\n{BOLD}{'=' * 60}")
print(f" MEETING ROOM — {data['name']}")
print(f"{'=' * 60}{RESET}\n")
def _run_agent_turn(role_config, problem, conversation, is_first_message):
"""Run one agent turn with tool support. Returns final text response."""
system_msg = {"role": "system", "content": role_config["system_prompt"].strip()}
if is_first_message:
context_msg = {
"role": "user",
"content": (
f"Проблема для обсуждения:\n{problem}\n\n"
"Обсуди её с другими участниками. Выскажи свою позицию. "
"При необходимости используй инструменты."
),
}
else:
context_msg = {
"role": "user",
"content": (
f"Вот что уже обсудили:\n\n{_format_conversation(conversation)}\n\n"
"Продолжи обсуждение. Ответь на аргументы, предложи своё. "
"При необходимости используй инструменты."
),
}
messages = [system_msg, context_msg]
tool_schemas = get_tool_schemas(role_config.get("tools", "none"))
has_tools = len(tool_schemas) > 0
for _ in range(MAX_TOOL_ITERATIONS):
if has_tools:
result = chat(
provider=role_config["provider"],
model=role_config["model"],
messages=messages,
temperature=role_config.get("temperature", 0.7),
tools=tool_schemas,
)
else:
content = chat_stream(
provider=role_config["provider"],
model=role_config["model"],
messages=messages,
temperature=role_config.get("temperature", 0.7),
)
return content
if result["tool_calls"]:
for tc in result["tool_calls"]:
print(f"\n{TOOL_COLOR} -> {tc['name']}({tc['arguments']}){RESET}")
messages.append({
"role": "assistant",
"content": result["content"] or None,
"tool_calls": [
{
"id": tc["id"],
"type": "function",
"function": {"name": tc["name"], "arguments": tc["arguments"]},
}
for tc in result["tool_calls"]
],
})
for tc in result["tool_calls"]:
try:
args = json.loads(tc["arguments"]) if isinstance(tc["arguments"], str) else tc["arguments"]
except json.JSONDecodeError:
args = {}
tool_result = execute_tool(tc["name"], args)
preview = tool_result[:200].replace("\n", " ")
print(f"{TOOL_COLOR} <- {preview}{'...' if len(tool_result) > 200 else ''}{RESET}")
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": tool_result,
})
continue
if result["content"]:
print(result["content"])
return result["content"]
return result.get("content", "")
def run(scenario_path: str, config: dict):
init_providers(config)
scenario = load_scenario(scenario_path)
roles_config = config["roles"]
problem = scenario["problem"]
participants = scenario.get("participants", list(roles_config.keys()))
max_rounds = scenario.get("max_rounds", config["defaults"]["max_rounds"])
set_workdir(config["defaults"].get("workdir", "."))
conversation = []
print(f"\n{BOLD}{'='*60}")
print(f" MEETING ROOM — {scenario['name']}")
print(f"{'='*60}{RESET}\n")
print(f"{BOLD}Problem:{RESET}")
for line in problem.split("\n")[:5]:
print(f" {line}")
if problem.count("\n") > 5:
print(" ...")
print()
print(f"{BOLD}Participants:{RESET}")
for rid in participants:
r = roles_config[rid]
print(f" {r['name']}{r['provider']}/{r['model']} [tools: {r.get('tools', 'none')}]")
print(f"\n{BOLD}Rounds:{RESET} {max_rounds}")
print(f"{DIM}{''*60}{RESET}\n")
for round_num in range(1, max_rounds + 1):
print(f"{BOLD}[Round {round_num}/{max_rounds}]{RESET}\n")
for role_id in participants:
role = roles_config[role_id]
color = COLORS.get(role_id, "")
name = role["name"]
is_first = len(conversation) == 0
print(f"{color}{BOLD} {name}:{RESET}", end=" ")
try:
content = _run_agent_turn(role, problem, conversation, is_first)
except Exception as e:
content = f"[Error: {e}]"
print(content)
conversation.append({"role_id": role_id, "name": name, "content": content})
problem = data.get("problem", "")
if problem:
print(f"{BOLD}Problem:{RESET}")
for line in problem.split("\n")[:5]:
print(f" {line}")
if problem.count("\n") > 5:
print(" ...")
print()
print(f"{DIM}{''*60}{RESET}\n")
participants = data.get("participants", [])
if participants:
print(f"{BOLD}Participants:{RESET}")
# Note: we don't have role details in the event, just names
# The engine emits participant names, not full role info.
for name in participants:
print(f" {name}")
print()
print(f"\n{BOLD}{'='*60}")
print(f" DISCUSSION COMPLETE — {len(conversation)} messages")
print(f"{'='*60}{RESET}\n")
print(f"{BOLD}Rounds:{RESET} {data['rounds']}")
print(f"{DIM}{'' * 60}{RESET}\n")
return conversation
def on_round_start(event) -> None:
data = event.data
print(f"{BOLD}[Round {data['round_num']}/{data['total_rounds']}]{RESET}\n")
def on_agent_turn_start(event) -> None:
data = event.data
color = COLORS.get(data["role_id"], "")
print(f"{color}{BOLD} {data['name']}:{RESET}", end=" ")
def on_agent_message(event) -> None:
# Content printed inline after name; just end the line
print()
def on_tool_call(event) -> None:
data = event.data
print(f"\n{TOOL_COLOR} -> {data['tool_name']}({data['arguments']}){RESET}")
def on_tool_result(event) -> None:
data = event.data
preview = data["result_preview"]
suffix = "..." if len(data.get("full_result", "")) > 200 else ""
print(f"{TOOL_COLOR} <- {preview}{suffix}{RESET}")
def on_agent_error(event) -> None:
data = event.data
print(f" [Error: {data['error']}]")
def on_round_end(event) -> None:
print(f"{DIM}{'' * 60}{RESET}\n")
def on_discussion_end(event) -> None:
data = event.data
print(f"\n{BOLD}{'=' * 60}")
print(f" DISCUSSION COMPLETE — {data['total_messages']} messages")
print(f"{'=' * 60}{RESET}\n")
bus.on(DISCUSSION_START, on_discussion_start)
bus.on(ROUND_START, on_round_start)
bus.on(AGENT_TURN_START, on_agent_turn_start)
bus.on(AGENT_MESSAGE, on_agent_message)
bus.on(TOOL_CALL, on_tool_call)
bus.on(TOOL_RESULT, on_tool_result)
bus.on(AGENT_ERROR, on_agent_error)
bus.on(ROUND_END, on_round_end)
bus.on(DISCUSSION_END, on_discussion_end)

View File

@@ -1,49 +1,60 @@
"""Meeting Room CLI — main entry point."""
"""Meeting Room CLI — main entry point.
Refactored (v2): run_discussion() now creates a DiscussionEngine and
subscribes to events with ANSI-color output instead of calling the
legacy backend directly.
"""
import sys
import os
import json
import argparse
import yaml
import os
import sys
from datetime import datetime
from meeting_room.scenario_loader import load_scenario, list_scenarios as _list_scenarios
# Force UTF-8 output on Windows (cp1251 can't handle LLM unicode output)
if sys.stdout.encoding != "utf-8":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if sys.stderr.encoding != "utf-8":
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
from meeting_room.api_client import APIClient
from meeting_room.config import find_workspace_config, load_config, load_discussion_config
from meeting_room.engine import DiscussionEngine
from meeting_room.events import (
AGENT_ERROR,
AGENT_MESSAGE,
AGENT_TURN_START,
DISCUSSION_END,
DISCUSSION_START,
ROUND_END,
ROUND_START,
TOOL_CALL,
TOOL_RESULT,
EventEmitter,
)
from meeting_room.models import DiscussionConfig, ProviderConfig
from meeting_room.scenario_loader import load_scenario
from meeting_room.tools import ToolRegistry
# ── ANSI colors ──
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
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"
def list_scenarios(scenarios_dir: str | None = None):
"""Print available scenarios to stdout."""
from meeting_room.scenario_loader import list_scenarios as _list_scenarios
if scenarios_dir is None:
scenarios_dir = os.path.join(PACKAGE_DIR, "scenarios")
scenarios_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenarios")
items = _list_scenarios(scenarios_dir)
if not items:
print("No scenarios found. Create .md or .yaml in scenarios/")
@@ -59,6 +70,7 @@ def list_scenarios(scenarios_dir: str | None = None):
def list_roles(config: dict):
"""Print roles and model bindings to stdout."""
roles = config.get("roles", {})
providers = config.get("providers", {})
print("\nRoles and model bindings:")
@@ -79,6 +91,8 @@ def list_roles(config: dict):
def init_workspace(path: str = "."):
"""Initialize a .meeting-room workspace in given directory."""
import yaml # noqa: avoid top-level import for speed
target = os.path.abspath(path)
ws_dir = os.path.join(target, ".meeting-room")
@@ -193,7 +207,7 @@ def save_session(conversation: list[dict], scenario_name: str, sessions_dir: str
"""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()
safe_name = scenario_name.replace(" ", "-").replace("", "-").lower()
filename = f"{timestamp}_{safe_name}.json"
filepath = os.path.join(sessions_dir, filename)
@@ -209,41 +223,155 @@ def save_session(conversation: list[dict], scenario_name: str, sessions_dir: str
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
# ---------------------------------------------------------------------------
# Event handlers — produce the same ANSI output as the legacy backend
# ---------------------------------------------------------------------------
def run_discussion(backend: str, scenario: str, config: dict, save: bool = False):
def _subscribe_cli_handlers(bus: EventEmitter, max_rounds: int) -> None:
"""Wire up event handlers that print ANSI-colored output to stdout.
The output format matches the original backends/custom/run.py exactly.
"""
def on_discussion_start(event) -> None:
data = event.data
print(f"\n{BOLD}{'=' * 60}")
print(f" MEETING ROOM — {data['name']}")
print(f"{'=' * 60}{RESET}\n")
# Print problem preview
problem = data.get("problem", "")
if problem:
print(f"{BOLD}Problem:{RESET}")
for line in problem.split("\n")[:5]:
print(f" {line}")
if problem.count("\n") > 5:
print(" ...")
print()
# Print participants
participants = data.get("participants", [])
if participants:
print(f"{BOLD}Participants:{RESET}")
for name in participants:
print(f" {name}")
print()
print(f"{BOLD}Rounds:{RESET} {data['rounds']}")
print(f"{DIM}{'-' * 60}{RESET}\n")
def on_round_start(event) -> None:
data = event.data
print(f"{BOLD}[Round {data['round_num']}/{data['total_rounds']}]{RESET}\n")
def on_agent_turn_start(event) -> None:
data = event.data
color = COLORS.get(data["role_id"], "")
print(f"{color}{BOLD} {data['name']}:{RESET}", end=" ")
def on_agent_message(event) -> None:
# Content is printed inline after the name; just end the line.
print()
def on_tool_call(event) -> None:
data = event.data
print(f"\n{TOOL_COLOR} -> {data['tool_name']}({data['arguments']}){RESET}")
def on_tool_result(event) -> None:
data = event.data
preview = data["result_preview"]
suffix = "..." if len(data.get("full_result", "")) > 200 else ""
print(f"{TOOL_COLOR} <- {preview}{suffix}{RESET}")
def on_agent_error(event) -> None:
data = event.data
# Error content is already printed inline by the name header;
# just note the error type.
print(f" [Error: {data['error']}]")
def on_round_end(event) -> None:
print(f"{DIM}{'-' * 60}{RESET}\n")
def on_discussion_end(event) -> None:
data = event.data
print(f"\n{BOLD}{'=' * 60}")
print(f" DISCUSSION COMPLETE — {data['total_messages']} messages")
print(f"{'=' * 60}{RESET}\n")
bus.on(DISCUSSION_START, on_discussion_start)
bus.on(ROUND_START, on_round_start)
bus.on(AGENT_TURN_START, on_agent_turn_start)
bus.on(AGENT_MESSAGE, on_agent_message)
bus.on(TOOL_CALL, on_tool_call)
bus.on(TOOL_RESULT, on_tool_result)
bus.on(AGENT_ERROR, on_agent_error)
bus.on(ROUND_END, on_round_end)
bus.on(DISCUSSION_END, on_discussion_end)
def run_discussion(scenario: str, config: dict, save: bool = False):
"""Run a discussion through DiscussionEngine with ANSI event output.
Parameters
----------
scenario : str
Path or name of the scenario file.
config : dict
Raw config dict (as returned by :func:`load_config`).
save : bool
Whether to save the session to a JSON file.
Returns
-------
list[dict]
Conversation in the legacy dict format ``[{role_id, name, content}, ...]``.
"""
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)
scenario_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "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)
# Convert raw config to typed DiscussionConfig
discussion_config = load_discussion_config()
# Apply any overrides from the raw config (e.g. workdir from CLI)
if "defaults" in config and "workdir" in config["defaults"]:
discussion_config.defaults.workdir = config["defaults"]["workdir"]
# Build engine components
providers = {
name: ProviderConfig(**vals)
for name, vals in config.get("providers", {}).items()
}
api_client = APIClient(providers=providers)
tool_registry = ToolRegistry(workdir=discussion_config.defaults.workdir)
event_bus = EventEmitter()
# Subscribe CLI output handlers
_subscribe_cli_handlers(event_bus, max_rounds=discussion_config.defaults.max_rounds)
# Load scenario
scenario_data = load_scenario(scenario_path)
# Create and run engine
engine = DiscussionEngine(
config=discussion_config,
api_client=api_client,
tool_registry=tool_registry,
events=event_bus,
)
messages = engine.run(scenario_data)
# Convert Message objects to legacy dict format
conversation = [
{"role_id": m.role_id, "name": m.name, "content": m.content}
for m in messages
]
if save and conversation:
# Determine sessions dir
@@ -253,22 +381,19 @@ def run_discussion(backend: str, scenario: str, config: dict, save: bool = False
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)
scenario_obj = load_scenario(scenario_path)
save_session(conversation, scenario_obj.get("name", "discussion"), sessions_dir)
return conversation
def main():
"""CLI entry point."""
import argparse
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",
@@ -301,6 +426,11 @@ def main():
init_parser = subparsers.add_parser("init", help="Initialize .meeting-room workspace")
init_parser.add_argument("path", nargs="?", default=".", help="Where to create .meeting-room")
serve_parser = subparsers.add_parser("serve", help="Start web server")
serve_parser.add_argument("--host", default="0.0.0.0", help="Host to bind (default: 0.0.0.0)")
serve_parser.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)")
serve_parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
args = parser.parse_args()
# Handle init command
@@ -308,6 +438,18 @@ def main():
init_workspace(args.path)
return
# Handle serve command
if args.command == "serve":
import uvicorn
from meeting_room.server import app
uvicorn.run(
"meeting_room.server:app" if args.reload else app,
host=args.host,
port=args.port,
reload=args.reload,
)
return
# Find config: CLI arg > .meeting-room/config.yaml > package default
if args.config:
config = load_config(args.config)
@@ -336,8 +478,8 @@ def main():
if args.workdir:
config["defaults"]["workdir"] = args.workdir
run_discussion(args.backend, args.scenario, config, save=args.save)
run_discussion(args.scenario, config, save=args.save)
if __name__ == "__main__":
main()
main()

115
meeting_room/config.py Normal file
View File

@@ -0,0 +1,115 @@
"""Configuration loading for Meeting Room.
Provides load_config(), find_workspace_config(), and load_discussion_config()
which converts a raw YAML dict into a typed DiscussionConfig.
"""
from __future__ import annotations
import os
import re
import yaml
from meeting_room.models import DefaultsConfig, DiscussionConfig, ProviderConfig, RoleConfig
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.
Parameters
----------
config_path : str | None
Path to the YAML config file. When *None*, loads the package
default config at ``meeting_room/config/config.yaml``.
Returns
-------
dict
Raw configuration dictionary (the result of ``yaml.safe_load``).
"""
if config_path is None:
config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml")
with open(config_path, encoding="utf-8") as f:
raw = f.read()
# Resolve ${ENV_VAR} placeholders
def _env_replace(match: re.Match) -> str:
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 find_workspace_config() -> str | None:
"""Search for .meeting-room/config.yaml starting from CWD upward.
Returns
-------
str | None
Absolute path to the workspace config file, or *None* if not
found.
"""
current = os.getcwd()
while True:
candidate = os.path.join(current, ".meeting-room", "config.yaml")
if os.path.exists(candidate):
return candidate
parent = os.path.dirname(current)
if parent == current:
# Reached filesystem root
break
current = parent
return None
def load_discussion_config(config_path: str | None = None) -> DiscussionConfig:
"""Load config and return a typed DiscussionConfig.
Convenience wrapper that calls :func:`load_config` and then
coerces the raw dict into :class:`DiscussionConfig`.
Parameters
----------
config_path : str | None
Path to the YAML config file. When *None*, loads the package
default.
Returns
-------
DiscussionConfig
Typed configuration object ready for use by DiscussionEngine.
"""
raw = load_config(config_path)
providers = {
name: ProviderConfig(**vals)
for name, vals in raw.get("providers", {}).items()
}
roles = {
name: RoleConfig(**vals)
for name, vals in raw.get("roles", {}).items()
}
defaults = DefaultsConfig(**raw.get("defaults", {}))
return DiscussionConfig(
providers=providers,
roles=roles,
defaults=defaults,
)

View File

@@ -9,6 +9,9 @@ providers:
routerai:
base_url: "https://routerai.ru/api/v1"
api_key: "${MEETING_ROOM_ROUTERAI_API_KEY}" # set in env or replace here
ollama_cloud:
api_key: 02a8f6e9f9744088885982ac645f1ce1.HB1MFh4AlTpyic9oJGxQjNuA
base_url: https://ollama.com/v1
# openai:
# base_url: "https://api.openai.com/v1"
@@ -22,14 +25,19 @@ providers:
# base_url: "http://localhost:11434/v1"
# api_key: "ollama"
# deepseek:
# base_url: "https://api.deepseek.com/v1"
# api_key: "${MEETING_ROOM_DEEPSEEK_API_KEY}"
# ─── Roles ───
# tools: all | full | files | readonly | web | none | or list ["read_file", "web_search"]
roles:
# ── General discussion ──
moderator:
name: "Moderator"
provider: routerai
model: "gpt-4o-mini"
provider: ollama_cloud
model: "glm-5.1"
temperature: 0.7
tools: "none"
system_prompt: |
@@ -40,8 +48,8 @@ roles:
skeptic:
name: "Skeptic"
provider: routerai
model: "gpt-4o-mini"
provider: ollama_cloud
model: "glm-5.1"
temperature: 0.9
tools: "readonly"
system_prompt: |
@@ -52,8 +60,8 @@ roles:
idea_generator:
name: "Idea Generator"
provider: routerai
model: "gpt-4o-mini"
provider: ollama_cloud
model: "glm-5.1"
temperature: 1.0
tools: "web"
system_prompt: |
@@ -65,8 +73,8 @@ roles:
analyst:
name: "Analyst"
provider: routerai
model: "gpt-4o-mini"
provider: ollama_cloud
model: "glm-5.1"
temperature: 0.5
tools: "all"
system_prompt: |
@@ -76,6 +84,45 @@ roles:
Propose concrete action plans.
Use tools to verify facts, read project files, and search for info.
# ── Code review ──
defender:
name: "Defender"
provider: ollama_cloud
model: "glm-5.1"
temperature: 0.6
tools: "readonly"
system_prompt: |
You wrote this code. Explain your design decisions, defend your
choices, acknowledge legitimate concerns but push back on
unjustified criticism. Reference specific files and line numbers
when explaining your logic. Read the code files being reviewed.
attacker:
name: "Attacker"
provider: ollama_cloud
model: "glm-5.1"
temperature: 0.9
tools: "readonly"
system_prompt: |
You are a ruthless code reviewer. Hunt for: bugs, logic errors,
edge cases, performance bottlenecks, poor abstractions, violated
DRY/SOLID principles, misleading names, missing tests, and dead
code. Don't be polite — be precise. Cite specific lines.
Use read_file and search_in_files to examine the code thoroughly.
security:
name: "Security"
provider: ollama_cloud
model: "glm-5.1"
temperature: 0.4
tools: "web"
system_prompt: |
You are a security specialist. Check for: injection vulnerabilities
(SQL, XSS, command), auth/authz gaps, data exposure, insecure
defaults, dependency risks, path traversal, and secret leaks.
Use web_search to check for known CVEs in dependencies.
Be specific — cite exact code locations and explain the attack vector.
defaults:
max_rounds: 8
language: "ru"

337
meeting_room/engine.py Normal file
View File

@@ -0,0 +1,337 @@
"""DiscussionEngine — event-driven round-robin discussion runner.
Replaces the monolithic backends/custom/run.py with a clean, testable
architecture that emits typed events instead of printing to stdout.
"""
from __future__ import annotations
import json
import logging
from typing import Union
from meeting_room.api_client import APIClient
from meeting_room.events import (
AGENT_ERROR,
AGENT_MESSAGE,
AGENT_TURN_START,
BOSS_TURN,
DISCUSSION_END,
DISCUSSION_START,
ROUND_END,
ROUND_START,
TOOL_CALL,
TOOL_RESULT,
EventEmitter,
)
from meeting_room.models import (
DiscussionConfig,
Message,
RoleConfig,
ScenarioConfig,
ToolCallRecord,
)
from meeting_room.tools import ToolRegistry
logger = logging.getLogger(__name__)
MAX_TOOL_ITERATIONS = 5
class DiscussionEngine:
"""Orchestrates a multi-agent discussion using events for output.
Parameters
----------
config : DiscussionConfig
Top-level configuration (providers, roles, defaults).
api_client : APIClient
Client for making LLM API calls.
tool_registry : ToolRegistry
Registry for tool schema lookup and execution.
events : EventEmitter
Event bus for emitting lifecycle events.
"""
def __init__(
self,
config: DiscussionConfig,
api_client: APIClient,
tool_registry: ToolRegistry,
events: EventEmitter,
) -> None:
self.config = config
self.api = api_client
self.tools = tool_registry
self.events = events
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def run(
self,
scenario: Union[ScenarioConfig, dict],
) -> list[Message]:
"""Run a full discussion and return the conversation as Message objects.
Parameters
----------
scenario : ScenarioConfig or dict
The scenario to run. A dict is accepted for backward
compatibility and is coerced to ScenarioConfig.
Returns
-------
list[Message]
The ordered list of messages produced during the discussion.
"""
if isinstance(scenario, dict):
scenario = ScenarioConfig(**scenario)
participants = scenario.participants or list(self.config.roles.keys())
max_rounds = scenario.max_rounds or self.config.defaults.max_rounds
# Eagerly resolve role configs so missing role_id fails fast.
roles: dict[str, RoleConfig] = {}
for rid in participants:
if rid not in self.config.roles:
raise ValueError(f"Unknown role_id '{rid}' — not in config.roles")
roles[rid] = self.config.roles[rid]
conversation: list[Message] = []
self.events.emit(
DISCUSSION_START,
name=scenario.name,
participants=[roles[rid].name for rid in participants],
rounds=max_rounds,
problem=scenario.problem,
)
for round_num in range(1, max_rounds + 1):
self.events.emit(ROUND_START, round_num=round_num, total_rounds=max_rounds)
for role_id in participants:
role = roles[role_id]
is_first = len(conversation) == 0
self.events.emit(
AGENT_TURN_START,
role_id=role_id,
name=role.name,
)
try:
content = self._run_agent_turn(
role=role,
role_id=role_id,
problem=scenario.problem,
conversation=conversation,
is_first_message=is_first,
)
except Exception as exc:
content = f"[Error: {exc}]"
self.events.emit(
AGENT_ERROR,
role_id=role_id,
name=role.name,
error=str(exc),
)
conversation.append(
Message(role_id=role_id, name=role.name, content=content)
)
self.events.emit(
AGENT_MESSAGE,
role_id=role_id,
name=role.name,
content=content,
)
self.events.emit(ROUND_END, round_num=round_num, total_rounds=max_rounds)
self.events.emit(
DISCUSSION_END,
total_messages=len(conversation),
)
return conversation
def inject_message(self, role_id: str, content: str) -> Message:
"""Inject an arbitrary message from a Boss/CSO role.
This does **not** make an LLM call — it simply appends a message
and emits a BOSS_TURN event so subscribers can log or relay it.
Parameters
----------
role_id : str
Identifier of the injecting role (e.g. "boss", "cso").
content : str
The message content to inject.
Returns
-------
Message
The created Message object.
Raises
------
ValueError
If *role_id* is not in config.roles.
"""
if role_id not in self.config.roles:
raise ValueError(f"Unknown role_id '{role_id}' — not in config.roles")
role = self.config.roles[role_id]
msg = Message(role_id=role_id, name=role.name, content=content)
self.events.emit(
BOSS_TURN,
role_id=role_id,
name=role.name,
content=content,
)
return msg
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _format_conversation(conversation: list[Message]) -> str:
"""Format a list of Message objects into a prompt-friendly string."""
return "\n\n".join(
f"[{msg.name}]: {msg.content}" for msg in conversation
)
def _build_messages(
self,
role: RoleConfig,
problem: str,
conversation: list[Message],
is_first_message: bool,
) -> list[dict]:
"""Build the API message list for one agent turn."""
system_msg = {"role": "system", "content": role.system_prompt.strip()}
if is_first_message:
context_msg = {
"role": "user",
"content": (
f"Problem for discussion:\n{problem}\n\n"
"Discuss it with other participants. Express your position. "
"Use tools if necessary."
),
}
else:
context_msg = {
"role": "user",
"content": (
f"Here is what has been discussed so far:\n\n"
f"{self._format_conversation(conversation)}\n\n"
"Continue the discussion. Respond to arguments, offer your own. "
"Use tools if necessary."
),
}
return [system_msg, context_msg]
def _run_agent_turn(
self,
role: RoleConfig,
role_id: str,
problem: str,
conversation: list[Message],
is_first_message: bool,
) -> str:
"""Run one agent turn with tool support.
Returns the final text content produced by the agent.
"""
messages = self._build_messages(role, problem, conversation, is_first_message)
tool_schemas = self.tools.get_tool_schemas(role.tools)
has_tools = len(tool_schemas) > 0
for _ in range(MAX_TOOL_ITERATIONS):
if has_tools:
result = self.api.chat(
provider=role.provider,
model=role.model,
messages=messages,
temperature=role.temperature,
tools=tool_schemas,
)
else:
content = self.api.chat_stream(
provider=role.provider,
model=role.model,
messages=messages,
temperature=role.temperature,
)
return content
if result["tool_calls"]:
# Append assistant message with tool calls
assistant_msg: dict = {
"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"]
],
}
messages.append(assistant_msg)
# Execute each tool call and append results
for tc in result["tool_calls"]:
self.events.emit(
TOOL_CALL,
role_id=role_id,
tool_name=tc["name"],
arguments=tc["arguments"],
)
try:
args = (
json.loads(tc["arguments"])
if isinstance(tc["arguments"], str)
else tc["arguments"]
)
except json.JSONDecodeError:
args = {}
tool_result = self.tools.execute_tool(tc["name"], args)
preview = tool_result[:200].replace("\n", " ")
self.events.emit(
TOOL_RESULT,
role_id=role_id,
tool_name=tc["name"],
result_preview=preview,
full_result=tool_result,
)
messages.append(
{
"role": "tool",
"tool_call_id": tc["id"],
"content": tool_result,
}
)
continue
# No tool calls — return the content
if result["content"]:
return result["content"]
# Exhausted tool iterations — return whatever we have
return result.get("content", "")

247
meeting_room/events.py Normal file
View File

@@ -0,0 +1,247 @@
"""EventEmitter and event types for the Meeting Room discussion engine.
Synchronous event bus with AsyncEventBridge for forwarding events
to asyncio consumers (WebSocket / SSE).
"""
from __future__ import annotations
import asyncio
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Event type constants
# ---------------------------------------------------------------------------
DISCUSSION_START = "discussion_start"
ROUND_START = "round_start"
AGENT_TURN_START = "agent_turn_start"
AGENT_MESSAGE = "agent_message"
TOOL_CALL = "tool_call"
TOOL_RESULT = "tool_result"
AGENT_ERROR = "agent_error"
ROUND_END = "round_end"
DISCUSSION_END = "discussion_end"
BOSS_TURN = "boss_turn"
# ---------------------------------------------------------------------------
# Event dataclass
# ---------------------------------------------------------------------------
@dataclass
class Event:
"""A single event emitted by the discussion engine."""
type: str
data: dict[str, Any] = field(default_factory=dict)
timestamp: datetime = field(default_factory=datetime.now)
# ---------------------------------------------------------------------------
# EventEmitter
# ---------------------------------------------------------------------------
# Type alias for handler callbacks
Handler = Callable[[Event], None]
class EventEmitter:
"""Synchronous pub/sub event bus.
Usage::
bus = EventEmitter()
def on_message(event: Event):
print(event.data)
bus.on(AGENT_MESSAGE, on_message)
bus.emit(AGENT_MESSAGE, text="Hello", agent="Alice")
bus.off(AGENT_MESSAGE, on_message)
"""
def __init__(self) -> None:
self._handlers: dict[str, list[Handler]] = {}
# -- subscription --------------------------------------------------------
def on(self, event_type: str, handler: Handler) -> Handler:
"""Subscribe *handler* to *event_type*.
Returns the handler so callers can use ``off`` with the same
reference, or chain calls.
"""
self._handlers.setdefault(event_type, []).append(handler)
return handler
def off(self, event_type: str, handler: Handler) -> None:
"""Unsubscribe *handler* from *event_type*.
Silently ignores unknown event types or missing handlers.
"""
handlers = self._handlers.get(event_type)
if handlers is None:
return
try:
handlers.remove(handler)
except ValueError:
pass
# Clean up empty lists to avoid unbounded growth
if not handlers:
del self._handlers[event_type]
# -- emission ------------------------------------------------------------
def emit(self, event_type: str, **data: Any) -> Event:
"""Create an Event and call every subscribed handler synchronously.
Handlers that raise exceptions are caught and logged; the emitter
never crashes due to a bad handler.
Returns the Event that was emitted (useful for testing).
"""
event = Event(type=event_type, data=data)
handlers = self._handlers.get(event_type, [])
for handler in handlers:
try:
handler(event)
except Exception:
logger.exception(
"Handler %r raised for event %s",
handler,
event_type,
)
return event
# -- introspection -------------------------------------------------------
def handlers(self, event_type: str) -> list[Handler]:
"""Return a *copy* of the handler list for *event_type*."""
return list(self._handlers.get(event_type, []))
def clear(self) -> None:
"""Remove all subscriptions."""
self._handlers.clear()
# ---------------------------------------------------------------------------
# AsyncEventBridge
# ---------------------------------------------------------------------------
# All event types the bridge forwards.
_ALL_EVENT_TYPES = [
DISCUSSION_START,
ROUND_START,
AGENT_TURN_START,
AGENT_MESSAGE,
TOOL_CALL,
TOOL_RESULT,
AGENT_ERROR,
ROUND_END,
DISCUSSION_END,
BOSS_TURN,
]
class AsyncEventBridge:
"""Bridges synchronous EventEmitter events to asyncio consumers.
Subscribes to all event types on a sync ``EventEmitter`` and forwards
each event into ``asyncio.Queue`` objects — one per consumer. Designed
for running ``DiscussionEngine`` in a worker thread via
``asyncio.to_thread()`` while WebSocket / SSE handlers await events
on the async side.
Usage::
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
bridge.start(asyncio.get_event_loop())
# In an async handler:
queue = bridge.subscribe()
try:
while True:
event = await queue.get()
await websocket.send_json(event)
finally:
bridge.unsubscribe(queue)
# Run engine in a thread:
await asyncio.to_thread(engine.run, scenario)
# Cleanup:
bridge.stop()
"""
def __init__(self, event_bus: EventEmitter) -> None:
self._bus = event_bus
self._loop: asyncio.AbstractEventLoop | None = None
self._queues: list[asyncio.Queue[dict]] = []
self._lock = threading.Lock()
self._handler = self._on_sync_event
# -- lifecycle -----------------------------------------------------------
def start(self, loop: asyncio.AbstractEventLoop) -> None:
"""Subscribe to all event types on the bus."""
self._loop = loop
for event_type in _ALL_EVENT_TYPES:
self._bus.on(event_type, self._handler)
def stop(self) -> None:
"""Unsubscribe from all event types."""
for event_type in _ALL_EVENT_TYPES:
self._bus.off(event_type, self._handler)
# -- consumer management ------------------------------------------------
def subscribe(self) -> asyncio.Queue[dict]:
"""Create a consumer queue that receives all events.
Call from async context. The caller owns the queue and must
call ``unsubscribe()`` when done.
"""
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=1000)
with self._lock:
self._queues.append(q)
return q
def unsubscribe(self, q: asyncio.Queue[dict]) -> None:
"""Remove a consumer queue. Safe to call multiple times."""
with self._lock:
try:
self._queues.remove(q)
except ValueError:
pass
# -- internal ------------------------------------------------------------
def _on_sync_event(self, event: Event) -> None:
"""Sync handler called by EventEmitter on any thread."""
payload = {
"type": event.type,
"data": event.data,
"timestamp": event.timestamp.isoformat(),
}
if self._loop is None or not self._loop.is_running():
return
with self._lock:
queues = list(self._queues)
for q in queues:
self._loop.call_soon_threadsafe(self._safe_put, q, payload)
@staticmethod
def _safe_put(q: asyncio.Queue[dict], payload: dict) -> None:
"""Put payload into queue, dropping if full."""
try:
q.put_nowait(payload)
except asyncio.QueueFull:
logger.warning("AsyncEventBridge: dropping event, queue full")

124
meeting_room/models.py Normal file
View File

@@ -0,0 +1,124 @@
"""Pydantic v2 data models for Meeting Room."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Union
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ---------------------------------------------------------------------------
# Configuration models — must be compatible with config.yaml parsed by
# yaml.safe_load() so that DiscussionConfig(**yaml_config) works unchanged.
# ---------------------------------------------------------------------------
class ProviderConfig(BaseModel):
"""A single API provider (OpenAI-compatible endpoint)."""
model_config = ConfigDict(extra="forbid")
base_url: str
api_key: str
class RoleConfig(BaseModel):
"""A discussion participant role bound to a provider/model."""
model_config = ConfigDict(extra="forbid")
name: str
provider: str
model: str
temperature: float = 0.7
tools: Union[str, list[str]] = "none"
system_prompt: str = ""
@field_validator("tools", mode="before")
@classmethod
def _coerce_tools(cls, v: object) -> Union[str, list[str]]:
if isinstance(v, str):
return v
if isinstance(v, list):
return [str(item) for item in v]
raise ValueError(f"tools must be a string or list of strings, got {type(v).__name__}")
class DefaultsConfig(BaseModel):
"""Default settings inherited by scenarios."""
model_config = ConfigDict(extra="ignore")
max_rounds: int = 8
language: str = "ru"
framework: str = "custom"
workdir: str = "."
class DiscussionConfig(BaseModel):
"""Top-level config — the direct counterpart of config.yaml."""
model_config = ConfigDict(extra="ignore")
providers: dict[str, ProviderConfig] = Field(default_factory=dict)
roles: dict[str, RoleConfig] = Field(default_factory=dict)
defaults: DefaultsConfig = Field(default_factory=DefaultsConfig)
# ---------------------------------------------------------------------------
# Scenario model
# ---------------------------------------------------------------------------
class ScenarioConfig(BaseModel):
"""A discussion scenario (loaded from .md / .yaml files)."""
model_config = ConfigDict(extra="ignore")
name: str = "Untitled"
participants: list[str] = Field(default_factory=list)
max_rounds: int = 8
problem: str = ""
# ---------------------------------------------------------------------------
# Runtime models
# ---------------------------------------------------------------------------
class Message(BaseModel):
"""A single message in a discussion session."""
model_config = ConfigDict(extra="forbid")
role_id: str
name: str
content: str
class ToolCallRecord(BaseModel):
"""Record of a single tool call made by an agent."""
model_config = ConfigDict(extra="forbid")
id: str
name: str
arguments: str
result: str = ""
class Session(BaseModel):
"""A complete discussion session (scenario + messages + timestamps)."""
model_config = ConfigDict(extra="ignore")
scenario: ScenarioConfig = Field(default_factory=ScenarioConfig)
messages: list[Message] = Field(default_factory=list)
tool_calls: list[ToolCallRecord] = Field(default_factory=list)
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,39 @@
---
name: "Code Review — Cross-LLM"
participants:
- defender
- attacker
- security
- moderator
max_rounds: 4
---
# Code Review: Cross-LLM Verification
Automated multi-agent code review where different LLM providers cross-check each other's work.
## Instructions for Participants
**defender** — You wrote this code. Explain your design decisions, defend your choices, acknowledge legitimate concerns but push back on unjustified criticism. Reference specific files and line numbers when explaining your logic. Read the code files being reviewed.
**attacker** — You are a ruthless code reviewer. Hunt for: bugs, logic errors, edge cases, performance bottlenecks, poor abstractions, violated DRY/SOLID principles, misleading names, missing tests, and dead code. Don't be polite — be precise. Cite specific lines.
**security** — You are a security specialist. Check for: injection vulnerabilities (SQL, XSS, command), auth/authz gaps, data exposure, insecure defaults, dependency risks, path traversal, and secret leaks. Use web search to check for known CVEs in dependencies.
**moderator** — Keep the review focused and productive. Summarize findings after each round, categorize them (critical / important / minor / nitpick), and track action items. Ensure every concern gets a response from the defender.
## Review Criteria
1. **Correctness** — Does the code do what it's supposed to?
2. **Security** — Are there vulnerabilities or data leaks?
3. **Performance** — Any bottlenecks or unnecessary overhead?
4. **Maintainability** — Is the code readable, well-structured, easy to change?
5. **Testing** — Are edge cases covered? Are tests meaningful?
## Output Format
At the end, the moderator should produce a structured review summary:
- **CRITICAL** — Must fix before merge
- **IMPORTANT** — Should fix soon
- **MINOR** — Nice to have
- **APPROVED** — Items that passed review

451
meeting_room/server.py Normal file
View File

@@ -0,0 +1,451 @@
"""FastAPI web server for Meeting Room — REST API, WebSocket, and SSE endpoints."""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from contextlib import asynccontextmanager
from typing import Any
from fastapi import APIRouter, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from meeting_room.api_client import APIClient
from meeting_room.config import load_config, load_discussion_config, find_workspace_config
from meeting_room.engine import DiscussionEngine
from meeting_room.events import AGENT_ERROR, AGENT_MESSAGE, BOSS_TURN, AsyncEventBridge, EventEmitter
from meeting_room.models import DiscussionConfig, ProviderConfig
from meeting_room.scenario_loader import load_scenario
from meeting_room.tools import ToolRegistry
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pydantic models for API request/response
# ---------------------------------------------------------------------------
class StartDiscussionRequest(BaseModel):
"""Request body for starting a discussion."""
scenario: str # scenario file path or name
config: str | None = None # optional config path override
save: bool = False
class InjectMessageRequest(BaseModel):
"""Request body for injecting a message."""
role_id: str
content: str
class SessionResponse(BaseModel):
"""Response for session creation."""
id: str
status: str
class SessionDetailResponse(BaseModel):
"""Response for session detail."""
id: str
status: str
scenario_name: str | None = None
message_count: int = 0
# ---------------------------------------------------------------------------
# Session state (renamed from Session to avoid collision with models.Session)
# ---------------------------------------------------------------------------
class ServerSession:
"""Holds state for a single discussion session."""
def __init__(
self,
session_id: str,
config: dict,
discussion_config: DiscussionConfig,
) -> None:
self.id = session_id
self.config = config
self.discussion_config = discussion_config
self.status: str = "created" # created | running | completed | error
self.scenario_name: str | None = None
self.messages: list[dict] = []
self.event_bus = EventEmitter()
self.bridge = AsyncEventBridge(self.event_bus)
self._engine_task: asyncio.Task | None = None
self._collect_handler = None # stored for cleanup
def build_engine(self) -> DiscussionEngine:
"""Build a DiscussionEngine using this session's event_bus."""
providers = {
name: ProviderConfig(**vals)
for name, vals in self.config.get("providers", {}).items()
}
api_client = APIClient(providers=providers)
tool_registry = ToolRegistry(workdir=self.discussion_config.defaults.workdir)
return DiscussionEngine(
config=self.discussion_config,
api_client=api_client,
tool_registry=tool_registry,
events=self.event_bus,
)
def cleanup(self) -> None:
"""Stop bridge and remove event handlers."""
self.bridge.stop()
if self._collect_handler is not None:
for event_type in (AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR):
self.event_bus.off(event_type, self._collect_handler)
self._collect_handler = None
class SessionStore:
"""In-memory session store."""
def __init__(self) -> None:
self._sessions: dict[str, ServerSession] = {}
def create(self, config: dict, discussion_config: DiscussionConfig) -> ServerSession:
session_id = uuid.uuid4().hex[:12]
session = ServerSession(session_id, config, discussion_config)
self._sessions[session_id] = session
return session
def get(self, session_id: str) -> ServerSession | None:
return self._sessions.get(session_id)
def list_sessions(self) -> list[ServerSession]:
return list(self._sessions.values())
# ---------------------------------------------------------------------------
# Application factory
# ---------------------------------------------------------------------------
store = SessionStore()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup/shutdown lifecycle."""
logger.info("Meeting Room server started")
yield
# Cleanup: cancel running engine tasks and stop bridges
for session in store.list_sessions():
if session._engine_task and not session._engine_task.done():
session._engine_task.cancel()
session.cleanup()
logger.info("Meeting Room server stopped")
app = FastAPI(
title="Meeting Room",
version="0.2.1",
description="Multi-agent discussion framework — REST API and WebSocket",
lifespan=lifespan,
)
# Serve static files (web UI)
_static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
@app.get("/")
async def index():
"""Redirect to the web UI."""
return RedirectResponse(url="/static/index.html")
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _load_config_or_default(config_path: str | None) -> dict:
"""Load config from path, workspace, or package default."""
if config_path:
return load_config(config_path)
ws_config = find_workspace_config()
if ws_config:
return load_config(ws_config)
return load_config()
def _resolve_scenario_path(scenario: str) -> str:
"""Resolve scenario path, preventing path traversal.
Only allows scenarios from CWD or the package scenarios directory.
"""
# Reject path traversal
if ".." in scenario:
raise HTTPException(status_code=400, detail="Invalid scenario path: path traversal not allowed")
if os.path.isabs(scenario_path := scenario):
if not os.path.exists(scenario_path):
raise HTTPException(status_code=404, detail=f"Scenario not found: {scenario}")
return scenario_path
# Try relative to CWD first
cwd_path = os.path.join(os.getcwd(), scenario)
if os.path.exists(cwd_path):
return cwd_path
# Try package scenarios directory
pkg_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenarios", scenario)
if os.path.exists(pkg_path):
return pkg_path
raise HTTPException(status_code=404, detail=f"Scenario not found: {scenario}")
def _mask_api_keys(providers: dict) -> dict:
"""Mask API keys in provider config for safe display."""
masked = {}
for name, cfg in providers.items():
masked[name] = {
"base_url": cfg.get("base_url", ""),
"api_key": cfg.get("api_key", "")[:4] + "****" if cfg.get("api_key") else "",
}
return masked
# ---------------------------------------------------------------------------
# REST API (mounted at /api)
# ---------------------------------------------------------------------------
api = APIRouter(prefix="/api")
@api.post("/sessions", response_model=SessionResponse)
async def create_session(config: str | None = None):
"""Create a new discussion session."""
raw_config = _load_config_or_default(config)
discussion_config = load_discussion_config()
session = store.create(raw_config, discussion_config)
return SessionResponse(id=session.id, status=session.status)
@api.get("/sessions", response_model=list[SessionDetailResponse])
async def list_sessions():
"""List all sessions."""
sessions = store.list_sessions()
return [
SessionDetailResponse(
id=s.id,
status=s.status,
scenario_name=s.scenario_name,
message_count=len(s.messages),
)
for s in sessions
]
@api.get("/sessions/{session_id}", response_model=SessionDetailResponse)
async def get_session(session_id: str):
"""Get session details."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return SessionDetailResponse(
id=session.id,
status=session.status,
scenario_name=session.scenario_name,
message_count=len(session.messages),
)
@api.post("/sessions/{session_id}/start", response_model=SessionDetailResponse)
async def start_discussion(session_id: str, body: StartDiscussionRequest):
"""Start a discussion in a session."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.status == "running":
raise HTTPException(status_code=409, detail="Discussion already running")
# Resolve and validate scenario path
scenario_path = _resolve_scenario_path(body.scenario)
scenario_data = load_scenario(scenario_path)
session.scenario_name = scenario_data.get("name", "Untitled")
# Rebuild config if overridden
if body.config:
raw_config = _load_config_or_default(body.config)
discussion_config = load_discussion_config()
session.config = raw_config
session.discussion_config = discussion_config
# Start the bridge on the running event loop
loop = asyncio.get_running_loop()
session.bridge.start(loop)
# Subscribe a handler to collect messages (stored for cleanup)
def collect_message(event):
session.messages.append({
"role_id": event.data.get("role_id", ""),
"name": event.data.get("name", ""),
"content": event.data.get("content", event.data.get("error", "")),
})
session._collect_handler = collect_message
session.event_bus.on(AGENT_MESSAGE, collect_message)
session.event_bus.on(BOSS_TURN, collect_message)
session.event_bus.on(AGENT_ERROR, collect_message)
# Run engine in a thread
engine = session.build_engine()
session.status = "running"
async def run_engine():
try:
await asyncio.to_thread(engine.run, scenario_data)
session.status = "completed"
except Exception as exc:
logger.exception("Engine error in session %s", session.id)
session.status = "error"
finally:
session.cleanup()
session._engine_task = asyncio.create_task(run_engine())
return SessionDetailResponse(
id=session.id,
status=session.status,
scenario_name=session.scenario_name,
message_count=0,
)
@api.post("/sessions/{session_id}/inject", response_model=dict)
async def inject_message(session_id: str, body: InjectMessageRequest):
"""Inject a message from a Boss/CSO role.
Uses the session's event_bus so WebSocket/SSE clients receive the event.
"""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.status != "running":
raise HTTPException(status_code=409, detail="Session is not running")
# Use the session's event_bus — inject_message emits BOSS_TURN on it,
# which the bridge forwards to all subscribers (WebSocket/SSE).
engine = session.build_engine()
msg = engine.inject_message(body.role_id, body.content)
return {"role_id": msg.role_id, "name": msg.name, "content": msg.content}
@api.get("/sessions/{session_id}/messages")
async def get_messages(session_id: str):
"""Get all messages in a session."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session.messages
@api.get("/roles")
async def list_roles(config: str | None = None):
"""List available roles and model bindings."""
raw_config = _load_config_or_default(config)
return raw_config.get("roles", {})
@api.get("/providers")
async def list_providers(config: str | None = None):
"""List configured providers (API keys masked)."""
raw_config = _load_config_or_default(config)
return _mask_api_keys(raw_config.get("providers", {}))
# Mount API router
app.include_router(api)
# ---------------------------------------------------------------------------
# WebSocket
# ---------------------------------------------------------------------------
@app.websocket("/ws/{session_id}")
async def websocket_endpoint(websocket: WebSocket, session_id: str):
"""WebSocket endpoint for real-time event streaming and message injection."""
session = store.get(session_id)
if not session:
await websocket.close(code=4004, reason="Session not found")
return
await websocket.accept()
queue = session.bridge.subscribe()
send_task = asyncio.create_task(_ws_send_loop(websocket, queue))
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "inject":
role_id = data.get("role_id", "boss")
content = data.get("content", "")
# Use session's event_bus so events reach all subscribers
engine = session.build_engine()
engine.inject_message(role_id, content)
except WebSocketDisconnect:
pass
finally:
send_task.cancel()
session.bridge.unsubscribe(queue)
async def _ws_send_loop(websocket: WebSocket, queue: asyncio.Queue[dict]) -> None:
"""Forward events from bridge queue to WebSocket."""
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except asyncio.CancelledError:
pass
# ---------------------------------------------------------------------------
# SSE
# ---------------------------------------------------------------------------
@app.get("/events/{session_id}")
async def sse_endpoint(session_id: str):
"""SSE endpoint for read-only event streaming."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
async def event_generator():
queue = session.bridge.subscribe()
try:
while True:
event = await queue.get()
yield f"data: {json.dumps(event)}\n\n"
except asyncio.CancelledError:
pass
finally:
session.bridge.unsubscribe(queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Room</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--card: #0f3460;
--border: #533483;
--accent: #e94560;
--text: #eee;
--muted: #999;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--surface);
padding: 1rem 2rem;
border-bottom: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 { font-size: 1.4rem; }
header .status {
font-size: 0.85rem;
color: var(--muted);
}
.status .dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.dot.connected { background: #4caf50; }
.dot.disconnected { background: var(--accent); }
main {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.panel h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
color: var(--accent);
}
.controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
select, input, button {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--card);
color: var(--text);
}
button {
cursor: pointer;
border-color: var(--accent);
transition: background 0.2s;
}
button:hover { background: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
#messages {
max-height: 60vh;
overflow-y: auto;
padding: 0.5rem 0;
}
.msg {
padding: 0.75rem 1rem;
margin: 0.5rem 0;
border-radius: 6px;
background: var(--card);
border-left: 3px solid var(--border);
animation: fadeIn 0.3s;
}
.msg .name {
font-weight: 600;
color: var(--accent);
font-size: 0.85rem;
}
.msg .content {
margin-top: 0.3rem;
white-space: pre-wrap;
font-size: 0.9rem;
line-height: 1.5;
}
.msg.tool-call {
border-left-color: #ff9800;
font-size: 0.8rem;
color: var(--muted);
}
.msg.error {
border-left-color: #f44336;
}
.inject-bar {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.inject-bar input { flex: 1; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.round-marker {
text-align: center;
color: var(--muted);
font-size: 0.8rem;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
}
</style>
</head>
<body>
<header>
<h1>Meeting Room</h1>
<div class="status">
<span class="dot disconnected" id="statusDot"></span>
<span id="statusText">Disconnected</span>
</div>
</header>
<main>
<div class="panel">
<h2>Start Discussion</h2>
<div class="controls">
<select id="scenarioSelect">
<option value="">Loading scenarios...</option>
</select>
<button id="startBtn" onclick="startSession()">Start</button>
</div>
</div>
<div class="panel" id="chatPanel" style="display:none">
<h2 id="sessionTitle">Discussion</h2>
<div id="messages"></div>
<div class="inject-bar">
<input id="injectRole" placeholder="Role (boss)" value="boss" style="width:120px">
<input id="injectText" placeholder="Inject message..." onkeydown="if(event.key==='Enter')injectMessage()">
<button onclick="injectMessage()">Send</button>
</div>
</div>
</main>
<script>
let ws = null;
let sessionId = null;
// --- API helpers ---
async function api(path, opts = {}) {
const res = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
}
// --- Load scenarios (placeholder) ---
// In a full implementation, we'd load from /api/scenarios
// For now, scenarios are provided by path
// --- Create and start session ---
async function startSession() {
const scenario = document.getElementById('scenarioSelect').value;
if (!scenario) return alert('Select a scenario first');
try {
// Create session
const sess = await api('/sessions', { method: 'POST' });
sessionId = sess.id;
// Start discussion
await api(`/sessions/${sessionId}/start`, {
method: 'POST',
body: JSON.stringify({ scenario }),
});
// Connect WebSocket
connectWS(sessionId);
document.getElementById('chatPanel').style.display = 'block';
document.getElementById('startBtn').disabled = true;
} catch (e) {
alert('Error: ' + e.message);
}
}
// --- WebSocket ---
function connectWS(sid) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/${sid}`);
ws.onopen = () => {
document.getElementById('statusDot').className = 'dot connected';
document.getElementById('statusText').textContent = 'Connected';
};
ws.onclose = () => {
document.getElementById('statusDot').className = 'dot disconnected';
document.getElementById('statusText').textContent = 'Disconnected';
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleEvent(data);
};
}
// --- Handle events ---
const ROLE_COLORS = {
moderator: '#4fc3f7',
skeptic: '#ef5350',
idea_generator: '#66bb6a',
analyst: '#ffa726',
boss: '#ab47bc',
defender: '#42a5f5',
attacker: '#ff7043',
security: '#78909c',
};
function handleEvent(data) {
const container = document.getElementById('messages');
const type = data.type;
const d = data.data || {};
if (type === 'discussion_start') {
document.getElementById('sessionTitle').textContent = d.name || 'Discussion';
container.innerHTML = '';
} else if (type === 'round_start') {
const el = document.createElement('div');
el.className = 'round-marker';
el.textContent = `— Round ${d.round_num}/${d.total_rounds}`;
container.appendChild(el);
} else if (type === 'agent_message' || type === 'boss_turn') {
const el = document.createElement('div');
el.className = 'msg';
const color = ROLE_COLORS[d.role_id] || '#999';
el.innerHTML = `<div class="name" style="color:${color}">${escapeHtml(d.name)}</div><div class="content">${escapeHtml(d.content || '')}</div>`;
container.appendChild(el);
} else if (type === 'agent_error') {
const el = document.createElement('div');
el.className = 'msg error';
el.innerHTML = `<div class="name">${escapeHtml(d.name)}</div><div class="content">Error: ${escapeHtml(d.error || '')}</div>`;
container.appendChild(el);
} else if (type === 'tool_call') {
const el = document.createElement('div');
el.className = 'msg tool-call';
el.textContent = `🔧 ${d.tool_name}(${d.arguments || ''})`;
container.appendChild(el);
} else if (type === 'discussion_end') {
const el = document.createElement('div');
el.className = 'round-marker';
el.textContent = `— Discussion complete (${d.total_messages} messages) —`;
container.appendChild(el);
}
container.scrollTop = container.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Inject message ---
function injectMessage() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('Not connected');
return;
}
const role = document.getElementById('injectRole').value || 'boss';
const content = document.getElementById('injectText').value;
if (!content) return;
ws.send(JSON.stringify({ type: 'inject', role_id: role, content }));
document.getElementById('injectText').value = '';
}
// --- Init: fetch scenarios (todo: add API endpoint) ---
// For now, just show a text input
document.getElementById('scenarioSelect').outerHTML =
'<input id="scenarioSelect" placeholder="Scenario path (e.g. scenarios/example.md)" style="flex:1">';
</script>
</body>
</html>

View File

@@ -1,32 +1,17 @@
"""
Tools system for Meeting Room agents.
Provides file access and web capabilities via OpenAI function calling.
The ToolRegistry class encapsulates all tool logic with a configurable
workdir. Module-level wrappers maintain backward compatibility.
"""
import fnmatch
import httpx
import json
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) ───
@@ -36,13 +21,13 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "read_file",
"description": "Прочитать содержимое файла. Возвращает текст с номерами строк.",
"description": "Read file contents. Returns text with line numbers.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Путь к файлу"},
"offset": {"type": "integer", "description": "Начать с этой строки (1-indexed)", "default": 1},
"limit": {"type": "integer", "description": "Максимум строк", "default": 100},
"path": {"type": "string", "description": "Path to the file"},
"offset": {"type": "integer", "description": "Start from this line (1-indexed)", "default": 1},
"limit": {"type": "integer", "description": "Maximum number of lines", "default": 100},
},
"required": ["path"],
},
@@ -52,13 +37,13 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "list_files",
"description": "Показать список файлов и директорий.",
"description": "List files and directories.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Директория для просмотра"},
"pattern": {"type": "string", "description": "Фильтр по имени (glob)"},
"max_depth": {"type": "integer", "description": "Глубина обхода", "default": 3},
"path": {"type": "string", "description": "Directory to list"},
"pattern": {"type": "string", "description": "Filter by name (glob)"},
"max_depth": {"type": "integer", "description": "Traversal depth", "default": 3},
},
"required": [],
},
@@ -68,13 +53,13 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "search_in_files",
"description": "Поиск текста в файлах (regex).",
"description": "Search text in files (regex).",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Поисковый запрос (regex)"},
"path": {"type": "string", "description": "Директория для поиска"},
"file_pattern": {"type": "string", "description": "Фильтр по расширению"},
"query": {"type": "string", "description": "Search query (regex)"},
"path": {"type": "string", "description": "Directory to search in"},
"file_pattern": {"type": "string", "description": "Filter by extension (e.g. *.py)"},
},
"required": ["query"],
},
@@ -84,12 +69,12 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "web_search",
"description": "Поиск в интернете. Возвращает заголовки, сниппеты и URL.",
"description": "Search the web. Returns titles, snippets and URLs.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Поисковый запрос"},
"max_results": {"type": "integer", "description": "Максимум результатов", "default": 5},
"query": {"type": "string", "description": "Search query"},
"max_results": {"type": "integer", "description": "Maximum results", "default": 5},
},
"required": ["query"],
},
@@ -99,12 +84,12 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "web_fetch",
"description": "Скачать содержимое веб-страницы.",
"description": "Fetch web page content.",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL для загрузки"},
"max_length": {"type": "integer", "description": "Максимум символов", "default": 5000},
"url": {"type": "string", "description": "URL to fetch"},
"max_length": {"type": "integer", "description": "Maximum characters", "default": 5000},
},
"required": ["url"],
},
@@ -114,12 +99,12 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "write_file",
"description": "Записать содержимое в файл.",
"description": "Write content to a file.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Путь к файлу"},
"content": {"type": "string", "description": "Содержимое"},
"path": {"type": "string", "description": "Path to the file"},
"content": {"type": "string", "description": "Content to write"},
},
"required": ["path", "content"],
},
@@ -129,12 +114,12 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "run_command",
"description": "Выполнить shell-команду.",
"description": "Run a shell command.",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Команда"},
"timeout": {"type": "integer", "description": "Таймаут (сек)", "default": 30},
"command": {"type": "string", "description": "Command to run"},
"timeout": {"type": "integer", "description": "Timeout in seconds", "default": 30},
},
"required": ["command"],
},
@@ -142,170 +127,251 @@ TOOL_SCHEMAS = [
},
]
# Directories to skip during file traversal
_SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".mypy_cache", ".pytest_cache"}
# ─── 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 set definitions
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": [],
"all": ["read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"],
"full": ["read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"],
"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 _truncate(text: str, max_len: int = 5000) -> str:
if len(text) <= max_len:
return text
return text[:max_len] + f"\n... [truncated, {len(text)} chars total]"
class ToolRegistry:
"""Manages tool schemas and execution for a given working directory."""
def __init__(self, workdir: str = ".") -> None:
self._workdir = os.path.abspath(workdir)
# ── Path resolution ──
def _resolve_path(self, path: str) -> str:
if os.path.isabs(path):
return path
return os.path.normpath(os.path.join(self._workdir, path))
# ── Schema / dispatch ──
def get_tool_schemas(self, tool_set: str | list = "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(self, name: str, arguments: dict) -> str:
method_map = {
"read_file": self.tool_read_file,
"list_files": self.tool_list_files,
"search_in_files": self.tool_search_in_files,
"web_search": self.tool_web_search,
"web_fetch": self.tool_web_fetch,
"write_file": self.tool_write_file,
"run_command": self.tool_run_command,
}
func = method_map.get(name)
if not func:
return f"Unknown tool: {name}"
try:
return func(**arguments)
except Exception as e:
return f"Error executing {name}: {e}"
# ── Tool implementations ──
def tool_read_file(self, path: str, offset: int = 1, limit: int = 100) -> str:
resolved = self._resolve_path(path)
if not os.path.isfile(resolved):
return f"Error: file not found — {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} lines]\n{result}")
except Exception as e:
return f"Error reading file: {e}"
def tool_list_files(self, path: str = "", pattern: str = "", max_depth: int = 3) -> str:
base = self._resolve_path(path or ".")
if not os.path.isdir(base):
return f"Error: directory not found — {base}"
try:
results: list[str] = []
base_depth = base.count(os.sep)
for dirpath, dirnames, filenames in os.walk(base):
# Prune skipped directories in-place so os.walk skips them
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
# Check depth
current_depth = dirpath.count(os.sep) - base_depth
if current_depth >= max_depth:
# Don't descend further, but still list this dir's files
dirnames.clear()
# Add directory itself
rel_dir = os.path.relpath(dirpath, base)
if rel_dir == ".":
results.append(dirpath)
else:
results.append(dirpath)
# Add files
for fname in filenames:
if pattern and not fnmatch.fnmatch(fname, pattern):
continue
results.append(os.path.join(dirpath, fname))
results.sort()
return _truncate("\n".join(results))
except Exception as e:
return f"Error: {e}"
def tool_search_in_files(self, query: str, path: str = "", file_pattern: str = "") -> str:
base = self._resolve_path(path or ".")
if not os.path.isdir(base):
return f"Error: directory not found — {base}"
try:
regex = re.compile(query, re.IGNORECASE)
except re.error as e:
return f"Error: invalid regex — {e}"
try:
results: list[str] = []
for dirpath, dirnames, filenames in os.walk(base):
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
for fname in filenames:
if file_pattern and not fnmatch.fnmatch(fname, file_pattern):
continue
fpath = os.path.join(dirpath, fname)
try:
with open(fpath, encoding="utf-8", errors="replace") as f:
for line_num, line in enumerate(f, start=1):
if regex.search(line):
results.append(f"{fpath}:{line_num}:{line.rstrip()}")
if len(results) >= 200:
return _truncate("\n".join(results))
except (OSError, UnicodeDecodeError):
continue
if not results:
return "No matches found."
return _truncate("\n".join(results))
except Exception as e:
return f"Error: {e}"
def tool_web_search(self, 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 "No results found."
return _truncate("\n\n".join(f"[{i+1}] {r}" for i, r in enumerate(results)))
except Exception as e:
return f"Search error: {e}"
def tool_web_fetch(self, 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)} chars]\n{text}", max_length)
except Exception as e:
return f"Fetch error: {e}"
def tool_write_file(self, path: str, content: str) -> str:
resolved = self._resolve_path(path)
try:
os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True)
with open(resolved, "w", encoding="utf-8") as f:
f.write(content)
return f"Wrote {len(content)} chars to {resolved}"
except Exception as e:
return f"Write error: {e}"
def tool_run_command(self, command: str, timeout: int = 30) -> str:
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=True,
timeout=timeout, cwd=self._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 ({timeout}s)"
except Exception as e:
return f"Error: {e}"
# ─── Module-level backward-compatible wrappers ───
_default_registry: ToolRegistry | None = None
def set_workdir(path: str) -> None:
"""Set the working directory for module-level tool functions."""
global _default_registry
_default_registry = ToolRegistry(workdir=path)
def _get_registry() -> ToolRegistry:
global _default_registry
if _default_registry is None:
_default_registry = ToolRegistry()
return _default_registry
def get_tool_schemas(tool_set: str | list = "all") -> list[dict]:
"""Return tool schemas for the given set name or explicit list."""
return _get_registry().get_tool_schemas(tool_set)
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}"
"""Execute a tool by name with the given arguments."""
return _get_registry().execute_tool(name, arguments)
# Legacy dict-based dispatch (kept for any code that imports TOOL_FUNCTIONS)
TOOL_FUNCTIONS = {
"read_file": lambda **kw: _get_registry().tool_read_file(**kw),
"list_files": lambda **kw: _get_registry().tool_list_files(**kw),
"search_in_files": lambda **kw: _get_registry().tool_search_in_files(**kw),
"web_search": lambda **kw: _get_registry().tool_web_search(**kw),
"web_fetch": lambda **kw: _get_registry().tool_web_fetch(**kw),
"write_file": lambda **kw: _get_registry().tool_write_file(**kw),
"run_command": lambda **kw: _get_registry().tool_run_command(**kw),
}

View File

@@ -11,13 +11,14 @@ requires-python = ">=3.11"
license = {text = "MIT"}
dependencies = [
"httpx>=0.27",
"pydantic>=2.0",
"pyyaml>=6.0",
]
[project.optional-dependencies]
autogen = ["autogen-agentchat", "autogen-ext"]
crewai = ["crewai"]
all = ["autogen-agentchat", "autogen-ext", "crewai"]
dev = ["pytest>=7.0", "pytest-mock>=3.0", "pytest-asyncio>=0.23"]
web = ["fastapi>=0.100", "uvicorn>=0.20", "websockets>=11.0"]
[project.scripts]
meeting-room = "meeting_room.cli:main"

View File

@@ -1,2 +1,3 @@
httpx>=0.27
pyyaml>=6.0
pydantic>=2.0
pyyaml>=6.0

0
tests/__init__.py Normal file
View File

359
tests/test_api_client.py Normal file
View File

@@ -0,0 +1,359 @@
"""Tests for meeting_room.api_client — APIClient class and module-level wrappers."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
from meeting_room.api_client import APIClient, chat, chat_stream, init_providers
from meeting_room.models import ProviderConfig
# ---------------------------------------------------------------------------
# Fixtures & helpers
# ---------------------------------------------------------------------------
PROVIDERS = {
"testprov": ProviderConfig(
base_url="https://api.test.example.com/v1",
api_key="sk-test-key-123",
),
}
def _make_chat_response(
content: str = "Hello!",
tool_calls: list[dict] | None = None,
) -> dict:
"""Build a minimal OpenAI-style chat completion response."""
message: dict = {"content": content}
if tool_calls:
message["tool_calls"] = tool_calls
return {"choices": [{"message": message}]}
def _make_stream_lines(chunks: list[str]) -> list[str]:
"""Build SSE lines that look like what OpenAI streaming returns.
Each chunk becomes a ``data: {json}`` line. A ``data: [DONE]``
sentinel is appended automatically.
"""
lines: list[str] = []
for text in chunks:
delta = {"choices": [{"delta": {"content": text}}]}
lines.append(f"data: {json.dumps(delta)}")
lines.append("data: [DONE]")
return lines
# ---------------------------------------------------------------------------
# APIClient — construction
# ---------------------------------------------------------------------------
class TestAPIClientInit:
def test_stores_providers(self) -> None:
client = APIClient(PROVIDERS)
assert "testprov" in client.providers
assert client.providers["testprov"].base_url == "https://api.test.example.com/v1"
def test_empty_providers_ok(self) -> None:
client = APIClient({})
assert client.providers == {}
# ---------------------------------------------------------------------------
# APIClient.chat
# ---------------------------------------------------------------------------
class TestAPIClientChat:
@patch("meeting_room.api_client.httpx.Client")
def test_basic_text_response(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("Hi there")
mock_resp.raise_for_status = MagicMock()
mock_client_instance = mock_client_cls.return_value
mock_client_instance.post.return_value = mock_resp
client = APIClient(PROVIDERS)
result = client.chat("testprov", "gpt-4o-mini", [{"role": "user", "content": "Hi"}])
assert result == {"content": "Hi there", "tool_calls": None}
mock_client_instance.post.assert_called_once()
call_args = mock_client_instance.post.call_args
assert call_args[0][0] == "/chat/completions"
payload = call_args[1]["json"]
assert payload["model"] == "gpt-4o-mini"
assert payload["messages"] == [{"role": "user", "content": "Hi"}]
assert payload["temperature"] == 0.7
@patch("meeting_room.api_client.httpx.Client")
def test_custom_temperature(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("cold")
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
client = APIClient(PROVIDERS)
client.chat("testprov", "m", [], temperature=0.2)
payload = mock_client_cls.return_value.post.call_args[1]["json"]
assert payload["temperature"] == 0.2
@patch("meeting_room.api_client.httpx.Client")
def test_tool_calls_in_response(self, mock_client_cls: MagicMock) -> None:
tool_calls = [
{
"id": "call_abc",
"function": {"name": "read_file", "arguments": '{"path": "/tmp/x"}'},
"type": "function",
}
]
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("", tool_calls=tool_calls)
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
client = APIClient(PROVIDERS)
result = client.chat("testprov", "m", [])
assert result["tool_calls"] == [
{"id": "call_abc", "name": "read_file", "arguments": '{"path": "/tmp/x"}'}
]
@patch("meeting_room.api_client.httpx.Client")
def test_tools_sent_in_payload(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("ok")
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
tools = [{"type": "function", "function": {"name": "search"}}]
client = APIClient(PROVIDERS)
client.chat("testprov", "m", [], tools=tools)
payload = mock_client_cls.return_value.post.call_args[1]["json"]
assert "tools" in payload
assert payload["tool_choice"] == "auto"
def test_unknown_provider_raises(self) -> None:
client = APIClient(PROVIDERS)
with pytest.raises(ValueError, match="Unknown provider: nonexistent"):
client.chat("nonexistent", "m", [])
@patch("meeting_room.api_client.httpx.Client")
def test_empty_content_yields_empty_string(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.json.return_value = {"choices": [{"message": {"content": None}}]}
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
client = APIClient(PROVIDERS)
result = client.chat("testprov", "m", [])
assert result["content"] == ""
@patch("meeting_room.api_client.httpx.Client")
def test_http_error_propagates(self, mock_client_cls: MagicMock) -> None:
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"Bad Request",
request=MagicMock(),
response=httpx.Response(400),
)
mock_client_cls.return_value.post.return_value = mock_resp
client = APIClient(PROVIDERS)
with pytest.raises(httpx.HTTPStatusError):
client.chat("testprov", "m", [])
# ---------------------------------------------------------------------------
# APIClient.chat_stream
# ---------------------------------------------------------------------------
class TestAPIClientChatStream:
@patch("meeting_room.api_client.httpx.Client")
def test_accumulates_text(self, mock_client_cls: MagicMock) -> None:
"""chat_stream must return the concatenated text, NOT print."""
lines = _make_stream_lines(["Hello", " world", "!"])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_instance = mock_client_cls.return_value
mock_client_instance.stream.return_value = mock_response
client = APIClient(PROVIDERS)
result = client.chat_stream("testprov", "gpt-4o-mini", [{"role": "user", "content": "Hi"}])
assert result == "Hello world!"
@patch("meeting_room.api_client.httpx.Client")
def test_stream_payload_includes_stream_flag(self, mock_client_cls: MagicMock) -> None:
lines = _make_stream_lines(["x"])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
client = APIClient(PROVIDERS)
client.chat_stream("testprov", "m", [])
call_args = mock_client_cls.return_value.stream.call_args
payload = call_args[1]["json"]
assert payload["stream"] is True
@patch("meeting_room.api_client.httpx.Client")
def test_empty_stream_returns_empty_string(self, mock_client_cls: MagicMock) -> None:
lines = _make_stream_lines([])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
client = APIClient(PROVIDERS)
result = client.chat_stream("testprov", "m", [])
assert result == ""
@patch("meeting_room.api_client.httpx.Client")
def test_malformed_lines_skipped(self, mock_client_cls: MagicMock) -> None:
"""Lines that are not valid SSE data should be silently skipped."""
raw_lines = [
"", # empty line — skipped
"not SSE", # no "data: " prefix — skipped
"data: {bad json", # malformed JSON — skipped
'data: {"choices":[{"delta":{"content":"ok"}}]}', # valid
"data: [DONE]",
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = raw_lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
client = APIClient(PROVIDERS)
result = client.chat_stream("testprov", "m", [])
assert result == "ok"
@patch("meeting_room.api_client.httpx.Client")
def test_does_not_print(self, mock_client_cls: MagicMock, capsys: pytest.CaptureFixture) -> None:
"""chat_stream must NOT write to stdout."""
lines = _make_stream_lines(["Hello", " world"])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
client = APIClient(PROVIDERS)
result = client.chat_stream("testprov", "m", [])
captured = capsys.readouterr()
assert captured.out == ""
assert result == "Hello world"
def test_unknown_provider_raises(self) -> None:
client = APIClient(PROVIDERS)
with pytest.raises(ValueError, match="Unknown provider: nope"):
client.chat_stream("nope", "m", [])
# ---------------------------------------------------------------------------
# Module-level wrappers — backward compatibility
# ---------------------------------------------------------------------------
class TestModuleLevelWrappers:
def test_init_providers_creates_default_client(self) -> None:
import meeting_room.api_client as mod
config = {
"providers": {
"myprov": {"base_url": "https://api.example.com/v1", "api_key": "sk-abc"},
}
}
init_providers(config)
assert mod._default_client is not None
assert "myprov" in mod._default_client.providers
assert mod._default_client.providers["myprov"].api_key == "sk-abc"
@patch("meeting_room.api_client.httpx.Client")
def test_module_chat_delegates(self, mock_client_cls: MagicMock) -> None:
import meeting_room.api_client as mod
init_providers({"providers": {"prov": {"base_url": "https://x/v1", "api_key": "k"}}})
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("delegated")
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
result = mod.chat("prov", "m", [])
assert result["content"] == "delegated"
@patch("meeting_room.api_client.httpx.Client")
def test_module_chat_stream_delegates(self, mock_client_cls: MagicMock) -> None:
import meeting_room.api_client as mod
init_providers({"providers": {"prov": {"base_url": "https://x/v1", "api_key": "k"}}})
lines = _make_stream_lines(["abc"])
mock_response = MagicMock()
mock_response.iter_lines.return_value = lines
mock_response.raise_for_status = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_client_cls.return_value.stream.return_value = mock_response
result = mod.chat_stream("prov", "m", [])
assert result == "abc"
def test_module_chat_without_init_raises(self) -> None:
import meeting_room.api_client as mod
mod._default_client = None # ensure clean state
with pytest.raises(RuntimeError, match="init_providers"):
mod.chat("prov", "m", [])
def test_module_chat_stream_without_init_raises(self) -> None:
import meeting_room.api_client as mod
mod._default_client = None
with pytest.raises(RuntimeError, match="init_providers"):
mod.chat_stream("prov", "m", [])
# ---------------------------------------------------------------------------
# APIClient._get_client — httpx.Client construction
# ---------------------------------------------------------------------------
class TestGetClient:
@patch("meeting_room.api_client.httpx.Client")
def test_client_created_with_correct_params(self, mock_client_cls: MagicMock) -> None:
client = APIClient(PROVIDERS)
# Trigger _get_client by calling chat with a mocked response
mock_resp = MagicMock()
mock_resp.json.return_value = _make_chat_response("ok")
mock_resp.raise_for_status = MagicMock()
mock_client_cls.return_value.post.return_value = mock_resp
client.chat("testprov", "m", [])
mock_client_cls.assert_called_once_with(
base_url="https://api.test.example.com/v1",
headers={"Authorization": "Bearer sk-test-key-123"},
timeout=120.0,
)

225
tests/test_async_bridge.py Normal file
View File

@@ -0,0 +1,225 @@
"""Tests for AsyncEventBridge — sync EventEmitter → asyncio.Queue bridge."""
from __future__ import annotations
import asyncio
import pytest
from meeting_room.events import (
AGENT_MESSAGE,
AGENT_TURN_START,
DISCUSSION_END,
DISCUSSION_START,
ROUND_END,
ROUND_START,
TOOL_CALL,
AsyncEventBridge,
EventEmitter,
)
class TestAsyncEventBridgeSubscribe:
"""subscribe() creates a queue that receives events."""
@pytest.mark.asyncio
async def test_subscribe_returns_queue(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
q = bridge.subscribe()
assert isinstance(q, asyncio.Queue)
@pytest.mark.asyncio
async def test_multiple_subscribers(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
q1 = bridge.subscribe()
q2 = bridge.subscribe()
assert q1 is not q2
class TestAsyncEventBridgeStartStop:
"""start()/stop() wire the bridge to the sync bus."""
@pytest.mark.asyncio
async def test_start_subscribes_to_all_event_types(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
loop = asyncio.get_event_loop()
bridge.start(loop)
for event_type in [
DISCUSSION_START, ROUND_START, AGENT_TURN_START,
AGENT_MESSAGE, TOOL_CALL, ROUND_END, DISCUSSION_END,
]:
assert len(bus.handlers(event_type)) == 1
bridge.stop()
@pytest.mark.asyncio
async def test_stop_unsubscribes(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
loop = asyncio.get_event_loop()
bridge.start(loop)
bridge.stop()
for event_type in [
DISCUSSION_START, ROUND_START, AGENT_TURN_START,
AGENT_MESSAGE, TOOL_CALL, ROUND_END, DISCUSSION_END,
]:
assert len(bus.handlers(event_type)) == 0
class TestAsyncEventBridgeForwarding:
"""Events emitted on the sync bus arrive in async queues."""
@pytest.mark.asyncio
async def test_forward_event_to_queue(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
loop = asyncio.get_event_loop()
bridge.start(loop)
q = bridge.subscribe()
# Emit a sync event
bus.emit(AGENT_MESSAGE, role_id="moderator", name="Moderator", content="Hello")
# Give the event loop a chance to process call_soon_threadsafe
await asyncio.sleep(0.05)
payload = q.get_nowait()
assert payload["type"] == AGENT_MESSAGE
assert payload["data"]["role_id"] == "moderator"
assert payload["data"]["content"] == "Hello"
assert "timestamp" in payload
bridge.stop()
@pytest.mark.asyncio
async def test_multiple_queues_receive_same_event(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
loop = asyncio.get_event_loop()
bridge.start(loop)
q1 = bridge.subscribe()
q2 = bridge.subscribe()
bus.emit(DISCUSSION_START, name="test")
await asyncio.sleep(0.05)
p1 = q1.get_nowait()
p2 = q2.get_nowait()
assert p1["type"] == DISCUSSION_START
assert p2["type"] == DISCUSSION_START
assert p1["data"]["name"] == "test"
assert p2["data"]["name"] == "test"
bridge.stop()
@pytest.mark.asyncio
async def test_unsubscribe_stops_delivery(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
loop = asyncio.get_event_loop()
bridge.start(loop)
q = bridge.subscribe()
bridge.unsubscribe(q)
bus.emit(AGENT_MESSAGE, role_id="x", name="X", content="Y")
await asyncio.sleep(0.05)
assert q.empty()
bridge.stop()
class TestAsyncEventBridgeThreadedForwarding:
"""Events from a worker thread reach async consumers."""
@pytest.mark.asyncio
async def test_forward_from_thread(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
loop = asyncio.get_event_loop()
bridge.start(loop)
q = bridge.subscribe()
# Run engine in a thread (simplified)
def sync_work() -> None:
bus.emit(DISCUSSION_START, name="threaded")
bus.emit(AGENT_MESSAGE, role_id="bot", name="Bot", content="Hi")
bus.emit(DISCUSSION_END, total_messages=1)
await asyncio.to_thread(sync_work)
await asyncio.sleep(0.1)
events = []
while not q.empty():
events.append(q.get_nowait())
types = [e["type"] for e in events]
assert "discussion_start" in types
assert "agent_message" in types
assert "discussion_end" in types
bridge.stop()
class TestAsyncEventBridgePayloadFormat:
"""Serialized events contain type, data, and ISO timestamp."""
@pytest.mark.asyncio
async def test_payload_structure(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
loop = asyncio.get_event_loop()
bridge.start(loop)
q = bridge.subscribe()
bus.emit(TOOL_CALL, role_id="analyst", tool_name="search", arguments='{"q":"test"}')
await asyncio.sleep(0.05)
payload = q.get_nowait()
assert "type" in payload
assert "data" in payload
assert "timestamp" in payload
# timestamp is ISO format string
assert isinstance(payload["timestamp"], str)
assert payload["data"]["tool_name"] == "search"
bridge.stop()
class TestAsyncEventBridgeDropPolicy:
"""When a queue is full, events are dropped without crashing."""
@pytest.mark.asyncio
async def test_full_queue_drops_events(self) -> None:
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
loop = asyncio.get_event_loop()
bridge.start(loop)
# maxsize=2 to make it fill quickly
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=2)
with bridge._lock:
bridge._queues.append(q)
# Emit 5 events — queue can hold 2
for i in range(5):
bus.emit(AGENT_MESSAGE, role_id="x", name="X", content=f"msg{i}")
await asyncio.sleep(0.1)
# Queue should have at most 2 items (rest dropped)
received = []
while not q.empty():
received.append(q.get_nowait())
assert len(received) <= 2
bridge.stop()

263
tests/test_config.py Normal file
View File

@@ -0,0 +1,263 @@
"""Tests for meeting_room.config — config loading and DiscussionConfig conversion."""
from __future__ import annotations
import os
import tempfile
import pytest
import yaml
from meeting_room.config import (
find_workspace_config,
load_config,
load_discussion_config,
)
from meeting_room.models import (
DefaultsConfig,
DiscussionConfig,
ProviderConfig,
RoleConfig,
)
# ---------------------------------------------------------------------------
# load_config — basic loading
# ---------------------------------------------------------------------------
class TestLoadConfig:
def test_loads_default_config(self) -> None:
"""load_config() with no path loads the package default config.yaml."""
config = load_config()
assert "providers" in config
assert "roles" in config
assert "defaults" in config
def test_loads_custom_config(self, tmp_path) -> None:
"""load_config(path) loads a specific YAML file."""
custom = {
"providers": {
"test": {"base_url": "https://api.test.com/v1", "api_key": "sk-test"},
},
"roles": {
"analyst": {
"name": "Analyst",
"provider": "test",
"model": "gpt-3.5",
"temperature": 0.5,
"tools": "none",
"system_prompt": "You are an analyst.",
},
},
"defaults": {"max_rounds": 5, "language": "en"},
}
config_path = tmp_path / "config.yaml"
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
config = load_config(str(config_path))
assert config["providers"]["test"]["base_url"] == "https://api.test.com/v1"
assert config["roles"]["analyst"]["name"] == "Analyst"
assert config["defaults"]["max_rounds"] == 5
def test_env_var_resolution(self, tmp_path, monkeypatch) -> None:
"""${ENV_VAR} placeholders are resolved from the environment."""
monkeypatch.setenv("TEST_MR_API_KEY", "sk-from-env")
monkeypatch.setenv("TEST_MR_BASE_URL", "https://env-url.com/v1")
custom = {
"providers": {
"testprov": {
"base_url": "${TEST_MR_BASE_URL}",
"api_key": "${TEST_MR_API_KEY}",
},
},
"roles": {},
"defaults": {"max_rounds": 3},
}
config_path = tmp_path / "config.yaml"
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
config = load_config(str(config_path))
assert config["providers"]["testprov"]["base_url"] == "https://env-url.com/v1"
assert config["providers"]["testprov"]["api_key"] == "sk-from-env"
def test_unresolved_env_var_preserved(self, tmp_path) -> None:
"""Unresolved ${ENV_VAR} stays as-is in the config."""
custom = {
"providers": {
"testprov": {
"base_url": "https://api.test.com/v1",
"api_key": "${NONEXISTENT_VAR_12345}",
},
},
"roles": {},
"defaults": {},
}
config_path = tmp_path / "config.yaml"
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
config = load_config(str(config_path))
assert config["providers"]["testprov"]["api_key"] == "${NONEXISTENT_VAR_12345}"
def test_env_override_api_key(self, monkeypatch) -> None:
"""MEETING_ROOM_<PROV>_API_KEY overrides the config value."""
monkeypatch.setenv("MEETING_ROOM_ROUTERAI_API_KEY", "sk-override")
config = load_config()
assert config["providers"]["routerai"]["api_key"] == "sk-override"
def test_env_override_base_url(self, monkeypatch) -> None:
"""MEETING_ROOM_<PROV>_BASE_URL overrides the config value."""
monkeypatch.setenv("MEETING_ROOM_ROUTERAI_BASE_URL", "https://override.com/v1")
config = load_config()
assert config["providers"]["routerai"]["base_url"] == "https://override.com/v1"
# ---------------------------------------------------------------------------
# find_workspace_config
# ---------------------------------------------------------------------------
class TestFindWorkspaceConfig:
def test_finds_config_in_cwd(self, tmp_path, monkeypatch) -> None:
"""Finds .meeting-room/config.yaml in the current working directory."""
ws_dir = tmp_path / ".meeting-room"
ws_dir.mkdir()
(ws_dir / "config.yaml").write_text("defaults: {}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
result = find_workspace_config()
assert result is not None
assert result.endswith(".meeting-room\\config.yaml") or result.endswith(".meeting-room/config.yaml")
def test_finds_config_in_parent(self, tmp_path, monkeypatch) -> None:
"""Searches upward to find .meeting-room/config.yaml."""
ws_dir = tmp_path / ".meeting-room"
ws_dir.mkdir()
(ws_dir / "config.yaml").write_text("defaults: {}", encoding="utf-8")
child = tmp_path / "subdir"
child.mkdir()
monkeypatch.chdir(child)
result = find_workspace_config()
assert result is not None
def test_returns_none_when_not_found(self, tmp_path, monkeypatch) -> None:
"""Returns None when no .meeting-room/config.yaml exists."""
# Use a tmp dir with no .meeting-room
isolated = tmp_path / "isolated"
isolated.mkdir()
monkeypatch.chdir(isolated)
# Ensure no .meeting-room exists anywhere up to root
# (This may find a .meeting-room in a parent if one exists,
# but in a temp dir hierarchy it shouldn't)
result = find_workspace_config()
# We can't guarantee None since there might be .meeting-room in parent dirs,
# so we just check it's either None or a valid path
if result is not None:
assert os.path.exists(result)
# ---------------------------------------------------------------------------
# load_discussion_config
# ---------------------------------------------------------------------------
class TestLoadDiscussionConfig:
def test_returns_discussion_config(self) -> None:
"""load_discussion_config() returns a DiscussionConfig instance."""
config = load_discussion_config()
assert isinstance(config, DiscussionConfig)
assert isinstance(config.providers, dict)
assert isinstance(config.roles, dict)
assert isinstance(config.defaults, DefaultsConfig)
def test_providers_are_typed(self) -> None:
"""Provider values are ProviderConfig instances."""
config = load_discussion_config()
for name, prov in config.providers.items():
assert isinstance(prov, ProviderConfig), f"Provider {name} is not ProviderConfig"
assert prov.base_url
assert prov.api_key is not None
def test_roles_are_typed(self) -> None:
"""Role values are RoleConfig instances."""
config = load_discussion_config()
for name, role in config.roles.items():
assert isinstance(role, RoleConfig), f"Role {name} is not RoleConfig"
assert role.name
assert role.provider
assert role.model
def test_defaults_populated(self) -> None:
"""DefaultsConfig has expected fields."""
config = load_discussion_config()
assert config.defaults.max_rounds > 0
assert config.defaults.language is not None
assert config.defaults.framework is not None
def test_custom_config_path(self, tmp_path) -> None:
"""load_discussion_config(path) loads from a custom file."""
custom = {
"providers": {
"test": {"base_url": "https://api.test.com/v1", "api_key": "sk-test"},
},
"roles": {
"analyst": {
"name": "Analyst",
"provider": "test",
"model": "gpt-3.5",
"temperature": 0.5,
"tools": "none",
"system_prompt": "You are an analyst.",
},
},
"defaults": {"max_rounds": 5, "language": "en"},
}
config_path = tmp_path / "config.yaml"
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
config = load_discussion_config(str(config_path))
assert isinstance(config, DiscussionConfig)
assert "test" in config.providers
assert config.providers["test"].base_url == "https://api.test.com/v1"
assert "analyst" in config.roles
assert config.roles["analyst"].model == "gpt-3.5"
assert config.defaults.max_rounds == 5
def test_env_overrides_applied_before_typing(self, monkeypatch) -> None:
"""Environment variable overrides are applied before converting to typed config."""
monkeypatch.setenv("MEETING_ROOM_ROUTERAI_API_KEY", "sk-from-env")
config = load_discussion_config()
assert config.providers["routerai"].api_key == "sk-from-env"
def test_minimal_config(self, tmp_path) -> None:
"""A minimal config with just defaults works."""
minimal = {
"providers": {},
"roles": {},
"defaults": {"max_rounds": 3},
}
config_path = tmp_path / "config.yaml"
config_path.write_text(yaml.dump(minimal, default_flow_style=False), encoding="utf-8")
config = load_discussion_config(str(config_path))
assert isinstance(config, DiscussionConfig)
assert len(config.providers) == 0
assert len(config.roles) == 0
assert config.defaults.max_rounds == 3

646
tests/test_engine.py Normal file
View File

@@ -0,0 +1,646 @@
"""Tests for meeting_room.engine — DiscussionEngine."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from meeting_room.api_client import APIClient
from meeting_room.engine import DiscussionEngine
from meeting_room.events import (
AGENT_ERROR,
AGENT_MESSAGE,
AGENT_TURN_START,
BOSS_TURN,
DISCUSSION_END,
DISCUSSION_START,
ROUND_END,
ROUND_START,
TOOL_CALL,
TOOL_RESULT,
Event,
EventEmitter,
)
from meeting_room.models import (
DefaultsConfig,
DiscussionConfig,
ProviderConfig,
RoleConfig,
ScenarioConfig,
)
from meeting_room.tools import ToolRegistry
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
PROVIDERS = {
"openai": ProviderConfig(base_url="https://api.openai.com/v1", api_key="sk-test"),
}
ROLES = {
"analyst": RoleConfig(
name="Analyst",
provider="openai",
model="gpt-4o-mini",
temperature=0.7,
tools="none",
system_prompt="You are an analyst.",
),
"skeptic": RoleConfig(
name="Skeptic",
provider="openai",
model="gpt-4o-mini",
temperature=0.5,
tools="none",
system_prompt="You are a skeptic.",
),
"researcher": RoleConfig(
name="Researcher",
provider="openai",
model="gpt-4o-mini",
temperature=0.7,
tools="all",
system_prompt="You research things.",
),
}
BASIC_CONFIG = DiscussionConfig(
providers=PROVIDERS,
roles=ROLES,
defaults=DefaultsConfig(max_rounds=3),
)
BASIC_SCENARIO = ScenarioConfig(
name="Test Discussion",
participants=["analyst", "skeptic"],
max_rounds=2,
problem="Should we use Python or Rust?",
)
def _make_mock_api(
stream_responses: dict[str, str] | None = None,
chat_responses: dict[str, dict] | None = None,
) -> MagicMock:
"""Create a mock APIClient.
stream_responses: maps model -> text response for chat_stream
chat_responses: maps model -> dict response for chat
"""
api = MagicMock(spec=APIClient)
default_stream = stream_responses or {"gpt-4o-mini": "I think Python is great."}
default_chat = chat_responses or {}
def chat_side_effect(provider, model, messages, temperature=0.7, tools=None):
if model in default_chat:
return default_chat[model]
return {"content": "Chat response", "tool_calls": None}
def stream_side_effect(provider, model, messages, temperature=0.7):
return default_stream.get(model, "Stream response")
api.chat.side_effect = chat_side_effect
api.chat_stream.side_effect = stream_side_effect
return api
def _make_engine(
config: DiscussionConfig | None = None,
api: APIClient | None = None,
events: EventEmitter | None = None,
) -> tuple[DiscussionEngine, EventEmitter]:
"""Create a DiscussionEngine with sensible defaults. Returns (engine, event_bus)."""
cfg = config or BASIC_CONFIG
bus = events or EventEmitter()
api_client = api or _make_mock_api()
tools = ToolRegistry(workdir=".")
engine = DiscussionEngine(
config=cfg,
api_client=api_client,
tool_registry=tools,
events=bus,
)
return engine, bus
# ---------------------------------------------------------------------------
# DiscussionEngine — construction
# ---------------------------------------------------------------------------
class TestDiscussionEngineInit:
def test_stores_config(self) -> None:
engine, _ = _make_engine()
assert engine.config is BASIC_CONFIG
def test_stores_dependencies(self) -> None:
api = _make_mock_api()
bus = EventEmitter()
tools = ToolRegistry()
engine = DiscussionEngine(BASIC_CONFIG, api, tools, bus)
assert engine.api is api
assert engine.tools is tools
assert engine.events is bus
# ---------------------------------------------------------------------------
# DiscussionEngine.run — basic lifecycle
# ---------------------------------------------------------------------------
class TestDiscussionEngineRunLifecycle:
def test_emits_discussion_start(self) -> None:
engine, bus = _make_engine()
events: list[Event] = []
bus.on(DISCUSSION_START, lambda e: events.append(e))
engine.run(BASIC_SCENARIO)
assert len(events) == 1
assert events[0].data["name"] == "Test Discussion"
assert events[0].data["rounds"] == 2
def test_emits_round_start_and_end(self) -> None:
engine, bus = _make_engine()
starts: list[Event] = []
ends: list[Event] = []
bus.on(ROUND_START, lambda e: starts.append(e))
bus.on(ROUND_END, lambda e: ends.append(e))
engine.run(BASIC_SCENARIO)
assert len(starts) == 2 # 2 rounds
assert len(ends) == 2
assert starts[0].data["round_num"] == 1
assert starts[1].data["round_num"] == 2
assert ends[0].data["total_rounds"] == 2
def test_emits_agent_turn_start_for_each_participant(self) -> None:
engine, bus = _make_engine()
turn_starts: list[Event] = []
bus.on(AGENT_TURN_START, lambda e: turn_starts.append(e))
engine.run(BASIC_SCENARIO)
# 2 participants * 2 rounds = 4 turns
assert len(turn_starts) == 4
assert turn_starts[0].data["role_id"] == "analyst"
assert turn_starts[0].data["name"] == "Analyst"
assert turn_starts[1].data["role_id"] == "skeptic"
def test_emits_agent_message_for_each_turn(self) -> None:
engine, bus = _make_engine()
messages: list[Event] = []
bus.on(AGENT_MESSAGE, lambda e: messages.append(e))
engine.run(BASIC_SCENARIO)
assert len(messages) == 4 # 2 participants * 2 rounds
def test_emits_discussion_end(self) -> None:
engine, bus = _make_engine()
end_events: list[Event] = []
bus.on(DISCUSSION_END, lambda e: end_events.append(e))
engine.run(BASIC_SCENARIO)
assert len(end_events) == 1
assert end_events[0].data["total_messages"] == 4
def test_returns_conversation(self) -> None:
engine, _ = _make_engine()
result = engine.run(BASIC_SCENARIO)
assert len(result) == 4
assert all(isinstance(m, __import__("meeting_room.models", fromlist=["Message"]).Message) for m in result)
assert result[0].role_id == "analyst"
assert result[1].role_id == "skeptic"
def test_full_event_order(self) -> None:
"""Events must be emitted in the correct order for a 1-round, 2-participant discussion."""
config = DiscussionConfig(
providers=PROVIDERS,
roles=ROLES,
defaults=DefaultsConfig(max_rounds=3),
)
scenario = ScenarioConfig(
name="Order Test",
participants=["analyst", "skeptic"],
max_rounds=1,
problem="Test problem",
)
engine, bus = _make_engine(config=config)
log: list[str] = []
bus.on(DISCUSSION_START, lambda e: log.append("DISCUSSION_START"))
bus.on(ROUND_START, lambda e: log.append(f"ROUND_START:{e.data['round_num']}"))
bus.on(AGENT_TURN_START, lambda e: log.append(f"TURN_START:{e.data['role_id']}"))
bus.on(AGENT_MESSAGE, lambda e: log.append(f"AGENT_MESSAGE:{e.data['role_id']}"))
bus.on(ROUND_END, lambda e: log.append(f"ROUND_END:{e.data['round_num']}"))
bus.on(DISCUSSION_END, lambda e: log.append("DISCUSSION_END"))
engine.run(scenario)
assert log == [
"DISCUSSION_START",
"ROUND_START:1",
"TURN_START:analyst",
"AGENT_MESSAGE:analyst",
"TURN_START:skeptic",
"AGENT_MESSAGE:skeptic",
"ROUND_END:1",
"DISCUSSION_END",
]
# ---------------------------------------------------------------------------
# DiscussionEngine.run — backward compatibility
# ---------------------------------------------------------------------------
class TestDiscussionEngineRunDict:
def test_accepts_dict_scenario(self) -> None:
engine, bus = _make_engine()
events: list[Event] = []
bus.on(DISCUSSION_START, lambda e: events.append(e))
scenario_dict = {
"name": "Dict Test",
"participants": ["analyst"],
"max_rounds": 1,
"problem": "Dict problem?",
}
result = engine.run(scenario_dict)
assert events[0].data["name"] == "Dict Test"
assert len(result) == 1
def test_uses_default_participants_when_empty(self) -> None:
"""If scenario.participants is empty, use all roles from config."""
engine, bus = _make_engine()
turn_starts: list[Event] = []
bus.on(AGENT_TURN_START, lambda e: turn_starts.append(e))
scenario = ScenarioConfig(
name="Default Participants",
participants=[], # empty
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
# Should use all 3 roles from config: analyst, skeptic, researcher
# (but researcher has tools="all" — mock the chat call)
assert len(turn_starts) == 3
assert len(result) == 3
def test_uses_config_default_max_rounds_when_zero(self) -> None:
engine, bus = _make_engine()
end_events: list[Event] = []
bus.on(DISCUSSION_END, lambda e: end_events.append(e))
scenario = ScenarioConfig(
name="Rounds Test",
participants=["analyst"],
max_rounds=0, # zero → should fall back to config default (3)
problem="Test",
)
engine.run(scenario)
assert end_events[0].data["total_messages"] == 3 # 1 participant * 3 rounds
# ---------------------------------------------------------------------------
# DiscussionEngine.run — unknown role
# ---------------------------------------------------------------------------
class TestDiscussionEngineRunUnknownRole:
def test_unknown_role_id_raises(self) -> None:
engine, _ = _make_engine()
scenario = ScenarioConfig(
name="Bad Role",
participants=["nonexistent_role"],
max_rounds=1,
problem="Test",
)
with pytest.raises(ValueError, match="Unknown role_id"):
engine.run(scenario)
# ---------------------------------------------------------------------------
# DiscussionEngine._run_agent_turn — tool calls
# ---------------------------------------------------------------------------
class TestDiscussionEngineToolCalls:
def test_tool_call_events_are_emitted(self) -> None:
"""When an agent makes a tool call, TOOL_CALL and TOOL_RESULT events must fire."""
api = _make_mock_api()
# First call returns a tool call, second call returns text
api.chat.side_effect = None
api.chat.side_effect = [
{
"content": None,
"tool_calls": [
{
"id": "call_001",
"name": "read_file",
"arguments": '{"path": "/tmp/test.txt"}',
}
],
},
{"content": "I read the file.", "tool_calls": None},
]
engine, bus = _make_engine(api=api)
tool_calls: list[Event] = []
tool_results: list[Event] = []
bus.on(TOOL_CALL, lambda e: tool_calls.append(e))
bus.on(TOOL_RESULT, lambda e: tool_results.append(e))
scenario = ScenarioConfig(
name="Tool Test",
participants=["researcher"], # has tools="all"
max_rounds=1,
problem="Read a file",
)
result = engine.run(scenario)
assert len(tool_calls) == 1
assert tool_calls[0].data["role_id"] == "researcher"
assert tool_calls[0].data["tool_name"] == "read_file"
assert len(tool_results) == 1
assert tool_results[0].data["role_id"] == "researcher"
assert tool_results[0].data["tool_name"] == "read_file"
# The final message should contain the text response
assert "I read the file." in result[0].content
def test_multiple_tool_calls_in_single_turn(self) -> None:
"""Multiple tool calls in one response should all emit events."""
api = _make_mock_api()
api.chat.side_effect = None
api.chat.side_effect = [
{
"content": None,
"tool_calls": [
{"id": "call_001", "name": "read_file", "arguments": '{"path": "/a"}'},
{"id": "call_002", "name": "list_files", "arguments": '{"path": "/b"}'},
],
},
{"content": "Done researching.", "tool_calls": None},
]
engine, bus = _make_engine(api=api)
tool_calls: list[Event] = []
bus.on(TOOL_CALL, lambda e: tool_calls.append(e))
scenario = ScenarioConfig(
name="Multi-Tool",
participants=["researcher"],
max_rounds=1,
problem="Research",
)
engine.run(scenario)
assert len(tool_calls) == 2
assert tool_calls[0].data["tool_name"] == "read_file"
assert tool_calls[1].data["tool_name"] == "list_files"
def test_tool_result_preview_truncated(self) -> None:
"""Tool results longer than 200 chars should have truncated preview in event."""
long_result = "x" * 500
api = _make_mock_api()
api.chat.side_effect = None
# We patch execute_tool to return a long string
engine, bus = _make_engine(api=api)
tool_results: list[Event] = []
bus.on(TOOL_RESULT, lambda e: tool_results.append(e))
original_execute = engine.tools.execute_tool
def mock_execute(name, args):
if name == "read_file":
return long_result
return original_execute(name, args)
engine.tools.execute_tool = mock_execute
api.chat.side_effect = [
{
"content": None,
"tool_calls": [
{"id": "call_001", "name": "read_file", "arguments": '{"path": "/x"}'},
],
},
{"content": "Got it.", "tool_calls": None},
]
scenario = ScenarioConfig(
name="Truncation Test",
participants=["researcher"],
max_rounds=1,
problem="Read big file",
)
engine.run(scenario)
# Preview is first 200 chars with newlines replaced by spaces
assert len(tool_results[0].data["result_preview"]) <= 200
assert "\n" not in tool_results[0].data["result_preview"]
# Full result should be preserved
assert tool_results[0].data["full_result"] == long_result
# ---------------------------------------------------------------------------
# DiscussionEngine.run — error handling
# ---------------------------------------------------------------------------
class TestDiscussionEngineErrors:
def test_api_error_emits_agent_error(self) -> None:
"""If the API call raises, the engine should emit AGENT_ERROR and continue."""
api = _make_mock_api()
api.chat_stream.side_effect = None
api.chat_stream.side_effect = RuntimeError("API timeout")
engine, bus = _make_engine(api=api)
error_events: list[Event] = []
bus.on(AGENT_ERROR, lambda e: error_events.append(e))
scenario = ScenarioConfig(
name="Error Test",
participants=["analyst"], # no tools, uses chat_stream
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
assert len(error_events) == 1
assert "API timeout" in error_events[0].data["error"]
# The message content should contain the error
assert "[Error:" in result[0].content
def test_all_agents_error_still_completes_discussion(self) -> None:
"""Even if every agent errors, the discussion should complete with DISCUSSION_END."""
api = _make_mock_api()
api.chat_stream.side_effect = None
api.chat_stream.side_effect = RuntimeError("fail")
engine, bus = _make_engine(api=api)
end_events: list[Event] = []
bus.on(DISCUSSION_END, lambda e: end_events.append(e))
scenario = ScenarioConfig(
name="All Error",
participants=["analyst", "skeptic"],
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
assert len(end_events) == 1
assert end_events[0].data["total_messages"] == 2
assert all("[Error:" in m.content for m in result)
# ---------------------------------------------------------------------------
# DiscussionEngine.inject_message
# ---------------------------------------------------------------------------
class TestDiscussionEngineInjectMessage:
def test_inject_emits_boss_turn(self) -> None:
engine, bus = _make_engine()
boss_events: list[Event] = []
bus.on(BOSS_TURN, lambda e: boss_events.append(e))
msg = engine.inject_message("analyst", "Stop discussing and wrap up.")
assert len(boss_events) == 1
assert boss_events[0].data["role_id"] == "analyst"
assert boss_events[0].data["content"] == "Stop discussing and wrap up."
assert boss_events[0].data["name"] == "Analyst"
def test_inject_returns_message(self) -> None:
engine, _ = _make_engine()
msg = engine.inject_message("skeptic", "I disagree.")
assert msg.role_id == "skeptic"
assert msg.name == "Skeptic"
assert msg.content == "I disagree."
def test_inject_unknown_role_raises(self) -> None:
engine, _ = _make_engine()
with pytest.raises(ValueError, match="Unknown role_id"):
engine.inject_message("nonexistent", "test")
# ---------------------------------------------------------------------------
# DiscussionEngine._format_conversation
# ---------------------------------------------------------------------------
class TestFormatConversation:
def test_formats_messages(self) -> None:
conversation = [
__import__("meeting_room.models", fromlist=["Message"]).Message(
role_id="a", name="Alice", content="Hello"
),
__import__("meeting_room.models", fromlist=["Message"]).Message(
role_id="b", name="Bob", content="Hi there"
),
]
result = DiscussionEngine._format_conversation(conversation)
assert "[Alice]: Hello" in result
assert "[Bob]: Hi there" in result
def test_empty_conversation(self) -> None:
result = DiscussionEngine._format_conversation([])
assert result == ""
# ---------------------------------------------------------------------------
# DiscussionEngine._build_messages
# ---------------------------------------------------------------------------
class TestBuildMessages:
def test_first_message_contains_problem(self) -> None:
engine, _ = _make_engine()
role = ROLES["analyst"]
messages = engine._build_messages(role, "Should we use Go?", [], is_first_message=True)
assert messages[0]["role"] == "system"
assert messages[0]["content"] == role.system_prompt.strip()
assert "Should we use Go?" in messages[1]["content"]
def test_subsequent_message_contains_conversation(self) -> None:
engine, _ = _make_engine()
role = ROLES["analyst"]
conversation = [
__import__("meeting_room.models", fromlist=["Message"]).Message(
role_id="analyst", name="Analyst", content="I think Python is great."
),
]
messages = engine._build_messages(role, "The problem", conversation, is_first_message=False)
assert "I think Python is great." in messages[1]["content"]
assert "Continue the discussion" in messages[1]["content"]
# ---------------------------------------------------------------------------
# Integration: streaming vs non-streaming path
# ---------------------------------------------------------------------------
class TestStreamingPath:
def test_no_tools_uses_chat_stream(self) -> None:
"""Agents with tools='none' should use chat_stream (streaming)."""
api = _make_mock_api(stream_responses={"gpt-4o-mini": "Streamed response"})
engine, bus = _make_engine(api=api)
msg_events: list[Event] = []
bus.on(AGENT_MESSAGE, lambda e: msg_events.append(e))
scenario = ScenarioConfig(
name="Stream Test",
participants=["analyst"], # tools="none"
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
api.chat_stream.assert_called_once()
api.chat.assert_not_called()
assert result[0].content == "Streamed response"
def test_with_tools_uses_chat(self) -> None:
"""Agents with tools should use chat (non-streaming)."""
api = _make_mock_api()
api.chat.side_effect = None
api.chat.return_value = {"content": "Research done.", "tool_calls": None}
engine, bus = _make_engine(api=api)
scenario = ScenarioConfig(
name="Chat Test",
participants=["researcher"], # tools="all"
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
api.chat.assert_called_once()
api.chat_stream.assert_not_called()
assert result[0].content == "Research done."

311
tests/test_events.py Normal file
View File

@@ -0,0 +1,311 @@
"""Tests for meeting_room.events — EventEmitter and event types."""
from __future__ import annotations
from datetime import datetime
import pytest
from meeting_room.events import (
AGENT_ERROR,
AGENT_MESSAGE,
AGENT_TURN_START,
BOSS_TURN,
DISCUSSION_END,
DISCUSSION_START,
Event,
EventEmitter,
ROUND_END,
ROUND_START,
TOOL_CALL,
TOOL_RESULT,
)
# ---------------------------------------------------------------------------
# Event type constants
# ---------------------------------------------------------------------------
class TestEventConstants:
"""All event type constants must be lowercase strings."""
@pytest.mark.parametrize(
"name, value",
[
("DISCUSSION_START", "discussion_start"),
("ROUND_START", "round_start"),
("AGENT_TURN_START", "agent_turn_start"),
("AGENT_MESSAGE", "agent_message"),
("TOOL_CALL", "tool_call"),
("TOOL_RESULT", "tool_result"),
("AGENT_ERROR", "agent_error"),
("ROUND_END", "round_end"),
("DISCUSSION_END", "discussion_end"),
("BOSS_TURN", "boss_turn"),
],
)
def test_constants(self, name: str, value: str) -> None:
import meeting_room.events as mod
assert getattr(mod, name) == value
# ---------------------------------------------------------------------------
# Event dataclass
# ---------------------------------------------------------------------------
class TestEvent:
def test_defaults(self) -> None:
e = Event(type=AGENT_MESSAGE)
assert e.type == AGENT_MESSAGE
assert e.data == {}
assert isinstance(e.timestamp, datetime)
def test_custom_data(self) -> None:
e = Event(type=TOOL_CALL, data={"tool": "search", "query": "test"})
assert e.data["tool"] == "search"
assert e.data["query"] == "test"
def test_timestamp_preserved(self) -> None:
ts = datetime(2025, 1, 1, 12, 0, 0)
e = Event(type=AGENT_ERROR, data={"msg": "fail"}, timestamp=ts)
assert e.timestamp == ts
# ---------------------------------------------------------------------------
# EventEmitter — subscription
# ---------------------------------------------------------------------------
class TestEventEmitterOn:
def test_on_returns_handler(self) -> None:
bus = EventEmitter()
def handler(event: Event) -> None:
pass
result = bus.on(AGENT_MESSAGE, handler)
assert result is handler
def test_on_stores_handler(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
assert len(bus.handlers(AGENT_MESSAGE)) == 1
def test_on_multiple_handlers_same_event(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
bus.on(AGENT_MESSAGE, lambda e: None)
bus.on(AGENT_MESSAGE, lambda e: None)
assert len(bus.handlers(AGENT_MESSAGE)) == 3
def test_on_different_event_types(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
bus.on(TOOL_CALL, lambda e: None)
assert len(bus.handlers(AGENT_MESSAGE)) == 1
assert len(bus.handlers(TOOL_CALL)) == 1
# ---------------------------------------------------------------------------
# EventEmitter — unsubscription
# ---------------------------------------------------------------------------
class TestEventEmitterOff:
def test_off_removes_handler(self) -> None:
bus = EventEmitter()
def handler(event: Event) -> None:
pass
bus.on(AGENT_MESSAGE, handler)
bus.off(AGENT_MESSAGE, handler)
assert len(bus.handlers(AGENT_MESSAGE)) == 0
def test_off_unknown_event_type_is_noop(self) -> None:
bus = EventEmitter()
bus.off("nonexistent_event", lambda e: None) # should not raise
def test_off_missing_handler_is_noop(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
bus.off(AGENT_MESSAGE, lambda e: None) # different lambda, no raise
def test_off_cleans_up_empty_list(self) -> None:
bus = EventEmitter()
def handler(event: Event) -> None:
pass
bus.on(AGENT_MESSAGE, handler)
bus.off(AGENT_MESSAGE, handler)
# Internal dict should not keep empty lists
assert AGENT_MESSAGE not in bus._handlers
# ---------------------------------------------------------------------------
# EventEmitter — emission
# ---------------------------------------------------------------------------
class TestEventEmitterEmit:
def test_emit_calls_handler(self) -> None:
bus = EventEmitter()
received: list[Event] = []
bus.on(AGENT_MESSAGE, lambda e: received.append(e))
bus.emit(AGENT_MESSAGE, text="hello", agent="Alice")
assert len(received) == 1
assert received[0].type == AGENT_MESSAGE
assert received[0].data == {"text": "hello", "agent": "Alice"}
def test_emit_calls_all_handlers(self) -> None:
bus = EventEmitter()
results_a: list[str] = []
results_b: list[str] = []
bus.on(AGENT_MESSAGE, lambda e: results_a.append(e.data["text"]))
bus.on(AGENT_MESSAGE, lambda e: results_b.append(e.data["text"]))
bus.emit(AGENT_MESSAGE, text="hello")
assert results_a == ["hello"]
assert results_b == ["hello"]
def test_emit_no_handlers_is_safe(self) -> None:
bus = EventEmitter()
event = bus.emit(DISCUSSION_START) # no subscribers — no crash
assert event.type == DISCUSSION_START
def test_emit_returns_event(self) -> None:
bus = EventEmitter()
event = bus.emit(ROUND_START, round_number=3)
assert event.type == ROUND_START
assert event.data == {"round_number": 3}
assert isinstance(event.timestamp, datetime)
def test_emit_handler_exception_does_not_crash(self) -> None:
bus = EventEmitter()
call_count = 0
def bad_handler(event: Event) -> None:
nonlocal call_count
call_count += 1
raise RuntimeError("boom")
def good_handler(event: Event) -> None:
nonlocal call_count
call_count += 1
bus.on(AGENT_ERROR, bad_handler)
bus.on(AGENT_ERROR, good_handler)
# bad_handler raises, but good_handler still runs
bus.emit(AGENT_ERROR, msg="fail")
assert call_count == 2 # both handlers were called
def test_emit_multiple_events_independently(self) -> None:
bus = EventEmitter()
msg_log: list[str] = []
tool_log: list[str] = []
bus.on(AGENT_MESSAGE, lambda e: msg_log.append(e.data["text"]))
bus.on(TOOL_CALL, lambda e: tool_log.append(e.data["tool"]))
bus.emit(AGENT_MESSAGE, text="hi")
bus.emit(TOOL_CALL, tool="search")
bus.emit(AGENT_MESSAGE, text="bye")
assert msg_log == ["hi", "bye"]
assert tool_log == ["search"]
# ---------------------------------------------------------------------------
# EventEmitter — clear
# ---------------------------------------------------------------------------
class TestEventEmitterClear:
def test_clear_removes_all_handlers(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
bus.on(TOOL_CALL, lambda e: None)
bus.clear()
assert bus.handlers(AGENT_MESSAGE) == []
assert bus.handlers(TOOL_CALL) == []
assert bus._handlers == {}
def test_handlers_returns_copy(self) -> None:
bus = EventEmitter()
def handler(event: Event) -> None:
pass
bus.on(AGENT_MESSAGE, handler)
h = bus.handlers(AGENT_MESSAGE)
h.clear() # mutating the copy should not affect the bus
assert len(bus.handlers(AGENT_MESSAGE)) == 1
# ---------------------------------------------------------------------------
# Integration-style: full discussion lifecycle
# ---------------------------------------------------------------------------
class TestDiscussionLifecycle:
"""Simulate a full discussion lifecycle to exercise all event types."""
def test_full_lifecycle(self) -> None:
bus = EventEmitter()
log: list[str] = []
bus.on(DISCUSSION_START, lambda e: log.append(f"DISCUSSION_START"))
bus.on(ROUND_START, lambda e: log.append(f"ROUND_START:{e.data['round']}"))
bus.on(AGENT_TURN_START, lambda e: log.append(f"TURN:{e.data['agent']}"))
bus.on(AGENT_MESSAGE, lambda e: log.append(f"MSG:{e.data['text']}"))
bus.on(TOOL_CALL, lambda e: log.append(f"TOOL:{e.data['tool']}"))
bus.on(TOOL_RESULT, lambda e: log.append(f"RESULT:{e.data['tool']}"))
bus.on(AGENT_ERROR, lambda e: log.append(f"ERR:{e.data['msg']}"))
bus.on(ROUND_END, lambda e: log.append(f"ROUND_END:{e.data['round']}"))
bus.on(BOSS_TURN, lambda e: log.append(f"BOSS:{e.data['decision']}"))
bus.on(DISCUSSION_END, lambda e: log.append("DISCUSSION_END"))
bus.emit(DISCUSSION_START)
bus.emit(ROUND_START, round=1)
bus.emit(AGENT_TURN_START, agent="Alice")
bus.emit(AGENT_MESSAGE, text="I think we should use Python.")
bus.emit(TOOL_CALL, tool="search")
bus.emit(TOOL_RESULT, tool="search")
bus.emit(ROUND_END, round=1)
bus.emit(BOSS_TURN, decision="proceed")
bus.emit(AGENT_ERROR, msg="timeout")
bus.emit(DISCUSSION_END)
assert log == [
"DISCUSSION_START",
"ROUND_START:1",
"TURN:Alice",
"MSG:I think we should use Python.",
"TOOL:search",
"RESULT:search",
"ROUND_END:1",
"BOSS:proceed",
"ERR:timeout",
"DISCUSSION_END",
]

396
tests/test_models.py Normal file
View File

@@ -0,0 +1,396 @@
"""Tests for meeting_room.models — Pydantic v2 data models."""
import copy
from datetime import datetime, timezone
import pytest
import yaml
from meeting_room.models import (
DefaultsConfig,
DiscussionConfig,
Message,
ProviderConfig,
RoleConfig,
ScenarioConfig,
Session,
ToolCallRecord,
)
# ---------------------------------------------------------------------------
# Fixtures — sample data matching config.yaml structure
# ---------------------------------------------------------------------------
SAMPLE_YAML = """\
providers:
routerai:
base_url: "https://routerai.ru/api/v1"
api_key: "sk-test-key"
ollama_cloud:
api_key: "02a8f6e9f9744088885982ac645f1ce1.HB1MFh4AlTpyic9oJGxQjNuA"
base_url: "https://ollama.com/v1"
roles:
moderator:
name: "Moderator"
provider: ollama_cloud
model: "gpt-4o-mini"
temperature: 0.7
tools: "none"
system_prompt: |
You are the moderator of a discussion.
skeptic:
name: "Skeptic"
provider: ollama_cloud
model: "gpt-4o-mini"
temperature: 0.9
tools: "readonly"
system_prompt: |
You are a skeptic and critic.
defaults:
max_rounds: 8
language: "ru"
framework: "custom"
workdir: "."
"""
@pytest.fixture
def yaml_config() -> dict:
"""Parse SAMPLE_YAML the same way cli.py does."""
return yaml.safe_load(SAMPLE_YAML)
# ===========================================================================
# ProviderConfig
# ===========================================================================
class TestProviderConfig:
def test_basic(self):
p = ProviderConfig(base_url="https://api.example.com/v1", api_key="sk-123")
assert p.base_url == "https://api.example.com/v1"
assert p.api_key == "sk-123"
def test_extra_fields_forbidden(self):
with pytest.raises(Exception):
ProviderConfig(base_url="https://x", api_key="k", unknown_field="oops")
def test_missing_required(self):
with pytest.raises(Exception):
ProviderConfig(base_url="https://x") # missing api_key
# ===========================================================================
# RoleConfig
# ===========================================================================
class TestRoleConfig:
def test_basic(self):
r = RoleConfig(
name="Moderator",
provider="ollama_cloud",
model="gpt-4o-mini",
temperature=0.7,
tools="none",
system_prompt="You moderate.",
)
assert r.name == "Moderator"
assert r.temperature == 0.7
assert r.tools == "none"
def test_defaults(self):
r = RoleConfig(name="Bot", provider="p", model="m")
assert r.temperature == 0.7
assert r.tools == "none"
assert r.system_prompt == ""
def test_tools_string_values(self):
for val in ("all", "none", "readonly", "web", "full", "files"):
r = RoleConfig(name="X", provider="p", model="m", tools=val)
assert r.tools == val
def test_tools_list(self):
r = RoleConfig(name="X", provider="p", model="m", tools=["read_file", "web_search"])
assert r.tools == ["read_file", "web_search"]
def test_tools_coercion_list_items_to_str(self):
r = RoleConfig(name="X", provider="p", model="m", tools=["read_file"])
assert isinstance(r.tools[0], str)
def test_tools_invalid_type(self):
with pytest.raises(Exception):
RoleConfig(name="X", provider="p", model="m", tools=42)
def test_extra_fields_forbidden(self):
with pytest.raises(Exception):
RoleConfig(name="X", provider="p", model="m", rogue_field=True)
# ===========================================================================
# DefaultsConfig
# ===========================================================================
class TestDefaultsConfig:
def test_defaults(self):
d = DefaultsConfig()
assert d.max_rounds == 8
assert d.language == "ru"
assert d.framework == "custom"
assert d.workdir == "."
def test_custom(self):
d = DefaultsConfig(max_rounds=12, language="en", framework="autogen", workdir="/tmp")
assert d.max_rounds == 12
def test_extra_ignored(self):
# DefaultsConfig uses extra="ignore" — unknown keys are silently dropped
d = DefaultsConfig(max_rounds=5, unknown_key="oops")
assert d.max_rounds == 5
assert not hasattr(d, "unknown_key")
# ===========================================================================
# DiscussionConfig — the critical one
# ===========================================================================
class TestDiscussionConfig:
def test_from_yaml_config(self, yaml_config: dict):
"""DiscussionConfig(**yaml_config) must work without changes."""
cfg = DiscussionConfig(**yaml_config)
assert "routerai" in cfg.providers
assert "ollama_cloud" in cfg.providers
assert cfg.providers["routerai"].base_url == "https://routerai.ru/api/v1"
assert "moderator" in cfg.roles
assert "skeptic" in cfg.roles
assert cfg.roles["moderator"].name == "Moderator"
assert cfg.roles["skeptic"].tools == "readonly"
assert cfg.defaults.max_rounds == 8
def test_roundtrip_serialization(self, yaml_config: dict):
"""Model can be built from yaml, exported, and rebuilt."""
cfg = DiscussionConfig(**yaml_config)
exported = cfg.model_dump()
rebuilt = DiscussionConfig(**exported)
assert rebuilt.providers.keys() == cfg.providers.keys()
assert rebuilt.roles.keys() == cfg.roles.keys()
def test_empty_config(self):
cfg = DiscussionConfig()
assert cfg.providers == {}
assert cfg.roles == {}
assert cfg.defaults.max_rounds == 8
def test_extra_keys_ignored(self):
# DiscussionConfig uses extra="ignore"
cfg = DiscussionConfig(
providers={},
roles={},
defaults={},
unknown_top_level="should be dropped",
)
assert not hasattr(cfg, "unknown_top_level")
def test_nested_provider_is_typed(self, yaml_config: dict):
cfg = DiscussionConfig(**yaml_config)
prov = cfg.providers["routerai"]
assert isinstance(prov, ProviderConfig)
def test_nested_role_is_typed(self, yaml_config: dict):
cfg = DiscussionConfig(**yaml_config)
role = cfg.roles["moderator"]
assert isinstance(role, RoleConfig)
# ===========================================================================
# ScenarioConfig
# ===========================================================================
class TestScenarioConfig:
def test_defaults(self):
s = ScenarioConfig()
assert s.name == "Untitled"
assert s.participants == []
assert s.max_rounds == 8
assert s.problem == ""
def test_from_frontmatter(self):
data = {
"name": "SaaS Pricing",
"participants": ["moderator", "skeptic"],
"max_rounds": 6,
"problem": "How should we price our SaaS?",
}
s = ScenarioConfig(**data)
assert s.name == "SaaS Pricing"
assert s.participants == ["moderator", "skeptic"]
assert s.max_rounds == 6
def test_extra_ignored(self):
s = ScenarioConfig(name="X", surprise="drop me")
assert s.name == "X"
assert not hasattr(s, "surprise")
# ===========================================================================
# Message
# ===========================================================================
class TestMessage:
def test_basic(self):
m = Message(role_id="moderator", name="Moderator", content="Hello everyone!")
assert m.role_id == "moderator"
assert m.name == "Moderator"
assert m.content == "Hello everyone!"
def test_extra_forbidden(self):
with pytest.raises(Exception):
Message(role_id="mod", name="Mod", content="hi", extra=True)
def test_missing_required(self):
with pytest.raises(Exception):
Message(role_id="mod", name="Mod") # missing content
# ===========================================================================
# ToolCallRecord
# ===========================================================================
class TestToolCallRecord:
def test_basic(self):
t = ToolCallRecord(id="call_abc", name="read_file", arguments='{"path": "/tmp/x"}', result="file contents")
assert t.id == "call_abc"
assert t.name == "read_file"
assert t.result == "file contents"
def test_default_result(self):
t = ToolCallRecord(id="call_1", name="web_search", arguments='{"q": "test"}')
assert t.result == ""
def test_extra_forbidden(self):
with pytest.raises(Exception):
ToolCallRecord(id="1", name="n", arguments="{}", extra="bad")
# ===========================================================================
# Session
# ===========================================================================
class TestSession:
def test_defaults(self):
s = Session()
assert s.scenario.name == "Untitled"
assert s.messages == []
assert s.tool_calls == []
assert isinstance(s.created_at, datetime)
assert isinstance(s.updated_at, datetime)
def test_with_messages(self):
msg = Message(role_id="moderator", name="Moderator", content="Let's begin.")
s = Session(messages=[msg])
assert len(s.messages) == 1
assert s.messages[0].content == "Let's begin."
def test_timestamps_auto_set(self):
before = datetime.now(timezone.utc)
s = Session()
after = datetime.now(timezone.utc)
assert before <= s.created_at <= after
assert before <= s.updated_at <= after
def test_explicit_timestamps(self):
t = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
s = Session(created_at=t, updated_at=t)
assert s.created_at.year == 2025
def test_serialization_roundtrip(self):
msg = Message(role_id="mod", name="Mod", content="Hi")
tc = ToolCallRecord(id="c1", name="search", arguments='{}', result="ok")
s = Session(
scenario=ScenarioConfig(name="Test"),
messages=[msg],
tool_calls=[tc],
)
data = s.model_dump()
rebuilt = Session(**data)
assert rebuilt.messages[0].content == "Hi"
assert rebuilt.tool_calls[0].name == "search"
assert rebuilt.scenario.name == "Test"
def test_extra_ignored(self):
s = Session(unknown_field="drop me")
assert not hasattr(s, "unknown_field")
# ===========================================================================
# Edge cases with real config.yaml structure
# ===========================================================================
class TestConfigYamlCompatibility:
"""Verify that the models accept exactly what yaml.safe_load produces
from the actual config.yaml in the package.
"""
def test_full_config_yaml_structure(self):
"""Build DiscussionConfig from the full config.yaml structure
(minus env-var resolution) and verify every field."""
raw = yaml.safe_load(SAMPLE_YAML)
cfg = DiscussionConfig(**raw)
# Providers
assert len(cfg.providers) == 2
assert cfg.providers["routerai"].base_url == "https://routerai.ru/api/v1"
assert cfg.providers["routerai"].api_key == "sk-test-key"
assert cfg.providers["ollama_cloud"].base_url == "https://ollama.com/v1"
# Roles
assert len(cfg.roles) == 2
mod = cfg.roles["moderator"]
assert mod.name == "Moderator"
assert mod.provider == "ollama_cloud"
assert mod.model == "gpt-4o-mini"
assert mod.temperature == 0.7
assert mod.tools == "none"
assert "moderator" in mod.system_prompt.lower() or "moderator" in mod.system_prompt
sk = cfg.roles["skeptic"]
assert sk.name == "Skeptic"
assert sk.temperature == 0.9
assert sk.tools == "readonly"
# Defaults
assert cfg.defaults.max_rounds == 8
assert cfg.defaults.language == "ru"
def test_tools_as_list_in_role(self):
"""Roles can specify tools as a list of tool names."""
cfg = DiscussionConfig(
roles={
"analyst": RoleConfig(
name="Analyst",
provider="ollama_cloud",
model="gpt-4o-mini",
tools=["read_file", "web_search", "list_directory"],
),
},
)
assert cfg.roles["analyst"].tools == ["read_file", "web_search", "list_directory"]
def test_minimal_config(self):
"""Minimal valid config with just one provider and one role."""
cfg = DiscussionConfig(
providers={"local": ProviderConfig(base_url="http://localhost:11434/v1", api_key="ollama")},
roles={"bot": RoleConfig(name="Bot", provider="local", model="llama3")},
)
assert cfg.providers["local"].api_key == "ollama"
assert cfg.roles["bot"].model == "llama3"
assert cfg.defaults.max_rounds == 8 # default

204
tests/test_server.py Normal file
View File

@@ -0,0 +1,204 @@
"""Tests for meeting_room.server — FastAPI app, REST API, WebSocket/SSE."""
from __future__ import annotations
import asyncio
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from meeting_room.events import AsyncEventBridge, EventEmitter
from meeting_room.server import ServerSession, SessionStore, app, store
@pytest.fixture(autouse=True)
def _reset_store():
"""Clear the global session store before each test."""
store._sessions.clear()
yield
@pytest.fixture
def client():
"""FastAPI test client."""
return TestClient(app)
# ---------------------------------------------------------------------------
# SessionStore
# ---------------------------------------------------------------------------
class TestSessionStore:
def test_create_session(self) -> None:
store = SessionStore()
session = store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
assert session.id
assert session.status == "created"
def test_get_session(self) -> None:
store = SessionStore()
session = store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
found = store.get(session.id)
assert found is session
def test_get_nonexistent_session(self) -> None:
store = SessionStore()
assert store.get("nonexistent") is None
def test_list_sessions(self) -> None:
store = SessionStore()
store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
assert len(store.list_sessions()) == 2
# ---------------------------------------------------------------------------
# REST API endpoints
# ---------------------------------------------------------------------------
class TestCreateSession:
def test_create_session_returns_id_and_status(self, client) -> None:
response = client.post("/api/sessions")
assert response.status_code == 200
data = response.json()
assert "id" in data
assert data["status"] == "created"
class TestListSessions:
def test_list_empty(self, client) -> None:
response = client.get("/api/sessions")
assert response.status_code == 200
assert response.json() == []
def test_list_after_create(self, client) -> None:
client.post("/api/sessions")
client.post("/api/sessions")
response = client.get("/api/sessions")
assert response.status_code == 200
assert len(response.json()) == 2
class TestGetSession:
def test_get_existing_session(self, client) -> None:
create_resp = client.post("/api/sessions")
session_id = create_resp.json()["id"]
response = client.get(f"/api/sessions/{session_id}")
assert response.status_code == 200
assert response.json()["id"] == session_id
def test_get_nonexistent_session(self, client) -> None:
response = client.get("/api/sessions/nonexistent")
assert response.status_code == 404
class TestRolesAndProviders:
def test_list_roles(self, client) -> None:
response = client.get("/api/roles")
assert response.status_code == 200
data = response.json()
assert "moderator" in data or isinstance(data, dict)
def test_list_providers(self, client) -> None:
response = client.get("/api/providers")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
# ---------------------------------------------------------------------------
# Session model
# ---------------------------------------------------------------------------
class TestServerSession:
def test_build_engine(self) -> None:
config = _minimal_config()
session = ServerSession("test-id", config, _minimal_discussion_config())
engine = session.build_engine()
assert engine is not None
def test_cleanup_removes_collect_handler(self) -> None:
from meeting_room.events import AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR
config = _minimal_config()
session = ServerSession("test-id", config, _minimal_discussion_config())
# Simulate what start_discussion does
def collect_message(event):
pass
session._collect_handler = collect_message
session.event_bus.on(AGENT_MESSAGE, collect_message)
session.event_bus.on(BOSS_TURN, collect_message)
session.event_bus.on(AGENT_ERROR, collect_message)
assert len(session.event_bus.handlers(AGENT_MESSAGE)) == 1
session.cleanup()
assert len(session.event_bus.handlers(AGENT_MESSAGE)) == 0
class TestProviderMasking:
def test_api_keys_masked(self) -> None:
from meeting_room.server import _mask_api_keys
providers = {
"routerai": {"base_url": "https://api.example.com", "api_key": "sk-1234567890abcdef"},
}
masked = _mask_api_keys(providers)
assert masked["routerai"]["api_key"] == "sk-1****"
assert masked["routerai"]["base_url"] == "https://api.example.com"
def test_empty_key(self) -> None:
from meeting_room.server import _mask_api_keys
providers = {"test": {"base_url": "https://x.com", "api_key": ""}}
masked = _mask_api_keys(providers)
assert masked["test"]["api_key"] == ""
class TestPathTraversal:
def test_reject_parent_dir(self) -> None:
from meeting_room.server import _resolve_scenario_path
with pytest.raises(HTTPException) as exc_info:
_resolve_scenario_path("../../etc/passwd")
assert exc_info.value.status_code == 400
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _minimal_config() -> dict:
return {
"providers": {
"test_provider": {
"base_url": "https://api.example.com/v1",
"api_key": "test-key",
}
},
"roles": {
"moderator": {
"name": "Moderator",
"provider": "test_provider",
"model": "gpt-4o-mini",
"temperature": 0.7,
"tools": "none",
"system_prompt": "You are a moderator.",
}
},
"defaults": {
"max_rounds": 3,
"language": "en",
"framework": "custom",
"workdir": ".",
},
}
def _minimal_discussion_config():
from meeting_room.models import DefaultsConfig, DiscussionConfig
return DiscussionConfig(
providers={"test_provider": {"base_url": "https://api.example.com/v1", "api_key": "test-key"}},
roles={"moderator": {"name": "Moderator", "provider": "test_provider", "model": "gpt-4o-mini"}},
defaults=DefaultsConfig(max_rounds=3),
)

389
tests/test_tools.py Normal file
View File

@@ -0,0 +1,389 @@
"""Tests for meeting_room.tools — ToolRegistry class and module-level wrappers."""
import os
import textwrap
import pytest
from meeting_room.tools import (
TOOL_SCHEMAS,
TOOL_SETS,
ToolRegistry,
execute_tool,
get_tool_schemas,
set_workdir,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_workdir(tmp_path):
"""Create a temporary directory with some files for testing."""
# Create directories
(tmp_path / "src").mkdir()
(tmp_path / "src" / "sub").mkdir()
(tmp_path / "docs").mkdir()
(tmp_path / ".git" / "objects").mkdir(parents=True)
(tmp_path / "node_modules" / "pkg").mkdir(parents=True)
(tmp_path / "__pycache__").mkdir()
# Create files
(tmp_path / "README.md").write_text("# Hello\nWorld", encoding="utf-8")
(tmp_path / "src" / "main.py").write_text("def main():\n pass\n", encoding="utf-8")
(tmp_path / "src" / "utils.py").write_text("def add(a, b):\n return a + b\n", encoding="utf-8")
(tmp_path / "src" / "sub" / "deep.py").write_text("# deep file\nx = 42\n", encoding="utf-8")
(tmp_path / "docs" / "notes.txt").write_text("Meeting notes\n", encoding="utf-8")
(tmp_path / "config.yaml").write_text("key: value\n", encoding="utf-8")
# Files in skip dirs
(tmp_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
(tmp_path / "node_modules" / "pkg" / "index.js").write_text("module.exports = {};\n", encoding="utf-8")
(tmp_path / "__pycache__" / "cache.pyc").write_text("bytecode", encoding="utf-8")
return tmp_path
@pytest.fixture
def reg(tmp_workdir):
"""ToolRegistry pointed at the temp workdir."""
return ToolRegistry(workdir=str(tmp_workdir))
# ===========================================================================
# TOOL_SCHEMAS — structure checks
# ===========================================================================
class TestToolSchemas:
def test_schema_names_match_expected(self):
names = {s["function"]["name"] for s in TOOL_SCHEMAS}
expected = {"read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"}
assert names == expected
def test_each_schema_has_required_fields(self):
for s in TOOL_SCHEMAS:
func = s["function"]
assert "name" in func
assert "description" in func
assert "parameters" in func
params = func["parameters"]
assert params["type"] == "object"
assert "properties" in params
def test_descriptions_are_in_english(self):
"""All descriptions must be in English (no Cyrillic)."""
for s in TOOL_SCHEMAS:
desc = s["function"]["description"]
# Check no Cyrillic characters
assert not re.search(r"[а-яА-ЯёЁ]", desc), f"Cyrillic in description: {desc}"
# ===========================================================================
# TOOL_SETS — grouping checks
# ===========================================================================
class TestToolSets:
def test_all_sets_exist(self):
for name in ("all", "full", "files", "readonly", "web", "none"):
assert name in TOOL_SETS
def test_all_equals_full(self):
assert TOOL_SETS["all"] == TOOL_SETS["full"]
def test_none_is_empty(self):
assert TOOL_SETS["none"] == []
def test_readonly_subset_of_files(self):
assert set(TOOL_SETS["readonly"]).issubset(set(TOOL_SETS["files"]))
def test_web_only_web_tools(self):
assert set(TOOL_SETS["web"]) == {"web_search", "web_fetch"}
# ===========================================================================
# ToolRegistry — construction and path resolution
# ===========================================================================
class TestToolRegistryInit:
def test_default_workdir(self):
r = ToolRegistry()
assert r._workdir == os.path.abspath(".")
def test_custom_workdir(self, tmp_path):
r = ToolRegistry(workdir=str(tmp_path))
assert r._workdir == str(tmp_path)
def test_resolve_absolute_path(self, reg):
abs_path = os.path.abspath(os.path.join(os.sep, "tmp", "some", "file.txt"))
assert reg._resolve_path(abs_path) == abs_path
def test_resolve_relative_path(self, reg, tmp_workdir):
result = reg._resolve_path("src/main.py")
assert result == os.path.normpath(os.path.join(str(tmp_workdir), "src/main.py"))
# ===========================================================================
# get_tool_schemas
# ===========================================================================
class TestGetToolSchemas:
def test_all_set(self, reg):
schemas = reg.get_tool_schemas("all")
assert len(schemas) == len(TOOL_SCHEMAS)
def test_none_set(self, reg):
schemas = reg.get_tool_schemas("none")
assert schemas == []
def test_readonly_set(self, reg):
schemas = reg.get_tool_schemas("readonly")
names = {s["function"]["name"] for s in schemas}
assert names == {"read_file", "list_files", "search_in_files"}
def test_explicit_list(self, reg):
schemas = reg.get_tool_schemas(["read_file", "web_search"])
names = {s["function"]["name"] for s in schemas}
assert names == {"read_file", "web_search"}
def test_unknown_set_falls_back_to_all(self, reg):
schemas = reg.get_tool_schemas("nonexistent_set")
assert len(schemas) == len(TOOL_SCHEMAS)
# ===========================================================================
# execute_tool dispatch
# ===========================================================================
class TestExecuteTool:
def test_unknown_tool(self, reg):
result = reg.execute_tool("nonexistent_tool", {})
assert "Unknown tool" in result
def test_execute_read_file(self, reg):
result = reg.execute_tool("read_file", {"path": "README.md"})
assert "Hello" in result
def test_execute_write_then_read(self, reg, tmp_workdir):
reg.execute_tool("write_file", {"path": "new_file.txt", "content": "test content"})
result = reg.execute_tool("read_file", {"path": "new_file.txt"})
assert "test content" in result
# ===========================================================================
# tool_read_file
# ===========================================================================
class TestToolReadFile:
def test_read_existing_file(self, reg):
result = reg.tool_read_file("README.md")
assert "Hello" in result
def test_read_with_offset(self, reg):
result = reg.tool_read_file("README.md", offset=2)
assert "World" in result
# Line numbers start at offset
assert "2|" in result
def test_read_with_limit(self, reg):
result = reg.tool_read_file("src/main.py", limit=1)
# Should have only 1 content line
lines = [l for l in result.split("\n") if "|" in l]
assert len(lines) == 1
def test_read_nonexistent_file(self, reg):
result = reg.tool_read_file("nonexistent.txt")
assert "not found" in result
def test_read_shows_total_lines(self, reg):
result = reg.tool_read_file("README.md")
assert "lines" in result
# ===========================================================================
# tool_list_files — pure Python, Windows-compatible
# ===========================================================================
class TestToolListFiles:
def test_list_root(self, reg, tmp_workdir):
result = reg.tool_list_files()
# Must include README.md and config.yaml at root
assert "README.md" in result
assert "config.yaml" in result
def test_list_skips_git(self, reg):
result = reg.tool_list_files()
# .git contents should NOT appear
assert ".git" not in result or "HEAD" not in result
def test_list_skips_node_modules(self, reg):
result = reg.tool_list_files()
assert "index.js" not in result
def test_list_skips_pycache(self, reg):
result = reg.tool_list_files()
assert "cache.pyc" not in result
def test_list_with_pattern(self, reg):
result = reg.tool_list_files(pattern="*.py")
lines = result.strip().split("\n") if result.strip() else []
# Only .py files should appear
for line in lines:
if line.strip():
assert line.endswith(".py") or os.path.isdir(line), f"Non-Python file in filtered results: {line}"
def test_list_with_path(self, reg, tmp_workdir):
result = reg.tool_list_files(path="src")
assert "main.py" in result
assert "utils.py" in result
def test_list_depth_limit(self, reg):
result = reg.tool_list_files(max_depth=1)
# With depth 1, should include root dir and its immediate files
# but NOT deeply nested files like src/sub/deep.py
# (it may list the src directory itself)
assert "config.yaml" in result
def test_list_nonexistent_dir(self, reg):
result = reg.tool_list_files(path="nonexistent_dir")
assert "not found" in result
def test_no_find_command_used(self):
"""Verify list_files implementation does not shell out to find."""
import inspect
source = inspect.getsource(ToolRegistry.tool_list_files)
assert "subprocess" not in source
assert '"find"' not in source
# ===========================================================================
# tool_search_in_files — pure Python, Windows-compatible
# ===========================================================================
class TestToolSearchInFiles:
def test_search_basic(self, reg):
result = reg.tool_search_in_files(query="def main")
assert "main.py" in result
def test_search_regex(self, reg):
result = reg.tool_search_in_files(query=r"def \w+\(")
# Should find function definitions
assert "def " in result
def test_search_with_file_pattern(self, reg):
result = reg.tool_search_in_files(query="value", file_pattern="*.yaml")
assert "config.yaml" in result
def test_search_no_matches(self, reg):
result = reg.tool_search_in_files(query="zzz_nonexistent_pattern_xyz")
assert "No matches" in result
def test_search_invalid_regex(self, reg):
result = reg.tool_search_in_files(query="[invalid")
assert "invalid regex" in result
def test_search_nonexistent_dir(self, reg):
result = reg.tool_search_in_files(query="test", path="nonexistent_dir")
assert "not found" in result
def test_search_skips_git(self, reg):
result = reg.tool_search_in_files(query="refs")
# Should not find content inside .git/HEAD
# (unless something else matches, but the file .git/HEAD itself should be skipped)
lines = result.strip().split("\n") if result.strip() else []
for line in lines:
assert ".git" not in line
def test_search_shows_line_numbers(self, reg):
result = reg.tool_search_in_files(query="def add")
# Format is filepath:linenum:content
assert ":" in result
def test_no_grep_command_used(self):
"""Verify search_in_files implementation does not shell out to grep."""
import inspect
source = inspect.getsource(ToolRegistry.tool_search_in_files)
assert "subprocess" not in source
assert '"grep"' not in source
# ===========================================================================
# tool_write_file
# ===========================================================================
class TestToolWriteFile:
def test_write_new_file(self, reg, tmp_workdir):
result = reg.tool_write_file("output.txt", "hello world")
assert "Wrote" in result
assert os.path.isfile(os.path.join(str(tmp_workdir), "output.txt"))
def test_write_creates_subdirs(self, reg, tmp_workdir):
result = reg.tool_write_file("sub/dir/test.txt", "nested")
assert "Wrote" in result
assert os.path.isfile(os.path.join(str(tmp_workdir), "sub", "dir", "test.txt"))
def test_write_overwrites(self, reg, tmp_workdir):
reg.tool_write_file("overwrite.txt", "first")
reg.tool_write_file("overwrite.txt", "second")
result = reg.tool_read_file("overwrite.txt")
assert "second" in result
# ===========================================================================
# tool_run_command
# ===========================================================================
class TestToolRunCommand:
def test_run_simple_command(self, reg):
result = reg.tool_run_command("echo hello")
assert "hello" in result.lower()
def test_run_command_timeout(self, reg):
# Use a command that sleeps longer than the timeout
result = reg.tool_run_command("ping -n 10 127.0.0.1" if os.name == "nt" else "sleep 30", timeout=1)
assert "Timeout" in result or "timeout" in result.lower()
def test_run_command_nonzero_exit(self, reg):
result = reg.tool_run_command("exit 1" if os.name != "nt" else "cmd /c exit 1")
# Should show exit code
assert "exit code" in result or result.strip() != ""
# ===========================================================================
# Module-level backward compatibility wrappers
# ===========================================================================
class TestModuleLevelWrappers:
def test_set_workdir_and_execute(self, tmp_workdir):
set_workdir(str(tmp_workdir))
result = execute_tool("read_file", {"path": "README.md"})
assert "Hello" in result
def test_get_tool_schemas_default(self):
# Without set_workdir, should still work (defaults to ".")
schemas = get_tool_schemas("all")
assert len(schemas) == len(TOOL_SCHEMAS)
def test_get_tool_schemas_module_level(self):
schemas = get_tool_schemas("readonly")
names = {s["function"]["name"] for s in schemas}
assert "read_file" in names
assert "web_search" not in names
def test_execute_unknown_tool(self):
result = execute_tool("does_not_exist", {})
assert "Unknown tool" in result
# Need re import for Cyrillic check
import re