fix: defer Agent(from_repository=...) resolution to first execution

This commit is contained in:
Renato Nitta
2026-06-25 15:47:05 -03:00
parent 9b31226494
commit a77b9f85a2
2 changed files with 79 additions and 7 deletions

View File

@@ -200,6 +200,7 @@ class Agent(BaseAgent):
_times_executed: int = PrivateAttr(default=0)
_mcp_resolver: MCPToolResolver | None = PrivateAttr(default=None)
_last_messages: list[LLMMessage] = PrivateAttr(default_factory=list)
_from_repository_resolved: bool = PrivateAttr(default=False)
max_execution_time: int | None = Field(
default=None,
description="Maximum execution time for an agent to execute a task",
@@ -346,11 +347,42 @@ class Agent(BaseAgent):
@model_validator(mode="before")
@classmethod
def validate_from_repository(cls, v: Any) -> dict[str, Any] | None | Any:
"""Merge repository agent config with provided values before validation."""
if v is not None and (from_repository := v.get("from_repository")):
return load_agent_from_repository(from_repository) | v
"""Defer repository resolution until the agent first runs.
Loading an agent from the repository requires an authenticated network
call. Performing it here — during construction — forces that call while a
crew is being loaded, before the runtime has wired deployment auth, which
breaks ``from_repository`` agents in deployed environments. Instead we keep
``from_repository`` and seed the required identity fields with placeholders
so the model validates now; the real definition is fetched on first use
(see ``_resolve_from_repository``).
"""
if isinstance(v, dict) and v.get("from_repository"):
for field in ("role", "goal", "backstory"):
v.setdefault(field, "")
return v
def _resolve_from_repository(self) -> None:
"""Fetch and apply the repository agent definition on first use.
Values supplied explicitly at construction take precedence; the
repository fills in everything else (including the placeholder identity
fields). Runs at most once and only when ``from_repository`` is set.
"""
if not self.from_repository or self._from_repository_resolved:
return
attributes = load_agent_from_repository(self.from_repository)
identity_fields = ("role", "goal", "backstory")
for key, value in attributes.items():
if key in identity_fields:
if not getattr(self, key, None):
setattr(self, key, value)
elif key not in self.model_fields_set and hasattr(self, key):
setattr(self, key, value)
self._from_repository_resolved = True
@model_validator(mode="after")
def post_init_setup(self) -> Self:
"""Initialize LLM, executor, code tools, and skills after model creation."""
@@ -804,6 +836,7 @@ class Agent(BaseAgent):
ValueError: If the max execution time is not a positive integer.
RuntimeError: If the agent execution fails for other reasons.
"""
self._resolve_from_repository()
task_prompt = self._prepare_task_execution(task, context)
knowledge_config = get_knowledge_config(self)
@@ -940,6 +973,7 @@ class Agent(BaseAgent):
ValueError: If the max execution time is not a positive integer.
RuntimeError: If the agent execution fails for other reasons.
"""
self._resolve_from_repository()
task_prompt = self._prepare_task_execution(task, context)
knowledge_config = get_knowledge_config(self)
@@ -1418,6 +1452,7 @@ class Agent(BaseAgent):
Returns:
Tuple of (executor, inputs, agent_info, parsed_tools) ready for execution.
"""
self._resolve_from_repository()
if self.apps:
platform_tools = self.get_platform_tools(self.apps)
if platform_tools:

View File

@@ -2207,6 +2207,11 @@ def test_agent_from_repository(mock_get_agent, mock_get_auth_token):
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent")
# Resolution is deferred: nothing is fetched at construction.
assert mock_get_agent.called is False
# Resolution happens on first execution (triggered here directly).
agent._resolve_from_repository()
assert agent.role == "test role"
assert agent.goal == "test goal"
@@ -2219,6 +2224,32 @@ def test_agent_from_repository(mock_get_agent, mock_get_auth_token):
assert agent.tools[1].file_path == "test.txt"
@patch("crewai.plus_api.PlusAPI.get_agent")
def test_agent_from_repository_is_deferred_until_execution(
mock_get_agent, mock_get_auth_token
):
mock_get_response = MagicMock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"role": "test role",
"goal": "test goal",
"backstory": "test backstory",
}
mock_get_agent.return_value = mock_get_response
# Construction must not touch the network — this is what lets from_repository
# agents be built while a crew loads, before deployment auth is wired.
agent = Agent(from_repository="test_agent")
assert mock_get_agent.called is False
assert agent.role == ""
# Executing the agent resolves the definition; a second run does not refetch.
agent._resolve_from_repository()
agent._resolve_from_repository()
assert agent.role == "test role"
assert mock_get_agent.call_count == 1
@patch("crewai.plus_api.PlusAPI.get_agent")
def test_agent_from_repository_override_attributes(mock_get_agent, mock_get_auth_token):
from crewai_tools import SerperDevTool
@@ -2235,6 +2266,7 @@ def test_agent_from_repository_override_attributes(mock_get_agent, mock_get_auth
}
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent", role="Custom Role")
agent._resolve_from_repository()
assert agent.role == "Custom Role"
assert agent.goal == "test goal"
@@ -2259,11 +2291,12 @@ def test_agent_from_repository_with_invalid_tools(mock_get_agent, mock_get_auth_
],
}
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent")
with pytest.raises(
AgentRepositoryError,
match="Tool DoesNotExist could not be loaded: module 'crewai_tools' has no attribute 'DoesNotExist'",
):
Agent(from_repository="test_agent")
agent._resolve_from_repository()
@patch("crewai.plus_api.PlusAPI.get_agent")
@@ -2272,11 +2305,12 @@ def test_agent_from_repository_internal_error(mock_get_agent, mock_get_auth_toke
mock_get_response.status_code = 500
mock_get_response.text = "Internal server error"
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent")
with pytest.raises(
AgentRepositoryError,
match="Agent test_agent could not be loaded: Internal server error",
):
Agent(from_repository="test_agent")
agent._resolve_from_repository()
@patch("crewai.plus_api.PlusAPI.get_agent")
@@ -2285,11 +2319,12 @@ def test_agent_from_repository_agent_not_found(mock_get_agent, mock_get_auth_tok
mock_get_response.status_code = 404
mock_get_response.text = "Agent not found"
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent")
with pytest.raises(
AgentRepositoryError,
match="Agent test_agent does not exist, make sure the name is correct or the agent is available on your organization",
):
Agent(from_repository="test_agent")
agent._resolve_from_repository()
@patch("crewai.plus_api.PlusAPI.get_agent")
@@ -2314,6 +2349,7 @@ def test_agent_from_repository_displays_org_info(
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent")
agent._resolve_from_repository()
mock_console.print.assert_any_call(
"Fetching agent from organization: Test Organization (test-org-uuid)",
@@ -2341,11 +2377,12 @@ def test_agent_from_repository_without_org_set(
mock_get_response.text = "Unauthorized access"
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent")
with pytest.raises(
AgentRepositoryError,
match="Agent test_agent could not be loaded: Unauthorized access",
):
Agent(from_repository="test_agent")
agent._resolve_from_repository()
mock_console.print.assert_any_call(
"No organization currently set. We recommend setting one before using: `crewai org switch <org_id>` command.",