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

182 lines
9.4 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.
---
## Связанные заметки
- [[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)