Compare commits

..

10 Commits

Author SHA1 Message Date
962b0beddd docs: update README, wiki overview, index, log; add roles/code-review/pydantic wiki pages
- README: remove autogen/crewai references, add v2 architecture diagram,
  code-review scenario, cross-LLM config examples, roadmap
- wiki/overview: event-driven architecture, Pydantic v2, roadmap
- wiki/index: add entities/roles, concepts/code-review, packages/pydantic
- wiki/log: Phase 1 completion, code-review decision
- New: entities/roles.md, concepts/code-review.md, packages/pydantic.md

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

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

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

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

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

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

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

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

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

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

55
.tasks/STATUS.md Normal file
View File

@@ -0,0 +1,55 @@
# Task Board
_Updated: 2026-05-04_
## 🟢 v2-p1-deps — Обновить зависимости (pydantic, pytest, [web])
**Status:** done
**Branch:** feat/v2-phase1-core-refactor
---
## 🟢 v2-p1-models — Pydantic модели данных (ProviderConfig, RoleConfig, Session и др.)
**Status:** done
**Branch:** feat/v2-phase1-core-refactor
---
## 🟢 v2-p1-events — EventEmitter и типы событий
**Status:** done
**Branch:** feat/v2-phase1-core-refactor
---
## 🟢 v2-p1-api-client — Рефактор APIClient (класс вместо глобалов)
**Status:** done
**Branch:** feat/v2-phase1-core-refactor
---
## 🟢 v2-p1-tools — Рефактор ToolRegistry (класс, Windows-совместимость)
**Status:** done
**Branch:** feat/v2-phase1-core-refactor
---
## 🟢 v2-p1-engine — DiscussionEngine (emit вместо print)
**Status:** done
**Branch:** feat/v2-phase1-core-refactor
---
## 🟢 v2-p1-config-cli — config.py + CLI рефактор + deprecation shim
**Status:** done
**Branch:** feat/v2-phase1-core-refactor
---
<!--
Фаза 1 завершена! Фаза 2 (Web-сервер) задачи будут добавлены.
Статусы:
🔴 Active — только одна одновременно
🟡 Paused — в процессе, возобновима
⚪ Ready — определена, не начата
🟢 Done — до слияния
🔵 Blocked — ждёт внешнего
-->

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

35
.wiki/CLAUDE.md Normal file
View File

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

View File

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

View File

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

View File

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

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

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

25
.wiki/index.md Normal file
View File

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

19
.wiki/log.md Normal file
View File

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

41
.wiki/overview.md Normal file
View File

@@ -0,0 +1,41 @@
---
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, future WebSocket server will stream 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
- **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
- Event-driven architecture (EventEmitter, Phase 2: AsyncEventBridge)
- OpenAI-compatible API (multi-provider: RouterAI, Ollama Cloud, OpenAI, Anthropic, DeepSeek, local Ollama)
- Planned: FastAPI + WebSocket for Web UI (Phase 2)
## Roadmap
- [x] Phase 1: Core refactor — Pydantic models, EventEmitter, class-based APIClient/ToolRegistry, DiscussionEngine
- [ ] Phase 2: Web server — FastAPI + WebSocket + SSE, REST API, HTML UI
- [ ] Phase 3: Interactivity — Boss role, CSO injection, Setup Wizard, BrainstormEngine
- [ ] Phase 4: Artifacts — LLM extraction to .tasks/.wiki, SessionArchive

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

View File

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

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

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

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

11
CLAUDE.md Normal file
View File

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

103
README.md
View File

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

View File

