Compare commits
22 Commits
99cb5a499b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49e0ce5290 | |||
| 1aa70a00d3 | |||
| 8215d8af6e | |||
| cc91de04a0 | |||
| ccc170735c | |||
| b57a1529e8 | |||
| afc43668fe | |||
| f0500502a7 | |||
| ff5ee2879a | |||
| 1dffaae822 | |||
| 069462953b | |||
| ae7fb35d4b | |||
| 962b0beddd | |||
| 2887b2309a | |||
| 96b6f061f3 | |||
| d0507b3b0c | |||
| 858618b326 | |||
| 6bbbefaa6f | |||
| d292d32afa | |||
| 1064276e81 | |||
| 52e77bb50c | |||
| 5ed79a2e95 |
102
.tasks/STATUS.md
Normal file
102
.tasks/STATUS.md
Normal 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 -->
|
||||||
|
|
||||||
|
---
|
||||||
25
.tasks/v2-p1-api-client.md
Normal file
25
.tasks/v2-p1-api-client.md
Normal 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).
|
||||||
26
.tasks/v2-p1-config-cli.md
Normal file
26
.tasks/v2-p1-config-cli.md
Normal 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
25
.tasks/v2-p1-deps.md
Normal 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
25
.tasks/v2-p1-engine.md
Normal 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
24
.tasks/v2-p1-events.md
Normal 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
22
.tasks/v2-p1-models.md
Normal 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
26
.tasks/v2-p1-tools.md
Normal 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.
|
||||||
26
.tasks/v2-p2-async-bridge.md
Normal file
26
.tasks/v2-p2-async-bridge.md
Normal 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
|
||||||
22
.tasks/v2-p2-fastapi-app.md
Normal file
22
.tasks/v2-p2-fastapi-app.md
Normal 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)
|
||||||
17
.tasks/v2-p2-serve-command.md
Normal file
17
.tasks/v2-p2-serve-command.md
Normal 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
18
.tasks/v2-p2-web-ui.md
Normal 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
|
||||||
17
.tasks/v2-p2-websocket-sse.md
Normal file
17
.tasks/v2-p2-websocket-sse.md
Normal 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
35
.wiki/CLAUDE.md
Normal 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)
|
||||||
19
.wiki/concepts/bootstrap-manifest.md
Normal file
19
.wiki/concepts/bootstrap-manifest.md
Normal 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`.
|
||||||
37
.wiki/concepts/code-review.md
Normal file
37
.wiki/concepts/code-review.md
Normal 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.
|
||||||
57
.wiki/concepts/meeting-room-v2.md
Normal file
57
.wiki/concepts/meeting-room-v2.md
Normal 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
45
.wiki/entities/roles.md
Normal 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
25
.wiki/index.md
Normal 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
19
.wiki/log.md
Normal 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
43
.wiki/overview.md
Normal 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
0
.wiki/packages/.gitkeep
Normal file
38
.wiki/packages/pydantic.md
Normal file
38
.wiki/packages/pydantic.md
Normal 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
11
.wiki/raw/README.md
Normal 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
0
.wiki/sources/.gitkeep
Normal file
11
CLAUDE.md
Normal file
11
CLAUDE.md
Normal 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
105
README.md
@@ -9,9 +9,11 @@ git clone <repo-url> meeting-room
|
|||||||
cd meeting-room
|
cd meeting-room
|
||||||
pip install -e .
|
pip install -e .
|
||||||
|
|
||||||
# Optional backends
|
# With dev dependencies (pytest, pytest-mock)
|
||||||
pip install -e ".[autogen]"
|
pip install -e ".[dev]"
|
||||||
pip install -e ".[crewai]"
|
|
||||||
|
# With web server dependencies (Phase 2)
|
||||||
|
pip install -e ".[web]"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -20,32 +22,18 @@ pip install -e ".[crewai]"
|
|||||||
# Initialize workspace (creates .meeting-room/ in current directory)
|
# Initialize workspace (creates .meeting-room/ in current directory)
|
||||||
meeting-room init
|
meeting-room init
|
||||||
|
|
||||||
# Create a scenario
|
# Run a discussion with default scenario
|
||||||
cat > .meeting-room/scenarios/my-problem.md << 'EOF'
|
meeting-room -s example-saas.md
|
||||||
---
|
|
||||||
name: "My Problem"
|
|
||||||
participants:
|
|
||||||
- moderator
|
|
||||||
- skeptic
|
|
||||||
- idea_generator
|
|
||||||
- analyst
|
|
||||||
max_rounds: 6
|
|
||||||
---
|
|
||||||
|
|
||||||
# Problem Title
|
# Run and save result to sessions/
|
||||||
|
|
||||||
Describe the problem in Markdown...
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Run discussion
|
|
||||||
cd .meeting-room
|
|
||||||
meeting-room -s scenarios/my-problem.md
|
|
||||||
|
|
||||||
# Run and save result
|
|
||||||
meeting-room -s scenarios/my-problem.md --save
|
meeting-room -s scenarios/my-problem.md --save
|
||||||
|
|
||||||
# Point agents at a project for file tools
|
# Cross-LLM code review — point agents at a project
|
||||||
meeting-room -w /path/to/project -s scenarios/code-review.md
|
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
|
## 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 init [path] # Create .meeting-room workspace
|
||||||
meeting-room -s scenarios/my.md # Run discussion
|
meeting-room -s scenarios/my.md # Run discussion
|
||||||
meeting-room -s scenarios/my.md --save # Run and save result
|
meeting-room -s scenarios/my.md --save # Run and save result
|
||||||
meeting-room -b autogen -s scenarios/my.md # Use AutoGen backend
|
meeting-room -s code-review.md -w /path # Code review on a project
|
||||||
meeting-room -b crewai -s scenarios/my.md # Use CrewAI backend
|
|
||||||
meeting-room --list-scenarios # Show available scenarios
|
meeting-room --list-scenarios # Show available scenarios
|
||||||
meeting-room --list-roles # Show roles and model bindings
|
meeting-room --list-roles # Show roles and model bindings
|
||||||
meeting-room -c /path/to/config.yaml -s ... # Custom config file
|
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
|
## Configuration
|
||||||
|
|
||||||
### Config Search Order
|
### 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_ROUTERAI_BASE_URL="https://routerai.ru/api/v1"
|
||||||
export MEETING_ROOM_OPENAI_API_KEY="sk-..."
|
export MEETING_ROOM_OPENAI_API_KEY="sk-..."
|
||||||
export MEETING_ROOM_ANTHROPIC_API_KEY="sk-ant-..."
|
export MEETING_ROOM_ANTHROPIC_API_KEY="sk-ant-..."
|
||||||
|
export MEETING_ROOM_DEEPSEEK_API_KEY="sk-..."
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use `${ENV_VAR}` placeholders in config.yaml.
|
Or use `${ENV_VAR}` placeholders in config.yaml.
|
||||||
@@ -98,12 +115,23 @@ Each role binds to a provider + model + tool set:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
roles:
|
roles:
|
||||||
analyst:
|
defender:
|
||||||
name: "Analyst"
|
name: "Defender"
|
||||||
provider: anthropic
|
provider: anthropic # one LLM defends
|
||||||
model: "claude-sonnet-4"
|
model: "claude-sonnet-4-6"
|
||||||
temperature: 0.5
|
tools: "readonly"
|
||||||
tools: "all"
|
|
||||||
|
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
|
### Tool Sets
|
||||||
@@ -145,6 +173,13 @@ Full Markdown description...
|
|||||||
2. How to scale?
|
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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
@@ -1,26 +1,157 @@
|
|||||||
"""Universal API client supporting multiple OpenAI-compatible providers."""
|
"""Universal API client supporting multiple OpenAI-compatible providers."""
|
||||||
|
|
||||||
import httpx
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
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
|
# Module-level backward-compatible wrappers
|
||||||
PROVIDERS = config.get("providers", {})
|
# ======================================================================
|
||||||
|
|
||||||
|
_default_client: APIClient | None = None
|
||||||
|
|
||||||
|
|
||||||
def _get_client(provider_name: str) -> httpx.Client:
|
def init_providers(config: dict) -> None:
|
||||||
p = PROVIDERS.get(provider_name)
|
"""Initialise the module-level default ``APIClient``.
|
||||||
if not p:
|
|
||||||
raise ValueError(f"Unknown provider: {provider_name}")
|
*config* is the raw dict produced by ``yaml.safe_load()`` — it must
|
||||||
return httpx.Client(
|
contain a ``providers`` key whose value maps provider names to
|
||||||
base_url=p["base_url"],
|
``{base_url, api_key}`` dicts.
|
||||||
headers={"Authorization": f"Bearer {p['api_key']}"},
|
"""
|
||||||
timeout=120.0,
|
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(
|
def chat(
|
||||||
@@ -30,36 +161,8 @@ def chat(
|
|||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
tools: list[dict] | None = None,
|
tools: list[dict] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
client = _get_client(provider)
|
"""Module-level wrapper that delegates to the default ``APIClient``."""
|
||||||
payload = {
|
return _require_default().chat(provider, model, messages, temperature, tools)
|
||||||
"model": model,
|
|
||||||
"messages": messages,
|
|
||||||
"temperature": temperature,
|
|
||||||
}
|
|
||||||
if tools:
|
|
||||||
payload["tools"] = tools
|
|
||||||
payload["tool_choice"] = "auto"
|
|
||||||
|
|
||||||
resp = client.post("/chat/completions", json=payload)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
message = data["choices"][0]["message"]
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"content": message.get("content") or "",
|
|
||||||
"tool_calls": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if message.get("tool_calls"):
|
|
||||||
result["tool_calls"] = []
|
|
||||||
for tc in message["tool_calls"]:
|
|
||||||
result["tool_calls"].append({
|
|
||||||
"id": tc["id"],
|
|
||||||
"name": tc["function"]["name"],
|
|
||||||
"arguments": tc["function"]["arguments"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def chat_stream(
|
def chat_stream(
|
||||||
@@ -68,29 +171,8 @@ def chat_stream(
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
) -> str:
|
) -> str:
|
||||||
client = _get_client(provider)
|
"""Module-level wrapper that delegates to the default ``APIClient``.
|
||||||
payload = {
|
|
||||||
"model": model,
|
Returns the accumulated text — does NOT print to stdout.
|
||||||
"messages": messages,
|
"""
|
||||||
"temperature": temperature,
|
return _require_default().chat_stream(provider, model, messages, 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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,16 +1,37 @@
|
|||||||
"""Custom backend — round-robin agent discussion with tool support."""
|
"""Legacy backend — delegates to DiscussionEngine.
|
||||||
|
|
||||||
import json
|
This module is deprecated. Use meeting_room.engine.DiscussionEngine directly.
|
||||||
import os
|
"""
|
||||||
import sys
|
|
||||||
|
|
||||||
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.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 = {
|
COLORS = {
|
||||||
"moderator": "\033[96m",
|
"moderator": "\033[96m",
|
||||||
"skeptic": "\033[91m",
|
"skeptic": "\033[91m",
|
||||||
@@ -22,150 +43,137 @@ RESET = "\033[0m"
|
|||||||
BOLD = "\033[1m"
|
BOLD = "\033[1m"
|
||||||
DIM = "\033[2m"
|
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:
|
def _subscribe_legacy_handlers(bus: EventEmitter) -> None:
|
||||||
return "\n\n".join(f"[{msg['name']}]: {msg['content']}" for msg in conversation)
|
"""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):
|
problem = data.get("problem", "")
|
||||||
"""Run one agent turn with tool support. Returns final text response."""
|
if problem:
|
||||||
system_msg = {"role": "system", "content": role_config["system_prompt"].strip()}
|
print(f"{BOLD}Problem:{RESET}")
|
||||||
|
for line in problem.split("\n")[:5]:
|
||||||
if is_first_message:
|
print(f" {line}")
|
||||||
context_msg = {
|
if problem.count("\n") > 5:
|
||||||
"role": "user",
|
print(" ...")
|
||||||
"content": (
|
|
||||||
f"Проблема для обсуждения:\n{problem}\n\n"
|
|
||||||
"Обсуди её с другими участниками. Выскажи свою позицию. "
|
|
||||||
"При необходимости используй инструменты."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
context_msg = {
|
|
||||||
"role": "user",
|
|
||||||
"content": (
|
|
||||||
f"Вот что уже обсудили:\n\n{_format_conversation(conversation)}\n\n"
|
|
||||||
"Продолжи обсуждение. Ответь на аргументы, предложи своё. "
|
|
||||||
"При необходимости используй инструменты."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = [system_msg, context_msg]
|
|
||||||
tool_schemas = get_tool_schemas(role_config.get("tools", "none"))
|
|
||||||
has_tools = len(tool_schemas) > 0
|
|
||||||
|
|
||||||
for _ in range(MAX_TOOL_ITERATIONS):
|
|
||||||
if has_tools:
|
|
||||||
result = chat(
|
|
||||||
provider=role_config["provider"],
|
|
||||||
model=role_config["model"],
|
|
||||||
messages=messages,
|
|
||||||
temperature=role_config.get("temperature", 0.7),
|
|
||||||
tools=tool_schemas,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
content = chat_stream(
|
|
||||||
provider=role_config["provider"],
|
|
||||||
model=role_config["model"],
|
|
||||||
messages=messages,
|
|
||||||
temperature=role_config.get("temperature", 0.7),
|
|
||||||
)
|
|
||||||
return content
|
|
||||||
|
|
||||||
if result["tool_calls"]:
|
|
||||||
for tc in result["tool_calls"]:
|
|
||||||
print(f"\n{TOOL_COLOR} -> {tc['name']}({tc['arguments']}){RESET}")
|
|
||||||
|
|
||||||
messages.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": result["content"] or None,
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": tc["id"],
|
|
||||||
"type": "function",
|
|
||||||
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
|
||||||
}
|
|
||||||
for tc in result["tool_calls"]
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
for tc in result["tool_calls"]:
|
|
||||||
try:
|
|
||||||
args = json.loads(tc["arguments"]) if isinstance(tc["arguments"], str) else tc["arguments"]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
args = {}
|
|
||||||
tool_result = execute_tool(tc["name"], args)
|
|
||||||
preview = tool_result[:200].replace("\n", " ")
|
|
||||||
print(f"{TOOL_COLOR} <- {preview}{'...' if len(tool_result) > 200 else ''}{RESET}")
|
|
||||||
messages.append({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tc["id"],
|
|
||||||
"content": tool_result,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result["content"]:
|
|
||||||
print(result["content"])
|
|
||||||
return result["content"]
|
|
||||||
|
|
||||||
return result.get("content", "")
|
|
||||||
|
|
||||||
|
|
||||||
def run(scenario_path: str, config: dict):
|
|
||||||
init_providers(config)
|
|
||||||
scenario = load_scenario(scenario_path)
|
|
||||||
roles_config = config["roles"]
|
|
||||||
|
|
||||||
problem = scenario["problem"]
|
|
||||||
participants = scenario.get("participants", list(roles_config.keys()))
|
|
||||||
max_rounds = scenario.get("max_rounds", config["defaults"]["max_rounds"])
|
|
||||||
|
|
||||||
set_workdir(config["defaults"].get("workdir", "."))
|
|
||||||
|
|
||||||
conversation = []
|
|
||||||
|
|
||||||
print(f"\n{BOLD}{'='*60}")
|
|
||||||
print(f" MEETING ROOM — {scenario['name']}")
|
|
||||||
print(f"{'='*60}{RESET}\n")
|
|
||||||
print(f"{BOLD}Problem:{RESET}")
|
|
||||||
for line in problem.split("\n")[:5]:
|
|
||||||
print(f" {line}")
|
|
||||||
if problem.count("\n") > 5:
|
|
||||||
print(" ...")
|
|
||||||
print()
|
|
||||||
print(f"{BOLD}Participants:{RESET}")
|
|
||||||
for rid in participants:
|
|
||||||
r = roles_config[rid]
|
|
||||||
print(f" {r['name']} — {r['provider']}/{r['model']} [tools: {r.get('tools', 'none')}]")
|
|
||||||
print(f"\n{BOLD}Rounds:{RESET} {max_rounds}")
|
|
||||||
print(f"{DIM}{'─'*60}{RESET}\n")
|
|
||||||
|
|
||||||
for round_num in range(1, max_rounds + 1):
|
|
||||||
print(f"{BOLD}[Round {round_num}/{max_rounds}]{RESET}\n")
|
|
||||||
|
|
||||||
for role_id in participants:
|
|
||||||
role = roles_config[role_id]
|
|
||||||
color = COLORS.get(role_id, "")
|
|
||||||
name = role["name"]
|
|
||||||
is_first = len(conversation) == 0
|
|
||||||
|
|
||||||
print(f"{color}{BOLD} {name}:{RESET}", end=" ")
|
|
||||||
|
|
||||||
try:
|
|
||||||
content = _run_agent_turn(role, problem, conversation, is_first)
|
|
||||||
except Exception as e:
|
|
||||||
content = f"[Error: {e}]"
|
|
||||||
print(content)
|
|
||||||
|
|
||||||
conversation.append({"role_id": role_id, "name": name, "content": content})
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
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"{BOLD}Rounds:{RESET} {data['rounds']}")
|
||||||
print(f" DISCUSSION COMPLETE — {len(conversation)} messages")
|
print(f"{DIM}{'─' * 60}{RESET}\n")
|
||||||
print(f"{'='*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)
|
||||||
@@ -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 json
|
||||||
import argparse
|
import os
|
||||||
import yaml
|
import sys
|
||||||
from datetime import datetime
|
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:
|
COLORS = {
|
||||||
"""Load config from file, resolve env vars, apply overrides."""
|
"moderator": "\033[96m",
|
||||||
if config_path is None:
|
"skeptic": "\033[91m",
|
||||||
config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml")
|
"idea_generator": "\033[92m",
|
||||||
|
"analyst": "\033[93m",
|
||||||
with open(config_path) as f:
|
}
|
||||||
raw = f.read()
|
TOOL_COLOR = "\033[90m"
|
||||||
|
RESET = "\033[0m"
|
||||||
# Resolve ${ENV_VAR} placeholders
|
BOLD = "\033[1m"
|
||||||
import re
|
DIM = "\033[2m"
|
||||||
def _env_replace(match):
|
|
||||||
var = match.group(1)
|
|
||||||
return os.environ.get(var, match.group(0))
|
|
||||||
raw = re.sub(r'\$\{(\w+)\}', _env_replace, raw)
|
|
||||||
|
|
||||||
config = yaml.safe_load(raw)
|
|
||||||
|
|
||||||
# Override API keys from environment if set
|
|
||||||
for prov_name, prov_cfg in config.get("providers", {}).items():
|
|
||||||
env_key = f"MEETING_ROOM_{prov_name.upper()}_API_KEY"
|
|
||||||
if os.environ.get(env_key):
|
|
||||||
prov_cfg["api_key"] = os.environ[env_key]
|
|
||||||
env_url = f"MEETING_ROOM_{prov_name.upper()}_BASE_URL"
|
|
||||||
if os.environ.get(env_url):
|
|
||||||
prov_cfg["base_url"] = os.environ[env_url]
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def list_scenarios(scenarios_dir: str | None = None):
|
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:
|
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)
|
items = _list_scenarios(scenarios_dir)
|
||||||
if not items:
|
if not items:
|
||||||
print("No scenarios found. Create .md or .yaml in scenarios/")
|
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):
|
def list_roles(config: dict):
|
||||||
|
"""Print roles and model bindings to stdout."""
|
||||||
roles = config.get("roles", {})
|
roles = config.get("roles", {})
|
||||||
providers = config.get("providers", {})
|
providers = config.get("providers", {})
|
||||||
print("\nRoles and model bindings:")
|
print("\nRoles and model bindings:")
|
||||||
@@ -79,6 +91,8 @@ def list_roles(config: dict):
|
|||||||
|
|
||||||
def init_workspace(path: str = "."):
|
def init_workspace(path: str = "."):
|
||||||
"""Initialize a .meeting-room workspace in given directory."""
|
"""Initialize a .meeting-room workspace in given directory."""
|
||||||
|
import yaml # noqa: avoid top-level import for speed
|
||||||
|
|
||||||
target = os.path.abspath(path)
|
target = os.path.abspath(path)
|
||||||
ws_dir = os.path.join(target, ".meeting-room")
|
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."""
|
"""Save discussion result to a JSON file."""
|
||||||
os.makedirs(sessions_dir, exist_ok=True)
|
os.makedirs(sessions_dir, exist_ok=True)
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
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"
|
filename = f"{timestamp}_{safe_name}.json"
|
||||||
filepath = os.path.join(sessions_dir, filename)
|
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
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
def find_workspace_config() -> str | None:
|
# ---------------------------------------------------------------------------
|
||||||
"""Search for .meeting-room/config.yaml starting from CWD upward."""
|
# Event handlers — produce the same ANSI output as the legacy backend
|
||||||
current = os.getcwd()
|
# ---------------------------------------------------------------------------
|
||||||
while current != "/":
|
|
||||||
candidate = os.path.join(current, ".meeting-room", "config.yaml")
|
|
||||||
if os.path.exists(candidate):
|
|
||||||
return candidate
|
|
||||||
parent = os.path.dirname(current)
|
|
||||||
if parent == current:
|
|
||||||
break
|
|
||||||
current = parent
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def run_discussion(backend: str, scenario: str, config: dict, save: bool = False):
|
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)
|
scenario_path = scenario if os.path.isabs(scenario) else os.path.join(os.getcwd(), scenario)
|
||||||
if not os.path.exists(scenario_path):
|
if not os.path.exists(scenario_path):
|
||||||
# Try bundled scenarios
|
# 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):
|
if not os.path.exists(scenario_path):
|
||||||
print(f"Scenario not found: {scenario}")
|
print(f"Scenario not found: {scenario}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if backend == "custom":
|
# Convert raw config to typed DiscussionConfig
|
||||||
from meeting_room.backends.custom.run import run
|
discussion_config = load_discussion_config()
|
||||||
conversation = run(scenario_path, config)
|
# Apply any overrides from the raw config (e.g. workdir from CLI)
|
||||||
elif backend == "autogen":
|
if "defaults" in config and "workdir" in config["defaults"]:
|
||||||
from meeting_room.backends.autogen.run import run
|
discussion_config.defaults.workdir = config["defaults"]["workdir"]
|
||||||
conversation = run(scenario_path, config)
|
|
||||||
elif backend == "crewai":
|
# Build engine components
|
||||||
from meeting_room.backends.crewai.run import run
|
providers = {
|
||||||
conversation = run(scenario_path, config)
|
name: ProviderConfig(**vals)
|
||||||
else:
|
for name, vals in config.get("providers", {}).items()
|
||||||
print(f"Unknown backend: {backend}")
|
}
|
||||||
sys.exit(1)
|
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:
|
if save and conversation:
|
||||||
# Determine sessions dir
|
# Determine sessions dir
|
||||||
@@ -253,22 +381,19 @@ def run_discussion(backend: str, scenario: str, config: dict, save: bool = False
|
|||||||
else:
|
else:
|
||||||
sessions_dir = os.path.join(os.getcwd(), "sessions")
|
sessions_dir = os.path.join(os.getcwd(), "sessions")
|
||||||
|
|
||||||
scenario_data = load_scenario(scenario_path)
|
scenario_obj = load_scenario(scenario_path)
|
||||||
save_session(conversation, scenario_data.get("name", "discussion"), sessions_dir)
|
save_session(conversation, scenario_obj.get("name", "discussion"), sessions_dir)
|
||||||
|
|
||||||
return conversation
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
"""CLI entry point."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Meeting Room — Multi-Agent Discussion Framework",
|
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(
|
parser.add_argument(
|
||||||
"-s", "--scenario",
|
"-s", "--scenario",
|
||||||
default="example-saas.md",
|
default="example-saas.md",
|
||||||
@@ -301,6 +426,11 @@ def main():
|
|||||||
init_parser = subparsers.add_parser("init", help="Initialize .meeting-room workspace")
|
init_parser = subparsers.add_parser("init", help="Initialize .meeting-room workspace")
|
||||||
init_parser.add_argument("path", nargs="?", default=".", help="Where to create .meeting-room")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Handle init command
|
# Handle init command
|
||||||
@@ -308,6 +438,18 @@ def main():
|
|||||||
init_workspace(args.path)
|
init_workspace(args.path)
|
||||||
return
|
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
|
# Find config: CLI arg > .meeting-room/config.yaml > package default
|
||||||
if args.config:
|
if args.config:
|
||||||
config = load_config(args.config)
|
config = load_config(args.config)
|
||||||
@@ -336,8 +478,8 @@ def main():
|
|||||||
if args.workdir:
|
if args.workdir:
|
||||||
config["defaults"]["workdir"] = 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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
115
meeting_room/config.py
Normal file
115
meeting_room/config.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -9,6 +9,9 @@ providers:
|
|||||||
routerai:
|
routerai:
|
||||||
base_url: "https://routerai.ru/api/v1"
|
base_url: "https://routerai.ru/api/v1"
|
||||||
api_key: "${MEETING_ROOM_ROUTERAI_API_KEY}" # set in env or replace here
|
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:
|
# openai:
|
||||||
# base_url: "https://api.openai.com/v1"
|
# base_url: "https://api.openai.com/v1"
|
||||||
@@ -22,14 +25,19 @@ providers:
|
|||||||
# base_url: "http://localhost:11434/v1"
|
# base_url: "http://localhost:11434/v1"
|
||||||
# api_key: "ollama"
|
# api_key: "ollama"
|
||||||
|
|
||||||
|
# deepseek:
|
||||||
|
# base_url: "https://api.deepseek.com/v1"
|
||||||
|
# api_key: "${MEETING_ROOM_DEEPSEEK_API_KEY}"
|
||||||
|
|
||||||
# ─── Roles ───
|
# ─── Roles ───
|
||||||
# tools: all | full | files | readonly | web | none | or list ["read_file", "web_search"]
|
# tools: all | full | files | readonly | web | none | or list ["read_file", "web_search"]
|
||||||
|
|
||||||
roles:
|
roles:
|
||||||
|
# ── General discussion ──
|
||||||
moderator:
|
moderator:
|
||||||
name: "Moderator"
|
name: "Moderator"
|
||||||
provider: routerai
|
provider: ollama_cloud
|
||||||
model: "gpt-4o-mini"
|
model: "glm-5.1"
|
||||||
temperature: 0.7
|
temperature: 0.7
|
||||||
tools: "none"
|
tools: "none"
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
@@ -40,8 +48,8 @@ roles:
|
|||||||
|
|
||||||
skeptic:
|
skeptic:
|
||||||
name: "Skeptic"
|
name: "Skeptic"
|
||||||
provider: routerai
|
provider: ollama_cloud
|
||||||
model: "gpt-4o-mini"
|
model: "glm-5.1"
|
||||||
temperature: 0.9
|
temperature: 0.9
|
||||||
tools: "readonly"
|
tools: "readonly"
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
@@ -52,8 +60,8 @@ roles:
|
|||||||
|
|
||||||
idea_generator:
|
idea_generator:
|
||||||
name: "Idea Generator"
|
name: "Idea Generator"
|
||||||
provider: routerai
|
provider: ollama_cloud
|
||||||
model: "gpt-4o-mini"
|
model: "glm-5.1"
|
||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
tools: "web"
|
tools: "web"
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
@@ -65,8 +73,8 @@ roles:
|
|||||||
|
|
||||||
analyst:
|
analyst:
|
||||||
name: "Analyst"
|
name: "Analyst"
|
||||||
provider: routerai
|
provider: ollama_cloud
|
||||||
model: "gpt-4o-mini"
|
model: "glm-5.1"
|
||||||
temperature: 0.5
|
temperature: 0.5
|
||||||
tools: "all"
|
tools: "all"
|
||||||
system_prompt: |
|
system_prompt: |
|
||||||
@@ -76,6 +84,45 @@ roles:
|
|||||||
Propose concrete action plans.
|
Propose concrete action plans.
|
||||||
Use tools to verify facts, read project files, and search for info.
|
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:
|
defaults:
|
||||||
max_rounds: 8
|
max_rounds: 8
|
||||||
language: "ru"
|
language: "ru"
|
||||||
|
|||||||
337
meeting_room/engine.py
Normal file
337
meeting_room/engine.py
Normal 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
247
meeting_room/events.py
Normal 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
124
meeting_room/models.py
Normal 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),
|
||||||
|
)
|
||||||
39
meeting_room/scenarios/code-review.md
Normal file
39
meeting_room/scenarios/code-review.md
Normal 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
451
meeting_room/server.py
Normal 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
306
meeting_room/static/index.html
Normal file
306
meeting_room/static/index.html
Normal 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>
|
||||||
@@ -1,32 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Tools system for Meeting Room agents.
|
Tools system for Meeting Room agents.
|
||||||
Provides file access and web capabilities via OpenAI function calling.
|
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 os
|
||||||
import re
|
import re
|
||||||
import json
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import httpx
|
|
||||||
|
|
||||||
WORKDIR = "."
|
|
||||||
|
|
||||||
|
|
||||||
def set_workdir(path: str):
|
|
||||||
global WORKDIR
|
|
||||||
WORKDIR = os.path.abspath(path)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_path(path: str) -> str:
|
|
||||||
if os.path.isabs(path):
|
|
||||||
return path
|
|
||||||
return os.path.normpath(os.path.join(WORKDIR, path))
|
|
||||||
|
|
||||||
|
|
||||||
def _truncate(text: str, max_len: int = 5000) -> str:
|
|
||||||
if len(text) <= max_len:
|
|
||||||
return text
|
|
||||||
return text[:max_len] + f"\n... [обрезано, всего {len(text)} символов]"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Tool schemas (OpenAI function calling format) ───
|
# ─── Tool schemas (OpenAI function calling format) ───
|
||||||
@@ -36,13 +21,13 @@ TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "read_file",
|
"name": "read_file",
|
||||||
"description": "Прочитать содержимое файла. Возвращает текст с номерами строк.",
|
"description": "Read file contents. Returns text with line numbers.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string", "description": "Путь к файлу"},
|
"path": {"type": "string", "description": "Path to the file"},
|
||||||
"offset": {"type": "integer", "description": "Начать с этой строки (1-indexed)", "default": 1},
|
"offset": {"type": "integer", "description": "Start from this line (1-indexed)", "default": 1},
|
||||||
"limit": {"type": "integer", "description": "Максимум строк", "default": 100},
|
"limit": {"type": "integer", "description": "Maximum number of lines", "default": 100},
|
||||||
},
|
},
|
||||||
"required": ["path"],
|
"required": ["path"],
|
||||||
},
|
},
|
||||||
@@ -52,13 +37,13 @@ TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "list_files",
|
"name": "list_files",
|
||||||
"description": "Показать список файлов и директорий.",
|
"description": "List files and directories.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string", "description": "Директория для просмотра"},
|
"path": {"type": "string", "description": "Directory to list"},
|
||||||
"pattern": {"type": "string", "description": "Фильтр по имени (glob)"},
|
"pattern": {"type": "string", "description": "Filter by name (glob)"},
|
||||||
"max_depth": {"type": "integer", "description": "Глубина обхода", "default": 3},
|
"max_depth": {"type": "integer", "description": "Traversal depth", "default": 3},
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [],
|
||||||
},
|
},
|
||||||
@@ -68,13 +53,13 @@ TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "search_in_files",
|
"name": "search_in_files",
|
||||||
"description": "Поиск текста в файлах (regex).",
|
"description": "Search text in files (regex).",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"query": {"type": "string", "description": "Поисковый запрос (regex)"},
|
"query": {"type": "string", "description": "Search query (regex)"},
|
||||||
"path": {"type": "string", "description": "Директория для поиска"},
|
"path": {"type": "string", "description": "Directory to search in"},
|
||||||
"file_pattern": {"type": "string", "description": "Фильтр по расширению"},
|
"file_pattern": {"type": "string", "description": "Filter by extension (e.g. *.py)"},
|
||||||
},
|
},
|
||||||
"required": ["query"],
|
"required": ["query"],
|
||||||
},
|
},
|
||||||
@@ -84,12 +69,12 @@ TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "web_search",
|
"name": "web_search",
|
||||||
"description": "Поиск в интернете. Возвращает заголовки, сниппеты и URL.",
|
"description": "Search the web. Returns titles, snippets and URLs.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"query": {"type": "string", "description": "Поисковый запрос"},
|
"query": {"type": "string", "description": "Search query"},
|
||||||
"max_results": {"type": "integer", "description": "Максимум результатов", "default": 5},
|
"max_results": {"type": "integer", "description": "Maximum results", "default": 5},
|
||||||
},
|
},
|
||||||
"required": ["query"],
|
"required": ["query"],
|
||||||
},
|
},
|
||||||
@@ -99,12 +84,12 @@ TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "web_fetch",
|
"name": "web_fetch",
|
||||||
"description": "Скачать содержимое веб-страницы.",
|
"description": "Fetch web page content.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"url": {"type": "string", "description": "URL для загрузки"},
|
"url": {"type": "string", "description": "URL to fetch"},
|
||||||
"max_length": {"type": "integer", "description": "Максимум символов", "default": 5000},
|
"max_length": {"type": "integer", "description": "Maximum characters", "default": 5000},
|
||||||
},
|
},
|
||||||
"required": ["url"],
|
"required": ["url"],
|
||||||
},
|
},
|
||||||
@@ -114,12 +99,12 @@ TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "write_file",
|
"name": "write_file",
|
||||||
"description": "Записать содержимое в файл.",
|
"description": "Write content to a file.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string", "description": "Путь к файлу"},
|
"path": {"type": "string", "description": "Path to the file"},
|
||||||
"content": {"type": "string", "description": "Содержимое"},
|
"content": {"type": "string", "description": "Content to write"},
|
||||||
},
|
},
|
||||||
"required": ["path", "content"],
|
"required": ["path", "content"],
|
||||||
},
|
},
|
||||||
@@ -129,12 +114,12 @@ TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "run_command",
|
"name": "run_command",
|
||||||
"description": "Выполнить shell-команду.",
|
"description": "Run a shell command.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"command": {"type": "string", "description": "Команда"},
|
"command": {"type": "string", "description": "Command to run"},
|
||||||
"timeout": {"type": "integer", "description": "Таймаут (сек)", "default": 30},
|
"timeout": {"type": "integer", "description": "Timeout in seconds", "default": 30},
|
||||||
},
|
},
|
||||||
"required": ["command"],
|
"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 ───
|
# Tool set definitions
|
||||||
|
|
||||||
def tool_read_file(path: str, offset: int = 1, limit: int = 100) -> str:
|
|
||||||
resolved = _resolve_path(path)
|
|
||||||
if not os.path.isfile(resolved):
|
|
||||||
return f"Ошибка: файл не найден — {resolved}"
|
|
||||||
try:
|
|
||||||
with open(resolved, encoding="utf-8", errors="replace") as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
total = len(lines)
|
|
||||||
selected = lines[offset - 1 : offset - 1 + limit]
|
|
||||||
result = "".join(f"{i}|{line}" for i, line in enumerate(selected, start=offset))
|
|
||||||
return _truncate(f"[{resolved} — {total} строк]\n{result}")
|
|
||||||
except Exception as e:
|
|
||||||
return f"Ошибка чтения: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def tool_list_files(path: str = "", pattern: str = "", max_depth: int = 3) -> str:
|
|
||||||
base = _resolve_path(path or ".")
|
|
||||||
if not os.path.isdir(base):
|
|
||||||
return f"Ошибка: директория не найдена — {base}"
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["find", base, "-maxdepth", str(max_depth),
|
|
||||||
"-not", "-path", "*/.git/*", "-not", "-path", "*/.venv/*",
|
|
||||||
"-not", "-path", "*/node_modules/*", "-not", "-path", "*/__pycache__/*"],
|
|
||||||
capture_output=True, text=True, timeout=10,
|
|
||||||
)
|
|
||||||
lines = result.stdout.strip().split("\n")
|
|
||||||
if pattern:
|
|
||||||
import fnmatch
|
|
||||||
lines = [l for l in lines if fnmatch.fnmatch(os.path.basename(l), pattern)]
|
|
||||||
lines.sort()
|
|
||||||
return _truncate("\n".join(lines))
|
|
||||||
except Exception as e:
|
|
||||||
return f"Ошибка: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def tool_search_in_files(query: str, path: str = "", file_pattern: str = "") -> str:
|
|
||||||
base = _resolve_path(path or ".")
|
|
||||||
cmd = ["grep", "-rn", "--color=never", "-E", query, base]
|
|
||||||
if file_pattern:
|
|
||||||
cmd.extend(["--include", file_pattern])
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
|
||||||
if result.returncode == 1:
|
|
||||||
return "Совпадений не найдено."
|
|
||||||
return _truncate(result.stdout.strip())
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return "Таймаут поиска."
|
|
||||||
except Exception as e:
|
|
||||||
return f"Ошибка: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def tool_web_search(query: str, max_results: int = 5) -> str:
|
|
||||||
try:
|
|
||||||
client = httpx.Client(timeout=15, follow_redirects=True)
|
|
||||||
resp = client.get(
|
|
||||||
"https://html.duckduckgo.com/html/", params={"q": query},
|
|
||||||
headers={"User-Agent": "Mozilla/5.0"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
results = []
|
|
||||||
for match in re.finditer(
|
|
||||||
r'<a rel="nofollow" class="result__a" href="([^"]+)">\s*(.*?)\s*</a>.*?'
|
|
||||||
r'<a class="result__snippet".*?>(.*?)</a>',
|
|
||||||
resp.text, re.DOTALL,
|
|
||||||
):
|
|
||||||
url = match.group(1)
|
|
||||||
title = re.sub(r'<[^>]+>', '', match.group(2)).strip()
|
|
||||||
snippet = re.sub(r'<[^>]+>', '', match.group(3)).strip()
|
|
||||||
results.append(f"{title}\n {url}\n {snippet}")
|
|
||||||
if len(results) >= max_results:
|
|
||||||
break
|
|
||||||
if not results:
|
|
||||||
return "Результатов не найдено."
|
|
||||||
return _truncate("\n\n".join(f"[{i+1}] {r}" for i, r in enumerate(results)))
|
|
||||||
except Exception as e:
|
|
||||||
return f"Ошибка поиска: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def tool_web_fetch(url: str, max_length: int = 5000) -> str:
|
|
||||||
try:
|
|
||||||
client = httpx.Client(timeout=20, follow_redirects=True)
|
|
||||||
resp = client.get(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
||||||
resp.raise_for_status()
|
|
||||||
content_type = resp.headers.get("content-type", "")
|
|
||||||
if "html" in content_type:
|
|
||||||
text = re.sub(r'<script[^>]*>.*?</script>', '', resp.text, flags=re.DOTALL)
|
|
||||||
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL)
|
|
||||||
text = re.sub(r'<[^>]+>', ' ', text)
|
|
||||||
text = re.sub(r'\s+', ' ', text).strip()
|
|
||||||
else:
|
|
||||||
text = resp.text
|
|
||||||
return _truncate(f"[{url} — {len(resp.text)} символов]\n{text}", max_length)
|
|
||||||
except Exception as e:
|
|
||||||
return f"Ошибка загрузки: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def tool_write_file(path: str, content: str) -> str:
|
|
||||||
resolved = _resolve_path(path)
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(resolved), exist_ok=True)
|
|
||||||
with open(resolved, "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
return f"Записано {len(content)} символов в {resolved}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Ошибка записи: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def tool_run_command(command: str, timeout: int = 30) -> str:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
command, shell=True, capture_output=True, text=True,
|
|
||||||
timeout=timeout, cwd=WORKDIR,
|
|
||||||
)
|
|
||||||
output = result.stdout
|
|
||||||
if result.stderr:
|
|
||||||
output += f"\nSTDERR:\n{result.stderr}"
|
|
||||||
if result.returncode != 0:
|
|
||||||
output += f"\n[exit code: {result.returncode}]"
|
|
||||||
return _truncate(output)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return f"Таймаут ({timeout}с)"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Ошибка: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Dispatcher ───
|
|
||||||
|
|
||||||
TOOL_FUNCTIONS = {
|
|
||||||
"read_file": tool_read_file,
|
|
||||||
"list_files": tool_list_files,
|
|
||||||
"search_in_files": tool_search_in_files,
|
|
||||||
"web_search": tool_web_search,
|
|
||||||
"web_fetch": tool_web_fetch,
|
|
||||||
"write_file": tool_write_file,
|
|
||||||
"run_command": tool_run_command,
|
|
||||||
}
|
|
||||||
|
|
||||||
TOOL_SETS = {
|
TOOL_SETS = {
|
||||||
"all": list(TOOL_FUNCTIONS.keys()),
|
"all": ["read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"],
|
||||||
"full": list(TOOL_FUNCTIONS.keys()),
|
"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"],
|
"files": ["read_file", "list_files", "search_in_files", "write_file"],
|
||||||
"readonly": ["read_file", "list_files", "search_in_files"],
|
"readonly": ["read_file", "list_files", "search_in_files"],
|
||||||
"web": ["web_search", "web_fetch"],
|
"web": ["web_search", "web_fetch"],
|
||||||
"none": [],
|
"none": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_tool_schemas(tool_set: str = "all") -> list[dict]:
|
def _truncate(text: str, max_len: int = 5000) -> str:
|
||||||
if isinstance(tool_set, list):
|
if len(text) <= max_len:
|
||||||
names = tool_set
|
return text
|
||||||
else:
|
return text[:max_len] + f"\n... [truncated, {len(text)} chars total]"
|
||||||
names = TOOL_SETS.get(tool_set, TOOL_SETS["all"])
|
|
||||||
return [s for s in TOOL_SCHEMAS if s["function"]["name"] in names]
|
|
||||||
|
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:
|
def execute_tool(name: str, arguments: dict) -> str:
|
||||||
func = TOOL_FUNCTIONS.get(name)
|
"""Execute a tool by name with the given arguments."""
|
||||||
if not func:
|
return _get_registry().execute_tool(name, arguments)
|
||||||
return f"Неизвестный инструмент: {name}"
|
|
||||||
try:
|
|
||||||
return func(**arguments)
|
# Legacy dict-based dispatch (kept for any code that imports TOOL_FUNCTIONS)
|
||||||
except Exception as e:
|
TOOL_FUNCTIONS = {
|
||||||
return f"Ошибка выполнения {name}: {e}"
|
"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),
|
||||||
|
}
|
||||||
@@ -11,13 +11,14 @@ requires-python = ">=3.11"
|
|||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"pydantic>=2.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
autogen = ["autogen-agentchat", "autogen-ext"]
|
dev = ["pytest>=7.0", "pytest-mock>=3.0", "pytest-asyncio>=0.23"]
|
||||||
crewai = ["crewai"]
|
web = ["fastapi>=0.100", "uvicorn>=0.20", "websockets>=11.0"]
|
||||||
all = ["autogen-agentchat", "autogen-ext", "crewai"]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
meeting-room = "meeting_room.cli:main"
|
meeting-room = "meeting_room.cli:main"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
pyyaml>=6.0
|
pydantic>=2.0
|
||||||
|
pyyaml>=6.0
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
359
tests/test_api_client.py
Normal file
359
tests/test_api_client.py
Normal 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
225
tests/test_async_bridge.py
Normal 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
263
tests/test_config.py
Normal 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
646
tests/test_engine.py
Normal 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
311
tests/test_events.py
Normal 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
396
tests/test_models.py
Normal 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
204
tests/test_server.py
Normal 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
389
tests/test_tools.py
Normal 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
|
||||||
Reference in New Issue
Block a user