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

249 lines
17 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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]] — метод дельт, питфоллы