Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/edge/en/concepts/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ The Enterprise Tools Repository includes:
- **Error Handling**: Incorporates robust error handling mechanisms to ensure smooth operation.
- **Caching Mechanism**: Features intelligent caching to optimize performance and reduce redundant operations.
- **Asynchronous Support**: Handles both synchronous and asynchronous tools, enabling non-blocking operations.
- **Typed Outputs**: Uses optional Pydantic models to give agents clear JSON fields while direct Python calls still receive the tool's normal return value.

## Using CrewAI Tools

Expand Down Expand Up @@ -184,6 +185,55 @@ class MyCustomTool(BaseTool):
return "Tool's result"
```

### Typed Tool Outputs

When a tool returns structured data, define a Pydantic output model. This gives the agent field names it can trust, such as `sku`, `quantity`, or `needs_reorder`.

Direct Python calls still receive the value your tool returns. When an agent uses the tool, CrewAI sends the agent a JSON string based on the output model.

```python Code
from crewai.tools import BaseTool
from pydantic import BaseModel

class InventoryResult(BaseModel):
sku: str
quantity: int
needs_reorder: bool

class InventoryTool(BaseTool):
name: str = "Inventory Check"
description: str = "Checks current stock for a product SKU."

def _run(self, sku: str) -> InventoryResult:
quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)

tool = InventoryTool()

# Direct calls receive the raw Pydantic object.
result = tool.run(sku="SKU-123")
print(result.quantity)
```

To send Markdown or another short text format to the agent, override `format_output_for_agent`. Direct calls to `tool.run(...)` still return the normal Python value.

```python Code
class InventoryTool(BaseTool):
name: str = "Inventory Check"
description: str = "Checks current stock for a product SKU."

def _run(self, sku: str) -> InventoryResult:
quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)

def format_output_for_agent(self, raw_result: object) -> str:
result = InventoryResult.model_validate(raw_result)
status = "reorder needed" if result.needs_reorder else "stock is healthy"
return f"{result.sku}: {result.quantity} units. {status}."
```

If you do not override `format_output_for_agent`, typed outputs are sent to the agent as JSON. Plain string results work as before.

## Asynchronous Tool Support

CrewAI supports asynchronous tools, allowing you to implement tools that perform non-blocking operations like network requests, file I/O, or other async operations without blocking the main execution thread.
Expand Down
65 changes: 63 additions & 2 deletions docs/edge/en/guides/tools/publish-custom-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Regardless of which approach you use, your tool must:
- 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).
- Return a **string** result, or define an optional Pydantic output schema for structured results.

### Optional: Async Support

Expand Down Expand Up @@ -104,6 +104,67 @@ class TranslateInput(BaseModel):

Explicit schemas are recommended for published tools — they produce better agent behavior and clearer documentation for your users.

### Optional: Typed Outputs with `result_schema`

If your tool returns structured data, define a Pydantic output model. This is a good default for published tools because users and agents can rely on named fields.

Direct Python calls still receive the value your tool returns. When an agent uses the tool, CrewAI sends the agent JSON based on the output model.

CrewAI can infer the output schema from a Pydantic return annotation:

```python
from crewai.tools import BaseTool
from pydantic import BaseModel, Field


class GeolocateResult(BaseModel):
latitude: float = Field(..., description="Latitude in decimal degrees.")
longitude: float = Field(..., description="Longitude in decimal degrees.")


class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converts a street address into latitude/longitude coordinates."

def _run(self, address: str) -> GeolocateResult:
if "1600 Pennsylvania" in address:
return GeolocateResult(latitude=38.8977, longitude=-77.0365)
return GeolocateResult(latitude=40.7128, longitude=-74.0060)
```

Set `result_schema` explicitly when your tool returns a dictionary:

```python
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converts a street address into latitude/longitude coordinates."
result_schema: type[BaseModel] = GeolocateResult

def _run(self, address: str) -> dict[str, float]:
if "1600 Pennsylvania" in address:
return {"latitude": 38.8977, "longitude": -77.0365}
return {"latitude": 40.7128, "longitude": -74.0060}
```

If agents should receive a short text summary instead of JSON, override `format_output_for_agent` on your `BaseTool` subclass.

```python
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converts a street address into latitude/longitude coordinates."

