Compare commits

..

6 Commits

Author SHA1 Message Date
Lorenze Jay
c091aa63c6 Merge branch 'main' into fix/oss-9-extra-forbidden-security-context 2026-04-02 09:10:49 -07:00
Greyson LaLonde
9e51229e6c chore: add ExecutionContext model for state 2026-04-02 23:44:21 +08:00
Greyson LaLonde
247d623499 docs: update changelog and version for v1.13.0a7 2026-04-02 22:21:17 +08:00
Greyson LaLonde
c260f3e19f feat: bump versions to 1.13.0a7 2026-04-02 22:16:05 +08:00
Greyson LaLonde
d9cf7dda31 chore: type remaining Any fields on BaseAgent and Crew 2026-04-02 21:17:35 +08:00
Iris Clawd
edd79e50ef fix: ignore extra fields in dynamic tool schemas to prevent security_context validation errors
Changes the default Pydantic config in create_model_from_schema() from
extra='forbid' to extra='ignore'. This fixes OSS-9 where the framework
injects security_context metadata into tool call arguments, but MCP tools
and integration tools (created via create_model_from_schema) reject any
extra fields with Pydantic's extra_forbidden error.

Affected tools: all MCP tools (MCPServerAdapter, MCPToolResolver) and
all platform integration tools (CrewAIPlatformActionTool) — these all
use create_model_from_schema() without a custom __config__, so they
inherited the extra='forbid' default.

Regular user-defined tools (subclassing BaseModel) were not affected
because BaseModel defaults to extra='ignore'.

The fix is backward-compatible:
- Required fields are still enforced
- Type validation is still enforced
- Callers can still opt into extra='forbid' via __config__ parameter
- Tools that define security_context in their schema still receive it

Fixes: OSS-9
Related: #4796, #4841
2026-04-01 19:42:13 +00:00
27 changed files with 523 additions and 748 deletions

View File

@@ -4,6 +4,28 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="2 أبريل 2026">
## v1.13.0a7
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.13.0a7)
## ما الذي تغير
### الميزات
- إضافة امتداد A2UI مع دعم v0.8/v0.9، والمخططات، والوثائق
### إصلاحات الأخطاء
- إصلاح بادئات الرؤية متعددة الأنماط عن طريق إضافة GPT-5 وسلسلة o
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.13.0a6
## المساهمون
@alex-clawd, @greysonlalonde, @joaomdmoura
</Update>
<Update label="1 أبريل 2026">
## v1.13.0a6

View File

@@ -4,6 +4,28 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Apr 02, 2026">
## v1.13.0a7
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.13.0a7)
## What's Changed
### Features
- Add A2UI extension with v0.8/v0.9 support, schemas, and docs
### Bug Fixes
- Fix multimodal vision prefixes by adding GPT-5 and o-series
### Documentation
- Update changelog and version for v1.13.0a6
## Contributors
@alex-clawd, @greysonlalonde, @joaomdmoura
</Update>
<Update label="Apr 01, 2026">
## v1.13.0a6

View File

@@ -4,6 +4,28 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 4월 2일">
## v1.13.0a7
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.13.0a7)
## 변경 사항
### 기능
- v0.8/v0.9 지원, 스키마 및 문서가 포함된 A2UI 확장 추가
### 버그 수정
- GPT-5 및 o-series를 추가하여 다중 모드 비전 접두사 수정
### 문서
- v1.13.0a6에 대한 변경 로그 및 버전 업데이트
## 기여자
@alex-clawd, @greysonlalonde, @joaomdmoura
</Update>
<Update label="2026년 4월 1일">
## v1.13.0a6

View File

@@ -4,6 +4,28 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="02 abr 2026">
## v1.13.0a7
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.13.0a7)
## O que Mudou
### Funcionalidades
- Adicionar a extensão A2UI com suporte a v0.8/v0.9, esquemas e documentação
### Correções de Bugs
- Corrigir prefixos de visão multimodal adicionando GPT-5 e o-series
### Documentação
- Atualizar changelog e versão para v1.13.0a6
## Contribuidores
@alex-clawd, @greysonlalonde, @joaomdmoura
</Update>
<Update label="01 abr 2026">
## v1.13.0a6

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.13.0a6"
__version__ = "1.13.0a7"

View File

