Compare commits

..

1 Commits

Author SHA1 Message Date
Vinicius Brasil
570b100b33 Allow repository-backed flow agents
Flow declarations can now reference full repository-backed Agent config
without duplicating it inline. The repository response is merged the same
way as `Agent(from_repository=...)`, so values like `role`, `goal`,
`backstory`, `tools`, `planning_config`, deprecated `reasoning`, `memory`,
and other Agent fields can come from the repository. Local YAML fields
still override repository values.

Standalone agent action:

```yaml
do:
  call: agent
  with:
    from_repository: support_specialist
    input: "${state.question}"
```

Inline crew agent:

```yaml
do:
  call: crew
  with:
    name: inline_research
    agents:
      researcher:
        from_repository: researcher
    tasks:
      - description: Research {topic}
        expected_output: Findings about {topic}
        agent: researcher
```
2026-07-02 09:30:37 -07:00
15 changed files with 218 additions and 498 deletions

View File

@@ -51,7 +51,6 @@ jobs:
--ignore-vuln PYSEC-2024-277 \
--ignore-vuln PYSEC-2026-89 \
--ignore-vuln PYSEC-2026-97 \
--ignore-vuln PYSEC-2026-597 \
--ignore-vuln PYSEC-2025-148 \
--ignore-vuln PYSEC-2025-183 \
--ignore-vuln PYSEC-2025-189 \
@@ -79,7 +78,6 @@ jobs:
# PYSEC-2024-277 - joblib 1.5.3: disputed; NumpyArrayWrapper only used with trusted caches
# PYSEC-2026-89 - markdown 3.10.2: DoS via malformed HTML; fix 3.8.1 — already past, advisory range is stale
# PYSEC-2026-97 - nltk 3.9.4: arbitrary file read in filestring(); no fix available
# PYSEC-2026-597 - nltk 3.9.4: path traversal via percent-encoded sequences in data.load()/find() (incomplete fix for #3504); latest release, no fix available. Transitive via unstructured; crewai never calls nltk.data.load/find with untrusted input.
# PYSEC-2025-148 - onnx 1.21.0: path traversal in save_external_data; no fix available
# PYSEC-2025-183 - pyjwt 2.12.1: disputed weak-encryption claim; key length is application-chosen
# PYSEC-2025-189..197 - torch 2.11.0: memory-corruption/DoS in functions only reachable via untrusted models; no fix available

View File

