Files
obsidian-vault/personal/plans/runtime-pr5-toolset-dedup-review-fixes.md
T

184 lines
15 KiB
Markdown
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.
> Создан 2026-09-28. Статус: **📋 ПЛАН, ЖДЁТ ОДОБРЕНИЯ ALEX.**
> Ветка: `fix/hosted-toolsets-duplicate-endpoint` (@ `b01bdc6`, 2 файла, +263)
> **PR normahq/runtime#5** — OPEN, `CHANGES_REQUESTED` от **metalagman** (2026-09-28T09:04:10Z)
> Репо: `~/Developer/runtime-local`. Связано: [[personal/plans/runtime-pr4-openai-toolcalls-fixes]].
---
## 0. Что говорит ревью (дословно, 3 пункта)
| # | Severity | Суть |
|---|---|---|
| 1 | **P1 — blocking** | `mcpEndpointKey` выбрасывает **все** query-параметры, а группировка игнорирует `Headers`. И query, и headers могут выбирать тенанта / авторизационный контекст / режим сервера. `/mcp?tenant=a` и `/mcp?tenant=b` схлопываются в один произвольный конфиг → **доступ к чужому tool-set / пересечение границы доступа**. Ревьюер: *«keep generic endpoint identity exact»* и *«resolve the Balda-specific duplicate in the Balda composition layer, or introduce explicit alias/replacement metadata»*. |
| 2 | **P2 — test gap** | `selectScopedOnly` **повторяет алгоритм продакшена** внутри теста и вычисляет ожидаемого победителя сам. Не проверяет, что реально удержанный toolset/транспорт — именно scoped URL. Ассерт вакуумный. |
| 3 | **P2 — test gap** | Локальный интерфейс `ProcessRequest(ctx any, req any) error` — Go требует **точных** типов параметров, поэтому реальный метод `ProcessRequest(agent.Context, *model.LLMRequest) error` **всё равно не удовлетворит** интерфейс. Тест не может поймать регрессию. |
**Validation ревьюера:** `go test -race ./...`, `go vet ./...`, `go tool golangci-lint run ./...` — проходят. `GitHub currently reports no check runs/statuses` (тот же fork-блокер, что в PR #4).
---
## 1. Подтверждение фактами
### P1 — ключ стирает query и не смотрит Headers
`agentfactory/agentfactory.go` (`b01bdc6`), функция `mcpEndpointKey`:
```go
u.RawQuery = ""
u.Fragment = ""
return u.String()
```
→ `http://h:1/mcp/balda?ctx=abc123` и `http://h:1/mcp/balda?tenant=b` дают **один ключ**.
`Headers` в ключе не участвуют вообще: `MCPServerConfig.Headers` есть
(`toRuntimeMCPServers` его клонирует, `httpClientWithHeaders` его применяет) — значит разные
`Authorization` на одном URL сейчас считаются «тем же сервером». **Это реальная дыра, ревью право.**
Побочный эффект `mcpURLIsScoped`: «scoped» = «есть хоть какой-то query». Это тоже выводит
семантику из формы хоста/пути/строки — ровно то, против чего ревьюер.
### P2-1 — тест дублирует прод
`duplicate_toolset_test.go:49,57-94`: `selectScopedOnly` — **копия** цикла `hostedToolsets`
(`keeperOf`/`skip`/`mcpURLIsScoped`). Тест проверяет копию алгоритма, а не результат работы
`hostedToolsets` (который вернул непрозрачные `tool.Toolset`; тест лишь считает `len(toolsets)`).
Замечание верное: если поменять приоритет в проде и в копии синхронно — тест останется зелёным.
### P2-2 — вакуумный ассерт
```go
type requestProcessor interface { ProcessRequest(ctx any, req any) error }
```
Продакшн-сигнатура (подтверждено в модульном кэше
`adk/v2@v2.4.0/internal/toolinternal/tool.go:41` и `tool/tool.go:192`):
```go
ProcessRequest(ctx agent.Context, req *model.LLMRequest) error
```
`any` ≠ `agent.Context` → метод **не** реализует интерфейс ни в каком случае. `if _, ok := ...; ok`
всегда `false`, `Fatalf` недостижим. **Вакуумный.**
---
## 2. План правок
### Фикс 1 (P1) — точная идентичность на generic-слое + явный алиас на слое Balda
Разделяем **«когда схлопывать»** и **«какой именно дубль схлопывать»**.
**(a) `agentfactory`: убрать умозаключения из хоста/пути.**
- Удалить `mcpEndpointKey` (стирание query) и `mcpURLIsScoped`.
- Ввести **явное** объявление эквивалентности, а не вывод из формы URL. Два кандидата:
- `MCPServerConfig.Aliases []string` — «id этого конфига дублирует id X» (аддитивно к
`agentconfig`; правка публичного API → документировать в `README.md` по правилу `AGENTS.md`);
- либо поле `DedupKey string` — «схлопывать только конфиги с одинаковым непустым ключом».
- **Рекомендация: `DedupKey string`** (`omitempty`). Почему: не требует знания id-ов,
не создаёт транзитивных связей, неинвазивно для остальных потребителей (пусто → не схлопывается
никогда). Generic-слой больше ничего не выводит — кто задал ключ, тот и отвечает за смысл.
- Схлопывание: только `key != "" && key == key` → **детерминированный** победитель (см. ниже).
**(b) `balda` (composition layer) — объявить дубль явно.**
В `internal/apps/balda/agent/runtime_manager.go:344` (место `registry.Set(binding.ID, binding.Config)`)
проставлять `DedupKey` = endpoint **без** query, но **только** для связки
`bundledBaldaServerID` → session-scoped binding. Это то самое «resolve in the Balda composition
layer», чего просит ревьюер. Разные `?tenant=`/`Authorization` в общем слое остаются разными.
**(c) Детерминированный победитель — без «scoped эвристики».**
Вместо `mcpURLIsScoped` (снова форма URL) — при `DedupKey`-группе размером >1 выбирать
**лексикографически меньший id** и логировать остальные как skipped. В Balda это даст
`balda` (стабильно, не зависит от случайного токена). 🔴 **Следствие для Balda:** session-токен
перестанет попадать в URL активного тулсета — надо подтвердить, что context-broker доставляет
контекст иначе (см. §3, риск 1). Если нет — победителя задавать явным полем
`DedupPreferred bool`, а не эвристикой.
**Обоснование смены победителя (было: scoped, стало: явное/lexicographic):**
старый выбор «scoped» — это тоже неявный вывод, и ревьюер его отвергает по духу P1.
Явное поле/детерминированный порядок — предсказуемо и тестируемо.
### Фикс 2 (P2-1) — тест на фактический результат
Вместо копии алгоритма проверять **то, что реально вернул `hostedToolsets`**:
- поднять **два httptest-HTTP-сервера** (MCP handler) с *разными* tool-листами по разным путям;
настоящий `hostedToolsets` → вызвать `Tools()`/`Name()` полученных тулсетов и убедиться, что
виден tool-set сервера с ожидаемого URL;
- либо, если поднять MCP-сервер дорого, — прокинуть наружу фактически использованный endpoint через
**инъектируемый конструктор транспорта** (`var mcpTransportForConfig = ...` + подмена в тесте),
и ассертить `Transport.Endpoint`. Тогда тест падает, если прод удержал не тот конфиг.
- 🔴 Предпочтение: инъекция конструктора — дешевле, детерминированнее и не тянет сеть. Реализовать
как тестовый хук в `agentfactory` (тонкая обёртка), без изменения публичного API.
### Фикс 3 (P2-2) — точная сигнатура
Заменить вакуумный интерфейс:
```go
import (
"google.golang.org/adk/v2/agent"
"google.golang.org/adk/v2/model"
)
type requestProcessor interface {
ProcessRequest(ctx agent.Context, req *model.LLMRequest) error
}
```
Продакшн-типы (`agent.Context`, `*model.LLMRequest`) подтверждены в модульном кэше.
Плюс **позитивный контроль**: если ассерт вакуумный, тест должен падать. Добавить в тот же тест
проверку, что интерфейс **реализуется** заведомо подходящим типом (например,
`tool.SetRequestProcessor`-совместимой обёрткой или `*mcpTool` из `mcptoolset`) — если сигнатура
разъедется с продакшном, тест это покажет, а не будет молчать.
### Тесты (контрактные)
| Тест | Что фиксирует |
|---|---|
| `TestDistinctQueryParamsNeverCollapseWithoutAlias` | `/mcp?tenant=a` vs `/mcp?tenant=b` (без `DedupKey`) → **2** тулсета |
| `TestDistinctHeadersNeverCollapseWithoutAlias` | один URL, разные `Authorization` → **2** тулсета |
| `TestSameDedupKeyCollapses` | два конфига с одинаковым `DedupKey` → **1**, и удержан **детерминированный** |
| `TestEmptyDedupKeyNeverCollapses` | `DedupKey:""` + одинаковые URL → **2** (generic-слой ничего не выводит) |
| `TestHostedToolsetsKeepsRetainedEndpoint` | через хук транспорта: удержан именно ожидаемый endpoint/id (замена `selectScopedOnly`) |
| `TestMcpToolsetRequestProcessorSignature` | точная сигнатура `agent.Context`/`*model.LLMRequest` + позитивный контроль |
### Гейты
```
gofmt -l agentfactory/ agentconfig/
go build ./...
go test ./... && go test -race ./...
go vet ./...
go tool golangci-lint run ./...
```
---
## 3. Риски / питфоллы
1. 🔴 **Смена победителя в Balda — поведенческий риск, не косметика.** Сейчас в URL живёт
`ctx=<токен>` (`binding.Config.URL`, ставит context-broker). Если удержать `balda` (без токена),
session-memory/context может перестать доставляться. **Перед реализацией (b/c) — проверить,
как композируется `binding.Config.URL` и нужен ли токен в URL вообще.**
Развилка: (i) токен не нужен → lexicographic-победитель; (ii) токен нужен → явное
`DedupPreferred` на scoped-конфиге. **Решение за Alex.**
2. ⚠️ Правка `agentconfig.MCPServerConfig` = **публичный API** модуля. По `AGENTS.md`: сохранять
публичные API, изменения документировать в `README.md`. `DedupKey string` — аддитивно, но
документировать обязательно.
3. ⚠️ Правки делать **только дельта-апплаером** (`tools/run-project-deltas.sh`, `check` → `apply`),
не sed/in-place (правило Alex).
4. 🔴 **Ветка PR #5 ≠ текущая локальная ветка.** Сейчас checked out `feat/openai-tool-calls-v2`
(PR #4). Нужен `git switch fix/hosted-toolsets-duplicate-endpoint` **до** правок; `git status`
чистый (untracked `tools/` — не коммитить).
5. ⚠️ **PR #4 и PR #5 оба открыты и оба форк-PR** → CI не запускается без одобрения мейнтейнера
(см. §0в в [[personal/plans/runtime-pr4-openai-toolcalls-fixes]]). Локальные гейты — эквивалент CI.
6. ⚠️ Ревьюер — **metalagman**. Ревью опубликовано в PR (в отличие от PR #4, где работали по тексту
Alex). Ответ ревьюеру писать в PR.
---
## 4. Порядок (после одобрения)
1. ⛔ **Ожидание решения Alex по §3.1** (нужен ли `ctx`-токен в URL удержанного тулсета)
2. ⛔ Ожидание решения по §2-Фикс1(a): `DedupKey` vs `Aliases`
3. `git switch fix/hosted-toolsets-duplicate-endpoint`; бэкап-коммит текущего состояния (проверить, что нет своих правок)
4. `agentconfig`: добавить поле (+ документировать в `README.md`)
5. `agentfactory`: убрать `mcpEndpointKey`/`mcpURLIsScoped`; схлопывание по явному ключу; детерминированный победитель; тестовый хук транспорта
6. `balda`: проставить `DedupKey` в composition layer (`runtime_manager.go:344`)
7. Переписать тесты: удалить `selectScopedOnly`, добавить 6 контрактных; фикс сигнатуры
8. Гейты (§2) — все зелёные
9. Коммит (Conventional Commits) → push → `gh pr checks 5`
10. Ответ ревьюеру в PR + обновить Obsidian (этот файл + [[personal/tech/adk-hostedagent-openai-mcp]])
## Связанные заметки
- [[personal/plans/runtime-pr4-openai-toolcalls-fixes]] — PR #4, контекст форка и fork-CI-блокера
- [[personal/tech/adk-hostedagent-openai-mcp]] — архитектура MCP/toolset, `runtime.mcp_servers`
- [[personal/tech/balda-go-fx-pitfalls]] — метод дельт, питфоллы