[2026-09-28] eagle: personal/plans/runtime-pr5-toolset-dedup-review-fixes.md
This commit is contained in:
1 parent
f45b518d8f
commit
5f3bdf6bff
1 file changed
+183
@@ -0,0 +1,183 @@
|
|||||||
|
> Создан 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]] — метод дельт, питфоллы
|
||||||
Reference in new issue
Block a user