ba5ae13cc1
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
264 lines
9.3 KiB
Python
264 lines
9.3 KiB
Python
# mypy: ignore-errors
|
|
"""Regression tests for EPD-180: tool-result caching used to be ON by default,
|
|
so an LLM calling the same tool with identical arguments twice in one run got
|
|
the first (possibly stale) result back without the tool executing — silently
|
|
wrong for live-data tools, and silently dropped actions for stateful tools.
|
|
|
|
Caching is now opt-in: ``Crew(cache=True)`` for crews, ``Agent(cache=True)``
|
|
(or an explicit ``cache_handler``) for standalone agents. The machinery —
|
|
including per-tool ``cache_function`` write gating — is unchanged once opted
|
|
in.
|
|
|
|
The end-to-end tests run fully offline: a fake OpenAI client scripts two
|
|
identical tool calls followed by a final answer, mirroring the EPD-180
|
|
clean-room repro.
|
|
"""
|
|
|
|
from openai.types.chat import ChatCompletion
|
|
from pydantic import BaseModel, Field
|
|
|
|
from crewai import LLM, Agent, Crew, Task
|
|
from crewai.agents.cache.cache_handler import CacheHandler
|
|
from crewai.tools import BaseTool
|
|
|
|
|
|
class LookupArgs(BaseModel):
|
|
city: str = Field(description="City to look up.")
|
|
|
|
|
|
def make_live_tool():
|
|
"""A tool returning a different value on every real execution."""
|
|
executions = []
|
|
|
|
class LiveLookupTool(BaseTool):
|
|
name: str = "live_lookup"
|
|
description: str = "Returns a live (time-varying) reading for a city."
|
|
args_schema: type[BaseModel] = LookupArgs
|
|
# cache_function deliberately NOT set — exercising the default.
|
|
|
|
def _run(self, city: str) -> str:
|
|
executions.append(city)
|
|
return f"reading #{len(executions)} for {city}"
|
|
|
|
return LiveLookupTool(), executions
|
|
|
|
|
|
def make_scripted_llm():
|
|
"""An offline LLM whose client scripts two identical tool calls."""
|
|
|
|
def tool_call_response(call_id: str):
|
|
return {
|
|
"index": 0,
|
|
"finish_reason": "tool_calls",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": call_id,
|
|
"type": "function",
|
|
"function": {
|
|
"name": "live_lookup",
|
|
"arguments": '{"city": "paris"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
scripted = [
|
|
tool_call_response("call_1"),
|
|
tool_call_response("call_2"), # identical name+args, new id
|
|
{
|
|
"index": 0,
|
|
"finish_reason": "stop",
|
|
"message": {"role": "assistant", "content": "Final answer: done."},
|
|
},
|
|
]
|
|
|
|
class FakeCompletions:
|
|
def __init__(self):
|
|
self.n = 0
|
|
|
|
def create(self, **params):
|
|
choice = scripted[min(self.n, len(scripted) - 1)]
|
|
self.n += 1
|
|
return ChatCompletion.model_validate(
|
|
{
|
|
"id": f"chatcmpl-fake-{self.n}",
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": params.get("model", "gpt-4o"),
|
|
"choices": [choice],
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 5,
|
|
"total_tokens": 15,
|
|
},
|
|
}
|
|
)
|
|
|
|
class FakeClient:
|
|
def __init__(self):
|
|
self.chat = type("Chat", (), {"completions": FakeCompletions()})()
|
|
|
|
llm = LLM(model="openai/gpt-4o")
|
|
llm._client = FakeClient()
|
|
return llm
|
|
|
|
|
|
def run_crew(**crew_kwargs):
|
|
tool, executions = make_live_tool()
|
|
agent = Agent(
|
|
role="reader",
|
|
goal="Look things up.",
|
|
backstory="Test agent.",
|
|
llm=make_scripted_llm(),
|
|
tools=[tool],
|
|
verbose=False,
|
|
)
|
|
task = Task(
|
|
description="Look up paris twice and report.",
|
|
expected_output="A report.",
|
|
agent=agent,
|
|
)
|
|
crew = Crew(agents=[agent], tasks=[task], verbose=False, **crew_kwargs)
|
|
crew.kickoff()
|
|
return executions
|
|
|
|
|
|
class TestToolCachingIsOptIn:
|
|
def test_default_reexecutes_identical_tool_calls(self):
|
|
"""EPD-180: with no opt-in, both identical calls must really execute."""
|
|
executions = run_crew()
|
|
assert len(executions) == 2
|
|
|
|
def test_crew_cache_true_dedupes_identical_tool_calls(self):
|
|
"""Opting in via Crew(cache=True) restores the dedup behavior."""
|
|
executions = run_crew(cache=True)
|
|
assert len(executions) == 1
|
|
|
|
|
|
class TestAgentCacheWiring:
|
|
def _agent(self, **kwargs) -> Agent:
|
|
return Agent(
|
|
role="reader",
|
|
goal="Look things up.",
|
|
backstory="Test agent.",
|
|
**kwargs,
|
|
)
|
|
|
|
def test_standalone_agent_has_no_cache_by_default(self):
|
|
agent = self._agent()
|
|
assert agent.tools_handler.cache is None
|
|
assert agent.cache_handler is None
|
|
|
|
def test_standalone_agent_explicit_cache_true_opts_in(self):
|
|
agent = self._agent(cache=True)
|
|
assert agent.tools_handler.cache is not None
|
|
assert agent.cache_handler is not None
|
|
|
|
def test_standalone_agent_explicit_cache_handler_opts_in(self):
|
|
handler = CacheHandler()
|
|
agent = self._agent(cache_handler=handler)
|
|
assert agent.tools_handler.cache is handler
|
|
|
|
def test_explicit_cache_false_stays_off_even_with_handler(self):
|
|
agent = self._agent(cache=False, cache_handler=CacheHandler())
|
|
assert agent.tools_handler.cache is None
|
|
|
|
def test_agents_accept_a_crew_offered_handler_by_default(self):
|
|
"""``Crew(cache=True)`` offers its handler via set_cache_handler at
|
|
kickoff; agents that didn't explicitly opt out must accept it."""
|
|
agent = self._agent()
|
|
assert agent.tools_handler.cache is None
|
|
|
|
handler = CacheHandler()
|
|
agent.set_cache_handler(handler)
|
|
assert agent.tools_handler.cache is handler
|
|
|
|
def test_agents_that_opted_out_refuse_a_crew_offered_handler(self):
|
|
agent = self._agent(cache=False)
|
|
agent.set_cache_handler(CacheHandler())
|
|
assert agent.tools_handler.cache is None
|
|
|
|
def test_copy_of_default_agent_does_not_opt_in(self):
|
|
"""copy() rebuilds from model_dump(), which includes the field
|
|
default cache=True — that must not read as an explicit opt-in on
|
|
the copy (Bugbot review finding on the original PR)."""
|
|
copied = self._agent().copy()
|
|
assert copied.tools_handler.cache is None
|
|
assert copied.cache_handler is None
|
|
|
|
def test_copy_of_opted_in_agent_stays_opted_in(self):
|
|
copied = self._agent(cache=True).copy()
|
|
assert copied.tools_handler.cache is not None
|
|
|
|
def test_copy_of_handler_opted_in_agent_stays_opted_in(self):
|
|
"""An explicit cache_handler is an opt-in too; copy() excludes the
|
|
handler itself, but the consent must survive — the copy wires its
|
|
own fresh handler (Bugbot review finding on the original PR)."""
|
|
source = self._agent(cache_handler=CacheHandler())
|
|
copied = source.copy()
|
|
assert copied.tools_handler.cache is not None
|
|
assert copied.tools_handler.cache is not source.tools_handler.cache
|
|
|
|
def test_copy_of_explicit_cache_false_with_handler_stays_off(self):
|
|
copied = self._agent(cache=False, cache_handler=CacheHandler()).copy()
|
|
assert copied.tools_handler.cache is None
|
|
|
|
def test_copy_of_crew_wired_agent_does_not_opt_in(self):
|
|
"""A handler offered by a crew at kickoff (set_cache_handler) is
|
|
runtime wiring, not construction-time consent — copies of such
|
|
agents must not become standalone cachers (Bugbot review finding
|
|
on the original PR)."""
|
|
agent = self._agent()
|
|
agent.set_cache_handler(CacheHandler()) # what Crew(cache=True) does
|
|
assert agent.tools_handler.cache is not None
|
|
|
|
copied = agent.copy()
|
|
assert copied.tools_handler.cache is None
|
|
assert copied.cache_handler is None
|
|
|
|
|
|
class TestHierarchicalManagerCacheWiring:
|
|
"""The auto-created hierarchical manager is built outside the agents
|
|
loop that offers the crew's cache handler; an opted-in crew must wire
|
|
the manager too (Bugbot review finding on the original PR)."""
|
|
|
|
def _crew(self, **crew_kwargs) -> Crew:
|
|
from crewai.process import Process
|
|
|
|
agent = Agent(role="worker", goal="Do work.", backstory="Test agent.")
|
|
task = Task(description="Do the work.", expected_output="Done.")
|
|
return Crew(
|
|
agents=[agent],
|
|
tasks=[task],
|
|
process=Process.hierarchical,
|
|
manager_llm="gpt-4o",
|
|
**crew_kwargs,
|
|
)
|
|
|
|
def test_manager_gets_crew_handler_when_cache_enabled(self):
|
|
crew = self._crew(cache=True)
|
|
crew._create_manager_agent()
|
|
assert crew.manager_agent.tools_handler.cache is crew._cache_handler
|
|
|
|
def test_manager_has_no_cache_when_crew_did_not_opt_in(self):
|
|
crew = self._crew()
|
|
crew._create_manager_agent()
|
|
assert crew.manager_agent.tools_handler.cache is None
|
|
|
|
def test_user_provided_manager_with_cache_false_stays_excluded(self):
|
|
manager = Agent(
|
|
role="manager",
|
|
goal="Manage.",
|
|
backstory="Test manager.",
|
|
cache=False,
|
|
allow_delegation=True,
|
|
)
|
|
crew = self._crew(cache=True)
|
|
crew.manager_agent = manager
|
|
crew._create_manager_agent()
|
|
assert manager.tools_handler.cache is None
|