{"record":{"id":"0952cbbdcdff1728","repo":"vercel/ai","slug":"host-tool-body-toolname-was-invoked-from-stale","errorCode":null,"errorMessage":"Host tool ${body.toolName} was invoked from stale catalog revision ${body.catalogRevision}; the active revision is ${state.revision}.","messagePattern":"Host tool (.+?) was invoked from stale catalog revision (.+?); the active revision is (.+?)\\.","errorType":"http","errorClass":"RelayRequestError","httpStatus":409,"severity":"error","filePath":"packages/harness-acp/src/v1/bridge/host-tool-relay.ts","lineNumber":290,"sourceCode":"    throw new RelayRequestError({\n      status: 409,\n      message: 'No ACP prompt turn is active.',\n    });\n  }\n  if (\n    !isRecord(body) ||\n    typeof body.requestId !== 'string' ||\n    typeof body.toolName !== 'string' ||\n    !isRecord(body.input) ||\n    !Number.isSafeInteger(body.catalogRevision)\n  ) {\n    throw new RelayRequestError({\n      status: 400,\n      message: 'Invalid host tool relay request.',\n    });\n  }\n  if (body.catalogRevision !== state.revision) {\n    throw new RelayRequestError({\n      status: 409,\n      message:\n        `Host tool ${body.toolName} was invoked from stale catalog revision ` +\n        `${body.catalogRevision}; the active revision is ${state.revision}.`,\n    });\n  }\n  const tool = state.tools.find(item => item.name === body.toolName);\n  if (tool == null) {\n    throw new RelayRequestError({\n      status: 404,\n      message:\n        `Host tool ${body.toolName} is not active in catalog revision ` +\n        `${state.revision}.`,\n    });\n  }\n\n  const correlationToken = randomBytes(32).toString('hex');\n  turn.registerCorrelationInvocation({","sourceCodeStart":272,"sourceCodeEnd":308,"githubUrl":"https://github.com/vercel/ai/blob/69428b1f8b037e4d118fb4853428d5c4e620493c/packages/harness-acp/src/v1/bridge/host-tool-relay.ts#L272-L308","documentation":"Host tool invocations are pinned to a catalog revision: the catalogRevision sent in the /invoke body must equal the relay's current state.revision. If the tool catalog changed (updateCatalog bumped the revision) since the client last fetched it, the relay rejects with this 409 so a stale tool definition is never executed.","triggerScenarios":"The host updated its tool list (updateCatalog produced changed: true, revision+1) while the MCP client still holds the old catalog and invokes with the old catalogRevision; a slow/parked client resumes mid-turn after a tool re-registration; multiple clients with out-of-sync revisions.","commonSituations":"Dynamic tool registration/removal during an active session; agent process restarted tools mid-conversation; client long-poll lag — it invoked before polling /catalog/next for the new revision; ignoring the waitForCatalogRefresh signal.","solutions":["Re-poll /catalog/next to obtain the new revision and tool list, then retry the invocation with the fresh catalogRevision.","After updateCatalog changes the revision, wait for waitForCatalogRefresh (clients ack via /catalog/seen) before dispatching tool calls.","Implement automatic retry-on-409: refresh catalog, look up the tool by name, re-invoke once.","Avoid changing the tool set mid-turn; batch tool updates between turns."],"exampleFix":"// before\nawait post(invokeUrl, { requestId, toolName, input, catalogRevision: cachedRev });\n// after\nlet res = await post(invokeUrl, { requestId, toolName, input, catalogRevision: cachedRev });\nif (res.status === 409) {\n  const catalog = await pollCatalog(cachedRev); // GET new revision via /catalog/next\n  cachedRev = catalog.revision;\n  res = await post(invokeUrl, { requestId, toolName, input, catalogRevision: cachedRev });\n}","handlingStrategy":"retry","validationCode":"// Check freshness before invoking\nasync function ensureFreshRevision(cached: number, poll: (after: number) => Promise<{ revision: number }>) {\n  const latest = await poll(cached);\n  if (latest.revision !== cached) {\n    throw new Error(`Catalog changed: cached ${cached}, current ${latest.revision}. Re-fetch tools before invoking.`);\n  }\n  return cached;\n}","typeGuard":null,"tryCatchPattern":"let res = await postJson(invokeUrl, { ...body, catalogRevision: cachedRev });\nif (res.status === 409 && /stale catalog revision/.test((await res.json()).error ?? '')) {\n  const next = await pollCatalog(cachedRev); // long-poll /catalog/next\n  cachedRev = next.revision;\n  res = await postJson(invokeUrl, { ...body, catalogRevision: cachedRev }); // retry once\n}","preventionTips":["Re-poll /catalog/next immediately after any tool-set change notification.","Use waitForCatalogRefresh / /catalog/seen handshake before resuming invocations after updates.","Avoid mutating the tool catalog mid-turn.","Track the revision returned with every catalog fetch and always send that exact value."],"tags":["relay","stale-state","concurrency","catalog","harness-acp"],"backgroundTag":"stale-cache-revision-conflict","analyzedSha":"69428b1f8b037e4d118fb4853428d5c4e620493c","analyzedAt":"2026-08-30T12:32:21.016Z","schemaVersion":2},"datasetVersion":"2026-08-30T13:17:10.514Z"}