def _run(self, address: str) -> GeolocateResult:
if "1600 Pennsylvania" in address:
return GeolocateResult(latitude=38.8977, longitude=-77.0365)
return GeolocateResult(latitude=40.7128, longitude=-74.0060)

def format_output_for_agent(self, raw_result: object) -> str:
result = GeolocateResult.model_validate(raw_result)
return f"Latitude {result.latitude}, longitude {result.longitude}"
```

The override only changes what the agent sees. Direct users of your package still receive the normal value from `tool.run(...)`.

### Optional: Environment Variables

If your tool requires API keys or other configuration, declare them with `env_vars` so users know what to set:
Expand Down Expand Up @@ -241,4 +302,4 @@ agent = Agent(
tools=[GeolocateTool()],
# ...
)
```
```
105 changes: 105 additions & 0 deletions docs/edge/en/learn/create-custom-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,111 @@ def my_simple_tool(question: str) -> str:
return "Tool output"
```

### Best Practice: Define Typed Outputs

When a tool returns structured data, define a Pydantic output model. This helps the agent read the result as clear fields instead of guessing from plain text.

Typed outputs are useful for results with stable fields, such as IDs, status values, scores, prices, or lists. Plain strings are still fine for short prose results.

Direct Python calls still receive the value your tool returns. When an agent uses a typed tool, CrewAI sends the agent JSON based on the output model.

#### Return a Pydantic Model

CrewAI infers the output schema when your `BaseTool` has a Pydantic return annotation.

```python Code
from crewai.tools import BaseTool
from pydantic import BaseModel, Field

class InventoryResult(BaseModel):
sku: str = Field(description="The product SKU.")
quantity: int = Field(description="Units available.")
needs_reorder: bool = Field(description="Whether the item should be reordered.")

class InventoryTool(BaseTool):
name: str = "Inventory Check"
description: str = "Check current stock for a product SKU."

def _run(self, sku: str) -> InventoryResult:
quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)

tool = InventoryTool()
result = tool.run(sku="SKU-123")

# Direct Python calls receive the raw Pydantic object.
print(result.quantity)
```

When an agent calls `InventoryTool`, it receives JSON like this:

```json
{"sku":"SKU-123","quantity":14,"needs_reorder":false}
```

#### Use `result_schema` with Dictionary Results

If your tool returns a dictionary, set `result_schema` explicitly. You can do this on a `BaseTool` subclass or with the `@tool` decorator:

```python Code
from crewai.tools import tool
from pydantic import BaseModel, Field

class ProductResult(BaseModel):
sku: str = Field(description="The product SKU.")
name: str = Field(description="The product name.")
in_stock: bool = Field(description="Whether the product is available.")

@tool("Product Lookup", result_schema=ProductResult)
def product_lookup(sku: str) -> dict[str, object]:
"""Look up product availability by SKU."""
catalog = {
"SKU-123": ("Noise-canceling headset", True),
"SKU-456": ("USB-C dock", False),
}
name, in_stock = catalog.get(sku, ("Unknown product", False))
return {
"sku": sku,
"name": name,
"in_stock": in_stock,
}
```

#### Customize the Text Sent to the Agent

By default, typed tool outputs are sent to the agent as JSON. If the agent should receive a short summary instead, subclass `BaseTool` and override `format_output_for_agent`.

```python Code
from crewai.tools import BaseTool
from pydantic import BaseModel, Field

class InventoryResult(BaseModel):
sku: str = Field(description="The product SKU.")
quantity: int = Field(description="Units available.")
needs_reorder: bool = Field(description="Whether the item should be reordered.")

class InventoryTool(BaseTool):
name: str = "Inventory Check"
description: str = "Check current stock for a product SKU."

def _run(self, sku: str) -> InventoryResult:
quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)

def format_output_for_agent(self, raw_result: object) -> str:
result = InventoryResult.model_validate(raw_result)
status = "reorder needed" if result.needs_reorder else "stock is healthy"
return f"{result.sku}: {result.quantity} units. {status}."

tool = InventoryTool()
result = tool.run(sku="SKU-123")

# Direct Python calls receive the raw Pydantic object.
print(result.quantity)
```

The override only changes what the agent sees. Direct calls to `tool.run(...)` still return the normal Python value.

### Defining a Cache Function for the Tool