@@ -1,37 +1,61 @@
"""Universal API client supporting multiple OpenAI-compatible providers."""
import httpx
from __future__ import annotations
import json
from typing import Iterator
import httpx
from meeting_room.models import ProviderConfig
PROVIDERS = {}
class APIClient:
"""Stateful client that holds provider configs and makes LLM calls.
Parameters
----------
providers:
Mapping of provider name to ``ProviderConfig`` (base_url + api_key).
"""
def init_providers(config: dict):
global PROVIDERS
PROVIDERS = config.get("providers", {})
def __init__(self, providers: dict[str, ProviderConfig]) -> None:
self.providers: dict[str, ProviderConfig] = providers
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_client(provider_name: str) -> httpx.Client:
p = PROVIDERS.get(provider_name)
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']}"},
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:
client = _get_client(provider)
payload = {
"""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,
@@ -45,31 +69,37 @@ def chat(
data = resp.json()
message = data["choices"][0]["message"]
result = {
result: dict = {
"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({
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:
client = _get_client(provider)
payload = {
"""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,
@@ -88,9 +118,61 @@ def chat_stream(
chunk = json.loads(data)
delta = chunk["choices"][0]["delta"]
if "content" in delta and delta["content"]:
print(delta["content"], end="", flush=True)
full_text += delta["content"]
except (json.JSONDecodeError, KeyError, IndexError):
continue
print()
return full_text
# ======================================================================
# Module-level backward-compatible wrappers
# ======================================================================
_default_client: APIClient | None = None
def init_providers(config: dict) -> None:
"""Initialise the module-level default ``APIClient``.
*config* is the raw dict produced by ``yaml.safe_load()`` — it must
contain a ``providers`` key whose value maps provider names to
``{base_url, api_key}`` dicts.
"""
global _default_client
raw: dict = config.get("providers", {})
providers: dict[str, ProviderConfig] = {
name: ProviderConfig(**vals) for name, vals in raw.items()
}
_default_client = APIClient(providers)
def _require_default() -> APIClient:
if _default_client is None:
raise RuntimeError(
"init_providers() must be called before using module-level chat/chat_stream"
)
return _default_client
def chat(
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
tools: list[dict] | None = None,
) -> dict:
"""Module-level wrapper that delegates to the default ``APIClient``."""
return _require_default().chat(provider, model, messages, temperature, tools)
def chat_stream(
provider: str,
model: str,
messages: list[dict],
temperature: float = 0.7,
) -> str:
"""Module-level wrapper that delegates to the default ``APIClient``.
Returns the accumulated text — does NOT print to stdout.
"""
return _require_default().chat_stream(provider, model, messages, temperature)

View File

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

View File

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

View File

@@ -1,16 +1,37 @@
"""Custom backend — round-robin agent discussion with tool support."""
"""Legacy backend — delegates to DiscussionEngine.
import json
import os
import sys
This module is deprecated. Use meeting_room.engine.DiscussionEngine directly.
"""
import yaml
import warnings
from meeting_room.api_client import chat, chat_stream, init_providers
warnings.warn(
"backends.custom.run is deprecated. Use meeting_room.engine.DiscussionEngine instead.",
DeprecationWarning,
stacklevel=2,
)
from meeting_room.api_client import APIClient
from meeting_room.config import load_config
from meeting_room.engine import DiscussionEngine
from meeting_room.events import (
AGENT_ERROR,
AGENT_MESSAGE,
AGENT_TURN_START,
DISCUSSION_END,
DISCUSSION_START,
ROUND_END,
ROUND_START,
TOOL_CALL,
TOOL_RESULT,
EventEmitter,
)
from meeting_room.models import ProviderConfig
from meeting_room.scenario_loader import load_scenario
from meeting_room.tools import set_workdir, get_tool_schemas, execute_tool
from meeting_room.tools import ToolRegistry
# ── ANSI colors (kept for backward-compatible output) ──
# ANSI colors
COLORS = {
"moderator": "\033[96m",
"skeptic": "\033[91m",
@@ -22,150 +43,137 @@ RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
MAX_TOOL_ITERATIONS = 5
def run(scenario_path: str, config: dict) -> list[dict]:
"""Run a discussion and return conversation in legacy dict format.
def _format_conversation(conversation: list[dict]) -> str:
return "\n\n".join(f"[{msg['name']}]: {msg['content']}" for msg in conversation)
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`).
def _run_agent_turn(role_config, problem, conversation, is_first_message):
"""Run one agent turn with tool support. Returns final text response."""
system_msg = {"role": "system", "content": role_config["system_prompt"].strip()}
Returns
-------
list[dict]
Conversation in legacy format ``[{role_id, name, content}, ...]``.
"""
from meeting_room.config import load_discussion_config
if is_first_message:
context_msg = {
"role": "user",
"content": (
f"Проблема для обсуждения:\n{problem}\n\n"
"Обсуди её с другими участниками. Выскажи свою позицию. "
"При необходимости используй инструменты."
),
}
else:
context_msg = {
"role": "user",
"content": (
f"Вот что уже обсудили:\n\n{_format_conversation(conversation)}\n\n"
"Продолжи обсуждение. Ответь на аргументы, предложи своё. "
"При необходимости используй инструменты."
),
# 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()
messages = [system_msg, context_msg]
tool_schemas = get_tool_schemas(role_config.get("tools", "none"))
has_tools = len(tool_schemas) > 0
# Subscribe event handlers for print output (same format as before)
_subscribe_legacy_handlers(event_bus)
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,
# 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,
)
else:
content = chat_stream(
provider=role_config["provider"],
model=role_config["model"],
messages=messages,
temperature=role_config.get("temperature", 0.7),
)
return content
messages = engine.run(scenario_data)
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", "")
# 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 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 = []
def _subscribe_legacy_handlers(bus: EventEmitter) -> None:
"""Wire up event handlers that produce the same ANSI output as before."""
def on_discussion_start(event) -> None:
data = event.data
print(f"\n{BOLD}{'=' * 60}")
print(f" MEETING ROOM — {scenario['name']}")
print(f" MEETING ROOM — {data['name']}")
print(f"{'=' * 60}{RESET}\n")
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()
participants = data.get("participants", [])
if participants:
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})
# 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"{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 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 — {len(conversation)} messages")
print(f" DISCUSSION COMPLETE — {data['total_messages']} messages")
print(f"{'=' * 60}{RESET}\n")
return conversation
bus.on(DISCUSSION_START, on_discussion_start)
bus.on(ROUND_START, on_round_start)
bus.on(AGENT_TURN_START, on_agent_turn_start)
bus.on(AGENT_MESSAGE, on_agent_message)
bus.on(TOOL_CALL, on_tool_call)
bus.on(TOOL_RESULT, on_tool_result)
bus.on(AGENT_ERROR, on_agent_error)
bus.on(ROUND_END, on_round_end)
bus.on(DISCUSSION_END, on_discussion_end)

View File

@@ -1,49 +1,54 @@
"""Meeting Room CLI — main entry point."""
"""Meeting Room CLI — main entry point.
Refactored (v2): run_discussion() now creates a DiscussionEngine and
subscribes to events with ANSI-color output instead of calling the
legacy backend directly.
"""
import sys
import os
import json
import argparse
import yaml
import os
import sys
from datetime import datetime
from meeting_room.scenario_loader import load_scenario, list_scenarios as _list_scenarios
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
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
# ── ANSI colors ──
def load_config(config_path: str | None = None) -> dict:
"""Load config from file, resolve env vars, apply overrides."""
if config_path is None:
config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml")
with open(config_path) as f:
raw = f.read()
# Resolve ${ENV_VAR} placeholders
import re
def _env_replace(match):
var = match.group(1)
return os.environ.get(var, match.group(0))
raw = re.sub(r'\$\{(\w+)\}', _env_replace, raw)
config = yaml.safe_load(raw)
# Override API keys from environment if set
for prov_name, prov_cfg in config.get("providers", {}).items():
env_key = f"MEETING_ROOM_{prov_name.upper()}_API_KEY"
if os.environ.get(env_key):
prov_cfg["api_key"] = os.environ[env_key]
env_url = f"MEETING_ROOM_{prov_name.upper()}_BASE_URL"
if os.environ.get(env_url):
prov_cfg["base_url"] = os.environ[env_url]
return config
COLORS = {
"moderator": "\033[96m",
"skeptic": "\033[91m",
"idea_generator": "\033[92m",
"analyst": "\033[93m",
}
TOOL_COLOR = "\033[90m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
def list_scenarios(scenarios_dir: str | None = None):
"""Print available scenarios to stdout."""
from meeting_room.scenario_loader import list_scenarios as _list_scenarios
if scenarios_dir is None:
scenarios_dir = os.path.join(PACKAGE_DIR, "scenarios")
scenarios_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenarios")
items = _list_scenarios(scenarios_dir)
if not items:
print("No scenarios found. Create .md or .yaml in scenarios/")
@@ -59,6 +64,7 @@ def list_scenarios(scenarios_dir: str | None = None):
def list_roles(config: dict):
"""Print roles and model bindings to stdout."""
roles = config.get("roles", {})
providers = config.get("providers", {})
print("\nRoles and model bindings:")
@@ -79,6 +85,8 @@ def list_roles(config: dict):
def init_workspace(path: str = "."):
"""Initialize a .meeting-room workspace in given directory."""
import yaml # noqa: avoid top-level import for speed
target = os.path.abspath(path)
ws_dir = os.path.join(target, ".meeting-room")
@@ -209,41 +217,155 @@ def save_session(conversation: list[dict], scenario_name: str, sessions_dir: str
return filepath
def find_workspace_config() -> str | None:
"""Search for .meeting-room/config.yaml starting from CWD upward."""
current = os.getcwd()
while current != "/":
candidate = os.path.join(current, ".meeting-room", "config.yaml")
if os.path.exists(candidate):
return candidate
parent = os.path.dirname(current)
if parent == current:
break
current = parent
return None
# ---------------------------------------------------------------------------
# Event handlers — produce the same ANSI output as the legacy backend
# ---------------------------------------------------------------------------
def run_discussion(backend: str, scenario: str, config: dict, save: bool = False):
def _subscribe_cli_handlers(bus: EventEmitter, max_rounds: int) -> None:
"""Wire up event handlers that print ANSI-colored output to stdout.
The output format matches the original backends/custom/run.py exactly.
"""
def on_discussion_start(event) -> None:
data = event.data
print(f"\n{BOLD}{'=' * 60}")
print(f" MEETING ROOM — {data['name']}")
print(f"{'=' * 60}{RESET}\n")
# Print problem preview
problem = data.get("problem", "")
if problem:
print(f"{BOLD}Problem:{RESET}")
for line in problem.split("\n")[:5]:
print(f" {line}")
if problem.count("\n") > 5:
print(" ...")
print()
# Print participants
participants = data.get("participants", [])
if participants:
print(f"{BOLD}Participants:{RESET}")
for name in participants:
print(f" {name}")
print()
print(f"{BOLD}Rounds:{RESET} {data['rounds']}")
print(f"{DIM}{'' * 60}{RESET}\n")
def on_round_start(event) -> None:
data = event.data
print(f"{BOLD}[Round {data['round_num']}/{data['total_rounds']}]{RESET}\n")
def on_agent_turn_start(event) -> None:
data = event.data
color = COLORS.get(data["role_id"], "")
print(f"{color}{BOLD} {data['name']}:{RESET}", end=" ")
def on_agent_message(event) -> None:
# Content is printed inline after the name; just end the line.
print()
def on_tool_call(event) -> None:
data = event.data
print(f"\n{TOOL_COLOR} -> {data['tool_name']}({data['arguments']}){RESET}")
def on_tool_result(event) -> None:
data = event.data
preview = data["result_preview"]
suffix = "..." if len(data.get("full_result", "")) > 200 else ""
print(f"{TOOL_COLOR} <- {preview}{suffix}{RESET}")
def on_agent_error(event) -> None:
data = event.data
# Error content is already printed inline by the name header;
# just note the error type.
print(f" [Error: {data['error']}]")
def on_round_end(event) -> None:
print(f"{DIM}{'' * 60}{RESET}\n")
def on_discussion_end(event) -> None:
data = event.data
print(f"\n{BOLD}{'=' * 60}")
print(f" DISCUSSION COMPLETE — {data['total_messages']} messages")
print(f"{'=' * 60}{RESET}\n")
bus.on(DISCUSSION_START, on_discussion_start)
bus.on(ROUND_START, on_round_start)
bus.on(AGENT_TURN_START, on_agent_turn_start)
bus.on(AGENT_MESSAGE, on_agent_message)
bus.on(TOOL_CALL, on_tool_call)
bus.on(TOOL_RESULT, on_tool_result)
bus.on(AGENT_ERROR, on_agent_error)
bus.on(ROUND_END, on_round_end)
bus.on(DISCUSSION_END, on_discussion_end)
def run_discussion(scenario: str, config: dict, save: bool = False):
"""Run a discussion through DiscussionEngine with ANSI event output.
Parameters
----------
scenario : str
Path or name of the scenario file.
config : dict
Raw config dict (as returned by :func:`load_config`).
save : bool
Whether to save the session to a JSON file.
Returns
-------
list[dict]
Conversation in the legacy dict format ``[{role_id, name, content}, ...]``.
"""
scenario_path = scenario if os.path.isabs(scenario) else os.path.join(os.getcwd(), scenario)
if not os.path.exists(scenario_path):
# Try bundled scenarios
scenario_path = os.path.join(PACKAGE_DIR, "scenarios", scenario)
scenario_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "scenarios", scenario
)
if not os.path.exists(scenario_path):
print(f"Scenario not found: {scenario}")
sys.exit(1)
if backend == "custom":
from meeting_room.backends.custom.run import run
conversation = run(scenario_path, config)
elif backend == "autogen":
from meeting_room.backends.autogen.run import run
conversation = run(scenario_path, config)
elif backend == "crewai":
from meeting_room.backends.crewai.run import run
conversation = run(scenario_path, config)
else:
print(f"Unknown backend: {backend}")
sys.exit(1)
# Convert raw config to typed DiscussionConfig
discussion_config = load_discussion_config()
# Apply any overrides from the raw config (e.g. workdir from CLI)
if "defaults" in config and "workdir" in config["defaults"]:
discussion_config.defaults.workdir = config["defaults"]["workdir"]
# Build engine components
providers = {
name: ProviderConfig(**vals)
for name, vals in config.get("providers", {}).items()
}
api_client = APIClient(providers=providers)
tool_registry = ToolRegistry(workdir=discussion_config.defaults.workdir)
event_bus = EventEmitter()
# Subscribe CLI output handlers
_subscribe_cli_handlers(event_bus, max_rounds=discussion_config.defaults.max_rounds)
# Load scenario
scenario_data = load_scenario(scenario_path)
# Create and run engine
engine = DiscussionEngine(
config=discussion_config,
api_client=api_client,
tool_registry=tool_registry,
events=event_bus,
)
messages = engine.run(scenario_data)
# Convert Message objects to legacy dict format
conversation = [
{"role_id": m.role_id, "name": m.name, "content": m.content}
for m in messages
]
if save and conversation:
# Determine sessions dir
@@ -253,22 +375,19 @@ def run_discussion(backend: str, scenario: str, config: dict, save: bool = False
else:
sessions_dir = os.path.join(os.getcwd(), "sessions")
scenario_data = load_scenario(scenario_path)
save_session(conversation, scenario_data.get("name", "discussion"), sessions_dir)
scenario_obj = load_scenario(scenario_path)
save_session(conversation, scenario_obj.get("name", "discussion"), sessions_dir)
return conversation
def main():
"""CLI entry point."""
import argparse
parser = argparse.ArgumentParser(
description="Meeting Room — Multi-Agent Discussion Framework",
)
parser.add_argument(
"-b", "--backend",
choices=["custom", "autogen", "crewai"],
default="custom",
help="Discussion framework (default: custom)",
)
parser.add_argument(
"-s", "--scenario",
default="example-saas.md",
@@ -336,7 +455,7 @@ def main():
if args.workdir:
config["defaults"]["workdir"] = args.workdir
run_discussion(args.backend, args.scenario, config, save=args.save)
run_discussion(args.scenario, config, save=args.save)
if __name__ == "__main__":

115
meeting_room/config.py Normal file
View File

@@ -0,0 +1,115 @@
"""Configuration loading for Meeting Room.
Provides load_config(), find_workspace_config(), and load_discussion_config()
which converts a raw YAML dict into a typed DiscussionConfig.
"""
from __future__ import annotations
import os
import re
import yaml
from meeting_room.models import DefaultsConfig, DiscussionConfig, ProviderConfig, RoleConfig
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
def load_config(config_path: str | None = None) -> dict:
"""Load config from file, resolve env vars, apply overrides.
Parameters
----------
config_path : str | None
Path to the YAML config file. When *None*, loads the package
default config at ``meeting_room/config/config.yaml``.
Returns
-------
dict
Raw configuration dictionary (the result of ``yaml.safe_load``).
"""
if config_path is None:
config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml")
with open(config_path, encoding="utf-8") as f:
raw = f.read()
# Resolve ${ENV_VAR} placeholders
def _env_replace(match: re.Match) -> str:
var = match.group(1)
return os.environ.get(var, match.group(0))
raw = re.sub(r"\$\{(\w+)\}", _env_replace, raw)
config = yaml.safe_load(raw)
# Override API keys from environment if set
for prov_name, prov_cfg in config.get("providers", {}).items():
env_key = f"MEETING_ROOM_{prov_name.upper()}_API_KEY"
if os.environ.get(env_key):
prov_cfg["api_key"] = os.environ[env_key]
env_url = f"MEETING_ROOM_{prov_name.upper()}_BASE_URL"
if os.environ.get(env_url):
prov_cfg["base_url"] = os.environ[env_url]
return config
def find_workspace_config() -> str | None:
"""Search for .meeting-room/config.yaml starting from CWD upward.
Returns
-------
str | None
Absolute path to the workspace config file, or *None* if not
found.
"""
current = os.getcwd()
while True:
candidate = os.path.join(current, ".meeting-room", "config.yaml")
if os.path.exists(candidate):
return candidate
parent = os.path.dirname(current)
if parent == current:
# Reached filesystem root
break
current = parent
return None
def load_discussion_config(config_path: str | None = None) -> DiscussionConfig:
"""Load config and return a typed DiscussionConfig.
Convenience wrapper that calls :func:`load_config` and then
coerces the raw dict into :class:`DiscussionConfig`.
Parameters
----------
config_path : str | None
Path to the YAML config file. When *None*, loads the package
default.
Returns
-------
DiscussionConfig
Typed configuration object ready for use by DiscussionEngine.
"""
raw = load_config(config_path)
providers = {
name: ProviderConfig(**vals)
for name, vals in raw.get("providers", {}).items()
}
roles = {
name: RoleConfig(**vals)
for name, vals in raw.get("roles", {}).items()
}
defaults = DefaultsConfig(**raw.get("defaults", {}))
return DiscussionConfig(
providers=providers,
roles=roles,
defaults=defaults,
)

View File

@@ -9,6 +9,9 @@ providers:
routerai:
base_url: "https://routerai.ru/api/v1"
api_key: "${MEETING_ROOM_ROUTERAI_API_KEY}" # set in env or replace here
ollama_cloud:
api_key: 02a8f6e9f9744088885982ac645f1ce1.HB1MFh4AlTpyic9oJGxQjNuA
base_url: https://ollama.com/v1
# openai:
# base_url: "https://api.openai.com/v1"
@@ -22,13 +25,18 @@ providers:
# base_url: "http://localhost:11434/v1"
# api_key: "ollama"
# deepseek:
# base_url: "https://api.deepseek.com/v1"
# api_key: "${MEETING_ROOM_DEEPSEEK_API_KEY}"
# ─── Roles ───
# tools: all | full | files | readonly | web | none | or list ["read_file", "web_search"]
roles:
# ── General discussion ──
moderator:
name: "Moderator"
provider: routerai
provider: ollama_cloud
model: "gpt-4o-mini"
temperature: 0.7
tools: "none"
@@ -40,7 +48,7 @@ roles:
skeptic:
name: "Skeptic"
provider: routerai
provider: ollama_cloud
model: "gpt-4o-mini"
temperature: 0.9
tools: "readonly"
@@ -52,7 +60,7 @@ roles:
idea_generator:
name: "Idea Generator"
provider: routerai
provider: ollama_cloud
model: "gpt-4o-mini"
temperature: 1.0
tools: "web"
@@ -65,7 +73,7 @@ roles:
analyst:
name: "Analyst"
provider: routerai
provider: ollama_cloud
model: "gpt-4o-mini"
temperature: 0.5
tools: "all"
@@ -76,6 +84,45 @@ roles:
Propose concrete action plans.
Use tools to verify facts, read project files, and search for info.
# ── Code review ──
defender:
name: "Defender"
provider: ollama_cloud
model: "gpt-4o-mini"
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: "gpt-4o-mini"
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: "gpt-4o-mini"
temperature: 0.4
tools: "web"
system_prompt: |
You are a security specialist. Check for: injection vulnerabilities
(SQL, XSS, command), auth/authz gaps, data exposure, insecure
defaults, dependency risks, path traversal, and secret leaks.
Use web_search to check for known CVEs in dependencies.
Be specific — cite exact code locations and explain the attack vector.
defaults:
max_rounds: 8
language: "ru"

337
meeting_room/engine.py Normal file
View File

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

129
meeting_room/events.py Normal file
View File

@@ -0,0 +1,129 @@
"""EventEmitter and event types for the Meeting Room discussion engine.
Synchronous event bus. In Phase 2, an AsyncEventBridge will forward
events to WebSocket clients.
"""
from __future__ import annotations
import logging
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()

124
meeting_room/models.py Normal file
View File

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

View File

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

View File

@@ -1,32 +1,17 @@
"""
Tools system for Meeting Room agents.
Provides file access and web capabilities via OpenAI function calling.
The ToolRegistry class encapsulates all tool logic with a configurable
workdir. Module-level wrappers maintain backward compatibility.
"""
import fnmatch
import httpx
import json
import os
import re
import json
import subprocess
import httpx
WORKDIR = "."
def set_workdir(path: str):
global WORKDIR
WORKDIR = os.path.abspath(path)
def _resolve_path(path: str) -> str:
if os.path.isabs(path):
return path
return os.path.normpath(os.path.join(WORKDIR, path))
def _truncate(text: str, max_len: int = 5000) -> str:
if len(text) <= max_len:
return text
return text[:max_len] + f"\n... [обрезано, всего {len(text)} символов]"
# ─── Tool schemas (OpenAI function calling format) ───
@@ -36,13 +21,13 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "read_file",
"description": "Прочитать содержимое файла. Возвращает текст с номерами строк.",
"description": "Read file contents. Returns text with line numbers.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Путь к файлу"},
"offset": {"type": "integer", "description": "Начать с этой строки (1-indexed)", "default": 1},
"limit": {"type": "integer", "description": "Максимум строк", "default": 100},
"path": {"type": "string", "description": "Path to the file"},
"offset": {"type": "integer", "description": "Start from this line (1-indexed)", "default": 1},
"limit": {"type": "integer", "description": "Maximum number of lines", "default": 100},
},
"required": ["path"],
},
@@ -52,13 +37,13 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "list_files",
"description": "Показать список файлов и директорий.",
"description": "List files and directories.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Директория для просмотра"},
"pattern": {"type": "string", "description": "Фильтр по имени (glob)"},
"max_depth": {"type": "integer", "description": "Глубина обхода", "default": 3},
"path": {"type": "string", "description": "Directory to list"},
"pattern": {"type": "string", "description": "Filter by name (glob)"},
"max_depth": {"type": "integer", "description": "Traversal depth", "default": 3},
},
"required": [],
},
@@ -68,13 +53,13 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "search_in_files",
"description": "Поиск текста в файлах (regex).",
"description": "Search text in files (regex).",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Поисковый запрос (regex)"},
"path": {"type": "string", "description": "Директория для поиска"},
"file_pattern": {"type": "string", "description": "Фильтр по расширению"},
"query": {"type": "string", "description": "Search query (regex)"},
"path": {"type": "string", "description": "Directory to search in"},
"file_pattern": {"type": "string", "description": "Filter by extension (e.g. *.py)"},
},
"required": ["query"],
},
@@ -84,12 +69,12 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "web_search",
"description": "Поиск в интернете. Возвращает заголовки, сниппеты и URL.",
"description": "Search the web. Returns titles, snippets and URLs.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Поисковый запрос"},
"max_results": {"type": "integer", "description": "Максимум результатов", "default": 5},
"query": {"type": "string", "description": "Search query"},
"max_results": {"type": "integer", "description": "Maximum results", "default": 5},
},
"required": ["query"],
},
@@ -99,12 +84,12 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "web_fetch",
"description": "Скачать содержимое веб-страницы.",
"description": "Fetch web page content.",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL для загрузки"},
"max_length": {"type": "integer", "description": "Максимум символов", "default": 5000},
"url": {"type": "string", "description": "URL to fetch"},
"max_length": {"type": "integer", "description": "Maximum characters", "default": 5000},
},
"required": ["url"],
},
@@ -114,12 +99,12 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "write_file",
"description": "Записать содержимое в файл.",
"description": "Write content to a file.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Путь к файлу"},
"content": {"type": "string", "description": "Содержимое"},
"path": {"type": "string", "description": "Path to the file"},
"content": {"type": "string", "description": "Content to write"},
},
"required": ["path", "content"],
},
@@ -129,12 +114,12 @@ TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "run_command",
"description": "Выполнить shell-команду.",
"description": "Run a shell command.",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Команда"},
"timeout": {"type": "integer", "description": "Таймаут (сек)", "default": 30},
"command": {"type": "string", "description": "Command to run"},
"timeout": {"type": "integer", "description": "Timeout in seconds", "default": 30},
},
"required": ["command"],
},
@@ -142,66 +127,150 @@ TOOL_SCHEMAS = [
},
]
# Directories to skip during file traversal
_SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".mypy_cache", ".pytest_cache"}
# ─── Implementations ───
# Tool set definitions
TOOL_SETS = {
"all": ["read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"],
"full": ["read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"],
"files": ["read_file", "list_files", "search_in_files", "write_file"],
"readonly": ["read_file", "list_files", "search_in_files"],
"web": ["web_search", "web_fetch"],
"none": [],
}
def tool_read_file(path: str, offset: int = 1, limit: int = 100) -> str:
resolved = _resolve_path(path)
def _truncate(text: str, max_len: int = 5000) -> str:
if len(text) <= max_len:
return text
return text[:max_len] + f"\n... [truncated, {len(text)} chars total]"
class ToolRegistry:
"""Manages tool schemas and execution for a given working directory."""
def __init__(self, workdir: str = ".") -> None:
self._workdir = os.path.abspath(workdir)
# ── Path resolution ──
def _resolve_path(self, path: str) -> str:
if os.path.isabs(path):
return path
return os.path.normpath(os.path.join(self._workdir, path))
# ── Schema / dispatch ──
def get_tool_schemas(self, tool_set: str | list = "all") -> list[dict]:
if isinstance(tool_set, list):
names = tool_set
else:
names = TOOL_SETS.get(tool_set, TOOL_SETS["all"])
return [s for s in TOOL_SCHEMAS if s["function"]["name"] in names]
def execute_tool(self, name: str, arguments: dict) -> str:
method_map = {
"read_file": self.tool_read_file,
"list_files": self.tool_list_files,
"search_in_files": self.tool_search_in_files,
"web_search": self.tool_web_search,
"web_fetch": self.tool_web_fetch,
"write_file": self.tool_write_file,
"run_command": self.tool_run_command,
}
func = method_map.get(name)
if not func:
return f"Unknown tool: {name}"
try:
return func(**arguments)
except Exception as e:
return f"Error executing {name}: {e}"
# ── Tool implementations ──
def tool_read_file(self, path: str, offset: int = 1, limit: int = 100) -> str:
resolved = self._resolve_path(path)
if not os.path.isfile(resolved):
return f"Ошибка: файл не найден{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} строк]\n{result}")
return _truncate(f"[{resolved}{total} lines]\n{result}")
except Exception as e:
return f"Ошибка чтения: {e}"
return f"Error reading file: {e}"
def tool_list_files(path: str = "", pattern: str = "", max_depth: int = 3) -> str:
base = _resolve_path(path or ".")
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"Ошибка: директория не найдена{base}"
return f"Error: directory not found{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))
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"Ошибка: {e}"
return f"Error: {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])
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:
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 "Таймаут поиска."
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"Ошибка: {e}"
return f"Error: {e}"
def tool_web_search(query: str, max_results: int = 5) -> str:
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},
"https://html.duckduckgo.com/html/",
params={"q": query},
headers={"User-Agent": "Mozilla/5.0"},
)
resp.raise_for_status()
@@ -218,13 +287,12 @@ def tool_web_search(query: str, max_results: int = 5) -> str:
if len(results) >= max_results:
break
if not results:
return "Результатов не найдено."
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"Ошибка поиска: {e}"
return f"Search error: {e}"
def tool_web_fetch(url: str, max_length: int = 5000) -> str:
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"})
@@ -237,27 +305,25 @@ def tool_web_fetch(url: str, max_length: int = 5000) -> str:
text = re.sub(r'\s+', ' ', text).strip()
else:
text = resp.text
return _truncate(f"[{url}{len(resp.text)} символов]\n{text}", max_length)
return _truncate(f"[{url}{len(resp.text)} chars]\n{text}", max_length)
except Exception as e:
return f"Ошибка загрузки: {e}"
return f"Fetch error: {e}"
def tool_write_file(path: str, content: str) -> str:
resolved = _resolve_path(path)
def tool_write_file(self, path: str, content: str) -> str:
resolved = self._resolve_path(path)
try:
os.makedirs(os.path.dirname(resolved), exist_ok=True)
os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True)
with open(resolved, "w", encoding="utf-8") as f:
f.write(content)
return f"Записано {len(content)} символов в {resolved}"
return f"Wrote {len(content)} chars to {resolved}"
except Exception as e:
return f"Ошибка записи: {e}"
return f"Write error: {e}"
def tool_run_command(command: str, timeout: int = 30) -> str:
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=WORKDIR,
timeout=timeout, cwd=self._workdir,
)
output = result.stdout
if result.stderr:
@@ -266,46 +332,46 @@ def tool_run_command(command: str, timeout: int = 30) -> str:
output += f"\n[exit code: {result.returncode}]"
return _truncate(output)
except subprocess.TimeoutExpired:
return f"Таймаут ({timeout}с)"
return f"Timeout ({timeout}s)"
except Exception as e:
return f"Ошибка: {e}"
return f"Error: {e}"
# ─── Dispatcher ───
# ─── Module-level backward-compatible wrappers ───
TOOL_FUNCTIONS = {
"read_file": tool_read_file,
"list_files": tool_list_files,
"search_in_files": tool_search_in_files,
"web_search": tool_web_search,
"web_fetch": tool_web_fetch,
"write_file": tool_write_file,
"run_command": tool_run_command,
}
TOOL_SETS = {
"all": list(TOOL_FUNCTIONS.keys()),
"full": list(TOOL_FUNCTIONS.keys()),
"files": ["read_file", "list_files", "search_in_files", "write_file"],
"readonly": ["read_file", "list_files", "search_in_files"],
"web": ["web_search", "web_fetch"],
"none": [],
}
_default_registry: ToolRegistry | None = None
def get_tool_schemas(tool_set: str = "all") -> list[dict]:
if isinstance(tool_set, list):
names = tool_set
else:
names = TOOL_SETS.get(tool_set, TOOL_SETS["all"])
return [s for s in TOOL_SCHEMAS if s["function"]["name"] in names]
def set_workdir(path: str) -> None:
"""Set the working directory for module-level tool functions."""
global _default_registry
_default_registry = ToolRegistry(workdir=path)
def _get_registry() -> ToolRegistry:
global _default_registry
if _default_registry is None:
_default_registry = ToolRegistry()
return _default_registry
def get_tool_schemas(tool_set: str | list = "all") -> list[dict]:
"""Return tool schemas for the given set name or explicit list."""
return _get_registry().get_tool_schemas(tool_set)
def execute_tool(name: str, arguments: dict) -> str:
func = TOOL_FUNCTIONS.get(name)
if not func:
return f"Неизвестный инструмент: {name}"
try:
return func(**arguments)
except Exception as e:
return f"Ошибка выполнения {name}: {e}"
"""Execute a tool by name with the given arguments."""
return _get_registry().execute_tool(name, arguments)
# Legacy dict-based dispatch (kept for any code that imports TOOL_FUNCTIONS)
TOOL_FUNCTIONS = {
"read_file": lambda **kw: _get_registry().tool_read_file(**kw),
"list_files": lambda **kw: _get_registry().tool_list_files(**kw),
"search_in_files": lambda **kw: _get_registry().tool_search_in_files(**kw),
"web_search": lambda **kw: _get_registry().tool_web_search(**kw),
"web_fetch": lambda **kw: _get_registry().tool_web_fetch(**kw),
"write_file": lambda **kw: _get_registry().tool_write_file(**kw),
"run_command": lambda **kw: _get_registry().tool_run_command(**kw),
}

View File

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

View File

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

359
tests/test_api_client.py Normal file
View File

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

263
tests/test_config.py Normal file
View File

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

646
tests/test_engine.py Normal file
View File

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

311
tests/test_events.py Normal file
View File

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

396
tests/test_models.py Normal file
View File

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

389
tests/test_tools.py Normal file
View File

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