Skip to content

Commit 8742400

Browse files
Merge pull request #1382 from crewAIInc/tm-basic-event-structure
Add tool usage events
2 parents 7c1f88c + 02fe581 commit 8742400

File tree

4 files changed

+134
-6
lines changed

4 files changed

+134
-6
lines changed

src/crewai/tools/tool_usage.py

+49-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import ast
2+
import datetime
23
import os
4+
import time
35
from difflib import SequenceMatcher
46
from textwrap import dedent
57
from typing import Any, List, Union
@@ -8,7 +10,10 @@
810
from crewai.task import Task
911
from crewai.telemetry import Telemetry
1012
from crewai.tools.tool_calling import InstructorToolCalling, ToolCalling
13+
from crewai.tools.tool_usage_events import ToolUsageError, ToolUsageFinished
1114
from crewai.utilities import I18N, Converter, ConverterError, Printer
15+
import crewai.utilities.events as events
16+
1217

1318
agentops = None
1419
if os.environ.get("AGENTOPS_API_KEY"):
@@ -126,12 +131,16 @@ def _use(
126131
except Exception:
127132
self.task.increment_tools_errors()
128133

129-
result = None # type: ignore # Incompatible types in assignment (expression has type "None", variable has type "str")
134+
started_at = time.time()
135+
from_cache = False
130136

137+
result = None # type: ignore # Incompatible types in assignment (expression has type "None", variable has type "str")
138+
# check if cache is available
131139
if self.tools_handler.cache:
132140
result = self.tools_handler.cache.read( # type: ignore # Incompatible types in assignment (expression has type "str | None", variable has type "str")
133141
tool=calling.tool_name, input=calling.arguments
134142
)
143+
from_cache = result is not None
135144

136145
original_tool = next(
137146
(ot for ot in self.original_tools if ot.name == tool.name), None
@@ -163,6 +172,7 @@ def _use(
163172
else:
164173
result = tool.invoke(input={})
165174
except Exception as e:
175+
self.on_tool_error(tool=tool, tool_calling=calling, e=e)
166176
self._run_attempts += 1
167177
if self._run_attempts > self._max_parsing_attempts:
168178
self._telemetry.tool_usage_error(llm=self.function_calling_llm)
@@ -214,6 +224,13 @@ def _use(
214224
"tool_args": calling.arguments,
215225
}
216226

227+
self.on_tool_use_finished(
228+
tool=tool,
229+
tool_calling=calling,
230+
from_cache=from_cache,
231+
started_at=started_at,
232+
)
233+
217234
if (
218235
hasattr(original_tool, "result_as_answer")
219236
and original_tool.result_as_answer # type: ignore # Item "None" of "Any | None" has no attribute "cache_function"
@@ -431,3 +448,34 @@ def _validate_tool_input(self, tool_input: str) -> str:
431448
# Reconstruct the JSON string
432449
new_json_string = "{" + ", ".join(formatted_entries) + "}"
433450
return new_json_string
451+
452+
def on_tool_error(self, tool: Any, tool_calling: ToolCalling, e: Exception) -> None:
453+
event_data = self._prepare_event_data(tool, tool_calling)
454+
events.emit(
455+
source=self, event=ToolUsageError(**{**event_data, "error": str(e)})
456+
)
457+
458+
def on_tool_use_finished(
459+
self, tool: Any, tool_calling: ToolCalling, from_cache: bool, started_at: float
460+
) -> None:
461+
finished_at = time.time()
462+
event_data = self._prepare_event_data(tool, tool_calling)
463+
event_data.update(
464+
{
465+
"started_at": datetime.datetime.fromtimestamp(started_at),
466+
"finished_at": datetime.datetime.fromtimestamp(finished_at),
467+
"from_cache": from_cache,
468+
}
469+
)
470+
events.emit(source=self, event=ToolUsageFinished(**event_data))
471+
472+
def _prepare_event_data(self, tool: Any, tool_calling: ToolCalling) -> dict:
473+
return {
474+
"agent_key": self.agent.key,
475+
"agent_role": (self.agent._original_role or self.agent.role),
476+
"run_attempts": self._run_attempts,
477+
"delegations": self.task.delegations,
478+
"tool_name": tool.name,
479+
"tool_args": tool_calling.arguments,
480+
"tool_class": tool.__class__.__name__,
481+
}

src/crewai/tools/tool_usage_events.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Any, Dict
2+
from pydantic import BaseModel
3+
from datetime import datetime
4+
5+
6+
class ToolUsageEvent(BaseModel):
7+
agent_key: str
8+
agent_role: str
9+
tool_name: str
10+
tool_args: Dict[str, Any]
11+
tool_class: str
12+
run_attempts: int | None = None
13+
delegations: int | None = None
14+
15+
16+
class ToolUsageFinished(ToolUsageEvent):
17+
started_at: datetime
18+
finished_at: datetime
19+
from_cache: bool = False
20+
21+
22+
class ToolUsageError(ToolUsageEvent):
23+
error: str

src/crewai/utilities/events.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typing import Any, Callable, Generic, List, Dict, Type, TypeVar
2+
from functools import wraps
3+
from pydantic import BaseModel
4+
5+
6+
T = TypeVar("T")
7+
EVT = TypeVar("EVT", bound=BaseModel)
8+
9+
10+
class Emitter(Generic[T, EVT]):
11+
_listeners: Dict[Type[EVT], List[Callable]] = {}
12+
13+
def on(self, event_type: Type[EVT]):
14+
def decorator(func: Callable):
15+
@wraps(func)
16+
def wrapper(*args, **kwargs):
17+
return func(*args, **kwargs)
18+
19+
self._listeners.setdefault(event_type, []).append(wrapper)
20+
return wrapper
21+
22+
return decorator
23+
24+
def emit(self, source: T, event: EVT) -> None:
25+
event_type = type(event)
26+
for func in self._listeners.get(event_type, []):
27+
func(source, event)
28+
29+
30+
default_emitter = Emitter[Any, BaseModel]()
31+
32+
33+
def emit(source: Any, event: BaseModel, raise_on_error: bool = False) -> None:
34+
try:
35+
default_emitter.emit(source, event)
36+
except Exception as e:
37+
if raise_on_error:
38+
raise e
39+
else:
40+
print(f"Error emitting event: {e}")
41+
42+
43+
def on(event_type: Type[BaseModel]) -> Callable:
44+
return default_emitter.on(event_type)

tests/agent_test.py

+18-5
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
from crewai.agents.parser import CrewAgentParser, OutputParserException
1313
from crewai.tools.tool_calling import InstructorToolCalling
1414
from crewai.tools.tool_usage import ToolUsage
15+
from crewai.tools.tool_usage_events import ToolUsageFinished
1516
from crewai.utilities import RPMController
1617
from crewai_tools import tool
1718
from crewai.agents.parser import AgentAction
19+
from crewai.utilities.events import Emitter
1820

1921

2022
def test_agent_llm_creation_with_env_vars():
@@ -71,7 +73,7 @@ def test_agent_creation():
7173

7274
def test_agent_default_values():
7375
agent = Agent(role="test role", goal="test goal", backstory="test backstory")
74-
assert agent.llm.model == "gpt-4o"
76+
assert agent.llm.model == "gpt-4o-mini"
7577
assert agent.allow_delegation is False
7678

7779

@@ -178,8 +180,15 @@ def multiplier(first_number: int, second_number: int) -> float:
178180
agent=agent,
179181
expected_output="The result of the multiplication.",
180182
)
181-
output = agent.execute_task(task)
182-
assert output == "The result of the multiplication is 12."
183+
with patch.object(Emitter, "emit") as emit:
184+
output = agent.execute_task(task)
185+
assert output == "The result of the multiplication is 12."
186+
assert emit.call_count == 1
187+
args, _ = emit.call_args
188+
assert isinstance(args[1], ToolUsageFinished)
189+
assert not args[1].from_cache
190+
assert args[1].tool_name == "multiplier"
191+
assert args[1].tool_args == {"first_number": 3, "second_number": 4}
183192

184193

185194
@pytest.mark.vcr(filter_headers=["authorization"])
@@ -197,7 +206,7 @@ def multiplier(first_number: int, second_number: int) -> float:
197206
verbose=True,
198207
)
199208

200-
assert agent.llm.model == "gpt-4o"
209+
assert agent.llm.model == "gpt-4o-mini"
201210
assert agent.tools_handler.last_used_tool == {}
202211
task = Task(
203212
description="What is 3 times 4?",
@@ -267,7 +276,7 @@ def multiplier(first_number: int, second_number: int) -> float:
267276
"multiplier-{'first_number': 12, 'second_number': 3}": 36,
268277
}
269278

270-
with patch.object(CacheHandler, "read") as read:
279+
with patch.object(CacheHandler, "read") as read, patch.object(Emitter, "emit") as emit:
271280
read.return_value = "0"
272281
task = Task(
273282
description="What is 2 times 6? Ignore correctness and just return the result of the multiplication tool, you must use the tool.",
@@ -279,6 +288,10 @@ def multiplier(first_number: int, second_number: int) -> float:
279288
read.assert_called_with(
280289
tool="multiplier", input={"first_number": 2, "second_number": 6}
281290
)
291+
assert emit.call_count == 1
292+
args, _ = emit.call_args
293+
assert isinstance(args[1], ToolUsageFinished)
294+
assert args[1].from_cache
282295

283296

284297
@pytest.mark.vcr(filter_headers=["authorization"])

0 commit comments

Comments
 (0)