17 KiB
Executable File
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.📌 После мержа:
upstream/main(c41939f) содержит и PR #4, и PR #5, и мейнтейнерские фиксы к обоим. Форкmallexxx/runtimeиreplaceв Balda больше не нужны — см. personal/plans/balda-runtime-rebuild-mm-validation. ⚠️ Мейнтейнер добавил в ветку PR #4 коммитd83b8e0fix(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) → поле выпадает → на провод уходит:
{"role":"tool","tool_call_id":"call-1"}
content обязателен для tool-message по схеме OpenAI → 400.
Нужно сериализовать хотя бы "" — то есть убрать omitempty для tool-роли (или всегда
писать content, а omitempty убрать совсем).
P2-a. Ветка toolResponses отбрасывает текст смешанного Content
openai.go:381-382:
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. Риски / питфоллы
- 🔴
omitemptyу Content — не только tool. Убрать его совсем =content:""появится и у assistant-сообщения с чистым tool_call. OpenAI это допускает, но безопаснее сверить: у assistant сtool_callscontent допустимnull/"". Проверить на контракт. - ⚠️ Не-объектные аргументы.
genai.FunctionCall.Argsby design — объект. Массив в аргументах — невалидный вызов инструмента, но нельзя отдавать{"raw":...}молча: лучше положить под осмысленным ключом и залогировать/вернуть ошибку. - 🔴 Модуль кэша read-only — путь: форк +
replaceвgo.modпотребителя (см. навык). Ревью/правки только в~/Developer/runtime-local. - ⚠️ CI PR не запускался («нет check runs для SHA»). Возможно, workflow требует
одобрения для форк-PR. После push — проверить
gh pr checks 4. - ⚠️ Правки дельта-апплаером (
tools/run-project-deltas.sh,check→apply), не sed/in-place — правило Alex.
4. Порядок — выполнен
- ✅ Проверить все 4 дефекта фактами (пробники) — §1
- ✅ Правки в
hostedagent/openai.goдельтами (5 групп) - ✅ 4+ контрактных теста в
hostedagent/openai_contract_test.go - ✅ Гейты:
go test -race ./...+golangci-lint— зелёные - ⚠️
go buildпотребителя — balda ссылается на СВОЮ копиюbalda/runtime-local(HEAD05342c5a, 516 строк, без правок). Это отдельная задача синхронизации, не часть PR #4 - ✅ Коммит
0937011→ push вfeat/openai-tool-calls-v2 - ⛔
gh pr checks 4—action_required(fork-PR, нужно одобрение мейнтейнера), §0в - ✅ Obsidian обновлён: этот файл + personal/tech/adk-hostedagent-openai-mcp
4a. ✅ ЗАКРЫТО: синхронизация копии рантайма в balda
Обновлено 2026-09-28 (после мержа). Вопрос решён концептуально, реализация — открыта.
Уточнено фактами (прежнее описание в этом разделе было неточным):
~/Developer/balda/runtime-local— НЕ git-клон runtime и не «HEAD05342c5aruntime». Это простой копированный снапшот каталога 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:
- убрать
replace, поднятьrequire github.com/normahq/runtime/v2до версии с PR4+PR5 (штатный путь); - либо заменить содержимое
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 — метод дельт, питфоллы