@@ -258,9 +258,10 @@ Fields:
#### Crew Agent Definition (`methods.<name>.do[call=crew].with.agents.<name>`)
Fields:
- `role` (required): string. Crew agent role. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Research analyst`
- `goal` (required): string. Crew agent goal. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Research {topic}`
- `backstory` (required): string. Crew agent backstory. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Expert at concise technical research.`
- `role` (optional): string | null; default `null`. Crew agent role. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Research analyst`
- `goal` (optional): string | null; default `null`. Crew agent goal. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Research {topic}`
- `backstory` (optional): string | null; default `null`. Crew agent backstory. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Expert at concise technical research.`
- `from_repository` (optional): string | null; default `null`. Agent repository name to load. Repository values supply missing agent configuration; explicitly provided local fields override the repository values. Example: `researcher`
- `settings` (optional): map of string to any. Additional agent settings passed to the loader. Example: `{"llm": "openai/gpt-4o-mini"}`
- `llm` (optional): string or inline LLM config; default `null`. Language model that runs this crew agent. Use an object when setting LLM options such as `max_tokens`. Example: `{"max_tokens": 4096, "model": "openai/gpt-4o-mini"}`
- `planning_config` (optional): object | null; default `null`. Agent planning configuration. Set `max_attempts` to limit planning refinement attempts before task execution. Example: `{"max_attempts": 3}`
@@ -292,15 +293,16 @@ Shape:
- `call: agent`
Fields:
- `call` (required): must be `agent`. Action discriminator. Use agent to run an individual inline Agent definition outside of a crew. Example: `agent`
- `call` (required): must be `agent`. Action discriminator. Use agent to run an individual inline or repository-backed Agent definition outside of a crew. Example: `agent`
- `with` (required): any. Individual Agent definition to load and execute outside of a crew for this action. Put the agent input in `with.input`; agent actions do not support action-level `inputs`. Example: `{"backstory": "Precise and concise.", "goal": "Answer user questions", "input": "${state.question}", "role": "Analyst", "settings": {"llm": "openai/gpt-4o-mini"}}`
#### Agent Definition (`methods.<name>.do[call=agent].with`)
Fields:
- `role` (required): string. Individual agent role used by a Flow agent action outside of a crew. Example: `Support specialist`
- `goal` (required): string. Individual agent goal for the Flow agent action outside of a crew. Example: `Draft a concise customer reply`
- `backstory` (required): string. Individual agent backstory used to shape behavior outside of a crew. Example: `Expert at resolving SaaS support questions.`
- `role` (optional): string | null; default `null`. Individual agent role used by a Flow agent action outside of a crew. Example: `Support specialist`
- `goal` (optional): string | null; default `null`. Individual agent goal for the Flow agent action outside of a crew. Example: `Draft a concise customer reply`
- `backstory` (optional): string | null; default `null`. Individual agent backstory used to shape behavior outside of a crew. Example: `Expert at resolving SaaS support questions.`
- `from_repository` (optional): string | null; default `null`. Agent repository name to load. Repository values supply missing agent configuration; explicitly provided local fields override the repository values. Example: `support_specialist`
- `settings` (optional): map of string to any. Additional agent settings passed to the loader. Example: `{"llm": "openai/gpt-4o-mini"}`
- `llm` (optional): string or inline LLM config; default `null`. Language model that runs this agent. Use an object when setting LLM options such as `max_tokens`. Example: `{"max_tokens": 4096, "model": "openai/gpt-4o-mini"}`
- `planning_config` (optional): object | null; default `null`. Agent planning configuration. Set `max_attempts` to limit planning refinement attempts before task execution. Example: `{"max_attempts": 3}`

View File

@@ -1,6 +1,8 @@
import os
import unittest
from unittest.mock import ANY, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from crewai_cli.plus_api import PlusAPI
@@ -341,23 +343,28 @@ class TestPlusAPI(unittest.TestCase):
)
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_get_agent(mock_make_request):
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
async def test_get_agent(mock_async_client_class):
api = PlusAPI("test_api_key")
mock_response = MagicMock()
mock_make_request.return_value = mock_response
mock_client_instance = AsyncMock()
mock_client_instance.get.return_value = mock_response
mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance
response = api.get_agent("test_agent_handle")
response = await api.get_agent("test_agent_handle")
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/agents/test_agent_handle"
mock_client_instance.get.assert_called_once_with(
f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle",
headers=api.headers,
)
assert response == mock_response
@patch("crewai_core.plus_api.PlusAPI._make_request")
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
@patch("crewai_core.plus_api.Settings")
def test_get_agent_with_org_uuid(mock_settings_class, mock_make_request):
async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class):
org_uuid = "test-org-uuid"
mock_settings = MagicMock()
mock_settings.org_uuid = org_uuid
@@ -367,12 +374,15 @@ def test_get_agent_with_org_uuid(mock_settings_class, mock_make_request):
api = PlusAPI("test_api_key")
mock_response = MagicMock()
mock_make_request.return_value = mock_response
mock_client_instance = AsyncMock()
mock_client_instance.get.return_value = mock_response
mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance
response = api.get_agent("test_agent_handle")
response = await api.get_agent("test_agent_handle")
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/agents/test_agent_handle"
mock_client_instance.get.assert_called_once_with(
f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle",
headers=api.headers,
)
assert "X-Crewai-Organization-Id" in api.headers
assert api.headers["X-Crewai-Organization-Id"] == org_uuid

View File

