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

17 KiB
Executable File
Raw Blame History

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

{"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

Обновлено 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 как на открытый.

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