# 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`) — **тот же эндпоинт** + `?=` - `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-" ``` --- ## 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)