Skip to content

Implement A2A protocol support for recursive agent invocation #2901

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions score.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"score": 4
}
1 change: 1 addition & 0 deletions src/crewai/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ def execute_task(
task: Task,
context: Optional[str] = None,
tools: Optional[List[BaseTool]] = None,
recursion_depth: int = 0,
) -> str:
"""Execute a task with the agent.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, AsyncIterable, Dict, List, Optional
from typing import Any, Dict, List, Optional

from pydantic import Field, PrivateAttr

Expand All @@ -22,7 +22,6 @@
)

try:
from langchain_core.messages import ToolMessage
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent

Expand Down Expand Up @@ -126,6 +125,7 @@ def execute_task(
task: Any,
context: Optional[str] = None,
tools: Optional[List[BaseTool]] = None,
recursion_depth: int = 0,
) -> str:
"""Execute a task using the LangGraph workflow."""
self.create_agent_executor(tools)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def post_process_result(self, result: str) -> str:
# Validate it's proper JSON
json.loads(extracted)
return extracted
except:
except json.JSONDecodeError:
pass

return result
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def execute_task(
task: Any,
context: Optional[str] = None,
tools: Optional[List[BaseTool]] = None,
recursion_depth: int = 0,
) -> str:
"""Execute a task using the OpenAI Assistant"""
self._converter_adapter.configure_structured_output(task)
Expand Down
3 changes: 2 additions & 1 deletion src/crewai/agents/agent_builder/base_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from crewai.tools.base_tool import BaseTool, Tool
from crewai.utilities import I18N, Logger, RPMController
from crewai.utilities.config import process_config
from crewai.utilities.converter import Converter
# Removed unused import: from crewai.utilities.converter import Converter
from crewai.utilities.string_utils import interpolate_only

T = TypeVar("T", bound="BaseAgent")
Expand Down Expand Up @@ -254,6 +254,7 @@ def execute_task(
task: Any,
context: Optional[str] = None,
tools: Optional[List[BaseTool]] = None,
recursion_depth: int = 0,
) -> str:
pass

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from typing import Any, Optional
from typing import Any

from pydantic import BaseModel, Field

Expand Down
2 changes: 0 additions & 2 deletions src/crewai/agents/crew_agent_executor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import json
import re
from typing import Any, Callable, Dict, List, Optional, Union

from crewai.agents.agent_builder.base_agent import BaseAgent
Expand Down
1 change: 0 additions & 1 deletion src/crewai/cli/run_crew.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import subprocess
from enum import Enum
from typing import List, Optional

import click
from packaging import version
Expand Down
3 changes: 1 addition & 2 deletions src/crewai/flow/flow_visualizer.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
# flow_visualizer.py

import os
from pathlib import Path

from pyvis.network import Network

from crewai.flow.config import COLORS, NODE_STYLES
from crewai.flow.html_template_handler import HTMLTemplateHandler
from crewai.flow.legend_generator import generate_legend_items_html, get_legend_items
from crewai.flow.path_utils import safe_path_join, validate_path_exists
from crewai.flow.path_utils import safe_path_join
from crewai.flow.utils import calculate_node_levels
from crewai.flow.visualization_utils import (
add_edges,
Expand Down
3 changes: 1 addition & 2 deletions src/crewai/flow/html_template_handler.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import base64
import re
from pathlib import Path

from crewai.flow.path_utils import safe_path_join, validate_path_exists
from crewai.flow.path_utils import validate_path_exists


class HTMLTemplateHandler:
Expand Down
1 change: 0 additions & 1 deletion src/crewai/flow/path_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
traversal attacks and ensure paths remain within allowed boundaries.
"""

import os
from pathlib import Path
from typing import List, Union

Expand Down
2 changes: 1 addition & 1 deletion src/crewai/flow/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import inspect
import textwrap
from collections import defaultdict, deque
from typing import Any, Deque, Dict, List, Optional, Set, Union
from typing import Any, Deque, Dict, List, Optional, Set


def get_possible_return_constants(function: Any) -> Optional[List[str]]:
Expand Down
2 changes: 1 addition & 1 deletion src/crewai/flow/visualization_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import ast
import inspect
from typing import Any, Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Tuple, Union