@@ -232,8 +232,10 @@ class PlusAPI:
def get_tool(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
def get_agent(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.AGENTS_RESOURCE}/{handle}")
async def get_agent(self, handle: str) -> httpx.Response:
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
async with httpx.AsyncClient() as client:
return await client.get(url, headers=cast(dict[str, str], self.headers))
def publish_tool(
self,

View File

@@ -472,8 +472,8 @@ class FlowAgentActionDefinition(BaseModel):
call: Literal["agent"] = Field(
description=(
"Action discriminator. Use agent to run an individual inline Agent "
"definition outside of a crew."
"Action discriminator. Use agent to run an individual inline or "
"repository-backed Agent definition outside of a crew."
),
examples=["agent"],
)
@@ -481,7 +481,8 @@ class FlowAgentActionDefinition(BaseModel):
alias="with",
description=(
"Individual Agent definition to load and execute outside of a crew "
"for this action."
"for this action. Set from_repository to load agent configuration "
"from the agent repository."
),
examples=[
{

View File

@@ -138,12 +138,11 @@ class CrewAction:
local_context = _pop_local_context(kwargs)
if self.definition.from_declaration is not None:
crew, default_inputs = await asyncio.to_thread(
load_crew,
crew, default_inputs = load_crew(
_resolve_crew_declaration(
self.definition.from_declaration,
base_dir=self.flow._definition.source_dir,
),
)
)
input_template = {**default_inputs, **(self.definition.inputs or {})}
else:
@@ -156,9 +155,7 @@ class CrewAction:
**crew_definition.inputs,
**(self.definition.inputs or {}),
}
crew, _ = await asyncio.to_thread(
load_crew_from_definition, crew_definition, source="crew action"
)
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
inputs = Expression.from_flow(
cast(ExpressionData, input_template),
@@ -187,8 +184,7 @@ class AgentAction:
if not isinstance(rendered_input, str):
raise ValueError("agent input must render to a string")
agent, response_format = await asyncio.to_thread(
load_agent_from_definition,
agent, response_format = load_agent_from_definition(
self.definition.with_,
source="agent action",
)

View File

@@ -62,7 +62,7 @@ class LLMDefinition(BaseModel):
class CrewAgentDefinition(BaseModel):
"""Inline agent definition used by a crew definition."""
"""Agent definition used by a crew definition."""
model_config = ConfigDict(extra="allow")
@@ -191,9 +191,27 @@ class CrewAgentDefinition(BaseModel):
raise ValueError("agent.settings must be a mapping")
return value or {}
@model_validator(mode="after")
def _validate_agent_source(self) -> CrewAgentDefinition:
if self.from_repository:
return self
missing = [
field
for field in ("role", "goal", "backstory")
if getattr(self, field) is None
]
if missing:
missing_fields = ", ".join(f"'{field}'" for field in missing)
raise ValueError(
f"agent definition requires {missing_fields} unless "
"from_repository is set"
)
return self
class AgentDefinition(CrewAgentDefinition):
"""Inline individual agent definition used outside of a crew."""
"""Individual agent definition used outside of a crew."""
role: str | None = Field(
default=None,
@@ -217,6 +235,15 @@ class AgentDefinition(CrewAgentDefinition):
description="Optional built-in type or Python reference used to load the agent.",
examples=["agent", {"python": "my_project.agents.SupportAgent"}],
)
from_repository: str | None = Field(
default=None,
description=(
"Agent repository name to load. Repository values supply missing "
"agent configuration; explicitly provided local fields override the "
"repository values."
),
examples=["support_specialist"],
)
settings: dict[str, Any] = Field(
default_factory=dict,
description="Additional agent settings passed to the loader.",

View File

@@ -10,7 +10,7 @@ from hashlib import md5
import inspect
import json
import logging
from pathlib import Path, PurePosixPath, PureWindowsPath
from pathlib import Path
import threading
from typing import (
Annotated,
@@ -504,29 +504,6 @@ class Task(BaseModel):
if value is None:
return None
if "{" in value or "}" in value:
template_vars = [part.split("}")[0] for part in value.split("{")[1:]]
for var in template_vars:
if not var.isidentifier():
raise ValueError(f"Invalid template variable name: {var}")
# Literal portions are still checked here; the fully interpolated
# path is re-validated at runtime (see
# interpolate_inputs_and_add_conversation_history) because template
# variables may be filled from untrusted kickoff inputs.
cls._sanitize_output_file_path(value)
return value
return cls._sanitize_output_file_path(value)
@staticmethod
def _sanitize_output_file_path(value: str) -> str:
"""Enforce path-safety on an ``output_file`` value.
Shared by the field validator's literal and template branches. Rejects
traversal sequences, shell expansion, and shell metacharacters, and
strips a leading ``/`` so a literal path stays relative to the working
directory.
"""
if ".." in value:
raise ValueError(
"Path traversal attempts are not allowed in output_file paths"
@@ -542,56 +519,17 @@ class Task(BaseModel):
"Shell special characters are not allowed in output_file paths"
)
if "{" in value or "}" in value:
template_vars = [part.split("}")[0] for part in value.split("{")[1:]]
for var in template_vars:
if not var.isidentifier():
raise ValueError(f"Invalid template variable name: {var}")
return value
if value.startswith("/"):
return value[1:]
return value
def _validate_output_file_input_values(
self, inputs: dict[str, str | int | float | dict[str, Any] | list[Any]]
) -> None:
"""Reject untrusted input values that would escape the output path.
Only the variables that actually appear in the ``output_file`` template
are checked. The developer-authored template is trusted (it may contain
an absolute base directory), but a value substituted into it must not
introduce path traversal (``..``), an absolute path, a home/variable
expansion (``~``/``$``), or shell metacharacters that would redirect the
write outside the intended location.
"""
if not self._original_output_file:
return
template_vars = [
part.split("}")[0] for part in self._original_output_file.split("{")[1:]
]
for var in template_vars:
if var not in inputs:
continue
value = str(inputs[var])
if ".." in value:
raise ValueError(
f"Invalid value for output_file variable '{var}': path "
"traversal sequences ('..') are not allowed"
)
if value.startswith(("~", "$")):
raise ValueError(
f"Invalid value for output_file variable '{var}': shell "
"expansion characters are not allowed"
)
if any(char in value for char in ["|", ">", "<", "&", ";"]):
raise ValueError(
f"Invalid value for output_file variable '{var}': shell "
"special characters are not allowed"
)
if (
PurePosixPath(value).is_absolute()
or PureWindowsPath(value).is_absolute()
):
raise ValueError(
f"Invalid value for output_file variable '{var}': absolute "
"paths are not allowed"
)
@model_validator(mode="after")
def set_attributes_based_on_config(self) -> Task:
"""Set attributes based on the agent configuration."""
@@ -1088,12 +1026,6 @@ Follow these guidelines:
raise ValueError(f"Error interpolating expected_output: {e!s}") from e
if self.output_file is not None:
# Values interpolated into the output path may come from untrusted
# kickoff inputs. The developer-authored template (including any
# absolute base directory) is trusted, but an injected value must
# not introduce path traversal, an absolute path, or shell
# expansion that would escape the intended location.
self._validate_output_file_input_values(inputs)
try:
self.output_file = interpolate_only(
input_string=self._original_output_file, inputs=inputs

View File

@@ -1125,7 +1125,7 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]:
client = PlusAPI(api_key=get_auth_token())
_print_current_organization()
response = client.get_agent(from_repository)
response = asyncio.run(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."
@@ -1158,8 +1158,6 @@ 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

View File

@@ -2243,27 +2243,6 @@ 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_empty_skills(
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",
"tools": [],
"skills": [],
}
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent")
assert agent.role == "test role"
assert agent.skills is None
@patch("crewai.plus_api.PlusAPI.get_agent")
def test_agent_from_repository_with_invalid_tools(mock_get_agent, mock_get_auth_token):
mock_get_response = MagicMock()

View File

@@ -1,6 +1,8 @@
import os
import unittest
from unittest.mock import ANY, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from crewai.plus_api import PlusAPI
@@ -394,23 +396,28 @@ class TestPlusAPI(unittest.TestCase):
)
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_get_agent(mock_make_request):
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
async def test_get_agent(mock_async_client_class):
api = PlusAPI("test_api_key")
mock_response = MagicMock()
mock_make_request.return_value = mock_response
mock_client_instance = AsyncMock()
mock_client_instance.get.return_value = mock_response
mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance
response = api.get_agent("test_agent_handle")
response = await api.get_agent("test_agent_handle")
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/agents/test_agent_handle"
mock_client_instance.get.assert_called_once_with(
f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle",
headers=api.headers,
)
assert response == mock_response
@patch("crewai_core.plus_api.PlusAPI._make_request")
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
@patch("crewai_core.plus_api.Settings")
def test_get_agent_with_org_uuid(mock_settings_class, mock_make_request):
async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class):
org_uuid = "test-org-uuid"
mock_settings = MagicMock()
mock_settings.org_uuid = org_uuid
@@ -420,12 +427,15 @@ def test_get_agent_with_org_uuid(mock_settings_class, mock_make_request):
api = PlusAPI("test_api_key")
mock_response = MagicMock()
mock_make_request.return_value = mock_response
mock_client_instance = AsyncMock()
mock_client_instance.get.return_value = mock_response
mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance
response = api.get_agent("test_agent_handle")
response = await api.get_agent("test_agent_handle")
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/agents/test_agent_handle"
mock_client_instance.get.assert_called_once_with(
f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle",
headers=api.headers,
)
assert "X-Crewai-Organization-Id" in api.headers
assert api.headers["X-Crewai-Organization-Id"] == org_uuid

View File

@@ -355,22 +355,16 @@ class TestLoadAgent:
with pytest.raises(Exception):
load_agent(agent_file)
@pytest.mark.parametrize("field", ["role", "goal", "backstory"])
def test_load_agent_rejects_null_required_fields(
self, tmp_path: Path, field: str
):
def test_load_agent_rejects_null_required_fields(self, tmp_path: Path):
agent_def = {
"role": "Researcher",
"role": None,
"goal": "Find information",
"backstory": "Expert researcher.",
}
agent_def[field] = None
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
with pytest.raises(
JSONProjectValidationError, match=f"missing required field '{field}'"
):
with pytest.raises(JSONProjectValidationError, match="missing required field 'role'"):
load_agent(agent_file)
def test_load_agent_file_not_found(self):

View File

@@ -88,7 +88,7 @@ def test_flow_definition_json_schema_carries_reference_descriptions():
agent_properties = defs["FlowAgentActionDefinition"]["properties"]
assert "Individual Agent definition" in agent_properties["with"]["description"]
assert "outside of a crew" in agent_properties["with"]["description"]
assert "individual inline Agent" in agent_properties["call"]["description"]
assert "repository-backed Agent" in agent_properties["call"]["description"]
expression_rule = FLOW_TEMPLATE_EXPRESSION_RULES[0]
code_properties = defs["FlowCodeActionDefinition"]["properties"]

View File

@@ -1166,35 +1166,28 @@ methods:
def test_agent_action_runs_repository_yaml_definition(
monkeypatch: pytest.MonkeyPatch,
):
import crewai.agent.core as agent_core
from crewai import Agent
from crewai.plus_api import PlusAPI
fetched_agents: list[str] = []
class FakeResponse:
status_code = 200
text = ""
def json(self) -> dict[str, Any]:
return {
"role": "Repository specialist",
"goal": "Answer support questions",
"backstory": "Loaded from the agent repository.",
"max_iter": 3,
"tools": [],
}
def fake_get_agent(self: PlusAPI, handle: str) -> FakeResponse:
fetched_agents.append(handle)
return FakeResponse()
def fake_load_agent_from_repository(from_repository: str) -> dict[str, Any]:
assert from_repository == "support_specialist"
return {
"role": "Repository specialist",
"goal": "Answer support questions",
"backstory": "Loaded from the agent repository.",
"max_iter": 3,
}
async def fake_kickoff_async(
self: Agent, messages: str, **_kwargs: Any
) -> dict[str, Any]:
return {"agent": self.role, "input": messages, "max_iter": self.max_iter}
monkeypatch.setattr("crewai.auth.token.get_auth_token", lambda: "test-token")
monkeypatch.setattr(PlusAPI, "get_agent", fake_get_agent)
monkeypatch.setattr(
agent_core,
"load_agent_from_repository",
fake_load_agent_from_repository,
)
monkeypatch.setattr(Agent, "kickoff_async", fake_kickoff_async)
yaml_str = """
@@ -1217,83 +1210,6 @@ methods:
"input": "What is CrewAI?",
"max_iter": 3,
}
assert fetched_agents == ["support_specialist"]
def test_agent_action_repository_fetch_does_not_block_event_loop(
monkeypatch: pytest.MonkeyPatch,
):
from crewai import Agent
from crewai.plus_api import PlusAPI
loop_marker_ran = threading.Event()
fetch_started = threading.Event()
release_fetch = threading.Event()
fetch_saw_loop_marker = False
class FakeResponse:
status_code = 200
text = ""
def json(self) -> dict[str, Any]:
return {
"role": "Repository specialist",
"goal": "Answer support questions",
"backstory": "Loaded from the agent repository.",
"tools": [],
}
def fake_get_agent(self: PlusAPI, handle: str) -> FakeResponse:
nonlocal fetch_saw_loop_marker
fetch_started.set()
release_fetch.wait(timeout=1)
fetch_saw_loop_marker = loop_marker_ran.is_set()
return FakeResponse()
async def fake_kickoff_async(
self: Agent, messages: str, **_kwargs: Any
) -> str:
return f"{self.role}:{messages}"
monkeypatch.setattr("crewai.auth.token.get_auth_token", lambda: "test-token")
monkeypatch.setattr(PlusAPI, "get_agent", fake_get_agent)
monkeypatch.setattr(Agent, "kickoff_async", fake_kickoff_async)
yaml_str = """
schema: crewai.flow/v1
name: AgentFlow
methods:
answer:
do:
call: agent
with:
from_repository: support_specialist
input: "${state.question}"
start: true
"""
flow = Flow.from_declaration(contents=yaml_str)
async def run_flow() -> str:
async def mark_loop_progress() -> None:
while not fetch_started.is_set():
await asyncio.sleep(0)
loop_marker_ran.set()
release_fetch.set()
marker_task = asyncio.create_task(mark_loop_progress())
kickoff_task = asyncio.create_task(
flow.kickoff_async(inputs={"question": "What is CrewAI?"})
)
try:
result = await asyncio.wait_for(kickoff_task, timeout=2)
await asyncio.wait_for(marker_task, timeout=2)
return result
finally:
release_fetch.set()
assert asyncio.run(run_flow()) == "Repository specialist:What is CrewAI?"
assert fetch_saw_loop_marker
def test_agent_action_renders_text_custom_expression_input(
@@ -1519,167 +1435,6 @@ methods:
}
def test_crew_action_runs_repository_agent_yaml_definition(
monkeypatch: pytest.MonkeyPatch,
):
from crewai import Crew
from crewai.plus_api import PlusAPI
fetched_agents: list[str] = []
class FakeResponse:
status_code = 200
text = ""
def json(self) -> dict[str, Any]:
return {
"role": "Repository researcher",
"goal": "Research {topic}",
"backstory": "Loaded from the agent repository.",
"max_iter": 5,
"tools": [],
}
def fake_get_agent(self: PlusAPI, handle: str) -> FakeResponse:
fetched_agents.append(handle)
return FakeResponse()
async def fake_kickoff_async(
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
) -> dict[str, Any]:
return {
"crew": self.name,
"agents": [
{"role": agent.role, "max_iter": agent.max_iter}
for agent in self.agents
],
"tasks": [task.description for task in self.tasks],
"inputs": inputs,
}
monkeypatch.setattr("crewai.auth.token.get_auth_token", lambda: "test-token")
monkeypatch.setattr(PlusAPI, "get_agent", fake_get_agent)
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
with:
name: inline_research
agents:
researcher:
from_repository: researcher
tasks:
- name: research_task
description: Research {topic}
expected_output: Findings about {topic}
agent: researcher
inputs:
topic: "${state.topic}"
start: true
"""
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "inline_research",
"agents": [{"role": "Repository researcher", "max_iter": 5}],
"tasks": ["Research {topic}"],
"inputs": {"topic": "AI"},
}
assert fetched_agents == ["researcher"]
def test_crew_action_repository_fetch_does_not_block_event_loop(
monkeypatch: pytest.MonkeyPatch,
):
from crewai import Crew
from crewai.plus_api import PlusAPI
loop_marker_ran = threading.Event()
fetch_started = threading.Event()
release_fetch = threading.Event()
fetch_saw_loop_marker = False
class FakeResponse:
status_code = 200
text = ""
def json(self) -> dict[str, Any]:
return {
"role": "Repository researcher",
"goal": "Research {topic}",
"backstory": "Loaded from the agent repository.",
"tools": [],
}
def fake_get_agent(self: PlusAPI, handle: str) -> FakeResponse:
nonlocal fetch_saw_loop_marker
fetch_started.set()
release_fetch.wait(timeout=1)
fetch_saw_loop_marker = loop_marker_ran.is_set()
return FakeResponse()
async def fake_kickoff_async(
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
) -> dict[str, Any]:
return {"agents": [agent.role for agent in self.agents], "inputs": inputs}
monkeypatch.setattr("crewai.auth.token.get_auth_token", lambda: "test-token")
monkeypatch.setattr(PlusAPI, "get_agent", fake_get_agent)
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
with:
agents:
researcher:
from_repository: researcher
tasks:
- description: Research {topic}
expected_output: Findings about {topic}
agent: researcher
inputs:
topic: "${state.topic}"
start: true
"""
flow = Flow.from_declaration(contents=yaml_str)
async def run_flow() -> dict[str, Any]:
async def mark_loop_progress() -> None:
while not fetch_started.is_set():
await asyncio.sleep(0)
loop_marker_ran.set()
release_fetch.set()
marker_task = asyncio.create_task(mark_loop_progress())
kickoff_task = asyncio.create_task(
flow.kickoff_async(inputs={"topic": "AI"})
)
try:
result = await asyncio.wait_for(kickoff_task, timeout=2)
await asyncio.wait_for(marker_task, timeout=2)
return result
finally:
release_fetch.set()
assert asyncio.run(run_flow()) == {
"agents": ["Repository researcher"],
"inputs": {"topic": "AI"},
}
assert fetch_saw_loop_marker
def test_crew_action_interpolates_runtime_strings_and_lists(
monkeypatch: pytest.MonkeyPatch,
):
@@ -1732,6 +1487,73 @@ methods:
}
def test_crew_action_runs_repository_agent_yaml_definition(
monkeypatch: pytest.MonkeyPatch,
):
import crewai.agent.core as agent_core
from crewai import Crew
def fake_load_agent_from_repository(from_repository: str) -> dict[str, Any]:
assert from_repository == "researcher"
return {
"role": "Repository researcher",
"goal": "Research {topic}",
"backstory": "Loaded from the agent repository.",
"max_iter": 5,
}
async def fake_kickoff_async(
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
) -> dict[str, Any]:
return {
"crew": self.name,
"agents": [
{"role": agent.role, "max_iter": agent.max_iter}
for agent in self.agents
],
"tasks": [task.description for task in self.tasks],
"inputs": inputs,
}
monkeypatch.setattr(
agent_core,
"load_agent_from_repository",
fake_load_agent_from_repository,
)
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
with:
name: inline_research
agents:
researcher:
from_repository: researcher
tasks:
- name: research_task
description: Research {topic}
expected_output: Findings about {topic}
agent: researcher
inputs:
topic: "${state.topic}"
start: true
"""
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "inline_research",
"agents": [{"role": "Repository researcher", "max_iter": 5}],
"tasks": ["Research {topic}"],
"inputs": {"topic": "AI"},
}
def test_crew_action_runs_crew_from_declaration(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
@@ -2024,45 +1846,36 @@ def test_crew_action_json_schema_describes_inline_crew_definitions():
def test_crew_action_rejects_incomplete_inline_agent_definition():
from crewai.project.crew_loader import load_crew_from_definition
from crewai.project.json_loader import JSONProjectValidationError
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
"methods": {
"research": {
"start": True,
"do": {
"call": "crew",
"with": {
"agents": {
"researcher": {
"role": "Researcher",
"backstory": "Knows things.",
}
with pytest.raises(ValidationError, match="goal"):
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
"methods": {
"research": {
"start": True,
"do": {
"call": "crew",
"with": {
"agents": {
"researcher": {
"role": "Researcher",
"backstory": "Knows things.",
}
},
"tasks": [
{
"description": "Research",
"expected_output": "Findings",
"agent": "researcher",
}
],
},
"tasks": [
{
"description": "Research",
"expected_output": "Findings",
"agent": "researcher",
}
],
},
},
}
},
}
)
crew_definition = definition.methods["research"].do.with_
assert crew_definition.agents["researcher"].goal is None
with pytest.raises(
JSONProjectValidationError, match="missing required field 'goal'"
):
load_crew_from_definition(crew_definition, source="crew action")
}
},
}
)
def test_crew_action_rejects_python_ref_field():

View File

@@ -931,48 +931,6 @@ def test_interpolate_inputs(tmp_path):
assert task.output_file == str(tmp_path / "ML" / "output_2025.txt")
@pytest.mark.parametrize(
("template", "malicious_inputs", "expected_error"),
[
("reports/{name}.md", {"name": "../../../../tmp/pwn"}, "path traversal"),
("{p}", {"p": "/tmp/abs_pwn"}, "absolute paths"),
("{p}", {"p": "~/.bashrc"}, "shell expansion"),
("{p}", {"p": "x;rm -rf /"}, "shell special characters"),
("{p}", {"p": r"C:\Windows\evil"}, "absolute paths"),
],
)
def test_interpolate_output_file_rejects_unsafe_inputs(
template, malicious_inputs, expected_error
):
"""Untrusted inputs must not escape the output_file path via interpolation."""
task = Task(
description="do {p} {name}".replace("{p}", "x").replace("{name}", "x"),
expected_output="e",
output_file=template,
)
with pytest.raises(ValueError, match=expected_error):
task.interpolate_inputs_and_add_conversation_history(inputs=malicious_inputs)
def test_interpolate_output_file_allows_safe_inputs(tmp_path):
"""Safe input values and developer-chosen absolute base paths still work."""
task = Task(
description="d",
expected_output="e",
output_file="reports/{name}.md",
)
task.interpolate_inputs_and_add_conversation_history(inputs={"name": "q3_summary"})
assert task.output_file == "reports/q3_summary.md"
abs_task = Task(
description="d",
expected_output="e",
output_file=str(tmp_path / "{topic}" / "out.md"),
)
abs_task.interpolate_inputs_and_add_conversation_history(inputs={"topic": "sales"})
assert abs_task.output_file == str(tmp_path / "sales" / "out.md")
def test_interpolate_only():
"""Test the interpolate_only method for various scenarios including JSON structure preservation."""