diff --git a/personal/plans/runtime-pr4-openai-toolcalls-fixes.md b/personal/plans/runtime-pr4-openai-toolcalls-fixes.md new file mode 100644 index 00000000..cdba73b7 --- /dev/null +++ b/personal/plans/runtime-pr4-openai-toolcalls-fixes.md @@ -0,0 +1,218 @@ +# runtime PR #4 — OpenAI tool_calls: исправление 4 дефектов ревью + +> Создан 2026-09-28. Статус: **✅ РЕАЛИЗОВАНО, ЗАПУШЕНО. CI блокирован одобрением мейнтейнера (внешний).** +> Голова PR: **`0937011`** (3 файла, +1045/−27), ветка `feat/openai-tool-calls-v2`. +> **PR normahq/runtime#4** — OPEN, MERGEABLE. +> Все 4 дефекта ревью закрыты + 4 контрактных теста. Гейты репо зелёные локально. +> Репо: `~/Developer/runtime-local`. +> Связано: [[personal/tech/adk-hostedagent-openai-mcp]], [[personal/projects/balda/balda-valera]]. + +--- + +## 0б. РЕЗУЛЬТАТ — что сделано + +Коммит **`0937011`** `fix(hostedagent): emit OpenAI-compatible tool payloads`. + +**Гейты локально — все зелёные:** +``` +gofmt -l hostedagent/ → чисто +go build ./... → OK +go test ./... → все 10 пакетов ok +go test -race ./... → все ok +go tool golangci-lint run ./... → 0 issues +``` + +**Правки (все применены дельта-апплаером):** + +| # | Дефект | Фикс | +|---|---|---| +| P1-a | `genai.Schema` → `"type":"OBJECT"` | Конвертер `jsonSchemaFromGenai` + карта `genaiSchemaTypes`: тип в lowercase, рекурсия в `properties`/`items`, перенос `description`/`format`/`enum`/`required`/`nullable` | +| P1-b | `content,omitempty` терял ключ у пустого tool-результата | `omitempty` убран с `Content` — ключ всегда на проводе | +| P2-a | Ветка `toolResponses` теряла текст смешанного Content | Текст добавляется отдельным **user-message** после tool-результатов | +| P2-b | Аргументы → `{"raw":...}` для не-объекта | `decodeFunctionArguments`: bounded unwrap (глубина 4), всегда возвращает map; массив/скаляр — под ключом `value` | + +**Тесты — 4 контрактных** (`hostedagent/openai_contract_test.go` + правка существующего): +| Тест | Фиксирует | +|---|---| +| `TestToolsSerializeLowercaseJSONSchema` | нет `"OBJECT"`/`"STRING"` на проводе; все типы lowercase, включая вложенные `items` | +| `TestEmptyToolResultKeepsContentField` | ключ `content` есть (2 подслучая: nil-ответ и пустой объект) | +| `TestMixedToolAndTextContentKeepsText` | и tool-result, и текст-сообщение на месте | +| `TestArgumentsUnwrapRepeatedEncoding` | 5 форм: объект / одинарное / двойное / массив / скаляр → без `raw` | +| `TestArgumentsUnwrapIsBounded` | 12 уровней вложенности не зацикливают | +| `TestArgumentsUnwrapEmpty` | пустые аргументы → nil | + +🔴 **Найден маскирующий тест.** Существовавший `TestOpenAIToolsFromConfig_WithParameters` +проверял `params["type"] != "OBJECT" && params["type"] != "object"` — то есть **принимал обе +формы**, и именно поэтому uppercase-баг P1-a жил незамеченным. Ужесточён до строгого +`schemaTypeObject = "object"`. Урок: терпимый ассерт = отсутствие ассерта. + +--- + +## 0в. ⛔ CI: внешний блокер, не наш дефект + +`gh pr checks 4` → «no checks reported». **Причина найдена фактом:** +``` +gh run list --repo normahq/runtime +→ все PR-раны для feat/openai-tool-calls-v2: completed | action_required | 0s +→ run 36261447558/36261447575 (первый push 26.09), 36401446186/36401446397 (сегодня) +``` +**`action_required` + длительность `0s`** = GitHub блокирует запуск workflow для PR **из форка** +(`isCrossRepository: true`, `headRepository: mallexxx/runtime`) до одобрения мейнтейнером +(`normahq/runtime` → Settings → Actions → fork PR approval). + +Контрольный факт: PR #3 шёл из ветки **в самом upstream** — `push`-ран `1m51s`, `pull_request`-ран +`1m5s`, всё прошло. То есть workflows исправны, дело именно в fork-политике. + +📌 **Что это значит:** ревью право («нет CI check runs»), но починить это **мы не можем** — +ждать одобрения владельца репо. Наши локальные гейты (`go test -race ./...` + `golangci-lint`) +эквивалентны тому, что делает CI (`test.yml`: `go test -v -race ./...` + govulncheck; +`lint.yml`: golangci-lint-action v2.11.4). +📌 Возможный обход (по решению Alex): завести ветку **в самом** `normahq/runtime`, если есть write +доступ — тогда CI запустится сразу. + +--- + +## 0. Контекст + +`hostedagent/openai.go` — патч, дающий `OpenAIModel` поддержку `tool_calls`. +Без него MCP-инструменты **не исполняются** при `type: openai` (см. навык +`docker-balda-service`, §«MCP-инструменты не исполняются: два пути»). Патч нужен нашему +стенду Балды (DeepSeek через OpenAI-совместимый API). + +- **PR:** normahq/runtime#4 · OPEN · MERGEABLE · 3 файла · +1045/−27 +- **Ревью НЕ опубликовано** в GitHub (ни `reviews`, ни inline-комментарии в `normahq` и + `mallexxx` — проверено API). Работали по тексту ревью от Alex. + +--- + +## 1. Четыре дефекта — подтверждены фактами (не на слово) + +### P1-a. `genai.Schema` сериализуется напрямую → типы UPPERCASE + +`openai.go:220` — `json.Marshal(fd.Parameters)`, где `Parameters *genai.Schema`. + +**Проверено пробником:** +``` +{"properties":{"limit":{"type":"INTEGER"},"query":{"description":"search text","type":"STRING"}}, + "required":["query"],"type":"OBJECT"} +``` +Go маршалит по именам полей, а `genai` использует uppercase-константы (`genai.TypeObject` += `"OBJECT"`). **OpenAI требует JSON Schema: `object`, `string`, `integer` в lowercase.** +Запрос с такими инструментами → 400. Контракт OpenAI нарушен. + +⚠️ То же для `fd.ParametersJsonSchema` (`:224`) — если это `*genai.Schema`-совместимая форма. + +### P1-b. `content,omitempty` удаляет content у пустого tool-результата + +`openai.go:85` — `Content string \`json:"content,omitempty"\``. + +Пустой tool-результат (`respStr == ""`, `:441`) → поле выпадает → на провод уходит: +```json +{"role":"tool","tool_call_id":"call-1"} +``` +**`content` обязателен для tool-message** по схеме OpenAI → 400. +Нужно сериализовать хотя бы `""` — то есть убрать `omitempty` для tool-роли (или всегда +писать content, а omitempty убрать совсем). + +### P2-a. Ветка toolResponses отбрасывает текст смешанного Content + +`openai.go:381-382`: +```go +case len(toolResponses) > 0: + messages = append(messages, toolResponses...) // ← text потерян +``` +ADK разрешает `Content{FunctionResponse + Text}` в одном сообщении. Текст надо сохранить +**отдельным user-message**, а не молча терять. Сейчас теряется. + +### P2-b. Двойное декодирование аргументов не работает для не-объекта + +`openai.go:319-329`. **Проверено пробником:** + +| Форма аргументов | Результат | +|---|---| +| `{"query":"x"}` (объект) | ✅ `{"query":"x"}` | +| `"{\"query\":\"x\"}"` (двойное, объект) | ✅ `{"query":"x"}` | +| `"[{\"query\":\"x\"}]"` (двойное, **массив**) | ❌ `{"raw":"[{\"query\":\"x\"}]"}` | +| `"\"just-a-string\""` (двойное, скаляр) | ❌ `{"raw":"\"just-a-string\""}` | +| `"\"{\\\"query\\\":\\\"x\\\"}\""` (**тройное**) | ❌ `{"raw":"..."}` | + +**Корень:** `json.Unmarshal(..., &args)` c `args map[string]any` **падает**, если после +unwrap получился не объект. Сейчас любой не-объект падает в мусорный `{"raw":...}`, и +исполнитель получает `{"raw":"..."}` вместо аргументов — ровно то, о чём пишет ревью. +Нужен **повторный unwrap строки до JSON-объекта с ограничением глубины**. + +--- + +## 2. План правок + +| # | Дефект | Правка | +|---|---|---| +| 1 | P1-a | Сериализовать `genai.Schema` → **JSON Schema lowercase**: рекурсивный конвертер `Schema`→`map[string]any` с маппингом типов (`OBJECT`→`object`, `STRING`→`string`, `INTEGER`→`integer`, `NUMBER`→`number`, `BOOLEAN`→`boolean`, `ARRAY`→`array`). Обрабатывать `Properties`/`Items`/`Required`/`Enum`/`Description`/`Format`/`Nullable` рекурсивно | +| 2 | P1-b | Убрать `omitempty` у `Content` (или ввести два типа сообщения). Проще и безопаснее: **убрать `omitempty`** — все роли, для которых content уместен, получат `""` явно | +| 3 | P2-a | В ветке `len(toolResponses) > 0` дополнительно добавлять user-message с `text`, если он непустой. Порядок: assistant(tool_calls) → tool-результаты → user(текст) | +| 4 | P2-b | Хелпер `unwrapJSONObject(raw []byte, maxDepth int)`: цикл до `maxDepth`, на каждом шаге пробуем `map[string]any`; для массива — оборачивать осмысленно (`{"items":[...]}`); скаляр — не терять. Ограничение глубины (например 4) от зацикливания | + +### Тесты (контрактные — «четыре дополнительные контрактные проверки» из ревью) + +| Тест | Что фиксирует | +|---|---| +| `TestToolsSerializeLowercaseJSONSchema` | в теле запроса `"type":"object"`, **не** `"OBJECT"`; вложенные properties тоже lowercase | +| `TestEmptyToolResultKeepsContentField` | tool-message содержит `"content":""` (поле есть, не выпало) | +| `TestMixedToolAndTextContentKeepsText` | `Content{FunctionResponse+Text}` → и tool-message, и user-message с текстом | +| `TestArgumentsUnwrapRepeatedEncoding` | таблица: объект / двойное-объект / двойное-массив / скаляр / тройное → исполнитель получает валидные аргументы, не `{"raw":...}` | + +### Гейты (из `AGENTS.md` репо) +``` +go test ./... +go test -race ./... +go tool golangci-lint run ./... +``` + +--- + +## 3. Риски / питфоллы + +1. 🔴 **`omitempty` у Content — не только tool.** Убрать его совсем = `content:""` появится и + у assistant-сообщения с чистым tool_call. OpenAI это допускает, но безопаснее сверить: + у assistant с `tool_calls` content допустим `null`/`""`. Проверить на контракт. +2. ⚠️ **Не-объектные аргументы.** `genai.FunctionCall.Args` by design — объект. Массив в + аргументах — невалидный вызов инструмента, но **нельзя** отдавать `{"raw":...}` молча: + лучше положить под осмысленным ключом и залогировать/вернуть ошибку. +3. 🔴 **Модуль кэша read-only** — путь: форк + `replace` в `go.mod` потребителя + (см. навык). Ревью/правки только в `~/Developer/runtime-local`. +4. ⚠️ **CI PR не запускался** («нет check runs для SHA»). Возможно, workflow требует + одобрения для форк-PR. После push — проверить `gh pr checks 4`. +5. ⚠️ **Правки дельта-апплаером** (`tools/run-project-deltas.sh`, `check` → `apply`), + не sed/in-place — правило Alex. + +--- + +## 4. Порядок — выполнен + +1. ✅ Проверить все 4 дефекта фактами (пробники) — §1 +2. ✅ Правки в `hostedagent/openai.go` дельтами (5 групп) +3. ✅ 4+ контрактных теста в `hostedagent/openai_contract_test.go` +4. ✅ Гейты: `go test -race ./...` + `golangci-lint` — зелёные +5. ⚠️ `go build` потребителя — **balda ссылается на СВОЮ копию** `balda/runtime-local` + (HEAD `05342c5a`, 516 строк, без правок). Это **отдельная задача синхронизации**, не часть PR #4 +6. ✅ Коммит `0937011` → push в `feat/openai-tool-calls-v2` +7. ⛔ `gh pr checks 4` — `action_required` (fork-PR, нужно одобрение мейнтейнера), §0в +8. ✅ Obsidian обновлён: этот файл + [[personal/tech/adk-hostedagent-openai-mcp]] + +## 4a. Открытый вопрос: синхронизация копии рантайма в balda + +`~/Developer/balda/runtime-local` — **отдельная копия** (не симлинк), которую `balda/go.mod` +подключает через `replace github.com/normahq/runtime/v2 => ./runtime-local`. +Её HEAD = `05342c5a` (коммит **balda**, другой репо), 516 строк, без правок PR #4. + +Значит наш боевой стенд Балды **пока не получил** фиксы tool_calls. Синхронизировать = +перенести `hostedagent/openai.go` из `~/Developer/runtime-local` в `~/Developer/balda/runtime-local` +(плюс решить, как версионировать: submodule / копия / отдельная ветка). + +🔴 **Решать после мержа PR #4** — иначе копия разъедется повторно при следующей правке апстрима. + +## Связанные заметки + +- [[personal/tech/adk-hostedagent-openai-mcp]] — MCP/tool_calls архитектура, этот патч +- [[personal/projects/balda/balda-valera]] — где патч применяется в бою +- [[personal/tech/balda-go-fx-pitfalls]] — метод дельт, питфоллы diff --git a/personal/tech/adk-hostedagent-openai-mcp.md b/personal/tech/adk-hostedagent-openai-mcp.md index ce8f91b8..62c55562 100755 --- a/personal/tech/adk-hostedagent-openai-mcp.md +++ b/personal/tech/adk-hostedagent-openai-mcp.md @@ -173,8 +173,67 @@ if _, ok := toolset.(requestProcessor); ok { --- +## 8. Wire-контракт OpenAI tool_calls: 4 дефекта (2026-09-28) + +`hostedagent/openai.go` — наш патч tool_calls поверх runtime v2. Ревью PR +**normahq/runtime#4** дало вердикт «Request changes» с 4 воспроизводимыми дефектами. +Все подтверждены фактами и закрыты коммитом **`0937011`**. Разбор — +[[personal/plans/runtime-pr4-openai-toolcalls-fixes]]. + +| # | Дефект | Суть | +|---|---|---| +| P1-a | `genai.Schema` маршалится напрямую | типы уходят как `"type":"OBJECT"`/`"STRING"` — **genai использует uppercase-константы**, а OpenAI требует JSON Schema lowercase → 400 | +| P1-b | `content,omitempty` у tool-message | пустой результат → `{"role":"tool","tool_call_id":"..."}` без `content`, а он **обязателен** по схеме OpenAI → 400 | +| P2-a | Ветка `toolResponses` | `Content{FunctionResponse + Text}` терял текст (ADK такой Content разрешает) | +| P2-b | Декод аргументов | не-объект (массив/скаляр) падал в мусорный `{"raw":...}` → исполнитель не получал аргументов | + +📌 **Как проверить фактом, а не глазами** (использовать при любой правке схем): +```go +// пробник: как реально сериализуется genai.Schema +out, _ := json.Marshal(&genai.Schema{Type: genai.TypeObject, Properties: ...}) +t.Logf("%s", out) // → "type":"OBJECT" ← вот дефект +``` +📌 **`genai.Schema.Nullable` — это `*bool`**, не `bool`. Компилятор ловит: +`non-boolean condition in if statement`. + +🔴 **Урок про маскирующие тесты.** Существовавший +`TestOpenAIToolsFromConfig_WithParameters` проверял +`params["type"] != "OBJECT" && params["type"] != "object"` — **принимал обе формы**, поэтому +uppercase-баг жил незамеченным через все гейты. **Терпимый ассерт = отсутствующая проверка.** +Ассерт должен пинить ровно одно ожидаемое значение. + +📌 **Формы аргументов, которые надо переживать** (все встречаются у провайдеров): +`{"query":"x"}` · `"{\"query\":\"x\"}"` · `"\"{\\\"query\\\":\\\"x\\\"}\""` (двойное) · +`"[...]"` (массив) · `"scalar"`. Корень бага: `json.Unmarshal` в `map[string]any` **падает**, +если после unwrap получился не объект. + +📌 **`FunctionCall.Args` по контракту — объект.** Поэтому не-объектный результат оборачивается +под стабильный ключ, а не отбрасывается. + +### ⛔ CI PR из форка: `action_required`, 0s + +Все PR-раны для веток из форка (`isCrossRepository: true`) висят в статусе **`action_required` +с длительностью `0s`** — GitHub не запускает workflow до одобрения мейнтейнером. +Диагностика: +```bash +gh run list --repo normahq/runtime --limit 10 # → completed | action_required | 0s +gh pr view 4 --json isCrossRepository,headRepository +``` +Контроль: PR из ветки **в самом upstream** проходит нормально (`1m5s`). Это **внешний блокер**, +не дефект кода. Обход — ветка в upstream при наличии write-доступа. + +### ⚠️ Копия рантайма в balda — отдельная! + +`~/Developer/balda/runtime-local` — **отдельная копия** (не симлинк), подключена через +`replace github.com/normahq/runtime/v2 => ./runtime-local`. Её HEAD — коммит **balda**, а не +runtime. Правки в `~/Developer/runtime-local` **не доезжают** до боевого стенда автоматически. +Проверять: `git -C ~/Developer/balda/runtime-local rev-parse --short HEAD`. + +--- + ## Связанные заметки +- [[personal/plans/runtime-pr4-openai-toolcalls-fixes]] — PR #4: 4 дефекта, фиксы, тесты - [[personal/plans/balda-mm-phase4-agents]] — §19 (корень, фикс, тесты, приёмка), §16.2 и §18 опровергнуты - [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3, образ `balda-mm:0.1.13` - [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы Балды diff --git a/personal/tech/flaresolverr-taiga.md b/personal/tech/flaresolverr-taiga.md index d5ca64b9..54fe3071 100755 --- a/personal/tech/flaresolverr-taiga.md +++ b/personal/tech/flaresolverr-taiga.md @@ -1,10 +1,11 @@ # FlareSolverr на Тайге + обход блокировок индексеров -> ✅ **СОСТОЯНИЕ 2026-09-28 (поздний вечер): ГОТОВО на всех четырёх arr-сервисах.** -> **Prowlarr** — 6/6 индексеров, `health` → `[]`, `indexerstatus` → `[]`. +> ✅ **СОСТОЯНИЕ 2026-09-28 (финал): инфраструктура готова, 2 дефекта на стороне трекеров.** +> **Prowlarr** — `health` → `[]`, 6/6 индексеров настроены; RuTracker в карантине (капча, см. ниже). > **Radarr** — `health` → `[]`. **Sonarr** — `health` → `[]`. -> **Lidarr** — работает (SkyHook + 23 релиза); 1 warning `Kinozal (M)` лечится -> `docker restart lidarr` после правки `proxyBypassFilter` (см. питфолл в конце). +> **Lidarr** — SkyHook работает, 1 артист, поиск релизов отдаёт результаты. +> ⚠️ Открыто: **RuTracker — капча на логин** (нужен ручной вход Alex), **RuTor — категории** +> (не отдаёт фильмы/сериалы). Оба — на стороне трекеров, не инфраструктура. > Контейнер `flaresolverr` (v3.5.2), порт `8191`, сеть `media_net`, `shm_size: 1gb`, > `LOG_LEVEL=warning`, **`PROXY_URL=socks5://vless-proxy:1080`**. > Compose — `/mnt/RED_2TB/docker/arr/docker-compose.yml` (6-й сервис). @@ -242,6 +243,43 @@ CPU 0.68%); канал прокси нормальный (kinozal через soc прокси-конфига. Остаток: RuTor — несовпадение категорий (не блок), и 1 warning в Lidarr до перезапуска с новым bypass. +## 🔴 ОТКРЫТО 2026-09-28 (финал сессии): RuTracker отдаёт КАПЧУ на логин + +Хронология одного вечера — пароль один и тот же, результат разный: + +| Время | Проверка | `len` HTML | Итог | +|---|---|---|---| +| ~15:20 | `request.post /forum/login.php` | **251 792** | ✅ залогинен | +| ~16:40 | `request.post /forum/login.php` | **13 335** | ❌ форма вернулась | + +Пароль в Prowlarr при этом **не менялся** (сверено: `username`/`password` из +`/api/v1/indexer/1` совпадают с локальной копией). Причина смены результата — +**трекер выдаёт капчу**: в HTML найден блок `cap_code` + метка `Код:`. +Пишется это в лог Prowlarr как `NzbDrone.Core.Indexers.Exceptions.IndexerAuthException: +RuTracker Auth Failed` и `Invalid Credentials for RuTracker [https://rutracker.org/forum/tracker.php?nm=...&f=...]`. + +🔴 **Правильный признак «залогинен/нет» — РАЗМЕР страницы, а не подстрока.** +- **~250 KB** = залогиненный форум. +- **~13 KB** = форма логина (вернулась). + +Подстрока `login_username` присутствует **в обоих** случаях (HTML-шаблон + скрытая форма +быстрого входа) — по ней диагноз ставить НЕЛЬЗЯ, это дало ложное «Auth Failed» двумя +диагнозами подряд. + +**Обходного пути в Prowlarr нет:** форма индексера RuTracker принимает только +`username`/`password` — поля для cookie-сессии (`bb_data`) не существует, вписать +живую сессию из браузера нельзя. + +**Что делать (нужен Alex):** зайти на `rutracker.org` браузером с **этого же** +исходящего IP (через тот же `vless-proxy`), ввести логин+пароль и **пройти капчу один раз**. +После успешного входа капча обычно снимается. Если не поможет — сменить пароль на +трекере и вписать новый в Prowlarr (`Indexers → RuTracker`), либо поискать у RuTracker +обход капчи (в Cardigann-определении поддержки нет). + +⚠️ Следствие: **RuTracker в карантине** в Prowlarr, поэтому его `caps` отдаёт **429** → +каскад `429:TooManyRequests` в Radarr/Sonarr/Lidarr. Это нормальное поведение, лечится +только восстановлением логина. См. питфолл про `429` ниже. + ## ✅ ЗАКРЫТО: пароль RuTracker — рабочий (первый диагноз был НЕВЕРЕН) **Итог 2026-09-28 (позже в тот же вечер):** сохранённая в Prowlarr пара