from .utils import (
build_ancestor_dict,
Expand Down
3 changes: 1 addition & 2 deletions src/crewai/knowledge/source/excel_knowledge_source.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Union
from urllib.parse import urlparse
from typing import Dict, List, Optional, Union

from pydantic import Field, field_validator

Expand Down
3 changes: 1 addition & 2 deletions src/crewai/lite_agent.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
import uuid
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Type, Union, cast

from pydantic import BaseModel, Field, InstanceOf, PrivateAttr, model_validator
Expand Down Expand Up @@ -35,7 +34,7 @@
render_text_description_and_args,
show_agent_logs,
)
from crewai.utilities.converter import convert_to_model, generate_model_description
from crewai.utilities.converter import generate_model_description
from crewai.utilities.events.agent_events import (
LiteAgentExecutionCompletedEvent,
LiteAgentExecutionErrorEvent,
Expand Down
5 changes: 0 additions & 5 deletions src/crewai/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,11 @@


class FilteredStream(io.TextIOBase):
_lock = None

def __init__(self, original_stream: TextIO):
self._original_stream = original_stream
self._lock = threading.Lock()

def write(self, s: str) -> int:
if not self._lock:
self._lock = threading.Lock()

with self._lock:
# Filter out extraneous messages from LiteLLM
if (
Expand Down
2 changes: 1 addition & 1 deletion src/crewai/llms/base_llm.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union


class BaseLLM(ABC):
Expand Down
2 changes: 1 addition & 1 deletion src/crewai/security/security_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
in CrewAI applications.
"""

from typing import Any, Dict, Optional
from typing import Any, Dict

from pydantic import BaseModel, ConfigDict, Field, model_validator

Expand Down
3 changes: 1 addition & 2 deletions src/crewai/tasks/llm_guardrail.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from typing import Any, Optional, Tuple
from typing import Any, Tuple

from pydantic import BaseModel, Field

from crewai.agent import Agent, LiteAgentOutput
from crewai.llm import LLM
from crewai.task import Task
from crewai.tasks.task_output import TaskOutput


Expand Down
2 changes: 1 addition & 1 deletion src/crewai/tools/agent_tools/add_image_tool.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Dict, Optional, Union
from typing import Optional

from pydantic import BaseModel, Field

Expand Down
24 changes: 24 additions & 0 deletions src/crewai/tools/agent_tools/agent_tools.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import List, Set

from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.tools.base_tool import BaseTool
from crewai.utilities import I18N
Expand All @@ -22,11 +24,33 @@ def tools(self) -> list[BaseTool]:
i18n=self.i18n,
description=self.i18n.tools("delegate_work").format(coworkers=coworkers), # type: ignore
)
delegate_tool._agent_tools = self._get_all_agent_tools()

ask_tool = AskQuestionTool(
agents=self.agents,
i18n=self.i18n,
description=self.i18n.tools("ask_question").format(coworkers=coworkers), # type: ignore
)
ask_tool._agent_tools = self._get_all_agent_tools()

return [delegate_tool, ask_tool]

def _get_all_agent_tools(self) -> list[BaseTool]:
"""
Get all tools from all agents for recursive invocation.

Returns:
list[BaseTool]: A deduplicated list of all tools from all agents.
"""
seen_tools: Set[int] = set()
unique_tools: List[BaseTool] = []

for agent in self.agents:
if agent.tools:
for tool in agent.tools:
tool_id = id(tool)
if tool_id not in seen_tools:
seen_tools.add(tool_id)
unique_tools.append(tool)

return unique_tools
7 changes: 5 additions & 2 deletions src/crewai/tools/agent_tools/ask_question_tool.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Optional
from typing import List, Optional

from pydantic import BaseModel, Field

from crewai.tools.agent_tools.base_agent_tools import BaseAgentTool
from crewai.tools.base_tool import BaseTool


class AskQuestionToolSchema(BaseModel):
Expand All @@ -16,6 +17,7 @@ class AskQuestionTool(BaseAgentTool):

name: str = "Ask question to coworker"
args_schema: type[BaseModel] = AskQuestionToolSchema
_agent_tools: Optional[List[BaseTool]] = None

def _run(
self,
Expand All @@ -25,4 +27,5 @@ def _run(
**kwargs,
) -> str:
coworker = self._get_coworker(coworker, **kwargs)
return self._execute(coworker, question, context)
tools = self._get_tools(**kwargs)
return self._execute(coworker, question, context, tools)
44 changes: 37 additions & 7 deletions src/crewai/tools/agent_tools/base_agent_tools.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from typing import Optional
from typing import List, Optional

from pydantic import Field

Expand All @@ -18,6 +18,7 @@ class BaseAgentTool(BaseTool):
i18n: I18N = Field(
default_factory=I18N, description="Internationalization settings"
)
MAX_RECURSION_DEPTH: int = 5

def sanitize_agent_name(self, name: str) -> str:
"""
Expand Down Expand Up @@ -46,11 +47,22 @@ def _get_coworker(self, coworker: Optional[str], **kwargs) -> Optional[str]:
coworker = coworker[1:-1].split(",")[0]
return coworker

def _get_tools(self, **kwargs) -> Optional[List[BaseTool]]:
"""
Get tools from instance or kwargs.

Returns:
Optional[List[BaseTool]]: The tools to use for recursive invocation.
"""
return getattr(self, "_agent_tools", None) or kwargs.get("tools")

def _execute(
self,
agent_name: Optional[str],
task: str,
context: Optional[str] = None
context: Optional[str] = None,
tools: Optional[List[BaseTool]] = None,
recursion_depth: int = 0,
) -> str:
"""
Execute delegation to an agent with case-insensitive and whitespace-tolerant matching.
Expand All @@ -59,11 +71,25 @@ def _execute(
agent_name: Name/role of the agent to delegate to (case-insensitive)
task: The specific question or task to delegate
context: Optional additional context for the task execution
tools: Optional tools to pass to the delegated agent for recursive invocation
recursion_depth: Current recursion depth to prevent infinite loops

Returns:
str: The execution result from the delegated agent or an error message
if the agent cannot be found
"""
if tools is not None and not all(isinstance(tool, BaseTool) for tool in tools):
return self.i18n.errors("agent_tool_execution_error").format(
agent_role="unknown",
error="Invalid tools provided: all tools must inherit from BaseTool",
)

if recursion_depth >= self.MAX_RECURSION_DEPTH:
return self.i18n.errors("agent_tool_execution_error").format(
agent_role="unknown",
error=f"Maximum recursion depth ({self.MAX_RECURSION_DEPTH}) exceeded",
)

try:
if agent_name is None:
agent_name = ""
Expand All @@ -82,12 +108,12 @@ def _execute(
available_agents = [agent.role for agent in self.agents]
logger.debug(f"Available agents: {available_agents}")

agent = [ # type: ignore # Incompatible types in assignment (expression has type "list[BaseAgent]", variable has type "str | None")
matching_agents = [
available_agent
for available_agent in self.agents
if self.sanitize_agent_name(available_agent.role) == sanitized_name
]
logger.debug(f"Found {len(agent)} matching agents for role '{sanitized_name}'")
logger.debug(f"Found {len(matching_agents)} matching agents for role '{sanitized_name}'")
except (AttributeError, ValueError) as e:
# Handle specific exceptions that might occur during role name processing
return self.i18n.errors("agent_tool_unexisting_coworker").format(
Expand All @@ -97,7 +123,7 @@ def _execute(
error=str(e)
)

if not agent:
if not matching_agents:
# No matching agent found after sanitization
return self.i18n.errors("agent_tool_unexisting_coworker").format(
coworkers="\n".join(
Expand All @@ -106,16 +132,20 @@ def _execute(
error=f"No agent found with role '{sanitized_name}'"
)

agent = agent[0]
agent = matching_agents[0]
try:
logger.debug(f"Executing task with {len(tools) if tools else 0} tools at recursion depth {recursion_depth}")
task_with_assigned_agent = Task(
description=task,
agent=agent,
expected_output=agent.i18n.slice("manager_request"),
i18n=agent.i18n,
tools=tools,
)
logger.debug(f"Created task for agent '{self.sanitize_agent_name(agent.role)}': {task}")
return agent.execute_task(task_with_assigned_agent, context)
return agent.execute_task(
task_with_assigned_agent, context, tools, recursion_depth + 1
)
except Exception as e:
# Handle task creation or execution errors
return self.i18n.errors("agent_tool_execution_error").format(
Expand Down
Loading
Loading