Files
obsidian-vault/personal/tech/adk-hostedagent-openai-mcp.md
T

241 lines
14 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.
# ADK hostedagent — OpenAI/MCP: `duplicate tool` и правда о `ProcessRequest`
> Создано 2026-09-27. Зеркало скилла [[software-development/adk-hostedagent-openai-mcp|`adk-hostedagent-openai-mcp`]].
> Здесь — **исправление к скиллу** и разбор дефекта, найденного на Балде.
> Проект: [[personal/plans/balda-mm-phase4-agents]] §19, [[personal/projects/balda/balda-kraken-mm-deploy]] §19.
---
## 1. ⚠️ Исправление к скиллу (было неверно до 2026-09-27)
Скилл (v3.2.0) утверждал:
> «**MCP tools implement `RequestProcessor`** → их `ProcessRequest()` вызывает
> `toolutils.PackTool(req, tool)`, поэтому инструменты пакуются и в `toolPreprocess`, и в
> `toolsetPreprocess`».
**Это ложь.** Проверено исполняемым тестом:
```
go test ./agentfactory/ -run TestMcpToolsetImplementsRequestProcessor -v
→ MCP toolset "mcp_tool_set" does NOT implement ProcessRequest
```
В `adk/v2@v2.4.0`:
| Тип | Файл | `ProcessRequest` |
|---|---|---|
| `mcpTool` | `tool/mcptoolset/tool.go:88` | ✅ есть |
| `set` (сам тулсет) | `tool/mcptoolset/set.go:144-185` | ❌ **НЕТ** (есть только `Tools()`) |
Поэтому `toolsetPreprocess` (`base_flow.go:798`) на MCP-тулсетах делает `if !ok { continue }` —
**пропускает их**. Единственный паковщик для MCP-инструментов — `toolPreprocess` (`:778`)
по плоскому `Flow.Tools`, который наполняет `toolProcessor` (`tools_processor.go:49`).
⛔ **Не диагностировать `duplicate tool` как «двойную паковку двух слоёв ADK» — этот путь
физически невозможен.** Полчаса чтения `base_flow.go` вели именно к этой ложной версии.
---
## 2. Как выглядит дефект
```
error … "agent run: duplicate tool: \"balda.control.shutdown\""
warn … command failed with retryable error (delivery_attempt 1..5)
warn … command deadlettered (terminal:unclassified)
```
`PackTool` (`tool/toolutils/toolutils.go:47-48`):
```go
if _, ok := req.Tools[name]; ok {
return fmt.Errorf("duplicate tool: %q", name)
}
```
`PackTool` **не идемпотентен**, а `toolProcessor` схлопывает **все** тулсеты в один плоский
`Flow.Tools`. Значит: **два MCP-сервера с одинаковыми именами инструментов = падение хода.**
🔴 **Ключевая сигнатура:** обычный текстовый ответ работает, падает **только** ход с явным
запросом инструмента. Это указывает на момент паковки, а не на конфиг.
---
## 3. Настоящий корень (найден 2026-09-27)
Balda регистрировала bundled MCP-сервер **ДВАЖДЫ на один и тот же URL**:
```go
// internal/apps/balda/internalmcp/manager.go
// :206 — база для session-scoped брокера
m.contextBroker.SetBaseURL("http://" + res.Addr + baldaRoute)
// :213 — и тот же эндпоинт в реестр
m.registry.Set(bundledBaldaServerID, agentconfig.MCPServerConfig{
Type: agentconfig.MCPServerTypeHTTP,
URL: fmt.Sprintf("http://%s%s", res.Addr, baldaRoute),
})
```
`sessionmemorymcp/broker.go`:
- `Bind()` (`:111`) → `ID: "balda-session-memory-"+token`, а URL (через `contextURL:216-225`) —
**тот же эндпоинт** + `?<contextQueryParameter>=<token>`
- `Wrap()` (`:118-153`) — тот же handler, инжектит trusted-заголовки по токену
`agent/runtime_manager.go:344-345` добавляет привязку в набор серверов агента:
```go
registry.Set(binding.ID, binding.Config)
extraMCPServerIDs = append(extraMCPServerIDs, binding.ID)
```
→ `hostedToolsets` строит **два тулсета на один сервер** → одинаковые имена → `PackTool` падает.
**Триггер:** `balda.session_memory.enabled: true` создаёт scoped-привязку. Без неё дефекта нет.
---
## 4. Фикс — дедуп по эндпоинту
**Файл:** `runtime-local/agentfactory/agentfactory.go`, функция `hostedToolsets`.
Два конфига, адресующие **один host+path**, — один сервер с точки зрения пространства имён
инструментов. Различие в query (контекст-токен) не делает их разными.
Хелперы:
- `mcpEndpointKey(cfg)` — `url.Parse`, обнуляет `RawQuery` и `Fragment`.
Покрывает **`http` и `sse`**; `stdio` возвращает `""` (общего эндпоинта нет) → никогда не схлопывается.
- `mcpURLIsScoped(cfg)` — есть ли query (признак session-scoped привязки Балды).
🔴 **Критично, какую запись оставлять.** Наивный «первый по алфавиту» оставляет `balda` и
**выбрасывает scoped-привязку** — краш уходит, но **session memory ломается** (теряется
контекст-токен). Поэтому keeper — запись **со** scoped-URL: она строгий супернабор
(без токена `Wrap` просто пропускает запрос в `next.ServeHTTP`).
Результат в логе:
```
[mcp] skipping duplicate toolset "balda": endpoint already served by "balda-session-memory-<token>"
```
---
## 5. Проверка (что реально подтверждает фикс)
| Проверка | Команда | Ожидание |
|---|---|---|
| Тесты | `go test ./agentfactory/ -run 'Toolset\|MCPEndpoint\|McpToolset' -v` | 6/6 PASS |
| Ошибка ушла | `docker logs balda-mm \| grep -c "duplicate tool:"` | **0** |
| Инструмент вызван | лог: `function_response_part_count` | **1** |
| Ход завершён | `turn_complete` / `final_response` | `true` / `true` |
| Живая приёмка | пост владельца с явным вызовом инструмента | ответ с реальным результатом |
⚠️ **Питфолл своей же проверки:** `grep -c "duplicate tool"` даёт ложную **1**, потому что
собственная строка лога фикса — «skipping **duplicate tool**set». Искать строго
`grep -c "duplicate tool:"` (с двоеточием, как в формате `PackTool`).
⚠️ **Тест должен быть чувствительным.** Первая версия проверяла только *количество* тулсетов и
**проходила даже со сломанным фиксом** (отключение keeper-предпочтения через `if false && …` её
не ломало). Добавлен `selectScopedOnly` — теперь тест падает, если выживает не scoped-запись.
Слабый тест = ложная уверенность.
---
## 6. Метод: исполняемый пробник вместо чтения
Час чтения исходников ADK дал **неверную** версию. Один тест на присутствие интерфейса убил её
за секунду:
```go
type requestProcessor interface {
ProcessRequest(ctx any, req any) error
}
if _, ok := toolset.(requestProcessor); ok {
t.Fatal("unexpected: premise changed")
}
```
📌 **Правило:** когда гипотеза касается **интерфейса или пути вызова** — писать исполняемый
пробник, а не читать дальше. Чтение даёт правдоподобные версии; тест даёт факт.
📌 **Искать ошибку в БАЗОВОМ модуле, а не в своём репо:** `grep -rn "duplicate tool"` по
репозиторию Balda **пуст** — текст живёт в `google.golang.org/adk` (`$GOMODCACHE`). Симптом
«в моём коде такого нет» означает «ищи в зависимостях».
---
## 7. Порядок сборки образа (урок сессии)
Сборка, запущенная **до** последней правки кода, даёт устаревший бинарник. В этой сессии build
стартовал, затем я доработал `mcpEndpointKey` (добавил `sse`), пришлось убивать процесс и
пересобирать.
**Правильный порядок:** все правки → финальный `go build` / `go test` → и только затем docker build.
---
## 8. Wire-контракт OpenAI tool_calls: 4 дефекта (2026-09-28)
`hostedagent/openai.go` — наш патч tool_calls поверх runtime v2. Ревью PR
**normahq/runtime#4** дало вердикт «Request changes» с 4 воспроизводимыми дефектами.
Все подтверждены фактами и закрыты коммитом **`0937011`**. Разбор —
[[personal/plans/runtime-pr4-openai-toolcalls-fixes]].
| # | Дефект | Суть |
|---|---|---|
| P1-a | `genai.Schema` маршалится напрямую | типы уходят как `"type":"OBJECT"`/`"STRING"` — **genai использует uppercase-константы**, а OpenAI требует JSON Schema lowercase → 400 |
| P1-b | `content,omitempty` у tool-message | пустой результат → `{"role":"tool","tool_call_id":"..."}` без `content`, а он **обязателен** по схеме OpenAI → 400 |
| P2-a | Ветка `toolResponses` | `Content{FunctionResponse + Text}` терял текст (ADK такой Content разрешает) |
| P2-b | Декод аргументов | не-объект (массив/скаляр) падал в мусорный `{"raw":...}` → исполнитель не получал аргументов |
📌 **Как проверить фактом, а не глазами** (использовать при любой правке схем):
```go
// пробник: как реально сериализуется genai.Schema
out, _ := json.Marshal(&genai.Schema{Type: genai.TypeObject, Properties: ...})
t.Logf("%s", out) // → "type":"OBJECT" ← вот дефект
```
📌 **`genai.Schema.Nullable` — это `*bool`**, не `bool`. Компилятор ловит:
`non-boolean condition in if statement`.
🔴 **Урок про маскирующие тесты.** Существовавший
`TestOpenAIToolsFromConfig_WithParameters` проверял
`params["type"] != "OBJECT" && params["type"] != "object"` — **принимал обе формы**, поэтому
uppercase-баг жил незамеченным через все гейты. **Терпимый ассерт = отсутствующая проверка.**
Ассерт должен пинить ровно одно ожидаемое значение.
📌 **Формы аргументов, которые надо переживать** (все встречаются у провайдеров):
`{"query":"x"}` · `"{\"query\":\"x\"}"` · `"\"{\\\"query\\\":\\\"x\\\"}\""` (двойное) ·
`"[...]"` (массив) · `"scalar"`. Корень бага: `json.Unmarshal` в `map[string]any` **падает**,
если после unwrap получился не объект.
📌 **`FunctionCall.Args` по контракту — объект.** Поэтому не-объектный результат оборачивается
под стабильный ключ, а не отбрасывается.
### ⛔ CI PR из форка: `action_required`, 0s
Все PR-раны для веток из форка (`isCrossRepository: true`) висят в статусе **`action_required`
с длительностью `0s`** — GitHub не запускает workflow до одобрения мейнтейнером.
Диагностика:
```bash
gh run list --repo normahq/runtime --limit 10 # → completed | action_required | 0s
gh pr view 4 --json isCrossRepository,headRepository
```
Контроль: PR из ветки **в самом upstream** проходит нормально (`1m5s`). Это **внешний блокер**,
не дефект кода. Обход — ветка в upstream при наличии write-доступа.
### ⚠️ Копия рантайма в balda — отдельная!
`~/Developer/balda/runtime-local` — **отдельная копия** (не симлинк), подключена через
`replace github.com/normahq/runtime/v2 => ./runtime-local`. Её HEAD — коммит **balda**, а не
runtime. Правки в `~/Developer/runtime-local` **не доезжают** до боевого стенда автоматически.
Проверять: `git -C ~/Developer/balda/runtime-local rev-parse --short HEAD`.
---
## Связанные заметки
- [[personal/plans/runtime-pr4-openai-toolcalls-fixes]] — PR #4: 4 дефекта, фиксы, тесты
- [[personal/plans/balda-mm-phase4-agents]] — §19 (корень, фикс, тесты, приёмка), §16.2 и §18 опровергнуты
- [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3, образ `balda-mm:0.1.13`
- [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы Балды
- скилл `adk-hostedagent-openai-mcp` — источник (исправлен 2026-09-27)