182 lines
9.4 KiB
Markdown
Executable File
182 lines
9.4 KiB
Markdown
Executable File
# 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)
|