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

20 KiB
Executable File
Raw Blame History

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.

🔴 ОБНОВЛЕНО 2026-09-28: первая версия фикса дедупа (§4) и два теста (§5, §6) отклонены ревью PR #5. Правки помечены в §4a / §5 / §6 — читать их вместе с §4.


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):

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:

// 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 добавляет привязку в набор серверов агента:

registry.Set(binding.ID, binding.Config)
extraMCPServerIDs = append(extraMCPServerIDs, binding.ID)

→ hostedToolsets строит два тулсета на один сервер → одинаковые имена → PackTool падает.

Триггер: balda.session_memory.enabled: true создаёт scoped-привязку. Без неё дефекта нет.


4. Фикс — дедуп по эндпоинту ⚠️ (ОТВЕРГНУТ ревью PR #5, 2026-09-28)

🔴 Статус: первая версия фикса отклонена ревьюer'ом metalagman в personal/plans/runtime-pr5-toolset-dedup-review-fixes (CHANGES_REQUESTED). Ниже — как было сделано; что именно неверно и чем заменяем — см. §4a. Не копировать этот раздел в новые реализации.

Файл: 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>"

4a. Почему версия из §4 неверна (ревью PR #5, 2026-09-28)

Три замечания ревьюer'а, все подтверждены фактами. Разбор и план — personal/plans/runtime-pr5-toolset-dedup-review-fixes.

# Severity Что не так
P1 blocking mcpEndpointKey стирает все query-параметры, а Headers в ключе не участвуют. Query и headers выбирают тенанта / авторизационный контекст. /mcp?tenant=a vs /mcp?tenant=b, или один URL с разными Authorization → схлопываются в один произвольный конфиг = доступ к чужому tool-set. Вывод идентичности из host/path — небезопасен.
P2 test gap selectScopedOnly (§5) дублирует алгоритм продакшена и сам вычисляет «ожидаемого» победителя → не проверяет фактически удержанный конфиг. Вакуумный ассерт.
P2 test gap Пробник ProcessRequest(ctx any, req any) (§6) — Go требует точных типов, реальная подпись (agent.Context, *model.LLMRequest) его не удовлетворяет → проверка не может сработать.

📌 Направление замены (ожидает одобрения Alex):

  • generic-слой (agentfactory) перестаёт выводить идентичность из формы URL;
  • схлопывание только по явному ключу — предлагается DedupKey string в agentconfig.MCPServerConfig (альтернатива — Aliases []string);
  • Balda объявляет свой дубль сама в composition layer — internal/apps/balda/agent/runtime_manager.go:344 (registry.Set(binding.ID, binding.Config)) — ровно этого просит ревьюer («resolve the Balda-specific duplicate in the Balda composition layer»);
  • победитель — детерминированный (lexicographic) или явное поле, но не эвристика по наличию query.

⚠️ Разные ?tenant=/Authorization в общем слое обязаны оставаться разными серверами.


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 toolset». Искать строго grep -c "duplicate tool:" (с двоеточием, как в формате PackTool).

⚠️ Тест должен быть чувствительным. Первая версия проверяла только количество тулсетов и проходила даже со сломанным фиксом (отключение keeper-предпочтения через if false && … её не ломало). Добавлен selectScopedOnly — теперь тест падает, если выживает не scoped-запись. Слабый тест = ложная уверенность.

🔴 НО: selectScopedOnly тоже признан вакуумным (ревью PR #5, §4a P2). Он копирует цикл hostedToolsets внутри теста и возвращает «ожидаемого» победителя сам — проверяется копия алгоритма, а не реально удержанный конфиг (hostedToolsets отдаёт непрозрачные tool.Toolset; тест считает только len()). Если поменять приоритет синхронно в проде и в копии — тест останется зелёным. Правильная форма: ассертить фактически использованный endpoint — либо через инъектируемый конструктор транспорта (тестовый хук, подмена в тесте + проверка Transport.Endpoint), либо подняв реальные httptest-MCP-серверы с разными tool-листами. Урок усиливается: копия логики в тесте — тот же класс ошибки, что терпимый ассерт. Тест обязан наблюдать результат продакшена.


6. Метод: исполняемый пробник вместо чтения

Час чтения исходников ADK дал неверную версию. Один тест на присутствие интерфейса убил её за секунду:

import (
    "google.golang.org/adk/v2/agent"
    "google.golang.org/adk/v2/model"
)

type requestProcessor interface {
    ProcessRequest(ctx agent.Context, req *model.LLMRequest) error
}
if _, ok := toolset.(requestProcessor); ok {
    t.Fatal("unexpected: premise changed")
}

🔴 Типы должны быть ТОЧНЫМИ (ревью PR #5, §4a P2). Изначальная форма ProcessRequest(ctx any, req any) error — вакуумная: Go требует точного совпадения типов, поэтому реальный метод (agent.Context, *model.LLMRequest) такой интерфейс не реализует, ok всегда false, t.Fatal недостижим. Пробник выглядел как проверка, но не проверял ничего — ровно та же болезнь, что «терпимый ассерт» в §8. Полезное дополнение: позитивный контроль — рядом проверить, что интерфейс реализуется заведомо подходящим типом; если сигнатура разъедется, тест это покажет.

⚠️ Тип agent.Context в adk/v2 — интерфейс (github.com/google/uuid-подобных полей нет). Продакшн-подписи подтверждены в модульном кэше: adk/v2@v2.4.0/internal/toolinternal/tool.go:41 и tool/tool.go:192:

ProcessRequest(ctx agent.Context, req *model.LLMRequest) error

📌 Правило: когда гипотеза касается интерфейса или пути вызова — писать исполняемый пробник, а не читать дальше. Чтение даёт правдоподобные версии; тест даёт факт.

📌 Искать ошибку в БАЗОВОМ модуле, а не в своём репо: 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":...} → исполнитель не получал аргументов

📌 Как проверить фактом, а не глазами (использовать при любой правке схем):

// пробник: как реально сериализуется 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 до одобрения мейнтейнером. Диагностика:

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.


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