mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-27 09:45:16 +00:00
* feat: emit skill usage events at runtime * test: cover skill events via execute_task paths
212 lines
7.3 KiB
Python
212 lines
7.3 KiB
Python
"""Tests for runtime skill usage events.
|
|
|
|
Discovery/load/activation events cover setup. `SkillUsedEvent` is the runtime
|
|
signal: it fires whenever a skill's context is injected into an agent's prompt
|
|
for a task, so observability can attribute usage to an agent and task.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from crewai import Agent, Task
|
|
from crewai.events import crewai_event_bus
|
|
from crewai.events.types.skill_events import SkillUsedEvent
|
|
|
|
|
|
# Handlers run on the event bus thread pool, so tests must flush before
|
|
# asserting — otherwise assertions race the handler and pass/fail on timing.
|
|
|
|
|
|
def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path:
|
|
"""Create a skill directory containing a minimal SKILL.md."""
|
|
skill_dir = parent / name
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
f"---\nname: {name}\ndescription: Skill {name}\n---\n{body}"
|
|
)
|
|
return skill_dir
|
|
|
|
|
|
def _agent_with_skills(search_path: Path) -> Agent:
|
|
# discover_skills scans the SUBdirectories of the given path.
|
|
return Agent(
|
|
role="Analyst",
|
|
goal="Analyze things",
|
|
backstory="Experienced",
|
|
skills=[str(search_path)],
|
|
llm="gpt-4o-mini",
|
|
)
|
|
|
|
|
|
def _task_for(agent: Agent) -> Task:
|
|
return Task(description="Do the thing", expected_output="text", agent=agent)
|
|
|
|
|
|
class TestSkillUsedEvent:
|
|
def test_emits_one_event_per_skill_with_agent_and_task_attribution(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
_create_skill_dir(tmp_path, "alpha")
|
|
agent = _agent_with_skills(tmp_path)
|
|
task = _task_for(agent)
|
|
|
|
received: list[SkillUsedEvent] = []
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(SkillUsedEvent)
|
|
def _handler(source, event: SkillUsedEvent) -> None: # noqa: ARG001
|
|
received.append(event)
|
|
|
|
agent._finalize_task_prompt("PROMPT", [], task)
|
|
assert crewai_event_bus.flush(timeout=10)
|
|
|
|
assert len(received) == 1
|
|
event = received[0]
|
|
assert event.type == "skill_used"
|
|
assert event.skill_name == "alpha"
|
|
assert event.skill_path is not None
|
|
# Attribution is what makes the event useful in traces.
|
|
assert event.agent_role == "Analyst"
|
|
assert event.agent_id == str(agent.id)
|
|
assert event.task_id == str(task.id)
|
|
|
|
def test_emits_for_every_skill(self, tmp_path: Path) -> None:
|
|
_create_skill_dir(tmp_path, "alpha")
|
|
_create_skill_dir(tmp_path, "beta")
|
|
agent = _agent_with_skills(tmp_path)
|
|
task = _task_for(agent)
|
|
|
|
received: list[SkillUsedEvent] = []
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(SkillUsedEvent)
|
|
def _handler(source, event: SkillUsedEvent) -> None: # noqa: ARG001
|
|
received.append(event)
|
|
|
|
agent._finalize_task_prompt("PROMPT", [], task)
|
|
assert crewai_event_bus.flush(timeout=10)
|
|
|
|
assert sorted(e.skill_name for e in received) == ["alpha", "beta"]
|
|
|
|
def test_re_emits_on_each_execution(self, tmp_path: Path) -> None:
|
|
"""Usage is per-execution, unlike activation which is idempotent."""
|
|
_create_skill_dir(tmp_path, "alpha")
|
|
agent = _agent_with_skills(tmp_path)
|
|
task = _task_for(agent)
|
|
|
|
received: list[SkillUsedEvent] = []
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(SkillUsedEvent)
|
|
def _handler(source, event: SkillUsedEvent) -> None: # noqa: ARG001
|
|
received.append(event)
|
|
|
|
agent._finalize_task_prompt("PROMPT", [], task)
|
|
agent._finalize_task_prompt("PROMPT", [], task)
|
|
assert crewai_event_bus.flush(timeout=10)
|
|
|
|
assert len(received) == 2
|
|
|
|
def test_reports_disclosure_level(self, tmp_path: Path) -> None:
|
|
"""Path-loaded skills are activated, so they report INSTRUCTIONS level."""
|
|
_create_skill_dir(tmp_path, "alpha")
|
|
agent = _agent_with_skills(tmp_path)
|
|
task = _task_for(agent)
|
|
|
|
received: list[SkillUsedEvent] = []
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(SkillUsedEvent)
|
|
def _handler(source, event: SkillUsedEvent) -> None: # noqa: ARG001
|
|
received.append(event)
|
|
|
|
agent._finalize_task_prompt("PROMPT", [], task)
|
|
assert crewai_event_bus.flush(timeout=10)
|
|
|
|
assert received[0].disclosure_level >= 2
|
|
|
|
def test_agent_without_skills_emits_nothing(self) -> None:
|
|
agent = Agent(
|
|
role="Analyst",
|
|
goal="Analyze things",
|
|
backstory="Experienced",
|
|
llm="gpt-4o-mini",
|
|
)
|
|
task = _task_for(agent)
|
|
|
|
received: list[SkillUsedEvent] = []
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(SkillUsedEvent)
|
|
def _handler(source, event: SkillUsedEvent) -> None: # noqa: ARG001
|
|
received.append(event)
|
|
|
|
agent._finalize_task_prompt("PROMPT", [], task)
|
|
assert crewai_event_bus.flush(timeout=10)
|
|
|
|
assert received == []
|
|
|
|
def test_event_is_exported_from_events_package(self) -> None:
|
|
from crewai.events import SkillUsedEvent as Exported
|
|
|
|
assert Exported is SkillUsedEvent
|
|
|
|
|
|
class TestSkillUsedEventThroughExecution:
|
|
"""Coverage through the public execution entry points.
|
|
|
|
The helper-level tests above pin per-skill details; these prove the event
|
|
actually reaches the bus during a real `execute_task` / `aexecute_task`
|
|
run. The agent executor is stubbed so no LLM call is made.
|
|
"""
|
|
|
|
def test_emitted_during_execute_task(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_create_skill_dir(tmp_path, "alpha")
|
|
agent = _agent_with_skills(tmp_path)
|
|
task = _task_for(agent)
|
|
monkeypatch.setattr(
|
|
Agent, "_execute_without_timeout", lambda self, prompt, task: "done"
|
|
)
|
|
|
|
received: list[SkillUsedEvent] = []
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(SkillUsedEvent)
|
|
def _handler(source, event: SkillUsedEvent) -> None: # noqa: ARG001
|
|
received.append(event)
|
|
|
|
agent.execute_task(task)
|
|
assert crewai_event_bus.flush(timeout=10)
|
|
|
|
assert [e.skill_name for e in received] == ["alpha"]
|
|
assert received[0].task_id == str(task.id)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emitted_during_aexecute_task(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_create_skill_dir(tmp_path, "alpha")
|
|
agent = _agent_with_skills(tmp_path)
|
|
task = _task_for(agent)
|
|
|
|
async def _fake_execute(self, prompt: str, task: Task) -> str: # noqa: ANN001
|
|
return "done"
|
|
|
|
monkeypatch.setattr(Agent, "_aexecute_without_timeout", _fake_execute)
|
|
|
|
received: list[SkillUsedEvent] = []
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(SkillUsedEvent)
|
|
def _handler(source, event: SkillUsedEvent) -> None: # noqa: ARG001
|
|
received.append(event)
|
|
|
|
await agent.aexecute_task(task)
|
|
assert crewai_event_bus.flush(timeout=10)
|
|
|
|
assert [e.skill_name for e in received] == ["alpha"]
|
|
assert received[0].task_id == str(task.id)
|