crewAIInc / crewAIInc/crewAI

[BUG] fetch_agent_card(use_cache=False) still returns a cached AgentCard

Open
#6,731 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

no-issue-activity
Dominant language
Python
Stars
58.8k
Forks
8.5k
Avg merge
1d 15h
Merged PRs (30d)
109

Description

Description

fetch_agent_card(endpoint, use_cache=False) still serves the AgentCard from a cache. The
explicit opt-out is dropped on the way to the implementation, so a caller who asks for a
fresh card can get one that is up to 5 minutes stale.

The uncached branch of the sync entry point delegates to the async entry point without
forwarding use_cache:

https://github.com/crewAIInc/crewAI/blob/ceed4a3ff71b5b4cb0ca316b4178ffcce74a53b2/lib/crewai/src/crewai/a2a/utils/agent_card.py#L143

def fetch_agent_card(
    endpoint: str,
    auth: ClientAuthScheme | None = None,
    timeout: int = 30,
    use_cache: bool = True,          # <-- caller passes False
    cache_ttl: int = 300,
) -> AgentCard:
    if use_cache:
        ...
        return _fetch_agent_card_cached(endpoint, auth_hash, timeout, ttl_hash)

    coro = afetch_agent_card(endpoint=endpoint, auth=auth, timeout=timeout)
    #                                                     ^ use_cache not forwarded

afetch_agent_card declares use_cache: bool = True, so the request re-enters the cached
branch and is answered by _afetch_agent_card_cached, which carries
@cached(ttl=300, serializer=PickleSerializer()). The use_cache=False argument is
silently reinterpreted as use_cache=True.

Reproduction

Patch the network layer with a counter that returns a distinguishable card on every real
fetch, then compare the three code paths:

import asyncio
from a2a.types import AgentCard
from crewai.a2a.utils import agent_card as m

calls = []

async def _impl(endpoint, auth, timeout):
    calls.append(endpoint)
    return AgentCard.model_validate({
        "name": f"probe-agent-{len(calls)}", "description": "probe",
        "url": "http://example.com/", "version": "1", "protocol_version": "0.3.0",
        "capabilities": {}, "default_input_modes": ["text/plain"],
        "default_output_modes": ["text/plain"], "skills": [],
    })

m._afetch_agent_card_impl = _impl

def probe(label, fn):
    calls.clear()
    a, b = fn()
    print(f"{label:24} -> real fetches: {len(calls)} | names: {a.name} {b.name}")

probe("sync  use_cache=False", lambda: (
    m.fetch_agent_card("http://a/.well-known/agent-card.json", use_cache=False),
    m.fetch_agent_card("http://a/.well-known/agent-card.json", use_cache=False)))
probe("sync  use_cache=True", lambda: (
    m.fetch_agent_card("http://b/.well-known/agent-card.json", use_cache=True),
    m.fetch_agent_card("http://b/.well-known/agent-card.json", use_cache=True)))
probe("async use_cache=False", lambda: asyncio.run(_two_async()))

Observed on main (ceed4a3):

path real fetches card names returned correct?
fetch_agent_card(..., use_cache=False) 1 probe-agent-1, probe-agent-1 no
fetch_agent_card(..., use_cache=True) 1 probe-agent-1, probe-agent-1 yes
await afetch_agent_card(..., use_cache=False) 2 probe-agent-1, probe-agent-2 yes

The third row is what makes this a bug rather than a design choice: the async entry point
already treats use_cache=False as meaningful, so the two entry points contradict each
other on the same argument. use_cache=False on the sync path is indistinguishable from
use_cache=True.

Expected behaviour

fetch_agent_card(..., use_cache=False) performs a real fetch on every call, matching
afetch_agent_card(..., use_cache=False).

Why this matters in normal use

Both in-tree call sites (lib/crewai/src/crewai/a2a/utils/wrapper.py:252 in
_fetch_card_from_config, and :1426 in _afetch_card_from_config) rely on the default,
so the defect is only reachable when a user explicitly asks for a fresh card — which is
exactly when staleness is the thing they were guarding against:

  • A remote agent's capabilities changed. An AgentCard advertises skills and
    capabilities. After a peer redeploys with a new skill set, a caller who passes
    use_cache=False to pick up the change keeps routing against the old card for the
    remainder of the 300-second TTL.
  • Auth rotation. _afetch_agent_card_cached is keyed on a hash of the auth scheme, so
    a rotated credential does produce a new key — but a caller that re-fetches with
    unchanged auth to confirm a peer is reachable gets the cached card and no request
    leaves the process, so a peer that has gone away looks healthy.
  • Tests and health checks. Any code that fetches twice with use_cache=False to
    observe a change cannot observe it.

The failure is silent: no exception, no warning, and the returned object is a
well-formed AgentCard — just the wrong one.

Fix

Forward the argument:

    coro = afetch_agent_card(
        endpoint=endpoint, auth=auth, timeout=timeout, use_cache=False
    )

I have a PR ready with this one-line change plus three regression tests (one for the bug,
two controls proving the cached path and the async path are unaffected).

Related, not fixed here

fetch_agent_card accepts cache_ttl: int = 300 and honours it — the sync cache derives
ttl_hash = int(time.time() // cache_ttl) as a cache-key component, which I confirmed
works (cache_ttl=1, two calls 1.1s apart → 2 real fetches). afetch_agent_card has no
cache_ttl parameter at all; its cache is a hardcoded @cached(ttl=300). So an async
caller cannot tune the TTL the way a sync caller can. Fixing that means reworking the
decorator rather than forwarding an argument, so it is a feature change and I have left it
out of the PR — flagging it here in case a maintainer wants it tracked.

Environment
  • crewAI main @ ceed4a3ff71b5b4cb0ca316b4178ffcce74a53b2
  • Python 3.12, a2a-sdk~=0.3.10

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in lib/crewai/src/crewai/a2a/utils/agent_card.py at the sync fetch_agent_card entry point around line 143, and compare its delegation with afetch_agent_card. Done means the sync use_cache=False path performs a real fetch on every call while the cached sync path and async path remain unchanged; the issue describes three regression tests to verify these cases.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, backend
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.