Files
obsidian-vault/personal/plans/runtime-pr4-openai-toolcalls-fixes.md
T

15 KiB
Executable File
Raw Blame History

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) → поле выпадает → на провод уходит:

{"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. Риски / питфоллы

  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 — иначе копия разъедется повторно при следующей правке апстрима.

Связанные заметки