mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-01-05 22:28:29 +00:00
Compare commits
37 Commits
devin/1761
...
alert-auto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7976565e5 | ||
|
|
bcc3e358cb | ||
|
|
d160f0874a | ||
|
|
a60bb90d6a | ||
|
|
9fcf55198f | ||
|
|
f46a846ddc | ||
|
|
b546982690 | ||
|
|
d7bdac12a2 | ||
|
|
528d812263 | ||
|
|
ffd717c51a | ||
|
|
fbe4aa4bd1 | ||
|
|
c205d2e8de | ||
|
|
fcb5b19b2e | ||
|
|
01f0111d52 | ||
|
|
6b52587c67 | ||
|
|
629f7f34ce | ||
|
|
f557334c01 | ||
|
|
0f1c173d02 | ||
|
|
19c5b9a35e | ||
|
|
1ed307b58c | ||
|
|
d29867bbb6 | ||
|
|
b2c278ed22 | ||
|
|
f6aed9798b | ||
|
|
40a2d387a1 | ||
|
|
6f36d7003b | ||
|
|
9e5906c52f | ||
|
|
fc521839e4 | ||
|
|
e4cc9a664c | ||
|
|
7e6171d5bc | ||
|
|
61ad1fb112 | ||
|
|
54710a8711 | ||
|
|
5abf976373 | ||
|
|
329567153b | ||
|
|
60332e0b19 | ||
|
|
40932af3fa | ||
|
|
e134e5305b | ||
|
|
e229ef4e19 |
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: uv # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
35
.github/workflows/docs-broken-links.yml
vendored
Normal file
35
.github/workflows/docs-broken-links.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Check Documentation Broken Links
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "docs.json"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "docs.json"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-links:
|
||||
name: Check broken links
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "latest"
|
||||
|
||||
- name: Install Mintlify CLI
|
||||
run: npm i -g mintlify
|
||||
|
||||
- name: Run broken link checker
|
||||
run: |
|
||||
# Auto-answer the prompt with yes command
|
||||
yes "" | mintlify broken-links || test $? -eq 141
|
||||
working-directory: ./docs
|
||||
@@ -19,6 +19,7 @@ repos:
|
||||
language: system
|
||||
pass_filenames: true
|
||||
types: [python]
|
||||
exclude: ^(lib/crewai/src/crewai/cli/templates/|lib/crewai/tests/|lib/crewai-tools/tests/)
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.9.3
|
||||
hooks:
|
||||
|
||||
@@ -313,7 +313,10 @@
|
||||
"en/learn/multimodal-agents",
|
||||
"en/learn/replay-tasks-from-latest-crew-kickoff",
|
||||
"en/learn/sequential-process",
|
||||
"en/learn/using-annotations"
|
||||
"en/learn/using-annotations",
|
||||
"en/learn/execution-hooks",
|
||||
"en/learn/llm-hooks",
|
||||
"en/learn/tool-hooks"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -737,7 +740,10 @@
|
||||
"pt-BR/learn/multimodal-agents",
|
||||
"pt-BR/learn/replay-tasks-from-latest-crew-kickoff",
|
||||
"pt-BR/learn/sequential-process",
|
||||
"pt-BR/learn/using-annotations"
|
||||
"pt-BR/learn/using-annotations",
|
||||
"pt-BR/learn/execution-hooks",
|
||||
"pt-BR/learn/llm-hooks",
|
||||
"pt-BR/learn/tool-hooks"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1170,7 +1176,10 @@
|
||||
"ko/learn/multimodal-agents",
|
||||
"ko/learn/replay-tasks-from-latest-crew-kickoff",
|
||||
"ko/learn/sequential-process",
|
||||
"ko/learn/using-annotations"
|
||||
"ko/learn/using-annotations",
|
||||
"ko/learn/execution-hooks",
|
||||
"ko/learn/llm-hooks",
|
||||
"ko/learn/tool-hooks"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -402,6 +402,77 @@ crewai config reset
|
||||
After resetting configuration, re-run `crewai login` to authenticate again.
|
||||
</Tip>
|
||||
|
||||
### 14. Trace Management
|
||||
|
||||
Manage trace collection preferences for your Crew and Flow executions.
|
||||
|
||||
```shell Terminal
|
||||
crewai traces [COMMAND]
|
||||
```
|
||||
|
||||
#### Commands:
|
||||
|
||||
- `enable`: Enable trace collection for crew/flow executions
|
||||
```shell Terminal
|
||||
crewai traces enable
|
||||
```
|
||||
|
||||
- `disable`: Disable trace collection for crew/flow executions
|
||||
```shell Terminal
|
||||
crewai traces disable
|
||||
```
|
||||
|
||||
- `status`: Show current trace collection status
|
||||
```shell Terminal
|
||||
crewai traces status
|
||||
```
|
||||
|
||||
#### How Tracing Works
|
||||
|
||||
Trace collection is controlled by checking three settings in priority order:
|
||||
|
||||
1. **Explicit flag in code** (highest priority - can enable OR disable):
|
||||
```python
|
||||
crew = Crew(agents=[...], tasks=[...], tracing=True) # Always enable
|
||||
crew = Crew(agents=[...], tasks=[...], tracing=False) # Always disable
|
||||
crew = Crew(agents=[...], tasks=[...]) # Check lower priorities (default)
|
||||
```
|
||||
- `tracing=True` will **always enable** tracing (overrides everything)
|
||||
- `tracing=False` will **always disable** tracing (overrides everything)
|
||||
- `tracing=None` or omitted will check lower priority settings
|
||||
|
||||
2. **Environment variable** (second priority):
|
||||
```env
|
||||
CREWAI_TRACING_ENABLED=true
|
||||
```
|
||||
- Checked only if `tracing` is not explicitly set to `True` or `False` in code
|
||||
- Set to `true` or `1` to enable tracing
|
||||
|
||||
3. **User preference** (lowest priority):
|
||||
```shell Terminal
|
||||
crewai traces enable
|
||||
```
|
||||
- Checked only if `tracing` is not set in code and `CREWAI_TRACING_ENABLED` is not set to `true`
|
||||
- Running `crewai traces enable` is sufficient to enable tracing by itself
|
||||
|
||||
<Note>
|
||||
**To enable tracing**, use any one of these methods:
|
||||
- Set `tracing=True` in your Crew/Flow code, OR
|
||||
- Add `CREWAI_TRACING_ENABLED=true` to your `.env` file, OR
|
||||
- Run `crewai traces enable`
|
||||
|
||||
**To disable tracing**, use any ONE of these methods:
|
||||
- Set `tracing=False` in your Crew/Flow code (overrides everything), OR
|
||||
- Remove or set to `false` the `CREWAI_TRACING_ENABLED` env var, OR
|
||||
- Run `crewai traces disable`
|
||||
|
||||
Higher priority settings override lower ones.
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
For more information about tracing, see the [Tracing documentation](/observability/tracing).
|
||||
</Tip>
|
||||
|
||||
<Tip>
|
||||
CrewAI CLI handles authentication to the Tool Repository automatically when adding packages to your project. Just append `crewai` before any `uv` command to use it. E.g. `crewai uv add requests`. For more information, see [Tool Repository](https://docs.crewai.com/enterprise/features/tool-repository) docs.
|
||||
</Tip>
|
||||
|
||||
@@ -739,7 +739,7 @@ class KnowledgeMonitorListener(BaseEventListener):
|
||||
knowledge_monitor = KnowledgeMonitorListener()
|
||||
```
|
||||
|
||||
For more information on using events, see the [Event Listeners](https://docs.crewai.com/concepts/event-listener) documentation.
|
||||
For more information on using events, see the [Event Listeners](/en/concepts/event-listener) documentation.
|
||||
|
||||
### Custom Knowledge Sources
|
||||
|
||||
|
||||
@@ -1035,7 +1035,7 @@ CrewAI supports streaming responses from LLMs, allowing your application to rece
|
||||
```
|
||||
|
||||
<Tip>
|
||||
[Click here](https://docs.crewai.com/concepts/event-listener#event-listeners) for more details
|
||||
[Click here](/en/concepts/event-listener#event-listeners) for more details
|
||||
</Tip>
|
||||
</Tab>
|
||||
|
||||
@@ -1200,6 +1200,52 @@ Learn how to get the most out of your LLM configuration:
|
||||
)
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Transport Interceptors">
|
||||
CrewAI provides message interceptors for several providers, allowing you to hook into request/response cycles at the transport layer.
|
||||
|
||||
**Supported Providers:**
|
||||
- ✅ OpenAI
|
||||
- ✅ Anthropic
|
||||
|
||||
**Basic Usage:**
|
||||
```python
|
||||
import httpx
|
||||
from crewai import LLM
|
||||
from crewai.llms.hooks import BaseInterceptor
|
||||
|
||||
class CustomInterceptor(BaseInterceptor[httpx.Request, httpx.Response]):
|
||||
"""Custom interceptor to modify requests and responses."""
|
||||
|
||||
def on_outbound(self, request: httpx.Request) -> httpx.Request:
|
||||
"""Print request before sending to the LLM provider."""
|
||||
print(request)
|
||||
return request
|
||||
|
||||
def on_inbound(self, response: httpx.Response) -> httpx.Response:
|
||||
"""Process response after receiving from the LLM provider."""
|
||||
print(f"Status: {response.status_code}")
|
||||
print(f"Response time: {response.elapsed}")
|
||||
return response
|
||||
|
||||
# Use the interceptor with an LLM
|
||||
llm = LLM(
|
||||
model="openai/gpt-4o",
|
||||
interceptor=CustomInterceptor()
|
||||
)
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Both methods must return the received object or type of object.
|
||||
- Modifying received objects may result in unexpected behavior or application crashes.
|
||||
- Not all providers support interceptors - check the supported providers list above
|
||||
|
||||
<Info>
|
||||
Interceptors operate at the transport layer. This is particularly useful for:
|
||||
- Message transformation and filtering
|
||||
- Debugging API interactions
|
||||
</Info>
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
@@ -60,6 +60,7 @@ crew = Crew(
|
||||
| **Output Pydantic** _(optional)_ | `output_pydantic` | `Optional[Type[BaseModel]]` | A Pydantic model for task output. |
|
||||
| **Callback** _(optional)_ | `callback` | `Optional[Any]` | Function/object to be executed after task completion. |
|
||||
| **Guardrail** _(optional)_ | `guardrail` | `Optional[Callable]` | Function to validate task output before proceeding to next task. |
|
||||
| **Guardrails** _(optional)_ | `guardrails` | `Optional[List[Callable] | List[str]]` | List of guardrails to validate task output before proceeding to next task. |
|
||||
| **Guardrail Max Retries** _(optional)_ | `guardrail_max_retries` | `Optional[int]` | Maximum number of retries when guardrail validation fails. Defaults to 3. |
|
||||
|
||||
<Note type="warning" title="Deprecated: max_retries">
|
||||
@@ -223,6 +224,7 @@ By default, the `TaskOutput` will only include the `raw` output. A `TaskOutput`
|
||||
| **JSON Dict** | `json_dict` | `Optional[Dict[str, Any]]` | A dictionary representing the JSON output of the task. |
|
||||
| **Agent** | `agent` | `str` | The agent that executed the task. |
|
||||
| **Output Format** | `output_format` | `OutputFormat` | The format of the task output, with options including RAW, JSON, and Pydantic. The default is RAW. |
|
||||
| **Messages** | `messages` | `list[LLMMessage]` | The messages from the last task execution. |
|
||||
|
||||
### Task Methods and Properties
|
||||
|
||||
@@ -341,7 +343,11 @@ Task guardrails provide a way to validate and transform task outputs before they
|
||||
are passed to the next task. This feature helps ensure data quality and provides
|
||||
feedback to agents when their output doesn't meet specific criteria.
|
||||
|
||||
Guardrails are implemented as Python functions that contain custom validation logic, giving you complete control over the validation process and ensuring reliable, deterministic results.
|
||||
CrewAI supports two types of guardrails:
|
||||
|
||||
1. **Function-based guardrails**: Python functions with custom validation logic, giving you complete control over the validation process and ensuring reliable, deterministic results.
|
||||
|
||||
2. **LLM-based guardrails**: String descriptions that use the agent's LLM to validate outputs based on natural language criteria. These are ideal for complex or subjective validation requirements.
|
||||
|
||||
### Function-Based Guardrails
|
||||
|
||||
@@ -355,12 +361,12 @@ def validate_blog_content(result: TaskOutput) -> Tuple[bool, Any]:
|
||||
"""Validate blog content meets requirements."""
|
||||
try:
|
||||
# Check word count
|
||||
word_count = len(result.split())
|
||||
word_count = len(result.raw.split())
|
||||
if word_count > 200:
|
||||
return (False, "Blog content exceeds 200 words")
|
||||
|
||||
# Additional validation logic here
|
||||
return (True, result.strip())
|
||||
return (True, result.raw.strip())
|
||||
except Exception as e:
|
||||
return (False, "Unexpected error during validation")
|
||||
|
||||
@@ -372,6 +378,147 @@ blog_task = Task(
|
||||
)
|
||||
```
|
||||
|
||||
### LLM-Based Guardrails (String Descriptions)
|
||||
|
||||
Instead of writing custom validation functions, you can use string descriptions that leverage LLM-based validation. When you provide a string to the `guardrail` or `guardrails` parameter, CrewAI automatically creates an `LLMGuardrail` that uses the agent's LLM to validate the output based on your description.
|
||||
|
||||
**Requirements**:
|
||||
- The task must have an `agent` assigned (the guardrail uses the agent's LLM)
|
||||
- Provide a clear, descriptive string explaining the validation criteria
|
||||
|
||||
```python Code
|
||||
from crewai import Task
|
||||
|
||||
# Single LLM-based guardrail
|
||||
blog_task = Task(
|
||||
description="Write a blog post about AI",
|
||||
expected_output="A blog post under 200 words",
|
||||
agent=blog_agent,
|
||||
guardrail="The blog post must be under 200 words and contain no technical jargon"
|
||||
)
|
||||
```
|
||||
|
||||
LLM-based guardrails are particularly useful for:
|
||||
- **Complex validation logic** that's difficult to express programmatically
|
||||
- **Subjective criteria** like tone, style, or quality assessments
|
||||
- **Natural language requirements** that are easier to describe than code
|
||||
|
||||
The LLM guardrail will:
|
||||
1. Analyze the task output against your description
|
||||
2. Return `(True, output)` if the output complies with the criteria
|
||||
3. Return `(False, feedback)` with specific feedback if validation fails
|
||||
|
||||
**Example with detailed validation criteria**:
|
||||
|
||||
```python Code
|
||||
research_task = Task(
|
||||
description="Research the latest developments in quantum computing",
|
||||
expected_output="A comprehensive research report",
|
||||
agent=researcher_agent,
|
||||
guardrail="""
|
||||
The research report must:
|
||||
- Be at least 1000 words long
|
||||
- Include at least 5 credible sources
|
||||
- Cover both technical and practical applications
|
||||
- Be written in a professional, academic tone
|
||||
- Avoid speculation or unverified claims
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
### Multiple Guardrails
|
||||
|
||||
You can apply multiple guardrails to a task using the `guardrails` parameter. Multiple guardrails are executed sequentially, with each guardrail receiving the output from the previous one. This allows you to chain validation and transformation steps.
|
||||
|
||||
The `guardrails` parameter accepts:
|
||||
- A list of guardrail functions or string descriptions
|
||||
- A single guardrail function or string (same as `guardrail`)
|
||||
|
||||
**Note**: If `guardrails` is provided, it takes precedence over `guardrail`. The `guardrail` parameter will be ignored when `guardrails` is set.
|
||||
|
||||
```python Code
|
||||
from typing import Tuple, Any
|
||||
from crewai import TaskOutput, Task
|
||||
|
||||
def validate_word_count(result: TaskOutput) -> Tuple[bool, Any]:
|
||||
"""Validate word count is within limits."""
|
||||
word_count = len(result.raw.split())
|
||||
if word_count < 100:
|
||||
return (False, f"Content too short: {word_count} words. Need at least 100 words.")
|
||||
if word_count > 500:
|
||||
return (False, f"Content too long: {word_count} words. Maximum is 500 words.")
|
||||
return (True, result.raw)
|
||||
|
||||
def validate_no_profanity(result: TaskOutput) -> Tuple[bool, Any]:
|
||||
"""Check for inappropriate language."""
|
||||
profanity_words = ["badword1", "badword2"] # Example list
|
||||
content_lower = result.raw.lower()
|
||||
for word in profanity_words:
|
||||
if word in content_lower:
|
||||
return (False, f"Inappropriate language detected: {word}")
|
||||
return (True, result.raw)
|
||||
|
||||
def format_output(result: TaskOutput) -> Tuple[bool, Any]:
|
||||
"""Format and clean the output."""
|
||||
formatted = result.raw.strip()
|
||||
# Capitalize first letter
|
||||
formatted = formatted[0].upper() + formatted[1:] if formatted else formatted
|
||||
return (True, formatted)
|
||||
|
||||
# Apply multiple guardrails sequentially
|
||||
blog_task = Task(
|
||||
description="Write a blog post about AI",
|
||||
expected_output="A well-formatted blog post between 100-500 words",
|
||||
agent=blog_agent,
|
||||
guardrails=[
|
||||
validate_word_count, # First: validate length
|
||||
validate_no_profanity, # Second: check content
|
||||
format_output # Third: format the result
|
||||
],
|
||||
guardrail_max_retries=3
|
||||
)
|
||||
```
|
||||
|
||||
In this example, the guardrails execute in order:
|
||||
1. `validate_word_count` checks the word count
|
||||
2. `validate_no_profanity` checks for inappropriate language (using the output from step 1)
|
||||
3. `format_output` formats the final result (using the output from step 2)
|
||||
|
||||
If any guardrail fails, the error is sent back to the agent, and the task is retried up to `guardrail_max_retries` times.
|
||||
|
||||
**Mixing function-based and LLM-based guardrails**:
|
||||
|
||||
You can combine both function-based and string-based guardrails in the same list:
|
||||
|
||||
```python Code
|
||||
from typing import Tuple, Any
|
||||
from crewai import TaskOutput, Task
|
||||
|
||||
def validate_word_count(result: TaskOutput) -> Tuple[bool, Any]:
|
||||
"""Validate word count is within limits."""
|
||||
word_count = len(result.raw.split())
|
||||
if word_count < 100:
|
||||
return (False, f"Content too short: {word_count} words. Need at least 100 words.")
|
||||
if word_count > 500:
|
||||
return (False, f"Content too long: {word_count} words. Maximum is 500 words.")
|
||||
return (True, result.raw)
|
||||
|
||||
# Mix function-based and LLM-based guardrails
|
||||
blog_task = Task(
|
||||
description="Write a blog post about AI",
|
||||
expected_output="A well-formatted blog post between 100-500 words",
|
||||
agent=blog_agent,
|
||||
guardrails=[
|
||||
validate_word_count, # Function-based: precise word count check
|
||||
"The content must be engaging and suitable for a general audience", # LLM-based: subjective quality check
|
||||
"The writing style should be clear, concise, and free of technical jargon" # LLM-based: style validation
|
||||
],
|
||||
guardrail_max_retries=3
|
||||
)
|
||||
```
|
||||
|
||||
This approach combines the precision of programmatic validation with the flexibility of LLM-based assessment for subjective criteria.
|
||||
|
||||
### Guardrail Function Requirements
|
||||
|
||||
1. **Function Signature**:
|
||||
|
||||
@@ -37,7 +37,7 @@ you can use them locally or refine them to your needs.
|
||||
<Card title="Tools & Integrations" href="/en/enterprise/features/tools-and-integrations" icon="wrench">
|
||||
Connect external apps and manage internal tools your agents can use.
|
||||
</Card>
|
||||
<Card title="Tool Repository" href="/en/enterprise/features/tool-repository" icon="toolbox">
|
||||
<Card title="Tool Repository" href="/en/enterprise/guides/tool-repository#tool-repository" icon="toolbox">
|
||||
Publish and install tools to enhance your crews' capabilities.
|
||||
</Card>
|
||||
<Card title="Agents Repository" href="/en/enterprise/features/agent-repositories" icon="people-group">
|
||||
|
||||
@@ -241,7 +241,7 @@ Tools & Integrations is the central hub for connecting third‑party apps and ma
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Tool Repository" href="/en/enterprise/features/tool-repository" icon="toolbox">
|
||||
<Card title="Tool Repository" href="/en/enterprise/guides/tool-repository#tool-repository" icon="toolbox">
|
||||
Create, publish, and version custom tools for your organization.
|
||||
</Card>
|
||||
<Card title="Webhook Automation" href="/en/enterprise/guides/webhook-automation" icon="bolt">
|
||||
|
||||
@@ -21,7 +21,7 @@ The repository is not a version control system. Use Git to track code changes an
|
||||
Before using the Tool Repository, ensure you have:
|
||||
|
||||
- A [CrewAI AMP](https://app.crewai.com) account
|
||||
- [CrewAI CLI](https://docs.crewai.com/concepts/cli#cli) installed
|
||||
- [CrewAI CLI](/en/concepts/cli#cli) installed
|
||||
- uv>=0.5.0 installed. Check out [how to upgrade](https://docs.astral.sh/uv/getting-started/installation/#upgrading-uv)
|
||||
- [Git](https://git-scm.com) installed and configured
|
||||
- Access permissions to publish or install tools in your CrewAI AMP organization
|
||||
@@ -112,7 +112,7 @@ By default, tools are published as private. To make a tool public:
|
||||
crewai tool publish --public
|
||||
```
|
||||
|
||||
For more details on how to build tools, see [Creating your own tools](https://docs.crewai.com/concepts/tools#creating-your-own-tools).
|
||||
For more details on how to build tools, see [Creating your own tools](/en/concepts/tools#creating-your-own-tools).
|
||||
|
||||
## Updating Tools
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ mode: "wide"
|
||||
|
||||
To integrate human input into agent execution, set the `human_input` flag in the task definition. When enabled, the agent prompts the user for input before delivering its final answer. This input can provide extra context, clarify ambiguities, or validate the agent's output.
|
||||
|
||||
For detailed implementation guidance, see our [Human-in-the-Loop guide](/en/how-to/human-in-the-loop).
|
||||
For detailed implementation guidance, see our [Human-in-the-Loop guide](/en/enterprise/guides/human-in-the-loop).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="What advanced customization options are available for tailoring and enhancing agent behavior and capabilities in CrewAI?">
|
||||
@@ -142,7 +142,7 @@ mode: "wide"
|
||||
<Accordion title="How can I create custom tools for my CrewAI agents?">
|
||||
You can create custom tools by subclassing the `BaseTool` class provided by CrewAI or by using the tool decorator. Subclassing involves defining a new class that inherits from `BaseTool`, specifying the name, description, and the `_run` method for operational logic. The tool decorator allows you to create a `Tool` object directly with the required attributes and a functional logic.
|
||||
|
||||
<Card href="https://docs.crewai.com/how-to/create-custom-tools" icon="code">CrewAI Tools Guide</Card>
|
||||
<Card href="/en/learn/create-custom-tools" icon="code">CrewAI Tools Guide</Card>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How can you control the maximum number of requests per minute that the entire crew can perform?">
|
||||
|
||||
295
docs/en/learn/a2a-agent-delegation.mdx
Normal file
295
docs/en/learn/a2a-agent-delegation.mdx
Normal file
@@ -0,0 +1,295 @@
|
||||
---
|
||||
title: Agent-to-Agent (A2A) Protocol
|
||||
description: Enable CrewAI agents to delegate tasks to remote A2A-compliant agents for specialized handling
|
||||
icon: network-wired
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## A2A Agent Delegation
|
||||
|
||||
CrewAI supports the Agent-to-Agent (A2A) protocol, allowing agents to delegate tasks to remote specialized agents. The agent's LLM automatically decides whether to handle a task directly or delegate to an A2A agent based on the task requirements.
|
||||
|
||||
<Note>
|
||||
A2A delegation requires the `a2a-sdk` package. Install with: `uv add 'crewai[a2a]'` or `pip install 'crewai[a2a]'`
|
||||
</Note>
|
||||
|
||||
## How It Works
|
||||
|
||||
When an agent is configured with A2A capabilities:
|
||||
|
||||
1. The LLM analyzes each task
|
||||
2. It decides to either:
|
||||
- Handle the task directly using its own capabilities
|
||||
- Delegate to a remote A2A agent for specialized handling
|
||||
3. If delegating, the agent communicates with the remote A2A agent through the protocol
|
||||
4. Results are returned to the CrewAI workflow
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
Configure an agent for A2A delegation by setting the `a2a` parameter:
|
||||
|
||||
```python Code
|
||||
from crewai import Agent, Crew, Task
|
||||
from crewai.a2a import A2AConfig
|
||||
|
||||
agent = Agent(
|
||||
role="Research Coordinator",
|
||||
goal="Coordinate research tasks efficiently",
|
||||
backstory="Expert at delegating to specialized research agents",
|
||||
llm="gpt-4o",
|
||||
a2a=A2AConfig(
|
||||
endpoint="https://example.com/.well-known/agent-card.json",
|
||||
timeout=120,
|
||||
max_turns=10
|
||||
)
|
||||
)
|
||||
|
||||
task = Task(
|
||||
description="Research the latest developments in quantum computing",
|
||||
expected_output="A comprehensive research report",
|
||||
agent=agent
|
||||
)
|
||||
|
||||
crew = Crew(agents=[agent], tasks=[task], verbose=True)
|
||||
result = crew.kickoff()
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The `A2AConfig` class accepts the following parameters:
|
||||
|
||||
<ParamField path="endpoint" type="str" required>
|
||||
The A2A agent endpoint URL (typically points to `.well-known/agent-card.json`)
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="auth" type="AuthScheme" default="None">
|
||||
Authentication scheme for the A2A agent. Supports Bearer tokens, OAuth2, API keys, and HTTP authentication.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="timeout" type="int" default="120">
|
||||
Request timeout in seconds
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="max_turns" type="int" default="10">
|
||||
Maximum number of conversation turns with the A2A agent
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="response_model" type="type[BaseModel]" default="None">
|
||||
Optional Pydantic model for requesting structured output from an A2A agent. A2A protocol does not
|
||||
enforce this, so an A2A agent does not need to honor this request.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="fail_fast" type="bool" default="True">
|
||||
Whether to raise an error immediately if agent connection fails. When `False`, the agent continues with available agents and informs the LLM about unavailable ones.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="trust_remote_completion_status" type="bool" default="False">
|
||||
When `True`, returns the A2A agent's result directly when it signals completion. When `False`, allows the server agent to review the result and potentially continue the conversation.
|
||||
</ParamField>
|
||||
|
||||
## Authentication
|
||||
|
||||
For A2A agents that require authentication, use one of the provided auth schemes:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Bearer Token">
|
||||
```python Code
|
||||
from crewai.a2a import A2AConfig
|
||||
from crewai.a2a.auth import BearerTokenAuth
|
||||
|
||||
agent = Agent(
|
||||
role="Secure Coordinator",
|
||||
goal="Coordinate tasks with secured agents",
|
||||
backstory="Manages secure agent communications",
|
||||
llm="gpt-4o",
|
||||
a2a=A2AConfig(
|
||||
endpoint="https://secure-agent.example.com/.well-known/agent-card.json",
|
||||
auth=BearerTokenAuth(token="your-bearer-token"),
|
||||
timeout=120
|
||||
)
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="API Key">
|
||||
```python Code
|
||||
from crewai.a2a import A2AConfig
|
||||
from crewai.a2a.auth import APIKeyAuth
|
||||
|
||||
agent = Agent(
|
||||
role="API Coordinator",
|
||||
goal="Coordinate with API-based agents",
|
||||
backstory="Manages API-authenticated communications",
|
||||
llm="gpt-4o",
|
||||
a2a=A2AConfig(
|
||||
endpoint="https://api-agent.example.com/.well-known/agent-card.json",
|
||||
auth=APIKeyAuth(
|
||||
api_key="your-api-key",
|
||||
location="header", # or "query" or "cookie"
|
||||
name="X-API-Key"
|
||||
),
|
||||
timeout=120
|
||||
)
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="OAuth2">
|
||||
```python Code
|
||||
from crewai.a2a import A2AConfig
|
||||
from crewai.a2a.auth import OAuth2ClientCredentials
|
||||
|
||||
agent = Agent(
|
||||
role="OAuth Coordinator",
|
||||
goal="Coordinate with OAuth-secured agents",
|
||||
backstory="Manages OAuth-authenticated communications",
|
||||
llm="gpt-4o",
|
||||
a2a=A2AConfig(
|
||||
endpoint="https://oauth-agent.example.com/.well-known/agent-card.json",
|
||||
auth=OAuth2ClientCredentials(
|
||||
token_url="https://auth.example.com/oauth/token",
|
||||
client_id="your-client-id",
|
||||
client_secret="your-client-secret",
|
||||
scopes=["read", "write"]
|
||||
),
|
||||
timeout=120
|
||||
)
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="HTTP Basic">
|
||||
```python Code
|
||||
from crewai.a2a import A2AConfig
|
||||
from crewai.a2a.auth import HTTPBasicAuth
|
||||
|
||||
agent = Agent(
|
||||
role="Basic Auth Coordinator",
|
||||
goal="Coordinate with basic auth agents",
|
||||
backstory="Manages basic authentication communications",
|
||||
llm="gpt-4o",
|
||||
a2a=A2AConfig(
|
||||
endpoint="https://basic-agent.example.com/.well-known/agent-card.json",
|
||||
auth=HTTPBasicAuth(
|
||||
username="your-username",
|
||||
password="your-password"
|
||||
),
|
||||
timeout=120
|
||||
)
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Multiple A2A Agents
|
||||
|
||||
Configure multiple A2A agents for delegation by passing a list:
|
||||
|
||||
```python Code
|
||||
from crewai.a2a import A2AConfig
|
||||
from crewai.a2a.auth import BearerTokenAuth
|
||||
|
||||
agent = Agent(
|
||||
role="Multi-Agent Coordinator",
|
||||
goal="Coordinate with multiple specialized agents",
|
||||
backstory="Expert at delegating to the right specialist",
|
||||
llm="gpt-4o",
|
||||
a2a=[
|
||||
A2AConfig(
|
||||
endpoint="https://research.example.com/.well-known/agent-card.json",
|
||||
timeout=120
|
||||
),
|
||||
A2AConfig(
|
||||
endpoint="https://data.example.com/.well-known/agent-card.json",
|
||||
auth=BearerTokenAuth(token="data-token"),
|
||||
timeout=90
|
||||
)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
The LLM will automatically choose which A2A agent to delegate to based on the task requirements.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Control how agent connection failures are handled using the `fail_fast` parameter:
|
||||
|
||||
```python Code
|
||||
from crewai.a2a import A2AConfig
|
||||
|
||||
# Fail immediately on connection errors (default)
|
||||
agent = Agent(
|
||||
role="Research Coordinator",
|
||||
goal="Coordinate research tasks",
|
||||
backstory="Expert at delegation",
|
||||
llm="gpt-4o",
|
||||
a2a=A2AConfig(
|
||||
endpoint="https://research.example.com/.well-known/agent-card.json",
|
||||
fail_fast=True
|
||||
)
|
||||
)
|
||||
|
||||
# Continue with available agents
|
||||
agent = Agent(
|
||||
role="Multi-Agent Coordinator",
|
||||
goal="Coordinate with multiple agents",
|
||||
backstory="Expert at working with available resources",
|
||||
llm="gpt-4o",
|
||||
a2a=[
|
||||
A2AConfig(
|
||||
endpoint="https://primary.example.com/.well-known/agent-card.json",
|
||||
fail_fast=False
|
||||
),
|
||||
A2AConfig(
|
||||
endpoint="https://backup.example.com/.well-known/agent-card.json",
|
||||
fail_fast=False
|
||||
)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
When `fail_fast=False`:
|
||||
- If some agents fail, the LLM is informed which agents are unavailable and can delegate to working agents
|
||||
- If all agents fail, the LLM receives a notice about unavailable agents and handles the task directly
|
||||
- Connection errors are captured and included in the context for better decision-making
|
||||
|
||||
## Best Practices
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Set Appropriate Timeouts" icon="clock">
|
||||
Configure timeouts based on expected A2A agent response times. Longer-running tasks may need higher timeout values.
|
||||
</Card>
|
||||
|
||||
<Card title="Limit Conversation Turns" icon="comments">
|
||||
Use `max_turns` to prevent excessive back-and-forth. The agent will automatically conclude conversations before hitting the limit.
|
||||
</Card>
|
||||
|
||||
<Card title="Use Resilient Error Handling" icon="shield-check">
|
||||
Set `fail_fast=False` for production environments with multiple agents to gracefully handle connection failures and maintain workflow continuity.
|
||||
</Card>
|
||||
|
||||
<Card title="Secure Your Credentials" icon="lock">
|
||||
Store authentication tokens and credentials as environment variables, not in code.
|
||||
</Card>
|
||||
|
||||
<Card title="Monitor Delegation Decisions" icon="eye">
|
||||
Use verbose mode to observe when the LLM chooses to delegate versus handle tasks directly.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Supported Authentication Methods
|
||||
|
||||
- **Bearer Token** - Simple token-based authentication
|
||||
- **OAuth2 Client Credentials** - OAuth2 flow for machine-to-machine communication
|
||||
- **OAuth2 Authorization Code** - OAuth2 flow requiring user authorization
|
||||
- **API Key** - Key-based authentication (header, query param, or cookie)
|
||||
- **HTTP Basic** - Username/password authentication
|
||||
- **HTTP Digest** - Digest authentication (requires `httpx-auth` package)
|
||||
|
||||
## Learn More
|
||||
|
||||
For more information about the A2A protocol and reference implementations:
|
||||
|
||||
- [A2A Protocol Documentation](https://a2a-protocol.org)
|
||||
- [A2A Sample Implementations](https://github.com/a2aproject/a2a-samples)
|
||||
- [A2A Python SDK](https://github.com/a2aproject/a2a-python)
|
||||
522
docs/en/learn/execution-hooks.mdx
Normal file
522
docs/en/learn/execution-hooks.mdx
Normal file
@@ -0,0 +1,522 @@
|
||||
---
|
||||
title: Execution Hooks Overview
|
||||
description: Understanding and using execution hooks in CrewAI for fine-grained control over agent operations
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
Execution Hooks provide fine-grained control over the runtime behavior of your CrewAI agents. Unlike kickoff hooks that run before and after crew execution, execution hooks intercept specific operations during agent execution, allowing you to modify behavior, implement safety checks, and add comprehensive monitoring.
|
||||
|
||||
## Types of Execution Hooks
|
||||
|
||||
CrewAI provides two main categories of execution hooks:
|
||||
|
||||
### 1. [LLM Call Hooks](/learn/llm-hooks)
|
||||
|
||||
Control and monitor language model interactions:
|
||||
- **Before LLM Call**: Modify prompts, validate inputs, implement approval gates
|
||||
- **After LLM Call**: Transform responses, sanitize outputs, update conversation history
|
||||
|
||||
**Use Cases:**
|
||||
- Iteration limiting
|
||||
- Cost tracking and token usage monitoring
|
||||
- Response sanitization and content filtering
|
||||
- Human-in-the-loop approval for LLM calls
|
||||
- Adding safety guidelines or context
|
||||
- Debug logging and request/response inspection
|
||||
|
||||
[View LLM Hooks Documentation →](/learn/llm-hooks)
|
||||
|
||||
### 2. [Tool Call Hooks](/learn/tool-hooks)
|
||||
|
||||
Control and monitor tool execution:
|
||||
- **Before Tool Call**: Modify inputs, validate parameters, block dangerous operations
|
||||
- **After Tool Call**: Transform results, sanitize outputs, log execution details
|
||||
|
||||
**Use Cases:**
|
||||
- Safety guardrails for destructive operations
|
||||
- Human approval for sensitive actions
|
||||
- Input validation and sanitization
|
||||
- Result caching and rate limiting
|
||||
- Tool usage analytics
|
||||
- Debug logging and monitoring
|
||||
|
||||
[View Tool Hooks Documentation →](/learn/tool-hooks)
|
||||
|
||||
## Hook Registration Methods
|
||||
|
||||
### 1. Decorator-Based Hooks (Recommended)
|
||||
|
||||
The cleanest and most Pythonic way to register hooks:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_llm_call, after_llm_call, before_tool_call, after_tool_call
|
||||
|
||||
@before_llm_call
|
||||
def limit_iterations(context):
|
||||
"""Prevent infinite loops by limiting iterations."""
|
||||
if context.iterations > 10:
|
||||
return False # Block execution
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def sanitize_response(context):
|
||||
"""Remove sensitive data from LLM responses."""
|
||||
if "API_KEY" in context.response:
|
||||
return context.response.replace("API_KEY", "[REDACTED]")
|
||||
return None
|
||||
|
||||
@before_tool_call
|
||||
def block_dangerous_tools(context):
|
||||
"""Block destructive operations."""
|
||||
if context.tool_name == "delete_database":
|
||||
return False # Block execution
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def log_tool_result(context):
|
||||
"""Log tool execution."""
|
||||
print(f"Tool {context.tool_name} completed")
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. Crew-Scoped Hooks
|
||||
|
||||
Apply hooks only to specific crew instances:
|
||||
|
||||
```python
|
||||
from crewai import CrewBase
|
||||
from crewai.project import crew
|
||||
from crewai.hooks import before_llm_call_crew, after_tool_call_crew
|
||||
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_llm_call_crew
|
||||
def validate_inputs(self, context):
|
||||
# Only applies to this crew
|
||||
print(f"LLM call in {self.__class__.__name__}")
|
||||
return None
|
||||
|
||||
@after_tool_call_crew
|
||||
def log_results(self, context):
|
||||
# Crew-specific logging
|
||||
print(f"Tool result: {context.tool_result[:50]}...")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential
|
||||
)
|
||||
```
|
||||
|
||||
## Hook Execution Flow
|
||||
|
||||
### LLM Call Flow
|
||||
|
||||
```
|
||||
Agent needs to call LLM
|
||||
↓
|
||||
[Before LLM Call Hooks Execute]
|
||||
├→ Hook 1: Validate iteration count
|
||||
├→ Hook 2: Add safety context
|
||||
└→ Hook 3: Log request
|
||||
↓
|
||||
If any hook returns False:
|
||||
├→ Block LLM call
|
||||
└→ Raise ValueError
|
||||
↓
|
||||
If all hooks return True/None:
|
||||
├→ LLM call proceeds
|
||||
└→ Response generated
|
||||
↓
|
||||
[After LLM Call Hooks Execute]
|
||||
├→ Hook 1: Sanitize response
|
||||
├→ Hook 2: Log response
|
||||
└→ Hook 3: Update metrics
|
||||
↓
|
||||
Final response returned
|
||||
```
|
||||
|
||||
### Tool Call Flow
|
||||
|
||||
```
|
||||
Agent needs to execute tool
|
||||
↓
|
||||
[Before Tool Call Hooks Execute]
|
||||
├→ Hook 1: Check if tool is allowed
|
||||
├→ Hook 2: Validate inputs
|
||||
└→ Hook 3: Request approval if needed
|
||||
↓
|
||||
If any hook returns False:
|
||||
├→ Block tool execution
|
||||
└→ Return error message
|
||||
↓
|
||||
If all hooks return True/None:
|
||||
├→ Tool execution proceeds
|
||||
└→ Result generated
|
||||
↓
|
||||
[After Tool Call Hooks Execute]
|
||||
├→ Hook 1: Sanitize result
|
||||
├→ Hook 2: Cache result
|
||||
└→ Hook 3: Log metrics
|
||||
↓
|
||||
Final result returned
|
||||
```
|
||||
|
||||
## Hook Context Objects
|
||||
|
||||
### LLMCallHookContext
|
||||
|
||||
Provides access to LLM execution state:
|
||||
|
||||
```python
|
||||
class LLMCallHookContext:
|
||||
executor: CrewAgentExecutor # Full executor access
|
||||
messages: list # Mutable message list
|
||||
agent: Agent # Current agent
|
||||
task: Task # Current task
|
||||
crew: Crew # Crew instance
|
||||
llm: BaseLLM # LLM instance
|
||||
iterations: int # Current iteration
|
||||
response: str | None # LLM response (after hooks)
|
||||
```
|
||||
|
||||
### ToolCallHookContext
|
||||
|
||||
Provides access to tool execution state:
|
||||
|
||||
```python
|
||||
class ToolCallHookContext:
|
||||
tool_name: str # Tool being called
|
||||
tool_input: dict # Mutable input parameters
|
||||
tool: CrewStructuredTool # Tool instance
|
||||
agent: Agent | None # Agent executing
|
||||
task: Task | None # Current task
|
||||
crew: Crew | None # Crew instance
|
||||
tool_result: str | None # Tool result (after hooks)
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Safety and Validation
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safety_check(context):
|
||||
"""Block destructive operations."""
|
||||
dangerous = ['delete_file', 'drop_table', 'system_shutdown']
|
||||
if context.tool_name in dangerous:
|
||||
print(f"🛑 Blocked: {context.tool_name}")
|
||||
return False
|
||||
return None
|
||||
|
||||
@before_llm_call
|
||||
def iteration_limit(context):
|
||||
"""Prevent infinite loops."""
|
||||
if context.iterations > 15:
|
||||
print("⛔ Maximum iterations exceeded")
|
||||
return False
|
||||
return None
|
||||
```
|
||||
|
||||
### Human-in-the-Loop
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def require_approval(context):
|
||||
"""Require approval for sensitive operations."""
|
||||
sensitive = ['send_email', 'make_payment', 'post_message']
|
||||
|
||||
if context.tool_name in sensitive:
|
||||
response = context.request_human_input(
|
||||
prompt=f"Approve {context.tool_name}?",
|
||||
default_message="Type 'yes' to approve:"
|
||||
)
|
||||
|
||||
if response.lower() != 'yes':
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Monitoring and Analytics
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
import time
|
||||
|
||||
metrics = defaultdict(lambda: {'count': 0, 'total_time': 0})
|
||||
|
||||
@before_tool_call
|
||||
def start_timer(context):
|
||||
context.tool_input['_start'] = time.time()
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def track_metrics(context):
|
||||
start = context.tool_input.get('_start', time.time())
|
||||
duration = time.time() - start
|
||||
|
||||
metrics[context.tool_name]['count'] += 1
|
||||
metrics[context.tool_name]['total_time'] += duration
|
||||
|
||||
return None
|
||||
|
||||
# View metrics
|
||||
def print_metrics():
|
||||
for tool, data in metrics.items():
|
||||
avg = data['total_time'] / data['count']
|
||||
print(f"{tool}: {data['count']} calls, {avg:.2f}s avg")
|
||||
```
|
||||
|
||||
### Response Sanitization
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
@after_llm_call
|
||||
def sanitize_llm_response(context):
|
||||
"""Remove sensitive data from LLM responses."""
|
||||
if not context.response:
|
||||
return None
|
||||
|
||||
result = context.response
|
||||
result = re.sub(r'(api[_-]?key)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||
r'\1: [REDACTED]', result, flags=re.IGNORECASE)
|
||||
return result
|
||||
|
||||
@after_tool_call
|
||||
def sanitize_tool_result(context):
|
||||
"""Remove sensitive data from tool results."""
|
||||
if not context.tool_result:
|
||||
return None
|
||||
|
||||
result = context.tool_result
|
||||
result = re.sub(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
||||
'[EMAIL-REDACTED]', result)
|
||||
return result
|
||||
```
|
||||
|
||||
## Hook Management
|
||||
|
||||
### Clearing All Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import clear_all_global_hooks
|
||||
|
||||
# Clear all hooks at once
|
||||
result = clear_all_global_hooks()
|
||||
print(f"Cleared {result['total']} hooks")
|
||||
# Output: {'llm_hooks': (2, 1), 'tool_hooks': (1, 2), 'total': (3, 3)}
|
||||
```
|
||||
|
||||
### Clearing Specific Hook Types
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_llm_call_hooks,
|
||||
clear_after_llm_call_hooks,
|
||||
clear_before_tool_call_hooks,
|
||||
clear_after_tool_call_hooks
|
||||
)
|
||||
|
||||
# Clear specific types
|
||||
llm_before_count = clear_before_llm_call_hooks()
|
||||
tool_after_count = clear_after_tool_call_hooks()
|
||||
```
|
||||
|
||||
### Unregistering Individual Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_llm_call_hook,
|
||||
unregister_after_tool_call_hook
|
||||
)
|
||||
|
||||
def my_hook(context):
|
||||
...
|
||||
|
||||
# Register
|
||||
register_before_llm_call_hook(my_hook)
|
||||
|
||||
# Later, unregister
|
||||
success = unregister_before_llm_call_hook(my_hook)
|
||||
print(f"Unregistered: {success}")
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Hooks Focused
|
||||
Each hook should have a single, clear responsibility:
|
||||
|
||||
```python
|
||||
# ✅ Good - focused responsibility
|
||||
@before_tool_call
|
||||
def validate_file_path(context):
|
||||
if context.tool_name == 'read_file':
|
||||
if '..' in context.tool_input.get('path', ''):
|
||||
return False
|
||||
return None
|
||||
|
||||
# ❌ Bad - too many responsibilities
|
||||
@before_tool_call
|
||||
def do_everything(context):
|
||||
# Validation + logging + metrics + approval...
|
||||
...
|
||||
```
|
||||
|
||||
### 2. Handle Errors Gracefully
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def safe_hook(context):
|
||||
try:
|
||||
# Your logic
|
||||
if some_condition:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Hook error: {e}")
|
||||
return None # Allow execution despite error
|
||||
```
|
||||
|
||||
### 3. Modify Context In-Place
|
||||
|
||||
```python
|
||||
# ✅ Correct - modify in-place
|
||||
@before_llm_call
|
||||
def add_context(context):
|
||||
context.messages.append({"role": "system", "content": "Be concise"})
|
||||
|
||||
# ❌ Wrong - replaces reference
|
||||
@before_llm_call
|
||||
def wrong_approach(context):
|
||||
context.messages = [{"role": "system", "content": "Be concise"}]
|
||||
```
|
||||
|
||||
### 4. Use Type Hints
|
||||
|
||||
```python
|
||||
from crewai.hooks import LLMCallHookContext, ToolCallHookContext
|
||||
|
||||
def my_llm_hook(context: LLMCallHookContext) -> bool | None:
|
||||
# IDE autocomplete and type checking
|
||||
return None
|
||||
|
||||
def my_tool_hook(context: ToolCallHookContext) -> str | None:
|
||||
return None
|
||||
```
|
||||
|
||||
### 5. Clean Up in Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from crewai.hooks import clear_all_global_hooks
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_hooks():
|
||||
"""Reset hooks before each test."""
|
||||
yield
|
||||
clear_all_global_hooks()
|
||||
```
|
||||
|
||||
## When to Use Which Hook
|
||||
|
||||
### Use LLM Hooks When:
|
||||
- Implementing iteration limits
|
||||
- Adding context or safety guidelines to prompts
|
||||
- Tracking token usage and costs
|
||||
- Sanitizing or transforming responses
|
||||
- Implementing approval gates for LLM calls
|
||||
- Debugging prompt/response interactions
|
||||
|
||||
### Use Tool Hooks When:
|
||||
- Blocking dangerous or destructive operations
|
||||
- Validating tool inputs before execution
|
||||
- Implementing approval gates for sensitive actions
|
||||
- Caching tool results
|
||||
- Tracking tool usage and performance
|
||||
- Sanitizing tool outputs
|
||||
- Rate limiting tool calls
|
||||
|
||||
### Use Both When:
|
||||
Building comprehensive observability, safety, or approval systems that need to monitor all agent operations.
|
||||
|
||||
## Alternative Registration Methods
|
||||
|
||||
### Programmatic Registration (Advanced)
|
||||
|
||||
For dynamic hook registration or when you need to register hooks programmatically:
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
register_before_llm_call_hook,
|
||||
register_after_tool_call_hook
|
||||
)
|
||||
|
||||
def my_hook(context):
|
||||
return None
|
||||
|
||||
# Register programmatically
|
||||
register_before_llm_call_hook(my_hook)
|
||||
|
||||
# Useful for:
|
||||
# - Loading hooks from configuration
|
||||
# - Conditional hook registration
|
||||
# - Plugin systems
|
||||
```
|
||||
|
||||
**Note:** For most use cases, decorators are cleaner and more maintainable.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Keep Hooks Fast**: Hooks execute on every call - avoid heavy computation
|
||||
2. **Cache When Possible**: Store expensive validations or lookups
|
||||
3. **Be Selective**: Use crew-scoped hooks when global hooks aren't needed
|
||||
4. **Monitor Hook Overhead**: Profile hook execution time in production
|
||||
5. **Lazy Import**: Import heavy dependencies only when needed
|
||||
|
||||
## Debugging Hooks
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@before_llm_call
|
||||
def debug_hook(context):
|
||||
logger.debug(f"LLM call: {context.agent.role}, iteration {context.iterations}")
|
||||
return None
|
||||
```
|
||||
|
||||
### Hook Execution Order
|
||||
|
||||
Hooks execute in registration order. If a before hook returns `False`, subsequent hooks don't execute:
|
||||
|
||||
```python
|
||||
# Register order matters!
|
||||
register_before_tool_call_hook(hook1) # Executes first
|
||||
register_before_tool_call_hook(hook2) # Executes second
|
||||
register_before_tool_call_hook(hook3) # Executes third
|
||||
|
||||
# If hook2 returns False:
|
||||
# - hook1 executed
|
||||
# - hook2 executed and returned False
|
||||
# - hook3 NOT executed
|
||||
# - Tool call blocked
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [LLM Call Hooks →](/learn/llm-hooks) - Detailed LLM hook documentation
|
||||
- [Tool Call Hooks →](/learn/tool-hooks) - Detailed tool hook documentation
|
||||
- [Before and After Kickoff Hooks →](/learn/before-and-after-kickoff-hooks) - Crew lifecycle hooks
|
||||
- [Human-in-the-Loop →](/learn/human-in-the-loop) - Human input patterns
|
||||
|
||||
## Conclusion
|
||||
|
||||
Execution hooks provide powerful control over agent runtime behavior. Use them to implement safety guardrails, approval workflows, comprehensive monitoring, and custom business logic. Combined with proper error handling, type safety, and performance considerations, hooks enable production-ready, secure, and observable agent systems.
|
||||
@@ -97,7 +97,7 @@ project_crew = Crew(
|
||||
```
|
||||
|
||||
<Tip>
|
||||
For more details on creating and customizing a manager agent, check out the [Custom Manager Agent documentation](https://docs.crewai.com/how-to/custom-manager-agent#custom-manager-agent).
|
||||
For more details on creating and customizing a manager agent, check out the [Custom Manager Agent documentation](/en/learn/custom-manager-agent).
|
||||
</Tip>
|
||||
|
||||
|
||||
|
||||
427
docs/en/learn/llm-hooks.mdx
Normal file
427
docs/en/learn/llm-hooks.mdx
Normal file
@@ -0,0 +1,427 @@
|
||||
---
|
||||
title: LLM Call Hooks
|
||||
description: Learn how to use LLM call hooks to intercept, modify, and control language model interactions in CrewAI
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
LLM Call Hooks provide fine-grained control over language model interactions during agent execution. These hooks allow you to intercept LLM calls, modify prompts, transform responses, implement approval gates, and add custom logging or monitoring.
|
||||
|
||||
## Overview
|
||||
|
||||
LLM hooks are executed at two critical points:
|
||||
- **Before LLM Call**: Modify messages, validate inputs, or block execution
|
||||
- **After LLM Call**: Transform responses, sanitize outputs, or modify conversation history
|
||||
|
||||
## Hook Types
|
||||
|
||||
### Before LLM Call Hooks
|
||||
|
||||
Executed before every LLM call, these hooks can:
|
||||
- Inspect and modify messages sent to the LLM
|
||||
- Block LLM execution based on conditions
|
||||
- Implement rate limiting or approval gates
|
||||
- Add context or system messages
|
||||
- Log request details
|
||||
|
||||
**Signature:**
|
||||
```python
|
||||
def before_hook(context: LLMCallHookContext) -> bool | None:
|
||||
# Return False to block execution
|
||||
# Return True or None to allow execution
|
||||
...
|
||||
```
|
||||
|
||||
### After LLM Call Hooks
|
||||
|
||||
Executed after every LLM call, these hooks can:
|
||||
- Modify or sanitize LLM responses
|
||||
- Add metadata or formatting
|
||||
- Log response details
|
||||
- Update conversation history
|
||||
- Implement content filtering
|
||||
|
||||
**Signature:**
|
||||
```python
|
||||
def after_hook(context: LLMCallHookContext) -> str | None:
|
||||
# Return modified response string
|
||||
# Return None to keep original response
|
||||
...
|
||||
```
|
||||
|
||||
## LLM Hook Context
|
||||
|
||||
The `LLMCallHookContext` object provides comprehensive access to execution state:
|
||||
|
||||
```python
|
||||
class LLMCallHookContext:
|
||||
executor: CrewAgentExecutor # Full executor reference
|
||||
messages: list # Mutable message list
|
||||
agent: Agent # Current agent
|
||||
task: Task # Current task
|
||||
crew: Crew # Crew instance
|
||||
llm: BaseLLM # LLM instance
|
||||
iterations: int # Current iteration count
|
||||
response: str | None # LLM response (after hooks only)
|
||||
```
|
||||
|
||||
### Modifying Messages
|
||||
|
||||
**Important:** Always modify messages in-place:
|
||||
|
||||
```python
|
||||
# ✅ Correct - modify in-place
|
||||
def add_context(context: LLMCallHookContext) -> None:
|
||||
context.messages.append({"role": "system", "content": "Be concise"})
|
||||
|
||||
# ❌ Wrong - replaces list reference
|
||||
def wrong_approach(context: LLMCallHookContext) -> None:
|
||||
context.messages = [{"role": "system", "content": "Be concise"}]
|
||||
```
|
||||
|
||||
## Registration Methods
|
||||
|
||||
### 1. Global Hook Registration
|
||||
|
||||
Register hooks that apply to all LLM calls across all crews:
|
||||
|
||||
```python
|
||||
from crewai.hooks import register_before_llm_call_hook, register_after_llm_call_hook
|
||||
|
||||
def log_llm_call(context):
|
||||
print(f"LLM call by {context.agent.role} at iteration {context.iterations}")
|
||||
return None # Allow execution
|
||||
|
||||
register_before_llm_call_hook(log_llm_call)
|
||||
```
|
||||
|
||||
### 2. Decorator-Based Registration
|
||||
|
||||
Use decorators for cleaner syntax:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_llm_call, after_llm_call
|
||||
|
||||
@before_llm_call
|
||||
def validate_iteration_count(context):
|
||||
if context.iterations > 10:
|
||||
print("⚠️ Exceeded maximum iterations")
|
||||
return False # Block execution
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def sanitize_response(context):
|
||||
if context.response and "API_KEY" in context.response:
|
||||
return context.response.replace("API_KEY", "[REDACTED]")
|
||||
return None
|
||||
```
|
||||
|
||||
### 3. Crew-Scoped Hooks
|
||||
|
||||
Register hooks for a specific crew instance:
|
||||
|
||||
```python
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_llm_call_crew
|
||||
def validate_inputs(self, context):
|
||||
# Only applies to this crew
|
||||
if context.iterations == 0:
|
||||
print(f"Starting task: {context.task.description}")
|
||||
return None
|
||||
|
||||
@after_llm_call_crew
|
||||
def log_responses(self, context):
|
||||
# Crew-specific response logging
|
||||
print(f"Response length: {len(context.response)}")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential,
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. Iteration Limiting
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def limit_iterations(context: LLMCallHookContext) -> bool | None:
|
||||
max_iterations = 15
|
||||
if context.iterations > max_iterations:
|
||||
print(f"⛔ Blocked: Exceeded {max_iterations} iterations")
|
||||
return False # Block execution
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. Human Approval Gate
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def require_approval(context: LLMCallHookContext) -> bool | None:
|
||||
if context.iterations > 5:
|
||||
response = context.request_human_input(
|
||||
prompt=f"Iteration {context.iterations}: Approve LLM call?",
|
||||
default_message="Press Enter to approve, or type 'no' to block:"
|
||||
)
|
||||
if response.lower() == "no":
|
||||
print("🚫 LLM call blocked by user")
|
||||
return False
|
||||
return None
|
||||
```
|
||||
|
||||
### 3. Adding System Context
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def add_guardrails(context: LLMCallHookContext) -> None:
|
||||
# Add safety guidelines to every LLM call
|
||||
context.messages.append({
|
||||
"role": "system",
|
||||
"content": "Ensure responses are factual and cite sources when possible."
|
||||
})
|
||||
return None
|
||||
```
|
||||
|
||||
### 4. Response Sanitization
|
||||
|
||||
```python
|
||||
@after_llm_call
|
||||
def sanitize_sensitive_data(context: LLMCallHookContext) -> str | None:
|
||||
if not context.response:
|
||||
return None
|
||||
|
||||
# Remove sensitive patterns
|
||||
import re
|
||||
sanitized = context.response
|
||||
sanitized = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN-REDACTED]', sanitized)
|
||||
sanitized = re.sub(r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b', '[CARD-REDACTED]', sanitized)
|
||||
|
||||
return sanitized
|
||||
```
|
||||
|
||||
### 5. Cost Tracking
|
||||
|
||||
```python
|
||||
import tiktoken
|
||||
|
||||
@before_llm_call
|
||||
def track_token_usage(context: LLMCallHookContext) -> None:
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
total_tokens = sum(
|
||||
len(encoding.encode(msg.get("content", "")))
|
||||
for msg in context.messages
|
||||
)
|
||||
print(f"📊 Input tokens: ~{total_tokens}")
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def track_response_tokens(context: LLMCallHookContext) -> None:
|
||||
if context.response:
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
tokens = len(encoding.encode(context.response))
|
||||
print(f"📊 Response tokens: ~{tokens}")
|
||||
return None
|
||||
```
|
||||
|
||||
### 6. Debug Logging
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def debug_request(context: LLMCallHookContext) -> None:
|
||||
print(f"""
|
||||
🔍 LLM Call Debug:
|
||||
- Agent: {context.agent.role}
|
||||
- Task: {context.task.description[:50]}...
|
||||
- Iteration: {context.iterations}
|
||||
- Message Count: {len(context.messages)}
|
||||
- Last Message: {context.messages[-1] if context.messages else 'None'}
|
||||
""")
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def debug_response(context: LLMCallHookContext) -> None:
|
||||
if context.response:
|
||||
print(f"✅ Response Preview: {context.response[:100]}...")
|
||||
return None
|
||||
```
|
||||
|
||||
## Hook Management
|
||||
|
||||
### Unregistering Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_llm_call_hook,
|
||||
unregister_after_llm_call_hook
|
||||
)
|
||||
|
||||
# Unregister specific hook
|
||||
def my_hook(context):
|
||||
...
|
||||
|
||||
register_before_llm_call_hook(my_hook)
|
||||
# Later...
|
||||
unregister_before_llm_call_hook(my_hook) # Returns True if found
|
||||
```
|
||||
|
||||
### Clearing Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_llm_call_hooks,
|
||||
clear_after_llm_call_hooks,
|
||||
clear_all_llm_call_hooks
|
||||
)
|
||||
|
||||
# Clear specific hook type
|
||||
count = clear_before_llm_call_hooks()
|
||||
print(f"Cleared {count} before hooks")
|
||||
|
||||
# Clear all LLM hooks
|
||||
before_count, after_count = clear_all_llm_call_hooks()
|
||||
print(f"Cleared {before_count} before and {after_count} after hooks")
|
||||
```
|
||||
|
||||
### Listing Registered Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
get_before_llm_call_hooks,
|
||||
get_after_llm_call_hooks
|
||||
)
|
||||
|
||||
# Get current hooks
|
||||
before_hooks = get_before_llm_call_hooks()
|
||||
after_hooks = get_after_llm_call_hooks()
|
||||
|
||||
print(f"Registered: {len(before_hooks)} before, {len(after_hooks)} after")
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Conditional Hook Execution
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def conditional_blocking(context: LLMCallHookContext) -> bool | None:
|
||||
# Only block for specific agents
|
||||
if context.agent.role == "researcher" and context.iterations > 10:
|
||||
return False
|
||||
|
||||
# Only block for specific tasks
|
||||
if "sensitive" in context.task.description.lower() and context.iterations > 5:
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Context-Aware Modifications
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def adaptive_prompting(context: LLMCallHookContext) -> None:
|
||||
# Add different context based on iteration
|
||||
if context.iterations == 0:
|
||||
context.messages.append({
|
||||
"role": "system",
|
||||
"content": "Start with a high-level overview."
|
||||
})
|
||||
elif context.iterations > 3:
|
||||
context.messages.append({
|
||||
"role": "system",
|
||||
"content": "Focus on specific details and provide examples."
|
||||
})
|
||||
return None
|
||||
```
|
||||
|
||||
### Chaining Hooks
|
||||
|
||||
```python
|
||||
# Multiple hooks execute in registration order
|
||||
|
||||
@before_llm_call
|
||||
def first_hook(context):
|
||||
print("1. First hook executed")
|
||||
return None
|
||||
|
||||
@before_llm_call
|
||||
def second_hook(context):
|
||||
print("2. Second hook executed")
|
||||
return None
|
||||
|
||||
@before_llm_call
|
||||
def blocking_hook(context):
|
||||
if context.iterations > 10:
|
||||
print("3. Blocking hook - execution stopped")
|
||||
return False # Subsequent hooks won't execute
|
||||
print("3. Blocking hook - execution allowed")
|
||||
return None
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep Hooks Focused**: Each hook should have a single responsibility
|
||||
2. **Avoid Heavy Computation**: Hooks execute on every LLM call
|
||||
3. **Handle Errors Gracefully**: Use try-except to prevent hook failures from breaking execution
|
||||
4. **Use Type Hints**: Leverage `LLMCallHookContext` for better IDE support
|
||||
5. **Document Hook Behavior**: Especially for blocking conditions
|
||||
6. **Test Hooks Independently**: Unit test hooks before using in production
|
||||
7. **Clear Hooks in Tests**: Use `clear_all_llm_call_hooks()` between test runs
|
||||
8. **Modify In-Place**: Always modify `context.messages` in-place, never replace
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def safe_hook(context: LLMCallHookContext) -> bool | None:
|
||||
try:
|
||||
# Your hook logic
|
||||
if some_condition:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"⚠️ Hook error: {e}")
|
||||
# Decide: allow or block on error
|
||||
return None # Allow execution despite error
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
|
||||
```python
|
||||
from crewai.hooks import LLMCallHookContext, BeforeLLMCallHookType, AfterLLMCallHookType
|
||||
|
||||
# Explicit type annotations
|
||||
def my_before_hook(context: LLMCallHookContext) -> bool | None:
|
||||
return None
|
||||
|
||||
def my_after_hook(context: LLMCallHookContext) -> str | None:
|
||||
return None
|
||||
|
||||
# Type-safe registration
|
||||
register_before_llm_call_hook(my_before_hook)
|
||||
register_after_llm_call_hook(my_after_hook)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook Not Executing
|
||||
- Verify hook is registered before crew execution
|
||||
- Check if previous hook returned `False` (blocks subsequent hooks)
|
||||
- Ensure hook signature matches expected type
|
||||
|
||||
### Message Modifications Not Persisting
|
||||
- Use in-place modifications: `context.messages.append()`
|
||||
- Don't replace the list: `context.messages = []`
|
||||
|
||||
### Response Modifications Not Working
|
||||
- Return the modified string from after hooks
|
||||
- Returning `None` keeps the original response
|
||||
|
||||
## Conclusion
|
||||
|
||||
LLM Call Hooks provide powerful capabilities for controlling and monitoring language model interactions in CrewAI. Use them to implement safety guardrails, approval gates, logging, cost tracking, and response sanitization. Combined with proper error handling and type safety, hooks enable robust and production-ready agent systems.
|
||||
600
docs/en/learn/tool-hooks.mdx
Normal file
600
docs/en/learn/tool-hooks.mdx
Normal file
@@ -0,0 +1,600 @@
|
||||
---
|
||||
title: Tool Call Hooks
|
||||
description: Learn how to use tool call hooks to intercept, modify, and control tool execution in CrewAI
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
Tool Call Hooks provide fine-grained control over tool execution during agent operations. These hooks allow you to intercept tool calls, modify inputs, transform outputs, implement safety checks, and add comprehensive logging or monitoring.
|
||||
|
||||
## Overview
|
||||
|
||||
Tool hooks are executed at two critical points:
|
||||
- **Before Tool Call**: Modify inputs, validate parameters, or block execution
|
||||
- **After Tool Call**: Transform results, sanitize outputs, or log execution details
|
||||
|
||||
## Hook Types
|
||||
|
||||
### Before Tool Call Hooks
|
||||
|
||||
Executed before every tool execution, these hooks can:
|
||||
- Inspect and modify tool inputs
|
||||
- Block tool execution based on conditions
|
||||
- Implement approval gates for dangerous operations
|
||||
- Validate parameters
|
||||
- Log tool invocations
|
||||
|
||||
**Signature:**
|
||||
```python
|
||||
def before_hook(context: ToolCallHookContext) -> bool | None:
|
||||
# Return False to block execution
|
||||
# Return True or None to allow execution
|
||||
...
|
||||
```
|
||||
|
||||
### After Tool Call Hooks
|
||||
|
||||
Executed after every tool execution, these hooks can:
|
||||
- Modify or sanitize tool results
|
||||
- Add metadata or formatting
|
||||
- Log execution results
|
||||
- Implement result validation
|
||||
- Transform output formats
|
||||
|
||||
**Signature:**
|
||||
```python
|
||||
def after_hook(context: ToolCallHookContext) -> str | None:
|
||||
# Return modified result string
|
||||
# Return None to keep original result
|
||||
...
|
||||
```
|
||||
|
||||
## Tool Hook Context
|
||||
|
||||
The `ToolCallHookContext` object provides comprehensive access to tool execution state:
|
||||
|
||||
```python
|
||||
class ToolCallHookContext:
|
||||
tool_name: str # Name of the tool being called
|
||||
tool_input: dict[str, Any] # Mutable tool input parameters
|
||||
tool: CrewStructuredTool # Tool instance reference
|
||||
agent: Agent | BaseAgent | None # Agent executing the tool
|
||||
task: Task | None # Current task
|
||||
crew: Crew | None # Crew instance
|
||||
tool_result: str | None # Tool result (after hooks only)
|
||||
```
|
||||
|
||||
### Modifying Tool Inputs
|
||||
|
||||
**Important:** Always modify tool inputs in-place:
|
||||
|
||||
```python
|
||||
# ✅ Correct - modify in-place
|
||||
def sanitize_input(context: ToolCallHookContext) -> None:
|
||||
context.tool_input['query'] = context.tool_input['query'].lower()
|
||||
|
||||
# ❌ Wrong - replaces dict reference
|
||||
def wrong_approach(context: ToolCallHookContext) -> None:
|
||||
context.tool_input = {'query': 'new query'}
|
||||
```
|
||||
|
||||
## Registration Methods
|
||||
|
||||
### 1. Global Hook Registration
|
||||
|
||||
Register hooks that apply to all tool calls across all crews:
|
||||
|
||||
```python
|
||||
from crewai.hooks import register_before_tool_call_hook, register_after_tool_call_hook
|
||||
|
||||
def log_tool_call(context):
|
||||
print(f"Tool: {context.tool_name}")
|
||||
print(f"Input: {context.tool_input}")
|
||||
return None # Allow execution
|
||||
|
||||
register_before_tool_call_hook(log_tool_call)
|
||||
```
|
||||
|
||||
### 2. Decorator-Based Registration
|
||||
|
||||
Use decorators for cleaner syntax:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_tool_call, after_tool_call
|
||||
|
||||
@before_tool_call
|
||||
def block_dangerous_tools(context):
|
||||
dangerous_tools = ['delete_database', 'drop_table', 'rm_rf']
|
||||
if context.tool_name in dangerous_tools:
|
||||
print(f"⛔ Blocked dangerous tool: {context.tool_name}")
|
||||
return False # Block execution
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def sanitize_results(context):
|
||||
if context.tool_result and "password" in context.tool_result.lower():
|
||||
return context.tool_result.replace("password", "[REDACTED]")
|
||||
return None
|
||||
```
|
||||
|
||||
### 3. Crew-Scoped Hooks
|
||||
|
||||
Register hooks for a specific crew instance:
|
||||
|
||||
```python
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_tool_call_crew
|
||||
def validate_tool_inputs(self, context):
|
||||
# Only applies to this crew
|
||||
if context.tool_name == "web_search":
|
||||
if not context.tool_input.get('query'):
|
||||
print("❌ Invalid search query")
|
||||
return False
|
||||
return None
|
||||
|
||||
@after_tool_call_crew
|
||||
def log_tool_results(self, context):
|
||||
# Crew-specific tool logging
|
||||
print(f"✅ {context.tool_name} completed")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential,
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. Safety Guardrails
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safety_check(context: ToolCallHookContext) -> bool | None:
|
||||
# Block tools that could cause harm
|
||||
destructive_tools = [
|
||||
'delete_file',
|
||||
'drop_table',
|
||||
'remove_user',
|
||||
'system_shutdown'
|
||||
]
|
||||
|
||||
if context.tool_name in destructive_tools:
|
||||
print(f"🛑 Blocked destructive tool: {context.tool_name}")
|
||||
return False
|
||||
|
||||
# Warn on sensitive operations
|
||||
sensitive_tools = ['send_email', 'post_to_social_media', 'charge_payment']
|
||||
if context.tool_name in sensitive_tools:
|
||||
print(f"⚠️ Executing sensitive tool: {context.tool_name}")
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. Human Approval Gate
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def require_approval_for_actions(context: ToolCallHookContext) -> bool | None:
|
||||
approval_required = [
|
||||
'send_email',
|
||||
'make_purchase',
|
||||
'delete_file',
|
||||
'post_message'
|
||||
]
|
||||
|
||||
if context.tool_name in approval_required:
|
||||
response = context.request_human_input(
|
||||
prompt=f"Approve {context.tool_name}?",
|
||||
default_message=f"Input: {context.tool_input}\nType 'yes' to approve:"
|
||||
)
|
||||
|
||||
if response.lower() != 'yes':
|
||||
print(f"❌ Tool execution denied: {context.tool_name}")
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 3. Input Validation and Sanitization
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def validate_and_sanitize_inputs(context: ToolCallHookContext) -> bool | None:
|
||||
# Validate search queries
|
||||
if context.tool_name == 'web_search':
|
||||
query = context.tool_input.get('query', '')
|
||||
if len(query) < 3:
|
||||
print("❌ Search query too short")
|
||||
return False
|
||||
|
||||
# Sanitize query
|
||||
context.tool_input['query'] = query.strip().lower()
|
||||
|
||||
# Validate file paths
|
||||
if context.tool_name == 'read_file':
|
||||
path = context.tool_input.get('path', '')
|
||||
if '..' in path or path.startswith('/'):
|
||||
print("❌ Invalid file path")
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 4. Result Sanitization
|
||||
|
||||
```python
|
||||
@after_tool_call
|
||||
def sanitize_sensitive_data(context: ToolCallHookContext) -> str | None:
|
||||
if not context.tool_result:
|
||||
return None
|
||||
|
||||
import re
|
||||
result = context.tool_result
|
||||
|
||||
# Remove API keys
|
||||
result = re.sub(
|
||||
r'(api[_-]?key|token)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||
r'\1: [REDACTED]',
|
||||
result,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
# Remove email addresses
|
||||
result = re.sub(
|
||||
r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
||||
'[EMAIL-REDACTED]',
|
||||
result
|
||||
)
|
||||
|
||||
# Remove credit card numbers
|
||||
result = re.sub(
|
||||
r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b',
|
||||
'[CARD-REDACTED]',
|
||||
result
|
||||
)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### 5. Tool Usage Analytics
|
||||
|
||||
```python
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
tool_stats = defaultdict(lambda: {'count': 0, 'total_time': 0, 'failures': 0})
|
||||
|
||||
@before_tool_call
|
||||
def start_timer(context: ToolCallHookContext) -> None:
|
||||
context.tool_input['_start_time'] = time.time()
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def track_tool_usage(context: ToolCallHookContext) -> None:
|
||||
start_time = context.tool_input.get('_start_time', time.time())
|
||||
duration = time.time() - start_time
|
||||
|
||||
tool_stats[context.tool_name]['count'] += 1
|
||||
tool_stats[context.tool_name]['total_time'] += duration
|
||||
|
||||
if not context.tool_result or 'error' in context.tool_result.lower():
|
||||
tool_stats[context.tool_name]['failures'] += 1
|
||||
|
||||
print(f"""
|
||||
📊 Tool Stats for {context.tool_name}:
|
||||
- Executions: {tool_stats[context.tool_name]['count']}
|
||||
- Avg Time: {tool_stats[context.tool_name]['total_time'] / tool_stats[context.tool_name]['count']:.2f}s
|
||||
- Failures: {tool_stats[context.tool_name]['failures']}
|
||||
""")
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 6. Rate Limiting
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
tool_call_history = defaultdict(list)
|
||||
|
||||
@before_tool_call
|
||||
def rate_limit_tools(context: ToolCallHookContext) -> bool | None:
|
||||
tool_name = context.tool_name
|
||||
now = datetime.now()
|
||||
|
||||
# Clean old entries (older than 1 minute)
|
||||
tool_call_history[tool_name] = [
|
||||
call_time for call_time in tool_call_history[tool_name]
|
||||
if now - call_time < timedelta(minutes=1)
|
||||
]
|
||||
|
||||
# Check rate limit (max 10 calls per minute)
|
||||
if len(tool_call_history[tool_name]) >= 10:
|
||||
print(f"🚫 Rate limit exceeded for {tool_name}")
|
||||
return False
|
||||
|
||||
# Record this call
|
||||
tool_call_history[tool_name].append(now)
|
||||
return None
|
||||
```
|
||||
|
||||
### 7. Caching Tool Results
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
tool_cache = {}
|
||||
|
||||
def cache_key(tool_name: str, tool_input: dict) -> str:
|
||||
"""Generate cache key from tool name and input."""
|
||||
input_str = json.dumps(tool_input, sort_keys=True)
|
||||
return hashlib.md5(f"{tool_name}:{input_str}".encode()).hexdigest()
|
||||
|
||||
@before_tool_call
|
||||
def check_cache(context: ToolCallHookContext) -> bool | None:
|
||||
key = cache_key(context.tool_name, context.tool_input)
|
||||
if key in tool_cache:
|
||||
print(f"💾 Cache hit for {context.tool_name}")
|
||||
# Note: Can't return cached result from before hook
|
||||
# Would need to implement this differently
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def cache_result(context: ToolCallHookContext) -> None:
|
||||
if context.tool_result:
|
||||
key = cache_key(context.tool_name, context.tool_input)
|
||||
tool_cache[key] = context.tool_result
|
||||
print(f"💾 Cached result for {context.tool_name}")
|
||||
return None
|
||||
```
|
||||
|
||||
### 8. Debug Logging
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def debug_tool_call(context: ToolCallHookContext) -> None:
|
||||
print(f"""
|
||||
🔍 Tool Call Debug:
|
||||
- Tool: {context.tool_name}
|
||||
- Agent: {context.agent.role if context.agent else 'Unknown'}
|
||||
- Task: {context.task.description[:50] if context.task else 'Unknown'}...
|
||||
- Input: {context.tool_input}
|
||||
""")
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def debug_tool_result(context: ToolCallHookContext) -> None:
|
||||
if context.tool_result:
|
||||
result_preview = context.tool_result[:200]
|
||||
print(f"✅ Result Preview: {result_preview}...")
|
||||
else:
|
||||
print("⚠️ No result returned")
|
||||
return None
|
||||
```
|
||||
|
||||
## Hook Management
|
||||
|
||||
### Unregistering Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_tool_call_hook,
|
||||
unregister_after_tool_call_hook
|
||||
)
|
||||
|
||||
# Unregister specific hook
|
||||
def my_hook(context):
|
||||
...
|
||||
|
||||
register_before_tool_call_hook(my_hook)
|
||||
# Later...
|
||||
success = unregister_before_tool_call_hook(my_hook)
|
||||
print(f"Unregistered: {success}")
|
||||
```
|
||||
|
||||
### Clearing Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_tool_call_hooks,
|
||||
clear_after_tool_call_hooks,
|
||||
clear_all_tool_call_hooks
|
||||
)
|
||||
|
||||
# Clear specific hook type
|
||||
count = clear_before_tool_call_hooks()
|
||||
print(f"Cleared {count} before hooks")
|
||||
|
||||
# Clear all tool hooks
|
||||
before_count, after_count = clear_all_tool_call_hooks()
|
||||
print(f"Cleared {before_count} before and {after_count} after hooks")
|
||||
```
|
||||
|
||||
### Listing Registered Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
get_before_tool_call_hooks,
|
||||
get_after_tool_call_hooks
|
||||
)
|
||||
|
||||
# Get current hooks
|
||||
before_hooks = get_before_tool_call_hooks()
|
||||
after_hooks = get_after_tool_call_hooks()
|
||||
|
||||
print(f"Registered: {len(before_hooks)} before, {len(after_hooks)} after")
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Conditional Hook Execution
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def conditional_blocking(context: ToolCallHookContext) -> bool | None:
|
||||
# Only block for specific agents
|
||||
if context.agent and context.agent.role == "junior_agent":
|
||||
if context.tool_name in ['delete_file', 'send_email']:
|
||||
print(f"❌ Junior agents cannot use {context.tool_name}")
|
||||
return False
|
||||
|
||||
# Only block during specific tasks
|
||||
if context.task and "sensitive" in context.task.description.lower():
|
||||
if context.tool_name == 'web_search':
|
||||
print("❌ Web search blocked for sensitive tasks")
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Context-Aware Input Modification
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def enhance_tool_inputs(context: ToolCallHookContext) -> None:
|
||||
# Add context based on agent role
|
||||
if context.agent and context.agent.role == "researcher":
|
||||
if context.tool_name == 'web_search':
|
||||
# Add domain restrictions for researchers
|
||||
context.tool_input['domains'] = ['edu', 'gov', 'org']
|
||||
|
||||
# Add context based on task
|
||||
if context.task and "urgent" in context.task.description.lower():
|
||||
if context.tool_name == 'send_email':
|
||||
context.tool_input['priority'] = 'high'
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Tool Chain Monitoring
|
||||
|
||||
```python
|
||||
tool_call_chain = []
|
||||
|
||||
@before_tool_call
|
||||
def track_tool_chain(context: ToolCallHookContext) -> None:
|
||||
tool_call_chain.append({
|
||||
'tool': context.tool_name,
|
||||
'timestamp': time.time(),
|
||||
'agent': context.agent.role if context.agent else 'Unknown'
|
||||
})
|
||||
|
||||
# Detect potential infinite loops
|
||||
recent_calls = tool_call_chain[-5:]
|
||||
if len(recent_calls) == 5 and all(c['tool'] == context.tool_name for c in recent_calls):
|
||||
print(f"⚠️ Warning: {context.tool_name} called 5 times in a row")
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep Hooks Focused**: Each hook should have a single responsibility
|
||||
2. **Avoid Heavy Computation**: Hooks execute on every tool call
|
||||
3. **Handle Errors Gracefully**: Use try-except to prevent hook failures
|
||||
4. **Use Type Hints**: Leverage `ToolCallHookContext` for better IDE support
|
||||
5. **Document Blocking Conditions**: Make it clear when/why tools are blocked
|
||||
6. **Test Hooks Independently**: Unit test hooks before using in production
|
||||
7. **Clear Hooks in Tests**: Use `clear_all_tool_call_hooks()` between test runs
|
||||
8. **Modify In-Place**: Always modify `context.tool_input` in-place, never replace
|
||||
9. **Log Important Decisions**: Especially when blocking tool execution
|
||||
10. **Consider Performance**: Cache expensive validations when possible
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safe_validation(context: ToolCallHookContext) -> bool | None:
|
||||
try:
|
||||
# Your validation logic
|
||||
if not validate_input(context.tool_input):
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"⚠️ Hook error: {e}")
|
||||
# Decide: allow or block on error
|
||||
return None # Allow execution despite error
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
|
||||
```python
|
||||
from crewai.hooks import ToolCallHookContext, BeforeToolCallHookType, AfterToolCallHookType
|
||||
|
||||
# Explicit type annotations
|
||||
def my_before_hook(context: ToolCallHookContext) -> bool | None:
|
||||
return None
|
||||
|
||||
def my_after_hook(context: ToolCallHookContext) -> str | None:
|
||||
return None
|
||||
|
||||
# Type-safe registration
|
||||
register_before_tool_call_hook(my_before_hook)
|
||||
register_after_tool_call_hook(my_after_hook)
|
||||
```
|
||||
|
||||
## Integration with Existing Tools
|
||||
|
||||
### Wrapping Existing Validation
|
||||
|
||||
```python
|
||||
def existing_validator(tool_name: str, inputs: dict) -> bool:
|
||||
"""Your existing validation function."""
|
||||
# Your validation logic
|
||||
return True
|
||||
|
||||
@before_tool_call
|
||||
def integrate_validator(context: ToolCallHookContext) -> bool | None:
|
||||
if not existing_validator(context.tool_name, context.tool_input):
|
||||
print(f"❌ Validation failed for {context.tool_name}")
|
||||
return False
|
||||
return None
|
||||
```
|
||||
|
||||
### Logging to External Systems
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@before_tool_call
|
||||
def log_to_external_system(context: ToolCallHookContext) -> None:
|
||||
logger.info(f"Tool call: {context.tool_name}", extra={
|
||||
'tool_name': context.tool_name,
|
||||
'tool_input': context.tool_input,
|
||||
'agent': context.agent.role if context.agent else None
|
||||
})
|
||||
return None
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook Not Executing
|
||||
- Verify hook is registered before crew execution
|
||||
- Check if previous hook returned `False` (blocks execution and subsequent hooks)
|
||||
- Ensure hook signature matches expected type
|
||||
|
||||
### Input Modifications Not Working
|
||||
- Use in-place modifications: `context.tool_input['key'] = value`
|
||||
- Don't replace the dict: `context.tool_input = {}`
|
||||
|
||||
### Result Modifications Not Working
|
||||
- Return the modified string from after hooks
|
||||
- Returning `None` keeps the original result
|
||||
- Ensure the tool actually returned a result
|
||||
|
||||
### Tool Blocked Unexpectedly
|
||||
- Check all before hooks for blocking conditions
|
||||
- Verify hook execution order
|
||||
- Add debug logging to identify which hook is blocking
|
||||
|
||||
## Conclusion
|
||||
|
||||
Tool Call Hooks provide powerful capabilities for controlling and monitoring tool execution in CrewAI. Use them to implement safety guardrails, approval gates, input validation, result sanitization, logging, and analytics. Combined with proper error handling and type safety, hooks enable secure and production-ready agent systems with comprehensive observability.
|
||||
@@ -11,9 +11,13 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP)
|
||||
|
||||
CrewAI offers **two approaches** for MCP integration:
|
||||
|
||||
### Simple DSL Integration** (Recommended)
|
||||
### 🚀 **Simple DSL Integration** (Recommended)
|
||||
|
||||
Use the `mcps` field directly on agents for seamless MCP tool integration:
|
||||
Use the `mcps` field directly on agents for seamless MCP tool integration. The DSL supports both **string references** (for quick setup) and **structured configurations** (for full control).
|
||||
|
||||
#### String-Based References (Quick Setup)
|
||||
|
||||
Perfect for remote HTTPS servers and CrewAI AMP marketplace:
|
||||
|
||||
```python
|
||||
from crewai import Agent
|
||||
@@ -32,6 +36,46 @@ agent = Agent(
|
||||
# MCP tools are now automatically available to your agent!
|
||||
```
|
||||
|
||||
#### Structured Configurations (Full Control)
|
||||
|
||||
For complete control over connection settings, tool filtering, and all transport types:
|
||||
|
||||
```python
|
||||
from crewai import Agent
|
||||
from crewai.mcp import MCPServerStdio, MCPServerHTTP, MCPServerSSE
|
||||
from crewai.mcp.filters import create_static_tool_filter
|
||||
|
||||
agent = Agent(
|
||||
role="Advanced Research Analyst",
|
||||
goal="Research with full control over MCP connections",
|
||||
backstory="Expert researcher with advanced tool access",
|
||||
mcps=[
|
||||
# Stdio transport for local servers
|
||||
MCPServerStdio(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
env={"API_KEY": "your_key"},
|
||||
tool_filter=create_static_tool_filter(
|
||||
allowed_tool_names=["read_file", "list_directory"]
|
||||
),
|
||||
cache_tools_list=True,
|
||||
),
|
||||
# HTTP/Streamable HTTP transport for remote servers
|
||||
MCPServerHTTP(
|
||||
url="https://api.example.com/mcp",
|
||||
headers={"Authorization": "Bearer your_token"},
|
||||
streamable=True,
|
||||
cache_tools_list=True,
|
||||
),
|
||||
# SSE transport for real-time streaming
|
||||
MCPServerSSE(
|
||||
url="https://stream.example.com/mcp/sse",
|
||||
headers={"Authorization": "Bearer your_token"},
|
||||
),
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### 🔧 **Advanced: MCPServerAdapter** (For Complex Scenarios)
|
||||
|
||||
For advanced use cases requiring manual connection management, the `crewai-tools` library provides the `MCPServerAdapter` class.
|
||||
@@ -68,12 +112,14 @@ uv pip install 'crewai-tools[mcp]'
|
||||
|
||||
## Quick Start: Simple DSL Integration
|
||||
|
||||
The easiest way to integrate MCP servers is using the `mcps` field on your agents:
|
||||
The easiest way to integrate MCP servers is using the `mcps` field on your agents. You can use either string references or structured configurations.
|
||||
|
||||
### Quick Start with String References
|
||||
|
||||
```python
|
||||
from crewai import Agent, Task, Crew
|
||||
|
||||
# Create agent with MCP tools
|
||||
# Create agent with MCP tools using string references
|
||||
research_agent = Agent(
|
||||
role="Research Analyst",
|
||||
goal="Find and analyze information using advanced search tools",
|
||||
@@ -96,13 +142,53 @@ crew = Crew(agents=[research_agent], tasks=[research_task])
|
||||
result = crew.kickoff()
|
||||
```
|
||||
|
||||
### Quick Start with Structured Configurations
|
||||
|
||||
```python
|
||||
from crewai import Agent, Task, Crew
|
||||
from crewai.mcp import MCPServerStdio, MCPServerHTTP, MCPServerSSE
|
||||
|
||||
# Create agent with structured MCP configurations
|
||||
research_agent = Agent(
|
||||
role="Research Analyst",
|
||||
goal="Find and analyze information using advanced search tools",
|
||||
backstory="Expert researcher with access to multiple data sources",
|
||||
mcps=[
|
||||
# Local stdio server
|
||||
MCPServerStdio(
|
||||
command="python",
|
||||
args=["local_server.py"],
|
||||
env={"API_KEY": "your_key"},
|
||||
),
|
||||
# Remote HTTP server
|
||||
MCPServerHTTP(
|
||||
url="https://api.research.com/mcp",
|
||||
headers={"Authorization": "Bearer your_token"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Create task
|
||||
research_task = Task(
|
||||
description="Research the latest developments in AI agent frameworks",
|
||||
expected_output="Comprehensive research report with citations",
|
||||
agent=research_agent
|
||||
)
|
||||
|
||||
# Create and run crew
|
||||
crew = Crew(agents=[research_agent], tasks=[research_task])
|
||||
result = crew.kickoff()
|
||||
```
|
||||
|
||||
That's it! The MCP tools are automatically discovered and available to your agent.
|
||||
|
||||
## MCP Reference Formats
|
||||
|
||||
The `mcps` field supports various reference formats for maximum flexibility:
|
||||
The `mcps` field supports both **string references** (for quick setup) and **structured configurations** (for full control). You can mix both formats in the same list.
|
||||
|
||||
### External MCP Servers
|
||||
### String-Based References
|
||||
|
||||
#### External MCP Servers
|
||||
|
||||
```python
|
||||
mcps=[
|
||||
@@ -117,7 +203,7 @@ mcps=[
|
||||
]
|
||||
```
|
||||
|
||||
### CrewAI AMP Marketplace
|
||||
#### CrewAI AMP Marketplace
|
||||
|
||||
```python
|
||||
mcps=[
|
||||
@@ -133,17 +219,166 @@ mcps=[
|
||||
]
|
||||
```
|
||||
|
||||
### Mixed References
|
||||
### Structured Configurations
|
||||
|
||||
#### Stdio Transport (Local Servers)
|
||||
|
||||
Perfect for local MCP servers that run as processes:
|
||||
|
||||
```python
|
||||
from crewai.mcp import MCPServerStdio
|
||||
from crewai.mcp.filters import create_static_tool_filter
|
||||
|
||||
mcps=[
|
||||
"https://external-api.com/mcp", # External server
|
||||
"https://weather.service.com/mcp#forecast", # Specific external tool
|
||||
"crewai-amp:financial-insights", # AMP service
|
||||
"crewai-amp:data-analysis#sentiment_tool" # Specific AMP tool
|
||||
MCPServerStdio(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
env={"API_KEY": "your_key"},
|
||||
tool_filter=create_static_tool_filter(
|
||||
allowed_tool_names=["read_file", "write_file"]
|
||||
),
|
||||
cache_tools_list=True,
|
||||
),
|
||||
# Python-based server
|
||||
MCPServerStdio(
|
||||
command="python",
|
||||
args=["path/to/server.py"],
|
||||
env={"UV_PYTHON": "3.12", "API_KEY": "your_key"},
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
#### HTTP/Streamable HTTP Transport (Remote Servers)
|
||||
|
||||
For remote MCP servers over HTTP/HTTPS:
|
||||
|
||||
```python
|
||||
from crewai.mcp import MCPServerHTTP
|
||||
|
||||
mcps=[
|
||||
# Streamable HTTP (default)
|
||||
MCPServerHTTP(
|
||||
url="https://api.example.com/mcp",
|
||||
headers={"Authorization": "Bearer your_token"},
|
||||
streamable=True,
|
||||
cache_tools_list=True,
|
||||
),
|
||||
# Standard HTTP
|
||||
MCPServerHTTP(
|
||||
url="https://api.example.com/mcp",
|
||||
headers={"Authorization": "Bearer your_token"},
|
||||
streamable=False,
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
#### SSE Transport (Real-Time Streaming)
|
||||
|
||||
For remote servers using Server-Sent Events:
|
||||
|
||||
```python
|
||||
from crewai.mcp import MCPServerSSE
|
||||
|
||||
mcps=[
|
||||
MCPServerSSE(
|
||||
url="https://stream.example.com/mcp/sse",
|
||||
headers={"Authorization": "Bearer your_token"},
|
||||
cache_tools_list=True,
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
### Mixed References
|
||||
|
||||
You can combine string references and structured configurations:
|
||||
|
||||
```python
|
||||
from crewai.mcp import MCPServerStdio, MCPServerHTTP
|
||||
|
||||
mcps=[
|
||||
# String references
|
||||
"https://external-api.com/mcp", # External server
|
||||
"crewai-amp:financial-insights", # AMP service
|
||||
|
||||
# Structured configurations
|
||||
MCPServerStdio(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
),
|
||||
MCPServerHTTP(
|
||||
url="https://api.example.com/mcp",
|
||||
headers={"Authorization": "Bearer token"},
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
### Tool Filtering
|
||||
|
||||
Structured configurations support advanced tool filtering:
|
||||
|
||||
```python
|
||||
from crewai.mcp import MCPServerStdio
|
||||
from crewai.mcp.filters import create_static_tool_filter, create_dynamic_tool_filter, ToolFilterContext
|
||||
|
||||
# Static filtering (allow/block lists)
|
||||
static_filter = create_static_tool_filter(
|
||||
allowed_tool_names=["read_file", "write_file"],
|
||||
blocked_tool_names=["delete_file"],
|
||||
)
|
||||
|
||||
# Dynamic filtering (context-aware)
|
||||
def dynamic_filter(context: ToolFilterContext, tool: dict) -> bool:
|
||||
# Block dangerous tools for certain agent roles
|
||||
if context.agent.role == "Code Reviewer":
|
||||
if "delete" in tool.get("name", "").lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
mcps=[
|
||||
MCPServerStdio(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
tool_filter=static_filter, # or dynamic_filter
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
Each transport type supports specific configuration options:
|
||||
|
||||
### MCPServerStdio Parameters
|
||||
|
||||
- **`command`** (required): Command to execute (e.g., `"python"`, `"node"`, `"npx"`, `"uvx"`)
|
||||
- **`args`** (optional): List of command arguments (e.g., `["server.py"]` or `["-y", "@mcp/server"]`)
|
||||
- **`env`** (optional): Dictionary of environment variables to pass to the process
|
||||
- **`tool_filter`** (optional): Tool filter function for filtering available tools
|
||||
- **`cache_tools_list`** (optional): Whether to cache the tool list for faster subsequent access (default: `False`)
|
||||
|
||||
### MCPServerHTTP Parameters
|
||||
|
||||
- **`url`** (required): Server URL (e.g., `"https://api.example.com/mcp"`)
|
||||
- **`headers`** (optional): Dictionary of HTTP headers for authentication or other purposes
|
||||
- **`streamable`** (optional): Whether to use streamable HTTP transport (default: `True`)
|
||||
- **`tool_filter`** (optional): Tool filter function for filtering available tools
|
||||
- **`cache_tools_list`** (optional): Whether to cache the tool list for faster subsequent access (default: `False`)
|
||||
|
||||
### MCPServerSSE Parameters
|
||||
|
||||
- **`url`** (required): Server URL (e.g., `"https://api.example.com/mcp/sse"`)
|
||||
- **`headers`** (optional): Dictionary of HTTP headers for authentication or other purposes
|
||||
- **`tool_filter`** (optional): Tool filter function for filtering available tools
|
||||
- **`cache_tools_list`** (optional): Whether to cache the tool list for faster subsequent access (default: `False`)
|
||||
|
||||
### Common Parameters
|
||||
|
||||
All transport types support:
|
||||
- **`tool_filter`**: Filter function to control which tools are available. Can be:
|
||||
- `None` (default): All tools are available
|
||||
- Static filter: Created with `create_static_tool_filter()` for allow/block lists
|
||||
- Dynamic filter: Created with `create_dynamic_tool_filter()` for context-aware filtering
|
||||
- **`cache_tools_list`**: When `True`, caches the tool list after first discovery to improve performance on subsequent connections
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🔄 **Automatic Tool Discovery**: Tools are automatically discovered and integrated
|
||||
@@ -152,26 +387,47 @@ mcps=[
|
||||
- 🛡️ **Error Resilience**: Graceful handling of unavailable servers
|
||||
- ⏱️ **Timeout Protection**: Built-in timeouts prevent hanging connections
|
||||
- 📊 **Transparent Integration**: Works seamlessly with existing CrewAI features
|
||||
- 🔧 **Full Transport Support**: Stdio, HTTP/Streamable HTTP, and SSE transports
|
||||
- 🎯 **Advanced Filtering**: Static and dynamic tool filtering capabilities
|
||||
- 🔐 **Flexible Authentication**: Support for headers, environment variables, and query parameters
|
||||
|
||||
## Error Handling
|
||||
|
||||
The MCP DSL integration is designed to be resilient:
|
||||
The MCP DSL integration is designed to be resilient and handles failures gracefully:
|
||||
|
||||
```python
|
||||
from crewai import Agent
|
||||
from crewai.mcp import MCPServerStdio, MCPServerHTTP
|
||||
|
||||
agent = Agent(
|
||||
role="Resilient Agent",
|
||||
goal="Continue working despite server issues",
|
||||
backstory="Agent that handles failures gracefully",
|
||||
mcps=[
|
||||
# String references
|
||||
"https://reliable-server.com/mcp", # Will work
|
||||
"https://unreachable-server.com/mcp", # Will be skipped gracefully
|
||||
"https://slow-server.com/mcp", # Will timeout gracefully
|
||||
"crewai-amp:working-service" # Will work
|
||||
"crewai-amp:working-service", # Will work
|
||||
|
||||
# Structured configs
|
||||
MCPServerStdio(
|
||||
command="python",
|
||||
args=["reliable_server.py"], # Will work
|
||||
),
|
||||
MCPServerHTTP(
|
||||
url="https://slow-server.com/mcp", # Will timeout gracefully
|
||||
),
|
||||
]
|
||||
)
|
||||
# Agent will use tools from working servers and log warnings for failing ones
|
||||
```
|
||||
|
||||
All connection errors are handled gracefully:
|
||||
- **Connection failures**: Logged as warnings, agent continues with available tools
|
||||
- **Timeout errors**: Connections timeout after 30 seconds (configurable)
|
||||
- **Authentication errors**: Logged clearly for debugging
|
||||
- **Invalid configurations**: Validation errors are raised at agent creation time
|
||||
|
||||
## Advanced: MCPServerAdapter
|
||||
|
||||
For complex scenarios requiring manual connection management, use the `MCPServerAdapter` class from `crewai-tools`. Using a Python context manager (`with` statement) is the recommended approach as it automatically handles starting and stopping the connection to the MCP server.
|
||||
|
||||
@@ -733,9 +733,7 @@ Here's a basic configuration to route requests to OpenAI, specifically using GPT
|
||||
- Collect relevant metadata to filter logs
|
||||
- Enforce access permissions
|
||||
|
||||
Create API keys through:
|
||||
- [Portkey App](https://app.portkey.ai/)
|
||||
- [API Key Management API](/en/api-reference/admin-api/control-plane/api-keys/create-api-key)
|
||||
Create API keys through the [Portkey App](https://app.portkey.ai/)
|
||||
|
||||
Example using Python SDK:
|
||||
```python
|
||||
@@ -758,7 +756,7 @@ Here's a basic configuration to route requests to OpenAI, specifically using GPT
|
||||
)
|
||||
```
|
||||
|
||||
For detailed key management instructions, see our [API Keys documentation](/en/api-reference/admin-api/control-plane/api-keys/create-api-key).
|
||||
For detailed key management instructions, see the [Portkey documentation](https://portkey.ai/docs).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Step 4: Deploy & Monitor">
|
||||
|
||||
@@ -18,7 +18,7 @@ These tools enable your agents to interact with cloud services, access cloud sto
|
||||
Write and upload files to Amazon S3 storage.
|
||||
</Card>
|
||||
|
||||
<Card title="Bedrock Invoke Agent" icon="aws" href="/en/tools/cloud-storage/bedrockinvokeagenttool">
|
||||
<Card title="Bedrock Invoke Agent" icon="aws" href="/en/tools/integration/bedrockinvokeagenttool">
|
||||
Invoke Amazon Bedrock agents for AI-powered tasks.
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -632,11 +632,11 @@ mode: "wide"
|
||||
|
||||
## 기여
|
||||
|
||||
기여를 원하시면, [기여 가이드](CONTRIBUTING.md)를 참조하세요.
|
||||
기여를 원하시면, [기여 가이드](https://github.com/crewAIInc/crewAI/blob/main/CONTRIBUTING.md)를 참조하세요.
|
||||
|
||||
## 라이센스
|
||||
|
||||
이 프로젝트는 MIT 라이센스 하에 배포됩니다. 자세한 내용은 [LICENSE](LICENSE) 파일을 확인하세요.
|
||||
이 프로젝트는 MIT 라이센스 하에 배포됩니다. 자세한 내용은 [LICENSE](https://github.com/crewAIInc/crewAI/blob/main/LICENSE) 파일을 확인하세요.
|
||||
</Update>
|
||||
|
||||
<Update label="2025년 5월 22일">
|
||||
|
||||
@@ -706,7 +706,7 @@ class KnowledgeMonitorListener(BaseEventListener):
|
||||
knowledge_monitor = KnowledgeMonitorListener()
|
||||
```
|
||||
|
||||
이벤트 사용에 대한 자세한 내용은 [이벤트 리스너](https://docs.crewai.com/concepts/event-listener) 문서를 참고하세요.
|
||||
이벤트 사용에 대한 자세한 내용은 [이벤트 리스너](/ko/concepts/event-listener) 문서를 참고하세요.
|
||||
|
||||
### 맞춤형 지식 소스
|
||||
|
||||
|
||||
@@ -748,7 +748,7 @@ CrewAI는 LLM의 스트리밍 응답을 지원하여, 애플리케이션이 출
|
||||
```
|
||||
|
||||
<Tip>
|
||||
[자세한 내용은 여기를 클릭하세요](https://docs.crewai.com/concepts/event-listener#event-listeners)
|
||||
[자세한 내용은 여기를 클릭하세요](/ko/concepts/event-listener#event-listeners)
|
||||
</Tip>
|
||||
</Tab>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ mode: "wide"
|
||||
<Card title="도구 & 통합" href="/ko/enterprise/features/tools-and-integrations" icon="wrench">
|
||||
에이전트가 사용할 외부 앱 연결 및 내부 도구 관리.
|
||||
</Card>
|
||||
<Card title="도구 저장소" href="/ko/enterprise/features/tool-repository" icon="toolbox">
|
||||
<Card title="도구 저장소" href="/ko/enterprise/guides/tool-repository" icon="toolbox">
|
||||
크루 기능을 확장할 수 있도록 도구를 게시하고 설치.
|
||||
</Card>
|
||||
<Card title="에이전트 저장소" href="/ko/enterprise/features/agent-repositories" icon="people-group">
|
||||
|
||||
@@ -231,7 +231,7 @@ mode: "wide"
|
||||
## 관련 문서
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="도구 저장소" href="/ko/enterprise/features/tool-repository" icon="toolbox">
|
||||
<Card title="도구 저장소" href="/ko/enterprise/guides/tool-repository" icon="toolbox">
|
||||
크루 기능을 확장할 수 있도록 도구를 게시하고 설치하세요.
|
||||
</Card>
|
||||
<Card title="Webhook 자동화" href="/ko/enterprise/guides/webhook-automation" icon="bolt">
|
||||
|
||||
@@ -21,7 +21,7 @@ Tool Repository는 CrewAI 도구를 위한 패키지 관리자입니다. 사용
|
||||
Tool Repository를 사용하기 전에 다음이 준비되어 있어야 합니다:
|
||||
|
||||
- [CrewAI AMP](https://app.crewai.com) 계정
|
||||
- [CrewAI CLI](https://docs.crewai.com/concepts/cli#cli) 설치됨
|
||||
- [CrewAI CLI](/ko/concepts/cli#cli) 설치됨
|
||||
- uv>=0.5.0 이 설치되어 있어야 합니다. [업그레이드 방법](https://docs.astral.sh/uv/getting-started/installation/#upgrading-uv)을 참고하세요.
|
||||
- [Git](https://git-scm.com) 설치 및 구성 완료
|
||||
- CrewAI AMP 조직에서 도구를 게시하거나 설치할 수 있는 액세스 권한
|
||||
@@ -66,7 +66,7 @@ crewai tool publish
|
||||
crewai tool publish --public
|
||||
```
|
||||
|
||||
도구 빌드에 대한 자세한 내용은 [나만의 도구 만들기](https://docs.crewai.com/concepts/tools#creating-your-own-tools)를 참고하세요.
|
||||
도구 빌드에 대한 자세한 내용은 [나만의 도구 만들기](/ko/concepts/tools#creating-your-own-tools)를 참고하세요.
|
||||
|
||||
## 도구 업데이트
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ mode: "wide"
|
||||
|
||||
에이전트 실행에 인간 입력을 통합하려면 작업 정의에서 `human_input` 플래그를 설정하세요. 활성화하면, 에이전트가 최종 답변을 제공하기 전에 사용자에게 입력을 요청합니다. 이 입력은 추가 맥락을 제공하거나, 애매함을 해소하거나, 에이전트의 출력을 검증해야 할 때 활용될 수 있습니다.
|
||||
|
||||
자세한 구현 방법은 [Human-in-the-Loop 가이드](/ko/how-to/human-in-the-loop)를 참고해 주세요.
|
||||
자세한 구현 방법은 [Human-in-the-Loop 가이드](/ko/enterprise/guides/human-in-the-loop)를 참고해 주세요.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="CrewAI에서 에이전트의 행동과 역량을 맞춤화하고 향상시키기 위한 고급 커스터마이징 옵션에는 어떤 것이 있나요?">
|
||||
@@ -142,7 +142,7 @@ mode: "wide"
|
||||
<Accordion title="CrewAI 에이전트를 위한 커스텀 도구는 어떻게 만들 수 있습니까?">
|
||||
CrewAI에서 제공하는 `BaseTool` 클래스를 상속받아 커스텀 도구를 직접 만들거나, tool 데코레이터를 활용할 수 있습니다. 상속 방식은 `BaseTool`을 상속하는 새로운 클래스를 정의해 이름, 설명, 그리고 실제 논리를 처리하는 `_run` 메서드를 작성합니다. tool 데코레이터를 사용하면 필수 속성과 운영 로직만 정의해 바로 `Tool` 객체를 만들 수 있습니다.
|
||||
|
||||
<Card href="https://docs.crewai.com/how-to/create-custom-tools" icon="code">CrewAI 도구 가이드</Card>
|
||||
<Card href="/ko/learn/create-custom-tools" icon="code">CrewAI 도구 가이드</Card>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="전체 crew가 수행할 수 있는 분당 최대 요청 수는 어떻게 제한할 수 있나요?">
|
||||
|
||||
379
docs/ko/learn/execution-hooks.mdx
Normal file
379
docs/ko/learn/execution-hooks.mdx
Normal file
@@ -0,0 +1,379 @@
|
||||
---
|
||||
title: 실행 훅 개요
|
||||
description: 에이전트 작업에 대한 세밀한 제어를 위한 CrewAI 실행 훅 이해 및 사용
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
실행 훅(Execution Hooks)은 CrewAI 에이전트의 런타임 동작을 세밀하게 제어할 수 있게 해줍니다. 크루 실행 전후에 실행되는 킥오프 훅과 달리, 실행 훅은 에이전트 실행 중 특정 작업을 가로채서 동작을 수정하고, 안전성 검사를 구현하며, 포괄적인 모니터링을 추가할 수 있습니다.
|
||||
|
||||
## 실행 훅의 유형
|
||||
|
||||
CrewAI는 두 가지 주요 범주의 실행 훅을 제공합니다:
|
||||
|
||||
### 1. [LLM 호출 훅](/learn/llm-hooks)
|
||||
|
||||
언어 모델 상호작용을 제어하고 모니터링합니다:
|
||||
- **LLM 호출 전**: 프롬프트 수정, 입력 검증, 승인 게이트 구현
|
||||
- **LLM 호출 후**: 응답 변환, 출력 정제, 대화 기록 업데이트
|
||||
|
||||
**사용 사례:**
|
||||
- 반복 제한
|
||||
- 비용 추적 및 토큰 사용량 모니터링
|
||||
- 응답 정제 및 콘텐츠 필터링
|
||||
- LLM 호출에 대한 사람의 승인
|
||||
- 안전 가이드라인 또는 컨텍스트 추가
|
||||
- 디버그 로깅 및 요청/응답 검사
|
||||
|
||||
[LLM 훅 문서 보기 →](/learn/llm-hooks)
|
||||
|
||||
### 2. [도구 호출 훅](/learn/tool-hooks)
|
||||
|
||||
도구 실행을 제어하고 모니터링합니다:
|
||||
- **도구 호출 전**: 입력 수정, 매개변수 검증, 위험한 작업 차단
|
||||
- **도구 호출 후**: 결과 변환, 출력 정제, 실행 세부사항 로깅
|
||||
|
||||
**사용 사례:**
|
||||
- 파괴적인 작업에 대한 안전 가드레일
|
||||
- 민감한 작업에 대한 사람의 승인
|
||||
- 입력 검증 및 정제
|
||||
- 결과 캐싱 및 속도 제한
|
||||
- 도구 사용 분석
|
||||
- 디버그 로깅 및 모니터링
|
||||
|
||||
[도구 훅 문서 보기 →](/learn/tool-hooks)
|
||||
|
||||
## 훅 등록 방법
|
||||
|
||||
### 1. 데코레이터 기반 훅 (권장)
|
||||
|
||||
훅을 등록하는 가장 깔끔하고 파이썬스러운 방법:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_llm_call, after_llm_call, before_tool_call, after_tool_call
|
||||
|
||||
@before_llm_call
|
||||
def limit_iterations(context):
|
||||
"""반복 횟수를 제한하여 무한 루프를 방지합니다."""
|
||||
if context.iterations > 10:
|
||||
return False # 실행 차단
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def sanitize_response(context):
|
||||
"""LLM 응답에서 민감한 데이터를 제거합니다."""
|
||||
if "API_KEY" in context.response:
|
||||
return context.response.replace("API_KEY", "[수정됨]")
|
||||
return None
|
||||
|
||||
@before_tool_call
|
||||
def block_dangerous_tools(context):
|
||||
"""파괴적인 작업을 차단합니다."""
|
||||
if context.tool_name == "delete_database":
|
||||
return False # 실행 차단
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def log_tool_result(context):
|
||||
"""도구 실행을 로깅합니다."""
|
||||
print(f"도구 {context.tool_name} 완료")
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. 크루 범위 훅
|
||||
|
||||
특정 크루 인스턴스에만 훅을 적용합니다:
|
||||
|
||||
```python
|
||||
from crewai import CrewBase
|
||||
from crewai.project import crew
|
||||
from crewai.hooks import before_llm_call_crew, after_tool_call_crew
|
||||
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_llm_call_crew
|
||||
def validate_inputs(self, context):
|
||||
# 이 크루에만 적용됩니다
|
||||
print(f"{self.__class__.__name__}에서 LLM 호출")
|
||||
return None
|
||||
|
||||
@after_tool_call_crew
|
||||
def log_results(self, context):
|
||||
# 크루별 로깅
|
||||
print(f"도구 결과: {context.tool_result[:50]}...")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential
|
||||
)
|
||||
```
|
||||
|
||||
## 훅 실행 흐름
|
||||
|
||||
### LLM 호출 흐름
|
||||
|
||||
```
|
||||
에이전트가 LLM을 호출해야 함
|
||||
↓
|
||||
[LLM 호출 전 훅 실행]
|
||||
├→ 훅 1: 반복 횟수 검증
|
||||
├→ 훅 2: 안전 컨텍스트 추가
|
||||
└→ 훅 3: 요청 로깅
|
||||
↓
|
||||
훅이 False를 반환하는 경우:
|
||||
├→ LLM 호출 차단
|
||||
└→ ValueError 발생
|
||||
↓
|
||||
모든 훅이 True/None을 반환하는 경우:
|
||||
├→ LLM 호출 진행
|
||||
└→ 응답 생성
|
||||
↓
|
||||
[LLM 호출 후 훅 실행]
|
||||
├→ 훅 1: 응답 정제
|
||||
├→ 훅 2: 응답 로깅
|
||||
└→ 훅 3: 메트릭 업데이트
|
||||
↓
|
||||
최종 응답 반환
|
||||
```
|
||||
|
||||
### 도구 호출 흐름
|
||||
|
||||
```
|
||||
에이전트가 도구를 실행해야 함
|
||||
↓
|
||||
[도구 호출 전 훅 실행]
|
||||
├→ 훅 1: 도구 허용 여부 확인
|
||||
├→ 훅 2: 입력 검증
|
||||
└→ 훅 3: 필요시 승인 요청
|
||||
↓
|
||||
훅이 False를 반환하는 경우:
|
||||
├→ 도구 실행 차단
|
||||
└→ 오류 메시지 반환
|
||||
↓
|
||||
모든 훅이 True/None을 반환하는 경우:
|
||||
├→ 도구 실행 진행
|
||||
└→ 결과 생성
|
||||
↓
|
||||
[도구 호출 후 훅 실행]
|
||||
├→ 훅 1: 결과 정제
|
||||
├→ 훅 2: 결과 캐싱
|
||||
└→ 훅 3: 메트릭 로깅
|
||||
↓
|
||||
최종 결과 반환
|
||||
```
|
||||
|
||||
## 훅 컨텍스트 객체
|
||||
|
||||
### LLMCallHookContext
|
||||
|
||||
LLM 실행 상태에 대한 액세스를 제공합니다:
|
||||
|
||||
```python
|
||||
class LLMCallHookContext:
|
||||
executor: CrewAgentExecutor # 전체 실행자 액세스
|
||||
messages: list # 변경 가능한 메시지 목록
|
||||
agent: Agent # 현재 에이전트
|
||||
task: Task # 현재 작업
|
||||
crew: Crew # 크루 인스턴스
|
||||
llm: BaseLLM # LLM 인스턴스
|
||||
iterations: int # 현재 반복 횟수
|
||||
response: str | None # LLM 응답 (후 훅용)
|
||||
```
|
||||
|
||||
### ToolCallHookContext
|
||||
|
||||
도구 실행 상태에 대한 액세스를 제공합니다:
|
||||
|
||||
```python
|
||||
class ToolCallHookContext:
|
||||
tool_name: str # 호출되는 도구
|
||||
tool_input: dict # 변경 가능한 입력 매개변수
|
||||
tool: CrewStructuredTool # 도구 인스턴스
|
||||
agent: Agent | None # 실행 중인 에이전트
|
||||
task: Task | None # 현재 작업
|
||||
crew: Crew | None # 크루 인스턴스
|
||||
tool_result: str | None # 도구 결과 (후 훅용)
|
||||
```
|
||||
|
||||
## 일반적인 패턴
|
||||
|
||||
### 안전 및 검증
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safety_check(context):
|
||||
"""파괴적인 작업을 차단합니다."""
|
||||
dangerous = ['delete_file', 'drop_table', 'system_shutdown']
|
||||
if context.tool_name in dangerous:
|
||||
print(f"🛑 차단됨: {context.tool_name}")
|
||||
return False
|
||||
return None
|
||||
|
||||
@before_llm_call
|
||||
def iteration_limit(context):
|
||||
"""무한 루프를 방지합니다."""
|
||||
if context.iterations > 15:
|
||||
print("⛔ 최대 반복 횟수 초과")
|
||||
return False
|
||||
return None
|
||||
```
|
||||
|
||||
### 사람의 개입
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def require_approval(context):
|
||||
"""민감한 작업에 대한 승인을 요구합니다."""
|
||||
sensitive = ['send_email', 'make_payment', 'post_message']
|
||||
|
||||
if context.tool_name in sensitive:
|
||||
response = context.request_human_input(
|
||||
prompt=f"{context.tool_name} 승인하시겠습니까?",
|
||||
default_message="승인하려면 'yes'를 입력하세요:"
|
||||
)
|
||||
|
||||
if response.lower() != 'yes':
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 모니터링 및 분석
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
import time
|
||||
|
||||
metrics = defaultdict(lambda: {'count': 0, 'total_time': 0})
|
||||
|
||||
@before_tool_call
|
||||
def start_timer(context):
|
||||
context.tool_input['_start'] = time.time()
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def track_metrics(context):
|
||||
start = context.tool_input.get('_start', time.time())
|
||||
duration = time.time() - start
|
||||
|
||||
metrics[context.tool_name]['count'] += 1
|
||||
metrics[context.tool_name]['total_time'] += duration
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
## 훅 관리
|
||||
|
||||
### 모든 훅 지우기
|
||||
|
||||
```python
|
||||
from crewai.hooks import clear_all_global_hooks
|
||||
|
||||
# 모든 훅을 한 번에 지웁니다
|
||||
result = clear_all_global_hooks()
|
||||
print(f"{result['total']} 훅이 지워졌습니다")
|
||||
```
|
||||
|
||||
### 특정 훅 유형 지우기
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_llm_call_hooks,
|
||||
clear_after_llm_call_hooks,
|
||||
clear_before_tool_call_hooks,
|
||||
clear_after_tool_call_hooks
|
||||
)
|
||||
|
||||
# 특정 유형 지우기
|
||||
llm_before_count = clear_before_llm_call_hooks()
|
||||
tool_after_count = clear_after_tool_call_hooks()
|
||||
```
|
||||
|
||||
## 모범 사례
|
||||
|
||||
### 1. 훅을 집중적으로 유지
|
||||
각 훅은 단일하고 명확한 책임을 가져야 합니다.
|
||||
|
||||
### 2. 오류를 우아하게 처리
|
||||
```python
|
||||
@before_llm_call
|
||||
def safe_hook(context):
|
||||
try:
|
||||
if some_condition:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"훅 오류: {e}")
|
||||
return None # 오류에도 불구하고 실행 허용
|
||||
```
|
||||
|
||||
### 3. 컨텍스트를 제자리에서 수정
|
||||
```python
|
||||
# ✅ 올바름 - 제자리에서 수정
|
||||
@before_llm_call
|
||||
def add_context(context):
|
||||
context.messages.append({"role": "system", "content": "간결하게"})
|
||||
|
||||
# ❌ 잘못됨 - 참조를 교체
|
||||
@before_llm_call
|
||||
def wrong_approach(context):
|
||||
context.messages = [{"role": "system", "content": "간결하게"}]
|
||||
```
|
||||
|
||||
### 4. 타입 힌트 사용
|
||||
```python
|
||||
from crewai.hooks import LLMCallHookContext, ToolCallHookContext
|
||||
|
||||
def my_llm_hook(context: LLMCallHookContext) -> bool | None:
|
||||
return None
|
||||
|
||||
def my_tool_hook(context: ToolCallHookContext) -> str | None:
|
||||
return None
|
||||
```
|
||||
|
||||
### 5. 테스트에서 정리
|
||||
```python
|
||||
import pytest
|
||||
from crewai.hooks import clear_all_global_hooks
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_hooks():
|
||||
"""각 테스트 전에 훅을 재설정합니다."""
|
||||
yield
|
||||
clear_all_global_hooks()
|
||||
```
|
||||
|
||||
## 어떤 훅을 사용해야 할까요
|
||||
|
||||
### LLM 훅을 사용하는 경우:
|
||||
- 반복 제한 구현
|
||||
- 프롬프트에 컨텍스트 또는 안전 가이드라인 추가
|
||||
- 토큰 사용량 및 비용 추적
|
||||
- 응답 정제 또는 변환
|
||||
- LLM 호출에 대한 승인 게이트 구현
|
||||
- 프롬프트/응답 상호작용 디버깅
|
||||
|
||||
### 도구 훅을 사용하는 경우:
|
||||
- 위험하거나 파괴적인 작업 차단
|
||||
- 실행 전 도구 입력 검증
|
||||
- 민감한 작업에 대한 승인 게이트 구현
|
||||
- 도구 결과 캐싱
|
||||
- 도구 사용 및 성능 추적
|
||||
- 도구 출력 정제
|
||||
- 도구 호출 속도 제한
|
||||
|
||||
### 둘 다 사용하는 경우:
|
||||
모든 에이전트 작업을 모니터링해야 하는 포괄적인 관찰성, 안전 또는 승인 시스템을 구축하는 경우.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [LLM 호출 훅 →](/learn/llm-hooks) - 상세한 LLM 훅 문서
|
||||
- [도구 호출 훅 →](/learn/tool-hooks) - 상세한 도구 훅 문서
|
||||
- [킥오프 전후 훅 →](/learn/before-and-after-kickoff-hooks) - 크루 생명주기 훅
|
||||
- [사람의 개입 →](/learn/human-in-the-loop) - 사람 입력 패턴
|
||||
|
||||
## 결론
|
||||
|
||||
실행 훅은 에이전트 런타임 동작에 대한 강력한 제어를 제공합니다. 이를 사용하여 안전 가드레일, 승인 워크플로우, 포괄적인 모니터링 및 사용자 정의 비즈니스 로직을 구현하세요. 적절한 오류 처리, 타입 안전성 및 성능 고려사항과 결합하면, 훅을 통해 프로덕션 준비가 된 안전하고 관찰 가능한 에이전트 시스템을 구축할 수 있습니다.
|
||||
@@ -95,7 +95,7 @@ project_crew = Crew(
|
||||
```
|
||||
|
||||
<Tip>
|
||||
매니저 에이전트 생성 및 맞춤화에 대한 자세한 내용은 [커스텀 매니저 에이전트 문서](https://docs.crewai.com/how-to/custom-manager-agent#custom-manager-agent)를 참고하세요.
|
||||
매니저 에이전트 생성 및 맞춤화에 대한 자세한 내용은 [커스텀 매니저 에이전트 문서](/ko/learn/custom-manager-agent)를 참고하세요.
|
||||
</Tip>
|
||||
|
||||
### 워크플로우 실행
|
||||
|
||||
412
docs/ko/learn/llm-hooks.mdx
Normal file
412
docs/ko/learn/llm-hooks.mdx
Normal file
@@ -0,0 +1,412 @@
|
||||
---
|
||||
title: LLM 호출 훅
|
||||
description: CrewAI에서 언어 모델 상호작용을 가로채고, 수정하고, 제어하는 LLM 호출 훅 사용 방법 배우기
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
LLM 호출 훅(LLM Call Hooks)은 에이전트 실행 중 언어 모델 상호작용에 대한 세밀한 제어를 제공합니다. 이러한 훅을 사용하면 LLM 호출을 가로채고, 프롬프트를 수정하고, 응답을 변환하고, 승인 게이트를 구현하고, 사용자 정의 로깅 또는 모니터링을 추가할 수 있습니다.
|
||||
|
||||
## 개요
|
||||
|
||||
LLM 훅은 두 가지 중요한 시점에 실행됩니다:
|
||||
- **LLM 호출 전**: 메시지 수정, 입력 검증 또는 실행 차단
|
||||
- **LLM 호출 후**: 응답 변환, 출력 정제 또는 대화 기록 수정
|
||||
|
||||
## 훅 타입
|
||||
|
||||
### LLM 호출 전 훅
|
||||
|
||||
모든 LLM 호출 전에 실행되며, 다음을 수행할 수 있습니다:
|
||||
- LLM에 전송되는 메시지 검사 및 수정
|
||||
- 조건에 따라 LLM 실행 차단
|
||||
- 속도 제한 또는 승인 게이트 구현
|
||||
- 컨텍스트 또는 시스템 메시지 추가
|
||||
- 요청 세부사항 로깅
|
||||
|
||||
**시그니처:**
|
||||
```python
|
||||
def before_hook(context: LLMCallHookContext) -> bool | None:
|
||||
# 실행을 차단하려면 False 반환
|
||||
# 실행을 허용하려면 True 또는 None 반환
|
||||
...
|
||||
```
|
||||
|
||||
### LLM 호출 후 훅
|
||||
|
||||
모든 LLM 호출 후에 실행되며, 다음을 수행할 수 있습니다:
|
||||
- LLM 응답 수정 또는 정제
|
||||
- 메타데이터 또는 서식 추가
|
||||
- 응답 세부사항 로깅
|
||||
- 대화 기록 업데이트
|
||||
- 콘텐츠 필터링 구현
|
||||
|
||||
**시그니처:**
|
||||
```python
|
||||
def after_hook(context: LLMCallHookContext) -> str | None:
|
||||
# 수정된 응답 문자열 반환
|
||||
# 원본 응답을 유지하려면 None 반환
|
||||
...
|
||||
```
|
||||
|
||||
## LLM 훅 컨텍스트
|
||||
|
||||
`LLMCallHookContext` 객체는 실행 상태에 대한 포괄적인 액세스를 제공합니다:
|
||||
|
||||
```python
|
||||
class LLMCallHookContext:
|
||||
executor: CrewAgentExecutor # 전체 실행자 참조
|
||||
messages: list # 변경 가능한 메시지 목록
|
||||
agent: Agent # 현재 에이전트
|
||||
task: Task # 현재 작업
|
||||
crew: Crew # 크루 인스턴스
|
||||
llm: BaseLLM # LLM 인스턴스
|
||||
iterations: int # 현재 반복 횟수
|
||||
response: str | None # LLM 응답 (후 훅용)
|
||||
```
|
||||
|
||||
### 메시지 수정
|
||||
|
||||
**중요:** 항상 메시지를 제자리에서 수정하세요:
|
||||
|
||||
```python
|
||||
# ✅ 올바름 - 제자리에서 수정
|
||||
def add_context(context: LLMCallHookContext) -> None:
|
||||
context.messages.append({"role": "system", "content": "간결하게 작성하세요"})
|
||||
|
||||
# ❌ 잘못됨 - 리스트 참조를 교체
|
||||
def wrong_approach(context: LLMCallHookContext) -> None:
|
||||
context.messages = [{"role": "system", "content": "간결하게 작성하세요"}]
|
||||
```
|
||||
|
||||
## 등록 방법
|
||||
|
||||
### 1. 데코레이터 기반 등록 (권장)
|
||||
|
||||
더 깔끔한 구문을 위해 데코레이터를 사용합니다:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_llm_call, after_llm_call
|
||||
|
||||
@before_llm_call
|
||||
def validate_iteration_count(context):
|
||||
"""반복 횟수를 검증합니다."""
|
||||
if context.iterations > 10:
|
||||
print("⚠️ 최대 반복 횟수 초과")
|
||||
return False # 실행 차단
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def sanitize_response(context):
|
||||
"""민감한 데이터를 제거합니다."""
|
||||
if context.response and "API_KEY" in context.response:
|
||||
return context.response.replace("API_KEY", "[수정됨]")
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. 크루 범위 훅
|
||||
|
||||
특정 크루 인스턴스에 대한 훅을 등록합니다:
|
||||
|
||||
```python
|
||||
from crewai import CrewBase
|
||||
from crewai.project import crew
|
||||
from crewai.hooks import before_llm_call_crew, after_llm_call_crew
|
||||
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_llm_call_crew
|
||||
def validate_inputs(self, context):
|
||||
# 이 크루에만 적용됩니다
|
||||
if context.iterations == 0:
|
||||
print(f"작업 시작: {context.task.description}")
|
||||
return None
|
||||
|
||||
@after_llm_call_crew
|
||||
def log_responses(self, context):
|
||||
# 크루별 응답 로깅
|
||||
print(f"응답 길이: {len(context.response)}")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential,
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
|
||||
## 일반적인 사용 사례
|
||||
|
||||
### 1. 반복 제한
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def limit_iterations(context: LLMCallHookContext) -> bool | None:
|
||||
"""무한 루프를 방지하기 위해 반복을 제한합니다."""
|
||||
max_iterations = 15
|
||||
if context.iterations > max_iterations:
|
||||
print(f"⛔ 차단됨: {max_iterations}회 반복 초과")
|
||||
return False # 실행 차단
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. 사람의 승인 게이트
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def require_approval(context: LLMCallHookContext) -> bool | None:
|
||||
"""특정 반복 후 승인을 요구합니다."""
|
||||
if context.iterations > 5:
|
||||
response = context.request_human_input(
|
||||
prompt=f"반복 {context.iterations}: LLM 호출을 승인하시겠습니까?",
|
||||
default_message="승인하려면 Enter를 누르고, 차단하려면 'no'를 입력하세요:"
|
||||
)
|
||||
if response.lower() == "no":
|
||||
print("🚫 사용자에 의해 LLM 호출이 차단되었습니다")
|
||||
return False
|
||||
return None
|
||||
```
|
||||
|
||||
### 3. 시스템 컨텍스트 추가
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def add_guardrails(context: LLMCallHookContext) -> None:
|
||||
"""모든 LLM 호출에 안전 가이드라인을 추가합니다."""
|
||||
context.messages.append({
|
||||
"role": "system",
|
||||
"content": "응답이 사실에 기반하고 가능한 경우 출처를 인용하도록 하세요."
|
||||
})
|
||||
return None
|
||||
```
|
||||
|
||||
### 4. 응답 정제
|
||||
|
||||
```python
|
||||
@after_llm_call
|
||||
def sanitize_sensitive_data(context: LLMCallHookContext) -> str | None:
|
||||
"""민감한 데이터 패턴을 제거합니다."""
|
||||
if not context.response:
|
||||
return None
|
||||
|
||||
import re
|
||||
sanitized = context.response
|
||||
sanitized = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[주민번호-수정됨]', sanitized)
|
||||
sanitized = re.sub(r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b', '[카드번호-수정됨]', sanitized)
|
||||
|
||||
return sanitized
|
||||
```
|
||||
|
||||
### 5. 비용 추적
|
||||
|
||||
```python
|
||||
import tiktoken
|
||||
|
||||
@before_llm_call
|
||||
def track_token_usage(context: LLMCallHookContext) -> None:
|
||||
"""입력 토큰을 추적합니다."""
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
total_tokens = sum(
|
||||
len(encoding.encode(msg.get("content", "")))
|
||||
for msg in context.messages
|
||||
)
|
||||
print(f"📊 입력 토큰: ~{total_tokens}")
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def track_response_tokens(context: LLMCallHookContext) -> None:
|
||||
"""응답 토큰을 추적합니다."""
|
||||
if context.response:
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
tokens = len(encoding.encode(context.response))
|
||||
print(f"📊 응답 토큰: ~{tokens}")
|
||||
return None
|
||||
```
|
||||
|
||||
### 6. 디버그 로깅
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def debug_request(context: LLMCallHookContext) -> None:
|
||||
"""LLM 요청을 디버그합니다."""
|
||||
print(f"""
|
||||
🔍 LLM 호출 디버그:
|
||||
- 에이전트: {context.agent.role}
|
||||
- 작업: {context.task.description[:50]}...
|
||||
- 반복: {context.iterations}
|
||||
- 메시지 수: {len(context.messages)}
|
||||
- 마지막 메시지: {context.messages[-1] if context.messages else 'None'}
|
||||
""")
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def debug_response(context: LLMCallHookContext) -> None:
|
||||
"""LLM 응답을 디버그합니다."""
|
||||
if context.response:
|
||||
print(f"✅ 응답 미리보기: {context.response[:100]}...")
|
||||
return None
|
||||
```
|
||||
|
||||
## 훅 관리
|
||||
|
||||
### 훅 등록 해제
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_llm_call_hook,
|
||||
unregister_after_llm_call_hook
|
||||
)
|
||||
|
||||
# 특정 훅 등록 해제
|
||||
def my_hook(context):
|
||||
...
|
||||
|
||||
register_before_llm_call_hook(my_hook)
|
||||
# 나중에...
|
||||
unregister_before_llm_call_hook(my_hook) # 찾으면 True 반환
|
||||
```
|
||||
|
||||
### 훅 지우기
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_llm_call_hooks,
|
||||
clear_after_llm_call_hooks,
|
||||
clear_all_llm_call_hooks
|
||||
)
|
||||
|
||||
# 특정 훅 타입 지우기
|
||||
count = clear_before_llm_call_hooks()
|
||||
print(f"{count}개의 전(before) 훅이 지워졌습니다")
|
||||
|
||||
# 모든 LLM 훅 지우기
|
||||
before_count, after_count = clear_all_llm_call_hooks()
|
||||
print(f"{before_count}개의 전(before) 훅과 {after_count}개의 후(after) 훅이 지워졌습니다")
|
||||
```
|
||||
|
||||
## 고급 패턴
|
||||
|
||||
### 조건부 훅 실행
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def conditional_blocking(context: LLMCallHookContext) -> bool | None:
|
||||
"""특정 조건에서만 차단합니다."""
|
||||
# 특정 에이전트에 대해서만 차단
|
||||
if context.agent.role == "researcher" and context.iterations > 10:
|
||||
return False
|
||||
|
||||
# 특정 작업에 대해서만 차단
|
||||
if "민감한" in context.task.description.lower() and context.iterations > 5:
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 컨텍스트 인식 수정
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def adaptive_prompting(context: LLMCallHookContext) -> None:
|
||||
"""반복에 따라 다른 컨텍스트를 추가합니다."""
|
||||
if context.iterations == 0:
|
||||
context.messages.append({
|
||||
"role": "system",
|
||||
"content": "높은 수준의 개요부터 시작하세요."
|
||||
})
|
||||
elif context.iterations > 3:
|
||||
context.messages.append({
|
||||
"role": "system",
|
||||
"content": "구체적인 세부사항에 집중하고 예제를 제공하세요."
|
||||
})
|
||||
return None
|
||||
```
|
||||
|
||||
### 훅 체이닝
|
||||
|
||||
```python
|
||||
# 여러 훅은 등록 순서대로 실행됩니다
|
||||
|
||||
@before_llm_call
|
||||
def first_hook(context):
|
||||
print("1. 첫 번째 훅 실행됨")
|
||||
return None
|
||||
|
||||
@before_llm_call
|
||||
def second_hook(context):
|
||||
print("2. 두 번째 훅 실행됨")
|
||||
return None
|
||||
|
||||
@before_llm_call
|
||||
def blocking_hook(context):
|
||||
if context.iterations > 10:
|
||||
print("3. 차단 훅 - 실행 중지")
|
||||
return False # 후속 훅은 실행되지 않습니다
|
||||
print("3. 차단 훅 - 실행 허용")
|
||||
return None
|
||||
```
|
||||
|
||||
## 모범 사례
|
||||
|
||||
1. **훅을 집중적으로 유지**: 각 훅은 단일 책임을 가져야 합니다
|
||||
2. **무거운 계산 피하기**: 훅은 모든 LLM 호출마다 실행됩니다
|
||||
3. **오류를 우아하게 처리**: try-except를 사용하여 훅 실패로 인한 실행 중단 방지
|
||||
4. **타입 힌트 사용**: 더 나은 IDE 지원을 위해 `LLMCallHookContext` 활용
|
||||
5. **훅 동작 문서화**: 특히 차단 조건에 대해
|
||||
6. **훅을 독립적으로 테스트**: 프로덕션에서 사용하기 전에 단위 테스트
|
||||
7. **테스트에서 훅 지우기**: 테스트 실행 간 `clear_all_llm_call_hooks()` 사용
|
||||
8. **제자리에서 수정**: 항상 `context.messages`를 제자리에서 수정하고 교체하지 마세요
|
||||
|
||||
## 오류 처리
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def safe_hook(context: LLMCallHookContext) -> bool | None:
|
||||
try:
|
||||
# 훅 로직
|
||||
if some_condition:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"⚠️ 훅 오류: {e}")
|
||||
# 결정: 오류 발생 시 허용 또는 차단
|
||||
return None # 오류에도 불구하고 실행 허용
|
||||
```
|
||||
|
||||
## 타입 안전성
|
||||
|
||||
```python
|
||||
from crewai.hooks import LLMCallHookContext, BeforeLLMCallHookType, AfterLLMCallHookType
|
||||
|
||||
# 명시적 타입 주석
|
||||
def my_before_hook(context: LLMCallHookContext) -> bool | None:
|
||||
return None
|
||||
|
||||
def my_after_hook(context: LLMCallHookContext) -> str | None:
|
||||
return None
|
||||
|
||||
# 타입 안전 등록
|
||||
register_before_llm_call_hook(my_before_hook)
|
||||
register_after_llm_call_hook(my_after_hook)
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 훅이 실행되지 않음
|
||||
- 크루 실행 전에 훅이 등록되었는지 확인
|
||||
- 이전 훅이 `False`를 반환했는지 확인 (후속 훅 차단)
|
||||
- 훅 시그니처가 예상 타입과 일치하는지 확인
|
||||
|
||||
### 메시지 수정이 지속되지 않음
|
||||
- 제자리 수정 사용: `context.messages.append()`
|
||||
- 리스트를 교체하지 마세요: `context.messages = []`
|
||||
|
||||
### 응답 수정이 작동하지 않음
|
||||
- 후 훅에서 수정된 문자열을 반환
|
||||
- `None`을 반환하면 원본 응답이 유지됩니다
|
||||
|
||||
## 결론
|
||||
|
||||
LLM 호출 훅은 CrewAI에서 언어 모델 상호작용을 제어하고 모니터링하는 강력한 기능을 제공합니다. 이를 사용하여 안전 가드레일, 승인 게이트, 로깅, 비용 추적 및 응답 정제를 구현하세요. 적절한 오류 처리 및 타입 안전성과 결합하면, 훅을 통해 강력하고 프로덕션 준비가 된 에이전트 시스템을 구축할 수 있습니다.
|
||||
|
||||
498
docs/ko/learn/tool-hooks.mdx
Normal file
498
docs/ko/learn/tool-hooks.mdx
Normal file
@@ -0,0 +1,498 @@
|
||||
---
|
||||
title: 도구 호출 훅
|
||||
description: CrewAI에서 도구 실행을 가로채고, 수정하고, 제어하는 도구 호출 훅 사용 방법 배우기
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
도구 호출 훅(Tool Call Hooks)은 에이전트 작업 중 도구 실행에 대한 세밀한 제어를 제공합니다. 이러한 훅을 사용하면 도구 호출을 가로채고, 입력을 수정하고, 출력을 변환하고, 안전 검사를 구현하고, 포괄적인 로깅 또는 모니터링을 추가할 수 있습니다.
|
||||
|
||||
## 개요
|
||||
|
||||
도구 훅은 두 가지 중요한 시점에 실행됩니다:
|
||||
- **도구 호출 전**: 입력 수정, 매개변수 검증 또는 실행 차단
|
||||
- **도구 호출 후**: 결과 변환, 출력 정제 또는 실행 세부사항 로깅
|
||||
|
||||
## 훅 타입
|
||||
|
||||
### 도구 호출 전 훅
|
||||
|
||||
모든 도구 실행 전에 실행되며, 다음을 수행할 수 있습니다:
|
||||
- 도구 입력 검사 및 수정
|
||||
- 조건에 따라 도구 실행 차단
|
||||
- 위험한 작업에 대한 승인 게이트 구현
|
||||
- 매개변수 검증
|
||||
- 도구 호출 로깅
|
||||
|
||||
**시그니처:**
|
||||
```python
|
||||
def before_hook(context: ToolCallHookContext) -> bool | None:
|
||||
# 실행을 차단하려면 False 반환
|
||||
# 실행을 허용하려면 True 또는 None 반환
|
||||
...
|
||||
```
|
||||
|
||||
### 도구 호출 후 훅
|
||||
|
||||
모든 도구 실행 후에 실행되며, 다음을 수행할 수 있습니다:
|
||||
- 도구 결과 수정 또는 정제
|
||||
- 메타데이터 또는 서식 추가
|
||||
- 실행 결과 로깅
|
||||
- 결과 검증 구현
|
||||
- 출력 형식 변환
|
||||
|
||||
**시그니처:**
|
||||
```python
|
||||
def after_hook(context: ToolCallHookContext) -> str | None:
|
||||
# 수정된 결과 문자열 반환
|
||||
# 원본 결과를 유지하려면 None 반환
|
||||
...
|
||||
```
|
||||
|
||||
## 도구 훅 컨텍스트
|
||||
|
||||
`ToolCallHookContext` 객체는 도구 실행 상태에 대한 포괄적인 액세스를 제공합니다:
|
||||
|
||||
```python
|
||||
class ToolCallHookContext:
|
||||
tool_name: str # 호출되는 도구의 이름
|
||||
tool_input: dict[str, Any] # 변경 가능한 도구 입력 매개변수
|
||||
tool: CrewStructuredTool # 도구 인스턴스 참조
|
||||
agent: Agent | BaseAgent | None # 도구를 실행하는 에이전트
|
||||
task: Task | None # 현재 작업
|
||||
crew: Crew | None # 크루 인스턴스
|
||||
tool_result: str | None # 도구 결과 (후 훅용)
|
||||
```
|
||||
|
||||
### 도구 입력 수정
|
||||
|
||||
**중요:** 항상 도구 입력을 제자리에서 수정하세요:
|
||||
|
||||
```python
|
||||
# ✅ 올바름 - 제자리에서 수정
|
||||
def sanitize_input(context: ToolCallHookContext) -> None:
|
||||
context.tool_input['query'] = context.tool_input['query'].lower()
|
||||
|
||||
# ❌ 잘못됨 - 딕셔너리 참조를 교체
|
||||
def wrong_approach(context: ToolCallHookContext) -> None:
|
||||
context.tool_input = {'query': 'new query'}
|
||||
```
|
||||
|
||||
## 등록 방법
|
||||
|
||||
### 1. 데코레이터 기반 등록 (권장)
|
||||
|
||||
더 깔끔한 구문을 위해 데코레이터를 사용합니다:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_tool_call, after_tool_call
|
||||
|
||||
@before_tool_call
|
||||
def block_dangerous_tools(context):
|
||||
"""위험한 도구를 차단합니다."""
|
||||
dangerous_tools = ['delete_database', 'drop_table', 'rm_rf']
|
||||
if context.tool_name in dangerous_tools:
|
||||
print(f"⛔ 위험한 도구 차단됨: {context.tool_name}")
|
||||
return False # 실행 차단
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def sanitize_results(context):
|
||||
"""결과를 정제합니다."""
|
||||
if context.tool_result and "password" in context.tool_result.lower():
|
||||
return context.tool_result.replace("password", "[수정됨]")
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. 크루 범위 훅
|
||||
|
||||
특정 크루 인스턴스에 대한 훅을 등록합니다:
|
||||
|
||||
```python
|
||||
from crewai import CrewBase
|
||||
from crewai.project import crew
|
||||
from crewai.hooks import before_tool_call_crew, after_tool_call_crew
|
||||
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_tool_call_crew
|
||||
def validate_tool_inputs(self, context):
|
||||
# 이 크루에만 적용됩니다
|
||||
if context.tool_name == "web_search":
|
||||
if not context.tool_input.get('query'):
|
||||
print("❌ 잘못된 검색 쿼리")
|
||||
return False
|
||||
return None
|
||||
|
||||
@after_tool_call_crew
|
||||
def log_tool_results(self, context):
|
||||
# 크루별 도구 로깅
|
||||
print(f"✅ {context.tool_name} 완료됨")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential,
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
|
||||
## 일반적인 사용 사례
|
||||
|
||||
### 1. 안전 가드레일
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safety_check(context: ToolCallHookContext) -> bool | None:
|
||||
"""해를 끼칠 수 있는 도구를 차단합니다."""
|
||||
destructive_tools = [
|
||||
'delete_file',
|
||||
'drop_table',
|
||||
'remove_user',
|
||||
'system_shutdown'
|
||||
]
|
||||
|
||||
if context.tool_name in destructive_tools:
|
||||
print(f"🛑 파괴적인 도구 차단됨: {context.tool_name}")
|
||||
return False
|
||||
|
||||
# 민감한 작업에 대해 경고
|
||||
sensitive_tools = ['send_email', 'post_to_social_media', 'charge_payment']
|
||||
if context.tool_name in sensitive_tools:
|
||||
print(f"⚠️ 민감한 도구 실행 중: {context.tool_name}")
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. 사람의 승인 게이트
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def require_approval_for_actions(context: ToolCallHookContext) -> bool | None:
|
||||
"""특정 작업에 대한 승인을 요구합니다."""
|
||||
approval_required = [
|
||||
'send_email',
|
||||
'make_purchase',
|
||||
'delete_file',
|
||||
'post_message'
|
||||
]
|
||||
|
||||
if context.tool_name in approval_required:
|
||||
response = context.request_human_input(
|
||||
prompt=f"{context.tool_name}을(를) 승인하시겠습니까?",
|
||||
default_message=f"입력: {context.tool_input}\n승인하려면 'yes'를 입력하세요:"
|
||||
)
|
||||
|
||||
if response.lower() != 'yes':
|
||||
print(f"❌ 도구 실행 거부됨: {context.tool_name}")
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 3. 입력 검증 및 정제
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def validate_and_sanitize_inputs(context: ToolCallHookContext) -> bool | None:
|
||||
"""입력을 검증하고 정제합니다."""
|
||||
# 검색 쿼리 검증
|
||||
if context.tool_name == 'web_search':
|
||||
query = context.tool_input.get('query', '')
|
||||
if len(query) < 3:
|
||||
print("❌ 검색 쿼리가 너무 짧습니다")
|
||||
return False
|
||||
|
||||
# 쿼리 정제
|
||||
context.tool_input['query'] = query.strip().lower()
|
||||
|
||||
# 파일 경로 검증
|
||||
if context.tool_name == 'read_file':
|
||||
path = context.tool_input.get('path', '')
|
||||
if '..' in path or path.startswith('/'):
|
||||
print("❌ 잘못된 파일 경로")
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 4. 결과 정제
|
||||
|
||||
```python
|
||||
@after_tool_call
|
||||
def sanitize_sensitive_data(context: ToolCallHookContext) -> str | None:
|
||||
"""민감한 데이터를 정제합니다."""
|
||||
if not context.tool_result:
|
||||
return None
|
||||
|
||||
import re
|
||||
result = context.tool_result
|
||||
|
||||
# API 키 제거
|
||||
result = re.sub(
|
||||
r'(api[_-]?key|token)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||
r'\1: [수정됨]',
|
||||
result,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
# 이메일 주소 제거
|
||||
result = re.sub(
|
||||
r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
||||
'[이메일-수정됨]',
|
||||
result
|
||||
)
|
||||
|
||||
# 신용카드 번호 제거
|
||||
result = re.sub(
|
||||
r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b',
|
||||
'[카드-수정됨]',
|
||||
result
|
||||
)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### 5. 도구 사용 분석
|
||||
|
||||
```python
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
tool_stats = defaultdict(lambda: {'count': 0, 'total_time': 0, 'failures': 0})
|
||||
|
||||
@before_tool_call
|
||||
def start_timer(context: ToolCallHookContext) -> None:
|
||||
context.tool_input['_start_time'] = time.time()
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def track_tool_usage(context: ToolCallHookContext) -> None:
|
||||
start_time = context.tool_input.get('_start_time', time.time())
|
||||
duration = time.time() - start_time
|
||||
|
||||
tool_stats[context.tool_name]['count'] += 1
|
||||
tool_stats[context.tool_name]['total_time'] += duration
|
||||
|
||||
if not context.tool_result or 'error' in context.tool_result.lower():
|
||||
tool_stats[context.tool_name]['failures'] += 1
|
||||
|
||||
print(f"""
|
||||
📊 {context.tool_name} 도구 통계:
|
||||
- 실행 횟수: {tool_stats[context.tool_name]['count']}
|
||||
- 평균 시간: {tool_stats[context.tool_name]['total_time'] / tool_stats[context.tool_name]['count']:.2f}초
|
||||
- 실패: {tool_stats[context.tool_name]['failures']}
|
||||
""")
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 6. 속도 제한
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
tool_call_history = defaultdict(list)
|
||||
|
||||
@before_tool_call
|
||||
def rate_limit_tools(context: ToolCallHookContext) -> bool | None:
|
||||
"""도구 호출 속도를 제한합니다."""
|
||||
tool_name = context.tool_name
|
||||
now = datetime.now()
|
||||
|
||||
# 오래된 항목 정리 (1분 이상 된 것)
|
||||
tool_call_history[tool_name] = [
|
||||
call_time for call_time in tool_call_history[tool_name]
|
||||
if now - call_time < timedelta(minutes=1)
|
||||
]
|
||||
|
||||
# 속도 제한 확인 (분당 최대 10회 호출)
|
||||
if len(tool_call_history[tool_name]) >= 10:
|
||||
print(f"🚫 {tool_name}에 대한 속도 제한 초과")
|
||||
return False
|
||||
|
||||
# 이 호출 기록
|
||||
tool_call_history[tool_name].append(now)
|
||||
return None
|
||||
```
|
||||
|
||||
### 7. 디버그 로깅
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def debug_tool_call(context: ToolCallHookContext) -> None:
|
||||
"""도구 호출을 디버그합니다."""
|
||||
print(f"""
|
||||
🔍 도구 호출 디버그:
|
||||
- 도구: {context.tool_name}
|
||||
- 에이전트: {context.agent.role if context.agent else '알 수 없음'}
|
||||
- 작업: {context.task.description[:50] if context.task else '알 수 없음'}...
|
||||
- 입력: {context.tool_input}
|
||||
""")
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def debug_tool_result(context: ToolCallHookContext) -> None:
|
||||
"""도구 결과를 디버그합니다."""
|
||||
if context.tool_result:
|
||||
result_preview = context.tool_result[:200]
|
||||
print(f"✅ 결과 미리보기: {result_preview}...")
|
||||
else:
|
||||
print("⚠️ 반환된 결과 없음")
|
||||
return None
|
||||
```
|
||||
|
||||
## 훅 관리
|
||||
|
||||
### 훅 등록 해제
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_tool_call_hook,
|
||||
unregister_after_tool_call_hook
|
||||
)
|
||||
|
||||
# 특정 훅 등록 해제
|
||||
def my_hook(context):
|
||||
...
|
||||
|
||||
register_before_tool_call_hook(my_hook)
|
||||
# 나중에...
|
||||
success = unregister_before_tool_call_hook(my_hook)
|
||||
print(f"등록 해제됨: {success}")
|
||||
```
|
||||
|
||||
### 훅 지우기
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_tool_call_hooks,
|
||||
clear_after_tool_call_hooks,
|
||||
clear_all_tool_call_hooks
|
||||
)
|
||||
|
||||
# 특정 훅 타입 지우기
|
||||
count = clear_before_tool_call_hooks()
|
||||
print(f"{count}개의 전(before) 훅이 지워졌습니다")
|
||||
|
||||
# 모든 도구 훅 지우기
|
||||
before_count, after_count = clear_all_tool_call_hooks()
|
||||
print(f"{before_count}개의 전(before) 훅과 {after_count}개의 후(after) 훅이 지워졌습니다")
|
||||
```
|
||||
|
||||
## 고급 패턴
|
||||
|
||||
### 조건부 훅 실행
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def conditional_blocking(context: ToolCallHookContext) -> bool | None:
|
||||
"""특정 조건에서만 차단합니다."""
|
||||
# 특정 에이전트에 대해서만 차단
|
||||
if context.agent and context.agent.role == "junior_agent":
|
||||
if context.tool_name in ['delete_file', 'send_email']:
|
||||
print(f"❌ 주니어 에이전트는 {context.tool_name}을(를) 사용할 수 없습니다")
|
||||
return False
|
||||
|
||||
# 특정 작업 중에만 차단
|
||||
if context.task and "민감한" in context.task.description.lower():
|
||||
if context.tool_name == 'web_search':
|
||||
print("❌ 민감한 작업에서는 웹 검색이 차단됩니다")
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 컨텍스트 인식 입력 수정
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def enhance_tool_inputs(context: ToolCallHookContext) -> None:
|
||||
"""에이전트 역할에 따라 컨텍스트를 추가합니다."""
|
||||
# 에이전트 역할에 따라 컨텍스트 추가
|
||||
if context.agent and context.agent.role == "researcher":
|
||||
if context.tool_name == 'web_search':
|
||||
# 연구원에 대한 도메인 제한 추가
|
||||
context.tool_input['domains'] = ['edu', 'gov', 'org']
|
||||
|
||||
# 작업에 따라 컨텍스트 추가
|
||||
if context.task and "긴급" in context.task.description.lower():
|
||||
if context.tool_name == 'send_email':
|
||||
context.tool_input['priority'] = 'high'
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
## 모범 사례
|
||||
|
||||
1. **훅을 집중적으로 유지**: 각 훅은 단일 책임을 가져야 합니다
|
||||
2. **무거운 계산 피하기**: 훅은 모든 도구 호출마다 실행됩니다
|
||||
3. **오류를 우아하게 처리**: try-except를 사용하여 훅 실패 방지
|
||||
4. **타입 힌트 사용**: 더 나은 IDE 지원을 위해 `ToolCallHookContext` 활용
|
||||
5. **차단 조건 문서화**: 도구가 차단되는 시기/이유를 명확히 하세요
|
||||
6. **훅을 독립적으로 테스트**: 프로덕션에서 사용하기 전에 단위 테스트
|
||||
7. **테스트에서 훅 지우기**: 테스트 실행 간 `clear_all_tool_call_hooks()` 사용
|
||||
8. **제자리에서 수정**: 항상 `context.tool_input`을 제자리에서 수정하고 교체하지 마세요
|
||||
9. **중요한 결정 로깅**: 특히 도구 실행을 차단할 때
|
||||
10. **성능 고려**: 가능한 경우 비용이 많이 드는 검증을 캐시
|
||||
|
||||
## 오류 처리
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safe_validation(context: ToolCallHookContext) -> bool | None:
|
||||
try:
|
||||
# 검증 로직
|
||||
if not validate_input(context.tool_input):
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"⚠️ 훅 오류: {e}")
|
||||
# 결정: 오류 발생 시 허용 또는 차단
|
||||
return None # 오류에도 불구하고 실행 허용
|
||||
```
|
||||
|
||||
## 타입 안전성
|
||||
|
||||
```python
|
||||
from crewai.hooks import ToolCallHookContext, BeforeToolCallHookType, AfterToolCallHookType
|
||||
|
||||
# 명시적 타입 주석
|
||||
def my_before_hook(context: ToolCallHookContext) -> bool | None:
|
||||
return None
|
||||
|
||||
def my_after_hook(context: ToolCallHookContext) -> str | None:
|
||||
return None
|
||||
|
||||
# 타입 안전 등록
|
||||
register_before_tool_call_hook(my_before_hook)
|
||||
register_after_tool_call_hook(my_after_hook)
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 훅이 실행되지 않음
|
||||
- 크루 실행 전에 훅이 등록되었는지 확인
|
||||
- 이전 훅이 `False`를 반환했는지 확인 (실행 및 후속 훅 차단)
|
||||
- 훅 시그니처가 예상 타입과 일치하는지 확인
|
||||
|
||||
### 입력 수정이 작동하지 않음
|
||||
- 제자리 수정 사용: `context.tool_input['key'] = value`
|
||||
- 딕셔너리를 교체하지 마세요: `context.tool_input = {}`
|
||||
|
||||
### 결과 수정이 작동하지 않음
|
||||
- 후 훅에서 수정된 문자열을 반환
|
||||
- `None`을 반환하면 원본 결과가 유지됩니다
|
||||
- 도구가 실제로 결과를 반환했는지 확인
|
||||
|
||||
### 도구가 예기치 않게 차단됨
|
||||
- 차단 조건에 대한 모든 전(before) 훅 확인
|
||||
- 훅 실행 순서 확인
|
||||
- 어떤 훅이 차단하는지 식별하기 위해 디버그 로깅 추가
|
||||
|
||||
## 결론
|
||||
|
||||
도구 호출 훅은 CrewAI에서 도구 실행을 제어하고 모니터링하는 강력한 기능을 제공합니다. 이를 사용하여 안전 가드레일, 승인 게이트, 입력 검증, 결과 정제, 로깅 및 분석을 구현하세요. 적절한 오류 처리 및 타입 안전성과 결합하면, 훅을 통해 포괄적인 관찰성을 갖춘 안전하고 프로덕션 준비가 된 에이전트 시스템을 구축할 수 있습니다.
|
||||
|
||||
@@ -730,9 +730,7 @@ Portkey 대시보드에서 [구성 페이지](https://app.portkey.ai/configs)에
|
||||
- 로그를 필터링하기 위한 관련 메타데이터 수집
|
||||
- 액세스 권한 적용
|
||||
|
||||
API 키 생성 방법:
|
||||
- [Portkey App](https://app.portkey.ai/)
|
||||
- [API Key Management API](/ko/api-reference/admin-api/control-plane/api-keys/create-api-key)
|
||||
[Portkey App](https://app.portkey.ai/)를 통해 API 키를 생성하세요
|
||||
|
||||
Python SDK를 사용한 예시:
|
||||
```python
|
||||
@@ -755,7 +753,7 @@ api_key = portkey.api_keys.create(
|
||||
)
|
||||
```
|
||||
|
||||
자세한 키 관리 방법은 [API 키 문서](/ko/api-reference/admin-api/control-plane/api-keys/create-api-key)를 참조하세요.
|
||||
자세한 키 관리 방법은 [Portkey 문서](https://portkey.ai/docs)를 참조하세요.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="4단계: 배포 및 모니터링">
|
||||
|
||||
@@ -18,7 +18,7 @@ mode: "wide"
|
||||
파일을 Amazon S3 스토리지에 작성하고 업로드합니다.
|
||||
</Card>
|
||||
|
||||
<Card title="Bedrock Invoke Agent" icon="aws" href="/ko/tools/cloud-storage/bedrockinvokeagenttool">
|
||||
<Card title="Bedrock Invoke Agent" icon="aws" href="/ko/tools/integration/bedrockinvokeagenttool">
|
||||
AI 기반 작업을 위해 Amazon Bedrock 에이전트를 호출합니다.
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ mode: "wide"
|
||||
<Card
|
||||
title="Bedrock Invoke Agent Tool"
|
||||
icon="cloud"
|
||||
href="/en/tools/tool-integrations/bedrockinvokeagenttool"
|
||||
href="/ko/tools/integration/bedrockinvokeagenttool"
|
||||
color="#0891B2"
|
||||
>
|
||||
Invoke Amazon Bedrock Agents from CrewAI to orchestrate actions across AWS services.
|
||||
@@ -20,7 +20,7 @@ mode: "wide"
|
||||
<Card
|
||||
title="CrewAI Automation Tool"
|
||||
icon="bolt"
|
||||
href="/en/tools/tool-integrations/crewaiautomationtool"
|
||||
href="/ko/tools/integration/crewaiautomationtool"
|
||||
color="#7C3AED"
|
||||
>
|
||||
Automate deployment and operations by integrating CrewAI with external platforms and workflows.
|
||||
|
||||
@@ -704,7 +704,7 @@ class KnowledgeMonitorListener(BaseEventListener):
|
||||
knowledge_monitor = KnowledgeMonitorListener()
|
||||
```
|
||||
|
||||
Para mais informações sobre como usar eventos, consulte a documentação [Event Listeners](https://docs.crewai.com/concepts/event-listener).
|
||||
Para mais informações sobre como usar eventos, consulte a documentação [Event Listeners](/pt-BR/concepts/event-listener).
|
||||
|
||||
### Fontes de Knowledge Personalizadas
|
||||
|
||||
|
||||
@@ -725,7 +725,7 @@ O CrewAI suporta respostas em streaming de LLMs, permitindo que sua aplicação
|
||||
```
|
||||
|
||||
<Tip>
|
||||
[Clique aqui](https://docs.crewai.com/concepts/event-listener#event-listeners) para mais detalhes
|
||||
[Clique aqui](/pt-BR/concepts/event-listener#event-listeners) para mais detalhes
|
||||
</Tip>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -36,7 +36,7 @@ Você também pode baixar templates diretamente do marketplace clicando em `Down
|
||||
<Card title="Ferramentas & Integrações" href="/pt-BR/enterprise/features/tools-and-integrations" icon="wrench">
|
||||
Conecte apps externos e gerencie ferramentas internas que seus agentes podem usar.
|
||||
</Card>
|
||||
<Card title="Repositório de Ferramentas" href="/pt-BR/enterprise/features/tool-repository" icon="toolbox">
|
||||
<Card title="Repositório de Ferramentas" href="/pt-BR/enterprise/guides/tool-repository" icon="toolbox">
|
||||
Publique e instale ferramentas para ampliar as capacidades dos seus crews.
|
||||
</Card>
|
||||
<Card title="Repositório de Agentes" href="/pt-BR/enterprise/features/agent-repositories" icon="people-group">
|
||||
|
||||
@@ -231,7 +231,7 @@ Ferramentas & Integrações é o hub central para conectar aplicações de terce
|
||||
## Relacionados
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Repositório de Ferramentas" href="/pt-BR/enterprise/features/tool-repository" icon="toolbox">
|
||||
<Card title="Repositório de Ferramentas" href="/pt-BR/enterprise/guides/tool-repository" icon="toolbox">
|
||||
Publique e instale ferramentas para ampliar as capacidades dos seus crews.
|
||||
</Card>
|
||||
<Card title="Automação com Webhook" href="/pt-BR/enterprise/guides/webhook-automation" icon="bolt">
|
||||
|
||||
@@ -21,7 +21,7 @@ O repositório não é um sistema de controle de versões. Use Git para rastrear
|
||||
Antes de usar o Repositório de Ferramentas, certifique-se de que você possui:
|
||||
|
||||
- Uma conta [CrewAI AMP](https://app.crewai.com)
|
||||
- [CrewAI CLI](https://docs.crewai.com/concepts/cli#cli) instalada
|
||||
- [CrewAI CLI](/pt-BR/concepts/cli#cli) instalada
|
||||
- uv>=0.5.0 instalado. Veja [como atualizar](https://docs.astral.sh/uv/getting-started/installation/#upgrading-uv)
|
||||
- [Git](https://git-scm.com) instalado e configurado
|
||||
- Permissões de acesso para publicar ou instalar ferramentas em sua organização CrewAI AMP
|
||||
@@ -66,7 +66,7 @@ Por padrão, as ferramentas são publicadas como privadas. Para tornar uma ferra
|
||||
crewai tool publish --public
|
||||
```
|
||||
|
||||
Para mais detalhes sobre como construir ferramentas, acesse [Criando suas próprias ferramentas](https://docs.crewai.com/concepts/tools#creating-your-own-tools).
|
||||
Para mais detalhes sobre como construir ferramentas, acesse [Criando suas próprias ferramentas](/pt-BR/concepts/tools#creating-your-own-tools).
|
||||
|
||||
## Atualizando ferramentas
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ mode: "wide"
|
||||
|
||||
Para integrar a entrada humana na execução do agente, defina a flag `human_input` na definição da tarefa. Quando habilitada, o agente solicitará a entrada do usuário antes de entregar sua resposta final. Essa entrada pode fornecer contexto extra, esclarecer ambiguidades ou validar a saída do agente.
|
||||
|
||||
Para orientações detalhadas de implementação, veja nosso [guia Human-in-the-Loop](/pt-BR/how-to/human-in-the-loop).
|
||||
Para orientações detalhadas de implementação, veja nosso [guia Human-in-the-Loop](/pt-BR/enterprise/guides/human-in-the-loop).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Quais opções avançadas de customização estão disponíveis para aprimorar e personalizar o comportamento e as capacidades dos agentes na CrewAI?">
|
||||
@@ -142,7 +142,7 @@ mode: "wide"
|
||||
<Accordion title="Como posso criar ferramentas personalizadas para meus agentes CrewAI?">
|
||||
Você pode criar ferramentas personalizadas herdando da classe `BaseTool` fornecida pela CrewAI ou usando o decorador de ferramenta. Herdar envolve definir uma nova classe que herda de `BaseTool`, especificando o nome, a descrição e o método `_run` para a lógica operacional. O decorador de ferramenta permite criar um objeto `Tool` diretamente com os atributos necessários e uma lógica funcional.
|
||||
|
||||
<Card href="https://docs.crewai.com/how-to/create-custom-tools" icon="code">CrewAI Tools Guide</Card>
|
||||
<Card href="/pt-BR/learn/create-custom-tools" icon="code">CrewAI Tools Guide</Card>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Como controlar o número máximo de solicitações por minuto que toda a crew pode realizar?">
|
||||
|
||||
379
docs/pt-BR/learn/execution-hooks.mdx
Normal file
379
docs/pt-BR/learn/execution-hooks.mdx
Normal file
@@ -0,0 +1,379 @@
|
||||
---
|
||||
title: Visão Geral dos Hooks de Execução
|
||||
description: Entendendo e usando hooks de execução no CrewAI para controle fino sobre operações de agentes
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
Os Hooks de Execução fornecem controle fino sobre o comportamento em tempo de execução dos seus agentes CrewAI. Diferentemente dos hooks de kickoff que são executados antes e depois da execução da crew, os hooks de execução interceptam operações específicas durante a execução do agente, permitindo que você modifique comportamentos, implemente verificações de segurança e adicione monitoramento abrangente.
|
||||
|
||||
## Tipos de Hooks de Execução
|
||||
|
||||
O CrewAI fornece duas categorias principais de hooks de execução:
|
||||
|
||||
### 1. [Hooks de Chamada LLM](/learn/llm-hooks)
|
||||
|
||||
Controle e monitore interações com o modelo de linguagem:
|
||||
- **Antes da Chamada LLM**: Modifique prompts, valide entradas, implemente gates de aprovação
|
||||
- **Depois da Chamada LLM**: Transforme respostas, sanitize saídas, atualize histórico de conversação
|
||||
|
||||
**Casos de Uso:**
|
||||
- Limitação de iterações
|
||||
- Rastreamento de custos e monitoramento de uso de tokens
|
||||
- Sanitização de respostas e filtragem de conteúdo
|
||||
- Aprovação humana para chamadas LLM
|
||||
- Adição de diretrizes de segurança ou contexto
|
||||
- Logging de debug e inspeção de requisição/resposta
|
||||
|
||||
[Ver Documentação de Hooks LLM →](/learn/llm-hooks)
|
||||
|
||||
### 2. [Hooks de Chamada de Ferramenta](/learn/tool-hooks)
|
||||
|
||||
Controle e monitore execução de ferramentas:
|
||||
- **Antes da Chamada de Ferramenta**: Modifique entradas, valide parâmetros, bloqueie operações perigosas
|
||||
- **Depois da Chamada de Ferramenta**: Transforme resultados, sanitize saídas, registre detalhes de execução
|
||||
|
||||
**Casos de Uso:**
|
||||
- Guardrails de segurança para operações destrutivas
|
||||
- Aprovação humana para ações sensíveis
|
||||
- Validação e sanitização de entrada
|
||||
- Cache de resultados e limitação de taxa
|
||||
- Análise de uso de ferramentas
|
||||
- Logging de debug e monitoramento
|
||||
|
||||
[Ver Documentação de Hooks de Ferramenta →](/learn/tool-hooks)
|
||||
|
||||
## Métodos de Registro
|
||||
|
||||
### 1. Hooks Baseados em Decoradores (Recomendado)
|
||||
|
||||
A maneira mais limpa e pythônica de registrar hooks:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_llm_call, after_llm_call, before_tool_call, after_tool_call
|
||||
|
||||
@before_llm_call
|
||||
def limit_iterations(context):
|
||||
"""Previne loops infinitos limitando iterações."""
|
||||
if context.iterations > 10:
|
||||
return False # Bloquear execução
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def sanitize_response(context):
|
||||
"""Remove dados sensíveis das respostas do LLM."""
|
||||
if "API_KEY" in context.response:
|
||||
return context.response.replace("API_KEY", "[CENSURADO]")
|
||||
return None
|
||||
|
||||
@before_tool_call
|
||||
def block_dangerous_tools(context):
|
||||
"""Bloqueia operações destrutivas."""
|
||||
if context.tool_name == "delete_database":
|
||||
return False # Bloquear execução
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def log_tool_result(context):
|
||||
"""Registra execução de ferramenta."""
|
||||
print(f"Ferramenta {context.tool_name} concluída")
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. Hooks com Escopo de Crew
|
||||
|
||||
Aplica hooks apenas a instâncias específicas de crew:
|
||||
|
||||
```python
|
||||
from crewai import CrewBase
|
||||
from crewai.project import crew
|
||||
from crewai.hooks import before_llm_call_crew, after_tool_call_crew
|
||||
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_llm_call_crew
|
||||
def validate_inputs(self, context):
|
||||
# Aplica-se apenas a esta crew
|
||||
print(f"Chamada LLM em {self.__class__.__name__}")
|
||||
return None
|
||||
|
||||
@after_tool_call_crew
|
||||
def log_results(self, context):
|
||||
# Logging específico da crew
|
||||
print(f"Resultado da ferramenta: {context.tool_result[:50]}...")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential
|
||||
)
|
||||
```
|
||||
|
||||
## Fluxo de Execução de Hooks
|
||||
|
||||
### Fluxo de Chamada LLM
|
||||
|
||||
```
|
||||
Agente precisa chamar LLM
|
||||
↓
|
||||
[Hooks Antes da Chamada LLM Executam]
|
||||
├→ Hook 1: Validar contagem de iterações
|
||||
├→ Hook 2: Adicionar contexto de segurança
|
||||
└→ Hook 3: Registrar requisição
|
||||
↓
|
||||
Se algum hook retornar False:
|
||||
├→ Bloquear chamada LLM
|
||||
└→ Lançar ValueError
|
||||
↓
|
||||
Se todos os hooks retornarem True/None:
|
||||
├→ Chamada LLM prossegue
|
||||
└→ Resposta gerada
|
||||
↓
|
||||
[Hooks Depois da Chamada LLM Executam]
|
||||
├→ Hook 1: Sanitizar resposta
|
||||
├→ Hook 2: Registrar resposta
|
||||
└→ Hook 3: Atualizar métricas
|
||||
↓
|
||||
Resposta final retornada
|
||||
```
|
||||
|
||||
### Fluxo de Chamada de Ferramenta
|
||||
|
||||
```
|
||||
Agente precisa executar ferramenta
|
||||
↓
|
||||
[Hooks Antes da Chamada de Ferramenta Executam]
|
||||
├→ Hook 1: Verificar se ferramenta é permitida
|
||||
├→ Hook 2: Validar entradas
|
||||
└→ Hook 3: Solicitar aprovação se necessário
|
||||
↓
|
||||
Se algum hook retornar False:
|
||||
├→ Bloquear execução da ferramenta
|
||||
└→ Retornar mensagem de erro
|
||||
↓
|
||||
Se todos os hooks retornarem True/None:
|
||||
├→ Execução da ferramenta prossegue
|
||||
└→ Resultado gerado
|
||||
↓
|
||||
[Hooks Depois da Chamada de Ferramenta Executam]
|
||||
├→ Hook 1: Sanitizar resultado
|
||||
├→ Hook 2: Fazer cache do resultado
|
||||
└→ Hook 3: Registrar métricas
|
||||
↓
|
||||
Resultado final retornado
|
||||
```
|
||||
|
||||
## Objetos de Contexto de Hook
|
||||
|
||||
### LLMCallHookContext
|
||||
|
||||
Fornece acesso ao estado de execução do LLM:
|
||||
|
||||
```python
|
||||
class LLMCallHookContext:
|
||||
executor: CrewAgentExecutor # Acesso completo ao executor
|
||||
messages: list # Lista de mensagens mutável
|
||||
agent: Agent # Agente atual
|
||||
task: Task # Tarefa atual
|
||||
crew: Crew # Instância da crew
|
||||
llm: BaseLLM # Instância do LLM
|
||||
iterations: int # Iteração atual
|
||||
response: str | None # Resposta do LLM (hooks posteriores)
|
||||
```
|
||||
|
||||
### ToolCallHookContext
|
||||
|
||||
Fornece acesso ao estado de execução da ferramenta:
|
||||
|
||||
```python
|
||||
class ToolCallHookContext:
|
||||
tool_name: str # Ferramenta sendo chamada
|
||||
tool_input: dict # Parâmetros de entrada mutáveis
|
||||
tool: CrewStructuredTool # Instância da ferramenta
|
||||
agent: Agent | None # Agente executando
|
||||
task: Task | None # Tarefa atual
|
||||
crew: Crew | None # Instância da crew
|
||||
tool_result: str | None # Resultado da ferramenta (hooks posteriores)
|
||||
```
|
||||
|
||||
## Padrões Comuns
|
||||
|
||||
### Segurança e Validação
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safety_check(context):
|
||||
"""Bloqueia operações destrutivas."""
|
||||
dangerous = ['delete_file', 'drop_table', 'system_shutdown']
|
||||
if context.tool_name in dangerous:
|
||||
print(f"🛑 Bloqueado: {context.tool_name}")
|
||||
return False
|
||||
return None
|
||||
|
||||
@before_llm_call
|
||||
def iteration_limit(context):
|
||||
"""Previne loops infinitos."""
|
||||
if context.iterations > 15:
|
||||
print("⛔ Máximo de iterações excedido")
|
||||
return False
|
||||
return None
|
||||
```
|
||||
|
||||
### Humano no Loop
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def require_approval(context):
|
||||
"""Requer aprovação para operações sensíveis."""
|
||||
sensitive = ['send_email', 'make_payment', 'post_message']
|
||||
|
||||
if context.tool_name in sensitive:
|
||||
response = context.request_human_input(
|
||||
prompt=f"Aprovar {context.tool_name}?",
|
||||
default_message="Digite 'sim' para aprovar:"
|
||||
)
|
||||
|
||||
if response.lower() != 'sim':
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Monitoramento e Análise
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
import time
|
||||
|
||||
metrics = defaultdict(lambda: {'count': 0, 'total_time': 0})
|
||||
|
||||
@before_tool_call
|
||||
def start_timer(context):
|
||||
context.tool_input['_start'] = time.time()
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def track_metrics(context):
|
||||
start = context.tool_input.get('_start', time.time())
|
||||
duration = time.time() - start
|
||||
|
||||
metrics[context.tool_name]['count'] += 1
|
||||
metrics[context.tool_name]['total_time'] += duration
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
## Gerenciamento de Hooks
|
||||
|
||||
### Limpar Todos os Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import clear_all_global_hooks
|
||||
|
||||
# Limpa todos os hooks de uma vez
|
||||
result = clear_all_global_hooks()
|
||||
print(f"Limpou {result['total']} hooks")
|
||||
```
|
||||
|
||||
### Limpar Tipos Específicos de Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_llm_call_hooks,
|
||||
clear_after_llm_call_hooks,
|
||||
clear_before_tool_call_hooks,
|
||||
clear_after_tool_call_hooks
|
||||
)
|
||||
|
||||
# Limpar tipos específicos
|
||||
llm_before_count = clear_before_llm_call_hooks()
|
||||
tool_after_count = clear_after_tool_call_hooks()
|
||||
```
|
||||
|
||||
## Melhores Práticas
|
||||
|
||||
### 1. Mantenha os Hooks Focados
|
||||
Cada hook deve ter uma responsabilidade única e clara.
|
||||
|
||||
### 2. Trate Erros Graciosamente
|
||||
```python
|
||||
@before_llm_call
|
||||
def safe_hook(context):
|
||||
try:
|
||||
if some_condition:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Erro no hook: {e}")
|
||||
return None # Permitir execução apesar do erro
|
||||
```
|
||||
|
||||
### 3. Modifique o Contexto In-Place
|
||||
```python
|
||||
# ✅ Correto - modificar in-place
|
||||
@before_llm_call
|
||||
def add_context(context):
|
||||
context.messages.append({"role": "system", "content": "Seja conciso"})
|
||||
|
||||
# ❌ Errado - substitui referência
|
||||
@before_llm_call
|
||||
def wrong_approach(context):
|
||||
context.messages = [{"role": "system", "content": "Seja conciso"}]
|
||||
```
|
||||
|
||||
### 4. Use Type Hints
|
||||
```python
|
||||
from crewai.hooks import LLMCallHookContext, ToolCallHookContext
|
||||
|
||||
def my_llm_hook(context: LLMCallHookContext) -> bool | None:
|
||||
return None
|
||||
|
||||
def my_tool_hook(context: ToolCallHookContext) -> str | None:
|
||||
return None
|
||||
```
|
||||
|
||||
### 5. Limpe em Testes
|
||||
```python
|
||||
import pytest
|
||||
from crewai.hooks import clear_all_global_hooks
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_hooks():
|
||||
"""Reseta hooks antes de cada teste."""
|
||||
yield
|
||||
clear_all_global_hooks()
|
||||
```
|
||||
|
||||
## Quando Usar Qual Hook
|
||||
|
||||
### Use Hooks LLM Quando:
|
||||
- Implementar limites de iteração
|
||||
- Adicionar contexto ou diretrizes de segurança aos prompts
|
||||
- Rastrear uso de tokens e custos
|
||||
- Sanitizar ou transformar respostas
|
||||
- Implementar gates de aprovação para chamadas LLM
|
||||
- Fazer debug de interações de prompt/resposta
|
||||
|
||||
### Use Hooks de Ferramenta Quando:
|
||||
- Bloquear operações perigosas ou destrutivas
|
||||
- Validar entradas de ferramenta antes da execução
|
||||
- Implementar gates de aprovação para ações sensíveis
|
||||
- Fazer cache de resultados de ferramenta
|
||||
- Rastrear uso e performance de ferramentas
|
||||
- Sanitizar saídas de ferramenta
|
||||
- Limitar taxa de chamadas de ferramenta
|
||||
|
||||
### Use Ambos Quando:
|
||||
Construir sistemas abrangentes de observabilidade, segurança ou aprovação que precisam monitorar todas as operações do agente.
|
||||
|
||||
## Documentação Relacionada
|
||||
|
||||
- [Hooks de Chamada LLM →](/learn/llm-hooks) - Documentação detalhada de hooks LLM
|
||||
- [Hooks de Chamada de Ferramenta →](/learn/tool-hooks) - Documentação detalhada de hooks de ferramenta
|
||||
- [Hooks Antes e Depois do Kickoff →](/learn/before-and-after-kickoff-hooks) - Hooks do ciclo de vida da crew
|
||||
- [Humano no Loop →](/learn/human-in-the-loop) - Padrões de entrada humana
|
||||
|
||||
## Conclusão
|
||||
|
||||
Os Hooks de Execução fornecem controle poderoso sobre o comportamento em tempo de execução do agente. Use-os para implementar guardrails de segurança, fluxos de trabalho de aprovação, monitoramento abrangente e lógica de negócio personalizada. Combinados com tratamento adequado de erros, segurança de tipos e considerações de performance, os hooks permitem sistemas de agentes seguros, prontos para produção e observáveis.
|
||||
@@ -96,7 +96,7 @@ project_crew = Crew(
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Para mais detalhes sobre a criação e personalização de um agente gerente, confira a [documentação do Custom Manager Agent](https://docs.crewai.com/how-to/custom-manager-agent#custom-manager-agent).
|
||||
Para mais detalhes sobre a criação e personalização de um agente gerente, confira a [documentação do Custom Manager Agent](/pt-BR/learn/custom-manager-agent).
|
||||
</Tip>
|
||||
|
||||
|
||||
|
||||
388
docs/pt-BR/learn/llm-hooks.mdx
Normal file
388
docs/pt-BR/learn/llm-hooks.mdx
Normal file
@@ -0,0 +1,388 @@
|
||||
---
|
||||
title: Hooks de Chamada LLM
|
||||
description: Aprenda a usar hooks de chamada LLM para interceptar, modificar e controlar interações com modelos de linguagem no CrewAI
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
Os Hooks de Chamada LLM fornecem controle fino sobre interações com modelos de linguagem durante a execução do agente. Esses hooks permitem interceptar chamadas LLM, modificar prompts, transformar respostas, implementar gates de aprovação e adicionar logging ou monitoramento personalizado.
|
||||
|
||||
## Visão Geral
|
||||
|
||||
Os hooks LLM são executados em dois pontos críticos:
|
||||
- **Antes da Chamada LLM**: Modificar mensagens, validar entradas ou bloquear execução
|
||||
- **Depois da Chamada LLM**: Transformar respostas, sanitizar saídas ou modificar histórico de conversação
|
||||
|
||||
## Tipos de Hook
|
||||
|
||||
### Hooks Antes da Chamada LLM
|
||||
|
||||
Executados antes de cada chamada LLM, esses hooks podem:
|
||||
- Inspecionar e modificar mensagens enviadas ao LLM
|
||||
- Bloquear execução LLM com base em condições
|
||||
- Implementar limitação de taxa ou gates de aprovação
|
||||
- Adicionar contexto ou mensagens do sistema
|
||||
- Registrar detalhes da requisição
|
||||
|
||||
**Assinatura:**
|
||||
```python
|
||||
def before_hook(context: LLMCallHookContext) -> bool | None:
|
||||
# Retorne False para bloquear execução
|
||||
# Retorne True ou None para permitir execução
|
||||
...
|
||||
```
|
||||
|
||||
### Hooks Depois da Chamada LLM
|
||||
|
||||
Executados depois de cada chamada LLM, esses hooks podem:
|
||||
- Modificar ou sanitizar respostas do LLM
|
||||
- Adicionar metadados ou formatação
|
||||
- Registrar detalhes da resposta
|
||||
- Atualizar histórico de conversação
|
||||
- Implementar filtragem de conteúdo
|
||||
|
||||
**Assinatura:**
|
||||
```python
|
||||
def after_hook(context: LLMCallHookContext) -> str | None:
|
||||
# Retorne string de resposta modificada
|
||||
# Retorne None para manter resposta original
|
||||
...
|
||||
```
|
||||
|
||||
## Contexto do Hook LLM
|
||||
|
||||
O objeto `LLMCallHookContext` fornece acesso abrangente ao estado de execução:
|
||||
|
||||
```python
|
||||
class LLMCallHookContext:
|
||||
executor: CrewAgentExecutor # Referência completa ao executor
|
||||
messages: list # Lista de mensagens mutável
|
||||
agent: Agent # Agente atual
|
||||
task: Task # Tarefa atual
|
||||
crew: Crew # Instância da crew
|
||||
llm: BaseLLM # Instância do LLM
|
||||
iterations: int # Contagem de iteração atual
|
||||
response: str | None # Resposta do LLM (apenas hooks posteriores)
|
||||
```
|
||||
|
||||
### Modificando Mensagens
|
||||
|
||||
**Importante:** Sempre modifique mensagens in-place:
|
||||
|
||||
```python
|
||||
# ✅ Correto - modificar in-place
|
||||
def add_context(context: LLMCallHookContext) -> None:
|
||||
context.messages.append({"role": "system", "content": "Seja conciso"})
|
||||
|
||||
# ❌ Errado - substitui referência da lista
|
||||
def wrong_approach(context: LLMCallHookContext) -> None:
|
||||
context.messages = [{"role": "system", "content": "Seja conciso"}]
|
||||
```
|
||||
|
||||
## Métodos de Registro
|
||||
|
||||
### 1. Registro Baseado em Decoradores (Recomendado)
|
||||
|
||||
Use decoradores para sintaxe mais limpa:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_llm_call, after_llm_call
|
||||
|
||||
@before_llm_call
|
||||
def validate_iteration_count(context):
|
||||
"""Valida a contagem de iterações."""
|
||||
if context.iterations > 10:
|
||||
print("⚠️ Máximo de iterações excedido")
|
||||
return False # Bloquear execução
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def sanitize_response(context):
|
||||
"""Remove dados sensíveis."""
|
||||
if context.response and "API_KEY" in context.response:
|
||||
return context.response.replace("API_KEY", "[CENSURADO]")
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. Hooks com Escopo de Crew
|
||||
|
||||
Registre hooks para uma instância específica de crew:
|
||||
|
||||
```python
|
||||
from crewai import CrewBase
|
||||
from crewai.project import crew
|
||||
from crewai.hooks import before_llm_call_crew, after_llm_call_crew
|
||||
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_llm_call_crew
|
||||
def validate_inputs(self, context):
|
||||
# Aplica-se apenas a esta crew
|
||||
if context.iterations == 0:
|
||||
print(f"Iniciando tarefa: {context.task.description}")
|
||||
return None
|
||||
|
||||
@after_llm_call_crew
|
||||
def log_responses(self, context):
|
||||
# Logging específico da crew
|
||||
print(f"Comprimento da resposta: {len(context.response)}")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential,
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
|
||||
## Casos de Uso Comuns
|
||||
|
||||
### 1. Limitação de Iterações
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def limit_iterations(context: LLMCallHookContext) -> bool | None:
|
||||
"""Previne loops infinitos limitando iterações."""
|
||||
max_iterations = 15
|
||||
if context.iterations > max_iterations:
|
||||
print(f"⛔ Bloqueado: Excedeu {max_iterations} iterações")
|
||||
return False # Bloquear execução
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. Gate de Aprovação Humana
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def require_approval(context: LLMCallHookContext) -> bool | None:
|
||||
"""Requer aprovação após certas iterações."""
|
||||
if context.iterations > 5:
|
||||
response = context.request_human_input(
|
||||
prompt=f"Iteração {context.iterations}: Aprovar chamada LLM?",
|
||||
default_message="Pressione Enter para aprovar, ou digite 'não' para bloquear:"
|
||||
)
|
||||
if response.lower() == "não":
|
||||
print("🚫 Chamada LLM bloqueada pelo usuário")
|
||||
return False
|
||||
return None
|
||||
```
|
||||
|
||||
### 3. Adicionando Contexto do Sistema
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def add_guardrails(context: LLMCallHookContext) -> None:
|
||||
"""Adiciona diretrizes de segurança a cada chamada LLM."""
|
||||
context.messages.append({
|
||||
"role": "system",
|
||||
"content": "Garanta que as respostas sejam factuais e cite fontes quando possível."
|
||||
})
|
||||
return None
|
||||
```
|
||||
|
||||
### 4. Sanitização de Resposta
|
||||
|
||||
```python
|
||||
@after_llm_call
|
||||
def sanitize_sensitive_data(context: LLMCallHookContext) -> str | None:
|
||||
"""Remove padrões sensíveis."""
|
||||
if not context.response:
|
||||
return None
|
||||
|
||||
import re
|
||||
sanitized = context.response
|
||||
sanitized = re.sub(r'\b\d{3}\.\d{3}\.\d{3}-\d{2}\b', '[CPF-CENSURADO]', sanitized)
|
||||
sanitized = re.sub(r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b', '[CARTÃO-CENSURADO]', sanitized)
|
||||
|
||||
return sanitized
|
||||
```
|
||||
|
||||
### 5. Rastreamento de Custos
|
||||
|
||||
```python
|
||||
import tiktoken
|
||||
|
||||
@before_llm_call
|
||||
def track_token_usage(context: LLMCallHookContext) -> None:
|
||||
"""Rastreia tokens de entrada."""
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
total_tokens = sum(
|
||||
len(encoding.encode(msg.get("content", "")))
|
||||
for msg in context.messages
|
||||
)
|
||||
print(f"📊 Tokens de entrada: ~{total_tokens}")
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def track_response_tokens(context: LLMCallHookContext) -> None:
|
||||
"""Rastreia tokens de resposta."""
|
||||
if context.response:
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
tokens = len(encoding.encode(context.response))
|
||||
print(f"📊 Tokens de resposta: ~{tokens}")
|
||||
return None
|
||||
```
|
||||
|
||||
### 6. Logging de Debug
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def debug_request(context: LLMCallHookContext) -> None:
|
||||
"""Debug de requisição LLM."""
|
||||
print(f"""
|
||||
🔍 Debug de Chamada LLM:
|
||||
- Agente: {context.agent.role}
|
||||
- Tarefa: {context.task.description[:50]}...
|
||||
- Iteração: {context.iterations}
|
||||
- Contagem de Mensagens: {len(context.messages)}
|
||||
- Última Mensagem: {context.messages[-1] if context.messages else 'Nenhuma'}
|
||||
""")
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def debug_response(context: LLMCallHookContext) -> None:
|
||||
"""Debug de resposta LLM."""
|
||||
if context.response:
|
||||
print(f"✅ Preview da Resposta: {context.response[:100]}...")
|
||||
return None
|
||||
```
|
||||
|
||||
## Gerenciamento de Hooks
|
||||
|
||||
### Desregistrando Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_llm_call_hook,
|
||||
unregister_after_llm_call_hook
|
||||
)
|
||||
|
||||
# Desregistrar hook específico
|
||||
def my_hook(context):
|
||||
...
|
||||
|
||||
register_before_llm_call_hook(my_hook)
|
||||
# Mais tarde...
|
||||
unregister_before_llm_call_hook(my_hook) # Retorna True se encontrado
|
||||
```
|
||||
|
||||
### Limpando Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_llm_call_hooks,
|
||||
clear_after_llm_call_hooks,
|
||||
clear_all_llm_call_hooks
|
||||
)
|
||||
|
||||
# Limpar tipo específico de hook
|
||||
count = clear_before_llm_call_hooks()
|
||||
print(f"Limpou {count} hooks antes")
|
||||
|
||||
# Limpar todos os hooks LLM
|
||||
before_count, after_count = clear_all_llm_call_hooks()
|
||||
print(f"Limpou {before_count} hooks antes e {after_count} hooks depois")
|
||||
```
|
||||
|
||||
## Padrões Avançados
|
||||
|
||||
### Execução Condicional de Hook
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def conditional_blocking(context: LLMCallHookContext) -> bool | None:
|
||||
"""Bloqueia apenas em condições específicas."""
|
||||
# Bloquear apenas para agentes específicos
|
||||
if context.agent.role == "researcher" and context.iterations > 10:
|
||||
return False
|
||||
|
||||
# Bloquear apenas para tarefas específicas
|
||||
if "sensível" in context.task.description.lower() and context.iterations > 5:
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Modificações com Consciência de Contexto
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def adaptive_prompting(context: LLMCallHookContext) -> None:
|
||||
"""Adiciona contexto diferente baseado na iteração."""
|
||||
if context.iterations == 0:
|
||||
context.messages.append({
|
||||
"role": "system",
|
||||
"content": "Comece com uma visão geral de alto nível."
|
||||
})
|
||||
elif context.iterations > 3:
|
||||
context.messages.append({
|
||||
"role": "system",
|
||||
"content": "Foque em detalhes específicos e forneça exemplos."
|
||||
})
|
||||
return None
|
||||
```
|
||||
|
||||
## Melhores Práticas
|
||||
|
||||
1. **Mantenha Hooks Focados**: Cada hook deve ter uma responsabilidade única
|
||||
2. **Evite Computação Pesada**: Hooks executam em cada chamada LLM
|
||||
3. **Trate Erros Graciosamente**: Use try-except para prevenir falhas de hooks
|
||||
4. **Use Type Hints**: Aproveite `LLMCallHookContext` para melhor suporte IDE
|
||||
5. **Documente Comportamento do Hook**: Especialmente para condições de bloqueio
|
||||
6. **Teste Hooks Independentemente**: Teste unitário de hooks antes de usar em produção
|
||||
7. **Limpe Hooks em Testes**: Use `clear_all_llm_call_hooks()` entre execuções de teste
|
||||
8. **Modifique In-Place**: Sempre modifique `context.messages` in-place, nunca substitua
|
||||
|
||||
## Tratamento de Erros
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def safe_hook(context: LLMCallHookContext) -> bool | None:
|
||||
try:
|
||||
# Sua lógica de hook
|
||||
if some_condition:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"⚠️ Erro no hook: {e}")
|
||||
# Decida: permitir ou bloquear em erro
|
||||
return None # Permitir execução apesar do erro
|
||||
```
|
||||
|
||||
## Segurança de Tipos
|
||||
|
||||
```python
|
||||
from crewai.hooks import LLMCallHookContext, BeforeLLMCallHookType, AfterLLMCallHookType
|
||||
|
||||
# Anotações de tipo explícitas
|
||||
def my_before_hook(context: LLMCallHookContext) -> bool | None:
|
||||
return None
|
||||
|
||||
def my_after_hook(context: LLMCallHookContext) -> str | None:
|
||||
return None
|
||||
|
||||
# Registro type-safe
|
||||
register_before_llm_call_hook(my_before_hook)
|
||||
register_after_llm_call_hook(my_after_hook)
|
||||
```
|
||||
|
||||
## Solução de Problemas
|
||||
|
||||
### Hook Não Está Executando
|
||||
- Verifique se o hook está registrado antes da execução da crew
|
||||
- Verifique se hook anterior retornou `False` (bloqueia hooks subsequentes)
|
||||
- Garanta que assinatura do hook corresponda ao tipo esperado
|
||||
|
||||
### Modificações de Mensagem Não Persistem
|
||||
- Use modificações in-place: `context.messages.append()`
|
||||
- Não substitua a lista: `context.messages = []`
|
||||
|
||||
### Modificações de Resposta Não Funcionam
|
||||
- Retorne a string modificada dos hooks posteriores
|
||||
- Retornar `None` mantém a resposta original
|
||||
|
||||
## Conclusão
|
||||
|
||||
Os Hooks de Chamada LLM fornecem capacidades poderosas para controlar e monitorar interações com modelos de linguagem no CrewAI. Use-os para implementar guardrails de segurança, gates de aprovação, logging, rastreamento de custos e sanitização de respostas. Combinados com tratamento adequado de erros e segurança de tipos, os hooks permitem sistemas de agentes robustos e prontos para produção.
|
||||
|
||||
498
docs/pt-BR/learn/tool-hooks.mdx
Normal file
498
docs/pt-BR/learn/tool-hooks.mdx
Normal file
@@ -0,0 +1,498 @@
|
||||
---
|
||||
title: Hooks de Chamada de Ferramenta
|
||||
description: Aprenda a usar hooks de chamada de ferramenta para interceptar, modificar e controlar execução de ferramentas no CrewAI
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
Os Hooks de Chamada de Ferramenta fornecem controle fino sobre a execução de ferramentas durante operações do agente. Esses hooks permitem interceptar chamadas de ferramenta, modificar entradas, transformar saídas, implementar verificações de segurança e adicionar logging ou monitoramento abrangente.
|
||||
|
||||
## Visão Geral
|
||||
|
||||
Os hooks de ferramenta são executados em dois pontos críticos:
|
||||
- **Antes da Chamada de Ferramenta**: Modificar entradas, validar parâmetros ou bloquear execução
|
||||
- **Depois da Chamada de Ferramenta**: Transformar resultados, sanitizar saídas ou registrar detalhes de execução
|
||||
|
||||
## Tipos de Hook
|
||||
|
||||
### Hooks Antes da Chamada de Ferramenta
|
||||
|
||||
Executados antes de cada execução de ferramenta, esses hooks podem:
|
||||
- Inspecionar e modificar entradas de ferramenta
|
||||
- Bloquear execução de ferramenta com base em condições
|
||||
- Implementar gates de aprovação para operações perigosas
|
||||
- Validar parâmetros
|
||||
- Registrar invocações de ferramenta
|
||||
|
||||
**Assinatura:**
|
||||
```python
|
||||
def before_hook(context: ToolCallHookContext) -> bool | None:
|
||||
# Retorne False para bloquear execução
|
||||
# Retorne True ou None para permitir execução
|
||||
...
|
||||
```
|
||||
|
||||
### Hooks Depois da Chamada de Ferramenta
|
||||
|
||||
Executados depois de cada execução de ferramenta, esses hooks podem:
|
||||
- Modificar ou sanitizar resultados de ferramenta
|
||||
- Adicionar metadados ou formatação
|
||||
- Registrar resultados de execução
|
||||
- Implementar validação de resultado
|
||||
- Transformar formatos de saída
|
||||
|
||||
**Assinatura:**
|
||||
```python
|
||||
def after_hook(context: ToolCallHookContext) -> str | None:
|
||||
# Retorne string de resultado modificado
|
||||
# Retorne None para manter resultado original
|
||||
...
|
||||
```
|
||||
|
||||
## Contexto do Hook de Ferramenta
|
||||
|
||||
O objeto `ToolCallHookContext` fornece acesso abrangente ao estado de execução da ferramenta:
|
||||
|
||||
```python
|
||||
class ToolCallHookContext:
|
||||
tool_name: str # Nome da ferramenta sendo chamada
|
||||
tool_input: dict[str, Any] # Parâmetros de entrada mutáveis da ferramenta
|
||||
tool: CrewStructuredTool # Referência da instância da ferramenta
|
||||
agent: Agent | BaseAgent | None # Agente executando a ferramenta
|
||||
task: Task | None # Tarefa atual
|
||||
crew: Crew | None # Instância da crew
|
||||
tool_result: str | None # Resultado da ferramenta (apenas hooks posteriores)
|
||||
```
|
||||
|
||||
### Modificando Entradas de Ferramenta
|
||||
|
||||
**Importante:** Sempre modifique entradas de ferramenta in-place:
|
||||
|
||||
```python
|
||||
# ✅ Correto - modificar in-place
|
||||
def sanitize_input(context: ToolCallHookContext) -> None:
|
||||
context.tool_input['query'] = context.tool_input['query'].lower()
|
||||
|
||||
# ❌ Errado - substitui referência do dict
|
||||
def wrong_approach(context: ToolCallHookContext) -> None:
|
||||
context.tool_input = {'query': 'nova consulta'}
|
||||
```
|
||||
|
||||
## Métodos de Registro
|
||||
|
||||
### 1. Registro Baseado em Decoradores (Recomendado)
|
||||
|
||||
Use decoradores para sintaxe mais limpa:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_tool_call, after_tool_call
|
||||
|
||||
@before_tool_call
|
||||
def block_dangerous_tools(context):
|
||||
"""Bloqueia ferramentas perigosas."""
|
||||
dangerous_tools = ['delete_database', 'drop_table', 'rm_rf']
|
||||
if context.tool_name in dangerous_tools:
|
||||
print(f"⛔ Ferramenta perigosa bloqueada: {context.tool_name}")
|
||||
return False # Bloquear execução
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def sanitize_results(context):
|
||||
"""Sanitiza resultados."""
|
||||
if context.tool_result and "password" in context.tool_result.lower():
|
||||
return context.tool_result.replace("password", "[CENSURADO]")
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. Hooks com Escopo de Crew
|
||||
|
||||
Registre hooks para uma instância específica de crew:
|
||||
|
||||
```python
|
||||
from crewai import CrewBase
|
||||
from crewai.project import crew
|
||||
from crewai.hooks import before_tool_call_crew, after_tool_call_crew
|
||||
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_tool_call_crew
|
||||
def validate_tool_inputs(self, context):
|
||||
# Aplica-se apenas a esta crew
|
||||
if context.tool_name == "web_search":
|
||||
if not context.tool_input.get('query'):
|
||||
print("❌ Consulta de busca inválida")
|
||||
return False
|
||||
return None
|
||||
|
||||
@after_tool_call_crew
|
||||
def log_tool_results(self, context):
|
||||
# Logging de ferramenta específico da crew
|
||||
print(f"✅ {context.tool_name} concluída")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential,
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
|
||||
## Casos de Uso Comuns
|
||||
|
||||
### 1. Guardrails de Segurança
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safety_check(context: ToolCallHookContext) -> bool | None:
|
||||
"""Bloqueia ferramentas que podem causar danos."""
|
||||
destructive_tools = [
|
||||
'delete_file',
|
||||
'drop_table',
|
||||
'remove_user',
|
||||
'system_shutdown'
|
||||
]
|
||||
|
||||
if context.tool_name in destructive_tools:
|
||||
print(f"🛑 Ferramenta destrutiva bloqueada: {context.tool_name}")
|
||||
return False
|
||||
|
||||
# Avisar em operações sensíveis
|
||||
sensitive_tools = ['send_email', 'post_to_social_media', 'charge_payment']
|
||||
if context.tool_name in sensitive_tools:
|
||||
print(f"⚠️ Executando ferramenta sensível: {context.tool_name}")
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. Gate de Aprovação Humana
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def require_approval_for_actions(context: ToolCallHookContext) -> bool | None:
|
||||
"""Requer aprovação para ações específicas."""
|
||||
approval_required = [
|
||||
'send_email',
|
||||
'make_purchase',
|
||||
'delete_file',
|
||||
'post_message'
|
||||
]
|
||||
|
||||
if context.tool_name in approval_required:
|
||||
response = context.request_human_input(
|
||||
prompt=f"Aprovar {context.tool_name}?",
|
||||
default_message=f"Entrada: {context.tool_input}\nDigite 'sim' para aprovar:"
|
||||
)
|
||||
|
||||
if response.lower() != 'sim':
|
||||
print(f"❌ Execução de ferramenta negada: {context.tool_name}")
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 3. Validação e Sanitização de Entrada
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def validate_and_sanitize_inputs(context: ToolCallHookContext) -> bool | None:
|
||||
"""Valida e sanitiza entradas."""
|
||||
# Validar consultas de busca
|
||||
if context.tool_name == 'web_search':
|
||||
query = context.tool_input.get('query', '')
|
||||
if len(query) < 3:
|
||||
print("❌ Consulta de busca muito curta")
|
||||
return False
|
||||
|
||||
# Sanitizar consulta
|
||||
context.tool_input['query'] = query.strip().lower()
|
||||
|
||||
# Validar caminhos de arquivo
|
||||
if context.tool_name == 'read_file':
|
||||
path = context.tool_input.get('path', '')
|
||||
if '..' in path or path.startswith('/'):
|
||||
print("❌ Caminho de arquivo inválido")
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 4. Sanitização de Resultado
|
||||
|
||||
```python
|
||||
@after_tool_call
|
||||
def sanitize_sensitive_data(context: ToolCallHookContext) -> str | None:
|
||||
"""Sanitiza dados sensíveis."""
|
||||
if not context.tool_result:
|
||||
return None
|
||||
|
||||
import re
|
||||
result = context.tool_result
|
||||
|
||||
# Remover chaves de API
|
||||
result = re.sub(
|
||||
r'(api[_-]?key|token)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||
r'\1: [CENSURADO]',
|
||||
result,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
# Remover endereços de email
|
||||
result = re.sub(
|
||||
r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
||||
'[EMAIL-CENSURADO]',
|
||||
result
|
||||
)
|
||||
|
||||
# Remover números de cartão de crédito
|
||||
result = re.sub(
|
||||
r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b',
|
||||
'[CARTÃO-CENSURADO]',
|
||||
result
|
||||
)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### 5. Análise de Uso de Ferramenta
|
||||
|
||||
```python
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
tool_stats = defaultdict(lambda: {'count': 0, 'total_time': 0, 'failures': 0})
|
||||
|
||||
@before_tool_call
|
||||
def start_timer(context: ToolCallHookContext) -> None:
|
||||
context.tool_input['_start_time'] = time.time()
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def track_tool_usage(context: ToolCallHookContext) -> None:
|
||||
start_time = context.tool_input.get('_start_time', time.time())
|
||||
duration = time.time() - start_time
|
||||
|
||||
tool_stats[context.tool_name]['count'] += 1
|
||||
tool_stats[context.tool_name]['total_time'] += duration
|
||||
|
||||
if not context.tool_result or 'error' in context.tool_result.lower():
|
||||
tool_stats[context.tool_name]['failures'] += 1
|
||||
|
||||
print(f"""
|
||||
📊 Estatísticas da Ferramenta {context.tool_name}:
|
||||
- Execuções: {tool_stats[context.tool_name]['count']}
|
||||
- Tempo Médio: {tool_stats[context.tool_name]['total_time'] / tool_stats[context.tool_name]['count']:.2f}s
|
||||
- Falhas: {tool_stats[context.tool_name]['failures']}
|
||||
""")
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 6. Limitação de Taxa
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
tool_call_history = defaultdict(list)
|
||||
|
||||
@before_tool_call
|
||||
def rate_limit_tools(context: ToolCallHookContext) -> bool | None:
|
||||
"""Limita taxa de chamadas de ferramenta."""
|
||||
tool_name = context.tool_name
|
||||
now = datetime.now()
|
||||
|
||||
# Limpar entradas antigas (mais antigas que 1 minuto)
|
||||
tool_call_history[tool_name] = [
|
||||
call_time for call_time in tool_call_history[tool_name]
|
||||
if now - call_time < timedelta(minutes=1)
|
||||
]
|
||||
|
||||
# Verificar limite de taxa (máximo 10 chamadas por minuto)
|
||||
if len(tool_call_history[tool_name]) >= 10:
|
||||
print(f"🚫 Limite de taxa excedido para {tool_name}")
|
||||
return False
|
||||
|
||||
# Registrar esta chamada
|
||||
tool_call_history[tool_name].append(now)
|
||||
return None
|
||||
```
|
||||
|
||||
### 7. Logging de Debug
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def debug_tool_call(context: ToolCallHookContext) -> None:
|
||||
"""Debug de chamada de ferramenta."""
|
||||
print(f"""
|
||||
🔍 Debug de Chamada de Ferramenta:
|
||||
- Ferramenta: {context.tool_name}
|
||||
- Agente: {context.agent.role if context.agent else 'Desconhecido'}
|
||||
- Tarefa: {context.task.description[:50] if context.task else 'Desconhecida'}...
|
||||
- Entrada: {context.tool_input}
|
||||
""")
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def debug_tool_result(context: ToolCallHookContext) -> None:
|
||||
"""Debug de resultado de ferramenta."""
|
||||
if context.tool_result:
|
||||
result_preview = context.tool_result[:200]
|
||||
print(f"✅ Preview do Resultado: {result_preview}...")
|
||||
else:
|
||||
print("⚠️ Nenhum resultado retornado")
|
||||
return None
|
||||
```
|
||||
|
||||
## Gerenciamento de Hooks
|
||||
|
||||
### Desregistrando Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_tool_call_hook,
|
||||
unregister_after_tool_call_hook
|
||||
)
|
||||
|
||||
# Desregistrar hook específico
|
||||
def my_hook(context):
|
||||
...
|
||||
|
||||
register_before_tool_call_hook(my_hook)
|
||||
# Mais tarde...
|
||||
success = unregister_before_tool_call_hook(my_hook)
|
||||
print(f"Desregistrado: {success}")
|
||||
```
|
||||
|
||||
### Limpando Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_tool_call_hooks,
|
||||
clear_after_tool_call_hooks,
|
||||
clear_all_tool_call_hooks
|
||||
)
|
||||
|
||||
# Limpar tipo específico de hook
|
||||
count = clear_before_tool_call_hooks()
|
||||
print(f"Limpou {count} hooks antes")
|
||||
|
||||
# Limpar todos os hooks de ferramenta
|
||||
before_count, after_count = clear_all_tool_call_hooks()
|
||||
print(f"Limpou {before_count} hooks antes e {after_count} hooks depois")
|
||||
```
|
||||
|
||||
## Padrões Avançados
|
||||
|
||||
### Execução Condicional de Hook
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def conditional_blocking(context: ToolCallHookContext) -> bool | None:
|
||||
"""Bloqueia apenas em condições específicas."""
|
||||
# Bloquear apenas para agentes específicos
|
||||
if context.agent and context.agent.role == "junior_agent":
|
||||
if context.tool_name in ['delete_file', 'send_email']:
|
||||
print(f"❌ Agentes júnior não podem usar {context.tool_name}")
|
||||
return False
|
||||
|
||||
# Bloquear apenas durante tarefas específicas
|
||||
if context.task and "sensível" in context.task.description.lower():
|
||||
if context.tool_name == 'web_search':
|
||||
print("❌ Busca na web bloqueada para tarefas sensíveis")
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Modificação de Entrada com Consciência de Contexto
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def enhance_tool_inputs(context: ToolCallHookContext) -> None:
|
||||
"""Adiciona contexto baseado no papel do agente."""
|
||||
# Adicionar contexto baseado no papel do agente
|
||||
if context.agent and context.agent.role == "researcher":
|
||||
if context.tool_name == 'web_search':
|
||||
# Adicionar restrições de domínio para pesquisadores
|
||||
context.tool_input['domains'] = ['edu', 'gov', 'org']
|
||||
|
||||
# Adicionar contexto baseado na tarefa
|
||||
if context.task and "urgente" in context.task.description.lower():
|
||||
if context.tool_name == 'send_email':
|
||||
context.tool_input['priority'] = 'high'
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
## Melhores Práticas
|
||||
|
||||
1. **Mantenha Hooks Focados**: Cada hook deve ter uma responsabilidade única
|
||||
2. **Evite Computação Pesada**: Hooks executam em cada chamada de ferramenta
|
||||
3. **Trate Erros Graciosamente**: Use try-except para prevenir falhas de hooks
|
||||
4. **Use Type Hints**: Aproveite `ToolCallHookContext` para melhor suporte IDE
|
||||
5. **Documente Condições de Bloqueio**: Deixe claro quando/por que ferramentas são bloqueadas
|
||||
6. **Teste Hooks Independentemente**: Teste unitário de hooks antes de usar em produção
|
||||
7. **Limpe Hooks em Testes**: Use `clear_all_tool_call_hooks()` entre execuções de teste
|
||||
8. **Modifique In-Place**: Sempre modifique `context.tool_input` in-place, nunca substitua
|
||||
9. **Registre Decisões Importantes**: Especialmente ao bloquear execução de ferramenta
|
||||
10. **Considere Performance**: Cache validações caras quando possível
|
||||
|
||||
## Tratamento de Erros
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safe_validation(context: ToolCallHookContext) -> bool | None:
|
||||
try:
|
||||
# Sua lógica de validação
|
||||
if not validate_input(context.tool_input):
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"⚠️ Erro no hook: {e}")
|
||||
# Decida: permitir ou bloquear em erro
|
||||
return None # Permitir execução apesar do erro
|
||||
```
|
||||
|
||||
## Segurança de Tipos
|
||||
|
||||
```python
|
||||
from crewai.hooks import ToolCallHookContext, BeforeToolCallHookType, AfterToolCallHookType
|
||||
|
||||
# Anotações de tipo explícitas
|
||||
def my_before_hook(context: ToolCallHookContext) -> bool | None:
|
||||
return None
|
||||
|
||||
def my_after_hook(context: ToolCallHookContext) -> str | None:
|
||||
return None
|
||||
|
||||
# Registro type-safe
|
||||
register_before_tool_call_hook(my_before_hook)
|
||||
register_after_tool_call_hook(my_after_hook)
|
||||
```
|
||||
|
||||
## Solução de Problemas
|
||||
|
||||
### Hook Não Está Executando
|
||||
- Verifique se hook está registrado antes da execução da crew
|
||||
- Verifique se hook anterior retornou `False` (bloqueia execução e hooks subsequentes)
|
||||
- Garanta que assinatura do hook corresponda ao tipo esperado
|
||||
|
||||
### Modificações de Entrada Não Funcionam
|
||||
- Use modificações in-place: `context.tool_input['key'] = value`
|
||||
- Não substitua o dict: `context.tool_input = {}`
|
||||
|
||||
### Modificações de Resultado Não Funcionam
|
||||
- Retorne a string modificada dos hooks posteriores
|
||||
- Retornar `None` mantém o resultado original
|
||||
- Garanta que a ferramenta realmente retornou um resultado
|
||||
|
||||
### Ferramenta Bloqueada Inesperadamente
|
||||
- Verifique todos os hooks antes por condições de bloqueio
|
||||
- Verifique ordem de execução do hook
|
||||
- Adicione logging de debug para identificar qual hook está bloqueando
|
||||
|
||||
## Conclusão
|
||||
|
||||
Os Hooks de Chamada de Ferramenta fornecem capacidades poderosas para controlar e monitorar execução de ferramentas no CrewAI. Use-os para implementar guardrails de segurança, gates de aprovação, validação de entrada, sanitização de resultado, logging e análise. Combinados com tratamento adequado de erros e segurança de tipos, os hooks permitem sistemas de agentes seguros e prontos para produção com observabilidade abrangente.
|
||||
|
||||
@@ -733,9 +733,7 @@ Aqui está um exemplo básico para rotear requisições ao OpenAI, usando especi
|
||||
- Coletam metadados relevantes para filtragem de logs
|
||||
- Impõem permissões de acesso
|
||||
|
||||
Crie chaves de API através de:
|
||||
- [Portkey App](https://app.portkey.ai/)
|
||||
- [API Key Management API](/pt-BR/api-reference/admin-api/control-plane/api-keys/create-api-key)
|
||||
Crie chaves de API através do [Portkey App](https://app.portkey.ai/)
|
||||
|
||||
Exemplo usando Python SDK:
|
||||
```python
|
||||
@@ -758,7 +756,7 @@ Aqui está um exemplo básico para rotear requisições ao OpenAI, usando especi
|
||||
)
|
||||
```
|
||||
|
||||
Para instruções detalhadas de gerenciamento de chaves, veja nossa [documentação de API Keys](/pt-BR/api-reference/admin-api/control-plane/api-keys/create-api-key).
|
||||
Para instruções detalhadas de gerenciamento de chaves, veja a [documentação Portkey](https://portkey.ai/docs).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Etapa 4: Implante & Monitore">
|
||||
|
||||
@@ -18,7 +18,7 @@ Essas ferramentas permitem que seus agentes interajam com serviços em nuvem, ac
|
||||
Escreva e faça upload de arquivos para o armazenamento Amazon S3.
|
||||
</Card>
|
||||
|
||||
<Card title="Bedrock Invoke Agent" icon="aws" href="/pt-BR/tools/cloud-storage/bedrockinvokeagenttool">
|
||||
<Card title="Bedrock Invoke Agent" icon="aws" href="/pt-BR/tools/integration/bedrockinvokeagenttool">
|
||||
Acione agentes Amazon Bedrock para tarefas orientadas por IA.
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ mode: "wide"
|
||||
<Card
|
||||
title="Bedrock Invoke Agent Tool"
|
||||
icon="cloud"
|
||||
href="/en/tools/tool-integrations/bedrockinvokeagenttool"
|
||||
href="/pt-BR/tools/integration/bedrockinvokeagenttool"
|
||||
color="#0891B2"
|
||||
>
|
||||
Invoke Amazon Bedrock Agents from CrewAI to orchestrate actions across AWS services.
|
||||
@@ -20,7 +20,7 @@ mode: "wide"
|
||||
<Card
|
||||
title="CrewAI Automation Tool"
|
||||
icon="bolt"
|
||||
href="/en/tools/tool-integrations/crewaiautomationtool"
|
||||
href="/pt-BR/tools/integration/crewaiautomationtool"
|
||||
color="#7C3AED"
|
||||
>
|
||||
Automate deployment and operations by integrating CrewAI with external platforms and workflows.
|
||||
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"pytube>=15.0.0",
|
||||
"requests>=2.32.5",
|
||||
"docker>=7.1.0",
|
||||
"crewai==1.2.1",
|
||||
"crewai==1.5.0",
|
||||
"lancedb>=0.5.4",
|
||||
"tiktoken>=0.8.0",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
|
||||
@@ -90,6 +90,9 @@ from crewai_tools.tools.json_search_tool.json_search_tool import JSONSearchTool
|
||||
from crewai_tools.tools.linkup.linkup_search_tool import LinkupSearchTool
|
||||
from crewai_tools.tools.llamaindex_tool.llamaindex_tool import LlamaIndexTool
|
||||
from crewai_tools.tools.mdx_search_tool.mdx_search_tool import MDXSearchTool
|
||||
from crewai_tools.tools.merge_agent_handler_tool.merge_agent_handler_tool import (
|
||||
MergeAgentHandlerTool,
|
||||
)
|
||||
from crewai_tools.tools.mongodb_vector_search_tool.vector_search import (
|
||||
MongoDBVectorSearchConfig,
|
||||
MongoDBVectorSearchTool,
|
||||
@@ -235,6 +238,7 @@ __all__ = [
|
||||
"LlamaIndexTool",
|
||||
"MCPServerAdapter",
|
||||
"MDXSearchTool",
|
||||
"MergeAgentHandlerTool",
|
||||
"MongoDBVectorSearchConfig",
|
||||
"MongoDBVectorSearchTool",
|
||||
"MultiOnTool",
|
||||
@@ -287,4 +291,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.2.1"
|
||||
__version__ = "1.5.0"
|
||||
|
||||
@@ -229,6 +229,7 @@ class CrewAIRagAdapter(Adapter):
|
||||
continue
|
||||
else:
|
||||
metadata: dict[str, Any] = base_metadata.copy()
|
||||
source_content = SourceContent(source_ref)
|
||||
|
||||
if data_type in [
|
||||
DataType.PDF_FILE,
|
||||
@@ -239,13 +240,12 @@ class CrewAIRagAdapter(Adapter):
|
||||
DataType.XML,
|
||||
DataType.MDX,
|
||||
]:
|
||||
if not os.path.isfile(source_ref):
|
||||
if not source_content.is_url() and not source_content.path_exists():
|
||||
raise FileNotFoundError(f"File does not exist: {source_ref}")
|
||||
|
||||
loader = data_type.get_loader()
|
||||
chunker = data_type.get_chunker()
|
||||
|
||||
source_content = SourceContent(source_ref)
|
||||
loader_result: LoaderResult = loader.load(source_content)
|
||||
|
||||
chunks = chunker.chunk(loader_result.content)
|
||||
|
||||
@@ -79,6 +79,9 @@ from crewai_tools.tools.json_search_tool.json_search_tool import JSONSearchTool
|
||||
from crewai_tools.tools.linkup.linkup_search_tool import LinkupSearchTool
|
||||
from crewai_tools.tools.llamaindex_tool.llamaindex_tool import LlamaIndexTool
|
||||
from crewai_tools.tools.mdx_search_tool.mdx_search_tool import MDXSearchTool
|
||||
from crewai_tools.tools.merge_agent_handler_tool.merge_agent_handler_tool import (
|
||||
MergeAgentHandlerTool,
|
||||
)
|
||||
from crewai_tools.tools.mongodb_vector_search_tool import (
|
||||
MongoDBToolSchema,
|
||||
MongoDBVectorSearchConfig,
|
||||
@@ -218,6 +221,7 @@ __all__ = [
|
||||
"LinkupSearchTool",
|
||||
"LlamaIndexTool",
|
||||
"MDXSearchTool",
|
||||
"MergeAgentHandlerTool",
|
||||
"MongoDBToolSchema",
|
||||
"MongoDBVectorSearchConfig",
|
||||
"MongoDBVectorSearchTool",
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# MergeAgentHandlerTool Documentation
|
||||
|
||||
## Description
|
||||
|
||||
This tool is a wrapper around the Merge Agent Handler platform and gives your agent access to third-party tools and integrations via the Model Context Protocol (MCP). Merge Agent Handler securely manages authentication, permissions, and monitoring of all tool interactions across platforms like Linear, Jira, Slack, GitHub, and many more.
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Set up a virtual environment (recommended)
|
||||
|
||||
It's recommended to use a virtual environment to avoid conflicts with other packages:
|
||||
|
||||
```shell
|
||||
# Create a virtual environment
|
||||
python3 -m venv venv
|
||||
|
||||
# Activate the virtual environment
|
||||
# On macOS/Linux:
|
||||
source venv/bin/activate
|
||||
|
||||
# On Windows:
|
||||
# venv\Scripts\activate
|
||||
```
|
||||
|
||||
### Step 2: Install CrewAI Tools
|
||||
|
||||
To incorporate this tool into your project, install CrewAI with tools support:
|
||||
|
||||
```shell
|
||||
pip install 'crewai[tools]'
|
||||
```
|
||||
|
||||
### Step 3: Set up your Agent Handler credentials
|
||||
|
||||
You'll need to set up your Agent Handler API key. You can get your API key from the [Agent Handler dashboard](https://ah.merge.dev).
|
||||
|
||||
```shell
|
||||
# Set the API key in your current terminal session
|
||||
export AGENT_HANDLER_API_KEY='your-api-key-here'
|
||||
|
||||
# Or add it to your shell profile for persistence (e.g., ~/.bashrc, ~/.zshrc)
|
||||
echo "export AGENT_HANDLER_API_KEY='your-api-key-here'" >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
**Alternative: Use a `.env` file**
|
||||
|
||||
You can also use a `.env` file in your project directory:
|
||||
|
||||
```shell
|
||||
# Create a .env file
|
||||
echo "AGENT_HANDLER_API_KEY=your-api-key-here" > .env
|
||||
|
||||
# Load it in your Python script
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
```
|
||||
|
||||
**Note**: Make sure to add `.env` to your `.gitignore` to avoid committing secrets!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before using this tool, you need to:
|
||||
|
||||
1. **Create a Tool Pack** in Agent Handler with the connectors and tools you want to use
|
||||
2. **Register a User** who will be executing the tools
|
||||
3. **Authenticate connectors** for the registered user (using Agent Handler Link)
|
||||
|
||||
You can do this via the [Agent Handler dashboard](https://ah.merge.dev) or the [Agent Handler API](https://docs.ah.merge.dev).
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Example 1: Using a specific tool
|
||||
|
||||
The following example demonstrates how to initialize a specific tool and use it with a CrewAI agent:
|
||||
|
||||
```python
|
||||
from crewai_tools import MergeAgentHandlerTool
|
||||
from crewai import Agent, Task
|
||||
|
||||
# Initialize a specific tool
|
||||
create_issue_tool = MergeAgentHandlerTool.from_tool_name(
|
||||
tool_name="linear__create_issue",
|
||||
tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
|
||||
registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa"
|
||||
)
|
||||
|
||||
# Define agent with the tool
|
||||
project_manager = Agent(
|
||||
role="Project Manager",
|
||||
goal="Create and manage project tasks efficiently",
|
||||
backstory=(
|
||||
"You are an experienced project manager who tracks tasks "
|
||||
"and issues across various project management tools."
|
||||
),
|
||||
verbose=True,
|
||||
tools=[create_issue_tool],
|
||||
)
|
||||
|
||||
# Execute task
|
||||
task = Task(
|
||||
description="Create a new issue in Linear titled 'Implement user authentication' with high priority",
|
||||
agent=project_manager,
|
||||
expected_output="Confirmation that the issue was created with its ID",
|
||||
)
|
||||
|
||||
task.execute()
|
||||
```
|
||||
|
||||
### Example 2: Loading all tools from a Tool Pack
|
||||
|
||||
You can load all tools from a Tool Pack at once:
|
||||
|
||||
```python
|
||||
from crewai_tools import MergeAgentHandlerTool
|
||||
from crewai import Agent, Task
|
||||
|
||||
# Load all tools from a Tool Pack
|
||||
tools = MergeAgentHandlerTool.from_tool_pack(
|
||||
tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
|
||||
registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa"
|
||||
)
|
||||
|
||||
# Define agent with all tools
|
||||
support_agent = Agent(
|
||||
role="Support Engineer",
|
||||
goal="Handle customer support requests across multiple platforms",
|
||||
backstory=(
|
||||
"You are a skilled support engineer who can access customer "
|
||||
"data and create tickets across various support tools."
|
||||
),
|
||||
verbose=True,
|
||||
tools=tools,
|
||||
)
|
||||
```
|
||||
|
||||
### Example 3: Loading specific tools from a Tool Pack
|
||||
|
||||
You can also load only specific tools from a Tool Pack:
|
||||
|
||||
```python
|
||||
from crewai_tools import MergeAgentHandlerTool
|
||||
|
||||
# Load only specific tools
|
||||
tools = MergeAgentHandlerTool.from_tool_pack(
|
||||
tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
|
||||
registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa",
|
||||
tool_names=["linear__create_issue", "linear__get_issues", "slack__send_message"]
|
||||
)
|
||||
```
|
||||
|
||||
### Example 4: Using with local/staging environment
|
||||
|
||||
For development, you can point to a different Agent Handler environment:
|
||||
|
||||
```python
|
||||
from crewai_tools import MergeAgentHandlerTool
|
||||
|
||||
# Use with local or staging environment
|
||||
tool = MergeAgentHandlerTool.from_tool_name(
|
||||
tool_name="linear__create_issue",
|
||||
tool_pack_id="your-tool-pack-id",
|
||||
registered_user_id="your-user-id",
|
||||
base_url="http://localhost:8000" # or your staging URL
|
||||
)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Class Methods
|
||||
|
||||
#### `from_tool_name()`
|
||||
|
||||
Create a single tool instance for a specific tool.
|
||||
|
||||
**Parameters:**
|
||||
- `tool_name` (str): Name of the tool (e.g., "linear__create_issue")
|
||||
- `tool_pack_id` (str): UUID of the Tool Pack
|
||||
- `registered_user_id` (str): UUID or origin_id of the registered user
|
||||
- `base_url` (str, optional): Base URL for Agent Handler API (defaults to "https://api.ah.merge.dev")
|
||||
|
||||
**Returns:** `MergeAgentHandlerTool` instance
|
||||
|
||||
#### `from_tool_pack()`
|
||||
|
||||
Create multiple tool instances from a Tool Pack.
|
||||
|
||||
**Parameters:**
|
||||
- `tool_pack_id` (str): UUID of the Tool Pack
|
||||
- `registered_user_id` (str): UUID or origin_id of the registered user
|
||||
- `tool_names` (List[str], optional): List of specific tool names to load. If None, loads all tools.
|
||||
- `base_url` (str, optional): Base URL for Agent Handler API (defaults to "https://api.ah.merge.dev")
|
||||
|
||||
**Returns:** `List[MergeAgentHandlerTool]` instances
|
||||
|
||||
## Available Connectors
|
||||
|
||||
Merge Agent Handler supports 100+ integrations including:
|
||||
|
||||
**Project Management:** Linear, Jira, Asana, Monday, ClickUp, Height, Shortcut
|
||||
|
||||
**Communication:** Slack, Microsoft Teams, Discord
|
||||
|
||||
**CRM:** Salesforce, HubSpot, Pipedrive
|
||||
|
||||
**Development:** GitHub, GitLab, Bitbucket
|
||||
|
||||
**Documentation:** Notion, Confluence, Google Docs
|
||||
|
||||
**And many more...**
|
||||
|
||||
For a complete list of available connectors and tools, visit the [Agent Handler documentation](https://docs.ah.merge.dev).
|
||||
|
||||
## Authentication
|
||||
|
||||
Agent Handler handles all authentication for you. Users authenticate to third-party services via Agent Handler Link, and the platform securely manages tokens and credentials. Your agents can then execute tools without worrying about authentication details.
|
||||
|
||||
## Security
|
||||
|
||||
All tool executions are:
|
||||
- **Logged and monitored** for audit trails
|
||||
- **Scanned for PII** to prevent sensitive data leaks
|
||||
- **Rate limited** based on your plan
|
||||
- **Permission-controlled** at the user and organization level
|
||||
|
||||
## Support
|
||||
|
||||
For questions or issues:
|
||||
- 📚 [Documentation](https://docs.ah.merge.dev)
|
||||
- 💬 [Discord Community](https://merge.dev/discord)
|
||||
- 📧 [Support Email](mailto:support@merge.dev)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Merge Agent Handler tool for CrewAI."""
|
||||
|
||||
from crewai_tools.tools.merge_agent_handler_tool.merge_agent_handler_tool import (
|
||||
MergeAgentHandlerTool,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MergeAgentHandlerTool"]
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Merge Agent Handler tools wrapper for CrewAI."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
import requests
|
||||
import typing_extensions as te
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MergeAgentHandlerToolError(Exception):
|
||||
"""Base exception for Merge Agent Handler tool errors."""
|
||||
|
||||
|
||||
|
||||
class MergeAgentHandlerTool(BaseTool):
|
||||
"""
|
||||
Wrapper for Merge Agent Handler tools.
|
||||
|
||||
This tool allows CrewAI agents to execute tools from Merge Agent Handler,
|
||||
which provides secure access to third-party integrations via the Model Context Protocol (MCP).
|
||||
|
||||
Agent Handler manages authentication, permissions, and monitoring of all tool interactions.
|
||||
"""
|
||||
|
||||
tool_pack_id: str = Field(
|
||||
..., description="UUID of the Agent Handler Tool Pack to use"
|
||||
)
|
||||
registered_user_id: str = Field(
|
||||
..., description="UUID or origin_id of the registered user"
|
||||
)
|
||||
tool_name: str = Field(..., description="Name of the specific tool to execute")
|
||||
base_url: str = Field(
|
||||
default="https://ah-api.merge.dev",
|
||||
description="Base URL for Agent Handler API",
|
||||
)
|
||||
session_id: str | None = Field(
|
||||
default=None, description="MCP session ID (generated if not provided)"
|
||||
)
|
||||
env_vars: list[EnvVar] = Field(
|
||||
default_factory=lambda: [
|
||||
EnvVar(
|
||||
name="AGENT_HANDLER_API_KEY",
|
||||
description="Production API key for Agent Handler services",
|
||||
required=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Initialize session ID if not provided."""
|
||||
super().model_post_init(__context)
|
||||
if self.session_id is None:
|
||||
self.session_id = str(uuid4())
|
||||
|
||||
def _get_api_key(self) -> str:
|
||||
"""Get the API key from environment variables."""
|
||||
import os
|
||||
|
||||
api_key = os.environ.get("AGENT_HANDLER_API_KEY")
|
||||
if not api_key:
|
||||
raise MergeAgentHandlerToolError(
|
||||
"AGENT_HANDLER_API_KEY environment variable is required. "
|
||||
"Set it with: export AGENT_HANDLER_API_KEY='your-key-here'"
|
||||
)
|
||||
return api_key
|
||||
|
||||
def _make_mcp_request(
|
||||
self, method: str, params: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Make a JSON-RPC 2.0 MCP request to Agent Handler."""
|
||||
url = f"{self.base_url}/api/v1/tool-packs/{self.tool_pack_id}/registered-users/{self.registered_user_id}/mcp"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self._get_api_key()}",
|
||||
"Mcp-Session-Id": self.session_id or str(uuid4()),
|
||||
}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"id": str(uuid4()),
|
||||
}
|
||||
|
||||
if params:
|
||||
payload["params"] = params
|
||||
|
||||
# Log the full payload for debugging
|
||||
logger.debug(f"MCP Request to {url}: {json.dumps(payload, indent=2)}")
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=60)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Handle JSON-RPC error responses
|
||||
if "error" in result:
|
||||
error_msg = result["error"].get("message", "Unknown error")
|
||||
error_code = result["error"].get("code", -1)
|
||||
logger.error(
|
||||
f"Agent Handler API error (code {error_code}): {error_msg}"
|
||||
)
|
||||
raise MergeAgentHandlerToolError(f"API Error: {error_msg}")
|
||||
|
||||
return result
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to call Agent Handler API: {e!s}")
|
||||
raise MergeAgentHandlerToolError(
|
||||
f"Failed to communicate with Agent Handler API: {e!s}"
|
||||
) from e
|
||||
|
||||
def _run(self, **kwargs: Any) -> Any:
|
||||
"""Execute the Agent Handler tool with the given arguments."""
|
||||
try:
|
||||
# Log what we're about to send
|
||||
logger.info(f"Executing {self.tool_name} with arguments: {kwargs}")
|
||||
|
||||
# Make the tool call via MCP
|
||||
result = self._make_mcp_request(
|
||||
method="tools/call",
|
||||
params={"name": self.tool_name, "arguments": kwargs},
|
||||
)
|
||||
|
||||
# Extract the actual result from the MCP response
|
||||
if "result" in result and "content" in result["result"]:
|
||||
content = result["result"]["content"]
|
||||
if content and len(content) > 0:
|
||||
# Parse the text content (it's JSON-encoded)
|
||||
text_content = content[0].get("text", "")
|
||||
try:
|
||||
return json.loads(text_content)
|
||||
except json.JSONDecodeError:
|
||||
return text_content
|
||||
|
||||
return result
|
||||
|
||||
except MergeAgentHandlerToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error executing tool {self.tool_name}: {e!s}")
|
||||
raise MergeAgentHandlerToolError(f"Tool execution failed: {e!s}") from e
|
||||
|
||||
@classmethod
|
||||
def from_tool_name(
|
||||
cls,
|
||||
tool_name: str,
|
||||
tool_pack_id: str,
|
||||
registered_user_id: str,
|
||||
base_url: str = "https://ah-api.merge.dev",
|
||||
**kwargs: Any,
|
||||
) -> te.Self:
|
||||
"""
|
||||
Create a MergeAgentHandlerTool from a tool name.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool (e.g., "linear__create_issue")
|
||||
tool_pack_id: UUID of the Tool Pack
|
||||
registered_user_id: UUID of the registered user
|
||||
base_url: Base URL for Agent Handler API (defaults to production)
|
||||
**kwargs: Additional arguments to pass to the tool
|
||||
|
||||
Returns:
|
||||
MergeAgentHandlerTool instance ready to use
|
||||
|
||||
Example:
|
||||
>>> tool = MergeAgentHandlerTool.from_tool_name(
|
||||
... tool_name="linear__create_issue",
|
||||
... tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
|
||||
... registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa"
|
||||
... )
|
||||
"""
|
||||
# Create an empty args schema model (proper BaseModel subclass)
|
||||
empty_args_schema = create_model(f"{tool_name.replace('__', '_').title()}Args")
|
||||
|
||||
# Initialize session and get tool schema
|
||||
instance = cls(
|
||||
name=tool_name,
|
||||
description=f"Execute {tool_name} via Agent Handler",
|
||||
tool_pack_id=tool_pack_id,
|
||||
registered_user_id=registered_user_id,
|
||||
tool_name=tool_name,
|
||||
base_url=base_url,
|
||||
args_schema=empty_args_schema, # Empty schema that properly inherits from BaseModel
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Try to fetch the actual tool schema from Agent Handler
|
||||
try:
|
||||
result = instance._make_mcp_request(method="tools/list")
|
||||
if "result" in result and "tools" in result["result"]:
|
||||
tools = result["result"]["tools"]
|
||||
tool_schema = next(
|
||||
(t for t in tools if t.get("name") == tool_name), None
|
||||
)
|
||||
|
||||
if tool_schema:
|
||||
instance.description = tool_schema.get(
|
||||
"description", instance.description
|
||||
)
|
||||
|
||||
# Convert parameters schema to Pydantic model
|
||||
if "parameters" in tool_schema:
|
||||
try:
|
||||
params = tool_schema["parameters"]
|
||||
if params.get("type") == "object" and "properties" in params:
|
||||
# Build field definitions for Pydantic
|
||||
fields = {}
|
||||
properties = params["properties"]
|
||||
required = params.get("required", [])
|
||||
|
||||
for field_name, field_schema in properties.items():
|
||||
field_type = Any # Default type
|
||||
field_default = ... # Required by default
|
||||
|
||||
# Map JSON schema types to Python types
|
||||
json_type = field_schema.get("type", "string")
|
||||
if json_type == "string":
|
||||
field_type = str
|
||||
elif json_type == "integer":
|
||||
field_type = int
|
||||
elif json_type == "number":
|
||||
field_type = float
|
||||
elif json_type == "boolean":
|
||||
field_type = bool
|
||||
elif json_type == "array":
|
||||
field_type = list[Any]
|
||||
elif json_type == "object":
|
||||
field_type = dict[str, Any]
|
||||
|
||||
# Make field optional if not required
|
||||
if field_name not in required:
|
||||
field_type = field_type | None
|
||||
field_default = None
|
||||
|
||||
field_description = field_schema.get("description")
|
||||
if field_description:
|
||||
fields[field_name] = (
|
||||
field_type,
|
||||
Field(
|
||||
default=field_default,
|
||||
description=field_description,
|
||||
),
|
||||
)
|
||||
else:
|
||||
fields[field_name] = (field_type, field_default)
|
||||
|
||||
# Create the Pydantic model
|
||||
if fields:
|
||||
args_schema = create_model(
|
||||
f"{tool_name.replace('__', '_').title()}Args",
|
||||
**fields,
|
||||
)
|
||||
instance.args_schema = args_schema
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to create args schema for {tool_name}: {e!s}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to fetch tool schema for {tool_name}, using defaults: {e!s}"
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
def from_tool_pack(
|
||||
cls,
|
||||
tool_pack_id: str,
|
||||
registered_user_id: str,
|
||||
tool_names: list[str] | None = None,
|
||||
base_url: str = "https://ah-api.merge.dev",
|
||||
**kwargs: Any,
|
||||
) -> list[te.Self]:
|
||||
"""
|
||||
Create multiple MergeAgentHandlerTool instances from a Tool Pack.
|
||||
|
||||
Args:
|
||||
tool_pack_id: UUID of the Tool Pack
|
||||
registered_user_id: UUID or origin_id of the registered user
|
||||
tool_names: Optional list of specific tool names to load. If None, loads all tools.
|
||||
base_url: Base URL for Agent Handler API (defaults to production)
|
||||
**kwargs: Additional arguments to pass to each tool
|
||||
|
||||
Returns:
|
||||
List of MergeAgentHandlerTool instances
|
||||
|
||||
Example:
|
||||
>>> tools = MergeAgentHandlerTool.from_tool_pack(
|
||||
... tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
|
||||
... registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa",
|
||||
... tool_names=["linear__create_issue", "linear__get_issues"]
|
||||
... )
|
||||
"""
|
||||
# Create a temporary instance to fetch the tool list
|
||||
temp_instance = cls(
|
||||
name="temp",
|
||||
description="temp",
|
||||
tool_pack_id=tool_pack_id,
|
||||
registered_user_id=registered_user_id,
|
||||
tool_name="temp",
|
||||
base_url=base_url,
|
||||
args_schema=BaseModel,
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch available tools
|
||||
result = temp_instance._make_mcp_request(method="tools/list")
|
||||
|
||||
if "result" not in result or "tools" not in result["result"]:
|
||||
raise MergeAgentHandlerToolError(
|
||||
"Failed to fetch tools from Agent Handler Tool Pack"
|
||||
)
|
||||
|
||||
available_tools = result["result"]["tools"]
|
||||
|
||||
# Filter tools if specific names were requested
|
||||
if tool_names:
|
||||
available_tools = [
|
||||
t for t in available_tools if t.get("name") in tool_names
|
||||
]
|
||||
|
||||
# Check if all requested tools were found
|
||||
found_names = {t.get("name") for t in available_tools}
|
||||
missing_names = set(tool_names) - found_names
|
||||
if missing_names:
|
||||
logger.warning(
|
||||
f"The following tools were not found in the Tool Pack: {missing_names}"
|
||||
)
|
||||
|
||||
# Create tool instances
|
||||
tools = []
|
||||
for tool_schema in available_tools:
|
||||
tool_name = tool_schema.get("name")
|
||||
if not tool_name:
|
||||
continue
|
||||
|
||||
tool = cls.from_tool_name(
|
||||
tool_name=tool_name,
|
||||
tool_pack_id=tool_pack_id,
|
||||
registered_user_id=registered_user_id,
|
||||
base_url=base_url,
|
||||
**kwargs,
|
||||
)
|
||||
tools.append(tool)
|
||||
|
||||
return tools
|
||||
|
||||
except MergeAgentHandlerToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create tools from Tool Pack: {e!s}")
|
||||
raise MergeAgentHandlerToolError(f"Failed to load Tool Pack: {e!s}") from e
|
||||
@@ -12,12 +12,16 @@ from pydantic.types import ImportString
|
||||
|
||||
|
||||
class QdrantToolSchema(BaseModel):
|
||||
query: str = Field(..., description="Query to search in Qdrant DB")
|
||||
query: str = Field(
|
||||
..., description="Query to search in Qdrant DB - always required."
|
||||
)
|
||||
filter_by: str | None = Field(
|
||||
default=None, description="Parameter to filter the search by."
|
||||
default=None,
|
||||
description="Parameter to filter the search by. When filtering, needs to be used in conjunction with filter_value.",
|
||||
)
|
||||
filter_value: Any | None = Field(
|
||||
default=None, description="Value to filter the search by."
|
||||
default=None,
|
||||
description="Value to filter the search by. When filtering, needs to be used in conjunction with filter_by.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
490
lib/crewai-tools/tests/tools/merge_agent_handler_tool_test.py
Normal file
490
lib/crewai-tools/tests/tools/merge_agent_handler_tool_test.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""Tests for MergeAgentHandlerTool."""
|
||||
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai_tools import MergeAgentHandlerTool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_agent_handler_api_key():
|
||||
"""Mock the Agent Handler API key environment variable."""
|
||||
with patch.dict(os.environ, {"AGENT_HANDLER_API_KEY": "test_key"}):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_pack_response():
|
||||
"""Mock response for tools/list MCP request."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "test-id",
|
||||
"result": {
|
||||
"tools": [
|
||||
{
|
||||
"name": "linear__create_issue",
|
||||
"description": "Creates a new issue in Linear",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The issue title",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "The issue description",
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"description": "Priority level (1-4)",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "linear__get_issues",
|
||||
"description": "Get issues from Linear",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"type": "object",
|
||||
"description": "Filter criteria",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_execute_response():
|
||||
"""Mock response for tools/call MCP request."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "test-id",
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": '{"success": true, "id": "ISS-123", "title": "Test Issue"}',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_tool_initialization():
|
||||
"""Test basic tool initialization."""
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__create_issue",
|
||||
)
|
||||
|
||||
assert tool.name == "test_tool"
|
||||
assert "Test tool" in tool.description # Description gets formatted by BaseTool
|
||||
assert tool.tool_pack_id == "test-pack-id"
|
||||
assert tool.registered_user_id == "test-user-id"
|
||||
assert tool.tool_name == "linear__create_issue"
|
||||
assert tool.base_url == "https://ah-api.merge.dev"
|
||||
assert tool.session_id is not None
|
||||
|
||||
|
||||
def test_tool_initialization_with_custom_base_url():
|
||||
"""Test tool initialization with custom base URL."""
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__create_issue",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
assert tool.base_url == "http://localhost:8000"
|
||||
|
||||
|
||||
def test_missing_api_key():
|
||||
"""Test that missing API key raises appropriate error."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__create_issue",
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
tool._get_api_key()
|
||||
|
||||
assert "AGENT_HANDLER_API_KEY" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_mcp_request_success(mock_post, mock_tool_pack_response):
|
||||
"""Test successful MCP request."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_tool_pack_response
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__create_issue",
|
||||
)
|
||||
|
||||
result = tool._make_mcp_request(method="tools/list")
|
||||
|
||||
assert "result" in result
|
||||
assert "tools" in result["result"]
|
||||
assert len(result["result"]["tools"]) == 2
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_mcp_request_error(mock_post):
|
||||
"""Test MCP request with error response."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "test-id",
|
||||
"error": {"code": -32601, "message": "Method not found"},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__create_issue",
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
tool._make_mcp_request(method="invalid/method")
|
||||
|
||||
assert "Method not found" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_mcp_request_http_error(mock_post):
|
||||
"""Test MCP request with HTTP error."""
|
||||
mock_post.side_effect = Exception("Connection error")
|
||||
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__create_issue",
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
tool._make_mcp_request(method="tools/list")
|
||||
|
||||
assert "Connection error" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_tool_execution(mock_post, mock_tool_execute_response):
|
||||
"""Test tool execution via _run method."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_tool_execute_response
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__create_issue",
|
||||
)
|
||||
|
||||
result = tool._run(title="Test Issue", description="Test description")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["id"] == "ISS-123"
|
||||
assert result["title"] == "Test Issue"
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_from_tool_name(mock_post, mock_tool_pack_response):
|
||||
"""Test creating tool from tool name."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_tool_pack_response
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tool = MergeAgentHandlerTool.from_tool_name(
|
||||
tool_name="linear__create_issue",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
)
|
||||
|
||||
assert tool.name == "linear__create_issue"
|
||||
assert tool.description == "Creates a new issue in Linear"
|
||||
assert tool.tool_name == "linear__create_issue"
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_from_tool_name_with_custom_base_url(mock_post, mock_tool_pack_response):
|
||||
"""Test creating tool from tool name with custom base URL."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_tool_pack_response
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tool = MergeAgentHandlerTool.from_tool_name(
|
||||
tool_name="linear__create_issue",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
assert tool.base_url == "http://localhost:8000"
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_from_tool_pack_all_tools(mock_post, mock_tool_pack_response):
|
||||
"""Test creating all tools from a Tool Pack."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_tool_pack_response
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tools = MergeAgentHandlerTool.from_tool_pack(
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
)
|
||||
|
||||
assert len(tools) == 2
|
||||
assert tools[0].name == "linear__create_issue"
|
||||
assert tools[1].name == "linear__get_issues"
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_from_tool_pack_specific_tools(mock_post, mock_tool_pack_response):
|
||||
"""Test creating specific tools from a Tool Pack."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_tool_pack_response
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tools = MergeAgentHandlerTool.from_tool_pack(
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_names=["linear__create_issue"],
|
||||
)
|
||||
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "linear__create_issue"
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_from_tool_pack_with_custom_base_url(mock_post, mock_tool_pack_response):
|
||||
"""Test creating tools from Tool Pack with custom base URL."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_tool_pack_response
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tools = MergeAgentHandlerTool.from_tool_pack(
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
assert len(tools) == 2
|
||||
assert all(tool.base_url == "http://localhost:8000" for tool in tools)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_tool_execution_with_text_response(mock_post):
|
||||
"""Test tool execution with plain text response."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "test-id",
|
||||
"result": {"content": [{"type": "text", "text": "Plain text result"}]},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__create_issue",
|
||||
)
|
||||
|
||||
result = tool._run(title="Test")
|
||||
|
||||
assert result == "Plain text result"
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_mcp_request_builds_correct_url(mock_post, mock_tool_pack_response):
|
||||
"""Test that MCP request builds correct URL."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_tool_pack_response
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-123",
|
||||
registered_user_id="user-456",
|
||||
tool_name="linear__create_issue",
|
||||
base_url="https://ah-api.merge.dev",
|
||||
)
|
||||
|
||||
tool._make_mcp_request(method="tools/list")
|
||||
|
||||
expected_url = (
|
||||
"https://ah-api.merge.dev/api/v1/tool-packs/"
|
||||
"test-pack-123/registered-users/user-456/mcp"
|
||||
)
|
||||
mock_post.assert_called_once()
|
||||
assert mock_post.call_args[0][0] == expected_url
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_mcp_request_includes_correct_headers(mock_post, mock_tool_pack_response):
|
||||
"""Test that MCP request includes correct headers."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_tool_pack_response
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__create_issue",
|
||||
)
|
||||
|
||||
tool._make_mcp_request(method="tools/list")
|
||||
|
||||
mock_post.assert_called_once()
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
assert headers["Authorization"] == "Bearer test_key"
|
||||
assert "Mcp-Session-Id" in headers
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_tool_parameters_are_passed_in_request(mock_post):
|
||||
"""Test that tool parameters are correctly included in the MCP request."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "test-id",
|
||||
"result": {"content": [{"type": "text", "text": '{"success": true}'}]},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
tool = MergeAgentHandlerTool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
tool_name="linear__update_issue",
|
||||
)
|
||||
|
||||
# Execute tool with specific parameters
|
||||
tool._run(id="issue-123", title="New Title", priority=1)
|
||||
|
||||
# Verify the request was made
|
||||
mock_post.assert_called_once()
|
||||
|
||||
# Get the JSON payload that was sent
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
|
||||
# Verify MCP structure
|
||||
assert payload["jsonrpc"] == "2.0"
|
||||
assert payload["method"] == "tools/call"
|
||||
assert "id" in payload
|
||||
|
||||
# Verify parameters are in the request
|
||||
assert "params" in payload
|
||||
assert payload["params"]["name"] == "linear__update_issue"
|
||||
assert "arguments" in payload["params"]
|
||||
|
||||
# Verify the actual arguments were passed
|
||||
arguments = payload["params"]["arguments"]
|
||||
assert arguments["id"] == "issue-123"
|
||||
assert arguments["title"] == "New Title"
|
||||
assert arguments["priority"] == 1
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_tool_run_method_passes_parameters(mock_post, mock_tool_pack_response):
|
||||
"""Test that parameters are passed when using the .run() method (how CrewAI calls it)."""
|
||||
# Mock the tools/list response
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
# First call: tools/list
|
||||
# Second call: tools/call
|
||||
mock_response.json.side_effect = [
|
||||
mock_tool_pack_response, # tools/list response
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "test-id",
|
||||
"result": {"content": [{"type": "text", "text": '{"success": true, "id": "issue-123"}'}]},
|
||||
}, # tools/call response
|
||||
]
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
# Create tool using from_tool_name (which fetches schema)
|
||||
tool = MergeAgentHandlerTool.from_tool_name(
|
||||
tool_name="linear__create_issue",
|
||||
tool_pack_id="test-pack-id",
|
||||
registered_user_id="test-user-id",
|
||||
)
|
||||
|
||||
# Call using .run() method (this is how CrewAI invokes tools)
|
||||
result = tool.run(title="Test Issue", description="Test description", priority=2)
|
||||
|
||||
# Verify two calls were made: tools/list and tools/call
|
||||
assert mock_post.call_count == 2
|
||||
|
||||
# Get the second call (tools/call)
|
||||
second_call = mock_post.call_args_list[1]
|
||||
payload = second_call.kwargs["json"]
|
||||
|
||||
# Verify it's a tools/call request
|
||||
assert payload["method"] == "tools/call"
|
||||
assert payload["params"]["name"] == "linear__create_issue"
|
||||
|
||||
# Verify parameters were passed
|
||||
arguments = payload["params"]["arguments"]
|
||||
assert arguments["title"] == "Test Issue"
|
||||
assert arguments["description"] == "Test description"
|
||||
assert arguments["priority"] == 2
|
||||
|
||||
# Verify result was returned
|
||||
assert result["success"] is True
|
||||
assert result["id"] == "issue-123"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -23,7 +23,6 @@ dependencies = [
|
||||
"chromadb~=1.1.0",
|
||||
"tokenizers>=0.20.3",
|
||||
"openpyxl>=3.1.5",
|
||||
"pyvis>=0.3.2",
|
||||
# Authentication and Security
|
||||
"python-dotenv>=1.1.1",
|
||||
"pyjwt>=2.9.0",
|
||||
@@ -49,7 +48,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tools = [
|
||||
"crewai-tools==1.2.1",
|
||||
"crewai-tools==1.5.0",
|
||||
]
|
||||
embeddings = [
|
||||
"tiktoken~=0.8.0"
|
||||
@@ -94,10 +93,11 @@ azure-ai-inference = [
|
||||
anthropic = [
|
||||
"anthropic>=0.69.0",
|
||||
]
|
||||
# a2a = [
|
||||
# "a2a-sdk~=0.3.9",
|
||||
# "httpx-sse>=0.4.0",
|
||||
# ]
|
||||
a2a = [
|
||||
"a2a-sdk~=0.3.10",
|
||||
"httpx-auth>=0.23.1",
|
||||
"httpx-sse>=0.4.0",
|
||||
]
|
||||
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any
|
||||
import urllib.request
|
||||
import warnings
|
||||
|
||||
from crewai.agent import Agent
|
||||
from crewai.agent.core import Agent
|
||||
from crewai.crew import Crew
|
||||
from crewai.crews.crew_output import CrewOutput
|
||||
from crewai.flow.flow import Flow
|
||||
@@ -40,7 +40,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
|
||||
|
||||
_suppress_pydantic_deprecation_warnings()
|
||||
|
||||
__version__ = "1.2.1"
|
||||
__version__ = "1.5.0"
|
||||
_telemetry_submitted = False
|
||||
|
||||
|
||||
|
||||
6
lib/crewai/src/crewai/a2a/__init__.py
Normal file
6
lib/crewai/src/crewai/a2a/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Agent-to-Agent (A2A) protocol communication module for CrewAI."""
|
||||
|
||||
from crewai.a2a.config import A2AConfig
|
||||
|
||||
|
||||
__all__ = ["A2AConfig"]
|
||||
20
lib/crewai/src/crewai/a2a/auth/__init__.py
Normal file
20
lib/crewai/src/crewai/a2a/auth/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""A2A authentication schemas."""
|
||||
|
||||
from crewai.a2a.auth.schemas import (
|
||||
APIKeyAuth,
|
||||
BearerTokenAuth,
|
||||
HTTPBasicAuth,
|
||||
HTTPDigestAuth,
|
||||
OAuth2AuthorizationCode,
|
||||
OAuth2ClientCredentials,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"APIKeyAuth",
|
||||
"BearerTokenAuth",
|
||||
"HTTPBasicAuth",
|
||||
"HTTPDigestAuth",
|
||||
"OAuth2AuthorizationCode",
|
||||
"OAuth2ClientCredentials",
|
||||
]
|
||||
392
lib/crewai/src/crewai/a2a/auth/schemas.py
Normal file
392
lib/crewai/src/crewai/a2a/auth/schemas.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""Authentication schemes for A2A protocol agents.
|
||||
|
||||
Supported authentication methods:
|
||||
- Bearer tokens
|
||||
- OAuth2 (Client Credentials, Authorization Code)
|
||||
- API Keys (header, query, cookie)
|
||||
- HTTP Basic authentication
|
||||
- HTTP Digest authentication
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import base64
|
||||
from collections.abc import Awaitable, Callable, MutableMapping
|
||||
import time
|
||||
from typing import Literal
|
||||
import urllib.parse
|
||||
|
||||
import httpx
|
||||
from httpx import DigestAuth
|
||||
from pydantic import BaseModel, Field, PrivateAttr
|
||||
|
||||
|
||||
class AuthScheme(ABC, BaseModel):
|
||||
"""Base class for authentication schemes."""
|
||||
|
||||
@abstractmethod
|
||||
async def apply_auth(
|
||||
self, client: httpx.AsyncClient, headers: MutableMapping[str, str]
|
||||
) -> MutableMapping[str, str]:
|
||||
"""Apply authentication to request headers.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making auth requests.
|
||||
headers: Current request headers.
|
||||
|
||||
Returns:
|
||||
Updated headers with authentication applied.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class BearerTokenAuth(AuthScheme):
|
||||
"""Bearer token authentication (Authorization: Bearer <token>).
|
||||
|
||||
Attributes:
|
||||
token: Bearer token for authentication.
|
||||
"""
|
||||
|
||||
token: str = Field(description="Bearer token")
|
||||
|
||||
async def apply_auth(
|
||||
self, client: httpx.AsyncClient, headers: MutableMapping[str, str]
|
||||
) -> MutableMapping[str, str]:
|
||||
"""Apply Bearer token to Authorization header.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making auth requests.
|
||||
headers: Current request headers.
|
||||
|
||||
Returns:
|
||||
Updated headers with Bearer token in Authorization header.
|
||||
"""
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
|
||||
class HTTPBasicAuth(AuthScheme):
|
||||
"""HTTP Basic authentication.
|
||||
|
||||
Attributes:
|
||||
username: Username for Basic authentication.
|
||||
password: Password for Basic authentication.
|
||||
"""
|
||||
|
||||
username: str = Field(description="Username")
|
||||
password: str = Field(description="Password")
|
||||
|
||||
async def apply_auth(
|
||||
self, client: httpx.AsyncClient, headers: MutableMapping[str, str]
|
||||
) -> MutableMapping[str, str]:
|
||||
"""Apply HTTP Basic authentication.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making auth requests.
|
||||
headers: Current request headers.
|
||||
|
||||
Returns:
|
||||
Updated headers with Basic auth in Authorization header.
|
||||
"""
|
||||
credentials = f"{self.username}:{self.password}"
|
||||
encoded = base64.b64encode(credentials.encode()).decode()
|
||||
headers["Authorization"] = f"Basic {encoded}"
|
||||
return headers
|
||||
|
||||
|
||||
class HTTPDigestAuth(AuthScheme):
|
||||
"""HTTP Digest authentication.
|
||||
|
||||
Note: Uses httpx-auth library for digest implementation.
|
||||
|
||||
Attributes:
|
||||
username: Username for Digest authentication.
|
||||
password: Password for Digest authentication.
|
||||
"""
|
||||
|
||||
username: str = Field(description="Username")
|
||||
password: str = Field(description="Password")
|
||||
|
||||
async def apply_auth(
|
||||
self, client: httpx.AsyncClient, headers: MutableMapping[str, str]
|
||||
) -> MutableMapping[str, str]:
|
||||
"""Digest auth is handled by httpx auth flow, not headers.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making auth requests.
|
||||
headers: Current request headers.
|
||||
|
||||
Returns:
|
||||
Unchanged headers (Digest auth handled by httpx auth flow).
|
||||
"""
|
||||
return headers
|
||||
|
||||
def configure_client(self, client: httpx.AsyncClient) -> None:
|
||||
"""Configure client with Digest auth.
|
||||
|
||||
Args:
|
||||
client: HTTP client to configure with Digest authentication.
|
||||
"""
|
||||
client.auth = DigestAuth(self.username, self.password)
|
||||
|
||||
|
||||
class APIKeyAuth(AuthScheme):
|
||||
"""API Key authentication (header, query, or cookie).
|
||||
|
||||
Attributes:
|
||||
api_key: API key value for authentication.
|
||||
location: Where to send the API key (header, query, or cookie).
|
||||
name: Parameter name for the API key (default: X-API-Key).
|
||||
"""
|
||||
|
||||
api_key: str = Field(description="API key value")
|
||||
location: Literal["header", "query", "cookie"] = Field(
|
||||
default="header", description="Where to send the API key"
|
||||
)
|
||||
name: str = Field(default="X-API-Key", description="Parameter name for the API key")
|
||||
|
||||
async def apply_auth(
|
||||
self, client: httpx.AsyncClient, headers: MutableMapping[str, str]
|
||||
) -> MutableMapping[str, str]:
|
||||
"""Apply API key authentication.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making auth requests.
|
||||
headers: Current request headers.
|
||||
|
||||
Returns:
|
||||
Updated headers with API key (for header/cookie locations).
|
||||
"""
|
||||
if self.location == "header":
|
||||
headers[self.name] = self.api_key
|
||||
elif self.location == "cookie":
|
||||
headers["Cookie"] = f"{self.name}={self.api_key}"
|
||||
return headers
|
||||
|
||||
def configure_client(self, client: httpx.AsyncClient) -> None:
|
||||
"""Configure client for query param API keys.
|
||||
|
||||
Args:
|
||||
client: HTTP client to configure with query param API key hook.
|
||||
"""
|
||||
if self.location == "query":
|
||||
|
||||
async def _add_api_key_param(request: httpx.Request) -> None:
|
||||
url = httpx.URL(request.url)
|
||||
request.url = url.copy_add_param(self.name, self.api_key)
|
||||
|
||||
client.event_hooks["request"].append(_add_api_key_param)
|
||||
|
||||
|
||||
class OAuth2ClientCredentials(AuthScheme):
|
||||
"""OAuth2 Client Credentials flow authentication.
|
||||
|
||||
Attributes:
|
||||
token_url: OAuth2 token endpoint URL.
|
||||
client_id: OAuth2 client identifier.
|
||||
client_secret: OAuth2 client secret.
|
||||
scopes: List of required OAuth2 scopes.
|
||||
"""
|
||||
|
||||
token_url: str = Field(description="OAuth2 token endpoint")
|
||||
client_id: str = Field(description="OAuth2 client ID")
|
||||
client_secret: str = Field(description="OAuth2 client secret")
|
||||
scopes: list[str] = Field(
|
||||
default_factory=list, description="Required OAuth2 scopes"
|
||||
)
|
||||
|
||||
_access_token: str | None = PrivateAttr(default=None)
|
||||
_token_expires_at: float | None = PrivateAttr(default=None)
|
||||
|
||||
async def apply_auth(
|
||||
self, client: httpx.AsyncClient, headers: MutableMapping[str, str]
|
||||
) -> MutableMapping[str, str]:
|
||||
"""Apply OAuth2 access token to Authorization header.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making token requests.
|
||||
headers: Current request headers.
|
||||
|
||||
Returns:
|
||||
Updated headers with OAuth2 access token in Authorization header.
|
||||
"""
|
||||
if (
|
||||
self._access_token is None
|
||||
or self._token_expires_at is None
|
||||
or time.time() >= self._token_expires_at
|
||||
):
|
||||
await self._fetch_token(client)
|
||||
|
||||
if self._access_token:
|
||||
headers["Authorization"] = f"Bearer {self._access_token}"
|
||||
|
||||
return headers
|
||||
|
||||
async def _fetch_token(self, client: httpx.AsyncClient) -> None:
|
||||
"""Fetch OAuth2 access token using client credentials flow.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making token request.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If token request fails.
|
||||
"""
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
|
||||
if self.scopes:
|
||||
data["scope"] = " ".join(self.scopes)
|
||||
|
||||
response = await client.post(self.token_url, data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
self._access_token = token_data["access_token"]
|
||||
expires_in = token_data.get("expires_in", 3600)
|
||||
self._token_expires_at = time.time() + expires_in - 60
|
||||
|
||||
|
||||
class OAuth2AuthorizationCode(AuthScheme):
|
||||
"""OAuth2 Authorization Code flow authentication.
|
||||
|
||||
Note: Requires interactive authorization.
|
||||
|
||||
Attributes:
|
||||
authorization_url: OAuth2 authorization endpoint URL.
|
||||
token_url: OAuth2 token endpoint URL.
|
||||
client_id: OAuth2 client identifier.
|
||||
client_secret: OAuth2 client secret.
|
||||
redirect_uri: OAuth2 redirect URI for callback.
|
||||
scopes: List of required OAuth2 scopes.
|
||||
"""
|
||||
|
||||
authorization_url: str = Field(description="OAuth2 authorization endpoint")
|
||||
token_url: str = Field(description="OAuth2 token endpoint")
|
||||
client_id: str = Field(description="OAuth2 client ID")
|
||||
client_secret: str = Field(description="OAuth2 client secret")
|
||||
redirect_uri: str = Field(description="OAuth2 redirect URI")
|
||||
scopes: list[str] = Field(
|
||||
default_factory=list, description="Required OAuth2 scopes"
|
||||
)
|
||||
|
||||
_access_token: str | None = PrivateAttr(default=None)
|
||||
_refresh_token: str | None = PrivateAttr(default=None)
|
||||
_token_expires_at: float | None = PrivateAttr(default=None)
|
||||
_authorization_callback: Callable[[str], Awaitable[str]] | None = PrivateAttr(
|
||||
default=None
|
||||
)
|
||||
|
||||
def set_authorization_callback(
|
||||
self, callback: Callable[[str], Awaitable[str]] | None
|
||||
) -> None:
|
||||
"""Set callback to handle authorization URL.
|
||||
|
||||
Args:
|
||||
callback: Async function that receives authorization URL and returns auth code.
|
||||
"""
|
||||
self._authorization_callback = callback
|
||||
|
||||
async def apply_auth(
|
||||
self, client: httpx.AsyncClient, headers: MutableMapping[str, str]
|
||||
) -> MutableMapping[str, str]:
|
||||
"""Apply OAuth2 access token to Authorization header.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making token requests.
|
||||
headers: Current request headers.
|
||||
|
||||
Returns:
|
||||
Updated headers with OAuth2 access token in Authorization header.
|
||||
|
||||
Raises:
|
||||
ValueError: If authorization callback is not set.
|
||||
"""
|
||||
|
||||
if self._access_token is None:
|
||||
if self._authorization_callback is None:
|
||||
msg = "Authorization callback not set. Use set_authorization_callback()"
|
||||
raise ValueError(msg)
|
||||
await self._fetch_initial_token(client)
|
||||
elif self._token_expires_at and time.time() >= self._token_expires_at:
|
||||
await self._refresh_access_token(client)
|
||||
|
||||
if self._access_token:
|
||||
headers["Authorization"] = f"Bearer {self._access_token}"
|
||||
|
||||
return headers
|
||||
|
||||
async def _fetch_initial_token(self, client: httpx.AsyncClient) -> None:
|
||||
"""Fetch initial access token using authorization code flow.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making token request.
|
||||
|
||||
Raises:
|
||||
ValueError: If authorization callback is not set.
|
||||
httpx.HTTPStatusError: If token request fails.
|
||||
"""
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"scope": " ".join(self.scopes),
|
||||
}
|
||||
auth_url = f"{self.authorization_url}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
if self._authorization_callback is None:
|
||||
msg = "Authorization callback not set"
|
||||
raise ValueError(msg)
|
||||
auth_code = await self._authorization_callback(auth_url)
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
}
|
||||
|
||||
response = await client.post(self.token_url, data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
self._access_token = token_data["access_token"]
|
||||
self._refresh_token = token_data.get("refresh_token")
|
||||
|
||||
expires_in = token_data.get("expires_in", 3600)
|
||||
self._token_expires_at = time.time() + expires_in - 60
|
||||
|
||||
async def _refresh_access_token(self, client: httpx.AsyncClient) -> None:
|
||||
"""Refresh the access token using refresh token.
|
||||
|
||||
Args:
|
||||
client: HTTP client for making token request.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If token refresh request fails.
|
||||
"""
|
||||
if not self._refresh_token:
|
||||
await self._fetch_initial_token(client)
|
||||
return
|
||||
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self._refresh_token,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
|
||||
response = await client.post(self.token_url, data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
self._access_token = token_data["access_token"]
|
||||
if "refresh_token" in token_data:
|
||||
self._refresh_token = token_data["refresh_token"]
|
||||
|
||||
expires_in = token_data.get("expires_in", 3600)
|
||||
self._token_expires_at = time.time() + expires_in - 60
|
||||
236
lib/crewai/src/crewai/a2a/auth/utils.py
Normal file
236
lib/crewai/src/crewai/a2a/auth/utils.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Authentication utilities for A2A protocol agent communication.
|
||||
|
||||
Provides validation and retry logic for various authentication schemes including
|
||||
OAuth2, API keys, and HTTP authentication methods.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, MutableMapping
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from a2a.client.errors import A2AClientHTTPError
|
||||
from a2a.types import (
|
||||
APIKeySecurityScheme,
|
||||
AgentCard,
|
||||
HTTPAuthSecurityScheme,
|
||||
OAuth2SecurityScheme,
|
||||
)
|
||||
from httpx import AsyncClient, Response
|
||||
|
||||
from crewai.a2a.auth.schemas import (
|
||||
APIKeyAuth,
|
||||
AuthScheme,
|
||||
BearerTokenAuth,
|
||||
HTTPBasicAuth,
|
||||
HTTPDigestAuth,
|
||||
OAuth2AuthorizationCode,
|
||||
OAuth2ClientCredentials,
|
||||
)
|
||||
|
||||
|
||||
_auth_store: dict[int, AuthScheme | None] = {}
|
||||
|
||||
_SCHEME_PATTERN: Final[re.Pattern[str]] = re.compile(r"(\w+)\s+(.+?)(?=,\s*\w+\s+|$)")
|
||||
_PARAM_PATTERN: Final[re.Pattern[str]] = re.compile(r'(\w+)=(?:"([^"]*)"|([^\s,]+))')
|
||||
|
||||
_SCHEME_AUTH_MAPPING: Final[dict[type, tuple[type[AuthScheme], ...]]] = {
|
||||
OAuth2SecurityScheme: (
|
||||
OAuth2ClientCredentials,
|
||||
OAuth2AuthorizationCode,
|
||||
BearerTokenAuth,
|
||||
),
|
||||
APIKeySecurityScheme: (APIKeyAuth,),
|
||||
}
|
||||
|
||||
_HTTP_SCHEME_MAPPING: Final[dict[str, type[AuthScheme]]] = {
|
||||
"basic": HTTPBasicAuth,
|
||||
"digest": HTTPDigestAuth,
|
||||
"bearer": BearerTokenAuth,
|
||||
}
|
||||
|
||||
|
||||
def _raise_auth_mismatch(
|
||||
expected_classes: type[AuthScheme] | tuple[type[AuthScheme], ...],
|
||||
provided_auth: AuthScheme,
|
||||
) -> None:
|
||||
"""Raise authentication mismatch error.
|
||||
|
||||
Args:
|
||||
expected_classes: Expected authentication class or tuple of classes.
|
||||
provided_auth: Actually provided authentication instance.
|
||||
|
||||
Raises:
|
||||
A2AClientHTTPError: Always raises with 401 status code.
|
||||
"""
|
||||
if isinstance(expected_classes, tuple):
|
||||
if len(expected_classes) == 1:
|
||||
required = expected_classes[0].__name__
|
||||
else:
|
||||
names = [cls.__name__ for cls in expected_classes]
|
||||
required = f"one of ({', '.join(names)})"
|
||||
else:
|
||||
required = expected_classes.__name__
|
||||
|
||||
msg = (
|
||||
f"AgentCard requires {required} authentication, "
|
||||
f"but {type(provided_auth).__name__} was provided"
|
||||
)
|
||||
raise A2AClientHTTPError(401, msg)
|
||||
|
||||
|
||||
def parse_www_authenticate(header_value: str) -> dict[str, dict[str, str]]:
|
||||
"""Parse WWW-Authenticate header into auth challenges.
|
||||
|
||||
Args:
|
||||
header_value: The WWW-Authenticate header value.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping auth scheme to its parameters.
|
||||
Example: {"Bearer": {"realm": "api", "scope": "read write"}}
|
||||
"""
|
||||
if not header_value:
|
||||
return {}
|
||||
|
||||
challenges: dict[str, dict[str, str]] = {}
|
||||
|
||||
for match in _SCHEME_PATTERN.finditer(header_value):
|
||||
scheme = match.group(1)
|
||||
params_str = match.group(2)
|
||||
|
||||
params: dict[str, str] = {}
|
||||
|
||||
for param_match in _PARAM_PATTERN.finditer(params_str):
|
||||
key = param_match.group(1)
|
||||
value = param_match.group(2) or param_match.group(3)
|
||||
params[key] = value
|
||||
|
||||
challenges[scheme] = params
|
||||
|
||||
return challenges
|
||||
|
||||
|
||||
def validate_auth_against_agent_card(
|
||||
agent_card: AgentCard, auth: AuthScheme | None
|
||||
) -> None:
|
||||
"""Validate that provided auth matches AgentCard security requirements.
|
||||
|
||||
Args:
|
||||
agent_card: The A2A AgentCard containing security requirements.
|
||||
auth: User-provided authentication scheme (or None).
|
||||
|
||||
Raises:
|
||||
A2AClientHTTPError: If auth doesn't match AgentCard requirements (status_code=401).
|
||||
"""
|
||||
|
||||
if not agent_card.security or not agent_card.security_schemes:
|
||||
return
|
||||
|
||||
if not auth:
|
||||
msg = "AgentCard requires authentication but no auth scheme provided"
|
||||
raise A2AClientHTTPError(401, msg)
|
||||
|
||||
first_security_req = agent_card.security[0] if agent_card.security else {}
|
||||
|
||||
for scheme_name in first_security_req.keys():
|
||||
security_scheme_wrapper = agent_card.security_schemes.get(scheme_name)
|
||||
if not security_scheme_wrapper:
|
||||
continue
|
||||
|
||||
scheme = security_scheme_wrapper.root
|
||||
|
||||
if allowed_classes := _SCHEME_AUTH_MAPPING.get(type(scheme)):
|
||||
if not isinstance(auth, allowed_classes):
|
||||
_raise_auth_mismatch(allowed_classes, auth)
|
||||
return
|
||||
|
||||
if isinstance(scheme, HTTPAuthSecurityScheme):
|
||||
if required_class := _HTTP_SCHEME_MAPPING.get(scheme.scheme.lower()):
|
||||
if not isinstance(auth, required_class):
|
||||
_raise_auth_mismatch(required_class, auth)
|
||||
return
|
||||
|
||||
msg = "Could not validate auth against AgentCard security requirements"
|
||||
raise A2AClientHTTPError(401, msg)
|
||||
|
||||
|
||||
async def retry_on_401(
|
||||
request_func: Callable[[], Awaitable[Response]],
|
||||
auth_scheme: AuthScheme | None,
|
||||
client: AsyncClient,
|
||||
headers: MutableMapping[str, str],
|
||||
max_retries: int = 3,
|
||||
) -> Response:
|
||||
"""Retry a request on 401 authentication error.
|
||||
|
||||
Handles 401 errors by:
|
||||
1. Parsing WWW-Authenticate header
|
||||
2. Re-acquiring credentials
|
||||
3. Retrying the request
|
||||
|
||||
Args:
|
||||
request_func: Async function that makes the HTTP request.
|
||||
auth_scheme: Authentication scheme to refresh credentials with.
|
||||
client: HTTP client for making requests.
|
||||
headers: Request headers to update with new auth.
|
||||
max_retries: Maximum number of retry attempts (default: 3).
|
||||
|
||||
Returns:
|
||||
HTTP response from the request.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If retries are exhausted or auth scheme is None.
|
||||
"""
|
||||
last_response: Response | None = None
|
||||
last_challenges: dict[str, dict[str, str]] = {}
|
||||
|
||||
for attempt in range(max_retries):
|
||||
response = await request_func()
|
||||
|
||||
if response.status_code != 401:
|
||||
return response
|
||||
|
||||
last_response = response
|
||||
|
||||
if auth_scheme is None:
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
www_authenticate = response.headers.get("WWW-Authenticate", "")
|
||||
challenges = parse_www_authenticate(www_authenticate)
|
||||
last_challenges = challenges
|
||||
|
||||
if attempt >= max_retries - 1:
|
||||
break
|
||||
|
||||
backoff_time = 2**attempt
|
||||
await asyncio.sleep(backoff_time)
|
||||
|
||||
await auth_scheme.apply_auth(client, headers)
|
||||
|
||||
if last_response:
|
||||
last_response.raise_for_status()
|
||||
return last_response
|
||||
|
||||
msg = "retry_on_401 failed without making any requests"
|
||||
if last_challenges:
|
||||
challenge_info = ", ".join(
|
||||
f"{scheme} (realm={params.get('realm', 'N/A')})"
|
||||
for scheme, params in last_challenges.items()
|
||||
)
|
||||
msg = f"{msg}. Server challenges: {challenge_info}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def configure_auth_client(
|
||||
auth: HTTPDigestAuth | APIKeyAuth, client: AsyncClient
|
||||
) -> None:
|
||||
"""Configure HTTP client with auth-specific settings.
|
||||
|
||||
Only HTTPDigestAuth and APIKeyAuth need client configuration.
|
||||
|
||||
Args:
|
||||
auth: Authentication scheme that requires client configuration.
|
||||
client: HTTP client to configure.
|
||||
"""
|
||||
auth.configure_client(client)
|
||||
64
lib/crewai/src/crewai/a2a/config.py
Normal file
64
lib/crewai/src/crewai/a2a/config.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""A2A configuration types.
|
||||
|
||||
This module is separate from experimental.a2a to avoid circular imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
BeforeValidator,
|
||||
Field,
|
||||
HttpUrl,
|
||||
TypeAdapter,
|
||||
)
|
||||
|
||||
from crewai.a2a.auth.schemas import AuthScheme
|
||||
|
||||
|
||||
http_url_adapter = TypeAdapter(HttpUrl)
|
||||
|
||||
Url = Annotated[
|
||||
str,
|
||||
BeforeValidator(
|
||||
lambda value: str(http_url_adapter.validate_python(value, strict=True))
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class A2AConfig(BaseModel):
|
||||
"""Configuration for A2A protocol integration.
|
||||
|
||||
Attributes:
|
||||
endpoint: A2A agent endpoint URL.
|
||||
auth: Authentication scheme (Bearer, OAuth2, API Key, HTTP Basic/Digest).
|
||||
timeout: Request timeout in seconds (default: 120).
|
||||
max_turns: Maximum conversation turns with A2A agent (default: 10).
|
||||
response_model: Optional Pydantic model for structured A2A agent responses.
|
||||
fail_fast: If True, raise error when agent unreachable; if False, skip and continue (default: True).
|
||||
trust_remote_completion_status: If True, return A2A agent's result directly when status is "completed"; if False, always ask server agent to respond (default: False).
|
||||
"""
|
||||
|
||||
endpoint: Url = Field(description="A2A agent endpoint URL")
|
||||
auth: AuthScheme | None = Field(
|
||||
default=None,
|
||||
description="Authentication scheme (Bearer, OAuth2, API Key, HTTP Basic/Digest)",
|
||||
)
|
||||
timeout: int = Field(default=120, description="Request timeout in seconds")
|
||||
max_turns: int = Field(
|
||||
default=10, description="Maximum conversation turns with A2A agent"
|
||||
)
|
||||
response_model: type[BaseModel] | None = Field(
|
||||
default=None,
|
||||
description="Optional Pydantic model for structured A2A agent responses. When specified, the A2A agent is expected to return JSON matching this schema.",
|
||||
)
|
||||
fail_fast: bool = Field(
|
||||
default=True,
|
||||
description="If True, raise an error immediately when the A2A agent is unreachable. If False, skip the A2A agent and continue execution.",
|
||||
)
|
||||
trust_remote_completion_status: bool = Field(
|
||||
default=False,
|
||||
description='If True, return the A2A agent\'s result directly when status is "completed" without asking the server agent to respond. If False, always ask the server agent to respond, allowing it to potentially delegate again.',
|
||||
)
|
||||
29
lib/crewai/src/crewai/a2a/templates.py
Normal file
29
lib/crewai/src/crewai/a2a/templates.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""String templates for A2A (Agent-to-Agent) protocol messaging and status."""
|
||||
|
||||
from string import Template
|
||||
from typing import Final
|
||||
|
||||
|
||||
AVAILABLE_AGENTS_TEMPLATE: Final[Template] = Template(
|
||||
"\n<AVAILABLE_A2A_AGENTS>\n $available_a2a_agents\n</AVAILABLE_A2A_AGENTS>\n"
|
||||
)
|
||||
PREVIOUS_A2A_CONVERSATION_TEMPLATE: Final[Template] = Template(
|
||||
"\n<PREVIOUS_A2A_CONVERSATION>\n"
|
||||
" $previous_a2a_conversation"
|
||||
"\n</PREVIOUS_A2A_CONVERSATION>\n"
|
||||
)
|
||||
CONVERSATION_TURN_INFO_TEMPLATE: Final[Template] = Template(
|
||||
"\n<CONVERSATION_PROGRESS>\n"
|
||||
' turn="$turn_count"\n'
|
||||
' max_turns="$max_turns"\n'
|
||||
" $warning"
|
||||
"\n</CONVERSATION_PROGRESS>\n"
|
||||
)
|
||||
UNAVAILABLE_AGENTS_NOTICE_TEMPLATE: Final[Template] = Template(
|
||||
"\n<A2A_AGENTS_STATUS>\n"
|
||||
" NOTE: A2A agents were configured but are currently unavailable.\n"
|
||||
" You cannot delegate to remote agents for this task.\n\n"
|
||||
" Unavailable Agents:\n"
|
||||
" $unavailable_agents"
|
||||
"\n</A2A_AGENTS_STATUS>\n"
|
||||
)
|
||||
38
lib/crewai/src/crewai/a2a/types.py
Normal file
38
lib/crewai/src/crewai/a2a/types.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Type definitions for A2A protocol message parts."""
|
||||
|
||||
from typing import Any, Literal, Protocol, TypedDict, runtime_checkable
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AgentResponseProtocol(Protocol):
|
||||
"""Protocol for the dynamically created AgentResponse model."""
|
||||
|
||||
a2a_ids: tuple[str, ...]
|
||||
message: str
|
||||
is_a2a: bool
|
||||
|
||||
|
||||
class PartsMetadataDict(TypedDict, total=False):
|
||||
"""Metadata for A2A message parts.
|
||||
|
||||
Attributes:
|
||||
mimeType: MIME type for the part content.
|
||||
schema: JSON schema for the part content.
|
||||
"""
|
||||
|
||||
mimeType: Literal["application/json"]
|
||||
schema: dict[str, Any]
|
||||
|
||||
|
||||
class PartsDict(TypedDict):
|
||||
"""A2A message part containing text and optional metadata.
|
||||
|
||||
Attributes:
|
||||
text: The text content of the message part.
|
||||
metadata: Optional metadata describing the part content.
|
||||
"""
|
||||
|
||||
text: str
|
||||
metadata: NotRequired[PartsMetadataDict]
|
||||
755
lib/crewai/src/crewai/a2a/utils.py
Normal file
755
lib/crewai/src/crewai/a2a/utils.py
Normal file
@@ -0,0 +1,755 @@
|
||||
"""Utility functions for A2A (Agent-to-Agent) protocol delegation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, MutableMapping
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import lru_cache
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import uuid
|
||||
|
||||
from a2a.client import Client, ClientConfig, ClientFactory
|
||||
from a2a.client.errors import A2AClientHTTPError
|
||||
from a2a.types import (
|
||||
AgentCard,
|
||||
Message,
|
||||
Part,
|
||||
Role,
|
||||
TaskArtifactUpdateEvent,
|
||||
TaskState,
|
||||
TaskStatusUpdateEvent,
|
||||
TextPart,
|
||||
TransportProtocol,
|
||||
)
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
|
||||
from crewai.a2a.auth.schemas import APIKeyAuth, HTTPDigestAuth
|
||||
from crewai.a2a.auth.utils import (
|
||||
_auth_store,
|
||||
configure_auth_client,
|
||||
retry_on_401,
|
||||
validate_auth_against_agent_card,
|
||||
)
|
||||
from crewai.a2a.config import A2AConfig
|
||||
from crewai.a2a.types import PartsDict, PartsMetadataDict
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.a2a_events import (
|
||||
A2AConversationStartedEvent,
|
||||
A2ADelegationCompletedEvent,
|
||||
A2ADelegationStartedEvent,
|
||||
A2AMessageSentEvent,
|
||||
A2AResponseReceivedEvent,
|
||||
)
|
||||
from crewai.types.utils import create_literals_from_strings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from a2a.types import Message, Task as A2ATask
|
||||
|
||||
from crewai.a2a.auth.schemas import AuthScheme
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def _fetch_agent_card_cached(
|
||||
endpoint: str,
|
||||
auth_hash: int,
|
||||
timeout: int,
|
||||
_ttl_hash: int,
|
||||
) -> AgentCard:
|
||||
"""Cached version of fetch_agent_card with auth support.
|
||||
|
||||
Args:
|
||||
endpoint: A2A agent endpoint URL
|
||||
auth_hash: Hash of the auth object
|
||||
timeout: Request timeout
|
||||
_ttl_hash: Time-based hash for cache invalidation (unused in body)
|
||||
|
||||
Returns:
|
||||
Cached AgentCard
|
||||
"""
|
||||
auth = _auth_store.get(auth_hash)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(
|
||||
_fetch_agent_card_async(endpoint=endpoint, auth=auth, timeout=timeout)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def fetch_agent_card(
|
||||
endpoint: str,
|
||||
auth: AuthScheme | None = None,
|
||||
timeout: int = 30,
|
||||
use_cache: bool = True,
|
||||
cache_ttl: int = 300,
|
||||
) -> AgentCard:
|
||||
"""Fetch AgentCard from an A2A endpoint with optional caching.
|
||||
|
||||
Args:
|
||||
endpoint: A2A agent endpoint URL (AgentCard URL)
|
||||
auth: Optional AuthScheme for authentication
|
||||
timeout: Request timeout in seconds
|
||||
use_cache: Whether to use caching (default True)
|
||||
cache_ttl: Cache TTL in seconds (default 300 = 5 minutes)
|
||||
|
||||
Returns:
|
||||
AgentCard object with agent capabilities and skills
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the request fails
|
||||
A2AClientHTTPError: If authentication fails
|
||||
"""
|
||||
if use_cache:
|
||||
auth_hash = hash((type(auth).__name__, id(auth))) if auth else 0
|
||||
_auth_store[auth_hash] = auth
|
||||
ttl_hash = int(time.time() // cache_ttl)
|
||||
return _fetch_agent_card_cached(endpoint, auth_hash, timeout, ttl_hash)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(
|
||||
_fetch_agent_card_async(endpoint=endpoint, auth=auth, timeout=timeout)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _fetch_agent_card_async(
|
||||
endpoint: str,
|
||||
auth: AuthScheme | None,
|
||||
timeout: int,
|
||||
) -> AgentCard:
|
||||
"""Async implementation of AgentCard fetching.
|
||||
|
||||
Args:
|
||||
endpoint: A2A agent endpoint URL
|
||||
auth: Optional AuthScheme for authentication
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
AgentCard object
|
||||
"""
|
||||
if "/.well-known/agent-card.json" in endpoint:
|
||||
base_url = endpoint.replace("/.well-known/agent-card.json", "")
|
||||
agent_card_path = "/.well-known/agent-card.json"
|
||||
else:
|
||||
url_parts = endpoint.split("/", 3)
|
||||
base_url = f"{url_parts[0]}//{url_parts[2]}"
|
||||
agent_card_path = f"/{url_parts[3]}" if len(url_parts) > 3 else "/"
|
||||
|
||||
headers: MutableMapping[str, str] = {}
|
||||
if auth:
|
||||
async with httpx.AsyncClient(timeout=timeout) as temp_auth_client:
|
||||
if isinstance(auth, (HTTPDigestAuth, APIKeyAuth)):
|
||||
configure_auth_client(auth, temp_auth_client)
|
||||
headers = await auth.apply_auth(temp_auth_client, {})
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, headers=headers) as temp_client:
|
||||
if auth and isinstance(auth, (HTTPDigestAuth, APIKeyAuth)):
|
||||
configure_auth_client(auth, temp_client)
|
||||
|
||||
agent_card_url = f"{base_url}{agent_card_path}"
|
||||
|
||||
async def _fetch_agent_card_request() -> httpx.Response:
|
||||
return await temp_client.get(agent_card_url)
|
||||
|
||||
try:
|
||||
response = await retry_on_401(
|
||||
request_func=_fetch_agent_card_request,
|
||||
auth_scheme=auth,
|
||||
client=temp_client,
|
||||
headers=temp_client.headers,
|
||||
max_retries=2,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return AgentCard.model_validate(response.json())
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
error_details = ["Authentication failed"]
|
||||
www_auth = e.response.headers.get("WWW-Authenticate")
|
||||
if www_auth:
|
||||
error_details.append(f"WWW-Authenticate: {www_auth}")
|
||||
if not auth:
|
||||
error_details.append("No auth scheme provided")
|
||||
msg = " | ".join(error_details)
|
||||
raise A2AClientHTTPError(401, msg) from e
|
||||
raise
|
||||
|
||||
|
||||
def execute_a2a_delegation(
|
||||
endpoint: str,
|
||||
auth: AuthScheme | None,
|
||||
timeout: int,
|
||||
task_description: str,
|
||||
context: str | None = None,
|
||||
context_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
reference_task_ids: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
extensions: dict[str, Any] | None = None,
|
||||
conversation_history: list[Message] | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_role: Role | None = None,
|
||||
agent_branch: Any | None = None,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
turn_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a task delegation to a remote A2A agent with multi-turn support.
|
||||
|
||||
Handles:
|
||||
- AgentCard discovery
|
||||
- Authentication setup
|
||||
- Message creation and sending
|
||||
- Response parsing
|
||||
- Multi-turn conversations
|
||||
|
||||
Args:
|
||||
endpoint: A2A agent endpoint URL (AgentCard URL)
|
||||
auth: Optional AuthScheme for authentication (Bearer, OAuth2, API Key, HTTP Basic/Digest)
|
||||
timeout: Request timeout in seconds
|
||||
task_description: The task to delegate
|
||||
context: Optional context information
|
||||
context_id: Context ID for correlating messages/tasks
|
||||
task_id: Specific task identifier
|
||||
reference_task_ids: List of related task IDs
|
||||
metadata: Additional metadata (external_id, request_id, etc.)
|
||||
extensions: Protocol extensions for custom fields
|
||||
conversation_history: Previous Message objects from conversation
|
||||
agent_id: Agent identifier for logging
|
||||
agent_role: Role of the CrewAI agent delegating the task
|
||||
agent_branch: Optional agent tree branch for logging
|
||||
response_model: Optional Pydantic model for structured outputs
|
||||
turn_number: Optional turn number for multi-turn conversations
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- status: "completed", "input_required", "failed", etc.
|
||||
- result: Result string (if completed)
|
||||
- error: Error message (if failed)
|
||||
- history: List of new Message objects from this exchange
|
||||
|
||||
Raises:
|
||||
ImportError: If a2a-sdk is not installed
|
||||
"""
|
||||
is_multiturn = bool(conversation_history and len(conversation_history) > 0)
|
||||
if turn_number is None:
|
||||
turn_number = (
|
||||
len([m for m in (conversation_history or []) if m.role == Role.user]) + 1
|
||||
)
|
||||
crewai_event_bus.emit(
|
||||
agent_branch,
|
||||
A2ADelegationStartedEvent(
|
||||
endpoint=endpoint,
|
||||
task_description=task_description,
|
||||
agent_id=agent_id,
|
||||
is_multiturn=is_multiturn,
|
||||
turn_number=turn_number,
|
||||
),
|
||||
)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(
|
||||
_execute_a2a_delegation_async(
|
||||
endpoint=endpoint,
|
||||
auth=auth,
|
||||
timeout=timeout,
|
||||
task_description=task_description,
|
||||
context=context,
|
||||
context_id=context_id,
|
||||
task_id=task_id,
|
||||
reference_task_ids=reference_task_ids,
|
||||
metadata=metadata,
|
||||
extensions=extensions,
|
||||
conversation_history=conversation_history or [],
|
||||
is_multiturn=is_multiturn,
|
||||
turn_number=turn_number,
|
||||
agent_branch=agent_branch,
|
||||
agent_id=agent_id,
|
||||
agent_role=agent_role,
|
||||
response_model=response_model,
|
||||
)
|
||||
)
|
||||
|
||||
crewai_event_bus.emit(
|
||||
agent_branch,
|
||||
A2ADelegationCompletedEvent(
|
||||
status=result["status"],
|
||||
result=result.get("result"),
|
||||
error=result.get("error"),
|
||||
is_multiturn=is_multiturn,
|
||||
),
|
||||
)
|
||||
|
||||
return result
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _execute_a2a_delegation_async(
|
||||
endpoint: str,
|
||||
auth: AuthScheme | None,
|
||||
timeout: int,
|
||||
task_description: str,
|
||||
context: str | None,
|
||||
context_id: str | None,
|
||||
task_id: str | None,
|
||||
reference_task_ids: list[str] | None,
|
||||
metadata: dict[str, Any] | None,
|
||||
extensions: dict[str, Any] | None,
|
||||
conversation_history: list[Message],
|
||||
is_multiturn: bool = False,
|
||||
turn_number: int = 1,
|
||||
agent_branch: Any | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_role: str | None = None,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Async implementation of A2A delegation with multi-turn support.
|
||||
|
||||
Args:
|
||||
endpoint: A2A agent endpoint URL
|
||||
auth: Optional AuthScheme for authentication
|
||||
timeout: Request timeout in seconds
|
||||
task_description: Task to delegate
|
||||
context: Optional context
|
||||
context_id: Context ID for correlation
|
||||
task_id: Specific task identifier
|
||||
reference_task_ids: Related task IDs
|
||||
metadata: Additional metadata
|
||||
extensions: Protocol extensions
|
||||
conversation_history: Previous Message objects
|
||||
is_multiturn: Whether this is a multi-turn conversation
|
||||
turn_number: Current turn number
|
||||
agent_branch: Agent tree branch for logging
|
||||
agent_id: Agent identifier for logging
|
||||
agent_role: Agent role for logging
|
||||
response_model: Optional Pydantic model for structured outputs
|
||||
|
||||
Returns:
|
||||
Dictionary with status, result/error, and new history
|
||||
"""
|
||||
agent_card = await _fetch_agent_card_async(endpoint, auth, timeout)
|
||||
|
||||
validate_auth_against_agent_card(agent_card, auth)
|
||||
|
||||
headers: MutableMapping[str, str] = {}
|
||||
if auth:
|
||||
async with httpx.AsyncClient(timeout=timeout) as temp_auth_client:
|
||||
if isinstance(auth, (HTTPDigestAuth, APIKeyAuth)):
|
||||
configure_auth_client(auth, temp_auth_client)
|
||||
headers = await auth.apply_auth(temp_auth_client, {})
|
||||
|
||||
a2a_agent_name = None
|
||||
if agent_card.name:
|
||||
a2a_agent_name = agent_card.name
|
||||
|
||||
if turn_number == 1:
|
||||
agent_id_for_event = agent_id or endpoint
|
||||
crewai_event_bus.emit(
|
||||
agent_branch,
|
||||
A2AConversationStartedEvent(
|
||||
agent_id=agent_id_for_event,
|
||||
endpoint=endpoint,
|
||||
a2a_agent_name=a2a_agent_name,
|
||||
),
|
||||
)
|
||||
|
||||
message_parts = []
|
||||
|
||||
if context:
|
||||
message_parts.append(f"Context:\n{context}\n\n")
|
||||
message_parts.append(f"{task_description}")
|
||||
message_text = "".join(message_parts)
|
||||
|
||||
if is_multiturn and conversation_history and not task_id:
|
||||
if first_task_id := conversation_history[0].task_id:
|
||||
task_id = first_task_id
|
||||
|
||||
parts: PartsDict = {"text": message_text}
|
||||
if response_model:
|
||||
parts.update(
|
||||
{
|
||||
"metadata": PartsMetadataDict(
|
||||
mimeType="application/json",
|
||||
schema=response_model.model_json_schema(),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
message = Message(
|
||||
role=Role.user,
|
||||
message_id=str(uuid.uuid4()),
|
||||
parts=[Part(root=TextPart(**parts))],
|
||||
context_id=context_id,
|
||||
task_id=task_id,
|
||||
reference_task_ids=reference_task_ids,
|
||||
metadata=metadata,
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
transport_protocol = TransportProtocol("JSONRPC")
|
||||
new_messages: list[Message] = [*conversation_history, message]
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AMessageSentEvent(
|
||||
message=message_text,
|
||||
turn_number=turn_number,
|
||||
is_multiturn=is_multiturn,
|
||||
agent_role=agent_role,
|
||||
),
|
||||
)
|
||||
|
||||
async with _create_a2a_client(
|
||||
agent_card=agent_card,
|
||||
transport_protocol=transport_protocol,
|
||||
timeout=timeout,
|
||||
headers=headers,
|
||||
streaming=True,
|
||||
auth=auth,
|
||||
) as client:
|
||||
result_parts: list[str] = []
|
||||
final_result: dict[str, Any] | None = None
|
||||
event_stream = client.send_message(message)
|
||||
|
||||
try:
|
||||
async for event in event_stream:
|
||||
if isinstance(event, Message):
|
||||
new_messages.append(event)
|
||||
for part in event.parts:
|
||||
if part.root.kind == "text":
|
||||
text = part.root.text
|
||||
result_parts.append(text)
|
||||
|
||||
elif isinstance(event, tuple):
|
||||
a2a_task, update = event
|
||||
|
||||
if isinstance(update, TaskArtifactUpdateEvent):
|
||||
artifact = update.artifact
|
||||
result_parts.extend(
|
||||
part.root.text
|
||||
for part in artifact.parts
|
||||
if part.root.kind == "text"
|
||||
)
|
||||
|
||||
is_final_update = False
|
||||
if isinstance(update, TaskStatusUpdateEvent):
|
||||
is_final_update = update.final
|
||||
|
||||
if not is_final_update and a2a_task.status.state not in [
|
||||
TaskState.completed,
|
||||
TaskState.input_required,
|
||||
TaskState.failed,
|
||||
TaskState.rejected,
|
||||
TaskState.auth_required,
|
||||
TaskState.canceled,
|
||||
]:
|
||||
continue
|
||||
|
||||
if a2a_task.status.state == TaskState.completed:
|
||||
extracted_parts = _extract_task_result_parts(a2a_task)
|
||||
result_parts.extend(extracted_parts)
|
||||
if a2a_task.history:
|
||||
new_messages.extend(a2a_task.history)
|
||||
|
||||
response_text = " ".join(result_parts) if result_parts else ""
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AResponseReceivedEvent(
|
||||
response=response_text,
|
||||
turn_number=turn_number,
|
||||
is_multiturn=is_multiturn,
|
||||
status="completed",
|
||||
agent_role=agent_role,
|
||||
),
|
||||
)
|
||||
|
||||
final_result = {
|
||||
"status": "completed",
|
||||
"result": response_text,
|
||||
"history": new_messages,
|
||||
"agent_card": agent_card,
|
||||
}
|
||||
break
|
||||
|
||||
if a2a_task.status.state == TaskState.input_required:
|
||||
if a2a_task.history:
|
||||
new_messages.extend(a2a_task.history)
|
||||
|
||||
response_text = _extract_error_message(
|
||||
a2a_task, "Additional input required"
|
||||
)
|
||||
if response_text and not a2a_task.history:
|
||||
agent_message = Message(
|
||||
role=Role.agent,
|
||||
message_id=str(uuid.uuid4()),
|
||||
parts=[Part(root=TextPart(text=response_text))],
|
||||
context_id=a2a_task.context_id
|
||||
if hasattr(a2a_task, "context_id")
|
||||
else None,
|
||||
task_id=a2a_task.task_id
|
||||
if hasattr(a2a_task, "task_id")
|
||||
else None,
|
||||
)
|
||||
new_messages.append(agent_message)
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AResponseReceivedEvent(
|
||||
response=response_text,
|
||||
turn_number=turn_number,
|
||||
is_multiturn=is_multiturn,
|
||||
status="input_required",
|
||||
agent_role=agent_role,
|
||||
),
|
||||
)
|
||||
|
||||
final_result = {
|
||||
"status": "input_required",
|
||||
"error": response_text,
|
||||
"history": new_messages,
|
||||
"agent_card": agent_card,
|
||||
}
|
||||
break
|
||||
|
||||
if a2a_task.status.state in [TaskState.failed, TaskState.rejected]:
|
||||
error_msg = _extract_error_message(
|
||||
a2a_task, "Task failed without error message"
|
||||
)
|
||||
if a2a_task.history:
|
||||
new_messages.extend(a2a_task.history)
|
||||
final_result = {
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"history": new_messages,
|
||||
}
|
||||
break
|
||||
|
||||
if a2a_task.status.state == TaskState.auth_required:
|
||||
error_msg = _extract_error_message(
|
||||
a2a_task, "Authentication required"
|
||||
)
|
||||
final_result = {
|
||||
"status": "auth_required",
|
||||
"error": error_msg,
|
||||
"history": new_messages,
|
||||
}
|
||||
break
|
||||
|
||||
if a2a_task.status.state == TaskState.canceled:
|
||||
error_msg = _extract_error_message(
|
||||
a2a_task, "Task was canceled"
|
||||
)
|
||||
final_result = {
|
||||
"status": "canceled",
|
||||
"error": error_msg,
|
||||
"history": new_messages,
|
||||
}
|
||||
break
|
||||
except Exception as e:
|
||||
current_exception: Exception | BaseException | None = e
|
||||
while current_exception:
|
||||
if hasattr(current_exception, "response"):
|
||||
response = current_exception.response
|
||||
if hasattr(response, "text"):
|
||||
break
|
||||
if current_exception and hasattr(current_exception, "__cause__"):
|
||||
current_exception = current_exception.__cause__
|
||||
raise
|
||||
finally:
|
||||
if hasattr(event_stream, "aclose"):
|
||||
await event_stream.aclose()
|
||||
|
||||
if final_result:
|
||||
return final_result
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"result": " ".join(result_parts) if result_parts else "",
|
||||
"history": new_messages,
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _create_a2a_client(
|
||||
agent_card: AgentCard,
|
||||
transport_protocol: TransportProtocol,
|
||||
timeout: int,
|
||||
headers: MutableMapping[str, str],
|
||||
streaming: bool,
|
||||
auth: AuthScheme | None = None,
|
||||
) -> AsyncIterator[Client]:
|
||||
"""Create and configure an A2A client.
|
||||
|
||||
Args:
|
||||
agent_card: The A2A agent card
|
||||
transport_protocol: Transport protocol to use
|
||||
timeout: Request timeout in seconds
|
||||
headers: HTTP headers (already with auth applied)
|
||||
streaming: Enable streaming responses
|
||||
auth: Optional AuthScheme for client configuration
|
||||
|
||||
Yields:
|
||||
Configured A2A client instance
|
||||
"""
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout,
|
||||
headers=headers,
|
||||
) as httpx_client:
|
||||
if auth and isinstance(auth, (HTTPDigestAuth, APIKeyAuth)):
|
||||
configure_auth_client(auth, httpx_client)
|
||||
|
||||
config = ClientConfig(
|
||||
httpx_client=httpx_client,
|
||||
supported_transports=[str(transport_protocol.value)],
|
||||
streaming=streaming,
|
||||
accepted_output_modes=["application/json"],
|
||||
)
|
||||
|
||||
factory = ClientFactory(config)
|
||||
client = factory.create(agent_card)
|
||||
yield client
|
||||
|
||||
|
||||
def _extract_task_result_parts(a2a_task: A2ATask) -> list[str]:
|
||||
"""Extract result parts from A2A task history and artifacts.
|
||||
|
||||
Args:
|
||||
a2a_task: A2A Task object with history and artifacts
|
||||
|
||||
Returns:
|
||||
List of result text parts
|
||||
"""
|
||||
|
||||
result_parts: list[str] = []
|
||||
|
||||
if a2a_task.history:
|
||||
for history_msg in reversed(a2a_task.history):
|
||||
if history_msg.role == Role.agent:
|
||||
result_parts.extend(
|
||||
part.root.text
|
||||
for part in history_msg.parts
|
||||
if part.root.kind == "text"
|
||||
)
|
||||
break
|
||||
|
||||
if a2a_task.artifacts:
|
||||
result_parts.extend(
|
||||
part.root.text
|
||||
for artifact in a2a_task.artifacts
|
||||
for part in artifact.parts
|
||||
if part.root.kind == "text"
|
||||
)
|
||||
|
||||
return result_parts
|
||||
|
||||
|
||||
def _extract_error_message(a2a_task: A2ATask, default: str) -> str:
|
||||
"""Extract error message from A2A task.
|
||||
|
||||
Args:
|
||||
a2a_task: A2A Task object
|
||||
default: Default message if no error found
|
||||
|
||||
Returns:
|
||||
Error message string
|
||||
"""
|
||||
if a2a_task.status and a2a_task.status.message:
|
||||
msg = a2a_task.status.message
|
||||
if msg:
|
||||
for part in msg.parts:
|
||||
if part.root.kind == "text":
|
||||
return str(part.root.text)
|
||||
return str(msg)
|
||||
|
||||
if a2a_task.history:
|
||||
for history_msg in reversed(a2a_task.history):
|
||||
for part in history_msg.parts:
|
||||
if part.root.kind == "text":
|
||||
return str(part.root.text)
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def create_agent_response_model(agent_ids: tuple[str, ...]) -> type[BaseModel]:
|
||||
"""Create a dynamic AgentResponse model with Literal types for agent IDs.
|
||||
|
||||
Args:
|
||||
agent_ids: List of available A2A agent IDs
|
||||
|
||||
Returns:
|
||||
Dynamically created Pydantic model with Literal-constrained a2a_ids field
|
||||
"""
|
||||
|
||||
DynamicLiteral = create_literals_from_strings(agent_ids) # noqa: N806
|
||||
|
||||
return create_model(
|
||||
"AgentResponse",
|
||||
a2a_ids=(
|
||||
tuple[DynamicLiteral, ...], # type: ignore[valid-type]
|
||||
Field(
|
||||
default_factory=tuple,
|
||||
max_length=len(agent_ids),
|
||||
description="A2A agent IDs to delegate to.",
|
||||
),
|
||||
),
|
||||
message=(
|
||||
str,
|
||||
Field(
|
||||
description="The message content. If is_a2a=true, this is sent to the A2A agent. If is_a2a=false, this is your final answer ending the conversation."
|
||||
),
|
||||
),
|
||||
is_a2a=(
|
||||
bool,
|
||||
Field(
|
||||
description="Set to true to continue the conversation by sending this message to the A2A agent and awaiting their response. Set to false ONLY when you are completely done and providing your final answer (not when asking questions)."
|
||||
),
|
||||
),
|
||||
__base__=BaseModel,
|
||||
)
|
||||
|
||||
|
||||
def extract_a2a_agent_ids_from_config(
|
||||
a2a_config: list[A2AConfig] | A2AConfig | None,
|
||||
) -> tuple[list[A2AConfig], tuple[str, ...]]:
|
||||
"""Extract A2A agent IDs from A2A configuration.
|
||||
|
||||
Args:
|
||||
a2a_config: A2A configuration
|
||||
|
||||
Returns:
|
||||
List of A2A agent IDs
|
||||
"""
|
||||
if a2a_config is None:
|
||||
return [], ()
|
||||
|
||||
if isinstance(a2a_config, A2AConfig):
|
||||
a2a_agents = [a2a_config]
|
||||
else:
|
||||
a2a_agents = a2a_config
|
||||
return a2a_agents, tuple(config.endpoint for config in a2a_agents)
|
||||
|
||||
|
||||
def get_a2a_agents_and_response_model(
|
||||
a2a_config: list[A2AConfig] | A2AConfig | None,
|
||||
) -> tuple[list[A2AConfig], type[BaseModel]]:
|
||||
"""Get A2A agent IDs and response model.
|
||||
|
||||
Args:
|
||||
a2a_config: A2A configuration
|
||||
|
||||
Returns:
|
||||
Tuple of A2A agent IDs and response model
|
||||
"""
|
||||
a2a_agents, agent_ids = extract_a2a_agent_ids_from_config(a2a_config=a2a_config)
|
||||
return a2a_agents, create_agent_response_model(agent_ids)
|
||||
587
lib/crewai/src/crewai/a2a/wrapper.py
Normal file
587
lib/crewai/src/crewai/a2a/wrapper.py
Normal file
@@ -0,0 +1,587 @@
|
||||
"""A2A agent wrapping logic for metaclass integration.
|
||||
|
||||
Wraps agent classes with A2A delegation capabilities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from functools import wraps
|
||||
from types import MethodType
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from a2a.types import Role
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from crewai.a2a.config import A2AConfig
|
||||
from crewai.a2a.templates import (
|
||||
AVAILABLE_AGENTS_TEMPLATE,
|
||||
CONVERSATION_TURN_INFO_TEMPLATE,
|
||||
PREVIOUS_A2A_CONVERSATION_TEMPLATE,
|
||||
UNAVAILABLE_AGENTS_NOTICE_TEMPLATE,
|
||||
)
|
||||
from crewai.a2a.types import AgentResponseProtocol
|
||||
from crewai.a2a.utils import (
|
||||
execute_a2a_delegation,
|
||||
fetch_agent_card,
|
||||
get_a2a_agents_and_response_model,
|
||||
)
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.a2a_events import (
|
||||
A2AConversationCompletedEvent,
|
||||
A2AMessageSentEvent,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from a2a.types import AgentCard, Message
|
||||
|
||||
from crewai.agent.core import Agent
|
||||
from crewai.task import Task
|
||||
from crewai.tools.base_tool import BaseTool
|
||||
|
||||
|
||||
def wrap_agent_with_a2a_instance(agent: Agent) -> None:
|
||||
"""Wrap an agent instance's execute_task method with A2A support.
|
||||
|
||||
This function modifies the agent instance by wrapping its execute_task
|
||||
method to add A2A delegation capabilities. Should only be called when
|
||||
the agent has a2a configuration set.
|
||||
|
||||
Args:
|
||||
agent: The agent instance to wrap
|
||||
"""
|
||||
original_execute_task = agent.execute_task.__func__ # type: ignore[attr-defined]
|
||||
|
||||
@wraps(original_execute_task)
|
||||
def execute_task_with_a2a(
|
||||
self: Agent,
|
||||
task: Task,
|
||||
context: str | None = None,
|
||||
tools: list[BaseTool] | None = None,
|
||||
) -> str:
|
||||
"""Execute task with A2A delegation support.
|
||||
|
||||
Args:
|
||||
self: The agent instance
|
||||
task: The task to execute
|
||||
context: Optional context for task execution
|
||||
tools: Optional tools available to the agent
|
||||
|
||||
Returns:
|
||||
Task execution result
|
||||
"""
|
||||
if not self.a2a:
|
||||
return original_execute_task(self, task, context, tools) # type: ignore[no-any-return]
|
||||
|
||||
a2a_agents, agent_response_model = get_a2a_agents_and_response_model(self.a2a)
|
||||
|
||||
return _execute_task_with_a2a(
|
||||
self=self,
|
||||
a2a_agents=a2a_agents,
|
||||
original_fn=original_execute_task,
|
||||
task=task,
|
||||
agent_response_model=agent_response_model,
|
||||
context=context,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
object.__setattr__(agent, "execute_task", MethodType(execute_task_with_a2a, agent))
|
||||
|
||||
|
||||
def _fetch_card_from_config(
|
||||
config: A2AConfig,
|
||||
) -> tuple[A2AConfig, AgentCard | Exception]:
|
||||
"""Fetch agent card from A2A config.
|
||||
|
||||
Args:
|
||||
config: A2A configuration
|
||||
|
||||
Returns:
|
||||
Tuple of (config, card or exception)
|
||||
"""
|
||||
try:
|
||||
card = fetch_agent_card(
|
||||
endpoint=config.endpoint,
|
||||
auth=config.auth,
|
||||
timeout=config.timeout,
|
||||
)
|
||||
return config, card
|
||||
except Exception as e:
|
||||
return config, e
|
||||
|
||||
|
||||
def _fetch_agent_cards_concurrently(
|
||||
a2a_agents: list[A2AConfig],
|
||||
) -> tuple[dict[str, AgentCard], dict[str, str]]:
|
||||
"""Fetch agent cards concurrently for multiple A2A agents.
|
||||
|
||||
Args:
|
||||
a2a_agents: List of A2A agent configurations
|
||||
|
||||
Returns:
|
||||
Tuple of (agent_cards dict, failed_agents dict mapping endpoint to error message)
|
||||
"""
|
||||
agent_cards: dict[str, AgentCard] = {}
|
||||
failed_agents: dict[str, str] = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(a2a_agents)) as executor:
|
||||
futures = {
|
||||
executor.submit(_fetch_card_from_config, config): config
|
||||
for config in a2a_agents
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
config, result = future.result()
|
||||
if isinstance(result, Exception):
|
||||
if config.fail_fast:
|
||||
raise RuntimeError(
|
||||
f"Failed to fetch agent card from {config.endpoint}. "
|
||||
f"Ensure the A2A agent is running and accessible. Error: {result}"
|
||||
) from result
|
||||
failed_agents[config.endpoint] = str(result)
|
||||
else:
|
||||
agent_cards[config.endpoint] = result
|
||||
|
||||
return agent_cards, failed_agents
|
||||
|
||||
|
||||
def _execute_task_with_a2a(
|
||||
self: Agent,
|
||||
a2a_agents: list[A2AConfig],
|
||||
original_fn: Callable[..., str],
|
||||
task: Task,
|
||||
agent_response_model: type[BaseModel],
|
||||
context: str | None,
|
||||
tools: list[BaseTool] | None,
|
||||
) -> str:
|
||||
"""Wrap execute_task with A2A delegation logic.
|
||||
|
||||
Args:
|
||||
self: The agent instance
|
||||
a2a_agents: Dictionary of A2A agent configurations
|
||||
original_fn: The original execute_task method
|
||||
task: The task to execute
|
||||
context: Optional context for task execution
|
||||
tools: Optional tools available to the agent
|
||||
agent_response_model: Optional agent response model
|
||||
|
||||
Returns:
|
||||
Task execution result (either from LLM or A2A agent)
|
||||
"""
|
||||
original_description: str = task.description
|
||||
original_output_pydantic = task.output_pydantic
|
||||
original_response_model = task.response_model
|
||||
|
||||
agent_cards, failed_agents = _fetch_agent_cards_concurrently(a2a_agents)
|
||||
|
||||
if not agent_cards and a2a_agents and failed_agents:
|
||||
unavailable_agents_text = ""
|
||||
for endpoint, error in failed_agents.items():
|
||||
unavailable_agents_text += f" - {endpoint}: {error}\n"
|
||||
|
||||
notice = UNAVAILABLE_AGENTS_NOTICE_TEMPLATE.substitute(
|
||||
unavailable_agents=unavailable_agents_text
|
||||
)
|
||||
task.description = f"{original_description}{notice}"
|
||||
|
||||
try:
|
||||
return original_fn(self, task, context, tools)
|
||||
finally:
|
||||
task.description = original_description
|
||||
|
||||
task.description = _augment_prompt_with_a2a(
|
||||
a2a_agents=a2a_agents,
|
||||
task_description=original_description,
|
||||
agent_cards=agent_cards,
|
||||
failed_agents=failed_agents,
|
||||
)
|
||||
task.response_model = agent_response_model
|
||||
|
||||
try:
|
||||
raw_result = original_fn(self, task, context, tools)
|
||||
agent_response = _parse_agent_response(
|
||||
raw_result=raw_result, agent_response_model=agent_response_model
|
||||
)
|
||||
|
||||
if isinstance(agent_response, BaseModel) and isinstance(
|
||||
agent_response, AgentResponseProtocol
|
||||
):
|
||||
if agent_response.is_a2a:
|
||||
return _delegate_to_a2a(
|
||||
self,
|
||||
agent_response=agent_response,
|
||||
task=task,
|
||||
original_fn=original_fn,
|
||||
context=context,
|
||||
tools=tools,
|
||||
agent_cards=agent_cards,
|
||||
original_task_description=original_description,
|
||||
)
|
||||
return str(agent_response.message)
|
||||
|
||||
return raw_result
|
||||
finally:
|
||||
task.description = original_description
|
||||
task.output_pydantic = original_output_pydantic
|
||||
task.response_model = original_response_model
|
||||
|
||||
|
||||
def _augment_prompt_with_a2a(
|
||||
a2a_agents: list[A2AConfig],
|
||||
task_description: str,
|
||||
agent_cards: dict[str, AgentCard],
|
||||
conversation_history: list[Message] | None = None,
|
||||
turn_num: int = 0,
|
||||
max_turns: int | None = None,
|
||||
failed_agents: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Add A2A delegation instructions to prompt.
|
||||
|
||||
Args:
|
||||
a2a_agents: Dictionary of A2A agent configurations
|
||||
task_description: Original task description
|
||||
agent_cards: dictionary mapping agent IDs to AgentCards
|
||||
conversation_history: Previous A2A Messages from conversation
|
||||
turn_num: Current turn number (0-indexed)
|
||||
max_turns: Maximum allowed turns (from config)
|
||||
failed_agents: Dictionary mapping failed agent endpoints to error messages
|
||||
|
||||
Returns:
|
||||
Augmented task description with A2A instructions
|
||||
"""
|
||||
|
||||
if not agent_cards:
|
||||
return task_description
|
||||
|
||||
agents_text = ""
|
||||
|
||||
for config in a2a_agents:
|
||||
if config.endpoint in agent_cards:
|
||||
card = agent_cards[config.endpoint]
|
||||
agents_text += f"\n{card.model_dump_json(indent=2, exclude_none=True, include={'description', 'url', 'skills'})}\n"
|
||||
|
||||
failed_agents = failed_agents or {}
|
||||
if failed_agents:
|
||||
agents_text += "\n<!-- Unavailable Agents -->\n"
|
||||
for endpoint, error in failed_agents.items():
|
||||
agents_text += f"\n<!-- Agent: {endpoint}\n Status: Unavailable\n Error: {error} -->\n"
|
||||
|
||||
agents_text = AVAILABLE_AGENTS_TEMPLATE.substitute(available_a2a_agents=agents_text)
|
||||
|
||||
history_text = ""
|
||||
if conversation_history:
|
||||
for msg in conversation_history:
|
||||
history_text += f"\n{msg.model_dump_json(indent=2, exclude_none=True, exclude={'message_id'})}\n"
|
||||
|
||||
history_text = PREVIOUS_A2A_CONVERSATION_TEMPLATE.substitute(
|
||||
previous_a2a_conversation=history_text
|
||||
)
|
||||
turn_info = ""
|
||||
|
||||
if max_turns is not None and conversation_history:
|
||||
turn_count = turn_num + 1
|
||||
warning = ""
|
||||
if turn_count >= max_turns:
|
||||
warning = (
|
||||
"CRITICAL: This is the FINAL turn. You MUST conclude the conversation now.\n"
|
||||
"Set is_a2a=false and provide your final response to complete the task."
|
||||
)
|
||||
elif turn_count == max_turns - 1:
|
||||
warning = "WARNING: Next turn will be the last. Consider wrapping up the conversation."
|
||||
|
||||
turn_info = CONVERSATION_TURN_INFO_TEMPLATE.substitute(
|
||||
turn_count=turn_count,
|
||||
max_turns=max_turns,
|
||||
warning=warning,
|
||||
)
|
||||
|
||||
return f"""{task_description}
|
||||
|
||||
IMPORTANT: You have the ability to delegate this task to remote A2A agents.
|
||||
|
||||
{agents_text}
|
||||
{history_text}{turn_info}
|
||||
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def _parse_agent_response(
|
||||
raw_result: str | dict[str, Any], agent_response_model: type[BaseModel]
|
||||
) -> BaseModel | str:
|
||||
"""Parse LLM output as AgentResponse or return raw agent response.
|
||||
|
||||
Args:
|
||||
raw_result: Raw output from LLM
|
||||
agent_response_model: The agent response model
|
||||
|
||||
Returns:
|
||||
Parsed AgentResponse or string
|
||||
"""
|
||||
if agent_response_model:
|
||||
try:
|
||||
if isinstance(raw_result, str):
|
||||
return agent_response_model.model_validate_json(raw_result)
|
||||
if isinstance(raw_result, dict):
|
||||
return agent_response_model.model_validate(raw_result)
|
||||
except ValidationError:
|
||||
return cast(str, raw_result)
|
||||
return cast(str, raw_result)
|
||||
|
||||
|
||||
def _handle_agent_response_and_continue(
|
||||
self: Agent,
|
||||
a2a_result: dict[str, Any],
|
||||
agent_id: str,
|
||||
agent_cards: dict[str, AgentCard] | None,
|
||||
a2a_agents: list[A2AConfig],
|
||||
original_task_description: str,
|
||||
conversation_history: list[Message],
|
||||
turn_num: int,
|
||||
max_turns: int,
|
||||
task: Task,
|
||||
original_fn: Callable[..., str],
|
||||
context: str | None,
|
||||
tools: list[BaseTool] | None,
|
||||
agent_response_model: type[BaseModel],
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Handle A2A result and get CrewAI agent's response.
|
||||
|
||||
Args:
|
||||
self: The agent instance
|
||||
a2a_result: Result from A2A delegation
|
||||
agent_id: ID of the A2A agent
|
||||
agent_cards: Pre-fetched agent cards
|
||||
a2a_agents: List of A2A configurations
|
||||
original_task_description: Original task description
|
||||
conversation_history: Conversation history
|
||||
turn_num: Current turn number
|
||||
max_turns: Maximum turns allowed
|
||||
task: The task being executed
|
||||
original_fn: Original execute_task method
|
||||
context: Optional context
|
||||
tools: Optional tools
|
||||
agent_response_model: Response model for parsing
|
||||
|
||||
Returns:
|
||||
Tuple of (final_result, current_request) where:
|
||||
- final_result is not None if conversation should end
|
||||
- current_request is the next message to send if continuing
|
||||
"""
|
||||
agent_cards_dict = agent_cards or {}
|
||||
if "agent_card" in a2a_result and agent_id not in agent_cards_dict:
|
||||
agent_cards_dict[agent_id] = a2a_result["agent_card"]
|
||||
|
||||
task.description = _augment_prompt_with_a2a(
|
||||
a2a_agents=a2a_agents,
|
||||
task_description=original_task_description,
|
||||
conversation_history=conversation_history,
|
||||
turn_num=turn_num,
|
||||
max_turns=max_turns,
|
||||
agent_cards=agent_cards_dict,
|
||||
)
|
||||
|
||||
raw_result = original_fn(self, task, context, tools)
|
||||
llm_response = _parse_agent_response(
|
||||
raw_result=raw_result, agent_response_model=agent_response_model
|
||||
)
|
||||
|
||||
if isinstance(llm_response, BaseModel) and isinstance(
|
||||
llm_response, AgentResponseProtocol
|
||||
):
|
||||
if not llm_response.is_a2a:
|
||||
final_turn_number = turn_num + 1
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AMessageSentEvent(
|
||||
message=str(llm_response.message),
|
||||
turn_number=final_turn_number,
|
||||
is_multiturn=True,
|
||||
agent_role=self.role,
|
||||
),
|
||||
)
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AConversationCompletedEvent(
|
||||
status="completed",
|
||||
final_result=str(llm_response.message),
|
||||
error=None,
|
||||
total_turns=final_turn_number,
|
||||
),
|
||||
)
|
||||
return str(llm_response.message), None
|
||||
return None, str(llm_response.message)
|
||||
|
||||
return str(raw_result), None
|
||||
|
||||
|
||||
def _delegate_to_a2a(
|
||||
self: Agent,
|
||||
agent_response: AgentResponseProtocol,
|
||||
task: Task,
|
||||
original_fn: Callable[..., str],
|
||||
context: str | None,
|
||||
tools: list[BaseTool] | None,
|
||||
agent_cards: dict[str, AgentCard] | None = None,
|
||||
original_task_description: str | None = None,
|
||||
) -> str:
|
||||
"""Delegate to A2A agent with multi-turn conversation support.
|
||||
|
||||
Args:
|
||||
self: The agent instance
|
||||
agent_response: The AgentResponse indicating delegation
|
||||
task: The task being executed (for extracting A2A fields)
|
||||
original_fn: The original execute_task method for follow-ups
|
||||
context: Optional context for task execution
|
||||
tools: Optional tools available to the agent
|
||||
agent_cards: Pre-fetched agent cards from _execute_task_with_a2a
|
||||
original_task_description: The original task description before A2A augmentation
|
||||
|
||||
Returns:
|
||||
Result from A2A agent
|
||||
|
||||
Raises:
|
||||
ImportError: If a2a-sdk is not installed
|
||||
"""
|
||||
a2a_agents, agent_response_model = get_a2a_agents_and_response_model(self.a2a)
|
||||
agent_ids = tuple(config.endpoint for config in a2a_agents)
|
||||
current_request = str(agent_response.message)
|
||||
agent_id = agent_response.a2a_ids[0]
|
||||
|
||||
if agent_id not in agent_ids:
|
||||
raise ValueError(
|
||||
f"Unknown A2A agent ID(s): {agent_response.a2a_ids} not in {agent_ids}"
|
||||
)
|
||||
|
||||
agent_config = next(filter(lambda x: x.endpoint == agent_id, a2a_agents))
|
||||
task_config = task.config or {}
|
||||
context_id = task_config.get("context_id")
|
||||
task_id_config = task_config.get("task_id")
|
||||
reference_task_ids = task_config.get("reference_task_ids")
|
||||
metadata = task_config.get("metadata")
|
||||
extensions = task_config.get("extensions")
|
||||
|
||||
if original_task_description is None:
|
||||
original_task_description = task.description
|
||||
|
||||
conversation_history: list[Message] = []
|
||||
max_turns = agent_config.max_turns
|
||||
|
||||
try:
|
||||
for turn_num in range(max_turns):
|
||||
console_formatter = getattr(crewai_event_bus, "_console", None)
|
||||
agent_branch = None
|
||||
if console_formatter:
|
||||
agent_branch = getattr(
|
||||
console_formatter, "current_agent_branch", None
|
||||
) or getattr(console_formatter, "current_task_branch", None)
|
||||
|
||||
a2a_result = execute_a2a_delegation(
|
||||
endpoint=agent_config.endpoint,
|
||||
auth=agent_config.auth,
|
||||
timeout=agent_config.timeout,
|
||||
task_description=current_request,
|
||||
context_id=context_id,
|
||||
task_id=task_id_config,
|
||||
reference_task_ids=reference_task_ids,
|
||||
metadata=metadata,
|
||||
extensions=extensions,
|
||||
conversation_history=conversation_history,
|
||||
agent_id=agent_id,
|
||||
agent_role=Role.user,
|
||||
agent_branch=agent_branch,
|
||||
response_model=agent_config.response_model,
|
||||
turn_number=turn_num + 1,
|
||||
)
|
||||
|
||||
conversation_history = a2a_result.get("history", [])
|
||||
|
||||
if a2a_result["status"] in ["completed", "input_required"]:
|
||||
if (
|
||||
a2a_result["status"] == "completed"
|
||||
and agent_config.trust_remote_completion_status
|
||||
):
|
||||
result_text = a2a_result.get("result", "")
|
||||
final_turn_number = turn_num + 1
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AConversationCompletedEvent(
|
||||
status="completed",
|
||||
final_result=result_text,
|
||||
error=None,
|
||||
total_turns=final_turn_number,
|
||||
),
|
||||
)
|
||||
return result_text # type: ignore[no-any-return]
|
||||
|
||||
final_result, next_request = _handle_agent_response_and_continue(
|
||||
self=self,
|
||||
a2a_result=a2a_result,
|
||||
agent_id=agent_id,
|
||||
agent_cards=agent_cards,
|
||||
a2a_agents=a2a_agents,
|
||||
original_task_description=original_task_description,
|
||||
conversation_history=conversation_history,
|
||||
turn_num=turn_num,
|
||||
max_turns=max_turns,
|
||||
task=task,
|
||||
original_fn=original_fn,
|
||||
context=context,
|
||||
tools=tools,
|
||||
agent_response_model=agent_response_model,
|
||||
)
|
||||
|
||||
if final_result is not None:
|
||||
return final_result
|
||||
|
||||
if next_request is not None:
|
||||
current_request = next_request
|
||||
|
||||
continue
|
||||
|
||||
error_msg = a2a_result.get("error", "Unknown error")
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AConversationCompletedEvent(
|
||||
status="failed",
|
||||
final_result=None,
|
||||
error=error_msg,
|
||||
total_turns=turn_num + 1,
|
||||
),
|
||||
)
|
||||
raise Exception(f"A2A delegation failed: {error_msg}")
|
||||
|
||||
if conversation_history:
|
||||
for msg in reversed(conversation_history):
|
||||
if msg.role == Role.agent:
|
||||
text_parts = [
|
||||
part.root.text for part in msg.parts if part.root.kind == "text"
|
||||
]
|
||||
final_message = (
|
||||
" ".join(text_parts) if text_parts else "Conversation completed"
|
||||
)
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AConversationCompletedEvent(
|
||||
status="completed",
|
||||
final_result=final_message,
|
||||
error=None,
|
||||
total_turns=max_turns,
|
||||
),
|
||||
)
|
||||
return final_message
|
||||
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AConversationCompletedEvent(
|
||||
status="failed",
|
||||
final_result=None,
|
||||
error=f"Conversation exceeded maximum turns ({max_turns})",
|
||||
total_turns=max_turns,
|
||||
),
|
||||
)
|
||||
raise Exception(f"A2A conversation exceeded maximum turns ({max_turns})")
|
||||
|
||||
finally:
|
||||
task.description = original_task_description
|
||||
5
lib/crewai/src/crewai/agent/__init__.py
Normal file
5
lib/crewai/src/crewai/agent/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from crewai.agent.core import Agent
|
||||
from crewai.utilities.training_handler import CrewTrainingHandler
|
||||
|
||||
|
||||
__all__ = ["Agent", "CrewTrainingHandler"]
|
||||
@@ -2,27 +2,27 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Final,
|
||||
Literal,
|
||||
cast,
|
||||
)
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field, InstanceOf, PrivateAttr, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from crewai.a2a.config import A2AConfig
|
||||
from crewai.agents.agent_builder.base_agent import BaseAgent
|
||||
from crewai.agents.cache.cache_handler import CacheHandler
|
||||
from crewai.agents.crew_agent_executor import CrewAgentExecutor
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.agent_events import (
|
||||
AgentExecutionCompletedEvent,
|
||||
AgentExecutionErrorEvent,
|
||||
AgentExecutionStartedEvent,
|
||||
)
|
||||
from crewai.events.types.knowledge_events import (
|
||||
KnowledgeQueryCompletedEvent,
|
||||
KnowledgeQueryFailedEvent,
|
||||
@@ -40,6 +40,16 @@ from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
from crewai.knowledge.utils.knowledge_utils import extract_knowledge_context
|
||||
from crewai.lite_agent import LiteAgent
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
from crewai.mcp import (
|
||||
MCPClient,
|
||||
MCPServerConfig,
|
||||
MCPServerHTTP,
|
||||
MCPServerSSE,
|
||||
MCPServerStdio,
|
||||
)
|
||||
from crewai.mcp.transports.http import HTTPTransport
|
||||
from crewai.mcp.transports.sse import SSETransport
|
||||
from crewai.mcp.transports.stdio import StdioTransport
|
||||
from crewai.memory.contextual.contextual_memory import ContextualMemory
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
from crewai.security.fingerprint import Fingerprint
|
||||
@@ -70,14 +80,14 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
# MCP Connection timeout constants (in seconds)
|
||||
MCP_CONNECTION_TIMEOUT = 10
|
||||
MCP_TOOL_EXECUTION_TIMEOUT = 30
|
||||
MCP_DISCOVERY_TIMEOUT = 15
|
||||
MCP_MAX_RETRIES = 3
|
||||
MCP_CONNECTION_TIMEOUT: Final[int] = 10
|
||||
MCP_TOOL_EXECUTION_TIMEOUT: Final[int] = 30
|
||||
MCP_DISCOVERY_TIMEOUT: Final[int] = 15
|
||||
MCP_MAX_RETRIES: Final[int] = 3
|
||||
|
||||
# Simple in-memory cache for MCP tool schemas (duration: 5 minutes)
|
||||
_mcp_schema_cache = {}
|
||||
_cache_ttl = 300 # 5 minutes
|
||||
_mcp_schema_cache: dict[str, Any] = {}
|
||||
_cache_ttl: Final[int] = 300 # 5 minutes
|
||||
|
||||
|
||||
class Agent(BaseAgent):
|
||||
@@ -108,6 +118,8 @@ class Agent(BaseAgent):
|
||||
"""
|
||||
|
||||
_times_executed: int = PrivateAttr(default=0)
|
||||
_mcp_clients: list[Any] = PrivateAttr(default_factory=list)
|
||||
_last_messages: list[LLMMessage] = PrivateAttr(default_factory=list)
|
||||
max_execution_time: int | None = Field(
|
||||
default=None,
|
||||
description="Maximum execution time for an agent to execute a task",
|
||||
@@ -197,6 +209,10 @@ class Agent(BaseAgent):
|
||||
guardrail_max_retries: int = Field(
|
||||
default=3, description="Maximum number of retries when guardrail fails"
|
||||
)
|
||||
a2a: list[A2AConfig] | A2AConfig | None = Field(
|
||||
default=None,
|
||||
description="A2A (Agent-to-Agent) configuration for delegating tasks to remote agents. Can be a single A2AConfig or a dict mapping agent IDs to configs.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
def validate_from_repository(cls, v: Any) -> dict[str, Any] | None | Any: # noqa: N805
|
||||
@@ -305,17 +321,19 @@ class Agent(BaseAgent):
|
||||
# If the task requires output in JSON or Pydantic format,
|
||||
# append specific instructions to the task prompt to ensure
|
||||
# that the final answer does not include any code block markers
|
||||
if task.output_json or task.output_pydantic:
|
||||
# Skip this if task.response_model is set, as native structured outputs handle schema automatically
|
||||
if (task.output_json or task.output_pydantic) and not task.response_model:
|
||||
# Generate the schema based on the output format
|
||||
if task.output_json:
|
||||
# schema = json.dumps(task.output_json, indent=2)
|
||||
schema = generate_model_description(task.output_json)
|
||||
schema_dict = generate_model_description(task.output_json)
|
||||
schema = json.dumps(schema_dict["json_schema"]["schema"], indent=2)
|
||||
task_prompt += "\n" + self.i18n.slice(
|
||||
"formatted_task_instructions"
|
||||
).format(output_format=schema)
|
||||
|
||||
elif task.output_pydantic:
|
||||
schema = generate_model_description(task.output_pydantic)
|
||||
schema_dict = generate_model_description(task.output_pydantic)
|
||||
schema = json.dumps(schema_dict["json_schema"]["schema"], indent=2)
|
||||
task_prompt += "\n" + self.i18n.slice(
|
||||
"formatted_task_instructions"
|
||||
).format(output_format=schema)
|
||||
@@ -438,6 +456,13 @@ class Agent(BaseAgent):
|
||||
else:
|
||||
task_prompt = self._use_trained_data(task_prompt=task_prompt)
|
||||
|
||||
# Import agent events locally to avoid circular imports
|
||||
from crewai.events.types.agent_events import (
|
||||
AgentExecutionCompletedEvent,
|
||||
AgentExecutionErrorEvent,
|
||||
AgentExecutionStartedEvent,
|
||||
)
|
||||
|
||||
try:
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
@@ -513,6 +538,15 @@ class Agent(BaseAgent):
|
||||
self,
|
||||
event=AgentExecutionCompletedEvent(agent=self, task=task, output=result),
|
||||
)
|
||||
|
||||
self._last_messages = (
|
||||
self.agent_executor.messages.copy()
|
||||
if self.agent_executor and hasattr(self.agent_executor, "messages")
|
||||
else []
|
||||
)
|
||||
|
||||
self._cleanup_mcp_clients()
|
||||
|
||||
return result
|
||||
|
||||
def _execute_with_timeout(self, task_prompt: str, task: Task, timeout: int) -> Any:
|
||||
@@ -618,6 +652,7 @@ class Agent(BaseAgent):
|
||||
self._rpm_controller.check_or_wait if self._rpm_controller else None
|
||||
),
|
||||
callbacks=[TokenCalcHandler(self._token_process)],
|
||||
response_model=task.response_model if task else None,
|
||||
)
|
||||
|
||||
def get_delegation_tools(self, agents: list[BaseAgent]) -> list[BaseTool]:
|
||||
@@ -635,30 +670,70 @@ class Agent(BaseAgent):
|
||||
self._logger.log("error", f"Error getting platform tools: {e!s}")
|
||||
return []
|
||||
|
||||
def get_mcp_tools(self, mcps: list[str]) -> list[BaseTool]:
|
||||
"""Convert MCP server references to CrewAI tools."""
|
||||
def get_mcp_tools(self, mcps: list[str | MCPServerConfig]) -> list[BaseTool]:
|
||||
"""Convert MCP server references/configs to CrewAI tools.
|
||||
|
||||
Supports both string references (backwards compatible) and structured
|
||||
configuration objects (MCPServerStdio, MCPServerHTTP, MCPServerSSE).
|
||||
|
||||
Args:
|
||||
mcps: List of MCP server references (strings) or configurations.
|
||||
|
||||
Returns:
|
||||
List of BaseTool instances from MCP servers.
|
||||
"""
|
||||
all_tools = []
|
||||
clients = []
|
||||
|
||||
for mcp_ref in mcps:
|
||||
try:
|
||||
if mcp_ref.startswith("crewai-amp:"):
|
||||
tools = self._get_amp_mcp_tools(mcp_ref)
|
||||
elif mcp_ref.startswith("https://"):
|
||||
tools = self._get_external_mcp_tools(mcp_ref)
|
||||
else:
|
||||
continue
|
||||
for mcp_config in mcps:
|
||||
if isinstance(mcp_config, str):
|
||||
tools = self._get_mcp_tools_from_string(mcp_config)
|
||||
else:
|
||||
tools, client = self._get_native_mcp_tools(mcp_config)
|
||||
if client:
|
||||
clients.append(client)
|
||||
|
||||
all_tools.extend(tools)
|
||||
self._logger.log(
|
||||
"info", f"Successfully loaded {len(tools)} tools from {mcp_ref}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.log("warning", f"Skipping MCP {mcp_ref} due to error: {e}")
|
||||
continue
|
||||
all_tools.extend(tools)
|
||||
|
||||
# Store clients for cleanup
|
||||
self._mcp_clients.extend(clients)
|
||||
return all_tools
|
||||
|
||||
def _cleanup_mcp_clients(self) -> None:
|
||||
"""Cleanup MCP client connections after task execution."""
|
||||
if not self._mcp_clients:
|
||||
return
|
||||
|
||||
async def _disconnect_all() -> None:
|
||||
for client in self._mcp_clients:
|
||||
if client and hasattr(client, "connected") and client.connected:
|
||||
await client.disconnect()
|
||||
|
||||
try:
|
||||
asyncio.run(_disconnect_all())
|
||||
except Exception as e:
|
||||
self._logger.log("error", f"Error during MCP client cleanup: {e}")
|
||||
finally:
|
||||
self._mcp_clients.clear()
|
||||
|
||||
def _get_mcp_tools_from_string(self, mcp_ref: str) -> list[BaseTool]:
|
||||
"""Get tools from legacy string-based MCP references.
|
||||
|
||||
This method maintains backwards compatibility with string-based
|
||||
MCP references (https://... and crewai-amp:...).
|
||||
|
||||
Args:
|
||||
mcp_ref: String reference to MCP server.
|
||||
|
||||
Returns:
|
||||
List of BaseTool instances.
|
||||
"""
|
||||
if mcp_ref.startswith("crewai-amp:"):
|
||||
return self._get_amp_mcp_tools(mcp_ref)
|
||||
if mcp_ref.startswith("https://"):
|
||||
return self._get_external_mcp_tools(mcp_ref)
|
||||
return []
|
||||
|
||||
def _get_external_mcp_tools(self, mcp_ref: str) -> list[BaseTool]:
|
||||
"""Get tools from external HTTPS MCP server with graceful error handling."""
|
||||
from crewai.tools.mcp_tool_wrapper import MCPToolWrapper
|
||||
@@ -709,7 +784,7 @@ class Agent(BaseAgent):
|
||||
f"Specific tool '{specific_tool}' not found on MCP server: {server_url}",
|
||||
)
|
||||
|
||||
return tools
|
||||
return cast(list[BaseTool], tools)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.log(
|
||||
@@ -717,6 +792,164 @@ class Agent(BaseAgent):
|
||||
)
|
||||
return []
|
||||
|
||||
def _get_native_mcp_tools(
|
||||
self, mcp_config: MCPServerConfig
|
||||
) -> tuple[list[BaseTool], Any | None]:
|
||||
"""Get tools from MCP server using structured configuration.
|
||||
|
||||
This method creates an MCP client based on the configuration type,
|
||||
connects to the server, discovers tools, applies filtering, and
|
||||
returns wrapped tools along with the client instance for cleanup.
|
||||
|
||||
Args:
|
||||
mcp_config: MCP server configuration (MCPServerStdio, MCPServerHTTP, or MCPServerSSE).
|
||||
|
||||
Returns:
|
||||
Tuple of (list of BaseTool instances, MCPClient instance for cleanup).
|
||||
"""
|
||||
from crewai.tools.base_tool import BaseTool
|
||||
from crewai.tools.mcp_native_tool import MCPNativeTool
|
||||
|
||||
if isinstance(mcp_config, MCPServerStdio):
|
||||
transport = StdioTransport(
|
||||
command=mcp_config.command,
|
||||
args=mcp_config.args,
|
||||
env=mcp_config.env,
|
||||
)
|
||||
server_name = f"{mcp_config.command}_{'_'.join(mcp_config.args)}"
|
||||
elif isinstance(mcp_config, MCPServerHTTP):
|
||||
transport = HTTPTransport(
|
||||
url=mcp_config.url,
|
||||
headers=mcp_config.headers,
|
||||
streamable=mcp_config.streamable,
|
||||
)
|
||||
server_name = self._extract_server_name(mcp_config.url)
|
||||
elif isinstance(mcp_config, MCPServerSSE):
|
||||
transport = SSETransport(
|
||||
url=mcp_config.url,
|
||||
headers=mcp_config.headers,
|
||||
)
|
||||
server_name = self._extract_server_name(mcp_config.url)
|
||||
else:
|
||||
raise ValueError(f"Unsupported MCP server config type: {type(mcp_config)}")
|
||||
|
||||
client = MCPClient(
|
||||
transport=transport,
|
||||
cache_tools_list=mcp_config.cache_tools_list,
|
||||
)
|
||||
|
||||
async def _setup_client_and_list_tools() -> list[dict[str, Any]]:
|
||||
"""Async helper to connect and list tools in same event loop."""
|
||||
|
||||
try:
|
||||
if not client.connected:
|
||||
await client.connect()
|
||||
|
||||
tools_list = await client.list_tools()
|
||||
|
||||
try:
|
||||
await client.disconnect()
|
||||
# Small delay to allow background tasks to finish cleanup
|
||||
# This helps prevent "cancel scope in different task" errors
|
||||
# when asyncio.run() closes the event loop
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception as e:
|
||||
self._logger.log("error", f"Error during disconnect: {e}")
|
||||
|
||||
return tools_list
|
||||
except Exception as e:
|
||||
if client.connected:
|
||||
await client.disconnect()
|
||||
await asyncio.sleep(0.1)
|
||||
raise RuntimeError(
|
||||
f"Error during setup client and list tools: {e}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(
|
||||
asyncio.run, _setup_client_and_list_tools()
|
||||
)
|
||||
tools_list = future.result()
|
||||
except RuntimeError:
|
||||
try:
|
||||
tools_list = asyncio.run(_setup_client_and_list_tools())
|
||||
except RuntimeError as e:
|
||||
error_msg = str(e).lower()
|
||||
if "cancel scope" in error_msg or "task" in error_msg:
|
||||
raise ConnectionError(
|
||||
"MCP connection failed due to event loop cleanup issues. "
|
||||
"This may be due to authentication errors or server unavailability."
|
||||
) from e
|
||||
except asyncio.CancelledError as e:
|
||||
raise ConnectionError(
|
||||
"MCP connection was cancelled. This may indicate an authentication "
|
||||
"error or server unavailability."
|
||||
) from e
|
||||
|
||||
if mcp_config.tool_filter:
|
||||
filtered_tools = []
|
||||
for tool in tools_list:
|
||||
if callable(mcp_config.tool_filter):
|
||||
try:
|
||||
from crewai.mcp.filters import ToolFilterContext
|
||||
|
||||
context = ToolFilterContext(
|
||||
agent=self,
|
||||
server_name=server_name,
|
||||
run_context=None,
|
||||
)
|
||||
if mcp_config.tool_filter(context, tool):
|
||||
filtered_tools.append(tool)
|
||||
except (TypeError, AttributeError):
|
||||
if mcp_config.tool_filter(tool):
|
||||
filtered_tools.append(tool)
|
||||
else:
|
||||
# Not callable - include tool
|
||||
filtered_tools.append(tool)
|
||||
tools_list = filtered_tools
|
||||
|
||||
tools = []
|
||||
for tool_def in tools_list:
|
||||
tool_name = tool_def.get("name", "")
|
||||
if not tool_name:
|
||||
continue
|
||||
|
||||
# Convert inputSchema to Pydantic model if present
|
||||
args_schema = None
|
||||
if tool_def.get("inputSchema"):
|
||||
args_schema = self._json_schema_to_pydantic(
|
||||
tool_name, tool_def["inputSchema"]
|
||||
)
|
||||
|
||||
tool_schema = {
|
||||
"description": tool_def.get("description", ""),
|
||||
"args_schema": args_schema,
|
||||
}
|
||||
|
||||
try:
|
||||
native_tool = MCPNativeTool(
|
||||
mcp_client=client,
|
||||
tool_name=tool_name,
|
||||
tool_schema=tool_schema,
|
||||
server_name=server_name,
|
||||
)
|
||||
tools.append(native_tool)
|
||||
except Exception as e:
|
||||
self._logger.log("error", f"Failed to create native MCP tool: {e}")
|
||||
continue
|
||||
|
||||
return cast(list[BaseTool], tools), client
|
||||
except Exception as e:
|
||||
if client.connected:
|
||||
asyncio.run(client.disconnect())
|
||||
|
||||
raise RuntimeError(f"Failed to get native MCP tools: {e}") from e
|
||||
|
||||
def _get_amp_mcp_tools(self, amp_ref: str) -> list[BaseTool]:
|
||||
"""Get tools from CrewAI AMP MCP marketplace."""
|
||||
# Parse: "crewai-amp:mcp-name" or "crewai-amp:mcp-name#tool_name"
|
||||
@@ -739,9 +972,9 @@ class Agent(BaseAgent):
|
||||
|
||||
return tools
|
||||
|
||||
def _extract_server_name(self, server_url: str) -> str:
|
||||
@staticmethod
|
||||
def _extract_server_name(server_url: str) -> str:
|
||||
"""Extract clean server name from URL for tool prefixing."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(server_url)
|
||||
domain = parsed.netloc.replace(".", "_")
|
||||
@@ -778,7 +1011,9 @@ class Agent(BaseAgent):
|
||||
)
|
||||
return {}
|
||||
|
||||
async def _get_mcp_tool_schemas_async(self, server_params: dict) -> dict[str, dict]:
|
||||
async def _get_mcp_tool_schemas_async(
|
||||
self, server_params: dict[str, Any]
|
||||
) -> dict[str, dict]:
|
||||
"""Async implementation of MCP tool schema retrieval with timeouts and retries."""
|
||||
server_url = server_params["url"]
|
||||
return await self._retry_mcp_discovery(
|
||||
@@ -787,7 +1022,7 @@ class Agent(BaseAgent):
|
||||
|
||||
async def _retry_mcp_discovery(
|
||||
self, operation_func, server_url: str
|
||||
) -> dict[str, dict]:
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Retry MCP discovery operation with exponential backoff, avoiding try-except in loop."""
|
||||
last_error = None
|
||||
|
||||
@@ -815,9 +1050,10 @@ class Agent(BaseAgent):
|
||||
f"Failed to discover MCP tools after {MCP_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _attempt_mcp_discovery(
|
||||
self, operation_func, server_url: str
|
||||
) -> tuple[dict[str, dict] | None, str, bool]:
|
||||
operation_func, server_url: str
|
||||
) -> tuple[dict[str, dict[str, Any]] | None, str, bool]:
|
||||
"""Attempt single MCP discovery operation and return (result, error_message, should_retry)."""
|
||||
try:
|
||||
result = await operation_func(server_url)
|
||||
@@ -851,13 +1087,13 @@ class Agent(BaseAgent):
|
||||
|
||||
async def _discover_mcp_tools_with_timeout(
|
||||
self, server_url: str
|
||||
) -> dict[str, dict]:
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Discover MCP tools with timeout wrapper."""
|
||||
return await asyncio.wait_for(
|
||||
self._discover_mcp_tools(server_url), timeout=MCP_DISCOVERY_TIMEOUT
|
||||
)
|
||||
|
||||
async def _discover_mcp_tools(self, server_url: str) -> dict[str, dict]:
|
||||
async def _discover_mcp_tools(self, server_url: str) -> dict[str, dict[str, Any]]:
|
||||
"""Discover tools from MCP server with proper timeout handling."""
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
@@ -889,7 +1125,9 @@ class Agent(BaseAgent):
|
||||
}
|
||||
return schemas
|
||||
|
||||
def _json_schema_to_pydantic(self, tool_name: str, json_schema: dict) -> type:
|
||||
def _json_schema_to_pydantic(
|
||||
self, tool_name: str, json_schema: dict[str, Any]
|
||||
) -> type:
|
||||
"""Convert JSON Schema to Pydantic model for tool arguments.
|
||||
|
||||
Args:
|
||||
@@ -926,7 +1164,7 @@ class Agent(BaseAgent):
|
||||
model_name = f"{tool_name.replace('-', '_').replace(' ', '_')}Schema"
|
||||
return create_model(model_name, **field_definitions)
|
||||
|
||||
def _json_type_to_python(self, field_schema: dict) -> type:
|
||||
def _json_type_to_python(self, field_schema: dict[str, Any]) -> type:
|
||||
"""Convert JSON Schema type to Python type.
|
||||
|
||||
Args:
|
||||
@@ -935,7 +1173,6 @@ class Agent(BaseAgent):
|
||||
Returns:
|
||||
Python type
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
json_type = field_schema.get("type")
|
||||
|
||||
@@ -965,13 +1202,15 @@ class Agent(BaseAgent):
|
||||
|
||||
return type_mapping.get(json_type, Any)
|
||||
|
||||
def _fetch_amp_mcp_servers(self, mcp_name: str) -> list[dict]:
|
||||
@staticmethod
|
||||
def _fetch_amp_mcp_servers(mcp_name: str) -> list[dict]:
|
||||
"""Fetch MCP server configurations from CrewAI AMP API."""
|
||||
# TODO: Implement AMP API call to "integrations/mcps" endpoint
|
||||
# Should return list of server configs with URLs
|
||||
return []
|
||||
|
||||
def get_multimodal_tools(self) -> Sequence[BaseTool]:
|
||||
@staticmethod
|
||||
def get_multimodal_tools() -> Sequence[BaseTool]:
|
||||
from crewai.tools.agent_tools.add_image_tool import AddImageTool
|
||||
|
||||
return [AddImageTool()]
|
||||
@@ -991,8 +1230,9 @@ class Agent(BaseAgent):
|
||||
)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_output_converter(
|
||||
self, llm: BaseLLM, text: str, model: type[BaseModel], instructions: str
|
||||
llm: BaseLLM, text: str, model: type[BaseModel], instructions: str
|
||||
) -> Converter:
|
||||
return Converter(llm=llm, text=text, model=model, instructions=instructions)
|
||||
|
||||
@@ -1022,7 +1262,8 @@ class Agent(BaseAgent):
|
||||
)
|
||||
return task_prompt
|
||||
|
||||
def _render_text_description(self, tools: list[Any]) -> str:
|
||||
@staticmethod
|
||||
def _render_text_description(tools: list[Any]) -> str:
|
||||
"""Render the tool name and description in plain text.
|
||||
|
||||
Output will be in the format of:
|
||||
@@ -1107,6 +1348,15 @@ class Agent(BaseAgent):
|
||||
def set_fingerprint(self, fingerprint: Fingerprint) -> None:
|
||||
self.security_config.fingerprint = fingerprint
|
||||
|
||||
@property
|
||||
def last_messages(self) -> list[LLMMessage]:
|
||||
"""Get messages from the last task execution.
|
||||
|
||||
Returns:
|
||||
List of LLM messages from the most recent task execution.
|
||||
"""
|
||||
return self._last_messages
|
||||
|
||||
def _get_knowledge_search_query(self, task_prompt: str, task: Task) -> str | None:
|
||||
"""Generate a search query for the knowledge base based on the task description."""
|
||||
crewai_event_bus.emit(
|
||||
0
lib/crewai/src/crewai/agent/internal/__init__.py
Normal file
0
lib/crewai/src/crewai/agent/internal/__init__.py
Normal file
76
lib/crewai/src/crewai/agent/internal/meta.py
Normal file
76
lib/crewai/src/crewai/agent/internal/meta.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Generic metaclass for agent extensions.
|
||||
|
||||
This metaclass enables extension capabilities for agents by detecting
|
||||
extension fields in class annotations and applying appropriate wrappers.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic._internal._model_construction import ModelMetaclass
|
||||
|
||||
|
||||
class AgentMeta(ModelMetaclass):
|
||||
"""Generic metaclass for agent extensions.
|
||||
|
||||
Detects extension fields (like 'a2a') in class annotations and applies
|
||||
the appropriate wrapper logic to enable extension functionality.
|
||||
"""
|
||||
|
||||
def __new__(
|
||||
mcs,
|
||||
name: str,
|
||||
bases: tuple[type, ...],
|
||||
namespace: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> type:
|
||||
"""Create a new class with extension support.
|
||||
|
||||
Args:
|
||||
name: The name of the class being created
|
||||
bases: Base classes
|
||||
namespace: Class namespace dictionary
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
The newly created class with extension support if applicable
|
||||
"""
|
||||
orig_post_init_setup = namespace.get("post_init_setup")
|
||||
|
||||
if orig_post_init_setup is not None:
|
||||
original_func = (
|
||||
orig_post_init_setup.wrapped
|
||||
if hasattr(orig_post_init_setup, "wrapped")
|
||||
else orig_post_init_setup
|
||||
)
|
||||
|
||||
def post_init_setup_with_extensions(self: Any) -> Any:
|
||||
"""Wrap post_init_setup to apply extensions after initialization.
|
||||
|
||||
Args:
|
||||
self: The agent instance
|
||||
|
||||
Returns:
|
||||
The agent instance
|
||||
"""
|
||||
result = original_func(self)
|
||||
|
||||
a2a_value = getattr(self, "a2a", None)
|
||||
if a2a_value is not None:
|
||||
from crewai.a2a.wrapper import wrap_agent_with_a2a_instance
|
||||
|
||||
wrap_agent_with_a2a_instance(self)
|
||||
|
||||
return result
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore", message=".*overrides an existing Pydantic.*"
|
||||
)
|
||||
namespace["post_init_setup"] = model_validator(mode="after")(
|
||||
post_init_setup_with_extensions
|
||||
)
|
||||
|
||||
return super().__new__(mcs, name, bases, namespace, **kwargs)
|
||||
@@ -7,7 +7,7 @@ output conversion for OpenAI agents, supporting JSON and Pydantic model formats.
|
||||
from typing import Any
|
||||
|
||||
from crewai.agents.agent_adapters.base_converter_adapter import BaseConverterAdapter
|
||||
from crewai.utilities.i18n import I18N
|
||||
from crewai.utilities.i18n import get_i18n
|
||||
|
||||
|
||||
class OpenAIConverterAdapter(BaseConverterAdapter):
|
||||
@@ -59,7 +59,7 @@ class OpenAIConverterAdapter(BaseConverterAdapter):
|
||||
return base_prompt
|
||||
|
||||
output_schema: str = (
|
||||
I18N()
|
||||
get_i18n()
|
||||
.slice("formatted_task_instructions")
|
||||
.format(output_format=self._schema)
|
||||
)
|
||||
|
||||
@@ -18,17 +18,19 @@ from pydantic import (
|
||||
from pydantic_core import PydanticCustomError
|
||||
from typing_extensions import Self
|
||||
|
||||
from crewai.agent.internal.meta import AgentMeta
|
||||
from crewai.agents.agent_builder.utilities.base_token_process import TokenProcess
|
||||
from crewai.agents.cache.cache_handler import CacheHandler
|
||||
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.mcp.config import MCPServerConfig
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
from crewai.security.security_config import SecurityConfig
|
||||
from crewai.tools.base_tool import BaseTool, Tool
|
||||
from crewai.utilities.config import process_config
|
||||
from crewai.utilities.i18n import I18N
|
||||
from crewai.utilities.i18n import I18N, get_i18n
|
||||
from crewai.utilities.logger import Logger
|
||||
from crewai.utilities.rpm_controller import RPMController
|
||||
from crewai.utilities.string_utils import interpolate_only
|
||||
@@ -56,7 +58,7 @@ PlatformApp = Literal[
|
||||
PlatformAppOrAction = PlatformApp | str
|
||||
|
||||
|
||||
class BaseAgent(BaseModel, ABC):
|
||||
class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
"""Abstract Base Class for all third party agents compatible with CrewAI.
|
||||
|
||||
Attributes:
|
||||
@@ -106,7 +108,7 @@ class BaseAgent(BaseModel, ABC):
|
||||
Set private attributes.
|
||||
"""
|
||||
|
||||
__hash__ = object.__hash__ # type: ignore
|
||||
__hash__ = object.__hash__
|
||||
_logger: Logger = PrivateAttr(default_factory=lambda: Logger(verbose=False))
|
||||
_rpm_controller: RPMController | None = PrivateAttr(default=None)
|
||||
_request_within_rpm_limit: Any = PrivateAttr(default=None)
|
||||
@@ -149,7 +151,7 @@ class BaseAgent(BaseModel, ABC):
|
||||
)
|
||||
crew: Any = Field(default=None, description="Crew to which the agent belongs.")
|
||||
i18n: I18N = Field(
|
||||
default_factory=I18N, description="Internationalization settings."
|
||||
default_factory=get_i18n, description="Internationalization settings."
|
||||
)
|
||||
cache_handler: CacheHandler | None = Field(
|
||||
default=None, description="An instance of the CacheHandler class."
|
||||
@@ -179,8 +181,8 @@ class BaseAgent(BaseModel, ABC):
|
||||
default_factory=SecurityConfig,
|
||||
description="Security configuration for the agent, including fingerprinting.",
|
||||
)
|
||||
callbacks: list[Callable] = Field(
|
||||
default=[], description="Callbacks to be used for the agent"
|
||||
callbacks: list[Callable[[Any], Any]] = Field(
|
||||
default_factory=list, description="Callbacks to be used for the agent"
|
||||
)
|
||||
adapted_agent: bool = Field(
|
||||
default=False, description="Whether the agent is adapted"
|
||||
@@ -193,14 +195,14 @@ class BaseAgent(BaseModel, ABC):
|
||||
default=None,
|
||||
description="List of applications or application/action combinations that the agent can access through CrewAI Platform. Can contain app names (e.g., 'gmail') or specific actions (e.g., 'gmail/send_email')",
|
||||
)
|
||||
mcps: list[str] | None = Field(
|
||||
mcps: list[str | MCPServerConfig] | None = Field(
|
||||
default=None,
|
||||
description="List of MCP server references. Supports 'https://server.com/path' for external servers and 'crewai-amp:mcp-name' for AMP marketplace. Use '#tool_name' suffix for specific tools.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def process_model_config(cls, values):
|
||||
def process_model_config(cls, values: Any) -> dict[str, Any]:
|
||||
return process_config(values, cls)
|
||||
|
||||
@field_validator("tools")
|
||||
@@ -252,23 +254,39 @@ class BaseAgent(BaseModel, ABC):
|
||||
|
||||
@field_validator("mcps")
|
||||
@classmethod
|
||||
def validate_mcps(cls, mcps: list[str] | None) -> list[str] | None:
|
||||
def validate_mcps(
|
||||
cls, mcps: list[str | MCPServerConfig] | None
|
||||
) -> list[str | MCPServerConfig] | None:
|
||||
"""Validate MCP server references and configurations.
|
||||
|
||||
Supports both string references (for backwards compatibility) and
|
||||
structured configuration objects (MCPServerStdio, MCPServerHTTP, MCPServerSSE).
|
||||
"""
|
||||
if not mcps:
|
||||
return mcps
|
||||
|
||||
validated_mcps = []
|
||||
for mcp in mcps:
|
||||
if mcp.startswith(("https://", "crewai-amp:")):
|
||||
if isinstance(mcp, str):
|
||||
if mcp.startswith(("https://", "crewai-amp:")):
|
||||
validated_mcps.append(mcp)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid MCP reference: {mcp}. "
|
||||
"String references must start with 'https://' or 'crewai-amp:'"
|
||||
)
|
||||
|
||||
elif isinstance(mcp, (MCPServerConfig)):
|
||||
validated_mcps.append(mcp)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid MCP reference: {mcp}. Must start with 'https://' or 'crewai-amp:'"
|
||||
f"Invalid MCP configuration: {type(mcp)}. "
|
||||
"Must be a string reference or MCPServerConfig instance."
|
||||
)
|
||||
|
||||
return list(set(validated_mcps))
|
||||
return validated_mcps
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_and_set_attributes(self):
|
||||
def validate_and_set_attributes(self) -> Self:
|
||||
# Validate required fields
|
||||
for field in ["role", "goal", "backstory"]:
|
||||
if getattr(self, field) is None:
|
||||
@@ -300,7 +318,7 @@ class BaseAgent(BaseModel, ABC):
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def set_private_attrs(self):
|
||||
def set_private_attrs(self) -> Self:
|
||||
"""Set private attributes."""
|
||||
self._logger = Logger(verbose=self.verbose)
|
||||
if self.max_rpm and not self._rpm_controller:
|
||||
@@ -312,7 +330,7 @@ class BaseAgent(BaseModel, ABC):
|
||||
return self
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
def key(self) -> str:
|
||||
source = [
|
||||
self._original_role or self.role,
|
||||
self._original_goal or self.goal,
|
||||
@@ -330,7 +348,7 @@ class BaseAgent(BaseModel, ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_agent_executor(self, tools=None) -> None:
|
||||
def create_agent_executor(self, tools: list[BaseTool] | None = None) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -342,7 +360,7 @@ class BaseAgent(BaseModel, ABC):
|
||||
"""Get platform tools for the specified list of applications and/or application/action combinations."""
|
||||
|
||||
@abstractmethod
|
||||
def get_mcp_tools(self, mcps: list[str]) -> list[BaseTool]:
|
||||
def get_mcp_tools(self, mcps: list[str | MCPServerConfig]) -> list[BaseTool]:
|
||||
"""Get MCP tools for the specified list of MCP server references."""
|
||||
|
||||
def copy(self) -> Self: # type: ignore # Signature of "copy" incompatible with supertype "BaseModel"
|
||||
@@ -442,5 +460,5 @@ class BaseAgent(BaseModel, ABC):
|
||||
self._rpm_controller = rpm_controller
|
||||
self.create_agent_executor()
|
||||
|
||||
def set_knowledge(self, crew_embedder: EmbedderConfig | None = None):
|
||||
def set_knowledge(self, crew_embedder: EmbedderConfig | None = None) -> None:
|
||||
pass
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from pydantic import GetCoreSchemaHandler
|
||||
from pydantic import BaseModel, GetCoreSchemaHandler
|
||||
from pydantic_core import CoreSchema, core_schema
|
||||
|
||||
from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin
|
||||
@@ -23,6 +23,10 @@ from crewai.events.types.logging_events import (
|
||||
AgentLogsExecutionEvent,
|
||||
AgentLogsStartedEvent,
|
||||
)
|
||||
from crewai.hooks.llm_hooks import (
|
||||
get_after_llm_call_hooks,
|
||||
get_before_llm_call_hooks,
|
||||
)
|
||||
from crewai.utilities.agent_utils import (
|
||||
enforce_rpm_limit,
|
||||
format_message_for_llm,
|
||||
@@ -37,7 +41,7 @@ from crewai.utilities.agent_utils import (
|
||||
process_llm_response,
|
||||
)
|
||||
from crewai.utilities.constants import TRAINING_DATA_FILE
|
||||
from crewai.utilities.i18n import I18N
|
||||
from crewai.utilities.i18n import I18N, get_i18n
|
||||
from crewai.utilities.printer import Printer
|
||||
from crewai.utilities.tool_utils import execute_tool_and_check_finality
|
||||
from crewai.utilities.training_handler import CrewTrainingHandler
|
||||
@@ -65,7 +69,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: BaseLLM | Any,
|
||||
llm: BaseLLM,
|
||||
task: Task,
|
||||
crew: Crew,
|
||||
agent: Agent,
|
||||
@@ -82,6 +86,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
respect_context_window: bool = False,
|
||||
request_within_rpm_limit: Callable[[], bool] | None = None,
|
||||
callbacks: list[Any] | None = None,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
) -> None:
|
||||
"""Initialize executor.
|
||||
|
||||
@@ -103,8 +108,9 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
respect_context_window: Respect context limits.
|
||||
request_within_rpm_limit: RPM limit check function.
|
||||
callbacks: Optional callbacks list.
|
||||
response_model: Optional Pydantic model for structured outputs.
|
||||
"""
|
||||
self._i18n: I18N = I18N()
|
||||
self._i18n: I18N = get_i18n()
|
||||
self.llm = llm
|
||||
self.task = task
|
||||
self.agent = agent
|
||||
@@ -123,10 +129,15 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
self.function_calling_llm = function_calling_llm
|
||||
self.respect_context_window = respect_context_window
|
||||
self.request_within_rpm_limit = request_within_rpm_limit
|
||||
self.response_model = response_model
|
||||
self.ask_for_human_input = False
|
||||
self.messages: list[LLMMessage] = []
|
||||
self.iterations = 0
|
||||
self.log_error_after = 3
|
||||
self.before_llm_call_hooks: list[Callable] = []
|
||||
self.after_llm_call_hooks: list[Callable] = []
|
||||
self.before_llm_call_hooks.extend(get_before_llm_call_hooks())
|
||||
self.after_llm_call_hooks.extend(get_after_llm_call_hooks())
|
||||
if self.llm:
|
||||
# This may be mutating the shared llm object and needs further evaluation
|
||||
existing_stop = getattr(self.llm, "stop", [])
|
||||
@@ -211,6 +222,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
llm=self.llm,
|
||||
callbacks=self.callbacks,
|
||||
)
|
||||
break
|
||||
|
||||
enforce_rpm_limit(self.request_within_rpm_limit)
|
||||
|
||||
@@ -221,8 +233,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
printer=self._printer,
|
||||
from_task=self.task,
|
||||
from_agent=self.agent,
|
||||
response_model=self.response_model,
|
||||
executor_context=self,
|
||||
)
|
||||
formatted_answer = process_llm_response(answer, self.use_stop_words)
|
||||
formatted_answer = process_llm_response(answer, self.use_stop_words) # type: ignore[assignment]
|
||||
|
||||
if isinstance(formatted_answer, AgentAction):
|
||||
# Extract agent fingerprint if available
|
||||
@@ -249,16 +263,17 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
task=self.task,
|
||||
agent=self.agent,
|
||||
function_calling_llm=self.function_calling_llm,
|
||||
crew=self.crew,
|
||||
)
|
||||
formatted_answer = self._handle_agent_action(
|
||||
formatted_answer, tool_result
|
||||
)
|
||||
|
||||
self._invoke_step_callback(formatted_answer)
|
||||
self._append_message(formatted_answer.text)
|
||||
self._invoke_step_callback(formatted_answer) # type: ignore[arg-type]
|
||||
self._append_message(formatted_answer.text) # type: ignore[union-attr,attr-defined]
|
||||
|
||||
except OutputParserError as e: # noqa: PERF203
|
||||
formatted_answer = handle_output_parser_exception(
|
||||
except OutputParserError as e:
|
||||
formatted_answer = handle_output_parser_exception( # type: ignore[assignment]
|
||||
e=e,
|
||||
messages=self.messages,
|
||||
iterations=self.iterations,
|
||||
|
||||
@@ -18,10 +18,10 @@ from crewai.agents.constants import (
|
||||
MISSING_ACTION_INPUT_AFTER_ACTION_ERROR_MESSAGE,
|
||||
UNABLE_TO_REPAIR_JSON_RESULTS,
|
||||
)
|
||||
from crewai.utilities.i18n import I18N
|
||||
from crewai.utilities.i18n import get_i18n
|
||||
|
||||
|
||||
_I18N = I18N()
|
||||
_I18N = get_i18n()
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import time
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
import webbrowser
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -13,6 +13,8 @@ from crewai.cli.shared.token_manager import TokenManager
|
||||
|
||||
console = Console()
|
||||
|
||||
TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings")
|
||||
|
||||
|
||||
class Oauth2Settings(BaseModel):
|
||||
provider: str = Field(
|
||||
@@ -28,9 +30,15 @@ class Oauth2Settings(BaseModel):
|
||||
description="OAuth2 audience value, typically used to identify the target API or resource.",
|
||||
default=None,
|
||||
)
|
||||
extra: dict[str, Any] = Field(
|
||||
description="Extra configuration for the OAuth2 provider.",
|
||||
default={},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls):
|
||||
def from_settings(cls: type[TOauth2Settings]) -> TOauth2Settings:
|
||||
"""Create an Oauth2Settings instance from the CLI settings."""
|
||||
|
||||
settings = Settings()
|
||||
|
||||
return cls(
|
||||
@@ -38,12 +46,20 @@ class Oauth2Settings(BaseModel):
|
||||
domain=settings.oauth2_domain,
|
||||
client_id=settings.oauth2_client_id,
|
||||
audience=settings.oauth2_audience,
|
||||
extra=settings.oauth2_extra,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai.cli.authentication.providers.base_provider import BaseProvider
|
||||
|
||||
|
||||
class ProviderFactory:
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Oauth2Settings | None = None):
|
||||
def from_settings(
|
||||
cls: type["ProviderFactory"], # noqa: UP037
|
||||
settings: Oauth2Settings | None = None,
|
||||
) -> "BaseProvider": # noqa: UP037
|
||||
settings = settings or Oauth2Settings.from_settings()
|
||||
|
||||
import importlib
|
||||
@@ -53,11 +69,11 @@ class ProviderFactory:
|
||||
)
|
||||
provider = getattr(module, f"{settings.provider.capitalize()}Provider")
|
||||
|
||||
return provider(settings)
|
||||
return cast("BaseProvider", provider(settings))
|
||||
|
||||
|
||||
class AuthenticationCommand:
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.token_manager = TokenManager()
|
||||
self.oauth2_provider = ProviderFactory.from_settings()
|
||||
|
||||
@@ -84,7 +100,7 @@ class AuthenticationCommand:
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return cast(dict[str, Any], response.json())
|
||||
|
||||
def _display_auth_instructions(self, device_code_data: dict[str, str]) -> None:
|
||||
"""Display the authentication instructions to the user."""
|
||||
|
||||
@@ -24,3 +24,7 @@ class BaseProvider(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def get_client_id(self) -> str: ...
|
||||
|
||||
def get_required_fields(self) -> list[str]:
|
||||
"""Returns which provider-specific fields inside the "extra" dict will be required"""
|
||||
return []
|
||||
|
||||
@@ -3,16 +3,16 @@ from crewai.cli.authentication.providers.base_provider import BaseProvider
|
||||
|
||||
class OktaProvider(BaseProvider):
|
||||
def get_authorize_url(self) -> str:
|
||||
return f"https://{self.settings.domain}/oauth2/default/v1/device/authorize"
|
||||
return f"{self._oauth2_base_url()}/v1/device/authorize"
|
||||
|
||||
def get_token_url(self) -> str:
|
||||
return f"https://{self.settings.domain}/oauth2/default/v1/token"
|
||||
return f"{self._oauth2_base_url()}/v1/token"
|
||||
|
||||
def get_jwks_url(self) -> str:
|
||||
return f"https://{self.settings.domain}/oauth2/default/v1/keys"
|
||||
return f"{self._oauth2_base_url()}/v1/keys"
|
||||
|
||||
def get_issuer(self) -> str:
|
||||
return f"https://{self.settings.domain}/oauth2/default"
|
||||
return self._oauth2_base_url().removesuffix("/oauth2")
|
||||
|
||||
def get_audience(self) -> str:
|
||||
if self.settings.audience is None:
|
||||
@@ -27,3 +27,16 @@ class OktaProvider(BaseProvider):
|
||||
"Client ID is required. Please set it in the configuration."
|
||||
)
|
||||
return self.settings.client_id
|
||||
|
||||
def get_required_fields(self) -> list[str]:
|
||||
return ["authorization_server_name", "using_org_auth_server"]
|
||||
|
||||
def _oauth2_base_url(self) -> str:
|
||||
using_org_auth_server = self.settings.extra.get("using_org_auth_server", False)
|
||||
|
||||
if using_org_auth_server:
|
||||
base_url = f"https://{self.settings.domain}/oauth2"
|
||||
else:
|
||||
base_url = f"https://{self.settings.domain}/oauth2/{self.settings.extra.get('authorization_server_name', 'default')}"
|
||||
|
||||
return f"{base_url}"
|
||||
|
||||
@@ -493,5 +493,206 @@ def config_reset():
|
||||
config_command.reset_all_settings()
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def env():
|
||||
"""Environment variable commands."""
|
||||
|
||||
|
||||
@env.command("view")
|
||||
def env_view():
|
||||
"""View tracing-related environment variables."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
# Check for .env file
|
||||
env_file = Path(".env")
|
||||
env_file_exists = env_file.exists()
|
||||
|
||||
# Create table for environment variables
|
||||
table = Table(show_header=True, header_style="bold cyan", expand=True)
|
||||
table.add_column("Environment Variable", style="cyan", width=30)
|
||||
table.add_column("Value", style="white", width=20)
|
||||
table.add_column("Source", style="yellow", width=20)
|
||||
|
||||
# Check CREWAI_TRACING_ENABLED
|
||||
crewai_tracing = os.getenv("CREWAI_TRACING_ENABLED", "")
|
||||
if crewai_tracing:
|
||||
table.add_row(
|
||||
"CREWAI_TRACING_ENABLED",
|
||||
crewai_tracing,
|
||||
"Environment/Shell",
|
||||
)
|
||||
else:
|
||||
table.add_row(
|
||||
"CREWAI_TRACING_ENABLED",
|
||||
"[dim]Not set[/dim]",
|
||||
"[dim]—[/dim]",
|
||||
)
|
||||
|
||||
# Check other related env vars
|
||||
crewai_testing = os.getenv("CREWAI_TESTING", "")
|
||||
if crewai_testing:
|
||||
table.add_row("CREWAI_TESTING", crewai_testing, "Environment/Shell")
|
||||
|
||||
crewai_user_id = os.getenv("CREWAI_USER_ID", "")
|
||||
if crewai_user_id:
|
||||
table.add_row("CREWAI_USER_ID", crewai_user_id, "Environment/Shell")
|
||||
|
||||
crewai_org_id = os.getenv("CREWAI_ORG_ID", "")
|
||||
if crewai_org_id:
|
||||
table.add_row("CREWAI_ORG_ID", crewai_org_id, "Environment/Shell")
|
||||
|
||||
# Check if .env file exists
|
||||
table.add_row(
|
||||
".env file",
|
||||
"✅ Found" if env_file_exists else "❌ Not found",
|
||||
str(env_file.resolve()) if env_file_exists else "N/A",
|
||||
)
|
||||
|
||||
panel = Panel(
|
||||
table,
|
||||
title="Tracing Environment Variables",
|
||||
border_style="blue",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print("\n")
|
||||
console.print(panel)
|
||||
|
||||
# Show helpful message
|
||||
if env_file_exists:
|
||||
console.print(
|
||||
"\n[dim]💡 Tip: To enable tracing via .env, add: CREWAI_TRACING_ENABLED=true[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"\n[dim]💡 Tip: Create a .env file in your project root and add: CREWAI_TRACING_ENABLED=true[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def traces():
|
||||
"""Trace collection management commands."""
|
||||
|
||||
|
||||
@traces.command("enable")
|
||||
def traces_enable():
|
||||
"""Enable trace collection for crew/flow executions."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from crewai.events.listeners.tracing.utils import (
|
||||
_load_user_data,
|
||||
_save_user_data,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
|
||||
# Update user data to enable traces
|
||||
user_data = _load_user_data()
|
||||
user_data["trace_consent"] = True
|
||||
user_data["first_execution_done"] = True
|
||||
_save_user_data(user_data)
|
||||
|
||||
panel = Panel(
|
||||
"✅ Trace collection has been enabled!\n\n"
|
||||
"Your crew/flow executions will now send traces to CrewAI+.\n"
|
||||
"Use 'crewai traces disable' to turn off trace collection.",
|
||||
title="Traces Enabled",
|
||||
border_style="green",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
|
||||
@traces.command("disable")
|
||||
def traces_disable():
|
||||
"""Disable trace collection for crew/flow executions."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from crewai.events.listeners.tracing.utils import (
|
||||
_load_user_data,
|
||||
_save_user_data,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
|
||||
# Update user data to disable traces
|
||||
user_data = _load_user_data()
|
||||
user_data["trace_consent"] = False
|
||||
user_data["first_execution_done"] = True
|
||||
_save_user_data(user_data)
|
||||
|
||||
panel = Panel(
|
||||
"❌ Trace collection has been disabled!\n\n"
|
||||
"Your crew/flow executions will no longer send traces.\n"
|
||||
"Use 'crewai traces enable' to turn trace collection back on.",
|
||||
title="Traces Disabled",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
|
||||
@traces.command("status")
|
||||
def traces_status():
|
||||
"""Show current trace collection status."""
|
||||
import os
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from crewai.events.listeners.tracing.utils import (
|
||||
_load_user_data,
|
||||
is_tracing_enabled,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
user_data = _load_user_data()
|
||||
|
||||
table = Table(show_header=False, box=None)
|
||||
table.add_column("Setting", style="cyan")
|
||||
table.add_column("Value", style="white")
|
||||
|
||||
# Check environment variable
|
||||
env_enabled = os.getenv("CREWAI_TRACING_ENABLED", "false")
|
||||
table.add_row("CREWAI_TRACING_ENABLED", env_enabled)
|
||||
|
||||
# Check user consent
|
||||
trace_consent = user_data.get("trace_consent")
|
||||
if trace_consent is True:
|
||||
consent_status = "✅ Enabled (user consented)"
|
||||
elif trace_consent is False:
|
||||
consent_status = "❌ Disabled (user declined)"
|
||||
else:
|
||||
consent_status = "⚪ Not set (first-time user)"
|
||||
table.add_row("User Consent", consent_status)
|
||||
|
||||
# Check overall status
|
||||
if is_tracing_enabled():
|
||||
overall_status = "✅ ENABLED"
|
||||
border_style = "green"
|
||||
else:
|
||||
overall_status = "❌ DISABLED"
|
||||
border_style = "red"
|
||||
table.add_row("Overall Status", overall_status)
|
||||
|
||||
panel = Panel(
|
||||
table,
|
||||
title="Trace Collection Status",
|
||||
border_style=border_style,
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
crewai()
|
||||
|
||||
@@ -11,18 +11,18 @@ console = Console()
|
||||
|
||||
|
||||
class BaseCommand:
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self._telemetry = Telemetry()
|
||||
self._telemetry.set_tracer()
|
||||
|
||||
|
||||
class PlusAPIMixin:
|
||||
def __init__(self, telemetry):
|
||||
def __init__(self, telemetry: Telemetry) -> None:
|
||||
try:
|
||||
telemetry.set_tracer()
|
||||
self.plus_api_client = PlusAPI(api_key=get_auth_token())
|
||||
except Exception:
|
||||
self._deploy_signup_error_span = telemetry.deploy_signup_error_span()
|
||||
telemetry.deploy_signup_error_span()
|
||||
console.print(
|
||||
"Please sign up/login to CrewAI+ before using the CLI.",
|
||||
style="bold red",
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -136,7 +137,12 @@ class Settings(BaseModel):
|
||||
default=DEFAULT_CLI_SETTINGS["oauth2_domain"],
|
||||
)
|
||||
|
||||
def __init__(self, config_path: Path | None = None, **data):
|
||||
oauth2_extra: dict[str, Any] = Field(
|
||||
description="Extra configuration for the OAuth2 provider.",
|
||||
default={},
|
||||
)
|
||||
|
||||
def __init__(self, config_path: Path | None = None, **data: dict[str, Any]) -> None:
|
||||
"""Load Settings from config path with fallback support"""
|
||||
if config_path is None:
|
||||
config_path = get_writable_config_path()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import requests
|
||||
from requests.exceptions import JSONDecodeError, RequestException
|
||||
from rich.console import Console
|
||||
|
||||
from crewai.cli.authentication.main import Oauth2Settings, ProviderFactory
|
||||
from crewai.cli.command import BaseCommand
|
||||
from crewai.cli.settings.main import SettingsCommand
|
||||
from crewai.cli.version import get_crewai_version
|
||||
@@ -13,7 +14,7 @@ console = Console()
|
||||
|
||||
|
||||
class EnterpriseConfigureCommand(BaseCommand):
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.settings_command = SettingsCommand()
|
||||
|
||||
@@ -54,25 +55,12 @@ class EnterpriseConfigureCommand(BaseCommand):
|
||||
except JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON response from {oauth_endpoint}") from e
|
||||
|
||||
required_fields = [
|
||||
"audience",
|
||||
"domain",
|
||||
"device_authorization_client_id",
|
||||
"provider",
|
||||
]
|
||||
missing_fields = [
|
||||
field for field in required_fields if field not in oauth_config
|
||||
]
|
||||
|
||||
if missing_fields:
|
||||
raise ValueError(
|
||||
f"Missing required fields in OAuth2 configuration: {', '.join(missing_fields)}"
|
||||
)
|
||||
self._validate_oauth_config(oauth_config)
|
||||
|
||||
console.print(
|
||||
"✅ Successfully retrieved OAuth2 configuration", style="green"
|
||||
)
|
||||
return oauth_config
|
||||
return cast(dict[str, Any], oauth_config)
|
||||
|
||||
except RequestException as e:
|
||||
raise ValueError(f"Failed to connect to enterprise URL: {e!s}") from e
|
||||
@@ -89,6 +77,7 @@ class EnterpriseConfigureCommand(BaseCommand):
|
||||
"oauth2_audience": oauth_config["audience"],
|
||||
"oauth2_client_id": oauth_config["device_authorization_client_id"],
|
||||
"oauth2_domain": oauth_config["domain"],
|
||||
"oauth2_extra": oauth_config["extra"],
|
||||
}
|
||||
|
||||
console.print("🔄 Updating local OAuth2 configuration...")
|
||||
@@ -99,3 +88,38 @@ class EnterpriseConfigureCommand(BaseCommand):
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to update OAuth2 settings: {e!s}") from e
|
||||
|
||||
def _validate_oauth_config(self, oauth_config: dict[str, Any]) -> None:
|
||||
required_fields = [
|
||||
"audience",
|
||||
"domain",
|
||||
"device_authorization_client_id",
|
||||
"provider",
|
||||
"extra",
|
||||
]
|
||||
|
||||
missing_basic_fields = [
|
||||
field for field in required_fields if field not in oauth_config
|
||||
]
|
||||
missing_provider_specific_fields = [
|
||||
field
|
||||
for field in self._get_provider_specific_fields(oauth_config["provider"])
|
||||
if field not in oauth_config.get("extra", {})
|
||||
]
|
||||
|
||||
if missing_basic_fields:
|
||||
raise ValueError(
|
||||
f"Missing required fields in OAuth2 configuration: [{', '.join(missing_basic_fields)}]"
|
||||
)
|
||||
|
||||
if missing_provider_specific_fields:
|
||||
raise ValueError(
|
||||
f"Missing authentication provider required fields in OAuth2 configuration: [{', '.join(missing_provider_specific_fields)}] (Configured provider: '{oauth_config['provider']}')"
|
||||
)
|
||||
|
||||
def _get_provider_specific_fields(self, provider_name: str) -> list[str]:
|
||||
provider = ProviderFactory.from_settings(
|
||||
Oauth2Settings(provider=provider_name, client_id="dummy", domain="dummy")
|
||||
)
|
||||
|
||||
return provider.get_required_fields()
|
||||
|
||||
@@ -3,7 +3,7 @@ import subprocess
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self, path="."):
|
||||
def __init__(self, path: str = ".") -> None:
|
||||
self.path = path
|
||||
|
||||
if not self.is_git_installed():
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
@@ -36,19 +37,21 @@ class PlusAPI:
|
||||
str(settings.enterprise_base_url) or DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
)
|
||||
|
||||
def _make_request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
||||
def _make_request(
|
||||
self, method: str, endpoint: str, **kwargs: Any
|
||||
) -> requests.Response:
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
session = requests.Session()
|
||||
session.trust_env = False
|
||||
return session.request(method, url, headers=self.headers, **kwargs)
|
||||
|
||||
def login_to_tool_repository(self):
|
||||
def login_to_tool_repository(self) -> requests.Response:
|
||||
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login")
|
||||
|
||||
def get_tool(self, handle: str):
|
||||
def get_tool(self, handle: str) -> requests.Response:
|
||||
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
|
||||
|
||||
def get_agent(self, handle: str):
|
||||
def get_agent(self, handle: str) -> requests.Response:
|
||||
return self._make_request("GET", f"{self.AGENTS_RESOURCE}/{handle}")
|
||||
|
||||
def publish_tool(
|
||||
@@ -58,8 +61,8 @@ class PlusAPI:
|
||||
version: str,
|
||||
description: str | None,
|
||||
encoded_file: str,
|
||||
available_exports: list[str] | None = None,
|
||||
):
|
||||
available_exports: list[dict[str, Any]] | None = None,
|
||||
) -> requests.Response:
|
||||
params = {
|
||||
"handle": handle,
|
||||
"public": is_public,
|
||||
@@ -111,13 +114,13 @@ class PlusAPI:
|
||||
def list_crews(self) -> requests.Response:
|
||||
return self._make_request("GET", self.CREWS_RESOURCE)
|
||||
|
||||
def create_crew(self, payload) -> requests.Response:
|
||||
def create_crew(self, payload: dict[str, Any]) -> requests.Response:
|
||||
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
|
||||
|
||||
def get_organizations(self) -> requests.Response:
|
||||
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
|
||||
|
||||
def initialize_trace_batch(self, payload) -> requests.Response:
|
||||
def initialize_trace_batch(self, payload: dict[str, Any]) -> requests.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
f"{self.TRACING_RESOURCE}/batches",
|
||||
@@ -125,14 +128,18 @@ class PlusAPI:
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def initialize_ephemeral_trace_batch(self, payload) -> requests.Response:
|
||||
def initialize_ephemeral_trace_batch(
|
||||
self, payload: dict[str, Any]
|
||||
) -> requests.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches",
|
||||
json=payload,
|
||||
)
|
||||
|
||||
def send_trace_events(self, trace_batch_id: str, payload) -> requests.Response:
|
||||
def send_trace_events(
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
) -> requests.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/events",
|
||||
@@ -141,7 +148,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def send_ephemeral_trace_events(
|
||||
self, trace_batch_id: str, payload
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
) -> requests.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
@@ -150,7 +157,9 @@ class PlusAPI:
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def finalize_trace_batch(self, trace_batch_id: str, payload) -> requests.Response:
|
||||
def finalize_trace_batch(
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
) -> requests.Response:
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
|
||||
@@ -159,7 +168,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def finalize_ephemeral_trace_batch(
|
||||
self, trace_batch_id: str, payload
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
) -> requests.Response:
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
@@ -5,6 +7,7 @@ from rich.table import Table
|
||||
|
||||
from crewai.cli.command import BaseCommand
|
||||
from crewai.cli.config import HIDDEN_SETTINGS_KEYS, READONLY_SETTINGS_KEYS, Settings
|
||||
from crewai.events.listeners.tracing.utils import _load_user_data
|
||||
|
||||
|
||||
console = Console()
|
||||
@@ -34,11 +37,47 @@ class SettingsCommand(BaseCommand):
|
||||
current_value = getattr(self.settings, field_name)
|
||||
description = field_info.description or "No description available"
|
||||
display_value = (
|
||||
str(current_value) if current_value is not None else "Not set"
|
||||
str(current_value) if current_value not in [None, {}] else "Not set"
|
||||
)
|
||||
|
||||
table.add_row(field_name, display_value, description)
|
||||
|
||||
# Add trace-related settings from user data
|
||||
user_data = _load_user_data()
|
||||
|
||||
# CREWAI_TRACING_ENABLED environment variable
|
||||
env_tracing = os.getenv("CREWAI_TRACING_ENABLED", "")
|
||||
env_tracing_display = env_tracing if env_tracing else "Not set"
|
||||
table.add_row(
|
||||
"CREWAI_TRACING_ENABLED",
|
||||
env_tracing_display,
|
||||
"Environment variable to enable/disable tracing",
|
||||
)
|
||||
|
||||
# Trace consent status
|
||||
trace_consent = user_data.get("trace_consent")
|
||||
if trace_consent is True:
|
||||
consent_display = "✅ Enabled"
|
||||
elif trace_consent is False:
|
||||
consent_display = "❌ Disabled"
|
||||
else:
|
||||
consent_display = "Not set"
|
||||
table.add_row(
|
||||
"trace_consent", consent_display, "Whether trace collection is enabled"
|
||||
)
|
||||
|
||||
# First execution timestamp
|
||||
if user_data.get("first_execution_at"):
|
||||
timestamp = datetime.fromtimestamp(user_data["first_execution_at"])
|
||||
first_exec_display = timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
first_exec_display = "Not set"
|
||||
table.add_row(
|
||||
"first_execution_at",
|
||||
first_exec_display,
|
||||
"Timestamp of first crew/flow execution",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
|
||||
@@ -3,10 +3,17 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import BinaryIO, cast
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
|
||||
class TokenManager:
|
||||
def __init__(self, file_path: str = "tokens.enc") -> None:
|
||||
"""
|
||||
@@ -18,21 +25,74 @@ class TokenManager:
|
||||
self.key = self._get_or_create_key()
|
||||
self.fernet = Fernet(self.key)
|
||||
|
||||
@staticmethod
|
||||
def _acquire_lock(file_handle: BinaryIO) -> None:
|
||||
"""
|
||||
Acquire an exclusive lock on a file handle.
|
||||
|
||||
Args:
|
||||
file_handle: Open file handle to lock.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
msvcrt.locking(file_handle.fileno(), msvcrt.LK_LOCK, 1)
|
||||
else:
|
||||
fcntl.flock(file_handle.fileno(), fcntl.LOCK_EX)
|
||||
|
||||
@staticmethod
|
||||
def _release_lock(file_handle: BinaryIO) -> None:
|
||||
"""
|
||||
Release the lock on a file handle.
|
||||
|
||||
Args:
|
||||
file_handle: Open file handle to unlock.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
msvcrt.locking(file_handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
def _get_or_create_key(self) -> bytes:
|
||||
"""
|
||||
Get or create the encryption key.
|
||||
Get or create the encryption key with file locking to prevent race conditions.
|
||||
|
||||
:return: The encryption key.
|
||||
Returns:
|
||||
The encryption key.
|
||||
"""
|
||||
key_filename = "secret.key"
|
||||
key = self.read_secure_file(key_filename)
|
||||
storage_path = self.get_secure_storage_path()
|
||||
|
||||
if key is not None:
|
||||
key = self.read_secure_file(key_filename)
|
||||
if key is not None and len(key) == 44:
|
||||
return key
|
||||
|
||||
new_key = Fernet.generate_key()
|
||||
self.save_secure_file(key_filename, new_key)
|
||||
return new_key
|
||||
lock_file_path = storage_path / f"{key_filename}.lock"
|
||||
|
||||
try:
|
||||
lock_file_path.touch()
|
||||
|
||||
with open(lock_file_path, "r+b") as lock_file:
|
||||
self._acquire_lock(lock_file)
|
||||
try:
|
||||
key = self.read_secure_file(key_filename)
|
||||
if key is not None and len(key) == 44:
|
||||
return key
|
||||
|
||||
new_key = Fernet.generate_key()
|
||||
self.save_secure_file(key_filename, new_key)
|
||||
return new_key
|
||||
finally:
|
||||
try:
|
||||
self._release_lock(lock_file)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
key = self.read_secure_file(key_filename)
|
||||
if key is not None and len(key) == 44:
|
||||
return key
|
||||
|
||||
new_key = Fernet.generate_key()
|
||||
self.save_secure_file(key_filename, new_key)
|
||||
return new_key
|
||||
|
||||
def save_tokens(self, access_token: str, expires_at: int) -> None:
|
||||
"""
|
||||
@@ -59,14 +119,14 @@ class TokenManager:
|
||||
if encrypted_data is None:
|
||||
return None
|
||||
|
||||
decrypted_data = self.fernet.decrypt(encrypted_data) # type: ignore
|
||||
decrypted_data = self.fernet.decrypt(encrypted_data)
|
||||
data = json.loads(decrypted_data)
|
||||
|
||||
expiration = datetime.fromisoformat(data["expiration"])
|
||||
if expiration <= datetime.now():
|
||||
return None
|
||||
|
||||
return data["access_token"]
|
||||
return cast(str | None, data["access_token"])
|
||||
|
||||
def clear_tokens(self) -> None:
|
||||
"""
|
||||
@@ -74,20 +134,18 @@ class TokenManager:
|
||||
"""
|
||||
self.delete_secure_file(self.file_path)
|
||||
|
||||
def get_secure_storage_path(self) -> Path:
|
||||
@staticmethod
|
||||
def get_secure_storage_path() -> Path:
|
||||
"""
|
||||
Get the secure storage path based on the operating system.
|
||||
|
||||
:return: The secure storage path.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
# Windows: Use %LOCALAPPDATA%
|
||||
base_path = os.environ.get("LOCALAPPDATA")
|
||||
elif sys.platform == "darwin":
|
||||
# macOS: Use ~/Library/Application Support
|
||||
base_path = os.path.expanduser("~/Library/Application Support")
|
||||
else:
|
||||
# Linux and other Unix-like: Use ~/.local/share
|
||||
base_path = os.path.expanduser("~/.local/share")
|
||||
|
||||
app_name = "crewai/credentials"
|
||||
@@ -110,7 +168,6 @@ class TokenManager:
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Set appropriate permissions (read/write for owner only)
|
||||
os.chmod(file_path, 0o600)
|
||||
|
||||
def read_secure_file(self, filename: str) -> bytes | None:
|
||||
|
||||
@@ -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.2.1"
|
||||
"crewai[tools]==1.5.0"
|
||||
]
|
||||
|
||||
[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.2.1"
|
||||
"crewai[tools]==1.5.0"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -30,11 +30,11 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
||||
A class to handle tool repository related operations for CrewAI projects.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
BaseCommand.__init__(self)
|
||||
PlusAPIMixin.__init__(self, telemetry=self._telemetry)
|
||||
|
||||
def create(self, handle: str):
|
||||
def create(self, handle: str) -> None:
|
||||
self._ensure_not_in_project()
|
||||
|
||||
folder_name = handle.replace(" ", "_").replace("-", "_").lower()
|
||||
@@ -64,7 +64,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
||||
finally:
|
||||
os.chdir(old_directory)
|
||||
|
||||
def publish(self, is_public: bool, force: bool = False):
|
||||
def publish(self, is_public: bool, force: bool = False) -> None:
|
||||
if not git.Repository().is_synced() and not force:
|
||||
console.print(
|
||||
"[bold red]Failed to publish tool.[/bold red]\n"
|
||||
@@ -137,7 +137,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
||||
style="bold green",
|
||||
)
|
||||
|
||||
def install(self, handle: str):
|
||||
def install(self, handle: str) -> None:
|
||||
self._print_current_organization()
|
||||
get_response = self.plus_api_client.get_tool(handle)
|
||||
|
||||
@@ -180,7 +180,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
||||
settings.org_name = login_response_json["current_organization"]["name"]
|
||||
settings.dump()
|
||||
|
||||
def _add_package(self, tool_details: dict[str, Any]):
|
||||
def _add_package(self, tool_details: dict[str, Any]) -> None:
|
||||
is_from_pypi = tool_details.get("source", None) == "pypi"
|
||||
tool_handle = tool_details["handle"]
|
||||
repository_handle = tool_details["repository"]["handle"]
|
||||
@@ -209,7 +209,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
||||
click.echo(add_package_result.stderr, err=True)
|
||||
raise SystemExit
|
||||
|
||||
def _ensure_not_in_project(self):
|
||||
def _ensure_not_in_project(self) -> None:
|
||||
if os.path.isfile("./pyproject.toml"):
|
||||
console.print(
|
||||
"[bold red]Oops! It looks like you're inside a project.[/bold red]"
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Any, get_type_hints
|
||||
from typing import Any, cast, get_type_hints
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
@@ -23,7 +23,9 @@ if sys.version_info >= (3, 11):
|
||||
console = Console()
|
||||
|
||||
|
||||
def copy_template(src, dst, name, class_name, folder_name):
|
||||
def copy_template(
|
||||
src: Path, dst: Path, name: str, class_name: str, folder_name: str
|
||||
) -> None:
|
||||
"""Copy a file from src to dst."""
|
||||
with open(src, "r") as file:
|
||||
content = file.read()
|
||||
@@ -40,13 +42,13 @@ def copy_template(src, dst, name, class_name, folder_name):
|
||||
click.secho(f" - Created {dst}", fg="green")
|
||||
|
||||
|
||||
def read_toml(file_path: str = "pyproject.toml"):
|
||||
def read_toml(file_path: str = "pyproject.toml") -> dict[str, Any]:
|
||||
"""Read the content of a TOML file and return it as a dictionary."""
|
||||
with open(file_path, "rb") as f:
|
||||
return tomli.load(f)
|
||||
|
||||
|
||||
def parse_toml(content):
|
||||
def parse_toml(content: str) -> dict[str, Any]:
|
||||
if sys.version_info >= (3, 11):
|
||||
return tomllib.loads(content)
|
||||
return tomli.loads(content)
|
||||
@@ -103,7 +105,7 @@ def _get_project_attribute(
|
||||
)
|
||||
except Exception as e:
|
||||
# Handle TOML decode errors for Python 3.11+
|
||||
if sys.version_info >= (3, 11) and isinstance(e, tomllib.TOMLDecodeError): # type: ignore
|
||||
if sys.version_info >= (3, 11) and isinstance(e, tomllib.TOMLDecodeError):
|
||||
console.print(
|
||||
f"Error: {pyproject_path} is not a valid TOML file.", style="bold red"
|
||||
)
|
||||
@@ -126,7 +128,7 @@ def _get_nested_value(data: dict[str, Any], keys: list[str]) -> Any:
|
||||
return reduce(dict.__getitem__, keys, data)
|
||||
|
||||
|
||||
def fetch_and_json_env_file(env_file_path: str = ".env") -> dict:
|
||||
def fetch_and_json_env_file(env_file_path: str = ".env") -> dict[str, Any]:
|
||||
"""Fetch the environment variables from a .env file and return them as a dictionary."""
|
||||
try:
|
||||
# Read the .env file
|
||||
@@ -150,7 +152,7 @@ def fetch_and_json_env_file(env_file_path: str = ".env") -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def tree_copy(source, destination):
|
||||
def tree_copy(source: Path, destination: Path) -> None:
|
||||
"""Copies the entire directory structure from the source to the destination."""
|
||||
for item in os.listdir(source):
|
||||
source_item = os.path.join(source, item)
|
||||
@@ -161,7 +163,7 @@ def tree_copy(source, destination):
|
||||
shutil.copy2(source_item, destination_item)
|
||||
|
||||
|
||||
def tree_find_and_replace(directory, find, replace):
|
||||
def tree_find_and_replace(directory: Path, find: str, replace: str) -> None:
|
||||
"""Recursively searches through a directory, replacing a target string in
|
||||
both file contents and filenames with a specified replacement string.
|
||||
"""
|
||||
@@ -187,7 +189,7 @@ def tree_find_and_replace(directory, find, replace):
|
||||
os.rename(old_dirpath, new_dirpath)
|
||||
|
||||
|
||||
def load_env_vars(folder_path):
|
||||
def load_env_vars(folder_path: Path) -> dict[str, Any]:
|
||||
"""
|
||||
Loads environment variables from a .env file in the specified folder path.
|
||||
|
||||
@@ -208,7 +210,9 @@ def load_env_vars(folder_path):
|
||||
return env_vars
|
||||
|
||||
|
||||
def update_env_vars(env_vars, provider, model):
|
||||
def update_env_vars(
|
||||
env_vars: dict[str, Any], provider: str, model: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Updates environment variables with the API key for the selected provider and model.
|
||||
|
||||
@@ -220,15 +224,20 @@ def update_env_vars(env_vars, provider, model):
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
api_key_var = ENV_VARS.get(
|
||||
provider,
|
||||
[
|
||||
click.prompt(
|
||||
f"Enter the environment variable name for your {provider.capitalize()} API key",
|
||||
type=str,
|
||||
)
|
||||
],
|
||||
)[0]
|
||||
provider_config = cast(
|
||||
list[str],
|
||||
ENV_VARS.get(
|
||||
provider,
|
||||
[
|
||||
click.prompt(
|
||||
f"Enter the environment variable name for your {provider.capitalize()} API key",
|
||||
type=str,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
api_key_var = provider_config[0]
|
||||
|
||||
if api_key_var not in env_vars:
|
||||
try:
|
||||
@@ -246,7 +255,7 @@ def update_env_vars(env_vars, provider, model):
|
||||
return env_vars
|
||||
|
||||
|
||||
def write_env_file(folder_path, env_vars):
|
||||
def write_env_file(folder_path: Path, env_vars: dict[str, Any]) -> None:
|
||||
"""
|
||||
Writes environment variables to a .env file in the specified folder.
|
||||
|
||||
@@ -342,18 +351,18 @@ def get_crews(crew_path: str = "crew.py", require: bool = False) -> list[Crew]:
|
||||
return crew_instances
|
||||
|
||||
|
||||
def get_crew_instance(module_attr) -> Crew | None:
|
||||
def get_crew_instance(module_attr: Any) -> Crew | None:
|
||||
if (
|
||||
callable(module_attr)
|
||||
and hasattr(module_attr, "is_crew_class")
|
||||
and module_attr.is_crew_class
|
||||
):
|
||||
return module_attr().crew()
|
||||
return cast(Crew, module_attr().crew())
|
||||
try:
|
||||
if (ismethod(module_attr) or isfunction(module_attr)) and get_type_hints(
|
||||
module_attr
|
||||
).get("return") is Crew:
|
||||
return module_attr()
|
||||
return cast(Crew, module_attr())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -362,7 +371,7 @@ def get_crew_instance(module_attr) -> Crew | None:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_crews(module_attr) -> list[Crew]:
|
||||
def fetch_crews(module_attr: Any) -> list[Crew]:
|
||||
crew_instances: list[Crew] = []
|
||||
|
||||
if crew_instance := get_crew_instance(module_attr):
|
||||
@@ -377,7 +386,7 @@ def fetch_crews(module_attr) -> list[Crew]:
|
||||
return crew_instances
|
||||
|
||||
|
||||
def is_valid_tool(obj):
|
||||
def is_valid_tool(obj: Any) -> bool:
|
||||
from crewai.tools.base_tool import Tool
|
||||
|
||||
if isclass(obj):
|
||||
@@ -389,7 +398,7 @@ def is_valid_tool(obj):
|
||||
return isinstance(obj, Tool)
|
||||
|
||||
|
||||
def extract_available_exports(dir_path: str = "src"):
|
||||
def extract_available_exports(dir_path: str = "src") -> list[dict[str, Any]]:
|
||||
"""
|
||||
Extract available tool classes from the project's __init__.py files.
|
||||
Only includes classes that inherit from BaseTool or functions decorated with @tool.
|
||||
@@ -419,7 +428,9 @@ def extract_available_exports(dir_path: str = "src"):
|
||||
raise SystemExit(1) from e
|
||||
|
||||
|
||||
def build_env_with_tool_repository_credentials(repository_handle: str):
|
||||
def build_env_with_tool_repository_credentials(
|
||||
repository_handle: str,
|
||||
) -> dict[str, Any]:
|
||||
repository_handle = repository_handle.upper().replace("-", "_")
|
||||
settings = Settings()
|
||||
|
||||
@@ -472,7 +483,7 @@ def _load_tools_from_init(init_file: Path) -> list[dict[str, Any]]:
|
||||
sys.modules.pop("temp_module", None)
|
||||
|
||||
|
||||
def _print_no_tools_warning():
|
||||
def _print_no_tools_warning() -> None:
|
||||
"""
|
||||
Display warning and usage instructions if no tools were found.
|
||||
"""
|
||||
|
||||
@@ -27,6 +27,9 @@ from pydantic import (
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_core import PydanticCustomError
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from typing_extensions import Self
|
||||
|
||||
from crewai.agent import Agent
|
||||
from crewai.agents.agent_builder.base_agent import BaseAgent
|
||||
@@ -38,8 +41,8 @@ from crewai.events.listeners.tracing.trace_listener import (
|
||||
TraceCollectionListener,
|
||||
)
|
||||
from crewai.events.listeners.tracing.utils import (
|
||||
is_tracing_enabled,
|
||||
should_auto_collect_first_time_traces,
|
||||
set_tracing_enabled,
|
||||
should_enable_tracing,
|
||||
)
|
||||
from crewai.events.types.crew_events import (
|
||||
CrewKickoffCompletedEvent,
|
||||
@@ -70,7 +73,7 @@ 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.base_tool import BaseTool, Tool
|
||||
from crewai.tools.base_tool import BaseTool
|
||||
from crewai.types.usage_metrics import UsageMetrics
|
||||
from crewai.utilities.constants import NOT_SPECIFIED, TRAINING_DATA_FILE
|
||||
from crewai.utilities.crew.models import CrewContext
|
||||
@@ -81,7 +84,7 @@ from crewai.utilities.formatter import (
|
||||
aggregate_raw_outputs_from_task_outputs,
|
||||
aggregate_raw_outputs_from_tasks,
|
||||
)
|
||||
from crewai.utilities.i18n import I18N
|
||||
from crewai.utilities.i18n import get_i18n
|
||||
from crewai.utilities.llm_utils import create_llm
|
||||
from crewai.utilities.logger import Logger
|
||||
from crewai.utilities.planning_handler import CrewPlanner
|
||||
@@ -195,7 +198,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
function_calling_llm: str | InstanceOf[LLM] | Any | None = Field(
|
||||
description="Language model that will run the agent.", default=None
|
||||
)
|
||||
config: Json | dict[str, Any] | None = Field(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(
|
||||
@@ -279,8 +282,8 @@ class Crew(FlowTrackable, BaseModel):
|
||||
description="Metrics for the LLM usage during all tasks execution.",
|
||||
)
|
||||
tracing: bool | None = Field(
|
||||
default=False,
|
||||
description="Whether to enable tracing for the crew.",
|
||||
default=None,
|
||||
description="Whether to enable tracing for the crew. True=always enable, False=always disable, None=check environment/user settings.",
|
||||
)
|
||||
|
||||
@field_validator("id", mode="before")
|
||||
@@ -294,7 +297,9 @@ class Crew(FlowTrackable, BaseModel):
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def check_config_type(cls, v: Json | dict[str, Any]) -> Json | dict[str, Any]:
|
||||
def check_config_type(
|
||||
cls, v: Json[dict[str, Any]] | dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Validates that the config is a valid type.
|
||||
Args:
|
||||
v: The config to be validated.
|
||||
@@ -308,17 +313,16 @@ class Crew(FlowTrackable, BaseModel):
|
||||
@model_validator(mode="after")
|
||||
def set_private_attrs(self) -> Crew:
|
||||
"""set private attributes."""
|
||||
|
||||
self._cache_handler = CacheHandler()
|
||||
event_listener = EventListener()
|
||||
event_listener = EventListener() # type: ignore[no-untyped-call]
|
||||
|
||||
if (
|
||||
is_tracing_enabled()
|
||||
or self.tracing
|
||||
or should_auto_collect_first_time_traces()
|
||||
):
|
||||
trace_listener = TraceCollectionListener()
|
||||
trace_listener.setup_listeners(crewai_event_bus)
|
||||
# Determine and set tracing state once for this execution
|
||||
tracing_enabled = should_enable_tracing(override=self.tracing)
|
||||
set_tracing_enabled(tracing_enabled)
|
||||
|
||||
# Always setup trace listener - actual execution control is via contextvar
|
||||
trace_listener = TraceCollectionListener()
|
||||
trace_listener.setup_listeners(crewai_event_bus)
|
||||
event_listener.verbose = self.verbose
|
||||
event_listener.formatter.verbose = self.verbose
|
||||
self._logger = Logger(verbose=self.verbose)
|
||||
@@ -330,13 +334,13 @@ class Crew(FlowTrackable, BaseModel):
|
||||
|
||||
return self
|
||||
|
||||
def _initialize_default_memories(self):
|
||||
self._long_term_memory = self._long_term_memory or LongTermMemory()
|
||||
self._short_term_memory = self._short_term_memory or ShortTermMemory(
|
||||
def _initialize_default_memories(self) -> None:
|
||||
self._long_term_memory = self._long_term_memory or LongTermMemory() # type: ignore[no-untyped-call]
|
||||
self._short_term_memory = self._short_term_memory or ShortTermMemory( # type: ignore[no-untyped-call]
|
||||
crew=self,
|
||||
embedder_config=self.embedder,
|
||||
)
|
||||
self._entity_memory = self.entity_memory or EntityMemory(
|
||||
self._entity_memory = self.entity_memory or EntityMemory( # type: ignore[no-untyped-call]
|
||||
crew=self, embedder_config=self.embedder
|
||||
)
|
||||
|
||||
@@ -380,7 +384,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_manager_llm(self):
|
||||
def check_manager_llm(self) -> Self:
|
||||
"""Validates that the language model is set when using hierarchical process."""
|
||||
if self.process == Process.hierarchical:
|
||||
if not self.manager_llm and not self.manager_agent:
|
||||
@@ -405,7 +409,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_config(self):
|
||||
def check_config(self) -> Self:
|
||||
"""Validates that the crew is properly configured with agents and tasks."""
|
||||
if not self.config and not self.tasks and not self.agents:
|
||||
raise PydanticCustomError(
|
||||
@@ -426,23 +430,20 @@ class Crew(FlowTrackable, BaseModel):
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_tasks(self):
|
||||
def validate_tasks(self) -> Self:
|
||||
if self.process == Process.sequential:
|
||||
for task in self.tasks:
|
||||
if task.agent is None:
|
||||
raise PydanticCustomError(
|
||||
"missing_agent_in_task",
|
||||
(
|
||||
f"Sequential process error: Agent is missing in the task "
|
||||
f"with the following description: {task.description}"
|
||||
), # type: ignore # Dynamic string in error message
|
||||
{},
|
||||
"Sequential process error: Agent is missing in the task with the following description: {description}",
|
||||
{"description": task.description},
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_end_with_at_most_one_async_task(self):
|
||||
def validate_end_with_at_most_one_async_task(self) -> Self:
|
||||
"""Validates that the crew ends with at most one asynchronous task."""
|
||||
final_async_task_count = 0
|
||||
|
||||
@@ -505,7 +506,9 @@ class Crew(FlowTrackable, BaseModel):
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_async_task_cannot_include_sequential_async_tasks_in_context(self):
|
||||
def validate_async_task_cannot_include_sequential_async_tasks_in_context(
|
||||
self,
|
||||
) -> Self:
|
||||
"""
|
||||
Validates that if a task is set to be executed asynchronously,
|
||||
it cannot include other asynchronous tasks in its context unless
|
||||
@@ -527,7 +530,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_context_no_future_tasks(self):
|
||||
def validate_context_no_future_tasks(self) -> Self:
|
||||
"""Validates that a task's context does not include future tasks."""
|
||||
task_indices = {id(task): i for i, task in enumerate(self.tasks)}
|
||||
|
||||
@@ -561,7 +564,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
"""
|
||||
return self.security_config.fingerprint
|
||||
|
||||
def _setup_from_config(self):
|
||||
def _setup_from_config(self) -> None:
|
||||
"""Initializes agents and tasks from the provided config."""
|
||||
if self.config is None:
|
||||
raise ValueError("Config should not be None.")
|
||||
@@ -628,12 +631,12 @@ class Crew(FlowTrackable, BaseModel):
|
||||
|
||||
for agent in train_crew.agents:
|
||||
if training_data.get(str(agent.id)):
|
||||
result = TaskEvaluator(agent).evaluate_training_data(
|
||||
result = TaskEvaluator(agent).evaluate_training_data( # type: ignore[arg-type]
|
||||
training_data=training_data, agent_id=str(agent.id)
|
||||
)
|
||||
CrewTrainingHandler(filename).save_trained_data(
|
||||
agent_id=str(agent.role),
|
||||
trained_data=result.model_dump(), # type: ignore[arg-type]
|
||||
trained_data=result.model_dump(),
|
||||
)
|
||||
|
||||
crewai_event_bus.emit(
|
||||
@@ -684,12 +687,8 @@ class Crew(FlowTrackable, BaseModel):
|
||||
self._set_tasks_callbacks()
|
||||
self._set_allow_crewai_trigger_context_for_first_task()
|
||||
|
||||
i18n = I18N(prompt_file=self.prompt_file)
|
||||
|
||||
for agent in self.agents:
|
||||
agent.i18n = i18n
|
||||
# type: ignore[attr-defined] # Argument 1 to "_interpolate_inputs" of "Crew" has incompatible type "dict[str, Any] | None"; expected "dict[str, Any]"
|
||||
agent.crew = self # type: ignore[attr-defined]
|
||||
agent.crew = self
|
||||
agent.set_knowledge(crew_embedder=self.embedder)
|
||||
# TODO: Create an AgentFunctionCalling protocol for future refactoring
|
||||
if not agent.function_calling_llm: # type: ignore # "BaseAgent" has no attribute "function_calling_llm"
|
||||
@@ -753,10 +752,12 @@ class Crew(FlowTrackable, BaseModel):
|
||||
inputs = inputs or {}
|
||||
return await asyncio.to_thread(self.kickoff, inputs)
|
||||
|
||||
async def kickoff_for_each_async(self, inputs: list[dict]) -> list[CrewOutput]:
|
||||
async def kickoff_for_each_async(
|
||||
self, inputs: list[dict[str, Any]]
|
||||
) -> list[CrewOutput]:
|
||||
crew_copies = [self.copy() for _ in inputs]
|
||||
|
||||
async def run_crew(crew, input_data):
|
||||
async def run_crew(crew: Self, input_data: Any) -> CrewOutput:
|
||||
return await crew.kickoff_async(inputs=input_data)
|
||||
|
||||
tasks = [
|
||||
@@ -775,7 +776,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
self._task_output_handler.reset()
|
||||
return results
|
||||
|
||||
def _handle_crew_planning(self):
|
||||
def _handle_crew_planning(self) -> None:
|
||||
"""Handles the Crew planning."""
|
||||
self._logger.log("info", "Planning the crew execution")
|
||||
result = CrewPlanner(
|
||||
@@ -793,7 +794,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
output: TaskOutput,
|
||||
task_index: int,
|
||||
was_replayed: bool = False,
|
||||
):
|
||||
) -> None:
|
||||
if self._inputs:
|
||||
inputs = self._inputs
|
||||
else:
|
||||
@@ -809,6 +810,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
"json_dict": output.json_dict,
|
||||
"output_format": output.output_format,
|
||||
"agent": output.agent,
|
||||
"messages": output.messages,
|
||||
},
|
||||
"task_index": task_index,
|
||||
"inputs": inputs,
|
||||
@@ -825,19 +827,21 @@ class Crew(FlowTrackable, BaseModel):
|
||||
self._create_manager_agent()
|
||||
return self._execute_tasks(self.tasks)
|
||||
|
||||
def _create_manager_agent(self):
|
||||
i18n = I18N(prompt_file=self.prompt_file)
|
||||
def _create_manager_agent(self) -> None:
|
||||
if self.manager_agent is not None:
|
||||
self.manager_agent.allow_delegation = True
|
||||
manager = self.manager_agent
|
||||
if manager.tools is not None and len(manager.tools) > 0:
|
||||
self._logger.log(
|
||||
"warning", "Manager agent should not have tools", color="orange"
|
||||
"warning",
|
||||
"Manager agent should not have tools",
|
||||
color="bold_yellow",
|
||||
)
|
||||
manager.tools = []
|
||||
raise Exception("Manager agent should not have tools")
|
||||
else:
|
||||
self.manager_llm = create_llm(self.manager_llm)
|
||||
i18n = get_i18n(prompt_file=self.prompt_file)
|
||||
manager = Agent(
|
||||
role=i18n.retrieve("hierarchical_manager_agent", "role"),
|
||||
goal=i18n.retrieve("hierarchical_manager_agent", "goal"),
|
||||
@@ -895,7 +899,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
tools_for_task = self._prepare_tools(
|
||||
agent_to_use,
|
||||
task,
|
||||
cast(list[Tool] | list[BaseTool], tools_for_task),
|
||||
tools_for_task,
|
||||
)
|
||||
|
||||
self._log_task_start(task, agent_to_use.role)
|
||||
@@ -915,7 +919,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
future = task.execute_async(
|
||||
agent=agent_to_use,
|
||||
context=context,
|
||||
tools=cast(list[BaseTool], tools_for_task),
|
||||
tools=tools_for_task,
|
||||
)
|
||||
futures.append((task, future, task_index))
|
||||
else:
|
||||
@@ -927,7 +931,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
task_output = task.execute_sync(
|
||||
agent=agent_to_use,
|
||||
context=context,
|
||||
tools=cast(list[BaseTool], tools_for_task),
|
||||
tools=tools_for_task,
|
||||
)
|
||||
task_outputs.append(task_output)
|
||||
self._process_task_result(task, task_output)
|
||||
@@ -965,7 +969,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
return None
|
||||
|
||||
def _prepare_tools(
|
||||
self, agent: BaseAgent, task: Task, tools: list[Tool] | list[BaseTool]
|
||||
self, agent: BaseAgent, task: Task, tools: list[BaseTool]
|
||||
) -> list[BaseTool]:
|
||||
# Add delegation tools if agent allows delegation
|
||||
if hasattr(agent, "allow_delegation") and getattr(
|
||||
@@ -1002,21 +1006,21 @@ class Crew(FlowTrackable, BaseModel):
|
||||
tools = self._add_mcp_tools(task, tools)
|
||||
|
||||
# Return a list[BaseTool] compatible with Task.execute_sync and execute_async
|
||||
return cast(list[BaseTool], tools)
|
||||
return tools
|
||||
|
||||
def _get_agent_to_use(self, task: Task) -> BaseAgent | None:
|
||||
if self.process == Process.hierarchical:
|
||||
return self.manager_agent
|
||||
return task.agent
|
||||
|
||||
@staticmethod
|
||||
def _merge_tools(
|
||||
self,
|
||||
existing_tools: list[Tool] | list[BaseTool],
|
||||
new_tools: list[Tool] | list[BaseTool],
|
||||
existing_tools: list[BaseTool],
|
||||
new_tools: list[BaseTool],
|
||||
) -> list[BaseTool]:
|
||||
"""Merge new tools into existing tools list, avoiding duplicates."""
|
||||
if not new_tools:
|
||||
return cast(list[BaseTool], existing_tools)
|
||||
return existing_tools
|
||||
|
||||
# Create mapping of tool names to new tools
|
||||
new_tool_map = {tool.name: tool for tool in new_tools}
|
||||
@@ -1027,63 +1031,62 @@ class Crew(FlowTrackable, BaseModel):
|
||||
# Add all new tools
|
||||
tools.extend(new_tools)
|
||||
|
||||
return cast(list[BaseTool], tools)
|
||||
return tools
|
||||
|
||||
def _inject_delegation_tools(
|
||||
self,
|
||||
tools: list[Tool] | list[BaseTool],
|
||||
tools: list[BaseTool],
|
||||
task_agent: BaseAgent,
|
||||
agents: list[BaseAgent],
|
||||
) -> list[BaseTool]:
|
||||
if hasattr(task_agent, "get_delegation_tools"):
|
||||
delegation_tools = task_agent.get_delegation_tools(agents)
|
||||
# Cast delegation_tools to the expected type for _merge_tools
|
||||
return self._merge_tools(tools, cast(list[BaseTool], delegation_tools))
|
||||
return cast(list[BaseTool], tools)
|
||||
return self._merge_tools(tools, delegation_tools)
|
||||
return tools
|
||||
|
||||
def _inject_platform_tools(
|
||||
self,
|
||||
tools: list[Tool] | list[BaseTool],
|
||||
tools: list[BaseTool],
|
||||
task_agent: BaseAgent,
|
||||
) -> list[BaseTool]:
|
||||
apps = getattr(task_agent, "apps", None) or []
|
||||
|
||||
if hasattr(task_agent, "get_platform_tools") and apps:
|
||||
platform_tools = task_agent.get_platform_tools(apps=apps)
|
||||
return self._merge_tools(tools, cast(list[BaseTool], platform_tools))
|
||||
return cast(list[BaseTool], tools)
|
||||
return self._merge_tools(tools, platform_tools)
|
||||
return tools
|
||||
|
||||
def _inject_mcp_tools(
|
||||
self,
|
||||
tools: list[Tool] | list[BaseTool],
|
||||
tools: list[BaseTool],
|
||||
task_agent: BaseAgent,
|
||||
) -> list[BaseTool]:
|
||||
mcps = getattr(task_agent, "mcps", None) or []
|
||||
if hasattr(task_agent, "get_mcp_tools") and mcps:
|
||||
mcp_tools = task_agent.get_mcp_tools(mcps=mcps)
|
||||
return self._merge_tools(tools, cast(list[BaseTool], mcp_tools))
|
||||
return cast(list[BaseTool], tools)
|
||||
return self._merge_tools(tools, mcp_tools)
|
||||
return tools
|
||||
|
||||
def _add_multimodal_tools(
|
||||
self, agent: BaseAgent, tools: list[Tool] | list[BaseTool]
|
||||
self, agent: BaseAgent, tools: list[BaseTool]
|
||||
) -> list[BaseTool]:
|
||||
if hasattr(agent, "get_multimodal_tools"):
|
||||
multimodal_tools = agent.get_multimodal_tools()
|
||||
# Cast multimodal_tools to the expected type for _merge_tools
|
||||
return self._merge_tools(tools, cast(list[BaseTool], multimodal_tools))
|
||||
return cast(list[BaseTool], tools)
|
||||
return tools
|
||||
|
||||
def _add_code_execution_tools(
|
||||
self, agent: BaseAgent, tools: list[Tool] | list[BaseTool]
|
||||
self, agent: BaseAgent, tools: list[BaseTool]
|
||||
) -> list[BaseTool]:
|
||||
if hasattr(agent, "get_code_execution_tools"):
|
||||
code_tools = agent.get_code_execution_tools()
|
||||
# Cast code_tools to the expected type for _merge_tools
|
||||
return self._merge_tools(tools, cast(list[BaseTool], code_tools))
|
||||
return cast(list[BaseTool], tools)
|
||||
return tools
|
||||
|
||||
def _add_delegation_tools(
|
||||
self, task: Task, tools: list[Tool] | list[BaseTool]
|
||||
self, task: Task, tools: list[BaseTool]
|
||||
) -> list[BaseTool]:
|
||||
agents_for_delegation = [agent for agent in self.agents if agent != task.agent]
|
||||
if len(self.agents) > 1 and len(agents_for_delegation) > 0 and task.agent:
|
||||
@@ -1092,25 +1095,21 @@ class Crew(FlowTrackable, BaseModel):
|
||||
tools = self._inject_delegation_tools(
|
||||
tools, task.agent, agents_for_delegation
|
||||
)
|
||||
return cast(list[BaseTool], tools)
|
||||
return tools
|
||||
|
||||
def _add_platform_tools(
|
||||
self, task: Task, tools: list[Tool] | list[BaseTool]
|
||||
) -> list[BaseTool]:
|
||||
def _add_platform_tools(self, task: Task, tools: list[BaseTool]) -> list[BaseTool]:
|
||||
if task.agent:
|
||||
tools = self._inject_platform_tools(tools, task.agent)
|
||||
|
||||
return cast(list[BaseTool], tools or [])
|
||||
return tools or []
|
||||
|
||||
def _add_mcp_tools(
|
||||
self, task: Task, tools: list[Tool] | list[BaseTool]
|
||||
) -> list[BaseTool]:
|
||||
def _add_mcp_tools(self, task: Task, tools: list[BaseTool]) -> list[BaseTool]:
|
||||
if task.agent:
|
||||
tools = self._inject_mcp_tools(tools, task.agent)
|
||||
|
||||
return cast(list[BaseTool], tools or [])
|
||||
return tools or []
|
||||
|
||||
def _log_task_start(self, task: Task, role: str = "None"):
|
||||
def _log_task_start(self, task: Task, role: str = "None") -> None:
|
||||
if self.output_log_file:
|
||||
self._file_handler.log(
|
||||
task_name=task.name, # type: ignore[arg-type]
|
||||
@@ -1120,7 +1119,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
)
|
||||
|
||||
def _update_manager_tools(
|
||||
self, task: Task, tools: list[Tool] | list[BaseTool]
|
||||
self, task: Task, tools: list[BaseTool]
|
||||
) -> list[BaseTool]:
|
||||
if self.manager_agent:
|
||||
if task.agent:
|
||||
@@ -1129,7 +1128,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
tools = self._inject_delegation_tools(
|
||||
tools, self.manager_agent, self.agents
|
||||
)
|
||||
return cast(list[BaseTool], tools)
|
||||
return tools
|
||||
|
||||
def _get_context(self, task: Task, task_outputs: list[TaskOutput]) -> str:
|
||||
if not task.context:
|
||||
@@ -1173,6 +1172,10 @@ class Crew(FlowTrackable, BaseModel):
|
||||
total_tokens=self.token_usage.total_tokens,
|
||||
),
|
||||
)
|
||||
|
||||
# Finalization is handled by trace listener (always initialized)
|
||||
# The batch manager checks contextvar to determine if tracing is enabled
|
||||
|
||||
return CrewOutput(
|
||||
raw=final_task_output.raw,
|
||||
pydantic=final_task_output.pydantic,
|
||||
@@ -1239,6 +1242,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
pydantic=stored_output["pydantic"],
|
||||
json_dict=stored_output["json_dict"],
|
||||
output_format=stored_output["output_format"],
|
||||
messages=stored_output.get("messages", []),
|
||||
)
|
||||
self.tasks[i].output = task_output
|
||||
|
||||
@@ -1280,7 +1284,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
|
||||
return required_inputs
|
||||
|
||||
def copy(self):
|
||||
def copy(self) -> Crew: # type: ignore[override]
|
||||
"""
|
||||
Creates a deep copy of the Crew instance.
|
||||
|
||||
@@ -1311,7 +1315,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
manager_agent = self.manager_agent.copy() if self.manager_agent else None
|
||||
manager_llm = shallow_copy(self.manager_llm) if self.manager_llm else None
|
||||
|
||||
task_mapping = {}
|
||||
task_mapping: dict[str, Any] = {}
|
||||
|
||||
cloned_tasks = []
|
||||
existing_knowledge_sources = shallow_copy(self.knowledge_sources)
|
||||
@@ -1373,7 +1377,6 @@ class Crew(FlowTrackable, BaseModel):
|
||||
)
|
||||
for task in self.tasks
|
||||
]
|
||||
# type: ignore # "interpolate_inputs" of "Agent" does not return a value (it only ever returns None)
|
||||
for agent in self.agents:
|
||||
agent.interpolate_inputs(inputs)
|
||||
|
||||
@@ -1463,7 +1466,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
)
|
||||
raise
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"Crew(id={self.id}, process={self.process}, "
|
||||
f"number_of_agents={len(self.agents)}, "
|
||||
@@ -1520,7 +1523,9 @@ class Crew(FlowTrackable, BaseModel):
|
||||
if (system := config.get("system")) is not None:
|
||||
name = config.get("name")
|
||||
try:
|
||||
reset_fn: Callable = cast(Callable, config.get("reset"))
|
||||
reset_fn: Callable[[Any], Any] = cast(
|
||||
Callable[[Any], Any], config.get("reset")
|
||||
)
|
||||
reset_fn(system)
|
||||
self._logger.log(
|
||||
"info",
|
||||
@@ -1551,7 +1556,9 @@ class Crew(FlowTrackable, BaseModel):
|
||||
raise RuntimeError(f"{name} memory system is not initialized")
|
||||
|
||||
try:
|
||||
reset_fn: Callable = cast(Callable, config.get("reset"))
|
||||
reset_fn: Callable[[Any], Any] = cast(
|
||||
Callable[[Any], Any], config.get("reset")
|
||||
)
|
||||
reset_fn(system)
|
||||
self._logger.log(
|
||||
"info",
|
||||
@@ -1564,7 +1571,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
f"Failed to reset {name} memory: {e!s}"
|
||||
) from e
|
||||
|
||||
def _get_memory_systems(self):
|
||||
def _get_memory_systems(self) -> dict[str, Any]:
|
||||
"""Get all available memory systems with their configuration.
|
||||
|
||||
Returns:
|
||||
@@ -1572,10 +1579,10 @@ class Crew(FlowTrackable, BaseModel):
|
||||
display names.
|
||||
"""
|
||||
|
||||
def default_reset(memory):
|
||||
def default_reset(memory: Any) -> Any:
|
||||
return memory.reset()
|
||||
|
||||
def knowledge_reset(memory):
|
||||
def knowledge_reset(memory: Any) -> Any:
|
||||
return self.reset_knowledge(memory)
|
||||
|
||||
# Get knowledge for agents
|
||||
@@ -1635,7 +1642,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
for ks in knowledges:
|
||||
ks.reset()
|
||||
|
||||
def _set_allow_crewai_trigger_context_for_first_task(self):
|
||||
def _set_allow_crewai_trigger_context_for_first_task(self) -> None:
|
||||
crewai_trigger_payload = self._inputs and self._inputs.get(
|
||||
"crewai_trigger_payload"
|
||||
)
|
||||
@@ -1649,3 +1656,32 @@ class Crew(FlowTrackable, BaseModel):
|
||||
and able_to_inject
|
||||
):
|
||||
self.tasks[0].allow_crewai_trigger_context = True
|
||||
|
||||
def _show_tracing_disabled_message(self) -> None:
|
||||
"""Show a message when tracing is disabled."""
|
||||
from crewai.events.listeners.tracing.utils import has_user_declined_tracing
|
||||
|
||||
console = Console()
|
||||
|
||||
if has_user_declined_tracing():
|
||||
message = """Info: Tracing is disabled.
|
||||
|
||||
To enable tracing, do any one of these:
|
||||
• Set tracing=True in your Crew code
|
||||
• Set CREWAI_TRACING_ENABLED=true in your project's .env file
|
||||
• Run: crewai traces enable"""
|
||||
else:
|
||||
message = """Info: Tracing is disabled.
|
||||
|
||||
To enable tracing, do any one of these:
|
||||
• Set tracing=True in your Crew code
|
||||
• Set CREWAI_TRACING_ENABLED=true in your project's .env file
|
||||
• Run: crewai traces enable"""
|
||||
|
||||
panel = Panel(
|
||||
message,
|
||||
title="Tracing Status",
|
||||
border_style="blue",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
@@ -8,21 +8,14 @@ This module provides the event infrastructure that allows users to:
|
||||
- Declare handler dependencies for ordered execution
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crewai.events.base_event_listener import BaseEventListener
|
||||
from crewai.events.depends import Depends
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.handler_graph import CircularDependencyError
|
||||
from crewai.events.types.agent_events import (
|
||||
AgentEvaluationCompletedEvent,
|
||||
AgentEvaluationFailedEvent,
|
||||
AgentEvaluationStartedEvent,
|
||||
AgentExecutionCompletedEvent,
|
||||
AgentExecutionErrorEvent,
|
||||
AgentExecutionStartedEvent,
|
||||
LiteAgentExecutionCompletedEvent,
|
||||
LiteAgentExecutionErrorEvent,
|
||||
LiteAgentExecutionStartedEvent,
|
||||
)
|
||||
from crewai.events.types.crew_events import (
|
||||
CrewKickoffCompletedEvent,
|
||||
CrewKickoffFailedEvent,
|
||||
@@ -67,6 +60,14 @@ from crewai.events.types.logging_events import (
|
||||
AgentLogsExecutionEvent,
|
||||
AgentLogsStartedEvent,
|
||||
)
|
||||
from crewai.events.types.mcp_events import (
|
||||
MCPConnectionCompletedEvent,
|
||||
MCPConnectionFailedEvent,
|
||||
MCPConnectionStartedEvent,
|
||||
MCPToolExecutionCompletedEvent,
|
||||
MCPToolExecutionFailedEvent,
|
||||
MCPToolExecutionStartedEvent,
|
||||
)
|
||||
from crewai.events.types.memory_events import (
|
||||
MemoryQueryCompletedEvent,
|
||||
MemoryQueryFailedEvent,
|
||||
@@ -100,6 +101,20 @@ from crewai.events.types.tool_usage_events import (
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai.events.types.agent_events import (
|
||||
AgentEvaluationCompletedEvent,
|
||||
AgentEvaluationFailedEvent,
|
||||
AgentEvaluationStartedEvent,
|
||||
AgentExecutionCompletedEvent,
|
||||
AgentExecutionErrorEvent,
|
||||
AgentExecutionStartedEvent,
|
||||
LiteAgentExecutionCompletedEvent,
|
||||
LiteAgentExecutionErrorEvent,
|
||||
LiteAgentExecutionStartedEvent,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentEvaluationCompletedEvent",
|
||||
"AgentEvaluationFailedEvent",
|
||||
@@ -145,6 +160,12 @@ __all__ = [
|
||||
"LiteAgentExecutionCompletedEvent",
|
||||
"LiteAgentExecutionErrorEvent",
|
||||
"LiteAgentExecutionStartedEvent",
|
||||
"MCPConnectionCompletedEvent",
|
||||
"MCPConnectionFailedEvent",
|
||||
"MCPConnectionStartedEvent",
|
||||
"MCPToolExecutionCompletedEvent",
|
||||
"MCPToolExecutionFailedEvent",
|
||||
"MCPToolExecutionStartedEvent",
|
||||
"MemoryQueryCompletedEvent",
|
||||
"MemoryQueryFailedEvent",
|
||||
"MemoryQueryStartedEvent",
|
||||
@@ -170,3 +191,27 @@ __all__ = [
|
||||
"ToolValidateInputErrorEvent",
|
||||
"crewai_event_bus",
|
||||
]
|
||||
|
||||
_AGENT_EVENT_MAPPING = {
|
||||
"AgentEvaluationCompletedEvent": "crewai.events.types.agent_events",
|
||||
"AgentEvaluationFailedEvent": "crewai.events.types.agent_events",
|
||||
"AgentEvaluationStartedEvent": "crewai.events.types.agent_events",
|
||||
"AgentExecutionCompletedEvent": "crewai.events.types.agent_events",
|
||||
"AgentExecutionErrorEvent": "crewai.events.types.agent_events",
|
||||
"AgentExecutionStartedEvent": "crewai.events.types.agent_events",
|
||||
"LiteAgentExecutionCompletedEvent": "crewai.events.types.agent_events",
|
||||
"LiteAgentExecutionErrorEvent": "crewai.events.types.agent_events",
|
||||
"LiteAgentExecutionStartedEvent": "crewai.events.types.agent_events",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import for agent events to avoid circular imports."""
|
||||
if name in _AGENT_EVENT_MAPPING:
|
||||
import importlib
|
||||
|
||||
module_path = _AGENT_EVENT_MAPPING[name]
|
||||
module = importlib.import_module(module_path)
|
||||
return getattr(module, name)
|
||||
msg = f"module {__name__!r} has no attribute {name!r}"
|
||||
raise AttributeError(msg)
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
"""Base event listener for CrewAI event system."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from crewai.events.event_bus import CrewAIEventsBus, crewai_event_bus
|
||||
|
||||
|
||||
class BaseEventListener(ABC):
|
||||
"""Abstract base class for event listeners."""
|
||||
|
||||
verbose: bool = False
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the event listener and register handlers."""
|
||||
super().__init__()
|
||||
self.setup_listeners(crewai_event_bus)
|
||||
crewai_event_bus.validate_dependencies()
|
||||
|
||||
@abstractmethod
|
||||
def setup_listeners(self, crewai_event_bus: CrewAIEventsBus):
|
||||
def setup_listeners(self, crewai_event_bus: CrewAIEventsBus) -> None:
|
||||
"""Setup event listeners on the event bus.
|
||||
|
||||
Args:
|
||||
crewai_event_bus: The event bus to register listeners on.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -10,6 +10,7 @@ import atexit
|
||||
from collections.abc import Callable, Generator
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
import contextvars
|
||||
import threading
|
||||
from typing import Any, Final, ParamSpec, TypeVar
|
||||
|
||||
@@ -288,8 +289,9 @@ class CrewAIEventsBus:
|
||||
if event_type is LLMStreamChunkEvent:
|
||||
self._call_handlers(source, event, level_sync)
|
||||
else:
|
||||
ctx = contextvars.copy_context()
|
||||
future = self._sync_executor.submit(
|
||||
self._call_handlers, source, event, level_sync
|
||||
ctx.run, self._call_handlers, source, event, level_sync
|
||||
)
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
None, future.result
|
||||
@@ -346,8 +348,9 @@ class CrewAIEventsBus:
|
||||
if event_type is LLMStreamChunkEvent:
|
||||
self._call_handlers(source, event, sync_handlers)
|
||||
else:
|
||||
ctx = contextvars.copy_context()
|
||||
sync_future = self._sync_executor.submit(
|
||||
self._call_handlers, source, event, sync_handlers
|
||||
ctx.run, self._call_handlers, source, event, sync_handlers
|
||||
)
|
||||
if not async_handlers:
|
||||
return sync_future
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import Field, PrivateAttr
|
||||
|
||||
from crewai.events.base_event_listener import BaseEventListener
|
||||
from crewai.events.listeners.memory_listener import MemoryListener
|
||||
from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener
|
||||
from crewai.events.types.a2a_events import (
|
||||
A2AConversationCompletedEvent,
|
||||
A2AConversationStartedEvent,
|
||||
A2ADelegationCompletedEvent,
|
||||
A2ADelegationStartedEvent,
|
||||
A2AMessageSentEvent,
|
||||
A2AResponseReceivedEvent,
|
||||
)
|
||||
from crewai.events.types.agent_events import (
|
||||
AgentExecutionCompletedEvent,
|
||||
AgentExecutionStartedEvent,
|
||||
@@ -56,6 +65,14 @@ from crewai.events.types.logging_events import (
|
||||
AgentLogsExecutionEvent,
|
||||
AgentLogsStartedEvent,
|
||||
)
|
||||
from crewai.events.types.mcp_events import (
|
||||
MCPConnectionCompletedEvent,
|
||||
MCPConnectionFailedEvent,
|
||||
MCPConnectionStartedEvent,
|
||||
MCPToolExecutionCompletedEvent,
|
||||
MCPToolExecutionFailedEvent,
|
||||
MCPToolExecutionStartedEvent,
|
||||
)
|
||||
from crewai.events.types.reasoning_events import (
|
||||
AgentReasoningCompletedEvent,
|
||||
AgentReasoningFailedEvent,
|
||||
@@ -79,6 +96,10 @@ from crewai.utilities import Logger
|
||||
from crewai.utilities.constants import EMITTER_COLOR
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai.events.event_bus import CrewAIEventsBus
|
||||
|
||||
|
||||
class EventListener(BaseEventListener):
|
||||
_instance = None
|
||||
_telemetry: Telemetry = PrivateAttr(default_factory=lambda: Telemetry())
|
||||
@@ -88,6 +109,7 @@ class EventListener(BaseEventListener):
|
||||
text_stream = StringIO()
|
||||
knowledge_retrieval_in_progress = False
|
||||
knowledge_query_in_progress = False
|
||||
method_branches: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
@@ -101,21 +123,27 @@ class EventListener(BaseEventListener):
|
||||
self._telemetry = Telemetry()
|
||||
self._telemetry.set_tracer()
|
||||
self.execution_spans = {}
|
||||
self.method_branches = {}
|
||||
self._initialized = True
|
||||
self.formatter = ConsoleFormatter(verbose=True)
|
||||
self._crew_tree_lock = threading.Condition()
|
||||
|
||||
MemoryListener(formatter=self.formatter)
|
||||
# Initialize trace listener with formatter for memory event handling
|
||||
trace_listener = TraceCollectionListener()
|
||||
trace_listener.formatter = self.formatter
|
||||
|
||||
# ----------- CREW EVENTS -----------
|
||||
|
||||
def setup_listeners(self, crewai_event_bus):
|
||||
def setup_listeners(self, crewai_event_bus: CrewAIEventsBus) -> None:
|
||||
@crewai_event_bus.on(CrewKickoffStartedEvent)
|
||||
def on_crew_started(source, event: CrewKickoffStartedEvent):
|
||||
self.formatter.create_crew_tree(event.crew_name or "Crew", source.id)
|
||||
self._telemetry.crew_execution_span(source, event.inputs)
|
||||
def on_crew_started(source, event: CrewKickoffStartedEvent) -> None:
|
||||
with self._crew_tree_lock:
|
||||
self.formatter.create_crew_tree(event.crew_name or "Crew", source.id)
|
||||
self._telemetry.crew_execution_span(source, event.inputs)
|
||||
self._crew_tree_lock.notify_all()
|
||||
|
||||
@crewai_event_bus.on(CrewKickoffCompletedEvent)
|
||||
def on_crew_completed(source, event: CrewKickoffCompletedEvent):
|
||||
def on_crew_completed(source, event: CrewKickoffCompletedEvent) -> None:
|
||||
# Handle telemetry
|
||||
final_string_output = event.output.raw
|
||||
self._telemetry.end_crew(source, final_string_output)
|
||||
@@ -129,7 +157,7 @@ class EventListener(BaseEventListener):
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(CrewKickoffFailedEvent)
|
||||
def on_crew_failed(source, event: CrewKickoffFailedEvent):
|
||||
def on_crew_failed(source, event: CrewKickoffFailedEvent) -> None:
|
||||
self.formatter.update_crew_tree(
|
||||
self.formatter.current_crew_tree,
|
||||
event.crew_name or "Crew",
|
||||
@@ -138,23 +166,23 @@ class EventListener(BaseEventListener):
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(CrewTrainStartedEvent)
|
||||
def on_crew_train_started(source, event: CrewTrainStartedEvent):
|
||||
def on_crew_train_started(source, event: CrewTrainStartedEvent) -> None:
|
||||
self.formatter.handle_crew_train_started(
|
||||
event.crew_name or "Crew", str(event.timestamp)
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(CrewTrainCompletedEvent)
|
||||
def on_crew_train_completed(source, event: CrewTrainCompletedEvent):
|
||||
def on_crew_train_completed(source, event: CrewTrainCompletedEvent) -> None:
|
||||
self.formatter.handle_crew_train_completed(
|
||||
event.crew_name or "Crew", str(event.timestamp)
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(CrewTrainFailedEvent)
|
||||
def on_crew_train_failed(source, event: CrewTrainFailedEvent):
|
||||
def on_crew_train_failed(source, event: CrewTrainFailedEvent) -> None:
|
||||
self.formatter.handle_crew_train_failed(event.crew_name or "Crew")
|
||||
|
||||
@crewai_event_bus.on(CrewTestResultEvent)
|
||||
def on_crew_test_result(source, event: CrewTestResultEvent):
|
||||
def on_crew_test_result(source, event: CrewTestResultEvent) -> None:
|
||||
self._telemetry.individual_test_result_span(
|
||||
source.crew,
|
||||
event.quality,
|
||||
@@ -165,14 +193,22 @@ class EventListener(BaseEventListener):
|
||||
# ----------- TASK EVENTS -----------
|
||||
|
||||
@crewai_event_bus.on(TaskStartedEvent)
|
||||
def on_task_started(source, event: TaskStartedEvent):
|
||||
def on_task_started(source, event: TaskStartedEvent) -> None:
|
||||
span = self._telemetry.task_started(crew=source.agent.crew, task=source)
|
||||
self.execution_spans[source] = span
|
||||
# Pass both task ID and task name (if set)
|
||||
task_name = source.name if hasattr(source, "name") and source.name else None
|
||||
self.formatter.create_task_branch(
|
||||
self.formatter.current_crew_tree, source.id, task_name
|
||||
)
|
||||
|
||||
with self._crew_tree_lock:
|
||||
self._crew_tree_lock.wait_for(
|
||||
lambda: self.formatter.current_crew_tree is not None, timeout=5.0
|
||||
)
|
||||
|
||||
if self.formatter.current_crew_tree is not None:
|
||||
task_name = (
|
||||
source.name if hasattr(source, "name") and source.name else None
|
||||
)
|
||||
self.formatter.create_task_branch(
|
||||
self.formatter.current_crew_tree, source.id, task_name
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(TaskCompletedEvent)
|
||||
def on_task_completed(source, event: TaskCompletedEvent):
|
||||
@@ -263,7 +299,8 @@ class EventListener(BaseEventListener):
|
||||
@crewai_event_bus.on(FlowCreatedEvent)
|
||||
def on_flow_created(source, event: FlowCreatedEvent):
|
||||
self._telemetry.flow_creation_span(event.flow_name)
|
||||
self.formatter.create_flow_tree(event.flow_name, str(source.flow_id))
|
||||
tree = self.formatter.create_flow_tree(event.flow_name, str(source.flow_id))
|
||||
self.formatter.current_flow_tree = tree
|
||||
|
||||
@crewai_event_bus.on(FlowStartedEvent)
|
||||
def on_flow_started(source, event: FlowStartedEvent):
|
||||
@@ -280,30 +317,36 @@ class EventListener(BaseEventListener):
|
||||
|
||||
@crewai_event_bus.on(MethodExecutionStartedEvent)
|
||||
def on_method_execution_started(source, event: MethodExecutionStartedEvent):
|
||||
self.formatter.update_method_status(
|
||||
self.formatter.current_method_branch,
|
||||
method_branch = self.method_branches.get(event.method_name)
|
||||
updated_branch = self.formatter.update_method_status(
|
||||
method_branch,
|
||||
self.formatter.current_flow_tree,
|
||||
event.method_name,
|
||||
"running",
|
||||
)
|
||||
self.method_branches[event.method_name] = updated_branch
|
||||
|
||||
@crewai_event_bus.on(MethodExecutionFinishedEvent)
|
||||
def on_method_execution_finished(source, event: MethodExecutionFinishedEvent):
|
||||
self.formatter.update_method_status(
|
||||
self.formatter.current_method_branch,
|
||||
method_branch = self.method_branches.get(event.method_name)
|
||||
updated_branch = self.formatter.update_method_status(
|
||||
method_branch,
|
||||
self.formatter.current_flow_tree,
|
||||
event.method_name,
|
||||
"completed",
|
||||
)
|
||||
self.method_branches[event.method_name] = updated_branch
|
||||
|
||||
@crewai_event_bus.on(MethodExecutionFailedEvent)
|
||||
def on_method_execution_failed(source, event: MethodExecutionFailedEvent):
|
||||
self.formatter.update_method_status(
|
||||
self.formatter.current_method_branch,
|
||||
method_branch = self.method_branches.get(event.method_name)
|
||||
updated_branch = self.formatter.update_method_status(
|
||||
method_branch,
|
||||
self.formatter.current_flow_tree,
|
||||
event.method_name,
|
||||
"failed",
|
||||
)
|
||||
self.method_branches[event.method_name] = updated_branch
|
||||
|
||||
# ----------- TOOL USAGE EVENTS -----------
|
||||
|
||||
@@ -524,5 +567,123 @@ class EventListener(BaseEventListener):
|
||||
event.verbose,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(A2ADelegationStartedEvent)
|
||||
def on_a2a_delegation_started(source, event: A2ADelegationStartedEvent):
|
||||
self.formatter.handle_a2a_delegation_started(
|
||||
event.endpoint,
|
||||
event.task_description,
|
||||
event.agent_id,
|
||||
event.is_multiturn,
|
||||
event.turn_number,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(A2ADelegationCompletedEvent)
|
||||
def on_a2a_delegation_completed(source, event: A2ADelegationCompletedEvent):
|
||||
self.formatter.handle_a2a_delegation_completed(
|
||||
event.status,
|
||||
event.result,
|
||||
event.error,
|
||||
event.is_multiturn,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(A2AConversationStartedEvent)
|
||||
def on_a2a_conversation_started(source, event: A2AConversationStartedEvent):
|
||||
# Store A2A agent name for display in conversation tree
|
||||
if event.a2a_agent_name:
|
||||
self.formatter._current_a2a_agent_name = event.a2a_agent_name
|
||||
|
||||
self.formatter.handle_a2a_conversation_started(
|
||||
event.agent_id,
|
||||
event.endpoint,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(A2AMessageSentEvent)
|
||||
def on_a2a_message_sent(source, event: A2AMessageSentEvent):
|
||||
self.formatter.handle_a2a_message_sent(
|
||||
event.message,
|
||||
event.turn_number,
|
||||
event.agent_role,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(A2AResponseReceivedEvent)
|
||||
def on_a2a_response_received(source, event: A2AResponseReceivedEvent):
|
||||
self.formatter.handle_a2a_response_received(
|
||||
event.response,
|
||||
event.turn_number,
|
||||
event.status,
|
||||
event.agent_role,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(A2AConversationCompletedEvent)
|
||||
def on_a2a_conversation_completed(source, event: A2AConversationCompletedEvent):
|
||||
self.formatter.handle_a2a_conversation_completed(
|
||||
event.status,
|
||||
event.final_result,
|
||||
event.error,
|
||||
event.total_turns,
|
||||
)
|
||||
|
||||
# ----------- MCP EVENTS -----------
|
||||
|
||||
@crewai_event_bus.on(MCPConnectionStartedEvent)
|
||||
def on_mcp_connection_started(source, event: MCPConnectionStartedEvent):
|
||||
self.formatter.handle_mcp_connection_started(
|
||||
event.server_name,
|
||||
event.server_url,
|
||||
event.transport_type,
|
||||
event.is_reconnect,
|
||||
event.connect_timeout,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MCPConnectionCompletedEvent)
|
||||
def on_mcp_connection_completed(source, event: MCPConnectionCompletedEvent):
|
||||
self.formatter.handle_mcp_connection_completed(
|
||||
event.server_name,
|
||||
event.server_url,
|
||||
event.transport_type,
|
||||
event.connection_duration_ms,
|
||||
event.is_reconnect,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MCPConnectionFailedEvent)
|
||||
def on_mcp_connection_failed(source, event: MCPConnectionFailedEvent):
|
||||
self.formatter.handle_mcp_connection_failed(
|
||||
event.server_name,
|
||||
event.server_url,
|
||||
event.transport_type,
|
||||
event.error,
|
||||
event.error_type,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MCPToolExecutionStartedEvent)
|
||||
def on_mcp_tool_execution_started(source, event: MCPToolExecutionStartedEvent):
|
||||
self.formatter.handle_mcp_tool_execution_started(
|
||||
event.server_name,
|
||||
event.tool_name,
|
||||
event.tool_args,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MCPToolExecutionCompletedEvent)
|
||||
def on_mcp_tool_execution_completed(
|
||||
source, event: MCPToolExecutionCompletedEvent
|
||||
):
|
||||
self.formatter.handle_mcp_tool_execution_completed(
|
||||
event.server_name,
|
||||
event.tool_name,
|
||||
event.tool_args,
|
||||
event.result,
|
||||
event.execution_duration_ms,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MCPToolExecutionFailedEvent)
|
||||
def on_mcp_tool_execution_failed(source, event: MCPToolExecutionFailedEvent):
|
||||
self.formatter.handle_mcp_tool_execution_failed(
|
||||
event.server_name,
|
||||
event.tool_name,
|
||||
event.tool_args,
|
||||
event.error,
|
||||
event.error_type,
|
||||
)
|
||||
|
||||
|
||||
event_listener = EventListener()
|
||||
|
||||
@@ -40,6 +40,14 @@ from crewai.events.types.llm_guardrail_events import (
|
||||
LLMGuardrailCompletedEvent,
|
||||
LLMGuardrailStartedEvent,
|
||||
)
|
||||
from crewai.events.types.mcp_events import (
|
||||
MCPConnectionCompletedEvent,
|
||||
MCPConnectionFailedEvent,
|
||||
MCPConnectionStartedEvent,
|
||||
MCPToolExecutionCompletedEvent,
|
||||
MCPToolExecutionFailedEvent,
|
||||
MCPToolExecutionStartedEvent,
|
||||
)
|
||||
from crewai.events.types.memory_events import (
|
||||
MemoryQueryCompletedEvent,
|
||||
MemoryQueryFailedEvent,
|
||||
@@ -115,4 +123,10 @@ EventTypes = (
|
||||
| MemoryQueryFailedEvent
|
||||
| MemoryRetrievalStartedEvent
|
||||
| MemoryRetrievalCompletedEvent
|
||||
| MCPConnectionStartedEvent
|
||||
| MCPConnectionCompletedEvent
|
||||
| MCPConnectionFailedEvent
|
||||
| MCPToolExecutionStartedEvent
|
||||
| MCPToolExecutionCompletedEvent
|
||||
| MCPToolExecutionFailedEvent
|
||||
)
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
from crewai.events.base_event_listener import BaseEventListener
|
||||
from crewai.events.types.memory_events import (
|
||||
MemoryQueryCompletedEvent,
|
||||
MemoryQueryFailedEvent,
|
||||
MemoryRetrievalCompletedEvent,
|
||||
MemoryRetrievalStartedEvent,
|
||||
MemorySaveCompletedEvent,
|
||||
MemorySaveFailedEvent,
|
||||
MemorySaveStartedEvent,
|
||||
)
|
||||
|
||||
|
||||
class MemoryListener(BaseEventListener):
|
||||
def __init__(self, formatter):
|
||||
super().__init__()
|
||||
self.formatter = formatter
|
||||
self.memory_retrieval_in_progress = False
|
||||
self.memory_save_in_progress = False
|
||||
|
||||
def setup_listeners(self, crewai_event_bus):
|
||||
@crewai_event_bus.on(MemoryRetrievalStartedEvent)
|
||||
def on_memory_retrieval_started(source, event: MemoryRetrievalStartedEvent):
|
||||
if self.memory_retrieval_in_progress:
|
||||
return
|
||||
|
||||
self.memory_retrieval_in_progress = True
|
||||
|
||||
self.formatter.handle_memory_retrieval_started(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MemoryRetrievalCompletedEvent)
|
||||
def on_memory_retrieval_completed(source, event: MemoryRetrievalCompletedEvent):
|
||||
if not self.memory_retrieval_in_progress:
|
||||
return
|
||||
|
||||
self.memory_retrieval_in_progress = False
|
||||
self.formatter.handle_memory_retrieval_completed(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
event.memory_content,
|
||||
event.retrieval_time_ms,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MemoryQueryCompletedEvent)
|
||||
def on_memory_query_completed(source, event: MemoryQueryCompletedEvent):
|
||||
if not self.memory_retrieval_in_progress:
|
||||
return
|
||||
|
||||
self.formatter.handle_memory_query_completed(
|
||||
self.formatter.current_agent_branch,
|
||||
event.source_type,
|
||||
event.query_time_ms,
|
||||
self.formatter.current_crew_tree,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MemoryQueryFailedEvent)
|
||||
def on_memory_query_failed(source, event: MemoryQueryFailedEvent):
|
||||
if not self.memory_retrieval_in_progress:
|
||||
return
|
||||
|
||||
self.formatter.handle_memory_query_failed(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
event.error,
|
||||
event.source_type,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MemorySaveStartedEvent)
|
||||
def on_memory_save_started(source, event: MemorySaveStartedEvent):
|
||||
if self.memory_save_in_progress:
|
||||
return
|
||||
|
||||
self.memory_save_in_progress = True
|
||||
|
||||
self.formatter.handle_memory_save_started(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MemorySaveCompletedEvent)
|
||||
def on_memory_save_completed(source, event: MemorySaveCompletedEvent):
|
||||
if not self.memory_save_in_progress:
|
||||
return
|
||||
|
||||
self.memory_save_in_progress = False
|
||||
|
||||
self.formatter.handle_memory_save_completed(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
event.save_time_ms,
|
||||
event.source_type,
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MemorySaveFailedEvent)
|
||||
def on_memory_save_failed(source, event: MemorySaveFailedEvent):
|
||||
if not self.memory_save_in_progress:
|
||||
return
|
||||
|
||||
self.formatter.handle_memory_save_failed(
|
||||
self.formatter.current_agent_branch,
|
||||
event.error,
|
||||
event.source_type,
|
||||
self.formatter.current_crew_tree,
|
||||
)
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import webbrowser
|
||||
|
||||
@@ -17,47 +16,6 @@ from crewai.events.listeners.tracing.utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _update_or_create_env_file():
|
||||
"""Update or create .env file with CREWAI_TRACING_ENABLED=true."""
|
||||
env_path = Path(".env")
|
||||
env_content = ""
|
||||
variable_name = "CREWAI_TRACING_ENABLED"
|
||||
variable_value = "true"
|
||||
|
||||
# Read existing content if file exists
|
||||
if env_path.exists():
|
||||
with open(env_path, "r") as f:
|
||||
env_content = f.read()
|
||||
|
||||
# Check if CREWAI_TRACING_ENABLED is already set
|
||||
lines = env_content.splitlines()
|
||||
variable_exists = False
|
||||
updated_lines = []
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith(f"{variable_name}="):
|
||||
# Update existing variable
|
||||
updated_lines.append(f"{variable_name}={variable_value}")
|
||||
variable_exists = True
|
||||
else:
|
||||
updated_lines.append(line)
|
||||
|
||||
# Add variable if it doesn't exist
|
||||
if not variable_exists:
|
||||
if updated_lines and not updated_lines[-1].strip():
|
||||
# If last line is empty, replace it
|
||||
updated_lines[-1] = f"{variable_name}={variable_value}"
|
||||
else:
|
||||
# Add new line and then the variable
|
||||
updated_lines.append(f"{variable_name}={variable_value}")
|
||||
|
||||
# Write updated content
|
||||
with open(env_path, "w") as f:
|
||||
f.write("\n".join(updated_lines))
|
||||
if updated_lines: # Add final newline if there's content
|
||||
f.write("\n")
|
||||
|
||||
|
||||
class FirstTimeTraceHandler:
|
||||
"""Handles the first-time user trace collection and display flow."""
|
||||
|
||||
@@ -73,15 +31,19 @@ class FirstTimeTraceHandler:
|
||||
self.is_first_time = should_auto_collect_first_time_traces()
|
||||
return self.is_first_time
|
||||
|
||||
def set_batch_manager(self, batch_manager: TraceBatchManager):
|
||||
"""Set reference to batch manager for sending events."""
|
||||
def set_batch_manager(self, batch_manager: TraceBatchManager) -> None:
|
||||
"""Set reference to batch manager for sending events.
|
||||
|
||||
Args:
|
||||
batch_manager: The trace batch manager instance.
|
||||
"""
|
||||
self.batch_manager = batch_manager
|
||||
|
||||
def mark_events_collected(self):
|
||||
def mark_events_collected(self) -> None:
|
||||
"""Mark that events have been collected during execution."""
|
||||
self.collected_events = True
|
||||
|
||||
def handle_execution_completion(self):
|
||||
def handle_execution_completion(self) -> None:
|
||||
"""Handle the completion flow as shown in your diagram."""
|
||||
if not self.is_first_time or not self.collected_events:
|
||||
return
|
||||
@@ -92,20 +54,16 @@ class FirstTimeTraceHandler:
|
||||
if user_wants_traces:
|
||||
self._initialize_backend_and_send_events()
|
||||
|
||||
# Enable tracing for future runs by updating .env file
|
||||
try:
|
||||
_update_or_create_env_file()
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
if self.ephemeral_url:
|
||||
self._display_ephemeral_trace_link()
|
||||
else:
|
||||
self._show_tracing_declined_message()
|
||||
|
||||
mark_first_execution_completed()
|
||||
mark_first_execution_completed(user_consented=user_wants_traces)
|
||||
|
||||
except Exception as e:
|
||||
self._gracefully_fail(f"Error in trace handling: {e}")
|
||||
mark_first_execution_completed()
|
||||
mark_first_execution_completed(user_consented=False)
|
||||
|
||||
def _initialize_backend_and_send_events(self):
|
||||
"""Initialize backend batch and send collected events."""
|
||||
@@ -178,8 +136,13 @@ This trace shows:
|
||||
• Tool usage and results
|
||||
• LLM calls and responses
|
||||
|
||||
✅ Tracing has been enabled for future runs! (CREWAI_TRACING_ENABLED=true added to .env)
|
||||
You can also add tracing=True to your Crew(tracing=True) / Flow(tracing=True) for more control.
|
||||
✅ Tracing has been enabled for future runs!
|
||||
Your preference has been saved. Future Crew/Flow executions will automatically collect traces.
|
||||
|
||||
To disable tracing later, do any one of these:
|
||||
• Set tracing=False in your Crew/Flow code
|
||||
• Set CREWAI_TRACING_ENABLED=false in your project's .env file
|
||||
• Run: crewai traces disable
|
||||
|
||||
📝 Note: This link will expire in 24 hours.
|
||||
""".strip()
|
||||
@@ -195,6 +158,32 @@ You can also add tracing=True to your Crew(tracing=True) / Flow(tracing=True) fo
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
def _show_tracing_declined_message(self):
|
||||
"""Show message when user declines tracing."""
|
||||
console = Console()
|
||||
|
||||
panel_content = """
|
||||
Info: Tracing has been disabled.
|
||||
|
||||
Your preference has been saved. Future Crew/Flow executions will not collect traces.
|
||||
|
||||
To enable tracing later, do any one of these:
|
||||
• Set tracing=True in your Crew/Flow code
|
||||
• Set CREWAI_TRACING_ENABLED=true in your project's .env file
|
||||
• Run: crewai traces enable
|
||||
""".strip()
|
||||
|
||||
panel = Panel(
|
||||
panel_content,
|
||||
title="Tracing Preference Saved",
|
||||
border_style="blue",
|
||||
padding=(1, 2),
|
||||
)
|
||||
|
||||
console.print("\n")
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
def _gracefully_fail(self, error_message: str):
|
||||
"""Handle errors gracefully without disrupting user experience."""
|
||||
console = Console()
|
||||
@@ -214,8 +203,14 @@ Unfortunately, we couldn't upload them to the server right now, but here's what
|
||||
• Execution duration: {self.batch_manager.calculate_duration("execution")}ms
|
||||
• Batch ID: {self.batch_manager.trace_batch_id}
|
||||
|
||||
Tracing has been enabled for future runs! (CREWAI_TRACING_ENABLED=true added to .env)
|
||||
✅ Tracing has been enabled for future runs!
|
||||
Your preference has been saved. Future Crew/Flow executions will automatically collect traces.
|
||||
The traces include agent decisions, task execution, and tool usage.
|
||||
|
||||
To disable tracing later, do any one of these:
|
||||
• Set tracing=False in your Crew/Flow code
|
||||
• Set CREWAI_TRACING_ENABLED=false in your project's .env file
|
||||
• Run: crewai traces disable
|
||||
""".strip()
|
||||
|
||||
panel = Panel(
|
||||
|
||||
@@ -12,7 +12,10 @@ from crewai.cli.authentication.token import AuthError, get_auth_token
|
||||
from crewai.cli.plus_api import PlusAPI
|
||||
from crewai.cli.version import get_crewai_version
|
||||
from crewai.events.listeners.tracing.types import TraceEvent
|
||||
from crewai.events.listeners.tracing.utils import should_auto_collect_first_time_traces
|
||||
from crewai.events.listeners.tracing.utils import (
|
||||
is_tracing_enabled_in_context,
|
||||
should_auto_collect_first_time_traces,
|
||||
)
|
||||
from crewai.utilities.constants import CREWAI_BASE_URL
|
||||
|
||||
|
||||
@@ -44,6 +47,7 @@ class TraceBatchManager:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._init_lock = Lock()
|
||||
self._batch_ready_cv = Condition(self._init_lock)
|
||||
self._pending_events_lock = Lock()
|
||||
self._pending_events_cv = Condition(self._pending_events_lock)
|
||||
self._pending_events_count = 0
|
||||
@@ -94,6 +98,8 @@ class TraceBatchManager:
|
||||
)
|
||||
self.backend_initialized = True
|
||||
|
||||
self._batch_ready_cv.notify_all()
|
||||
|
||||
return self.current_batch
|
||||
|
||||
def _initialize_backend_batch(
|
||||
@@ -104,6 +110,9 @@ class TraceBatchManager:
|
||||
):
|
||||
"""Send batch initialization to backend"""
|
||||
|
||||
if not is_tracing_enabled_in_context():
|
||||
return
|
||||
|
||||
if not self.plus_api or not self.current_batch:
|
||||
return
|
||||
|
||||
@@ -161,13 +170,13 @@ class TraceBatchManager:
|
||||
f"Error initializing trace batch: {e}. Continuing without tracing."
|
||||
)
|
||||
|
||||
def begin_event_processing(self):
|
||||
"""Mark that an event handler started processing (for synchronization)"""
|
||||
def begin_event_processing(self) -> None:
|
||||
"""Mark that an event handler started processing (for synchronization)."""
|
||||
with self._pending_events_lock:
|
||||
self._pending_events_count += 1
|
||||
|
||||
def end_event_processing(self):
|
||||
"""Mark that an event handler finished processing (for synchronization)"""
|
||||
def end_event_processing(self) -> None:
|
||||
"""Mark that an event handler finished processing (for synchronization)."""
|
||||
with self._pending_events_cv:
|
||||
self._pending_events_count -= 1
|
||||
if self._pending_events_count == 0:
|
||||
@@ -240,7 +249,8 @@ class TraceBatchManager:
|
||||
|
||||
def finalize_batch(self) -> TraceBatch | None:
|
||||
"""Finalize batch and return it for sending"""
|
||||
if not self.current_batch:
|
||||
|
||||
if not self.current_batch or not is_tracing_enabled_in_context():
|
||||
return None
|
||||
|
||||
all_handlers_completed = self.wait_for_pending_events()
|
||||
@@ -385,6 +395,22 @@ class TraceBatchManager:
|
||||
"""Check if batch is initialized"""
|
||||
return self.current_batch is not None
|
||||
|
||||
def wait_for_batch_initialization(self, timeout: float = 2.0) -> bool:
|
||||
"""Wait for batch to be initialized.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait in seconds (default: 2.0)
|
||||
|
||||
Returns:
|
||||
True if batch was initialized, False if timeout occurred
|
||||
"""
|
||||
with self._batch_ready_cv:
|
||||
if self.current_batch is not None:
|
||||
return True
|
||||
return self._batch_ready_cv.wait_for(
|
||||
lambda: self.current_batch is not None, timeout=timeout
|
||||
)
|
||||
|
||||
def record_start_time(self, key: str):
|
||||
"""Record start time for duration calculation"""
|
||||
self.execution_start_times[key] = datetime.now(timezone.utc)
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
"""Trace collection listener for orchestrating trace collection."""
|
||||
|
||||
import os
|
||||
from typing import Any, ClassVar
|
||||
import uuid
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from crewai.cli.authentication.token import AuthError, get_auth_token
|
||||
from crewai.cli.version import get_crewai_version
|
||||
from crewai.events.base_event_listener import BaseEventListener
|
||||
from crewai.events.event_bus import CrewAIEventsBus
|
||||
from crewai.events.listeners.tracing.first_time_trace_handler import (
|
||||
FirstTimeTraceHandler,
|
||||
)
|
||||
from crewai.events.listeners.tracing.trace_batch_manager import TraceBatchManager
|
||||
from crewai.events.listeners.tracing.types import TraceEvent
|
||||
from crewai.events.listeners.tracing.utils import safe_serialize_to_dict
|
||||
from crewai.events.listeners.tracing.utils import (
|
||||
safe_serialize_to_dict,
|
||||
)
|
||||
from crewai.events.types.agent_events import (
|
||||
AgentExecutionCompletedEvent,
|
||||
AgentExecutionErrorEvent,
|
||||
@@ -53,6 +60,8 @@ from crewai.events.types.memory_events import (
|
||||
MemoryQueryCompletedEvent,
|
||||
MemoryQueryFailedEvent,
|
||||
MemoryQueryStartedEvent,
|
||||
MemoryRetrievalCompletedEvent,
|
||||
MemoryRetrievalStartedEvent,
|
||||
MemorySaveCompletedEvent,
|
||||
MemorySaveFailedEvent,
|
||||
MemorySaveStartedEvent,
|
||||
@@ -72,12 +81,11 @@ from crewai.events.types.tool_usage_events import (
|
||||
ToolUsageFinishedEvent,
|
||||
ToolUsageStartedEvent,
|
||||
)
|
||||
from crewai.events.utils.console_formatter import ConsoleFormatter
|
||||
|
||||
|
||||
class TraceCollectionListener(BaseEventListener):
|
||||
"""
|
||||
Trace collection listener that orchestrates trace collection
|
||||
"""
|
||||
"""Trace collection listener that orchestrates trace collection."""
|
||||
|
||||
complex_events: ClassVar[list[str]] = [
|
||||
"task_started",
|
||||
@@ -88,11 +96,12 @@ class TraceCollectionListener(BaseEventListener):
|
||||
"agent_execution_completed",
|
||||
]
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
_listeners_setup = False
|
||||
_instance: Self | None = None
|
||||
_initialized: bool = False
|
||||
_listeners_setup: bool = False
|
||||
|
||||
def __new__(cls, batch_manager: TraceBatchManager | None = None):
|
||||
def __new__(cls, batch_manager: TraceBatchManager | None = None) -> Self:
|
||||
"""Create or return singleton instance."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
@@ -100,7 +109,14 @@ class TraceCollectionListener(BaseEventListener):
|
||||
def __init__(
|
||||
self,
|
||||
batch_manager: TraceBatchManager | None = None,
|
||||
):
|
||||
formatter: ConsoleFormatter | None = None,
|
||||
) -> None:
|
||||
"""Initialize trace collection listener.
|
||||
|
||||
Args:
|
||||
batch_manager: Optional trace batch manager instance.
|
||||
formatter: Optional console formatter for output.
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
@@ -108,19 +124,22 @@ class TraceCollectionListener(BaseEventListener):
|
||||
self.batch_manager = batch_manager or TraceBatchManager()
|
||||
self._initialized = True
|
||||
self.first_time_handler = FirstTimeTraceHandler()
|
||||
self.formatter = formatter
|
||||
self.memory_retrieval_in_progress = False
|
||||
self.memory_save_in_progress = False
|
||||
|
||||
if self.first_time_handler.initialize_for_first_time_user():
|
||||
self.first_time_handler.set_batch_manager(self.batch_manager)
|
||||
|
||||
def _check_authenticated(self) -> bool:
|
||||
"""Check if tracing should be enabled"""
|
||||
"""Check if tracing should be enabled."""
|
||||
try:
|
||||
return bool(get_auth_token())
|
||||
except AuthError:
|
||||
return False
|
||||
|
||||
def _get_user_context(self) -> dict[str, str]:
|
||||
"""Extract user context for tracing"""
|
||||
"""Extract user context for tracing."""
|
||||
return {
|
||||
"user_id": os.getenv("CREWAI_USER_ID", "anonymous"),
|
||||
"organization_id": os.getenv("CREWAI_ORG_ID", ""),
|
||||
@@ -128,9 +147,12 @@ class TraceCollectionListener(BaseEventListener):
|
||||
"trace_id": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
def setup_listeners(self, crewai_event_bus):
|
||||
"""Setup event listeners - delegates to specific handlers"""
|
||||
def setup_listeners(self, crewai_event_bus: CrewAIEventsBus) -> None:
|
||||
"""Setup event listeners - delegates to specific handlers.
|
||||
|
||||
Args:
|
||||
crewai_event_bus: The event bus to register listeners on.
|
||||
"""
|
||||
if self._listeners_setup:
|
||||
return
|
||||
|
||||
@@ -140,50 +162,52 @@ class TraceCollectionListener(BaseEventListener):
|
||||
|
||||
self._listeners_setup = True
|
||||
|
||||
def _register_flow_event_handlers(self, event_bus):
|
||||
"""Register handlers for flow events"""
|
||||
def _register_flow_event_handlers(self, event_bus: CrewAIEventsBus) -> None:
|
||||
"""Register handlers for flow events."""
|
||||
|
||||
@event_bus.on(FlowCreatedEvent)
|
||||
def on_flow_created(source, event):
|
||||
def on_flow_created(source: Any, event: FlowCreatedEvent) -> None:
|
||||
pass
|
||||
|
||||
@event_bus.on(FlowStartedEvent)
|
||||
def on_flow_started(source, event):
|
||||
def on_flow_started(source: Any, event: FlowStartedEvent) -> None:
|
||||
if not self.batch_manager.is_batch_initialized():
|
||||
self._initialize_flow_batch(source, event)
|
||||
self._handle_trace_event("flow_started", source, event)
|
||||
|
||||
@event_bus.on(MethodExecutionStartedEvent)
|
||||
def on_method_started(source, event):
|
||||
def on_method_started(source: Any, event: MethodExecutionStartedEvent) -> None:
|
||||
self._handle_trace_event("method_execution_started", source, event)
|
||||
|
||||
@event_bus.on(MethodExecutionFinishedEvent)
|
||||
def on_method_finished(source, event):
|
||||
def on_method_finished(
|
||||
source: Any, event: MethodExecutionFinishedEvent
|
||||
) -> None:
|
||||
self._handle_trace_event("method_execution_finished", source, event)
|
||||
|
||||
@event_bus.on(MethodExecutionFailedEvent)
|
||||
def on_method_failed(source, event):
|
||||
def on_method_failed(source: Any, event: MethodExecutionFailedEvent) -> None:
|
||||
self._handle_trace_event("method_execution_failed", source, event)
|
||||
|
||||
@event_bus.on(FlowFinishedEvent)
|
||||
def on_flow_finished(source, event):
|
||||
def on_flow_finished(source: Any, event: FlowFinishedEvent) -> None:
|
||||
self._handle_trace_event("flow_finished", source, event)
|
||||
|
||||
@event_bus.on(FlowPlotEvent)
|
||||
def on_flow_plot(source, event):
|
||||
def on_flow_plot(source: Any, event: FlowPlotEvent) -> None:
|
||||
self._handle_action_event("flow_plot", source, event)
|
||||
|
||||
def _register_context_event_handlers(self, event_bus):
|
||||
"""Register handlers for context events (start/end)"""
|
||||
def _register_context_event_handlers(self, event_bus: CrewAIEventsBus) -> None:
|
||||
"""Register handlers for context events (start/end)."""
|
||||
|
||||
@event_bus.on(CrewKickoffStartedEvent)
|
||||
def on_crew_started(source, event):
|
||||
def on_crew_started(source: Any, event: CrewKickoffStartedEvent) -> None:
|
||||
if not self.batch_manager.is_batch_initialized():
|
||||
self._initialize_crew_batch(source, event)
|
||||
self._handle_trace_event("crew_kickoff_started", source, event)
|
||||
|
||||
@event_bus.on(CrewKickoffCompletedEvent)
|
||||
def on_crew_completed(source, event):
|
||||
def on_crew_completed(source: Any, event: CrewKickoffCompletedEvent) -> None:
|
||||
self._handle_trace_event("crew_kickoff_completed", source, event)
|
||||
if self.batch_manager.batch_owner_type == "crew":
|
||||
if self.first_time_handler.is_first_time:
|
||||
@@ -193,7 +217,7 @@ class TraceCollectionListener(BaseEventListener):
|
||||
self.batch_manager.finalize_batch()
|
||||
|
||||
@event_bus.on(CrewKickoffFailedEvent)
|
||||
def on_crew_failed(source, event):
|
||||
def on_crew_failed(source: Any, event: CrewKickoffFailedEvent) -> None:
|
||||
self._handle_trace_event("crew_kickoff_failed", source, event)
|
||||
if self.first_time_handler.is_first_time:
|
||||
self.first_time_handler.mark_events_collected()
|
||||
@@ -202,134 +226,245 @@ class TraceCollectionListener(BaseEventListener):
|
||||
self.batch_manager.finalize_batch()
|
||||
|
||||
@event_bus.on(TaskStartedEvent)
|
||||
def on_task_started(source, event):
|
||||
def on_task_started(source: Any, event: TaskStartedEvent) -> None:
|
||||
self._handle_trace_event("task_started", source, event)
|
||||
|
||||
@event_bus.on(TaskCompletedEvent)
|
||||
def on_task_completed(source, event):
|
||||
def on_task_completed(source: Any, event: TaskCompletedEvent) -> None:
|
||||
self._handle_trace_event("task_completed", source, event)
|
||||
|
||||
@event_bus.on(TaskFailedEvent)
|
||||
def on_task_failed(source, event):
|
||||
def on_task_failed(source: Any, event: TaskFailedEvent) -> None:
|
||||
self._handle_trace_event("task_failed", source, event)
|
||||
|
||||
@event_bus.on(AgentExecutionStartedEvent)
|
||||
def on_agent_started(source, event):
|
||||
def on_agent_started(source: Any, event: AgentExecutionStartedEvent) -> None:
|
||||
self._handle_trace_event("agent_execution_started", source, event)
|
||||
|
||||
@event_bus.on(AgentExecutionCompletedEvent)
|
||||
def on_agent_completed(source, event):
|
||||
def on_agent_completed(
|
||||
source: Any, event: AgentExecutionCompletedEvent
|
||||
) -> None:
|
||||
self._handle_trace_event("agent_execution_completed", source, event)
|
||||
|
||||
@event_bus.on(LiteAgentExecutionStartedEvent)
|
||||
def on_lite_agent_started(source, event):
|
||||
def on_lite_agent_started(
|
||||
source: Any, event: LiteAgentExecutionStartedEvent
|
||||
) -> None:
|
||||
self._handle_trace_event("lite_agent_execution_started", source, event)
|
||||
|
||||
@event_bus.on(LiteAgentExecutionCompletedEvent)
|
||||
def on_lite_agent_completed(source, event):
|
||||
def on_lite_agent_completed(
|
||||
source: Any, event: LiteAgentExecutionCompletedEvent
|
||||
) -> None:
|
||||
self._handle_trace_event("lite_agent_execution_completed", source, event)
|
||||
|
||||
@event_bus.on(LiteAgentExecutionErrorEvent)
|
||||
def on_lite_agent_error(source, event):
|
||||
def on_lite_agent_error(
|
||||
source: Any, event: LiteAgentExecutionErrorEvent
|
||||
) -> None:
|
||||
self._handle_trace_event("lite_agent_execution_error", source, event)
|
||||
|
||||
@event_bus.on(AgentExecutionErrorEvent)
|
||||
def on_agent_error(source, event):
|
||||
def on_agent_error(source: Any, event: AgentExecutionErrorEvent) -> None:
|
||||
self._handle_trace_event("agent_execution_error", source, event)
|
||||
|
||||
@event_bus.on(LLMGuardrailStartedEvent)
|
||||
def on_guardrail_started(source, event):
|
||||
def on_guardrail_started(source: Any, event: LLMGuardrailStartedEvent) -> None:
|
||||
self._handle_trace_event("llm_guardrail_started", source, event)
|
||||
|
||||
@event_bus.on(LLMGuardrailCompletedEvent)
|
||||
def on_guardrail_completed(source, event):
|
||||
def on_guardrail_completed(
|
||||
source: Any, event: LLMGuardrailCompletedEvent
|
||||
) -> None:
|
||||
self._handle_trace_event("llm_guardrail_completed", source, event)
|
||||
|
||||
def _register_action_event_handlers(self, event_bus):
|
||||
"""Register handlers for action events (LLM calls, tool usage)"""
|
||||
def _register_action_event_handlers(self, event_bus: CrewAIEventsBus) -> None:
|
||||
"""Register handlers for action events (LLM calls, tool usage)."""
|
||||
|
||||
@event_bus.on(LLMCallStartedEvent)
|
||||
def on_llm_call_started(source, event):
|
||||
def on_llm_call_started(source: Any, event: LLMCallStartedEvent) -> None:
|
||||
self._handle_action_event("llm_call_started", source, event)
|
||||
|
||||
@event_bus.on(LLMCallCompletedEvent)
|
||||
def on_llm_call_completed(source, event):
|
||||
def on_llm_call_completed(source: Any, event: LLMCallCompletedEvent) -> None:
|
||||
self._handle_action_event("llm_call_completed", source, event)
|
||||
|
||||
@event_bus.on(LLMCallFailedEvent)
|
||||
def on_llm_call_failed(source, event):
|
||||
def on_llm_call_failed(source: Any, event: LLMCallFailedEvent) -> None:
|
||||
self._handle_action_event("llm_call_failed", source, event)
|
||||
|
||||
@event_bus.on(ToolUsageStartedEvent)
|
||||
def on_tool_started(source, event):
|
||||
def on_tool_started(source: Any, event: ToolUsageStartedEvent) -> None:
|
||||
self._handle_action_event("tool_usage_started", source, event)
|
||||
|
||||
@event_bus.on(ToolUsageFinishedEvent)
|
||||
def on_tool_finished(source, event):
|
||||
def on_tool_finished(source: Any, event: ToolUsageFinishedEvent) -> None:
|
||||
self._handle_action_event("tool_usage_finished", source, event)
|
||||
|
||||
@event_bus.on(ToolUsageErrorEvent)
|
||||
def on_tool_error(source, event):
|
||||
def on_tool_error(source: Any, event: ToolUsageErrorEvent) -> None:
|
||||
self._handle_action_event("tool_usage_error", source, event)
|
||||
|
||||
@event_bus.on(MemoryQueryStartedEvent)
|
||||
def on_memory_query_started(source, event):
|
||||
def on_memory_query_started(
|
||||
source: Any, event: MemoryQueryStartedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("memory_query_started", source, event)
|
||||
|
||||
@event_bus.on(MemoryQueryCompletedEvent)
|
||||
def on_memory_query_completed(source, event):
|
||||
def on_memory_query_completed(
|
||||
source: Any, event: MemoryQueryCompletedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("memory_query_completed", source, event)
|
||||
if self.formatter and self.memory_retrieval_in_progress:
|
||||
self.formatter.handle_memory_query_completed(
|
||||
self.formatter.current_agent_branch,
|
||||
event.source_type or "memory",
|
||||
event.query_time_ms,
|
||||
self.formatter.current_crew_tree,
|
||||
)
|
||||
|
||||
@event_bus.on(MemoryQueryFailedEvent)
|
||||
def on_memory_query_failed(source, event):
|
||||
def on_memory_query_failed(source: Any, event: MemoryQueryFailedEvent) -> None:
|
||||
self._handle_action_event("memory_query_failed", source, event)
|
||||
if self.formatter and self.memory_retrieval_in_progress:
|
||||
self.formatter.handle_memory_query_failed(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
event.error,
|
||||
event.source_type or "memory",
|
||||
)
|
||||
|
||||
@event_bus.on(MemorySaveStartedEvent)
|
||||
def on_memory_save_started(source, event):
|
||||
def on_memory_save_started(source: Any, event: MemorySaveStartedEvent) -> None:
|
||||
self._handle_action_event("memory_save_started", source, event)
|
||||
if self.formatter:
|
||||
if self.memory_save_in_progress:
|
||||
return
|
||||
|
||||
self.memory_save_in_progress = True
|
||||
|
||||
self.formatter.handle_memory_save_started(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
)
|
||||
|
||||
@event_bus.on(MemorySaveCompletedEvent)
|
||||
def on_memory_save_completed(source, event):
|
||||
def on_memory_save_completed(
|
||||
source: Any, event: MemorySaveCompletedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("memory_save_completed", source, event)
|
||||
if self.formatter:
|
||||
if not self.memory_save_in_progress:
|
||||
return
|
||||
|
||||
self.memory_save_in_progress = False
|
||||
|
||||
self.formatter.handle_memory_save_completed(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
event.save_time_ms,
|
||||
event.source_type or "memory",
|
||||
)
|
||||
|
||||
@event_bus.on(MemorySaveFailedEvent)
|
||||
def on_memory_save_failed(source, event):
|
||||
def on_memory_save_failed(source: Any, event: MemorySaveFailedEvent) -> None:
|
||||
self._handle_action_event("memory_save_failed", source, event)
|
||||
if self.formatter and self.memory_save_in_progress:
|
||||
self.formatter.handle_memory_save_failed(
|
||||
self.formatter.current_agent_branch,
|
||||
event.error,
|
||||
event.source_type or "memory",
|
||||
self.formatter.current_crew_tree,
|
||||
)
|
||||
|
||||
@event_bus.on(MemoryRetrievalStartedEvent)
|
||||
def on_memory_retrieval_started(
|
||||
source: Any, event: MemoryRetrievalStartedEvent
|
||||
) -> None:
|
||||
if self.formatter:
|
||||
if self.memory_retrieval_in_progress:
|
||||
return
|
||||
|
||||
self.memory_retrieval_in_progress = True
|
||||
|
||||
self.formatter.handle_memory_retrieval_started(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
)
|
||||
|
||||
@event_bus.on(MemoryRetrievalCompletedEvent)
|
||||
def on_memory_retrieval_completed(
|
||||
source: Any, event: MemoryRetrievalCompletedEvent
|
||||
) -> None:
|
||||
if self.formatter:
|
||||
if not self.memory_retrieval_in_progress:
|
||||
return
|
||||
|
||||
self.memory_retrieval_in_progress = False
|
||||
self.formatter.handle_memory_retrieval_completed(
|
||||
self.formatter.current_agent_branch,
|
||||
self.formatter.current_crew_tree,
|
||||
event.memory_content,
|
||||
event.retrieval_time_ms,
|
||||
)
|
||||
|
||||
@event_bus.on(AgentReasoningStartedEvent)
|
||||
def on_agent_reasoning_started(source, event):
|
||||
def on_agent_reasoning_started(
|
||||
source: Any, event: AgentReasoningStartedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("agent_reasoning_started", source, event)
|
||||
|
||||
@event_bus.on(AgentReasoningCompletedEvent)
|
||||
def on_agent_reasoning_completed(source, event):
|
||||
def on_agent_reasoning_completed(
|
||||
source: Any, event: AgentReasoningCompletedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("agent_reasoning_completed", source, event)
|
||||
|
||||
@event_bus.on(AgentReasoningFailedEvent)
|
||||
def on_agent_reasoning_failed(source, event):
|
||||
def on_agent_reasoning_failed(
|
||||
source: Any, event: AgentReasoningFailedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("agent_reasoning_failed", source, event)
|
||||
|
||||
@event_bus.on(KnowledgeRetrievalStartedEvent)
|
||||
def on_knowledge_retrieval_started(source, event):
|
||||
def on_knowledge_retrieval_started(
|
||||
source: Any, event: KnowledgeRetrievalStartedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("knowledge_retrieval_started", source, event)
|
||||
|
||||
@event_bus.on(KnowledgeRetrievalCompletedEvent)
|
||||
def on_knowledge_retrieval_completed(source, event):
|
||||
def on_knowledge_retrieval_completed(
|
||||
source: Any, event: KnowledgeRetrievalCompletedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("knowledge_retrieval_completed", source, event)
|
||||
|
||||
@event_bus.on(KnowledgeQueryStartedEvent)
|
||||
def on_knowledge_query_started(source, event):
|
||||
def on_knowledge_query_started(
|
||||
source: Any, event: KnowledgeQueryStartedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("knowledge_query_started", source, event)
|
||||
|
||||
@event_bus.on(KnowledgeQueryCompletedEvent)
|
||||
def on_knowledge_query_completed(source, event):
|
||||
def on_knowledge_query_completed(
|
||||
source: Any, event: KnowledgeQueryCompletedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("knowledge_query_completed", source, event)
|
||||
|
||||
@event_bus.on(KnowledgeQueryFailedEvent)
|
||||
def on_knowledge_query_failed(source, event):
|
||||
def on_knowledge_query_failed(
|
||||
source: Any, event: KnowledgeQueryFailedEvent
|
||||
) -> None:
|
||||
self._handle_action_event("knowledge_query_failed", source, event)
|
||||
|
||||
def _initialize_crew_batch(self, source: Any, event: Any):
|
||||
"""Initialize trace batch"""
|
||||
def _initialize_crew_batch(self, source: Any, event: Any) -> None:
|
||||
"""Initialize trace batch.
|
||||
|
||||
Args:
|
||||
source: Source object that triggered the event.
|
||||
event: Event object containing crew information.
|
||||
"""
|
||||
user_context = self._get_user_context()
|
||||
execution_metadata = {
|
||||
"crew_name": getattr(event, "crew_name", "Unknown Crew"),
|
||||
@@ -342,8 +477,13 @@ class TraceCollectionListener(BaseEventListener):
|
||||
|
||||
self._initialize_batch(user_context, execution_metadata)
|
||||
|
||||
def _initialize_flow_batch(self, source: Any, event: Any):
|
||||
"""Initialize trace batch for Flow execution"""
|
||||
def _initialize_flow_batch(self, source: Any, event: Any) -> None:
|
||||
"""Initialize trace batch for Flow execution.
|
||||
|
||||
Args:
|
||||
source: Source object that triggered the event.
|
||||
event: Event object containing flow information.
|
||||
"""
|
||||
user_context = self._get_user_context()
|
||||
execution_metadata = {
|
||||
"flow_name": getattr(event, "flow_name", "Unknown Flow"),
|
||||
@@ -359,21 +499,32 @@ class TraceCollectionListener(BaseEventListener):
|
||||
|
||||
def _initialize_batch(
|
||||
self, user_context: dict[str, str], execution_metadata: dict[str, Any]
|
||||
):
|
||||
"""Initialize trace batch - auto-enable ephemeral for first-time users."""
|
||||
) -> None:
|
||||
"""Initialize trace batch - auto-enable ephemeral for first-time users.
|
||||
|
||||
Args:
|
||||
user_context: User context information.
|
||||
execution_metadata: Metadata about the execution.
|
||||
"""
|
||||
if self.first_time_handler.is_first_time:
|
||||
return self.batch_manager.initialize_batch(
|
||||
self.batch_manager.initialize_batch(
|
||||
user_context, execution_metadata, use_ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
use_ephemeral = not self._check_authenticated()
|
||||
return self.batch_manager.initialize_batch(
|
||||
self.batch_manager.initialize_batch(
|
||||
user_context, execution_metadata, use_ephemeral=use_ephemeral
|
||||
)
|
||||
|
||||
def _handle_trace_event(self, event_type: str, source: Any, event: Any):
|
||||
"""Generic handler for context end events"""
|
||||
def _handle_trace_event(self, event_type: str, source: Any, event: Any) -> None:
|
||||
"""Generic handler for context end events.
|
||||
|
||||
Args:
|
||||
event_type: Type of the event.
|
||||
source: Source object that triggered the event.
|
||||
event: Event object.
|
||||
"""
|
||||
self.batch_manager.begin_event_processing()
|
||||
try:
|
||||
trace_event = self._create_trace_event(event_type, source, event)
|
||||
@@ -381,9 +532,14 @@ class TraceCollectionListener(BaseEventListener):
|
||||
finally:
|
||||
self.batch_manager.end_event_processing()
|
||||
|
||||
def _handle_action_event(self, event_type: str, source: Any, event: Any):
|
||||
"""Generic handler for action events (LLM calls, tool usage)"""
|
||||
def _handle_action_event(self, event_type: str, source: Any, event: Any) -> None:
|
||||
"""Generic handler for action events (LLM calls, tool usage).
|
||||
|
||||
Args:
|
||||
event_type: Type of the event.
|
||||
source: Source object that triggered the event.
|
||||
event: Event object.
|
||||
"""
|
||||
if not self.batch_manager.is_batch_initialized():
|
||||
user_context = self._get_user_context()
|
||||
execution_metadata = {
|
||||
@@ -473,3 +629,35 @@ class TraceCollectionListener(BaseEventListener):
|
||||
"event": safe_serialize_to_dict(event),
|
||||
"source": source,
|
||||
}
|
||||
|
||||
def _show_tracing_disabled_message(self) -> None:
|
||||
"""Show a message when tracing is disabled."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from crewai.events.listeners.tracing.utils import has_user_declined_tracing
|
||||
|
||||
console = Console()
|
||||
|
||||
if has_user_declined_tracing():
|
||||
message = """Info: Tracing is disabled.
|
||||
|
||||
To enable tracing, do any one of these:
|
||||
• Set tracing=True in your Crew/Flow code
|
||||
• Set CREWAI_TRACING_ENABLED=true in your project's .env file
|
||||
• Run: crewai traces enable"""
|
||||
else:
|
||||
message = """Info: Tracing is disabled.
|
||||
|
||||
To enable tracing, do any one of these:
|
||||
• Set tracing=True in your Crew/Flow code
|
||||
• Set CREWAI_TRACING_ENABLED=true in your project's .env file
|
||||
• Run: crewai traces enable"""
|
||||
|
||||
panel = Panel(
|
||||
message,
|
||||
title="Tracing Status",
|
||||
border_style="blue",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user