mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-29 02:29:31 +00:00
Resolve merge conflicts with origin/main
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.15.2"
|
||||
__version__ = "1.15.7"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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}`.
|
||||
|
||||
|
||||
63
lib/cli/tests/skills/test_cli_commands.py
Normal file
63
lib/cli/tests/skills/test_cli_commands.py
Normal 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)
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.15.2"
|
||||
__version__ = "1.15.7"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.15.2"
|
||||
__version__ = "1.15.7"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
70
lib/crewai-tools/src/crewai_tools/tools/wait_tool/README.md
Normal file
70
lib/crewai-tools/src/crewai_tools/tools/wait_tool/README.md
Normal 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())
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
from crewai_tools.tools.wait_tool.wait_tool import WaitTool, WaitToolSchema
|
||||
|
||||
|
||||
__all__ = ["WaitTool", "WaitToolSchema"]
|
||||
232
lib/crewai-tools/src/crewai_tools/tools/wait_tool/wait_tool.py
Normal file
232
lib/crewai-tools/src/crewai_tools/tools/wait_tool/wait_tool.py
Normal 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)
|
||||
181
lib/crewai-tools/tests/tools/wait_tool_test.py
Normal file
181
lib/crewai-tools/tests/tools/wait_tool_test.py
Normal 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
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
19
lib/crewai/src/crewai/events/types/hook_events.py
Normal file
19
lib/crewai/src/crewai/events/types/hook_events.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
79
lib/crewai/src/crewai/hooks/contexts.py
Normal file
79
lib/crewai/src/crewai/hooks/contexts.py
Normal 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
|
||||
432
lib/crewai/src/crewai/hooks/dispatch.py
Normal file
432
lib/crewai/src/crewai/hooks/dispatch.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
@@ -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}"
|
||||
|
||||
379
lib/crewai/src/crewai/skills/registry.py
Normal file
379
lib/crewai/src/crewai/skills/registry.py
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
211
lib/crewai/tests/events/test_skill_usage_events.py
Normal file
211
lib/crewai/tests/events/test_skill_usage_events.py
Normal 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)
|
||||
@@ -1,6 +0,0 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_experimental_skills(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CREWAI_EXPERIMENTAL", "1")
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
|
||||
|
||||
296
lib/crewai/tests/hooks/test_dispatch.py
Normal file
296
lib/crewai/tests/hooks/test_dispatch.py
Normal 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
|
||||
353
lib/crewai/tests/hooks/test_interception_conformance.py
Normal file
353
lib/crewai/tests/hooks/test_interception_conformance.py
Normal 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")
|
||||
@@ -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]"
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
209
lib/crewai/tests/llms/openai/test_responses_only_models.py
Normal file
209
lib/crewai/tests/llms/openai/test_responses_only_models.py
Normal 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
|
||||
226
lib/crewai/tests/llms/openai/test_responses_tool_loop.py
Normal file
226
lib/crewai/tests/llms/openai/test_responses_tool_loop.py
Normal 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"]
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"})
|
||||
41
lib/crewai/tests/skills/test_experimental_shim.py
Normal file
41
lib/crewai/tests/skills/test_experimental_shim.py
Normal 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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
540
lib/crewai/tests/skills/test_registry.py
Normal file
540
lib/crewai/tests/skills/test_registry.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.15.2"
|
||||
__version__ = "1.15.7"
|
||||
|
||||
Reference in New Issue
Block a user