mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-04-24 03:42:38 +00:00
Compare commits
6 Commits
devin/1775
...
fix/oss-9-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c091aa63c6 | ||
|
|
9e51229e6c | ||
|
|
247d623499 | ||
|
|
c260f3e19f | ||
|
|
d9cf7dda31 | ||
|
|
edd79e50ef |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.13.0a6"
|
||||
__version__ = "1.13.0a7"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -309,4 +309,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.13.0a6"
|
||||
__version__ = "1.13.0a7"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
80
lib/crewai/src/crewai/execution_context.py
Normal file
80
lib/crewai/src/crewai/execution_context.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.13.0a6"
|
||||
__version__ = "1.13.0a7"
|
||||
|
||||
Reference in New Issue
Block a user