# runtime PR #4 — OpenAI tool_calls: исправление 4 дефектов ревью > Создан 2026-09-28. Статус: **✅ ВЛИТО В АПСТРИМ (2026-09-28T13:21:59Z).** > Голова PR: **`22f7f17`** (мой коммит `0937011` + правки мейнтейнера), ветка `feat/openai-tool-calls-v2`. > **PR normahq/runtime#4** — **MERGED** (merge-коммит `c6dfc6d`). > Все 4 дефекта ревью закрыты + 4 контрактных теста. Гейты репо зелёные локально. > Репо: `~/Developer/runtime-local`. > Связано: [[personal/tech/adk-hostedagent-openai-mcp]], [[personal/projects/balda/balda-valera]], > [[personal/plans/runtime-pr5-toolset-dedup-review-fixes]]. > > 🔴 **2026-09-29 — репо `normahq/runtime`, НЕ `baldaworks/runtime`** (такого не существует). > Ветка `feat/openai-tool-calls-v2` снесена локально и в `origin`. Дальше PR #7 > (`fix/openai-tool-call-roundtrip-v2`) влился в `upstream/main` = `5375072`, тег **`v2.1.1`**. > Детали — [[personal/plans/runtime-pr5-toolset-dedup-review-fixes]] шапка. > > 📌 **После мержа:** `upstream/main` (`c41939f`) содержит и PR #4, и PR #5, и мейнтейнерские > фиксы к обоим. Форк `mallexxx/runtime` и `replace` в Balda **больше не нужны** — > см. [[personal/plans/balda-runtime-rebuild-mm-validation]]. > ⚠️ Мейнтейнер добавил в ветку PR #4 коммит `d83b8e0` `fix(hostedagent): preserve aliased tool names` > — алиасы имён инструментов (`toolAliases`), которых в моём варианте не было. --- ## 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 > **Обновлено 2026-09-28 (после мержа).** Вопрос решён концептуально, реализация — открыта. Уточнено фактами (**прежнее описание в этом разделе было неточным**): - `~/Developer/balda/runtime-local` — **НЕ git-клон runtime и не «HEAD `05342c5a` runtime»**. Это **простой копированный снапшот** каталога runtime (есть `AGENTS.md`, `agentconfig/`, `hostedagent/`, `go.mod` и т.д., **но нет `.git`**). Каталог **git-ignored** в balda (`git ls-files runtime-local` → 0). - От `upstream/main` отличаются **ровно 3 файла**, и все три — **устаревшие** версии того, что уже влито: `agentfactory/agentfactory.go`, `hostedagent/openai.go`, `hostedagent/openai_test.go`. - `agentconfig/config.go` содержит `DedupKey` — **0 вхождений** (PR #5 отсутствует). ⇒ **Потери при замене нет.** Подключён через `replace github.com/normahq/runtime/v2 => ./runtime-local` (`go.mod:368`). 🔴 **Почему это больше не нужно:** PR #4 и PR #5 **влиты в `normahq/runtime`**, а мейнтейнер довёл оба до финального вида (`d83b8e0`, `f8011c1`, `e888d5e`, `facb0d0`). Форк `mallexxx/runtime` и `replace` утратили смысл. **Развилка (решение за Alex)** — [[personal/plans/balda-runtime-rebuild-mm-validation]] §1/§3: 1. убрать `replace`, поднять `require github.com/normahq/runtime/v2` до версии с PR4+PR5 (штатный путь); 2. либо заменить содержимое `runtime-local` на чекаут `upstream/main` @ `c41939f`, оставив `replace`. ⚠️ Обновить комментарий в `go.mod:365-367` — он ссылается на PR #4 как на **открытый**. ## Связанные заметки - [[personal/plans/runtime-pr5-toolset-dedup-review-fixes]] — PR #5, дедуп MCP-тулсетов по явному ключу - [[personal/plans/balda-runtime-rebuild-mm-validation]] — сборка + деплой на Kraken + валидация - [[personal/tech/adk-hostedagent-openai-mcp]] — MCP/tool_calls архитектура, этот патч - [[personal/projects/balda/balda-valera]] — где патч применяется в бою - [[personal/tech/balda-go-fx-pitfalls]] — метод дельт, питфоллы