Resolve merge conflicts with origin/main

This commit is contained in:
copilot-swe-agent[bot]
2026-07-28 04:16:58 +00:00
committed by GitHub
5566 changed files with 1232781 additions and 3453 deletions

View File

@@ -8,10 +8,10 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.15.2",
"crewai-core==1.15.7",
"click>=8.1.7,<9",
"pydantic>=2.11.9,<2.13",
"pydantic-settings~=2.10.1",
"pydantic-settings>=2.14.2,<3",
"appdirs~=1.4.4",
"cryptography>=42.0",
"httpx~=0.28.1",

View File

@@ -1 +1 @@
__version__ = "1.15.2"
__version__ = "1.15.7"

View File

@@ -686,20 +686,9 @@ def tool_publish(is_public: bool, force: bool) -> None:
tool_cmd.publish(is_public, force)
@crewai.group()
def experimental() -> None:
"""Experimental, unstable commands. Subject to change without notice."""
import os
if os.environ.get("CREWAI_EXPERIMENTAL") != "1":
raise click.UsageError(
"Experimental commands are gated. Set CREWAI_EXPERIMENTAL=1 to enable."
)
@experimental.group(name="skill")
@crewai.group(name="skill")
def skill() -> None:
"""Skill Repository related commands (experimental)."""
"""Create, publish, and install agent skills."""
@skill.command(name="create")
@@ -713,7 +702,7 @@ def skill() -> None:
help="Create skill in current dir instead of ./skills/",
)
def skill_create(name: str, in_project: bool) -> None:
from crewai_cli.experimental.skills.main import SkillCommand
from crewai_cli.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.create(name, in_project=in_project)
@@ -722,7 +711,7 @@ def skill_create(name: str, in_project: bool) -> None:
@skill.command(name="install")
@click.argument("ref")
def skill_install(ref: str) -> None:
from crewai_cli.experimental.skills.main import SkillCommand
from crewai_cli.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.install(ref)
@@ -736,20 +725,19 @@ def skill_install(ref: str) -> None:
show_default=True,
help="Skip git-state validation.",
)
@click.option("--public", "is_public", flag_value=True, default=False)
@click.option("--private", "is_public", flag_value=False)
@click.option("--org", default=None, help="Organisation slug (overrides settings).")
def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
from crewai_cli.experimental.skills.main import SkillCommand
def skill_publish(org: str | None, force: bool) -> None:
"""Publish the skill in the current directory, scoped to your organization."""
from crewai_cli.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.publish(is_public, org=org, force=force)
skill_cmd.publish(org=org, force=force)
@skill.command(name="list")
def skill_list() -> None:
"""List locally installed skills."""
from crewai_cli.experimental.skills.main import SkillCommand
from crewai_cli.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.list_cached()

View File

@@ -13,6 +13,7 @@ import zipfile
from rich.console import Console
from rich.table import Table
from crewai_cli import git
from crewai_cli.command import BaseCommand, PlusAPIMixin
from crewai_cli.config import Settings
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
@@ -63,7 +64,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
(skill_dir / "assets").mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(_SKILL_MD_TEMPLATE.format(name=name))
skill_md.write_text(_SKILL_MD_TEMPLATE.format(name=name), encoding="utf-8")
console.print(
f"[green]Created skill [bold]{name}[/bold] at [bold]{skill_dir}[/bold].[/green]"
@@ -148,7 +149,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
)
else:
try:
from crewai.experimental.skills.cache import SkillCacheManager
from crewai.skills.cache import SkillCacheManager
cache = SkillCacheManager()
cache.store(org, name, version, archive_bytes)
@@ -170,13 +171,19 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
"version": version,
"installed_at": datetime.now(tz=timezone.utc).isoformat(),
}
(cache_dir / ".crewai_meta.json").write_text(json.dumps(meta, indent=2))
(cache_dir / ".crewai_meta.json").write_text(
json.dumps(meta, indent=2), encoding="utf-8"
)
console.print(
f"[green]Installed [bold]{ref}[/bold]{' (' + version + ')' if version else ''} to global cache.[/green]"
)
def publish(self, is_public: bool, org: str | None, force: bool = False) -> None:
"""Publish the skill in the current directory to the registry."""
def publish(self, org: str | None = None, force: bool = False) -> None:
"""Publish the skill in the current directory to the registry.
Skills are always scoped to the publishing organization; there is no
public visibility option.
"""
skill_md = Path("SKILL.md")
if not skill_md.exists():
console.print(
@@ -185,8 +192,44 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
)
raise SystemExit(1)
if not force:
try:
repository = git.Repository(fetch=False)
except ValueError as exc:
if "not a Git repository" not in str(exc):
console.print(
f"[red]Unable to validate git state: {exc}\n"
"Fix the issue or pass --force to skip this check.[/red]"
)
raise SystemExit(1) from exc
# Standalone skill directories may live outside any git repo;
# there is no git state to validate in that case.
repository = None
if repository is not None:
try:
# Refresh remote-tracking refs so is_synced() compares
# against the actual remote, not stale local state.
repository.fetch()
except ValueError as exc:
console.print(
f"[red]Unable to validate git state: {exc}\n"
"Fix the issue or pass --force to skip this check.[/red]"
)
raise SystemExit(1) from exc
if repository is not None and not repository.is_synced():
console.print(
"[bold red]Failed to publish skill.[/bold red]\n"
"Local changes need to be resolved before publishing. Please do the following:\n"
"* [bold]Commit[/bold] your changes.\n"
"* [bold]Push[/bold] to sync with the remote.\n"
"* [bold]Pull[/bold] the latest changes from the remote.\n"
"\nOnce your repository is up-to-date, retry publishing the skill "
"(or pass --force to skip this check)."
)
raise SystemExit(1)
try:
frontmatter = self._parse_frontmatter(skill_md.read_text())
frontmatter = self._parse_frontmatter(skill_md.read_text(encoding="utf-8"))
except ValueError as exc:
console.print(f"[red]Failed to parse SKILL.md frontmatter: {exc}[/red]")
raise SystemExit(1) from exc
@@ -233,7 +276,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
org=effective_org,
name=name,
version=version,
is_public=is_public,
is_public=False,
description=description,
encoded_file=encoded_file,
)
@@ -277,7 +320,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
meta_file = skill_dir / ".crewai_meta.json"
if meta_file.exists():
try:
meta = json.loads(meta_file.read_text())
meta = json.loads(meta_file.read_text(encoding="utf-8"))
table.add_row(
"cache",
f"@{meta['org']}/{meta['name']}",
@@ -368,7 +411,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
def _read_version(self, skill_md: Path) -> str | None:
"""Read the version from a SKILL.md file's metadata, or None."""
try:
fm = self._parse_frontmatter(skill_md.read_text())
fm = self._parse_frontmatter(skill_md.read_text(encoding="utf-8"))
raw_metadata = fm.get("metadata")
if isinstance(raw_metadata, dict):
return raw_metadata.get("version")

View File

@@ -59,9 +59,9 @@ Use these expression forms correctly:
- Raw CEL: use in `expr`. Do not wrap raw CEL in `${...}`.
- Use `${...}` inside action mapping strings to read Flow data with CEL. Example value: `Ticket: ${state.ticket_id}`.
- Use `state` for input data. Use `outputs.step_name` for a completed method result.
- In action mapping strings, keep literal text outside `${...}` and interpolate each Flow value directly. Write `Ticket: ${state.ticket_id}`; do not assemble the string with CEL `+`.
- If a value is only one `${...}` expression, the result keeps its type. Use this for numbers, booleans, objects, and lists.
- If the string has other text, the final value is text. Non-text values become JSON. `null` becomes empty text.
- Use `text(root, "path", "default")` for values that may be missing or null. The default is optional and is `""`.
Expression examples:
@@ -78,12 +78,6 @@ domains: "${state.domains}"
limit: "${state.limit}"
```
Use a default for missing text:
```yaml
input: "Ticket ${text(state, \"ticket.id\", \"unknown\")}"
```
- Crew text: use `{name}` placeholders from crew inputs. Example: `Research {topic}`.
- Crew inputs become prompt text only when agent or task text references matching `{name}` placeholders.
- Passing an input that is not referenced by any `{name}` placeholder does not ground the crew. If the crew needs a field, put that placeholder in an agent `goal`, task `description`, or task `expected_output`.
@@ -111,6 +105,7 @@ Dynamic value rules:
- Do not use fields outside the declaration schema.
- Do not put more than one action under a method's `do`.
- Do not make `do` a list.
- Do not use CEL `+` to build text in action mappings. Keep the text literal and insert each dynamic value with `${...}`.
- Do not reference `outputs.some_method` before `some_method` can run.
- Do not set a method's `listen` to its own method name.
- Do not use the same string for an emitted event and a method name unless the user asks for it.
@@ -246,7 +241,7 @@ Shape:
Fields:
- `call` (required): must be `crew`. Action discriminator. Use crew to run an inline Crew definition. Example: `crew`
- `with` (required): inline crew definition. Inline Crew definition to load and execute for this action. Example: `{"agents": {"researcher": {"backstory": "Knows the domain.", "goal": "Research {topic}", "role": "Researcher"}}, "name": "inline_research", "tasks": [{"agent": "researcher", "description": "Research {topic}", "expected_output": "Findings about {topic}", "name": "research_task"}]}`
- `inputs` (optional): map of string to expression data | null; default `null`. Actual kickoff inputs passed to the Crew. Use `${...}` inside action mapping strings to read Flow data with CEL. Example value: `Ticket: ${state.ticket_id}`. Use `state` for input data. Use `outputs.step_name` for a completed method result. If a value is only one `${...}` expression, the result keeps its type. Use this for numbers, booleans, objects, and lists. If the string has other text, the final value is text. Non-text values become JSON. `null` becomes empty text. Use `text(root, "path", "default")` for values that may be missing or null. The default is optional and is `""`. The evaluated values are available to crew agent and task interpolation as `{name}` placeholders; reference each input the crew needs in agent or task text. Example: `{"topic": "${state.topic}"}`
- `inputs` (optional): map of string to expression data | null; default `null`. Runtime inputs passed to the Crew. Insert Flow values with `${...}` and reference each input as `{name}` in agent or task text. Example: `{"topic": "${state.topic}"}`
#### Crew Definition (`methods.<name>.do[call=crew].with`)
@@ -311,7 +306,7 @@ Fields:
- `tools` (optional): list[string | map of string to any] | null; default `null`. Tool refs or serialized tool definitions available to this agent. String refs can use CrewAI tool names, `custom:<name>`, or fully qualified `module:Class` references. Example: `["crewai_tools:SerperDevTool", "custom:file_read"]`
- `apps` (optional): list[string] | null; default `null`. Platform apps available to this agent. Can contain app names such as `gmail` or app/action refs such as `gmail/send_email`. Example: `["gmail", "slack/send_message"]`
- `mcps` (optional): list[string | map of string to any] | null; default `null`. MCP server refs or serialized MCP server configs available to this agent. String refs can use HTTPS URLs, connected MCP integration slugs, or refs with a `#tool_name` suffix for specific tools. Example: `["https://api.weather.com/mcp#get_current_weather", "snowflake", "stripe#list_invoices", {"cache_tools_list": true, "headers": {"Authorization": "Bearer your_token"}, "streamable": true, "url": "https://api.example.com/mcp"}]`
- `input` (required): string. Input passed to the individual agent kickoff outside of a crew. Use one string. Use `${...}` inside action mapping strings to read Flow data with CEL. Example value: `Ticket: ${state.ticket_id}`. Use `state` for input data. Use `outputs.step_name` for a completed method result. If a value is only one `${...}` expression, the result keeps its type. Use this for numbers, booleans, objects, and lists. If the string has other text, the final value is text. Non-text values become JSON. `null` becomes empty text. Use `text(root, "path", "default")` for values that may be missing or null. The default is optional and is `""`. When an agent needs multiple fields, write one string with labels and separators, for example `Ticket ID: ${state.ticket_id}; Message: ${state.message}`. Example: `${state.ticket.body}`
- `input` (required): string. Agent prompt template. Insert Flow values with `${...}`, for example `Ticket: ${state.ticket_id}`. Example: `${state.ticket.body}`
#### LLM Definition
@@ -349,4 +344,3 @@ Fields:
- Crew action-level `inputs` are the Crew kickoff inputs; use CEL-wrapped strings there for runtime values.
- Crew agent/task interpolation uses `{name}` placeholders from evaluated crew inputs.
- Agent `with.input` must be text. Use `${outputs.method_name.raw}` or a text field like `${outputs.method_name.json_dict.summary}`.

View File

@@ -0,0 +1,63 @@
"""Tests for the top-level `crewai skill` command group.
The Skills Repository graduated from the gated `crewai experimental skill`
group: the commands must be reachable at `crewai skill ...` without
CREWAI_EXPERIMENTAL set.
"""
from unittest import mock
from click.testing import CliRunner
from crewai_cli.cli import crewai
import pytest
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def no_experimental_env(monkeypatch):
monkeypatch.delenv("CREWAI_EXPERIMENTAL", raising=False)
class TestSkillGroupIsTopLevel:
def test_skill_group_registered_at_top_level(self, runner, no_experimental_env):
result = runner.invoke(crewai, ["skill", "--help"])
assert result.exit_code == 0, result.output
for subcommand in ("create", "install", "publish", "list"):
assert subcommand in result.output
def test_skill_group_not_gated_by_experimental_env(
self, runner, no_experimental_env
):
with mock.patch("crewai_cli.skills.main.SkillCommand") as mock_cmd:
result = runner.invoke(crewai, ["skill", "create", "my-skill"])
assert result.exit_code == 0, result.output
mock_cmd.return_value.create.assert_called_once_with(
"my-skill", in_project=True
)
def test_skill_create_no_project_flag(self, runner, no_experimental_env):
with mock.patch("crewai_cli.skills.main.SkillCommand") as mock_cmd:
result = runner.invoke(crewai, ["skill", "create", "my-skill", "--no-project"])
assert result.exit_code == 0, result.output
mock_cmd.return_value.create.assert_called_once_with(
"my-skill", in_project=False
)
class TestSkillPublishIsOrgScopedOnly:
def test_publish_rejects_public_flag(self, runner, no_experimental_env):
result = runner.invoke(crewai, ["skill", "publish", "--public"])
assert result.exit_code != 0
assert "No such option" in result.output
def test_publish_passes_org_and_force_only(self, runner, no_experimental_env):
with mock.patch("crewai_cli.skills.main.SkillCommand") as mock_cmd:
result = runner.invoke(
crewai, ["skill", "publish", "--org", "acme", "--force"]
)
assert result.exit_code == 0, result.output
mock_cmd.return_value.publish.assert_called_once_with(org="acme", force=True)

View File

@@ -36,7 +36,7 @@ def skill_command():
TokenManager().save_tokens(
"test-token", (datetime.now() + timedelta(seconds=36000)).timestamp()
)
from crewai_cli.experimental.skills.main import SkillCommand
from crewai_cli.skills.main import SkillCommand
cmd = SkillCommand()
yield cmd
@@ -113,7 +113,7 @@ class TestSkillPublish:
def test_publish_no_skill_md(self, skill_command):
with in_temp_dir():
with pytest.raises(SystemExit):
skill_command.publish(is_public=True, org="acme")
skill_command.publish(org="acme")
def test_publish_missing_version(self, skill_command):
with in_temp_dir():
@@ -121,7 +121,7 @@ class TestSkillPublish:
"---\nname: my-skill\ndescription: Test.\n---\nInstructions."
)
with pytest.raises(SystemExit):
skill_command.publish(is_public=True, org="acme")
skill_command.publish(org="acme")
def test_publish_missing_name(self, skill_command):
with in_temp_dir():
@@ -129,7 +129,7 @@ class TestSkillPublish:
"---\ndescription: Test.\nversion: 1.0.0\n---\nInstructions."
)
with pytest.raises(SystemExit):
skill_command.publish(is_public=True, org="acme")
skill_command.publish(org="acme")
def test_publish_no_org(self, skill_command):
with in_temp_dir():
@@ -142,11 +142,11 @@ class TestSkillPublish:
mock_resp.status_code = 200
mock_resp.json.return_value = {}
mock_client.publish_skill.return_value = mock_resp
with patch("crewai_cli.experimental.skills.main.Settings") as mock_settings_cls:
with patch("crewai_cli.skills.main.Settings") as mock_settings_cls:
mock_settings_cls.return_value.org_name = None
mock_settings_cls.return_value.enterprise_base_url = None
with pytest.raises(SystemExit):
skill_command.publish(is_public=True, org=None)
skill_command.publish(org=None)
def test_publish_calls_api(self, skill_command):
with in_temp_dir():
@@ -158,16 +158,102 @@ class TestSkillPublish:
mock_resp.status_code = 200
mock_resp.json.return_value = {}
skill_command.plus_api_client.publish_skill = MagicMock(return_value=mock_resp)
with patch("crewai_cli.experimental.skills.main.Settings") as mock_settings_cls:
with patch("crewai_cli.skills.main.Settings") as mock_settings_cls:
mock_settings_cls.return_value.org_name = "acme"
mock_settings_cls.return_value.enterprise_base_url = None
skill_command.publish(is_public=False, org="acme")
skill_command.publish(org="acme")
skill_command.plus_api_client.publish_skill.assert_called_once()
call_kwargs = skill_command.plus_api_client.publish_skill.call_args
assert call_kwargs.kwargs["name"] == "my-skill"
assert call_kwargs.kwargs["version"] == "1.0.0"
# Skills are always org-scoped; there is no public visibility.
assert call_kwargs.kwargs["is_public"] is False
def test_publish_blocked_when_git_not_synced(self, skill_command):
with in_temp_dir():
Path("SKILL.md").write_text(
"---\nname: my-skill\ndescription: A test skill.\nmetadata:\n version: 1.0.0\n---\nInstructions."
)
skill_command.plus_api_client.publish_skill = MagicMock()
with patch("crewai_cli.skills.main.git.Repository") as mock_repo_cls:
mock_repo_cls.return_value.is_synced.return_value = False
with pytest.raises(SystemExit):
skill_command.publish(org="acme")
skill_command.plus_api_client.publish_skill.assert_not_called()
def test_publish_force_skips_git_check(self, skill_command):
with in_temp_dir():
Path("SKILL.md").write_text(
"---\nname: my-skill\ndescription: A test skill.\nmetadata:\n version: 1.0.0\n---\nInstructions."
)
mock_resp = MagicMock()
mock_resp.is_success = True
mock_resp.status_code = 200
mock_resp.json.return_value = {}
skill_command.plus_api_client.publish_skill = MagicMock(return_value=mock_resp)
with (
patch("crewai_cli.skills.main.git.Repository") as mock_repo_cls,
patch("crewai_cli.skills.main.Settings") as mock_settings_cls,
):
mock_settings_cls.return_value.org_name = "acme"
mock_settings_cls.return_value.enterprise_base_url = None
skill_command.publish(org="acme", force=True)
mock_repo_cls.assert_not_called()
skill_command.plus_api_client.publish_skill.assert_called_once()
def test_publish_blocked_when_fetch_fails(self, skill_command):
"""A failing remote fetch must fail closed, not silently skip syncing."""
with in_temp_dir():
Path("SKILL.md").write_text(
"---\nname: my-skill\ndescription: A test skill.\nmetadata:\n version: 1.0.0\n---\nInstructions."
)
skill_command.plus_api_client.publish_skill = MagicMock()
with patch("crewai_cli.skills.main.git.Repository") as mock_repo_cls:
mock_repo_cls.return_value.fetch.side_effect = ValueError(
"Git fetch failed with exit code 128"
)
with pytest.raises(SystemExit):
skill_command.publish(org="acme")
skill_command.plus_api_client.publish_skill.assert_not_called()
def test_publish_blocked_when_git_state_cannot_be_validated(self, skill_command):
"""Git errors other than 'not a repo' must fail closed, not skip the check."""
with in_temp_dir():
Path("SKILL.md").write_text(
"---\nname: my-skill\ndescription: A test skill.\nmetadata:\n version: 1.0.0\n---\nInstructions."
)
skill_command.plus_api_client.publish_skill = MagicMock()
with patch(
"crewai_cli.skills.main.git.Repository",
side_effect=ValueError("Git fetch failed with exit code 128"),
):
with pytest.raises(SystemExit):
skill_command.publish(org="acme")
skill_command.plus_api_client.publish_skill.assert_not_called()
def test_publish_proceeds_outside_git_repo(self, skill_command):
with in_temp_dir():
Path("SKILL.md").write_text(
"---\nname: my-skill\ndescription: A test skill.\nmetadata:\n version: 1.0.0\n---\nInstructions."
)
mock_resp = MagicMock()
mock_resp.is_success = True
mock_resp.status_code = 200
mock_resp.json.return_value = {}
skill_command.plus_api_client.publish_skill = MagicMock(return_value=mock_resp)
with (
patch(
"crewai_cli.skills.main.git.Repository",
side_effect=ValueError("not a Git repository"),
),
patch("crewai_cli.skills.main.Settings") as mock_settings_cls,
):
mock_settings_cls.return_value.org_name = "acme"
mock_settings_cls.return_value.enterprise_base_url = None
skill_command.publish(org="acme")
skill_command.plus_api_client.publish_skill.assert_called_once()
class TestSkillListCached:

View File

@@ -14,7 +14,7 @@ from pathlib import Path
import pytest
from crewai_cli.experimental.skills.main import _safe_extractall
from crewai_cli.skills.main import _safe_extractall
def _tar_from_members(build) -> tarfile.TarFile:

View File