@@ -11,7 +11,7 @@ dependencies = [
"pytube~=15.0.0",
"requests~=2.32.5",
"docker~=7.1.0",
"crewai==1.13.0a6",
"crewai==1.13.0a7",
"tiktoken~=0.8.0",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -309,4 +309,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.13.0a6"
__version__ = "1.13.0a7"

View File

@@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.13.0a6",
"crewai-tools==1.13.0a7",
]
embeddings = [
"tiktoken~=0.8.0"

View File

@@ -10,6 +10,7 @@ from crewai.agent.core import Agent
from crewai.agent.planning_config import PlanningConfig
from crewai.crew import Crew
from crewai.crews.crew_output import CrewOutput
from crewai.execution_context import ExecutionContext
from crewai.flow.flow import Flow
from crewai.knowledge.knowledge import Knowledge
from crewai.llm import LLM
@@ -44,7 +45,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.13.0a6"
__version__ = "1.13.0a7"
_telemetry_submitted = False
@@ -96,6 +97,10 @@ def __getattr__(name: str) -> Any:
try:
from crewai.agents.agent_builder.base_agent import BaseAgent as _BaseAgent
from crewai.agents.agent_builder.base_agent_executor_mixin import (
CrewAgentExecutorMixin as _CrewAgentExecutorMixin,
)
from crewai.agents.tools_handler import ToolsHandler as _ToolsHandler
from crewai.experimental.agent_executor import AgentExecutor as _AgentExecutor
from crewai.hooks.llm_hooks import LLMCallHookContext as _LLMCallHookContext
@@ -105,25 +110,66 @@ try:
SystemPromptResult as _SystemPromptResult,
)
_AgentExecutor.model_rebuild(
force=True,
_types_namespace={
"Agent": Agent,
"ToolsHandler": _ToolsHandler,
"Crew": Crew,
"BaseLLM": BaseLLM,
"Task": Task,
"StandardPromptResult": _StandardPromptResult,
"SystemPromptResult": _SystemPromptResult,
"LLMCallHookContext": _LLMCallHookContext,
"ToolResult": _ToolResult,
},
)
_base_namespace: dict[str, type] = {
"Agent": Agent,
"Crew": Crew,
"BaseLLM": BaseLLM,
"Task": Task,
"CrewAgentExecutorMixin": _CrewAgentExecutorMixin,
}
try:
from crewai.a2a.config import (
A2AClientConfig as _A2AClientConfig,
A2AConfig as _A2AConfig,
A2AServerConfig as _A2AServerConfig,
)
_base_namespace.update(
{
"A2AConfig": _A2AConfig,
"A2AClientConfig": _A2AClientConfig,
"A2AServerConfig": _A2AServerConfig,
}
)
except ImportError:
pass
import sys
_full_namespace = {
**_base_namespace,
"ToolsHandler": _ToolsHandler,
"StandardPromptResult": _StandardPromptResult,
"SystemPromptResult": _SystemPromptResult,
"LLMCallHookContext": _LLMCallHookContext,
"ToolResult": _ToolResult,
}
_resolve_namespace = {
**_full_namespace,
**sys.modules[_BaseAgent.__module__].__dict__,
}
for _mod_name in (
_BaseAgent.__module__,
Agent.__module__,
_AgentExecutor.__module__,
):
sys.modules[_mod_name].__dict__.update(_resolve_namespace)
_BaseAgent.model_rebuild(force=True, _types_namespace=_full_namespace)
_AgentExecutor.model_rebuild(force=True, _types_namespace=_full_namespace)
try:
Agent.model_rebuild(force=True, _types_namespace=_full_namespace)
except PydanticUserError:
pass
except (ImportError, PydanticUserError):
import logging as _logging
_logging.getLogger(__name__).warning(
"AgentExecutor.model_rebuild() failed; forward refs may be unresolved.",
"model_rebuild() failed; forward refs may be unresolved.",
exc_info=True,
)
@@ -133,6 +179,7 @@ __all__ = [
"BaseLLM",
"Crew",
"CrewOutput",
"ExecutionContext",
"Flow",
"Knowledge",
"LLMGuardrail",

View File

@@ -25,6 +25,7 @@ from pydantic import (
BaseModel,
ConfigDict,
Field,
InstanceOf,
PrivateAttr,
model_validator,
)
@@ -267,6 +268,9 @@ class Agent(BaseAgent):
Can be a single A2AConfig/A2AClientConfig/A2AServerConfig, or a list of any number of A2AConfig/A2AClientConfig with a single A2AServerConfig.
""",
)
agent_executor: InstanceOf[CrewAgentExecutor] | InstanceOf[AgentExecutor] | None = (
Field(default=None, description="An instance of the CrewAgentExecutor class.")
)
executor_class: type[CrewAgentExecutor] | type[AgentExecutor] = Field(
default=CrewAgentExecutor,
description="Class to use for the agent executor. Defaults to CrewAgentExecutor, can optionally use AgentExecutor.",
@@ -690,7 +694,9 @@ class Agent(BaseAgent):
task_prompt,
knowledge_config,
self.knowledge.query if self.knowledge else lambda *a, **k: None,
self.crew.query_knowledge if self.crew else lambda *a, **k: None,
self.crew.query_knowledge
if self.crew and not isinstance(self.crew, str)
else lambda *a, **k: None,
)
task_prompt = self._finalize_task_prompt(task_prompt, tools, task)
@@ -777,14 +783,18 @@ class Agent(BaseAgent):
if not self.agent_executor:
raise RuntimeError("Agent executor is not initialized.")
return self.agent_executor.invoke(
{
"input": task_prompt,
"tool_names": self.agent_executor.tools_names,
"tools": self.agent_executor.tools_description,
"ask_for_human_input": task.human_input,
}
)["output"]
result = cast(
dict[str, Any],
self.agent_executor.invoke(
{
"input": task_prompt,
"tool_names": self.agent_executor.tools_names,
"tools": self.agent_executor.tools_description,
"ask_for_human_input": task.human_input,
}
),
)
return result["output"]
async def aexecute_task(
self,
@@ -955,19 +965,23 @@ class Agent(BaseAgent):
if self.agent_executor is not None:
self._update_executor_parameters(
task=task,
tools=parsed_tools, # type: ignore[arg-type]
tools=parsed_tools,
raw_tools=raw_tools,
prompt=prompt,
stop_words=stop_words,
rpm_limit_fn=rpm_limit_fn,
)
else:
if not isinstance(self.llm, BaseLLM):
raise RuntimeError(
"LLM must be resolved before creating agent executor."
)
self.agent_executor = self.executor_class(
llm=cast(BaseLLM, self.llm),
llm=self.llm,
task=task, # type: ignore[arg-type]
i18n=self.i18n,
agent=self,
crew=self.crew,
crew=self.crew, # type: ignore[arg-type]
tools=parsed_tools,
prompt=prompt,
original_tools=raw_tools,
@@ -991,7 +1005,7 @@ class Agent(BaseAgent):
def _update_executor_parameters(
self,
task: Task | None,
tools: list[BaseTool],
tools: list[CrewStructuredTool],
raw_tools: list[BaseTool],
prompt: SystemPromptResult | StandardPromptResult,
stop_words: list[str],
@@ -1007,11 +1021,17 @@ class Agent(BaseAgent):
stop_words: Stop words list.
rpm_limit_fn: RPM limit callback function.
"""
if self.agent_executor is None:
raise RuntimeError("Agent executor is not initialized.")
self.agent_executor.task = task
self.agent_executor.tools = tools
self.agent_executor.original_tools = raw_tools
self.agent_executor.prompt = prompt
self.agent_executor.stop_words = stop_words
if isinstance(self.agent_executor, AgentExecutor):
self.agent_executor.stop_words = stop_words
else:
self.agent_executor.stop = stop_words
self.agent_executor.tools_names = get_tool_names(tools)
self.agent_executor.tools_description = render_text_description_and_args(tools)
self.agent_executor.response_model = (
@@ -1787,21 +1807,3 @@ class Agent(BaseAgent):
LiteAgentOutput: The result of the agent execution.
"""
return await self.kickoff_async(messages, response_format, input_files)
try:
from crewai.a2a.config import (
A2AClientConfig as _A2AClientConfig,
A2AConfig as _A2AConfig,
A2AServerConfig as _A2AServerConfig,
)
Agent.model_rebuild(
_types_namespace={
"A2AConfig": _A2AConfig,
"A2AClientConfig": _A2AClientConfig,
"A2AServerConfig": _A2AServerConfig,
}
)
except ImportError:
pass

View File

@@ -137,7 +137,8 @@ def handle_knowledge_retrieval(
Returns:
The task prompt potentially augmented with knowledge context.
"""
if not (agent.knowledge or (agent.crew and agent.crew.knowledge)):
_crew = agent.crew if not isinstance(agent.crew, str) else None
if not (agent.knowledge or (_crew and _crew.knowledge)):
return task_prompt
crewai_event_bus.emit(
@@ -244,7 +245,7 @@ def apply_training_data(agent: Agent, task_prompt: str) -> str:
Returns:
The task prompt with training data applied.
"""
if agent.crew and agent.crew._train:
if agent.crew and not isinstance(agent.crew, str) and agent.crew._train:
return agent._training_handler(task_prompt=task_prompt)
return agent._use_trained_data(task_prompt=task_prompt)
@@ -355,7 +356,8 @@ async def ahandle_knowledge_retrieval(
Returns:
The task prompt potentially augmented with knowledge context.
"""
if not (agent.knowledge or (agent.crew and agent.crew.knowledge)):
_crew = agent.crew if not isinstance(agent.crew, str) else None
if not (agent.knowledge or (_crew and _crew.knowledge)):
return task_prompt
crewai_event_bus.emit(
@@ -381,15 +383,16 @@ async def ahandle_knowledge_retrieval(
if agent.agent_knowledge_context:
task_prompt += agent.agent_knowledge_context
knowledge_snippets = await agent.crew.aquery_knowledge(
[agent.knowledge_search_query], **knowledge_config
)
if knowledge_snippets:
agent.crew_knowledge_context = extract_knowledge_context(
knowledge_snippets
if _crew:
knowledge_snippets = await _crew.aquery_knowledge(
[agent.knowledge_search_query], **knowledge_config
)
if agent.crew_knowledge_context:
task_prompt += agent.crew_knowledge_context
if knowledge_snippets:
agent.crew_knowledge_context = extract_knowledge_context(
knowledge_snippets
)
if agent.crew_knowledge_context:
task_prompt += agent.crew_knowledge_context
crewai_event_bus.emit(
agent,

View File

@@ -188,14 +188,14 @@ class OpenAIAgentAdapter(BaseAgentAdapter):
self._openai_agent = OpenAIAgent(
name=self.role,
instructions=instructions,
model=self.llm,
model=str(self.llm),
**self._agent_config or {},
)
if all_tools:
self.configure_tools(all_tools)
self.agent_executor = Runner
self.agent_executor = Runner # type: ignore[assignment]
def configure_tools(self, tools: list[BaseTool] | None = None) -> None:
"""Configure tools for the OpenAI Assistant.

View File

@@ -5,21 +5,25 @@ from copy import copy as shallow_copy
from hashlib import md5
from pathlib import Path
import re
from typing import Any, Final, Literal
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal
import uuid
from pydantic import (
UUID4,
BaseModel,
BeforeValidator,
Field,
InstanceOf,
PrivateAttr,
field_validator,
model_validator,
)
from pydantic.functional_serializers import PlainSerializer
from pydantic_core import PydanticCustomError
from typing_extensions import Self
from crewai.agent.internal.meta import AgentMeta
from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin
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
@@ -27,6 +31,7 @@ from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.knowledge_config import KnowledgeConfig
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.llms.base_llm import BaseLLM
from crewai.mcp.config import MCPServerConfig
from crewai.memory.memory_scope import MemoryScope, MemorySlice
from crewai.memory.unified_memory import Memory
@@ -42,6 +47,20 @@ from crewai.utilities.rpm_controller import RPMController
from crewai.utilities.string_utils import interpolate_only
if TYPE_CHECKING:
from crewai.crew import Crew
def _validate_crew_ref(value: Any) -> Any:
return value
def _serialize_crew_ref(value: Any) -> str | None:
if value is None:
return None
return str(value.id) if hasattr(value, "id") else str(value)
_SLUG_RE: Final[re.Pattern[str]] = re.compile(
r"^(?:crewai-amp:)?[a-zA-Z0-9][a-zA-Z0-9_-]*(?:#[\w-]+)?$"
)
@@ -122,7 +141,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
__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)
_request_within_rpm_limit: SerializableCallable | None = PrivateAttr(default=None)
_original_role: str | None = PrivateAttr(default=None)
_original_goal: str | None = PrivateAttr(default=None)
_original_backstory: str | None = PrivateAttr(default=None)
@@ -154,13 +173,19 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
max_iter: int = Field(
default=25, description="Maximum iterations for an agent to execute a task"
)
agent_executor: Any = Field(
agent_executor: InstanceOf[CrewAgentExecutorMixin] | None = Field(
default=None, description="An instance of the CrewAgentExecutor class."
)
llm: Any = Field(
llm: str | BaseLLM | None = Field(
default=None, description="Language model that will run the agent."
)
crew: Any = Field(default=None, description="Crew to which the agent belongs.")
crew: Annotated[
Crew | str | None,
BeforeValidator(_validate_crew_ref),
PlainSerializer(
_serialize_crew_ref, return_type=str | None, when_used="always"
),
] = Field(default=None, description="Crew to which the agent belongs.")
i18n: I18N = Field(
default_factory=get_i18n, description="Internationalization settings."
)

View File

@@ -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.13.0a6"
"crewai[tools]==1.13.0a7"
]
[project.scripts]

View File

@@ -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.13.0a6"
"crewai[tools]==1.13.0a7"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.13.0a6"
"crewai[tools]==1.13.0a7"
]
[tool.crewai]

View File

@@ -266,7 +266,7 @@ class Crew(FlowTrackable, BaseModel):
default=False,
description="Plan the crew execution and add the plan to the crew.",
)
planning_llm: str | BaseLLM | Any | None = Field(
planning_llm: str | BaseLLM | None = Field(
default=None,
description=(
"Language model that will run the AgentPlanner if planning is True."
@@ -287,7 +287,7 @@ class Crew(FlowTrackable, BaseModel):
"knowledge object."
),
)
chat_llm: str | BaseLLM | Any | None = Field(
chat_llm: str | BaseLLM | None = Field(
default=None,
description="LLM used to handle chatting with the crew.",
)
@@ -1311,7 +1311,7 @@ class Crew(FlowTrackable, BaseModel):
and hasattr(agent, "multimodal")
and getattr(agent, "multimodal", False)
):
if not (agent.llm and agent.llm.supports_multimodal()):
if not (isinstance(agent.llm, BaseLLM) and agent.llm.supports_multimodal()):
tools = self._add_multimodal_tools(agent, tools)
if agent and (hasattr(agent, "apps") and getattr(agent, "apps", None)):
@@ -1328,7 +1328,11 @@ class Crew(FlowTrackable, BaseModel):
files = get_all_files(self.id, task.id)
if files:
supported_types: list[str] = []
if agent and agent.llm and agent.llm.supports_multimodal():
if (
agent
and isinstance(agent.llm, BaseLLM)
and agent.llm.supports_multimodal()
):
provider = (
getattr(agent.llm, "provider", None)
or getattr(agent.llm, "model", None)
@@ -1781,17 +1785,10 @@ class Crew(FlowTrackable, BaseModel):
token_sum = self.manager_agent._token_process.get_summary()
total_usage_metrics.add_usage_metrics(token_sum)
if (
self.manager_agent
and hasattr(self.manager_agent, "llm")
and hasattr(self.manager_agent.llm, "get_token_usage_summary")
):
if self.manager_agent:
if isinstance(self.manager_agent.llm, BaseLLM):
llm_usage = self.manager_agent.llm.get_token_usage_summary()
else:
llm_usage = self.manager_agent.llm._token_process.get_summary()
total_usage_metrics.add_usage_metrics(llm_usage)
total_usage_metrics.add_usage_metrics(llm_usage)
self.usage_metrics = total_usage_metrics
return total_usage_metrics

View File

@@ -11,6 +11,7 @@ from opentelemetry import baggage
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crews.crew_output import CrewOutput
from crewai.llms.base_llm import BaseLLM
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.skills.loader import activate_skill, discover_skills
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
@@ -50,7 +51,7 @@ def enable_agent_streaming(agents: Iterable[BaseAgent]) -> None:
agents: Iterable of agents to enable streaming on.
"""
for agent in agents:
if agent.llm is not None:
if isinstance(agent.llm, BaseLLM):
agent.llm.stream = True

View File

@@ -25,13 +25,25 @@ def _get_or_create_counter() -> Iterator[int]:
return counter
_last_emitted: contextvars.ContextVar[int] = contextvars.ContextVar(
"_last_emitted", default=0
)
def get_next_emission_sequence() -> int:
"""Get the next emission sequence number.
Returns:
The next sequence number.
"""
return next(_get_or_create_counter())
seq = next(_get_or_create_counter())
_last_emitted.set(seq)
return seq
def get_emission_sequence() -> int:
"""Get the current emission sequence value without incrementing."""
return _last_emitted.get()
def reset_emission_counter() -> None:
@@ -41,6 +53,14 @@ def reset_emission_counter() -> None:
"""
counter: Iterator[int] = itertools.count(start=1)
_emission_counter.set(counter)
_last_emitted.set(0)
def set_emission_counter(start: int) -> None:
"""Set the emission counter to resume from a given value."""
counter: Iterator[int] = itertools.count(start=start + 1)
_emission_counter.set(counter)
_last_emitted.set(start)
class BaseEvent(BaseModel):

View File

@@ -0,0 +1,80 @@
"""Checkpointable execution context for the crewAI runtime.
Captures the ContextVar state needed to resume execution from a checkpoint.
Used by the RootModel (step 5) to include execution context in snapshots.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
from crewai.context import (
_current_task_id,
_platform_integration_token,
)
from crewai.events.base_events import (
get_emission_sequence,
set_emission_counter,
)
from crewai.events.event_context import (
_event_id_stack,
_last_event_id,
_triggering_event_id,
)
from crewai.flow.flow_context import (
current_flow_id,
current_flow_method_name,
current_flow_request_id,
)
class ExecutionContext(BaseModel):
"""Snapshot of ContextVar state required for checkpoint/resume."""
current_task_id: str | None = Field(default=None)
flow_request_id: str | None = Field(default=None)
flow_id: str | None = Field(default=None)
flow_method_name: str = Field(default="unknown")
event_id_stack: tuple[tuple[str, str], ...] = Field(default=())
last_event_id: str | None = Field(default=None)
triggering_event_id: str | None = Field(default=None)
emission_sequence: int = Field(default=0)
feedback_callback_info: dict[str, Any] | None = Field(default=None)
platform_token: str | None = Field(default=None)
def capture_execution_context(
feedback_callback_info: dict[str, Any] | None = None,
) -> ExecutionContext:
"""Read all checkpoint-required ContextVars into an ExecutionContext."""
return ExecutionContext(
current_task_id=_current_task_id.get(),
flow_request_id=current_flow_request_id.get(),
flow_id=current_flow_id.get(),
flow_method_name=current_flow_method_name.get(),
event_id_stack=_event_id_stack.get(),
last_event_id=_last_event_id.get(),
triggering_event_id=_triggering_event_id.get(),
emission_sequence=get_emission_sequence(),
feedback_callback_info=feedback_callback_info,
platform_token=_platform_integration_token.get(),
)
def apply_execution_context(ctx: ExecutionContext) -> None:
"""Write an ExecutionContext back into the ContextVars."""
_current_task_id.set(ctx.current_task_id)
current_flow_request_id.set(ctx.flow_request_id)
current_flow_id.set(ctx.flow_id)
current_flow_method_name.set(ctx.flow_method_name)
_event_id_stack.set(ctx.event_id_stack)
_last_event_id.set(ctx.last_event_id)
_triggering_event_id.set(ctx.triggering_event_id)
set_emission_counter(ctx.emission_sequence)
_platform_integration_token.set(ctx.platform_token)

View File

@@ -41,13 +41,14 @@ from crewai.events.types.task_events import (
TaskFailedEvent,
TaskStartedEvent,
)
from crewai.llms.base_llm import BaseLLM
from crewai.security import Fingerprint, SecurityConfig
from crewai.tasks.output_format import OutputFormat
from crewai.tasks.task_output import TaskOutput
from crewai.tools.base_tool import BaseTool
from crewai.utilities.config import process_config
from crewai.utilities.constants import NOT_SPECIFIED, _NotSpecified
from crewai.utilities.converter import Converter, aconvert_to_model, convert_to_model
from crewai.utilities.converter import Converter, convert_to_model
from crewai.utilities.file_store import (
clear_task_files,
get_all_files,
@@ -316,6 +317,10 @@ class Task(BaseModel):
if self.agent is None:
raise ValueError("Agent is required to use LLMGuardrail")
if not isinstance(self.agent.llm, BaseLLM):
raise ValueError(
"Agent must have a BaseLLM instance to use LLMGuardrail"
)
self._guardrail = cast(
GuardrailCallable,
LLMGuardrail(description=self.guardrail, llm=self.agent.llm),
@@ -339,6 +344,10 @@ class Task(BaseModel):
)
from crewai.tasks.llm_guardrail import LLMGuardrail
if not isinstance(self.agent.llm, BaseLLM):
raise ValueError(
"Agent must have a BaseLLM instance to use LLMGuardrail"
)
guardrails.append(
cast(
GuardrailCallable,
@@ -359,6 +368,10 @@ class Task(BaseModel):
)
from crewai.tasks.llm_guardrail import LLMGuardrail
if not isinstance(self.agent.llm, BaseLLM):
raise ValueError(
"Agent must have a BaseLLM instance to use LLMGuardrail"
)
guardrails.append(
cast(
GuardrailCallable,
@@ -602,7 +615,7 @@ class Task(BaseModel):
json_output = None
elif not self._guardrails and not self._guardrail:
raw = result
pydantic_output, json_output = await self._aexport_output(result)
pydantic_output, json_output = self._export_output(result)
else:
raw = result
pydantic_output, json_output = None, None
@@ -646,7 +659,12 @@ class Task(BaseModel):
await cb_result
crew = self.agent.crew # type: ignore[union-attr]
if crew and crew.task_callback and crew.task_callback != self.callback:
if (
crew
and not isinstance(crew, str)
and crew.task_callback
and crew.task_callback != self.callback
):
cb_result = crew.task_callback(self.output)
if inspect.isawaitable(cb_result):
await cb_result
@@ -761,7 +779,12 @@ class Task(BaseModel):
asyncio.run(cb_result)
crew = self.agent.crew # type: ignore[union-attr]
if crew and crew.task_callback and crew.task_callback != self.callback:
if (
crew
and not isinstance(crew, str)
and crew.task_callback
and crew.task_callback != self.callback
):
cb_result = crew.task_callback(self.output)
if inspect.iscoroutine(cb_result):
asyncio.run(cb_result)
@@ -812,11 +835,14 @@ class Task(BaseModel):
if trigger_payload is not None:
description += f"\n\nTrigger Payload: {trigger_payload}"
if self.agent and self.agent.crew:
if self.agent and self.agent.crew and not isinstance(self.agent.crew, str):
files = get_all_files(self.agent.crew.id, self.id)
if files:
supported_types: list[str] = []
if self.agent.llm and self.agent.llm.supports_multimodal():
if (
isinstance(self.agent.llm, BaseLLM)
and self.agent.llm.supports_multimodal()
):
provider: str = str(
getattr(self.agent.llm, "provider", None)
or getattr(self.agent.llm, "model", "openai")
@@ -1040,34 +1066,6 @@ Follow these guidelines:
return pydantic_output, json_output
async def _aexport_output(
self, result: str
) -> tuple[BaseModel | None, dict[str, Any] | None]:
"""Async version of _export_output that uses async LLM calls."""
pydantic_output: BaseModel | None = None
json_output: dict[str, Any] | None = None
if self.output_pydantic or self.output_json:
model_output = await aconvert_to_model(
result,
self.output_pydantic,
self.output_json,
self.agent,
self.converter_cls,
)
if isinstance(model_output, BaseModel):
pydantic_output = model_output
elif isinstance(model_output, dict):
json_output = model_output
elif isinstance(model_output, str):
try:
json_output = json.loads(model_output)
except json.JSONDecodeError:
json_output = None
return pydantic_output, json_output
def _get_output_format(self) -> OutputFormat:
if self.output_json:
return OutputFormat.JSON
@@ -1283,7 +1281,7 @@ Follow these guidelines:
if isinstance(guardrail_result.result, str):
task_output.raw = guardrail_result.result
pydantic_output, json_output = await self._aexport_output(
pydantic_output, json_output = self._export_output(
guardrail_result.result
)
task_output.pydantic = pydantic_output
@@ -1328,7 +1326,7 @@ Follow these guidelines:
tools=tools,
)
pydantic_output, json_output = await self._aexport_output(result)
pydantic_output, json_output = self._export_output(result)
task_output = TaskOutput(
name=self.name or self.description,
description=self.description,

View File

@@ -41,6 +41,7 @@ from crewai.events.types.system_events import (
SigTStpEvent,
SigTermEvent,
)
from crewai.llms.base_llm import BaseLLM
from crewai.telemetry.constants import (
CREWAI_TELEMETRY_BASE_URL,
CREWAI_TELEMETRY_SERVICE_NAME,
@@ -323,7 +324,9 @@ class Telemetry:
if getattr(agent, "function_calling_llm", None)
else ""
),
"llm": agent.llm.model,
"llm": agent.llm.model
if isinstance(agent.llm, BaseLLM)
else str(agent.llm),
"delegation_enabled?": agent.allow_delegation,
"allow_code_execution?": getattr(
agent, "allow_code_execution", False
@@ -427,7 +430,9 @@ class Telemetry:
if getattr(agent, "function_calling_llm", None)
else ""
),
"llm": agent.llm.model,
"llm": agent.llm.model
if isinstance(agent.llm, BaseLLM)
else str(agent.llm),
"delegation_enabled?": agent.allow_delegation,
"allow_code_execution?": getattr(
agent, "allow_code_execution", False
@@ -840,7 +845,9 @@ class Telemetry:
"max_iter": agent.max_iter,
"max_rpm": agent.max_rpm,
"i18n": agent.i18n.prompt_file,
"llm": agent.llm.model,
"llm": agent.llm.model
if isinstance(agent.llm, BaseLLM)
else str(agent.llm),
"delegation_enabled?": agent.allow_delegation,
"tools_names": [
sanitize_tool_name(tool.name)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
import json
import re
from typing import TYPE_CHECKING, Any, Final, TypedDict
@@ -143,103 +142,6 @@ class Converter(OutputConverter):
return self.to_json(current_attempt + 1)
return ConverterError(f"Failed to convert text into JSON, error: {e}.")
async def ato_pydantic(self, current_attempt: int = 1) -> BaseModel:
"""Async version of to_pydantic. Convert text to pydantic without blocking the event loop.
Args:
current_attempt: The current attempt number for conversion retries.
Returns:
A Pydantic BaseModel instance.
Raises:
ConverterError: If conversion fails after maximum attempts.
"""
try:
if self.llm.supports_function_calling():
response = await self.llm.acall(
messages=[
{"role": "system", "content": self.instructions},
{"role": "user", "content": self.text},
],
response_model=self.model,
)
if isinstance(response, BaseModel):
result = response
else:
result = self.model.model_validate_json(response)
else:
response = await self.llm.acall(
[
{"role": "system", "content": self.instructions},
{"role": "user", "content": self.text},
]
)
try:
result = self.model.model_validate_json(response)
except ValidationError:
result = handle_partial_json( # type: ignore[assignment]
result=response,
model=self.model,
is_json_output=False,
agent=None,
)
if not isinstance(result, BaseModel):
if isinstance(result, dict):
result = self.model.model_validate(result)
elif isinstance(result, str):
try:
result = self.model.model_validate_json(result)
except Exception as parse_err:
raise ConverterError(
f"Failed to convert partial JSON result into Pydantic: {parse_err}"
) from parse_err
else:
raise ConverterError(
"handle_partial_json returned an unexpected type."
) from None
return result
except ValidationError as e:
if current_attempt < self.max_attempts:
return await self.ato_pydantic(current_attempt + 1)
raise ConverterError(
f"Failed to convert text into a Pydantic model due to validation error: {e}"
) from e
except Exception as e:
if current_attempt < self.max_attempts:
return await self.ato_pydantic(current_attempt + 1)
raise ConverterError(
f"Failed to convert text into a Pydantic model due to error: {e}"
) from e
async def ato_json(self, current_attempt: int = 1) -> str | ConverterError | Any:
"""Async version of to_json. Convert text to json without blocking the event loop.
Args:
current_attempt: The current attempt number for conversion retries.
Returns:
A JSON string or ConverterError if conversion fails.
Raises:
ConverterError: If conversion fails after maximum attempts.
"""
try:
if self.llm.supports_function_calling():
return await asyncio.to_thread(self._create_instructor().to_json)
return json.dumps(
await self.llm.acall(
[
{"role": "system", "content": self.instructions},
{"role": "user", "content": self.text},
]
)
)
except Exception as e:
if current_attempt < self.max_attempts:
return await self.ato_json(current_attempt + 1)
return ConverterError(f"Failed to convert text into JSON, error: {e}.")
def _create_instructor(self) -> InternalInstructor[Any]:
"""Create an instructor."""
@@ -436,176 +338,6 @@ def convert_with_instructions(
return exported_result
async def aconvert_to_model(
result: str,
output_pydantic: type[BaseModel] | None,
output_json: type[BaseModel] | None,
agent: Agent | BaseAgent | None = None,
converter_cls: type[Converter] | None = None,
) -> dict[str, Any] | BaseModel | str:
"""Async version of convert_to_model. Convert a result string to a Pydantic model or JSON.
Uses async LLM calls to avoid blocking the event loop.
Args:
result: The result string to convert.
output_pydantic: The Pydantic model class to convert to.
output_json: The Pydantic model class to convert to JSON.
agent: The agent instance.
converter_cls: The converter class to use.
Returns:
The converted result as a dict, BaseModel, or original string.
"""
model = output_pydantic or output_json
if model is None:
return result
if converter_cls:
return await aconvert_with_instructions(
result=result,
model=model,
is_json_output=bool(output_json),
agent=agent,
converter_cls=converter_cls,
)
try:
escaped_result = json.dumps(json.loads(result, strict=False))
return validate_model(
result=escaped_result, model=model, is_json_output=bool(output_json)
)
except json.JSONDecodeError:
return await ahandle_partial_json(
result=result,
model=model,
is_json_output=bool(output_json),
agent=agent,
converter_cls=converter_cls,
)
except ValidationError:
return await ahandle_partial_json(
result=result,
model=model,
is_json_output=bool(output_json),
agent=agent,
converter_cls=converter_cls,
)
except Exception as e:
if agent and getattr(agent, "verbose", True):
Printer().print(
content=f"Unexpected error during model conversion: {type(e).__name__}: {e}. Returning original result.",
color="red",
)
return result
async def ahandle_partial_json(
result: str,
model: type[BaseModel],
is_json_output: bool,
agent: Agent | BaseAgent | None,
converter_cls: type[Converter] | None = None,
) -> dict[str, Any] | BaseModel | str:
"""Async version of handle_partial_json.
Args:
result: The result string to process.
model: The Pydantic model class to convert to.
is_json_output: Whether to return a dict (True) or Pydantic model (False).
agent: The agent instance.
converter_cls: The converter class to use.
Returns:
The converted result as a dict, BaseModel, or original string.
"""
match = _JSON_PATTERN.search(result)
if match:
try:
exported_result = model.model_validate_json(match.group())
if is_json_output:
return exported_result.model_dump()
return exported_result
except json.JSONDecodeError:
pass
except ValidationError:
raise
except Exception as e:
if agent and getattr(agent, "verbose", True):
Printer().print(
content=f"Unexpected error during partial JSON handling: {type(e).__name__}: {e}. Attempting alternative conversion method.",
color="red",
)
return await aconvert_with_instructions(
result=result,
model=model,
is_json_output=is_json_output,
agent=agent,
converter_cls=converter_cls,
)
async def aconvert_with_instructions(
result: str,
model: type[BaseModel],
is_json_output: bool,
agent: Agent | BaseAgent | None,
converter_cls: type[Converter] | None = None,
) -> dict[str, Any] | BaseModel | str:
"""Async version of convert_with_instructions.
Uses async LLM calls to avoid blocking the event loop.
Args:
result: The result string to convert.
model: The Pydantic model class to convert to.
is_json_output: Whether to return a dict (True) or Pydantic model (False).
agent: The agent instance.
converter_cls: The converter class to use.
Returns:
The converted result as a dict, BaseModel, or original string.
Raises:
TypeError: If neither agent nor converter_cls is provided.
"""
if agent is None:
raise TypeError("Agent must be provided if converter_cls is not specified.")
llm = getattr(agent, "function_calling_llm", None) or agent.llm
if llm is None:
raise ValueError("Agent must have a valid LLM instance for conversion")
instructions = get_conversion_instructions(model=model, llm=llm)
converter = create_converter(
agent=agent,
converter_cls=converter_cls,
llm=llm,
text=result,
model=model,
instructions=instructions,
)
exported_result = (
await converter.ato_pydantic()
if not is_json_output
else await converter.ato_json()
)
if isinstance(exported_result, ConverterError):
if agent and getattr(agent, "verbose", True):
Printer().print(
content=f"Failed to convert result to model: {exported_result}",
color="red",
)
return result
return exported_result
def get_conversion_instructions(
model: type[BaseModel], llm: BaseLLM | LLM | str | Any
) -> str:

View File

@@ -623,7 +623,7 @@ def create_model_from_schema( # type: ignore[no-any-unimported]
for name, prop in (json_schema.get("properties", {}) or {}).items()
}
effective_config = __config__ or ConfigDict(extra="forbid")
effective_config = __config__ or ConfigDict(extra="ignore")
return create_model_base(
effective_name,

View File

@@ -8,9 +8,6 @@ from crewai.llm import LLM
from crewai.utilities.converter import (
Converter,
ConverterError,
aconvert_to_model,
aconvert_with_instructions,
ahandle_partial_json,
convert_to_model,
convert_with_instructions,
create_converter,
@@ -955,351 +952,3 @@ def test_internal_instructor_real_unsupported_provider() -> None:
# Verify it's a configuration error about unsupported provider
assert "Unsupported provider" in str(exc_info.value) or "unsupported" in str(exc_info.value).lower()
# ============================================================
# Async converter tests (issue #5230)
# ============================================================
@pytest.mark.asyncio
async def test_ato_pydantic_with_function_calling() -> None:
"""Test that ato_pydantic uses llm.acall instead of llm.call."""
from unittest.mock import AsyncMock
llm = Mock(spec=LLM)
llm.supports_function_calling.return_value = True
llm.acall = AsyncMock(return_value='{"name": "Eve", "age": 35}')
converter = Converter(
llm=llm,
text="Name: Eve, Age: 35",
model=SimpleModel,
instructions="Convert this text.",
)
output = await converter.ato_pydantic()
assert isinstance(output, SimpleModel)
assert output.name == "Eve"
assert output.age == 35
# Verify acall was used, not call
llm.acall.assert_called_once()
assert not hasattr(llm, "call") or not llm.call.called
@pytest.mark.asyncio
async def test_ato_pydantic_without_function_calling() -> None:
"""Test that ato_pydantic uses llm.acall for non-function-calling LLMs."""
from unittest.mock import AsyncMock
llm = Mock(spec=LLM)
llm.supports_function_calling.return_value = False
llm.acall = AsyncMock(return_value='{"name": "Alice", "age": 30}')
converter = Converter(
llm=llm,
text="Name: Alice, Age: 30",
model=SimpleModel,
instructions="Convert this text.",
)
output = await converter.ato_pydantic()
assert isinstance(output, SimpleModel)
assert output.name == "Alice"
assert output.age == 30
llm.acall.assert_called_once()
@pytest.mark.asyncio
async def test_ato_json_without_function_calling() -> None:
"""Test that ato_json uses llm.acall instead of llm.call."""
from unittest.mock import AsyncMock
llm = Mock(spec=LLM)
llm.supports_function_calling.return_value = False
llm.acall = AsyncMock(return_value='{"name": "Bob", "age": 40}')
converter = Converter(
llm=llm,
text="Name: Bob, Age: 40",
model=SimpleModel,
instructions="Convert this text.",
)
output = await converter.ato_json()
assert isinstance(output, str)
llm.acall.assert_called_once()
@pytest.mark.asyncio
async def test_ato_pydantic_retry_logic() -> None:
"""Test that ato_pydantic retries on failure using acall."""
from unittest.mock import AsyncMock
llm = Mock(spec=LLM)
llm.supports_function_calling.return_value = False
llm.acall = AsyncMock(
side_effect=[
"Invalid JSON",
"Still invalid",
'{"name": "Retry Alice", "age": 30}',
]
)
converter = Converter(
llm=llm,
text="Name: Retry Alice, Age: 30",
model=SimpleModel,
instructions="Convert this text.",
max_attempts=3,
)
output = await converter.ato_pydantic()
assert isinstance(output, SimpleModel)
assert output.name == "Retry Alice"
assert output.age == 30
assert llm.acall.call_count == 3
@pytest.mark.asyncio
async def test_ato_pydantic_error_after_max_attempts() -> None:
"""Test that ato_pydantic raises ConverterError after max attempts."""
from unittest.mock import AsyncMock
llm = Mock(spec=LLM)
llm.supports_function_calling.return_value = False
llm.acall = AsyncMock(return_value="Invalid JSON")
converter = Converter(
llm=llm,
text="Name: Alice, Age: 30",
model=SimpleModel,
instructions="Convert this text.",
max_attempts=3,
)
with pytest.raises(ConverterError) as exc_info:
await converter.ato_pydantic()
assert "Failed to convert text into a Pydantic model" in str(exc_info.value)
@pytest.mark.asyncio
async def test_ato_json_retry_logic() -> None:
"""Test that ato_json retries on failure using acall."""
from unittest.mock import AsyncMock
llm = Mock(spec=LLM)
llm.supports_function_calling.return_value = False
llm.acall = AsyncMock(
side_effect=[
Exception("LLM error"),
Exception("LLM error again"),
'{"name": "Bob", "age": 40}',
]
)
converter = Converter(
llm=llm,
text="Name: Bob, Age: 40",
model=SimpleModel,
instructions="Convert this text.",
max_attempts=3,
)
output = await converter.ato_json()
assert isinstance(output, str)
assert llm.acall.call_count == 3
@pytest.mark.asyncio
async def test_aconvert_to_model_with_valid_json() -> None:
"""Test aconvert_to_model with valid JSON (no LLM call needed)."""
result = '{"name": "John", "age": 30}'
output = await aconvert_to_model(result, SimpleModel, None, None)
assert isinstance(output, SimpleModel)
assert output.name == "John"
assert output.age == 30
@pytest.mark.asyncio
async def test_aconvert_to_model_with_no_model() -> None:
"""Test aconvert_to_model returns plain text when no model specified."""
result = "Plain text"
output = await aconvert_to_model(result, None, None, None)
assert output == "Plain text"
@pytest.mark.asyncio
async def test_aconvert_to_model_with_json_output() -> None:
"""Test aconvert_to_model returns dict when output_json is specified."""
result = '{"name": "John", "age": 30}'
output = await aconvert_to_model(result, None, SimpleModel, None)
assert isinstance(output, dict)
assert output == {"name": "John", "age": 30}
@pytest.mark.asyncio
async def test_aconvert_with_instructions_success() -> None:
"""Test aconvert_with_instructions uses async converter methods."""
from unittest.mock import AsyncMock
mock_agent = Mock()
mock_agent.function_calling_llm = None
mock_agent.llm = Mock()
with patch("crewai.utilities.converter.create_converter") as mock_create_converter, \
patch("crewai.utilities.converter.get_conversion_instructions") as mock_get_instructions:
mock_get_instructions.return_value = "Instructions"
mock_converter = Mock()
mock_converter.ato_pydantic = AsyncMock(
return_value=SimpleModel(name="David", age=50)
)
mock_create_converter.return_value = mock_converter
result = "Some text to convert"
output = await aconvert_with_instructions(result, SimpleModel, False, mock_agent)
assert isinstance(output, SimpleModel)
assert output.name == "David"
assert output.age == 50
mock_converter.ato_pydantic.assert_called_once()
@pytest.mark.asyncio
async def test_aconvert_with_instructions_json_output() -> None:
"""Test aconvert_with_instructions uses ato_json for JSON output."""
from unittest.mock import AsyncMock
mock_agent = Mock()
mock_agent.function_calling_llm = None
mock_agent.llm = Mock()
with patch("crewai.utilities.converter.create_converter") as mock_create_converter, \
patch("crewai.utilities.converter.get_conversion_instructions") as mock_get_instructions:
mock_get_instructions.return_value = "Instructions"
mock_converter = Mock()
mock_converter.ato_json = AsyncMock(
return_value='{"name": "David", "age": 50}'
)
mock_create_converter.return_value = mock_converter
result = "Some text to convert"
output = await aconvert_with_instructions(result, SimpleModel, True, mock_agent)
assert output == '{"name": "David", "age": 50}'
mock_converter.ato_json.assert_called_once()
@pytest.mark.asyncio
async def test_aconvert_with_instructions_failure() -> None:
"""Test aconvert_with_instructions returns original result on ConverterError."""
from unittest.mock import AsyncMock
mock_agent = Mock()
mock_agent.function_calling_llm = None
mock_agent.llm = Mock()
mock_agent.verbose = True
with patch("crewai.utilities.converter.create_converter") as mock_create_converter, \
patch("crewai.utilities.converter.get_conversion_instructions") as mock_get_instructions:
mock_get_instructions.return_value = "Instructions"
mock_converter = Mock()
mock_converter.ato_pydantic = AsyncMock(
return_value=ConverterError("Conversion failed")
)
mock_create_converter.return_value = mock_converter
result = "Some text to convert"
with patch("crewai.utilities.converter.Printer") as mock_printer:
output = await aconvert_with_instructions(result, SimpleModel, False, mock_agent)
assert output == result
mock_printer.return_value.print.assert_called_once()
@pytest.mark.asyncio
async def test_aconvert_with_instructions_no_agent() -> None:
"""Test aconvert_with_instructions raises TypeError without agent."""
with pytest.raises(TypeError, match="Agent must be provided"):
await aconvert_with_instructions("text", SimpleModel, False, None)
@pytest.mark.asyncio
async def test_ahandle_partial_json_with_valid_partial() -> None:
"""Test ahandle_partial_json with valid embedded JSON."""
result = 'Some text {"name": "Charlie", "age": 35} more text'
output = await ahandle_partial_json(result, SimpleModel, False, None)
assert isinstance(output, SimpleModel)
assert output.name == "Charlie"
assert output.age == 35
@pytest.mark.asyncio
async def test_ahandle_partial_json_delegates_to_aconvert_with_instructions() -> None:
"""Test ahandle_partial_json delegates to aconvert_with_instructions for invalid JSON."""
from unittest.mock import AsyncMock
mock_agent = Mock()
mock_agent.function_calling_llm = None
mock_agent.llm = Mock()
result = "No valid JSON here"
with patch("crewai.utilities.converter.aconvert_with_instructions", new_callable=AsyncMock) as mock_aconvert:
mock_aconvert.return_value = "Converted result"
output = await ahandle_partial_json(result, SimpleModel, False, mock_agent)
assert output == "Converted result"
mock_aconvert.assert_called_once()
@pytest.mark.asyncio
async def test_async_converter_does_not_call_sync_llm_call() -> None:
"""Core test for issue #5230: verify that async converter path never uses sync llm.call()."""
from unittest.mock import AsyncMock
llm = Mock(spec=LLM)
llm.supports_function_calling.return_value = True
llm.acall = AsyncMock(return_value='{"name": "Test", "age": 25}')
llm.call = Mock(side_effect=AssertionError("sync llm.call() should not be called in async path"))
converter = Converter(
llm=llm,
text="Name: Test, Age: 25",
model=SimpleModel,
instructions="Convert this text.",
)
# ato_pydantic should use acall, never call
output = await converter.ato_pydantic()
assert isinstance(output, SimpleModel)
assert output.name == "Test"
assert output.age == 25
llm.acall.assert_called_once()
llm.call.assert_not_called()
@pytest.mark.asyncio
async def test_async_converter_json_does_not_call_sync_llm_call() -> None:
"""Verify ato_json for non-function-calling LLMs never uses sync llm.call()."""
from unittest.mock import AsyncMock
llm = Mock(spec=LLM)
llm.supports_function_calling.return_value = False
llm.acall = AsyncMock(return_value='{"name": "Test", "age": 25}')
llm.call = Mock(side_effect=AssertionError("sync llm.call() should not be called in async path"))
converter = Converter(
llm=llm,
text="Name: Test, Age: 25",
model=SimpleModel,
instructions="Convert this text.",
)
output = await converter.ato_json()
assert isinstance(output, str)
llm.acall.assert_called_once()
llm.call.assert_not_called()

View File

@@ -882,3 +882,129 @@ class TestEndToEndMCPSchema:
)
assert obj.filters.date_from == datetime.date(2025, 1, 1)
assert obj.filters.categories == ["news", "tech"]
class TestExtraFieldsIgnored:
"""Regression tests for OSS-9: security_context injection causing
extra_forbidden errors on MCP and integration tool schemas.
When the framework injects metadata like security_context into tool call
arguments, dynamically-created Pydantic models must ignore (not reject)
extra fields so that tool execution is not blocked.
"""
SIMPLE_TOOL_SCHEMA: dict[str, Any] = {
"type": "object",
"title": "ExecuteSqlSchema",
"properties": {
"query": {
"type": "string",
"description": "The SQL query to execute.",
},
},
"required": ["query"],
}
OUTLOOK_TOOL_SCHEMA: dict[str, Any] = {
"type": "object",
"title": "MicrosoftOutlookSendEmailSchema",
"properties": {
"to_recipients": {
"type": "array",
"items": {"type": "string"},
"description": "Array of recipient email addresses.",
},
"subject": {
"type": "string",
"description": "Email subject line.",
},
"body": {
"type": "string",
"description": "Email body content.",
},
},
"required": ["to_recipients", "subject", "body"],
}
SECURITY_CONTEXT_PAYLOAD: dict[str, Any] = {
"agent_fingerprint": {
"user_id": "test-user-123",
"session_id": "test-session-456",
"metadata": {},
},
}
def test_mcp_tool_schema_ignores_security_context(self) -> None:
"""Reproduces OSS-9 Case 1: Databricks MCP execute_sql fails when
security_context is injected into tool args."""
Model = create_model_from_schema(self.SIMPLE_TOOL_SCHEMA)
# This previously raised: Extra inputs are not permitted
# [type=extra_forbidden, input_value={'agent_fingerprint': ...}]
obj = Model.model_validate(
{
"query": "SELECT * FROM my_table",
"security_context": self.SECURITY_CONTEXT_PAYLOAD,
}
)
assert obj.query == "SELECT * FROM my_table"
# security_context should be silently dropped, not present on the model
assert not hasattr(obj, "security_context")
def test_integration_tool_schema_ignores_security_context(self) -> None:
"""Reproduces OSS-9 Case 2: Microsoft Outlook send_email fails when
security_context is injected into tool args."""
Model = create_model_from_schema(self.OUTLOOK_TOOL_SCHEMA)
obj = Model.model_validate(
{
"to_recipients": ["user@example.com"],
"subject": "Test",
"body": "Hello",
"security_context": self.SECURITY_CONTEXT_PAYLOAD,
}
)
assert obj.to_recipients == ["user@example.com"]
assert obj.subject == "Test"
assert not hasattr(obj, "security_context")
def test_arbitrary_extra_fields_ignored(self) -> None:
"""Any unexpected extra field should be silently ignored, not just
security_context."""
Model = create_model_from_schema(self.SIMPLE_TOOL_SCHEMA)
obj = Model.model_validate(
{
"query": "SELECT 1",
"some_unknown_field": "should be dropped",
"another_extra": 42,
}
)
assert obj.query == "SELECT 1"
assert not hasattr(obj, "some_unknown_field")
assert not hasattr(obj, "another_extra")
def test_required_fields_still_enforced(self) -> None:
"""Changing to extra=ignore must NOT weaken required field validation."""
Model = create_model_from_schema(self.SIMPLE_TOOL_SCHEMA)
with pytest.raises(Exception):
Model.model_validate({"security_context": self.SECURITY_CONTEXT_PAYLOAD})
def test_type_validation_still_enforced(self) -> None:
"""Changing to extra=ignore must NOT weaken type validation."""
Model = create_model_from_schema(self.SIMPLE_TOOL_SCHEMA)
with pytest.raises(Exception):
Model.model_validate({"query": 12345}) # should be string
def test_explicit_extra_forbid_still_works(self) -> None:
"""Callers can still opt into extra=forbid via __config__."""
from pydantic import ConfigDict
Model = create_model_from_schema(
self.SIMPLE_TOOL_SCHEMA,
__config__=ConfigDict(extra="forbid"),
)
with pytest.raises(Exception):
Model.model_validate(
{
"query": "SELECT 1",
"security_context": self.SECURITY_CONTEXT_PAYLOAD,
}
)

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.13.0a6"
__version__ = "1.13.0a7"