To optimize tool performance with caching, define custom caching strategies using the `cache_function` attribute.
Expand Down
5 changes: 4 additions & 1 deletion docs/edge/en/learn/execution-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,12 @@ class ToolCallHookContext:
agent: Agent | None # Agent executing
task: Task | None # Current task
crew: Crew | None # Crew instance
tool_result: str | None # Tool result (after hooks)
tool_result: str | None # Agent-facing result string (after hooks)
raw_tool_result: Any | None # Raw Python result (after hooks)
```

For typed tool outputs, `tool_result` is the string the agent sees. By default, this is JSON. If the tool uses custom formatting, it can be Markdown or another string. `raw_tool_result` is the original Python value returned by the tool.

## Common Patterns

### Safety and Validation
Expand Down
5 changes: 4 additions & 1 deletion docs/edge/en/learn/tool-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,12 @@ class ToolCallHookContext:
agent: Agent | BaseAgent | None # Agent executing the tool
task: Task | None # Current task
crew: Crew | None # Crew instance
tool_result: str | None # Tool result (after hooks only)
tool_result: str | None # Agent-facing result string (after hooks only)
raw_tool_result: Any | None # Raw Python result (after hooks only)
```

For typed tool outputs, `tool_result` is the string the agent sees. By default, this is JSON. If the tool uses custom formatting, it can be Markdown or another string. Use `raw_tool_result` when your hook needs the typed object or dictionary.

### Modifying Tool Inputs

**Important:** Always modify tool inputs in-place:
Expand Down
52 changes: 30 additions & 22 deletions lib/crewai/src/crewai/agents/crew_agent_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
convert_tools_to_openai_schema,
enforce_rpm_limit,
format_message_for_llm,
format_native_tool_output_for_agent,
get_llm_response,
handle_agent_action_core,
handle_context_length,
Expand Down Expand Up @@ -907,19 +908,31 @@ def _execute_single_native_tool_call(
):
max_usage_reached = True

structured_tool: CrewStructuredTool | None = None
if original_tool is not None:
for structured in self.tools or []:
if getattr(structured, "_original_tool", None) is original_tool:
structured_tool = structured
break
if structured_tool is None:
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break

output_tool = original_tool or structured_tool

from_cache = False
result: str = "Tool not found"
raw_tool_result: Any = result
input_str = json.dumps(args_dict) if args_dict else ""
if self.tools_handler and self.tools_handler.cache:
if self.tools_handler and self.tools_handler.cache and output_tool is not None:
cached_result = self.tools_handler.cache.read(
tool=func_name, input=input_str
)
if cached_result is not None:
result = (
str(cached_result)
if not isinstance(cached_result, str)
else cached_result
)
raw_tool_result = cached_result
result = format_native_tool_output_for_agent(output_tool, cached_result)
from_cache = True

agent_key = getattr(self.agent, "key", "unknown") if self.agent else "unknown"
Expand All @@ -938,18 +951,6 @@ def _execute_single_native_tool_call(

track_delegation_if_needed(func_name, args_dict or {}, self.task)

structured_tool: CrewStructuredTool | None = None
if original_tool is not None:
for structured in self.tools or []:
if getattr(structured, "_original_tool", None) is original_tool:
structured_tool = structured
break
if structured_tool is None:
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break

hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
Expand All @@ -975,11 +976,18 @@ def _execute_single_native_tool_call(

if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
raw_tool_result = result
elif max_usage_reached and original_tool:
result = f"Tool '{func_name}' has reached its usage limit of {original_tool.max_usage_count} times and cannot be used anymore."
elif not from_cache and func_name in available_functions:
raw_tool_result = result
elif (
not from_cache
and func_name in available_functions
and output_tool is not None
):
try:
raw_result = available_functions[func_name](**(args_dict or {}))
raw_tool_result = raw_result

if self.tools_handler and self.tools_handler.cache:
should_cache = True
Expand All @@ -996,11 +1004,10 @@ def _execute_single_native_tool_call(
tool=func_name, input=input_str, output=raw_result
)

result = (
str(raw_result) if not isinstance(raw_result, str) else raw_result
)
result = format_native_tool_output_for_agent(output_tool, raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
raw_tool_result = result
if self.task:
self.task.increment_tools_errors()
crewai_event_bus.emit(
Expand All @@ -1024,6 +1031,7 @@ def _execute_single_native_tool_call(
task=self.task,
crew=self.crew,
tool_result=result,
raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
try:
Expand Down
Loading
Loading