@@ -29,7 +29,9 @@ def test_create_flow_declarative_project_can_run(
agents_md = (project_root / "AGENTS.md").read_text(encoding="utf-8")
assert "CrewAI Flow declaration" in agents_md
assert "schema: crewai.flow/v1" in agents_md
assert 'text(root, "path", "default")' in agents_md
assert "do not assemble the string with CEL `+`" in agents_md
assert "Agent prompt template. Insert Flow values with `${...}`" in agents_md
assert "Runtime inputs passed to the Crew" in agents_md
assert "call: expression" in agents_md
assert "call: tool" not in agents_md
assert "call: script" not in agents_md

View File

@@ -1 +1 @@
__version__ = "1.15.2"
__version__ = "1.15.7"

View File

@@ -149,7 +149,13 @@ class PlusAPI:
EPHEMERAL_TRACING_RESOURCE: Final = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE: Final = "/crewai_plus/api/v1/integrations"
def __init__(self, api_key: str | None = None) -> None:
def __init__(
self,
api_key: str | None = None,
*,
base_url: str | None = None,
organization_id: str | None = None,
) -> None:
version = get_crewai_version()
self.api_key = api_key
self.headers: Headers = {
@@ -161,12 +167,13 @@ class PlusAPI:
self.headers["Authorization"] = f"Bearer {api_key}"
settings = Settings()
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
if organization_id := organization_id or settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = organization_id
self.base_url = (
os.getenv("CREWAI_PLUS_URL")
or str(settings.enterprise_base_url)
base_url
or os.getenv("CREWAI_PLUS_URL")
or settings.enterprise_base_url
or DEFAULT_CREWAI_ENTERPRISE_URL
)

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"Pillow~=12.1.1",
"pypdf~=6.13.3",
"Pillow~=12.3.0",
"pypdf~=6.14.2",
"python-magic>=0.4.27",
"aiocache~=0.12.3",
"aiofiles~=24.1.0",
@@ -19,8 +19,8 @@ dependencies = [
[tool.uv]
exclude-newer = "3 days"
# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
# pypdf 6.14.2 is a security fix newer than the global supply-chain cutoff.
exclude-newer-package = { pypdf = "2026-06-24T00:00:00Z" }
[build-system]
requires = ["hatchling"]

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.15.2"
__version__ = "1.15.7"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.15.2",
"crewai==1.15.7",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
@@ -97,17 +97,17 @@ databricks-sdk = [
"databricks-sdk>=0.46.0",
]
couchbase = [
"couchbase>=4.3.5",
"couchbase>=4.6.0",
]
mcp = [
"mcp>=1.6.0",
"mcp>=1.28.1,<2",
"mcpadapt>=0.1.9",
]
stagehand = [
"stagehand>=0.4.1",
]
github = [
"gitpython>=3.1.50,<4",
"gitpython>=3.1.55,<4",
"PyGithub==1.59.1",
]
rag = [
@@ -131,7 +131,7 @@ postgresql = [
]
bedrock = [
"beautifulsoup4>=4.13.4",
"bedrock-agentcore>=1.7.0,<1.8.0",
"bedrock-agentcore>=1.18.1,<2.0.0",
"playwright>=1.52.0",
"nest-asyncio>=1.6.0",
]

View File

@@ -206,6 +206,7 @@ from crewai_tools.tools.tavily_research_tool.tavily_research_tool import (
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
from crewai_tools.tools.wait_tool.wait_tool import WaitTool
from crewai_tools.tools.weaviate_tool.vector_search import WeaviateVectorSearchTool
from crewai_tools.tools.website_search.website_search_tool import WebsiteSearchTool
from crewai_tools.tools.xml_search_tool.xml_search_tool import XMLSearchTool
@@ -321,6 +322,7 @@ __all__ = [
"TavilyResearchTool",
"TavilySearchTool",
"VisionTool",
"WaitTool",
"WeaviateVectorSearchTool",
"WebsiteSearchTool",
"XMLSearchTool",
@@ -330,4 +332,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.15.2"
__version__ = "1.15.7"

View File

@@ -193,6 +193,7 @@ from crewai_tools.tools.tavily_research_tool.tavily_research_tool import (
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
from crewai_tools.tools.wait_tool.wait_tool import WaitTool
from crewai_tools.tools.weaviate_tool.vector_search import WeaviateVectorSearchTool
from crewai_tools.tools.website_search.website_search_tool import WebsiteSearchTool
from crewai_tools.tools.xml_search_tool.xml_search_tool import XMLSearchTool
@@ -304,6 +305,7 @@ __all__ = [
"TavilyResearchTool",
"TavilySearchTool",
"VisionTool",
"WaitTool",
"WeaviateVectorSearchTool",
"WebsiteSearchTool",
"XMLSearchTool",

View File

@@ -88,7 +88,7 @@ class E2BBaseTool(BaseTool):
EnvVar(
name="E2B_API_KEY",
description="API key for E2B sandbox service",
required=False,
required=True,
),
EnvVar(
name="E2B_DOMAIN",

View File

@@ -0,0 +1,70 @@
# WaitTool
The **WaitTool** pauses execution for a given number of seconds. Use it when an agent needs to
let time pass before re-checking a long-running job — a sandbox build, a deployment, a batch
import, an async API call.
No API key, no dependencies beyond the standard library.
## Arguments
| Argument | Type | Required | Description |
| --------- | ------- | -------- | ---------------------------------------------------------------------------- |
| `seconds` | `float` | ✅ | How many seconds to wait. Must be zero or greater. |
| `reason` | `str` | ❌ | Optional note on what is being waited for. Echoed back in the tool's result. |
## Initialization Parameters
| Parameter | Type | Default | Description |
| ------------- | ------- | ------- | -------------------------------------------------------------------------------------- |
| `max_seconds` | `float` | `300` | Upper bound for a single wait. Longer requests are capped to this value, not rejected. |
## Usage Example
```python
from crewai import Agent
from crewai.tools import tool
from crewai_tools import WaitTool
wait_tool = WaitTool()
@tool("Check build status")
def check_build_status_tool(build_id: str) -> str:
"""Return the current status of a build: queued, running, passed, or failed."""
# Replace this with a call to your own build system.
return my_ci_client.get_build(build_id).status
agent = Agent(
role="Build Monitor",
goal="Start the build and report its final status",
backstory="An engineer who knows that builds take time.",
tools=[wait_tool, check_build_status_tool],
)
```
A single call waits at most `max_seconds`. Longer requests are capped and the result says so,
so the agent can simply call the tool again:
```python
wait_tool.run(seconds=3600)
# 'Waited 300 seconds. Requested 3600 seconds, capped at 300 seconds per call -
# call this tool again if more waiting is needed.'
WaitTool(max_seconds=1800).run(seconds=900)
# 'Waited 900 seconds.'
```
Async execution is supported and does not block the event loop:
```python
import asyncio
async def main():
print(await wait_tool.arun(seconds=30, reason="waiting for the sandbox build"))
asyncio.run(main())
```

View File

@@ -0,0 +1,4 @@
from crewai_tools.tools.wait_tool.wait_tool import WaitTool, WaitToolSchema
__all__ = ["WaitTool", "WaitToolSchema"]

View File

@@ -0,0 +1,232 @@
"""Tool that pauses execution for a given amount of time."""
import asyncio
import math
import time
from typing import Any
from crewai.tools import BaseTool
from crewai.types.callback import SerializableCallable
from pydantic import BaseModel, ConfigDict, Field, model_validator
from typing_extensions import Self
DEFAULT_MAX_SECONDS: float = 300.0
_DESCRIPTION_TEMPLATE = (
"Pause and do nothing for a set number of seconds before continuing.\n"
"Use this when work that was already started somewhere else needs real time to "
"finish before its status or result can be checked again, for example: a sandbox "
"build, test run, or script that is still executing; a deployment or provisioning "
"step that is still rolling out; a batch import, export, or training job; an async "
"API that returned a job id to poll later; or a rate limit or backoff that has to "
"cool down before retrying.\n"
"The usual pattern is: start the job, wait, check its status, and wait again if it "
"is still running.\n"
"Do not use this to pace a conversation, to pretend to work, or when the "
"information needed is already available. Waiting only lets clock time pass, it "
"does not advance or check the job.\n"
"A single call waits at most {cap}. If more time is needed, "
"call this tool again."
)
# Everything before the cap figure; used to tell a generated description from one
# the caller wrote, so only generated text is kept in sync with ``max_seconds``.
_GENERATED_DESCRIPTION_PREFIX = _DESCRIPTION_TEMPLATE.split("{cap}")[0]
def _format_seconds(value: float) -> str:
"""Render a duration with a correctly pluralized unit.
Args:
value: The duration in seconds.
Returns:
The duration and its unit, e.g. ``"1 second"`` or ``"300 seconds"``.
"""
return f"{value:g} second" if value == 1 else f"{value:g} seconds"
def _build_description(max_seconds: float) -> str:
"""Build the LLM-facing description, including the current per-call cap.
Args:
max_seconds: The per-call cap advertised to the model.
Returns:
The tool description shown to the model.
"""
return _DESCRIPTION_TEMPLATE.format(cap=_format_seconds(max_seconds))
def _is_generated_description(description: str) -> bool:
"""Report whether a description came from :func:`_build_description`.
Args:
description: The description to inspect.
Returns:
True if the text was generated rather than authored by the caller.
"""
return description.startswith(_GENERATED_DESCRIPTION_PREFIX)
def _never_cache(_args: Any = None, _result: Any = None) -> bool:
"""Refuse caching of wait results.
The value of a wait is the time that passed, not the message returned. A
cached hit would hand back "Waited 300 seconds." without waiting, silently
turning a poll-wait-check loop into a busy loop.
Args:
_args: Unused, part of the ``cache_function`` signature.
_result: Unused, part of the ``cache_function`` signature.
Returns:
Always False.
"""
return False
class WaitToolSchema(BaseModel):
"""Input for WaitTool."""
seconds: float = Field(
...,
ge=0,
description="How many seconds to wait before continuing.",
)
reason: str | None = Field(
default=None,
description="Optional short note on what is being waited for, echoed back in the result.",
)
class WaitTool(BaseTool):
"""Pause execution so a long-running job elsewhere has time to progress.
Agents that kick off out-of-band work (a sandbox build, a deployment, an async
API job) have no other way to let clock time pass: without this tool they either
poll in a tight loop or give up before the work finishes.
A single call waits at most ``max_seconds``. Longer requests are clamped to that
cap and the result says so, so the model can call again instead of failing.
The generated description advertises the cap to the model, and is kept in sync
with ``max_seconds``. A description supplied by the caller is left alone.
"""
model_config = ConfigDict(validate_assignment=True)
name: str = "Wait"
description: str = _build_description(DEFAULT_MAX_SECONDS)
args_schema: type[BaseModel] = WaitToolSchema
max_seconds: float = Field(
default=DEFAULT_MAX_SECONDS,
gt=0,
description="Upper bound, in seconds, for a single wait. Longer requests are capped to this value.",
)
cache_function: SerializableCallable = Field(
default=_never_cache,
description="Waits are never cached: a cache hit would return the message without any time passing.",
)
def __init__(self, max_seconds: float | None = None, **kwargs: Any) -> None:
if max_seconds is not None:
kwargs["max_seconds"] = max_seconds
super().__init__(**kwargs)
@model_validator(mode="after")
def _sync_description_with_cap(self) -> Self:
"""Keep a generated description's advertised cap equal to ``max_seconds``.
Runs on construction, ``model_validate``, and assignment, so the cap the
model is told about cannot drift from the one enforced. ``model_copy``
skips validation, as it does for any pydantic model, so prefer
construction or assignment when changing the cap.
Returns:
The validated tool.
"""
expected = _build_description(self.max_seconds)
if self.description != expected and _is_generated_description(self.description):
# Bypass the field setter: assigning here would re-enter validation.
self.__dict__["description"] = expected
return self
def _resolve_duration(self, seconds: float) -> tuple[float, bool]:
"""Validate and clamp the requested duration to ``max_seconds``.
``BaseTool.run`` skips ``args_schema`` validation when called with
positional arguments, so the bounds are enforced here too rather than
left to ``time.sleep`` to reject. Infinity is a valid request: it clamps
to the cap like any other oversized wait.
Args:
seconds: The requested wait duration.
Returns:
A tuple of the duration to actually wait and whether it was capped.
Raises:
ValueError: If ``seconds`` is negative or not a number.
"""
if math.isnan(seconds):
raise ValueError("seconds must be a number, got NaN.")
if seconds < 0:
raise ValueError(f"seconds must be zero or greater, got {seconds:g}.")
if seconds > self.max_seconds:
return self.max_seconds, True
return seconds, False
def _format_result(
self, waited: float, requested: float, reason: str | None
) -> str:
"""Describe the completed wait back to the model.
Args:
waited: The duration actually waited.
requested: The duration the model asked for.
reason: Optional note on what is being waited for.
Returns:
A summary of how long was waited and whether the request was capped.
"""
parts = [f"Waited {_format_seconds(waited)}."]
if waited < requested:
parts.append(
f"Requested {_format_seconds(requested)}, capped at "
f"{_format_seconds(self.max_seconds)} per call - "
"call this tool again if more waiting is needed."
)
if reason:
parts.append(f"Reason: {reason}")
return " ".join(parts)
def _run(self, seconds: float, reason: str | None = None) -> str:
"""Block for ``seconds``, capped at ``max_seconds``.
Args:
seconds: How many seconds to wait.
reason: Optional note on what is being waited for.
Returns:
A summary of how long was waited and whether the request was capped.
"""
waited, _ = self._resolve_duration(seconds)
time.sleep(waited)
return self._format_result(waited, seconds, reason)
async def _arun(self, seconds: float, reason: str | None = None) -> str:
"""Await for ``seconds``, capped at ``max_seconds``, without blocking the loop.
Args:
seconds: How many seconds to wait.
reason: Optional note on what is being waited for.
Returns:
A summary of how long was waited and whether the request was capped.
"""
waited, _ = self._resolve_duration(seconds)
await asyncio.sleep(waited)
return self._format_result(waited, seconds, reason)

View File

@@ -0,0 +1,181 @@
from unittest.mock import AsyncMock, patch
from crewai_tools.tools.wait_tool import WaitTool
from pydantic import ValidationError
import pytest
@pytest.fixture
def tool():
return WaitTool()
@patch("crewai_tools.tools.wait_tool.wait_tool.time.sleep")
def test_waits_requested_duration(mock_sleep, tool):
result = tool.run(seconds=2)
mock_sleep.assert_called_once_with(2)
assert "Waited 2 seconds." in result
assert "capped" not in result
@patch("crewai_tools.tools.wait_tool.wait_tool.time.sleep")
def test_caps_long_waits(mock_sleep, tool):
result = tool.run(seconds=3600)
mock_sleep.assert_called_once_with(300)
assert "Waited 300 seconds." in result
assert "Requested 3600 seconds, capped at 300 seconds per call" in result
assert "call this tool again" in result
@patch("crewai_tools.tools.wait_tool.wait_tool.time.sleep")
def test_custom_max_seconds(mock_sleep):
tool = WaitTool(max_seconds=10)
result = tool.run(seconds=45)
mock_sleep.assert_called_once_with(10)
assert "Waited 10 seconds." in result
assert "10 seconds" in tool.description
@patch("crewai_tools.tools.wait_tool.wait_tool.time.sleep")
def test_reason_is_echoed_back(mock_sleep, tool):
result = tool.run(seconds=1, reason="sandbox build running")
assert "Reason: sandbox build running" in result
def test_zero_seconds_is_allowed(tool):
assert "Waited 0 seconds." in tool.run(seconds=0)
def test_negative_seconds_is_rejected(tool):
with pytest.raises(ValueError, match="greater than or equal to 0"):
tool.run(seconds=-5)
@patch("crewai_tools.tools.wait_tool.wait_tool.time.sleep")
def test_negative_seconds_is_rejected_when_passed_positionally(mock_sleep, tool):
# BaseTool.run() skips args_schema validation for positional arguments.
with pytest.raises(ValueError, match="seconds must be zero or greater"):
tool.run(-5)
mock_sleep.assert_not_called()
@pytest.mark.asyncio
async def test_async_negative_seconds_is_rejected_when_passed_positionally(tool):
with patch(
"crewai_tools.tools.wait_tool.wait_tool.asyncio.sleep", new_callable=AsyncMock
) as mock_sleep:
with pytest.raises(ValueError, match="seconds must be zero or greater"):
await tool.arun(-5)
mock_sleep.assert_not_awaited()
@patch("crewai_tools.tools.wait_tool.wait_tool.time.sleep")
def test_nan_seconds_is_rejected_when_passed_positionally(mock_sleep, tool):
with pytest.raises(ValueError, match="seconds must be a number"):
tool.run(float("nan"))
mock_sleep.assert_not_called()
@patch("crewai_tools.tools.wait_tool.wait_tool.time.sleep")
def test_infinite_seconds_is_capped_like_any_other_long_wait(mock_sleep, tool):
result = tool.run(float("inf"))
mock_sleep.assert_called_once_with(300)
assert "Waited 300 seconds." in result
@patch("crewai_tools.tools.wait_tool.wait_tool.time.sleep")
def test_singular_second_is_not_pluralized(mock_sleep):
tool = WaitTool(max_seconds=1)
assert "Waited 1 second." in tool.run(seconds=1)
assert "capped at 1 second per call" in tool.run(seconds=5)
assert "at most 1 second." in tool.description
def test_invalid_max_seconds_is_rejected():
with pytest.raises(ValidationError):
WaitTool(max_seconds=0)
@pytest.mark.asyncio
async def test_async_wait_caps_long_waits(tool):
with patch(
"crewai_tools.tools.wait_tool.wait_tool.asyncio.sleep", new_callable=AsyncMock
) as mock_sleep:
result = await tool.arun(seconds=3600, reason="deployment")
mock_sleep.assert_awaited_once_with(300)
assert "Waited 300 seconds." in result
assert "Reason: deployment" in result
@pytest.mark.parametrize(
"build",
[
pytest.param(lambda d: WaitTool(max_seconds=10), id="init"),
pytest.param(lambda d: WaitTool(10), id="init_positional"),
pytest.param(lambda d: WaitTool(max_seconds=10, description=d), id="init_desc"),
pytest.param(
lambda d: WaitTool.model_validate({"max_seconds": 10, "description": d}),
id="model_validate",
),
pytest.param(
lambda d: WaitTool.model_validate(WaitTool(max_seconds=10).model_dump()),
id="round_trip",
),
],
)
def test_advertised_cap_matches_enforced_cap(build):
tool = build(WaitTool().description)
assert tool.max_seconds == 10
assert "at most 10 seconds" in tool.description
assert "300 seconds" not in tool.description
def test_advertised_cap_follows_assignment():
tool = WaitTool()
tool.max_seconds = 10
assert "at most 10 seconds" in tool.description
def test_caller_supplied_description_is_left_alone():
tool = WaitTool(max_seconds=10, description="Hold on for a bit.")
assert tool.description == "Hold on for a bit."
def test_waits_are_never_cached():
# A cache hit would hand back "Waited N seconds." without any time passing,
# turning a poll-wait-check loop into a busy loop.
tool = WaitTool()
assert tool.cache_function("{}", "Waited 1 second.") is False
assert WaitTool.model_validate(tool.model_dump()).cache_function() is False
def test_description_explains_when_to_use_it(tool):
description = tool.description.lower()
assert "sandbox" in description
assert "deployment" in description
assert "300 seconds" in description
assert "call this tool again" in description
def test_exported_from_package():
from crewai_tools import WaitTool as ExportedWaitTool
from crewai_tools.tools import WaitTool as ToolsExportedWaitTool
assert ExportedWaitTool is WaitTool
assert ToolsExportedWaitTool is WaitTool

View File

@@ -8967,7 +8967,7 @@
"default": null,
"description": "API key for E2B sandbox service",
"name": "E2B_API_KEY",
"required": false
"required": true
},
{
"default": null,
@@ -9188,7 +9188,7 @@
"default": null,
"description": "API key for E2B sandbox service",
"name": "E2B_API_KEY",
"required": false
"required": true
},
{
"default": null,
@@ -9408,7 +9408,7 @@
"default": null,
"description": "API key for E2B sandbox service",
"name": "E2B_API_KEY",
"required": false
"required": true
},
{
"default": null,
@@ -25793,6 +25793,94 @@
"type": "object"
}
},
{
"description": "Pause and do nothing for a set number of seconds before continuing.\nUse this when work that was already started somewhere else needs real time to finish before its status or result can be checked again, for example: a sandbox build, test run, or script that is still executing; a deployment or provisioning step that is still rolling out; a batch import, export, or training job; an async API that returned a job id to poll later; or a rate limit or backoff that has to cool down before retrying.\nThe usual pattern is: start the job, wait, check its status, and wait again if it is still running.\nDo not use this to pace a conversation, to pretend to work, or when the information needed is already available. Waiting only lets clock time pass, it does not advance or check the job.\nA single call waits at most 300 seconds. If more time is needed, call this tool again.",
"env_vars": [],
"humanized_name": "Wait",
"init_params_schema": {
"$defs": {
"EnvVar": {
"properties": {
"default": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Default"
},
"description": {
"title": "Description",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"required": {
"default": true,
"title": "Required",
"type": "boolean"
}
},
"required": [
"name",
"description"
],
"title": "EnvVar",
"type": "object"
}
},
"description": "Pause execution so a long-running job elsewhere has time to progress.\n\nAgents that kick off out-of-band work (a sandbox build, a deployment, an async\nAPI job) have no other way to let clock time pass: without this tool they either\npoll in a tight loop or give up before the work finishes.\n\nA single call waits at most ``max_seconds``. Longer requests are clamped to that\ncap and the result says so, so the model can call again instead of failing.\n\nThe generated description advertises the cap to the model, and is kept in sync\nwith ``max_seconds``. A description supplied by the caller is left alone.",
"properties": {
"max_seconds": {
"default": 300.0,
"description": "Upper bound, in seconds, for a single wait. Longer requests are capped to this value.",
"exclusiveMinimum": 0,
"title": "Max Seconds",
"type": "number"
}
},
"required": [],
"title": "WaitTool",
"type": "object"
},
"name": "WaitTool",
"package_dependencies": [],
"run_params_schema": {
"description": "Input for WaitTool.",
"properties": {
"reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional short note on what is being waited for, echoed back in the result.",
"title": "Reason"
},
"seconds": {
"description": "How many seconds to wait before continuing.",
"minimum": 0,
"title": "Seconds",
"type": "number"
}
},
"required": [
"seconds"
],
"title": "WaitToolSchema",
"type": "object"
}
},
{
"description": "A tool to search the Weaviate database for relevant information on internal documents.",
"env_vars": [

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.15.2",
"crewai-cli==1.15.2",
"crewai-core==1.15.7",
"crewai-cli==1.15.7",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -32,15 +32,15 @@ dependencies = [
"click>=8.1.7,<9",
"appdirs~=1.4.4",
"jsonref~=1.1.0",
"json-repair~=0.25.2",
"json-repair~=0.60.1",
"cel-python>=0.5.0,<0.6",
"tomli-w~=1.1.0",
"tomli~=2.0.2",
"json5~=0.10.0",
"portalocker~=2.7.0",
"pydantic-settings>=2.10.1,<3",
"pydantic-settings>=2.14.2,<3",
"httpx~=0.28.1",
"mcp~=1.26.0",
"mcp~=1.28.1",
"aiosqlite~=0.21.0",
"pyyaml~=6.0",
"aiofiles~=24.1.0",
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.15.2",
"crewai-tools==1.15.7",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"
@@ -78,8 +78,8 @@ qdrant = [
"qdrant-client[fastembed]~=1.14.3",
]
aws = [
"boto3~=1.42.90",
"aiobotocore~=3.5.0",
"boto3~=1.43.46",
"aiobotocore~=3.8.0",
]
watson = [
"ibm-watsonx-ai~=1.3.39",
@@ -91,8 +91,8 @@ litellm = [
"litellm>=1.84.0,<2",
]
bedrock = [
"boto3~=1.42.90",
"aiobotocore~=3.5.0",
"boto3~=1.43.46",
"aiobotocore~=3.8.0",
]
google-genai = [
"google-genai~=1.65.0",

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.15.2"
__version__ = "1.15.7"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

@@ -73,6 +73,7 @@ from crewai.events.types.memory_events import (
MemoryRetrievalFailedEvent,
MemoryRetrievalStartedEvent,
)
from crewai.events.types.skill_events import SkillUsedEvent
from crewai.experimental.agent_executor import AgentExecutor
from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
@@ -551,9 +552,37 @@ class Agent(BaseAgent):
The fully prepared task prompt.
"""
prepare_tools(self, tools, task)
self._emit_skill_usage(task)
return apply_training_data(self, task_prompt)
def _emit_skill_usage(self, task: Task) -> None:
"""Emit one SkillUsedEvent per skill injected into this task's prompt.
Skills are agent-scoped and rendered into the prompt on every execution,
so this is the runtime usage signal traces need — attributing each skill
to the agent and task that used it.
Args:
task: The task whose prompt the skills are being applied to.
"""
if not self.skills:
return
for skill in self.skills:
if not isinstance(skill, SkillModel):
continue
crewai_event_bus.emit(
self,
event=SkillUsedEvent(
from_agent=self,
from_task=task,
skill_name=skill.name,
skill_path=skill.path,
disclosure_level=skill.disclosure_level,
),
)
def _retrieve_memory_context(self, task: Task, task_prompt: str) -> str:
"""Retrieve memory context and append it to the task prompt.

View File

@@ -46,8 +46,8 @@ from crewai.hooks.llm_hooks import (
)
from crewai.hooks.tool_hooks import (
ToolCallHookContext,
get_after_tool_call_hooks,
get_before_tool_call_hooks,
run_after_tool_call_hooks,
run_before_tool_call_hooks,
)
from crewai.types.callback import SerializableCallable
from crewai.utilities.agent_utils import (
@@ -951,7 +951,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
track_delegation_if_needed(func_name, args_dict or {}, self.task)
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict or {},
@@ -960,19 +959,7 @@ class CrewAgentExecutor(BaseAgentExecutor):
task=self.task,
crew=self.crew,
)
before_hooks = get_before_tool_call_hooks()
try:
for hook in before_hooks:
hook_result = hook(before_hook_context)
if hook_result is False:
hook_blocked = True
break
except Exception as hook_error:
if self.agent.verbose:
PRINTER.print(
content=f"Error in before_tool_call hook: {hook_error}",
color="red",
)
hook_blocked = run_before_tool_call_hooks(before_hook_context)
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
@@ -1033,19 +1020,9 @@ class CrewAgentExecutor(BaseAgentExecutor):
tool_result=result,
raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
try:
for after_hook in after_hooks:
after_hook_result = after_hook(after_hook_context)
if after_hook_result is not None:
result = after_hook_result
after_hook_context.tool_result = result
except Exception as hook_error:
if self.agent.verbose:
PRINTER.print(
content=f"Error in after_tool_call hook: {hook_error}",
color="red",
)
modified_result = run_after_tool_call_hooks(after_hook_context)
if modified_result is not None:
result = modified_result
if not error_event_emitted:
crewai_event_bus.emit(

View File

@@ -7,7 +7,7 @@ AgentAction or AgentFinish objects.
from dataclasses import dataclass
from json_repair import repair_json # type: ignore[import-untyped]
from json_repair import repair_json
from pydantic import BaseModel
from crewai.agents.constants import (
@@ -173,7 +173,12 @@ def _safe_repair_json(tool_input: str) -> str:
tool_input = tool_input.replace('"""', '"')
result = repair_json(tool_input)
if result in UNABLE_TO_REPAIR_JSON_RESULTS:
if not result or result in UNABLE_TO_REPAIR_JSON_RESULTS:
return tool_input
# json-repair >= 0.60 wraps non-JSON input in a single-element list;
# treat that as unrepairable and return the original.
if result.startswith("[") and not tool_input.lstrip().startswith("["):
return tool_input
return str(result)

View File

@@ -217,6 +217,8 @@ class Crew(FlowTrackable, BaseModel):
default_factory=TaskOutputStorageHandler
)
_kickoff_event_id: str | None = PrivateAttr(default=None)
_execution_start_dispatched: bool = PrivateAttr(default=False)
_execution_end_dispatched: bool = PrivateAttr(default=False)
name: str | None = Field(default="crew")
cache: bool = Field(
@@ -1050,6 +1052,7 @@ class Crew(FlowTrackable, BaseModel):
return result
except Exception as e:
self._dispatch_execution_end_failure(e)
crewai_event_bus.emit(
self,
CrewKickoffFailedEvent(
@@ -1263,6 +1266,7 @@ class Crew(FlowTrackable, BaseModel):
return result
except Exception as e:
self._dispatch_execution_end_failure(e)
crewai_event_bus.emit(
self,
CrewKickoffFailedEvent(
@@ -1906,6 +1910,34 @@ class Crew(FlowTrackable, BaseModel):
final_string_output = final_task_output.raw
self._finish_execution(final_string_output)
self.token_usage = self.calculate_usage_metrics()
from crewai.hooks.contexts import ExecutionEndContext, OutputContext
from crewai.hooks.dispatch import InterceptionPoint, dispatch
crew_output = CrewOutput(
raw=final_task_output.raw,
pydantic=final_task_output.pydantic,
json_dict=final_task_output.json_dict,
tasks_output=task_outputs,
token_usage=self.token_usage,
)
output_ctx = OutputContext(crew=self, output=crew_output, payload=crew_output)
dispatch(InterceptionPoint.OUTPUT, output_ctx)
crew_output = cast(CrewOutput, output_ctx.payload)
end_ctx = ExecutionEndContext(
crew=self, output=crew_output, payload=crew_output
)
# Flag set before dispatching so an EXECUTION_END hook that raises
# HookAborted does not trigger a second (failure) dispatch upstream.
self._execution_end_dispatched = True
dispatch(InterceptionPoint.EXECUTION_END, end_ctx)
crew_output = cast(CrewOutput, end_ctx.payload)
if isinstance(crew_output, CrewOutput):
final_task_output.raw = crew_output.raw
# Ensure background memory saves finish (and emit their
# completed/failed events) before the kickoff-completed event below
# triggers listener teardown/finalization.
@@ -1924,13 +1956,33 @@ class Crew(FlowTrackable, BaseModel):
# Finalization is handled by trace listener (always initialized)
# The batch manager checks contextvar to determine if tracing is enabled
return CrewOutput(
raw=final_task_output.raw,
pydantic=final_task_output.pydantic,
json_dict=final_task_output.json_dict,
tasks_output=task_outputs,
token_usage=self.token_usage,
)
return crew_output
def _dispatch_execution_end_failure(self, error: BaseException) -> None:
"""Dispatch EXECUTION_END with status="failed" for a kickoff that raised.
No-op when EXECUTION_START never dispatched (pairing invariant) or when
EXECUTION_END already fired for this execution (exactly-once). Never
raises, so the original kickoff exception propagates unchanged.
Instance-level flags are sufficient here because crew kickoffs are not
reentrant on the same instance (``kickoff_for_each`` copies the crew;
unlike Flow, nothing in the crew runtime supports nested kickoffs).
"""
if not self._execution_start_dispatched or self._execution_end_dispatched:
return
self._execution_end_dispatched = True
from crewai.hooks.contexts import ExecutionEndContext
from crewai.hooks.dispatch import InterceptionPoint, dispatch
try:
dispatch(
InterceptionPoint.EXECUTION_END,
ExecutionEndContext(crew=self, status="failed", error=error),
)
except Exception: # noqa: S110 - aborting an already-failed execution is meaningless
pass
def _process_async_tasks(
self,

View File

@@ -278,6 +278,9 @@ def prepare_kickoff(
reset_emission_counter()
reset_last_event_id()
from crewai.hooks.contexts import ExecutionStartContext, InputContext
from crewai.hooks.dispatch import InterceptionPoint, dispatch
normalized: dict[str, Any] | None = None
if inputs is not None:
if not isinstance(inputs, Mapping):
@@ -286,11 +289,35 @@ def prepare_kickoff(
)
normalized = dict(inputs)
# ``inputs`` aliases the same object as ``payload`` (not a fresh ``{}`` from
# ``or``) so in-place edits to either survive read-back, per the context
# contract. ``None`` inputs are preserved rather than coerced to ``{}``.
start_ctx = ExecutionStartContext(
crew=crew,
inputs=normalized if normalized is not None else {},
payload=normalized,
)
# Pairing flags: EXECUTION_END fires (once) only for executions whose
# EXECUTION_START actually dispatched, including on the failure path.
crew._execution_start_dispatched = False
crew._execution_end_dispatched = False
dispatch(InterceptionPoint.EXECUTION_START, start_ctx)
crew._execution_start_dispatched = True
normalized = start_ctx.payload
for before_callback in crew.before_kickoff_callbacks:
if normalized is None:
normalized = {}
normalized = before_callback(normalized)
input_ctx = InputContext(
crew=crew,
inputs=normalized if normalized is not None else {},
payload=normalized,
)
dispatch(InterceptionPoint.INPUT, input_ctx)
normalized = input_ctx.payload
if resuming and crew._kickoff_event_id:
if crew.verbose:
from crewai.events.utils.console_formatter import ConsoleFormatter

View File

@@ -137,6 +137,7 @@ if TYPE_CHECKING:
SkillEvent,
SkillLoadFailedEvent,
SkillLoadedEvent,
SkillUsedEvent,
)
from crewai.events.types.task_events import (
TaskCompletedEvent,
@@ -244,6 +245,7 @@ _LAZY_EVENT_MAPPING: dict[str, str] = {
"SkillEvent": "crewai.events.types.skill_events",
"SkillLoadFailedEvent": "crewai.events.types.skill_events",
"SkillLoadedEvent": "crewai.events.types.skill_events",
"SkillUsedEvent": "crewai.events.types.skill_events",
"TaskCompletedEvent": "crewai.events.types.task_events",
"TaskEvaluationEvent": "crewai.events.types.task_events",
"TaskFailedEvent": "crewai.events.types.task_events",
@@ -376,6 +378,7 @@ __all__ = [
"SkillEvent",
"SkillLoadFailedEvent",
"SkillLoadedEvent",
"SkillUsedEvent",
"TaskCompletedEvent",
"TaskEvaluationEvent",
"TaskFailedEvent",

View File

@@ -0,0 +1,19 @@
from typing import Literal
from crewai.events.base_events import BaseEvent
class HookDispatchedEvent(BaseEvent):
"""Event emitted whenever an interception point dispatches to hooks.
Only emitted when at least one hook is registered for the point, so the
no-op fast path stays free of event overhead.
"""
type: Literal["hook_dispatched"] = "hook_dispatched"
interception_point: str
outcome: Literal["proceeded", "modified", "aborted"]
hook_count: int
duration_ms: float
abort_reason: str | None = None
abort_source: str | None = None

View File

@@ -60,3 +60,15 @@ class SkillLoadFailedEvent(SkillEvent):
type: Literal["skill_load_failed"] = "skill_load_failed"
error: str
class SkillUsedEvent(SkillEvent):
"""Event emitted when an agent uses a skill during task execution.
Discovery/load/activation events describe setup. This one is the runtime
signal: it fires each time a skill's context is injected into an agent's
prompt for a task, so traces can attribute skill usage to an agent and task.
"""
type: Literal["skill_used"] = "skill_used"
disclosure_level: int = 1

View File

@@ -62,8 +62,8 @@ from crewai.hooks.llm_hooks import (
)
from crewai.hooks.tool_hooks import (
ToolCallHookContext,
get_after_tool_call_hooks,
get_before_tool_call_hooks,
run_after_tool_call_hooks,
run_before_tool_call_hooks,
)
from crewai.hooks.types import (
AfterLLMCallHookCallable,
@@ -1975,7 +1975,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
track_delegation_if_needed(func_name, args_dict, self.task)
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
@@ -1984,19 +1983,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
task=self.task,
crew=self.crew,
)
before_hooks = get_before_tool_call_hooks()
try:
for hook in before_hooks:
hook_result = hook(before_hook_context)
if hook_result is False:
hook_blocked = True
break
except Exception as hook_error:
if self.agent.verbose:
PRINTER.print(
content=f"Error in before_tool_call hook: {hook_error}",
color="red",
)
hook_blocked = run_before_tool_call_hooks(before_hook_context)
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
@@ -2060,19 +2047,9 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
tool_result=result,
raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
try:
for after_hook in after_hooks:
after_hook_result = after_hook(after_hook_context)
if after_hook_result is not None:
result = after_hook_result
after_hook_context.tool_result = result
except Exception as hook_error:
if self.agent.verbose:
PRINTER.print(
content=f"Error in after_tool_call hook: {hook_error}",
color="red",
)
modified_result = run_after_tool_call_hooks(after_hook_context)
if modified_result is not None:
result = modified_result
if not error_event_emitted:
crewai_event_bus.emit(

View File

@@ -1,23 +1,39 @@
"""Experimental Skills Repository — registry refs, global cache, downloads.
"""Deprecated location for the Skills Repository.
This package contains the registry-backed pieces of the skills feature
(`@org/name` refs, `~/.crewai/skills/` cache, download events). The stable
filesystem-based skill loader still lives in `crewai.skills`.
The registry-backed skills feature graduated out of experimental; it now
lives in `crewai.skills` alongside the filesystem-based loader. This module
re-exports the public names and aliases the old submodules so imports
written against the experimental namespace keep working, and will be
removed in a future release.
"""
from crewai.experimental.skills.cache import SkillCacheManager
from crewai.experimental.skills.registry import (
SkillNotCachedError,
import sys
from crewai.skills import cache, events, registry
from crewai.skills.cache import SkillCacheManager
from crewai.skills.registry import (
SkillRef,
is_registry_ref,
parse_registry_ref,
parse_skill_ref,
resolve_registry_ref,
)
# Keep `crewai.experimental.skills.<module>` imports (and patch targets)
# resolving to the real modules in crewai.skills.
sys.modules[__name__ + ".cache"] = cache
sys.modules[__name__ + ".events"] = events
sys.modules[__name__ + ".registry"] = registry
__all__ = [
"SkillCacheManager",
"SkillNotCachedError",
"SkillRef",
"cache",
"events",
"is_registry_ref",
"parse_registry_ref",
"parse_skill_ref",
"registry",
"resolve_registry_ref",
]

View File

@@ -1,24 +0,0 @@
"""Experimental feature gate for the Skills Repository."""
from __future__ import annotations
import os
ENV_VAR = "CREWAI_EXPERIMENTAL"
class ExperimentalFeatureDisabledError(RuntimeError):
"""Raised when an experimental feature is used without the flag set."""
def is_enabled() -> bool:
return os.environ.get(ENV_VAR) == "1"
def require_experimental_skills() -> None:
if not is_enabled():
raise ExperimentalFeatureDisabledError(
"The Skills Repository (registry refs, cache, downloads) is "
f"experimental. Set {ENV_VAR}=1 to enable it."
)

View File

@@ -1,221 +0,0 @@
"""Registry reference resolution for the Agent Skills standard.
Handles @org/skill-name references, local-first resolution, and downloads
via the CrewAI+ API with a global cache at ~/.crewai/skills/.
"""
from __future__ import annotations
import logging
from pathlib import Path
import sys
from typing import Any
from crewai.experimental.skills.cache import SkillCacheManager
_logger = logging.getLogger(__name__)
class SkillNotCachedError(Exception):
"""Raised when a registry skill is not cached and the environment is non-interactive."""
def __init__(self, ref: str) -> None:
super().__init__(
f"Skill {ref!r} is not cached locally. "
f"Run `crewai skill install {ref}` to install it first."
)
self.ref = ref
def is_registry_ref(value: Any) -> bool:
"""Return True if *value* looks like a registry reference (@org/name)."""
return isinstance(value, str) and value.startswith("@")
def parse_registry_ref(ref: str) -> tuple[str, str]:
"""Parse '@org/skill-name' into (org, name).
Args:
ref: A registry reference, e.g. '@acme/my-skill'.
Returns:
A (org, name) tuple.
Raises:
ValueError: If the reference format is invalid.
"""
if not ref.startswith("@"):
raise ValueError(f"Registry reference must start with '@', got: {ref!r}")
without_at = ref[1:]
if without_at.count("/") != 1:
raise ValueError(
f"Registry reference must be in '@org/name' format, got: {ref!r}"
)
org, name = without_at.split("/", 1)
if (
not org
or not name
or org.startswith(".")
or name.startswith(".")
or "/" in org
or "/" in name
):
raise ValueError(
f"Registry reference org and name must be single, non-empty path "
f"segments (no '..' or leading dots), got: {ref!r}"
)
return org, name
def _is_noninteractive() -> bool:
"""Return True in CI or explicitly non-interactive environments."""
import os
return (
os.environ.get("CI") == "1"
or os.environ.get("CREWAI_NONINTERACTIVE") == "1"
or not sys.stdin.isatty()
)
def resolve_registry_ref(
ref: str,
source: Any = None,
) -> Skill: # type: ignore[name-defined] # noqa: F821
"""Resolve a registry reference to a Skill object.
Resolution order:
1. ./skills/{name}/ in the current working directory (project-local)
2. ~/.crewai/skills/{org}/{name}/ (global cache)
3. Download from registry (interactive only; raises SkillNotCachedError in CI)
Args:
ref: A registry reference, e.g. '@acme/my-skill'.
source: Optional source object passed through to skill loaders (for events).
Returns:
A Skill loaded at INSTRUCTIONS disclosure level.
Raises:
SkillNotCachedError: When not cached and running in non-interactive mode.
"""
from crewai.experimental.skills._flag import require_experimental_skills
from crewai.skills.loader import activate_skill
from crewai.skills.parser import load_skill_metadata
require_experimental_skills()
org, name = parse_registry_ref(ref)
local_path = Path.cwd() / "skills" / name
if local_path.is_dir() and (local_path / "SKILL.md").exists():
try:
skill = load_skill_metadata(local_path)
return activate_skill(skill, source=source)
except Exception:
_logger.debug("Failed to load local skill at %s", local_path, exc_info=True)
cache = SkillCacheManager()
cached_path = cache.get_cached_path(org, name)
if cached_path is not None and (cached_path / "SKILL.md").exists():
try:
skill = load_skill_metadata(cached_path)
return activate_skill(skill, source=source)
except Exception:
_logger.debug(
"Failed to load cached skill at %s", cached_path, exc_info=True
)
if _is_noninteractive():
raise SkillNotCachedError(ref)
return download_skill(org, name, source=source)
def download_skill(
org: str,
name: str,
source: Any = None,
) -> Skill: # type: ignore[name-defined] # noqa: F821
"""Download a skill from the registry and store it in the cache.
Args:
org: Organisation slug.
name: Skill name.
source: Optional source for event emission.
Returns:
The downloaded Skill at INSTRUCTIONS level.
"""
from crewai.skills.loader import activate_skill
from crewai.skills.parser import load_skill_metadata
ref = f"@{org}/{name}"
try:
from crewai.events.event_bus import crewai_event_bus
from crewai.experimental.skills.events import (
SkillDownloadCompletedEvent,
SkillDownloadStartedEvent,
)
_has_events = True
except ImportError:
_has_events = False
if _has_events:
crewai_event_bus.emit(
source,
event=SkillDownloadStartedEvent(
registry_ref=ref,
),
)
try:
from crewai_core.plus_api import PlusAPI
api = PlusAPI()
response = api.get_skill(org, name)
response.raise_for_status()
data = response.json()
except Exception as exc:
raise RuntimeError(
f"Failed to download skill {ref!r} from registry: {exc}"
) from exc
import base64
import httpx
version = data.get("latest_version") or data.get("version")
download_url = data.get("download_url")
if download_url:
dl_response = httpx.get(download_url, follow_redirects=True)
dl_response.raise_for_status()
archive_bytes = dl_response.content
else:
encoded = data.get("file", "")
if "," in encoded:
encoded = encoded.split(",", 1)[1]
archive_bytes = base64.b64decode(encoded)
cache = SkillCacheManager()
skill_dir = cache.store(org, name, version, archive_bytes)
if _has_events:
crewai_event_bus.emit(
source,
event=SkillDownloadCompletedEvent(
registry_ref=ref,
version=version,
cache_path=skill_dir,
),
)
if not (skill_dir / "SKILL.md").exists():
raise RuntimeError(
f"Skill archive for {ref!r} downloaded but no SKILL.md found in {skill_dir}"
)
skill = load_skill_metadata(skill_dir)
return activate_skill(skill, source=source)

View File

@@ -11,9 +11,6 @@ from crewai.utilities.serialization import to_serializable
if TYPE_CHECKING:
from celpy.celtypes import StringType
from celpy.evaluation import CELFunction
from crewai.flow.runtime import Flow
else:
from typing_extensions import TypeAliasType
@@ -24,29 +21,6 @@ _CEL_MACROS_WITH_LOCAL_BINDINGS = frozenset(
)
def _handle_text_custom_expression(
root: Any, path: Any, default: Any = ""
) -> StringType:
from celpy.celtypes import StringType
fallback = StringType("" if default is None else str(default))
current = root
for part in str(path).split("."):
if current is None:
return fallback
try:
if isinstance(current, list):
current = current[int(part)]
else:
current = current[StringType(part)]
except (KeyError, IndexError, TypeError, ValueError):
return fallback
if current is None:
return fallback
return StringType(_stringify_cel_value(current))
def _stringify_cel_value(value: Any) -> str:
from celpy.adapter import CELJSONEncoder
@@ -98,21 +72,18 @@ def _parse_template_segments(value: str) -> tuple[str | _ExpressionSegment, ...]
return tuple(segments)
_EXPRESSION_FUNCTIONS: dict[str, CELFunction] = {
"text": _handle_text_custom_expression,
}
FLOW_TEMPLATE_EXPRESSION_RULES: tuple[str, ...] = (
"Use `${...}` inside action mapping strings to read Flow data with CEL. "
"Example value: `Ticket: ${state.ticket_id}`.",
"Use `state` for input data. Use `outputs.step_name` for a completed "
"method result.",
"In action mapping strings, keep literal text outside `${...}` and "
"interpolate each Flow value directly. Write `Ticket: ${state.ticket_id}`; "
"do not assemble the string with CEL `+`.",
"If a value is only one `${...}` expression, the result keeps its type. "
"Use this for numbers, booleans, objects, and lists.",
"If the string has other text, the final value is text. Non-text values "
"become JSON. `null` becomes empty text.",
'Use `text(root, "path", "default")` for values that may be missing '
'or null. The default is optional and is `""`.',
)
FLOW_TEMPLATE_EXPRESSION_CONTRACT = " ".join(FLOW_TEMPLATE_EXPRESSION_RULES)
FLOW_TEMPLATE_EXPRESSION_EXAMPLES: dict[str, tuple[dict[str, str], ...]] = {
@@ -125,10 +96,6 @@ FLOW_TEMPLATE_EXPRESSION_EXAMPLES: dict[str, tuple[dict[str, str], ...]] = {
"title": "Keep a list or number type",
"code": 'domains: "${state.domains}"\nlimit: "${state.limit}"',
},
{
"title": "Use a default for missing text",
"code": 'input: "Ticket ${text(state, \\"ticket.id\\", \\"unknown\\")}"',
},
),
"json": (
{
@@ -141,14 +108,6 @@ FLOW_TEMPLATE_EXPRESSION_EXAMPLES: dict[str, tuple[dict[str, str], ...]] = {
'{\n "domains": "${state.domains}",\n "limit": "${state.limit}"\n}'
),
},
{
"title": "Use a default for missing text",
"code": (
"{\n"
' "input": "Ticket ${text(state, \\"ticket.id\\", \\"unknown\\")}"\n'
"}"
),
},
),
}
@@ -374,8 +333,7 @@ class Expression:
environment = Environment()
program = environment.program(
Expression._compile_cel(expression, environment=environment),
functions=_EXPRESSION_FUNCTIONS,
Expression._compile_cel(expression, environment=environment)
)
result = program.evaluate(cast(Context, json_to_cel(context)))
return json.loads(json.dumps(result, cls=CELJSONEncoder))

View File

@@ -1332,8 +1332,16 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
if self._flow_match_id is not None:
flow_id_token = current_flow_id.set(self._flow_match_id)
self._attach_usage_aggregation_listener()
# Per-invocation pairing state: a resumed execution's EXECUTION_START
# fired in the original kickoff, so a failure here still owes the
# paired EXECUTION_END (unless the body already dispatched it).
hook_state = {"end_dispatched": False}
try:
return await self._resume_async_body(feedback)
return await self._resume_async_body(feedback, hook_state)
except Exception as e:
if not hook_state["end_dispatched"]:
self._dispatch_execution_end_failure(e)
raise
finally:
# Match kickoff_async: drain pending handlers so the resumed
# phase's LLM events all hit `_aggregated_usage_metrics`
@@ -1343,7 +1351,9 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
if flow_id_token is not None:
current_flow_id.reset(flow_id_token)
async def _resume_async_body(self, feedback: str = "") -> Any:
async def _resume_async_body(
self, feedback: str = "", hook_state: dict[str, bool] | None = None
) -> Any:
if get_current_parent_id() is None:
reset_emission_counter()
reset_last_event_id()
@@ -1476,6 +1486,23 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
else (resumed_method_output if emit else result)
)
from crewai.hooks.contexts import ExecutionEndContext, OutputContext
from crewai.hooks.dispatch import InterceptionPoint, dispatch
output_ctx = OutputContext(flow=self, output=final_result, payload=final_result)
dispatch(InterceptionPoint.OUTPUT, output_ctx)
final_result = output_ctx.payload
end_ctx = ExecutionEndContext(
flow=self, output=final_result, payload=final_result
)
# Flag set before dispatching so an EXECUTION_END hook that raises
# HookAborted does not trigger a second (failure) dispatch upstream.
if hook_state is not None:
hook_state["end_dispatched"] = True
dispatch(InterceptionPoint.EXECUTION_END, end_ctx)
final_result = end_ctx.payload
if self._event_futures:
await asyncio.gather(
*[
@@ -2037,6 +2064,9 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
flow_name_token = None
flow_defer_trace_finalization_token = None
request_id_token = None
# Re-published after the INPUT hook so trigger-payload injection reads
# the hook-rewritten inputs rather than the pre-hook baggage above.
flow_inputs_token = None
if current_flow_id.get() is None:
flow_id_token = current_flow_id.set(self.flow_id)
flow_name_token = current_flow_name.set(
@@ -2061,7 +2091,45 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
self._aggregated_usage_metrics = UsageMetrics()
self._attach_usage_aggregation_listener()
# Pairing state is local (per invocation) so reentrant kickoffs on the
# same instance (see usage aggregation above) each track their own
# EXECUTION_START/EXECUTION_END dispatch independently.
execution_start_dispatched = False
execution_end_dispatched = False
try:
from crewai.hooks.contexts import (
ExecutionEndContext,
ExecutionStartContext,
InputContext,
OutputContext,
)
from crewai.hooks.dispatch import InterceptionPoint, dispatch
# ``inputs`` aliases the same object as ``payload`` (not a fresh
# ``{}`` from ``or``) so in-place edits survive read-back.
start_ctx = ExecutionStartContext(
flow=self,
inputs=inputs if inputs is not None else {},
payload=inputs,
)
dispatch(InterceptionPoint.EXECUTION_START, start_ctx)
execution_start_dispatched = True
inputs = start_ctx.payload
input_ctx = InputContext(
flow=self,
inputs=inputs if inputs is not None else {},
payload=inputs,
)
dispatch(InterceptionPoint.INPUT, input_ctx)
inputs = input_ctx.payload
# Publish the resolved inputs so trigger-payload injection and other
# baggage readers observe hook rewrites (the baggage set before the
# hooks carried the pre-hook inputs).
flow_inputs_token = attach(baggage.set_baggage("flow_inputs", inputs or {}))
# Reset flow state for fresh execution unless restoring from persistence
is_restoring = (
inputs and "id" in inputs and self.persistence is not None
@@ -2297,6 +2365,24 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
method_outputs = self.method_outputs
final_output = method_outputs[-1] if method_outputs else None
output_ctx = OutputContext(
flow=self, output=final_output, payload=final_output
)
dispatch(InterceptionPoint.OUTPUT, output_ctx)
final_output = output_ctx.payload
# EXECUTION_END runs before FlowFinishedEvent so a HookAborted
# prevents a spurious finished signal and payload replacement is
# honored on the emitted result and the returned value.
end_ctx = ExecutionEndContext(
flow=self, output=final_output, payload=final_output
)
# Flag set before dispatching so an EXECUTION_END hook that raises
# HookAborted does not trigger a second (failure) dispatch below.
execution_end_dispatched = True
dispatch(InterceptionPoint.EXECUTION_END, end_ctx)
final_output = end_ctx.payload
if self._event_futures:
await asyncio.gather(
*[asyncio.wrap_future(f) for f in self._event_futures]
@@ -2348,6 +2434,13 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
trace_listener.batch_manager.finalize_batch()
return final_output
except Exception as e:
# Pairing invariant: only fire the failure EXECUTION_END when this
# invocation's EXECUTION_START dispatched and its EXECUTION_END has
# not (exactly-once per invocation).
if execution_start_dispatched and not execution_end_dispatched:
self._dispatch_execution_end_failure(e)
raise
finally:
# Safety net for the exception path; the success path already
# drained before emitting FlowFinishedEvent.
@@ -2370,9 +2463,30 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
current_flow_name.reset(flow_name_token)
if flow_id_token is not None:
current_flow_id.reset(flow_id_token)
if flow_inputs_token is not None:
detach(flow_inputs_token)
detach(flow_token)
crewai_event_bus._exit_runtime_scope(runtime_scope)
def _dispatch_execution_end_failure(self, error: BaseException) -> None:
"""Dispatch EXECUTION_END with status="failed" for an execution that raised.
Callers enforce the pairing invariant (EXECUTION_START dispatched,
EXECUTION_END not yet) with per-invocation state, so reentrant kickoffs
on the same instance stay exactly-once. Never raises, so the original
exception propagates unchanged.
"""
from crewai.hooks.contexts import ExecutionEndContext
from crewai.hooks.dispatch import InterceptionPoint, dispatch
try:
dispatch(
InterceptionPoint.EXECUTION_END,
ExecutionEndContext(flow=self, status="failed", error=error),
)
except Exception: # noqa: S110 - aborting an already-failed execution is meaningless
pass
async def akickoff(
self,
inputs: dict[str, Any] | None = None,
@@ -2562,6 +2676,37 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
if future:
self._event_futures.append(future)
from crewai.hooks.contexts import StepContext
from crewai.hooks.dispatch import InterceptionPoint, dispatch
pre_step_ctx = StepContext(
kind="flow_method",
step_name=str(method_name),
flow=self,
payload=dumped_params,
)
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
# Apply hook edits/replacement of the step params back onto the
# call. ``dumped_params`` maps positional args to ``_0, _1, ...``
# keys and keeps kwargs by name, so reverse that mapping here.
updated_params = pre_step_ctx.payload
if isinstance(updated_params, dict):
positional = sorted(
(
k
for k in updated_params
if k.startswith("_") and k[1:].isdigit()
),
key=lambda k: int(k[1:]),
)
args = tuple(updated_params[k] for k in positional)
kwargs = {
k: v
for k, v in updated_params.items()
if not (k.startswith("_") and k[1:].isdigit())
}
# Set method name in context so ask() can read it without
# stack inspection. Must happen before copy_context() so the
# value propagates into the thread pool for sync methods.
@@ -2589,6 +2734,16 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
method_name, method_definition.human_feedback, result
)
post_step_ctx = StepContext(
kind="flow_method",
step_name=str(method_name),
flow=self,
output=result,
payload=result,
)
dispatch(InterceptionPoint.POST_STEP, post_step_ctx)
result = post_step_ctx.payload
self._method_outputs.append({"method": str(method_name), "output": result})
# For @human_feedback methods with emit, the result is the collapsed outcome

View File

@@ -11,7 +11,6 @@ from jinja2 import Environment, FileSystemLoader
import yaml
from crewai.flow.expressions import (
FLOW_TEMPLATE_EXPRESSION_CONTRACT,
FLOW_TEMPLATE_EXPRESSION_EXAMPLES,
FLOW_TEMPLATE_EXPRESSION_RULES,
)
@@ -186,7 +185,14 @@ MODEL_SPECS: tuple[ModelSpec, ...] = (
hidden=True,
),
ModelSpec("FlowScriptActionDefinition", "Action", "methods.<name>.do[call=script]"),
ModelSpec("FlowToolActionDefinition", "Action", "methods.<name>.do[call=tool]"),
ModelSpec(
"FlowToolActionDefinition",
"Action",
"methods.<name>.do[call=tool]",
descriptions={
"with": "Tool input arguments. Insert Flow values with `${...}`.",
},
),
ModelSpec(
"FlowCrewActionDefinition",
"Action",
@@ -194,7 +200,7 @@ MODEL_SPECS: tuple[ModelSpec, ...] = (
examples=True,
descriptions={
"call": "Action discriminator. Use crew to run an inline Crew definition.",
"inputs": f"Actual kickoff inputs passed to the Crew. {FLOW_TEMPLATE_EXPRESSION_CONTRACT} The evaluated values are available to crew agent and task interpolation as `{{name}}` placeholders; reference each input the crew needs in agent or task text.",
"inputs": "Runtime inputs passed to the Crew. Insert Flow values with `${...}` and reference each input as `{name}` in agent or task text.",
},
),
ModelSpec(
@@ -262,7 +268,7 @@ MODEL_SPECS: tuple[ModelSpec, ...] = (
hidden=True,
examples=True,
descriptions={
"input": f"Input passed to the individual agent kickoff outside of a crew. Use one string. {FLOW_TEMPLATE_EXPRESSION_CONTRACT} When an agent needs multiple fields, write one string with labels and separators, for example `Ticket ID: ${{state.ticket_id}}; Message: ${{state.message}}`.",
"input": "Agent prompt template. Insert Flow values with `${...}`, for example `Ticket: ${state.ticket_id}`.",
"llm": "Language model that runs this agent. Use an object when setting LLM options such as `max_tokens`.",
"planning_config": "Agent planning configuration. Set `max_attempts` to limit planning refinement attempts before task execution.",
},

View File

@@ -46,6 +46,7 @@ Pick the simplest action that does the job.
- Use `call: tool` for packaged deterministic work: API calls, searches, lookups, scoring, file work, or custom CrewAI tools.
{% endif %}
- Use `call: agent` for one AI worker that classifies, decides, summarizes, writes, or drafts. Put `role`, `goal`, `backstory`, and `input` under `with`. Do not add an action-level `inputs` map to an agent.
- Repository-backed agents may set `from_repository` and omit inline `role`, `goal`, and `backstory`. Explicitly provided fields override repository values.
- Use `call: crew` for coordinated AI work with multiple agents or tasks. Define the crew under `with`. Pass runtime values with the action-level `inputs` map.
{% if include_each_action %}
- Use `call: each` when the same ordered mini-pipeline must run once per item. Give every step a `name`.
@@ -137,6 +138,7 @@ Dynamic value rules:
- Do not use fields outside the declaration schema{% if include_tool_action %} or tool refs shown here{% endif %}.
- Do not put more than one action under a method's `do`.
- Do not make `do` a list.
- Do not use CEL `+` to build text in action mappings. Keep the text literal and insert each dynamic value with `${...}`.
- Do not reference `outputs.some_method` before `some_method` can run.
- Do not set a method's `listen` to its own method name.
- Do not use the same string for an emitted event and a method name unless the user asks for it.

View File

@@ -6,6 +6,17 @@ from crewai.hooks.decorators import (
before_llm_call,
before_tool_call,
)
from crewai.hooks.dispatch import (
HookAborted,
InterceptionPoint,
clear as clear_hooks,
clear_all as clear_all_hooks,
dispatch,
get_hooks,
on,
register as register_hook,
unregister as unregister_hook,
)
from crewai.hooks.llm_hooks import (
LLMCallHookContext,
clear_after_llm_call_hooks,
@@ -74,6 +85,8 @@ def clear_all_global_hooks() -> dict[str, tuple[int, int]]:
__all__ = [
"HookAborted",
"InterceptionPoint",
"LLMCallHookContext",
"ToolCallHookContext",
"after_llm_call",
@@ -83,20 +96,27 @@ __all__ = [
"clear_after_llm_call_hooks",
"clear_after_tool_call_hooks",
"clear_all_global_hooks",
"clear_all_hooks",
"clear_all_llm_call_hooks",
"clear_all_tool_call_hooks",
"clear_before_llm_call_hooks",
"clear_before_tool_call_hooks",
"clear_hooks",
"dispatch",
"get_after_llm_call_hooks",
"get_after_tool_call_hooks",
"get_before_llm_call_hooks",
"get_before_tool_call_hooks",
"get_hooks",
"on",
"register_after_llm_call_hook",
"register_after_tool_call_hook",
"register_before_llm_call_hook",
"register_before_tool_call_hook",
"register_hook",
"unregister_after_llm_call_hook",
"unregister_after_tool_call_hook",
"unregister_before_llm_call_hook",
"unregister_before_tool_call_hook",
"unregister_hook",
]

View File

@@ -0,0 +1,79 @@
"""Typed contexts for the interception points wired in phases 2-5.
Each context is a dataclass whose fields are nullable and defaulted, so a field
that is not meaningful for a given runtime (e.g. ``agent_role`` inside a flow)
is simply ``None`` rather than an error. Every context exposes a ``payload``
field: the interceptable value a hook may mutate in place or replace by
returning a new value.
The legacy ``pre/post_model_call`` and ``pre/post_tool_call`` points keep using
:class:`~crewai.hooks.llm_hooks.LLMCallHookContext` and
:class:`~crewai.hooks.tool_hooks.ToolCallHookContext` for backwards
compatibility; they are intentionally not redefined here.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
@dataclass
class InterceptionContext:
"""Base context shared by the framework-native interception points."""
payload: Any = None
agent: Any = None
agent_role: str | None = None
task: Any = None
crew: Any = None
flow: Any = None
@dataclass
class ExecutionStartContext(InterceptionContext):
"""``execution_start``: a crew or flow is about to begin. ``payload`` = inputs."""
inputs: dict[str, Any] = field(default_factory=dict)
@dataclass
class InputContext(InterceptionContext):
"""``input``: resolved inputs for an execution. ``payload`` = inputs."""
inputs: dict[str, Any] = field(default_factory=dict)
@dataclass
class OutputContext(InterceptionContext):
"""``output``: final result of a crew or flow. ``payload`` = the output object."""
output: Any = None
@dataclass
class ExecutionEndContext(InterceptionContext):
"""``execution_end``: a crew or flow has finished. ``payload`` = the output object.
Dispatched on both successful and failed executions. ``status`` is
``"completed"`` on success and ``"failed"`` when the execution raised;
``error`` carries the exception on failure (``output``/``payload`` are
``None`` in that case).
"""
output: Any = None
status: Literal["completed", "failed"] = "completed"
error: BaseException | None = None
@dataclass
class StepContext(InterceptionContext):
"""``pre_step`` / ``post_step``: a task or flow-method step boundary.
``kind`` is ``"task"`` for crew tasks and ``"flow_method"`` for flow methods.
``payload`` is the step input (pre) or step output (post).
"""
kind: str | None = None
step_name: str | None = None
output: Any = None

View File

@@ -0,0 +1,432 @@
"""Generic interception-hook dispatcher.
This module is the single engine behind every CrewAI interception point. A hook
receives a typed context, may mutate it in place and/or return a replacement
payload, and may raise :class:`HookAborted` to stop the intercepted operation
with a reason and source.
The four public hook families (``before/after_llm_call`` and
``before/after_tool_call``) are adapters registered on this dispatcher, so the
legacy dialect (``register_*``/decorators/``return False``) and the new dialect
(``@on(point)`` / ``HookAborted``) share one ordered queue per point.
Design notes:
- Global registration order is preserved; execution-scoped hooks (via
``contextvars``) run after global ones, mirroring
``events/event_bus.py``'s ``_runtime_state_var`` scoping pattern.
- ``dispatch`` has a no-op fast path (a single dict lookup) when no hooks are
registered for a point.
- Hooks are synchronous. They may be invoked from async seams, so they must not
block on heavy I/O (same restriction as the legacy hooks).
- ``HookAborted`` propagates by design. Any other exception raised by a hook is
swallowed (fail-open) to preserve the framework's protection against a buggy
user hook.
"""
from __future__ import annotations
from collections.abc import Callable, Iterator
from contextlib import contextmanager
import contextvars
from enum import Enum
from functools import wraps
import inspect
import time
from typing import Any
from crewai.utilities.string_utils import sanitize_tool_name
class InterceptionPoint(str, Enum):
"""Interception points wired by this layer.
New points are introduced alongside the seams that dispatch them, so the
enum only ever lists points with a live consumer.
"""
# Execution-level boundaries
EXECUTION_START = "execution_start"
INPUT = "input"
OUTPUT = "output"
EXECUTION_END = "execution_end"
# Model / tool boundaries (legacy-compatible)
PRE_MODEL_CALL = "pre_model_call"
POST_MODEL_CALL = "post_model_call"
PRE_TOOL_CALL = "pre_tool_call"
POST_TOOL_CALL = "post_tool_call"
# Step points
PRE_STEP = "pre_step"
POST_STEP = "post_step"
class HookAborted(Exception): # noqa: N818 - public contract name from OSS-86
"""Raised by a hook (or a legacy adapter) to abort the intercepted operation.
Args:
reason: Human-readable explanation of why the operation was aborted.
source: Optional identifier of the aborting hook (callable, string, or
any object). Used for telemetry and failure messages.
"""
def __init__(self, reason: str, source: Any = None) -> None:
super().__init__(reason)
self.reason = reason
self.source = source
HookFn = Callable[[Any], Any]
# (ctx, result) -> modified? A reducer maps a hook's return value onto the
# context using point-specific semantics. It may raise HookAborted.
Reducer = Callable[[Any, Any], bool]
_global_hooks: dict[InterceptionPoint, list[HookFn]] = {
point: [] for point in InterceptionPoint
}
_scoped_hooks_var: contextvars.ContextVar[
dict[InterceptionPoint, list[HookFn]] | None
] = contextvars.ContextVar("crewai_scoped_hooks", default=None)
_TELEMETRY_SOURCE = object()
def get_global_hook_list(point: InterceptionPoint) -> list[HookFn]:
"""Return the live global hook list for a point.
The returned list object is stable for the lifetime of the process, which
lets legacy modules alias their module-level registries to it. Mutate it in
place (append/remove/clear); never rebind it.
"""
return _global_hooks[point]
def register(point: InterceptionPoint, hook: HookFn) -> None:
"""Register a global hook for an interception point."""
_global_hooks[point].append(hook)
def unregister(point: InterceptionPoint, hook: HookFn) -> bool:
"""Unregister a specific global hook. Returns True if it was removed.
When ``hook`` was registered through :func:`on` with ``agents``/``tools``
filters, the stored callable is a wrapper rather than ``hook`` itself. The
wrapper is stashed on ``hook._registered_hook`` at registration time, so it
can be resolved and removed here.
"""
hooks = _global_hooks[point]
target = hook if hook in hooks else getattr(hook, "_registered_hook", hook)
try:
hooks.remove(target)
return True
except ValueError:
return False
def get_hooks(point: InterceptionPoint) -> list[HookFn]:
"""Return a copy of the global hooks registered for a point."""
return _global_hooks[point].copy()
def clear(point: InterceptionPoint) -> int:
"""Clear all global hooks for a point. Returns the number cleared."""
count = len(_global_hooks[point])
_global_hooks[point].clear()
return count
def clear_all() -> None:
"""Clear all global hooks across every interception point."""
for hooks in _global_hooks.values():
hooks.clear()
@contextmanager
def scoped_hooks(
hooks: dict[InterceptionPoint, list[HookFn]] | None = None,
) -> Iterator[dict[InterceptionPoint, list[HookFn]]]:
"""Enter an execution-scoped hook registry.
Hooks registered inside this context (via :func:`register_scoped`) run after
global hooks and are discarded when the context exits. Mirrors the event
bus's scoped-handler pattern.
"""
scope: dict[InterceptionPoint, list[HookFn]] = hooks if hooks is not None else {}
token = _scoped_hooks_var.set(scope)
try:
yield scope
finally:
_scoped_hooks_var.reset(token)
def register_scoped(point: InterceptionPoint, hook: HookFn) -> None:
"""Register a hook scoped to the current :func:`scoped_hooks` context."""
scope = _scoped_hooks_var.get()
if scope is None:
raise RuntimeError(
"register_scoped() called outside of a scoped_hooks() context"
)
scope.setdefault(point, []).append(hook)
def get_scoped_hooks(point: InterceptionPoint) -> list[HookFn]:
"""Return the hooks registered in the current execution scope for a point.
Used by seams that carry a pre-snapshotted hook list (e.g. the agent
executors' per-executor LLM hook lists) so they can merge in
execution-scoped hooks with the same snapshot-then-scoped ordering that
:func:`dispatch` applies to global vs scoped hooks.
"""
scope = _scoped_hooks_var.get()
if not scope:
return []
return list(scope.get(point, []))
def _resolve_hooks(point: InterceptionPoint) -> list[HookFn]:
"""Resolve the ordered hooks for a point: global first, then scoped."""
global_hooks = _global_hooks[point]
scope = _scoped_hooks_var.get()
if scope:
scoped = scope.get(point)
if scoped:
return [*global_hooks, *scoped]
return global_hooks
def _source_name(source: Any) -> str | None:
"""Best-effort readable name for a hook source."""
if source is None:
return None
if isinstance(source, str):
return source
name = getattr(source, "__name__", None)
if isinstance(name, str):
return name
return type(source).__name__
def _emit_telemetry(
point: InterceptionPoint,
outcome: str,
hook_count: int,
duration_ms: float,
abort_reason: str | None,
abort_source: str | None,
) -> None:
"""Emit a HookDispatchedEvent. Never raises."""
try:
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.hook_events import HookDispatchedEvent
crewai_event_bus.emit(
_TELEMETRY_SOURCE,
event=HookDispatchedEvent(
interception_point=point.value,
outcome=outcome,
hook_count=hook_count,
duration_ms=duration_ms,
abort_reason=abort_reason,
abort_source=abort_source,
),
)
except Exception: # noqa: S110 - telemetry must never break dispatch
pass
def _default_reducer(ctx: Any, result: Any) -> bool:
"""Default payload semantics: a non-None return replaces ``ctx.payload``.
Only reports a modification when the payload was actually applied, so a
context without a ``payload`` attribute does not produce a misleading
``"modified"`` telemetry outcome.
"""
if result is not None and hasattr(ctx, "payload"):
ctx.payload = result
return True
return False
def _invoke_hook(
point: InterceptionPoint,
hook: HookFn,
ctx: Any,
reducer: Reducer,
verbose: bool,
) -> bool:
"""Run a single hook and apply its result via the reducer.
Returns whether the context was modified. Raises :class:`HookAborted` (with
``source`` populated) to abort; any other exception is swallowed (fail-open).
"""
try:
result = hook(ctx)
return reducer(ctx, result)
except HookAborted as aborted:
if aborted.source is None:
aborted.source = hook
raise
except Exception as error:
if verbose:
from crewai_core.printer import PRINTER
PRINTER.print(
content=f"Error in {point.value} hook: {error}",
color="yellow",
)
return False
def run_hooks(
point: InterceptionPoint,
ctx: Any,
hooks: list[HookFn],
*,
reducer: Reducer | None = None,
verbose: bool = True,
) -> Any:
"""Execute an explicit list of hooks against a context.
This is the shared engine used both by :func:`dispatch` (which resolves
global + scoped hooks) and by seams that carry a pre-snapshotted hook list
(e.g. per-executor LLM hook lists).
Args:
point: The interception point being dispatched.
ctx: The typed context passed to each hook (mutated in place).
hooks: The ordered hooks to run.
reducer: Maps each hook's return value onto ``ctx``. Defaults to
:func:`_default_reducer` (payload replacement). May raise
:class:`HookAborted`.
verbose: Whether to print swallowed-hook-error warnings.
Returns:
The (possibly mutated) context.
Raises:
HookAborted: If a hook or the reducer aborts the operation. Telemetry is
still emitted before propagating.
"""
if not hooks:
return ctx
active_reducer = reducer if reducer is not None else _default_reducer
start = time.perf_counter()
outcome = "proceeded"
abort_reason: str | None = None
abort_source: str | None = None
modified = False
try:
for hook in list(hooks):
if _invoke_hook(point, hook, ctx, active_reducer, verbose):
modified = True
outcome = "modified" if modified else "proceeded"
return ctx
except HookAborted as aborted:
outcome = "aborted"
abort_reason = aborted.reason
abort_source = _source_name(aborted.source)
raise
finally:
_emit_telemetry(
point,
outcome,
len(hooks),
(time.perf_counter() - start) * 1000.0,
abort_reason,
abort_source,
)
def dispatch(
point: InterceptionPoint,
ctx: Any,
*,
reducer: Reducer | None = None,
verbose: bool = True,
) -> Any:
"""Dispatch a context to all hooks registered for a point.
Resolves global then scoped hooks and runs them through :func:`run_hooks`.
No-op fast path when nothing is registered.
"""
hooks = _resolve_hooks(point)
if not hooks:
return ctx
return run_hooks(point, ctx, hooks, reducer=reducer, verbose=verbose)
def _wrap_with_filters(
func: HookFn,
agents: list[str] | None,
tools: list[str] | None,
) -> HookFn:
"""Wrap a hook so it only runs for matching agents/tools (context-shape aware)."""
@wraps(func)
def filtered(ctx: Any) -> Any:
if tools:
tool_name = getattr(ctx, "tool_name", None)
if tool_name is not None and tool_name not in tools:
return None
if agents:
agent = getattr(ctx, "agent", None)
role = getattr(agent, "role", None) if agent is not None else None
if role is None:
role = getattr(ctx, "agent_role", None)
if role is not None and role not in agents:
return None
return func(ctx)
return filtered
def on(
point: InterceptionPoint,
*,
agents: list[str] | None = None,
tools: list[str] | None = None,
) -> Callable[[HookFn], HookFn]:
"""Register a function as a hook for an interception point.
Mirrors the legacy decorators' ergonomics: supports ``agents=`` / ``tools=``
filters and, when applied to a method inside a ``@CrewBase`` class, defers
registration to crew initialization (crew-scoped) instead of registering
globally.
Example:
>>> @on(InterceptionPoint.PRE_TOOL_CALL, tools=["delete_file"])
... def guard(ctx):
... raise HookAborted("deletion not allowed")
"""
normalized_tools = [sanitize_tool_name(t) for t in tools] if tools else None
def decorator(func: HookFn) -> HookFn:
func._interception_point = point # type: ignore[attr-defined]
if normalized_tools:
func._filter_tools = normalized_tools # type: ignore[attr-defined]
if agents:
func._filter_agents = agents # type: ignore[attr-defined]
params = list(inspect.signature(func).parameters.keys())
is_method = len(params) >= 2 and params[0] == "self"
if not is_method:
hook = (
_wrap_with_filters(func, agents, normalized_tools)
if (agents or normalized_tools)
else func
)
register(point, hook)
# Remember the actually-registered callable so unregister_hook(func)
# can resolve the filter wrapper.
func._registered_hook = hook # type: ignore[attr-defined]
return func
return decorator

View File

@@ -5,6 +5,11 @@ from typing import TYPE_CHECKING, Any, cast
from crewai_core.printer import PRINTER
from crewai.events.event_listener import event_listener
from crewai.hooks.dispatch import (
HookAborted,
InterceptionPoint,
get_global_hook_list,
)
from crewai.hooks.types import (
AfterLLMCallHookCallable,
AfterLLMCallHookType,
@@ -150,8 +155,37 @@ class LLMCallHookContext:
event_listener.formatter.resume_live_updates()
_before_llm_call_hooks: list[BeforeLLMCallHookType | BeforeLLMCallHookCallable] = []
_after_llm_call_hooks: list[AfterLLMCallHookType | AfterLLMCallHookCallable] = []
# The legacy registries are aliased to the generic dispatcher's global hook
# lists for the model-call points, so legacy registrations and new-dialect
# ``@on(InterceptionPoint.PRE_MODEL_CALL)`` hooks share one ordered queue.
_before_llm_call_hooks: list[BeforeLLMCallHookType | BeforeLLMCallHookCallable] = (
get_global_hook_list(InterceptionPoint.PRE_MODEL_CALL)
)
_after_llm_call_hooks: list[AfterLLMCallHookType | AfterLLMCallHookCallable] = (
get_global_hook_list(InterceptionPoint.POST_MODEL_CALL)
)
def before_llm_call_reducer(context: LLMCallHookContext, result: object) -> bool:
"""Legacy calling convention for ``pre_model_call`` hooks.
A ``False`` return aborts the call (mapped to :class:`HookAborted`); messages
are modified in place, so no payload replacement occurs here.
"""
if result is False:
raise HookAborted(reason="before_llm_call hook returned False")
return False
def after_llm_call_reducer(context: LLMCallHookContext, result: object) -> bool:
"""Legacy calling convention for ``post_model_call`` hooks.
A non-empty string return replaces the response on the context.
"""
if result is not None and isinstance(result, str):
context.response = result
return True
return False
def register_before_llm_call_hook(

View File

@@ -5,6 +5,12 @@ from typing import TYPE_CHECKING, Any
from crewai_core.printer import PRINTER
from crewai.events.event_listener import event_listener
from crewai.hooks.dispatch import (
HookAborted,
InterceptionPoint,
dispatch,
get_global_hook_list,
)
from crewai.hooks.types import (
AfterToolCallHookCallable,
AfterToolCallHookType,
@@ -121,8 +127,81 @@ class ToolCallHookContext:
event_listener.formatter.resume_live_updates()
_before_tool_call_hooks: list[BeforeToolCallHookType | BeforeToolCallHookCallable] = []
_after_tool_call_hooks: list[AfterToolCallHookType | AfterToolCallHookCallable] = []
# The legacy registries are aliased to the generic dispatcher's global hook
# lists for the tool-call points, so legacy registrations and new-dialect
# ``@on(InterceptionPoint.PRE_TOOL_CALL)`` hooks share one ordered queue.
_before_tool_call_hooks: list[BeforeToolCallHookType | BeforeToolCallHookCallable] = (
get_global_hook_list(InterceptionPoint.PRE_TOOL_CALL)
)
_after_tool_call_hooks: list[AfterToolCallHookType | AfterToolCallHookCallable] = (
get_global_hook_list(InterceptionPoint.POST_TOOL_CALL)
)
def before_tool_call_reducer(context: ToolCallHookContext, result: object) -> bool:
"""Legacy calling convention for ``pre_tool_call`` hooks.
A ``False`` return blocks the call (mapped to :class:`HookAborted`); tool
input is modified in place, so no payload replacement occurs here.
"""
if result is False:
raise HookAborted(reason="before_tool_call hook returned False")
return False
def after_tool_call_reducer(context: ToolCallHookContext, result: object) -> bool:
"""Legacy calling convention for ``post_tool_call`` hooks.
A non-None return replaces the tool result on the context.
"""
if isinstance(result, str):
context.tool_result = result
return True
return False
def _hook_verbose(context: ToolCallHookContext) -> bool:
"""Whether swallowed-hook-error warnings should be printed.
Mirrors the pre-dispatcher behavior where a failing tool hook surfaced a
warning when the executing agent was verbose.
"""
return bool(getattr(context.agent, "verbose", False))
def run_before_tool_call_hooks(context: ToolCallHookContext) -> bool:
"""Run all ``pre_tool_call`` hooks against a context.
Returns:
True if a hook blocked execution (returned False or raised
:class:`HookAborted`), False otherwise. Tool input mutations on the
context persist regardless.
"""
try:
dispatch(
InterceptionPoint.PRE_TOOL_CALL,
context,
reducer=before_tool_call_reducer,
verbose=_hook_verbose(context),
)
return False
except HookAborted:
return True
def run_after_tool_call_hooks(context: ToolCallHookContext) -> str | None:
"""Run all ``post_tool_call`` hooks against a context.
Returns:
The (possibly modified) tool result carried on the context.
"""
dispatch(
InterceptionPoint.POST_TOOL_CALL,
context,
reducer=after_tool_call_reducer,
verbose=_hook_verbose(context),
)
return context.tool_result
def register_before_tool_call_hook(

View File

@@ -1007,15 +1007,14 @@ class BaseLLM(BaseModel, ABC):
from crewai_core.printer import PRINTER
from crewai.hooks.dispatch import HookAborted, InterceptionPoint, dispatch
from crewai.hooks.llm_hooks import (
LLMCallHookContext,
get_before_llm_call_hooks,
before_llm_call_reducer,
)
before_hooks = get_before_llm_call_hooks()
if not before_hooks:
return True
# No early global-list guard: dispatch resolves global + execution-scoped
# hooks and has its own no-op fast path, so scoped hooks still run here.
hook_context = LLMCallHookContext(
executor=None,
messages=messages,
@@ -1024,24 +1023,19 @@ class BaseLLM(BaseModel, ABC):
task=None,
crew=None,
)
verbose = getattr(from_agent, "verbose", True) if from_agent else True
try:
for hook in before_hooks:
result = hook(hook_context)
if result is False:
if verbose:
PRINTER.print(
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
except Exception as e:
if verbose:
PRINTER.print(
content=f"Error in before_llm_call hook: {e}",
color="yellow",
)
dispatch(
InterceptionPoint.PRE_MODEL_CALL,
hook_context,
reducer=before_llm_call_reducer,
)
except HookAborted:
PRINTER.print(
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
return True
@@ -1074,17 +1068,14 @@ class BaseLLM(BaseModel, ABC):
if from_agent is not None or not isinstance(response, str):
return response
from crewai_core.printer import PRINTER
from crewai.hooks.dispatch import InterceptionPoint, dispatch
from crewai.hooks.llm_hooks import (
LLMCallHookContext,
get_after_llm_call_hooks,
after_llm_call_reducer,
)
after_hooks = get_after_llm_call_hooks()
if not after_hooks:
return response
# No early global-list guard: dispatch resolves global + execution-scoped
# hooks and has its own no-op fast path, so scoped hooks still run here.
hook_context = LLMCallHookContext(
executor=None,
messages=messages,
@@ -1094,20 +1085,11 @@ class BaseLLM(BaseModel, ABC):
crew=None,
response=response,
)
verbose = getattr(from_agent, "verbose", True) if from_agent else True
modified_response = response
try:
for hook in after_hooks:
result = hook(hook_context)
if result is not None and isinstance(result, str):
modified_response = result
hook_context.response = modified_response
except Exception as e:
if verbose:
PRINTER.print(
content=f"Error in after_llm_call hook: {e}",
color="yellow",
)
dispatch(
InterceptionPoint.POST_MODEL_CALL,
hook_context,
reducer=after_llm_call_reducer,
)
return modified_response
return hook_context.response if hook_context.response is not None else response

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import logging
import os
from typing import Any, Final, Literal, TypeGuard, cast
from typing import Any, Final, Literal, Protocol, TypeGuard, TypedDict, cast
from pydantic import BaseModel, PrivateAttr, model_validator
@@ -124,6 +124,68 @@ def _contains_file_id_reference(messages: list[dict[str, Any]]) -> bool:
return False
class _ToolUseDictBlock(TypedDict):
type: Literal["tool_use"]
id: str
name: str
input: dict[str, Any]
class _ToolUseObjectBlock(Protocol):
type: Literal["tool_use"]
id: str
name: str
input: dict[str, object]
_AnthropicToolUseBlock = (
ToolUseBlock | BetaToolUseBlock | _ToolUseDictBlock | _ToolUseObjectBlock
)
def _is_tool_use_block(block: Any) -> TypeGuard[_AnthropicToolUseBlock]:
"""Return true for complete Anthropic tool-use blocks across SDK shapes.
New/preview Anthropic models can return content blocks whose concrete SDK
class is not one of the imported ``ToolUseBlock`` aliases. The stable API
contract used by execution, streaming, and follow-up paths is ``type``,
``id``, ``name``, and ``input``, so validate that full shape before treating
a block as tool-use.
"""
if isinstance(block, dict):
return (
block.get("type") == "tool_use"
and isinstance(block.get("id"), str)
and isinstance(block.get("name"), str)
and isinstance(block.get("input"), dict)
)
return (
getattr(block, "type", None) == "tool_use"
and isinstance(getattr(block, "id", None), str)
and isinstance(getattr(block, "name", None), str)
and isinstance(getattr(block, "input", None), dict)
)
def _tool_use_blocks(blocks: list[Any]) -> list[_AnthropicToolUseBlock]:
return [block for block in blocks if _is_tool_use_block(block)]
def _tool_use_id(block: _AnthropicToolUseBlock) -> str:
return block["id"] if isinstance(block, dict) else block.id
def _tool_use_name(block: _AnthropicToolUseBlock) -> str:
return block["name"] if isinstance(block, dict) else block.name
def _tool_use_input(block: _AnthropicToolUseBlock) -> dict[str, Any]:
return (
block["input"] if isinstance(block, dict) else cast(dict[str, Any], block.input)
)
class AnthropicThinkingConfig(BaseModel):
type: Literal["enabled", "disabled"]
budget_tokens: int | None = None
@@ -589,20 +651,41 @@ class AnthropicCompletion(BaseLLM):
Returns:
Dictionary with thinking block data including signature, or None if not a thinking block
"""
if content_block.type == "thinking":
block_type = (
content_block.get("type")
if isinstance(content_block, dict)
else getattr(content_block, "type", None)
)
if block_type == "thinking":
thinking = (
content_block.get("thinking")
if isinstance(content_block, dict)
else content_block.thinking
)
thinking_block = {
"type": "thinking",
"thinking": content_block.thinking,
"thinking": thinking,
}
if hasattr(content_block, "signature"):
thinking_block["signature"] = content_block.signature
signature = (
content_block.get("signature")
if isinstance(content_block, dict)
else getattr(content_block, "signature", None)
)
if signature:
thinking_block["signature"] = signature
return thinking_block
if content_block.type == "redacted_thinking":
if block_type == "redacted_thinking":
redacted_block = {"type": "redacted_thinking"}
if hasattr(content_block, "thinking"):
redacted_block["thinking"] = content_block.thinking
if hasattr(content_block, "signature"):
redacted_block["signature"] = content_block.signature
if isinstance(content_block, dict):
if "thinking" in content_block:
redacted_block["thinking"] = content_block["thinking"]
if "signature" in content_block:
redacted_block["signature"] = content_block["signature"]
else:
if hasattr(content_block, "thinking"):
redacted_block["thinking"] = content_block.thinking
if hasattr(content_block, "signature"):
redacted_block["signature"] = content_block.signature
return redacted_block
return None
@@ -944,10 +1027,12 @@ class AnthropicCompletion(BaseLLM):
else:
for block in response.content:
if (
isinstance(block, (ToolUseBlock, BetaToolUseBlock))
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_name(block) == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_input(block)
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -962,11 +1047,7 @@ class AnthropicCompletion(BaseLLM):
# Check if Claude wants to use tools
if response.content:
tool_uses = [
block
for block in response.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(response.content))
if tool_uses:
# Without available_functions, return tool calls so the executor can
@@ -1093,11 +1174,13 @@ class AnthropicCompletion(BaseLLM):
if event.type == "content_block_start":
block = event.content_block
if block.type == "tool_use":
if _is_tool_use_block(block):
block_index = event.index
tool_use_id = _tool_use_id(block)
tool_name = _tool_use_name(block)
current_tool_calls[block_index] = {
"id": block.id,
"name": block.name,
"id": tool_use_id,
"name": tool_name,
"arguments": "",
"index": block_index,
}
@@ -1106,9 +1189,9 @@ class AnthropicCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
tool_call={
"id": block.id,
"id": tool_use_id,
"function": {
"name": block.name,
"name": tool_name,
"arguments": "",
},
"type": "function",
@@ -1177,10 +1260,12 @@ class AnthropicCompletion(BaseLLM):
return structured_data
for block in final_message.content:
if (
isinstance(block, ToolUseBlock)
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_name(block) == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_input(block)
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1194,11 +1279,7 @@ class AnthropicCompletion(BaseLLM):
return structured_data
if final_message.content:
tool_uses = [
block
for block in final_message.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(final_message.content))
if tool_uses:
if not available_functions:
@@ -1229,7 +1310,7 @@ class AnthropicCompletion(BaseLLM):
def _execute_tools_and_collect_results(
self,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[_AnthropicToolUseBlock],
available_functions: dict[str, Any],
from_task: Any | None = None,
from_agent: Any | None = None,
@@ -1248,12 +1329,12 @@ class AnthropicCompletion(BaseLLM):
tool_results = []
for tool_use in tool_uses:
function_name = tool_use.name
function_args = tool_use.input
function_name = _tool_use_name(tool_use)
function_args = _tool_use_input(tool_use)
result = self._handle_tool_execution(
function_name=function_name,
function_args=cast(dict[str, Any], function_args),
function_args=function_args,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
@@ -1261,7 +1342,7 @@ class AnthropicCompletion(BaseLLM):
tool_result = {
"type": "tool_result",
"tool_use_id": tool_use.id,
"tool_use_id": _tool_use_id(tool_use),
"content": str(result)
if result is not None
else "Tool execution completed",
@@ -1272,7 +1353,7 @@ class AnthropicCompletion(BaseLLM):
def _execute_first_tool(
self,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[_AnthropicToolUseBlock],
available_functions: dict[str, Any],
from_task: Any | None = None,
from_agent: Any | None = None,
@@ -1293,8 +1374,8 @@ class AnthropicCompletion(BaseLLM):
The result of the first tool execution, or None if execution failed
"""
tool_use = tool_uses[0]
function_name = tool_use.name
function_args = cast(dict[str, Any], tool_use.input)
function_name = _tool_use_name(tool_use)
function_args = _tool_use_input(tool_use)
return self._handle_tool_execution(
function_name=function_name,
@@ -1308,7 +1389,7 @@ class AnthropicCompletion(BaseLLM):
def _handle_tool_use_conversation(
self,
initial_response: Message | BetaMessage,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[_AnthropicToolUseBlock],
params: dict[str, Any],
available_functions: dict[str, Any],
from_task: Any | None = None,
@@ -1335,13 +1416,13 @@ class AnthropicCompletion(BaseLLM):
thinking_block = self._extract_thinking_block(block)
if thinking_block:
assistant_content.append(thinking_block)
elif block.type == "tool_use":
elif _is_tool_use_block(block):
assistant_content.append(
{
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
"id": _tool_use_id(block),
"name": _tool_use_name(block),
"input": _tool_use_input(block),
}
)
elif hasattr(block, "text"):
@@ -1494,10 +1575,12 @@ class AnthropicCompletion(BaseLLM):
else:
for block in response.content:
if (
isinstance(block, ToolUseBlock)
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_name(block) == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_input(block)
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1510,13 +1593,9 @@ class AnthropicCompletion(BaseLLM):
)
return structured_data
# Handle both ToolUseBlock (regular API) and BetaToolUseBlock (beta API features)
# Handle Anthropic tool-use blocks across stable, beta, and preview SDK shapes.
if response.content:
tool_uses = [
block
for block in response.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(response.content))
if tool_uses:
if not available_functions:
@@ -1629,11 +1708,13 @@ class AnthropicCompletion(BaseLLM):
if event.type == "content_block_start":
block = event.content_block
if block.type == "tool_use":
if _is_tool_use_block(block):
block_index = event.index
tool_use_id = _tool_use_id(block)
tool_name = _tool_use_name(block)
current_tool_calls[block_index] = {
"id": block.id,
"name": block.name,
"id": tool_use_id,
"name": tool_name,
"arguments": "",
"index": block_index,
}
@@ -1642,9 +1723,9 @@ class AnthropicCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
tool_call={
"id": block.id,
"id": tool_use_id,
"function": {
"name": block.name,
"name": tool_name,
"arguments": "",
},
"type": "function",
@@ -1703,10 +1784,12 @@ class AnthropicCompletion(BaseLLM):
return structured_data
for block in final_message.content:
if (
isinstance(block, ToolUseBlock)
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_name(block) == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_input(block)
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1720,11 +1803,7 @@ class AnthropicCompletion(BaseLLM):
return structured_data
if final_message.content:
tool_uses = [
block
for block in final_message.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(final_message.content))
if tool_uses:
if not available_functions:
@@ -1754,7 +1833,7 @@ class AnthropicCompletion(BaseLLM):
async def _ahandle_tool_use_conversation(
self,
initial_response: Message | BetaMessage,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[_AnthropicToolUseBlock],
params: dict[str, Any],
available_functions: dict[str, Any],
from_task: Any | None = None,

View File

@@ -8,7 +8,14 @@ import os
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict
import httpx
from openai import APIConnectionError, AsyncOpenAI, NotFoundError, OpenAI, Stream
from openai import (
APIConnectionError,
AsyncOpenAI,
BadRequestError,
NotFoundError,
OpenAI,
Stream,
)
from openai.lib.streaming.chat import ChatCompletionStream
from openai.types.chat import (
ChatCompletion,
@@ -51,6 +58,13 @@ if TYPE_CHECKING:
from crewai.tools.base_tool import BaseTool
# Models learned at runtime to be unavailable on /v1/chat/completions, keyed by
# the model string as configured. Populated from the 404 by
# `_remember_responses_only_model` so the wasted round trip is paid once per model
# per process rather than on every call.
_LEARNED_RESPONSES_ONLY_MODELS: set[str] = set()
class WebSearchResult(TypedDict, total=False):
"""Result from web search built-in tool."""
@@ -453,7 +467,7 @@ class OpenAICompletion(BaseLLM):
):
raise ValueError("LLM call blocked by before_llm_call hook")
if self.api == "responses":
if self._effective_api() == "responses":
return self._call_responses(
messages=formatted_messages,
tools=tools,
@@ -489,27 +503,65 @@ class OpenAICompletion(BaseLLM):
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Call OpenAI Chat Completions API."""
"""Call OpenAI Chat Completions API.
Falls back to the Responses API when the model turns out not to be served
by chat completions, which OpenAI reports as a 404 distinct from a genuine
unknown model.
"""
completion_params = self._prepare_completion_params(
messages=messages, tools=tools
)
if self._effective_stream():
return self._handle_streaming_completion(
params=completion_params,
def dispatch(params: dict[str, Any]) -> str | Any:
if self._effective_stream():
return self._handle_streaming_completion(
params=params,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
return self._handle_completion(
params=params,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
return self._handle_completion(
params=completion_params,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
try:
return dispatch(completion_params)
except Exception as e:
cause = e.__cause__ or e
if self._rejects_reasoning_effort_with_tools(cause):
retry_params = self._reasoning_effort_none_params(completion_params)
if retry_params is not None:
logging.debug(
'Retrying %r with reasoning_effort="none": function tools '
"and reasoning effort cannot be combined on "
'/v1/chat/completions. Use api="responses" to keep both.',
self.model,
)
return dispatch(retry_params)
if self.custom_openai or not self._is_responses_only_error(cause):
raise
self._remember_responses_only_model()
logging.debug(
"Retrying %r on the Responses API: not served by "
'/v1/chat/completions. Set api="responses" to skip this.',
self.model,
)
return self._call_responses(
messages=messages,
tools=tools,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
async def acall(
self,
@@ -548,7 +600,7 @@ class OpenAICompletion(BaseLLM):
formatted_messages = self._format_messages(messages)
if self.api == "responses":
if self._effective_api() == "responses":
return await self._acall_responses(
messages=formatted_messages,
tools=tools,
@@ -584,27 +636,53 @@ class OpenAICompletion(BaseLLM):
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Async call to OpenAI Chat Completions API."""
"""Async call to OpenAI Chat Completions API.
Falls back to the Responses API for models not served by chat completions,
mirroring :meth:`_call_completions`.
"""
completion_params = self._prepare_completion_params(
messages=messages, tools=tools
)
if self._effective_stream():
return await self._ahandle_streaming_completion(
params=completion_params,
async def dispatch(params: dict[str, Any]) -> str | Any:
if self._effective_stream():
return await self._ahandle_streaming_completion(
params=params,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
return await self._ahandle_completion(
params=params,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
return await self._ahandle_completion(
params=completion_params,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
try:
return await dispatch(completion_params)
except Exception as e:
cause = e.__cause__ or e
if self._rejects_reasoning_effort_with_tools(cause):
retry_params = self._reasoning_effort_none_params(completion_params)
if retry_params is not None:
return await dispatch(retry_params)
if self.custom_openai or not self._is_responses_only_error(cause):
raise
self._remember_responses_only_model()
return await self._acall_responses(
messages=messages,
tools=tools,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
def _call_responses(
self,
@@ -668,30 +746,36 @@ class OpenAICompletion(BaseLLM):
response_model=response_model,
)
def _convert_message_to_responses_input_items(
self, message: LLMMessage
) -> list[dict[str, Any] | LLMMessage]:
"""Convert a Chat-Completions-style message into Responses API input items.
@staticmethod
def _to_responses_input(message: LLMMessage) -> list[dict[str, Any] | LLMMessage]:
"""Translate a chat-format message into Responses ``input`` items.
The Responses API has no message shape for an assistant turn carrying
``tool_calls`` or for a ``tool`` role reply - those become standalone
``function_call`` / ``function_call_output`` input items instead. Plain
user/assistant text messages pass through unchanged (accepted as-is by
the Responses API's lenient "easy input message" shape).
Tool calling is expressed differently by the two APIs. Chat Completions
uses an assistant message carrying ``tool_calls`` (with ``content: None``)
followed by ``role: "tool"`` results; the Responses API uses flat
``function_call`` / ``function_call_output`` items keyed by ``call_id``.
Passing the chat shape straight through is rejected:
Invalid type for 'input[1].content': expected one of an array of
objects or string, but got null instead.
Anything without tool calls is already valid and passes through unchanged.
"""
role = message.get("role")
if role == "assistant" and message.get("tool_calls"):
items: list[dict[str, Any] | LLMMessage] = []
if message.get("content"):
items.append({"role": "assistant", "content": message["content"]})
for tool_call in message["tool_calls"]:
function = tool_call.get("function", {})
content = message.get("content")
if content:
items.append({"role": "assistant", "content": content})
for call in message["tool_calls"]:
function = call.get("function", {})
args = function.get("arguments", "")
items.append(
{
"type": "function_call",
"call_id": tool_call.get("id") or f"call_{id(tool_call)}",
"call_id": call.get("id") or f"call_{id(call)}",
"name": function.get("name", ""),
"arguments": args
if isinstance(args, str)
@@ -705,7 +789,7 @@ class OpenAICompletion(BaseLLM):
{
"type": "function_call_output",
"call_id": message.get("tool_call_id", ""),
"output": message.get("content") or "",
"output": str(message.get("content", "")),
}
]
@@ -737,9 +821,7 @@ class OpenAICompletion(BaseLLM):
else:
instructions = content_str
else:
input_messages.extend(
self._convert_message_to_responses_input_items(message)
)
input_messages.extend(self._to_responses_input(message))
# Prepend reasoning items for ZDR (zero-data-retention) chaining when configured
final_input: list[Any] = []
@@ -1602,6 +1684,105 @@ class OpenAICompletion(BaseLLM):
"""
return [item for item in response.output if item.type == "reasoning"]
@staticmethod
def _is_responses_only_error(error: BaseException) -> bool:
"""Whether a 404 means the model exists but isn't on chat completions.
OpenAI distinguishes the two 404s it returns here:
responses-only param="model", "only supported in v1/responses"
or "This is not a chat model"
genuine typo code="model_not_found", "does not exist"
Matching the former lets a newly Responses-only model be recovered without
needing to be listed in this file.
"""
if not isinstance(error, NotFoundError):
return False
body = getattr(error, "body", None)
inner = body.get("error") if isinstance(body, dict) else None
if isinstance(inner, dict) and inner.get("code") == "model_not_found":
return False
text = str(getattr(error, "message", "") or error).lower()
return "only supported in v1/responses" in text or "not a chat model" in text
def _remember_responses_only_model(self) -> None:
"""Record that this model must use the Responses API."""
_LEARNED_RESPONSES_ONLY_MODELS.add(self.model)
def _model_not_found_message(self, error: Exception) -> str:
"""Build a 404 message that says what to do about it.
OpenAI's own wording ("This is not a chat model") doesn't make the fix
obvious, so point at ``api="responses"`` when that's what it means.
"""
if self._is_responses_only_error(error):
return (
f"Model {self.model} is not available on /v1/chat/completions. "
f'Use api="responses" (LLM(model="{self.model}", api="responses")) '
f"to reach it: {error}"
)
return f"Model {self.model} not found: {error}"
@staticmethod
def _rejects_reasoning_effort_with_tools(error: BaseException) -> bool:
"""Whether a 400 is OpenAI refusing `reasoning_effort` alongside tools.
GPT-5.6 applies a server-side `reasoning_effort` default and then rejects
it when function tools are present, so a payload carrying no
`reasoning_effort` at all still fails:
"Function tools with reasoning_effort are not supported for
gpt-5.6-sol in /v1/chat/completions. To use function tools, use
/v1/responses or set reasoning_effort to 'none'."
Matched on the structured `param` field plus the message so the unrelated
"Unsupported value" 400 that o1/o3 return for `reasoning_effort="none"`
doesn't look recoverable.
"""
if not isinstance(error, BadRequestError):
return False
body = getattr(error, "body", None)
source = None
if isinstance(body, dict):
inner = body.get("error")
source = inner if isinstance(inner, dict) else body
if not source or source.get("param") != "reasoning_effort":
return False
message = str(source.get("message") or "").lower()
return "function tools" in message and "reasoning_effort" in message
def _reasoning_effort_none_params(
self, params: dict[str, Any]
) -> dict[str, Any] | None:
"""Params with an explicit `reasoning_effort="none"`, or None if already set.
Removing the key is not enough: absence means "use the server default",
which is what the request was rejected for in the first place.
"""
if params.get("reasoning_effort") == "none":
return None
return {**params, "reasoning_effort": "none"}
def _effective_api(self) -> str:
"""Which OpenAI API to actually use for this model.
Honours an explicit ``api`` setting, but upgrades to ``"responses"`` for
models already known -- from a previous 404 in this process -- not to be
served by chat completions.
Never overrides ``custom_openai=True``: an OpenAI-compatible server may
serve any model name on /v1/chat/completions and most don't implement
/v1/responses at all, so the user's choice stands.
"""
if (
self.api == "completions"
and not self.custom_openai
and self.model in _LEARNED_RESPONSES_ONLY_MODELS
):
return "responses"
return self.api
def _prepare_completion_params(
self, messages: list[LLMMessage], tools: list[dict[str, BaseTool]] | None = None
) -> dict[str, Any]:
@@ -1831,7 +2012,7 @@ class OpenAICompletion(BaseLLM):
params["messages"], content, from_agent
)
except NotFoundError as e:
error_msg = f"Model {self.model} not found: {e}"
error_msg = self._model_not_found_message(e)
logging.error(error_msg)
self._emit_call_failed_event(
error=error_msg, from_task=from_task, from_agent=from_agent
@@ -1849,6 +2030,11 @@ class OpenAICompletion(BaseLLM):
logging.error(f"Context window exceeded: {e}")
raise LLMContextLengthExceededError(str(e)) from e
# `_call_completions` retries this one, so reporting a failed call
# here would surface an error the caller never experiences.
if self._rejects_reasoning_effort_with_tools(e):
raise
error_msg = f"OpenAI API call failed: {e!s}"
logging.error(error_msg)
self._emit_call_failed_event(
@@ -2254,7 +2440,7 @@ class OpenAICompletion(BaseLLM):
if usage.get("total_tokens", 0) > 0:
logging.info(f"OpenAI API usage: {usage}")
except NotFoundError as e:
error_msg = f"Model {self.model} not found: {e}"
error_msg = self._model_not_found_message(e)
logging.error(error_msg)
self._emit_call_failed_event(
error=error_msg, from_task=from_task, from_agent=from_agent
@@ -2272,6 +2458,11 @@ class OpenAICompletion(BaseLLM):
logging.error(f"Context window exceeded: {e}")
raise LLMContextLengthExceededError(str(e)) from e
# `_call_completions` retries this one, so reporting a failed call
# here would surface an error the caller never experiences.
if self._rejects_reasoning_effort_with_tools(e):
raise
error_msg = f"OpenAI API call failed: {e!s}"
logging.error(error_msg)
self._emit_call_failed_event(

View File

@@ -452,7 +452,15 @@ def _register_crew_hooks(instance: CrewInstance, cls: type) -> None:
)
}
if not hook_methods:
# Methods decorated with @on(InterceptionPoint.X) carry ``_interception_point``
# instead of the legacy markers above.
on_methods = {
name: method
for name, method in cls.__dict__.items()
if hasattr(method, "_interception_point")
}
if not hook_methods and not on_methods:
return
from crewai.hooks import (
@@ -588,6 +596,25 @@ def _register_crew_hooks(instance: CrewInstance, cls: type) -> None:
("after_tool_call", after_tool_hook)
)
if on_methods:
from crewai.hooks.dispatch import (
_wrap_with_filters,
register as register_interception_hook,
)
for on_method in on_methods.values():
point = on_method._interception_point
bound_hook = on_method.__get__(instance, cls)
tools_filter = getattr(on_method, "_filter_tools", None)
agents_filter = getattr(on_method, "_filter_agents", None)
hook = (
_wrap_with_filters(bound_hook, agents_filter, tools_filter)
if (tools_filter or agents_filter)
else bound_hook
)
register_interception_hook(point, hook)
instance._registered_hook_functions.append((point.value, hook))
instance._hooks_being_registered = False

View File

@@ -1,8 +1,11 @@
"""Agent Skills standard implementation for crewAI.
Provides filesystem-based skill packaging with progressive disclosure.
Provides filesystem-based skill packaging with progressive disclosure, plus
the registry-backed Skills Repository (`@org/name` refs, global cache,
downloads).
"""
from crewai.skills.cache import SkillCacheManager
from crewai.skills.loader import (
activate_skill,
discover_skills,
@@ -11,14 +14,27 @@ from crewai.skills.loader import (
)
from crewai.skills.models import Skill, SkillFrontmatter
from crewai.skills.parser import SkillParseError
from crewai.skills.registry import (
SkillRef,
is_registry_ref,
parse_registry_ref,
parse_skill_ref,
resolve_registry_ref,
)
__all__ = [
"Skill",
"SkillCacheManager",
"SkillFrontmatter",
"SkillParseError",
"SkillRef",
"activate_skill",
"discover_skills",
"is_registry_ref",
"load_skill",
"load_skills",
"parse_registry_ref",
"parse_skill_ref",
"resolve_registry_ref",
]

View File

@@ -15,6 +15,8 @@ import tarfile
from typing import TypedDict
import zipfile
from crewai.skills.validation import versions_match
_logger = logging.getLogger(__name__)
@@ -39,13 +41,45 @@ class SkillCacheManager:
def _skill_dir(self, org: str, name: str) -> Path:
return self._root / org / name
def get_cached_path(self, org: str, name: str) -> Path | None:
"""Return the cached skill directory path if it exists, else None."""
def get_cached_path(
self, org: str, name: str, version: str | None = None
) -> Path | None:
"""Return the cached skill directory path if usable, else None.
Args:
org: Organisation slug.
name: Skill name.
version: When given, the cached entry must record this version.
The cache holds one version per skill, so a pinned lookup for a
different version reports a miss and the caller re-downloads
rather than loading the wrong version.
Returns:
The cached skill directory, or None on a miss.
"""
skill_dir = self._skill_dir(org, name)
meta_file = skill_dir / _META_FILENAME
if skill_dir.is_dir() and meta_file.exists():
return skill_dir
return None
if not (skill_dir.is_dir() and meta_file.exists()):
return None
if version is not None and not self._records_version(meta_file, version):
return None
return skill_dir
def _records_version(self, meta_file: Path, version: str) -> bool:
"""Return True when the cache metadata records *version*."""
try:
meta = json.loads(meta_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
# ValueError covers both JSONDecodeError and the UnicodeDecodeError
# a non-UTF-8 file raises, so a corrupted entry reads as a miss.
_logger.debug("Unreadable cache entry: %s", meta_file, exc_info=True)
return False
if not isinstance(meta, dict):
_logger.debug("Malformed cache entry: %s", meta_file)
return False
# versions_match() treats a non-string version as no match, so a
# corrupted entry reads as a miss rather than raising.
return versions_match(version, meta.get("version"))
def store(
self, org: str, name: str, version: str | None, archive_bytes: bytes
@@ -88,7 +122,9 @@ class SkillCacheManager:
"version": version,
"installed_at": datetime.now(tz=timezone.utc).isoformat(),
}
(skill_dir / _META_FILENAME).write_text(json.dumps(meta, indent=2))
(skill_dir / _META_FILENAME).write_text(
json.dumps(meta, indent=2), encoding="utf-8"
)
return skill_dir
def list_cached(self) -> list[SkillMetadata]:
@@ -103,7 +139,9 @@ class SkillCacheManager:
meta_file = skill_dir / _META_FILENAME
if meta_file.exists():
try:
results.append(json.loads(meta_file.read_text()))
results.append(
json.loads(meta_file.read_text(encoding="utf-8"))
)
except (json.JSONDecodeError, KeyError):
_logger.debug(
"Skipping malformed cache entry: %s",

View File

@@ -164,7 +164,7 @@ def load_skill(
for s in discover_skills(skill, source=source)
]
if isinstance(skill, str) and skill.startswith("@"):
from crewai.experimental.skills.registry import resolve_registry_ref
from crewai.skills.registry import resolve_registry_ref
return [resolve_registry_ref(skill, source=source)]
if isinstance(skill, str) and skill.lstrip().startswith("---\n"):
@@ -202,7 +202,7 @@ def load_skills(
for skill in load_skill(skill_input, source=source):
dedup_key = skill.name
if isinstance(skill_input, str) and skill_input.startswith("@"):
from crewai.experimental.skills.registry import parse_registry_ref
from crewai.skills.registry import parse_registry_ref
org, _ = parse_registry_ref(skill_input)
dedup_key = f"{org}/{skill.name}"

View File

@@ -0,0 +1,379 @@
"""Registry reference resolution for the Agent Skills standard.
Handles @org/skill-name references (optionally pinned to a version with
@org/skill-name@version), local-first resolution, and downloads via the CrewAI
AMP API with a global cache at ~/.crewai/skills/.
"""
from __future__ import annotations
import inspect
import logging
import os
from pathlib import Path
from typing import Any, NamedTuple
from crewai.skills.cache import SkillCacheManager
from crewai.skills.validation import versions_match
_logger = logging.getLogger(__name__)
class SkillRef(NamedTuple):
"""A parsed registry reference.
Attributes:
org: Organisation slug or uuid.
name: Skill name.
version: Pinned version, or None to resolve the latest.
"""
org: str
name: str
version: str | None = None
def __str__(self) -> str:
ref = f"@{self.org}/{self.name}"
return f"{ref}@{self.version}" if self.version else ref
def is_registry_ref(value: Any) -> bool:
"""Return True if *value* looks like a registry reference (@org/name)."""
return isinstance(value, str) and value.startswith("@")
def parse_skill_ref(ref: str) -> SkillRef:
"""Parse a registry reference into its organisation, name and version.
Accepts '@org/skill-name' and the version-pinned '@org/skill-name@1.2.0'
(a leading 'v' on the version is allowed: '@org/skill-name@v1.2.0').
Args:
ref: A registry reference.
Returns:
The parsed :class:`SkillRef`.
Raises:
ValueError: If the reference format is invalid.
"""
if not ref.startswith("@"):
raise ValueError(f"Registry reference must start with '@', got: {ref!r}")
without_at = ref[1:]
if without_at.count("/") != 1:
raise ValueError(
f"Registry reference must be in '@org/name' format, got: {ref!r}"
)
org, remainder = without_at.split("/", 1)
# Skill names cannot contain '@', so the first one starts the version pin.
name, separator, version = remainder.partition("@")
version = version.strip()
if (
not org
or not name
or org.startswith(".")
or name.startswith(".")
or "/" in org
or "/" in name
):
raise ValueError(
f"Registry reference org and name must be single, non-empty path "
f"segments (no '..' or leading dots), got: {ref!r}"
)
if separator and not version:
raise ValueError(
f"Registry reference version must be non-empty when pinned, got: {ref!r}"
)
if "@" in version:
raise ValueError(
f"Registry reference must carry a single version pin, got: {ref!r}"
)
return SkillRef(org=org, name=name, version=version or None)
def parse_registry_ref(ref: str) -> tuple[str, str]:
"""Parse '@org/skill-name' into (org, name), ignoring any version pin.
Retained for backwards compatibility. Use :func:`parse_skill_ref` to read
the pinned version too.
Args:
ref: A registry reference, e.g. '@acme/my-skill'.
Returns:
An (org, name) tuple.
Raises:
ValueError: If the reference format is invalid.
"""
parsed = parse_skill_ref(ref)
return parsed.org, parsed.name
def resolve_registry_ref(
ref: str,
source: Any = None,
) -> Skill: # type: ignore[name-defined] # noqa: F821
"""Resolve a registry reference to a Skill object.
Resolution order:
1. ./skills/{name}/ in the current working directory (project-local)
2. ~/.crewai/skills/{org}/{name}/ (global cache)
3. Download from registry
A version-pinned reference only accepts a local or cached copy that reports
the pinned version, and downloads otherwise — a pin is a request for a
specific version, not a hint.
Args:
ref: A registry reference, e.g. '@acme/my-skill' or '@acme/my-skill@1.2.0'.
source: Optional source object passed through to skill loaders (for events).
Returns:
A Skill loaded at INSTRUCTIONS disclosure level.
"""
from crewai.skills.loader import activate_skill
org, name, version = parse_skill_ref(ref)
local_path = Path.cwd() / "skills" / name
if local_path.is_dir() and (local_path / "SKILL.md").exists():
# A project-local copy has no installation metadata, so its frontmatter
# is the only thing a pin can be checked against.
skill = _load_matching_skill(local_path, version)
if skill is not None:
return activate_skill(skill, source=source)
cache = SkillCacheManager()
cached_path = cache.get_cached_path(org, name, version=version)
if cached_path is not None and (cached_path / "SKILL.md").exists():
# get_cached_path already matched the pin against the version the cache
# recorded when it stored the archive, which is authoritative. Checking
# the frontmatter as well would miss on every skill that doesn't declare
# metadata.version and re-download it on each resolution.
skill = _load_skill(cached_path)
if skill is not None:
return activate_skill(skill, source=source)
return download_skill(org, name, source=source, version=version)
def _load_skill(path: Path) -> Skill | None: # type: ignore[name-defined] # noqa: F821
"""Load the skill at *path*, or None when it can't be read."""
from crewai.skills.parser import load_skill_metadata
try:
return load_skill_metadata(path)
except Exception:
_logger.debug("Failed to load skill at %s", path, exc_info=True)
return None
def _load_matching_skill(
path: Path,
version: str | None,
) -> Skill | None: # type: ignore[name-defined] # noqa: F821
"""Load the skill at *path*, or None when it can't satisfy *version*.
Skills record their version in SKILL.md frontmatter under
``metadata.version``; a skill that declares no version can't satisfy a pin.
"""
skill = _load_skill(path)
if skill is None or version is None:
return skill
declared = (skill.frontmatter.metadata or {}).get("version")
if versions_match(version, declared):
return skill
_logger.debug(
"Skill at %s declares version %r, which does not satisfy the pinned %r",
path,
declared,
version,
)
return None
def _accepts_version(get_skill: Any) -> bool:
"""Return True when *get_skill* takes a ``version`` argument.
Assumes it does when the callable can't be introspected, so an unusual but
working client isn't pushed onto the fallback path.
"""
try:
parameters = inspect.signature(get_skill).parameters
except (TypeError, ValueError):
return True
return "version" in parameters or any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in parameters.values()
)
def _plus_client(ref: str, pinned: bool) -> Any:
"""Return the CrewAI AMP client to fetch skills with.
Hosted runtimes install a client authenticated for the environment they run
in, and have no user credential to fall back on — so skills resolve through
the same client the Agent Repository uses rather than building a ``PlusAPI``
of their own.
Args:
ref: The reference being resolved, for logging.
pinned: Whether this lookup carries a version pin, which the installed
client has to be able to forward.
"""
from crewai.utilities.agent_utils import resolve_plus_client
def build_default_client() -> Any:
# Deliberately wider than the Agent Repository's default, which reads the
# CLI login only: skills kept their existing precedence so this fix
# doesn't change which credential either lookup picks.
from crewai_core.plus_api import PlusAPI
from crewai.auth.token import get_auth_token
from crewai.context import get_platform_integration_token
return PlusAPI(
api_key=os.getenv("CREWAI_USER_PAT")
or get_platform_integration_token()
or get_auth_token(),
organization_id=os.getenv("CREWAI_ORGANIZATION_UUID"),
)
client = resolve_plus_client(build_default_client)
get_skill = getattr(client, "get_skill", None)
# A runtime predating the Skills Repository installs a client that can't
# fetch skills at all; one predating pins has a get_skill that would reject
# the version argument. Repository agents pin automatically, so both cases
# fall back to a client that can serve the request rather than raising.
if not callable(get_skill) or (pinned and not _accepts_version(get_skill)):
_logger.warning(
"The configured CrewAI AMP client cannot fetch %s, falling back to "
"credentials from the environment.",
"pinned skills" if pinned else "skills",
)
return build_default_client()
return client
def download_skill(
org: str,
name: str,
source: Any = None,
*,
version: str | None = None,
) -> Skill: # type: ignore[name-defined] # noqa: F821
"""Download a skill from the registry and store it in the cache.
Args:
org: Organisation slug.
name: Skill name.
source: Optional source for event emission.
version: Optional pinned version; the latest is fetched when omitted.
Returns:
The downloaded Skill at INSTRUCTIONS level.
Raises:
ValueError: If *version* is given but blank.
"""
from crewai.skills.loader import activate_skill
from crewai.skills.parser import load_skill_metadata
from crewai.utilities.agent_utils import resolve_plus_response
if version is not None:
# A blank pin would otherwise read as "unpinned" and quietly float to
# the latest version, which is not what a caller passing one asked for.
version = version.strip()
if not version:
raise ValueError(
"A pinned skill version must be non-empty; omit it to fetch the latest."
)
ref = str(SkillRef(org=org, name=name, version=version))
try:
from crewai.events.event_bus import crewai_event_bus
from crewai.skills.events import (
SkillDownloadCompletedEvent,
SkillDownloadStartedEvent,
)
_has_events = True
except ImportError:
_has_events = False
if _has_events:
crewai_event_bus.emit(
source,
event=SkillDownloadStartedEvent(
registry_ref=ref,
),
)
try:
api = _plus_client(ref, pinned=bool(version))
# Only pass the version when pinned, so clients predating version
# support keep working for unpinned refs. The pin goes over verbatim and
# the registry decides how to match it, including a leading "v".
pin = {"version": version} if version else {}
response = resolve_plus_response(api.get_skill(org, name, **pin))
response.raise_for_status()
data = response.json()
except Exception as exc:
raise RuntimeError(
f"Failed to download skill {ref!r} from registry: {exc}"
) from exc
import base64
import httpx
served_version = data.get("version") or data.get("latest_version")
if version and not versions_match(version, served_version):
# Registries that predate pinning ignore the parameter and serve their
# newest version. Caching that under the pinned version would poison
# every later lookup for the pin, so refuse it instead.
raise RuntimeError(
f"Failed to download skill {ref!r} from registry: it served version "
f"{served_version!r} rather than the pinned version {version!r}."
)
resolved_version = version or served_version
download_url = data.get("download_url")
if download_url:
dl_response = httpx.get(download_url, follow_redirects=True)
dl_response.raise_for_status()
archive_bytes = dl_response.content
else:
encoded = data.get("file", "")
if "," in encoded:
encoded = encoded.split(",", 1)[1]
archive_bytes = base64.b64decode(encoded)
cache = SkillCacheManager()
skill_dir = cache.store(org, name, resolved_version, archive_bytes)
if _has_events:
crewai_event_bus.emit(
source,
event=SkillDownloadCompletedEvent(
registry_ref=ref,
version=resolved_version,
cache_path=skill_dir,
),
)
if not (skill_dir / "SKILL.md").exists():
raise RuntimeError(
f"Skill archive for {ref!r} downloaded but no SKILL.md found in {skill_dir}"
)
skill = load_skill_metadata(skill_dir)
return activate_skill(skill, source=source)

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
from pathlib import Path
import re
from typing import Final
from typing import Any, Final
MAX_SKILL_NAME_LENGTH: Final[int] = 64
@@ -15,6 +15,31 @@ MIN_SKILL_NAME_LENGTH: Final[int] = 1
SKILL_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
def versions_match(left: Any, right: Any) -> bool:
"""Return True when two skill versions refer to the same version.
Versions arrive as ``1.2.0`` or ``v1.2.0`` depending on whether they came
from a registry ref, SKILL.md frontmatter or a cache entry, so comparison
ignores case and an optional leading ``v``.
Anything that isn't a version string — None, or a corrupted cache entry
holding a number — never matches: a pinned reference should re-resolve
rather than accept a version it can't confirm.
"""
normalized_left = _normalize_version(left)
normalized_right = _normalize_version(right)
if normalized_left is None or normalized_right is None:
return False
return normalized_left == normalized_right
def _normalize_version(value: Any) -> str | None:
"""Return a comparable form of a version, or None when it isn't one."""
if not isinstance(value, str):
return None
return value.strip().lower().removeprefix("v") or None
def validate_directory_name(skill_dir: Path, skill_name: str) -> None:
"""Validate that a directory name matches the skill name.

View File

@@ -662,6 +662,21 @@ class Task(BaseModel):
crewai_event_bus.emit(
self, TaskStartedEvent(context=context, task=self)
)
from crewai.hooks.contexts import StepContext
from crewai.hooks.dispatch import InterceptionPoint, dispatch
pre_step_ctx = StepContext(
kind="task",
step_name=self.name or self.description,
agent=agent,
agent_role=getattr(agent, "role", None),
task=self,
payload=context,
)
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
context = pre_step_ctx.payload
result = await agent.aexecute_task(
task=self,
context=context,
@@ -718,6 +733,18 @@ class Task(BaseModel):
guardrail=self._guardrail,
)
post_step_ctx = StepContext(
kind="task",
step_name=self.name or self.description,
agent=agent,
agent_role=getattr(agent, "role", None),
task=self,
output=task_output,
payload=task_output,
)
dispatch(InterceptionPoint.POST_STEP, post_step_ctx)
task_output = cast(TaskOutput, post_step_ctx.payload)
self.output = task_output
self.end_time = datetime.datetime.now()
@@ -739,10 +766,12 @@ class Task(BaseModel):
if self.output_file:
content = (
json_output
if json_output
task_output.json_dict
if task_output.json_dict
else (
pydantic_output.model_dump_json() if pydantic_output else result
task_output.pydantic.model_dump_json()
if task_output.pydantic
else task_output.raw
)
)
self._save_file(content)
@@ -787,6 +816,21 @@ class Task(BaseModel):
crewai_event_bus.emit(
self, TaskStartedEvent(context=context, task=self)
)
from crewai.hooks.contexts import StepContext
from crewai.hooks.dispatch import InterceptionPoint, dispatch
pre_step_ctx = StepContext(
kind="task",
step_name=self.name or self.description,
agent=agent,
agent_role=getattr(agent, "role", None),
task=self,
payload=context,
)
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
context = pre_step_ctx.payload
result = agent.execute_task(
task=self,
context=context,
@@ -843,6 +887,18 @@ class Task(BaseModel):
guardrail=self._guardrail,
)
post_step_ctx = StepContext(
kind="task",
step_name=self.name or self.description,
agent=agent,
agent_role=getattr(agent, "role", None),
task=self,
output=task_output,
payload=task_output,
)
dispatch(InterceptionPoint.POST_STEP, post_step_ctx)
task_output = cast(TaskOutput, post_step_ctx.payload)
self.output = task_output
self.end_time = datetime.datetime.now()
@@ -864,10 +920,12 @@ class Task(BaseModel):
if self.output_file:
content = (
json_output
if json_output
task_output.json_dict
if task_output.json_dict
else (
pydantic_output.model_dump_json() if pydantic_output else result
task_output.pydantic.model_dump_json()
if task_output.pydantic
else task_output.raw
)
)
self._save_file(content)
@@ -1316,7 +1374,6 @@ Follow these guidelines:
content=f"Guardrail {guardrail_index if guardrail_index is not None else ''} blocked (attempt {attempt + 1}/{max_attempts}), retrying due to: {guardrail_result.error}\n",
color="yellow",
)
result = agent.execute_task(
task=self,
context=context,
@@ -1426,7 +1483,6 @@ Follow these guidelines:
content=f"Guardrail {guardrail_index if guardrail_index is not None else ''} blocked (attempt {attempt + 1}/{max_attempts}), retrying due to: {guardrail_result.error}\n",
color="yellow",
)
result = await agent.aexecute_task(
task=self,
context=context,

View File

@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal
from crewai_core.printer import PRINTER
import json5
from json_repair import repair_json # type: ignore[import-untyped]
from json_repair import repair_json
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.tool_usage_events import (

View File

@@ -55,6 +55,71 @@ if TYPE_CHECKING:
_create_plus_client_hook: Callable[[], Any] | None = None
def resolve_plus_client(default: Callable[[], Any]) -> Any:
"""Return the CrewAI AMP client to use, or build ``default``.
Hosted runtimes install a client of their own through
``_create_plus_client_hook``, authenticated for the environment they run in.
Every registry lookup resolves through here so they all share that client.
Args:
default: Builds the client to use when no hook is installed. A callable
rather than a value so credential lookups that raise when absent are
only evaluated when they're actually needed.
Returns:
A CrewAI AMP client.
"""
if callable(_create_plus_client_hook):
return _create_plus_client_hook()
return default()
def resolve_plus_response(response: Any) -> Any:
"""Return ``response``, awaiting it first when the client is async.
Args:
response: A response, or an awaitable resolving to one.
Returns:
The resolved response.
Raises:
TypeError: If the awaitable is already bound to a loop (a Task or
Future), which can't be resolved synchronously.
"""
if not inspect.isawaitable(response):
return response
if isinstance(response, asyncio.Future):
raise TypeError(
"CrewAI AMP clients must return a coroutine, not a Task or Future "
"already bound to a running loop."
)
# asyncio.run() takes a coroutine specifically, while isawaitable() also
# covers custom __await__ objects, so wrap rather than passing it through.
async def await_response() -> Any:
return await response
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
# asyncio.run() refuses to nest inside a running loop, so hand the coroutine
# to a worker thread with a loop of its own — carrying a copy of the caller's
# context, since a fresh thread would otherwise start with empty ContextVars
# and a client reading runtime state (the platform token, flow context) would
# see defaults.
if loop and loop.is_running():
ctx = contextvars.copy_context()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(ctx.run, asyncio.run, await_response()).result()
return asyncio.run(await_response())
class SummaryContent(TypedDict):
"""Structure for summary content entries.
@@ -1127,15 +1192,15 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]:
if from_repository:
import importlib
if callable(_create_plus_client_hook):
client = _create_plus_client_hook()
else:
def build_default_client() -> Any:
from crewai.auth.token import get_auth_token
from crewai.plus_api import PlusAPI
client = PlusAPI(api_key=get_auth_token())
return PlusAPI(api_key=get_auth_token())
client = resolve_plus_client(build_default_client)
_print_current_organization()
response = client.get_agent(from_repository)
response = resolve_plus_response(client.get_agent(from_repository))
if response.status_code == 404:
raise AgentRepositoryError(
f"Agent {from_repository} does not exist, make sure the name is correct or the agent is available on your organization."
@@ -1149,8 +1214,18 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]:
)
agent = response.json()
skill_versions = agent.get("skill_versions") or []
for key, value in agent.items():
if key == "tools":
if value is None:
continue
if key == "skill_versions":
# Folded into the skill refs below, and not an Agent field.
continue
if key == "skills":
if not value:
continue
attributes[key] = _pin_skill_refs(value, skill_versions)
elif key == "tools":
attributes[key] = []
for tool in value:
try:
@@ -1168,13 +1243,48 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]:
raise AgentRepositoryError(
f"Tool {tool['name']} could not be loaded: {e}"
) from e
elif key == "skills" and value == []:
continue
else:
attributes[key] = value
return attributes
def _pin_skill_refs(refs: list[Any], skill_versions: list[dict[str, Any]]) -> list[Any]:
"""Pin repository skill refs to the versions the repository recorded.
An agent's ``skills`` are plain ``@org/name`` refs while ``skill_versions``
carries the version pinned against each one. Without folding the two
together the runtime resolves whatever version is newest, so publishing a
new version of a skill would silently change every agent using it.
Args:
refs: Skill references as returned by the repository.
skill_versions: Repository version records, each with a ``registry_ref``
and ``version``.
Returns:
The refs, version-pinned where the repository recorded one.
"""
pinned_versions = {
entry["registry_ref"]: entry["version"]
for entry in skill_versions
if isinstance(entry, dict)
and entry.get("registry_ref")
and entry.get("version")
}
if not pinned_versions:
return refs
pinned_refs: list[Any] = []
for ref in refs:
version = pinned_versions.get(ref) if isinstance(ref, str) else None
# Leave anything already pinned (a second '@') exactly as it is.
already_pinned = isinstance(ref, str) and "@" in ref[1:]
pinned_refs.append(
f"{ref}@{version}" if version and not already_pinned else ref
)
return pinned_refs
DELEGATION_TOOL_NAMES: Final[frozenset[str]] = frozenset(
[
sanitize_tool_name("Delegate work to coworker"),
@@ -1232,14 +1342,22 @@ def extract_tool_call_info(
call_id = getattr(tool_call, "id", f"call_{id(tool_call)}")
return call_id, sanitize_tool_name(tool_call.name), tool_call.input
if isinstance(tool_call, dict):
# Support OpenAI "id", Bedrock "toolUseId", or generate one
# Prefer the Responses API "call_id", then OpenAI "id", then Bedrock
# "toolUseId", else generate one. A raw Responses function_call item carries
# both "id" (fc_...) and "call_id" (call_...) with different values, and the
# matching function_call_output must reference "call_id" -- reading "id"
# would produce a tool result that can't be correlated to its invocation.
call_id = (
tool_call.get("id") or tool_call.get("toolUseId") or f"call_{id(tool_call)}"
tool_call.get("call_id")
or tool_call.get("id")
or tool_call.get("toolUseId")
or f"call_{id(tool_call)}"
)
func_info = tool_call.get("function", {})
func_name = func_info.get("name", "") or tool_call.get("name", "")
# OpenAI Responses API function_call items are flat dicts using
# "arguments" (not "input") with no nested "function" key.
# "arguments" is also read from the top level for the OpenAI Responses API,
# which emits {"id", "name", "arguments"} with no nested "function" object.
# Without it the args silently resolved to {} and the tool ran with no input.
func_args = (
func_info.get("arguments")
or tool_call.get("arguments")
@@ -1277,9 +1395,10 @@ def is_tool_call_list(response: list[Any]) -> bool:
# Bedrock-style
if isinstance(first_item, dict) and "name" in first_item and "input" in first_item:
return True
# OpenAI Responses API-style (flat dict, no nested "function" key). This
# intentionally accepts the same broad shape as the Bedrock check above;
# only provider paths that return lists reach this classifier.
# OpenAI Responses API style: {"id", "name", "arguments"}, with no nested
# "function" object and no "input". This intentionally accepts the same broad
# shape as the Bedrock check above; only provider paths that return lists reach
# this classifier.
if (
isinstance(first_item, dict)
and "name" in first_item
@@ -1469,8 +1588,8 @@ def execute_single_native_tool_call(
)
from crewai.hooks.tool_hooks import (
ToolCallHookContext,
get_after_tool_call_hooks,
get_before_tool_call_hooks,
run_after_tool_call_hooks,
run_before_tool_call_hooks,
)
info = extract_tool_call_info(tool_call)
@@ -1533,7 +1652,6 @@ def execute_single_native_tool_call(
track_delegation_if_needed(func_name, args_dict, task)
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
@@ -1542,13 +1660,7 @@ def execute_single_native_tool_call(
task=task,
crew=crew,
)
try:
for hook in get_before_tool_call_hooks():
if hook(before_hook_context) is False:
hook_blocked = True
break
except Exception: # noqa: S110
pass
hook_blocked = run_before_tool_call_hooks(before_hook_context)
error_event_emitted = False
if hook_blocked:
@@ -1603,14 +1715,9 @@ def execute_single_native_tool_call(
tool_result=result,
raw_tool_result=raw_tool_result,
)
try:
for after_hook in get_after_tool_call_hooks():
hook_result = after_hook(after_hook_context)
if hook_result is not None:
result = hook_result
after_hook_context.tool_result = result
except Exception: # noqa: S110
pass
modified_result = run_after_tool_call_hooks(after_hook_context)
if modified_result is not None:
result = modified_result
if not error_event_emitted:
crewai_event_bus.emit(
@@ -1706,28 +1813,42 @@ def _setup_before_llm_call_hooks(
Returns:
True if LLM execution should proceed, False if blocked by a hook.
"""
if executor_context and executor_context.before_llm_call_hooks:
from crewai.hooks.llm_hooks import LLMCallHookContext
if executor_context:
from crewai.hooks.dispatch import (
HookAborted,
InterceptionPoint,
get_scoped_hooks,
run_hooks,
)
from crewai.hooks.llm_hooks import LLMCallHookContext, before_llm_call_reducer
# Executor snapshot first, then execution-scoped hooks — the same
# ordering dispatch() applies to global vs scoped hooks.
hooks: list[Any] = [
*executor_context.before_llm_call_hooks,
*get_scoped_hooks(InterceptionPoint.PRE_MODEL_CALL),
]
if not hooks:
return True
original_messages = executor_context.messages
hook_context = LLMCallHookContext(executor_context)
try:
for hook in executor_context.before_llm_call_hooks:
result = hook(hook_context)
if result is False:
if verbose:
printer.print(
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
except Exception as e:
run_hooks(
InterceptionPoint.PRE_MODEL_CALL,
hook_context,
hooks,
reducer=before_llm_call_reducer,
verbose=verbose,
)
except HookAborted:
if verbose:
printer.print(
content=f"Error in before_llm_call hook: {e}",
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
if not isinstance(executor_context.messages, list):
if verbose:
@@ -1764,8 +1885,24 @@ def _setup_after_llm_call_hooks(
Returns:
The potentially modified response (string or Pydantic model).
"""
if executor_context and executor_context.after_llm_call_hooks:
from crewai.hooks.llm_hooks import LLMCallHookContext
if executor_context:
from crewai.hooks.dispatch import InterceptionPoint, get_scoped_hooks, run_hooks
from crewai.hooks.llm_hooks import LLMCallHookContext, after_llm_call_reducer
# Don't stringify structured tool-call payloads: the executor would
# treat the result as a final answer and skip tool execution (#6529).
# Hooks still run on the follow-up textual response.
if not isinstance(answer, (str, BaseModel)):
return answer
# Executor snapshot first, then execution-scoped hooks — the same
# ordering dispatch() applies to global vs scoped hooks.
hooks: list[Any] = [
*executor_context.after_llm_call_hooks,
*get_scoped_hooks(InterceptionPoint.POST_MODEL_CALL),
]
if not hooks:
return answer
original_messages = executor_context.messages
@@ -1778,18 +1915,15 @@ def _setup_after_llm_call_hooks(
hook_response = str(answer)
hook_context = LLMCallHookContext(executor_context, response=hook_response)
try:
for hook in executor_context.after_llm_call_hooks:
modified_response = hook(hook_context)
if modified_response is not None and isinstance(modified_response, str):
hook_response = modified_response
except Exception as e:
if verbose:
printer.print(
content=f"Error in after_llm_call hook: {e}",
color="yellow",
)
run_hooks(
InterceptionPoint.POST_MODEL_CALL,
hook_context,
hooks,
reducer=after_llm_call_reducer,
verbose=verbose,
)
if hook_context.response is not None:
hook_response = hook_context.response
if not isinstance(executor_context.messages, list):
if verbose:

View File

@@ -549,7 +549,13 @@ _CLAUDE_STRICT_UNSUPPORTED: Final[tuple[str, ...]] = (
def _strip_keys_recursive(
d: Any, keys: tuple[str, ...], _seen: set[int] | None = None
) -> Any:
"""Recursively delete a fixed set of keys from a schema."""
"""Recursively delete schema metadata keys without deleting property names.
JSON Schema stores fields under a ``properties`` map. A user/tool argument can
legitimately be named ``title``, ``default``, or another metadata key. Those
entries must stay in the map; only metadata inside the schema nodes should be
stripped.
"""
if _seen is None:
_seen = set()
if isinstance(d, dict):
@@ -558,8 +564,12 @@ def _strip_keys_recursive(
_seen.add(id(d))
for key in keys:
d.pop(key, None)
for v in d.values():
_strip_keys_recursive(v, keys, _seen)
for key, value in d.items():
if key == "properties" and isinstance(value, dict):
for property_schema in value.values():
_strip_keys_recursive(property_schema, keys, _seen)
else:
_strip_keys_recursive(value, keys, _seen)
elif isinstance(d, list):
if id(d) in _seen:
return d

View File

@@ -6,15 +6,14 @@ from crewai.agents.parser import AgentAction
from crewai.agents.tools_handler import ToolsHandler
from crewai.hooks.tool_hooks import (
ToolCallHookContext,
get_after_tool_call_hooks,
get_before_tool_call_hooks,
run_after_tool_call_hooks,
run_before_tool_call_hooks,
)
from crewai.security.fingerprint import Fingerprint
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.tool_types import ToolResult
from crewai.tools.tool_usage import ToolUsage, ToolUsageError
from crewai.utilities.i18n import I18N_DEFAULT
from crewai.utilities.logger import Logger
from crewai.utilities.string_utils import sanitize_tool_name
@@ -57,11 +56,10 @@ async def aexecute_tool_and_check_finality(
fingerprint_context: Optional context for fingerprinting.
crew: Optional crew instance for hook context.
Returns:
Returns:
ToolResult containing the execution result and whether it should be
treated as a final answer.
"""
logger = Logger(verbose=crew.verbose if crew else False)
tool_name_to_tool_map = {sanitize_tool_name(tool.name): tool for tool in tools}
if agent_key and agent_role and agent:
@@ -102,18 +100,27 @@ async def aexecute_tool_and_check_finality(
crew=crew,
)
before_hooks = get_before_tool_call_hooks()
try:
for hook in before_hooks:
result = hook(hook_context)
if result is False:
blocked_message = (
f"Tool execution blocked by hook. "
f"Tool: {tool_calling.tool_name}"
)
return ToolResult(blocked_message, False)
except Exception as e:
logger.log("error", f"Error in before_tool_call hook: {e}")
if run_before_tool_call_hooks(hook_context):
blocked_message = (
f"Tool execution blocked by hook. Tool: {tool_calling.tool_name}"
)
# Run POST_TOOL_CALL even on a blocked call so monitoring hooks
# still fire, matching the native tool-call paths.
blocked_hook_context = ToolCallHookContext(
tool_name=sanitized_tool_name,
tool_input=tool_input,
tool=tool,
agent=agent,
task=task,
crew=crew,
tool_result=blocked_message,
raw_tool_result=blocked_message,
)
modified_result = run_after_tool_call_hooks(blocked_hook_context)
return ToolResult(
modified_result if modified_result is not None else blocked_message,
False,
)
tool_result = await tool_usage.ause(tool_calling, agent_action.text)
raw_tool_result = tool_usage.get_last_raw_result(tool_result)
@@ -129,18 +136,12 @@ async def aexecute_tool_and_check_finality(
raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
modified_result: str = tool_result
try:
for after_hook in after_hooks:
hook_result = after_hook(after_hook_context)
if hook_result is not None:
modified_result = hook_result
after_hook_context.tool_result = modified_result
except Exception as e:
logger.log("error", f"Error in after_tool_call hook: {e}")
modified_result = run_after_tool_call_hooks(after_hook_context)
return ToolResult(modified_result, tool.result_as_answer)
return ToolResult(
modified_result if modified_result is not None else tool_result,
tool.result_as_answer,
)
tool_result = I18N_DEFAULT.errors("wrong_tool_name").format(
tool=sanitized_tool_name,
@@ -181,7 +182,6 @@ def execute_tool_and_check_finality(
Returns:
ToolResult containing the execution result and whether it should be treated as a final answer
"""
logger = Logger(verbose=crew.verbose if crew else False)
tool_name_to_tool_map = {sanitize_tool_name(tool.name): tool for tool in tools}
if agent_key and agent_role and agent:
@@ -222,18 +222,27 @@ def execute_tool_and_check_finality(
crew=crew,
)
before_hooks = get_before_tool_call_hooks()
try:
for hook in before_hooks:
result = hook(hook_context)
if result is False:
blocked_message = (
f"Tool execution blocked by hook. "
f"Tool: {tool_calling.tool_name}"
)
return ToolResult(blocked_message, False)
except Exception as e:
logger.log("error", f"Error in before_tool_call hook: {e}")
if run_before_tool_call_hooks(hook_context):
blocked_message = (
f"Tool execution blocked by hook. Tool: {tool_calling.tool_name}"
)
# Run POST_TOOL_CALL even on a blocked call so monitoring hooks
# still fire, matching the native tool-call paths.
blocked_hook_context = ToolCallHookContext(
tool_name=sanitized_tool_name,
tool_input=tool_input,
tool=tool,
agent=agent,
task=task,
crew=crew,
tool_result=blocked_message,
raw_tool_result=blocked_message,
)
modified_result = run_after_tool_call_hooks(blocked_hook_context)
return ToolResult(
modified_result if modified_result is not None else blocked_message,
False,
)
tool_result = tool_usage.use(tool_calling, agent_action.text)
raw_tool_result = tool_usage.get_last_raw_result(tool_result)
@@ -249,18 +258,12 @@ def execute_tool_and_check_finality(
raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
modified_result: str = tool_result
try:
for after_hook in after_hooks:
hook_result = after_hook(after_hook_context)
if hook_result is not None:
modified_result = hook_result
after_hook_context.tool_result = modified_result
except Exception as e:
logger.log("error", f"Error in after_tool_call hook: {e}")
modified_result = run_after_tool_call_hooks(after_hook_context)
return ToolResult(modified_result, tool.result_as_answer)
return ToolResult(
modified_result if modified_result is not None else tool_result,
tool.result_as_answer,
)
tool_result = I18N_DEFAULT.errors("wrong_tool_name").format(
tool=sanitized_tool_name,

View File

@@ -2335,6 +2335,25 @@ def test_agent_from_repository_override_attributes(mock_get_agent, mock_get_auth
assert isinstance(agent.tools[0], SerperDevTool)
@patch("crewai.plus_api.PlusAPI.get_agent")
def test_agent_from_repository_ignores_null_attributes(
mock_get_agent, mock_get_auth_token
):
mock_get_response = MagicMock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"role": "test role",
"goal": "test goal",
"backstory": "test backstory",
"reasoning": None,
}
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent")
assert agent.reasoning is False
@patch("crewai.plus_api.PlusAPI.get_agent")
def test_agent_from_repository_ignores_empty_skills(
mock_get_agent, mock_get_auth_token
@@ -2356,6 +2375,42 @@ def test_agent_from_repository_ignores_empty_skills(
assert agent.skills is None
@patch("crewai.plus_api.PlusAPI.get_agent")
def test_agent_from_repository_pins_skills_to_recorded_versions(
mock_get_agent, mock_get_auth_token
):
"""The repository records a version per skill; without the pin the runtime
resolves whatever is newest, so publishing a skill would silently change
every agent using it."""
from crewai.utilities.agent_utils import load_agent_from_repository
mock_get_response = MagicMock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"role": "test role",
"skills": [
"@acme/crewai-brand",
"@acme/already-pinned@3.0.0",
"@acme/unrecorded",
],
"skill_versions": [
{"registry_ref": "@acme/crewai-brand", "version": "2.1.0"},
{"registry_ref": "@acme/already-pinned", "version": "1.0.0"},
],
}
mock_get_agent.return_value = mock_get_response
attributes = load_agent_from_repository("test_agent")
assert attributes["skills"] == [
"@acme/crewai-brand@2.1.0",
"@acme/already-pinned@3.0.0", # keeps the pin it already carried
"@acme/unrecorded", # no recorded version to apply
]
# Not an Agent field — it only exists to carry the pins.
assert "skill_versions" not in attributes
@patch("crewai.plus_api.PlusAPI.get_agent")
def test_agent_from_repository_with_invalid_tools(mock_get_agent, mock_get_auth_token):
mock_get_response = MagicMock()

View File

@@ -30,6 +30,14 @@ def test_valid_action_parsing_with_json_tool_input():
assert result.tool_input == expected_tool_input
def test_valid_action_parsing_preserves_markdown_like_plain_text():
text = "Thought: Let's search\nAction: search\nAction Input: ** plain text"
result = parser.parse(text)
assert isinstance(result, AgentAction)
assert result.tool == "search"
assert result.tool_input == "** plain text"
def test_valid_action_parsing_with_quotes():
text = 'Thought: Let\'s find the temperature\nAction: search\nAction Input: "temperature in SF"'
result = parser.parse(text)

View File

@@ -1,7 +1,10 @@
import os
import unittest
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, patch
import pytest
from crewai.plus_api import PlusAPI
@@ -393,6 +396,45 @@ class TestPlusAPI(unittest.TestCase):
"https://custom-url-from-env.com",
)
@patch.dict(os.environ, {"CREWAI_PLUS_URL": "https://url-from-env.com"})
def test_explicit_target_takes_precedence(self):
api = PlusAPI(
"test_key",
base_url="https://explicit-url.com",
organization_id="explicit-org-id",
)
self.assertEqual(api.base_url, "https://explicit-url.com")
self.assertEqual(
api.headers["X-Crewai-Organization-Id"], "explicit-org-id"
)
@patch("crewai_core.plus_api.Settings")
def test_explicit_base_url_uses_organization_from_settings(
self, mock_settings_class
):
mock_settings_class.return_value.org_uuid = "settings-org-id"
api = PlusAPI("test_key", base_url="https://explicit-url.com")
self.assertEqual(api.base_url, "https://explicit-url.com")
self.assertEqual(api.headers["X-Crewai-Organization-Id"], "settings-org-id")
@patch("crewai_core.plus_api.Settings")
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
def test_explicit_organization_uses_base_url_from_settings(
self, mock_settings_class
):
mock_settings_class.return_value.enterprise_base_url = (
"https://url-from-settings.com"
)
api = PlusAPI("test_key", organization_id="explicit-org-id")
self.assertEqual(api.base_url, "https://url-from-settings.com")
self.assertEqual(
api.headers["X-Crewai-Organization-Id"], "explicit-org-id"
)
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_get_agent(mock_make_request):
@@ -430,3 +472,53 @@ def test_get_agent_with_org_uuid(mock_settings_class, mock_make_request):
assert "X-Crewai-Organization-Id" in api.headers
assert api.headers["X-Crewai-Organization-Id"] == org_uuid
assert response == mock_response
@pytest.mark.parametrize(
("target", "other_target"),
(
(
("https://first.example.com", "first-org"),
("https://second.example.com", "second-org"),
),
(
("https://second.example.com", "second-org"),
("https://first.example.com", "first-org"),
),
),
)
def test_clients_keep_targets_independent(target, other_target):
base_url, organization_id = target
api = PlusAPI(
"test_key",
base_url=base_url,
organization_id=organization_id,
)
other_base_url, other_organization_id = other_target
PlusAPI(
"test_key",
base_url=other_base_url,
organization_id=other_organization_id,
)
assert (api.base_url, api.headers["X-Crewai-Organization-Id"]) == target
@patch("crewai_core.plus_api.Settings")
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
def test_default_target_uses_single_settings_snapshot(mock_settings_class):
mock_settings_class.side_effect = (
SimpleNamespace(
org_uuid="first-org",
enterprise_base_url="https://first.example.com",
),
SimpleNamespace(
org_uuid="second-org",
enterprise_base_url="https://second.example.com",
),
)
api = PlusAPI("test_key")
assert api.base_url == "https://first.example.com"
assert api.headers["X-Crewai-Organization-Id"] == "first-org"

View File

@@ -0,0 +1,211 @@
"""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)

View File

@@ -1,6 +0,0 @@
import pytest
@pytest.fixture(autouse=True)
def _enable_experimental_skills(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CREWAI_EXPERIMENTAL", "1")

View File

@@ -1,30 +0,0 @@
"""Tests for the CREWAI_EXPERIMENTAL gate on Skills Repository."""
from __future__ import annotations
import pytest
from crewai.experimental.skills._flag import (
ExperimentalFeatureDisabledError,
require_experimental_skills,
)
from crewai.experimental.skills.registry import resolve_registry_ref
def test_require_raises_without_flag(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CREWAI_EXPERIMENTAL", raising=False)
with pytest.raises(ExperimentalFeatureDisabledError):
require_experimental_skills()
def test_resolve_registry_ref_raises_without_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("CREWAI_EXPERIMENTAL", raising=False)
with pytest.raises(ExperimentalFeatureDisabledError):
resolve_registry_ref("@acme/my-skill")
def test_require_passes_with_flag(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CREWAI_EXPERIMENTAL", "1")
require_experimental_skills()

View File

@@ -1,127 +0,0 @@
"""Tests for SkillRegistry."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from crewai.experimental.skills.registry import (
SkillNotCachedError,
is_registry_ref,
parse_registry_ref,
)
import pytest
class TestIsRegistryRef:
def test_at_prefixed(self) -> None:
assert is_registry_ref("@acme/my-skill") is True
def test_plain_string(self) -> None:
assert is_registry_ref("my-skill") is False
def test_path_like_string(self) -> None:
assert is_registry_ref("./skills/my-skill") is False
def test_non_string(self) -> None:
assert is_registry_ref(None) is False
assert is_registry_ref(42) is False
assert is_registry_ref(Path("something")) is False
class TestParseRegistryRef:
def test_valid(self) -> None:
assert parse_registry_ref("@acme/my-skill") == ("acme", "my-skill")
def test_valid_with_dashes(self) -> None:
assert parse_registry_ref("@my-org/cool-skill") == ("my-org", "cool-skill")
def test_missing_at(self) -> None:
with pytest.raises(ValueError, match="must start with '@'"):
parse_registry_ref("acme/my-skill")
def test_missing_slash(self) -> None:
with pytest.raises(ValueError, match="'@org/name' format"):
parse_registry_ref("@acme-skill")
def test_empty_org(self) -> None:
with pytest.raises(ValueError, match="non-empty"):
parse_registry_ref("@/my-skill")
def test_empty_name(self) -> None:
with pytest.raises(ValueError, match="non-empty"):
parse_registry_ref("@acme/")
class TestResolveRegistryRef:
"""Test resolution order and CI mode behaviour."""
def _make_skill_dir(self, base: Path, name: str) -> Path:
"""Write a minimal SKILL.md into base/name/."""
skill_dir = base / name
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Test skill.\n---\n\nInstructions."
)
return skill_dir
def test_resolves_project_local(self, tmp_path: Path) -> None:
"""Local ./skills/{name}/ takes priority over cache."""
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
self._make_skill_dir(skills_dir, "my-skill")
mock_cache = MagicMock()
mock_cache.get_cached_path.return_value = None
with (
patch("crewai.experimental.skills.registry._is_noninteractive", return_value=False),
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.experimental.skills.registry.SkillCacheManager", return_value=mock_cache),
):
from crewai.experimental.skills.registry import resolve_registry_ref
skill = resolve_registry_ref("@acme/my-skill")
assert skill.name == "my-skill"
def test_raises_in_ci_when_not_cached(self, tmp_path: Path) -> None:
"""In CI mode, raise SkillNotCachedError if no local or cached copy."""
mock_cache = MagicMock()
mock_cache.get_cached_path.return_value = None
with (
patch("crewai.experimental.skills.registry._is_noninteractive", return_value=True),
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.experimental.skills.registry.SkillCacheManager", return_value=mock_cache),
):
from crewai.experimental.skills.registry import resolve_registry_ref
with pytest.raises(SkillNotCachedError) as exc_info:
resolve_registry_ref("@acme/ghost-skill")
assert "@acme/ghost-skill" in str(exc_info.value)
def test_resolves_from_cache(self, tmp_path: Path) -> None:
"""Falls back to global cache when no project-local skill exists."""
cache_dir = tmp_path / "acme" / "cached-skill"
cache_dir.mkdir(parents=True)
(cache_dir / "SKILL.md").write_text(
"---\nname: cached-skill\ndescription: Cached.\n---\n\nCached instructions."
)
mock_cache = MagicMock()
mock_cache.get_cached_path.return_value = cache_dir
# tmp_path has no ./skills/ directory
with (
patch("crewai.experimental.skills.registry._is_noninteractive", return_value=False),
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.experimental.skills.registry.SkillCacheManager", return_value=mock_cache),
):
from crewai.experimental.skills.registry import resolve_registry_ref
skill = resolve_registry_ref("@acme/cached-skill")
assert skill.name == "cached-skill"
def test_skill_not_cached_error_contains_ref(self) -> None:
err = SkillNotCachedError("@foo/bar")
assert "@foo/bar" in str(err)
assert err.ref == "@foo/bar"

View File

@@ -306,6 +306,62 @@ class TestCrewScopedHooks:
assert len(execution_log) == 1
class TestCrewOnDecoratedMethods:
"""@on(InterceptionPoint.X) methods inside @CrewBase must register.
Regression: CrewBase only scanned the legacy ``is_*_hook`` markers, so
methods decorated with the generic ``@on`` decorator (which sets
``_interception_point``) were silently dropped and never ran.
"""
def test_on_decorated_method_registers_and_binds_self(self):
from crewai.hooks import InterceptionPoint, on
from crewai.hooks.dispatch import _resolve_hooks
execution_log = []
@CrewBase
class TestCrew:
def __init__(self):
self.name = "on-crew"
@on(InterceptionPoint.PRE_MODEL_CALL)
def on_pre_model(self, context):
execution_log.append(self.name)
@agent
def researcher(self):
return Agent(role="Researcher", goal="Research", backstory="Expert")
@crew
def crew(self):
return Crew(agents=self.agents, tasks=[], verbose=False)
before = len(_resolve_hooks(InterceptionPoint.PRE_MODEL_CALL))
instance = TestCrew()
hooks = _resolve_hooks(InterceptionPoint.PRE_MODEL_CALL)
assert len(hooks) == before + 1
assert (
InterceptionPoint.PRE_MODEL_CALL.value,
hooks[-1],
) in instance._registered_hook_functions
mock_executor = Mock()
mock_executor.messages = []
mock_executor.agent = Mock(role="Test")
mock_executor.task = Mock()
mock_executor.crew = Mock()
mock_executor.llm = Mock()
mock_executor.iterations = 0
hooks[-1](LLMCallHookContext(executor=mock_executor))
assert execution_log == ["on-crew"]
class TestCrewScopedHookAttributes:
"""Test that crew-scoped hooks have correct attributes set."""

View File

@@ -0,0 +1,296 @@
"""Unit tests for the generic interception-hook dispatcher.
These cover the new contract (payload-in/payload-out + HookAborted), the shared
ordered queue between the legacy and new dialects on the four model/tool points,
execution-scoped hooks, fail-open exception handling, telemetry, and the no-op
fast-path overhead budget.
"""
from __future__ import annotations
from dataclasses import dataclass
import time
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.hook_events import HookDispatchedEvent
from crewai.hooks.dispatch import (
HookAborted,
InterceptionPoint,
clear_all,
dispatch,
get_hooks,
on,
register,
register_scoped,
scoped_hooks,
unregister as unregister_hook,
)
from crewai.hooks.llm_hooks import (
get_before_llm_call_hooks,
register_before_llm_call_hook,
)
import pytest
@dataclass
class _Ctx:
payload: object = None
tool_name: str | None = None
agent: object = None
agent_role: str | None = None
@pytest.fixture(autouse=True)
def clear_dispatch_registry():
"""Ensure every test starts and ends with an empty global registry."""
clear_all()
yield
clear_all()
class TestDispatchContract:
"""The core payload-in/payload-out + HookAborted contract."""
def test_noop_fast_path_returns_context_unchanged(self):
ctx = _Ctx(payload="original")
result = dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
assert result is ctx
assert ctx.payload == "original"
def test_return_value_replaces_payload(self):
def double(ctx):
return ctx.payload * 2
register(InterceptionPoint.PRE_MODEL_CALL, double)
ctx = _Ctx(payload="ab")
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
assert ctx.payload == "abab"
def test_in_place_mutation_is_honored(self):
def mutate(ctx):
ctx.payload.append(1)
return None
register(InterceptionPoint.PRE_MODEL_CALL, mutate)
ctx = _Ctx(payload=[])
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
assert ctx.payload == [1]
def test_hooks_run_in_registration_order(self):
order: list[int] = []
register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: order.append(1))
register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: order.append(2))
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
assert order == [1, 2]
def test_hook_aborted_propagates_with_reason_and_source(self):
def blocker(ctx):
raise HookAborted(reason="nope", source="policy")
register(InterceptionPoint.PRE_MODEL_CALL, blocker)
with pytest.raises(HookAborted) as exc:
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
assert exc.value.reason == "nope"
assert exc.value.source == "policy"
def test_ordinary_exception_is_swallowed_and_later_hooks_run(self):
ran: list[str] = []
def boom(ctx):
ran.append("boom")
raise ValueError("bug in user hook")
def after(ctx):
ran.append("after")
register(InterceptionPoint.PRE_MODEL_CALL, boom)
register(InterceptionPoint.PRE_MODEL_CALL, after)
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(), verbose=False)
assert ran == ["boom", "after"]
class TestOnDecorator:
"""The @on decorator registers and filters like the legacy decorators."""
def test_on_registers_global_hook(self):
@on(InterceptionPoint.POST_TOOL_CALL)
def hook(ctx):
return None
assert hook in get_hooks(InterceptionPoint.POST_TOOL_CALL)
def test_tool_filter_skips_non_matching_tools(self):
seen: list[str] = []
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["allowed_tool"])
def hook(ctx):
seen.append(ctx.tool_name)
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(tool_name="other_tool"))
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(tool_name="allowed_tool"))
assert seen == ["allowed_tool"]
def test_agent_filter_skips_non_matching_agents(self):
seen: list[str] = []
class _Agent:
def __init__(self, role):
self.role = role
@on(InterceptionPoint.PRE_MODEL_CALL, agents=["Researcher"])
def hook(ctx):
seen.append(ctx.agent.role)
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(agent=_Agent("Writer")))
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(agent=_Agent("Researcher")))
assert seen == ["Researcher"]
def test_agent_filter_falls_back_to_agent_role(self):
seen: list[str] = []
@on(InterceptionPoint.PRE_TOOL_CALL, agents=["Researcher"])
def hook(ctx):
seen.append(ctx.agent_role)
# No agent object, only the agent_role string (e.g. flow seams).
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(agent_role="Writer"))
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(agent_role="Researcher"))
assert seen == ["Researcher"]
def test_unregister_resolves_filtered_wrapper(self):
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["allowed_tool"])
def hook(ctx):
return None
assert len(get_hooks(InterceptionPoint.PRE_TOOL_CALL)) == 1
assert unregister_hook(InterceptionPoint.PRE_TOOL_CALL, hook) is True
assert get_hooks(InterceptionPoint.PRE_TOOL_CALL) == []
class TestSharedQueueWithLegacyDialect:
"""Legacy registrations and @on hooks compose in one ordered queue."""
def test_on_and_legacy_share_pre_model_call_queue(self):
def legacy(ctx):
return None
@on(InterceptionPoint.PRE_MODEL_CALL)
def modern(ctx):
return None
register_before_llm_call_hook(legacy)
queue = get_before_llm_call_hooks()
assert modern in queue
assert legacy in queue
# registration order preserved: modern registered before legacy
assert queue.index(modern) < queue.index(legacy)
class TestScopedHooks:
"""Execution-scoped hooks run after globals and are discarded on exit."""
def test_scoped_runs_after_global_then_cleared(self):
order: list[str] = []
register(InterceptionPoint.POST_MODEL_CALL, lambda ctx: order.append("global"))
with scoped_hooks():
register_scoped(InterceptionPoint.POST_MODEL_CALL, lambda ctx: order.append("scoped"))
dispatch(InterceptionPoint.POST_MODEL_CALL, _Ctx())
# outside the scope the scoped hook is gone
dispatch(InterceptionPoint.POST_MODEL_CALL, _Ctx())
assert order == ["global", "scoped", "global"]
class TestTelemetry:
"""dispatch emits a HookDispatchedEvent only when hooks ran."""
def test_no_event_on_empty_fast_path(self):
events: list[HookDispatchedEvent] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(HookDispatchedEvent)
def _capture(_source, event):
events.append(event)
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
assert events == []
def test_event_reports_outcome(self):
events: list[HookDispatchedEvent] = []
register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: "changed")
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(HookDispatchedEvent)
def _capture(_source, event):
events.append(event)
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
# Telemetry handlers run on the bus's thread pool; flush so the
# assertion doesn't race the emit.
crewai_event_bus.flush()
assert len(events) == 1
assert events[0].interception_point == "pre_model_call"
assert events[0].outcome == "modified"
assert events[0].hook_count == 1
def test_event_reports_abort_outcome(self):
events: list[HookDispatchedEvent] = []
def blocker(ctx):
raise HookAborted(reason="blocked", source="policy")
register(InterceptionPoint.PRE_MODEL_CALL, blocker)
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(HookDispatchedEvent)
def _capture(_source, event):
events.append(event)
with pytest.raises(HookAborted):
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
crewai_event_bus.flush()
assert len(events) == 1
assert events[0].interception_point == "pre_model_call"
assert events[0].outcome == "aborted"
assert events[0].abort_reason == "blocked"
assert events[0].abort_source == "policy"
class TestNoOpOverhead:
"""The no-op fast path must stay cheap (a single dict lookup)."""
def test_noop_dispatch_overhead_is_bounded(self):
# Relative (not absolute) budget: the no-op fast path is a dict lookup
# plus a guard, so it should stay within a wide multiple of a bare
# function call. This catches accidental O(n) regressions without
# depending on absolute timing on shared CI runners.
ctx = _Ctx()
iterations = 100_000
def _baseline(_c):
return _c
for _ in range(1000): # warm up both paths
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
_baseline(ctx)
start = time.perf_counter()
for _ in range(iterations):
_baseline(ctx)
baseline = time.perf_counter() - start
start = time.perf_counter()
for _ in range(iterations):
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
noop = time.perf_counter() - start
assert noop < baseline * 50 + 5e-3

View File

@@ -0,0 +1,353 @@
"""Conformance suite for the framework-native interception points.
For each wired point this suite asserts the shared contract: the probe hook
sees a well-shaped payload, an in-place/returned modification is honored, and a
:class:`HookAborted` interrupts the step.
"""
from __future__ import annotations
import asyncio
from unittest.mock import patch
from crewai.agent import Agent
from crewai.crew import Crew
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.crew_events import CrewKickoffCompletedEvent
from crewai.flow.flow import Flow, listen, start
from crewai.hooks.dispatch import (
HookAborted,
InterceptionPoint,
clear_all,
on,
)
from crewai.task import Task
import pytest
@pytest.fixture(autouse=True)
def clear_dispatch_registry():
clear_all()
yield
clear_all()
class _SimpleFlow(Flow):
@start()
def begin(self):
return "begin"
@listen(begin)
def finish(self, _):
return "flow-result"
class _FailingFlow(Flow):
@start()
def begin(self):
raise RuntimeError("flow boom")
class _ReentrantFailingFlow(Flow):
"""Kicks itself off once from inside a method, then fails in the outer run."""
@start()
async def begin(self):
if getattr(self, "_reentered", False):
return "inner-ok"
self._reentered = True
await self.kickoff_async()
raise RuntimeError("outer boom")
class TestFlowExecutionBoundaries:
"""execution_start / input / output / execution_end on a flow."""
def test_all_boundary_points_fire_once(self):
fired: list[str] = []
for point in (
InterceptionPoint.EXECUTION_START,
InterceptionPoint.INPUT,
InterceptionPoint.OUTPUT,
InterceptionPoint.EXECUTION_END,
):
@on(point)
def _probe(ctx, _point=point):
fired.append(_point.value)
_SimpleFlow().kickoff(inputs={"seed": 1})
assert fired == [
"execution_start",
"input",
"output",
"execution_end",
]
def test_output_modification_is_honored(self):
@on(InterceptionPoint.OUTPUT)
def rewrite(ctx):
return "intercepted"
result = _SimpleFlow().kickoff()
assert result == "intercepted"
def test_input_payload_carries_inputs(self):
seen: dict = {}
@on(InterceptionPoint.INPUT)
def capture(ctx):
seen.update(ctx.payload or {})
_SimpleFlow().kickoff(inputs={"seed": 42})
assert seen == {"seed": 42}
def test_abort_at_execution_start_interrupts(self):
@on(InterceptionPoint.EXECUTION_START)
def block(ctx):
raise HookAborted(reason="not allowed", source="policy")
with pytest.raises(HookAborted) as exc:
_SimpleFlow().kickoff()
assert exc.value.reason == "not allowed"
class TestFlowStepPoints:
"""pre_step / post_step for flow methods (kind=flow_method)."""
def test_pre_and_post_step_fire_per_method(self):
kinds: list[tuple[str, str | None]] = []
@on(InterceptionPoint.PRE_STEP)
def pre(ctx):
kinds.append(("pre", ctx.step_name))
@on(InterceptionPoint.POST_STEP)
def post(ctx):
kinds.append(("post", ctx.step_name))
_SimpleFlow().kickoff()
assert ("pre", "begin") in kinds
assert ("post", "begin") in kinds
assert ("pre", "finish") in kinds
assert ("post", "finish") in kinds
def test_post_step_can_rewrite_method_output(self):
@on(InterceptionPoint.POST_STEP)
def rewrite(ctx):
if ctx.step_name == "finish":
return "rewritten"
return None
assert _SimpleFlow().kickoff() == "rewritten"
class TestTaskStepPoints:
"""pre_step / post_step for task execution (kind=task)."""
def test_post_step_rewrite_is_persisted_to_output_file(
self, tmp_path, monkeypatch
):
@on(InterceptionPoint.POST_STEP)
def sanitize(ctx):
return ctx.payload.model_copy(update={"raw": "sanitized output"})
monkeypatch.chdir(tmp_path)
agent = Agent(role="Writer", goal="Write", backstory="Writes things.")
task = Task(
description="Write something",
expected_output="Some text",
output_file="output.txt",
agent=agent,
)
with patch.object(Agent, "execute_task", return_value="original output"):
result = task.execute_sync(agent=agent)
assert result.raw == "sanitized output"
assert (tmp_path / "output.txt").read_text() == "sanitized output"
class TestExecutionEndOnFailure:
"""execution_end fires exactly once, on success and on failure alike."""
@staticmethod
def _crew() -> Crew:
agent = Agent(role="Writer", goal="Write", backstory="Writes things.")
task = Task(
description="Write something",
expected_output="Some text",
agent=agent,
)
return Crew(agents=[agent], tasks=[task], verbose=False)
def test_crew_success_fires_completed_once(self):
seen: list[tuple[str, BaseException | None]] = []
@on(InterceptionPoint.EXECUTION_END)
def capture(ctx):
seen.append((ctx.status, ctx.error))
with patch.object(Agent, "execute_task", return_value="fine"):
self._crew().kickoff()
assert seen == [("completed", None)]
def test_crew_failure_fires_failed_once_and_reraises(self):
seen = []
@on(InterceptionPoint.EXECUTION_END)
def capture(ctx):
seen.append(ctx)
error = RuntimeError("crew boom")
with patch.object(Agent, "execute_task", side_effect=error):
with pytest.raises(RuntimeError, match="crew boom"):
self._crew().kickoff()
assert len(seen) == 1
assert seen[0].status == "failed"
assert seen[0].error is error
assert seen[0].output is None
def test_crew_kickoff_async_failure_fires_failed_once(self):
seen = []
@on(InterceptionPoint.EXECUTION_END)
def capture(ctx):
seen.append(ctx)
with patch.object(
Agent, "execute_task", side_effect=RuntimeError("crew boom")
):
with pytest.raises(RuntimeError, match="crew boom"):
asyncio.run(self._crew().kickoff_async())
assert len(seen) == 1
assert seen[0].status == "failed"
def test_flow_success_fires_completed_once(self):
seen: list[tuple[str, BaseException | None]] = []
@on(InterceptionPoint.EXECUTION_END)
def capture(ctx):
seen.append((ctx.status, ctx.error))
_SimpleFlow().kickoff()
assert seen == [("completed", None)]
def test_flow_failure_fires_failed_once_and_reraises(self):
seen = []
@on(InterceptionPoint.EXECUTION_END)
def capture(ctx):
seen.append(ctx)
with pytest.raises(RuntimeError, match="flow boom"):
_FailingFlow().kickoff()
assert len(seen) == 1
assert seen[0].status == "failed"
assert isinstance(seen[0].error, RuntimeError)
assert seen[0].output is None
def test_reentrant_flow_kickoff_pairs_ends_per_invocation(self):
seen: list[tuple[str, str | None]] = []
@on(InterceptionPoint.EXECUTION_START)
def capture_start(ctx):
seen.append(("start", None))
@on(InterceptionPoint.EXECUTION_END)
def capture_end(ctx):
seen.append(("end", ctx.status))
with pytest.raises(RuntimeError, match="outer boom"):
_ReentrantFailingFlow().kickoff()
assert seen == [
("start", None),
("start", None),
("end", "completed"),
("end", "failed"),
]
def test_no_execution_end_when_execution_start_aborts(self):
seen = []
@on(InterceptionPoint.EXECUTION_START)
def block(ctx):
raise HookAborted(reason="blocked")
@on(InterceptionPoint.EXECUTION_END)
def capture(ctx):
seen.append(ctx)
with pytest.raises(HookAborted):
_SimpleFlow().kickoff()
with pytest.raises(HookAborted):
self._crew().kickoff()
assert seen == []
def test_aborting_execution_end_hook_fires_once_for_flow(self):
calls: list[str] = []
@on(InterceptionPoint.EXECUTION_END)
def abort_end(ctx):
calls.append(ctx.status)
raise HookAborted(reason="no")
with pytest.raises(HookAborted):
_SimpleFlow().kickoff()
assert calls == ["completed"]
def test_aborting_execution_end_hook_fires_once_for_crew(self):
calls: list[str] = []
@on(InterceptionPoint.EXECUTION_END)
def abort_end(ctx):
calls.append(ctx.status)
raise HookAborted(reason="no")
with patch.object(Agent, "execute_task", return_value="fine"):
with pytest.raises(HookAborted):
self._crew().kickoff()
assert calls == ["completed"]
class TestCrewOutput:
def test_output_modification_reaches_kickoff_completed_event(self):
@on(InterceptionPoint.OUTPUT)
def append_notice(ctx):
if hasattr(ctx.payload, "raw") and isinstance(ctx.payload.raw, str):
ctx.payload.raw += "\nchanged by hook"
return None
completed_raw: list[str] = []
@crewai_event_bus.on(CrewKickoffCompletedEvent)
def capture_completed(_source, event: CrewKickoffCompletedEvent):
completed_raw.append(event.output.raw)
agent = Agent(role="Writer", goal="Write", backstory="Writes things.")
task = Task(
description="Write something",
expected_output="Some text",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task], verbose=False)
with patch.object(Agent, "execute_task", return_value="original output"):
result = crew.kickoff()
crewai_event_bus.flush()
assert result.raw.endswith("changed by hook")
assert completed_raw
assert completed_raw[-1].endswith("changed by hook")

View File

@@ -272,6 +272,40 @@ class TestLLMHooksIntegration:
assert result == "Original [hook1] [hook2]"
def test_after_hooks_do_not_clobber_native_tool_call_responses(
self, mock_executor
):
"""A registered after hook must not break native tool execution.
Regression for crewAIInc/crewAI#6529: `_setup_after_llm_call_hooks`
stringified structured tool-call payloads, so the executor treated the
raw tool call as the final answer and never executed the tool. Non-str,
non-BaseModel responses now pass through untouched; hooks still fire on
textual responses.
"""
from crewai.utilities.agent_utils import _setup_after_llm_call_hooks
observed = []
def observer(context):
observed.append(context.response)
return None
register_after_llm_call_hook(observer)
mock_executor.after_llm_call_hooks = get_after_llm_call_hooks()
tool_calls = [Mock()] # structured native tool-call payload
result = _setup_after_llm_call_hooks(
mock_executor, tool_calls, printer=Mock(), verbose=False
)
assert result is tool_calls
text = _setup_after_llm_call_hooks(
mock_executor, "final answer", printer=Mock(), verbose=False
)
assert text == "final answer"
assert observed == ["final answer"]
def test_unregister_before_hook(self):
"""Test that before hooks can be unregistered."""
def test_hook(context):
@@ -303,6 +337,105 @@ class TestLLMHooksIntegration:
hooks = get_before_llm_call_hooks()
assert len(hooks) == 0
def test_raising_before_hook_does_not_skip_later_hooks(self, mock_executor):
"""Fail-open is per-hook: a crashing hook must not disable its neighbors.
Regression guard for the dispatcher migration: previously the
``except Exception`` wrapped the whole hook loop, so a raising hook
silently skipped every hook registered after it. Now swallowing is
per-hook — later hooks still run and the LLM call still proceeds.
"""
from crewai.utilities.agent_utils import _setup_before_llm_call_hooks
ran: list[str] = []
def crashing_hook(context):
ran.append("crashing")
raise ValueError("bug in user hook")
def later_hook(context):
ran.append("later")
register_before_llm_call_hook(crashing_hook)
register_before_llm_call_hook(later_hook)
mock_executor.before_llm_call_hooks = get_before_llm_call_hooks()
proceed = _setup_before_llm_call_hooks(
mock_executor, printer=Mock(), verbose=False
)
assert ran == ["crashing", "later"]
assert proceed is True
def test_scoped_hooks_fire_on_agent_executor_llm_seams(self, mock_executor):
"""register_scoped hooks must run on the executor model seams.
Regression: `_setup_before/after_llm_call_hooks` only ran the
executor's snapshot lists, so execution-scoped hooks never fired on
PRE/POST_MODEL_CALL during normal agent execution (while tool seams,
which go through `dispatch`, merged them). Scoped hooks run after the
snapshot, matching dispatch's global-then-scoped ordering.
"""
from crewai.hooks import InterceptionPoint
from crewai.hooks.dispatch import register_scoped, scoped_hooks
from crewai.utilities.agent_utils import (
_setup_after_llm_call_hooks,
_setup_before_llm_call_hooks,
)
order: list[str] = []
def snapshot_hook(context):
order.append("snapshot")
mock_executor.before_llm_call_hooks = [snapshot_hook]
mock_executor.after_llm_call_hooks = []
with scoped_hooks():
register_scoped(
InterceptionPoint.PRE_MODEL_CALL,
lambda ctx: order.append("scoped_pre"),
)
register_scoped(
InterceptionPoint.POST_MODEL_CALL,
lambda ctx: order.append("scoped_post"),
)
proceed = _setup_before_llm_call_hooks(
mock_executor, printer=Mock(), verbose=False
)
answer = _setup_after_llm_call_hooks(
mock_executor, "answer", printer=Mock(), verbose=False
)
assert order == ["snapshot", "scoped_pre", "scoped_post"]
assert proceed is True
assert answer == "answer"
def test_intentional_block_still_short_circuits_later_hooks(self, mock_executor):
"""A hook returning False blocks the call and skips later hooks (unchanged)."""
from crewai.utilities.agent_utils import _setup_before_llm_call_hooks
ran: list[str] = []
def blocking_hook(context):
ran.append("blocking")
return False
def later_hook(context):
ran.append("later")
register_before_llm_call_hook(blocking_hook)
register_before_llm_call_hook(later_hook)
mock_executor.before_llm_call_hooks = get_before_llm_call_hooks()
proceed = _setup_before_llm_call_hooks(
mock_executor, printer=Mock(), verbose=False
)
assert ran == ["blocking"]
assert proceed is False
@pytest.mark.vcr()
def test_lite_agent_hooks_integration_with_real_llm(self):
"""Test that LiteAgent executes before/after LLM call hooks and prints messages correctly."""
@@ -463,3 +596,77 @@ class TestLLMHooksIntegration:
finally:
unregister_before_llm_call_hook(before_hook)
unregister_after_llm_call_hook(after_hook)
class TestDirectLLMScopedHooks:
"""Direct (agent-less) LLM calls must honor execution-scoped hooks.
Regression: the direct-call helpers used to short-circuit when the global
hook list was empty, so hooks registered only for the current
``scoped_hooks()`` context never ran on this path.
"""
@staticmethod
def _stub_llm():
from crewai.llms.base_llm import BaseLLM
class _StubLLM(BaseLLM):
def call(self, *args: object, **kwargs: object) -> str:
return ""
return _StubLLM(model="stub")
def test_scoped_before_hook_runs_on_direct_call(self):
from crewai.hooks import InterceptionPoint
from crewai.hooks.dispatch import register_scoped, scoped_hooks
llm = self._stub_llm()
seen: list[int] = []
with scoped_hooks():
register_scoped(
InterceptionPoint.PRE_MODEL_CALL,
lambda ctx: seen.append(len(ctx.messages)),
)
proceed = llm._invoke_before_llm_call_hooks(
[{"role": "user", "content": "hi"}], from_agent=None
)
assert proceed is True
assert seen == [1]
def test_scoped_before_hook_can_block_direct_call(self):
from crewai.hooks import InterceptionPoint
from crewai.hooks.dispatch import HookAborted, register_scoped, scoped_hooks
llm = self._stub_llm()
def block(ctx: LLMCallHookContext) -> None:
raise HookAborted(reason="blocked by scoped hook")
with scoped_hooks():
register_scoped(InterceptionPoint.PRE_MODEL_CALL, block)
proceed = llm._invoke_before_llm_call_hooks(
[{"role": "user", "content": "hi"}], from_agent=None
)
assert proceed is False
def test_scoped_after_hook_modifies_direct_response(self):
from crewai.hooks import InterceptionPoint
from crewai.hooks.dispatch import register_scoped, scoped_hooks
llm = self._stub_llm()
def redact(ctx: LLMCallHookContext) -> str:
return ctx.response.replace("SECRET", "[REDACTED]")
with scoped_hooks():
register_scoped(InterceptionPoint.POST_MODEL_CALL, redact)
result = llm._invoke_after_llm_call_hooks(
[{"role": "user", "content": "hi"}],
"contains SECRET",
from_agent=None,
)
assert result == "contains [REDACTED]"

View File

@@ -576,6 +576,75 @@ class TestToolHooksIntegration:
unregister_after_tool_call_hook(after_tool_call_hook)
class TestPerHookFailOpen:
"""Fail-open is per-hook: a crashing hook must not disable its neighbors.
Regression guards for the dispatcher migration: previously each seam's
``except Exception`` wrapped the whole hook loop, so a raising hook
silently skipped every hook registered after it.
"""
def test_raising_before_hook_does_not_skip_later_hooks_or_block(
self, mock_tool, mock_agent
):
from crewai.hooks.tool_hooks import run_before_tool_call_hooks
mock_agent.verbose = False
ran: list[str] = []
def crashing_hook(context):
ran.append("crashing")
raise ValueError("bug in user hook")
def later_hook(context):
ran.append("later")
register_before_tool_call_hook(crashing_hook)
register_before_tool_call_hook(later_hook)
context = ToolCallHookContext(
tool_name="test_tool",
tool_input={"arg": "value"},
tool=mock_tool,
agent=mock_agent,
)
blocked = run_before_tool_call_hooks(context)
assert ran == ["crashing", "later"]
assert blocked is False
def test_raising_after_hook_does_not_skip_later_result_rewrites(
self, mock_tool, mock_agent
):
from crewai.hooks.tool_hooks import run_after_tool_call_hooks
mock_agent.verbose = False
ran: list[str] = []
def crashing_hook(context):
ran.append("crashing")
raise ValueError("bug in user hook")
def rewriting_hook(context):
ran.append("rewriting")
return f"{context.tool_result} [rewritten]"
register_after_tool_call_hook(crashing_hook)
register_after_tool_call_hook(rewriting_hook)
context = ToolCallHookContext(
tool_name="test_tool",
tool_input={"arg": "value"},
tool=mock_tool,
agent=mock_agent,
tool_result="original",
)
result = run_after_tool_call_hooks(context)
assert ran == ["crashing", "rewriting"]
assert result == "original [rewritten]"
class TestNativeToolCallingHooksIntegration:
"""Integration tests for hooks with native function calling (Agent and Crew)."""

View File

@@ -1,7 +1,7 @@
import os
import sys
import types
from unittest.mock import patch, MagicMock
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from crewai.llm import LLM
@@ -1358,6 +1358,248 @@ _MANY_TOOLS = [
]
def _dict_tool_use_response():
mock_response = MagicMock()
mock_response.content = [
{
"type": "tool_use",
"id": "toolu_123",
"name": "search_web",
"input": {"query": "CrewAI"},
}
]
mock_response.usage = MagicMock(input_tokens=10, output_tokens=2)
mock_response.stop_reason = "tool_use"
mock_response.id = "msg_123"
return mock_response
class _SyncAnthropicStream:
def __init__(self, events, final_message):
self.events = events
self.final_message = final_message
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def __iter__(self):
return iter(self.events)
def get_final_message(self):
return self.final_message
class _AsyncAnthropicStream:
def __init__(self, events, final_message):
self.events = list(events)
self.final_message = final_message
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
def __aiter__(self):
self._iter = iter(self.events)
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration
async def get_final_message(self):
return self.final_message
def _dict_tool_use_stream_events():
return [
types.SimpleNamespace(
type="content_block_start",
index=0,
content_block={
"type": "tool_use",
"id": "toolu_123",
"name": "search_web",
"input": {},
},
),
types.SimpleNamespace(
type="content_block_delta",
index=0,
delta=types.SimpleNamespace(
type="input_json_delta",
partial_json='{"query":"CrewAI"}',
),
),
]
def test_anthropic_tool_use_dict_blocks_are_returned_as_tool_calls():
"""Preview Anthropic models may return dict-shaped tool_use blocks."""
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
def test_anthropic_dict_tool_use_blocks_require_id():
"""Incomplete dict-shaped tool_use blocks are not valid tool calls."""
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
del mock_response.content[0]["id"]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == ""
def test_anthropic_object_tool_use_blocks_require_id():
"""Incomplete object-shaped tool_use blocks are not valid tool calls."""
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = MagicMock()
mock_response.content = [
types.SimpleNamespace(
type="tool_use",
name="search_web",
input={"query": "CrewAI"},
)
]
mock_response.usage = MagicMock(input_tokens=10, output_tokens=2)
mock_response.stop_reason = "tool_use"
mock_response.id = "msg_123"
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == ""
@pytest.mark.asyncio
async def test_anthropic_acall_returns_dict_tool_use_blocks_as_tool_calls():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
llm._async_client = mock_client
result = await llm.acall("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
def test_anthropic_streaming_returns_dict_tool_use_blocks_as_tool_calls():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5", stream=True)
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.stream.return_value = _SyncAnthropicStream(
_dict_tool_use_stream_events(), mock_response
)
llm._client = mock_client
llm._emit_stream_chunk_event = MagicMock()
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
assert any(
call.kwargs.get("tool_call", {}).get("id") == "toolu_123"
for call in llm._emit_stream_chunk_event.call_args_list
)
@pytest.mark.asyncio
async def test_anthropic_async_streaming_returns_dict_tool_use_blocks_as_tool_calls():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5", stream=True)
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.stream.return_value = _AsyncAnthropicStream(
_dict_tool_use_stream_events(), mock_response
)
llm._async_client = mock_client
llm._emit_stream_chunk_event = MagicMock()
result = await llm.acall("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
assert any(
call.kwargs.get("tool_call", {}).get("id") == "toolu_123"
for call in llm._emit_stream_chunk_event.call_args_list
)
def test_anthropic_dict_tool_use_blocks_execute_available_function():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call(
"Search for CrewAI",
tools=_MANY_TOOLS,
available_functions={"search_web": lambda query: f"found {query}"},
)
assert result == "found CrewAI"
def test_anthropic_dict_tool_use_blocks_work_in_follow_up_conversation():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
initial_response = _dict_tool_use_response()
final_response = MagicMock()
final_response.content = [types.SimpleNamespace(text="Final answer")]
final_response.usage = MagicMock(input_tokens=4, output_tokens=3)
final_response.stop_reason = "end_turn"
final_response.id = "msg_final"
mock_client = MagicMock()
mock_client.messages.create.return_value = final_response
llm._client = mock_client
result = llm._handle_tool_use_conversation(
initial_response,
initial_response.content,
params={"messages": []},
available_functions={"search_web": lambda query: f"found {query}"},
)
assert result == "Final answer"
@pytest.mark.vcr()
def test_tool_search_discovers_and_calls_tool():
"""Tool search should discover the right tool and return a tool_use block."""

View File

@@ -0,0 +1,209 @@
"""Tests for models that /v1/chat/completions doesn't serve at all.
The pro tier exists but is Responses-API-only. OpenAI reports it as a 404 that is
distinguishable from a genuine unknown model:
responses-only param="model", "only supported in v1/responses"
or "This is not a chat model"
genuine typo code="model_not_found", "does not exist"
Measured 2026-07 against the live endpoints: gpt-5-pro, gpt-5.2-pro, gpt-5.4-pro,
gpt-5.5-pro, o1-pro and o3-pro all 404 on chat completions and work on
/v1/responses. Rather than hardcoding that list, the 404 is caught and the call is
retried on the Responses API, then the model is remembered so the wasted round trip
is paid once per process.
"""
import httpx
import pytest
from openai import NotFoundError
from crewai.llms.providers.openai import completion as completion_module
from crewai.llms.providers.openai.completion import OpenAICompletion
MESSAGES = [{"role": "user", "content": "hi"}]
RESPONSES_ONLY_MESSAGES = (
"This model is only supported in v1/responses and not in /v1/chat/completions.",
"This is not a chat model and thus not supported in the v1/chat/completions "
"endpoint. Did you mean to use v1/completions?",
)
def build(model: str, **kwargs) -> OpenAICompletion:
return OpenAICompletion(model=model, api_key="sk-test", **kwargs)
def make_not_found(message: str, code: str | None = None) -> NotFoundError:
body = {
"error": {
"message": message,
"type": "invalid_request_error",
"param": None if code else "model",
"code": code,
}
}
response = httpx.Response(
status_code=404,
json=body,
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
)
return NotFoundError(message, response=response, body=body)
@pytest.fixture(autouse=True)
def _clear_learned_models():
"""Keep the process-wide learned set from leaking between tests."""
completion_module._LEARNED_RESPONSES_ONLY_MODELS.clear()
yield
completion_module._LEARNED_RESPONSES_ONLY_MODELS.clear()
class TestErrorClassification:
@pytest.mark.parametrize("message", RESPONSES_ONLY_MESSAGES)
def test_detects_responses_only_404(self, message: str):
assert OpenAICompletion._is_responses_only_error(make_not_found(message))
def test_ignores_genuine_unknown_model(self):
"""A real typo must keep failing, not get retried on another endpoint."""
error = make_not_found(
"The model `gpt-5.99-fake` does not exist or you do not have access "
"to it.",
code="model_not_found",
)
assert not OpenAICompletion._is_responses_only_error(error)
def test_ignores_unrelated_exceptions(self):
assert not OpenAICompletion._is_responses_only_error(RuntimeError("boom"))
class TestFallback:
def test_retries_on_responses_and_remembers_the_model(self, monkeypatch):
llm = build("gpt-5-pro")
calls: list[str] = []
def fail_completion(**kwargs):
calls.append("completions")
raise ValueError("wrapped") from make_not_found(
RESPONSES_ONLY_MESSAGES[0]
)
monkeypatch.setattr(llm, "_handle_completion", fail_completion)
monkeypatch.setattr(
llm, "_call_responses", lambda **kwargs: calls.append("responses") or "ok"
)
assert llm._call_completions(MESSAGES) == "ok"
assert calls == ["completions", "responses"]
# Second call must skip the doomed chat-completions attempt.
assert llm._effective_api() == "responses"
def test_does_not_retry_genuine_unknown_model(self, monkeypatch):
llm = build("gpt-5.99-fake")
calls: list[str] = []
def fail_completion(**kwargs):
calls.append("completions")
raise ValueError("wrapped") from make_not_found(
"The model does not exist.", code="model_not_found"
)
monkeypatch.setattr(llm, "_handle_completion", fail_completion)
monkeypatch.setattr(
llm, "_call_responses", lambda **kwargs: calls.append("responses") or "ok"
)
with pytest.raises(ValueError):
llm._call_completions(MESSAGES)
assert calls == ["completions"]
assert not completion_module._LEARNED_RESPONSES_ONLY_MODELS
def test_custom_endpoint_never_falls_back(self, monkeypatch):
"""An OpenAI-compatible server may not implement /v1/responses at all.
Most (vLLM, LiteLLM proxies, Ollama) don't, so a self-hosted model must
keep the endpoint the user chose rather than being silently rerouted.
"""
llm = build(
"o1-pro", custom_openai=True, base_url="https://my-vllm.internal/v1"
)
calls: list[str] = []
def fail_completion(**kwargs):
calls.append("completions")
raise ValueError("wrapped") from make_not_found(
RESPONSES_ONLY_MESSAGES[0]
)
monkeypatch.setattr(llm, "_handle_completion", fail_completion)
monkeypatch.setattr(
llm, "_call_responses", lambda **kwargs: calls.append("responses") or "ok"
)
with pytest.raises(ValueError):
llm._call_completions(MESSAGES)
assert calls == ["completions"]
@pytest.mark.asyncio
async def test_async_path_falls_back_too(self, monkeypatch):
llm = build("gpt-5-pro")
calls: list[str] = []
async def fail_completion(**kwargs):
calls.append("completions")
raise ValueError("wrapped") from make_not_found(
RESPONSES_ONLY_MESSAGES[0]
)
async def ok_responses(**kwargs):
calls.append("responses")
return "ok"
monkeypatch.setattr(llm, "_ahandle_completion", fail_completion)
monkeypatch.setattr(llm, "_acall_responses", ok_responses)
assert await llm._acall_completions(MESSAGES) == "ok"
assert calls == ["completions", "responses"]
class TestEffectiveApi:
def test_defaults_to_completions_for_unknown_models(self):
"""Nothing is assumed up front; the 404 is what teaches us."""
assert build("gpt-5-pro")._effective_api() == "completions"
def test_explicit_responses_is_honoured(self):
assert build("gpt-5.5", api="responses")._effective_api() == "responses"
def test_learned_model_routes_directly(self):
llm = build("gpt-5-pro")
llm._remember_responses_only_model()
assert llm._effective_api() == "responses"
def test_learned_model_still_respects_custom_endpoint(self):
llm = build(
"gpt-5-pro", custom_openai=True, base_url="https://my-vllm.internal/v1"
)
completion_module._LEARNED_RESPONSES_ONLY_MODELS.add("gpt-5-pro")
assert llm._effective_api() == "completions"
class TestNotFoundMessage:
@pytest.mark.parametrize("message", RESPONSES_ONLY_MESSAGES)
def test_points_at_responses_api(self, message: str):
msg = build("gpt-5.5")._model_not_found_message(make_not_found(message))
assert 'api="responses"' in msg
assert "not available on /v1/chat/completions" in msg
def test_keeps_plain_not_found_for_real_typos(self):
msg = build("gpt-5.5")._model_not_found_message(
make_not_found("The model does not exist.", code="model_not_found")
)
assert "not found" in msg
assert 'api="responses"' not in msg

View File

@@ -0,0 +1,226 @@
"""Tests for tool calling on the OpenAI Responses API path.
The two APIs express tool calling differently:
Chat Completions assistant message with `tool_calls` (content: None),
then {"role": "tool", "tool_call_id": ...}
Responses flat {"type": "function_call", "call_id", ...} and
{"type": "function_call_output", "call_id", "output"} items
Sending the chat shape to /v1/responses is rejected outright:
Invalid type for 'input[1].content': expected one of an array of objects or
string, but got null instead.
Verified against the live endpoint: the chat shape 400s, the native items complete.
"""
import pytest
from crewai.llms.providers.openai.completion import OpenAICompletion
from crewai.utilities.agent_utils import extract_tool_call_info, is_tool_call_list
# The shape OpenAICompletion._extract_function_calls_from_response builds from a
# Responses payload: no nested "function" object, no "input".
RESPONSES_TOOL_CALL = {
"id": "call_abc",
"name": "multiply",
"arguments": '{"a": 17, "b": 23}',
}
# A raw Responses `function_call` output item, as returned by the API. Note that
# "id" and "call_id" are different values -- the matching function_call_output must
# reference "call_id".
RAW_RESPONSES_ITEM = {
"type": "function_call",
"id": "fc_0adeb715c5d740c7006a65ccb7",
"call_id": "call_dEoHFrYnOgWYvk17FymdcDZ5",
"name": "multiply",
"arguments": '{"a": 17, "b": 23}',
"status": "completed",
}
def build(model: str = "gpt-5.5", **kwargs) -> OpenAICompletion:
return OpenAICompletion(model=model, api_key="sk-test", api="responses", **kwargs)
class TestToolCallRecognition:
"""The executor must recognize Responses-shaped tool calls."""
def test_recognizes_responses_shape(self):
assert is_tool_call_list([RESPONSES_TOOL_CALL])
def test_extracts_arguments_from_top_level(self):
"""Previously fell through to `input` and silently yielded {}."""
call_id, name, args = extract_tool_call_info(RESPONSES_TOOL_CALL)
assert call_id == "call_abc"
assert name == "multiply"
assert args == '{"a": 17, "b": 23}'
@pytest.mark.parametrize(
("tool_call", "expected_args"),
[
(
{"id": "c", "function": {"name": "f", "arguments": '{"x":1}'}},
'{"x":1}',
),
({"toolUseId": "c", "name": "f", "input": {"x": 1}}, {"x": 1}),
],
)
def test_other_provider_shapes_still_work(self, tool_call, expected_args):
"""Chat Completions and Bedrock shapes must be unaffected."""
assert is_tool_call_list([tool_call])
assert extract_tool_call_info(tool_call)[2] == expected_args
def test_raw_responses_item_uses_call_id_not_item_id(self):
"""A raw function_call item carries both; only call_id can be correlated.
function_call_output must reference call_id, so picking up the item's own
"id" (fc_...) would produce a tool result the model can't match to its
invocation.
"""
call_id, name, args = extract_tool_call_info(RAW_RESPONSES_ITEM)
assert call_id == "call_dEoHFrYnOgWYvk17FymdcDZ5"
assert call_id != RAW_RESPONSES_ITEM["id"]
assert name == "multiply"
assert args == '{"a": 17, "b": 23}'
class TestResponsesInputTranslation:
"""Chat-format tool messages must become native Responses items."""
def test_assistant_tool_calls_become_function_call_items(self):
message = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "multiply", "arguments": '{"a":17,"b":23}'},
}
],
}
assert OpenAICompletion._to_responses_input(message) == [
{
"type": "function_call",
"call_id": "call_1",
"name": "multiply",
"arguments": '{"a":17,"b":23}',
}
]
def test_tool_result_becomes_function_call_output(self):
message = {"role": "tool", "tool_call_id": "call_1", "content": "391"}
assert OpenAICompletion._to_responses_input(message) == [
{"type": "function_call_output", "call_id": "call_1", "output": "391"}
]
def test_assistant_text_alongside_tool_calls_is_preserved(self):
message = {
"role": "assistant",
"content": "Let me calculate.",
"tool_calls": [
{"id": "c1", "function": {"name": "multiply", "arguments": "{}"}}
],
}
items = OpenAICompletion._to_responses_input(message)
assert items[0] == {"role": "assistant", "content": "Let me calculate."}
assert items[1]["type"] == "function_call"
def test_parallel_tool_calls_become_separate_items(self):
message = {
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "c1", "function": {"name": "multiply", "arguments": "{}"}},
{"id": "c2", "function": {"name": "add", "arguments": "{}"}},
],
}
items = OpenAICompletion._to_responses_input(message)
assert [i["call_id"] for i in items] == ["c1", "c2"]
@pytest.mark.parametrize(
"message",
[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
],
)
def test_messages_without_tool_calls_pass_through(self, message):
assert OpenAICompletion._to_responses_input(message) == [message]
def test_non_string_tool_output_is_coerced(self):
"""Tool results arrive as ints, dicts, etc. The API requires a string."""
message = {"role": "tool", "tool_call_id": "c1", "content": 391}
assert OpenAICompletion._to_responses_input(message)[0]["output"] == "391"
def test_call_and_output_ids_round_trip(self):
"""The id extracted from a call must be the one sent back with its result.
This is the correlation the API relies on: a function_call_output whose
call_id doesn't match an emitted function_call is rejected or ignored.
"""
call_id, name, args = extract_tool_call_info(RAW_RESPONSES_ITEM)
assistant = OpenAICompletion._to_responses_input(
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": call_id, "function": {"name": name, "arguments": args}}
],
}
)
result = OpenAICompletion._to_responses_input(
{"role": "tool", "tool_call_id": call_id, "content": "391"}
)
assert assistant[0]["call_id"] == result[0]["call_id"] == call_id
class TestPreparedParams:
"""End-to-end shape of the `input` list handed to the Responses API."""
def test_tool_conversation_produces_valid_input(self):
llm = build()
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "multiply 17 and 23"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"function": {"name": "multiply", "arguments": '{"a":17,"b":23}'},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "391"},
]
params = llm._prepare_responses_params(messages)
assert params["instructions"] == "You are helpful."
assert [item.get("type") or item["role"] for item in params["input"]] == [
"user",
"function_call",
"function_call_output",
]
# The rejected shape was an item carrying an explicit content: None.
# function_call items have no content key at all, which is valid.
assert not any(
"content" in item and item["content"] is None for item in params["input"]
)

View File

@@ -0,0 +1,328 @@
"""GPT-5.6 rejects function tools alongside `reasoning_effort` on completions.
The family applies a server-side `reasoning_effort` default, so a payload that
never mentions the parameter is still refused as soon as tools are attached --
measured against the live endpoint:
gpt-5.6-sol tools, no reasoning_effort key -> 400
gpt-5.6-sol tools, reasoning_effort="none" -> OK
gpt-5.5 tools, no reasoning_effort key -> OK
So an ordinary agent with a tool fails on this model family and nothing else. The
400 is recovered by resending with an explicit "none"; no model list is involved,
so a family OpenAI restricts later is handled without a release here.
"""
import json
from types import SimpleNamespace
import httpx
import pytest
from openai import BadRequestError
from crewai import Agent, Crew, Task
from crewai.llms.providers.openai.completion import OpenAICompletion
from crewai.tools import tool
MESSAGES = [{"role": "user", "content": "hi"}]
TOOLS = [
{
"type": "function",
"function": {
"name": "multiply",
"description": "Multiply two integers",
"parameters": {
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
},
},
}
]
def build(model: str = "gpt-5.6-sol", **kwargs) -> OpenAICompletion:
return OpenAICompletion(model=model, api_key="sk-test", **kwargs)
def _bad_request(message: str, param: str = "reasoning_effort") -> BadRequestError:
body = {
"error": {
"message": message,
"type": "invalid_request_error",
"param": param,
}
}
return BadRequestError(
message,
response=httpx.Response(
400,
json=body,
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
),
body=body,
)
def tools_effort_error(model: str = "gpt-5.6-sol") -> BadRequestError:
"""The 400 OpenAI returns for tools + reasoning_effort."""
return _bad_request(
f"Function tools with reasoning_effort are not supported for {model} in "
"/v1/chat/completions. To use function tools, use /v1/responses or set "
"reasoning_effort to 'none'."
)
def unsupported_value_error() -> BadRequestError:
"""What o1/o3 return for reasoning_effort="none" -- not recoverable this way."""
return _bad_request("Unsupported value: 'reasoning_effort' does not support 'none'.")
class TestErrorDetection:
def test_matches_the_tools_effort_400(self):
assert OpenAICompletion._rejects_reasoning_effort_with_tools(
tools_effort_error()
)
def test_matches_a_flat_error_body(self):
"""The SDK populates `body` flat or nested depending on how it parsed."""
message = (
"Function tools with reasoning_effort are not supported for gpt-5.6-sol."
)
body = {"message": message, "param": "reasoning_effort"}
error = BadRequestError(
message,
response=httpx.Response(
400, json=body, request=httpx.Request("POST", "https://x/v1/c")
),
body=body,
)
assert OpenAICompletion._rejects_reasoning_effort_with_tools(error)
def test_ignores_the_unsupported_value_400(self):
"""o1/o3 reject reasoning_effort="none", so retrying with it can't help."""
assert not OpenAICompletion._rejects_reasoning_effort_with_tools(
unsupported_value_error()
)
def test_ignores_unrelated_exceptions(self):
assert not OpenAICompletion._rejects_reasoning_effort_with_tools(
RuntimeError("boom")
)
class TestRetryParams:
def test_sets_an_explicit_none(self):
"""Absence means "use the server default", which is what was rejected."""
params = build()._reasoning_effort_none_params({"model": "gpt-5.6-sol"})
assert params is not None
assert params["reasoning_effort"] == "none"
def test_overrides_a_requested_effort(self):
params = build()._reasoning_effort_none_params({"reasoning_effort": "high"})
assert params is not None
assert params["reasoning_effort"] == "none"
def test_returns_none_when_already_none(self):
"""Nothing left to try -- the retry must not loop."""
assert (
build()._reasoning_effort_none_params({"reasoning_effort": "none"}) is None
)
class TestRetryBehaviour:
def test_retries_with_none_and_succeeds(self, monkeypatch):
llm = build()
seen: list[dict] = []
def fake_handle(params, **kwargs):
seen.append(params)
if params.get("reasoning_effort") != "none":
raise tools_effort_error()
return "ok"
monkeypatch.setattr(llm, "_handle_completion", fake_handle)
assert llm._call_completions(MESSAGES, tools=TOOLS) == "ok"
assert len(seen) == 2, "expected one rejected call and one retry"
assert "reasoning_effort" not in seen[0]
assert seen[1]["reasoning_effort"] == "none"
assert seen[1]["tools"], "tools must survive the retry"
def test_does_not_retry_forever(self, monkeypatch):
"""A model that fails even with "none" must surface, not loop."""
llm = build()
calls: list[dict] = []
def always_fail(params, **kwargs):
calls.append(params)
raise tools_effort_error()
monkeypatch.setattr(llm, "_handle_completion", always_fail)
with pytest.raises(BadRequestError):
llm._call_completions(MESSAGES, tools=TOOLS)
assert len(calls) == 2, "one original call plus exactly one retry"
def test_does_not_retry_an_unrelated_400(self, monkeypatch):
llm = build("o3-mini")
calls: list[dict] = []
def fail(params, **kwargs):
calls.append(params)
raise unsupported_value_error()
monkeypatch.setattr(llm, "_handle_completion", fail)
with pytest.raises(BadRequestError):
llm._call_completions(MESSAGES, tools=TOOLS)
assert len(calls) == 1
def test_recovered_call_reports_no_failure(self, monkeypatch):
"""The retried attempt must not emit LLMCallFailedEvent."""
llm = build()
emitted: list[str] = []
monkeypatch.setattr(
llm,
"_emit_call_failed_event",
lambda **kwargs: emitted.append(kwargs.get("error", "")),
)
class FakeCompletions:
def create(self, **kwargs):
raise tools_effort_error()
monkeypatch.setattr(
llm,
"_get_sync_client",
lambda: SimpleNamespace(
chat=SimpleNamespace(completions=FakeCompletions())
),
)
# The original BadRequestError must survive for _call_completions to match.
with pytest.raises(BadRequestError):
llm._handle_completion({"model": "gpt-5.6-sol", "messages": MESSAGES})
assert emitted == []
@pytest.mark.asyncio
async def test_async_path_retries_too(self, monkeypatch):
llm = build()
seen: list[dict] = []
async def fake_handle(params, **kwargs):
seen.append(params)
if params.get("reasoning_effort") != "none":
raise tools_effort_error()
return "ok"
monkeypatch.setattr(llm, "_ahandle_completion", fake_handle)
assert await llm._acall_completions(MESSAGES, tools=TOOLS) == "ok"
assert seen[1]["reasoning_effort"] == "none"
class TestAgentDefinitions:
"""The bar: ordinary agent definitions run, with tools and with reasoning."""
@staticmethod
def _stub_transport(llm: OpenAICompletion, monkeypatch) -> list[dict]:
"""Reject like GPT-5.6 until an explicit reasoning_effort="none" arrives.
The reasoning planner calls the LLM with its own `available_functions`, so
the stub answers that request with the planner's JSON contract and every
other request with the final answer.
"""
sent: list[dict] = []
def fake_handle(params, available_functions=None, **kwargs):
sent.append(params)
if params.get("tools") and params.get("reasoning_effort") != "none":
raise tools_effort_error()
if available_functions and "create_reasoning_plan" in available_functions:
return json.dumps(
{"plan": "Multiply 17 by 23.", "steps": [], "ready": True}
)
return "391"
monkeypatch.setattr(llm, "_handle_completion", fake_handle)
return sent
@staticmethod
def _multiply_tool():
@tool("multiply")
def multiply(a: int, b: int) -> int:
"""Multiply two integers."""
return a * b
return multiply
def test_agent_with_tools(self, monkeypatch):
llm = build()
sent = self._stub_transport(llm, monkeypatch)
agent = Agent(
role="Math",
goal="Answer correctly",
backstory="You are precise.",
llm=llm,
tools=[self._multiply_tool()],
)
task = Task(
description="What is 17 times 23?",
expected_output="A number",
agent=agent,
)
assert "391" in str(Crew(agents=[agent], tasks=[task]).kickoff())
assert any(p.get("reasoning_effort") == "none" for p in sent)
def test_agent_with_tools_and_reasoning(self, monkeypatch):
"""`reasoning=True` adds a planning call, which must recover as well."""
llm = build()
sent = self._stub_transport(llm, monkeypatch)
agent = Agent(
role="Math",
goal="Answer correctly",
backstory="You are precise.",
llm=llm,
tools=[self._multiply_tool()],
reasoning=True,
)
task = Task(
description="What is 17 times 23?",
expected_output="A number",
agent=agent,
)
assert "391" in str(Crew(agents=[agent], tasks=[task]).kickoff())
assert any(p.get("reasoning_effort") == "none" for p in sent)
def test_agent_without_tools_is_untouched(self, monkeypatch):
"""No tools means no conflict, so nothing should be rewritten."""
llm = build()
sent = self._stub_transport(llm, monkeypatch)
agent = Agent(
role="Math",
goal="Answer correctly",
backstory="You are precise.",
llm=llm,
)
task = Task(
description="What is 17 times 23?",
expected_output="A number",
agent=agent,
)
assert "391" in str(Crew(agents=[agent], tasks=[task]).kickoff())
assert all("reasoning_effort" not in p for p in sent)

View File

@@ -10,7 +10,7 @@ from pathlib import Path
import pytest
from crewai.experimental.skills.cache import SkillCacheManager, _safe_extractall
from crewai.skills.cache import SkillCacheManager, _safe_extractall
def _make_tar_gz(files: dict[str, str]) -> bytes:
@@ -62,6 +62,67 @@ class TestSkillCacheManager:
retrieved = cache.get_cached_path("acme", "my-skill")
assert retrieved == dest
def test_get_cached_path_matches_a_requested_version(self, tmp_path: Path) -> None:
cache = SkillCacheManager(cache_root=tmp_path)
archive = _make_tar_gz({"SKILL.md": "---\nname: my-skill\n---\nHello"})
dest = cache.store("acme", "my-skill", "1.0.0", archive)
assert cache.get_cached_path("acme", "my-skill", version="1.0.0") == dest
# A leading "v" on either side describes the same version.
assert cache.get_cached_path("acme", "my-skill", version="v1.0.0") == dest
def test_get_cached_path_misses_a_different_version(self, tmp_path: Path) -> None:
cache = SkillCacheManager(cache_root=tmp_path)
archive = _make_tar_gz({"SKILL.md": "---\nname: my-skill\n---\nHello"})
cache.store("acme", "my-skill", "1.0.0", archive)
assert cache.get_cached_path("acme", "my-skill", version="2.0.0") is None
def test_get_cached_path_misses_when_the_recorded_version_is_not_a_string(
self, tmp_path: Path
) -> None:
cache = SkillCacheManager(cache_root=tmp_path)
archive = _make_tar_gz({"SKILL.md": "---\nname: my-skill\n---\nHello"})
dest = cache.store("acme", "my-skill", "1.0.0", archive)
meta_file = dest / ".crewai_meta.json"
meta = json.loads(meta_file.read_text(encoding="utf-8"))
meta["version"] = 1.0
meta_file.write_text(json.dumps(meta), encoding="utf-8")
assert cache.get_cached_path("acme", "my-skill", version="1.0.0") is None
def test_get_cached_path_misses_when_metadata_is_not_valid_utf8(
self, tmp_path: Path
) -> None:
cache = SkillCacheManager(cache_root=tmp_path)
archive = _make_tar_gz({"SKILL.md": "---\nname: my-skill\n---\nHello"})
dest = cache.store("acme", "my-skill", "1.0.0", archive)
(dest / ".crewai_meta.json").write_bytes(b"\x80\x81")
assert cache.get_cached_path("acme", "my-skill", version="1.0.0") is None
def test_get_cached_path_misses_when_metadata_is_not_an_object(
self, tmp_path: Path
) -> None:
cache = SkillCacheManager(cache_root=tmp_path)
archive = _make_tar_gz({"SKILL.md": "---\nname: my-skill\n---\nHello"})
dest = cache.store("acme", "my-skill", "1.0.0", archive)
(dest / ".crewai_meta.json").write_text("[]", encoding="utf-8")
assert cache.get_cached_path("acme", "my-skill", version="1.0.0") is None
def test_get_cached_path_misses_when_the_cached_version_is_unknown(
self, tmp_path: Path
) -> None:
cache = SkillCacheManager(cache_root=tmp_path)
archive = _make_tar_gz({"SKILL.md": "---\nname: my-skill\n---\nHello"})
dest = cache.store("acme", "my-skill", None, archive)
# Unversioned entries still satisfy unpinned lookups.
assert cache.get_cached_path("acme", "my-skill") == dest
# ...but can't confirm a pin, so a pinned lookup re-resolves.
assert cache.get_cached_path("acme", "my-skill", version="1.0.0") is None
def test_store_writes_metadata(self, tmp_path: Path) -> None:
cache = SkillCacheManager(cache_root=tmp_path)
archive = _make_tar_gz({"SKILL.md": "content"})

View File

@@ -0,0 +1,41 @@
"""The old experimental namespace must keep re-exporting the public names."""
def test_experimental_namespace_reexports_public_names():
from crewai.experimental import skills as experimental_skills
from crewai.skills.cache import SkillCacheManager
from crewai.skills.registry import (
is_registry_ref,
parse_registry_ref,
resolve_registry_ref,
)
assert experimental_skills.SkillCacheManager is SkillCacheManager
assert experimental_skills.is_registry_ref is is_registry_ref
assert experimental_skills.parse_registry_ref is parse_registry_ref
assert experimental_skills.resolve_registry_ref is resolve_registry_ref
def test_experimental_submodule_imports_alias_real_modules():
"""Old submodule import style must resolve to the crewai.skills modules."""
import importlib
from crewai.experimental.skills import cache as shim_cache
from crewai.experimental.skills import events as shim_events
from crewai.experimental.skills import registry as shim_registry
from crewai.experimental.skills.cache import SkillCacheManager
from crewai.experimental.skills.registry import resolve_registry_ref
from crewai.skills import cache as real_cache
from crewai.skills import events as real_events
from crewai.skills import registry as real_registry
assert shim_cache is real_cache
assert shim_events is real_events
assert shim_registry is real_registry
assert SkillCacheManager is real_cache.SkillCacheManager
assert resolve_registry_ref is real_registry.resolve_registry_ref
# Dotted-path import resolves through sys.modules to the same module.
assert (
importlib.import_module("crewai.experimental.skills.registry")
is real_registry
)

View File

@@ -197,7 +197,7 @@ class TestLoadSkill:
}[ref]
monkeypatch.setattr(
"crewai.experimental.skills.registry.resolve_registry_ref",
"crewai.skills.registry.resolve_registry_ref",
resolve_registry_ref,
)

View File

@@ -0,0 +1,540 @@
from __future__ import annotations
import base64
from collections.abc import Iterator
from contextlib import contextmanager
from io import BytesIO
from pathlib import Path
from unittest.mock import MagicMock, patch
from zipfile import ZipFile
from crewai.context import platform_context
from crewai.skills.cache import SkillCacheManager
from crewai.skills.registry import (
SkillRef,
download_skill,
is_registry_ref,
parse_registry_ref,
parse_skill_ref,
resolve_registry_ref,
)
import pytest
def _skill_archive(name: str, version: str | None = None) -> bytes:
metadata = f"metadata:\n version: {version}\n" if version else ""
archive = BytesIO()
with ZipFile(archive, "w") as zip_file:
zip_file.writestr(
"SKILL.md",
f"---\nname: {name}\ndescription: Test skill.\n{metadata}---\n\nInstructions.",
)
return archive.getvalue()
def _skill_response(name: str, version: str | None = None) -> MagicMock:
response = MagicMock()
payload: dict[str, object] = {
"latest_version": "1.0.0",
"file": base64.b64encode(_skill_archive(name, version)).decode(),
}
if version is not None:
payload["version"] = version
response.json.return_value = payload
return response
# Retained under its original name for the pre-existing tests below.
_mock_skill_response = _skill_response
def _stub_api(name: str, version: str | None = None) -> MagicMock:
"""A CrewAI AMP client stub serving one skill."""
api = MagicMock()
api.get_skill.return_value = _skill_response(name, version)
return api
def _cache(tmp_path: Path) -> SkillCacheManager:
return SkillCacheManager(cache_root=tmp_path / "cache")
def _write_local_skill(tmp_path: Path, name: str, version: str | None = None) -> Path:
"""Write a project-local ./skills/<name>/SKILL.md under *tmp_path*."""
skill_dir = tmp_path / "skills" / name
skill_dir.mkdir(parents=True)
metadata = f"metadata:\n version: {version}\n" if version else ""
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Test skill.\n{metadata}---\n\nInstructions."
)
return skill_dir
def _install_client(monkeypatch: pytest.MonkeyPatch, client: object) -> None:
"""Install *client* the way a hosted runtime does."""
from crewai.utilities import agent_utils
monkeypatch.setattr(agent_utils, "_create_plus_client_hook", lambda: client)
@contextmanager
def _resolving(
tmp_path: Path, api: MagicMock, cache: SkillCacheManager | None = None
) -> Iterator[SkillCacheManager]:
"""Resolve refs against a temp cwd and cache, with *api* as the registry."""
cache = cache or _cache(tmp_path)
with (
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.auth.token.get_auth_token", return_value="saved-login"),
patch("crewai.skills.registry.SkillCacheManager", return_value=cache),
patch("crewai_core.plus_api.PlusAPI", return_value=api),
):
yield cache
@pytest.fixture(autouse=True)
def _no_installed_client(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep a client installed by one test from leaking into the next."""
from crewai.utilities import agent_utils
monkeypatch.setattr(agent_utils, "_create_plus_client_hook", None, raising=False)
class TestIsRegistryRef:
def test_at_prefixed(self) -> None:
assert is_registry_ref("@acme/my-skill") is True
def test_plain_string(self) -> None:
assert is_registry_ref("my-skill") is False
def test_path_like_string(self) -> None:
assert is_registry_ref("./skills/my-skill") is False
def test_non_string(self) -> None:
assert is_registry_ref(None) is False
assert is_registry_ref(42) is False
assert is_registry_ref(Path("something")) is False
class TestParseRegistryRef:
def test_valid(self) -> None:
assert parse_registry_ref("@acme/my-skill") == ("acme", "my-skill")
def test_valid_with_dashes(self) -> None:
assert parse_registry_ref("@my-org/cool-skill") == ("my-org", "cool-skill")
def test_missing_at(self) -> None:
with pytest.raises(ValueError, match="must start with '@'"):
parse_registry_ref("acme/my-skill")
def test_missing_slash(self) -> None:
with pytest.raises(ValueError, match="'@org/name' format"):
parse_registry_ref("@acme-skill")
def test_empty_org(self) -> None:
with pytest.raises(ValueError, match="non-empty"):
parse_registry_ref("@/my-skill")
def test_empty_name(self) -> None:
with pytest.raises(ValueError, match="non-empty"):
parse_registry_ref("@acme/")
def test_drops_the_version_pin(self) -> None:
assert parse_registry_ref("@acme/my-skill@1.2.0") == ("acme", "my-skill")
class TestParseSkillRef:
def test_without_version(self) -> None:
assert parse_skill_ref("@acme/my-skill") == SkillRef("acme", "my-skill", None)
def test_with_version(self) -> None:
assert parse_skill_ref("@acme/my-skill@1.2.0") == SkillRef(
"acme", "my-skill", "1.2.0"
)
def test_with_v_prefixed_version(self) -> None:
assert parse_skill_ref("@acme/my-skill@v0.1").version == "v0.1"
def test_with_uuid_org(self) -> None:
assert parse_skill_ref(
"@548e8ab5-f806-4c44-9dea-087ea05880f1/crewai-brand@2.0"
) == SkillRef("548e8ab5-f806-4c44-9dea-087ea05880f1", "crewai-brand", "2.0")
def test_empty_version(self) -> None:
with pytest.raises(ValueError, match="version must be non-empty"):
parse_skill_ref("@acme/my-skill@")
def test_whitespace_only_version(self) -> None:
with pytest.raises(ValueError, match="version must be non-empty"):
parse_skill_ref("@acme/my-skill@ ")
def test_rejects_more_than_one_version_pin(self) -> None:
with pytest.raises(ValueError, match="single version pin"):
parse_skill_ref("@acme/my-skill@1.2.0@extra")
def test_strips_surrounding_whitespace_from_the_version(self) -> None:
assert parse_skill_ref("@acme/my-skill@ 1.2.0 ").version == "1.2.0"
def test_round_trips_through_str(self) -> None:
assert str(parse_skill_ref("@acme/my-skill@1.2.0")) == "@acme/my-skill@1.2.0"
assert str(parse_skill_ref("@acme/my-skill")) == "@acme/my-skill"
class TestResolveRegistryRef:
def test_prefers_project_local_skill_over_cached_skill(
self, tmp_path: Path
) -> None:
_write_local_skill(tmp_path, "my-skill")
cache_dir = tmp_path / "cache" / "acme" / "my-skill"
cache_dir.mkdir(parents=True)
(cache_dir / "SKILL.md").write_text(
"---\nname: cached-skill\ndescription: Cached.\n---\n\nCached instructions."
)
mock_cache = MagicMock()
mock_cache.get_cached_path.return_value = cache_dir
with (
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
):
from crewai.skills.registry import resolve_registry_ref
skill = resolve_registry_ref("@acme/my-skill")
assert skill.name == "my-skill"
def test_downloads_and_caches_uncached_skill_in_noninteractive_environment(
self, tmp_path: Path
) -> None:
cache = SkillCacheManager(cache_root=tmp_path / "cache")
api = MagicMock()
api.get_skill.return_value = _mock_skill_response("ghost-skill")
with (
patch.dict("os.environ", {"CI": "1", "CREWAI_NONINTERACTIVE": "1"}),
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.auth.token.get_auth_token", return_value="saved-login"),
patch("crewai.skills.registry.SkillCacheManager", return_value=cache),
patch("crewai_core.plus_api.PlusAPI", return_value=api),
):
from crewai.skills.registry import resolve_registry_ref
skill = resolve_registry_ref("@acme/ghost-skill")
assert skill.name == "ghost-skill"
api.get_skill.assert_called_once_with("acme", "ghost-skill")
assert cache.get_cached_path("acme", "ghost-skill") == (
tmp_path / "cache" / "acme" / "ghost-skill"
)
def test_resolves_cached_skill_when_project_local_skill_is_missing(
self, tmp_path: Path
) -> None:
cache_dir = tmp_path / "acme" / "cached-skill"
cache_dir.mkdir(parents=True)
(cache_dir / "SKILL.md").write_text(
"---\nname: cached-skill\ndescription: Cached.\n---\n\nCached instructions."
)
mock_cache = MagicMock()
mock_cache.get_cached_path.return_value = cache_dir
with (
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
):
from crewai.skills.registry import resolve_registry_ref
skill = resolve_registry_ref("@acme/cached-skill")
assert skill.name == "cached-skill"
class TestResolveVersionPinnedRef:
def test_requests_the_pinned_version_from_the_registry(self, tmp_path: Path) -> None:
api = _stub_api("pinned-skill", "1.0.0")
with _resolving(tmp_path, api):
skill = resolve_registry_ref("@acme/pinned-skill@1.0.0")
assert skill.name == "pinned-skill"
api.get_skill.assert_called_once_with("acme", "pinned-skill", version="1.0.0")
def test_unpinned_ref_omits_the_version_argument(self, tmp_path: Path) -> None:
api = _stub_api("floating-skill")
with _resolving(tmp_path, api):
resolve_registry_ref("@acme/floating-skill")
api.get_skill.assert_called_once_with("acme", "floating-skill")
def test_resolves_a_pinned_skill_from_the_cache(self, tmp_path: Path) -> None:
"""Skills need not declare metadata.version; the cache records what it
stored, so a second resolution must not download again."""
api = _stub_api("cached-skill")
with _resolving(tmp_path, api):
resolve_registry_ref("@acme/cached-skill@1.0.0")
skill = resolve_registry_ref("@acme/cached-skill@1.0.0")
assert skill.name == "cached-skill"
assert api.get_skill.call_count == 1
def test_refuses_a_version_the_registry_served_instead_of_the_pin(
self, tmp_path: Path
) -> None:
"""A registry predating pinning ignores the parameter and serves its
newest version; caching that under the pin would poison every later
lookup for it."""
cache = _cache(tmp_path)
api = _stub_api("drifting-skill", "2.0.0")
with _resolving(tmp_path, api, cache=cache), pytest.raises(
RuntimeError, match="served version '2.0.0' rather than the pinned"
):
resolve_registry_ref("@acme/drifting-skill@1.0.0")
assert cache.get_cached_path("acme", "drifting-skill") is None
def test_redownloads_when_the_cached_version_is_not_the_pinned_one(
self, tmp_path: Path
) -> None:
cache = SkillCacheManager(cache_root=tmp_path / "cache")
cache.store("acme", "drifting-skill", "1.0.0", _skill_archive("drifting-skill"))
api = _stub_api("drifting-skill", "2.0.0")
with _resolving(tmp_path, api, cache=cache):
resolve_registry_ref("@acme/drifting-skill@2.0.0")
api.get_skill.assert_called_once_with("acme", "drifting-skill", version="2.0.0")
def test_uses_a_project_local_skill_that_declares_the_pinned_version(
self, tmp_path: Path
) -> None:
_write_local_skill(tmp_path, "local-skill", "1.0.0")
api = _stub_api("local-skill")
with _resolving(tmp_path, api):
skill = resolve_registry_ref("@acme/local-skill@v1.0.0")
assert skill.name == "local-skill"
api.get_skill.assert_not_called()
def test_downloads_when_a_project_local_skill_declares_another_version(
self, tmp_path: Path
) -> None:
_write_local_skill(tmp_path, "stale-skill", "1.0.0")
api = _stub_api("stale-skill", "2.0.0")
with _resolving(tmp_path, api):
resolve_registry_ref("@acme/stale-skill@2.0.0")
api.get_skill.assert_called_once_with("acme", "stale-skill", version="2.0.0")
class TestDownloadSkillClient:
"""A hosted runtime has no user credential, so its own client must be used."""
def test_uses_the_client_the_runtime_installed(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
installed = _stub_api("my-skill")
monkeypatch.delenv("CREWAI_USER_PAT", raising=False)
_install_client(monkeypatch, installed)
with (
patch("crewai.skills.registry.SkillCacheManager", return_value=_cache(tmp_path)),
patch("crewai.auth.token.get_auth_token") as get_auth_token,
patch("crewai_core.plus_api.PlusAPI") as plus_api,
):
download_skill("acme", "my-skill")
installed.get_skill.assert_called_once_with("acme", "my-skill")
# No user credential is read at all when a client is installed.
plus_api.assert_not_called()
get_auth_token.assert_not_called()
def test_awaits_an_async_client(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
response = _skill_response("my-skill")
async def get_skill(org: str, name: str, **kwargs: object) -> MagicMock:
return response
installed = MagicMock()
installed.get_skill = get_skill
_install_client(monkeypatch, installed)
with patch(
"crewai.skills.registry.SkillCacheManager", return_value=_cache(tmp_path)
):
skill = download_skill("acme", "my-skill")
assert skill.name == "my-skill"
@pytest.mark.parametrize(
"get_skill", [None, "not-callable"], ids=["missing", "not-callable"]
)
def test_falls_back_when_the_client_cannot_fetch_skills(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, get_skill: object
) -> None:
api = _stub_api("my-skill")
monkeypatch.setenv("CREWAI_USER_PAT", "user-pat")
installed = MagicMock(spec=[] if get_skill is None else ["get_skill"])
if get_skill is not None:
installed.get_skill = get_skill
_install_client(monkeypatch, installed)
with (
patch("crewai.skills.registry.SkillCacheManager", return_value=_cache(tmp_path)),
patch("crewai_core.plus_api.PlusAPI", return_value=api) as plus_api,
):
skill = download_skill("acme", "my-skill")
assert skill.name == "my-skill"
plus_api.assert_called_once_with(api_key="user-pat", organization_id=None)
def test_falls_back_when_the_client_cannot_forward_a_pin(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Repository agents pin automatically, so a client whose get_skill
predates pinning must not turn a working lookup into a TypeError."""
api = _stub_api("my-skill", "1.0.0")
monkeypatch.setenv("CREWAI_USER_PAT", "user-pat")
class VersionUnawareClient:
def get_skill(self, org: str, name: str) -> MagicMock:
raise AssertionError("must not be called with a pin")
_install_client(monkeypatch, VersionUnawareClient())
with (
patch("crewai.skills.registry.SkillCacheManager", return_value=_cache(tmp_path)),
patch("crewai_core.plus_api.PlusAPI", return_value=api) as plus_api,
):
skill = download_skill("acme", "my-skill", version="1.0.0")
assert skill.name == "my-skill"
plus_api.assert_called_once_with(api_key="user-pat", organization_id=None)
def test_uses_a_version_unaware_client_for_unpinned_refs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: list[tuple[str, str]] = []
class VersionUnawareClient:
def get_skill(self, org: str, name: str) -> MagicMock:
calls.append((org, name))
return _skill_response("my-skill")
_install_client(monkeypatch, VersionUnawareClient())
with patch(
"crewai.skills.registry.SkillCacheManager", return_value=_cache(tmp_path)
):
download_skill("acme", "my-skill")
assert calls == [("acme", "my-skill")]
@pytest.mark.parametrize("version", ["", " "], ids=["empty", "whitespace"])
def test_rejects_a_blank_pin_rather_than_floating_to_latest(
self, monkeypatch: pytest.MonkeyPatch, version: str
) -> None:
installed = _stub_api("my-skill")
_install_client(monkeypatch, installed)
with pytest.raises(ValueError, match="must be non-empty"):
download_skill("acme", "my-skill", version=version)
installed.get_skill.assert_not_called()
def test_reports_the_pinned_ref_when_the_download_fails(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
installed = MagicMock()
installed.get_skill.side_effect = RuntimeError("401 Unauthorized")
_install_client(monkeypatch, installed)
with pytest.raises(
RuntimeError, match=r"Failed to download skill '@acme/my-skill@1\.0\.0'"
):
download_skill("acme", "my-skill", version="1.0.0")
class TestDownloadSkillAuthentication:
def test_user_pat_takes_precedence_over_platform_token(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
cache = SkillCacheManager(cache_root=tmp_path / "cache")
api = MagicMock()
api.get_skill.return_value = _mock_skill_response("my-skill")
monkeypatch.setenv("CREWAI_USER_PAT", "user-pat")
monkeypatch.delenv("CREWAI_ORGANIZATION_UUID", raising=False)
with (
platform_context("platform-token"),
patch("crewai.skills.registry.SkillCacheManager", return_value=cache),
patch("crewai_core.plus_api.PlusAPI", return_value=api) as plus_api,
):
download_skill("acme", "my-skill")
plus_api.assert_called_once_with(api_key="user-pat", organization_id=None)
def test_uses_platform_token_when_user_pat_is_missing(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
cache = SkillCacheManager(cache_root=tmp_path / "cache")
api = MagicMock()
api.get_skill.return_value = _mock_skill_response("my-skill")
monkeypatch.delenv("CREWAI_USER_PAT", raising=False)
monkeypatch.delenv("CREWAI_ORGANIZATION_UUID", raising=False)
with (
platform_context("platform-token"),
patch("crewai.skills.registry.SkillCacheManager", return_value=cache),
patch("crewai_core.plus_api.PlusAPI", return_value=api) as plus_api,
):
download_skill("acme", "my-skill")
plus_api.assert_called_once_with(api_key="platform-token", organization_id=None)
def test_uses_user_pat_and_organization_from_environment(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
cache = SkillCacheManager(cache_root=tmp_path / "cache")
api = MagicMock()
api.get_skill.return_value = _mock_skill_response("my-skill")
monkeypatch.delenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", raising=False)
monkeypatch.setenv("CREWAI_USER_PAT", "user-pat")
monkeypatch.setenv("CREWAI_ORGANIZATION_UUID", "organization-uuid")
with (
patch("crewai.skills.registry.SkillCacheManager", return_value=cache),
patch("crewai_core.plus_api.PlusAPI", return_value=api) as plus_api,
):
download_skill("acme", "my-skill")
plus_api.assert_called_once_with(
api_key="user-pat", organization_id="organization-uuid"
)
def test_uses_saved_cli_login_when_runtime_tokens_are_missing(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
cache = SkillCacheManager(cache_root=tmp_path / "cache")
api = MagicMock()
api.get_skill.return_value = _mock_skill_response("my-skill")
monkeypatch.delenv("CREWAI_USER_PAT", raising=False)
monkeypatch.delenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", raising=False)
monkeypatch.delenv("CREWAI_ORGANIZATION_UUID", raising=False)
with (
patch("crewai.auth.token.get_auth_token", return_value="saved-login"),
patch("crewai.skills.registry.SkillCacheManager", return_value=cache),
patch("crewai_core.plus_api.PlusAPI", return_value=api) as plus_api,
):
download_skill("acme", "my-skill")
plus_api.assert_called_once_with(api_key="saved-login", organization_id=None)

View File

@@ -1348,7 +1348,15 @@ def test_skill_documents_flow_wiring():
assert "```yaml" in skill
assert "[Method](#method-methods)" in skill
assert 'input: "Reviewed research: ${outputs.research_brief.raw}"' in skill
assert 'text(root, "path", "default")' in skill
assert "do not assemble the string with CEL `+`" in skill
assert "Do not use CEL `+` to build text in action mappings" in skill
assert "Agent prompt template. Insert Flow values with `${...}`" in skill
assert (
"Repository-backed agents may set `from_repository` and omit inline "
"`role`, `goal`, and `backstory`" in skill
)
assert "Runtime inputs passed to the Crew" in skill
assert "Tool input arguments. Insert Flow values with `${...}`" in skill
assert "trust CrewAI defaults and omit them" in skill
assert "#### LLM Definition" in skill
assert "`max_tokens` (optional): integer | null; default `null`" in skill

View File

@@ -1102,7 +1102,7 @@ methods:
)
def test_tool_action_renders_text_custom_expression_inputs():
def test_tool_action_renders_interpolated_inputs():
yaml_str = f"""
schema: crewai.flow/v1
name: ToolFlow
@@ -1112,8 +1112,8 @@ methods:
call: tool
ref: {__name__}:StaticSearchTool
with:
search_query: "${{'Ticket ID: ' + text(state, 'ticket.id') + '; Subject: ' + text(state, 'ticket.subject') + '; Priority: ' + text(state, 'priority', 'unknown') + '; Message: ' + text(state, 'messages.0.body')}}"
prefix: "${{text(state, 'ticket')}}"
search_query: "Ticket ID: ${{state.ticket.id}}; Subject: ${{state.ticket.subject}}; Message: ${{state.messages[0].body}}"
prefix: "${{state.prefix}}"
start: true
"""
@@ -1124,9 +1124,10 @@ methods:
inputs={
"ticket": {"id": 123, "subject": None},
"messages": [{"body": "Initial report"}],
"prefix": "ticket",
}
)
== '{"id": 123, "subject": null}:Ticket ID: 123; Subject: ; Priority: unknown; Message: Initial report'
== "ticket:Ticket ID: 123; Subject: ; Message: Initial report"
)
@@ -1319,7 +1320,7 @@ methods:
role: Analyst
goal: Answer questions
backstory: Knows things.
input: "Ticket ID: ${text(state, 'ticket.id')}; Subject: ${text(state, 'ticket.subject')}"
input: "Ticket ID: ${state.ticket.id}; Subject: ${state.ticket.subject}"
start: true
"""
@@ -2909,37 +2910,6 @@ def test_explicit_cel_fields_accept_expression_markers():
assert Flow.from_declaration(contents=definition).kickoff(inputs={"score": 90}) == "qualified"
def test_expression_action_runs_text_custom_expression():
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ExpressionFlow",
"methods": {
"summarize": {
"start": True,
"do": {
"call": "expression",
"expr": (
"'Ticket ID: ' + text(state, 'ticket.id') + "
"'; Tags: ' + text(state, 'tags')"
),
},
}
},
}
)
assert (
Flow.from_declaration(contents=definition).kickoff(
inputs={
"ticket": {"id": 123},
"tags": ["urgent", "billing"],
}
)
== 'Ticket ID: 123; Tags: ["urgent", "billing"]'
)
def test_expression_local_context_recurses_into_dataclass_values():
from crewai.flow.expressions import Expression

View File

@@ -1340,3 +1340,81 @@ class TestExecuteSingleNativeToolCall:
assert isinstance(result, NativeToolCallResult)
assert result.result_as_answer is False
assert "blocked by hook" in result.result
class TestResolvePlusClient:
def test_builds_the_default_when_no_client_is_installed(self) -> None:
from crewai.utilities.agent_utils import resolve_plus_client
default = MagicMock()
assert resolve_plus_client(lambda: default) is default
def test_prefers_an_installed_client(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A hosted runtime installs a client; the default must not be built,
since looking up a user credential raises when there isn't one."""
from crewai.utilities import agent_utils
installed = MagicMock()
monkeypatch.setattr(agent_utils, "_create_plus_client_hook", lambda: installed)
default = MagicMock(side_effect=AssertionError("must not be called"))
assert agent_utils.resolve_plus_client(default) is installed
default.assert_not_called()
class TestResolvePlusResponse:
def test_passes_through_a_sync_response(self) -> None:
from crewai.utilities.agent_utils import resolve_plus_response
response = MagicMock()
assert resolve_plus_response(response) is response
@pytest.mark.parametrize("inside_loop", [False, True])
def test_awaits_an_async_response(self, inside_loop: bool) -> None:
from crewai.utilities.agent_utils import resolve_plus_response
response = MagicMock()
async def call() -> Any:
return response
if not inside_loop:
assert resolve_plus_response(call()) is response
return
async def main() -> Any:
return resolve_plus_response(call())
assert asyncio.run(main()) is response
def test_carries_context_vars_into_the_worker_thread(self) -> None:
"""Inside a running loop the coroutine runs on another thread; a client
reading runtime state (the platform token, flow context) must still see
the caller's values rather than defaults."""
from crewai.context import get_platform_integration_token, platform_context
from crewai.utilities.agent_utils import resolve_plus_response
async def call() -> Any:
return get_platform_integration_token()
async def main() -> Any:
with platform_context("token-from-caller"):
return resolve_plus_response(call())
assert asyncio.run(main()) == "token-from-caller"
def test_rejects_an_awaitable_bound_to_a_loop(self) -> None:
from crewai.utilities.agent_utils import resolve_plus_response
async def main() -> None:
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
future.set_result(MagicMock())
with pytest.raises(TypeError, match="must return a coroutine"):
resolve_plus_response(future)
asyncio.run(main())

View File

@@ -933,7 +933,49 @@ class TestResolveRefsRecursive:
assert resolved["properties"]["x"]["type"] == "integer"
class TestSanitizeRecursiveSchemas:
class TestSanitizeStrictSchemas:
def test_openai_strict_preserves_property_named_title(self) -> None:
from crewai.utilities.pydantic_schema_utils import sanitize_tool_params_for_openai_strict
schema = {
"type": "object",
"properties": {
"title": {"title": "Title", "type": "string"},
"url": {"title": "Url", "type": "string"},
},
"required": ["title", "url"],
}
san = sanitize_tool_params_for_openai_strict(deepcopy(schema))
assert "title" in san["properties"]
assert set(san["required"]) == set(san["properties"].keys())
assert "title" not in san["properties"]["title"]
def test_openai_strict_preserves_nested_property_named_title(self) -> None:
from crewai.utilities.pydantic_schema_utils import sanitize_tool_params_for_openai_strict
schema = {
"type": "object",
"properties": {
"payload": {
"type": "object",
"properties": {
"title": {"title": "Nested Title", "type": "string"},
},
"required": ["title"],
},
},
"required": ["payload"],
}
san = sanitize_tool_params_for_openai_strict(deepcopy(schema))
payload = san["properties"]["payload"]
assert "title" in payload["properties"]
assert payload["required"] == ["title"]
assert "title" not in payload["properties"]["title"]
def test_anthropic_strict_preserves_recursive_type(self) -> None:
from crewai.utilities.pydantic_schema_utils import sanitize_tool_params_for_anthropic_strict

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.15.2"
__version__ = "1.15.7"