Merge branch 'main' into fix/bad-credentials-for-batch-push

This commit is contained in:
Greyson LaLonde
2026-03-20 15:21:14 -04:00
committed by GitHub
29 changed files with 1712 additions and 71 deletions

View File

@@ -115,6 +115,13 @@
"en/guides/flows/mastering-flow-state"
]
},
{
"group": "Tools",
"icon": "wrench",
"pages": [
"en/guides/tools/publish-custom-tools"
]
},
{
"group": "Coding Tools",
"icon": "terminal",
@@ -575,6 +582,13 @@
"en/guides/flows/mastering-flow-state"
]
},
{
"group": "Tools",
"icon": "wrench",
"pages": [
"en/guides/tools/publish-custom-tools"
]
},
{
"group": "Coding Tools",
"icon": "terminal",
@@ -1035,6 +1049,13 @@
"en/guides/flows/mastering-flow-state"
]
},
{
"group": "Tools",
"icon": "wrench",
"pages": [
"en/guides/tools/publish-custom-tools"
]
},
{
"group": "Coding Tools",
"icon": "terminal",
@@ -1525,6 +1546,20 @@
"pt-BR/guides/flows/mastering-flow-state"
]
},
{
"group": "Ferramentas",
"icon": "wrench",
"pages": [
"pt-BR/guides/tools/publish-custom-tools"
]
},
{
"group": "Ferramentas de Codificação",
"icon": "terminal",
"pages": [
"pt-BR/guides/coding-tools/agents-md"
]
},
{
"group": "Avançado",
"icon": "gear",
@@ -1964,6 +1999,20 @@
"pt-BR/guides/flows/mastering-flow-state"
]
},
{
"group": "Ferramentas",
"icon": "wrench",
"pages": [
"pt-BR/guides/tools/publish-custom-tools"
]
},
{
"group": "Ferramentas de Codificação",
"icon": "terminal",
"pages": [
"pt-BR/guides/coding-tools/agents-md"
]
},
{
"group": "Avançado",
"icon": "gear",
@@ -2403,6 +2452,20 @@
"pt-BR/guides/flows/mastering-flow-state"
]
},
{
"group": "Ferramentas",
"icon": "wrench",
"pages": [
"pt-BR/guides/tools/publish-custom-tools"
]
},
{
"group": "Ferramentas de Codificação",
"icon": "terminal",
"pages": [
"pt-BR/guides/coding-tools/agents-md"
]
},
{
"group": "Avançado",
"icon": "gear",
@@ -2872,6 +2935,20 @@
"ko/guides/flows/mastering-flow-state"
]
},
{
"group": "도구",
"icon": "wrench",
"pages": [
"ko/guides/tools/publish-custom-tools"
]
},
{
"group": "코딩 도구",
"icon": "terminal",
"pages": [
"ko/guides/coding-tools/agents-md"
]
},
{
"group": "고급",
"icon": "gear",
@@ -3323,6 +3400,20 @@
"ko/guides/flows/mastering-flow-state"
]
},
{
"group": "도구",
"icon": "wrench",
"pages": [
"ko/guides/tools/publish-custom-tools"
]
},
{
"group": "코딩 도구",
"icon": "terminal",
"pages": [
"ko/guides/coding-tools/agents-md"
]
},
{
"group": "고급",
"icon": "gear",
@@ -3774,6 +3865,20 @@
"ko/guides/flows/mastering-flow-state"
]
},
{
"group": "도구",
"icon": "wrench",
"pages": [
"ko/guides/tools/publish-custom-tools"
]
},
{
"group": "코딩 도구",
"icon": "terminal",
"pages": [
"ko/guides/coding-tools/agents-md"
]
},
{
"group": "고급",
"icon": "gear",

View File

@@ -0,0 +1,244 @@
---
title: Publish Custom Tools
description: How to build, package, and publish your own CrewAI-compatible tools to PyPI so any CrewAI user can install and use them.
icon: box-open
mode: "wide"
---
## Overview
CrewAI's tool system is designed to be extended. If you've built a tool that could benefit others, you can package it as a standalone Python library, publish it to PyPI, and make it available to any CrewAI user — no PR to the CrewAI repo required.
This guide walks through the full process: implementing the tools contract, structuring your package, and publishing to PyPI.
<Note type="info" title="Not looking to publish?">
If you just need a custom tool for your own project, see the [Create Custom Tools](/en/learn/create-custom-tools) guide instead.
</Note>
## The Tools Contract
Every CrewAI tool must satisfy one of two interfaces:
### Option 1: Subclass `BaseTool`
Subclass `crewai.tools.BaseTool` and implement the `_run` method. Define `name`, `description`, and optionally an `args_schema` for input validation.
```python
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
class GeolocateInput(BaseModel):
"""Input schema for GeolocateTool."""
address: str = Field(..., description="The street address to geolocate.")
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converts a street address into latitude/longitude coordinates."
args_schema: type[BaseModel] = GeolocateInput
def _run(self, address: str) -> str:
# Your implementation here
return f"40.7128, -74.0060"
```
### Option 2: Use the `@tool` Decorator
For simpler tools, the `@tool` decorator turns a function into a CrewAI tool. The function **must** have a docstring (used as the tool description) and type annotations.
```python
from crewai.tools import tool
@tool("Geolocate")
def geolocate(address: str) -> str:
"""Converts a street address into latitude/longitude coordinates."""
return "40.7128, -74.0060"
```
### Key Requirements
Regardless of which approach you use, your tool must:
- Have a **`name`** — a short, descriptive identifier.
- Have a **`description`** — tells the agent when and how to use the tool. This directly affects how well agents use your tool, so be clear and specific.
- Implement **`_run`** (BaseTool) or provide a **function body** (@tool) — the synchronous execution logic.
- Use **type annotations** on all parameters and return values.
- Return a **string** result (or something that can be meaningfully converted to one).
### Optional: Async Support
If your tool performs I/O-bound work, implement `_arun` for async execution:
```python
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converts a street address into latitude/longitude coordinates."
def _run(self, address: str) -> str:
# Sync implementation
...
async def _arun(self, address: str) -> str:
# Async implementation
...
```
### Optional: Input Validation with `args_schema`
Define a Pydantic model as your `args_schema` to get automatic input validation and clear error messages. If you don't provide one, CrewAI will infer it from your `_run` method's signature.
```python
from pydantic import BaseModel, Field
class TranslateInput(BaseModel):
"""Input schema for TranslateTool."""
text: str = Field(..., description="The text to translate.")
target_language: str = Field(
default="en",
description="ISO 639-1 language code for the target language.",
)
```
Explicit schemas are recommended for published tools — they produce better agent behavior and clearer documentation for your users.
### Optional: Environment Variables
If your tool requires API keys or other configuration, declare them with `env_vars` so users know what to set:
```python
from crewai.tools import BaseTool, EnvVar
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converts a street address into latitude/longitude coordinates."
env_vars: list[EnvVar] = [
EnvVar(
name="GEOCODING_API_KEY",
description="API key for the geocoding service.",
required=True,
),
]
def _run(self, address: str) -> str:
...
```
## Package Structure
Structure your project as a standard Python package. Here's a recommended layout:
```
crewai-geolocate/
├── pyproject.toml
├── LICENSE
├── README.md
└── src/
└── crewai_geolocate/
├── __init__.py
└── tools.py
```
### `pyproject.toml`
```toml
[project]
name = "crewai-geolocate"
version = "0.1.0"
description = "A CrewAI tool for geolocating street addresses."
requires-python = ">=3.10"
dependencies = [
"crewai",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
```
Declare `crewai` as a dependency so users get a compatible version automatically.
### `__init__.py`
Re-export your tool classes so users can import them directly:
```python
from crewai_geolocate.tools import GeolocateTool
__all__ = ["GeolocateTool"]
```
### Naming Conventions
- **Package name**: Use the prefix `crewai-` (e.g., `crewai-geolocate`). This makes your tool discoverable when users search PyPI.
- **Module name**: Use underscores (e.g., `crewai_geolocate`).
- **Tool class name**: Use PascalCase ending in `Tool` (e.g., `GeolocateTool`).
## Testing Your Tool
Before publishing, verify your tool works within a crew:
```python
from crewai import Agent, Crew, Task
from crewai_geolocate import GeolocateTool
agent = Agent(
role="Location Analyst",
goal="Find coordinates for given addresses.",
backstory="An expert in geospatial data.",
tools=[GeolocateTool()],
)
task = Task(
description="Find the coordinates of 1600 Pennsylvania Avenue, Washington, DC.",
expected_output="The latitude and longitude of the address.",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task])
result = crew.kickoff()
print(result)
```
## Publishing to PyPI
Once your tool is tested and ready:
```bash
# Build the package
uv build
# Publish to PyPI
uv publish
```
If this is your first time publishing, you'll need a [PyPI account](https://pypi.org/account/register/) and an [API token](https://pypi.org/help/#apitoken).
### After Publishing
Users can install your tool with:
```bash
pip install crewai-geolocate
```
Or with uv:
```bash
uv add crewai-geolocate
```
Then use it in their crews:
```python
from crewai_geolocate import GeolocateTool
agent = Agent(
role="Location Analyst",
tools=[GeolocateTool()],
# ...
)
```

View File

@@ -11,6 +11,10 @@ This guide provides detailed instructions on creating custom tools for the CrewA
incorporating the latest functionalities such as tool delegation, error handling, and dynamic tool calling. It also highlights the importance of collaboration tools,
enabling agents to perform a wide range of actions.
<Tip>
**Want to publish your tool for the community?** If you're building a tool that others could benefit from, check out the [Publish Custom Tools](/en/guides/tools/publish-custom-tools) guide to learn how to package and distribute your tool on PyPI.
</Tip>
### Subclassing `BaseTool`
To create a personalized tool, inherit from `BaseTool` and define the necessary attributes, including the `args_schema` for input validation, and the `_run` method.

View File

@@ -0,0 +1,61 @@
---
title: 코딩 도구
description: AGENTS.md를 사용하여 CrewAI 프로젝트 전반에서 코딩 에이전트와 IDE를 안내합니다.
icon: terminal
mode: "wide"
---
## AGENTS.md를 사용하는 이유
`AGENTS.md`는 가벼운 저장소 로컬 지침 파일로, 코딩 에이전트에게 일관되고 프로젝트별 안내를 제공합니다. 프로젝트 루트에 배치하고 어시스턴트가 작업하는 방식(컨벤션, 명령어, 아키텍처 노트, 가드레일)에 대한 신뢰할 수 있는 소스로 활용하세요.
## CLI로 프로젝트 생성
CrewAI CLI를 사용하여 프로젝트를 스캐폴딩하면, `AGENTS.md`가 루트에 자동으로 추가됩니다.
```bash
# Crew
crewai create crew my_crew
# Flow
crewai create flow my_flow
# Tool repository
crewai tool create my_tool
```
## 도구 설정: 어시스턴트에 AGENTS.md 연결
### Codex
Codex는 저장소에 배치된 `AGENTS.md` 파일로 안내할 수 있습니다. 컨벤션, 명령어, 워크플로우 기대치 등 지속적인 프로젝트 컨텍스트를 제공하는 데 사용하세요.
### Claude Code
Claude Code는 프로젝트 메모리를 `CLAUDE.md`에 저장합니다. `/init`으로 부트스트랩하고 `/memory`로 편집할 수 있습니다. Claude Code는 `CLAUDE.md` 내에서 임포트도 지원하므로, `@AGENTS.md`와 같은 한 줄을 추가하여 공유 지침을 중복 없이 가져올 수 있습니다.
간단하게 다음과 같이 사용할 수 있습니다:
```bash
mv AGENTS.md CLAUDE.md
```
### Gemini CLI와 Google Antigravity
Gemini CLI와 Antigravity는 저장소 루트 및 상위 디렉토리에서 프로젝트 컨텍스트 파일(기본값: `GEMINI.md`)을 로드합니다. Gemini CLI 설정에서 `context.fileName`을 설정하여 `AGENTS.md`를 대신(또는 추가로) 읽도록 구성할 수 있습니다. 예를 들어, `AGENTS.md`만 설정하거나 각 도구의 형식을 유지하고 싶다면 `AGENTS.md`와 `GEMINI.md`를 모두 포함할 수 있습니다.
간단하게 다음과 같이 사용할 수 있습니다:
```bash
mv AGENTS.md GEMINI.md
```
### Cursor
Cursor는 `AGENTS.md`를 프로젝트 지침 파일로 지원합니다. 프로젝트 루트에 배치하여 Cursor의 코딩 어시스턴트에 안내를 제공하세요.
### Windsurf
Claude Code는 Windsurf와의 공식 통합을 제공합니다. Windsurf 내에서 Claude Code를 사용하는 경우, 위의 Claude Code 안내를 따르고 `CLAUDE.md`에서 `AGENTS.md`를 임포트하세요.
Windsurf의 네이티브 어시스턴트를 사용하는 경우, 프로젝트 규칙 또는 지침 기능(사용 가능한 경우)을 구성하여 `AGENTS.md`에서 읽거나 내용을 직접 붙여넣으세요.

View File

@@ -0,0 +1,244 @@
---
title: 커스텀 도구 배포하기
description: PyPI에 게시할 수 있는 CrewAI 호환 도구를 빌드, 패키징, 배포하는 방법을 안내합니다.
icon: box-open
mode: "wide"
---
## 개요
CrewAI의 도구 시스템은 확장 가능하도록 설계되었습니다. 다른 사용자에게도 유용한 도구를 만들었다면, 독립적인 Python 라이브러리로 패키징하여 PyPI에 게시하고 모든 CrewAI 사용자가 사용할 수 있도록 할 수 있습니다. CrewAI 저장소에 PR을 보낼 필요가 없습니다.
이 가이드에서는 도구 계약 구현, 패키지 구조화, PyPI 게시까지의 전체 과정을 안내합니다.
<Note type="info" title="배포할 계획이 없으신가요?">
프로젝트 내에서만 사용할 커스텀 도구가 필요하다면 [커스텀 도구 생성](/ko/learn/create-custom-tools) 가이드를 참고하세요.
</Note>
## 도구 계약
모든 CrewAI 도구는 다음 두 가지 인터페이스 중 하나를 충족해야 합니다:
### 옵션 1: `BaseTool` 서브클래싱
`crewai.tools.BaseTool`을 서브클래싱하고 `_run` 메서드를 구현합니다. `name`, `description`, 그리고 선택적으로 입력 검증을 위한 `args_schema`를 정의합니다.
```python
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
class GeolocateInput(BaseModel):
"""GeolocateTool의 입력 스키마."""
address: str = Field(..., description="지오코딩할 도로명 주소.")
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "도로명 주소를 위도/경도 좌표로 변환합니다."
args_schema: type[BaseModel] = GeolocateInput
def _run(self, address: str) -> str:
# 구현 로직
return f"40.7128, -74.0060"
```
### 옵션 2: `@tool` 데코레이터 사용
간단한 도구의 경우, `@tool` 데코레이터로 함수를 CrewAI 도구로 변환할 수 있습니다. 함수에는 반드시 독스트링(도구 설명으로 사용됨)과 타입 어노테이션이 있어야 합니다.
```python
from crewai.tools import tool
@tool("Geolocate")
def geolocate(address: str) -> str:
"""도로명 주소를 위도/경도 좌표로 변환합니다."""
return "40.7128, -74.0060"
```
### 핵심 요구사항
어떤 방식을 사용하든, 도구는 다음을 충족해야 합니다:
- **`name`** — 짧고 설명적인 식별자.
- **`description`** — 에이전트에게 도구를 언제, 어떻게 사용할지 알려줍니다. 에이전트가 도구를 얼마나 잘 활용하는지에 직접적으로 영향을 미치므로 명확하고 구체적으로 작성하세요.
- **`_run`** (BaseTool) 또는 **함수 본문** (@tool) 구현 — 동기 실행 로직.
- 모든 매개변수와 반환 값에 **타입 어노테이션** 사용.
- **문자열** 결과를 반환 (또는 의미 있게 문자열로 변환 가능한 값).
### 선택사항: 비동기 지원
I/O 바운드 작업을 수행하는 도구의 경우 비동기 실행을 위해 `_arun`을 구현합니다:
```python
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "도로명 주소를 위도/경도 좌표로 변환합니다."
def _run(self, address: str) -> str:
# 동기 구현
...
async def _arun(self, address: str) -> str:
# 비동기 구현
...
```
### 선택사항: `args_schema`를 통한 입력 검증
Pydantic 모델을 `args_schema`로 정의하면 자동 입력 검증과 명확한 에러 메시지를 받을 수 있습니다. 제공하지 않으면 CrewAI가 `_run` 메서드의 시그니처에서 추론합니다.
```python
from pydantic import BaseModel, Field
class TranslateInput(BaseModel):
"""TranslateTool의 입력 스키마."""
text: str = Field(..., description="번역할 텍스트.")
target_language: str = Field(
default="en",
description="대상 언어의 ISO 639-1 언어 코드.",
)
```
배포용 도구에는 명시적 스키마를 권장합니다 — 에이전트 동작이 개선되고 사용자에게 더 명확한 문서를 제공합니다.
### 선택사항: 환경 변수
도구에 API 키나 기타 설정이 필요한 경우, `env_vars`로 선언하여 사용자가 무엇을 설정해야 하는지 알 수 있도록 합니다:
```python
from crewai.tools import BaseTool, EnvVar
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "도로명 주소를 위도/경도 좌표로 변환합니다."
env_vars: list[EnvVar] = [
EnvVar(
name="GEOCODING_API_KEY",
description="지오코딩 서비스 API 키.",
required=True,
),
]
def _run(self, address: str) -> str:
...
```
## 패키지 구조
프로젝트를 표준 Python 패키지로 구성합니다. 권장 레이아웃:
```
crewai-geolocate/
├── pyproject.toml
├── LICENSE
├── README.md
└── src/
└── crewai_geolocate/
├── __init__.py
└── tools.py
```
### `pyproject.toml`
```toml
[project]
name = "crewai-geolocate"
version = "0.1.0"
description = "도로명 주소를 지오코딩하는 CrewAI 도구."
requires-python = ">=3.10"
dependencies = [
"crewai",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
```
사용자가 자동으로 호환 버전을 받을 수 있도록 `crewai`를 의존성으로 선언합니다.
### `__init__.py`
사용자가 직접 import할 수 있도록 도구 클래스를 re-export합니다:
```python
from crewai_geolocate.tools import GeolocateTool
__all__ = ["GeolocateTool"]
```
### 명명 규칙
- **패키지 이름**: `crewai-` 접두사를 사용합니다 (예: `crewai-geolocate`). PyPI에서 검색할 때 도구를 쉽게 찾을 수 있습니다.
- **모듈 이름**: 밑줄을 사용합니다 (예: `crewai_geolocate`).
- **도구 클래스 이름**: `Tool`로 끝나는 PascalCase를 사용합니다 (예: `GeolocateTool`).
## 도구 테스트
게시 전에 도구가 크루 내에서 작동하는지 확인합니다:
```python
from crewai import Agent, Crew, Task
from crewai_geolocate import GeolocateTool
agent = Agent(
role="Location Analyst",
goal="주어진 주소의 좌표를 찾습니다.",
backstory="지리공간 데이터 전문가.",
tools=[GeolocateTool()],
)
task = Task(
description="1600 Pennsylvania Avenue, Washington, DC의 좌표를 찾으세요.",
expected_output="해당 주소의 위도와 경도.",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task])
result = crew.kickoff()
print(result)
```
## PyPI에 게시하기
도구 테스트를 완료하고 준비가 되면:
```bash
# 패키지 빌드
uv build
# PyPI에 게시
uv publish
```
처음 게시하는 경우 [PyPI 계정](https://pypi.org/account/register/)과 [API 토큰](https://pypi.org/help/#apitoken)이 필요합니다.
### 게시 후
사용자는 다음과 같이 도구를 설치할 수 있습니다:
```bash
pip install crewai-geolocate
```
또는 uv를 사용하여:
```bash
uv add crewai-geolocate
```
그런 다음 크루에서 사용합니다:
```python
from crewai_geolocate import GeolocateTool
agent = Agent(
role="Location Analyst",
tools=[GeolocateTool()],
# ...
)
```

View File

@@ -9,6 +9,10 @@ mode: "wide"
이 가이드는 CrewAI 프레임워크를 위한 커스텀 툴을 생성하는 방법과 최신 기능(툴 위임, 오류 처리, 동적 툴 호출 등)을 통합하여 이러한 툴을 효율적으로 관리하고 활용하는 방법에 대해 자세히 안내합니다. 또한 협업 툴의 중요성을 강조하며, 에이전트가 다양한 작업을 수행할 수 있도록 지원합니다.
<Tip>
**커뮤니티에 도구를 배포하고 싶으신가요?** 다른 사용자에게도 유용한 도구를 만들고 있다면, [커스텀 도구 배포하기](/ko/guides/tools/publish-custom-tools) 가이드에서 도구를 패키징하고 PyPI에 배포하는 방법을 알아보세요.
</Tip>
### `BaseTool` 서브클래싱
개인화된 툴을 생성하려면 `BaseTool`을 상속받고, 입력 검증을 위한 `args_schema`와 `_run` 메서드를 포함한 필요한 속성들을 정의해야 합니다.

View File

@@ -0,0 +1,61 @@
---
title: Ferramentas de Codificação
description: Use o AGENTS.md para guiar agentes de codificação e IDEs em seus projetos CrewAI.
icon: terminal
mode: "wide"
---
## Por que AGENTS.md
`AGENTS.md` é um arquivo de instruções leve e local do repositório que fornece aos agentes de codificação orientações consistentes e específicas do projeto. Mantenha-o na raiz do projeto e trate-o como a fonte da verdade para como você deseja que os assistentes trabalhem: convenções, comandos, notas de arquitetura e proteções.
## Criar um Projeto com o CLI
Use o CLI do CrewAI para criar a estrutura de um projeto, e o `AGENTS.md` será automaticamente adicionado na raiz.
```bash
# Crew
crewai create crew my_crew
# Flow
crewai create flow my_flow
# Tool repository
crewai tool create my_tool
```
## Configuração de Ferramentas: Direcione Assistentes para o AGENTS.md
### Codex
O Codex pode ser guiado por arquivos `AGENTS.md` colocados no seu repositório. Use-os para fornecer contexto persistente do projeto, como convenções, comandos e expectativas de fluxo de trabalho.
### Claude Code
O Claude Code armazena a memória do projeto em `CLAUDE.md`. Você pode inicializá-lo com `/init` e editá-lo usando `/memory`. O Claude Code também suporta importações dentro do `CLAUDE.md`, então você pode adicionar uma única linha como `@AGENTS.md` para incluir as instruções compartilhadas sem duplicá-las.
Você pode simplesmente usar:
```bash
mv AGENTS.md CLAUDE.md
```
### Gemini CLI e Google Antigravity
O Gemini CLI e o Antigravity carregam um arquivo de contexto do projeto (padrão: `GEMINI.md`) da raiz do repositório e diretórios pais. Você pode configurá-lo para ler o `AGENTS.md` em vez disso (ou além) definindo `context.fileName` nas configurações do Gemini CLI. Por exemplo, defina apenas para `AGENTS.md`, ou inclua tanto `AGENTS.md` quanto `GEMINI.md` se quiser manter o formato de cada ferramenta.
Você pode simplesmente usar:
```bash
mv AGENTS.md GEMINI.md
```
### Cursor
O Cursor suporta `AGENTS.md` como arquivo de instruções do projeto. Coloque-o na raiz do projeto para fornecer orientação ao assistente de codificação do Cursor.
### Windsurf
O Claude Code fornece uma integração oficial com o Windsurf. Se você usa o Claude Code dentro do Windsurf, siga a orientação do Claude Code acima e importe o `AGENTS.md` a partir do `CLAUDE.md`.
Se você está usando o assistente nativo do Windsurf, configure o recurso de regras ou instruções do projeto (se disponível) para ler o `AGENTS.md` ou cole o conteúdo diretamente.

View File

@@ -0,0 +1,244 @@
---
title: Publicar Ferramentas Personalizadas
description: Como construir, empacotar e publicar suas próprias ferramentas compatíveis com CrewAI no PyPI para que qualquer usuário do CrewAI possa instalá-las e usá-las.
icon: box-open
mode: "wide"
---
## Visão Geral
O sistema de ferramentas do CrewAI foi projetado para ser extensível. Se você construiu uma ferramenta que pode beneficiar outros, pode empacotá-la como uma biblioteca Python independente, publicá-la no PyPI e disponibilizá-la para qualquer usuário do CrewAI — sem necessidade de PR para o repositório do CrewAI.
Este guia percorre todo o processo: implementação do contrato de ferramentas, estruturação do pacote e publicação no PyPI.
<Note type="info" title="Não pretende publicar?">
Se você precisa apenas de uma ferramenta personalizada para seu próprio projeto, consulte o guia [Criar Ferramentas Personalizadas](/pt-BR/learn/create-custom-tools).
</Note>
## O Contrato de Ferramentas
Toda ferramenta CrewAI deve satisfazer uma das duas interfaces:
### Opção 1: Subclassificar `BaseTool`
Subclassifique `crewai.tools.BaseTool` e implemente o método `_run`. Defina `name`, `description` e, opcionalmente, um `args_schema` para validação de entrada.
```python
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
class GeolocateInput(BaseModel):
"""Esquema de entrada para GeolocateTool."""
address: str = Field(..., description="O endereço para geolocalizar.")
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converte um endereço em coordenadas de latitude/longitude."
args_schema: type[BaseModel] = GeolocateInput
def _run(self, address: str) -> str:
# Sua implementação aqui
return f"40.7128, -74.0060"
```
### Opção 2: Usar o Decorador `@tool`
Para ferramentas mais simples, o decorador `@tool` transforma uma função em uma ferramenta CrewAI. A função **deve** ter uma docstring (usada como descrição da ferramenta) e anotações de tipo.
```python
from crewai.tools import tool
@tool("Geolocate")
def geolocate(address: str) -> str:
"""Converte um endereço em coordenadas de latitude/longitude."""
return "40.7128, -74.0060"
```
### Requisitos Essenciais
Independentemente da abordagem escolhida, sua ferramenta deve:
- Ter um **`name`** — um identificador curto e descritivo.
- Ter uma **`description`** — informa ao agente quando e como usar a ferramenta. Isso afeta diretamente a qualidade do uso da ferramenta pelo agente, então seja claro e específico.
- Implementar **`_run`** (BaseTool) ou fornecer um **corpo de função** (@tool) — a lógica de execução síncrona.
- Usar **anotações de tipo** em todos os parâmetros e valores de retorno.
- Retornar um resultado em **string** (ou algo que possa ser convertido de forma significativa).
### Opcional: Suporte Assíncrono
Se sua ferramenta realiza operações de I/O, implemente `_arun` para execução assíncrona:
```python
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converte um endereço em coordenadas de latitude/longitude."
def _run(self, address: str) -> str:
# Implementação síncrona
...
async def _arun(self, address: str) -> str:
# Implementação assíncrona
...
```
### Opcional: Validação de Entrada com `args_schema`
Defina um modelo Pydantic como seu `args_schema` para obter validação automática de entrada e mensagens de erro claras. Se não fornecer um, o CrewAI irá inferi-lo da assinatura do seu método `_run`.
```python
from pydantic import BaseModel, Field
class TranslateInput(BaseModel):
"""Esquema de entrada para TranslateTool."""
text: str = Field(..., description="O texto a ser traduzido.")
target_language: str = Field(
default="en",
description="Código de idioma ISO 639-1 para o idioma de destino.",
)
```
Esquemas explícitos são recomendados para ferramentas publicadas — produzem melhor comportamento do agente e documentação mais clara para seus usuários.
### Opcional: Variáveis de Ambiente
Se sua ferramenta requer chaves de API ou outra configuração, declare-as com `env_vars` para que os usuários saibam o que configurar:
```python
from crewai.tools import BaseTool, EnvVar
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converte um endereço em coordenadas de latitude/longitude."
env_vars: list[EnvVar] = [
EnvVar(
name="GEOCODING_API_KEY",
description="Chave de API para o serviço de geocodificação.",
required=True,
),
]
def _run(self, address: str) -> str:
...
```
## Estrutura do Pacote
Estruture seu projeto como um pacote Python padrão. Layout recomendado:
```
crewai-geolocate/
├── pyproject.toml
├── LICENSE
├── README.md
└── src/
└── crewai_geolocate/
├── __init__.py
└── tools.py
```
### `pyproject.toml`
```toml
[project]
name = "crewai-geolocate"
version = "0.1.0"
description = "Uma ferramenta CrewAI para geolocalizar endereços."
requires-python = ">=3.10"
dependencies = [
"crewai",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
```
Declare `crewai` como dependência para que os usuários obtenham automaticamente uma versão compatível.
### `__init__.py`
Re-exporte suas classes de ferramenta para que os usuários possam importá-las diretamente:
```python
from crewai_geolocate.tools import GeolocateTool
__all__ = ["GeolocateTool"]
```
### Convenções de Nomenclatura
- **Nome do pacote**: Use o prefixo `crewai-` (ex.: `crewai-geolocate`). Isso torna sua ferramenta fácil de encontrar no PyPI.
- **Nome do módulo**: Use underscores (ex.: `crewai_geolocate`).
- **Nome da classe da ferramenta**: Use PascalCase terminando em `Tool` (ex.: `GeolocateTool`).
## Testando sua Ferramenta
Antes de publicar, verifique se sua ferramenta funciona dentro de uma crew:
```python
from crewai import Agent, Crew, Task
from crewai_geolocate import GeolocateTool
agent = Agent(
role="Analista de Localização",
goal="Encontrar coordenadas para os endereços fornecidos.",
backstory="Um especialista em dados geoespaciais.",
tools=[GeolocateTool()],
)
task = Task(
description="Encontre as coordenadas de 1600 Pennsylvania Avenue, Washington, DC.",
expected_output="A latitude e longitude do endereço.",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task])
result = crew.kickoff()
print(result)
```
## Publicando no PyPI
Quando sua ferramenta estiver testada e pronta:
```bash
# Construir o pacote
uv build
# Publicar no PyPI
uv publish
```
Se é sua primeira vez publicando, você precisará de uma [conta no PyPI](https://pypi.org/account/register/) e um [token de API](https://pypi.org/help/#apitoken).
### Após a Publicação
Os usuários podem instalar sua ferramenta com:
```bash
pip install crewai-geolocate
```
Ou com uv:
```bash
uv add crewai-geolocate
```
E então usá-la em suas crews:
```python
from crewai_geolocate import GeolocateTool
agent = Agent(
role="Analista de Localização",
tools=[GeolocateTool()],
# ...
)
```

View File

@@ -11,6 +11,10 @@ Este guia traz instruções detalhadas sobre como criar ferramentas personalizad
incorporando funcionalidades recentes, como delegação de ferramentas, tratamento de erros e chamada dinâmica de ferramentas. Destaca também a importância de ferramentas de colaboração,
permitindo que agentes executem uma ampla gama de ações.
<Tip>
**Quer publicar sua ferramenta para a comunidade?** Se você está construindo uma ferramenta que pode beneficiar outros, confira o guia [Publicar Ferramentas Personalizadas](/pt-BR/guides/tools/publish-custom-tools) para aprender como empacotar e distribuir sua ferramenta no PyPI.
</Tip>
### Subclassificando `BaseTool`
Para criar uma ferramenta personalizada, herde de `BaseTool` e defina os atributos necessários, incluindo o `args_schema` para validação de entrada e o método `_run`.

View File

@@ -1,4 +1,5 @@
import os
from pathlib import Path
from typing import Any
from crewai.tools import BaseTool
@@ -30,27 +31,39 @@ class FileWriterTool(BaseTool):
def _run(self, **kwargs: Any) -> str:
try:
directory = kwargs.get("directory") or "./"
filename = kwargs["filename"]
filepath = os.path.join(directory, filename)
# Prevent path traversal: the resolved path must be strictly inside
# the resolved directory. This blocks ../sequences, absolute paths in
# filename, and symlink escapes regardless of how directory is set.
# is_relative_to() does a proper path-component comparison that is
# safe on case-insensitive filesystems and avoids the "// " edge case
# that plagues startswith(real_directory + os.sep).
# We also reject the case where filepath resolves to the directory
# itself, since that is not a valid file target.
real_directory = Path(directory).resolve()
real_filepath = Path(filepath).resolve()
if not real_filepath.is_relative_to(real_directory) or real_filepath == real_directory:
return "Error: Invalid file path — the filename must not escape the target directory."
if kwargs.get("directory"):
os.makedirs(kwargs["directory"], exist_ok=True)
os.makedirs(real_directory, exist_ok=True)
# Construct the full path
filepath = os.path.join(kwargs.get("directory") or "", kwargs["filename"])
# Convert overwrite to boolean
kwargs["overwrite"] = strtobool(kwargs["overwrite"])
# Check if file exists and overwrite is not allowed
if os.path.exists(filepath) and not kwargs["overwrite"]:
return f"File {filepath} already exists and overwrite option was not passed."
if os.path.exists(real_filepath) and not kwargs["overwrite"]:
return f"File {real_filepath} already exists and overwrite option was not passed."
# Write content to the file
mode = "w" if kwargs["overwrite"] else "x"
with open(filepath, mode) as file:
with open(real_filepath, mode) as file:
file.write(kwargs["content"])
return f"Content successfully written to {filepath}"
return f"Content successfully written to {real_filepath}"
except FileExistsError:
return (
f"File {filepath} already exists and overwrite option was not passed."
f"File {real_filepath} already exists and overwrite option was not passed."
)
except KeyError as e:
return f"An error occurred while accessing key: {e!s}"

View File

@@ -135,3 +135,59 @@ def test_file_exists_error_handling(tool, temp_env, overwrite):
assert "already exists and overwrite option was not passed" in result
assert read_file(path) == "Pre-existing content"
# --- Path traversal prevention ---
def test_blocks_traversal_in_filename(tool, temp_env):
# Create a sibling "outside" directory so we can assert nothing was written there.
outside_dir = tempfile.mkdtemp()
outside_file = os.path.join(outside_dir, "outside.txt")
try:
result = tool._run(
filename=f"../{os.path.basename(outside_dir)}/outside.txt",
directory=temp_env["temp_dir"],
content="should not be written",
overwrite=True,
)
assert "Error" in result
assert not os.path.exists(outside_file)
finally:
shutil.rmtree(outside_dir, ignore_errors=True)
def test_blocks_absolute_path_in_filename(tool, temp_env):
# Use a temp file outside temp_dir as the absolute target so we don't
# depend on /etc/passwd existing or being writable on the host.
outside_dir = tempfile.mkdtemp()
outside_file = os.path.join(outside_dir, "target.txt")
try:
result = tool._run(
filename=outside_file,
directory=temp_env["temp_dir"],
content="should not be written",
overwrite=True,
)
assert "Error" in result
assert not os.path.exists(outside_file)
finally:
shutil.rmtree(outside_dir, ignore_errors=True)
def test_blocks_symlink_escape(tool, temp_env):
# Symlink inside temp_dir pointing to a separate temp "outside" directory.
outside_dir = tempfile.mkdtemp()
outside_file = os.path.join(outside_dir, "target.txt")
link = os.path.join(temp_env["temp_dir"], "escape")
os.symlink(outside_dir, link)
try:
result = tool._run(
filename="escape/target.txt",
directory=temp_env["temp_dir"],
content="should not be written",
overwrite=True,
)
assert "Error" in result
assert not os.path.exists(outside_file)
finally:
shutil.rmtree(outside_dir, ignore_errors=True)

View File

@@ -66,6 +66,7 @@ from crewai.mcp.tool_resolver import MCPToolResolver
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.fingerprint import Fingerprint
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.types.callback import SerializableCallable
from crewai.utilities.agent_utils import (
get_tool_names,
is_inside_event_loop,
@@ -75,6 +76,7 @@ from crewai.utilities.agent_utils import (
)
from crewai.utilities.constants import TRAINED_AGENTS_DATA_FILE, TRAINING_DATA_FILE
from crewai.utilities.converter import Converter, ConverterError
from crewai.utilities.env import get_env_context
from crewai.utilities.guardrail import process_guardrail
from crewai.utilities.guardrail_types import GuardrailType
from crewai.utilities.llm_utils import create_llm
@@ -142,7 +144,7 @@ class Agent(BaseAgent):
default=None,
description="Maximum execution time for an agent to execute a task",
)
step_callback: Any | None = Field(
step_callback: SerializableCallable | None = Field(
default=None,
description="Callback to be executed after each step of the agent execution.",
)
@@ -150,10 +152,10 @@ class Agent(BaseAgent):
default=True,
description="Use system prompt for the agent.",
)
llm: str | InstanceOf[BaseLLM] | Any = Field(
llm: str | InstanceOf[BaseLLM] | None = Field(
description="Language model that will run the agent.", default=None
)
function_calling_llm: str | InstanceOf[BaseLLM] | Any | None = Field(
function_calling_llm: str | InstanceOf[BaseLLM] | None = Field(
description="Language model that will run the agent.", default=None
)
system_template: str | None = Field(
@@ -339,7 +341,7 @@ class Agent(BaseAgent):
return (
hasattr(self.llm, "supports_function_calling")
and callable(getattr(self.llm, "supports_function_calling", None))
and self.llm.supports_function_calling()
and self.llm.supports_function_calling() # type: ignore[union-attr]
and len(tools) > 0
)
@@ -364,6 +366,7 @@ class Agent(BaseAgent):
ValueError: If the max execution time is not a positive integer.
RuntimeError: If the agent execution fails for other reasons.
"""
get_env_context()
# Only call handle_reasoning for legacy CrewAgentExecutor
# For AgentExecutor, planning is handled in AgentExecutor.generate_plan()
if self.executor_class is not AgentExecutor:

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
from copy import copy as shallow_copy
from hashlib import md5
import re
@@ -12,6 +11,7 @@ from pydantic import (
UUID4,
BaseModel,
Field,
InstanceOf,
PrivateAttr,
field_validator,
model_validator,
@@ -26,10 +26,14 @@ from crewai.agents.tools_handler import ToolsHandler
from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.knowledge_config import KnowledgeConfig
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.mcp.config import MCPServerConfig
from crewai.memory.memory_scope import MemoryScope, MemorySlice
from crewai.memory.unified_memory import Memory
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.security_config import SecurityConfig
from crewai.tools.base_tool import BaseTool, Tool
from crewai.types.callback import SerializableCallable
from crewai.utilities.config import process_config
from crewai.utilities.i18n import I18N, get_i18n
from crewai.utilities.logger import Logger
@@ -179,7 +183,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
default=None,
description="Knowledge sources for the agent.",
)
knowledge_storage: Any | None = Field(
knowledge_storage: InstanceOf[BaseKnowledgeStorage] | None = Field(
default=None,
description="Custom knowledge storage for the agent.",
)
@@ -187,7 +191,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
default_factory=SecurityConfig,
description="Security configuration for the agent, including fingerprinting.",
)
callbacks: list[Callable[[Any], Any]] = Field(
callbacks: list[SerializableCallable] = Field(
default_factory=list, description="Callbacks to be used for the agent"
)
adapted_agent: bool = Field(
@@ -205,7 +209,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
default=None,
description="List of MCP server references. Supports 'https://server.com/path' for external servers and bare slugs like 'notion' for connected MCP integrations. Use '#tool_name' suffix for specific tools.",
)
memory: Any = Field(
memory: bool | Memory | MemoryScope | MemorySlice | None = Field(
default=None,
description=(
"Enable agent memory. Pass True for default Memory(), "

View File

@@ -35,6 +35,7 @@ from typing_extensions import Self
if TYPE_CHECKING:
from crewai_files import FileInput
from opentelemetry.trace import Span
try:
from crewai_files import get_supported_content_types
@@ -83,6 +84,8 @@ from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM
from crewai.memory.memory_scope import MemoryScope, MemorySlice
from crewai.memory.unified_memory import Memory
from crewai.process import Process
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.rag.types import SearchResult
@@ -94,10 +97,12 @@ from crewai.tasks.task_output import TaskOutput
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.tools.agent_tools.read_file_tool import ReadFileTool
from crewai.tools.base_tool import BaseTool
from crewai.types.callback import SerializableCallable
from crewai.types.streaming import CrewStreamingOutput
from crewai.types.usage_metrics import UsageMetrics
from crewai.utilities.constants import NOT_SPECIFIED, TRAINING_DATA_FILE
from crewai.utilities.crew.models import CrewContext
from crewai.utilities.env import get_env_context
from crewai.utilities.evaluators.crew_evaluator_handler import CrewEvaluator
from crewai.utilities.evaluators.task_evaluator import TaskEvaluator
from crewai.utilities.file_handler import FileHandler
@@ -165,12 +170,12 @@ class Crew(FlowTrackable, BaseModel):
"""
__hash__ = object.__hash__
_execution_span: Any = PrivateAttr()
_execution_span: Span | None = PrivateAttr()
_rpm_controller: RPMController = PrivateAttr()
_logger: Logger = PrivateAttr()
_file_handler: FileHandler = PrivateAttr()
_cache_handler: InstanceOf[CacheHandler] = PrivateAttr(default_factory=CacheHandler)
_memory: Any = PrivateAttr(default=None) # Unified Memory | MemoryScope
_memory: Memory | MemoryScope | MemorySlice | None = PrivateAttr(default=None)
_train: bool | None = PrivateAttr(default=False)
_train_iteration: int | None = PrivateAttr()
_inputs: dict[str, Any] | None = PrivateAttr(default=None)
@@ -188,7 +193,7 @@ class Crew(FlowTrackable, BaseModel):
agents: list[BaseAgent] = Field(default_factory=list)
process: Process = Field(default=Process.sequential)
verbose: bool = Field(default=False)
memory: bool | Any = Field(
memory: bool | Memory | MemoryScope | MemorySlice | None = Field(
default=False,
description=(
"Enable crew memory. Pass True for default Memory(), "
@@ -203,36 +208,34 @@ class Crew(FlowTrackable, BaseModel):
default=None,
description="Metrics for the LLM usage during all tasks execution.",
)
manager_llm: str | InstanceOf[BaseLLM] | Any | None = Field(
manager_llm: str | InstanceOf[BaseLLM] | None = Field(
description="Language model that will run the agent.", default=None
)
manager_agent: BaseAgent | None = Field(
description="Custom agent that will be used as manager.", default=None
)
function_calling_llm: str | InstanceOf[LLM] | Any | None = Field(
function_calling_llm: str | InstanceOf[LLM] | None = Field(
description="Language model that will run the agent.", default=None
)
config: Json[dict[str, Any]] | dict[str, Any] | None = Field(default=None)
id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
share_crew: bool | None = Field(default=False)
step_callback: Any | None = Field(
step_callback: SerializableCallable | None = Field(
default=None,
description="Callback to be executed after each step for all agents execution.",
)
task_callback: Any | None = Field(
task_callback: SerializableCallable | None = Field(
default=None,
description="Callback to be executed after each task for all agents execution.",
)
before_kickoff_callbacks: list[
Callable[[dict[str, Any] | None], dict[str, Any] | None]
] = Field(
before_kickoff_callbacks: list[SerializableCallable] = Field(
default_factory=list,
description=(
"List of callbacks to be executed before crew kickoff. "
"It may be used to adjust inputs before the crew is executed."
),
)
after_kickoff_callbacks: list[Callable[[CrewOutput], CrewOutput]] = Field(
after_kickoff_callbacks: list[SerializableCallable] = Field(
default_factory=list,
description=(
"List of callbacks to be executed after crew kickoff. "
@@ -348,7 +351,7 @@ class Crew(FlowTrackable, BaseModel):
self._file_handler = FileHandler(self.output_log_file)
self._rpm_controller = RPMController(max_rpm=self.max_rpm, logger=self._logger)
if self.function_calling_llm and not isinstance(self.function_calling_llm, LLM):
self.function_calling_llm = create_llm(self.function_calling_llm)
self.function_calling_llm = create_llm(self.function_calling_llm) # type: ignore[assignment]
return self
@@ -362,7 +365,7 @@ class Crew(FlowTrackable, BaseModel):
if self.embedder is not None:
from crewai.rag.embeddings.factory import build_embedder
embedder = build_embedder(self.embedder)
embedder = build_embedder(self.embedder) # type: ignore[arg-type]
self._memory = Memory(embedder=embedder)
elif self.memory:
# User passed a Memory / MemoryScope / MemorySlice instance
@@ -679,6 +682,7 @@ class Crew(FlowTrackable, BaseModel):
Returns:
CrewOutput or CrewStreamingOutput if streaming is enabled.
"""
get_env_context()
if self.stream:
enable_agent_streaming(self.agents)
ctx = StreamingContext()

View File

@@ -34,6 +34,12 @@ from crewai.events.types.crew_events import (
CrewTrainFailedEvent,
CrewTrainStartedEvent,
)
from crewai.events.types.env_events import (
CCEnvEvent,
CodexEnvEvent,
CursorEnvEvent,
DefaultEnvEvent,
)
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowFinishedEvent,
@@ -143,6 +149,23 @@ class EventListener(BaseEventListener):
# ----------- CREW EVENTS -----------
def setup_listeners(self, crewai_event_bus: CrewAIEventsBus) -> None:
@crewai_event_bus.on(CCEnvEvent)
def on_cc_env(_: Any, event: CCEnvEvent) -> None:
self._telemetry.env_context_span(event.type)
@crewai_event_bus.on(CodexEnvEvent)
def on_codex_env(_: Any, event: CodexEnvEvent) -> None:
self._telemetry.env_context_span(event.type)
@crewai_event_bus.on(CursorEnvEvent)
def on_cursor_env(_: Any, event: CursorEnvEvent) -> None:
self._telemetry.env_context_span(event.type)
@crewai_event_bus.on(DefaultEnvEvent)
def on_default_env(_: Any, event: DefaultEnvEvent) -> None:
self._telemetry.env_context_span(event.type)
@crewai_event_bus.on(CrewKickoffStartedEvent)
def on_crew_started(source: Any, event: CrewKickoffStartedEvent) -> None:
self.formatter.handle_crew_started(event.crew_name or "Crew", source.id)

View File

@@ -58,6 +58,12 @@ from crewai.events.types.crew_events import (
CrewKickoffFailedEvent,
CrewKickoffStartedEvent,
)
from crewai.events.types.env_events import (
CCEnvEvent,
CodexEnvEvent,
CursorEnvEvent,
DefaultEnvEvent,
)
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowFinishedEvent,
@@ -192,6 +198,7 @@ class TraceCollectionListener(BaseEventListener):
if self._listeners_setup:
return
self._register_env_event_handlers(crewai_event_bus)
self._register_flow_event_handlers(crewai_event_bus)
self._register_context_event_handlers(crewai_event_bus)
self._register_action_event_handlers(crewai_event_bus)
@@ -200,6 +207,25 @@ class TraceCollectionListener(BaseEventListener):
self._listeners_setup = True
def _register_env_event_handlers(self, event_bus: CrewAIEventsBus) -> None:
"""Register handlers for environment context events."""
@event_bus.on(CCEnvEvent)
def on_cc_env(source: Any, event: CCEnvEvent) -> None:
self._handle_action_event("cc_env", source, event)
@event_bus.on(CodexEnvEvent)
def on_codex_env(source: Any, event: CodexEnvEvent) -> None:
self._handle_action_event("codex_env", source, event)
@event_bus.on(CursorEnvEvent)
def on_cursor_env(source: Any, event: CursorEnvEvent) -> None:
self._handle_action_event("cursor_env", source, event)
@event_bus.on(DefaultEnvEvent)
def on_default_env(source: Any, event: DefaultEnvEvent) -> None:
self._handle_action_event("default_env", source, event)
def _register_flow_event_handlers(self, event_bus: CrewAIEventsBus) -> None:
"""Register handlers for flow events."""

View File

@@ -0,0 +1,36 @@
from typing import Annotated, Literal
from pydantic import Field, TypeAdapter
from crewai.events.base_events import BaseEvent
class CCEnvEvent(BaseEvent):
type: Literal["cc_env"] = "cc_env"
class CodexEnvEvent(BaseEvent):
type: Literal["codex_env"] = "codex_env"
class CursorEnvEvent(BaseEvent):
type: Literal["cursor_env"] = "cursor_env"
class DefaultEnvEvent(BaseEvent):
type: Literal["default_env"] = "default_env"
EnvContextEvent = Annotated[
CCEnvEvent | CodexEnvEvent | CursorEnvEvent | DefaultEnvEvent,
Field(discriminator="type"),
]
env_context_event_adapter: TypeAdapter[EnvContextEvent] = TypeAdapter(EnvContextEvent)
ENV_CONTEXT_EVENT_TYPES: tuple[type[BaseEvent], ...] = (
CCEnvEvent,
CodexEnvEvent,
CursorEnvEvent,
DefaultEnvEvent,
)

View File

@@ -81,6 +81,7 @@ from crewai.flow.flow_wrappers import (
SimpleFlowCondition,
StartMethod,
)
from crewai.flow.input_provider import InputProvider
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.types import (
FlowExecutionData,
@@ -99,6 +100,8 @@ from crewai.flow.utils import (
is_flow_method_name,
is_simple_flow_condition,
)
from crewai.memory.memory_scope import MemoryScope, MemorySlice
from crewai.memory.unified_memory import Memory
if TYPE_CHECKING:
@@ -110,6 +113,7 @@ if TYPE_CHECKING:
from crewai.flow.visualization import build_flow_structure, render_interactive
from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput
from crewai.utilities.env import get_env_context
from crewai.utilities.streaming import (
TaskInfo,
create_async_chunk_generator,
@@ -500,7 +504,7 @@ class LockedListProxy(list, Generic[T]): # type: ignore[type-arg]
def index(
self, value: T, start: SupportsIndex = 0, stop: SupportsIndex | None = None
) -> int: # type: ignore[override]
) -> int:
if stop is None:
return self._list.index(value, start)
return self._list.index(value, start, stop)
@@ -519,13 +523,13 @@ class LockedListProxy(list, Generic[T]): # type: ignore[type-arg]
def copy(self) -> list[T]:
return self._list.copy()
def __add__(self, other: list[T]) -> list[T]:
def __add__(self, other: list[T]) -> list[T]: # type: ignore[override]
return self._list + other
def __radd__(self, other: list[T]) -> list[T]:
return other + self._list
def __iadd__(self, other: Iterable[T]) -> LockedListProxy[T]:
def __iadd__(self, other: Iterable[T]) -> LockedListProxy[T]: # type: ignore[override]
with self._lock:
self._list += list(other)
return self
@@ -629,13 +633,13 @@ class LockedDictProxy(dict, Generic[T]): # type: ignore[type-arg]
def copy(self) -> dict[str, T]:
return self._dict.copy()
def __or__(self, other: dict[str, T]) -> dict[str, T]:
def __or__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override]
return self._dict | other
def __ror__(self, other: dict[str, T]) -> dict[str, T]:
def __ror__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override]
return other | self._dict
def __ior__(self, other: dict[str, T]) -> LockedDictProxy[T]:
def __ior__(self, other: dict[str, T]) -> LockedDictProxy[T]: # type: ignore[override]
with self._lock:
self._dict |= other
return self
@@ -821,10 +825,8 @@ class Flow(Generic[T], metaclass=FlowMeta):
name: str | None = None
tracing: bool | None = None
stream: bool = False
memory: Any = (
None # Memory | MemoryScope | MemorySlice | None; auto-created if not set
)
input_provider: Any = None # InputProvider | None; per-flow override for self.ask()
memory: Memory | MemoryScope | MemorySlice | None = None
input_provider: InputProvider | None = None
def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]:
class _FlowGeneric(cls): # type: ignore
@@ -903,8 +905,6 @@ class Flow(Generic[T], metaclass=FlowMeta):
# Internal flows (RecallFlow, EncodingFlow) set _skip_auto_memory
# to avoid creating a wasteful standalone Memory instance.
if self.memory is None and not getattr(self, "_skip_auto_memory", False):
from crewai.memory.unified_memory import Memory
self.memory = Memory()
# Register all flow-related methods
@@ -950,10 +950,16 @@ class Flow(Generic[T], metaclass=FlowMeta):
Raises:
ValueError: If no memory is configured for this flow.
TypeError: If batch remember is attempted on a MemoryScope or MemorySlice.
"""
if self.memory is None:
raise ValueError("No memory configured for this flow")
if isinstance(content, list):
if not isinstance(self.memory, Memory):
raise TypeError(
"Batch remember requires a Memory instance, "
f"got {type(self.memory).__name__}"
)
return self.memory.remember_many(content, **kwargs)
return self.memory.remember(content, **kwargs)
@@ -1770,6 +1776,7 @@ class Flow(Generic[T], metaclass=FlowMeta):
Returns:
The final output from the flow or FlowStreamingOutput if streaming.
"""
get_env_context()
if self.stream:
result_holder: list[Any] = []
current_task_info: TaskInfo = {
@@ -2723,7 +2730,7 @@ class Flow(Generic[T], metaclass=FlowMeta):
# ── User Input (self.ask) ────────────────────────────────────────
def _resolve_input_provider(self) -> Any:
def _resolve_input_provider(self) -> InputProvider:
"""Resolve the input provider using the priority chain.
Resolution order:

View File

@@ -6,7 +6,7 @@ customize Flow behavior at runtime.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -32,17 +32,17 @@ class FlowConfig:
self._input_provider: InputProvider | None = None
@property
def hitl_provider(self) -> Any:
def hitl_provider(self) -> HumanFeedbackProvider | None:
"""Get the configured HITL provider."""
return self._hitl_provider
@hitl_provider.setter
def hitl_provider(self, provider: Any) -> None:
def hitl_provider(self, provider: HumanFeedbackProvider | None) -> None:
"""Set the HITL provider."""
self._hitl_provider = provider
@property
def input_provider(self) -> Any:
def input_provider(self) -> InputProvider | None:
"""Get the configured input provider for ``Flow.ask()``.
Returns:
@@ -52,7 +52,7 @@ class FlowConfig:
return self._input_provider
@input_provider.setter
def input_provider(self, provider: Any) -> None:
def input_provider(self, provider: InputProvider | None) -> None:
"""Set the input provider for ``Flow.ask()``.
Args:

View File

@@ -22,7 +22,6 @@ from crewai.events.types.memory_events import (
)
from crewai.llms.base_llm import BaseLLM
from crewai.memory.analyze import extract_memories_from_content
from crewai.memory.recall_flow import RecallFlow
from crewai.memory.storage.backend import StorageBackend
from crewai.memory.types import (
MemoryConfig,
@@ -620,6 +619,8 @@ class Memory(BaseModel):
)
results.sort(key=lambda m: m.score, reverse=True)
else:
from crewai.memory.recall_flow import RecallFlow
flow = RecallFlow(
storage=self._storage,
llm=self._llm,

View File

@@ -67,6 +67,7 @@ except ImportError:
return []
from crewai.types.callback import SerializableCallable
from crewai.utilities.guardrail import (
process_guardrail,
)
@@ -124,7 +125,7 @@ class Task(BaseModel):
description="Configuration for the agent",
default=None,
)
callback: Any | None = Field(
callback: SerializableCallable | None = Field(
description="Callback to be executed after the task is completed.", default=None
)
agent: BaseAgent | None = Field(

View File

@@ -986,6 +986,22 @@ class Telemetry:
self._safe_telemetry_operation(_operation)
def env_context_span(self, tool: str) -> None:
"""Records the coding tool environment context."""
def _operation() -> None:
tracer = trace.get_tracer("crewai.telemetry")
span = tracer.start_span("Environment Context")
self._add_attribute(
span,
"crewai_version",
version("crewai"),
)
self._add_attribute(span, "tool", tool)
close_span(span)
self._safe_telemetry_operation(_operation)
def human_feedback_span(
self,
event_type: str,

View File

@@ -0,0 +1,152 @@
"""Serializable callback type for Pydantic models.
Provides a ``SerializableCallable`` type alias that enables full JSON
round-tripping of callback fields, e.g. ``"builtins.print"`` ↔ ``print``.
Lambdas and closures serialize to a dotted path but cannot be deserialized
back — use module-level named functions for checkpointable callbacks.
"""
from __future__ import annotations
from collections.abc import Callable
import importlib
import inspect
import os
from typing import Annotated, Any
import warnings
from pydantic import BeforeValidator, WithJsonSchema
from pydantic.functional_serializers import PlainSerializer
def _is_non_roundtrippable(fn: object) -> bool:
"""Return ``True`` if *fn* cannot survive a serialize/deserialize round-trip.
Built-in functions, plain module-level functions, and classes produce
dotted paths that :func:`_resolve_dotted_path` can reliably resolve.
Bound methods, ``functools.partial`` objects, callable class instances,
lambdas, and closures all fail or silently change semantics during
round-tripping.
Args:
fn: The object to check.
Returns:
``True`` if *fn* would not round-trip through JSON serialization.
"""
if inspect.isbuiltin(fn) or inspect.isclass(fn):
return False
if inspect.isfunction(fn):
qualname = getattr(fn, "__qualname__", "")
return qualname.endswith("<lambda>") or "<locals>" in qualname
return True
def string_to_callable(value: Any) -> Callable[..., Any]:
"""Convert a dotted path string to the callable it references.
If *value* is already callable it is returned as-is, with a warning if
it cannot survive JSON round-tripping. Otherwise, it is treated as
``"module.qualname"`` and resolved via :func:`_resolve_dotted_path`.
Args:
value: A callable or a dotted-path string e.g. ``"builtins.print"``.
Returns:
The resolved callable.
Raises:
ValueError: If *value* is not callable or a resolvable dotted-path string.
"""
if callable(value):
if _is_non_roundtrippable(value):
warnings.warn(
f"{type(value).__name__} callbacks cannot be serialized "
"and will prevent checkpointing. "
"Use a module-level named function instead.",
UserWarning,
stacklevel=2,
)
return value # type: ignore[no-any-return]
if not isinstance(value, str):
raise ValueError(
f"Expected a callable or dotted-path string, got {type(value).__name__}"
)
if "." not in value:
raise ValueError(
f"Invalid callback path {value!r}: expected 'module.name' format"
)
if not os.environ.get("CREWAI_DESERIALIZE_CALLBACKS"):
raise ValueError(
f"Refusing to resolve callback path {value!r}: "
"set CREWAI_DESERIALIZE_CALLBACKS=1 to allow. "
"Only enable this for trusted checkpoint data."
)
return _resolve_dotted_path(value)
def _resolve_dotted_path(path: str) -> Callable[..., Any]:
"""Import a module and walk attribute lookups to resolve a dotted path.
Handles multi-level qualified names like ``"module.ClassName.method"``
by trying progressively shorter module paths and resolving the remainder
as chained attribute lookups.
Args:
path: A dotted string e.g. ``"builtins.print"`` or
``"mymodule.MyClass.my_method"``.
Returns:
The resolved callable.
Raises:
ValueError: If no valid module can be imported from the path.
"""
parts = path.split(".")
# Try importing progressively shorter prefixes as the module.
for i in range(len(parts), 0, -1):
module_path = ".".join(parts[:i])
try:
obj: Any = importlib.import_module(module_path)
except (ImportError, TypeError, ValueError):
continue
# Walk the remaining attribute chain.
try:
for attr in parts[i:]:
obj = getattr(obj, attr)
except AttributeError:
continue
if callable(obj):
return obj # type: ignore[no-any-return]
raise ValueError(f"Cannot resolve callback {path!r}")
def callable_to_string(fn: Callable[..., Any]) -> str:
"""Serialize a callable to its dotted-path string representation.
Uses ``fn.__module__`` and ``fn.__qualname__`` to produce a string such
as ``"builtins.print"``. Lambdas and closures produce paths that contain
``<locals>`` and cannot be round-tripped via :func:`string_to_callable`.
Args:
fn: The callable to serialize.
Returns:
A dotted string of the form ``"module.qualname"``.
"""
module = getattr(fn, "__module__", None)
qualname = getattr(fn, "__qualname__", None)
if module is None or qualname is None:
raise ValueError(
f"Cannot serialize {fn!r}: missing __module__ or __qualname__. "
"Use a module-level named function for checkpointable callbacks."
)
return f"{module}.{qualname}"
SerializableCallable = Annotated[
Callable[..., Any],
BeforeValidator(string_to_callable),
PlainSerializer(callable_to_string, return_type=str, when_used="json"),
WithJsonSchema({"type": "string"}),
]

View File

@@ -8,6 +8,21 @@ TRAINED_AGENTS_DATA_FILE: Final[str] = "trained_agents_data.pkl"
KNOWLEDGE_DIRECTORY: Final[str] = "knowledge"
MAX_FILE_NAME_LENGTH: Final[int] = 255
EMITTER_COLOR: Final[PrinterColor] = "bold_blue"
CC_ENV_VAR: Final[str] = "CLAUDECODE"
CODEX_ENV_VARS: Final[tuple[str, ...]] = (
"CODEX_CI",
"CODEX_MANAGED_BY_NPM",
"CODEX_SANDBOX",
"CODEX_SANDBOX_NETWORK_DISABLED",
"CODEX_THREAD_ID",
)
CURSOR_ENV_VARS: Final[tuple[str, ...]] = (
"CURSOR_AGENT",
"CURSOR_EXTENSION_HOST_ROLE",
"CURSOR_SANDBOX",
"CURSOR_TRACE_ID",
"CURSOR_WORKSPACE_LABEL",
)
class _NotSpecified:

View File

@@ -0,0 +1,39 @@
import contextvars
import os
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.env_events import (
CCEnvEvent,
CodexEnvEvent,
CursorEnvEvent,
DefaultEnvEvent,
)
from crewai.utilities.constants import CC_ENV_VAR, CODEX_ENV_VARS, CURSOR_ENV_VARS
_env_context_emitted: contextvars.ContextVar[bool] = contextvars.ContextVar(
"_env_context_emitted", default=False
)
def _is_codex_env() -> bool:
return any(os.environ.get(var) for var in CODEX_ENV_VARS)
def _is_cursor_env() -> bool:
return any(os.environ.get(var) for var in CURSOR_ENV_VARS)
def get_env_context() -> None:
if _env_context_emitted.get():
return
_env_context_emitted.set(True)
if os.environ.get(CC_ENV_VAR):
crewai_event_bus.emit(None, CCEnvEvent())
elif _is_codex_env():
crewai_event_bus.emit(None, CodexEnvEvent())
elif _is_cursor_env():
crewai_event_bus.emit(None, CursorEnvEvent())
else:
crewai_event_bus.emit(None, DefaultEnvEvent())

View File

@@ -1690,7 +1690,10 @@ def test_agent_with_knowledge_sources_works_with_copy():
with patch(
"crewai.knowledge.storage.knowledge_storage.KnowledgeStorage"
) as mock_knowledge_storage:
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
mock_knowledge_storage_instance = mock_knowledge_storage.return_value
mock_knowledge_storage_instance.__class__ = BaseKnowledgeStorage
agent.knowledge_storage = mock_knowledge_storage_instance
agent_copy = agent.copy()

View File

@@ -0,0 +1,237 @@
"""Tests for crewai.types.callback — SerializableCallable round-tripping."""
from __future__ import annotations
import functools
import os
from typing import Any
import pytest
from pydantic import BaseModel, ValidationError
from crewai.types.callback import (
SerializableCallable,
_is_non_roundtrippable,
_resolve_dotted_path,
callable_to_string,
string_to_callable,
)
# ── Helpers ──────────────────────────────────────────────────────────
def module_level_function() -> str:
"""Plain module-level function that should round-trip."""
return "hello"
class _CallableInstance:
"""Callable class instance — non-roundtrippable."""
def __call__(self) -> str:
return "instance"
class _HasMethod:
def method(self) -> str:
return "method"
class _Model(BaseModel):
cb: SerializableCallable | None = None
# ── _is_non_roundtrippable ───────────────────────────────────────────
class TestIsNonRoundtrippable:
def test_builtin_is_roundtrippable(self) -> None:
assert _is_non_roundtrippable(print) is False
assert _is_non_roundtrippable(len) is False
def test_class_is_roundtrippable(self) -> None:
assert _is_non_roundtrippable(dict) is False
assert _is_non_roundtrippable(_CallableInstance) is False
def test_module_level_function_is_roundtrippable(self) -> None:
assert _is_non_roundtrippable(module_level_function) is False
def test_lambda_is_non_roundtrippable(self) -> None:
assert _is_non_roundtrippable(lambda: None) is True
def test_closure_is_non_roundtrippable(self) -> None:
x = 1
def closure() -> int:
return x
assert _is_non_roundtrippable(closure) is True
def test_bound_method_is_non_roundtrippable(self) -> None:
assert _is_non_roundtrippable(_HasMethod().method) is True
def test_partial_is_non_roundtrippable(self) -> None:
assert _is_non_roundtrippable(functools.partial(print, "hi")) is True
def test_callable_instance_is_non_roundtrippable(self) -> None:
assert _is_non_roundtrippable(_CallableInstance()) is True
# ── callable_to_string ───────────────────────────────────────────────
class TestCallableToString:
def test_module_level_function(self) -> None:
result = callable_to_string(module_level_function)
assert result == f"{__name__}.module_level_function"
def test_class(self) -> None:
result = callable_to_string(dict)
assert result == "builtins.dict"
def test_builtin(self) -> None:
result = callable_to_string(print)
assert result == "builtins.print"
def test_lambda_produces_locals_path(self) -> None:
fn = lambda: None # noqa: E731
result = callable_to_string(fn)
assert "<lambda>" in result
def test_missing_qualname_raises(self) -> None:
obj = type("NoQual", (), {"__module__": "test"})()
obj.__qualname__ = None # type: ignore[assignment]
with pytest.raises(ValueError, match="missing __module__ or __qualname__"):
callable_to_string(obj)
def test_missing_module_raises(self) -> None:
# Create an object where getattr(obj, "__module__", None) returns None
ns: dict[str, Any] = {"__qualname__": "x", "__module__": None}
obj = type("NoMod", (), ns)()
with pytest.raises(ValueError, match="missing __module__"):
callable_to_string(obj)
# ── string_to_callable ───────────────────────────────────────────────
class TestStringToCallable:
def test_callable_passthrough(self) -> None:
assert string_to_callable(print) is print
def test_roundtrippable_callable_no_warning(self, recwarn: pytest.WarningsChecker) -> None:
string_to_callable(module_level_function)
our_warnings = [
w for w in recwarn if "cannot be serialized" in str(w.message)
]
assert our_warnings == []
def test_non_roundtrippable_warns(self) -> None:
with pytest.warns(UserWarning, match="cannot be serialized"):
string_to_callable(functools.partial(print))
def test_non_callable_non_string_raises(self) -> None:
with pytest.raises(ValueError, match="Expected a callable"):
string_to_callable(42)
def test_string_without_dot_raises(self) -> None:
with pytest.raises(ValueError, match="expected 'module.name' format"):
string_to_callable("nodots")
def test_string_refused_without_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CREWAI_DESERIALIZE_CALLBACKS", raising=False)
with pytest.raises(ValueError, match="Refusing to resolve"):
string_to_callable("builtins.print")
def test_string_resolves_with_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CREWAI_DESERIALIZE_CALLBACKS", "1")
result = string_to_callable("builtins.print")
assert result is print
def test_string_resolves_multi_level_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CREWAI_DESERIALIZE_CALLBACKS", "1")
result = string_to_callable("os.path.join")
assert result is os.path.join
def test_unresolvable_path_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CREWAI_DESERIALIZE_CALLBACKS", "1")
with pytest.raises(ValueError, match="Cannot resolve"):
string_to_callable("nonexistent.module.func")
# ── _resolve_dotted_path ─────────────────────────────────────────────
class TestResolveDottedPath:
def test_builtin(self) -> None:
assert _resolve_dotted_path("builtins.print") is print
def test_nested_module_attribute(self) -> None:
assert _resolve_dotted_path("os.path.join") is os.path.join
def test_class_on_module(self) -> None:
from collections import OrderedDict
assert _resolve_dotted_path("collections.OrderedDict") is OrderedDict
def test_nonexistent_raises(self) -> None:
with pytest.raises(ValueError, match="Cannot resolve"):
_resolve_dotted_path("no.such.module.func")
def test_non_callable_attribute_skipped(self) -> None:
# os.sep is a string, not callable — should not resolve
with pytest.raises(ValueError, match="Cannot resolve"):
_resolve_dotted_path("os.sep")
# ── Pydantic integration round-trip ──────────────────────────────────
class TestSerializableCallableRoundTrip:
def test_json_serialize_module_function(self) -> None:
m = _Model(cb=module_level_function)
data = m.model_dump(mode="json")
assert data["cb"] == f"{__name__}.module_level_function"
def test_json_round_trip(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CREWAI_DESERIALIZE_CALLBACKS", "1")
m = _Model(cb=print)
json_str = m.model_dump_json()
restored = _Model.model_validate_json(json_str)
assert restored.cb is print
def test_json_round_trip_class(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CREWAI_DESERIALIZE_CALLBACKS", "1")
m = _Model(cb=dict)
json_str = m.model_dump_json()
restored = _Model.model_validate_json(json_str)
assert restored.cb is dict
def test_python_mode_preserves_callable(self) -> None:
m = _Model(cb=module_level_function)
data = m.model_dump(mode="python")
assert data["cb"] is module_level_function
def test_none_field(self) -> None:
m = _Model(cb=None)
assert m.cb is None
data = m.model_dump(mode="json")
assert data["cb"] is None
def test_validation_error_for_int(self) -> None:
with pytest.raises(ValidationError):
_Model(cb=42) # type: ignore[arg-type]
def test_deserialization_refused_without_env(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("CREWAI_DESERIALIZE_CALLBACKS", raising=False)
with pytest.raises(ValidationError, match="Refusing to resolve"):
_Model.model_validate({"cb": "builtins.print"})
def test_json_schema_is_string(self) -> None:
schema = _Model.model_json_schema()
cb_schema = schema["properties"]["cb"]
# anyOf for Optional: one string, one null
types = {item.get("type") for item in cb_schema.get("anyOf", [cb_schema])}
assert "string" in types

View File

@@ -6,6 +6,7 @@ from crewai.agent import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crew import Crew
from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM
from crewai.project import (
CrewBase,
after_kickoff,
@@ -371,9 +372,12 @@ def test_internal_crew_with_mcp():
mock_adapter = Mock()
mock_adapter.tools = ToolCollection([simple_tool, another_simple_tool])
mock_llm = Mock()
mock_llm.__class__ = BaseLLM
with (
patch("crewai_tools.MCPServerAdapter", return_value=mock_adapter) as adapter_mock,
patch("crewai.llm.LLM.__new__", return_value=Mock()),
patch("crewai.llm.LLM.__new__", return_value=mock_llm),
):
crew = InternalCrewWithMCP()
assert crew.reporting_analyst().tools == [simple_tool, another_simple_tool]

View File

@@ -5,6 +5,7 @@ from pathlib import Path
import subprocess
import sys
import time
from typing import Final, Literal
import click
from dotenv import load_dotenv
@@ -250,7 +251,9 @@ def add_docs_version(docs_json_path: Path, version: str) -> bool:
return True
_PT_BR_MONTHS = {
ChangelogLang = Literal["en", "pt-BR", "ko"]
_PT_BR_MONTHS: Final[dict[int, str]] = {
1: "jan",
2: "fev",
3: "mar",
@@ -265,7 +268,9 @@ _PT_BR_MONTHS = {
12: "dez",
}
_CHANGELOG_LOCALES: dict[str, dict[str, str]] = {
_CHANGELOG_LOCALES: Final[
dict[ChangelogLang, dict[Literal["link_text", "language_name"], str]]
] = {
"en": {
"link_text": "View release on GitHub",
"language_name": "English",
@@ -283,7 +288,7 @@ _CHANGELOG_LOCALES: dict[str, dict[str, str]] = {
def translate_release_notes(
release_notes: str,
lang: str,
lang: ChangelogLang,
client: OpenAI,
) -> str:
"""Translate release notes into the target language using OpenAI.
@@ -326,7 +331,7 @@ def translate_release_notes(
return release_notes
def _format_changelog_date(lang: str) -> str:
def _format_changelog_date(lang: ChangelogLang) -> str:
"""Format today's date for a changelog entry in the given language."""
from datetime import datetime
@@ -342,7 +347,7 @@ def update_changelog(
changelog_path: Path,
version: str,
release_notes: str,
lang: str = "en",
lang: ChangelogLang = "en",
) -> bool:
"""Prepend a new release entry to a docs changelog file.
@@ -475,6 +480,23 @@ def get_packages(lib_dir: Path) -> list[Path]:
return packages
PrereleaseIndicator = Literal["a", "b", "rc", "alpha", "beta", "dev"]
_PRERELEASE_INDICATORS: Final[tuple[PrereleaseIndicator, ...]] = (
"a",
"b",
"rc",
"alpha",
"beta",
"dev",
)
def _is_prerelease(version: str) -> bool:
"""Check if a version string represents a pre-release."""
v = version.lower().lstrip("v")
return any(indicator in v for indicator in _PRERELEASE_INDICATORS)
def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]:
"""Get commits from the last tag, excluding current version.
@@ -489,6 +511,9 @@ def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]:
all_tags = run_command(["git", "tag", "--sort=-version:refname"]).split("\n")
prev_tags = [t for t in all_tags if t and t != tag_name and t != f"v{version}"]
if not _is_prerelease(version):
prev_tags = [t for t in prev_tags if not _is_prerelease(t)]
if prev_tags:
last_tag = prev_tags[0]
commit_range = f"{last_tag}..HEAD"
@@ -678,20 +703,28 @@ def _generate_release_notes(
with console.status("[cyan]Generating release notes..."):
try:
prev_bump_commit = run_command(
prev_bump_output = run_command(
[
"git",
"log",
"--grep=^feat: bump versions to",
"--format=%H",
"-n",
"2",
"--format=%H %s",
]
)
commits_list = prev_bump_commit.strip().split("\n")
bump_entries = [
line for line in prev_bump_output.strip().split("\n") if line.strip()
]
if len(commits_list) > 1:
prev_commit = commits_list[1]
is_stable = not _is_prerelease(version)
prev_commit = None
for entry in bump_entries[1:]:
bump_ver = entry.split("feat: bump versions to", 1)[-1].strip()
if is_stable and _is_prerelease(bump_ver):
continue
prev_commit = entry.split()[0]
break
if prev_commit:
commit_range = f"{prev_commit}..HEAD"
commits = run_command(
["git", "log", commit_range, "--pretty=format:%s"]
@@ -777,10 +810,7 @@ def _generate_release_notes(
"\n[green]✓[/green] Using generated release notes without editing"
)
is_prerelease = any(
indicator in version.lower()
for indicator in ["a", "b", "rc", "alpha", "beta", "dev"]
)
is_prerelease = _is_prerelease(version)
return release_notes, openai_client, is_prerelease
@@ -799,7 +829,7 @@ def _update_docs_and_create_pr(
The docs branch name if a PR was created, None otherwise.
"""
docs_json_path = cwd / "docs" / "docs.json"
changelog_langs = ["en", "pt-BR", "ko"]
changelog_langs: list[ChangelogLang] = ["en", "pt-BR", "ko"]
if not dry_run:
docs_files_staged: list[str] = []