diff --git a/docs/local-debate-lab/DEBATE_GUIDE.md b/docs/local-debate-lab/DEBATE_GUIDE.md new file mode 100644 index 0000000000..bb71f5c650 --- /dev/null +++ b/docs/local-debate-lab/DEBATE_GUIDE.md @@ -0,0 +1,325 @@ +# Дебати різних моделей у Buzz — ранбук + +Кілька агентів на різних рантаймах сидять в одному каналі й ведуть +структуровану дискусію. Один на Claude Code через OAuth, решта — на локальних +моделях через Ollama. Усе на власному релеї, піднятому в Docker. + +Перевірено 2026-08-10 на Windows 11, RTX 4080 Laptop (12 ГБ VRAM), +Buzz desktop 0.5.8, Ollama 0.32.6. Ранбук цілком на 0.5.14 не переганяли, але +знахідки, на яких він тримається, там переперевірені — див. +[FINDINGS.md](FINDINGS.md). + +Супутні документи: +[DEBATE_SETUP.md](DEBATE_SETUP.md) — чому саме такі значення конфігу; +[FINDINGS.md](FINDINGS.md) — знахідки про сам продукт. + +--- + +## Крок 0 — релей + +Windows-збірка десктопа везе всередині `buzz-acp`, `buzz-agent`, `buzz-dev-mcp` +і `buzz` CLI, тож збирати Rust не треба. Релей піднімається з +[../../deploy/compose](../../deploy/compose) готовим образом. + +```bash +cd deploy/compose +cp .env.example .env +./run.sh start +``` + +**У `.env` пиши `127.0.0.1`, а не `localhost`:** + +``` +BUZZ_DOMAIN=127.0.0.1 +RELAY_URL=ws://127.0.0.1:3000 +BUZZ_MEDIA_BASE_URL=http://127.0.0.1:3000/media +BUZZ_MEDIA_SERVER_DOMAIN=127.0.0.1 +``` + +Це не косметика. Релей резолвить спільноту з Host-заголовка й fail-closed падає +в 404 на незнайомому хості, а десктоп нормалізує будь-який loopback до +`127.0.0.1`, перш ніж передати адресу агентові. Якщо засіяти спільноту під +`localhost`, **жоден агент не під'єднається** — деталі в +[FINDINGS.md](FINDINGS.md#1). + +Для локальної лабораторії також варто вимкнути закритий режим: + +``` +BUZZ_REQUIRE_AUTH_TOKEN=false +BUZZ_REQUIRE_RELAY_MEMBERSHIP=false +BUZZ_ALLOW_NIP_OA_AUTH=true +BUZZ_AUTO_MIGRATE=true +``` + +`BUZZ_ALLOW_NIP_OA_AUTH` лишається увімкненим — на ньому тримається +взаємодія агентів між собою (див. «Гейт доступу» нижче). + +**Перевірка** — WebSocket-апгрейд має віддати 101, а не 404: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' \ + -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ + -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + http://127.0.0.1:3000/ +``` + +`/_liveness` для цієї перевірки не годиться: він віддає 200 на будь-якому +хості, зокрема на незмаплених. Саме тому «релей живий» і «агент під'єднається» +— різні твердження. + +У десктопі приєднайся до спільноти за адресою `ws://127.0.0.1:3000`. + +--- + +## Крок 1 — хмарні рантайми + +### Claude Code + +```bash +npm install -g @agentclientprotocol/claude-agent-acp +claude setup-token +``` + +Токен, який видасть `setup-token`, поклади у змінну оточення +`CLAUDE_CODE_OAUTH_TOKEN` (User scope). API-ключ не потрібен — вистачає +підписки. + +> `claude auth status` **нічого не каже** про готовність ACP-рантайму: вони +> читають різні сховища. За один день на тій самій машині проба розійшлася з +> реальністю в обидва боки — спершу «залогінений» на неробочому рантаймі, потім +> «не залогінений» на робочому. Buzz використовує саме цю перевірку як +> auth-probe, тож бейджу в UI не вір у жодному напрямку — перевіряй ACP-циклом. +> Деталі в [FINDINGS.md](FINDINGS.md#2). + +### Codex + +```bash +npm install -g @openai/codex @agentclientprotocol/codex-acp +codex login +``` + +ChatGPT-OAuth працює — `OPENAI_API_KEY` не потрібен, попри те що каже +[README крейта buzz-acp](../../crates/buzz-acp/README.md). + +### Як переконатись, що рантайм справді робочий + +Мінімальний ACP-цикл, без Buzz у ланцюзі: +`initialize` → `session/new` → `session/prompt`. Якщо він повертає +`stopReason: end_turn` — авторизація й модель у порядку, і подальші проблеми +шукай у релеї, а не в рантаймі. + +--- + +## Крок 2 — локальні моделі + +Ollama за замовчуванням обслуговує **8192** токени контексту, хоч би що +заявляли самі моделі. Змінна `OLLAMA_CONTEXT_LENGTH` вимагає рестарту сервера +і легко лишається непоміченою, тож надійніше зашити контекст у похідні моделі: + +```bash +printf 'FROM qwen3.5:9b\nPARAMETER num_ctx 32768\n' > Modelfile.qwen +printf 'FROM gemma4:12b\nPARAMETER num_ctx 32768\n' > Modelfile.gemma +ollama create qwen3.5-debate -f Modelfile.qwen +ollama create gemma4-debate -f Modelfile.gemma +``` + +Похідна модель посилається на ті самі блоби, тож місця майже не займає, а +налаштування переживає будь-який рестарт. + +**Перевірка** — колонка `CONTEXT` має показати нове значення, а `PROCESSOR` +бажано `100% GPU`: + +```bash +ollama ps +``` + +Модель, яка не вміщується у VRAM, з'їжджає частково на CPU й дає кратно меншу +швидкість. Заміри для конкретного заліза — у [DEBATE_SETUP.md](DEBATE_SETUP.md). + +--- + +## Крок 3 — агенти + +**Agents → Create an agent.** + +### Спільні поля локальних агентів + +| Поле | Значення | +|---|---| +| Runtime | `Buzz Agent` | +| Provider | **OpenAI-compatible** ← не «OpenAI» | + +``` +OPENAI_COMPAT_BASE_URL=http://localhost:11434/v1 +OPENAI_COMPAT_API_KEY=ollama +BUZZ_AGENT_MAX_CONTEXT_TOKENS=32768 +BUZZ_AGENT_MAX_OUTPUT_TOKENS=2048 +BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES=8000 +BUZZ_AGENT_REQUIRE_REPLY=1 +``` + +> **Не чіпай `BUZZ_AGENT_MAX_HISTORY_BYTES`.** Здається логічним зрізати його +> під невелике вікно, але це байтова межа стенограми, а не конкурент токенному +> вікну, і крейт має жорсткий поріг: значення нижче `MAX_PROMPT_BYTES` +> (1 048 576) валить агента на старті з `config: BUZZ_AGENT_MAX_HISTORY_BYTES +> (…) must be >= MAX_PROMPT_BYTES (1048576)`. Реальний дефолт — 16 МіБ, попри +> те що README крейта каже 1 МіБ. + +> **Провайдер саме `openai-compat`.** При значенні `openai` десктоп фільтрує +> список моделей до відомих йому текстових моделей OpenAI, і жодна модель +> Ollama у пікері не з'явиться — виглядає так, ніби Ollama не підключена. +> Крейт `buzz-agent` обидва написання приймає однаково. + +`OPENAI_COMPAT_API_KEY` має бути непорожнім: крейт відкидає порожнє значення, +а Ollama його ігнорує. + +### Системний промпт: тільки «як опублікувати» + +Перевірено 15/15 прогонів. Роль і завдання **не** кладемо сюди — вони йдуть у +повідомленні каналу, де їх можна міняти без ризику: + +``` +Ти учасник дебатів. + +ЯК ТИ ВІДПОВІДАЄШ (єдиний спосіб): +Ти НЕ відповідаєш текстом. Щоб сказати щось, виклич інструмент shell з командою: +buzz messages send --channel --content "твій текст" +Виклич його один раз за хід. +У команді НЕ вживай --reply-to: інакше відповідь потрапить у тред, і в каналі її ніхто не побачить. +``` + +**Не дописуй сюди нічого зайвого.** Заміри показали, що додаткові правила +знижують надійність слабших моделей: три уточнення про лапки збили +`qwen3.5-debate` з 4/5 до 2/5 коректних команд. Кожен доданий рядок треба +переміряти. + +Рядок про `--reply-to` натомість обов'язковий — без нього агент відповідає в +тред, і в стрічці каналу його не видно. + +### Ролі — у повідомленні каналу, не в промпті + +Системний промпт в усіх агентів однаковий (див. вище). Роль призначається +текстом раунду, і тоді її можна змінити на ходу — наприклад помінявши сторони +місцями — не чіпаючи налаштувань: + +``` +@Claude ти пропонент: захищай тезу «…». Аргументуй механізмом або прикладом. +@Codex ти скептик: атакуй найсильнішу версію його аргументу, не карикатуру. +@Gemma4 ти модератор: підсумуй, у чому саме вони не згодні. +``` + +Такий поділ вийшов не з міркувань стилю, а з замірів: усе, що дописували в +системний промпт понад інструкцію «як опублікувати», знижувало надійність +виклику інструмента у слабших моделей. + +### Кого брати локальним дебатером + +| Модель | Коректних команд | Придатність | +|---|---|---| +| `gemma4-debate` | 15/15 | єдина повністю надійна | +| `qwen3.5-debate` | 4/5 і 2/5 залежно від промпту | ламає форму команди | +| `ornith:9b` | 3/4 | межова | +| `lfm2.5:8b` | 0/4 | непридатна | +| `qwen3.6-debate` (27B) | не заміряна | 4 ток/с, 46% на CPU при 12 ГБ VRAM | + +Повна методика й числа — у [DEBATE_SETUP.md](DEBATE_SETUP.md). + +Додай усіх у канал і запусти кожного (play-бейдж на аватарі). + +### Гейт доступу + +Міняти нічого не треба. Дефолтний `respond-to: owner-only` впускає власника +**і всіх криптографічно верифікованих агентів того самого власника** — саме те, +що потрібно для дебатів. На цьому ж тримається вбудована Welcome Team, де лід +інструктує тіммейтів. + +Не перемикай на `anyone` на релеї, доступному поза localhost. + +--- + +## Крок 4 — раунд + +Готовий воркфлоу на три раунди: [debate-workflow.yaml](debate-workflow.yaml). +Створюється через **Settings → Experiments → Workflows**, далі +`Create Workflow` → `<> Edit as YAML`. Веде чергу сам: пише учаснику, чекає, +пише наступному — стан тримає рушій, а не модель. + +Два правила, які довелося вивести дорогою ціною: + +- **Тема має стояти в кожному кроці.** Локальні моделі не знаходять її в + історії каналу. У YAML вона підставляється через `{{trigger.text}}`. +- **Тригер — `str_starts_with`, не `str_contains`.** Інакше крок, який цитує + тему, містить тригерну фразу й запускає новий прогін. З `str_starts_with` + цитата всередині тексту безпечна. + +Запуск — повідомлення, що **починається** з `ДЕБАТИ СТАРТ`, далі тема. + +### Або вручну + + + +``` +Тема раунду: чи замінять локальні моделі хмарні протягом трьох років? + +@Пропонент захищай тезу «замінять». +@Скептик атакуй її. +@Модератор після їхніх реплік підсумуй, у чому саме вони не згодні. +``` + +Далі достатньо писати `@Пропонент відповідай на заперечення` — агенти бачать +історію каналу. + +### Методика + +- **Проводь кожне питання двічі, помінявши сторони.** Інакше різниця між + моделями змішана з тим, який бік аргументу їм дістався, і порівняння нічого + не означає. +- **Не став найслабшу модель суддею.** Або оцінює людина, або хмарна модель + судить локальні — не навпаки. +- **Не проси точну кількість слів.** «Рівно 100 слів» заганяє reasoning-модель + у цикл підрахунку: у замірах вона витрачала 2500 токенів і не видавала + жодного видимого слова. Обмеження став як максимум. + +--- + +## Якщо агент мовчить + +**Спершу панель Activity** — клік по агенту → вкладка Activity. Вона показує +кожен виклик інструмента разом з аргументом: + +- `Ran buzz messages send --channel …` — агент публікує правильно. +- `Ran <проза>` — модель поклала текст повідомлення в параметр `command` + замість командного рядка. Класична поламка слабшої локальної моделі. +- лише `Thinking` без жодного `Ran` — модель не викликає інструмент узагалі. + +Лог харнеса на рівні `info` викликів інструментів **не показує**, тому мовчання +там виглядає однаково в усіх трьох випадках. Для повного логу постав +`RUST_LOG=buzz_acp=info,buzz_agent=debug` у змінних агента — ключ не +зарезервований. + +Далі — лог харнеса: + +``` +%APPDATA%\xyz.block.buzz.app\agents\logs\__.log +``` + +Робочий старт виглядає так: + +``` +connected to relay at ws://127.0.0.1:3000 +owner resolved from BUZZ_AUTH_TAG: +discovered N channel(s) +presence set to online +``` + +Найчастіші поламки: + +| У лозі | Причина | +|---|---| +| `relay connect error: HTTP error: 404 Not Found` | хост спільноти не збігається — див. Крок 0 | +| `OAuth session expired and could not be refreshed` | немає `CLAUDE_CODE_OAUTH_TOKEN` | +| `usageLimitExceeded` | вичерпана квота акаунта | +| під'єднався, але тиша | гейт автора або фільтр згадок | + +Після термінальної помилки підключення харнес **не перезапускається сам** — +його треба зупинити й запустити в UI. diff --git a/docs/local-debate-lab/DEBATE_SETUP.md b/docs/local-debate-lab/DEBATE_SETUP.md new file mode 100644 index 0000000000..e05b4ac166 --- /dev/null +++ b/docs/local-debate-lab/DEBATE_SETUP.md @@ -0,0 +1,264 @@ +# Конфіг дебатної лабораторії та заміри, що його обґрунтовують + +Кожне значення тут виведене з вимірювання на конкретному залізі, а не взяте +як розумний дефолт. Заміряно 2026-08-10. + +Залізо: RTX 4080 Laptop, **12 ГБ VRAM**. Ollama 0.32.6, Buzz desktop 0.5.8. + +Ранбук: [DEBATE_GUIDE.md](DEBATE_GUIDE.md). Знахідки: [FINDINGS.md](FINDINGS.md). + +--- + +## Локальні моделі + +| Модель | Швидкість | Розміщення | Розмір | Reasoning | +|---|---|---|---|---| +| `qwen3.5:9b` | **21.5** ток/с | 100% GPU | 5.7 ГБ | так | +| `gemma4:12b` | **19.8** ток/с | 100% GPU | 8.4 ГБ | так | +| `qwen3.6:27b-q4_K_M` | **4.1** ток/с | 45% CPU / 55% GPU | 17 ГБ | так | + +27-мільярдна не вміщується у 12 ГБ і майже наполовину виконується на +процесорі — звідси п'ятикратна різниця. Для багатораундових дебатів це +хвилини на кожен хід. + +Одночасно резидентна лише одна модель: 5.7 + 8.4 > 12. Ollama вивантажує +попередню при зміні мовця, тож зміна учасника коштує паузи на завантаження. + +### Контекст: заявлений ≠ обслуговуваний + +`ollama show` для всіх трьох показує `context length 262144`. Це максимум +**моделі**. Реально сервер віддавав **8192**, бо `OLLAMA_CONTEXT_LENGTH` не +була виставлена: + +``` +llama_context: n_ctx = 8192 +n_ctx_seq (8192) < n_ctx_train (262144) -- the full capacity of the model will not be utilized +``` + +Виставлення змінної та рестарт трея **не подіяли** — сервер успадкував старе +оточення від батьківського процесу. Робоче рішення — зашити контекст у похідну +модель через `PARAMETER num_ctx`, тоді від оточення нічого не залежить: + +| Похідна модель | Контекст | Розміщення | Розмір | +|---|---|---|---| +| `qwen3.5-debate` | 32768 | 100% GPU | 6.6 ГБ | +| `gemma4-debate` | 32768 | 100% GPU | 8.1 ГБ | +| `qwen3.6-debate` | 16384 | частково CPU | 17 ГБ | + +Перевіряти треба колонкою `CONTEXT` у `ollama ps`, а не тим, що записано в +конфіг. + +### Reasoning з'їдає бюджет виходу + +Усі три моделі повертають `reasoning` окремим полем від `content`, і міркування +витрачають бюджет **першими**. Приклад: на запит «Say the single word: ready» +`qwen3.6:27b` видала 726 символів міркувань і 177 токенів там, де вистачило б +одного. + +Практичний наслідок: при малому `max_tokens` відповідь приходить **порожня** — +модель не встигає дійти до тексту. Треба закладати ~1200 токенів на реальну +відповідь, звідси `BUZZ_AGENT_MAX_OUTPUT_TOKENS=2048`. + +Ollama приймає `reasoning_effort: low` через OpenAI-сумісний шар: міркування +падають з 4680 до 2834 символів, якість відповіді не страждає. + +### Точні підрахунки ламають reasoning-моделі + +Формулювання «in exactly 100 words» відправило `qwen3.5:9b` у цикл підрахунку: +2500 токенів, `finish_reason: length`, **нуль** видимого тексту. Той самий +запит без вимоги точної кількості — 647 символів осмисленої відповіді й +`finish_reason: stop`. + +Це виглядало як зламана модель. Це був зламаний промпт. У промптах дебатів +обмеження задавай як максимум («не більше 4 речень»), ніколи як точну цифру. + +--- + +## Надійність виклику інструмента — головна метрика + +Публікувати в канал агент може лише через виклик інструмента `shell` з командою +`buzz messages send`. Тому придатність локальної моделі визначає не якість її +міркування, а те, **чи втримає вона форму команди**. Це різні здібності, і +друга не видно з жодного загального бенчмарку. + +П'ять прогонів на однаковій задачі, перевірка — чи справді аргументи виклику +мають вигляд `buzz messages send --channel --content "…"`: + +| Промпт | Модель | Викликів | Коректних команд | +|---|---|---|---| +| короткий | `qwen3.5-debate` | 5/5 | 4/5 | +| короткий | **`gemma4-debate`** | 5/5 | **5/5** | +| з доп. правилами | `qwen3.5-debate` | 4/5 | **2/5** | +| з доп. правилами | **`gemma4-debate`** | 5/5 | **5/5** | +| + правило про тред | **`gemma4-debate`** | 5/5 | **5/5**, `--reply-to` 0/5 | + +Ширший зріз за тим самим методом (менш строга перевірка, лише наявність +`messages send`): `ornith:9b` — 3/4; **`lfm2.5:8b` — 0/4**, для агентних задач +непридатний. + +**Висновки, які варто перенести на будь-яку іншу машину:** + +- **`gemma4-debate` — єдина повністю надійна** з перевірених: 15/15 у трьох + замірах, нечутлива до формулювання промпту. +- **`qwen3.5-debate` пише осмислені репліки, але ламає форму команди** — кладе + текст повідомлення прямо в параметр `command` замість командного рядка. У + панелі активності це видно як `Ran <проза>` замість `Ran buzz messages send…`. +- **Додаткові правила в системному промпті шкодять слабшій моделі.** Спроба + «допомогти» qwen3.5 трьома уточненнями про лапки знизила результат з 4/5 до + 2/5: вона почала викидати лапки взагалі. +- **Міряй повторними прогонами, не одним.** Ці моделі недетерміновані: той самий + промпт в одному запуску не дав виклику, а в наступному дав виклик із випадковою + командою. Два висновки, зроблені тут з одного запуску, виявились хибними. + +### Приклад у промпті робить слабшу модель гіршою + +В апстрімі ([block/buzz#2698](https://github.com/block/buzz/issues/2698)) +найнадійнішим обходом названий **one-shot приклад** — вигаданий попередній хід +із коректним викликом інструмента, вписаний у системний промпт. На наших моделях +він показав гірший результат, ніж проста інструкція: + +| Промпт | Модель | Виклик | Коректна команда | +|---|---|---|---| +| інструкція | `qwen3.5-debate` | 5/5 | **5/5** | +| інструкція | `gemma4-debate` | 5/5 | **5/5** | +| з прикладом | `qwen3.5-debate` | 5/5 | **3/5** | +| з прикладом | `gemma4-debate` | 5/5 | **5/5** | + +Причина видна в самій зіпсованій команді — модель **переписує UUID з прикладу й +плутає його**: + +``` +--channel daeb342d-41bf-43cd-875a-362c-eecdb7c <- видала модель +--channel daeb342d-41bf-43cd-875a-362c70ecdb7c <- насправді +``` + +Приклад подає UUID у промпті двічі замість одного разу, і 9B у 4 бітах +спотикається на транскрибуванні. Тобто обхід допомагає моделям, які не +**обирають** інструмент, і шкодить тим, які обирають правильно, але слабкі в +**копіюванні**. Перед тим як його застосовувати, варто розрізнити ці два збої. + +Заразом не відтворився ще один збій із того ж issue: коли в списку є другий +інструмент, модель нібито стабільно кличе не той. Ми додавали конкурентний +інструмент у кожен прогін — обидві моделі обрали правильний 20 разів із 20. + +### Друга умова надійності: завдання має бути у зверненні + +Форма команди — необхідна умова, але не достатня. Друга, виявлена пізніше й +дорожче: **локальна модель не знаходить тему в історії каналу**. У сесії, де +тему вказали лише першому учаснику, а решті написали «на ту саму тему», +`qwen3.5-debate` відповів про геополітику, а `gemma4-debate` підсумував тему +попереднього прогону. Після підстановки теми **в кожне звернення** плюс явного +«ігноруй усе вище» обидві заговорили по темі з першої спроби. + +Хмарні моделі цього не потребують — вони розбираються в засміченій історії самі. +Повні результати порівняння — у [RESULTS.md](RESULTS.md). + +### Робочий системний промпт + +Перевірено 15/15. Роль і завдання свідомо винесені **в повідомлення каналу**, а +не сюди — системний промпт відповідає лише на питання «як опублікувати»: + +``` +Ти модератор дебатів. + +ЯК ТИ ВІДПОВІДАЄШ (єдиний спосіб): +Ти НЕ відповідаєш текстом. Щоб сказати щось, виклич інструмент shell з командою: +buzz messages send --channel --content "твій текст" +Виклич його один раз за хід. +У команді НЕ вживай --reply-to: інакше відповідь потрапить у тред, і в каналі її ніхто не побачить. +``` + +Рядок про `--reply-to` обов'язковий: без нього модель відповідає в тред, і в +стрічці каналу репліки не видно — для дебатів це рівносильно мовчанню. + +### Діагностика мовчазного агента + +Панель **Activity** біля агента корисніша за лог харнеса: вона показує кожен +виклик інструмента разом з аргументом (`Ran <команда>`). У лозі на рівні `info` +викликів інструментів немає взагалі, тому мовчання там виглядає однаково і при +зламаному виклику, і при відсутності спроб. + +Для повного логу: `RUST_LOG=buzz_acp=info,buzz_agent=debug` у змінних агента — +цей ключ не зарезервований і перевизначається. + +--- + +## Змінні оточення локальних агентів + +``` +OPENAI_COMPAT_BASE_URL=http://localhost:11434/v1 +OPENAI_COMPAT_API_KEY=ollama +BUZZ_AGENT_MAX_CONTEXT_TOKENS=32768 +BUZZ_AGENT_MAX_OUTPUT_TOKENS=2048 +BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES=8000 +BUZZ_AGENT_REQUIRE_REPLY=1 +``` + +| Параметр | Дефолт | Чому змінено | +|---|---|---| +| провайдер | `openai` | при `openai` десктоп фільтрує пікер до відомих моделей OpenAI — модель Ollama обрати неможливо | +| `MAX_CONTEXT_TOKENS` | 200000 | реальне вікно 32768; при дефолті gate ніколи не спрацює, а Ollama мовчки обріже промпт | +| `MAX_OUTPUT_TOKENS` | 32768 | більше за саме вікно; водночас має вміщати міркування — 2048 | +| `MAX_TOOL_RESULT_TEXT_BYTES` | 51200 | ≈12800 токенів, один результат інструмента більший за вікно | + +### Пороги, які крейт перевіряє на старті + +Валідація в `crates/buzz-agent/src/config.rs` відкидає конфіг і агент не +стартує взагалі: + +| Змінна | Дозволений діапазон | +|---|---| +| `MAX_HISTORY_BYTES` | ≥ 1 048 576 (`MAX_PROMPT_BYTES`); реальний дефолт 16 МіБ | +| `MAX_TOOL_RESULT_TEXT_BYTES` | 1024 … 8 МіБ | +| `MAX_CONTEXT_TOKENS` | строго більше за `MAX_OUTPUT_TOKENS` | + +`MAX_HISTORY_BYTES` виглядає як щось, що варто зрізати під мале вікно — +не варто. Це байтова межа стенограми, не конкурент токенному вікну. +Значення 48000 валить усі десять процесів харнеса з +`all 10 agents failed to start`. README крейта тут теж вводить в оману: +він називає дефолтом 1 МіБ, тоді як у коді 16 МіБ. +| `REQUIRE_REPLY` | 0 | вимкнений для не-mesh агентів, а саме малі локальні моделі роблять роботу й завершують хід, нічого не запостивши | + +`OPENAI_COMPAT_API_KEY` має бути непорожнім — крейт відкидає порожнє значення, +Ollama його ігнорує. + +Про `REQUIRE_REPLY` прямо сказано в +[README крейта buzz-agent](../../crates/buzz-agent/README.md): його вмикають за +замовчуванням для mesh-агентів саме тому, що ті «run on small local models, +which are the ones most likely to do the work and then end the turn without +publishing it». Наші агенти технічно не mesh, тож цей дефолт до них не +застосовується. + +--- + +## Хмарні рантайми + +| Рантайм | Стан | Примітка | +|---|---|---| +| Claude Code | працює на OAuth | потрібен `CLAUDE_CODE_OAUTH_TOKEN` з `claude setup-token` | +| Codex | працює на ChatGPT-OAuth | `OPENAI_API_KEY` не потрібен | + +Обидва перевірені мінімальним ACP-циклом до підключення Buzz: +`initialize` → `session/new` → `session/prompt` → `end_turn`. + +--- + +## Релей + +``` +BUZZ_DOMAIN=127.0.0.1 +RELAY_URL=ws://127.0.0.1:3000 +BUZZ_MEDIA_BASE_URL=http://127.0.0.1:3000/media +BUZZ_MEDIA_SERVER_DOMAIN=127.0.0.1 +BUZZ_REQUIRE_AUTH_TOKEN=false +BUZZ_REQUIRE_RELAY_MEMBERSHIP=false +BUZZ_ALLOW_NIP_OA_AUTH=true +BUZZ_AUTO_MIGRATE=true +``` + +`127.0.0.1` замість `localhost` — обов'язково, причина в +[FINDINGS.md](FINDINGS.md#1). + +Перевірка готовності до підключення агентів — код `101` на WebSocket-апгрейді. +`/_liveness` не показник: він віддає `200` навіть на хості, для якого немає +спільноти. diff --git a/docs/local-debate-lab/FINDINGS.md b/docs/local-debate-lab/FINDINGS.md new file mode 100644 index 0000000000..882604ec23 --- /dev/null +++ b/docs/local-debate-lab/FINDINGS.md @@ -0,0 +1,436 @@ +# Знахідки про Buzz, зібрані при налаштуванні дебатної лабораторії + +Не поради з налаштування, а поведінка самого продукту, яка коштувала часу. +Перші дві — кандидати в баг-репорти, решта — розбіжності документації з кодом +і корисні пастки. + +Зібрано 2026-08-10 на Buzz desktop **0.5.8**, релей `ghcr.io/block/buzz:main`, +Windows 11. Застосунок оновився з 0.5.5 на початку сесії, тож усі заміри +належать 0.5.8 — версія тут не дрібниця, бо саме за нею читач зважує звіт. + +**Переперевірено 2026-08-15 на 0.5.14** — десктоп оновився сам, релей затягнули +свіжий (`ghcr.io/block/buzz:main`, зібраний 2026-08-15). Жодна знахідка не +відпала. Що саме перевірено — у таблиці нижче; де написано «не перевіряли», там +воно й не перевірялось. + +| # | Стан на 0.5.14 | Чим підтверджено | +|---|---|---| +| 1 | тримається | WS-апгрейд: `101` на `127.0.0.1`, `404` з `Host: localhost` | +| 2 | тримається | проба червона (`loggedIn:false`, код виходу 1), агент Claude при цьому відповів о 21:14 UTC | +| 3 | тримається | README крейта побайтово той самий, ключ і далі «required»; Codex відповів через OAuth | +| 4 | тримається | `filter_to_openai_text_models` і далі вмикається рівно на `provider == "openai"` | +| 5 | код не змінився | поведінку промпту не переганяли | +| 6 | тримається | `author_allowed` ідентичний рядок у рядок | +| 7 | тримається | `/_liveness` віддав `200` на незмапленому хості | +| 8 | тримається | відтворено окремим харнесом: `terminal error: HTTP error: 404 Not Found` → вихід без жодної спроби | +| 9 | тримається | [#2501](https://github.com/block/buzz/issues/2501) відкритий, PR #2505 не змерджений; розділення definition/instance видно у файлі | +| 10 | тримається | зловили сам SQL: десктоп просить 4 канали, у запит іде 1 — і не той | + +Два уточнення, які дало саме оновлення: + +- **Обхід із `managed-agents.json` пережив апдейт.** Після оновлення до 0.5.14 + харнеси піднялися з `respond_to=allowlist(1)`, тобто застосунок не переписав + instance-записи. Компільований клапан `owner_only()` + (`option_env!("BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY")`) у цій збірці + вимкнений — інакше в лог пішло б `owner-only` попри записи. +- **Лог харнеса дописується, а не перестворюється.** Порада «дивись перший + рядок» нижче була помилковою і коштувала мені хибного висновку: `head` показує + запуск тижневої давнини. Виправлено на «останній». + +--- + + +## 1. Self-host із `localhost` у `RELAY_URL` ламає геть усіх агентів + +**Симптом.** Агент створений, запущений, у UI виглядає живим. У каналі мовчить +на будь-яку згадку. Жодної помилки в інтерфейсі. + +**Що насправді.** У лозі харнеса: + +``` +WARN buzz_acp::relay: initial relay connect failed with terminal error: + WebSocket error: HTTP error: 404 Not Found +``` + +**Механізм — три частини, кожна сама по собі правильна.** + +1. Релей резолвить спільноту з `Host`-заголовка через мапу хостів у Postgres і + fail-closed падає на незмапленому хості + ([`crates/buzz-relay/src/tenant.rs`](../../crates/buzz-relay/src/tenant.rs)). + На старті він сіє спільноту під авторитетом, виведеним з `RELAY_URL`. +2. Десктоп зберігає адресу спільноти так, як її ввів користувач, наприклад + `ws://localhost:3000`. +3. Ключ рантайму агента будується через `normalize_relay_url` + ([`crates/buzz-core/src/relay.rs`](../../crates/buzz-core/src/relay.rs)), + яка **переписує будь-який loopback-хост на `127.0.0.1`**: + + ```rust + if loopback { + url.set_host(Some("127.0.0.1")) + ``` + +Разом: десктоп ходить на `localhost:3000` і працює, агент ходить на +`127.0.0.1:3000` і отримує 404, бо спільнота засіяна під `localhost:3000`. + +**Обхід.** Ставити `RELAY_URL=ws://127.0.0.1:3000` **з самого початку** і +приєднуватись у десктопі за тією ж адресою. + +**Уточнення від 2026-08-15, дороге й неочевидне.** «Fail-closed» стосується +лише **чужих** хостів. Для власного, виведеного з `RELAY_URL`, релей на старті +не падає, а **створює нову порожню спільноту**. Ми це побачили, коли навмисно +зіпсували мапу хостів, щоб відтворити 404: замість очікуваного 404 WS-апгрейд +віддав 101, бо релей за 10 секунд підняв другу спільноту під тим самим хостом. +Стара з усіма каналами лишилась висіти на зіпсованому хості — робоче +підключення, порожній воркспейс. Практичний наслідок: **правити +`communities.host` вручну не можна** — треба спершу зупинити релей, потім +міняти, інакше отримаєш дубль і мовчазно порожній воркспейс замість помилки. + +**Чого зробити не вийде** — перевірено, обидва шляхи глухі: + +- *Домапити другий хост до тієї самої спільноти.* `communities.host` має + унікальний індекс: одна спільнота — один хост. +- *Поставити проксі, що переписує `Host`.* Ламає NIP-98: підпис покриває повний + URL, і релей звіряє його з тим, що реконструює з `Host`. + + ``` + 401 Unauthorized: NIP-98 HTTP Auth verification failed: URL mismatch: + event has `http://localhost:3000/query`, expected `http://127.0.0.1:3000/query` + ``` + + `Host` тут не транспортна деталь, а частина підписаного матеріалу. +- *Засіяти обидва аліаси через `scripts/seed-local-community.sh`.* Ця порада + вже гуляє трекером ([#4147](https://github.com/block/buzz/issues/4147)), і + вона робить **гірше**. Скрипт вставляє по рядку на аліас, а не кілька хостів + на одну спільноту — `schema/schema.sql` має + `CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host))`, тож + інакше він і не може. Виходить до чотирьох окремих **порожніх** спільнот: + власник і агент так само в різних, зате 404 зникає. Гучна помилка стає тихою + порожнечею, і єдиним сигналом лишається `discovered 0 channel(s)` у лозі, + куди ніхто не дивиться. +- *Змусити агента вживати `localhost`.* `BUZZ_RELAY_URL` — зарезервований ключ + ([`reserved_env_keys.rs`](../../desktop/src-tauri/src/managed_agents/reserved_env_keys.rs)), + користувацькі змінні його не перекривають, а нормалізація зашита в код. + +**Чому це варто полагодити.** `localhost` — очевидний вибір для локального +релею, і `.env.example` йому не перешкоджає. Збій мовчазний: релей здоровий, +`/_liveness` віддає 200, десктоп працює, агент виглядає запущеним. Побачити +причину можна лише в лозі харнеса, куди користувач не дивиться, бо ніщо на +нього не вказує. + +**Що зробило б це помітним:** попередження на старті релею, якщо авторитет +`RELAY_URL` не збігається з тим, що дає `normalize_relay_url` для того самого +рядка. + +--- + + +## 2. `claude auth status` не є доказом готовності ACP-рантайму + +**Симптом.** Результат проби не корелює зі станом рантайму — і розходиться в +**обидва** боки. За один день на одній машині: + +| Час | `claude auth status` | Реальний ACP-цикл адаптера | +|---|---|---| +| ранок | `loggedIn: true`, код 0 | падає: `OAuth session expired and could not be refreshed` | +| вечір | `loggedIn: false`, код 1 | працює: `stopReason: end_turn` | + +Тобто проба спершу дала хибнопозитивний результат, а потім — хибнонегативний. +У першому випадку Buzz показав би «Authenticated» на неробочому агенті; у +другому позначив би цілком робочий рантайм як неавторизований. + +**Механізм.** Це два різні споживачі різних сховищ: + +- `claude` CLI на PATH читає системне сховище (на Windows — Credential Manager). +- ACP-адаптер несе **власний** `claude.exe` усередині пакета + `@anthropic-ai/claude-agent-sdk-*` і читає `~/.claude/.credentials.json` та + змінну `CLAUDE_CODE_OAUTH_TOKEN`. + +На цій машині `~/.claude/.credentials.json` увесь час лишався порожньою +заглушкою — `accessToken` довжини 0, `refreshToken` довжини 0, `expiresAt` = 0 — +незалежно від того, що показував CLI. Рантайм запрацював лише після того, як +з'явилася `CLAUDE_CODE_OAUTH_TOKEN`, і при цьому CLI звітує про +неавторизованість. + +Buzz бере саме `claude auth status` за auth-probe для цього рантайму +([`discovery.rs`](../../desktop/src-tauri/src/managed_agents/discovery.rs)). +Для CLI цей код виходу правдивий. Для рантайму, який Buzz потім запустить, — +ні: вони дивляться в різні місця. + +**Обхід.** `claude setup-token` і покласти результат у +`CLAUDE_CODE_OAUTH_TOKEN`. Адаптер читає цю змінну. + +**Що зробило б це надійним:** пробувати не `claude auth status`, а самий +адаптер — `initialize` + `session/new` + короткий `session/prompt`. Це +перевіряє того самого споживача, який виконуватиме роботу. + +--- + +## 3. README крейта `buzz-acp` застарів щодо Codex + +[README](../../crates/buzz-acp/README.md) стверджує, що `codex-acp` завжди +спершу пробує ChatGPT-логін, ловить `426 Upgrade Required` і падає назад на +`OPENAI_API_KEY`, а тому ключ обов'язковий. + +У `codex-acp` 1.1.13 ChatGPT-OAuth працює: `initialize`, `session/new` і +`session/prompt` доходять до моделі без жодного `OPENAI_API_KEY`. Жодного +`426`. (Перевірку зупинила вичерпана квота акаунта, а не авторизація — +`usageLimitExceeded` приходить уже від моделі.) + +--- + +## 4. Провайдер `openai` ховає всі моделі Ollama + +Десктоп виявляє моделі, опитуючи `{OPENAI_COMPAT_BASE_URL}/models`, — і це +працює з Ollama. Але при провайдері **рівно** `openai` список фільтрується до +відомих текстових моделей OpenAI +([`agent_models.rs`](../../desktop/src-tauri/src/commands/agent_models.rs)), +тож жодна модель Ollama у пікері не з'являється. + +Виглядає як «Ollama не підключена». Насправді треба обрати провайдер +**`openai-compat`** — крейт `buzz-agent` обидва написання приймає однаково +([`config.rs`](../../crates/buzz-agent/src/config.rs)). + +--- + +## 5. Агент за замовчуванням відповідає у тред + +Згадка в каналі спонукає агента відповісти реплікою в треді, тож у стрічці +каналу відповіді не видно. Для дебатів це руйнівно: учасники не бачать реплік +одне одного. + +Тред створює **виключно** прапорець `--reply-to` у `buzz messages send`. Без +нього повідомлення лягає в канал. Це керується системним промптом, і +формулювання «публікуй у канал» **недостатньо** — агент вважає, що тред у +каналі теж є каналом. Треба прямо заборонити `--reply-to`. + +--- + +## 6. `owner-only` — це не «тільки людина» + +Неочевидно й корисно: режим доступу `owner-only` впускає власника **і всіх +криптографічно NIP-OA-верифікованих агентів того самого власника** +([`access_policy.rs`](../../desktop/src-tauri/src/managed_agents/access_policy.rs)). + +Тобто агенти одного власника можуть звертатись одне до одного без будь-якого +послаблення гейту — на цьому тримається вбудована Welcome Team, де лід +інструктує тіммейтів. Для сценарію «кілька агентів дискутують між собою» +міняти налаштування доступу не треба. + +Підпис у UI це відображає: під «Only me» написано «Only you and your agents can +send instructions», хоча сама назва пункту про агентів мовчить. + +**Перевірено окремим дослідом**, бо спершу тут стояло лише читання коду, а в +трекері з'явився звіт, що агент-до-агента не працює. Умови поставили так, щоб +розбудити ціль могла **тільки** згадка від агента: людина згадала через равлик +лише Claude, слово «Codex» лишилось звичайним текстом без `p`-тега. + +``` +21:14:06 людина 9e353cbe… p-теги: Claude +21:14:31 Claude aa48f103… p-теги: Codex +21:14:54 Codex f8aa7979… p-теги: Claude <- відповів через 23 с +``` + +Codex при цьому має `respond_to: allowlist` з **єдиним** записом — ключем +воркфлоу, не Claude. Отже `allowlist.contains(author)` хибне, і впустити подію +міг лише `is_owner_or_sibling` — та сама гілка, яку викликає `OwnerOnly` +([lib.rs](../../crates/buzz-acp/src/lib.rs), `author_allowed`). + +Коли ж братерство **не** спрацьовує, шукати треба не політику, а +`is_owner_or_sibling`: вона падає закрито на тимчасовій помилці REST і кешує це +«ні» на весь час життя процесу без TTL +([#5450](https://github.com/block/buzz/issues/5450)). Один збій на старті мовчки +блокує колегу до перезапуску, і виглядає це як вибіркова глухота, бо власнику +агент відповідає миттєво іншим шляхом. + +--- + +## 7. `/_liveness` не свідчить про готовність приймати агентів + +`/_liveness` віддає `200` на **будь-якому** хості, зокрема на тому, для якого +спільноти не існує. Тобто «релей живий» і «агент зможе під'єднатись» — різні +твердження, і перше нічого не каже про друге. + +Перевіряти треба WebSocket-апгрейд: `101` проти `404`. + +```bash +curl -s -o /dev/null -w '%{http_code}\n' \ + -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ + -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + http://127.0.0.1:3000/ +``` + +--- + +## 8. Після термінальної помилки підключення харнес не оживає сам + +Отримавши 404 на підключенні, `buzz-acp` вважає помилку термінальною і виходить. +Десктоп його не перепідіймає. Навіть коли причину усунуто, агент лишається +мертвим, доки його не зупинити й не запустити вручну. + +Це подовжує будь-яку діагностику: полагодив релей — виглядає, ніби не +допомогло, бо агент більше не пробував. + +**Відтворено 2026-08-15 на 0.5.14.** Окремий `buzz-acp` з одноразовим ключем, +націлений на незмаплений хост: + +``` +WARN buzz_acp::relay: initial relay connect failed with terminal error: + WebSocket error: HTTP error: 404 Not Found +Error: relay connect error: WebSocket error: HTTP error: 404 Not Found (exit 1) +``` + +Жодної спроби повтору. Сім хвилин перед тим той самий бінарник пережив +перезапуск релею штатно: + +``` +INFO buzz_acp::relay: autonomous reconnect attempt 3/5 to ws://127.0.0.1:3000… +INFO buzz_acp::relay: autonomous reconnect succeeded (attempt 3) +``` + +Тобто механізм повтору є й працює — 404 просто до нього не потрапляє. Це +робить знахідку рівно про класифікацію помилки, а не про відсутність +перепідключення. Другу половину (що десктоп не перепідіймає процес) на 0.5.14 +не переганяли: для цього треба вбити справжнього агента. + +--- + +## 9. Воркфлоу не може керувати власними агентами + +**Симптом.** Workflow за розкладом публікує `@Агент …` — агенти мовчать. Паузи +витримуються точно, згадки резолвляться (`p`-теги на місці), але жоден агент не +реагує. + +**Механізм — два незалежні факти, що складаються в глухий кут.** + +1. **Рушій воркфлоу підписує повідомлення ключем релею**, не твоїм. У базі + видно різних авторів: тригер від власника, кроки — від `837d702e…` + (ключ з `BUZZ_RELAY_PRIVATE_KEY`). +2. **Гейт агента застряг на `owner-only`**, хоч би що обрати в інтерфейсі. + Це відомий баг [#2501](https://github.com/block/buzz/issues/2501), + підтверджений від 0.4.23 до 0.5.8; PR #2505 не змерджений. + +`owner-only` впускає власника та його верифікованих агентів — рушій воркфлоу не є +ні тим, ні тим, тож його події відкидаються до фільтра згадок. + +**Про баг #2501, бо він плутає діагностику.** `managed-agents.json` містить +**два записи на агента**: definition (`pubkey: ""`) та instance (з реальним +ключем). Інтерфейс пише вибір у `definition_respond_to` на definition-записі, +а харнес гейтить за `respond_to` з instance-запису. Тому: + +- сирий файл одночасно містить `"allowlist"` і `"owner-only"` — це різні записи; +- читати треба **instance**, інакше висновок буде хибним (я на цьому спіймався); +- єдина надійна перевірка — **останній** рядок `buzz-acp starting` у лозі + харнеса: `respond_to=…`. Саме останній: лог не перестворюється при запуску, а + дописується, тож `head` чи `grep -m1` віддадуть запуск тижневої давнини. Я на + цьому спіймався вдруге — прочитав старий рядок і встиг вирішити, що оновлення + зламало гейт. + +**Обхід — перевірений.** При **закритому** застосунку відредагувати +`respond_to` та `respond_to_allowlist` безпосередньо в **instance**-записах +`managed-agents.json` (ті, що мають непорожній `pubkey`). Після запуску харнес +піднімається з `respond_to=allowlist(1)`, і повідомлення воркфлоу починають +доходити: у нашому прогоні агент відповів на згадку, згенеровану рушієм, за 52 +секунди, тоді як усі згадки до перезапуску були проігноровані. + +Обхід крихкий: instance-записи переписує сам застосунок під час своїх операцій, +тож редагування агента через UI може повернути `owner-only`. Єдиний критерій — +останній рядок `buzz-acp starting` у лозі харнеса. Оновлення застосунку 0.5.8 → +0.5.14 обхід при цьому **пережив**: харнеси піднялись із `allowlist(1)`. + +**Практичний висновок для дебатів.** На релізній збірці детерміноване ведення +раундів через Workflow неможливе. Лишаються два варіанти, обидва повертають стан +у модель: естафета через промпти (працює на Claude і Codex, рветься на Gemma4) +або агент-диригент на heartbeat — його повідомлення проходять гейт, бо він +належить тому самому власнику. + +**Що при цьому працює бездоганно:** сам рушій. Паузи витримуються з точністю до +секунди (100 с → 100 с), кроки йдуть строго по черзі, згадки резолвляться. +Обмеження лежить виключно в тому, під чиїм ключем публікується результат. + +--- + +## 10. Воркфлоу працює, але його не видно й не зупинити + +**Симптом.** Екран Workflows показує «No workflows yet», хоча воркфлоу існує, +запускається за тригером і публікує повідомлення в канал. + +**Механізм** (спершу взятий з апстріму; 2026-08-15 знайшли саму функцію). Десктоп тягне +список одним запитом kind:30620 з багатозначним фільтром `#h`, перелічуючи всі +канали користувача. HTTP-міст релею мовчки звужує такий фільтр до **одного** +каналу — лексикографічно найменшого UUID +([#5053](https://github.com/block/buzz/issues/5053), +[#4659](https://github.com/block/buzz/issues/4659)). Якщо воркфлоу живе не в +ньому — список порожній. Звідси й формулювання +[#4804](https://github.com/block/buzz/issues/4804): «returns empty for any +account with more than one channel». + +Наш випадок збігається точно. Канали за зростанням UUID: + +``` +47da111f welcome-everyone <- саме його бачить міст +adafd400 general +d4ebe34a Welcome +daeb342d debate <- тут воркфлоу, найбільший UUID +``` + +Подія на релеї коректна: kind 30620, автор — власник, теги `d` (id воркфлоу) і +`h` (канал). Тобто дані на місці, зламана лише вибірка. + +**Де саме звужується — `crates/buzz-relay/src/handlers/req.rs`, +`extract_channel_id_from_filter`** (актуальний `main` на 2026-08-15): + +```rust +if key == "h" { + for val in tag_values { + if let Ok(id) = val.parse::() { + return Some(id); // перший придатний — решта відкидається +``` + +Значення тегів лежать у впорядкованій множині, тож «перший» — це найменший +UUID; це збігається з тим, який канал міст нам показував. Далі цей один +`channel_id` іде в SQL, а пост-фільтр (`filters_match`) лише **звужує** видачу і +не може повернути події з каналів, яких запит не читав. Тому багатозначний `#h` +не «частково працює», а мовчки перетворюється на однозначний. + +Обидві функції з таким іменем у релеї поводяться по-різному: копія в +`api/bridge.rs` на багатозначному фільтрі повертає `None`, а вирішальна — в +`handlers/req.rs` — перший елемент. Дивитись треба на другу. + +**Виміряно на живому запиті** (2026-08-15, релей від 08-15, десктоп 0.5.14). +Увімкнули `log_statement` у Postgres, відкрили екран Workflows — він показав +«No workflows yet» — і зловили сам SQL: + +```sql +SELECT … FROM events +WHERE community_id = $1 AND deleted_at IS NULL + AND channel_id = $2 + AND (channel_id IS NULL OR channel_id IN ($3, $4, $5, $6)) + AND kind IN ($7) +``` +``` +$2 = 47da111f… <- звужений скоуп: один канал +$3..$6 = 47da111f…, adafd400…, d4ebe34a…, daeb342d… <- усі чотири, як просив десктоп +$7 = 30620 +``` + +Десктоп попросив чотири канали, до SQL дійшов **один** — і саме той, у якому +воркфлоу немає. Обидві умови на `channel_id` стоять поруч у тому самому запиті, +тож ширша з них не рятує: `= $2` уже все вирішив. Це замикає ланцюг від кліку в +UI до рядка SQL, і жодного припущення в ньому не лишилось. + +**Чому це дорожче, ніж здається.** Порожній список — не лише незручність: з UI +не можна ні вимкнути воркфлоу, ні видалити його. У нашому випадку це дало три +**одночасні** прогони, які по черзі сипали інструкціями в канал, і зупинити їх +через застосунок було нічим. Плюс екран показує не помилку, а бадьоре +«No workflows yet» — тобто виглядає як «нічого не створено», а не як «запит не +повернув того, що мав». + +**Обхід.** Вимикати й вмикати безпосередньо в базі релею: + +```sql +UPDATE workflows SET enabled = false WHERE enabled = true; +``` + +Створення при цьому працює нормально — зламане лише відображення, тож новий +воркфлоу можна завести через діалог і керувати ним через SQL. + diff --git a/docs/local-debate-lab/RESULTS.md b/docs/local-debate-lab/RESULTS.md new file mode 100644 index 0000000000..5e217ddf67 --- /dev/null +++ b/docs/local-debate-lab/RESULTS.md @@ -0,0 +1,146 @@ +# Що показали дебати: чим моделі відрізняються в цій ролі + +Лабораторію будували, щоб порівняти моделі в багатораундовій суперечці. Нижче — +результат трьох повних сесій на 2026-08-10: дві на естафеті через промпти, одна +на воркфлоу [debate-workflow.yaml](debate-workflow.yaml). + +Учасники: **Claude Code** (`opus`) і **Codex** (`gpt-5.6-sol`) у хмарі, +**Qwen3.5 9B** і **Gemma4 12B** локально через Ollama, обидві Q4. + +--- + +## Головне: різниця не в ерудиції, а в здатності сперечатися + +Усі чотири моделі розуміють предмет. Різниця виявилась в іншому — чи здатна +модель **помітити конкретну хибу**, **визнати власну поразку** і **звузити своє +твердження** під критикою. Саме це відрізняє дебати від почергових монологів, і +саме тут проходить межа. + +| Модель | Тримає тему | Знаходить чужу хибу | Визнає свою | Звужує тезу | +|---|---|---|---|---| +| Claude | так | так | так | так | +| Codex | так | так | так | частково | +| Qwen3.5 9B | так, якщо подати прямо | ні | **ні** | ні | +| Gemma4 12B | так, якщо подати прямо | ні | — (роль модератора) | — | + +### Як це виглядало + +**Codex знайшов у Claude справжню технічну помилку.** Claude навів приклад із +macOS і тут же запропонував `nftables owner-match` та `netns` — механізми Linux, +яких на macOS немає. Codex це назвав, Claude прийняв і переписав позицію під +конкретні ОС. + +**Claude заніс власну помилку до переліку необґрунтованих тверджень** у +фінальному підсумку, поруч із чужими: + +> З мого боку без підстав прозвучали nftables owner-match і netns як відповідь +> на macOS-приклад — спростовано. + +**Qwen3.5 на прямий запит визнати помилку заявив, що його заперечення +витримало** — хоча по суті не відповів. Це повторилось в обох сесіях і не +залежало від формулювання запиту. + +**Gemma4 зафіксувала конфлікт, якого не було.** Її підсумок описав суперечку +«безпека проти автоматизації», тоді як обидві сторони насправді сходились на +«так, з ізоляцією» і сперечались про те, який саме примус це гарантує. +Підсумок, що фіксує неіснуючу незгоду, гірший за відсутність підсумку: він +ховає справжню розбіжність. + +--- + +## Хибний висновок, якого ми уникли + +У першій сесії Qwen3.5 відповів текстом про зовнішню підтримку агресорів і +«революцію розцвітки» — тема, якої в каналі не було. Gemma4 підсумувала тему +**попередньої** сесії. Спокусливо було записати це як межу малих моделей. + +Насправді причина була **структурною й нашою**: тема стояла лише в першому +кроці воркфлоу, решті сказано «на ту саму тему», а історія каналу вже містила +кілька прогонів з іншими темами. Хмарні моделі в цьому розібрались, локальні — +ні. + +Після того як тему підставили **в кожен крок** плюс явне «ігноруй усе вище», +обидві локальні заговорили по темі з першої спроби. + +**Практичне правило:** для локальної моделі завдання й тема мають бути в самому +зверненні. Покладатись на те, що вона знайде їх в історії каналу, не можна — +і це не про розмір контекстного вікна, а про здатність відділити релевантне від +сусіднього. + +--- + +## Заміри часу + +Затримка від згадки до опублікованої відповіді, сесія на воркфлоу: + +| Учасник | Раунд 1 | Раунд 2 | Раунд 3 | +|---|---|---|---| +| Claude | 20 с | 25 с | 22 с | +| Codex | 20 с | 21 с | 19 с | +| Qwen3.5 | 33 с | 27 с | 31 с | +| Gemma4 | **110 с** | ~100 с | — | + +Хмарні стабільні в межах 20–25 секунд. Qwen3.5 близько 30. Gemma4 має розкид +удвічі — від 66 секунд у ранніх замірах до 110 у цій сесії, тобто впритул до +відведеного бюджету. У воркфлоу їй варто давати **150 секунд**, решті вистачає +60 для хмарних і 110 для Qwen3.5. + +Уся сесія з трьох раундів — близько 15 хвилин. + +--- + +## Що лишилось невирішеним + +**Gemma4 публікує в тред.** Правило «не вживай `--reply-to`» в її системному +промпті є, але вона тримає його приблизно у двох випадках із семи. У стрічці +каналу таких реплік не видно взагалі. Через це фінальний підсумок у воркфлоу +віддано Claude — він пише в канал стабільно. + +**Qwen3.5 інколи публікує двічі.** Та сама відповідь з різницею у вісім секунд. +Трапилось в одній сесії з трьох. Ймовірний винуватець — `BUZZ_AGENT_REQUIRE_REPLY`, +який спрацьовує вже після успішної публікації. + +--- + +## Для чого локальні моделі тут придатні + +Не для суперечки. Але вони надійно роблять інше, і це варто рознести: + +- **тримають форму команди** — `gemma4-debate` дала 15/15 коректних викликів + інструмента публікації, краще за Qwen3.5; +- **тримають тему**, якщо її подати в зверненні; +- **дають відповідь за 30–110 секунд** без жодних витрат на API. + +Тобто як виконавці вузьких, чітко сформульованих завдань вони робочі. Роль +опонента в дебатах вимагає іншого — і саме її вони не тягнуть. + +--- + +## Дописано після четвертої сесії: ізольована проба переоцінює надійність + +Дві поспіль сесії на воркфлоу, однакові промпти, однаковий конфіг, різні теми: + +| Учасник | Сесія A | Сесія B | +|---|---|---| +| Claude | 4/4 | 4/4 | +| Codex | 3/3 | 3/3 | +| `qwen3.5-debate` | 3/3 | **0/3** | +| `gemma4-debate` | 2/2 | **1/2** | + +Хмарні — 7/7 в обох. Локальні — 5/5 і потім **1/5**. + +У сесії B Qwen3.5 не мовчав: у лозі харнеса два LLM-виклики через 21 і 24 секунди +після згадки, далі тиша й чисте завершення ходу. Тобто модель згенерувала +відповідь і не викликала інструмент публікації — [block/buzz#2698](https://github.com/block/buzz/issues/2698). + +**Що це означає для замірів вище.** Таблиця надійності виклику інструмента +(15/15 для gemma4, 5/5 для qwen3.5 на короткому промпті) міряна **прямими +запитами до Ollama** — один системний промпт, один-два інструменти, порожня +історія. У харнесі до цього додається системний промпт `buzz-agent`, історія +каналу й повний набір інструментів MCP. Ті самі моделі з тим самим промптом +поводяться там помітно гірше й **непередбачувано від сесії до сесії**. + +Тож ізольований бенчмарк показує **верхню межу здатності моделі**, а не робочу +надійність. Обидва числа потрібні, але плутати їх не можна: перше відповідає на +питання «чи вміє вона в принципі», друге — «чи можна на неї покластися». + diff --git a/docs/local-debate-lab/debate-workflow.yaml b/docs/local-debate-lab/debate-workflow.yaml new file mode 100644 index 0000000000..72375a6e00 --- /dev/null +++ b/docs/local-debate-lab/debate-workflow.yaml @@ -0,0 +1,146 @@ +name: Дебати v2 — тема в кожному кроці +description: >- + Четверо агентів, три раунди, строго по одному за раз. Проти v1 змінено три + речі: тема підставляється в КОЖЕН крок (у v1 її бачив лише перший, решта мала + шукати її в історії каналу — локальні моделі не знаходили і брали чужу тему з + попередніх прогонів); паузи зрізані вдвічі за виміряними затримками; фінальний + підсумок робить Claude, бо репліки Gemma4 стабільно потрапляють у тред і в + стрічці їх не видно. + Тригер спрацьовує лише коли повідомлення ПОЧИНАЄТЬСЯ з "ДЕБАТИ СТАРТ" — тому + кроки можуть цитувати тему всередині тексту, не запускаючи новий прогін. + + Дві межі рушія, які варто знати: одна пауза не може перевищувати 270 секунд + (приклади "5m" і "1h" у документації цю межу перевищують — block/buzz#3021), + а поле enabled у YAML не діє взагалі: планувальник читає окрему колонку в базі + (block/buzz#4639). Вимикати треба через API або SQL, не редагуванням YAML. +trigger: + on: message_posted + filter: str_starts_with(trigger_text, "ДЕБАТИ СТАРТ") +steps: + - id: r1_claude + name: Раунд 1 — Claude + action: send_message + text: >- + @Claude раунд 1. Обговорюємо рівно це і нічого іншого — {{trigger.text}} + Усе, що написано в каналі вище, стосується попередніх сесій: ігноруй. + Дай позицію і головний аргумент — механізм або приклад, без загальних слів. + - id: w1 + action: delay + duration: 60s + + - id: r1_codex + name: Раунд 1 — Codex + action: send_message + text: >- + @Codex раунд 1. Тема — {{trigger.text}} + Ігноруй усе, що в каналі вище: то попередні сесії. + Дай свою позицію незалежно від Claude, з механізмом або прикладом. + - id: w2 + action: delay + duration: 60s + + - id: r1_qwen + name: Раунд 1 — Qwen3.5 + action: send_message + text: >- + @Qwen3.5 раунд 1. Тема — {{trigger.text}} + Ігноруй усе, що в каналі вище: то попередні сесії, вони до цієї теми не стосуються. + Ти скептик: назви ОДНЕ заперечення саме проти цієї тези, два-три речення, + з механізмом або прикладом. Якщо не маєш заперечення по темі — так і напиши. + - id: w3 + action: delay + duration: 110s + + - id: r1_gemma + name: Раунд 1 — Gemma4 + action: send_message + text: >- + @Gemma4 раунд 1 завершено. Тема була — {{trigger.text}} + Підсумуй ОДНИМ абзацом, у чому саме учасники цього раунду не згодні. + Бери лише репліки цього раунду; усе, що вище в каналі, до цієї теми не стосується. + - id: w4 + action: delay + duration: 150s + + - id: r2_claude + name: Раунд 2 — Claude + action: send_message + text: >- + @Claude раунд 2. Тема — {{trigger.text}} + Атакуй оцінки попередників цього раунду: де саме вони помиляються і чому. + Якщо чиясь репліка не стосується теми — скажи це прямо й не витрачай на неї раунд. + Якщо в чомусь вони мають рацію — визнай, тоді заперечуй. + - id: w5 + action: delay + duration: 60s + + - id: r2_codex + name: Раунд 2 — Codex + action: send_message + text: >- + @Codex раунд 2. Тема — {{trigger.text}} + Назви конкретну хибу в оцінках попередників, включно з Claude. Не загальну незгоду. + - id: w6 + action: delay + duration: 60s + + - id: r2_qwen + name: Раунд 2 — Qwen3.5 + action: send_message + text: >- + @Qwen3.5 раунд 2. Тема — {{trigger.text}} + Твоє заперечення витримало чи ні? Якщо його спростували — скажи прямо. + Якщо твоє заперечення в раунді 1 було не по темі — визнай це одним реченням + і дай натомість заперечення саме по цій темі. + - id: w7 + action: delay + duration: 110s + + - id: r2_gemma + name: Раунд 2 — Gemma4 + action: send_message + text: >- + @Gemma4 раунд 2 завершено. Тема — {{trigger.text}} + Що змінилося проти раунду 1: чиї позиції зсунулись і які твердження лишились без підстав. + - id: w8 + action: delay + duration: 150s + + - id: r3_claude + name: Раунд 3 — Claude + action: send_message + text: >- + @Claude раунд 3. Тема — {{trigger.text}} + Сформулюй підсумкову позицію в тому вигляді, в якому вона витримала критику. + Назви одну умову, за якої ти б її змінив. + - id: w9 + action: delay + duration: 60s + + - id: r3_codex + name: Раунд 3 — Codex + action: send_message + text: >- + @Codex раунд 3. Тема — {{trigger.text}} + Підсумкова позиція і одна умова, за якої ти б її змінив. + - id: w10 + action: delay + duration: 60s + + - id: r3_qwen + name: Раунд 3 — Qwen3.5 + action: send_message + text: >- + @Qwen3.5 раунд 3. Тема — {{trigger.text}} + Одним реченням: що з тези опонентів ти визнаєш, а що ні. + - id: w11 + action: delay + duration: 110s + + - id: r3_final + name: Фінальний підсумок — Claude + action: send_message + text: >- + @Claude фінальний підсумок сесії. Тема була — {{trigger.text}} + Три пункти: до чого дійшли, у чому лишились незгодні, які твердження + прозвучали без підстав і від кого. Наприкінці напиши ДЕБАТИ ЗАВЕРШЕНО.