Skip to content

fix(agent): name the callable alias when a bridged tool call misses - #3920

Open
probepark wants to merge 2 commits into
Yeachan-Heo:devfrom
probepark:fix/issue-3917-tool-not-found-alias
Open

fix(agent): name the callable alias when a bridged tool call misses#3920
probepark wants to merge 2 commits into
Yeachan-Heo:devfrom
probepark:fix/issue-3917-tool-not-found-alias

Conversation

@probepark

Copy link
Copy Markdown
Contributor

Fixes #3917.

Problem

MCP bridges expose tools as mcp__<server>_<tool>, and proxied bridges add a per-session instance segment: mcp__<server>__<instance>_<tool>. Dispatch is exact-match on name / customWireName, so a name the model replayed from earlier context resolves to nothing even when the tool is active under a sibling name. Tool <name> not found named neither the live alias nor a way to find it.

Captured five times across three sessions today (019fd580, 019fd583, 019fd595), including two calls burned in a single turn:

Called Active
mcp__jzi2uzmxd57z__wbg7pcrl46bd_search search
mcp__jzi2uzmxd57z__2dl71hzhrahf_find find
mcp__jzi2uzmxd57z__jgspauo3hmi5_subagent mcp__jzi2uzmxd57z__gbbgnmhc3qkt_subagent

The recovery hint had the mirror-image defect: hasToolDiscovery was gated on a tool callable as exactly search_tool_bm25, and the hint text hardcoded that literal. A bridged discovery tool therefore dropped the hint entirely — and emitting it unconditionally would have been worse, naming a second non-callable tool.

Change

packages/agent/src/agent-loop.ts

  • parseToolCallName splits the bridge namespace (mcp__<server>__<instance>_) off a call name.
  • findToolCallNameAliases names active tools that denote the same tool. A namespaced call is matched only against its own server or against an unnamespaced tool — two servers can expose the same tool name, and routing the model at the wrong server is worse than the dead end.
  • findToolDiscoveryCallName resolves how discovery is actually callable, preferring the unbridged name.
  • toolMatchesCallName is replaced by toolCallNames, which the new helpers share; it had no other caller.

Tool <name> not found remains the byte-stable prefix.

Verification

bun test packages/agent/test — 689 pass, 0 fail. Seven new cases in agent-loop-tool-not-found-red-team.test.ts cover the bridged alias, a stale instance segment, customWireName reachability, cross-server refusal, absence of a false suggestion, the bridged discovery hint, and the unbridged-discovery preference. bun --cwd=packages/agent run check clean.

Note

