mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-03-24 20:58:14 +00:00
Compare commits
36 Commits
1.11.0
...
fix/bad-cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf22b2ebb | ||
|
|
123924c2dd | ||
|
|
e4c042b7cd | ||
|
|
0909d5b2f6 | ||
|
|
590245fc59 | ||
|
|
b7a6bf7b03 | ||
|
|
04f93ce892 | ||
|
|
7746c75857 | ||
|
|
bf56bd1037 | ||
|
|
6eda4a8327 | ||
|
|
153b46c77f | ||
|
|
9285e076cb | ||
|
|
a3c22b0d84 | ||
|
|
82a7c364c5 | ||
|
|
36702229d7 | ||
|
|
b266cf7a3e | ||
|
|
c542cc9f70 | ||
|
|
aced3e5c29 | ||
|
|
555ee462a3 | ||
|
|
dd9ae02159 | ||
|
|
949d7f1091 | ||
|
|
3b569b8da9 | ||
|
|
e88a8f2785 | ||
|
|
85199e9ffc | ||
|
|
c92de53da7 | ||
|
|
1704ccdfa8 | ||
|
|
09b84dd2b0 | ||
|
|
f13d307534 | ||
|
|
8e427164ca | ||
|
|
6495aff528 | ||
|
|
f7de8b2d28 | ||
|
|
8886f11672 | ||
|
|
713fa7d01b | ||
|
|
929d756ae2 | ||
|
|
6b262f5a6d | ||
|
|
6a6adaf2da |
32
.github/workflows/pr-size.yml
vendored
Normal file
32
.github/workflows/pr-size.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: PR Size Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
pr-size:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: codelytv/pr-size-labeler@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
xs_label: "size/XS"
|
||||
xs_max_size: 25
|
||||
s_label: "size/S"
|
||||
s_max_size: 100
|
||||
m_label: "size/M"
|
||||
m_max_size: 250
|
||||
l_label: "size/L"
|
||||
l_max_size: 500
|
||||
xl_label: "size/XL"
|
||||
fail_if_xl: false
|
||||
files_to_ignore: |
|
||||
uv.lock
|
||||
*.lock
|
||||
lib/crewai/src/crewai/cli/templates/**
|
||||
**/*.json
|
||||
**/test_durations/**
|
||||
**/cassettes/**
|
||||
41
.github/workflows/pr-title.yml
vendored
Normal file
41
.github/workflows/pr-title.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
pr-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
docs
|
||||
chore
|
||||
ci
|
||||
style
|
||||
revert
|
||||
requireScope: false
|
||||
subjectPattern: ^[a-z].+[^.]$
|
||||
subjectPatternError: >
|
||||
The PR title "{title}" does not follow conventional commit format.
|
||||
|
||||
Expected: <type>(<scope>): <lowercase description without trailing period>
|
||||
|
||||
Examples:
|
||||
feat(memory): add lancedb storage backend
|
||||
fix(agents): resolve deadlock in concurrent execution
|
||||
chore(deps): bump pydantic to 2.11.9
|
||||
1506
docs/docs.json
1506
docs/docs.json
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,38 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="Mar 23, 2026">
|
||||
## v1.11.1
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.11.1)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Add flow_structure() serializer for Flow class introspection.
|
||||
|
||||
### Bug Fixes
|
||||
- Fix security vulnerabilities by bumping pypdf, tinytag, and langchain-core.
|
||||
- Preserve full LLM config across HITL resume for non-OpenAI providers.
|
||||
- Prevent path traversal in FileWriterTool.
|
||||
- Fix lock_store crash when redis package is not installed.
|
||||
- Pass cache_function from BaseTool to CrewStructuredTool.
|
||||
|
||||
### Documentation
|
||||
- Add publish custom tools guide with translations.
|
||||
- Update changelog and version for v1.11.0.
|
||||
- Add missing event listeners documentation.
|
||||
|
||||
### Refactoring
|
||||
- Replace urllib with requests in pdf loader.
|
||||
- Replace Any-typed callback and model fields with serializable types.
|
||||
|
||||
## Contributors
|
||||
|
||||
@alex-clawd, @danielfsbarreto, @dependabot[bot], @greysonlalonde, @lorenzejay, @lucasgomide, @mattatcha, @theCyberTech, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Mar 18, 2026">
|
||||
## v1.11.0
|
||||
|
||||
|
||||
@@ -196,12 +196,19 @@ CrewAI provides a wide range of events that you can listen for:
|
||||
- **CrewTrainStartedEvent**: Emitted when a Crew starts training
|
||||
- **CrewTrainCompletedEvent**: Emitted when a Crew completes training
|
||||
- **CrewTrainFailedEvent**: Emitted when a Crew fails to complete training
|
||||
- **CrewTestResultEvent**: Emitted when a Crew test result is available. Contains the quality score, execution duration, and model used.
|
||||
|
||||
### Agent Events
|
||||
|
||||
- **AgentExecutionStartedEvent**: Emitted when an Agent starts executing a task
|
||||
- **AgentExecutionCompletedEvent**: Emitted when an Agent completes executing a task
|
||||
- **AgentExecutionErrorEvent**: Emitted when an Agent encounters an error during execution
|
||||
- **LiteAgentExecutionStartedEvent**: Emitted when a LiteAgent starts executing. Contains the agent info, tools, and messages.
|
||||
- **LiteAgentExecutionCompletedEvent**: Emitted when a LiteAgent completes execution. Contains the agent info and output.
|
||||
- **LiteAgentExecutionErrorEvent**: Emitted when a LiteAgent encounters an error during execution. Contains the agent info and error message.
|
||||
- **AgentEvaluationStartedEvent**: Emitted when an agent evaluation starts. Contains the agent ID, agent role, optional task ID, and iteration number.
|
||||
- **AgentEvaluationCompletedEvent**: Emitted when an agent evaluation completes. Contains the agent ID, agent role, optional task ID, iteration number, metric category, and score.
|
||||
- **AgentEvaluationFailedEvent**: Emitted when an agent evaluation fails. Contains the agent ID, agent role, optional task ID, iteration number, and error message.
|
||||
|
||||
### Task Events
|
||||
|
||||
@@ -242,16 +249,26 @@ CrewAI provides a wide range of events that you can listen for:
|
||||
|
||||
- **LLMGuardrailStartedEvent**: Emitted when a guardrail validation starts. Contains details about the guardrail being applied and retry count.
|
||||
- **LLMGuardrailCompletedEvent**: Emitted when a guardrail validation completes. Contains details about validation success/failure, results, and error messages if any.
|
||||
- **LLMGuardrailFailedEvent**: Emitted when a guardrail validation fails. Contains the error message and retry count.
|
||||
|
||||
### Flow Events
|
||||
|
||||
- **FlowCreatedEvent**: Emitted when a Flow is created
|
||||
- **FlowStartedEvent**: Emitted when a Flow starts execution
|
||||
- **FlowFinishedEvent**: Emitted when a Flow completes execution
|
||||
- **FlowPausedEvent**: Emitted when a Flow is paused waiting for human feedback. Contains the flow name, flow ID, method name, current state, message shown when requesting feedback, and optional list of possible outcomes for routing.
|
||||
- **FlowPlotEvent**: Emitted when a Flow is plotted
|
||||
- **MethodExecutionStartedEvent**: Emitted when a Flow method starts execution
|
||||
- **MethodExecutionFinishedEvent**: Emitted when a Flow method completes execution
|
||||
- **MethodExecutionFailedEvent**: Emitted when a Flow method fails to complete execution
|
||||
- **MethodExecutionPausedEvent**: Emitted when a Flow method is paused waiting for human feedback. Contains the flow name, method name, current state, flow ID, message shown when requesting feedback, and optional list of possible outcomes for routing.
|
||||
|
||||
### Human In The Loop Events
|
||||
|
||||
- **FlowInputRequestedEvent**: Emitted when a Flow requests user input via `Flow.ask()`. Contains the flow name, method name, the question or prompt being shown to the user, and optional metadata (e.g., user ID, channel, session context).
|
||||
- **FlowInputReceivedEvent**: Emitted when user input is received after `Flow.ask()`. Contains the flow name, method name, the original question, the user's response (or `None` if timed out), optional request metadata, and optional response metadata from the provider (e.g., who responded, thread ID, timestamps).
|
||||
- **HumanFeedbackRequestedEvent**: Emitted when a `@human_feedback` decorated method requires input from a human reviewer. Contains the flow name, method name, the method output shown to the human for review, the message displayed when requesting feedback, and optional list of possible outcomes for routing.
|
||||
- **HumanFeedbackReceivedEvent**: Emitted when a human provides feedback in response to a `@human_feedback` decorated method. Contains the flow name, method name, the raw text feedback provided by the human, and the collapsed outcome string (if emit was specified).
|
||||
|
||||
### LLM Events
|
||||
|
||||
@@ -259,6 +276,7 @@ CrewAI provides a wide range of events that you can listen for:
|
||||
- **LLMCallCompletedEvent**: Emitted when an LLM call completes
|
||||
- **LLMCallFailedEvent**: Emitted when an LLM call fails
|
||||
- **LLMStreamChunkEvent**: Emitted for each chunk received during streaming LLM responses
|
||||
- **LLMThinkingChunkEvent**: Emitted when a thinking/reasoning chunk is received from a thinking model. Contains the chunk text and optional response ID.
|
||||
|
||||
### Memory Events
|
||||
|
||||
@@ -270,6 +288,79 @@ CrewAI provides a wide range of events that you can listen for:
|
||||
- **MemorySaveFailedEvent**: Emitted when a memory save operation fails. Contains the value, metadata, agent role, and error message.
|
||||
- **MemoryRetrievalStartedEvent**: Emitted when memory retrieval for a task prompt starts. Contains the optional task ID.
|
||||
- **MemoryRetrievalCompletedEvent**: Emitted when memory retrieval for a task prompt completes successfully. Contains the task ID, memory content, and retrieval execution time.
|
||||
- **MemoryRetrievalFailedEvent**: Emitted when memory retrieval for a task prompt fails. Contains the optional task ID and error message.
|
||||
|
||||
### Reasoning Events
|
||||
|
||||
- **AgentReasoningStartedEvent**: Emitted when an agent starts reasoning about a task. Contains the agent role, task ID, and attempt number.
|
||||
- **AgentReasoningCompletedEvent**: Emitted when an agent finishes its reasoning process. Contains the agent role, task ID, the plan produced, and whether the agent is ready to proceed.
|
||||
- **AgentReasoningFailedEvent**: Emitted when the reasoning process fails. Contains the agent role, task ID, and error message.
|
||||
|
||||
### Observation Events
|
||||
|
||||
- **StepObservationStartedEvent**: Emitted when the Planner begins observing a step's result. Fires after every step execution, before the observation LLM call. Contains the agent role, step number, and step description.
|
||||
- **StepObservationCompletedEvent**: Emitted when the Planner finishes observing a step's result. Contains whether the step completed successfully, key information learned, whether the remaining plan is still valid, whether a full replan is needed, and suggested refinements.
|
||||
- **StepObservationFailedEvent**: Emitted when the observation LLM call itself fails. The system defaults to continuing the plan. Contains the error message.
|
||||
- **PlanRefinementEvent**: Emitted when the Planner refines upcoming step descriptions without a full replan. Contains the number of refined steps and the refinements applied.
|
||||
- **PlanReplanTriggeredEvent**: Emitted when the Planner triggers a full replan because the remaining plan was deemed fundamentally wrong. Contains the replan reason, replan count, and number of completed steps preserved.
|
||||
- **GoalAchievedEarlyEvent**: Emitted when the Planner detects the goal was achieved early and remaining steps will be skipped. Contains the number of steps remaining and steps completed.
|
||||
|
||||
### A2A (Agent-to-Agent) Events
|
||||
|
||||
#### Delegation Events
|
||||
|
||||
- **A2ADelegationStartedEvent**: Emitted when A2A delegation starts. Contains the endpoint URL, task description, agent ID, context ID, whether it's multiturn, turn number, agent card metadata, protocol version, provider info, and optional skill ID.
|
||||
- **A2ADelegationCompletedEvent**: Emitted when A2A delegation completes. Contains the completion status (`completed`, `input_required`, `failed`, etc.), result, error message, context ID, and agent card metadata.
|
||||
- **A2AParallelDelegationStartedEvent**: Emitted when parallel delegation to multiple A2A agents begins. Contains the list of endpoints and the task description.
|
||||
- **A2AParallelDelegationCompletedEvent**: Emitted when parallel delegation to multiple A2A agents completes. Contains the list of endpoints, success count, failure count, and results summary.
|
||||
|
||||
#### Conversation Events
|
||||
|
||||
- **A2AConversationStartedEvent**: Emitted once at the beginning of a multiturn A2A conversation, before the first message exchange. Contains the agent ID, endpoint, context ID, agent card metadata, protocol version, and provider info.
|
||||
- **A2AMessageSentEvent**: Emitted when a message is sent to the A2A agent. Contains the message content, turn number, context ID, message ID, and whether it's multiturn.
|
||||
- **A2AResponseReceivedEvent**: Emitted when a response is received from the A2A agent. Contains the response content, turn number, context ID, message ID, status, and whether it's the final response.
|
||||
- **A2AConversationCompletedEvent**: Emitted once at the end of a multiturn A2A conversation. Contains the final status (`completed` or `failed`), final result, error message, context ID, and total number of turns.
|
||||
|
||||
#### Streaming Events
|
||||
|
||||
- **A2AStreamingStartedEvent**: Emitted when streaming mode begins for A2A delegation. Contains the task ID, context ID, endpoint, turn number, and whether it's multiturn.
|
||||
- **A2AStreamingChunkEvent**: Emitted when a streaming chunk is received. Contains the chunk text, chunk index, whether it's the final chunk, task ID, context ID, and turn number.
|
||||
|
||||
#### Polling & Push Notification Events
|
||||
|
||||
- **A2APollingStartedEvent**: Emitted when polling mode begins for A2A delegation. Contains the task ID, context ID, polling interval in seconds, and endpoint.
|
||||
- **A2APollingStatusEvent**: Emitted on each polling iteration. Contains the task ID, context ID, current task state, elapsed seconds, and poll count.
|
||||
- **A2APushNotificationRegisteredEvent**: Emitted when a push notification callback is registered. Contains the task ID, context ID, callback URL, and endpoint.
|
||||
- **A2APushNotificationReceivedEvent**: Emitted when a push notification is received from the remote A2A agent. Contains the task ID, context ID, and current state.
|
||||
- **A2APushNotificationSentEvent**: Emitted when a push notification is sent to a callback URL. Contains the task ID, context ID, callback URL, state, whether delivery succeeded, and optional error message.
|
||||
- **A2APushNotificationTimeoutEvent**: Emitted when push notification wait times out. Contains the task ID, context ID, and timeout duration in seconds.
|
||||
|
||||
#### Connection & Authentication Events
|
||||
|
||||
- **A2AAgentCardFetchedEvent**: Emitted when an agent card is successfully fetched. Contains the endpoint, agent name, agent card metadata, protocol version, provider info, whether it was cached, and fetch time in milliseconds.
|
||||
- **A2AAuthenticationFailedEvent**: Emitted when authentication to an A2A agent fails. Contains the endpoint, auth type attempted (e.g., `bearer`, `oauth2`, `api_key`), error message, and HTTP status code.
|
||||
- **A2AConnectionErrorEvent**: Emitted when a connection error occurs during A2A communication. Contains the endpoint, error message, error type (e.g., `timeout`, `connection_refused`, `dns_error`), HTTP status code, and the operation being attempted.
|
||||
- **A2ATransportNegotiatedEvent**: Emitted when transport protocol is negotiated with an A2A agent. Contains the negotiated transport, negotiated URL, selection source (`client_preferred`, `server_preferred`, `fallback`), and client/server supported transports.
|
||||
- **A2AContentTypeNegotiatedEvent**: Emitted when content types are negotiated with an A2A agent. Contains the client/server input/output modes, negotiated input/output modes, and whether negotiation succeeded.
|
||||
|
||||
#### Artifact Events
|
||||
|
||||
- **A2AArtifactReceivedEvent**: Emitted when an artifact is received from a remote A2A agent. Contains the task ID, artifact ID, artifact name, description, MIME type, size in bytes, and whether content should be appended.
|
||||
|
||||
#### Server Task Events
|
||||
|
||||
- **A2AServerTaskStartedEvent**: Emitted when an A2A server task execution starts. Contains the task ID and context ID.
|
||||
- **A2AServerTaskCompletedEvent**: Emitted when an A2A server task execution completes. Contains the task ID, context ID, and result.
|
||||
- **A2AServerTaskCanceledEvent**: Emitted when an A2A server task execution is canceled. Contains the task ID and context ID.
|
||||
- **A2AServerTaskFailedEvent**: Emitted when an A2A server task execution fails. Contains the task ID, context ID, and error message.
|
||||
|
||||
#### Context Lifecycle Events
|
||||
|
||||
- **A2AContextCreatedEvent**: Emitted when an A2A context is created. Contexts group related tasks in a conversation or workflow. Contains the context ID and creation timestamp.
|
||||
- **A2AContextExpiredEvent**: Emitted when an A2A context expires due to TTL. Contains the context ID, creation timestamp, age in seconds, and task count.
|
||||
- **A2AContextIdleEvent**: Emitted when an A2A context becomes idle (no activity for the configured threshold). Contains the context ID, idle time in seconds, and task count.
|
||||
- **A2AContextCompletedEvent**: Emitted when all tasks in an A2A context complete. Contains the context ID, total tasks, and duration in seconds.
|
||||
- **A2AContextPrunedEvent**: Emitted when an A2A context is pruned (deleted). Contains the context ID, task count, and age in seconds.
|
||||
|
||||
## Event Handler Structure
|
||||
|
||||
|
||||
115
docs/en/concepts/skills.mdx
Normal file
115
docs/en/concepts/skills.mdx
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Skills
|
||||
description: Filesystem-based skill packages that inject context into agent prompts.
|
||||
icon: bolt
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Skills are self-contained directories that provide agents with domain-specific instructions, references, and assets. Each skill is defined by a `SKILL.md` file with YAML frontmatter and a markdown body.
|
||||
|
||||
Skills use **progressive disclosure** — metadata is loaded first, full instructions only when activated, and resource catalogs only when needed.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
my-skill/
|
||||
├── SKILL.md # Required — frontmatter + instructions
|
||||
├── scripts/ # Optional — executable scripts
|
||||
├── references/ # Optional — reference documents
|
||||
└── assets/ # Optional — static files (configs, data)
|
||||
```
|
||||
|
||||
The directory name must match the `name` field in `SKILL.md`.
|
||||
|
||||
## SKILL.md Format
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: Short description of what this skill does and when to use it.
|
||||
license: Apache-2.0 # optional
|
||||
compatibility: crewai>=0.1.0 # optional
|
||||
metadata: # optional
|
||||
author: your-name
|
||||
version: "1.0"
|
||||
allowed-tools: web-search file-read # optional, space-delimited
|
||||
---
|
||||
|
||||
Instructions for the agent go here. This markdown body is injected
|
||||
into the agent's prompt when the skill is activated.
|
||||
```
|
||||
|
||||
### Frontmatter Fields
|
||||
|
||||
| Field | Required | Constraints |
|
||||
| :-------------- | :------- | :----------------------------------------------------------------------- |
|
||||
| `name` | Yes | 1–64 chars. Lowercase alphanumeric and hyphens. No leading/trailing/consecutive hyphens. Must match directory name. |
|
||||
| `description` | Yes | 1–1024 chars. Describes what the skill does and when to use it. |
|
||||
| `license` | No | License name or reference to a bundled license file. |
|
||||
| `compatibility` | No | Max 500 chars. Environment requirements (products, packages, network). |
|
||||
| `metadata` | No | Arbitrary string key-value mapping. |
|
||||
| `allowed-tools` | No | Space-delimited list of pre-approved tools. Experimental. |
|
||||
|
||||
## Usage
|
||||
|
||||
### Agent-level Skills
|
||||
|
||||
Pass skill directory paths to an agent:
|
||||
|
||||
```python
|
||||
from crewai import Agent
|
||||
|
||||
agent = Agent(
|
||||
role="Researcher",
|
||||
goal="Find relevant information",
|
||||
backstory="An expert researcher.",
|
||||
skills=["./skills"], # discovers all skills in this directory
|
||||
)
|
||||
```
|
||||
|
||||
### Crew-level Skills
|
||||
|
||||
Skill paths on a crew are merged into every agent:
|
||||
|
||||
```python
|
||||
from crewai import Crew
|
||||
|
||||
crew = Crew(
|
||||
agents=[agent],
|
||||
tasks=[task],
|
||||
skills=["./skills"],
|
||||
)
|
||||
```
|
||||
|
||||
### Pre-loaded Skills
|
||||
|
||||
You can also pass `Skill` objects directly:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from crewai.skills import discover_skills, activate_skill
|
||||
|
||||
skills = discover_skills(Path("./skills"))
|
||||
activated = [activate_skill(s) for s in skills]
|
||||
|
||||
agent = Agent(
|
||||
role="Researcher",
|
||||
goal="Find relevant information",
|
||||
backstory="An expert researcher.",
|
||||
skills=activated,
|
||||
)
|
||||
```
|
||||
|
||||
## How Skills Are Loaded
|
||||
|
||||
Skills load progressively — only the data needed at each stage is read:
|
||||
|
||||
| Stage | What's loaded | When |
|
||||
| :--------------- | :------------------------------------------------ | :----------------- |
|
||||
| Discovery | Name, description, frontmatter fields | `discover_skills()` |
|
||||
| Activation | Full SKILL.md body text | `activate_skill()` |
|
||||
|
||||
During normal agent execution, skills are automatically discovered and activated. The `scripts/`, `references/`, and `assets/` directories are available on the skill's `path` for agents that need to reference files directly.
|
||||
|
||||
244
docs/en/guides/tools/publish-custom-tools.mdx
Normal file
244
docs/en/guides/tools/publish-custom-tools.mdx
Normal 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()],
|
||||
# ...
|
||||
)
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
358
docs/en/learn/litellm-removal-guide.mdx
Normal file
358
docs/en/learn/litellm-removal-guide.mdx
Normal file
@@ -0,0 +1,358 @@
|
||||
---
|
||||
title: Using CrewAI Without LiteLLM
|
||||
description: How to use CrewAI with native provider integrations and remove the LiteLLM dependency from your project.
|
||||
icon: shield-check
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CrewAI supports two paths for connecting to LLM providers:
|
||||
|
||||
1. **Native integrations** — direct SDK connections to OpenAI, Anthropic, Google Gemini, Azure OpenAI, and AWS Bedrock
|
||||
2. **LiteLLM fallback** — a translation layer that supports 100+ additional providers
|
||||
|
||||
This guide explains how to use CrewAI exclusively with native provider integrations, removing any dependency on LiteLLM.
|
||||
|
||||
<Warning>
|
||||
The `litellm` package was quarantined on PyPI due to a security/reliability incident. While this has been resolved, some teams prefer to minimize their dependency surface. CrewAI's native integrations give you full functionality without LiteLLM.
|
||||
</Warning>
|
||||
|
||||
## Why Remove LiteLLM?
|
||||
|
||||
- **Reduced dependency surface** — fewer packages means fewer potential supply-chain risks
|
||||
- **Better performance** — native SDKs communicate directly with provider APIs, eliminating a translation layer
|
||||
- **Simpler debugging** — one less abstraction layer between your code and the provider
|
||||
- **Smaller install footprint** — LiteLLM brings in many transitive dependencies
|
||||
|
||||
## Native Providers (No LiteLLM Required)
|
||||
|
||||
These providers use their own SDKs and work without LiteLLM installed:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="OpenAI" icon="bolt">
|
||||
GPT-4o, GPT-4o-mini, o1, o3-mini, and more.
|
||||
```bash
|
||||
uv add "crewai[openai]"
|
||||
```
|
||||
</Card>
|
||||
<Card title="Anthropic" icon="a">
|
||||
Claude Sonnet, Claude Haiku, and more.
|
||||
```bash
|
||||
uv add "crewai[anthropic]"
|
||||
```
|
||||
</Card>
|
||||
<Card title="Google Gemini" icon="google">
|
||||
Gemini 2.0 Flash, Gemini 2.0 Pro, and more.
|
||||
```bash
|
||||
uv add "crewai[gemini]"
|
||||
```
|
||||
</Card>
|
||||
<Card title="Azure OpenAI" icon="microsoft">
|
||||
Azure-hosted OpenAI models.
|
||||
```bash
|
||||
uv add "crewai[azure]"
|
||||
```
|
||||
</Card>
|
||||
<Card title="AWS Bedrock" icon="aws">
|
||||
Claude, Llama, Titan, and more via AWS.
|
||||
```bash
|
||||
uv add "crewai[bedrock]"
|
||||
```
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Info>
|
||||
If you only use native providers, you **never** need to install `crewai[litellm]`. The base `crewai` package plus your chosen provider extra is all you need.
|
||||
</Info>
|
||||
|
||||
## How to Check If You're Using LiteLLM
|
||||
|
||||
### Check your model strings
|
||||
|
||||
If your code uses model prefixes like these, you're routing through LiteLLM:
|
||||
|
||||
| Prefix | Provider | Uses LiteLLM? |
|
||||
|--------|----------|---------------|
|
||||
| `ollama/` | Ollama | ✅ Yes |
|
||||
| `groq/` | Groq | ✅ Yes |
|
||||
| `together_ai/` | Together AI | ✅ Yes |
|
||||
| `mistral/` | Mistral | ✅ Yes |
|
||||
| `cohere/` | Cohere | ✅ Yes |
|
||||
| `huggingface/` | Hugging Face | ✅ Yes |
|
||||
| `openai/` | OpenAI | ❌ Native |
|
||||
| `anthropic/` | Anthropic | ❌ Native |
|
||||
| `gemini/` | Google Gemini | ❌ Native |
|
||||
| `azure/` | Azure OpenAI | ❌ Native |
|
||||
| `bedrock/` | AWS Bedrock | ❌ Native |
|
||||
|
||||
### Check if LiteLLM is installed
|
||||
|
||||
```bash
|
||||
# Using pip
|
||||
pip show litellm
|
||||
|
||||
# Using uv
|
||||
uv pip show litellm
|
||||
```
|
||||
|
||||
If the command returns package information, LiteLLM is installed in your environment.
|
||||
|
||||
### Check your dependencies
|
||||
|
||||
Look at your `pyproject.toml` for `crewai[litellm]`:
|
||||
|
||||
```toml
|
||||
# If you see this, you have LiteLLM as a dependency
|
||||
dependencies = [
|
||||
"crewai[litellm]>=0.100.0", # ← Uses LiteLLM
|
||||
]
|
||||
|
||||
# Change to a native provider extra instead
|
||||
dependencies = [
|
||||
"crewai[openai]>=0.100.0", # ← Native, no LiteLLM
|
||||
]
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Step 1: Identify your current provider
|
||||
|
||||
Find all `LLM()` calls and model strings in your code:
|
||||
|
||||
```bash
|
||||
# Search your codebase for LLM model strings
|
||||
grep -r "LLM(" --include="*.py" .
|
||||
grep -r "llm=" --include="*.yaml" .
|
||||
grep -r "llm:" --include="*.yaml" .
|
||||
```
|
||||
|
||||
### Step 2: Switch to a native provider
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Switch to OpenAI">
|
||||
```python
|
||||
from crewai import LLM
|
||||
|
||||
# Before (LiteLLM):
|
||||
# llm = LLM(model="groq/llama-3.1-70b")
|
||||
|
||||
# After (Native):
|
||||
llm = LLM(model="openai/gpt-4o")
|
||||
```
|
||||
|
||||
```bash
|
||||
# Install
|
||||
uv add "crewai[openai]"
|
||||
|
||||
# Set your API key
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Switch to Anthropic">
|
||||
```python
|
||||
from crewai import LLM
|
||||
|
||||
# Before (LiteLLM):
|
||||
# llm = LLM(model="together_ai/meta-llama/Meta-Llama-3.1-70B")
|
||||
|
||||
# After (Native):
|
||||
llm = LLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
```
|
||||
|
||||
```bash
|
||||
# Install
|
||||
uv add "crewai[anthropic]"
|
||||
|
||||
# Set your API key
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Switch to Gemini">
|
||||
```python
|
||||
from crewai import LLM
|
||||
|
||||
# Before (LiteLLM):
|
||||
# llm = LLM(model="mistral/mistral-large-latest")
|
||||
|
||||
# After (Native):
|
||||
llm = LLM(model="gemini/gemini-2.0-flash")
|
||||
```
|
||||
|
||||
```bash
|
||||
# Install
|
||||
uv add "crewai[gemini]"
|
||||
|
||||
# Set your API key
|
||||
export GEMINI_API_KEY="..."
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Switch to Azure OpenAI">
|
||||
```python
|
||||
from crewai import LLM
|
||||
|
||||
# After (Native):
|
||||
llm = LLM(
|
||||
model="azure/your-deployment-name",
|
||||
api_key="your-azure-api-key",
|
||||
base_url="https://your-resource.openai.azure.com",
|
||||
api_version="2024-06-01"
|
||||
)
|
||||
```
|
||||
|
||||
```bash
|
||||
# Install
|
||||
uv add "crewai[azure]"
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Switch to AWS Bedrock">
|
||||
```python
|
||||
from crewai import LLM
|
||||
|
||||
# After (Native):
|
||||
llm = LLM(
|
||||
model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
aws_region_name="us-east-1"
|
||||
)
|
||||
```
|
||||
|
||||
```bash
|
||||
# Install
|
||||
uv add "crewai[bedrock]"
|
||||
|
||||
# Configure AWS credentials
|
||||
export AWS_ACCESS_KEY_ID="..."
|
||||
export AWS_SECRET_ACCESS_KEY="..."
|
||||
export AWS_DEFAULT_REGION="us-east-1"
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Step 3: Keep Ollama without LiteLLM
|
||||
|
||||
If you're using Ollama and want to keep using it, you can connect via Ollama's OpenAI-compatible API:
|
||||
|
||||
```python
|
||||
from crewai import LLM
|
||||
|
||||
# Before (LiteLLM):
|
||||
# llm = LLM(model="ollama/llama3")
|
||||
|
||||
# After (OpenAI-compatible mode, no LiteLLM needed):
|
||||
llm = LLM(
|
||||
model="openai/llama3",
|
||||
base_url="http://localhost:11434/v1",
|
||||
api_key="ollama" # Ollama doesn't require a real API key
|
||||
)
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Many local inference servers (Ollama, vLLM, LM Studio, llama.cpp) expose an OpenAI-compatible API. You can use the `openai/` prefix with a custom `base_url` to connect to any of them natively.
|
||||
</Tip>
|
||||
|
||||
### Step 4: Update your YAML configs
|
||||
|
||||
```yaml
|
||||
# Before (LiteLLM providers):
|
||||
researcher:
|
||||
role: Research Specialist
|
||||
goal: Conduct research
|
||||
backstory: A dedicated researcher
|
||||
llm: groq/llama-3.1-70b # ← LiteLLM
|
||||
|
||||
# After (Native provider):
|
||||
researcher:
|
||||
role: Research Specialist
|
||||
goal: Conduct research
|
||||
backstory: A dedicated researcher
|
||||
llm: openai/gpt-4o # ← Native
|
||||
```
|
||||
|
||||
### Step 5: Remove LiteLLM
|
||||
|
||||
Once you've migrated all your model references:
|
||||
|
||||
```bash
|
||||
# Remove litellm from your project
|
||||
uv remove litellm
|
||||
|
||||
# Or if using pip
|
||||
pip uninstall litellm
|
||||
|
||||
# Update your pyproject.toml: change crewai[litellm] to your provider extra
|
||||
# e.g., crewai[openai], crewai[anthropic], crewai[gemini]
|
||||
```
|
||||
|
||||
### Step 6: Verify
|
||||
|
||||
Run your project and confirm everything works:
|
||||
|
||||
```bash
|
||||
# Run your crew
|
||||
crewai run
|
||||
|
||||
# Or run your tests
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## Quick Reference: Model String Mapping
|
||||
|
||||
Here are common migration paths from LiteLLM-dependent providers to native ones:
|
||||
|
||||
```python
|
||||
from crewai import LLM
|
||||
|
||||
# ─── LiteLLM providers → Native alternatives ────────────────────
|
||||
|
||||
# Groq → OpenAI or Anthropic
|
||||
# llm = LLM(model="groq/llama-3.1-70b")
|
||||
llm = LLM(model="openai/gpt-4o-mini") # Fast & affordable
|
||||
llm = LLM(model="anthropic/claude-haiku-3-5") # Fast & affordable
|
||||
|
||||
# Together AI → OpenAI or Gemini
|
||||
# llm = LLM(model="together_ai/meta-llama/Meta-Llama-3.1-70B")
|
||||
llm = LLM(model="openai/gpt-4o") # High quality
|
||||
llm = LLM(model="gemini/gemini-2.0-flash") # Fast & capable
|
||||
|
||||
# Mistral → Anthropic or OpenAI
|
||||
# llm = LLM(model="mistral/mistral-large-latest")
|
||||
llm = LLM(model="anthropic/claude-sonnet-4-20250514") # High quality
|
||||
|
||||
# Ollama → OpenAI-compatible (keep using local models)
|
||||
# llm = LLM(model="ollama/llama3")
|
||||
llm = LLM(
|
||||
model="openai/llama3",
|
||||
base_url="http://localhost:11434/v1",
|
||||
api_key="ollama"
|
||||
)
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Do I lose any functionality by removing LiteLLM?">
|
||||
No, if you use one of the five natively supported providers (OpenAI, Anthropic, Gemini, Azure, Bedrock). These native integrations support all CrewAI features including streaming, tool calling, structured output, and more. You only lose access to providers that are exclusively available through LiteLLM (like Groq, Together AI, Mistral as first-class providers).
|
||||
</Accordion>
|
||||
<Accordion title="Can I use multiple native providers at the same time?">
|
||||
Yes. Install multiple extras and use different providers for different agents:
|
||||
```bash
|
||||
uv add "crewai[openai,anthropic,gemini]"
|
||||
```
|
||||
```python
|
||||
researcher = Agent(llm="openai/gpt-4o", ...)
|
||||
writer = Agent(llm="anthropic/claude-sonnet-4-20250514", ...)
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="Is LiteLLM safe to use now?">
|
||||
The quarantine has been resolved. However, reducing your dependency surface is a good security practice regardless. If you only need providers that CrewAI supports natively, there's no reason to keep LiteLLM installed.
|
||||
</Accordion>
|
||||
<Accordion title="What about environment variables like OPENAI_API_KEY?">
|
||||
Native providers use the same environment variables you're already familiar with. No changes needed for `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, etc.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [LLM Connections](/en/learn/llm-connections) — Full guide to connecting CrewAI with any LLM
|
||||
- [LLM Concepts](/en/concepts/llms) — Understanding LLMs in CrewAI
|
||||
- [LLM Selection Guide](/en/learn/llm-selection-guide) — Choosing the right model for your use case
|
||||
@@ -4,6 +4,38 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="2026년 3월 23일">
|
||||
## v1.11.1
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.11.1)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- Flow 클래스 내성 검사를 위한 flow_structure() 직렬 변환기 추가.
|
||||
|
||||
### 버그 수정
|
||||
- pypdf, tinytag 및 langchain-core의 버전을 업데이트하여 보안 취약점 수정.
|
||||
- 비-OpenAI 제공자의 HITL 재개 시 전체 LLM 구성 유지.
|
||||
- FileWriterTool에서 경로 탐색 방지.
|
||||
- redis 패키지가 설치되지 않았을 때 lock_store 충돌 수정.
|
||||
- BaseTool에서 CrewStructuredTool로 cache_function 전달.
|
||||
|
||||
### 문서화
|
||||
- 번역이 포함된 사용자 정의 도구 게시 가이드 추가.
|
||||
- v1.11.0에 대한 변경 로그 및 버전 업데이트.
|
||||
- 누락된 이벤트 리스너 문서 추가.
|
||||
|
||||
### 리팩토링
|
||||
- pdf 로더에서 urllib를 requests로 교체.
|
||||
- Any 유형의 콜백 및 모델 필드를 직렬화 가능한 유형으로 교체.
|
||||
|
||||
## 기여자
|
||||
|
||||
@alex-clawd, @danielfsbarreto, @dependabot[bot], @greysonlalonde, @lorenzejay, @lucasgomide, @mattatcha, @theCyberTech, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 3월 18일">
|
||||
## v1.11.0
|
||||
|
||||
|
||||
@@ -195,12 +195,19 @@ CrewAI는 여러분이 청취할 수 있는 다양한 이벤트를 제공합니
|
||||
- **CrewTrainStartedEvent**: Crew가 훈련을 시작할 때 발생
|
||||
- **CrewTrainCompletedEvent**: Crew가 훈련을 완료할 때 발생
|
||||
- **CrewTrainFailedEvent**: Crew가 훈련을 완료하지 못할 때 발생
|
||||
- **CrewTestResultEvent**: Crew 테스트 결과가 사용 가능할 때 발생합니다. 품질 점수, 실행 시간, 사용된 모델을 포함합니다.
|
||||
|
||||
### 에이전트 이벤트
|
||||
|
||||
- **AgentExecutionStartedEvent**: 에이전트가 작업 실행을 시작할 때 발생함
|
||||
- **AgentExecutionCompletedEvent**: 에이전트가 작업 실행을 완료할 때 발생함
|
||||
- **AgentExecutionErrorEvent**: 에이전트가 실행 도중 오류를 만날 때 발생함
|
||||
- **LiteAgentExecutionStartedEvent**: LiteAgent가 실행을 시작할 때 발생합니다. 에이전트 정보, 도구, 메시지를 포함합니다.
|
||||
- **LiteAgentExecutionCompletedEvent**: LiteAgent가 실행을 완료할 때 발생합니다. 에이전트 정보와 출력을 포함합니다.
|
||||
- **LiteAgentExecutionErrorEvent**: LiteAgent가 실행 중 오류를 만날 때 발생합니다. 에이전트 정보와 오류 메시지를 포함합니다.
|
||||
- **AgentEvaluationStartedEvent**: 에이전트 평가가 시작될 때 발생합니다. 에이전트 ID, 에이전트 역할, 선택적 태스크 ID, 반복 횟수를 포함합니다.
|
||||
- **AgentEvaluationCompletedEvent**: 에이전트 평가가 완료될 때 발생합니다. 에이전트 ID, 에이전트 역할, 선택적 태스크 ID, 반복 횟수, 메트릭 카테고리, 점수를 포함합니다.
|
||||
- **AgentEvaluationFailedEvent**: 에이전트 평가가 실패할 때 발생합니다. 에이전트 ID, 에이전트 역할, 선택적 태스크 ID, 반복 횟수, 오류 메시지를 포함합니다.
|
||||
|
||||
### 작업 이벤트
|
||||
|
||||
@@ -218,6 +225,16 @@ CrewAI는 여러분이 청취할 수 있는 다양한 이벤트를 제공합니
|
||||
- **ToolExecutionErrorEvent**: 도구 실행 중 오류가 발생할 때 발생함
|
||||
- **ToolSelectionErrorEvent**: 도구 선택 시 오류가 발생할 때 발생함
|
||||
|
||||
### MCP 이벤트
|
||||
|
||||
- **MCPConnectionStartedEvent**: MCP 서버 연결을 시작할 때 발생합니다. 서버 이름, URL, 전송 유형, 연결 시간 초과, 재연결 시도 여부를 포함합니다.
|
||||
- **MCPConnectionCompletedEvent**: MCP 서버에 성공적으로 연결될 때 발생합니다. 서버 이름, 연결 시간(밀리초), 재연결 여부를 포함합니다.
|
||||
- **MCPConnectionFailedEvent**: MCP 서버 연결이 실패할 때 발생합니다. 서버 이름, 오류 메시지, 오류 유형(`timeout`, `authentication`, `network` 등)을 포함합니다.
|
||||
- **MCPToolExecutionStartedEvent**: MCP 도구 실행을 시작할 때 발생합니다. 서버 이름, 도구 이름, 도구 인수를 포함합니다.
|
||||
- **MCPToolExecutionCompletedEvent**: MCP 도구 실행이 성공적으로 완료될 때 발생합니다. 서버 이름, 도구 이름, 결과, 실행 시간(밀리초)을 포함합니다.
|
||||
- **MCPToolExecutionFailedEvent**: MCP 도구 실행이 실패할 때 발생합니다. 서버 이름, 도구 이름, 오류 메시지, 오류 유형(`timeout`, `validation`, `server_error` 등)을 포함합니다.
|
||||
- **MCPConfigFetchFailedEvent**: MCP 서버 구성을 가져오는 데 실패할 때 발생합니다(예: 계정에서 MCP가 연결되지 않았거나, API 오류, 구성을 가져온 후 연결 실패). slug, 오류 메시지, 오류 유형(`not_connected`, `api_error`, `connection_failed`)을 포함합니다.
|
||||
|
||||
### 지식 이벤트
|
||||
|
||||
- **KnowledgeRetrievalStartedEvent**: 지식 검색이 시작될 때 발생
|
||||
@@ -231,16 +248,26 @@ CrewAI는 여러분이 청취할 수 있는 다양한 이벤트를 제공합니
|
||||
|
||||
- **LLMGuardrailStartedEvent**: 가드레일 검증이 시작될 때 발생합니다. 적용되는 가드레일에 대한 세부 정보와 재시도 횟수를 포함합니다.
|
||||
- **LLMGuardrailCompletedEvent**: 가드레일 검증이 완료될 때 발생합니다. 검증의 성공/실패, 결과 및 오류 메시지(있는 경우)에 대한 세부 정보를 포함합니다.
|
||||
- **LLMGuardrailFailedEvent**: 가드레일 검증이 실패할 때 발생합니다. 오류 메시지와 재시도 횟수를 포함합니다.
|
||||
|
||||
### Flow 이벤트
|
||||
|
||||
- **FlowCreatedEvent**: Flow가 생성될 때 발생
|
||||
- **FlowStartedEvent**: Flow가 실행을 시작할 때 발생
|
||||
- **FlowFinishedEvent**: Flow가 실행을 완료할 때 발생
|
||||
- **FlowPausedEvent**: 사람의 피드백을 기다리며 Flow가 일시 중지될 때 발생합니다. Flow 이름, Flow ID, 메서드 이름, 현재 상태, 피드백 요청 시 표시되는 메시지, 라우팅을 위한 선택적 결과 목록을 포함합니다.
|
||||
- **FlowPlotEvent**: Flow가 플롯될 때 발생
|
||||
- **MethodExecutionStartedEvent**: Flow 메서드가 실행을 시작할 때 발생
|
||||
- **MethodExecutionFinishedEvent**: Flow 메서드가 실행을 완료할 때 발생
|
||||
- **MethodExecutionFailedEvent**: Flow 메서드가 실행을 완료하지 못할 때 발생
|
||||
- **MethodExecutionPausedEvent**: 사람의 피드백을 기다리며 Flow 메서드가 일시 중지될 때 발생합니다. Flow 이름, 메서드 이름, 현재 상태, Flow ID, 피드백 요청 시 표시되는 메시지, 라우팅을 위한 선택적 결과 목록을 포함합니다.
|
||||
|
||||
### Human In The Loop 이벤트
|
||||
|
||||
- **FlowInputRequestedEvent**: `Flow.ask()`를 통해 Flow가 사용자 입력을 요청할 때 발생합니다. Flow 이름, 메서드 이름, 사용자에게 표시되는 질문 또는 프롬프트, 선택적 메타데이터(예: 사용자 ID, 채널, 세션 컨텍스트)를 포함합니다.
|
||||
- **FlowInputReceivedEvent**: `Flow.ask()` 이후 사용자 입력이 수신될 때 발생합니다. Flow 이름, 메서드 이름, 원래 질문, 사용자의 응답(시간 초과 시 `None`), 선택적 요청 메타데이터, 프로바이더의 선택적 응답 메타데이터(예: 응답자, 스레드 ID, 타임스탬프)를 포함합니다.
|
||||
- **HumanFeedbackRequestedEvent**: `@human_feedback` 데코레이터가 적용된 메서드가 사람 리뷰어의 입력을 필요로 할 때 발생합니다. Flow 이름, 메서드 이름, 사람에게 검토를 위해 표시되는 메서드 출력, 피드백 요청 시 표시되는 메시지, 라우팅을 위한 선택적 결과 목록을 포함합니다.
|
||||
- **HumanFeedbackReceivedEvent**: `@human_feedback` 데코레이터가 적용된 메서드에 대해 사람이 피드백을 제공할 때 발생합니다. Flow 이름, 메서드 이름, 사람이 제공한 원본 텍스트 피드백, 축약된 결과 문자열(emit이 지정된 경우)을 포함합니다.
|
||||
|
||||
### LLM 이벤트
|
||||
|
||||
@@ -248,6 +275,7 @@ CrewAI는 여러분이 청취할 수 있는 다양한 이벤트를 제공합니
|
||||
- **LLMCallCompletedEvent**: LLM 호출이 완료될 때 발생
|
||||
- **LLMCallFailedEvent**: LLM 호출이 실패할 때 발생
|
||||
- **LLMStreamChunkEvent**: 스트리밍 LLM 응답 중 각 청크를 받을 때마다 발생
|
||||
- **LLMThinkingChunkEvent**: thinking 모델에서 사고/추론 청크가 수신될 때 발생합니다. 청크 텍스트와 선택적 응답 ID를 포함합니다.
|
||||
|
||||
### 메모리 이벤트
|
||||
|
||||
@@ -259,6 +287,79 @@ CrewAI는 여러분이 청취할 수 있는 다양한 이벤트를 제공합니
|
||||
- **MemorySaveFailedEvent**: 메모리 저장 작업에 실패할 때 발생합니다. 값, 메타데이터, agent 역할, 오류 메시지를 포함합니다.
|
||||
- **MemoryRetrievalStartedEvent**: 태스크 프롬프트를 위한 메모리 검색이 시작될 때 발생합니다. 선택적 태스크 ID를 포함합니다.
|
||||
- **MemoryRetrievalCompletedEvent**: 태스크 프롬프트를 위한 메모리 검색이 성공적으로 완료될 때 발생합니다. 태스크 ID, 메모리 내용, 검색 실행 시간을 포함합니다.
|
||||
- **MemoryRetrievalFailedEvent**: 태스크 프롬프트를 위한 메모리 검색이 실패할 때 발생합니다. 선택적 태스크 ID와 오류 메시지를 포함합니다.
|
||||
|
||||
### 추론 이벤트
|
||||
|
||||
- **AgentReasoningStartedEvent**: 에이전트가 태스크에 대한 추론을 시작할 때 발생합니다. 에이전트 역할, 태스크 ID, 시도 횟수를 포함합니다.
|
||||
- **AgentReasoningCompletedEvent**: 에이전트가 추론 과정을 마칠 때 발생합니다. 에이전트 역할, 태스크 ID, 생성된 계획, 에이전트가 진행할 준비가 되었는지 여부를 포함합니다.
|
||||
- **AgentReasoningFailedEvent**: 추론 과정이 실패할 때 발생합니다. 에이전트 역할, 태스크 ID, 오류 메시지를 포함합니다.
|
||||
|
||||
### 관찰 이벤트
|
||||
|
||||
- **StepObservationStartedEvent**: Planner가 단계 결과를 관찰하기 시작할 때 발생합니다. 매 단계 실행 후, 관찰 LLM 호출 전에 발생합니다. 에이전트 역할, 단계 번호, 단계 설명을 포함합니다.
|
||||
- **StepObservationCompletedEvent**: Planner가 단계 결과 관찰을 마칠 때 발생합니다. 단계 성공 여부, 학습된 핵심 정보, 남은 계획의 유효성, 전체 재계획 필요 여부, 제안된 개선 사항을 포함합니다.
|
||||
- **StepObservationFailedEvent**: 관찰 LLM 호출 자체가 실패할 때 발생합니다. 시스템은 기본적으로 계획을 계속 진행합니다. 오류 메시지를 포함합니다.
|
||||
- **PlanRefinementEvent**: Planner가 전체 재계획 없이 다음 단계 설명을 개선할 때 발생합니다. 개선된 단계 수와 적용된 개선 사항을 포함합니다.
|
||||
- **PlanReplanTriggeredEvent**: 남은 계획이 근본적으로 잘못된 것으로 판단되어 Planner가 전체 재계획을 트리거할 때 발생합니다. 재계획 이유, 재계획 횟수, 보존된 완료 단계 수를 포함합니다.
|
||||
- **GoalAchievedEarlyEvent**: Planner가 목표가 조기에 달성되었음을 감지하고 나머지 단계를 건너뛸 때 발생합니다. 남은 단계 수와 완료된 단계 수를 포함합니다.
|
||||
|
||||
### A2A (Agent-to-Agent) 이벤트
|
||||
|
||||
#### 위임 이벤트
|
||||
|
||||
- **A2ADelegationStartedEvent**: A2A 위임이 시작될 때 발생합니다. 엔드포인트 URL, 태스크 설명, 에이전트 ID, 컨텍스트 ID, 멀티턴 여부, 턴 번호, agent card 메타데이터, 프로토콜 버전, 프로바이더 정보, 선택적 skill ID를 포함합니다.
|
||||
- **A2ADelegationCompletedEvent**: A2A 위임이 완료될 때 발생합니다. 완료 상태(`completed`, `input_required`, `failed` 등), 결과, 오류 메시지, 컨텍스트 ID, agent card 메타데이터를 포함합니다.
|
||||
- **A2AParallelDelegationStartedEvent**: 여러 A2A 에이전트로의 병렬 위임이 시작될 때 발생합니다. 엔드포인트 목록과 태스크 설명을 포함합니다.
|
||||
- **A2AParallelDelegationCompletedEvent**: 여러 A2A 에이전트로의 병렬 위임이 완료될 때 발생합니다. 엔드포인트 목록, 성공 수, 실패 수, 결과 요약을 포함합니다.
|
||||
|
||||
#### 대화 이벤트
|
||||
|
||||
- **A2AConversationStartedEvent**: 멀티턴 A2A 대화 시작 시 한 번 발생합니다. 첫 번째 메시지 교환 전에 발생합니다. 에이전트 ID, 엔드포인트, 컨텍스트 ID, agent card 메타데이터, 프로토콜 버전, 프로바이더 정보를 포함합니다.
|
||||
- **A2AMessageSentEvent**: A2A 에이전트에 메시지가 전송될 때 발생합니다. 메시지 내용, 턴 번호, 컨텍스트 ID, 메시지 ID, 멀티턴 여부를 포함합니다.
|
||||
- **A2AResponseReceivedEvent**: A2A 에이전트로부터 응답이 수신될 때 발생합니다. 응답 내용, 턴 번호, 컨텍스트 ID, 메시지 ID, 상태, 최종 응답 여부를 포함합니다.
|
||||
- **A2AConversationCompletedEvent**: 멀티턴 A2A 대화 종료 시 한 번 발생합니다. 최종 상태(`completed` 또는 `failed`), 최종 결과, 오류 메시지, 컨텍스트 ID, 총 턴 수를 포함합니다.
|
||||
|
||||
#### 스트리밍 이벤트
|
||||
|
||||
- **A2AStreamingStartedEvent**: A2A 위임을 위한 스트리밍 모드가 시작될 때 발생합니다. 태스크 ID, 컨텍스트 ID, 엔드포인트, 턴 번호, 멀티턴 여부를 포함합니다.
|
||||
- **A2AStreamingChunkEvent**: 스트리밍 청크가 수신될 때 발생합니다. 청크 텍스트, 청크 인덱스, 최종 청크 여부, 태스크 ID, 컨텍스트 ID, 턴 번호를 포함합니다.
|
||||
|
||||
#### 폴링 및 푸시 알림 이벤트
|
||||
|
||||
- **A2APollingStartedEvent**: A2A 위임을 위한 폴링 모드가 시작될 때 발생합니다. 태스크 ID, 컨텍스트 ID, 폴링 간격(초), 엔드포인트를 포함합니다.
|
||||
- **A2APollingStatusEvent**: 각 폴링 반복 시 발생합니다. 태스크 ID, 컨텍스트 ID, 현재 태스크 상태, 경과 시간, 폴링 횟수를 포함합니다.
|
||||
- **A2APushNotificationRegisteredEvent**: 푸시 알림 콜백이 등록될 때 발생합니다. 태스크 ID, 컨텍스트 ID, 콜백 URL, 엔드포인트를 포함합니다.
|
||||
- **A2APushNotificationReceivedEvent**: 원격 A2A 에이전트로부터 푸시 알림이 수신될 때 발생합니다. 태스크 ID, 컨텍스트 ID, 현재 상태를 포함합니다.
|
||||
- **A2APushNotificationSentEvent**: 콜백 URL로 푸시 알림이 전송될 때 발생합니다. 태스크 ID, 컨텍스트 ID, 콜백 URL, 상태, 전달 성공 여부, 선택적 오류 메시지를 포함합니다.
|
||||
- **A2APushNotificationTimeoutEvent**: 푸시 알림 대기가 시간 초과될 때 발생합니다. 태스크 ID, 컨텍스트 ID, 시간 초과 시간(초)을 포함합니다.
|
||||
|
||||
#### 연결 및 인증 이벤트
|
||||
|
||||
- **A2AAgentCardFetchedEvent**: agent card가 성공적으로 가져올 때 발생합니다. 엔드포인트, 에이전트 이름, agent card 메타데이터, 프로토콜 버전, 프로바이더 정보, 캐시 여부, 가져오기 시간(밀리초)을 포함합니다.
|
||||
- **A2AAuthenticationFailedEvent**: A2A 에이전트 인증이 실패할 때 발생합니다. 엔드포인트, 시도된 인증 유형(예: `bearer`, `oauth2`, `api_key`), 오류 메시지, HTTP 상태 코드를 포함합니다.
|
||||
- **A2AConnectionErrorEvent**: A2A 통신 중 연결 오류가 발생할 때 발생합니다. 엔드포인트, 오류 메시지, 오류 유형(예: `timeout`, `connection_refused`, `dns_error`), HTTP 상태 코드, 시도 중인 작업을 포함합니다.
|
||||
- **A2ATransportNegotiatedEvent**: A2A 에이전트와 전송 프로토콜이 협상될 때 발생합니다. 협상된 전송, 협상된 URL, 선택 소스(`client_preferred`, `server_preferred`, `fallback`), 클라이언트/서버 지원 전송을 포함합니다.
|
||||
- **A2AContentTypeNegotiatedEvent**: A2A 에이전트와 콘텐츠 유형이 협상될 때 발생합니다. 클라이언트/서버 입출력 모드, 협상된 입출력 모드, 협상 성공 여부를 포함합니다.
|
||||
|
||||
#### 아티팩트 이벤트
|
||||
|
||||
- **A2AArtifactReceivedEvent**: 원격 A2A 에이전트로부터 아티팩트가 수신될 때 발생합니다. 태스크 ID, 아티팩트 ID, 아티팩트 이름, 설명, MIME 유형, 크기(바이트), 콘텐츠 추가 여부를 포함합니다.
|
||||
|
||||
#### 서버 태스크 이벤트
|
||||
|
||||
- **A2AServerTaskStartedEvent**: A2A 서버 태스크 실행이 시작될 때 발생합니다. 태스크 ID와 컨텍스트 ID를 포함합니다.
|
||||
- **A2AServerTaskCompletedEvent**: A2A 서버 태스크 실행이 완료될 때 발생합니다. 태스크 ID, 컨텍스트 ID, 결과를 포함합니다.
|
||||
- **A2AServerTaskCanceledEvent**: A2A 서버 태스크 실행이 취소될 때 발생합니다. 태스크 ID와 컨텍스트 ID를 포함합니다.
|
||||
- **A2AServerTaskFailedEvent**: A2A 서버 태스크 실행이 실패할 때 발생합니다. 태스크 ID, 컨텍스트 ID, 오류 메시지를 포함합니다.
|
||||
|
||||
#### 컨텍스트 수명 주기 이벤트
|
||||
|
||||
- **A2AContextCreatedEvent**: A2A 컨텍스트가 생성될 때 발생합니다. 컨텍스트는 대화 또는 워크플로우에서 관련 태스크를 그룹화합니다. 컨텍스트 ID와 생성 타임스탬프를 포함합니다.
|
||||
- **A2AContextExpiredEvent**: TTL로 인해 A2A 컨텍스트가 만료될 때 발생합니다. 컨텍스트 ID, 생성 타임스탬프, 수명(초), 태스크 수를 포함합니다.
|
||||
- **A2AContextIdleEvent**: A2A 컨텍스트가 유휴 상태가 될 때(설정된 임계값 동안 활동 없음) 발생합니다. 컨텍스트 ID, 유휴 시간(초), 태스크 수를 포함합니다.
|
||||
- **A2AContextCompletedEvent**: A2A 컨텍스트의 모든 태스크가 완료될 때 발생합니다. 컨텍스트 ID, 총 태스크 수, 지속 시간(초)을 포함합니다.
|
||||
- **A2AContextPrunedEvent**: A2A 컨텍스트가 정리(삭제)될 때 발생합니다. 컨텍스트 ID, 태스크 수, 수명(초)을 포함합니다.
|
||||
|
||||
## 이벤트 핸들러 구조
|
||||
|
||||
|
||||
114
docs/ko/concepts/skills.mdx
Normal file
114
docs/ko/concepts/skills.mdx
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: 스킬
|
||||
description: 에이전트 프롬프트에 컨텍스트를 주입하는 파일 시스템 기반 스킬 패키지.
|
||||
icon: bolt
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
스킬은 에이전트에게 도메인별 지침, 참조 자료, 에셋을 제공하는 자체 포함 디렉터리입니다. 각 스킬은 YAML 프론트매터와 마크다운 본문이 포함된 `SKILL.md` 파일로 정의됩니다.
|
||||
|
||||
스킬은 **점진적 공개**를 사용합니다 — 메타데이터가 먼저 로드되고, 활성화 시에만 전체 지침이 로드되며, 필요할 때만 리소스 카탈로그가 로드됩니다.
|
||||
|
||||
## 디렉터리 구조
|
||||
|
||||
```
|
||||
my-skill/
|
||||
├── SKILL.md # 필수 — 프론트매터 + 지침
|
||||
├── scripts/ # 선택 — 실행 가능한 스크립트
|
||||
├── references/ # 선택 — 참조 문서
|
||||
└── assets/ # 선택 — 정적 파일 (설정, 데이터)
|
||||
```
|
||||
|
||||
디렉터리 이름은 `SKILL.md`의 `name` 필드와 일치해야 합니다.
|
||||
|
||||
## SKILL.md 형식
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: 이 스킬이 무엇을 하고 언제 사용하는지에 대한 간단한 설명.
|
||||
license: Apache-2.0 # 선택
|
||||
compatibility: crewai>=0.1.0 # 선택
|
||||
metadata: # 선택
|
||||
author: your-name
|
||||
version: "1.0"
|
||||
allowed-tools: web-search file-read # 선택, 공백으로 구분
|
||||
---
|
||||
|
||||
에이전트를 위한 지침이 여기에 들어갑니다. 이 마크다운 본문은
|
||||
스킬이 활성화되면 에이전트의 프롬프트에 주입됩니다.
|
||||
```
|
||||
|
||||
### 프론트매터 필드
|
||||
|
||||
| 필드 | 필수 | 제약 조건 |
|
||||
| :-------------- | :----- | :----------------------------------------------------------------------- |
|
||||
| `name` | 예 | 1–64자. 소문자 영숫자와 하이픈. 선행/후행/연속 하이픈 불가. 디렉터리 이름과 일치 필수. |
|
||||
| `description` | 예 | 1–1024자. 스킬이 무엇을 하고 언제 사용하는지 설명. |
|
||||
| `license` | 아니오 | 라이선스 이름 또는 번들된 라이선스 파일 참조. |
|
||||
| `compatibility` | 아니오 | 최대 500자. 환경 요구 사항 (제품, 패키지, 네트워크). |
|
||||
| `metadata` | 아니오 | 임의의 문자열 키-값 매핑. |
|
||||
| `allowed-tools` | 아니오 | 공백으로 구분된 사전 승인 도구 목록. 실험적. |
|
||||
|
||||
## 사용법
|
||||
|
||||
### 에이전트 레벨 스킬
|
||||
|
||||
에이전트에 스킬 디렉터리 경로를 전달합니다:
|
||||
|
||||
```python
|
||||
from crewai import Agent
|
||||
|
||||
agent = Agent(
|
||||
role="Researcher",
|
||||
goal="Find relevant information",
|
||||
backstory="An expert researcher.",
|
||||
skills=["./skills"], # 이 디렉터리의 모든 스킬을 검색
|
||||
)
|
||||
```
|
||||
|
||||
### 크루 레벨 스킬
|
||||
|
||||
크루의 스킬 경로는 모든 에이전트에 병합됩니다:
|
||||
|
||||
```python
|
||||
from crewai import Crew
|
||||
|
||||
crew = Crew(
|
||||
agents=[agent],
|
||||
tasks=[task],
|
||||
skills=["./skills"],
|
||||
)
|
||||
```
|
||||
|
||||
### 사전 로드된 스킬
|
||||
|
||||
`Skill` 객체를 직접 전달할 수도 있습니다:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from crewai.skills import discover_skills, activate_skill
|
||||
|
||||
skills = discover_skills(Path("./skills"))
|
||||
activated = [activate_skill(s) for s in skills]
|
||||
|
||||
agent = Agent(
|
||||
role="Researcher",
|
||||
goal="Find relevant information",
|
||||
backstory="An expert researcher.",
|
||||
skills=activated,
|
||||
)
|
||||
```
|
||||
|
||||
## 스킬 로드 방식
|
||||
|
||||
스킬은 점진적으로 로드됩니다 — 각 단계에서 필요한 데이터만 읽습니다:
|
||||
|
||||
| 단계 | 로드되는 내용 | 시점 |
|
||||
| :--------------- | :------------------------------------------------ | :----------------- |
|
||||
| 검색 | 이름, 설명, 프론트매터 필드 | `discover_skills()` |
|
||||
| 활성화 | 전체 SKILL.md 본문 텍스트 | `activate_skill()` |
|
||||
|
||||
일반적인 에이전트 실행 중에 스킬은 자동으로 검색되고 활성화됩니다. `scripts/`, `references/`, `assets/` 디렉터리는 파일을 직접 참조해야 하는 에이전트를 위해 스킬의 `path`에서 사용할 수 있습니다.
|
||||
61
docs/ko/guides/coding-tools/agents-md.mdx
Normal file
61
docs/ko/guides/coding-tools/agents-md.mdx
Normal 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`에서 읽거나 내용을 직접 붙여넣으세요.
|
||||
244
docs/ko/guides/tools/publish-custom-tools.mdx
Normal file
244
docs/ko/guides/tools/publish-custom-tools.mdx
Normal 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()],
|
||||
# ...
|
||||
)
|
||||
```
|
||||
@@ -9,6 +9,10 @@ mode: "wide"
|
||||
|
||||
이 가이드는 CrewAI 프레임워크를 위한 커스텀 툴을 생성하는 방법과 최신 기능(툴 위임, 오류 처리, 동적 툴 호출 등)을 통합하여 이러한 툴을 효율적으로 관리하고 활용하는 방법에 대해 자세히 안내합니다. 또한 협업 툴의 중요성을 강조하며, 에이전트가 다양한 작업을 수행할 수 있도록 지원합니다.
|
||||
|
||||
<Tip>
|
||||
**커뮤니티에 도구를 배포하고 싶으신가요?** 다른 사용자에게도 유용한 도구를 만들고 있다면, [커스텀 도구 배포하기](/ko/guides/tools/publish-custom-tools) 가이드에서 도구를 패키징하고 PyPI에 배포하는 방법을 알아보세요.
|
||||
</Tip>
|
||||
|
||||
### `BaseTool` 서브클래싱
|
||||
|
||||
개인화된 툴을 생성하려면 `BaseTool`을 상속받고, 입력 검증을 위한 `args_schema`와 `_run` 메서드를 포함한 필요한 속성들을 정의해야 합니다.
|
||||
|
||||
@@ -4,6 +4,38 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="23 mar 2026">
|
||||
## v1.11.1
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.11.1)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Funcionalidades
|
||||
- Adicionar o serializer flow_structure() para introspecção da classe Flow.
|
||||
|
||||
### Correções de Bugs
|
||||
- Corrigir vulnerabilidades de segurança atualizando pypdf, tinytag e langchain-core.
|
||||
- Preservar a configuração completa do LLM durante a retomada do HITL para provedores que não são da OpenAI.
|
||||
- Prevenir a travessia de caminho no FileWriterTool.
|
||||
- Corrigir a falha do lock_store quando o pacote redis não está instalado.
|
||||
- Passar cache_function de BaseTool para CrewStructuredTool.
|
||||
|
||||
### Documentação
|
||||
- Adicionar guia de publicação de ferramentas personalizadas com traduções.
|
||||
- Atualizar changelog e versão para v1.11.0.
|
||||
- Adicionar documentação de ouvintes de eventos ausentes.
|
||||
|
||||
### Refatoração
|
||||
- Substituir urllib por requests no carregador de pdf.
|
||||
- Substituir campos de callback e modelo do tipo Any por tipos serializáveis.
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@alex-clawd, @danielfsbarreto, @dependabot[bot], @greysonlalonde, @lorenzejay, @lucasgomide, @mattatcha, @theCyberTech, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="18 mar 2026">
|
||||
## v1.11.0
|
||||
|
||||
|
||||
@@ -196,12 +196,19 @@ O CrewAI fornece uma ampla variedade de eventos para escuta:
|
||||
- **CrewTrainStartedEvent**: Emitido ao iniciar o treinamento de um Crew
|
||||
- **CrewTrainCompletedEvent**: Emitido ao concluir o treinamento de um Crew
|
||||
- **CrewTrainFailedEvent**: Emitido ao falhar no treinamento de um Crew
|
||||
- **CrewTestResultEvent**: Emitido quando um resultado de teste de Crew está disponível. Contém a pontuação de qualidade, duração da execução e modelo utilizado.
|
||||
|
||||
### Eventos de Agent
|
||||
|
||||
- **AgentExecutionStartedEvent**: Emitido quando um Agent inicia a execução de uma tarefa
|
||||
- **AgentExecutionCompletedEvent**: Emitido quando um Agent conclui a execução de uma tarefa
|
||||
- **AgentExecutionErrorEvent**: Emitido quando um Agent encontra um erro durante a execução
|
||||
- **LiteAgentExecutionStartedEvent**: Emitido quando um LiteAgent inicia a execução. Contém as informações do agente, ferramentas e mensagens.
|
||||
- **LiteAgentExecutionCompletedEvent**: Emitido quando um LiteAgent conclui a execução. Contém as informações do agente e a saída.
|
||||
- **LiteAgentExecutionErrorEvent**: Emitido quando um LiteAgent encontra um erro durante a execução. Contém as informações do agente e a mensagem de erro.
|
||||
- **AgentEvaluationStartedEvent**: Emitido quando uma avaliação de agente é iniciada. Contém o ID do agente, papel do agente, ID da tarefa opcional e número da iteração.
|
||||
- **AgentEvaluationCompletedEvent**: Emitido quando uma avaliação de agente é concluída. Contém o ID do agente, papel do agente, ID da tarefa opcional, número da iteração, categoria da métrica e pontuação.
|
||||
- **AgentEvaluationFailedEvent**: Emitido quando uma avaliação de agente falha. Contém o ID do agente, papel do agente, ID da tarefa opcional, número da iteração e mensagem de erro.
|
||||
|
||||
### Eventos de Task
|
||||
|
||||
@@ -219,6 +226,16 @@ O CrewAI fornece uma ampla variedade de eventos para escuta:
|
||||
- **ToolExecutionErrorEvent**: Emitido quando ocorre erro na execução de uma ferramenta
|
||||
- **ToolSelectionErrorEvent**: Emitido ao ocorrer erro na seleção de uma ferramenta
|
||||
|
||||
### Eventos de MCP
|
||||
|
||||
- **MCPConnectionStartedEvent**: Emitido ao iniciar a conexão com um servidor MCP. Contém o nome do servidor, URL, tipo de transporte, timeout de conexão e se é uma tentativa de reconexão.
|
||||
- **MCPConnectionCompletedEvent**: Emitido ao conectar com sucesso a um servidor MCP. Contém o nome do servidor, duração da conexão em milissegundos e se foi uma reconexão.
|
||||
- **MCPConnectionFailedEvent**: Emitido quando a conexão com um servidor MCP falha. Contém o nome do servidor, mensagem de erro e tipo de erro (`timeout`, `authentication`, `network`, etc.).
|
||||
- **MCPToolExecutionStartedEvent**: Emitido ao iniciar a execução de uma ferramenta MCP. Contém o nome do servidor, nome da ferramenta e argumentos da ferramenta.
|
||||
- **MCPToolExecutionCompletedEvent**: Emitido quando a execução de uma ferramenta MCP é concluída com sucesso. Contém o nome do servidor, nome da ferramenta, resultado e duração da execução em milissegundos.
|
||||
- **MCPToolExecutionFailedEvent**: Emitido quando a execução de uma ferramenta MCP falha. Contém o nome do servidor, nome da ferramenta, mensagem de erro e tipo de erro (`timeout`, `validation`, `server_error`, etc.).
|
||||
- **MCPConfigFetchFailedEvent**: Emitido quando a obtenção da configuração de um servidor MCP falha (ex.: o MCP não está conectado na sua conta, erro de API ou falha de conexão após a configuração ser obtida). Contém o slug, mensagem de erro e tipo de erro (`not_connected`, `api_error`, `connection_failed`).
|
||||
|
||||
### Eventos de Knowledge
|
||||
|
||||
- **KnowledgeRetrievalStartedEvent**: Emitido ao iniciar recuperação de conhecimento
|
||||
@@ -232,16 +249,26 @@ O CrewAI fornece uma ampla variedade de eventos para escuta:
|
||||
|
||||
- **LLMGuardrailStartedEvent**: Emitido ao iniciar validação dos guardrails. Contém detalhes do guardrail aplicado e tentativas.
|
||||
- **LLMGuardrailCompletedEvent**: Emitido ao concluir validação dos guardrails. Contém detalhes sobre sucesso/falha na validação, resultados e mensagens de erro, se houver.
|
||||
- **LLMGuardrailFailedEvent**: Emitido quando a validação do guardrail falha. Contém a mensagem de erro e o número de tentativas.
|
||||
|
||||
### Eventos de Flow
|
||||
|
||||
- **FlowCreatedEvent**: Emitido ao criar um Flow
|
||||
- **FlowStartedEvent**: Emitido ao iniciar a execução de um Flow
|
||||
- **FlowFinishedEvent**: Emitido ao concluir a execução de um Flow
|
||||
- **FlowPausedEvent**: Emitido quando um Flow é pausado aguardando feedback humano. Contém o nome do flow, ID do flow, nome do método, estado atual, mensagem exibida ao solicitar feedback e lista opcional de resultados possíveis para roteamento.
|
||||
- **FlowPlotEvent**: Emitido ao plotar um Flow
|
||||
- **MethodExecutionStartedEvent**: Emitido ao iniciar a execução de um método do Flow
|
||||
- **MethodExecutionFinishedEvent**: Emitido ao concluir a execução de um método do Flow
|
||||
- **MethodExecutionFailedEvent**: Emitido ao falhar na execução de um método do Flow
|
||||
- **MethodExecutionPausedEvent**: Emitido quando um método do Flow é pausado aguardando feedback humano. Contém o nome do flow, nome do método, estado atual, ID do flow, mensagem exibida ao solicitar feedback e lista opcional de resultados possíveis para roteamento.
|
||||
|
||||
### Eventos de Human In The Loop
|
||||
|
||||
- **FlowInputRequestedEvent**: Emitido quando um Flow solicita entrada do usuário via `Flow.ask()`. Contém o nome do flow, nome do método, a pergunta ou prompt exibido ao usuário e metadados opcionais (ex.: ID do usuário, canal, contexto da sessão).
|
||||
- **FlowInputReceivedEvent**: Emitido quando a entrada do usuário é recebida após `Flow.ask()`. Contém o nome do flow, nome do método, a pergunta original, a resposta do usuário (ou `None` se expirou), metadados opcionais da solicitação e metadados opcionais da resposta do provedor (ex.: quem respondeu, ID do thread, timestamps).
|
||||
- **HumanFeedbackRequestedEvent**: Emitido quando um método decorado com `@human_feedback` requer entrada de um revisor humano. Contém o nome do flow, nome do método, a saída do método exibida ao humano para revisão, a mensagem exibida ao solicitar feedback e lista opcional de resultados possíveis para roteamento.
|
||||
- **HumanFeedbackReceivedEvent**: Emitido quando um humano fornece feedback em resposta a um método decorado com `@human_feedback`. Contém o nome do flow, nome do método, o texto bruto do feedback fornecido pelo humano e a string de resultado consolidada (se emit foi especificado).
|
||||
|
||||
### Eventos de LLM
|
||||
|
||||
@@ -249,6 +276,91 @@ O CrewAI fornece uma ampla variedade de eventos para escuta:
|
||||
- **LLMCallCompletedEvent**: Emitido ao concluir uma chamada LLM
|
||||
- **LLMCallFailedEvent**: Emitido ao falhar uma chamada LLM
|
||||
- **LLMStreamChunkEvent**: Emitido para cada chunk recebido durante respostas em streaming do LLM
|
||||
- **LLMThinkingChunkEvent**: Emitido quando um chunk de pensamento/raciocínio é recebido de um modelo de pensamento. Contém o texto do chunk e ID de resposta opcional.
|
||||
|
||||
### Eventos de Memória
|
||||
|
||||
- **MemoryQueryStartedEvent**: Emitido quando uma consulta de memória é iniciada. Contém a consulta, limite e threshold de pontuação opcional.
|
||||
- **MemoryQueryCompletedEvent**: Emitido quando uma consulta de memória é concluída com sucesso. Contém a consulta, resultados, limite, threshold de pontuação e tempo de execução da consulta.
|
||||
- **MemoryQueryFailedEvent**: Emitido quando uma consulta de memória falha. Contém a consulta, limite, threshold de pontuação e mensagem de erro.
|
||||
- **MemorySaveStartedEvent**: Emitido quando uma operação de salvamento de memória é iniciada. Contém o valor a ser salvo, metadados e papel do agente opcional.
|
||||
- **MemorySaveCompletedEvent**: Emitido quando uma operação de salvamento de memória é concluída com sucesso. Contém o valor salvo, metadados, papel do agente e tempo de salvamento.
|
||||
- **MemorySaveFailedEvent**: Emitido quando uma operação de salvamento de memória falha. Contém o valor, metadados, papel do agente e mensagem de erro.
|
||||
- **MemoryRetrievalStartedEvent**: Emitido quando a recuperação de memória para um prompt de tarefa é iniciada. Contém o ID da tarefa opcional.
|
||||
- **MemoryRetrievalCompletedEvent**: Emitido quando a recuperação de memória para um prompt de tarefa é concluída com sucesso. Contém o ID da tarefa, conteúdo da memória e tempo de execução da recuperação.
|
||||
- **MemoryRetrievalFailedEvent**: Emitido quando a recuperação de memória para um prompt de tarefa falha. Contém o ID da tarefa opcional e mensagem de erro.
|
||||
|
||||
### Eventos de Raciocínio
|
||||
|
||||
- **AgentReasoningStartedEvent**: Emitido quando um agente começa a raciocinar sobre uma tarefa. Contém o papel do agente, ID da tarefa e número da tentativa.
|
||||
- **AgentReasoningCompletedEvent**: Emitido quando um agente finaliza seu processo de raciocínio. Contém o papel do agente, ID da tarefa, o plano produzido e se o agente está pronto para prosseguir.
|
||||
- **AgentReasoningFailedEvent**: Emitido quando o processo de raciocínio falha. Contém o papel do agente, ID da tarefa e mensagem de erro.
|
||||
|
||||
### Eventos de Observação
|
||||
|
||||
- **StepObservationStartedEvent**: Emitido quando o Planner começa a observar o resultado de um passo. Disparado após cada execução de passo, antes da chamada LLM de observação. Contém o papel do agente, número do passo e descrição do passo.
|
||||
- **StepObservationCompletedEvent**: Emitido quando o Planner finaliza a observação do resultado de um passo. Contém se o passo foi concluído com sucesso, informações-chave aprendidas, se o plano restante ainda é válido, se é necessário um replanejamento completo e refinamentos sugeridos.
|
||||
- **StepObservationFailedEvent**: Emitido quando a chamada LLM de observação falha. O sistema continua o plano por padrão. Contém a mensagem de erro.
|
||||
- **PlanRefinementEvent**: Emitido quando o Planner refina descrições de passos futuros sem replanejamento completo. Contém o número de passos refinados e os refinamentos aplicados.
|
||||
- **PlanReplanTriggeredEvent**: Emitido quando o Planner dispara um replanejamento completo porque o plano restante foi considerado fundamentalmente incorreto. Contém o motivo do replanejamento, contagem de replanejamentos e número de passos concluídos preservados.
|
||||
- **GoalAchievedEarlyEvent**: Emitido quando o Planner detecta que o objetivo foi alcançado antecipadamente e os passos restantes serão ignorados. Contém o número de passos restantes e passos concluídos.
|
||||
|
||||
### Eventos A2A (Agent-to-Agent)
|
||||
|
||||
#### Eventos de Delegação
|
||||
|
||||
- **A2ADelegationStartedEvent**: Emitido quando a delegação A2A é iniciada. Contém a URL do endpoint, descrição da tarefa, ID do agente, ID do contexto, se é multiturn, número do turno, metadados do agent card, versão do protocolo, informações do provedor e ID da skill opcional.
|
||||
- **A2ADelegationCompletedEvent**: Emitido quando a delegação A2A é concluída. Contém o status de conclusão (`completed`, `input_required`, `failed`, etc.), resultado, mensagem de erro, ID do contexto e metadados do agent card.
|
||||
- **A2AParallelDelegationStartedEvent**: Emitido quando a delegação paralela para múltiplos agentes A2A é iniciada. Contém a lista de endpoints e a descrição da tarefa.
|
||||
- **A2AParallelDelegationCompletedEvent**: Emitido quando a delegação paralela para múltiplos agentes A2A é concluída. Contém a lista de endpoints, contagem de sucessos, contagem de falhas e resumo dos resultados.
|
||||
|
||||
#### Eventos de Conversação
|
||||
|
||||
- **A2AConversationStartedEvent**: Emitido uma vez no início de uma conversação multiturn A2A, antes da primeira troca de mensagens. Contém o ID do agente, endpoint, ID do contexto, metadados do agent card, versão do protocolo e informações do provedor.
|
||||
- **A2AMessageSentEvent**: Emitido quando uma mensagem é enviada ao agente A2A. Contém o conteúdo da mensagem, número do turno, ID do contexto, ID da mensagem e se é multiturn.
|
||||
- **A2AResponseReceivedEvent**: Emitido quando uma resposta é recebida do agente A2A. Contém o conteúdo da resposta, número do turno, ID do contexto, ID da mensagem, status e se é a resposta final.
|
||||
- **A2AConversationCompletedEvent**: Emitido uma vez ao final de uma conversação multiturn A2A. Contém o status final (`completed` ou `failed`), resultado final, mensagem de erro, ID do contexto e número total de turnos.
|
||||
|
||||
#### Eventos de Streaming
|
||||
|
||||
- **A2AStreamingStartedEvent**: Emitido quando o modo streaming é iniciado para delegação A2A. Contém o ID da tarefa, ID do contexto, endpoint, número do turno e se é multiturn.
|
||||
- **A2AStreamingChunkEvent**: Emitido quando um chunk de streaming é recebido. Contém o texto do chunk, índice do chunk, se é o chunk final, ID da tarefa, ID do contexto e número do turno.
|
||||
|
||||
#### Eventos de Polling e Push Notification
|
||||
|
||||
- **A2APollingStartedEvent**: Emitido quando o modo polling é iniciado para delegação A2A. Contém o ID da tarefa, ID do contexto, intervalo de polling em segundos e endpoint.
|
||||
- **A2APollingStatusEvent**: Emitido em cada iteração de polling. Contém o ID da tarefa, ID do contexto, estado atual da tarefa, segundos decorridos e contagem de polls.
|
||||
- **A2APushNotificationRegisteredEvent**: Emitido quando um callback de push notification é registrado. Contém o ID da tarefa, ID do contexto, URL do callback e endpoint.
|
||||
- **A2APushNotificationReceivedEvent**: Emitido quando uma push notification é recebida do agente A2A remoto. Contém o ID da tarefa, ID do contexto e estado atual.
|
||||
- **A2APushNotificationSentEvent**: Emitido quando uma push notification é enviada para uma URL de callback. Contém o ID da tarefa, ID do contexto, URL do callback, estado, se a entrega foi bem-sucedida e mensagem de erro opcional.
|
||||
- **A2APushNotificationTimeoutEvent**: Emitido quando a espera por push notification expira. Contém o ID da tarefa, ID do contexto e duração do timeout em segundos.
|
||||
|
||||
#### Eventos de Conexão e Autenticação
|
||||
|
||||
- **A2AAgentCardFetchedEvent**: Emitido quando um agent card é obtido com sucesso. Contém o endpoint, nome do agente, metadados do agent card, versão do protocolo, informações do provedor, se foi do cache e tempo de busca em milissegundos.
|
||||
- **A2AAuthenticationFailedEvent**: Emitido quando a autenticação com um agente A2A falha. Contém o endpoint, tipo de autenticação tentada (ex.: `bearer`, `oauth2`, `api_key`), mensagem de erro e código de status HTTP.
|
||||
- **A2AConnectionErrorEvent**: Emitido quando ocorre um erro de conexão durante a comunicação A2A. Contém o endpoint, mensagem de erro, tipo de erro (ex.: `timeout`, `connection_refused`, `dns_error`), código de status HTTP e a operação sendo tentada.
|
||||
- **A2ATransportNegotiatedEvent**: Emitido quando o protocolo de transporte é negociado com um agente A2A. Contém o transporte negociado, URL negociada, fonte de seleção (`client_preferred`, `server_preferred`, `fallback`) e transportes suportados pelo cliente/servidor.
|
||||
- **A2AContentTypeNegotiatedEvent**: Emitido quando os tipos de conteúdo são negociados com um agente A2A. Contém os modos de entrada/saída do cliente/servidor, modos de entrada/saída negociados e se a negociação foi bem-sucedida.
|
||||
|
||||
#### Eventos de Artefatos
|
||||
|
||||
- **A2AArtifactReceivedEvent**: Emitido quando um artefato é recebido de um agente A2A remoto. Contém o ID da tarefa, ID do artefato, nome do artefato, descrição, tipo MIME, tamanho em bytes e se o conteúdo deve ser concatenado.
|
||||
|
||||
#### Eventos de Tarefa do Servidor
|
||||
|
||||
- **A2AServerTaskStartedEvent**: Emitido quando a execução de uma tarefa do servidor A2A é iniciada. Contém o ID da tarefa e ID do contexto.
|
||||
- **A2AServerTaskCompletedEvent**: Emitido quando a execução de uma tarefa do servidor A2A é concluída. Contém o ID da tarefa, ID do contexto e resultado.
|
||||
- **A2AServerTaskCanceledEvent**: Emitido quando a execução de uma tarefa do servidor A2A é cancelada. Contém o ID da tarefa e ID do contexto.
|
||||
- **A2AServerTaskFailedEvent**: Emitido quando a execução de uma tarefa do servidor A2A falha. Contém o ID da tarefa, ID do contexto e mensagem de erro.
|
||||
|
||||
#### Eventos de Ciclo de Vida do Contexto
|
||||
|
||||
- **A2AContextCreatedEvent**: Emitido quando um contexto A2A é criado. Contextos agrupam tarefas relacionadas em uma conversação ou workflow. Contém o ID do contexto e timestamp de criação.
|
||||
- **A2AContextExpiredEvent**: Emitido quando um contexto A2A expira devido ao TTL. Contém o ID do contexto, timestamp de criação, idade em segundos e contagem de tarefas.
|
||||
- **A2AContextIdleEvent**: Emitido quando um contexto A2A fica inativo (sem atividade pelo threshold configurado). Contém o ID do contexto, tempo de inatividade em segundos e contagem de tarefas.
|
||||
- **A2AContextCompletedEvent**: Emitido quando todas as tarefas em um contexto A2A são concluídas. Contém o ID do contexto, total de tarefas e duração em segundos.
|
||||
- **A2AContextPrunedEvent**: Emitido quando um contexto A2A é podado (deletado). Contém o ID do contexto, contagem de tarefas e idade em segundos.
|
||||
|
||||
## Estrutura dos Handlers de Evento
|
||||
|
||||
|
||||
114
docs/pt-BR/concepts/skills.mdx
Normal file
114
docs/pt-BR/concepts/skills.mdx
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: Skills
|
||||
description: Pacotes de skills baseados em sistema de arquivos que injetam contexto nos prompts dos agentes.
|
||||
icon: bolt
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Visão Geral
|
||||
|
||||
Skills são diretórios autocontidos que fornecem aos agentes instruções, referências e assets específicos de domínio. Cada skill é definida por um arquivo `SKILL.md` com frontmatter YAML e um corpo em markdown.
|
||||
|
||||
Skills usam **divulgação progressiva** — metadados são carregados primeiro, instruções completas apenas quando ativadas, e catálogos de recursos apenas quando necessário.
|
||||
|
||||
## Estrutura de Diretório
|
||||
|
||||
```
|
||||
my-skill/
|
||||
├── SKILL.md # Obrigatório — frontmatter + instruções
|
||||
├── scripts/ # Opcional — scripts executáveis
|
||||
├── references/ # Opcional — documentos de referência
|
||||
└── assets/ # Opcional — arquivos estáticos (configs, dados)
|
||||
```
|
||||
|
||||
O nome do diretório deve corresponder ao campo `name` no `SKILL.md`.
|
||||
|
||||
## Formato do SKILL.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: Descrição curta do que esta skill faz e quando usá-la.
|
||||
license: Apache-2.0 # opcional
|
||||
compatibility: crewai>=0.1.0 # opcional
|
||||
metadata: # opcional
|
||||
author: your-name
|
||||
version: "1.0"
|
||||
allowed-tools: web-search file-read # opcional, delimitado por espaços
|
||||
---
|
||||
|
||||
Instruções para o agente vão aqui. Este corpo em markdown é injetado
|
||||
no prompt do agente quando a skill é ativada.
|
||||
```
|
||||
|
||||
### Campos do Frontmatter
|
||||
|
||||
| Campo | Obrigatório | Restrições |
|
||||
| :-------------- | :---------- | :----------------------------------------------------------------------- |
|
||||
| `name` | Sim | 1–64 chars. Alfanumérico minúsculo e hifens. Sem hifens iniciais/finais/consecutivos. Deve corresponder ao nome do diretório. |
|
||||
| `description` | Sim | 1–1024 chars. Descreve o que a skill faz e quando usá-la. |
|
||||
| `license` | Não | Nome da licença ou referência a um arquivo de licença incluído. |
|
||||
| `compatibility` | Não | Máx 500 chars. Requisitos de ambiente (produtos, pacotes, rede). |
|
||||
| `metadata` | Não | Mapeamento arbitrário de chave-valor string. |
|
||||
| `allowed-tools` | Não | Lista de ferramentas pré-aprovadas delimitada por espaços. Experimental. |
|
||||
|
||||
## Uso
|
||||
|
||||
### Skills no Nível do Agente
|
||||
|
||||
Passe caminhos de diretório de skills para um agente:
|
||||
|
||||
```python
|
||||
from crewai import Agent
|
||||
|
||||
agent = Agent(
|
||||
role="Researcher",
|
||||
goal="Find relevant information",
|
||||
backstory="An expert researcher.",
|
||||
skills=["./skills"], # descobre todas as skills neste diretório
|
||||
)
|
||||
```
|
||||
|
||||
### Skills no Nível do Crew
|
||||
|
||||
Caminhos de skills no crew são mesclados em todos os agentes:
|
||||
|
||||
```python
|
||||
from crewai import Crew
|
||||
|
||||
crew = Crew(
|
||||
agents=[agent],
|
||||
tasks=[task],
|
||||
skills=["./skills"],
|
||||
)
|
||||
```
|
||||
|
||||
### Skills Pré-carregadas
|
||||
|
||||
Você também pode passar objetos `Skill` diretamente:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from crewai.skills import discover_skills, activate_skill
|
||||
|
||||
skills = discover_skills(Path("./skills"))
|
||||
activated = [activate_skill(s) for s in skills]
|
||||
|
||||
agent = Agent(
|
||||
role="Researcher",
|
||||
goal="Find relevant information",
|
||||
backstory="An expert researcher.",
|
||||
skills=activated,
|
||||
)
|
||||
```
|
||||
|
||||
## Como as Skills São Carregadas
|
||||
|
||||
Skills carregam progressivamente — apenas os dados necessários em cada etapa são lidos:
|
||||
|
||||
| Etapa | O que é carregado | Quando |
|
||||
| :--------------- | :------------------------------------------------ | :------------------ |
|
||||
| Descoberta | Nome, descrição, campos do frontmatter | `discover_skills()` |
|
||||
| Ativação | Texto completo do corpo do SKILL.md | `activate_skill()` |
|
||||
|
||||
Durante a execução normal do agente, skills são automaticamente descobertas e ativadas. Os diretórios `scripts/`, `references/` e `assets/` estão disponíveis no `path` da skill para agentes que precisam referenciar arquivos diretamente.
|
||||
61
docs/pt-BR/guides/coding-tools/agents-md.mdx
Normal file
61
docs/pt-BR/guides/coding-tools/agents-md.mdx
Normal 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.
|
||||
244
docs/pt-BR/guides/tools/publish-custom-tools.mdx
Normal file
244
docs/pt-BR/guides/tools/publish-custom-tools.mdx
Normal 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()],
|
||||
# ...
|
||||
)
|
||||
```
|
||||
@@ -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`.
|
||||
|
||||
@@ -9,11 +9,11 @@ authors = [
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"Pillow~=12.1.1",
|
||||
"pypdf~=6.7.5",
|
||||
"pypdf~=6.9.1",
|
||||
"python-magic>=0.4.27",
|
||||
"aiocache~=0.12.3",
|
||||
"aiofiles~=24.1.0",
|
||||
"tinytag~=1.10.0",
|
||||
"tinytag~=2.2.1",
|
||||
"av~=13.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.11.0"
|
||||
__version__ = "1.11.1"
|
||||
|
||||
@@ -11,7 +11,7 @@ dependencies = [
|
||||
"pytube~=15.0.0",
|
||||
"requests~=2.32.5",
|
||||
"docker~=7.1.0",
|
||||
"crewai==1.11.0",
|
||||
"crewai==1.11.1",
|
||||
"tiktoken~=0.8.0",
|
||||
"beautifulsoup4~=4.13.4",
|
||||
"python-docx~=1.2.0",
|
||||
|
||||
@@ -309,4 +309,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.11.0"
|
||||
__version__ = "1.11.1"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""PDF loader for extracting text from PDF files."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
import urllib.request
|
||||
|
||||
import requests
|
||||
|
||||
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
|
||||
from crewai_tools.rag.source_content import SourceContent
|
||||
@@ -23,22 +25,34 @@ class PDFLoader(BaseLoader):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _download_pdf(url: str) -> bytes:
|
||||
"""Download PDF content from a URL.
|
||||
def _download_from_url(url: str, kwargs: dict) -> str:
|
||||
"""Download PDF from a URL to a temporary file and return its path.
|
||||
|
||||
Args:
|
||||
url: The URL to download from.
|
||||
kwargs: Optional dict that may contain custom headers.
|
||||
|
||||
Returns:
|
||||
The PDF content as bytes.
|
||||
Path to the temporary file containing the PDF.
|
||||
|
||||
Raises:
|
||||
ValueError: If the download fails.
|
||||
"""
|
||||
headers = kwargs.get(
|
||||
"headers",
|
||||
{
|
||||
"Accept": "application/pdf",
|
||||
"User-Agent": "Mozilla/5.0 (compatible; crewai-tools PDFLoader)",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
|
||||
return cast(bytes, response.read())
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file:
|
||||
temp_file.write(response.content)
|
||||
return temp_file.name
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to download PDF from {url}: {e!s}") from e
|
||||
|
||||
@@ -80,8 +94,8 @@ class PDFLoader(BaseLoader):
|
||||
|
||||
try:
|
||||
if is_url:
|
||||
pdf_bytes = self._download_pdf(file_path)
|
||||
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
|
||||
local_path = self._download_from_url(file_path, kwargs)
|
||||
doc = pymupdf.open(local_path)
|
||||
else:
|
||||
if not os.path.isfile(file_path):
|
||||
raise FileNotFoundError(f"PDF file not found: {file_path}")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -42,6 +42,7 @@ dependencies = [
|
||||
"mcp~=1.26.0",
|
||||
"uv~=0.9.13",
|
||||
"aiosqlite~=0.21.0",
|
||||
"pyyaml~=6.0",
|
||||
"lancedb>=0.29.2",
|
||||
]
|
||||
|
||||
@@ -53,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tools = [
|
||||
"crewai-tools==1.11.0",
|
||||
"crewai-tools==1.11.1",
|
||||
]
|
||||
embeddings = [
|
||||
"tiktoken~=0.8.0"
|
||||
|
||||
@@ -42,7 +42,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
|
||||
|
||||
_suppress_pydantic_deprecation_warnings()
|
||||
|
||||
__version__ = "1.11.0"
|
||||
__version__ = "1.11.1"
|
||||
_telemetry_submitted = False
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine, Sequence
|
||||
import contextvars
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
@@ -26,6 +27,7 @@ from typing_extensions import Self
|
||||
from crewai.agent.planning_config import PlanningConfig
|
||||
from crewai.agent.utils import (
|
||||
ahandle_knowledge_retrieval,
|
||||
append_skill_context,
|
||||
apply_training_data,
|
||||
build_task_prompt_with_schema,
|
||||
format_task_with_context,
|
||||
@@ -65,7 +67,10 @@ from crewai.mcp import MCPServerConfig
|
||||
from crewai.mcp.tool_resolver import MCPToolResolver
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
from crewai.security.fingerprint import Fingerprint
|
||||
from crewai.skills.loader import activate_skill, discover_skills
|
||||
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
|
||||
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 +80,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 +148,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 +156,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(
|
||||
@@ -276,6 +282,8 @@ class Agent(BaseAgent):
|
||||
if self.allow_code_execution:
|
||||
self._validate_docker_installation()
|
||||
|
||||
self.set_skills()
|
||||
|
||||
# Handle backward compatibility: convert reasoning=True to planning_config
|
||||
if self.reasoning and self.planning_config is None:
|
||||
import warnings
|
||||
@@ -319,6 +327,76 @@ class Agent(BaseAgent):
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"Invalid Knowledge Configuration: {e!s}") from e
|
||||
|
||||
def set_skills(
|
||||
self,
|
||||
resolved_crew_skills: list[SkillModel] | None = None,
|
||||
) -> None:
|
||||
"""Resolve skill paths and activate skills to INSTRUCTIONS level.
|
||||
|
||||
Path entries trigger discovery and activation. Pre-loaded Skill objects
|
||||
below INSTRUCTIONS level are activated. Crew-level skills are merged in
|
||||
with event emission so observability is consistent regardless of origin.
|
||||
|
||||
Args:
|
||||
resolved_crew_skills: Pre-resolved crew skills (already discovered
|
||||
and activated). When provided, avoids redundant discovery per agent.
|
||||
"""
|
||||
from crewai.crew import Crew
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.skill_events import SkillActivatedEvent
|
||||
|
||||
if resolved_crew_skills is None:
|
||||
crew_skills: list[Path | SkillModel] | None = (
|
||||
self.crew.skills
|
||||
if isinstance(self.crew, Crew) and isinstance(self.crew.skills, list)
|
||||
else None
|
||||
)
|
||||
else:
|
||||
crew_skills = list(resolved_crew_skills)
|
||||
|
||||
if not self.skills and not crew_skills:
|
||||
return
|
||||
|
||||
needs_work = self.skills and any(
|
||||
isinstance(s, Path)
|
||||
or (isinstance(s, SkillModel) and s.disclosure_level < INSTRUCTIONS)
|
||||
for s in self.skills
|
||||
)
|
||||
if not needs_work and not crew_skills:
|
||||
return
|
||||
|
||||
seen: set[str] = set()
|
||||
resolved: list[Path | SkillModel] = []
|
||||
items: list[Path | SkillModel] = list(self.skills) if self.skills else []
|
||||
|
||||
if crew_skills:
|
||||
items.extend(crew_skills)
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, Path):
|
||||
discovered = discover_skills(item, source=self)
|
||||
for skill in discovered:
|
||||
if skill.name not in seen:
|
||||
seen.add(skill.name)
|
||||
resolved.append(activate_skill(skill, source=self))
|
||||
elif isinstance(item, SkillModel):
|
||||
if item.name not in seen:
|
||||
seen.add(item.name)
|
||||
activated = activate_skill(item, source=self)
|
||||
if activated is item and item.disclosure_level >= INSTRUCTIONS:
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
event=SkillActivatedEvent(
|
||||
from_agent=self,
|
||||
skill_name=item.name,
|
||||
skill_path=item.path,
|
||||
disclosure_level=item.disclosure_level,
|
||||
),
|
||||
)
|
||||
resolved.append(activated)
|
||||
|
||||
self.skills = resolved if resolved else None
|
||||
|
||||
def _is_any_available_memory(self) -> bool:
|
||||
"""Check if unified memory is available (agent or crew)."""
|
||||
if getattr(self, "memory", None):
|
||||
@@ -339,7 +417,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 +442,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:
|
||||
@@ -439,6 +518,8 @@ class Agent(BaseAgent):
|
||||
self.crew.query_knowledge if self.crew else lambda *a, **k: None,
|
||||
)
|
||||
|
||||
task_prompt = append_skill_context(self, task_prompt)
|
||||
|
||||
prepare_tools(self, tools, task)
|
||||
task_prompt = apply_training_data(self, task_prompt)
|
||||
|
||||
@@ -679,6 +760,8 @@ class Agent(BaseAgent):
|
||||
self, task, task_prompt, knowledge_config
|
||||
)
|
||||
|
||||
task_prompt = append_skill_context(self, task_prompt)
|
||||
|
||||
prepare_tools(self, tools, task)
|
||||
task_prompt = apply_training_data(self, task_prompt)
|
||||
|
||||
@@ -1340,6 +1423,8 @@ class Agent(BaseAgent):
|
||||
),
|
||||
)
|
||||
|
||||
formatted_messages = append_skill_context(self, formatted_messages)
|
||||
|
||||
# Build the input dict for the executor
|
||||
inputs: dict[str, Any] = {
|
||||
"input": formatted_messages,
|
||||
|
||||
@@ -210,6 +210,30 @@ def _combine_knowledge_context(agent: Agent) -> str:
|
||||
return agent_ctx + separator + crew_ctx
|
||||
|
||||
|
||||
def append_skill_context(agent: Agent, task_prompt: str) -> str:
|
||||
"""Append activated skill context sections to the task prompt.
|
||||
|
||||
Args:
|
||||
agent: The agent with optional skills.
|
||||
task_prompt: The current task prompt.
|
||||
|
||||
Returns:
|
||||
The task prompt with skill context appended.
|
||||
"""
|
||||
if not agent.skills:
|
||||
return task_prompt
|
||||
|
||||
from crewai.skills.loader import format_skill_context
|
||||
from crewai.skills.models import Skill
|
||||
|
||||
skill_sections = [
|
||||
format_skill_context(s) for s in agent.skills if isinstance(s, Skill)
|
||||
]
|
||||
if skill_sections:
|
||||
task_prompt += "\n\n" + "\n\n".join(skill_sections)
|
||||
return task_prompt
|
||||
|
||||
|
||||
def apply_training_data(agent: Agent, task_prompt: str) -> str:
|
||||
"""Apply training data to the task prompt.
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Final, Literal
|
||||
import uuid
|
||||
@@ -12,6 +12,7 @@ from pydantic import (
|
||||
UUID4,
|
||||
BaseModel,
|
||||
Field,
|
||||
InstanceOf,
|
||||
PrivateAttr,
|
||||
field_validator,
|
||||
model_validator,
|
||||
@@ -26,10 +27,15 @@ 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.skills.models import Skill
|
||||
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 +185,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 +193,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 +211,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(), "
|
||||
@@ -213,6 +219,11 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
"If not set, falls back to crew memory."
|
||||
),
|
||||
)
|
||||
skills: list[Path | Skill] | None = Field(
|
||||
default=None,
|
||||
description="Agent Skills. Accepts paths for discovery or pre-loaded Skill objects.",
|
||||
min_length=1,
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@@ -496,3 +507,6 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
|
||||
def set_knowledge(self, crew_embedder: EmbedderConfig | None = None) -> None:
|
||||
pass
|
||||
|
||||
def set_skills(self, resolved_crew_skills: list[Any] | None = None) -> None:
|
||||
pass
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crewai.agents.parser import AgentFinish
|
||||
from crewai.memory.utils import sanitize_scope_name
|
||||
from crewai.utilities.printer import Printer
|
||||
from crewai.utilities.string_utils import sanitize_tool_name
|
||||
|
||||
@@ -26,7 +27,12 @@ class CrewAgentExecutorMixin:
|
||||
_printer: Printer = Printer()
|
||||
|
||||
def _save_to_memory(self, output: AgentFinish) -> None:
|
||||
"""Save task result to unified memory (memory or crew._memory)."""
|
||||
"""Save task result to unified memory (memory or crew._memory).
|
||||
|
||||
Extends the memory's root_scope with agent-specific path segment
|
||||
(e.g., '/crew/research-crew/agent/researcher') so that agent memories
|
||||
are scoped hierarchically under their crew.
|
||||
"""
|
||||
memory = getattr(self.agent, "memory", None) or (
|
||||
getattr(self.crew, "_memory", None) if self.crew else None
|
||||
)
|
||||
@@ -43,6 +49,21 @@ class CrewAgentExecutorMixin:
|
||||
)
|
||||
extracted = memory.extract_memories(raw)
|
||||
if extracted:
|
||||
memory.remember_many(extracted, agent_role=self.agent.role)
|
||||
# Get the memory's existing root_scope
|
||||
base_root = getattr(memory, "root_scope", None)
|
||||
|
||||
if isinstance(base_root, str) and base_root:
|
||||
# Memory has a root_scope — extend it with agent info
|
||||
agent_role = self.agent.role or "unknown"
|
||||
sanitized_role = sanitize_scope_name(agent_role)
|
||||
agent_root = f"{base_root.rstrip('/')}/agent/{sanitized_role}"
|
||||
if not agent_root.startswith("/"):
|
||||
agent_root = "/" + agent_root
|
||||
memory.remember_many(
|
||||
extracted, agent_role=self.agent.role, root_scope=agent_root
|
||||
)
|
||||
else:
|
||||
# No base root_scope — don't inject one, preserve backward compat
|
||||
memory.remember_many(extracted, agent_role=self.agent.role)
|
||||
except Exception as e:
|
||||
self.agent._logger.log("error", f"Failed to save to memory: {e}")
|
||||
|
||||
@@ -22,6 +22,7 @@ from crewai.cli.replay_from_task import replay_task_command
|
||||
from crewai.cli.reset_memories_command import reset_memories_command
|
||||
from crewai.cli.run_crew import run_crew
|
||||
from crewai.cli.settings.main import SettingsCommand
|
||||
from crewai.cli.shared.token_manager import TokenManager
|
||||
from crewai.cli.tools.main import ToolCommand
|
||||
from crewai.cli.train_crew import train_crew
|
||||
from crewai.cli.triggers.main import TriggersCommand
|
||||
@@ -34,7 +35,7 @@ from crewai.memory.storage.kickoff_task_outputs_storage import (
|
||||
|
||||
@click.group()
|
||||
@click.version_option(get_version("crewai"))
|
||||
def crewai():
|
||||
def crewai() -> None:
|
||||
"""Top-level command group for crewai."""
|
||||
|
||||
|
||||
@@ -45,7 +46,7 @@ def crewai():
|
||||
),
|
||||
)
|
||||
@click.argument("uv_args", nargs=-1, type=click.UNPROCESSED)
|
||||
def uv(uv_args):
|
||||
def uv(uv_args: tuple[str, ...]) -> None:
|
||||
"""A wrapper around uv commands that adds custom tool authentication through env vars."""
|
||||
env = os.environ.copy()
|
||||
try:
|
||||
@@ -83,7 +84,9 @@ def uv(uv_args):
|
||||
@click.argument("name")
|
||||
@click.option("--provider", type=str, help="The provider to use for the crew")
|
||||
@click.option("--skip_provider", is_flag=True, help="Skip provider validation")
|
||||
def create(type, name, provider, skip_provider=False):
|
||||
def create(
|
||||
type: str, name: str, provider: str | None, skip_provider: bool = False
|
||||
) -> None:
|
||||
"""Create a new crew, or flow."""
|
||||
if type == "crew":
|
||||
create_crew(name, provider, skip_provider)
|
||||
@@ -97,7 +100,7 @@ def create(type, name, provider, skip_provider=False):
|
||||
@click.option(
|
||||
"--tools", is_flag=True, help="Show the installed version of crewai tools"
|
||||
)
|
||||
def version(tools):
|
||||
def version(tools: bool) -> None:
|
||||
"""Show the installed version of crewai."""
|
||||
try:
|
||||
crewai_version = get_version("crewai")
|
||||
@@ -128,7 +131,7 @@ def version(tools):
|
||||
default="trained_agents_data.pkl",
|
||||
help="Path to a custom file for training",
|
||||
)
|
||||
def train(n_iterations: int, filename: str):
|
||||
def train(n_iterations: int, filename: str) -> None:
|
||||
"""Train the crew."""
|
||||
click.echo(f"Training the Crew for {n_iterations} iterations")
|
||||
train_crew(n_iterations, filename)
|
||||
@@ -334,7 +337,7 @@ def memory(
|
||||
default="gpt-4o-mini",
|
||||
help="LLM Model to run the tests on the Crew. For now only accepting only OpenAI models.",
|
||||
)
|
||||
def test(n_iterations: int, model: str):
|
||||
def test(n_iterations: int, model: str) -> None:
|
||||
"""Test the crew and evaluate the results."""
|
||||
click.echo(f"Testing the crew for {n_iterations} iterations with model {model}")
|
||||
evaluate_crew(n_iterations, model)
|
||||
@@ -347,46 +350,62 @@ def test(n_iterations: int, model: str):
|
||||
)
|
||||
)
|
||||
@click.pass_context
|
||||
def install(context):
|
||||
def install(context: click.Context) -> None:
|
||||
"""Install the Crew."""
|
||||
install_crew(context.args)
|
||||
|
||||
|
||||
@crewai.command()
|
||||
def run():
|
||||
def run() -> None:
|
||||
"""Run the Crew."""
|
||||
run_crew()
|
||||
|
||||
|
||||
@crewai.command()
|
||||
def update():
|
||||
def update() -> None:
|
||||
"""Update the pyproject.toml of the Crew project to use uv."""
|
||||
update_crew()
|
||||
|
||||
|
||||
@crewai.command()
|
||||
def login():
|
||||
def login() -> None:
|
||||
"""Sign Up/Login to CrewAI AMP."""
|
||||
Settings().clear_user_settings()
|
||||
AuthenticationCommand().login()
|
||||
|
||||
|
||||
@crewai.command()
|
||||
@click.option(
|
||||
"--reset", is_flag=True, help="Also reset all CLI configuration to defaults"
|
||||
)
|
||||
def logout(reset: bool) -> None:
|
||||
"""Logout from CrewAI AMP."""
|
||||
settings = Settings()
|
||||
if reset:
|
||||
settings.reset()
|
||||
click.echo("Successfully logged out and reset all CLI configuration.")
|
||||
else:
|
||||
TokenManager().clear_tokens()
|
||||
settings.clear_user_settings()
|
||||
click.echo("Successfully logged out from CrewAI AMP.")
|
||||
|
||||
|
||||
# DEPLOY CREWAI+ COMMANDS
|
||||
@crewai.group()
|
||||
def deploy():
|
||||
def deploy() -> None:
|
||||
"""Deploy the Crew CLI group."""
|
||||
|
||||
|
||||
@deploy.command(name="create")
|
||||
@click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt")
|
||||
def deploy_create(yes: bool):
|
||||
def deploy_create(yes: bool) -> None:
|
||||
"""Create a Crew deployment."""
|
||||
deploy_cmd = DeployCommand()
|
||||
deploy_cmd.create_crew(yes)
|
||||
|
||||
|
||||
@deploy.command(name="list")
|
||||
def deploy_list():
|
||||
def deploy_list() -> None:
|
||||
"""List all deployments."""
|
||||
deploy_cmd = DeployCommand()
|
||||
deploy_cmd.list_crews()
|
||||
@@ -394,7 +413,7 @@ def deploy_list():
|
||||
|
||||
@deploy.command(name="push")
|
||||
@click.option("-u", "--uuid", type=str, help="Crew UUID parameter")
|
||||
def deploy_push(uuid: str | None):
|
||||
def deploy_push(uuid: str | None) -> None:
|
||||
"""Deploy the Crew."""
|
||||
deploy_cmd = DeployCommand()
|
||||
deploy_cmd.deploy(uuid=uuid)
|
||||
@@ -402,7 +421,7 @@ def deploy_push(uuid: str | None):
|
||||
|
||||
@deploy.command(name="status")
|
||||
@click.option("-u", "--uuid", type=str, help="Crew UUID parameter")
|
||||
def deply_status(uuid: str | None):
|
||||
def deply_status(uuid: str | None) -> None:
|
||||
"""Get the status of a deployment."""
|
||||
deploy_cmd = DeployCommand()
|
||||
deploy_cmd.get_crew_status(uuid=uuid)
|
||||
@@ -410,7 +429,7 @@ def deply_status(uuid: str | None):
|
||||
|
||||
@deploy.command(name="logs")
|
||||
@click.option("-u", "--uuid", type=str, help="Crew UUID parameter")
|
||||
def deploy_logs(uuid: str | None):
|
||||
def deploy_logs(uuid: str | None) -> None:
|
||||
"""Get the logs of a deployment."""
|
||||
deploy_cmd = DeployCommand()
|
||||
deploy_cmd.get_crew_logs(uuid=uuid)
|
||||
@@ -418,27 +437,27 @@ def deploy_logs(uuid: str | None):
|
||||
|
||||
@deploy.command(name="remove")
|
||||
@click.option("-u", "--uuid", type=str, help="Crew UUID parameter")
|
||||
def deploy_remove(uuid: str | None):
|
||||
def deploy_remove(uuid: str | None) -> None:
|
||||
"""Remove a deployment."""
|
||||
deploy_cmd = DeployCommand()
|
||||
deploy_cmd.remove_crew(uuid=uuid)
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def tool():
|
||||
def tool() -> None:
|
||||
"""Tool Repository related commands."""
|
||||
|
||||
|
||||
@tool.command(name="create")
|
||||
@click.argument("handle")
|
||||
def tool_create(handle: str):
|
||||
def tool_create(handle: str) -> None:
|
||||
tool_cmd = ToolCommand()
|
||||
tool_cmd.create(handle)
|
||||
|
||||
|
||||
@tool.command(name="install")
|
||||
@click.argument("handle")
|
||||
def tool_install(handle: str):
|
||||
def tool_install(handle: str) -> None:
|
||||
tool_cmd = ToolCommand()
|
||||
tool_cmd.login()
|
||||
tool_cmd.install(handle)
|
||||
@@ -454,26 +473,26 @@ def tool_install(handle: str):
|
||||
)
|
||||
@click.option("--public", "is_public", flag_value=True, default=False)
|
||||
@click.option("--private", "is_public", flag_value=False)
|
||||
def tool_publish(is_public: bool, force: bool):
|
||||
def tool_publish(is_public: bool, force: bool) -> None:
|
||||
tool_cmd = ToolCommand()
|
||||
tool_cmd.login()
|
||||
tool_cmd.publish(is_public, force)
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def flow():
|
||||
def flow() -> None:
|
||||
"""Flow related commands."""
|
||||
|
||||
|
||||
@flow.command(name="kickoff")
|
||||
def flow_run():
|
||||
def flow_run() -> None:
|
||||
"""Kickoff the Flow."""
|
||||
click.echo("Running the Flow")
|
||||
kickoff_flow()
|
||||
|
||||
|
||||
@flow.command(name="plot")
|
||||
def flow_plot():
|
||||
def flow_plot() -> None:
|
||||
"""Plot the Flow."""
|
||||
click.echo("Plotting the Flow")
|
||||
plot_flow()
|
||||
@@ -481,19 +500,19 @@ def flow_plot():
|
||||
|
||||
@flow.command(name="add-crew")
|
||||
@click.argument("crew_name")
|
||||
def flow_add_crew(crew_name):
|
||||
def flow_add_crew(crew_name: str) -> None:
|
||||
"""Add a crew to an existing flow."""
|
||||
click.echo(f"Adding crew {crew_name} to the flow")
|
||||
add_crew_to_flow(crew_name)
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def triggers():
|
||||
def triggers() -> None:
|
||||
"""Trigger related commands. Use 'crewai triggers list' to see available triggers, or 'crewai triggers run app_slug/trigger_slug' to execute."""
|
||||
|
||||
|
||||
@triggers.command(name="list")
|
||||
def triggers_list():
|
||||
def triggers_list() -> None:
|
||||
"""List all available triggers from integrations."""
|
||||
triggers_cmd = TriggersCommand()
|
||||
triggers_cmd.list_triggers()
|
||||
@@ -501,14 +520,14 @@ def triggers_list():
|
||||
|
||||
@triggers.command(name="run")
|
||||
@click.argument("trigger_path")
|
||||
def triggers_run(trigger_path: str):
|
||||
def triggers_run(trigger_path: str) -> None:
|
||||
"""Execute crew with trigger payload. Format: app_slug/trigger_slug"""
|
||||
triggers_cmd = TriggersCommand()
|
||||
triggers_cmd.execute_with_trigger(trigger_path)
|
||||
|
||||
|
||||
@crewai.command()
|
||||
def chat():
|
||||
def chat() -> None:
|
||||
"""
|
||||
Start a conversation with the Crew, collecting user-supplied inputs,
|
||||
and using the Chat LLM to generate responses.
|
||||
@@ -521,12 +540,12 @@ def chat():
|
||||
|
||||
|
||||
@crewai.group(invoke_without_command=True)
|
||||
def org():
|
||||
def org() -> None:
|
||||
"""Organization management commands."""
|
||||
|
||||
|
||||
@org.command("list")
|
||||
def org_list():
|
||||
def org_list() -> None:
|
||||
"""List available organizations."""
|
||||
org_command = OrganizationCommand()
|
||||
org_command.list()
|
||||
@@ -534,39 +553,39 @@ def org_list():
|
||||
|
||||
@org.command()
|
||||
@click.argument("id")
|
||||
def switch(id):
|
||||
def switch(id: str) -> None:
|
||||
"""Switch to a specific organization."""
|
||||
org_command = OrganizationCommand()
|
||||
org_command.switch(id)
|
||||
|
||||
|
||||
@org.command()
|
||||
def current():
|
||||
def current() -> None:
|
||||
"""Show current organization when 'crewai org' is called without subcommands."""
|
||||
org_command = OrganizationCommand()
|
||||
org_command.current()
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def enterprise():
|
||||
def enterprise() -> None:
|
||||
"""Enterprise Configuration commands."""
|
||||
|
||||
|
||||
@enterprise.command("configure")
|
||||
@click.argument("enterprise_url")
|
||||
def enterprise_configure(enterprise_url: str):
|
||||
def enterprise_configure(enterprise_url: str) -> None:
|
||||
"""Configure CrewAI AMP OAuth2 settings from the provided Enterprise URL."""
|
||||
enterprise_command = EnterpriseConfigureCommand()
|
||||
enterprise_command.configure(enterprise_url)
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def config():
|
||||
def config() -> None:
|
||||
"""CLI Configuration commands."""
|
||||
|
||||
|
||||
@config.command("list")
|
||||
def config_list():
|
||||
def config_list() -> None:
|
||||
"""List all CLI configuration parameters."""
|
||||
config_command = SettingsCommand()
|
||||
config_command.list()
|
||||
@@ -575,26 +594,26 @@ def config_list():
|
||||
@config.command("set")
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
def config_set(key: str, value: str):
|
||||
def config_set(key: str, value: str) -> None:
|
||||
"""Set a CLI configuration parameter."""
|
||||
config_command = SettingsCommand()
|
||||
config_command.set(key, value)
|
||||
|
||||
|
||||
@config.command("reset")
|
||||
def config_reset():
|
||||
def config_reset() -> None:
|
||||
"""Reset all CLI configuration parameters to default values."""
|
||||
config_command = SettingsCommand()
|
||||
config_command.reset_all_settings()
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def env():
|
||||
def env() -> None:
|
||||
"""Environment variable commands."""
|
||||
|
||||
|
||||
@env.command("view")
|
||||
def env_view():
|
||||
def env_view() -> None:
|
||||
"""View tracing-related environment variables."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -672,12 +691,12 @@ def env_view():
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def traces():
|
||||
def traces() -> None:
|
||||
"""Trace collection management commands."""
|
||||
|
||||
|
||||
@traces.command("enable")
|
||||
def traces_enable():
|
||||
def traces_enable() -> None:
|
||||
"""Enable trace collection for crew/flow executions."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -700,7 +719,7 @@ def traces_enable():
|
||||
|
||||
|
||||
@traces.command("disable")
|
||||
def traces_disable():
|
||||
def traces_disable() -> None:
|
||||
"""Disable trace collection for crew/flow executions."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -723,7 +742,7 @@ def traces_disable():
|
||||
|
||||
|
||||
@traces.command("status")
|
||||
def traces_status():
|
||||
def traces_status() -> None:
|
||||
"""Show current trace collection status."""
|
||||
import os
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import click
|
||||
from crewai.telemetry import Telemetry
|
||||
|
||||
|
||||
def create_flow(name):
|
||||
def create_flow(name: str) -> None:
|
||||
"""Create a new flow."""
|
||||
folder_name = name.replace(" ", "_").replace("-", "_").lower()
|
||||
class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "")
|
||||
@@ -49,7 +49,7 @@ def create_flow(name):
|
||||
"poem_crew",
|
||||
]
|
||||
|
||||
def process_file(src_file, dst_file):
|
||||
def process_file(src_file: Path, dst_file: Path) -> None:
|
||||
if src_file.suffix in [".pyc", ".pyo", ".pyd"]:
|
||||
return
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
A class to handle deployment-related operations for CrewAI projects.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Initialize the DeployCommand with project name and API client.
|
||||
"""
|
||||
@@ -67,7 +67,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
Args:
|
||||
uuid (Optional[str]): The UUID of the crew to deploy.
|
||||
"""
|
||||
self._start_deployment_span = self._telemetry.start_deployment_span(uuid)
|
||||
self._telemetry.start_deployment_span(uuid)
|
||||
console.print("Starting deployment...", style="bold blue")
|
||||
if uuid:
|
||||
response = self.plus_api_client.deploy_by_uuid(uuid)
|
||||
@@ -84,9 +84,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
"""
|
||||
Create a new crew deployment.
|
||||
"""
|
||||
self._create_crew_deployment_span = (
|
||||
self._telemetry.create_crew_deployment_span()
|
||||
)
|
||||
self._telemetry.create_crew_deployment_span()
|
||||
console.print("Creating deployment...", style="bold blue")
|
||||
env_vars = fetch_and_json_env_file()
|
||||
|
||||
@@ -236,7 +234,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
uuid (Optional[str]): The UUID of the crew to get logs for.
|
||||
log_type (str): The type of logs to retrieve (default: "deployment").
|
||||
"""
|
||||
self._get_crew_logs_span = self._telemetry.get_crew_logs_span(uuid, log_type)
|
||||
self._telemetry.get_crew_logs_span(uuid, log_type)
|
||||
console.print(f"Fetching {log_type} logs...", style="bold blue")
|
||||
|
||||
if uuid:
|
||||
@@ -257,7 +255,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
Args:
|
||||
uuid (Optional[str]): The UUID of the crew to remove.
|
||||
"""
|
||||
self._remove_crew_span = self._telemetry.remove_crew_span(uuid)
|
||||
self._telemetry.remove_crew_span(uuid)
|
||||
console.print("Removing deployment...", style="bold blue")
|
||||
|
||||
if uuid:
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
|
||||
authors = [{ name = "Your Name", email = "you@example.com" }]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.11.0"
|
||||
"crewai[tools]==1.11.1"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
|
||||
authors = [{ name = "Your Name", email = "you@example.com" }]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.11.0"
|
||||
"crewai[tools]==1.11.1"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.11.0"
|
||||
"crewai[tools]==1.11.1"
|
||||
]
|
||||
|
||||
[tool.crewai]
|
||||
|
||||
@@ -16,7 +16,7 @@ class TriggersCommand(BaseCommand, PlusAPIMixin):
|
||||
A class to handle trigger-related operations for CrewAI projects.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
BaseCommand.__init__(self)
|
||||
PlusAPIMixin.__init__(self, telemetry=self._telemetry)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from concurrent.futures import Future
|
||||
from copy import copy as shallow_copy
|
||||
from hashlib import md5
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
@@ -35,6 +36,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,21 +85,26 @@ 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
|
||||
from crewai.security.fingerprint import Fingerprint
|
||||
from crewai.security.security_config import SecurityConfig
|
||||
from crewai.skills.models import Skill
|
||||
from crewai.task import Task
|
||||
from crewai.tasks.conditional_task import ConditionalTask
|
||||
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 +172,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 +195,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 +210,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. "
|
||||
@@ -291,6 +296,11 @@ class Crew(FlowTrackable, BaseModel):
|
||||
default=None,
|
||||
description="Knowledge for the crew.",
|
||||
)
|
||||
skills: list[Path | Skill] | None = Field(
|
||||
default=None,
|
||||
description="Skill search paths or pre-loaded Skill objects applied to all agents in the crew.",
|
||||
)
|
||||
|
||||
security_config: SecurityConfig = Field(
|
||||
default_factory=SecurityConfig,
|
||||
description="Security configuration for the crew, including fingerprinting.",
|
||||
@@ -348,13 +358,24 @@ 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
|
||||
|
||||
@model_validator(mode="after")
|
||||
def create_crew_memory(self) -> Crew:
|
||||
"""Initialize unified memory, respecting crew embedder config."""
|
||||
"""Initialize unified memory, respecting crew embedder config.
|
||||
|
||||
When memory is enabled, sets a hierarchical root_scope based on the
|
||||
crew name (e.g. '/crew/research-crew') so that all memories saved by
|
||||
this crew and its agents are organized under a consistent namespace.
|
||||
"""
|
||||
from crewai.memory.utils import sanitize_scope_name
|
||||
|
||||
# Compute sanitized crew name for root_scope
|
||||
crew_name = sanitize_scope_name(self.name or "crew")
|
||||
crew_root_scope = f"/crew/{crew_name}"
|
||||
|
||||
if self.memory is True:
|
||||
from crewai.memory.unified_memory import Memory
|
||||
|
||||
@@ -362,10 +383,11 @@ class Crew(FlowTrackable, BaseModel):
|
||||
if self.embedder is not None:
|
||||
from crewai.rag.embeddings.factory import build_embedder
|
||||
|
||||
embedder = build_embedder(self.embedder)
|
||||
self._memory = Memory(embedder=embedder)
|
||||
embedder = build_embedder(cast(dict[str, Any], self.embedder))
|
||||
self._memory = Memory(embedder=embedder, root_scope=crew_root_scope)
|
||||
elif self.memory:
|
||||
# User passed a Memory / MemoryScope / MemorySlice instance
|
||||
# Respect user's configuration — don't auto-set root_scope
|
||||
self._memory = self.memory
|
||||
else:
|
||||
self._memory = None
|
||||
@@ -679,6 +701,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()
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from opentelemetry import baggage
|
||||
@@ -11,6 +12,8 @@ from opentelemetry import baggage
|
||||
from crewai.agents.agent_builder.base_agent import BaseAgent
|
||||
from crewai.crews.crew_output import CrewOutput
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
from crewai.skills.loader import activate_skill, discover_skills
|
||||
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
|
||||
from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput
|
||||
from crewai.utilities.file_store import store_files
|
||||
from crewai.utilities.streaming import (
|
||||
@@ -51,6 +54,30 @@ def enable_agent_streaming(agents: Iterable[BaseAgent]) -> None:
|
||||
agent.llm.stream = True
|
||||
|
||||
|
||||
def _resolve_crew_skills(crew: Crew) -> list[SkillModel] | None:
|
||||
"""Resolve crew-level skill paths once so agents don't repeat the work."""
|
||||
if not isinstance(crew.skills, list) or not crew.skills:
|
||||
return None
|
||||
|
||||
resolved: list[SkillModel] = []
|
||||
seen: set[str] = set()
|
||||
for item in crew.skills:
|
||||
if isinstance(item, Path):
|
||||
for skill in discover_skills(item):
|
||||
if skill.name not in seen:
|
||||
seen.add(skill.name)
|
||||
resolved.append(activate_skill(skill))
|
||||
elif isinstance(item, SkillModel):
|
||||
if item.name not in seen:
|
||||
seen.add(item.name)
|
||||
resolved.append(
|
||||
activate_skill(item)
|
||||
if item.disclosure_level < INSTRUCTIONS
|
||||
else item
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def setup_agents(
|
||||
crew: Crew,
|
||||
agents: Iterable[BaseAgent],
|
||||
@@ -67,9 +94,12 @@ def setup_agents(
|
||||
function_calling_llm: Default function calling LLM for agents.
|
||||
step_callback: Default step callback for agents.
|
||||
"""
|
||||
resolved_crew_skills = _resolve_crew_skills(crew)
|
||||
|
||||
for agent in agents:
|
||||
agent.crew = crew
|
||||
agent.set_knowledge(crew_embedder=embedder)
|
||||
agent.set_skills(resolved_crew_skills=resolved_crew_skills)
|
||||
if not agent.function_calling_llm: # type: ignore[attr-defined]
|
||||
agent.function_calling_llm = function_calling_llm # type: ignore[attr-defined]
|
||||
if not agent.step_callback: # type: ignore[attr-defined]
|
||||
|
||||
@@ -88,6 +88,14 @@ from crewai.events.types.reasoning_events import (
|
||||
AgentReasoningStartedEvent,
|
||||
ReasoningEvent,
|
||||
)
|
||||
from crewai.events.types.skill_events import (
|
||||
SkillActivatedEvent,
|
||||
SkillDiscoveryCompletedEvent,
|
||||
SkillDiscoveryStartedEvent,
|
||||
SkillEvent,
|
||||
SkillLoadFailedEvent,
|
||||
SkillLoadedEvent,
|
||||
)
|
||||
from crewai.events.types.task_events import (
|
||||
TaskCompletedEvent,
|
||||
TaskEvaluationEvent,
|
||||
@@ -186,6 +194,12 @@ __all__ = [
|
||||
"MethodExecutionFinishedEvent",
|
||||
"MethodExecutionStartedEvent",
|
||||
"ReasoningEvent",
|
||||
"SkillActivatedEvent",
|
||||
"SkillDiscoveryCompletedEvent",
|
||||
"SkillDiscoveryStartedEvent",
|
||||
"SkillEvent",
|
||||
"SkillLoadFailedEvent",
|
||||
"SkillLoadedEvent",
|
||||
"TaskCompletedEvent",
|
||||
"TaskEvaluationEvent",
|
||||
"TaskFailedEvent",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import uuid
|
||||
import webbrowser
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -100,20 +101,48 @@ class FirstTimeTraceHandler:
|
||||
user_context=user_context,
|
||||
execution_metadata=execution_metadata,
|
||||
use_ephemeral=True,
|
||||
skip_context_check=True,
|
||||
)
|
||||
|
||||
if not self.batch_manager.trace_batch_id:
|
||||
self._gracefully_fail("Backend batch creation failed, cannot send events.")
|
||||
self._reset_batch_state()
|
||||
return
|
||||
|
||||
self.batch_manager.backend_initialized = True
|
||||
|
||||
if self.batch_manager.event_buffer:
|
||||
self.batch_manager._send_events_to_backend()
|
||||
# Capture values before send/finalize consume them
|
||||
events_count = len(self.batch_manager.event_buffer)
|
||||
batch_id = self.batch_manager.trace_batch_id
|
||||
# Read duration non-destructively — _finalize_backend_batch will consume it
|
||||
start_time = self.batch_manager.execution_start_times.get("execution")
|
||||
duration_ms = (
|
||||
int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
if start_time
|
||||
else 0
|
||||
)
|
||||
|
||||
self.batch_manager.finalize_batch()
|
||||
if self.batch_manager.event_buffer:
|
||||
send_status = self.batch_manager._send_events_to_backend()
|
||||
if send_status == 500 and self.batch_manager.trace_batch_id:
|
||||
self.batch_manager.plus_api.mark_trace_batch_as_failed(
|
||||
self.batch_manager.trace_batch_id,
|
||||
"Error sending events to backend",
|
||||
)
|
||||
self._reset_batch_state()
|
||||
return
|
||||
|
||||
self.batch_manager._finalize_backend_batch(events_count)
|
||||
self.ephemeral_url = self.batch_manager.ephemeral_trace_url
|
||||
|
||||
if not self.ephemeral_url:
|
||||
self._show_local_trace_message()
|
||||
self._show_local_trace_message(events_count, duration_ms, batch_id)
|
||||
|
||||
self._reset_batch_state()
|
||||
|
||||
except Exception as e:
|
||||
self._gracefully_fail(f"Backend initialization failed: {e}")
|
||||
self._reset_batch_state()
|
||||
|
||||
def _display_ephemeral_trace_link(self):
|
||||
"""Display the ephemeral trace link to the user and automatically open browser."""
|
||||
@@ -184,6 +213,19 @@ To enable tracing later, do any one of these:
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
def _reset_batch_state(self):
|
||||
"""Reset batch manager state to allow future executions to re-initialize."""
|
||||
if not self.batch_manager:
|
||||
return
|
||||
self.batch_manager.batch_owner_type = None
|
||||
self.batch_manager.batch_owner_id = None
|
||||
self.batch_manager.current_batch = None
|
||||
self.batch_manager.event_buffer.clear()
|
||||
self.batch_manager.trace_batch_id = None
|
||||
self.batch_manager.is_current_batch_ephemeral = False
|
||||
self.batch_manager.backend_initialized = False
|
||||
self.batch_manager._cleanup_batch_data()
|
||||
|
||||
def _gracefully_fail(self, error_message: str):
|
||||
"""Handle errors gracefully without disrupting user experience."""
|
||||
console = Console()
|
||||
@@ -191,7 +233,7 @@ To enable tracing later, do any one of these:
|
||||
|
||||
logger.debug(f"First-time trace error: {error_message}")
|
||||
|
||||
def _show_local_trace_message(self):
|
||||
def _show_local_trace_message(self, events_count: int = 0, duration_ms: int = 0, batch_id: str | None = None):
|
||||
"""Show message when traces were collected locally but couldn't be uploaded."""
|
||||
console = Console()
|
||||
|
||||
@@ -199,9 +241,9 @@ To enable tracing later, do any one of these:
|
||||
📊 Your execution traces were collected locally!
|
||||
|
||||
Unfortunately, we couldn't upload them to the server right now, but here's what we captured:
|
||||
• {len(self.batch_manager.event_buffer)} trace events
|
||||
• Execution duration: {self.batch_manager.calculate_duration("execution")}ms
|
||||
• Batch ID: {self.batch_manager.trace_batch_id}
|
||||
• {events_count} trace events
|
||||
• Execution duration: {duration_ms}ms
|
||||
• Batch ID: {batch_id}
|
||||
|
||||
✅ Tracing has been enabled for future runs!
|
||||
Your preference has been saved. Future Crew/Flow executions will automatically collect traces.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
@@ -108,10 +109,11 @@ class TraceBatchManager:
|
||||
user_context: dict[str, str],
|
||||
execution_metadata: dict[str, Any],
|
||||
use_ephemeral: bool = False,
|
||||
skip_context_check: bool = False,
|
||||
) -> None:
|
||||
"""Send batch initialization to backend"""
|
||||
|
||||
if not is_tracing_enabled_in_context():
|
||||
if not skip_context_check and not is_tracing_enabled_in_context():
|
||||
return
|
||||
|
||||
if not self.plus_api or not self.current_batch:
|
||||
@@ -142,19 +144,53 @@ class TraceBatchManager:
|
||||
payload["ephemeral_trace_id"] = self.current_batch.batch_id
|
||||
payload["user_identifier"] = get_user_id()
|
||||
|
||||
response = (
|
||||
self.plus_api.initialize_ephemeral_trace_batch(payload)
|
||||
if use_ephemeral
|
||||
else self.plus_api.initialize_trace_batch(payload)
|
||||
)
|
||||
max_retries = 1
|
||||
response = None
|
||||
|
||||
try:
|
||||
for attempt in range(max_retries + 1):
|
||||
response = (
|
||||
self.plus_api.initialize_ephemeral_trace_batch(payload)
|
||||
if use_ephemeral
|
||||
else self.plus_api.initialize_trace_batch(payload)
|
||||
)
|
||||
if response is not None and response.status_code < 500:
|
||||
break
|
||||
if attempt < max_retries:
|
||||
logger.debug(
|
||||
f"Trace batch init attempt {attempt + 1} failed "
|
||||
f"(status={response.status_code if response else 'None'}), retrying..."
|
||||
)
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error initializing trace batch: {e}. Continuing without tracing."
|
||||
)
|
||||
self.trace_batch_id = None
|
||||
return
|
||||
|
||||
if response is None:
|
||||
logger.warning(
|
||||
"Trace batch initialization failed gracefully. Continuing without tracing."
|
||||
)
|
||||
self.trace_batch_id = None
|
||||
return
|
||||
|
||||
# Fall back to ephemeral on auth failure (expired/revoked token)
|
||||
if response.status_code in [401, 403] and not use_ephemeral:
|
||||
logger.warning(
|
||||
"Auth rejected by server, falling back to ephemeral tracing."
|
||||
)
|
||||
self.is_current_batch_ephemeral = True
|
||||
return self._initialize_backend_batch(
|
||||
user_context,
|
||||
execution_metadata,
|
||||
use_ephemeral=True,
|
||||
skip_context_check=skip_context_check,
|
||||
)
|
||||
|
||||
if response.status_code in [201, 200]:
|
||||
self.is_current_batch_ephemeral = use_ephemeral
|
||||
response_data = response.json()
|
||||
self.trace_batch_id = (
|
||||
response_data["trace_id"]
|
||||
@@ -165,11 +201,13 @@ class TraceBatchManager:
|
||||
logger.warning(
|
||||
f"Trace batch initialization returned status {response.status_code}. Continuing without tracing."
|
||||
)
|
||||
self.trace_batch_id = None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error initializing trace batch: {e}. Continuing without tracing."
|
||||
)
|
||||
self.trace_batch_id = None
|
||||
|
||||
def begin_event_processing(self) -> None:
|
||||
"""Mark that an event handler started processing (for synchronization)."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -209,8 +235,11 @@ class TraceCollectionListener(BaseEventListener):
|
||||
|
||||
@event_bus.on(FlowStartedEvent)
|
||||
def on_flow_started(source: Any, event: FlowStartedEvent) -> None:
|
||||
if not self.batch_manager.is_batch_initialized():
|
||||
self._initialize_flow_batch(source, event)
|
||||
# Always call _initialize_flow_batch to claim ownership.
|
||||
# If batch was already initialized by a concurrent action event
|
||||
# (race condition), initialize_batch() returns early but
|
||||
# batch_owner_type is still correctly set to "flow".
|
||||
self._initialize_flow_batch(source, event)
|
||||
self._handle_trace_event("flow_started", source, event)
|
||||
|
||||
@event_bus.on(MethodExecutionStartedEvent)
|
||||
@@ -240,7 +269,12 @@ class TraceCollectionListener(BaseEventListener):
|
||||
|
||||
@event_bus.on(CrewKickoffStartedEvent)
|
||||
def on_crew_started(source: Any, event: CrewKickoffStartedEvent) -> None:
|
||||
if not self.batch_manager.is_batch_initialized():
|
||||
if self.batch_manager.batch_owner_type != "flow":
|
||||
# Always call _initialize_crew_batch to claim ownership.
|
||||
# If batch was already initialized by a concurrent action event
|
||||
# (race condition with DefaultEnvEvent), initialize_batch() returns
|
||||
# early but batch_owner_type is still correctly set to "crew".
|
||||
# Skip only when a parent flow already owns the batch.
|
||||
self._initialize_crew_batch(source, event)
|
||||
self._handle_trace_event("crew_kickoff_started", source, event)
|
||||
|
||||
|
||||
36
lib/crewai/src/crewai/events/types/env_events.py
Normal file
36
lib/crewai/src/crewai/events/types/env_events.py
Normal 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,
|
||||
)
|
||||
62
lib/crewai/src/crewai/events/types/skill_events.py
Normal file
62
lib/crewai/src/crewai/events/types/skill_events.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Skill lifecycle events for the Agent Skills standard.
|
||||
|
||||
Events emitted during skill discovery, loading, and activation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from crewai.events.base_events import BaseEvent
|
||||
|
||||
|
||||
class SkillEvent(BaseEvent):
|
||||
"""Base event for skill operations."""
|
||||
|
||||
skill_name: str = ""
|
||||
skill_path: Path | None = None
|
||||
from_agent: Any | None = None
|
||||
from_task: Any | None = None
|
||||
|
||||
def __init__(self, **data: Any) -> None:
|
||||
super().__init__(**data)
|
||||
self._set_agent_params(data)
|
||||
self._set_task_params(data)
|
||||
|
||||
|
||||
class SkillDiscoveryStartedEvent(SkillEvent):
|
||||
"""Event emitted when skill discovery begins."""
|
||||
|
||||
type: str = "skill_discovery_started"
|
||||
search_path: Path
|
||||
|
||||
|
||||
class SkillDiscoveryCompletedEvent(SkillEvent):
|
||||
"""Event emitted when skill discovery completes."""
|
||||
|
||||
type: str = "skill_discovery_completed"
|
||||
search_path: Path
|
||||
skills_found: int
|
||||
skill_names: list[str]
|
||||
|
||||
|
||||
class SkillLoadedEvent(SkillEvent):
|
||||
"""Event emitted when a skill is loaded at metadata level."""
|
||||
|
||||
type: str = "skill_loaded"
|
||||
disclosure_level: int = 1
|
||||
|
||||
|
||||
class SkillActivatedEvent(SkillEvent):
|
||||
"""Event emitted when a skill is activated (promoted to instructions level)."""
|
||||
|
||||
type: str = "skill_activated"
|
||||
disclosure_level: int = 2
|
||||
|
||||
|
||||
class SkillLoadFailedEvent(SkillEvent):
|
||||
"""Event emitted when skill loading fails."""
|
||||
|
||||
type: str = "skill_load_failed"
|
||||
error: str
|
||||
@@ -127,6 +127,9 @@ To update, run: uv sync --upgrade-package crewai"""
|
||||
|
||||
def _show_tracing_disabled_message_if_needed(self) -> None:
|
||||
"""Show tracing disabled message if tracing is not enabled."""
|
||||
from crewai.events.listeners.tracing.trace_listener import (
|
||||
TraceCollectionListener,
|
||||
)
|
||||
from crewai.events.listeners.tracing.utils import (
|
||||
has_user_declined_tracing,
|
||||
is_tracing_enabled_in_context,
|
||||
@@ -136,6 +139,11 @@ To update, run: uv sync --upgrade-package crewai"""
|
||||
if should_suppress_tracing_messages():
|
||||
return
|
||||
|
||||
# Don't show "disabled" message when the first-time handler will show
|
||||
# the trace prompt after execution completes (avoids confusing mid-flow messages)
|
||||
if TraceCollectionListener._instance and TraceCollectionListener._instance.first_time_handler.is_first_time:
|
||||
return
|
||||
|
||||
if not is_tracing_enabled_in_context():
|
||||
if has_user_declined_tracing():
|
||||
message = """Info: Tracing is disabled.
|
||||
|
||||
@@ -6,6 +6,7 @@ from crewai.flow.async_feedback import (
|
||||
)
|
||||
from crewai.flow.flow import Flow, and_, listen, or_, router, start
|
||||
from crewai.flow.flow_config import flow_config
|
||||
from crewai.flow.flow_serializer import flow_structure
|
||||
from crewai.flow.human_feedback import HumanFeedbackResult, human_feedback
|
||||
from crewai.flow.input_provider import InputProvider, InputResponse
|
||||
from crewai.flow.persistence import persist
|
||||
@@ -29,6 +30,7 @@ __all__ = [
|
||||
"and_",
|
||||
"build_flow_structure",
|
||||
"flow_config",
|
||||
"flow_structure",
|
||||
"human_feedback",
|
||||
"listen",
|
||||
"or_",
|
||||
|
||||
@@ -60,7 +60,7 @@ class PendingFeedbackContext:
|
||||
emit: list[str] | None = None
|
||||
default_outcome: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
llm: str | None = None
|
||||
llm: dict[str, Any] | str | None = None
|
||||
requested_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
|
||||
@@ -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,9 +905,10 @@ 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
|
||||
from crewai.memory.utils import sanitize_scope_name
|
||||
|
||||
self.memory = Memory()
|
||||
flow_name = sanitize_scope_name(self.name or self.__class__.__name__)
|
||||
self.memory = Memory(root_scope=f"/flow/{flow_name}")
|
||||
|
||||
# Register all flow-related methods
|
||||
for method_name in dir(self):
|
||||
@@ -950,10 +953,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)
|
||||
|
||||
@@ -1309,7 +1318,25 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
context = self._pending_feedback_context
|
||||
emit = context.emit
|
||||
default_outcome = context.default_outcome
|
||||
llm = context.llm
|
||||
|
||||
# Try to get the live LLM from the re-imported decorator first.
|
||||
# This preserves the fully-configured object (credentials, safety_settings, etc.)
|
||||
# for same-process resume. For cross-process resume, fall back to the
|
||||
# serialized context.llm which is now a dict with full config (or a legacy string).
|
||||
from crewai.flow.human_feedback import _deserialize_llm_from_context
|
||||
|
||||
llm = None
|
||||
method = self._methods.get(FlowMethodName(context.method_name))
|
||||
if method is not None:
|
||||
live_llm = getattr(method, "_hf_llm", None)
|
||||
if live_llm is not None:
|
||||
from crewai.llms.base_llm import BaseLLM as BaseLLMClass
|
||||
|
||||
if isinstance(live_llm, BaseLLMClass):
|
||||
llm = live_llm
|
||||
|
||||
if llm is None:
|
||||
llm = _deserialize_llm_from_context(context.llm)
|
||||
|
||||
# Determine outcome
|
||||
collapsed_outcome: str | None = None
|
||||
@@ -1770,6 +1797,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 +2751,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
619
lib/crewai/src/crewai/flow/flow_serializer.py
Normal file
619
lib/crewai/src/crewai/flow/flow_serializer.py
Normal file
@@ -0,0 +1,619 @@
|
||||
"""Flow structure serializer for introspecting Flow classes.
|
||||
|
||||
This module provides the flow_structure() function that analyzes a Flow class
|
||||
and returns a JSON-serializable dictionary describing its graph structure.
|
||||
This is used by Studio UI to render a visual flow graph.
|
||||
|
||||
Example:
|
||||
>>> from crewai.flow import Flow, start, listen
|
||||
>>> from crewai.flow.flow_serializer import flow_structure
|
||||
>>>
|
||||
>>> class MyFlow(Flow):
|
||||
... @start()
|
||||
... def begin(self):
|
||||
... return "started"
|
||||
...
|
||||
... @listen(begin)
|
||||
... def process(self):
|
||||
... return "done"
|
||||
>>>
|
||||
>>> structure = flow_structure(MyFlow)
|
||||
>>> print(structure["name"])
|
||||
'MyFlow'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import textwrap
|
||||
from typing import Any, TypedDict, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
from crewai.flow.flow_wrappers import (
|
||||
FlowCondition,
|
||||
FlowMethod,
|
||||
ListenMethod,
|
||||
RouterMethod,
|
||||
StartMethod,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MethodInfo(TypedDict, total=False):
|
||||
"""Information about a single flow method.
|
||||
|
||||
Attributes:
|
||||
name: The method name.
|
||||
type: Method type - start, listen, router, or start_router.
|
||||
trigger_methods: List of method names that trigger this method.
|
||||
condition_type: 'AND' or 'OR' for composite conditions, null otherwise.
|
||||
router_paths: For routers, the possible route names returned.
|
||||
has_human_feedback: Whether the method has @human_feedback decorator.
|
||||
has_crew: Whether the method body references a Crew.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
trigger_methods: list[str]
|
||||
condition_type: str | None
|
||||
router_paths: list[str]
|
||||
has_human_feedback: bool
|
||||
has_crew: bool
|
||||
|
||||
|
||||
class EdgeInfo(TypedDict, total=False):
|
||||
"""Information about an edge between flow methods.
|
||||
|
||||
Attributes:
|
||||
from_method: Source method name.
|
||||
to_method: Target method name.
|
||||
edge_type: Type of edge - 'listen' or 'route'.
|
||||
condition: Route name for router edges, null for listen edges.
|
||||
"""
|
||||
|
||||
from_method: str
|
||||
to_method: str
|
||||
edge_type: str
|
||||
condition: str | None
|
||||
|
||||
|
||||
class StateFieldInfo(TypedDict, total=False):
|
||||
"""Information about a state field.
|
||||
|
||||
Attributes:
|
||||
name: Field name.
|
||||
type: Field type as string.
|
||||
default: Default value if any.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
default: Any
|
||||
|
||||
|
||||
class StateSchemaInfo(TypedDict, total=False):
|
||||
"""Information about the flow's state schema.
|
||||
|
||||
Attributes:
|
||||
fields: List of field information.
|
||||
"""
|
||||
|
||||
fields: list[StateFieldInfo]
|
||||
|
||||
|
||||
class FlowStructureInfo(TypedDict, total=False):
|
||||
"""Complete flow structure information.
|
||||
|
||||
Attributes:
|
||||
name: Flow class name.
|
||||
description: Flow docstring if available.
|
||||
methods: List of method information.
|
||||
edges: List of edge information.
|
||||
state_schema: State schema if typed, null otherwise.
|
||||
inputs: Detected flow inputs if available.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str | None
|
||||
methods: list[MethodInfo]
|
||||
edges: list[EdgeInfo]
|
||||
state_schema: StateSchemaInfo | None
|
||||
inputs: list[str]
|
||||
|
||||
|
||||
def _get_method_type(
|
||||
method_name: str,
|
||||
method: Any,
|
||||
start_methods: list[str],
|
||||
routers: set[str],
|
||||
) -> str:
|
||||
"""Determine the type of a flow method.
|
||||
|
||||
Args:
|
||||
method_name: Name of the method.
|
||||
method: The method object.
|
||||
start_methods: List of start method names.
|
||||
routers: Set of router method names.
|
||||
|
||||
Returns:
|
||||
One of: 'start', 'listen', 'router', or 'start_router'.
|
||||
"""
|
||||
is_start = method_name in start_methods or getattr(
|
||||
method, "__is_start_method__", False
|
||||
)
|
||||
is_router = method_name in routers or getattr(method, "__is_router__", False)
|
||||
|
||||
if is_start and is_router:
|
||||
return "start_router"
|
||||
if is_start:
|
||||
return "start"
|
||||
if is_router:
|
||||
return "router"
|
||||
return "listen"
|
||||
|
||||
|
||||
def _has_human_feedback(method: Any) -> bool:
|
||||
"""Check if a method has the @human_feedback decorator.
|
||||
|
||||
Args:
|
||||
method: The method object to check.
|
||||
|
||||
Returns:
|
||||
True if the method has __human_feedback_config__ attribute.
|
||||
"""
|
||||
return hasattr(method, "__human_feedback_config__")
|
||||
|
||||
|
||||
def _detect_crew_reference(method: Any) -> bool:
|
||||
"""Detect if a method body references a Crew.
|
||||
|
||||
Checks for patterns like:
|
||||
- .crew() method calls
|
||||
- Crew( instantiation
|
||||
- References to Crew class in type hints
|
||||
|
||||
Note:
|
||||
This is a **best-effort heuristic for UI hints**, not a guarantee.
|
||||
Uses inspect.getsource + regex which can false-positive on comments
|
||||
or string literals, and may fail on dynamically generated methods
|
||||
or lambdas. Do not rely on this for correctness-critical logic.
|
||||
|
||||
Args:
|
||||
method: The method object to inspect.
|
||||
|
||||
Returns:
|
||||
True if crew reference detected, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Get the underlying function from wrapper
|
||||
func = method
|
||||
if hasattr(method, "_meth"):
|
||||
func = method._meth
|
||||
elif hasattr(method, "__wrapped__"):
|
||||
func = method.__wrapped__
|
||||
|
||||
source = inspect.getsource(func)
|
||||
source = textwrap.dedent(source)
|
||||
|
||||
# Patterns that indicate Crew usage
|
||||
crew_patterns = [
|
||||
r"\.crew\(\)", # .crew() method call
|
||||
r"Crew\s*\(", # Crew( instantiation
|
||||
r":\s*Crew\b", # Type hint with Crew
|
||||
r"->.*Crew", # Return type hint with Crew
|
||||
]
|
||||
|
||||
for pattern in crew_patterns:
|
||||
if re.search(pattern, source):
|
||||
return True
|
||||
|
||||
return False
|
||||
except (OSError, TypeError):
|
||||
# Can't get source code - assume no crew reference
|
||||
return False
|
||||
|
||||
|
||||
def _extract_trigger_methods(method: Any) -> tuple[list[str], str | None]:
|
||||
"""Extract trigger methods and condition type from a method.
|
||||
|
||||
Args:
|
||||
method: The method object to inspect.
|
||||
|
||||
Returns:
|
||||
Tuple of (trigger_methods list, condition_type or None).
|
||||
"""
|
||||
trigger_methods: list[str] = []
|
||||
condition_type: str | None = None
|
||||
|
||||
# First try __trigger_methods__ (populated for simple conditions)
|
||||
if hasattr(method, "__trigger_methods__") and method.__trigger_methods__:
|
||||
trigger_methods = [str(m) for m in method.__trigger_methods__]
|
||||
|
||||
# For complex conditions (or_/and_ combinators), extract from __trigger_condition__
|
||||
if (
|
||||
not trigger_methods
|
||||
and hasattr(method, "__trigger_condition__")
|
||||
and method.__trigger_condition__
|
||||
):
|
||||
trigger_condition = method.__trigger_condition__
|
||||
trigger_methods = _extract_all_methods_from_condition(trigger_condition)
|
||||
|
||||
if hasattr(method, "__condition_type__") and method.__condition_type__:
|
||||
condition_type = str(method.__condition_type__)
|
||||
|
||||
return trigger_methods, condition_type
|
||||
|
||||
|
||||
def _extract_router_paths(
|
||||
method: Any, router_paths_registry: dict[str, list[str]]
|
||||
) -> list[str]:
|
||||
"""Extract router paths for a router method.
|
||||
|
||||
Args:
|
||||
method: The method object.
|
||||
router_paths_registry: The class-level _router_paths dict.
|
||||
|
||||
Returns:
|
||||
List of possible route names.
|
||||
"""
|
||||
method_name = getattr(method, "__name__", "")
|
||||
|
||||
# First check if there are __router_paths__ on the method itself
|
||||
if hasattr(method, "__router_paths__") and method.__router_paths__:
|
||||
return [str(p) for p in method.__router_paths__]
|
||||
|
||||
# Then check the class-level registry
|
||||
if method_name in router_paths_registry:
|
||||
return [str(p) for p in router_paths_registry[method_name]]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _extract_all_methods_from_condition(
|
||||
condition: str | FlowCondition | dict[str, Any] | list[Any],
|
||||
) -> list[str]:
|
||||
"""Extract all method names from a condition tree recursively.
|
||||
|
||||
Args:
|
||||
condition: Can be a string, FlowCondition tuple, dict, or list.
|
||||
|
||||
Returns:
|
||||
List of all method names found in the condition.
|
||||
"""
|
||||
if isinstance(condition, str):
|
||||
return [condition]
|
||||
if isinstance(condition, tuple) and len(condition) == 2:
|
||||
# FlowCondition: (condition_type, methods_list)
|
||||
_, methods = condition
|
||||
if isinstance(methods, list):
|
||||
result: list[str] = []
|
||||
for m in methods:
|
||||
result.extend(_extract_all_methods_from_condition(m))
|
||||
return result
|
||||
return []
|
||||
if isinstance(condition, dict):
|
||||
conditions_list = condition.get("conditions", [])
|
||||
methods: list[str] = []
|
||||
for sub_cond in conditions_list:
|
||||
methods.extend(_extract_all_methods_from_condition(sub_cond))
|
||||
return methods
|
||||
if isinstance(condition, list):
|
||||
methods = []
|
||||
for item in condition:
|
||||
methods.extend(_extract_all_methods_from_condition(item))
|
||||
return methods
|
||||
return []
|
||||
|
||||
|
||||
def _generate_edges(
|
||||
listeners: dict[str, tuple[str, list[str]] | FlowCondition],
|
||||
routers: set[str],
|
||||
router_paths: dict[str, list[str]],
|
||||
all_methods: set[str],
|
||||
) -> list[EdgeInfo]:
|
||||
"""Generate edges from listeners and routers.
|
||||
|
||||
Args:
|
||||
listeners: Map of listener_name -> (condition_type, trigger_methods) or FlowCondition.
|
||||
routers: Set of router method names.
|
||||
router_paths: Map of router_name -> possible return values.
|
||||
all_methods: Set of all method names in the flow.
|
||||
|
||||
Returns:
|
||||
List of EdgeInfo dictionaries.
|
||||
"""
|
||||
edges: list[EdgeInfo] = []
|
||||
|
||||
# Generate edges from listeners (listen edges)
|
||||
for listener_name, condition_data in listeners.items():
|
||||
trigger_methods: list[str] = []
|
||||
|
||||
if isinstance(condition_data, tuple) and len(condition_data) == 2:
|
||||
_condition_type, methods = condition_data
|
||||
trigger_methods = [str(m) for m in methods]
|
||||
elif isinstance(condition_data, dict):
|
||||
trigger_methods = _extract_all_methods_from_condition(condition_data)
|
||||
|
||||
# Create edges from each trigger to the listener
|
||||
edges.extend(
|
||||
EdgeInfo(
|
||||
from_method=trigger,
|
||||
to_method=listener_name,
|
||||
edge_type="listen",
|
||||
condition=None,
|
||||
)
|
||||
for trigger in trigger_methods
|
||||
if trigger in all_methods
|
||||
)
|
||||
|
||||
# Generate edges from routers (route edges)
|
||||
for router_name, paths in router_paths.items():
|
||||
for path in paths:
|
||||
# Find listeners that listen to this path
|
||||
for listener_name, condition_data in listeners.items():
|
||||
path_triggers: list[str] = []
|
||||
|
||||
if isinstance(condition_data, tuple) and len(condition_data) == 2:
|
||||
_, methods = condition_data
|
||||
path_triggers = [str(m) for m in methods]
|
||||
elif isinstance(condition_data, dict):
|
||||
path_triggers = _extract_all_methods_from_condition(condition_data)
|
||||
|
||||
if str(path) in path_triggers:
|
||||
edges.append(
|
||||
EdgeInfo(
|
||||
from_method=router_name,
|
||||
to_method=listener_name,
|
||||
edge_type="route",
|
||||
condition=str(path),
|
||||
)
|
||||
)
|
||||
|
||||
return edges
|
||||
|
||||
|
||||
def _extract_state_schema(flow_class: type) -> StateSchemaInfo | None:
|
||||
"""Extract state schema from a Flow class.
|
||||
|
||||
Checks for:
|
||||
- Generic type parameter (Flow[MyState])
|
||||
- initial_state class attribute
|
||||
|
||||
Args:
|
||||
flow_class: The Flow class to inspect.
|
||||
|
||||
Returns:
|
||||
StateSchemaInfo if a Pydantic model state is detected, None otherwise.
|
||||
"""
|
||||
state_type: type | None = None
|
||||
|
||||
# Check for _initial_state_t set by __class_getitem__
|
||||
if hasattr(flow_class, "_initial_state_t"):
|
||||
state_type = flow_class._initial_state_t
|
||||
|
||||
# Check initial_state class attribute
|
||||
if state_type is None and hasattr(flow_class, "initial_state"):
|
||||
initial_state = flow_class.initial_state
|
||||
if isinstance(initial_state, type) and issubclass(initial_state, BaseModel):
|
||||
state_type = initial_state
|
||||
elif isinstance(initial_state, BaseModel):
|
||||
state_type = type(initial_state)
|
||||
|
||||
# Check __orig_bases__ for generic parameters
|
||||
if state_type is None and hasattr(flow_class, "__orig_bases__"):
|
||||
for base in flow_class.__orig_bases__:
|
||||
origin = get_origin(base)
|
||||
if origin is not None:
|
||||
args = get_args(base)
|
||||
if args:
|
||||
candidate = args[0]
|
||||
if isinstance(candidate, type) and issubclass(candidate, BaseModel):
|
||||
state_type = candidate
|
||||
break
|
||||
|
||||
if state_type is None or not issubclass(state_type, BaseModel):
|
||||
return None
|
||||
|
||||
# Extract fields from the Pydantic model
|
||||
fields: list[StateFieldInfo] = []
|
||||
try:
|
||||
model_fields = state_type.model_fields
|
||||
for field_name, field_info in model_fields.items():
|
||||
field_type_str = "Any"
|
||||
if field_info.annotation is not None:
|
||||
field_type_str = str(field_info.annotation)
|
||||
# Clean up the type string
|
||||
field_type_str = field_type_str.replace("typing.", "")
|
||||
field_type_str = field_type_str.replace("<class '", "").replace(
|
||||
"'>", ""
|
||||
)
|
||||
|
||||
default_value = None
|
||||
if (
|
||||
field_info.default is not PydanticUndefined
|
||||
and field_info.default is not None
|
||||
and not callable(field_info.default)
|
||||
):
|
||||
try:
|
||||
# Try to serialize the default value
|
||||
default_value = field_info.default
|
||||
except Exception:
|
||||
default_value = str(field_info.default)
|
||||
|
||||
fields.append(
|
||||
StateFieldInfo(
|
||||
name=field_name,
|
||||
type=field_type_str,
|
||||
default=default_value,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to extract state schema fields for %s", flow_class.__name__
|
||||
)
|
||||
|
||||
return StateSchemaInfo(fields=fields) if fields else None
|
||||
|
||||
|
||||
def _detect_flow_inputs(flow_class: type) -> list[str]:
|
||||
"""Detect flow input parameters.
|
||||
|
||||
Inspects the __init__ signature for custom parameters beyond standard Flow params.
|
||||
|
||||
Args:
|
||||
flow_class: The Flow class to inspect.
|
||||
|
||||
Returns:
|
||||
List of detected input names.
|
||||
"""
|
||||
inputs: list[str] = []
|
||||
|
||||
# Check for inputs in __init__ signature beyond standard Flow params
|
||||
try:
|
||||
init_sig = inspect.signature(flow_class.__init__)
|
||||
standard_params = {
|
||||
"self",
|
||||
"persistence",
|
||||
"tracing",
|
||||
"suppress_flow_events",
|
||||
"max_method_calls",
|
||||
"kwargs",
|
||||
}
|
||||
inputs.extend(
|
||||
param_name
|
||||
for param_name in init_sig.parameters
|
||||
if param_name not in standard_params and not param_name.startswith("_")
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to detect inputs from __init__ for %s", flow_class.__name__
|
||||
)
|
||||
|
||||
return inputs
|
||||
|
||||
|
||||
def flow_structure(flow_class: type) -> FlowStructureInfo:
|
||||
"""Introspect a Flow class and return its structure as a JSON-serializable dict.
|
||||
|
||||
This function analyzes a Flow CLASS (not instance) and returns complete
|
||||
information about its graph structure including methods, edges, and state.
|
||||
|
||||
Args:
|
||||
flow_class: A Flow class (not an instance) to introspect.
|
||||
|
||||
Returns:
|
||||
FlowStructureInfo dictionary containing:
|
||||
- name: Flow class name
|
||||
- description: Docstring if available
|
||||
- methods: List of method info dicts
|
||||
- edges: List of edge info dicts
|
||||
- state_schema: State schema if typed, None otherwise
|
||||
- inputs: Detected input names
|
||||
|
||||
Raises:
|
||||
TypeError: If flow_class is not a class.
|
||||
|
||||
Example:
|
||||
>>> structure = flow_structure(MyFlow)
|
||||
>>> print(structure["name"])
|
||||
'MyFlow'
|
||||
>>> for method in structure["methods"]:
|
||||
... print(method["name"], method["type"])
|
||||
"""
|
||||
if not isinstance(flow_class, type):
|
||||
raise TypeError(
|
||||
f"flow_structure requires a Flow class, not an instance. "
|
||||
f"Got {type(flow_class).__name__}"
|
||||
)
|
||||
|
||||
# Get class-level metadata set by FlowMeta
|
||||
start_methods: list[str] = getattr(flow_class, "_start_methods", [])
|
||||
listeners: dict[str, Any] = getattr(flow_class, "_listeners", {})
|
||||
routers: set[str] = getattr(flow_class, "_routers", set())
|
||||
router_paths_registry: dict[str, list[str]] = getattr(
|
||||
flow_class, "_router_paths", {}
|
||||
)
|
||||
|
||||
# Collect all flow methods
|
||||
methods: list[MethodInfo] = []
|
||||
all_method_names: set[str] = set()
|
||||
|
||||
for attr_name in dir(flow_class):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
|
||||
try:
|
||||
attr = getattr(flow_class, attr_name)
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
# Check if it's a flow method
|
||||
is_flow_method = (
|
||||
isinstance(attr, (FlowMethod, StartMethod, ListenMethod, RouterMethod))
|
||||
or hasattr(attr, "__is_flow_method__")
|
||||
or hasattr(attr, "__is_start_method__")
|
||||
or hasattr(attr, "__trigger_methods__")
|
||||
or hasattr(attr, "__is_router__")
|
||||
)
|
||||
|
||||
if not is_flow_method:
|
||||
continue
|
||||
|
||||
all_method_names.add(attr_name)
|
||||
|
||||
# Get method type
|
||||
method_type = _get_method_type(attr_name, attr, start_methods, routers)
|
||||
|
||||
# Get trigger methods and condition type
|
||||
trigger_methods, condition_type = _extract_trigger_methods(attr)
|
||||
|
||||
# Get router paths if applicable
|
||||
router_paths_list: list[str] = []
|
||||
if method_type in ("router", "start_router"):
|
||||
router_paths_list = _extract_router_paths(attr, router_paths_registry)
|
||||
|
||||
# Check for human feedback
|
||||
has_hf = _has_human_feedback(attr)
|
||||
|
||||
# Check for crew reference
|
||||
has_crew = _detect_crew_reference(attr)
|
||||
|
||||
method_info = MethodInfo(
|
||||
name=attr_name,
|
||||
type=method_type,
|
||||
trigger_methods=trigger_methods,
|
||||
condition_type=condition_type,
|
||||
router_paths=router_paths_list,
|
||||
has_human_feedback=has_hf,
|
||||
has_crew=has_crew,
|
||||
)
|
||||
methods.append(method_info)
|
||||
|
||||
# Generate edges
|
||||
edges = _generate_edges(listeners, routers, router_paths_registry, all_method_names)
|
||||
|
||||
# Extract state schema
|
||||
state_schema = _extract_state_schema(flow_class)
|
||||
|
||||
# Detect inputs
|
||||
inputs = _detect_flow_inputs(flow_class)
|
||||
|
||||
# Get flow description from docstring
|
||||
description: str | None = None
|
||||
if flow_class.__doc__:
|
||||
description = flow_class.__doc__.strip()
|
||||
|
||||
return FlowStructureInfo(
|
||||
name=flow_class.__name__,
|
||||
description=description,
|
||||
methods=methods,
|
||||
edges=edges,
|
||||
state_schema=state_schema,
|
||||
inputs=inputs,
|
||||
)
|
||||
@@ -75,6 +75,7 @@ class FlowMethod(Generic[P, R]):
|
||||
"__is_router__",
|
||||
"__router_paths__",
|
||||
"__human_feedback_config__",
|
||||
"_hf_llm", # Live LLM object for HITL resume
|
||||
]:
|
||||
if hasattr(meth, attr):
|
||||
setattr(self, attr, getattr(meth, attr))
|
||||
|
||||
@@ -76,22 +76,48 @@ if TYPE_CHECKING:
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def _serialize_llm_for_context(llm: Any) -> str | None:
|
||||
"""Serialize a BaseLLM object to a model string with provider prefix.
|
||||
def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None:
|
||||
"""Serialize a BaseLLM object to a dict preserving full config.
|
||||
|
||||
When persisting the LLM for HITL resume, we need to store enough info
|
||||
to reconstruct a working LLM on the resume worker. Just storing the bare
|
||||
model name (e.g. "gemini-3-flash-preview") causes provider inference to
|
||||
fail — it defaults to OpenAI. Including the provider prefix (e.g.
|
||||
"gemini/gemini-3-flash-preview") allows LLM() to correctly route.
|
||||
Delegates to ``llm.to_config_dict()`` when available (BaseLLM and
|
||||
subclasses). Falls back to extracting the model string with provider
|
||||
prefix for unknown LLM types.
|
||||
"""
|
||||
if hasattr(llm, "to_config_dict"):
|
||||
return llm.to_config_dict()
|
||||
|
||||
# Fallback for non-BaseLLM objects: just extract model + provider prefix
|
||||
model = getattr(llm, "model", None)
|
||||
if not model:
|
||||
return None
|
||||
provider = getattr(llm, "provider", None)
|
||||
if provider and "/" not in model:
|
||||
return f"{provider}/{model}"
|
||||
return model
|
||||
return f"{provider}/{model}" if provider and "/" not in model else model
|
||||
|
||||
|
||||
def _deserialize_llm_from_context(
|
||||
llm_data: dict[str, Any] | str | None,
|
||||
) -> BaseLLM | None:
|
||||
"""Reconstruct an LLM instance from serialized context data.
|
||||
|
||||
Handles both the new dict format (with full config) and the legacy
|
||||
string format (model name only) for backward compatibility.
|
||||
|
||||
Returns a BaseLLM instance, or None if llm_data is None.
|
||||
"""
|
||||
if llm_data is None:
|
||||
return None
|
||||
|
||||
from crewai.llm import LLM
|
||||
|
||||
if isinstance(llm_data, str):
|
||||
return LLM(model=llm_data)
|
||||
|
||||
if isinstance(llm_data, dict):
|
||||
model = llm_data.pop("model", None)
|
||||
if not model:
|
||||
return None
|
||||
return LLM(model=model, **llm_data)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -572,6 +598,14 @@ def human_feedback(
|
||||
wrapper.__is_router__ = True
|
||||
wrapper.__router_paths__ = list(emit)
|
||||
|
||||
# Stash the live LLM object for HITL resume to retrieve.
|
||||
# When a flow pauses for human feedback and later resumes (possibly in a
|
||||
# different process), the serialized context only contains a model string.
|
||||
# By storing the original LLM on the wrapper, resume_async can retrieve
|
||||
# the fully-configured LLM (with credentials, project, safety_settings, etc.)
|
||||
# instead of creating a bare LLM from just the model string.
|
||||
wrapper._hf_llm = llm
|
||||
|
||||
return wrapper # type: ignore[no-any-return]
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -62,18 +62,6 @@ except ImportError:
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.exceptions import ContextWindowExceededError
|
||||
from litellm.litellm_core_utils.get_supported_openai_params import (
|
||||
get_supported_openai_params,
|
||||
)
|
||||
from litellm.types.utils import (
|
||||
ChatCompletionDeltaToolCall,
|
||||
Choices,
|
||||
Function,
|
||||
ModelResponse,
|
||||
)
|
||||
from litellm.utils import supports_response_schema
|
||||
|
||||
from crewai.agent.core import Agent
|
||||
from crewai.llms.hooks.base import BaseInterceptor
|
||||
from crewai.llms.providers.anthropic.completion import AnthropicThinkingConfig
|
||||
@@ -83,8 +71,6 @@ if TYPE_CHECKING:
|
||||
|
||||
try:
|
||||
import litellm
|
||||
from litellm.exceptions import ContextWindowExceededError
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.get_supported_openai_params import (
|
||||
get_supported_openai_params,
|
||||
)
|
||||
@@ -99,15 +85,13 @@ try:
|
||||
LITELLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
LITELLM_AVAILABLE = False
|
||||
litellm = None # type: ignore
|
||||
Choices = None # type: ignore
|
||||
ContextWindowExceededError = Exception # type: ignore
|
||||
get_supported_openai_params = None # type: ignore
|
||||
ChatCompletionDeltaToolCall = None # type: ignore
|
||||
Function = None # type: ignore
|
||||
ModelResponse = None # type: ignore
|
||||
supports_response_schema = None # type: ignore
|
||||
CustomLogger = None # type: ignore
|
||||
litellm = None # type: ignore[assignment]
|
||||
Choices = None # type: ignore[assignment, misc]
|
||||
get_supported_openai_params = None # type: ignore[assignment]
|
||||
ChatCompletionDeltaToolCall = None # type: ignore[assignment, misc]
|
||||
Function = None # type: ignore[assignment, misc]
|
||||
ModelResponse = None # type: ignore[assignment, misc]
|
||||
supports_response_schema = None # type: ignore[assignment]
|
||||
|
||||
|
||||
load_dotenv()
|
||||
@@ -1009,12 +993,15 @@ class LLM(BaseLLM):
|
||||
)
|
||||
return full_response
|
||||
|
||||
except ContextWindowExceededError as e:
|
||||
# Catch context window errors from litellm and convert them to our own exception type.
|
||||
# This exception is handled by CrewAgentExecutor._invoke_loop() which can then
|
||||
# decide whether to summarize the content or abort based on the respect_context_window flag.
|
||||
raise LLMContextLengthExceededError(str(e)) from e
|
||||
except LLMContextLengthExceededError:
|
||||
# Re-raise our own context length error
|
||||
raise
|
||||
except Exception as e:
|
||||
# Check if this is a context window error and convert to our exception type
|
||||
error_msg = str(e)
|
||||
if LLMContextLengthExceededError._is_context_limit_error(error_msg):
|
||||
raise LLMContextLengthExceededError(error_msg) from e
|
||||
|
||||
logging.error(f"Error in streaming response: {e!s}")
|
||||
if full_response.strip():
|
||||
logging.warning(f"Returning partial response despite error: {e!s}")
|
||||
@@ -1195,10 +1182,15 @@ class LLM(BaseLLM):
|
||||
usage_info = response.usage
|
||||
self._track_token_usage_internal(usage_info)
|
||||
|
||||
except ContextWindowExceededError as e:
|
||||
# Convert litellm's context window error to our own exception type
|
||||
# for consistent handling in the rest of the codebase
|
||||
raise LLMContextLengthExceededError(str(e)) from e
|
||||
except LLMContextLengthExceededError:
|
||||
# Re-raise our own context length error
|
||||
raise
|
||||
except Exception as e:
|
||||
# Check if this is a context window error and convert to our exception type
|
||||
error_msg = str(e)
|
||||
if LLMContextLengthExceededError._is_context_limit_error(error_msg):
|
||||
raise LLMContextLengthExceededError(error_msg) from e
|
||||
raise
|
||||
|
||||
# --- 2) Handle structured output response (when response_model is provided)
|
||||
if response_model is not None:
|
||||
@@ -1330,8 +1322,15 @@ class LLM(BaseLLM):
|
||||
usage_info = response.usage
|
||||
self._track_token_usage_internal(usage_info)
|
||||
|
||||
except ContextWindowExceededError as e:
|
||||
raise LLMContextLengthExceededError(str(e)) from e
|
||||
except LLMContextLengthExceededError:
|
||||
# Re-raise our own context length error
|
||||
raise
|
||||
except Exception as e:
|
||||
# Check if this is a context window error and convert to our exception type
|
||||
error_msg = str(e)
|
||||
if LLMContextLengthExceededError._is_context_limit_error(error_msg):
|
||||
raise LLMContextLengthExceededError(error_msg) from e
|
||||
raise
|
||||
|
||||
if response_model is not None:
|
||||
if isinstance(response, BaseModel):
|
||||
@@ -1548,9 +1547,15 @@ class LLM(BaseLLM):
|
||||
)
|
||||
return full_response
|
||||
|
||||
except ContextWindowExceededError as e:
|
||||
raise LLMContextLengthExceededError(str(e)) from e
|
||||
except Exception:
|
||||
except LLMContextLengthExceededError:
|
||||
# Re-raise our own context length error
|
||||
raise
|
||||
except Exception as e:
|
||||
# Check if this is a context window error and convert to our exception type
|
||||
error_msg = str(e)
|
||||
if LLMContextLengthExceededError._is_context_limit_error(error_msg):
|
||||
raise LLMContextLengthExceededError(error_msg) from e
|
||||
|
||||
if chunk_count == 0:
|
||||
raise
|
||||
if full_response:
|
||||
@@ -1984,7 +1989,16 @@ class LLM(BaseLLM):
|
||||
Returns:
|
||||
Messages with files formatted into content blocks.
|
||||
"""
|
||||
if not HAS_CREWAI_FILES or not self.supports_multimodal():
|
||||
if not HAS_CREWAI_FILES:
|
||||
return messages
|
||||
|
||||
if not self.supports_multimodal():
|
||||
if any(msg.get("files") for msg in messages):
|
||||
raise ValueError(
|
||||
f"Model '{self.model}' does not support multimodal input, "
|
||||
"but files were provided via 'input_files'. "
|
||||
"Use a vision-capable model or remove the file inputs."
|
||||
)
|
||||
return messages
|
||||
|
||||
provider = getattr(self, "provider", None) or self.model
|
||||
@@ -2026,7 +2040,16 @@ class LLM(BaseLLM):
|
||||
Returns:
|
||||
Messages with files formatted into content blocks.
|
||||
"""
|
||||
if not HAS_CREWAI_FILES or not self.supports_multimodal():
|
||||
if not HAS_CREWAI_FILES:
|
||||
return messages
|
||||
|
||||
if not self.supports_multimodal():
|
||||
if any(msg.get("files") for msg in messages):
|
||||
raise ValueError(
|
||||
f"Model '{self.model}' does not support multimodal input, "
|
||||
"but files were provided via 'input_files'. "
|
||||
"Use a vision-capable model or remove the file inputs."
|
||||
)
|
||||
return messages
|
||||
|
||||
provider = getattr(self, "provider", None) or self.model
|
||||
@@ -2139,7 +2162,15 @@ class LLM(BaseLLM):
|
||||
- E.g., "openrouter/deepseek/deepseek-chat" yields "openrouter"
|
||||
- "gemini/gemini-1.5-pro" yields "gemini"
|
||||
- If no slash is present, "openai" is assumed.
|
||||
|
||||
Note: This validation only applies to the litellm fallback path.
|
||||
Native providers have their own validation.
|
||||
"""
|
||||
if not LITELLM_AVAILABLE or supports_response_schema is None:
|
||||
# When litellm is not available, skip validation
|
||||
# (this path should only be reached for litellm fallback models)
|
||||
return
|
||||
|
||||
provider = self._get_custom_llm_provider()
|
||||
if self.response_format is not None and not supports_response_schema(
|
||||
model=self.model,
|
||||
@@ -2151,6 +2182,16 @@ class LLM(BaseLLM):
|
||||
)
|
||||
|
||||
def supports_function_calling(self) -> bool:
|
||||
"""Check if the model supports function calling.
|
||||
|
||||
Note: This method is only used by the litellm fallback path.
|
||||
Native providers override this method with their own implementation.
|
||||
"""
|
||||
if not LITELLM_AVAILABLE:
|
||||
# When litellm is not available, assume function calling is supported
|
||||
# (all modern models support it)
|
||||
return True
|
||||
|
||||
try:
|
||||
provider = self._get_custom_llm_provider()
|
||||
return litellm.utils.supports_function_calling(
|
||||
@@ -2158,15 +2199,24 @@ class LLM(BaseLLM):
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to check function calling support: {e!s}")
|
||||
return False
|
||||
return True # Default to True for modern models
|
||||
|
||||
def supports_stop_words(self) -> bool:
|
||||
"""Check if the model supports stop words.
|
||||
|
||||
Note: This method is only used by the litellm fallback path.
|
||||
Native providers override this method with their own implementation.
|
||||
"""
|
||||
if not LITELLM_AVAILABLE or get_supported_openai_params is None:
|
||||
# When litellm is not available, assume stop words are supported
|
||||
return True
|
||||
|
||||
try:
|
||||
params = get_supported_openai_params(model=self.model)
|
||||
return params is not None and "stop" in params
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to get supported params: {e!s}")
|
||||
return False
|
||||
return True # Default to True
|
||||
|
||||
def get_context_window_size(self) -> int:
|
||||
"""
|
||||
@@ -2202,7 +2252,15 @@ class LLM(BaseLLM):
|
||||
"""
|
||||
Attempt to keep a single set of callbacks in litellm by removing old
|
||||
duplicates and adding new ones.
|
||||
|
||||
Note: This only affects the litellm fallback path. Native providers
|
||||
don't use litellm callbacks - they emit events via base_llm.py.
|
||||
"""
|
||||
if not LITELLM_AVAILABLE:
|
||||
# When litellm is not available, callbacks are still stored
|
||||
# but not registered with litellm globals
|
||||
return
|
||||
|
||||
with suppress_warnings():
|
||||
callback_types = [type(callback) for callback in callbacks]
|
||||
for callback in litellm.success_callback[:]:
|
||||
@@ -2227,6 +2285,9 @@ class LLM(BaseLLM):
|
||||
If the environment variables are not set or are empty, the corresponding callback lists
|
||||
will be set to empty lists.
|
||||
|
||||
Note: This only affects the litellm fallback path. Native providers
|
||||
don't use litellm callbacks - they emit events via base_llm.py.
|
||||
|
||||
Examples:
|
||||
LITELLM_SUCCESS_CALLBACKS="langfuse,langsmith"
|
||||
LITELLM_FAILURE_CALLBACKS="langfuse"
|
||||
@@ -2234,9 +2295,13 @@ class LLM(BaseLLM):
|
||||
This will set `litellm.success_callback` to ["langfuse", "langsmith"] and
|
||||
`litellm.failure_callback` to ["langfuse"].
|
||||
"""
|
||||
if not LITELLM_AVAILABLE:
|
||||
# When litellm is not available, env callbacks have no effect
|
||||
return
|
||||
|
||||
with suppress_warnings():
|
||||
success_callbacks_str = os.environ.get("LITELLM_SUCCESS_CALLBACKS", "")
|
||||
success_callbacks: list[str | Callable[..., Any] | CustomLogger] = []
|
||||
success_callbacks: list[str | Callable[..., Any]] = []
|
||||
if success_callbacks_str:
|
||||
success_callbacks = [
|
||||
cb.strip() for cb in success_callbacks_str.split(",") if cb.strip()
|
||||
@@ -2244,7 +2309,7 @@ class LLM(BaseLLM):
|
||||
|
||||
failure_callbacks_str = os.environ.get("LITELLM_FAILURE_CALLBACKS", "")
|
||||
if failure_callbacks_str:
|
||||
failure_callbacks: list[str | Callable[..., Any] | CustomLogger] = [
|
||||
failure_callbacks: list[str | Callable[..., Any]] = [
|
||||
cb.strip() for cb in failure_callbacks_str.split(",") if cb.strip()
|
||||
]
|
||||
|
||||
@@ -2398,6 +2463,9 @@ class LLM(BaseLLM):
|
||||
"gpt-4.1",
|
||||
"claude-3",
|
||||
"claude-4",
|
||||
"claude-sonnet-4",
|
||||
"claude-opus-4",
|
||||
"claude-haiku-4",
|
||||
"gemini",
|
||||
)
|
||||
model_lower = self.model.lower()
|
||||
|
||||
@@ -152,6 +152,28 @@ class BaseLLM(ABC):
|
||||
"cached_prompt_tokens": 0,
|
||||
}
|
||||
|
||||
def to_config_dict(self) -> dict[str, Any]:
|
||||
"""Serialize this LLM to a dict that can reconstruct it via ``LLM(**config)``.
|
||||
|
||||
Returns the core fields that BaseLLM owns. Provider subclasses should
|
||||
override this (calling ``super().to_config_dict()``) to add their own
|
||||
fields (e.g. ``project``, ``location``, ``safety_settings``).
|
||||
"""
|
||||
model = self.model
|
||||
provider = self.provider
|
||||
model_str = f"{provider}/{model}" if provider and "/" not in model else model
|
||||
|
||||
config: dict[str, Any] = {"model": model_str}
|
||||
|
||||
if self.temperature is not None:
|
||||
config["temperature"] = self.temperature
|
||||
if self.base_url is not None:
|
||||
config["base_url"] = self.base_url
|
||||
if self.stop:
|
||||
config["stop"] = self.stop
|
||||
|
||||
return config
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
"""Get the provider of the LLM."""
|
||||
@@ -619,7 +641,16 @@ class BaseLLM(ABC):
|
||||
Returns:
|
||||
Messages with files formatted into content blocks.
|
||||
"""
|
||||
if not HAS_CREWAI_FILES or not self.supports_multimodal():
|
||||
if not HAS_CREWAI_FILES:
|
||||
return messages
|
||||
|
||||
if not self.supports_multimodal():
|
||||
if any(msg.get("files") for msg in messages):
|
||||
raise ValueError(
|
||||
f"Model '{self.model}' does not support multimodal input, "
|
||||
"but files were provided via 'input_files'. "
|
||||
"Use a vision-capable model or remove the file inputs."
|
||||
)
|
||||
return messages
|
||||
|
||||
provider = getattr(self, "provider", None) or getattr(self, "model", "openai")
|
||||
|
||||
@@ -256,6 +256,19 @@ class AnthropicCompletion(BaseLLM):
|
||||
else:
|
||||
self.stop_sequences = []
|
||||
|
||||
def to_config_dict(self) -> dict[str, Any]:
|
||||
"""Extend base config with Anthropic-specific fields."""
|
||||
config = super().to_config_dict()
|
||||
if self.max_tokens != 4096: # non-default
|
||||
config["max_tokens"] = self.max_tokens
|
||||
if self.max_retries != 2: # non-default
|
||||
config["max_retries"] = self.max_retries
|
||||
if self.top_p is not None:
|
||||
config["top_p"] = self.top_p
|
||||
if self.timeout is not None:
|
||||
config["timeout"] = self.timeout
|
||||
return config
|
||||
|
||||
def _get_client_params(self) -> dict[str, Any]:
|
||||
"""Get client parameters."""
|
||||
|
||||
@@ -1753,7 +1766,14 @@ class AnthropicCompletion(BaseLLM):
|
||||
Returns:
|
||||
True if the model supports images and PDFs.
|
||||
"""
|
||||
return "claude-3" in self.model.lower() or "claude-4" in self.model.lower()
|
||||
model_lower = self.model.lower()
|
||||
return (
|
||||
"claude-3" in model_lower
|
||||
or "claude-4" in model_lower
|
||||
or "claude-sonnet-4" in model_lower
|
||||
or "claude-opus-4" in model_lower
|
||||
or "claude-haiku-4" in model_lower
|
||||
)
|
||||
|
||||
def get_file_uploader(self) -> Any:
|
||||
"""Get an Anthropic file uploader using this LLM's clients.
|
||||
|
||||
@@ -180,6 +180,27 @@ class AzureCompletion(BaseLLM):
|
||||
and "/openai/deployments/" in self.endpoint
|
||||
)
|
||||
|
||||
def to_config_dict(self) -> dict[str, Any]:
|
||||
"""Extend base config with Azure-specific fields."""
|
||||
config = super().to_config_dict()
|
||||
if self.endpoint:
|
||||
config["endpoint"] = self.endpoint
|
||||
if self.api_version and self.api_version != "2024-06-01":
|
||||
config["api_version"] = self.api_version
|
||||
if self.timeout is not None:
|
||||
config["timeout"] = self.timeout
|
||||
if self.max_retries != 2:
|
||||
config["max_retries"] = self.max_retries
|
||||
if self.top_p is not None:
|
||||
config["top_p"] = self.top_p
|
||||
if self.frequency_penalty is not None:
|
||||
config["frequency_penalty"] = self.frequency_penalty
|
||||
if self.presence_penalty is not None:
|
||||
config["presence_penalty"] = self.presence_penalty
|
||||
if self.max_tokens is not None:
|
||||
config["max_tokens"] = self.max_tokens
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _validate_and_fix_endpoint(endpoint: str, model: str) -> str:
|
||||
"""Validate and fix Azure endpoint URL format.
|
||||
|
||||
@@ -346,6 +346,23 @@ class BedrockCompletion(BaseLLM):
|
||||
# Handle inference profiles for newer models
|
||||
self.model_id = model
|
||||
|
||||
def to_config_dict(self) -> dict[str, Any]:
|
||||
"""Extend base config with Bedrock-specific fields."""
|
||||
config = super().to_config_dict()
|
||||
# NOTE: AWS credentials (access_key, secret_key, session_token) are
|
||||
# intentionally excluded — they must come from env on resume.
|
||||
if self.region_name and self.region_name != "us-east-1":
|
||||
config["region_name"] = self.region_name
|
||||
if self.max_tokens is not None:
|
||||
config["max_tokens"] = self.max_tokens
|
||||
if self.top_p is not None:
|
||||
config["top_p"] = self.top_p
|
||||
if self.top_k is not None:
|
||||
config["top_k"] = self.top_k
|
||||
if self.guardrail_config:
|
||||
config["guardrail_config"] = self.guardrail_config
|
||||
return config
|
||||
|
||||
@property
|
||||
def stop(self) -> list[str]:
|
||||
"""Get stop sequences sent to the API."""
|
||||
@@ -1880,7 +1897,9 @@ class BedrockCompletion(BaseLLM):
|
||||
# Anthropic (Claude) models reject assistant-last messages when
|
||||
# tools are in the request. Append a user message so the
|
||||
# Converse API accepts the payload.
|
||||
elif "anthropic" in self.model.lower() or "claude" in self.model.lower():
|
||||
elif (
|
||||
"anthropic" in self.model.lower() or "claude" in self.model.lower()
|
||||
):
|
||||
converse_messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
@@ -2100,12 +2119,18 @@ class BedrockCompletion(BaseLLM):
|
||||
model_lower = self.model.lower()
|
||||
vision_models = (
|
||||
"anthropic.claude-3",
|
||||
"anthropic.claude-sonnet-4",
|
||||
"anthropic.claude-opus-4",
|
||||
"anthropic.claude-haiku-4",
|
||||
"amazon.nova-lite",
|
||||
"amazon.nova-pro",
|
||||
"amazon.nova-premier",
|
||||
"us.amazon.nova-lite",
|
||||
"us.amazon.nova-pro",
|
||||
"us.amazon.nova-premier",
|
||||
"us.anthropic.claude-sonnet-4",
|
||||
"us.anthropic.claude-opus-4",
|
||||
"us.anthropic.claude-haiku-4",
|
||||
)
|
||||
return any(model_lower.startswith(m) for m in vision_models)
|
||||
|
||||
|
||||
@@ -176,6 +176,28 @@ class GeminiCompletion(BaseLLM):
|
||||
else:
|
||||
self.stop_sequences = []
|
||||
|
||||
def to_config_dict(self) -> dict[str, Any]:
|
||||
"""Extend base config with Gemini/Vertex-specific fields."""
|
||||
config = super().to_config_dict()
|
||||
if self.project:
|
||||
config["project"] = self.project
|
||||
if self.location and self.location != "us-central1":
|
||||
config["location"] = self.location
|
||||
if self.top_p is not None:
|
||||
config["top_p"] = self.top_p
|
||||
if self.top_k is not None:
|
||||
config["top_k"] = self.top_k
|
||||
if self.max_output_tokens is not None:
|
||||
config["max_output_tokens"] = self.max_output_tokens
|
||||
if self.safety_settings:
|
||||
config["safety_settings"] = [
|
||||
{"category": str(s.category), "threshold": str(s.threshold)}
|
||||
if hasattr(s, "category") and hasattr(s, "threshold")
|
||||
else s
|
||||
for s in self.safety_settings
|
||||
]
|
||||
return config
|
||||
|
||||
def _initialize_client(self, use_vertexai: bool = False) -> genai.Client:
|
||||
"""Initialize the Google Gen AI client with proper parameter handling.
|
||||
|
||||
|
||||
@@ -329,6 +329,35 @@ class OpenAICompletion(BaseLLM):
|
||||
"""
|
||||
self._last_reasoning_items = None
|
||||
|
||||
def to_config_dict(self) -> dict[str, Any]:
|
||||
"""Extend base config with OpenAI-specific fields."""
|
||||
config = super().to_config_dict()
|
||||
# Client-level params (from OpenAI SDK)
|
||||
if self.organization:
|
||||
config["organization"] = self.organization
|
||||
if self.project:
|
||||
config["project"] = self.project
|
||||
if self.timeout is not None:
|
||||
config["timeout"] = self.timeout
|
||||
if self.max_retries != 2:
|
||||
config["max_retries"] = self.max_retries
|
||||
# Completion params
|
||||
if self.top_p is not None:
|
||||
config["top_p"] = self.top_p
|
||||
if self.frequency_penalty is not None:
|
||||
config["frequency_penalty"] = self.frequency_penalty
|
||||
if self.presence_penalty is not None:
|
||||
config["presence_penalty"] = self.presence_penalty
|
||||
if self.max_tokens is not None:
|
||||
config["max_tokens"] = self.max_tokens
|
||||
if self.max_completion_tokens is not None:
|
||||
config["max_completion_tokens"] = self.max_completion_tokens
|
||||
if self.seed is not None:
|
||||
config["seed"] = self.seed
|
||||
if self.reasoning_effort is not None:
|
||||
config["reasoning_effort"] = self.reasoning_effort
|
||||
return config
|
||||
|
||||
def _get_client_params(self) -> dict[str, Any]:
|
||||
"""Get OpenAI client parameters."""
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from crewai.memory.analyze import (
|
||||
analyze_for_save,
|
||||
)
|
||||
from crewai.memory.types import MemoryConfig, MemoryRecord, embed_texts
|
||||
from crewai.memory.utils import join_scope_paths
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,6 +49,8 @@ class ItemState(BaseModel):
|
||||
importance: float | None = None
|
||||
source: str | None = None
|
||||
private: bool = False
|
||||
# Structural root scope prefix for hierarchical scoping
|
||||
root_scope: str | None = None
|
||||
# Resolved values
|
||||
resolved_scope: str = "/"
|
||||
resolved_categories: list[str] = Field(default_factory=list)
|
||||
@@ -104,6 +107,14 @@ class EncodingFlow(Flow[EncodingState]):
|
||||
embedder: Any,
|
||||
config: MemoryConfig | None = None,
|
||||
) -> None:
|
||||
"""Initialize the encoding flow.
|
||||
|
||||
Args:
|
||||
storage: Storage backend for persisting memories.
|
||||
llm: LLM instance for analysis.
|
||||
embedder: Embedder for generating vectors.
|
||||
config: Optional memory configuration.
|
||||
"""
|
||||
super().__init__(suppress_flow_events=True)
|
||||
self._storage = storage
|
||||
self._llm = llm
|
||||
@@ -180,10 +191,18 @@ class EncodingFlow(Flow[EncodingState]):
|
||||
def _search_one(
|
||||
item: ItemState,
|
||||
) -> list[tuple[MemoryRecord, float]]:
|
||||
scope_prefix = item.scope if item.scope and item.scope.strip("/") else None
|
||||
# Use root_scope as the search boundary, then narrow by explicit scope if provided
|
||||
effective_prefix = None
|
||||
if item.root_scope:
|
||||
effective_prefix = item.root_scope.rstrip("/")
|
||||
if item.scope and item.scope.strip("/"):
|
||||
effective_prefix = effective_prefix + "/" + item.scope.strip("/")
|
||||
elif item.scope and item.scope.strip("/"):
|
||||
effective_prefix = item.scope
|
||||
|
||||
return self._storage.search( # type: ignore[no-any-return]
|
||||
item.embedding,
|
||||
scope_prefix=scope_prefix,
|
||||
scope_prefix=effective_prefix,
|
||||
categories=None,
|
||||
limit=self._config.consolidation_limit,
|
||||
min_score=0.0,
|
||||
@@ -253,9 +272,16 @@ class EncodingFlow(Flow[EncodingState]):
|
||||
existing_scopes: list[str] = []
|
||||
existing_categories: list[str] = []
|
||||
if any_needs_fields:
|
||||
existing_scopes = self._storage.list_scopes("/") or ["/"]
|
||||
# Constrain scope/category suggestions to root_scope boundary
|
||||
# Check if any active item has root_scope
|
||||
active_root = next(
|
||||
(it.root_scope for it in items if not it.dropped and it.root_scope),
|
||||
None,
|
||||
)
|
||||
scope_search_root = active_root if active_root else "/"
|
||||
existing_scopes = self._storage.list_scopes(scope_search_root) or ["/"]
|
||||
existing_categories = list(
|
||||
self._storage.list_categories(scope_prefix=None).keys()
|
||||
self._storage.list_categories(scope_prefix=active_root).keys()
|
||||
)
|
||||
|
||||
# Classify items and submit LLM calls
|
||||
@@ -321,7 +347,13 @@ class EncodingFlow(Flow[EncodingState]):
|
||||
for i, future in save_futures.items():
|
||||
analysis = future.result()
|
||||
item = items[i]
|
||||
item.resolved_scope = item.scope or analysis.suggested_scope or "/"
|
||||
# Determine inner scope from explicit scope or LLM-inferred
|
||||
inner_scope = item.scope or analysis.suggested_scope or "/"
|
||||
# Join root_scope with inner scope if root_scope is set
|
||||
if item.root_scope:
|
||||
item.resolved_scope = join_scope_paths(item.root_scope, inner_scope)
|
||||
else:
|
||||
item.resolved_scope = inner_scope
|
||||
item.resolved_categories = (
|
||||
item.categories
|
||||
if item.categories is not None
|
||||
@@ -353,8 +385,18 @@ class EncodingFlow(Flow[EncodingState]):
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
def _apply_defaults(self, item: ItemState) -> None:
|
||||
"""Apply caller values with config defaults (fast path)."""
|
||||
item.resolved_scope = item.scope or "/"
|
||||
"""Apply caller values with config defaults (fast path).
|
||||
|
||||
If root_scope is set, prepends it to the inner scope to create the
|
||||
final resolved_scope.
|
||||
"""
|
||||
inner_scope = item.scope or "/"
|
||||
# Join root_scope with inner scope if root_scope is set
|
||||
if item.root_scope:
|
||||
item.resolved_scope = join_scope_paths(item.root_scope, inner_scope)
|
||||
else:
|
||||
item.resolved_scope = inner_scope if inner_scope != "/" else "/"
|
||||
|
||||
item.resolved_categories = item.categories or []
|
||||
item.resolved_metadata = item.metadata or {}
|
||||
item.resolved_importance = (
|
||||
|
||||
@@ -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,
|
||||
@@ -32,6 +31,7 @@ from crewai.memory.types import (
|
||||
compute_composite_score,
|
||||
embed_text,
|
||||
)
|
||||
from crewai.memory.utils import join_scope_paths
|
||||
from crewai.rag.embeddings.factory import build_embedder
|
||||
from crewai.rag.embeddings.providers.openai.types import OpenAIProviderSpec
|
||||
|
||||
@@ -127,6 +127,14 @@ class Memory(BaseModel):
|
||||
default=False,
|
||||
description="If True, remember() and remember_many() are silent no-ops.",
|
||||
)
|
||||
root_scope: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Structural root scope prefix. When set, LLM-inferred or explicit scopes "
|
||||
"are nested under this root. For example, a crew with root_scope='/crew/research' "
|
||||
"will store memories at '/crew/research/<inferred_scope>'."
|
||||
),
|
||||
)
|
||||
|
||||
_config: MemoryConfig = PrivateAttr()
|
||||
_llm_instance: BaseLLM | None = PrivateAttr(default=None)
|
||||
@@ -298,11 +306,26 @@ class Memory(BaseModel):
|
||||
importance: float | None = None,
|
||||
source: str | None = None,
|
||||
private: bool = False,
|
||||
root_scope: str | None = None,
|
||||
) -> list[MemoryRecord]:
|
||||
"""Run the batch EncodingFlow for one or more items. No event emission.
|
||||
|
||||
This is the core encoding logic shared by ``remember()`` and
|
||||
``remember_many()``. Events are managed by the calling method.
|
||||
|
||||
Args:
|
||||
contents: List of text content to encode and store.
|
||||
scope: Optional explicit scope (inner scope, nested under root_scope).
|
||||
categories: Optional categories for all items.
|
||||
metadata: Optional metadata for all items.
|
||||
importance: Optional importance score for all items.
|
||||
source: Optional source identifier for all items.
|
||||
private: Whether items are private.
|
||||
root_scope: Structural root scope prefix. LLM-inferred or explicit
|
||||
scopes are nested under this root.
|
||||
|
||||
Returns:
|
||||
List of created MemoryRecord instances.
|
||||
"""
|
||||
from crewai.memory.encoding_flow import EncodingFlow
|
||||
|
||||
@@ -321,6 +344,7 @@ class Memory(BaseModel):
|
||||
"importance": importance,
|
||||
"source": source,
|
||||
"private": private,
|
||||
"root_scope": root_scope,
|
||||
}
|
||||
for c in contents
|
||||
]
|
||||
@@ -341,6 +365,7 @@ class Memory(BaseModel):
|
||||
source: str | None = None,
|
||||
private: bool = False,
|
||||
agent_role: str | None = None,
|
||||
root_scope: str | None = None,
|
||||
) -> MemoryRecord | None:
|
||||
"""Store a single item in memory (synchronous).
|
||||
|
||||
@@ -350,13 +375,15 @@ class Memory(BaseModel):
|
||||
|
||||
Args:
|
||||
content: Text to remember.
|
||||
scope: Optional scope path; inferred if None.
|
||||
scope: Optional scope path (inner scope); inferred if None.
|
||||
categories: Optional categories; inferred if None.
|
||||
metadata: Optional metadata; merged with LLM-extracted if inferred.
|
||||
importance: Optional importance 0-1; inferred if None.
|
||||
source: Optional provenance identifier (e.g. user ID, session ID).
|
||||
private: If True, only visible to recall from the same source.
|
||||
agent_role: Optional agent role for event metadata.
|
||||
root_scope: Optional root scope override. If provided, this overrides
|
||||
the instance-level root_scope for this call only.
|
||||
|
||||
Returns:
|
||||
The created MemoryRecord, or None if this memory is read-only.
|
||||
@@ -366,6 +393,10 @@ class Memory(BaseModel):
|
||||
"""
|
||||
if self.read_only:
|
||||
return None
|
||||
|
||||
# Determine effective root_scope: per-call override takes precedence
|
||||
effective_root = root_scope if root_scope is not None else self.root_scope
|
||||
|
||||
_source_type = "unified_memory"
|
||||
try:
|
||||
crewai_event_bus.emit(
|
||||
@@ -389,6 +420,7 @@ class Memory(BaseModel):
|
||||
importance,
|
||||
source,
|
||||
private,
|
||||
effective_root,
|
||||
)
|
||||
records = future.result()
|
||||
record = records[0] if records else None
|
||||
@@ -427,6 +459,7 @@ class Memory(BaseModel):
|
||||
source: str | None = None,
|
||||
private: bool = False,
|
||||
agent_role: str | None = None,
|
||||
root_scope: str | None = None,
|
||||
) -> list[MemoryRecord]:
|
||||
"""Store multiple items in memory (non-blocking).
|
||||
|
||||
@@ -441,13 +474,15 @@ class Memory(BaseModel):
|
||||
|
||||
Args:
|
||||
contents: List of text items to remember.
|
||||
scope: Optional scope applied to all items.
|
||||
scope: Optional scope (inner scope) applied to all items.
|
||||
categories: Optional categories applied to all items.
|
||||
metadata: Optional metadata applied to all items.
|
||||
importance: Optional importance applied to all items.
|
||||
source: Optional provenance identifier applied to all items.
|
||||
private: Privacy flag applied to all items.
|
||||
agent_role: Optional agent role for event metadata.
|
||||
root_scope: Optional root scope override. If provided, this overrides
|
||||
the instance-level root_scope for this call only.
|
||||
|
||||
Returns:
|
||||
Empty list (records are not available until the background save completes).
|
||||
@@ -455,6 +490,9 @@ class Memory(BaseModel):
|
||||
if not contents or self.read_only:
|
||||
return []
|
||||
|
||||
# Determine effective root_scope: per-call override takes precedence
|
||||
effective_root = root_scope if root_scope is not None else self.root_scope
|
||||
|
||||
self._submit_save(
|
||||
self._background_encode_batch,
|
||||
contents,
|
||||
@@ -465,6 +503,7 @@ class Memory(BaseModel):
|
||||
source,
|
||||
private,
|
||||
agent_role,
|
||||
effective_root,
|
||||
)
|
||||
return []
|
||||
|
||||
@@ -478,6 +517,7 @@ class Memory(BaseModel):
|
||||
source: str | None,
|
||||
private: bool,
|
||||
agent_role: str | None,
|
||||
root_scope: str | None = None,
|
||||
) -> list[MemoryRecord]:
|
||||
"""Run the encoding pipeline in a background thread with event emission.
|
||||
|
||||
@@ -487,6 +527,20 @@ class Memory(BaseModel):
|
||||
All ``emit`` calls are wrapped in try/except to handle the case where
|
||||
the event bus shuts down before the background save finishes (e.g.
|
||||
during process exit).
|
||||
|
||||
Args:
|
||||
contents: List of text content to encode.
|
||||
scope: Optional inner scope for all items.
|
||||
categories: Optional categories for all items.
|
||||
metadata: Optional metadata for all items.
|
||||
importance: Optional importance for all items.
|
||||
source: Optional source identifier for all items.
|
||||
private: Whether items are private.
|
||||
agent_role: Optional agent role for event metadata.
|
||||
root_scope: Optional root scope prefix for hierarchical scoping.
|
||||
|
||||
Returns:
|
||||
List of created MemoryRecord instances.
|
||||
"""
|
||||
try:
|
||||
crewai_event_bus.emit(
|
||||
@@ -503,7 +557,14 @@ class Memory(BaseModel):
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
records = self._encode_batch(
|
||||
contents, scope, categories, metadata, importance, source, private
|
||||
contents,
|
||||
scope,
|
||||
categories,
|
||||
metadata,
|
||||
importance,
|
||||
source,
|
||||
private,
|
||||
root_scope,
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
except RuntimeError:
|
||||
@@ -576,6 +637,14 @@ class Memory(BaseModel):
|
||||
# so that the search sees all persisted records.
|
||||
self.drain_writes()
|
||||
|
||||
# Apply root_scope as default scope_prefix for read isolation
|
||||
effective_scope = scope
|
||||
if effective_scope is None and self.root_scope:
|
||||
effective_scope = self.root_scope
|
||||
elif effective_scope is not None and self.root_scope:
|
||||
# Nest provided scope under root
|
||||
effective_scope = join_scope_paths(self.root_scope, effective_scope)
|
||||
|
||||
_source = "unified_memory"
|
||||
try:
|
||||
crewai_event_bus.emit(
|
||||
@@ -596,7 +665,7 @@ class Memory(BaseModel):
|
||||
else:
|
||||
raw = self._storage.search(
|
||||
embedding,
|
||||
scope_prefix=scope,
|
||||
scope_prefix=effective_scope,
|
||||
categories=categories,
|
||||
limit=limit,
|
||||
min_score=0.0,
|
||||
@@ -620,6 +689,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,
|
||||
@@ -629,7 +700,7 @@ class Memory(BaseModel):
|
||||
flow.kickoff(
|
||||
inputs={
|
||||
"query": query,
|
||||
"scope": scope,
|
||||
"scope": effective_scope,
|
||||
"categories": categories or [],
|
||||
"limit": limit,
|
||||
"source": source,
|
||||
@@ -683,11 +754,24 @@ class Memory(BaseModel):
|
||||
) -> int:
|
||||
"""Delete memories matching criteria.
|
||||
|
||||
Args:
|
||||
scope: Scope to delete from. If None and root_scope is set, deletes
|
||||
only within root_scope.
|
||||
categories: Filter by categories.
|
||||
older_than: Delete records older than this datetime.
|
||||
metadata_filter: Filter by metadata fields.
|
||||
record_ids: Specific record IDs to delete.
|
||||
|
||||
Returns:
|
||||
Number of records deleted.
|
||||
"""
|
||||
effective_scope = scope
|
||||
if effective_scope is None and self.root_scope:
|
||||
effective_scope = self.root_scope
|
||||
elif effective_scope is not None and self.root_scope:
|
||||
effective_scope = join_scope_paths(self.root_scope, effective_scope)
|
||||
return self._storage.delete(
|
||||
scope_prefix=scope,
|
||||
scope_prefix=effective_scope,
|
||||
categories=categories,
|
||||
record_ids=record_ids,
|
||||
older_than=older_than,
|
||||
@@ -762,9 +846,21 @@ class Memory(BaseModel):
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
def list_scopes(self, path: str = "/") -> list[str]:
|
||||
"""List immediate child scopes under path."""
|
||||
return self._storage.list_scopes(path)
|
||||
def list_scopes(self, path: str | None = None) -> list[str]:
|
||||
"""List immediate child scopes under path.
|
||||
|
||||
Args:
|
||||
path: Scope path to list children of. If None and root_scope is set,
|
||||
defaults to root_scope. Otherwise defaults to '/'.
|
||||
"""
|
||||
effective_path = path
|
||||
if effective_path is None and self.root_scope:
|
||||
effective_path = self.root_scope
|
||||
elif effective_path is not None and self.root_scope:
|
||||
effective_path = join_scope_paths(self.root_scope, effective_path)
|
||||
elif effective_path is None:
|
||||
effective_path = "/"
|
||||
return self._storage.list_scopes(effective_path)
|
||||
|
||||
def list_records(
|
||||
self, scope: str | None = None, limit: int = 200, offset: int = 0
|
||||
@@ -772,20 +868,52 @@ class Memory(BaseModel):
|
||||
"""List records in a scope, newest first.
|
||||
|
||||
Args:
|
||||
scope: Optional scope path prefix to filter by.
|
||||
scope: Optional scope path prefix to filter by. If None and root_scope
|
||||
is set, defaults to root_scope.
|
||||
limit: Maximum number of records to return.
|
||||
offset: Number of records to skip (for pagination).
|
||||
"""
|
||||
effective_scope = scope
|
||||
if effective_scope is None and self.root_scope:
|
||||
effective_scope = self.root_scope
|
||||
elif effective_scope is not None and self.root_scope:
|
||||
effective_scope = join_scope_paths(self.root_scope, effective_scope)
|
||||
return self._storage.list_records(
|
||||
scope_prefix=scope, limit=limit, offset=offset
|
||||
scope_prefix=effective_scope, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
def info(self, path: str = "/") -> ScopeInfo:
|
||||
"""Return scope info for path."""
|
||||
return self._storage.get_scope_info(path)
|
||||
def info(self, path: str | None = None) -> ScopeInfo:
|
||||
"""Return scope info for path.
|
||||
|
||||
Args:
|
||||
path: Scope path to get info for. If None and root_scope is set,
|
||||
defaults to root_scope. Otherwise defaults to '/'.
|
||||
"""
|
||||
effective_path = path
|
||||
if effective_path is None and self.root_scope:
|
||||
effective_path = self.root_scope
|
||||
elif effective_path is not None and self.root_scope:
|
||||
effective_path = join_scope_paths(self.root_scope, effective_path)
|
||||
elif effective_path is None:
|
||||
effective_path = "/"
|
||||
return self._storage.get_scope_info(effective_path)
|
||||
|
||||
def tree(self, path: str | None = None, max_depth: int = 3) -> str:
|
||||
"""Return a formatted tree of scopes (string).
|
||||
|
||||
Args:
|
||||
path: Root path for the tree. If None and root_scope is set,
|
||||
defaults to root_scope. Otherwise defaults to '/'.
|
||||
max_depth: Maximum depth to traverse.
|
||||
"""
|
||||
effective_path = path
|
||||
if effective_path is None and self.root_scope:
|
||||
effective_path = self.root_scope
|
||||
elif effective_path is not None and self.root_scope:
|
||||
effective_path = join_scope_paths(self.root_scope, effective_path)
|
||||
elif effective_path is None:
|
||||
effective_path = "/"
|
||||
|
||||
def tree(self, path: str = "/", max_depth: int = 3) -> str:
|
||||
"""Return a formatted tree of scopes (string)."""
|
||||
lines: list[str] = []
|
||||
|
||||
def _walk(p: str, depth: int, prefix: str) -> None:
|
||||
@@ -796,16 +924,36 @@ class Memory(BaseModel):
|
||||
for child in info.child_scopes[:20]:
|
||||
_walk(child, depth + 1, prefix + " ")
|
||||
|
||||
_walk(path.rstrip("/") or "/", 0, "")
|
||||
return "\n".join(lines) if lines else f"{path or '/'} (0 records)"
|
||||
_walk(effective_path.rstrip("/") or "/", 0, "")
|
||||
return "\n".join(lines) if lines else f"{effective_path or '/'} (0 records)"
|
||||
|
||||
def list_categories(self, path: str | None = None) -> dict[str, int]:
|
||||
"""List categories and counts; path=None means global."""
|
||||
return self._storage.list_categories(scope_prefix=path)
|
||||
"""List categories and counts.
|
||||
|
||||
Args:
|
||||
path: Scope path to filter categories by. If None and root_scope is set,
|
||||
defaults to root_scope.
|
||||
"""
|
||||
effective_path = path
|
||||
if effective_path is None and self.root_scope:
|
||||
effective_path = self.root_scope
|
||||
elif effective_path is not None and self.root_scope:
|
||||
effective_path = join_scope_paths(self.root_scope, effective_path)
|
||||
return self._storage.list_categories(scope_prefix=effective_path)
|
||||
|
||||
def reset(self, scope: str | None = None) -> None:
|
||||
"""Reset (delete all) memories in scope. None = all."""
|
||||
self._storage.reset(scope_prefix=scope)
|
||||
"""Reset (delete all) memories in scope.
|
||||
|
||||
Args:
|
||||
scope: Scope to reset. If None and root_scope is set, resets only
|
||||
within root_scope. If None and no root_scope, resets all.
|
||||
"""
|
||||
effective_scope = scope
|
||||
if effective_scope is None and self.root_scope:
|
||||
effective_scope = self.root_scope
|
||||
elif effective_scope is not None and self.root_scope:
|
||||
effective_scope = join_scope_paths(self.root_scope, effective_scope)
|
||||
self._storage.reset(scope_prefix=effective_scope)
|
||||
|
||||
async def aextract_memories(self, content: str) -> list[str]:
|
||||
"""Async variant of extract_memories."""
|
||||
|
||||
110
lib/crewai/src/crewai/memory/utils.py
Normal file
110
lib/crewai/src/crewai/memory/utils.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Utility functions for the unified memory system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def sanitize_scope_name(name: str) -> str:
|
||||
"""Sanitize a name for use in hierarchical scope paths.
|
||||
|
||||
Converts to lowercase, replaces non-alphanumeric chars (except underscore
|
||||
and hyphen) with hyphens, collapses multiple hyphens, strips leading/trailing
|
||||
hyphens.
|
||||
|
||||
Args:
|
||||
name: The raw name to sanitize (e.g. crew name, agent role, flow class name).
|
||||
|
||||
Returns:
|
||||
A sanitized string safe for use in scope paths. Returns 'unknown' if the
|
||||
result would be empty.
|
||||
|
||||
Examples:
|
||||
>>> sanitize_scope_name("Research Crew")
|
||||
'research-crew'
|
||||
>>> sanitize_scope_name("Agent #1 (Main)")
|
||||
'agent-1-main'
|
||||
>>> sanitize_scope_name("café_worker")
|
||||
'caf-_worker'
|
||||
"""
|
||||
if not name:
|
||||
return "unknown"
|
||||
name = name.lower().strip()
|
||||
# Replace any character that's not alphanumeric, underscore, or hyphen with hyphen
|
||||
name = re.sub(r"[^a-z0-9_-]", "-", name)
|
||||
# Collapse multiple hyphens into one
|
||||
name = re.sub(r"-+", "-", name)
|
||||
# Strip leading/trailing hyphens
|
||||
name = name.strip("-")
|
||||
return name or "unknown"
|
||||
|
||||
|
||||
def normalize_scope_path(path: str) -> str:
|
||||
"""Normalize a scope path by removing double slashes and ensuring proper format.
|
||||
|
||||
Args:
|
||||
path: The raw scope path (e.g. '/crew/MyCrewName//agent//role').
|
||||
|
||||
Returns:
|
||||
A normalized path with leading slash, no trailing slash, no double slashes.
|
||||
Returns '/' for empty or root-only paths.
|
||||
|
||||
Examples:
|
||||
>>> normalize_scope_path("/crew/test//agent//")
|
||||
'/crew/test/agent'
|
||||
>>> normalize_scope_path("")
|
||||
'/'
|
||||
>>> normalize_scope_path("crew/test")
|
||||
'/crew/test'
|
||||
"""
|
||||
if not path or path == "/":
|
||||
return "/"
|
||||
# Collapse multiple slashes
|
||||
path = re.sub(r"/+", "/", path)
|
||||
# Ensure leading slash
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
# Remove trailing slash (unless it's just '/')
|
||||
if len(path) > 1:
|
||||
path = path.rstrip("/")
|
||||
return path
|
||||
|
||||
|
||||
def join_scope_paths(root: str | None, inner: str | None) -> str:
|
||||
"""Join a root scope with an inner scope, handling edge cases properly.
|
||||
|
||||
Args:
|
||||
root: The root scope prefix (e.g. '/crew/research-crew').
|
||||
inner: The inner scope (e.g. '/market-trends' or 'market-trends').
|
||||
|
||||
Returns:
|
||||
The combined, normalized scope path.
|
||||
|
||||
Examples:
|
||||
>>> join_scope_paths("/crew/test", "/market-trends")
|
||||
'/crew/test/market-trends'
|
||||
>>> join_scope_paths("/crew/test", "market-trends")
|
||||
'/crew/test/market-trends'
|
||||
>>> join_scope_paths("/crew/test", "/")
|
||||
'/crew/test'
|
||||
>>> join_scope_paths("/crew/test", None)
|
||||
'/crew/test'
|
||||
>>> join_scope_paths(None, "/market-trends")
|
||||
'/market-trends'
|
||||
>>> join_scope_paths(None, None)
|
||||
'/'
|
||||
"""
|
||||
# Normalize both parts
|
||||
root = root.rstrip("/") if root else ""
|
||||
inner = inner.strip("/") if inner else ""
|
||||
|
||||
if root and inner:
|
||||
result = f"{root}/{inner}"
|
||||
elif root:
|
||||
result = root
|
||||
elif inner:
|
||||
result = f"/{inner}"
|
||||
else:
|
||||
result = "/"
|
||||
|
||||
return normalize_scope_path(result)
|
||||
17
lib/crewai/src/crewai/skills/__init__.py
Normal file
17
lib/crewai/src/crewai/skills/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Agent Skills standard implementation for crewAI.
|
||||
|
||||
Provides filesystem-based skill packaging with progressive disclosure.
|
||||
"""
|
||||
|
||||
from crewai.skills.loader import activate_skill, discover_skills
|
||||
from crewai.skills.models import Skill, SkillFrontmatter
|
||||
from crewai.skills.parser import SkillParseError
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Skill",
|
||||
"SkillFrontmatter",
|
||||
"SkillParseError",
|
||||
"activate_skill",
|
||||
"discover_skills",
|
||||
]
|
||||
184
lib/crewai/src/crewai/skills/loader.py
Normal file
184
lib/crewai/src/crewai/skills/loader.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Filesystem discovery and progressive loading for Agent Skills.
|
||||
|
||||
Provides functions to discover skills in directories, activate them
|
||||
for agent use, and format skill context for prompt injection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.skill_events import (
|
||||
SkillActivatedEvent,
|
||||
SkillDiscoveryCompletedEvent,
|
||||
SkillDiscoveryStartedEvent,
|
||||
SkillLoadFailedEvent,
|
||||
SkillLoadedEvent,
|
||||
)
|
||||
from crewai.skills.models import INSTRUCTIONS, RESOURCES, Skill
|
||||
from crewai.skills.parser import (
|
||||
SKILL_FILENAME,
|
||||
load_skill_instructions,
|
||||
load_skill_metadata,
|
||||
load_skill_resources,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai.agents.agent_builder.base_agent import BaseAgent
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def discover_skills(
|
||||
search_path: Path,
|
||||
source: BaseAgent | None = None,
|
||||
) -> list[Skill]:
|
||||
"""Scan a directory for skill directories containing SKILL.md.
|
||||
|
||||
Loads each discovered skill at METADATA disclosure level.
|
||||
|
||||
Args:
|
||||
search_path: Directory to scan for skill subdirectories.
|
||||
source: Optional event source (agent or crew) for event emission.
|
||||
|
||||
Returns:
|
||||
List of Skill instances at METADATA level.
|
||||
"""
|
||||
if not search_path.is_dir():
|
||||
msg = f"Skill search path does not exist or is not a directory: {search_path}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
skills: list[Skill] = []
|
||||
|
||||
if source is not None:
|
||||
crewai_event_bus.emit(
|
||||
source,
|
||||
event=SkillDiscoveryStartedEvent(
|
||||
from_agent=source,
|
||||
search_path=search_path,
|
||||
),
|
||||
)
|
||||
|
||||
for child in sorted(search_path.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
skill_md = child / SKILL_FILENAME
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
try:
|
||||
skill = load_skill_metadata(child)
|
||||
skills.append(skill)
|
||||
if source is not None:
|
||||
crewai_event_bus.emit(
|
||||
source,
|
||||
event=SkillLoadedEvent(
|
||||
from_agent=source,
|
||||
skill_name=skill.name,
|
||||
skill_path=skill.path,
|
||||
disclosure_level=skill.disclosure_level,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning("Failed to load skill from %s: %s", child, e)
|
||||
if source is not None:
|
||||
crewai_event_bus.emit(
|
||||
source,
|
||||
event=SkillLoadFailedEvent(
|
||||
from_agent=source,
|
||||
skill_name=child.name,
|
||||
skill_path=child,
|
||||
error=str(e),
|
||||
),
|
||||
)
|
||||
|
||||
if source is not None:
|
||||
crewai_event_bus.emit(
|
||||
source,
|
||||
event=SkillDiscoveryCompletedEvent(
|
||||
from_agent=source,
|
||||
search_path=search_path,
|
||||
skills_found=len(skills),
|
||||
skill_names=[s.name for s in skills],
|
||||
),
|
||||
)
|
||||
|
||||
return skills
|
||||
|
||||
|
||||
def activate_skill(
|
||||
skill: Skill,
|
||||
source: BaseAgent | None = None,
|
||||
) -> Skill:
|
||||
"""Promote a skill to INSTRUCTIONS disclosure level.
|
||||
|
||||
Idempotent: returns the skill unchanged if already at or above INSTRUCTIONS.
|
||||
|
||||
Args:
|
||||
skill: Skill to activate.
|
||||
source: Optional event source for event emission.
|
||||
|
||||
Returns:
|
||||
Skill at INSTRUCTIONS level or higher.
|
||||
"""
|
||||
if skill.disclosure_level >= INSTRUCTIONS:
|
||||
return skill
|
||||
|
||||
activated = load_skill_instructions(skill)
|
||||
|
||||
if source is not None:
|
||||
crewai_event_bus.emit(
|
||||
source,
|
||||
event=SkillActivatedEvent(
|
||||
from_agent=source,
|
||||
skill_name=activated.name,
|
||||
skill_path=activated.path,
|
||||
disclosure_level=activated.disclosure_level,
|
||||
),
|
||||
)
|
||||
|
||||
return activated
|
||||
|
||||
|
||||
def load_resources(skill: Skill) -> Skill:
|
||||
"""Promote a skill to RESOURCES disclosure level.
|
||||
|
||||
Args:
|
||||
skill: Skill to promote.
|
||||
|
||||
Returns:
|
||||
Skill at RESOURCES level.
|
||||
"""
|
||||
return load_skill_resources(skill)
|
||||
|
||||
|
||||
def format_skill_context(skill: Skill) -> str:
|
||||
"""Format skill information for agent prompt injection.
|
||||
|
||||
At METADATA level: returns name and description only.
|
||||
At INSTRUCTIONS level or above: returns full SKILL.md body.
|
||||
|
||||
Args:
|
||||
skill: The skill to format.
|
||||
|
||||
Returns:
|
||||
Formatted skill context string.
|
||||
"""
|
||||
if skill.disclosure_level >= INSTRUCTIONS and skill.instructions:
|
||||
parts = [
|
||||
f"## Skill: {skill.name}",
|
||||
skill.description,
|
||||
"",
|
||||
skill.instructions,
|
||||
]
|
||||
if skill.disclosure_level >= RESOURCES and skill.resource_files:
|
||||
parts.append("")
|
||||
parts.append("### Available Resources")
|
||||
for dir_name, files in sorted(skill.resource_files.items()):
|
||||
if files:
|
||||
parts.append(f"- **{dir_name}/**: {', '.join(files)}")
|
||||
return "\n".join(parts)
|
||||
return f"## Skill: {skill.name}\n{skill.description}"
|
||||
175
lib/crewai/src/crewai/skills/models.py
Normal file
175
lib/crewai/src/crewai/skills/models.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Pydantic data models for the Agent Skills standard.
|
||||
|
||||
Defines DisclosureLevel, SkillFrontmatter, and Skill models for
|
||||
progressive disclosure of skill information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from crewai.skills.validation import (
|
||||
MAX_SKILL_NAME_LENGTH,
|
||||
MIN_SKILL_NAME_LENGTH,
|
||||
SKILL_NAME_PATTERN,
|
||||
)
|
||||
|
||||
|
||||
MAX_DESCRIPTION_LENGTH: Final[int] = 1024
|
||||
ResourceDirName = Literal["scripts", "references", "assets"]
|
||||
|
||||
|
||||
DisclosureLevel = Annotated[
|
||||
Literal[1, 2, 3], "Progressive disclosure levels for skill loading."
|
||||
]
|
||||
|
||||
METADATA: Final[
|
||||
Annotated[
|
||||
DisclosureLevel, "Only frontmatter metadata is loaded (name, description)."
|
||||
]
|
||||
] = 1
|
||||
INSTRUCTIONS: Final[Annotated[DisclosureLevel, "Full SKILL.md body is loaded."]] = 2
|
||||
RESOURCES: Final[
|
||||
Annotated[
|
||||
DisclosureLevel,
|
||||
"Resource directories (scripts, references, assets) are cataloged.",
|
||||
]
|
||||
] = 3
|
||||
|
||||
|
||||
class SkillFrontmatter(BaseModel):
|
||||
"""YAML frontmatter from a SKILL.md file.
|
||||
|
||||
Attributes:
|
||||
name: Unique skill identifier (1-64 chars, lowercase alphanumeric + hyphens).
|
||||
description: Human-readable description (1-1024 chars).
|
||||
license: Optional license name or reference.
|
||||
compatibility: Optional compatibility information (max 500 chars).
|
||||
metadata: Optional additional metadata as string key-value pairs.
|
||||
allowed_tools: Optional space-delimited list of pre-approved tools.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, populate_by_name=True)
|
||||
|
||||
name: str = Field(
|
||||
min_length=MIN_SKILL_NAME_LENGTH,
|
||||
max_length=MAX_SKILL_NAME_LENGTH,
|
||||
pattern=SKILL_NAME_PATTERN,
|
||||
)
|
||||
description: str = Field(min_length=1, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
license: str | None = Field(
|
||||
default=None,
|
||||
description="SPDX license identifier or free-text license reference, e.g. 'MIT', 'Apache-2.0'.",
|
||||
)
|
||||
compatibility: str | None = Field(
|
||||
default=None,
|
||||
max_length=500,
|
||||
description="Version or platform constraints for the skill, e.g. 'crewai >= 0.80'.",
|
||||
)
|
||||
metadata: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
description="Arbitrary string key-value pairs for custom skill metadata.",
|
||||
)
|
||||
allowed_tools: list[str] | None = Field(
|
||||
default=None,
|
||||
alias="allowed-tools",
|
||||
description="Pre-approved tool names the skill may use, parsed from a space-delimited string in frontmatter.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def parse_allowed_tools(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Parse space-delimited allowed-tools string into a list."""
|
||||
key = "allowed-tools"
|
||||
alt_key = "allowed_tools"
|
||||
raw = values.get(key) or values.get(alt_key)
|
||||
if isinstance(raw, str):
|
||||
values[key] = raw.split()
|
||||
return values
|
||||
|
||||
|
||||
class Skill(BaseModel):
|
||||
"""A loaded Agent Skill with progressive disclosure support.
|
||||
|
||||
Attributes:
|
||||
frontmatter: Parsed YAML frontmatter.
|
||||
instructions: Full SKILL.md body text (populated at INSTRUCTIONS level).
|
||||
path: Filesystem path to the skill directory.
|
||||
disclosure_level: Current disclosure level of the skill.
|
||||
resource_files: Cataloged resource files (populated at RESOURCES level).
|
||||
"""
|
||||
|
||||
frontmatter: SkillFrontmatter = Field(
|
||||
description="Parsed YAML frontmatter from SKILL.md.",
|
||||
)
|
||||
instructions: str | None = Field(
|
||||
default=None,
|
||||
description="Full SKILL.md body text, populated at INSTRUCTIONS level.",
|
||||
)
|
||||
path: Path = Field(
|
||||
description="Filesystem path to the skill directory.",
|
||||
)
|
||||
disclosure_level: DisclosureLevel = Field(
|
||||
default=METADATA,
|
||||
description="Current progressive disclosure level of the skill.",
|
||||
)
|
||||
resource_files: dict[ResourceDirName, list[str]] | None = Field(
|
||||
default=None,
|
||||
description="Cataloged resource files by directory, populated at RESOURCES level.",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Skill name from frontmatter."""
|
||||
return self.frontmatter.name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Skill description from frontmatter."""
|
||||
return self.frontmatter.description
|
||||
|
||||
@property
|
||||
def scripts_dir(self) -> Path:
|
||||
"""Path to the scripts directory."""
|
||||
return self.path / "scripts"
|
||||
|
||||
@property
|
||||
def references_dir(self) -> Path:
|
||||
"""Path to the references directory."""
|
||||
return self.path / "references"
|
||||
|
||||
@property
|
||||
def assets_dir(self) -> Path:
|
||||
"""Path to the assets directory."""
|
||||
return self.path / "assets"
|
||||
|
||||
def with_disclosure_level(
|
||||
self,
|
||||
level: DisclosureLevel,
|
||||
instructions: str | None = None,
|
||||
resource_files: dict[ResourceDirName, list[str]] | None = None,
|
||||
) -> Skill:
|
||||
"""Create a new Skill at a different disclosure level.
|
||||
|
||||
Args:
|
||||
level: The new disclosure level.
|
||||
instructions: Optional instructions body text.
|
||||
resource_files: Optional cataloged resource files.
|
||||
|
||||
Returns:
|
||||
A new Skill instance at the specified disclosure level.
|
||||
"""
|
||||
return Skill(
|
||||
frontmatter=self.frontmatter,
|
||||
instructions=instructions
|
||||
if instructions is not None
|
||||
else self.instructions,
|
||||
path=self.path,
|
||||
disclosure_level=level,
|
||||
resource_files=(
|
||||
resource_files if resource_files is not None else self.resource_files
|
||||
),
|
||||
)
|
||||
194
lib/crewai/src/crewai/skills/parser.py
Normal file
194
lib/crewai/src/crewai/skills/parser.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""SKILL.md file parsing for the Agent Skills standard.
|
||||
|
||||
Parses YAML frontmatter and markdown body from SKILL.md files,
|
||||
and provides progressive loading functions for skill data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Final
|
||||
|
||||
import yaml
|
||||
|
||||
from crewai.skills.models import (
|
||||
INSTRUCTIONS,
|
||||
METADATA,
|
||||
RESOURCES,
|
||||
ResourceDirName,
|
||||
Skill,
|
||||
SkillFrontmatter,
|
||||
)
|
||||
from crewai.skills.validation import validate_directory_name
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SKILL_FILENAME: Final[str] = "SKILL.md"
|
||||
_CLOSING_DELIMITER: Final[re.Pattern[str]] = re.compile(r"\n---[ \t]*(?:\n|$)")
|
||||
_MAX_BODY_CHARS: Final[int] = 50_000
|
||||
|
||||
|
||||
class SkillParseError(ValueError):
|
||||
"""Error raised when SKILL.md parsing fails."""
|
||||
|
||||
|
||||
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
||||
"""Split SKILL.md content into frontmatter dict and body text.
|
||||
|
||||
Args:
|
||||
content: Raw SKILL.md file content.
|
||||
|
||||
Returns:
|
||||
Tuple of (frontmatter dict, body text).
|
||||
|
||||
Raises:
|
||||
SkillParseError: If frontmatter delimiters are missing or YAML is invalid.
|
||||
"""
|
||||
if not content.startswith("---"):
|
||||
msg = "SKILL.md must start with '---' frontmatter delimiter"
|
||||
raise SkillParseError(msg)
|
||||
|
||||
match = _CLOSING_DELIMITER.search(content, pos=3)
|
||||
if match is None:
|
||||
msg = "SKILL.md missing closing '---' frontmatter delimiter"
|
||||
raise SkillParseError(msg)
|
||||
|
||||
yaml_content = content[3 : match.start()].strip()
|
||||
body = content[match.end() :].strip()
|
||||
|
||||
try:
|
||||
frontmatter = yaml.safe_load(yaml_content)
|
||||
except yaml.YAMLError as e:
|
||||
msg = f"Invalid YAML in frontmatter: {e}"
|
||||
raise SkillParseError(msg) from e
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
msg = "Frontmatter must be a YAML mapping"
|
||||
raise SkillParseError(msg)
|
||||
|
||||
return frontmatter, body
|
||||
|
||||
|
||||
def parse_skill_md(path: Path) -> tuple[SkillFrontmatter, str]:
|
||||
"""Read and parse a SKILL.md file.
|
||||
|
||||
Args:
|
||||
path: Path to the SKILL.md file.
|
||||
|
||||
Returns:
|
||||
Tuple of (SkillFrontmatter, body text).
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the file does not exist.
|
||||
SkillParseError: If parsing fails.
|
||||
"""
|
||||
content = path.read_text(encoding="utf-8")
|
||||
frontmatter_dict, body = parse_frontmatter(content)
|
||||
frontmatter = SkillFrontmatter(**frontmatter_dict)
|
||||
return frontmatter, body
|
||||
|
||||
|
||||
def load_skill_metadata(skill_dir: Path) -> Skill:
|
||||
"""Load a skill at METADATA disclosure level.
|
||||
|
||||
Parses SKILL.md frontmatter only and validates directory name.
|
||||
|
||||
Args:
|
||||
skill_dir: Path to the skill directory.
|
||||
|
||||
Returns:
|
||||
Skill instance at METADATA level.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If SKILL.md is missing.
|
||||
SkillParseError: If parsing fails.
|
||||
ValueError: If directory name doesn't match skill name.
|
||||
"""
|
||||
skill_md_path = skill_dir / SKILL_FILENAME
|
||||
frontmatter, body = parse_skill_md(skill_md_path)
|
||||
validate_directory_name(skill_dir, frontmatter.name)
|
||||
if len(body) > _MAX_BODY_CHARS:
|
||||
_logger.warning(
|
||||
"SKILL.md body for '%s' is %d chars (threshold: %d). "
|
||||
"Large bodies may consume significant context window when injected into prompts.",
|
||||
frontmatter.name,
|
||||
len(body),
|
||||
_MAX_BODY_CHARS,
|
||||
)
|
||||
return Skill(
|
||||
frontmatter=frontmatter,
|
||||
path=skill_dir,
|
||||
disclosure_level=METADATA,
|
||||
)
|
||||
|
||||
|
||||
def load_skill_instructions(skill: Skill) -> Skill:
|
||||
"""Promote a skill to INSTRUCTIONS disclosure level.
|
||||
|
||||
Reads the full SKILL.md body text.
|
||||
|
||||
Args:
|
||||
skill: Skill at METADATA level.
|
||||
|
||||
Returns:
|
||||
New Skill instance at INSTRUCTIONS level.
|
||||
"""
|
||||
if skill.disclosure_level >= INSTRUCTIONS:
|
||||
return skill
|
||||
|
||||
skill_md_path = skill.path / SKILL_FILENAME
|
||||
_, body = parse_skill_md(skill_md_path)
|
||||
if len(body) > _MAX_BODY_CHARS:
|
||||
_logger.warning(
|
||||
"SKILL.md body for '%s' is %d chars (threshold: %d). "
|
||||
"Large bodies may consume significant context window when injected into prompts.",
|
||||
skill.name,
|
||||
len(body),
|
||||
_MAX_BODY_CHARS,
|
||||
)
|
||||
return skill.with_disclosure_level(
|
||||
level=INSTRUCTIONS,
|
||||
instructions=body,
|
||||
)
|
||||
|
||||
|
||||
def load_skill_resources(skill: Skill) -> Skill:
|
||||
"""Promote a skill to RESOURCES disclosure level.
|
||||
|
||||
Catalogs available resource directories (scripts, references, assets).
|
||||
|
||||
Args:
|
||||
skill: Skill at any level.
|
||||
|
||||
Returns:
|
||||
New Skill instance at RESOURCES level.
|
||||
"""
|
||||
if skill.disclosure_level >= RESOURCES:
|
||||
return skill
|
||||
|
||||
if skill.disclosure_level < INSTRUCTIONS:
|
||||
skill = load_skill_instructions(skill)
|
||||
|
||||
resource_dirs: list[tuple[ResourceDirName, Path]] = [
|
||||
("scripts", skill.scripts_dir),
|
||||
("references", skill.references_dir),
|
||||
("assets", skill.assets_dir),
|
||||
]
|
||||
resource_files: dict[ResourceDirName, list[str]] = {}
|
||||
for dir_name, resource_dir in resource_dirs:
|
||||
if resource_dir.is_dir():
|
||||
resource_files[dir_name] = sorted(
|
||||
str(f.relative_to(resource_dir))
|
||||
for f in resource_dir.rglob("*")
|
||||
if f.is_file()
|
||||
)
|
||||
|
||||
return skill.with_disclosure_level(
|
||||
level=RESOURCES,
|
||||
instructions=skill.instructions,
|
||||
resource_files=resource_files,
|
||||
)
|
||||
31
lib/crewai/src/crewai/skills/validation.py
Normal file
31
lib/crewai/src/crewai/skills/validation.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Validation functions for Agent Skills specification constraints.
|
||||
|
||||
Validates skill names and directory structures per the Agent Skills standard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
|
||||
MAX_SKILL_NAME_LENGTH: Final[int] = 64
|
||||
MIN_SKILL_NAME_LENGTH: Final[int] = 1
|
||||
SKILL_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
def validate_directory_name(skill_dir: Path, skill_name: str) -> None:
|
||||
"""Validate that a directory name matches the skill name.
|
||||
|
||||
Args:
|
||||
skill_dir: Path to the skill directory.
|
||||
skill_name: The declared skill name from frontmatter.
|
||||
|
||||
Raises:
|
||||
ValueError: If the directory name does not match the skill name.
|
||||
"""
|
||||
dir_name = skill_dir.name
|
||||
if dir_name != skill_name:
|
||||
msg = f"Directory name '{dir_name}' does not match skill name '{skill_name}'"
|
||||
raise ValueError(msg)
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -281,6 +281,7 @@ class BaseTool(BaseModel, ABC):
|
||||
result_as_answer=self.result_as_answer,
|
||||
max_usage_count=self.max_usage_count,
|
||||
current_usage_count=self.current_usage_count,
|
||||
cache_function=self.cache_function,
|
||||
)
|
||||
structured_tool._original_tool = self
|
||||
return structured_tool
|
||||
|
||||
@@ -58,6 +58,7 @@ class CrewStructuredTool:
|
||||
result_as_answer: bool = False,
|
||||
max_usage_count: int | None = None,
|
||||
current_usage_count: int = 0,
|
||||
cache_function: Callable[..., bool] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the structured tool.
|
||||
|
||||
@@ -69,6 +70,7 @@ class CrewStructuredTool:
|
||||
result_as_answer: Whether to return the output directly
|
||||
max_usage_count: Maximum number of times this tool can be used. None means unlimited usage.
|
||||
current_usage_count: Current number of times this tool has been used.
|
||||
cache_function: Function to determine if the tool result should be cached.
|
||||
"""
|
||||
self.name = name
|
||||
self.description = description
|
||||
@@ -78,6 +80,7 @@ class CrewStructuredTool:
|
||||
self.result_as_answer = result_as_answer
|
||||
self.max_usage_count = max_usage_count
|
||||
self.current_usage_count = current_usage_count
|
||||
self.cache_function = cache_function
|
||||
self._original_tool: BaseTool | None = None
|
||||
|
||||
# Validate the function signature matches the schema
|
||||
@@ -86,7 +89,7 @@ class CrewStructuredTool:
|
||||
@classmethod
|
||||
def from_function(
|
||||
cls,
|
||||
func: Callable,
|
||||
func: Callable[..., Any],
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
return_direct: bool = False,
|
||||
@@ -147,7 +150,7 @@ class CrewStructuredTool:
|
||||
@staticmethod
|
||||
def _create_schema_from_function(
|
||||
name: str,
|
||||
func: Callable,
|
||||
func: Callable[..., Any],
|
||||
) -> type[BaseModel]:
|
||||
"""Create a Pydantic schema from a function's signature.
|
||||
|
||||
@@ -182,7 +185,7 @@ class CrewStructuredTool:
|
||||
|
||||
# Create model
|
||||
schema_name = f"{name.title()}Schema"
|
||||
return create_model(schema_name, **fields) # type: ignore[call-overload]
|
||||
return create_model(schema_name, **fields) # type: ignore[call-overload, no-any-return]
|
||||
|
||||
def _validate_function_signature(self) -> None:
|
||||
"""Validate that the function signature matches the args schema."""
|
||||
@@ -210,7 +213,7 @@ class CrewStructuredTool:
|
||||
f"not found in args_schema"
|
||||
)
|
||||
|
||||
def _parse_args(self, raw_args: str | dict) -> dict:
|
||||
def _parse_args(self, raw_args: str | dict[str, Any]) -> dict[str, Any]:
|
||||
"""Parse and validate the input arguments against the schema.
|
||||
|
||||
Args:
|
||||
@@ -234,8 +237,8 @@ class CrewStructuredTool:
|
||||
|
||||
async def ainvoke(
|
||||
self,
|
||||
input: str | dict,
|
||||
config: dict | None = None,
|
||||
input: str | dict[str, Any],
|
||||
config: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Asynchronously invoke the tool.
|
||||
@@ -269,7 +272,7 @@ class CrewStructuredTool:
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
def _run(self, *args, **kwargs) -> Any:
|
||||
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Legacy method for compatibility."""
|
||||
# Convert args/kwargs to our expected format
|
||||
input_dict = dict(zip(self.args_schema.model_fields.keys(), args, strict=False))
|
||||
@@ -277,7 +280,10 @@ class CrewStructuredTool:
|
||||
return self.invoke(input_dict)
|
||||
|
||||
def invoke(
|
||||
self, input: str | dict, config: dict | None = None, **kwargs: Any
|
||||
self,
|
||||
input: str | dict[str, Any],
|
||||
config: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Main method for tool execution."""
|
||||
parsed_args = self._parse_args(input)
|
||||
@@ -313,9 +319,10 @@ class CrewStructuredTool:
|
||||
self._original_tool.current_usage_count = self.current_usage_count
|
||||
|
||||
@property
|
||||
def args(self) -> dict:
|
||||
def args(self) -> dict[str, Any]:
|
||||
"""Get the tool's input arguments schema."""
|
||||
return self.args_schema.model_json_schema()["properties"]
|
||||
schema: dict[str, Any] = self.args_schema.model_json_schema()["properties"]
|
||||
return schema
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"CrewStructuredTool(name='{sanitize_tool_name(self.name)}', description='{self.description}')"
|
||||
|
||||
152
lib/crewai/src/crewai/types/callback.py
Normal file
152
lib/crewai/src/crewai/types/callback.py
Normal 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"}),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
39
lib/crewai/src/crewai/utilities/env.py
Normal file
39
lib/crewai/src/crewai/utilities/env.py
Normal 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())
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Centralised lock factory.
|
||||
|
||||
If ``REDIS_URL`` is set, locks are distributed via ``portalocker.RedisLock``. Otherwise, falls
|
||||
back to the standard ``portalocker.Lock``.
|
||||
If ``REDIS_URL`` is set and the ``redis`` package is installed, locks are distributed via
|
||||
``portalocker.RedisLock``. Otherwise, falls back to the standard ``portalocker.Lock``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,6 +30,18 @@ _REDIS_URL: str | None = os.environ.get("REDIS_URL")
|
||||
_DEFAULT_TIMEOUT: Final[int] = 120
|
||||
|
||||
|
||||
def _redis_available() -> bool:
|
||||
"""Return True if redis is installed and REDIS_URL is set."""
|
||||
if not _REDIS_URL:
|
||||
return False
|
||||
try:
|
||||
import redis # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _redis_connection() -> redis.Redis:
|
||||
"""Return a cached Redis connection, creating one on first call."""
|
||||
@@ -51,7 +63,7 @@ def lock(name: str, *, timeout: float = _DEFAULT_TIMEOUT) -> Iterator[None]:
|
||||
"""
|
||||
channel = f"crewai:{md5(name.encode(), usedforsecurity=False).hexdigest()}"
|
||||
|
||||
if _REDIS_URL:
|
||||
if _redis_available():
|
||||
with portalocker.RedisLock(
|
||||
channel=channel,
|
||||
connection=_redis_connection(),
|
||||
|
||||
@@ -1,37 +1,40 @@
|
||||
"""Token counting callback handler for LLM interactions.
|
||||
|
||||
This module provides a callback handler that tracks token usage
|
||||
for LLM API calls through the litellm library.
|
||||
for LLM API calls. Works standalone and also integrates with litellm
|
||||
when available (for the litellm fallback path).
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.utils import Usage
|
||||
else:
|
||||
try:
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.utils import Usage
|
||||
except ImportError:
|
||||
|
||||
class CustomLogger:
|
||||
"""Fallback CustomLogger when litellm is not available."""
|
||||
|
||||
class Usage:
|
||||
"""Fallback Usage when litellm is not available."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from crewai.agents.agent_builder.utilities.base_token_process import TokenProcess
|
||||
from crewai.utilities.logger_utils import suppress_warnings
|
||||
|
||||
|
||||
class TokenCalcHandler(CustomLogger):
|
||||
# Check if litellm is available for callback integration
|
||||
try:
|
||||
from litellm.integrations.custom_logger import CustomLogger as LiteLLMCustomLogger
|
||||
|
||||
LITELLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
LiteLLMCustomLogger = None # type: ignore[misc, assignment]
|
||||
LITELLM_AVAILABLE = False
|
||||
|
||||
|
||||
# Create a base class that conditionally inherits from litellm's CustomLogger
|
||||
# when available, or from object when not available
|
||||
if LITELLM_AVAILABLE and LiteLLMCustomLogger is not None:
|
||||
_BaseClass: type = LiteLLMCustomLogger
|
||||
else:
|
||||
_BaseClass = object
|
||||
|
||||
|
||||
class TokenCalcHandler(_BaseClass): # type: ignore[misc]
|
||||
"""Handler for calculating and tracking token usage in LLM calls.
|
||||
|
||||
This handler integrates with litellm's logging system to track
|
||||
prompt tokens, completion tokens, and cached tokens across requests.
|
||||
This handler tracks prompt tokens, completion tokens, and cached tokens
|
||||
across requests. It works standalone and also integrates with litellm's
|
||||
logging system when litellm is installed (for the fallback path).
|
||||
|
||||
Attributes:
|
||||
token_cost_process: The token process tracker to accumulate usage metrics.
|
||||
@@ -43,7 +46,9 @@ class TokenCalcHandler(CustomLogger):
|
||||
Args:
|
||||
token_cost_process: Optional token process tracker for accumulating metrics.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
# Only call super().__init__ if we have a real parent class with __init__
|
||||
if LITELLM_AVAILABLE and LiteLLMCustomLogger is not None:
|
||||
super().__init__(**kwargs)
|
||||
self.token_cost_process = token_cost_process
|
||||
|
||||
def log_success_event(
|
||||
@@ -55,6 +60,10 @@ class TokenCalcHandler(CustomLogger):
|
||||
) -> None:
|
||||
"""Log successful LLM API call and track token usage.
|
||||
|
||||
This method has the same interface as litellm's CustomLogger.log_success_event()
|
||||
so it can be used as a litellm callback when litellm is installed, or called
|
||||
directly when litellm is not installed.
|
||||
|
||||
Args:
|
||||
kwargs: The arguments passed to the LLM call.
|
||||
response_obj: The response object from the LLM API.
|
||||
@@ -66,7 +75,7 @@ class TokenCalcHandler(CustomLogger):
|
||||
|
||||
with suppress_warnings():
|
||||
if isinstance(response_obj, dict) and "usage" in response_obj:
|
||||
usage: Usage = response_obj["usage"]
|
||||
usage = response_obj["usage"]
|
||||
if usage:
|
||||
self.token_cost_process.sum_successful_requests(1)
|
||||
if hasattr(usage, "prompt_tokens"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
1209
lib/crewai/tests/memory/test_memory_root_scope.py
Normal file
1209
lib/crewai/tests/memory/test_memory_root_scope.py
Normal file
File diff suppressed because it is too large
Load Diff
0
lib/crewai/tests/skills/__init__.py
Normal file
0
lib/crewai/tests/skills/__init__.py
Normal file
4
lib/crewai/tests/skills/fixtures/invalid-name/SKILL.md
Normal file
4
lib/crewai/tests/skills/fixtures/invalid-name/SKILL.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
name: Invalid--Name
|
||||
description: This skill has an invalid name.
|
||||
---
|
||||
4
lib/crewai/tests/skills/fixtures/minimal-skill/SKILL.md
Normal file
4
lib/crewai/tests/skills/fixtures/minimal-skill/SKILL.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
name: minimal-skill
|
||||
description: A minimal skill with only required fields.
|
||||
---
|
||||
22
lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md
Normal file
22
lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: valid-skill
|
||||
description: A complete test skill with all optional directories.
|
||||
license: Apache-2.0
|
||||
compatibility: crewai>=0.1.0
|
||||
metadata:
|
||||
author: test
|
||||
version: "1.0"
|
||||
allowed-tools: web-search file-read
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
This skill provides comprehensive instructions for the agent.
|
||||
|
||||
### Usage
|
||||
|
||||
Follow these steps to use the skill effectively.
|
||||
|
||||
### Notes
|
||||
|
||||
Additional context for the agent.
|
||||
@@ -0,0 +1 @@
|
||||
{"key": "value"}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Reference Guide
|
||||
|
||||
This is a reference document for the skill.
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
echo "setup"
|
||||
78
lib/crewai/tests/skills/test_integration.py
Normal file
78
lib/crewai/tests/skills/test_integration.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Integration tests for the skills system."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.skills.loader import activate_skill, discover_skills, format_skill_context
|
||||
from crewai.skills.models import INSTRUCTIONS, METADATA
|
||||
|
||||
|
||||
def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path:
|
||||
"""Helper to create a skill directory with SKILL.md."""
|
||||
skill_dir = parent / name
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: Skill {name}\n---\n{body}"
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
|
||||
class TestSkillDiscoveryAndActivation:
|
||||
"""End-to-end tests for discover + activate workflow."""
|
||||
|
||||
def test_discover_and_activate(self, tmp_path: Path) -> None:
|
||||
_create_skill_dir(tmp_path, "my-skill", body="Use this skill.")
|
||||
skills = discover_skills(tmp_path)
|
||||
assert len(skills) == 1
|
||||
assert skills[0].disclosure_level == METADATA
|
||||
|
||||
activated = activate_skill(skills[0])
|
||||
assert activated.disclosure_level == INSTRUCTIONS
|
||||
assert activated.instructions == "Use this skill."
|
||||
|
||||
context = format_skill_context(activated)
|
||||
assert "## Skill: my-skill" in context
|
||||
assert "Use this skill." in context
|
||||
|
||||
def test_filter_by_skill_names(self, tmp_path: Path) -> None:
|
||||
_create_skill_dir(tmp_path, "alpha")
|
||||
_create_skill_dir(tmp_path, "beta")
|
||||
_create_skill_dir(tmp_path, "gamma")
|
||||
|
||||
all_skills = discover_skills(tmp_path)
|
||||
wanted = {"alpha", "gamma"}
|
||||
filtered = [s for s in all_skills if s.name in wanted]
|
||||
assert {s.name for s in filtered} == {"alpha", "gamma"}
|
||||
|
||||
def test_full_fixture_skill(self) -> None:
|
||||
fixtures = Path(__file__).parent / "fixtures"
|
||||
valid_dir = fixtures / "valid-skill"
|
||||
if not valid_dir.exists():
|
||||
pytest.skip("Fixture not found")
|
||||
|
||||
skills = discover_skills(fixtures)
|
||||
valid_skills = [s for s in skills if s.name == "valid-skill"]
|
||||
assert len(valid_skills) == 1
|
||||
|
||||
skill = valid_skills[0]
|
||||
assert skill.frontmatter.license == "Apache-2.0"
|
||||
assert skill.frontmatter.allowed_tools == ["web-search", "file-read"]
|
||||
|
||||
activated = activate_skill(skill)
|
||||
assert "Instructions" in (activated.instructions or "")
|
||||
|
||||
def test_multiple_search_paths(self, tmp_path: Path) -> None:
|
||||
path_a = tmp_path / "a"
|
||||
path_a.mkdir()
|
||||
_create_skill_dir(path_a, "skill-a")
|
||||
|
||||
path_b = tmp_path / "b"
|
||||
path_b.mkdir()
|
||||
_create_skill_dir(path_b, "skill-b")
|
||||
|
||||
all_skills = []
|
||||
for search_path in [path_a, path_b]:
|
||||
all_skills.extend(discover_skills(search_path))
|
||||
names = {s.name for s in all_skills}
|
||||
assert names == {"skill-a", "skill-b"}
|
||||
161
lib/crewai/tests/skills/test_loader.py
Normal file
161
lib/crewai/tests/skills/test_loader.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Tests for skills/loader.py."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.skills.loader import (
|
||||
activate_skill,
|
||||
discover_skills,
|
||||
format_skill_context,
|
||||
load_resources,
|
||||
)
|
||||
from crewai.skills.models import INSTRUCTIONS, METADATA, RESOURCES, Skill, SkillFrontmatter
|
||||
from crewai.skills.parser import load_skill_metadata
|
||||
|
||||
|
||||
def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path:
|
||||
"""Helper to create a skill directory with SKILL.md."""
|
||||
skill_dir = parent / name
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: Skill {name}\n---\n{body}"
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
|
||||
class TestDiscoverSkills:
|
||||
"""Tests for discover_skills."""
|
||||
|
||||
def test_finds_valid_skills(self, tmp_path: Path) -> None:
|
||||
_create_skill_dir(tmp_path, "alpha")
|
||||
_create_skill_dir(tmp_path, "beta")
|
||||
skills = discover_skills(tmp_path)
|
||||
names = {s.name for s in skills}
|
||||
assert names == {"alpha", "beta"}
|
||||
|
||||
def test_skips_dirs_without_skill_md(self, tmp_path: Path) -> None:
|
||||
_create_skill_dir(tmp_path, "valid")
|
||||
(tmp_path / "no-skill").mkdir()
|
||||
skills = discover_skills(tmp_path)
|
||||
assert len(skills) == 1
|
||||
assert skills[0].name == "valid"
|
||||
|
||||
def test_skips_invalid_skills(self, tmp_path: Path) -> None:
|
||||
_create_skill_dir(tmp_path, "good-skill")
|
||||
bad_dir = tmp_path / "bad-skill"
|
||||
bad_dir.mkdir()
|
||||
(bad_dir / "SKILL.md").write_text(
|
||||
"---\nname: Wrong-Name\ndescription: bad\n---\n"
|
||||
)
|
||||
skills = discover_skills(tmp_path)
|
||||
assert len(skills) == 1
|
||||
|
||||
def test_empty_directory(self, tmp_path: Path) -> None:
|
||||
skills = discover_skills(tmp_path)
|
||||
assert skills == []
|
||||
|
||||
def test_nonexistent_path(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
discover_skills(tmp_path / "nonexistent")
|
||||
|
||||
def test_sorted_by_name(self, tmp_path: Path) -> None:
|
||||
_create_skill_dir(tmp_path, "zebra")
|
||||
_create_skill_dir(tmp_path, "alpha")
|
||||
skills = discover_skills(tmp_path)
|
||||
assert [s.name for s in skills] == ["alpha", "zebra"]
|
||||
|
||||
|
||||
class TestActivateSkill:
|
||||
"""Tests for activate_skill."""
|
||||
|
||||
def test_promotes_to_instructions(self, tmp_path: Path) -> None:
|
||||
_create_skill_dir(tmp_path, "my-skill", body="Instructions.")
|
||||
skill = load_skill_metadata(tmp_path / "my-skill")
|
||||
activated = activate_skill(skill)
|
||||
assert activated.disclosure_level == INSTRUCTIONS
|
||||
assert activated.instructions == "Instructions."
|
||||
|
||||
def test_idempotent(self, tmp_path: Path) -> None:
|
||||
_create_skill_dir(tmp_path, "my-skill")
|
||||
skill = load_skill_metadata(tmp_path / "my-skill")
|
||||
activated = activate_skill(skill)
|
||||
again = activate_skill(activated)
|
||||
assert again is activated
|
||||
|
||||
|
||||
class TestLoadResources:
|
||||
"""Tests for load_resources."""
|
||||
|
||||
def test_promotes_to_resources(self, tmp_path: Path) -> None:
|
||||
skill_dir = _create_skill_dir(tmp_path, "my-skill")
|
||||
(skill_dir / "scripts").mkdir()
|
||||
(skill_dir / "scripts" / "run.sh").write_text("#!/bin/bash")
|
||||
skill = load_skill_metadata(skill_dir)
|
||||
full = load_resources(skill)
|
||||
assert full.disclosure_level == RESOURCES
|
||||
|
||||
|
||||
class TestFormatSkillContext:
|
||||
"""Tests for format_skill_context."""
|
||||
|
||||
def test_metadata_level(self, tmp_path: Path) -> None:
|
||||
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
||||
skill = Skill(
|
||||
frontmatter=fm, path=tmp_path, disclosure_level=METADATA
|
||||
)
|
||||
ctx = format_skill_context(skill)
|
||||
assert "## Skill: test-skill" in ctx
|
||||
assert "A skill" in ctx
|
||||
|
||||
def test_instructions_level(self, tmp_path: Path) -> None:
|
||||
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
||||
skill = Skill(
|
||||
frontmatter=fm,
|
||||
path=tmp_path,
|
||||
disclosure_level=INSTRUCTIONS,
|
||||
instructions="Do these things.",
|
||||
)
|
||||
ctx = format_skill_context(skill)
|
||||
assert "## Skill: test-skill" in ctx
|
||||
assert "Do these things." in ctx
|
||||
|
||||
def test_no_instructions_at_instructions_level(self, tmp_path: Path) -> None:
|
||||
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
||||
skill = Skill(
|
||||
frontmatter=fm,
|
||||
path=tmp_path,
|
||||
disclosure_level=INSTRUCTIONS,
|
||||
instructions=None,
|
||||
)
|
||||
ctx = format_skill_context(skill)
|
||||
assert ctx == "## Skill: test-skill\nA skill"
|
||||
|
||||
def test_resources_level(self, tmp_path: Path) -> None:
|
||||
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
||||
skill = Skill(
|
||||
frontmatter=fm,
|
||||
path=tmp_path,
|
||||
disclosure_level=RESOURCES,
|
||||
instructions="Do things.",
|
||||
resource_files={
|
||||
"scripts": ["run.sh"],
|
||||
"assets": ["data.json", "config.yaml"],
|
||||
},
|
||||
)
|
||||
ctx = format_skill_context(skill)
|
||||
assert "### Available Resources" in ctx
|
||||
assert "**assets/**: data.json, config.yaml" in ctx
|
||||
assert "**scripts/**: run.sh" in ctx
|
||||
|
||||
def test_resources_level_empty_files(self, tmp_path: Path) -> None:
|
||||
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
||||
skill = Skill(
|
||||
frontmatter=fm,
|
||||
path=tmp_path,
|
||||
disclosure_level=RESOURCES,
|
||||
instructions="Do things.",
|
||||
resource_files={},
|
||||
)
|
||||
ctx = format_skill_context(skill)
|
||||
assert "### Available Resources" not in ctx
|
||||
91
lib/crewai/tests/skills/test_models.py
Normal file
91
lib/crewai/tests/skills/test_models.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Tests for skills/models.py."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.skills.models import (
|
||||
INSTRUCTIONS,
|
||||
METADATA,
|
||||
RESOURCES,
|
||||
Skill,
|
||||
SkillFrontmatter,
|
||||
)
|
||||
|
||||
|
||||
class TestDisclosureLevel:
|
||||
"""Tests for DisclosureLevel constants."""
|
||||
|
||||
def test_ordering(self) -> None:
|
||||
assert METADATA < INSTRUCTIONS
|
||||
assert INSTRUCTIONS < RESOURCES
|
||||
|
||||
def test_values(self) -> None:
|
||||
assert METADATA == 1
|
||||
assert INSTRUCTIONS == 2
|
||||
assert RESOURCES == 3
|
||||
|
||||
|
||||
class TestSkillFrontmatter:
|
||||
"""Tests for SkillFrontmatter model."""
|
||||
|
||||
def test_required_fields(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A test skill")
|
||||
assert fm.name == "my-skill"
|
||||
assert fm.description == "A test skill"
|
||||
assert fm.license is None
|
||||
assert fm.metadata is None
|
||||
assert fm.allowed_tools is None
|
||||
|
||||
def test_all_fields(self) -> None:
|
||||
fm = SkillFrontmatter(
|
||||
name="web-search",
|
||||
description="Search the web",
|
||||
license="Apache-2.0",
|
||||
compatibility="crewai>=0.1.0",
|
||||
metadata={"author": "test"},
|
||||
allowed_tools=["browser"],
|
||||
)
|
||||
assert fm.license == "Apache-2.0"
|
||||
assert fm.metadata == {"author": "test"}
|
||||
assert fm.allowed_tools == ["browser"]
|
||||
|
||||
def test_frozen(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="desc")
|
||||
with pytest.raises(Exception):
|
||||
fm.name = "other" # type: ignore[misc]
|
||||
|
||||
def test_invalid_name_rejected(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
SkillFrontmatter(name="Invalid--Name", description="bad")
|
||||
|
||||
|
||||
class TestSkill:
|
||||
"""Tests for Skill model."""
|
||||
|
||||
def test_properties(self, tmp_path: Path) -> None:
|
||||
fm = SkillFrontmatter(name="test-skill", description="desc")
|
||||
skill = Skill(frontmatter=fm, path=tmp_path / "test-skill")
|
||||
assert skill.name == "test-skill"
|
||||
assert skill.description == "desc"
|
||||
assert skill.disclosure_level == METADATA
|
||||
|
||||
def test_resource_dirs(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "test-skill"
|
||||
skill_dir.mkdir()
|
||||
fm = SkillFrontmatter(name="test-skill", description="desc")
|
||||
skill = Skill(frontmatter=fm, path=skill_dir)
|
||||
assert skill.scripts_dir == skill_dir / "scripts"
|
||||
assert skill.references_dir == skill_dir / "references"
|
||||
assert skill.assets_dir == skill_dir / "assets"
|
||||
|
||||
def test_with_disclosure_level(self, tmp_path: Path) -> None:
|
||||
fm = SkillFrontmatter(name="test-skill", description="desc")
|
||||
skill = Skill(frontmatter=fm, path=tmp_path)
|
||||
promoted = skill.with_disclosure_level(
|
||||
INSTRUCTIONS,
|
||||
instructions="Do this.",
|
||||
)
|
||||
assert promoted.disclosure_level == INSTRUCTIONS
|
||||
assert promoted.instructions == "Do this."
|
||||
assert skill.disclosure_level == METADATA
|
||||
167
lib/crewai/tests/skills/test_parser.py
Normal file
167
lib/crewai/tests/skills/test_parser.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Tests for skills/parser.py."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.skills.models import INSTRUCTIONS, METADATA, RESOURCES
|
||||
from crewai.skills.parser import (
|
||||
SkillParseError,
|
||||
load_skill_instructions,
|
||||
load_skill_metadata,
|
||||
load_skill_resources,
|
||||
parse_frontmatter,
|
||||
parse_skill_md,
|
||||
)
|
||||
|
||||
|
||||
class TestParseFrontmatter:
|
||||
"""Tests for parse_frontmatter."""
|
||||
|
||||
def test_valid_frontmatter_and_body(self) -> None:
|
||||
content = "---\nname: test\ndescription: A test\n---\n\nBody text here."
|
||||
fm, body = parse_frontmatter(content)
|
||||
assert fm["name"] == "test"
|
||||
assert fm["description"] == "A test"
|
||||
assert body == "Body text here."
|
||||
|
||||
def test_empty_body(self) -> None:
|
||||
content = "---\nname: test\ndescription: A test\n---"
|
||||
fm, body = parse_frontmatter(content)
|
||||
assert fm["name"] == "test"
|
||||
assert body == ""
|
||||
|
||||
def test_missing_opening_delimiter(self) -> None:
|
||||
with pytest.raises(SkillParseError, match="must start with"):
|
||||
parse_frontmatter("name: test\n---\nBody")
|
||||
|
||||
def test_missing_closing_delimiter(self) -> None:
|
||||
with pytest.raises(SkillParseError, match="missing closing"):
|
||||
parse_frontmatter("---\nname: test\n")
|
||||
|
||||
def test_invalid_yaml(self) -> None:
|
||||
with pytest.raises(SkillParseError, match="Invalid YAML"):
|
||||
parse_frontmatter("---\n: :\n bad: [yaml\n---\nBody")
|
||||
|
||||
def test_triple_dash_in_body(self) -> None:
|
||||
content = "---\nname: test\ndescription: desc\n---\n\nBody with --- inside."
|
||||
fm, body = parse_frontmatter(content)
|
||||
assert "---" in body
|
||||
|
||||
def test_inline_triple_dash_in_yaml_value(self) -> None:
|
||||
content = '---\nname: test\ndescription: "Use---carefully"\n---\n\nBody.'
|
||||
fm, body = parse_frontmatter(content)
|
||||
assert fm["description"] == "Use---carefully"
|
||||
assert body == "Body."
|
||||
|
||||
def test_unicode_content(self) -> None:
|
||||
content = "---\nname: test\ndescription: Beschreibung\n---\n\nUnicode: \u00e4\u00f6\u00fc\u00df"
|
||||
fm, body = parse_frontmatter(content)
|
||||
assert fm["description"] == "Beschreibung"
|
||||
assert "\u00e4\u00f6\u00fc\u00df" in body
|
||||
|
||||
def test_non_mapping_frontmatter(self) -> None:
|
||||
with pytest.raises(SkillParseError, match="must be a YAML mapping"):
|
||||
parse_frontmatter("---\n- item1\n- item2\n---\nBody")
|
||||
|
||||
|
||||
class TestParseSkillMd:
|
||||
"""Tests for parse_skill_md."""
|
||||
|
||||
def test_valid_file(self, tmp_path: Path) -> None:
|
||||
skill_md = tmp_path / "SKILL.md"
|
||||
skill_md.write_text(
|
||||
"---\nname: my-skill\ndescription: desc\n---\nInstructions here."
|
||||
)
|
||||
fm, body = parse_skill_md(skill_md)
|
||||
assert fm.name == "my-skill"
|
||||
assert body == "Instructions here."
|
||||
|
||||
def test_file_not_found(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
parse_skill_md(tmp_path / "nonexistent" / "SKILL.md")
|
||||
|
||||
|
||||
class TestLoadSkillMetadata:
|
||||
"""Tests for load_skill_metadata."""
|
||||
|
||||
def test_valid_skill(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: Test skill\n---\nBody"
|
||||
)
|
||||
skill = load_skill_metadata(skill_dir)
|
||||
assert skill.name == "my-skill"
|
||||
assert skill.disclosure_level == METADATA
|
||||
assert skill.instructions is None
|
||||
|
||||
def test_directory_name_mismatch(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "wrong-name"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: Test skill\n---\n"
|
||||
)
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
load_skill_metadata(skill_dir)
|
||||
|
||||
|
||||
class TestLoadSkillInstructions:
|
||||
"""Tests for load_skill_instructions."""
|
||||
|
||||
def test_promotes_to_instructions(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: Test\n---\nFull body."
|
||||
)
|
||||
skill = load_skill_metadata(skill_dir)
|
||||
promoted = load_skill_instructions(skill)
|
||||
assert promoted.disclosure_level == INSTRUCTIONS
|
||||
assert promoted.instructions == "Full body."
|
||||
|
||||
def test_idempotent(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: Test\n---\nBody."
|
||||
)
|
||||
skill = load_skill_metadata(skill_dir)
|
||||
promoted = load_skill_instructions(skill)
|
||||
again = load_skill_instructions(promoted)
|
||||
assert again is promoted
|
||||
|
||||
|
||||
class TestLoadSkillResources:
|
||||
"""Tests for load_skill_resources."""
|
||||
|
||||
def test_catalogs_resources(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: Test\n---\nBody."
|
||||
)
|
||||
(skill_dir / "scripts").mkdir()
|
||||
(skill_dir / "scripts" / "run.sh").write_text("#!/bin/bash")
|
||||
(skill_dir / "assets").mkdir()
|
||||
(skill_dir / "assets" / "data.json").write_text("{}")
|
||||
|
||||
skill = load_skill_metadata(skill_dir)
|
||||
full = load_skill_resources(skill)
|
||||
assert full.disclosure_level == RESOURCES
|
||||
assert full.instructions == "Body."
|
||||
assert full.resource_files is not None
|
||||
assert "scripts" in full.resource_files
|
||||
assert "run.sh" in full.resource_files["scripts"]
|
||||
assert "assets" in full.resource_files
|
||||
assert "data.json" in full.resource_files["assets"]
|
||||
|
||||
def test_no_resource_dirs(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: Test\n---\nBody."
|
||||
)
|
||||
skill = load_skill_metadata(skill_dir)
|
||||
full = load_skill_resources(skill)
|
||||
assert full.resource_files == {}
|
||||
93
lib/crewai/tests/skills/test_validation.py
Normal file
93
lib/crewai/tests/skills/test_validation.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Tests for skills validation."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.skills.models import SkillFrontmatter
|
||||
from crewai.skills.validation import (
|
||||
MAX_SKILL_NAME_LENGTH,
|
||||
validate_directory_name,
|
||||
)
|
||||
|
||||
|
||||
def _make(name: str) -> SkillFrontmatter:
|
||||
"""Create a SkillFrontmatter with the given name."""
|
||||
return SkillFrontmatter(name=name, description="desc")
|
||||
|
||||
|
||||
class TestSkillNameValidation:
|
||||
"""Tests for skill name constraints via SkillFrontmatter."""
|
||||
|
||||
def test_simple_name(self) -> None:
|
||||
assert _make("web-search").name == "web-search"
|
||||
|
||||
def test_single_word(self) -> None:
|
||||
assert _make("search").name == "search"
|
||||
|
||||
def test_numeric(self) -> None:
|
||||
assert _make("tool3").name == "tool3"
|
||||
|
||||
def test_all_digits(self) -> None:
|
||||
assert _make("123").name == "123"
|
||||
|
||||
def test_single_char(self) -> None:
|
||||
assert _make("a").name == "a"
|
||||
|
||||
def test_max_length(self) -> None:
|
||||
name = "a" * MAX_SKILL_NAME_LENGTH
|
||||
assert _make(name).name == name
|
||||
|
||||
def test_multi_hyphen_segments(self) -> None:
|
||||
assert _make("my-cool-skill").name == "my-cool-skill"
|
||||
|
||||
def test_empty_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_make("")
|
||||
|
||||
def test_too_long_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_make("a" * (MAX_SKILL_NAME_LENGTH + 1))
|
||||
|
||||
def test_uppercase_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_make("MySkill")
|
||||
|
||||
def test_leading_hyphen_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_make("-skill")
|
||||
|
||||
def test_trailing_hyphen_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_make("skill-")
|
||||
|
||||
def test_consecutive_hyphens_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_make("my--skill")
|
||||
|
||||
def test_underscore_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_make("my_skill")
|
||||
|
||||
def test_space_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_make("my skill")
|
||||
|
||||
def test_special_chars_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_make("skill@v1")
|
||||
|
||||
|
||||
class TestValidateDirectoryName:
|
||||
"""Tests for validate_directory_name."""
|
||||
|
||||
def test_matching_names(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
validate_directory_name(skill_dir, "my-skill")
|
||||
|
||||
def test_mismatched_names(self, tmp_path: Path) -> None:
|
||||
skill_dir = tmp_path / "other-name"
|
||||
skill_dir.mkdir()
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
validate_directory_name(skill_dir, "my-skill")
|
||||
@@ -988,11 +988,9 @@ class TestLLMObjectPreservedInContext:
|
||||
db_path = os.path.join(tmpdir, "test_flows.db")
|
||||
persistence = SQLiteFlowPersistence(db_path)
|
||||
|
||||
# Create a mock BaseLLM object (not a string)
|
||||
# Simulates LLM(model="gemini-2.0-flash", provider="gemini")
|
||||
mock_llm_obj = MagicMock()
|
||||
mock_llm_obj.model = "gemini-2.0-flash"
|
||||
mock_llm_obj.provider = "gemini"
|
||||
# Create a real LLM object (not a string)
|
||||
from crewai.llm import LLM
|
||||
mock_llm_obj = LLM(model="gemini-2.0-flash", provider="gemini")
|
||||
|
||||
class PausingProvider:
|
||||
def __init__(self, persistence: SQLiteFlowPersistence):
|
||||
@@ -1041,32 +1039,37 @@ class TestLLMObjectPreservedInContext:
|
||||
result = flow1.kickoff()
|
||||
assert isinstance(result, HumanFeedbackPending)
|
||||
|
||||
# Verify the context stored the model STRING, not None
|
||||
# Verify the context stored the model config dict, not None
|
||||
assert provider.captured_context is not None
|
||||
assert provider.captured_context.llm == "gemini/gemini-2.0-flash"
|
||||
assert isinstance(provider.captured_context.llm, dict)
|
||||
assert provider.captured_context.llm["model"] == "gemini/gemini-2.0-flash"
|
||||
|
||||
# Verify it survives persistence roundtrip
|
||||
flow_id = result.context.flow_id
|
||||
loaded = persistence.load_pending_feedback(flow_id)
|
||||
assert loaded is not None
|
||||
_, loaded_context = loaded
|
||||
assert loaded_context.llm == "gemini/gemini-2.0-flash"
|
||||
assert isinstance(loaded_context.llm, dict)
|
||||
assert loaded_context.llm["model"] == "gemini/gemini-2.0-flash"
|
||||
|
||||
# Phase 2: Resume with positive feedback - should use LLM to classify
|
||||
flow2 = TestFlow.from_pending(flow_id, persistence)
|
||||
assert flow2._pending_feedback_context is not None
|
||||
assert flow2._pending_feedback_context.llm == "gemini/gemini-2.0-flash"
|
||||
assert isinstance(flow2._pending_feedback_context.llm, dict)
|
||||
assert flow2._pending_feedback_context.llm["model"] == "gemini/gemini-2.0-flash"
|
||||
|
||||
# Mock _collapse_to_outcome to verify it gets called (not skipped)
|
||||
with patch.object(flow2, "_collapse_to_outcome", return_value="approved") as mock_collapse:
|
||||
flow2.resume("this looks good, proceed!")
|
||||
|
||||
# The key assertion: _collapse_to_outcome was called (not skipped due to llm=None)
|
||||
mock_collapse.assert_called_once_with(
|
||||
feedback="this looks good, proceed!",
|
||||
outcomes=["needs_changes", "approved"],
|
||||
llm="gemini/gemini-2.0-flash",
|
||||
)
|
||||
mock_collapse.assert_called_once()
|
||||
call_kwargs = mock_collapse.call_args
|
||||
assert call_kwargs.kwargs["feedback"] == "this looks good, proceed!"
|
||||
assert call_kwargs.kwargs["outcomes"] == ["needs_changes", "approved"]
|
||||
# LLM should be a live object (from _hf_llm) or reconstructed, not None
|
||||
assert call_kwargs.kwargs["llm"] is not None
|
||||
assert getattr(call_kwargs.kwargs["llm"], "model", None) == "gemini-2.0-flash"
|
||||
assert flow2.last_human_feedback.outcome == "approved"
|
||||
assert flow2.result_path == "approved"
|
||||
|
||||
@@ -1096,23 +1099,25 @@ class TestLLMObjectPreservedInContext:
|
||||
def test_provider_prefix_added_to_bare_model(self) -> None:
|
||||
"""Test that provider prefix is added when model has no slash."""
|
||||
from crewai.flow.human_feedback import _serialize_llm_for_context
|
||||
from crewai.llm import LLM
|
||||
|
||||
mock_obj = MagicMock()
|
||||
mock_obj.model = "gemini-3-flash-preview"
|
||||
mock_obj.provider = "gemini"
|
||||
assert _serialize_llm_for_context(mock_obj) == "gemini/gemini-3-flash-preview"
|
||||
llm = LLM(model="gemini-2.0-flash", provider="gemini")
|
||||
result = _serialize_llm_for_context(llm)
|
||||
assert isinstance(result, dict)
|
||||
assert result["model"] == "gemini/gemini-2.0-flash"
|
||||
|
||||
def test_provider_prefix_not_doubled_when_already_present(self) -> None:
|
||||
"""Test that provider prefix is not added when model already has a slash."""
|
||||
from crewai.flow.human_feedback import _serialize_llm_for_context
|
||||
from crewai.llm import LLM
|
||||
|
||||
mock_obj = MagicMock()
|
||||
mock_obj.model = "gemini/gemini-2.0-flash"
|
||||
mock_obj.provider = "gemini"
|
||||
assert _serialize_llm_for_context(mock_obj) == "gemini/gemini-2.0-flash"
|
||||
llm = LLM(model="gemini/gemini-2.0-flash")
|
||||
result = _serialize_llm_for_context(llm)
|
||||
assert isinstance(result, dict)
|
||||
assert result["model"] == "gemini/gemini-2.0-flash"
|
||||
|
||||
def test_no_provider_attr_falls_back_to_bare_model(self) -> None:
|
||||
"""Test that bare model is used when no provider attribute exists."""
|
||||
"""Test that objects without to_config_dict fall back to model string."""
|
||||
from crewai.flow.human_feedback import _serialize_llm_for_context
|
||||
|
||||
mock_obj = MagicMock(spec=[])
|
||||
@@ -1216,3 +1221,279 @@ class TestAsyncHumanFeedbackEdgeCases:
|
||||
|
||||
assert flow.last_human_feedback.outcome == "approved"
|
||||
assert flow.last_human_feedback.feedback == ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for _hf_llm attribute and live LLM resolution on resume
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLiveLLMPreservationOnResume:
|
||||
"""Tests for preserving the full LLM config across HITL resume."""
|
||||
|
||||
def test_hf_llm_attribute_set_on_wrapper_with_basellm(self) -> None:
|
||||
"""Test that _hf_llm is set on the wrapper when llm is a BaseLLM instance."""
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
|
||||
# Create a mock BaseLLM object
|
||||
mock_llm = MagicMock(spec=BaseLLM)
|
||||
mock_llm.model = "gemini/gemini-3-flash"
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm=mock_llm,
|
||||
)
|
||||
def review(self):
|
||||
return "content"
|
||||
|
||||
flow = TestFlow()
|
||||
method = flow._methods.get("review")
|
||||
assert method is not None
|
||||
assert hasattr(method, "_hf_llm")
|
||||
assert method._hf_llm is mock_llm
|
||||
|
||||
def test_hf_llm_attribute_set_on_wrapper_with_string(self) -> None:
|
||||
"""Test that _hf_llm is set on the wrapper even when llm is a string."""
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def review(self):
|
||||
return "content"
|
||||
|
||||
flow = TestFlow()
|
||||
method = flow._methods.get("review")
|
||||
assert method is not None
|
||||
assert hasattr(method, "_hf_llm")
|
||||
assert method._hf_llm == "gpt-4o-mini"
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
def test_resume_async_uses_live_basellm_over_serialized_string(
|
||||
self, mock_emit: MagicMock
|
||||
) -> None:
|
||||
"""Test that resume_async uses the live BaseLLM from decorator instead of serialized string.
|
||||
|
||||
This is the main bug fix: when a flow resumes, it should use the fully-configured
|
||||
LLM from the re-imported decorator (with credentials, project, etc.) instead of
|
||||
creating a new LLM from just the model string.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = os.path.join(tmpdir, "test_flows.db")
|
||||
persistence = SQLiteFlowPersistence(db_path)
|
||||
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
|
||||
# Create a mock BaseLLM with full config (simulating Gemini with service account)
|
||||
live_llm = MagicMock(spec=BaseLLM)
|
||||
live_llm.model = "gemini/gemini-3-flash"
|
||||
|
||||
class TestFlow(Flow):
|
||||
result_path: str = ""
|
||||
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Approve?",
|
||||
emit=["approved", "rejected"],
|
||||
llm=live_llm, # Full LLM object with credentials
|
||||
)
|
||||
def review(self):
|
||||
return "content"
|
||||
|
||||
@listen("approved")
|
||||
def handle_approved(self):
|
||||
self.result_path = "approved"
|
||||
return "Approved!"
|
||||
|
||||
# Save pending feedback with just a model STRING (simulating serialization)
|
||||
context = PendingFeedbackContext(
|
||||
flow_id="live-llm-test",
|
||||
flow_class="TestFlow",
|
||||
method_name="review",
|
||||
method_output="content",
|
||||
message="Approve?",
|
||||
emit=["approved", "rejected"],
|
||||
llm="gemini/gemini-3-flash", # Serialized string, NOT the live object
|
||||
)
|
||||
persistence.save_pending_feedback(
|
||||
flow_uuid="live-llm-test",
|
||||
context=context,
|
||||
state_data={"id": "live-llm-test"},
|
||||
)
|
||||
|
||||
# Restore flow - this re-imports the class with the live LLM
|
||||
flow = TestFlow.from_pending("live-llm-test", persistence)
|
||||
|
||||
# Mock _collapse_to_outcome to capture what LLM it receives
|
||||
captured_llm = []
|
||||
|
||||
def capture_llm(feedback, outcomes, llm):
|
||||
captured_llm.append(llm)
|
||||
return "approved"
|
||||
|
||||
with patch.object(flow, "_collapse_to_outcome", side_effect=capture_llm):
|
||||
flow.resume("looks good!")
|
||||
|
||||
# The key assertion: _collapse_to_outcome received the LIVE BaseLLM object,
|
||||
# NOT the serialized string. The live_llm was captured at class definition
|
||||
# time and stored on the method wrapper as _hf_llm.
|
||||
assert len(captured_llm) == 1
|
||||
# Verify it's the same object that was passed to the decorator
|
||||
# (which is stored on the method's _hf_llm attribute)
|
||||
method = flow._methods.get("review")
|
||||
assert method is not None
|
||||
assert captured_llm[0] is method._hf_llm
|
||||
# And verify it's a BaseLLM instance, not a string
|
||||
assert isinstance(captured_llm[0], BaseLLM)
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
def test_resume_async_falls_back_to_serialized_string_when_no_hf_llm(
|
||||
self, mock_emit: MagicMock
|
||||
) -> None:
|
||||
"""Test that resume_async falls back to context.llm when _hf_llm is not available.
|
||||
|
||||
This ensures backward compatibility with flows that were paused before this fix.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = os.path.join(tmpdir, "test_flows.db")
|
||||
persistence = SQLiteFlowPersistence(db_path)
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Approve?",
|
||||
emit=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def review(self):
|
||||
return "content"
|
||||
|
||||
# Save pending feedback
|
||||
context = PendingFeedbackContext(
|
||||
flow_id="fallback-test",
|
||||
flow_class="TestFlow",
|
||||
method_name="review",
|
||||
method_output="content",
|
||||
message="Approve?",
|
||||
emit=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
persistence.save_pending_feedback(
|
||||
flow_uuid="fallback-test",
|
||||
context=context,
|
||||
state_data={"id": "fallback-test"},
|
||||
)
|
||||
|
||||
flow = TestFlow.from_pending("fallback-test", persistence)
|
||||
|
||||
# Remove _hf_llm to simulate old decorator without this attribute
|
||||
method = flow._methods.get("review")
|
||||
if hasattr(method, "_hf_llm"):
|
||||
delattr(method, "_hf_llm")
|
||||
|
||||
# Mock _collapse_to_outcome to capture what LLM it receives
|
||||
captured_llm = []
|
||||
|
||||
def capture_llm(feedback, outcomes, llm):
|
||||
captured_llm.append(llm)
|
||||
return "approved"
|
||||
|
||||
with patch.object(flow, "_collapse_to_outcome", side_effect=capture_llm):
|
||||
flow.resume("looks good!")
|
||||
|
||||
# Should fall back to deserialized LLM from context string
|
||||
assert len(captured_llm) == 1
|
||||
from crewai.llms.base_llm import BaseLLM as BaseLLMClass
|
||||
assert isinstance(captured_llm[0], BaseLLMClass)
|
||||
assert captured_llm[0].model == "gpt-4o-mini"
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
def test_resume_async_uses_string_from_context_when_hf_llm_is_string(
|
||||
self, mock_emit: MagicMock
|
||||
) -> None:
|
||||
"""Test that when _hf_llm is a string (not BaseLLM), we still use context.llm.
|
||||
|
||||
String LLM values offer no benefit over the serialized context.llm,
|
||||
so we don't prefer them.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = os.path.join(tmpdir, "test_flows.db")
|
||||
persistence = SQLiteFlowPersistence(db_path)
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Approve?",
|
||||
emit=["approved", "rejected"],
|
||||
llm="gpt-4o-mini", # String LLM
|
||||
)
|
||||
def review(self):
|
||||
return "content"
|
||||
|
||||
# Save pending feedback
|
||||
context = PendingFeedbackContext(
|
||||
flow_id="string-llm-test",
|
||||
flow_class="TestFlow",
|
||||
method_name="review",
|
||||
method_output="content",
|
||||
message="Approve?",
|
||||
emit=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
persistence.save_pending_feedback(
|
||||
flow_uuid="string-llm-test",
|
||||
context=context,
|
||||
state_data={"id": "string-llm-test"},
|
||||
)
|
||||
|
||||
flow = TestFlow.from_pending("string-llm-test", persistence)
|
||||
|
||||
# Verify _hf_llm is a string
|
||||
method = flow._methods.get("review")
|
||||
assert method._hf_llm == "gpt-4o-mini"
|
||||
|
||||
# Mock _collapse_to_outcome to capture what LLM it receives
|
||||
captured_llm = []
|
||||
|
||||
def capture_llm(feedback, outcomes, llm):
|
||||
captured_llm.append(llm)
|
||||
return "approved"
|
||||
|
||||
with patch.object(flow, "_collapse_to_outcome", side_effect=capture_llm):
|
||||
flow.resume("looks good!")
|
||||
|
||||
# _hf_llm is a string, so resume deserializes context.llm into an LLM instance
|
||||
assert len(captured_llm) == 1
|
||||
from crewai.llms.base_llm import BaseLLM as BaseLLMClass
|
||||
assert isinstance(captured_llm[0], BaseLLMClass)
|
||||
assert captured_llm[0].model == "gpt-4o-mini"
|
||||
|
||||
def test_hf_llm_set_for_async_wrapper(self) -> None:
|
||||
"""Test that _hf_llm is set on async wrapper functions."""
|
||||
import asyncio
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
|
||||
mock_llm = MagicMock(spec=BaseLLM)
|
||||
mock_llm.model = "gemini/gemini-3-flash"
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm=mock_llm,
|
||||
)
|
||||
async def async_review(self):
|
||||
return "content"
|
||||
|
||||
flow = TestFlow()
|
||||
method = flow._methods.get("async_review")
|
||||
assert method is not None
|
||||
assert hasattr(method, "_hf_llm")
|
||||
assert method._hf_llm is mock_llm
|
||||
|
||||
237
lib/crewai/tests/test_callback.py
Normal file
237
lib/crewai/tests/test_callback.py
Normal 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
|
||||
795
lib/crewai/tests/test_flow_serializer.py
Normal file
795
lib/crewai/tests/test_flow_serializer.py
Normal file
@@ -0,0 +1,795 @@
|
||||
"""Tests for flow_serializer.py - Flow structure serialization for Studio UI."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai.flow.flow import Flow, and_, listen, or_, router, start
|
||||
from crewai.flow.flow_serializer import flow_structure
|
||||
from crewai.flow.human_feedback import human_feedback
|
||||
|
||||
|
||||
class TestSimpleLinearFlow:
|
||||
"""Test simple linear flow (start → listen → listen)."""
|
||||
|
||||
def test_linear_flow_structure(self):
|
||||
"""Test a simple sequential flow structure."""
|
||||
|
||||
class LinearFlow(Flow):
|
||||
"""A simple linear flow for testing."""
|
||||
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@listen(begin)
|
||||
def process(self):
|
||||
return "processed"
|
||||
|
||||
@listen(process)
|
||||
def finalize(self):
|
||||
return "done"
|
||||
|
||||
structure = flow_structure(LinearFlow)
|
||||
|
||||
assert structure["name"] == "LinearFlow"
|
||||
assert structure["description"] == "A simple linear flow for testing."
|
||||
assert len(structure["methods"]) == 3
|
||||
|
||||
# Check method types
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["begin"]["type"] == "start"
|
||||
assert method_map["process"]["type"] == "listen"
|
||||
assert method_map["finalize"]["type"] == "listen"
|
||||
|
||||
# Check edges
|
||||
assert len(structure["edges"]) == 2
|
||||
|
||||
edge_pairs = [(e["from_method"], e["to_method"]) for e in structure["edges"]]
|
||||
assert ("begin", "process") in edge_pairs
|
||||
assert ("process", "finalize") in edge_pairs
|
||||
|
||||
# All edges should be listen type
|
||||
for edge in structure["edges"]:
|
||||
assert edge["edge_type"] == "listen"
|
||||
assert edge["condition"] is None
|
||||
|
||||
|
||||
class TestRouterFlow:
|
||||
"""Test flow with router branching."""
|
||||
|
||||
def test_router_flow_structure(self):
|
||||
"""Test a flow with router that branches to different paths."""
|
||||
|
||||
class BranchingFlow(Flow):
|
||||
@start()
|
||||
def init(self):
|
||||
return "initialized"
|
||||
|
||||
@router(init)
|
||||
def decide(self) -> Literal["path_a", "path_b"]:
|
||||
return "path_a"
|
||||
|
||||
@listen("path_a")
|
||||
def handle_a(self):
|
||||
return "handled_a"
|
||||
|
||||
@listen("path_b")
|
||||
def handle_b(self):
|
||||
return "handled_b"
|
||||
|
||||
structure = flow_structure(BranchingFlow)
|
||||
|
||||
assert structure["name"] == "BranchingFlow"
|
||||
assert len(structure["methods"]) == 4
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
# Check method types
|
||||
assert method_map["init"]["type"] == "start"
|
||||
assert method_map["decide"]["type"] == "router"
|
||||
assert method_map["handle_a"]["type"] == "listen"
|
||||
assert method_map["handle_b"]["type"] == "listen"
|
||||
|
||||
# Check router paths
|
||||
assert "path_a" in method_map["decide"]["router_paths"]
|
||||
assert "path_b" in method_map["decide"]["router_paths"]
|
||||
|
||||
# Check edges
|
||||
# Should have: init -> decide (listen), decide -> handle_a (route), decide -> handle_b (route)
|
||||
listen_edges = [e for e in structure["edges"] if e["edge_type"] == "listen"]
|
||||
route_edges = [e for e in structure["edges"] if e["edge_type"] == "route"]
|
||||
|
||||
assert len(listen_edges) == 1
|
||||
assert listen_edges[0]["from_method"] == "init"
|
||||
assert listen_edges[0]["to_method"] == "decide"
|
||||
|
||||
assert len(route_edges) == 2
|
||||
route_targets = {e["to_method"] for e in route_edges}
|
||||
assert "handle_a" in route_targets
|
||||
assert "handle_b" in route_targets
|
||||
|
||||
# Check route conditions
|
||||
route_conditions = {e["to_method"]: e["condition"] for e in route_edges}
|
||||
assert route_conditions["handle_a"] == "path_a"
|
||||
assert route_conditions["handle_b"] == "path_b"
|
||||
|
||||
|
||||
class TestAndOrConditions:
|
||||
"""Test flow with AND/OR conditions."""
|
||||
|
||||
def test_and_condition_flow(self):
|
||||
"""Test a flow where a method waits for multiple methods (AND)."""
|
||||
|
||||
class AndConditionFlow(Flow):
|
||||
@start()
|
||||
def step_a(self):
|
||||
return "a"
|
||||
|
||||
@start()
|
||||
def step_b(self):
|
||||
return "b"
|
||||
|
||||
@listen(and_(step_a, step_b))
|
||||
def converge(self):
|
||||
return "converged"
|
||||
|
||||
structure = flow_structure(AndConditionFlow)
|
||||
|
||||
assert len(structure["methods"]) == 3
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["step_a"]["type"] == "start"
|
||||
assert method_map["step_b"]["type"] == "start"
|
||||
assert method_map["converge"]["type"] == "listen"
|
||||
|
||||
# Check condition type
|
||||
assert method_map["converge"]["condition_type"] == "AND"
|
||||
|
||||
# Check trigger methods
|
||||
triggers = method_map["converge"]["trigger_methods"]
|
||||
assert "step_a" in triggers
|
||||
assert "step_b" in triggers
|
||||
|
||||
# Check edges - should have 2 edges to converge
|
||||
converge_edges = [e for e in structure["edges"] if e["to_method"] == "converge"]
|
||||
assert len(converge_edges) == 2
|
||||
|
||||
def test_or_condition_flow(self):
|
||||
"""Test a flow where a method is triggered by any of multiple methods (OR)."""
|
||||
|
||||
class OrConditionFlow(Flow):
|
||||
@start()
|
||||
def path_1(self):
|
||||
return "1"
|
||||
|
||||
@start()
|
||||
def path_2(self):
|
||||
return "2"
|
||||
|
||||
@listen(or_(path_1, path_2))
|
||||
def handle_any(self):
|
||||
return "handled"
|
||||
|
||||
structure = flow_structure(OrConditionFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["handle_any"]["condition_type"] == "OR"
|
||||
|
||||
triggers = method_map["handle_any"]["trigger_methods"]
|
||||
assert "path_1" in triggers
|
||||
assert "path_2" in triggers
|
||||
|
||||
|
||||
class TestHumanFeedbackMethods:
|
||||
"""Test flow with @human_feedback decorated methods."""
|
||||
|
||||
def test_human_feedback_detection(self):
|
||||
"""Test that human feedback methods are correctly identified."""
|
||||
|
||||
class HumanFeedbackFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Please review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def review_step(self):
|
||||
return "content to review"
|
||||
|
||||
@listen("approved")
|
||||
def handle_approved(self):
|
||||
return "approved"
|
||||
|
||||
@listen("rejected")
|
||||
def handle_rejected(self):
|
||||
return "rejected"
|
||||
|
||||
structure = flow_structure(HumanFeedbackFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
# review_step should have human feedback
|
||||
assert method_map["review_step"]["has_human_feedback"] is True
|
||||
# It's a start+router (due to emit)
|
||||
assert method_map["review_step"]["type"] == "start_router"
|
||||
assert "approved" in method_map["review_step"]["router_paths"]
|
||||
assert "rejected" in method_map["review_step"]["router_paths"]
|
||||
|
||||
# Other methods should not have human feedback
|
||||
assert method_map["handle_approved"]["has_human_feedback"] is False
|
||||
assert method_map["handle_rejected"]["has_human_feedback"] is False
|
||||
|
||||
|
||||
class TestCrewReferences:
|
||||
"""Test detection of Crew references in method bodies."""
|
||||
|
||||
def test_crew_detection_with_crew_call(self):
|
||||
"""Test that .crew() calls are detected."""
|
||||
|
||||
class FlowWithCrew(Flow):
|
||||
@start()
|
||||
def run_crew(self):
|
||||
# Simulating crew usage pattern
|
||||
# result = MyCrew().crew().kickoff()
|
||||
return "result"
|
||||
|
||||
@listen(run_crew)
|
||||
def no_crew(self):
|
||||
return "done"
|
||||
|
||||
structure = flow_structure(FlowWithCrew)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
# Note: Since the actual .crew() call is in a comment/string,
|
||||
# the detection might not trigger. In real code it would.
|
||||
# We're testing the mechanism exists.
|
||||
assert "has_crew" in method_map["run_crew"]
|
||||
assert "has_crew" in method_map["no_crew"]
|
||||
|
||||
def test_no_crew_when_absent(self):
|
||||
"""Test that methods without Crew refs return has_crew=False."""
|
||||
|
||||
class SimpleNonCrewFlow(Flow):
|
||||
@start()
|
||||
def calculate(self):
|
||||
return 1 + 1
|
||||
|
||||
@listen(calculate)
|
||||
def display(self):
|
||||
return "result"
|
||||
|
||||
structure = flow_structure(SimpleNonCrewFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["calculate"]["has_crew"] is False
|
||||
assert method_map["display"]["has_crew"] is False
|
||||
|
||||
|
||||
class TestTypedStateSchema:
|
||||
"""Test flow with typed Pydantic state."""
|
||||
|
||||
def test_pydantic_state_schema_extraction(self):
|
||||
"""Test extracting state schema from a Flow with Pydantic state."""
|
||||
|
||||
class MyState(BaseModel):
|
||||
counter: int = 0
|
||||
message: str = ""
|
||||
items: list[str] = Field(default_factory=list)
|
||||
|
||||
class TypedStateFlow(Flow[MyState]):
|
||||
initial_state = MyState
|
||||
|
||||
@start()
|
||||
def increment(self):
|
||||
self.state.counter += 1
|
||||
return self.state.counter
|
||||
|
||||
@listen(increment)
|
||||
def display(self):
|
||||
return f"Count: {self.state.counter}"
|
||||
|
||||
structure = flow_structure(TypedStateFlow)
|
||||
|
||||
assert structure["state_schema"] is not None
|
||||
fields = structure["state_schema"]["fields"]
|
||||
|
||||
field_names = {f["name"] for f in fields}
|
||||
assert "counter" in field_names
|
||||
assert "message" in field_names
|
||||
assert "items" in field_names
|
||||
|
||||
# Check types
|
||||
field_map = {f["name"]: f for f in fields}
|
||||
assert "int" in field_map["counter"]["type"]
|
||||
assert "str" in field_map["message"]["type"]
|
||||
|
||||
# Check defaults
|
||||
assert field_map["counter"]["default"] == 0
|
||||
assert field_map["message"]["default"] == ""
|
||||
|
||||
def test_dict_state_returns_none(self):
|
||||
"""Test that flows using dict state return None for state_schema."""
|
||||
|
||||
class DictStateFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
self.state["count"] = 1
|
||||
return "started"
|
||||
|
||||
structure = flow_structure(DictStateFlow)
|
||||
|
||||
assert structure["state_schema"] is None
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and special scenarios."""
|
||||
|
||||
def test_start_router_combo(self):
|
||||
"""Test a method that is both @start and a router (via human_feedback emit)."""
|
||||
|
||||
class StartRouterFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["continue", "stop"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def entry_point(self):
|
||||
return "data"
|
||||
|
||||
@listen("continue")
|
||||
def proceed(self):
|
||||
return "proceeding"
|
||||
|
||||
@listen("stop")
|
||||
def halt(self):
|
||||
return "halted"
|
||||
|
||||
structure = flow_structure(StartRouterFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["entry_point"]["type"] == "start_router"
|
||||
assert method_map["entry_point"]["has_human_feedback"] is True
|
||||
assert "continue" in method_map["entry_point"]["router_paths"]
|
||||
assert "stop" in method_map["entry_point"]["router_paths"]
|
||||
|
||||
def test_multiple_start_methods(self):
|
||||
"""Test a flow with multiple start methods."""
|
||||
|
||||
class MultiStartFlow(Flow):
|
||||
@start()
|
||||
def start_a(self):
|
||||
return "a"
|
||||
|
||||
@start()
|
||||
def start_b(self):
|
||||
return "b"
|
||||
|
||||
@listen(and_(start_a, start_b))
|
||||
def combine(self):
|
||||
return "combined"
|
||||
|
||||
structure = flow_structure(MultiStartFlow)
|
||||
|
||||
start_methods = [m for m in structure["methods"] if m["type"] == "start"]
|
||||
assert len(start_methods) == 2
|
||||
|
||||
start_names = {m["name"] for m in start_methods}
|
||||
assert "start_a" in start_names
|
||||
assert "start_b" in start_names
|
||||
|
||||
def test_orphan_methods(self):
|
||||
"""Test that orphan methods (not connected to flow) are still captured."""
|
||||
|
||||
class FlowWithOrphan(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@listen(begin)
|
||||
def connected(self):
|
||||
return "connected"
|
||||
|
||||
@listen("never_triggered")
|
||||
def orphan(self):
|
||||
return "orphan"
|
||||
|
||||
structure = flow_structure(FlowWithOrphan)
|
||||
|
||||
method_names = {m["name"] for m in structure["methods"]}
|
||||
assert "orphan" in method_names
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
assert method_map["orphan"]["trigger_methods"] == ["never_triggered"]
|
||||
|
||||
def test_empty_flow(self):
|
||||
"""Test building structure for a flow with no methods."""
|
||||
|
||||
class EmptyFlow(Flow):
|
||||
pass
|
||||
|
||||
structure = flow_structure(EmptyFlow)
|
||||
|
||||
assert structure["name"] == "EmptyFlow"
|
||||
assert structure["methods"] == []
|
||||
assert structure["edges"] == []
|
||||
assert structure["state_schema"] is None
|
||||
|
||||
def test_flow_with_docstring(self):
|
||||
"""Test that flow docstring is captured."""
|
||||
|
||||
class DocumentedFlow(Flow):
|
||||
"""This is a well-documented flow.
|
||||
|
||||
It has multiple lines of documentation.
|
||||
"""
|
||||
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
structure = flow_structure(DocumentedFlow)
|
||||
|
||||
assert structure["description"] is not None
|
||||
assert "well-documented flow" in structure["description"]
|
||||
|
||||
def test_flow_without_docstring(self):
|
||||
"""Test that missing docstring returns None."""
|
||||
|
||||
class UndocumentedFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
structure = flow_structure(UndocumentedFlow)
|
||||
|
||||
assert structure["description"] is None
|
||||
|
||||
def test_nested_conditions(self):
|
||||
"""Test flow with nested AND/OR conditions."""
|
||||
|
||||
class NestedConditionFlow(Flow):
|
||||
@start()
|
||||
def a(self):
|
||||
return "a"
|
||||
|
||||
@start()
|
||||
def b(self):
|
||||
return "b"
|
||||
|
||||
@start()
|
||||
def c(self):
|
||||
return "c"
|
||||
|
||||
@listen(or_(and_(a, b), c))
|
||||
def complex_trigger(self):
|
||||
return "triggered"
|
||||
|
||||
structure = flow_structure(NestedConditionFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
# Should have triggers for a, b, and c
|
||||
triggers = method_map["complex_trigger"]["trigger_methods"]
|
||||
assert len(triggers) == 3
|
||||
assert "a" in triggers
|
||||
assert "b" in triggers
|
||||
assert "c" in triggers
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling and validation."""
|
||||
|
||||
def test_instance_raises_type_error(self):
|
||||
"""Test that passing an instance raises TypeError."""
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
flow_instance = TestFlow()
|
||||
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
flow_structure(flow_instance)
|
||||
|
||||
assert "requires a Flow class, not an instance" in str(exc_info.value)
|
||||
|
||||
def test_non_class_raises_type_error(self):
|
||||
"""Test that passing non-class raises TypeError."""
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
flow_structure("not a class")
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
flow_structure(123)
|
||||
|
||||
|
||||
class TestEdgeGeneration:
|
||||
"""Test edge generation in various scenarios."""
|
||||
|
||||
def test_all_edges_generated_correctly(self):
|
||||
"""Verify all edges are correctly generated for a complex flow."""
|
||||
|
||||
class ComplexFlow(Flow):
|
||||
@start()
|
||||
def entry(self):
|
||||
return "started"
|
||||
|
||||
@listen(entry)
|
||||
def step_1(self):
|
||||
return "step_1"
|
||||
|
||||
@router(step_1)
|
||||
def branch(self) -> Literal["left", "right"]:
|
||||
return "left"
|
||||
|
||||
@listen("left")
|
||||
def left_path(self):
|
||||
return "left_done"
|
||||
|
||||
@listen("right")
|
||||
def right_path(self):
|
||||
return "right_done"
|
||||
|
||||
@listen(or_(left_path, right_path))
|
||||
def converge(self):
|
||||
return "done"
|
||||
|
||||
structure = flow_structure(ComplexFlow)
|
||||
|
||||
# Build edge map for easier checking
|
||||
edges = structure["edges"]
|
||||
|
||||
# Check listen edges
|
||||
listen_edges = [(e["from_method"], e["to_method"]) for e in edges if e["edge_type"] == "listen"]
|
||||
|
||||
assert ("entry", "step_1") in listen_edges
|
||||
assert ("step_1", "branch") in listen_edges
|
||||
assert ("left_path", "converge") in listen_edges
|
||||
assert ("right_path", "converge") in listen_edges
|
||||
|
||||
# Check route edges
|
||||
route_edges = [(e["from_method"], e["to_method"], e["condition"]) for e in edges if e["edge_type"] == "route"]
|
||||
|
||||
assert ("branch", "left_path", "left") in route_edges
|
||||
assert ("branch", "right_path", "right") in route_edges
|
||||
|
||||
def test_router_edge_conditions(self):
|
||||
"""Test that router edge conditions are properly set."""
|
||||
|
||||
class RouterConditionFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "start"
|
||||
|
||||
@router(begin)
|
||||
def route(self) -> Literal["option_1", "option_2", "option_3"]:
|
||||
return "option_1"
|
||||
|
||||
@listen("option_1")
|
||||
def handle_1(self):
|
||||
return "1"
|
||||
|
||||
@listen("option_2")
|
||||
def handle_2(self):
|
||||
return "2"
|
||||
|
||||
@listen("option_3")
|
||||
def handle_3(self):
|
||||
return "3"
|
||||
|
||||
structure = flow_structure(RouterConditionFlow)
|
||||
|
||||
route_edges = [e for e in structure["edges"] if e["edge_type"] == "route"]
|
||||
|
||||
# Should have 3 route edges
|
||||
assert len(route_edges) == 3
|
||||
|
||||
conditions = {e["to_method"]: e["condition"] for e in route_edges}
|
||||
assert conditions["handle_1"] == "option_1"
|
||||
assert conditions["handle_2"] == "option_2"
|
||||
assert conditions["handle_3"] == "option_3"
|
||||
|
||||
|
||||
class TestMethodTypeClassification:
|
||||
"""Test method type classification."""
|
||||
|
||||
def test_all_method_types(self):
|
||||
"""Test classification of all method types."""
|
||||
|
||||
class AllTypesFlow(Flow):
|
||||
@start()
|
||||
def start_only(self):
|
||||
return "start"
|
||||
|
||||
@listen(start_only)
|
||||
def listen_only(self):
|
||||
return "listen"
|
||||
|
||||
@router(listen_only)
|
||||
def router_only(self) -> Literal["path"]:
|
||||
return "path"
|
||||
|
||||
@listen("path")
|
||||
def after_router(self):
|
||||
return "after"
|
||||
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review",
|
||||
emit=["yes", "no"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def start_and_router(self):
|
||||
return "data"
|
||||
|
||||
structure = flow_structure(AllTypesFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["start_only"]["type"] == "start"
|
||||
assert method_map["listen_only"]["type"] == "listen"
|
||||
assert method_map["router_only"]["type"] == "router"
|
||||
assert method_map["after_router"]["type"] == "listen"
|
||||
assert method_map["start_and_router"]["type"] == "start_router"
|
||||
|
||||
|
||||
class TestInputDetection:
|
||||
"""Test flow input detection."""
|
||||
|
||||
def test_inputs_list_exists(self):
|
||||
"""Test that inputs list is always present."""
|
||||
|
||||
class SimpleFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
structure = flow_structure(SimpleFlow)
|
||||
|
||||
assert "inputs" in structure
|
||||
assert isinstance(structure["inputs"], list)
|
||||
|
||||
|
||||
class TestJsonSerializable:
|
||||
"""Test that output is JSON serializable."""
|
||||
|
||||
def test_structure_is_json_serializable(self):
|
||||
"""Test that the entire structure can be JSON serialized."""
|
||||
import json
|
||||
|
||||
class MyState(BaseModel):
|
||||
value: int = 0
|
||||
|
||||
class SerializableFlow(Flow[MyState]):
|
||||
"""Test flow for JSON serialization."""
|
||||
|
||||
initial_state = MyState
|
||||
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review",
|
||||
emit=["ok", "not_ok"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def begin(self):
|
||||
return "data"
|
||||
|
||||
@listen("ok")
|
||||
def proceed(self):
|
||||
return "done"
|
||||
|
||||
structure = flow_structure(SerializableFlow)
|
||||
|
||||
# Should not raise
|
||||
json_str = json.dumps(structure)
|
||||
assert json_str is not None
|
||||
|
||||
# Should round-trip
|
||||
parsed = json.loads(json_str)
|
||||
assert parsed["name"] == "SerializableFlow"
|
||||
assert len(parsed["methods"]) > 0
|
||||
|
||||
|
||||
class TestFlowInheritance:
|
||||
"""Test flow inheritance scenarios."""
|
||||
|
||||
def test_child_flow_inherits_parent_methods(self):
|
||||
"""Test that FlowB inheriting from FlowA includes methods from both.
|
||||
|
||||
Note: FlowMeta propagates methods but does NOT fully propagate the
|
||||
_listeners registry from parent classes. This means edges defined
|
||||
in the parent class (e.g., parent_start -> parent_process) may not
|
||||
appear in the child's structure. This is a known FlowMeta limitation.
|
||||
"""
|
||||
|
||||
class FlowA(Flow):
|
||||
"""Parent flow with start method."""
|
||||
|
||||
@start()
|
||||
def parent_start(self):
|
||||
return "parent started"
|
||||
|
||||
@listen(parent_start)
|
||||
def parent_process(self):
|
||||
return "parent processed"
|
||||
|
||||
class FlowB(FlowA):
|
||||
"""Child flow with additional methods."""
|
||||
|
||||
@listen(FlowA.parent_process)
|
||||
def child_continue(self):
|
||||
return "child continued"
|
||||
|
||||
@listen(child_continue)
|
||||
def child_finalize(self):
|
||||
return "child finalized"
|
||||
|
||||
structure = flow_structure(FlowB)
|
||||
|
||||
assert structure["name"] == "FlowB"
|
||||
|
||||
# Check all methods are present (from both parent and child)
|
||||
method_names = {m["name"] for m in structure["methods"]}
|
||||
assert "parent_start" in method_names
|
||||
assert "parent_process" in method_names
|
||||
assert "child_continue" in method_names
|
||||
assert "child_finalize" in method_names
|
||||
|
||||
# Check method types
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
assert method_map["parent_start"]["type"] == "start"
|
||||
assert method_map["parent_process"]["type"] == "listen"
|
||||
assert method_map["child_continue"]["type"] == "listen"
|
||||
assert method_map["child_finalize"]["type"] == "listen"
|
||||
|
||||
# Check edges defined in child class exist
|
||||
edge_pairs = [(e["from_method"], e["to_method"]) for e in structure["edges"]]
|
||||
assert ("parent_process", "child_continue") in edge_pairs
|
||||
assert ("child_continue", "child_finalize") in edge_pairs
|
||||
|
||||
# KNOWN LIMITATION: Edges defined in parent class (parent_start -> parent_process)
|
||||
# are NOT propagated to child's _listeners registry by FlowMeta.
|
||||
# The edge (parent_start, parent_process) will NOT be in edge_pairs.
|
||||
# This is a FlowMeta limitation, not a serializer bug.
|
||||
|
||||
def test_child_flow_can_override_parent_method(self):
|
||||
"""Test that child can override parent methods."""
|
||||
|
||||
class BaseFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "base begin"
|
||||
|
||||
@listen(begin)
|
||||
def process(self):
|
||||
return "base process"
|
||||
|
||||
class ExtendedFlow(BaseFlow):
|
||||
@listen(BaseFlow.begin)
|
||||
def process(self):
|
||||
# Override parent's process method
|
||||
return "extended process"
|
||||
|
||||
@listen(process)
|
||||
def finalize(self):
|
||||
return "extended finalize"
|
||||
|
||||
structure = flow_structure(ExtendedFlow)
|
||||
|
||||
method_names = {m["name"] for m in structure["methods"]}
|
||||
assert "begin" in method_names
|
||||
assert "process" in method_names
|
||||
assert "finalize" in method_names
|
||||
|
||||
# Should have 3 methods total (not 4, since process is overridden)
|
||||
assert len(structure["methods"]) == 3
|
||||
@@ -772,3 +772,204 @@ class TestEdgeCases:
|
||||
assert result.output == "content"
|
||||
assert result.feedback == "feedback"
|
||||
assert result.outcome is None # No routing, no outcome
|
||||
|
||||
|
||||
class TestLLMConfigPreservation:
|
||||
"""Tests that LLM config is preserved through @human_feedback serialization.
|
||||
|
||||
PR #4970 introduced _hf_llm stashing so the live LLM object survives
|
||||
decorator wrapping for same-process resume. The serialization path
|
||||
(_serialize_llm_for_context / _deserialize_llm_from_context) preserves
|
||||
config for cross-process resume.
|
||||
"""
|
||||
|
||||
def test_hf_llm_stashed_on_wrapper_with_llm_instance(self):
|
||||
"""Test that passing an LLM instance stashes it on the wrapper as _hf_llm."""
|
||||
from crewai.llm import LLM
|
||||
|
||||
llm_instance = LLM(model="gpt-4o-mini", temperature=0.42)
|
||||
|
||||
class ConfigFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm=llm_instance,
|
||||
)
|
||||
def review(self):
|
||||
return "content"
|
||||
|
||||
method = ConfigFlow.review
|
||||
assert hasattr(method, "_hf_llm"), "_hf_llm not found on wrapper"
|
||||
assert method._hf_llm is llm_instance, "_hf_llm is not the same object"
|
||||
|
||||
def test_hf_llm_preserved_on_listen_method(self):
|
||||
"""Test that _hf_llm is preserved when @human_feedback is on a @listen method."""
|
||||
from crewai.llm import LLM
|
||||
|
||||
llm_instance = LLM(model="gpt-4o-mini", temperature=0.7)
|
||||
|
||||
class ListenConfigFlow(Flow):
|
||||
@start()
|
||||
def generate(self):
|
||||
return "draft"
|
||||
|
||||
@listen("generate")
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm=llm_instance,
|
||||
)
|
||||
def review(self):
|
||||
return "content"
|
||||
|
||||
method = ListenConfigFlow.review
|
||||
assert hasattr(method, "_hf_llm")
|
||||
assert method._hf_llm is llm_instance
|
||||
|
||||
def test_hf_llm_accessible_on_instance(self):
|
||||
"""Test that _hf_llm survives Flow instantiation (bound method access)."""
|
||||
from crewai.llm import LLM
|
||||
|
||||
llm_instance = LLM(model="gpt-4o-mini", temperature=0.42)
|
||||
|
||||
class InstanceFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm=llm_instance,
|
||||
)
|
||||
def review(self):
|
||||
return "content"
|
||||
|
||||
flow = InstanceFlow()
|
||||
instance_method = flow.review
|
||||
assert hasattr(instance_method, "_hf_llm")
|
||||
assert instance_method._hf_llm is llm_instance
|
||||
|
||||
def test_serialize_llm_preserves_config_fields(self):
|
||||
"""Test that _serialize_llm_for_context captures temperature, base_url, etc."""
|
||||
from crewai.flow.human_feedback import _serialize_llm_for_context
|
||||
from crewai.llm import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="gpt-4o-mini",
|
||||
temperature=0.42,
|
||||
base_url="https://custom.example.com/v1",
|
||||
)
|
||||
|
||||
serialized = _serialize_llm_for_context(llm)
|
||||
|
||||
assert isinstance(serialized, dict), f"Expected dict, got {type(serialized)}"
|
||||
assert serialized["model"] == "openai/gpt-4o-mini"
|
||||
assert serialized["temperature"] == 0.42
|
||||
assert serialized["base_url"] == "https://custom.example.com/v1"
|
||||
|
||||
def test_serialize_llm_excludes_api_key(self):
|
||||
"""Test that api_key is NOT included in serialized output (security)."""
|
||||
from crewai.flow.human_feedback import _serialize_llm_for_context
|
||||
from crewai.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
serialized = _serialize_llm_for_context(llm)
|
||||
assert isinstance(serialized, dict)
|
||||
assert "api_key" not in serialized
|
||||
|
||||
def test_deserialize_round_trip_preserves_config(self):
|
||||
"""Test that serialize → deserialize round-trip preserves all config."""
|
||||
from crewai.flow.human_feedback import (
|
||||
_deserialize_llm_from_context,
|
||||
_serialize_llm_for_context,
|
||||
)
|
||||
from crewai.llm import LLM
|
||||
|
||||
original = LLM(
|
||||
model="gpt-4o-mini",
|
||||
temperature=0.42,
|
||||
base_url="https://custom.example.com/v1",
|
||||
)
|
||||
|
||||
serialized = _serialize_llm_for_context(original)
|
||||
reconstructed = _deserialize_llm_from_context(serialized)
|
||||
|
||||
assert reconstructed is not None
|
||||
assert reconstructed.model == original.model
|
||||
assert reconstructed.temperature == original.temperature
|
||||
assert reconstructed.base_url == original.base_url
|
||||
|
||||
def test_deserialize_handles_legacy_string_format(self):
|
||||
"""Test backward compat: plain string still reconstructs an LLM."""
|
||||
from crewai.flow.human_feedback import _deserialize_llm_from_context
|
||||
|
||||
reconstructed = _deserialize_llm_from_context("openai/gpt-4o-mini")
|
||||
|
||||
assert reconstructed is not None
|
||||
assert reconstructed.model == "gpt-4o-mini"
|
||||
|
||||
def test_deserialize_returns_none_for_none(self):
|
||||
"""Test that None input returns None."""
|
||||
from crewai.flow.human_feedback import _deserialize_llm_from_context
|
||||
|
||||
assert _deserialize_llm_from_context(None) is None
|
||||
|
||||
def test_serialize_llm_preserves_provider_specific_fields(self):
|
||||
"""Test that provider-specific fields like project/location are serialized."""
|
||||
from crewai.flow.human_feedback import _serialize_llm_for_context
|
||||
from crewai.llm import LLM
|
||||
|
||||
# Create a Gemini-style LLM with project and non-default location
|
||||
llm = LLM(
|
||||
model="gemini-2.0-flash",
|
||||
provider="gemini",
|
||||
project="my-project",
|
||||
location="europe-west1",
|
||||
temperature=0.3,
|
||||
)
|
||||
|
||||
serialized = _serialize_llm_for_context(llm)
|
||||
|
||||
assert isinstance(serialized, dict)
|
||||
assert serialized.get("project") == "my-project"
|
||||
assert serialized.get("location") == "europe-west1"
|
||||
assert serialized.get("temperature") == 0.3
|
||||
|
||||
def test_config_preserved_through_full_flow_execution(self):
|
||||
"""Test that the LLM with custom config is used during outcome collapsing."""
|
||||
from crewai.llm import LLM
|
||||
|
||||
llm_instance = LLM(model="gpt-4o-mini", temperature=0.42)
|
||||
collapse_calls = []
|
||||
|
||||
class FullFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm=llm_instance,
|
||||
)
|
||||
def review(self):
|
||||
return "content"
|
||||
|
||||
@listen("approved")
|
||||
def on_approved(self):
|
||||
return "done"
|
||||
|
||||
flow = FullFlow()
|
||||
|
||||
original_collapse = flow._collapse_to_outcome
|
||||
|
||||
def spy_collapse(feedback, outcomes, llm):
|
||||
collapse_calls.append(llm)
|
||||
return "approved"
|
||||
|
||||
with (
|
||||
patch.object(flow, "_request_human_feedback", return_value="looks good"),
|
||||
patch.object(flow, "_collapse_to_outcome", side_effect=spy_collapse),
|
||||
):
|
||||
flow.kickoff()
|
||||
|
||||
assert len(collapse_calls) == 1
|
||||
# The LLM passed to _collapse_to_outcome should be the original instance
|
||||
assert collapse_calls[0] is llm_instance
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user