dev is currently red independently of this PR: packages/coding-agent/test/agent-session-abort-timeout.test.ts fails since 4e33420 (#3894). Tracked and fixed separately.

@yazzang-homelab yazzang-homelab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed as independent architect review (read-only, no push access).

Verified:

  • parseToolCallName degrades safely: a server name containing _ simply fails to match and yields no alias, so the worst case is the pre-existing dead end, never a wrong route.
  • Cross-server refusal and the customWireName path are both covered by red-team cases.
  • Tool <name> not found prefix stays byte-stable; hint text is appended only.
  • Head contains current dev (b621997), all 14 checks green.

Nit (non-blocking): the alias limit of 3 is silent — if more than 3 bridged siblings match, the model sees a truncated set with no marker. Fine for the observed failure shape.

gajae.pr-review-verdict.v1 merge-approved sha256:512c666f0c82cfe264f0908036a10c56296193bc reviewer:architect evidence:https://github.com/Yeachan-Heo/gajae-code/pull/3920/checks

@yazzang-homelab

yazzang-homelab commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

이 코멘트를 철회한다. 원문은 틀렸다.

원문에서 "exact-head CI 게이트 때문에 머지 전 현재 dev로 리베이스가 필요하고, 리베이스로 head sha가 바뀌면 위 verdict가 무효가 된다"고 했다. 둘 다 사실이 아니다.

확인한 것:

  • dev에 브랜치 보호가 없다 (GET /branches/dev/protection → 404, protected: false, 룰셋 0개). 머지 전 브랜치 최신화를 강제하는 설정이 하나도 없다.
  • dev-ci.yml의 트리거는 pull_request: branches: [dev]이고 기본 타입은 opened/synchronize/reopened다. dev가 움직여도 재실행되지 않는다. exact-head 게이트(git merge-base --is-ancestor "$GITHUB_BASE_SHA" HEAD)는 이 PR의 head가 푸시될 때 그 시점 base로 이미 통과했고, 그 결과는 그대로 유효하다.

따라서 이 PR은 지금 head 그대로 머지 가능하며, 리베이스도 force-push도 필요 없다. 위쪽 리뷰의 verdict 블록은 그 head sha에 대해 계속 유효하다.

내가 stale한 origin/dev 참조를 잡은 뒤, 거기서 "리베이스가 필요하다"는 결론까지 검증 없이 밀고 나간 게 원인이다. 없는 요구사항을 만들어서 불필요한 force-push를 권했다 — 내가 같은 보고서에서 문제라고 지적한 churn을 내가 시킨 꼴이다. 미안하다.

(참고: 이 오진의 뿌리였던 병합 판정 불일치는 #3929로 따로 정리했다. .gitattributesmerge=union을 GitHub가 적용하지 않아 생기는 문제이고, union 줄 제거로 대응한다.)

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Batch review — gaebal-gajae review lane

VERDICT: MERGE_READY

gajae.pr-review-verdict.v1 merge-approved sha256:9b631a4a77134022cb624ba938098060349cc1bacbeeed3d389ebd249d923b4c reviewer:architect evidence:31088003291

Reviewed head: 512c666f0c82cfe264f0908036a10c56296193bc (current GitHub head at review time). Note: the briefed head 52706ae87d2a01a2a436217bedb186f613c906e6 had already drifted when the lane fetched; this review targets the true current head. No merge performed by this lane — an independent GJC terminal review is recorded here per repository workflow; merge remains the owner's action.

Maintainer constraint verification (issue #3917 intake)

  1. Prefix preservation — PASS. The base Tool <name> not found string is byte-unchanged (packages/agent/src/agent-loop.ts:2764); hints append after ${base}. only when a live alias or discovery name exists. Red-team tests assert the exact substring (expectBaseNotFound).
  2. Fail-closed aliases / cross-server names — PASS. findToolCallNameAliases matches on the parsed base name and never suggests a different server's tool when the call names a server (parseToolCallName server-scope guard); no alias is invented when no active tool shares the base (tests: does not cross servers when suggesting an alias, does not invent an alias when no active tool shares the base name).
  3. No capability/authority bypass — PASS. Tool dispatch is unchanged (exact match on name / customWireName); the change only enriches the not-found error message. No tool is routed or executed under a different identity, and no permission gate is touched.
  4. Live callable discovery suggestions — PASS. Aliases and the discovery call name are sourced exclusively from the active tools registry; findToolDiscoveryCallName prefers the exact search_tool_bm25 and otherwise resolves the bridged alias — it never emits the bare literal when discovery is bridged (tests: points at the bridged discovery call name, prefers the unbridged discovery name when both are callable).

Findings

  • CRITICAL: 0 · HIGH: 0 · MEDIUM: 0.
  • LOW (advisory, non-blocking):
    • parseToolCallName's regex assumes the bridge server/instance segments contain no _; a server id containing underscores or a literal non-bridged tool named mcp__<x>_<y> would misparse. Observed shapes degrade to no-suggestion (fail-closed), never to a wrong routing suggestion.
    • An unnamespaced call (e.g. foo) with multiple same-base bridged candidates across servers lists up to 3 aliases joined with "or". This is suggestion-only — nothing is routed on ambiguity.
  • Scope complete: Tool <name> not found has exactly one emission site (agent-loop.ts:2764), which this PR covers. CHANGELOG entry under ## [Unreleased] / ### Fixed follows repo convention.

Tests / CI

  • Local at exact head (isolated worktree /tmp/review-3920-exact, natives built at 0.12.12): agent-loop-tool-not-found-red-team.test.ts — 14 pass / 0 fail (57 expects); agent-loop.test.ts — 36 pass / 0 fail (162 expects).
  • CI at current head: 14/14 SUCCESS, 0 FAIL (run 31088003291), including the exact targeted test file test:packages/agent/test/agent-loop-tool-not-found-red-team.test.ts and the gjc-state-gates aggregates.

Disposition

  • Issue #3917: accepted intake; maintainer-defined scope is exactly the four constraints above; this batch lane is review owner. PR #3920 fully covers the issue; the issue is linked and its disposition is recorded in a separate signed comment.


[repo owner's gaebal-gajae (clawdbot) 🦞]

MCP bridges expose tools as `mcp__<server>__<instance>_<tool>` and mint the
instance segment per session, so a name the model replayed from earlier context
resolves to nothing while the tool is active under a sibling name. Dispatch is
exact-match, and the not-found error named neither the live alias nor a way to
find it -- captured sessions show the same turn burning two tool slots on it.

The discovery hint had the mirror-image defect: it was gated on, and hardcoded,
the bare `search_tool_bm25` literal, so a bridged discovery tool dropped the
hint entirely rather than naming how discovery is actually callable.

Lore-id: 8f2b41c9
Constraint: `Tool <name> not found` stays byte-stable -- downstream consumers
  match on that prefix
Rejected: fuzzy/edit-distance matching | silently routes the model at a
  different tool when it merely typo'd a name
Rejected: match on base name across servers | two servers can expose the same
  tool name, and the wrong server's tool is worse than the dead end
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: bun test packages/agent/test (689 pass), 7 new red-team cases covering
  bridged aliases, stale instance segments, customWireName, cross-server
  refusal, and bridged discovery
@Yeachan-Heo
Yeachan-Heo force-pushed the fix/issue-3917-tool-not-found-alias branch from 512c666 to 376400f Compare August 6, 2026 11:29
@yazzang-homelab

Copy link
Copy Markdown
Contributor

경고 — 이 PR의 현재 head가 CHANGELOG 전체를 삭제한다

머지하면 안 된다. 확인된 사실:

$ git cat-file -s <이 PR head>:<해당 CHANGELOG 경로>
1

1바이트 — 개행 하나만 남았다. dev의 같은 파일은 312,259 bytes(coding-agent) / 244,785 bytes(ai) / 45,275 bytes(agent)다. 릴리스 이력 전체가 사라진 상태다.

원인은 내 쪽이다

#3932(11:25:32Z 머지)가 .gitattributes에서 packages/*/CHANGELOG.md merge=union을 제거했다. 제거 자체는 근거가 있었다 — union은 충돌을 내지 않고 양쪽을 이어붙여서 이미 릴리스된 섹션에 항목을 조용히 밀어넣고 있었다(#3929, 실측 35건).

그런데 그 결과 리베이스에서 CHANGELOG가 처음으로 진짜 충돌을 내기 시작했고, 그 충돌을 해소하는 과정에서 파일이 비워졌다. 시간대가 명확하다:

시각 (UTC) 사건
11:25:32 #3932 머지 (union 제거)
11:29:29 ~ 11:35:02 #3920 #3697 #3870 #3908 #3887 #3864 #3729 #3869 #3866 #3873작성자 6명, 10개 PR이 전부 1바이트 CHANGELOG로 갱신됨

전환 비용을 예고하지 못한 건 내 잘못이다. 미안하다.

복구

git fetch origin
git checkout origin/dev -- packages/coding-agent/CHANGELOG.md   # 해당 패키지 경로로
# 그 다음 ## [Unreleased] 아래에 이 PR의 항목만 다시 추가
git add packages/coding-agent/CHANGELOG.md
git commit --amend --no-edit    # 또는 새 커밋

앞으로 리베이스에서 CHANGELOG 충돌이 나면 양쪽 항목을 모두 ## [Unreleased] 아래에 남기는 것이 올바른 해소다. 이미 릴리스된 ## [X.Y.Z] 섹션은 손대지 않는다. CONTRIBUTING.md의 "Rebasing onto dev" 절에 적어두었다.

푸시 전에 다음으로 자가 점검할 수 있다:

git cat-file -s HEAD:packages/coding-agent/CHANGELOG.md   # 30만 바이트 근처여야 정상

@yazzang-homelab yazzang-homelab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앞선 내 승인을 철회한다. 이 head는 머지하면 안 된다.

내가 승인한 시점 이후 head가 force-push됐고, 새 head에서 CHANGELOG가 파괴됐다. 승인은 그 이전 커밋에 대한 것이었으므로 현재 head에는 유효하지 않다.

코드 리뷰 내용 자체는 그대로 유효하다 — 로직에 대한 판단은 바뀌지 않았다. 되돌리는 것은 머지 가능 판정뿐이다.

원인은 내가 머지한 #3932다(union 드라이버 제거). 상세와 복구 명령은 이 PR에 이미 남긴 코멘트와 #3942 에 있다. CHANGELOG를 origin/dev에서 복원하고 이 PR의 항목만 다시 넣은 뒤 푸시하면, 그 head에 대해 즉시 재승인하겠다.

#3941(CI 가드)이 머지되면 이 상태는 CI에서 자동으로 걸린다.

@yazzang-homelab

Copy link
Copy Markdown
Contributor

@Yeachan-Heo 이 PR은 APPROVED 상태인데 현재 head에서 CHANGELOG가 파괴돼 있다. 지금 머지 버튼을 누르면 릴리스 이력이 날아간다.

  • 승인은 512c666f0에 대한 것이었다.
  • 현재 head는 376400f80이고, packages/agent/CHANGELOG.md1바이트다 (dev: 45,275).
$ git cat-file -s 376400f80:packages/agent/CHANGELOG.md
1

GitHub의 reviewDecision은 여전히 APPROVED로 보인다 — 승인 이후의 push가 승인을 자동 해제하지 않기 때문이다. 이 저장소에는 브랜치 보호가 없어서 "Dismiss stale approvals" 설정도 걸려 있지 않다.

원인은 내가 머지한 #3932다. #3942 에 전말을 정리했고, #3941 로 CI 가드를 올렸다. 12건이 같은 상태이며 그중 6건이 APPROVED 리뷰를 달고 있다.

복구:

git checkout origin/dev -- packages/agent/CHANGELOG.md

Yeachan-Heo pushed a commit that referenced this pull request Aug 6, 2026
Removing `packages/*/CHANGELOG.md merge=union` in #3932 was correct --
union never conflicts, it concatenates both sides of an overlapping hunk,
which silently filed entries into versions that had already shipped (35
such entries audited on dev, #3929). What it did not account for is the
transition: these files now conflict on rebase for the first time, and a
bad resolution drops the whole history with no marker.

That is not hypothetical. #3932 merged at 11:25:32Z. Between 11:29:29Z
and 11:35:02Z, ten open pull requests across six authors force-pushed
heads whose CHANGELOG was a single newline -- every released section
gone. #3920 #3697 #3870 #3908 #3887 #3864 #3729 #3869 #3866 #3873.
Nothing caught it: the files still parse, no test reads them, and the
loss looks like a large deletion inside an otherwise legitimate diff.

The guard asserts the one property that matters and nothing more: every
`## [X.Y.Z]` heading present at the merge base must still be present at
the head. Additions pass, rewording passes, and a release commit that
consumes `## [Unreleased]` into a new version passes. Only losing a
released section fails, and the message names the recovery command.

Runs in `affected-plan`, which already checks out full history and
carries the immutable event base sha, so it costs one bun invocation and
needs no new job.

Constraint: a release bump must still be able to add a version heading
Constraint: must not depend on byte-size heuristics -- a legitimately
  small changelog is not a violation
Rejected: threshold on deleted line count | fires on large legitimate
  edits and misses a small changelog emptied completely
Rejected: restore merge=union | reinstates the silent misfiling this
  replaced, and GitHub ignores the driver anyway
Confidence: high
Scope-risk: narrow
Reversibility: trivial
Tested: bun test scripts/changelog-history-guard.test.ts (11 pass);
  guard run against the three real broken heads (#3873 #3920 #3869)
  exits 1 and names the lost sections; clean range exits 0;
  bun run check:tools exit 0
Not-tested: a real release-bump PR end to end
Yeachan-Heo pushed a commit that referenced this pull request Aug 6, 2026
Removing `packages/*/CHANGELOG.md merge=union` in #3932 was correct --
union never conflicts, it concatenates both sides of an overlapping hunk,
which silently filed entries into versions that had already shipped (35
such entries audited on dev, #3929). What it did not account for is the
transition: these files now conflict on rebase for the first time, and a
bad resolution drops the whole history with no marker.

That is not hypothetical. #3932 merged at 11:25:32Z. Between 11:29:29Z
and 11:35:02Z, ten open pull requests across six authors force-pushed
heads whose CHANGELOG was a single newline -- every released section
gone. #3920 #3697 #3870 #3908 #3887 #3864 #3729 #3869 #3866 #3873.
Nothing caught it: the files still parse, no test reads them, and the
loss looks like a large deletion inside an otherwise legitimate diff.

The guard asserts the one property that matters and nothing more: every
`## [X.Y.Z]` heading present at the merge base must still be present at
the head. Additions pass, rewording passes, and a release commit that
consumes `## [Unreleased]` into a new version passes. Only losing a
released section fails, and the message names the recovery command.

Runs in `affected-plan`, which already checks out full history and
carries the immutable event base sha, so it costs one bun invocation and
needs no new job.

Constraint: a release bump must still be able to add a version heading
Constraint: must not depend on byte-size heuristics -- a legitimately
  small changelog is not a violation
Rejected: threshold on deleted line count | fires on large legitimate
  edits and misses a small changelog emptied completely
Rejected: restore merge=union | reinstates the silent misfiling this
  replaced, and GitHub ignores the driver anyway
Confidence: high
Scope-risk: narrow
Reversibility: trivial
Tested: bun test scripts/changelog-history-guard.test.ts (11 pass);
  guard run against the three real broken heads (#3873 #3920 #3869)
  exits 1 and names the lost sections; clean range exits 0;
  bun run check:tools exit 0
Not-tested: a real release-bump PR end to end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants