mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-22 19:06:25 +00:00
Merge branch 'main' into danielfsbarreto/mongodb-persist
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
@@ -16,6 +17,7 @@ from rich.text import Text
|
||||
from crewai_cli.constants import ENV_VARS
|
||||
from crewai_cli.git import initialize_if_git_available
|
||||
from crewai_cli.model_catalog import get_provider_models
|
||||
from crewai_cli.platform_tools_catalog import PLATFORM_TOOLS
|
||||
from crewai_cli.tui_picker import pick_many, pick_one
|
||||
from crewai_cli.utils import (
|
||||
enable_prompt_line_editing,
|
||||
@@ -103,6 +105,7 @@ _TEMPLATES_DIR = Path(__file__).parent / "templates" / "json_crew"
|
||||
# ── Common tools for picker ────────────────────────────────────
|
||||
|
||||
_TOOL_CATEGORIES: list[tuple[str, list[tuple[str, str]]]] = [
|
||||
("CrewAI Platform", PLATFORM_TOOLS),
|
||||
(
|
||||
"Search & Research",
|
||||
[
|
||||
@@ -304,6 +307,9 @@ def _show_interpolation_hint(kind: str) -> None:
|
||||
|
||||
|
||||
def _tool_label(name: str, description: str) -> str:
|
||||
if name.startswith("platform:"):
|
||||
app_name = description.removesuffix(" Integration").replace(" ", "")
|
||||
return f"{description:<48s} Platform: {app_name.replace(' ', '')}Integration"
|
||||
return f"{description:<48s} {name}"
|
||||
|
||||
|
||||
@@ -351,6 +357,7 @@ def _select_tools() -> list[str]:
|
||||
selected: set[str] = set()
|
||||
expanded: str | None = None
|
||||
focus_category: str | None = None
|
||||
first_render = True
|
||||
|
||||
while True:
|
||||
labels: list[str] = []
|
||||
@@ -387,13 +394,14 @@ def _select_tools() -> list[str]:
|
||||
labels.append(_tool_label(name, desc))
|
||||
|
||||
indices, action = pick_many(
|
||||
"Tools (space to toggle, enter to confirm):",
|
||||
"Tools (space to toggle, enter to confirm):" if first_render else "",
|
||||
labels,
|
||||
action_indices=action_indices,
|
||||
separator_indices=separator_indices,
|
||||
preselected=preselected,
|
||||
initial_cursor=initial_cursor,
|
||||
)
|
||||
first_render = False
|
||||
|
||||
# Carry over toggles made on this screen; tools not visible in this
|
||||
# render keep their previous state.
|
||||
@@ -612,6 +620,10 @@ def _wizard_agents_and_tasks(
|
||||
"inputs": {},
|
||||
}
|
||||
|
||||
# Platform authentication belongs to the final wizard step, after the
|
||||
# user has finished configuring agents, tasks, and crew settings.
|
||||
_setup_platform_auth(agents)
|
||||
|
||||
return agents, tasks, crew_settings
|
||||
|
||||
|
||||
@@ -864,6 +876,181 @@ def _setup_env(folder_path: Path, llm_model: str) -> None:
|
||||
click.secho(" API keys and model saved to .env file", fg="green")
|
||||
|
||||
|
||||
def _platform_apps_from_agents(agents: list[dict[str, Any]]) -> list[str]:
|
||||
"""Return unique platform applications selected across all agents."""
|
||||
apps: list[str] = []
|
||||
for agent in agents:
|
||||
for tool in agent.get("tools", []):
|
||||
if isinstance(tool, str) and tool.startswith("platform:"):
|
||||
app = tool.removeprefix("platform:")
|
||||
if app and app not in apps:
|
||||
apps.append(app)
|
||||
return apps
|
||||
|
||||
|
||||
def _platform_app_name(app: str) -> str:
|
||||
"""Return the display name for a platform application slug."""
|
||||
return (
|
||||
dict(PLATFORM_TOOLS)
|
||||
.get(f"platform:{app}", app.replace("_", " ").title())
|
||||
.removesuffix(" Integration")
|
||||
)
|
||||
|
||||
|
||||
def _prompt_platform_token() -> str:
|
||||
"""Explain how to obtain and securely prompt for an AMP integration token."""
|
||||
click.secho(
|
||||
" To use CrewAI Platform tools, you need a CrewAI Platform Integration Token.",
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho(
|
||||
" Get your token from CrewAI AMP: https://app.crewai.com "
|
||||
"→ Settings → Integration Tokens.",
|
||||
fg="cyan",
|
||||
)
|
||||
return str(
|
||||
click.prompt(
|
||||
click.style(" CREWAI_PLATFORM_INTEGRATION_TOKEN", fg="cyan"),
|
||||
hide_input=True,
|
||||
prompt_suffix=click.style(" > ", fg="bright_white"),
|
||||
)
|
||||
).strip()
|
||||
|
||||
|
||||
def _validate_platform_apps(
|
||||
apps: list[str], application_selector: Any, client_for_selector: Any
|
||||
) -> tuple[list[str], bool]:
|
||||
"""Check selected AMP applications and return failures and token validity."""
|
||||
failed: list[str] = []
|
||||
for app in apps:
|
||||
app_name = _platform_app_name(app)
|
||||
click.echo()
|
||||
click.secho(
|
||||
" Checking CrewAI Platform Integration Token and "
|
||||
f"{app_name} integration on AMP...",
|
||||
fg="cyan",
|
||||
)
|
||||
try:
|
||||
selector = application_selector.from_string(app)
|
||||
actions = client_for_selector(selector).get_actions([selector])
|
||||
except Exception as error:
|
||||
status_code = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if status_code in {401, 403}:
|
||||
click.secho(
|
||||
" ✘ CrewAI Platform Integration Token is invalid or expired",
|
||||
fg="red",
|
||||
)
|
||||
return failed, True
|
||||
click.secho(
|
||||
f" ✘ {app_name} integration could not be validated: {error}",
|
||||
fg="red",
|
||||
)
|
||||
failed.append(app)
|
||||
continue
|
||||
|
||||
if not actions:
|
||||
click.secho(
|
||||
f" ✘ {app_name} integration is not connected on CrewAI Platform",
|
||||
fg="red",
|
||||
)
|
||||
failed.append(app)
|
||||
else:
|
||||
click.secho(
|
||||
f" ✔ {app_name} integration is connected on CrewAI Platform",
|
||||
fg="green",
|
||||
)
|
||||
return failed, False
|
||||
|
||||
|
||||
def _show_platform_validation_guidance(
|
||||
failed_apps: list[str], token_invalid: bool
|
||||
) -> None:
|
||||
"""Tell the user what to fix before revalidating AMP integrations."""
|
||||
click.echo()
|
||||
if token_invalid:
|
||||
click.secho(
|
||||
" Check your CrewAI Platform Integration Token in AMP.",
|
||||
fg="yellow",
|
||||
)
|
||||
return
|
||||
|
||||
failed_app_names = [_platform_app_name(app) for app in failed_apps]
|
||||
click.secho(
|
||||
" Check the "
|
||||
f"{', '.join(failed_app_names)} integration"
|
||||
f"{'s' if len(failed_app_names) != 1 else ''} and your CrewAI "
|
||||
"Platform Integration Token in AMP.",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
|
||||
def _prompt_platform_revalidation_token() -> str:
|
||||
"""Prompt for an optional replacement token before the next validation pass."""
|
||||
click.echo()
|
||||
return str(
|
||||
click.prompt(
|
||||
click.style(
|
||||
" Press Enter to revalidate, or enter a replacement token",
|
||||
fg="cyan",
|
||||
),
|
||||
default="",
|
||||
show_default=False,
|
||||
hide_input=True,
|
||||
prompt_suffix=click.style(" > ", fg="bright_white"),
|
||||
)
|
||||
).strip()
|
||||
|
||||
|
||||
def _setup_platform_auth(agents: list[dict[str, Any]]) -> str | None:
|
||||
"""Get and validate AMP authentication for selected platform applications."""
|
||||
apps = _platform_apps_from_agents(agents)
|
||||
if not apps:
|
||||
return None
|
||||
|
||||
click.echo()
|
||||
try:
|
||||
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
ApplicationSelector,
|
||||
client_for_selector,
|
||||
)
|
||||
except ImportError as error:
|
||||
raise click.ClickException(
|
||||
"Platform tools require the 'crewai-tools' package. "
|
||||
"Install it with `uv add crewai-tools` or "
|
||||
"`pip install 'crewai[tools]'`."
|
||||
) from error
|
||||
|
||||
token = os.environ.get("CREWAI_PLATFORM_INTEGRATION_TOKEN", "")
|
||||
while True:
|
||||
if not token:
|
||||
token = _prompt_platform_token()
|
||||
if not token:
|
||||
click.secho(
|
||||
" A CrewAI Platform Integration Token is required to validate "
|
||||
"the selected integrations.",
|
||||
fg="yellow",
|
||||
)
|
||||
continue
|
||||
|
||||
os.environ["CREWAI_PLATFORM_INTEGRATION_TOKEN"] = token
|
||||
failed_apps, token_invalid = _validate_platform_apps(
|
||||
apps, ApplicationSelector, client_for_selector
|
||||
)
|
||||
if not failed_apps and not token_invalid:
|
||||
_success("CrewAI Platform integration token set", bold=True)
|
||||
_success(
|
||||
"CrewAI Platform integrations connected: "
|
||||
f"{', '.join(_platform_app_name(app) for app in apps)}"
|
||||
)
|
||||
return token
|
||||
|
||||
_show_platform_validation_guidance(failed_apps, token_invalid)
|
||||
replacement_token = _prompt_platform_revalidation_token()
|
||||
if replacement_token:
|
||||
token = replacement_token
|
||||
os.environ["CREWAI_PLATFORM_INTEGRATION_TOKEN"] = token
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -916,13 +1103,25 @@ def create_json_crew(
|
||||
default_llm=default_llm,
|
||||
)
|
||||
|
||||
# Create directories
|
||||
platform_token = (
|
||||
os.environ.get("CREWAI_PLATFORM_INTEGRATION_TOKEN")
|
||||
if _platform_apps_from_agents(agents) and not dmn_mode
|
||||
else None
|
||||
)
|
||||
|
||||
# Create directories only after platform authentication succeeds.
|
||||
folder_path.mkdir(parents=True)
|
||||
(folder_path / "agents").mkdir()
|
||||
(folder_path / "tools").mkdir()
|
||||
(folder_path / "skills").mkdir()
|
||||
(folder_path / "knowledge").mkdir()
|
||||
|
||||
if platform_token:
|
||||
os.environ["CREWAI_PLATFORM_INTEGRATION_TOKEN"] = platform_token
|
||||
env_vars = load_env_vars(folder_path)
|
||||
env_vars["CREWAI_PLATFORM_INTEGRATION_TOKEN"] = platform_token
|
||||
write_env_file(folder_path, env_vars)
|
||||
|
||||
for agent in agents:
|
||||
_write_jsonc(
|
||||
folder_path / "agents" / f"{agent['name']}.jsonc",
|
||||
|
||||
29
lib/cli/src/crewai_cli/platform_tools_catalog.py
Normal file
29
lib/cli/src/crewai_cli/platform_tools_catalog.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""CrewAI AMP platform tools exposed by the crew-creation wizard."""
|
||||
|
||||
from crewai_core.platform_apps import PLATFORM_APPS
|
||||
|
||||
|
||||
PLATFORM_TOOL_PREFIX = "platform:"
|
||||
|
||||
_APP_DESCRIPTIONS: dict[str, str] = {
|
||||
"asana": "Asana Integration",
|
||||
"box": "Box Integration",
|
||||
"clickup": "ClickUp Integration",
|
||||
"github": "GitHub Integration",
|
||||
"gmail": "Gmail Integration",
|
||||
"google_calendar": "Google Calendar Integration",
|
||||
"google_sheets": "Google Sheets Integration",
|
||||
"hubspot": "HubSpot Integration",
|
||||
"jira": "Jira Integration",
|
||||
"linear": "Linear Integration",
|
||||
"notion": "Notion Integration",
|
||||
"salesforce": "Salesforce Integration",
|
||||
"shopify": "Shopify Integration",
|
||||
"slack": "Slack Integration",
|
||||
"stripe": "Stripe Integration",
|
||||
"zendesk": "Zendesk Integration",
|
||||
}
|
||||
|
||||
PLATFORM_TOOLS: list[tuple[str, str]] = [
|
||||
(f"{PLATFORM_TOOL_PREFIX}{app}", _APP_DESCRIPTIONS[app]) for app in PLATFORM_APPS
|
||||
]
|
||||
@@ -388,8 +388,9 @@ def pick_many(
|
||||
Sorted list of selected indices, or ``(indices, action_index)`` when
|
||||
``action_indices`` is provided.
|
||||
"""
|
||||
click.echo()
|
||||
click.secho(f" {title}", fg="cyan")
|
||||
if title:
|
||||
click.echo()
|
||||
click.secho(f" {title}", fg="cyan")
|
||||
|
||||
if _is_interactive():
|
||||
try:
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest import mock
|
||||
import pytest
|
||||
import tomli
|
||||
from click.testing import CliRunner
|
||||
from crewai_core.platform_apps import PLATFORM_APPS
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.version import Version
|
||||
import crewai_cli.create_json_crew as json_crew
|
||||
@@ -620,6 +621,52 @@ def test_json_wizard_tool_picker_lists_builtin_tools_across_categories(monkeypat
|
||||
}.isdisjoint(tool_names)
|
||||
|
||||
|
||||
def test_json_wizard_platform_tool_selection_stays_in_agent_tools(monkeypatch):
|
||||
picker_calls = 0
|
||||
|
||||
def pick_many(title: str, labels: list[str], **kwargs):
|
||||
nonlocal picker_calls
|
||||
picker_calls += 1
|
||||
if picker_calls == 1:
|
||||
platform_row = next(
|
||||
idx for idx, label in enumerate(labels) if "CrewAI Platform" in label
|
||||
)
|
||||
return [], platform_row
|
||||
|
||||
github = next(
|
||||
idx
|
||||
for idx, label in enumerate(labels)
|
||||
if label.startswith("GitHub Integration")
|
||||
and label.endswith("Platform: GitHubIntegration")
|
||||
)
|
||||
return [github], None
|
||||
|
||||
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
||||
monkeypatch.setattr(
|
||||
json_crew, "_prompt_text", lambda label, **kwargs: label.lower()
|
||||
)
|
||||
monkeypatch.setattr(json_crew, "_select_model", lambda: "openai/gpt-5.5")
|
||||
monkeypatch.setattr(json_crew, "_confirm", lambda *_args, **_kwargs: False)
|
||||
|
||||
agent = json_crew._wizard_agent(agent_num=1, existing_names=[])
|
||||
|
||||
assert agent is not None
|
||||
assert agent["tools"] == ["platform:github"]
|
||||
assert '"tools": ["platform:github"]' in json_crew._agent_to_jsonc(agent)
|
||||
|
||||
|
||||
def test_json_wizard_platform_catalog_contains_every_supported_app():
|
||||
platform_category = next(
|
||||
tools
|
||||
for category, tools in json_crew._TOOL_CATEGORIES
|
||||
if category == "CrewAI Platform"
|
||||
)
|
||||
|
||||
assert [name for name, _description in platform_category] == [
|
||||
f"platform:{app}" for app in PLATFORM_APPS
|
||||
]
|
||||
|
||||
|
||||
def test_multi_picker_skips_separator_on_initial_cursor(monkeypatch):
|
||||
cursors: list[int] = []
|
||||
|
||||
|
||||
25
lib/crewai-core/src/crewai_core/platform_apps.py
Normal file
25
lib/crewai-core/src/crewai_core/platform_apps.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""CrewAI Platform application catalog."""
|
||||
|
||||
from typing import Final, Literal, get_args
|
||||
|
||||
|
||||
PlatformApp = Literal[
|
||||
"asana",
|
||||
"box",
|
||||
"clickup",
|
||||
"github",
|
||||
"gmail",
|
||||
"google_calendar",
|
||||
"google_sheets",
|
||||
"hubspot",
|
||||
"jira",
|
||||
"linear",
|
||||
"notion",
|
||||
"salesforce",
|
||||
"shopify",
|
||||
"slack",
|
||||
"stripe",
|
||||
"zendesk",
|
||||
]
|
||||
|
||||
PLATFORM_APPS: Final[tuple[str, ...]] = (*get_args(PlatformApp),)
|
||||
@@ -4,6 +4,8 @@ This module provides tools for integrating with various platform applications
|
||||
through the CrewAI platform API.
|
||||
"""
|
||||
|
||||
from crewai_core.platform_apps import PLATFORM_APPS
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
CrewAIPlatformActionTool,
|
||||
)
|
||||
@@ -13,6 +15,7 @@ from crewai_tools.tools.crewai_platform_tools.crewai_platform_tools import (
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PLATFORM_APPS",
|
||||
"CrewAIPlatformActionTool",
|
||||
"CrewaiPlatformTools",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
@@ -11,9 +9,6 @@ from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def CrewaiPlatformTools( # noqa: N802
|
||||
apps: list[str],
|
||||
) -> list[BaseTool]:
|
||||
@@ -28,17 +23,11 @@ def CrewaiPlatformTools( # noqa: N802
|
||||
selectors = [ApplicationSelector.from_string(app) for app in apps]
|
||||
tools: list[BaseTool] = []
|
||||
|
||||
try:
|
||||
for selector in selectors:
|
||||
client = client_for_selector(selector)
|
||||
tools.extend(
|
||||
CrewAIPlatformActionTool(tool_info, client=client)
|
||||
for tool_info in client.get_actions([selector])
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to fetch platform tools for apps {apps}: {error}")
|
||||
return []
|
||||
for selector in selectors:
|
||||
client = client_for_selector(selector)
|
||||
tools.extend(
|
||||
CrewAIPlatformActionTool(tool_info, client=client)
|
||||
for tool_info in client.get_actions([selector])
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from crewai_core.platform_apps import PLATFORM_APPS
|
||||
|
||||
|
||||
def test_platform_apps_contains_supported_application_catalog() -> None:
|
||||
assert PLATFORM_APPS == (
|
||||
"asana",
|
||||
"box",
|
||||
"clickup",
|
||||
"github",
|
||||
"gmail",
|
||||
"google_calendar",
|
||||
"google_sheets",
|
||||
"hubspot",
|
||||
"jira",
|
||||
"linear",
|
||||
"notion",
|
||||
"salesforce",
|
||||
"shopify",
|
||||
"slack",
|
||||
"stripe",
|
||||
"zendesk",
|
||||
)
|
||||
@@ -157,10 +157,8 @@ class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
def test_crewai_platform_tools_api_error_handling(self, mock_get):
|
||||
mock_get.side_effect = Exception("API Error")
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["github"])
|
||||
assert tools is not None
|
||||
assert isinstance(tools, list)
|
||||
assert len(tools) == 0
|
||||
with self.assertRaisesRegex(Exception, "API Error"):
|
||||
CrewaiPlatformTools(apps=["github"])
|
||||
|
||||
def test_crewai_platform_tools_no_token(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import re
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal
|
||||
import uuid
|
||||
|
||||
from crewai_core.platform_apps import PlatformApp
|
||||
from pydantic import (
|
||||
UUID4,
|
||||
BaseModel,
|
||||
@@ -180,25 +181,6 @@ _SLUG_RE: Final[re.Pattern[str]] = re.compile(
|
||||
)
|
||||
|
||||
|
||||
PlatformApp = Literal[
|
||||
"asana",
|
||||
"box",
|
||||
"clickup",
|
||||
"github",
|
||||
"gmail",
|
||||
"google_calendar",
|
||||
"google_sheets",
|
||||
"hubspot",
|
||||
"jira",
|
||||
"linear",
|
||||
"notion",
|
||||
"salesforce",
|
||||
"shopify",
|
||||
"slack",
|
||||
"stripe",
|
||||
"zendesk",
|
||||
]
|
||||
|
||||
PlatformAppOrAction = PlatformApp | str
|
||||
|
||||
|
||||
|
||||
@@ -364,7 +364,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
|
||||
try:
|
||||
if has_reached_max_iterations(self.iterations, self.max_iter):
|
||||
formatted_answer = handle_max_iterations_exceeded(
|
||||
formatted_answer,
|
||||
printer=PRINTER,
|
||||
messages=self.messages,
|
||||
llm=cast("BaseLLM", self.llm),
|
||||
@@ -524,7 +523,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
|
||||
try:
|
||||
if has_reached_max_iterations(self.iterations, self.max_iter):
|
||||
formatted_answer = handle_max_iterations_exceeded(
|
||||
None,
|
||||
printer=PRINTER,
|
||||
messages=self.messages,
|
||||
llm=cast("BaseLLM", self.llm),
|
||||
@@ -1178,7 +1176,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
|
||||
try:
|
||||
if has_reached_max_iterations(self.iterations, self.max_iter):
|
||||
formatted_answer = handle_max_iterations_exceeded(
|
||||
formatted_answer,
|
||||
printer=PRINTER,
|
||||
messages=self.messages,
|
||||
llm=cast("BaseLLM", self.llm),
|
||||
@@ -1324,7 +1321,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
|
||||
try:
|
||||
if has_reached_max_iterations(self.iterations, self.max_iter):
|
||||
formatted_answer = handle_max_iterations_exceeded(
|
||||
None,
|
||||
printer=PRINTER,
|
||||
messages=self.messages,
|
||||
llm=cast("BaseLLM", self.llm),
|
||||
|
||||
@@ -2031,6 +2031,63 @@ class Crew(FlowTrackable, BaseModel):
|
||||
None,
|
||||
)
|
||||
|
||||
def _validate_replay_tasks(
|
||||
self, stored_outputs: list[Any], start_index: int
|
||||
) -> None:
|
||||
"""Ensure stored outputs still correspond to the tasks that will receive them."""
|
||||
if len(self.tasks) <= start_index:
|
||||
raise ValueError(
|
||||
"Cannot replay because the current crew does not match the stored task outputs."
|
||||
)
|
||||
|
||||
stored_prefix = stored_outputs[: start_index + 1]
|
||||
stored_task_keys = [
|
||||
stored_output.get("task_key") for stored_output in stored_prefix
|
||||
]
|
||||
current_task_keys = [task.key for task in self.tasks[: start_index + 1]]
|
||||
if all(stored_task_keys):
|
||||
if len(set(stored_task_keys)) != len(stored_task_keys) or len(
|
||||
set(current_task_keys)
|
||||
) != len(current_task_keys):
|
||||
raise ValueError(
|
||||
"Cannot replay because the stored task identities are ambiguous."
|
||||
)
|
||||
if stored_task_keys != current_task_keys:
|
||||
raise ValueError(
|
||||
"Cannot replay because the current crew does not match the stored task outputs."
|
||||
)
|
||||
return
|
||||
|
||||
stored_identities = [
|
||||
(
|
||||
stored_output["output"].get("description"),
|
||||
stored_output.get("expected_output"),
|
||||
)
|
||||
for stored_output in stored_prefix
|
||||
]
|
||||
current_identities = [
|
||||
(task.description, task.expected_output)
|
||||
for task in self.tasks[: start_index + 1]
|
||||
]
|
||||
if len(set(stored_identities)) != len(stored_identities) or len(
|
||||
set(current_identities)
|
||||
) != len(current_identities):
|
||||
raise ValueError(
|
||||
"Cannot replay because the stored task identities are ambiguous."
|
||||
)
|
||||
|
||||
for index, stored_output in enumerate(stored_prefix):
|
||||
task = self.tasks[index]
|
||||
output = stored_output["output"]
|
||||
stored_expected_output = stored_output.get("expected_output")
|
||||
if task.description != output.get("description") or (
|
||||
stored_expected_output is not None
|
||||
and task.expected_output != stored_expected_output
|
||||
):
|
||||
raise ValueError(
|
||||
"Cannot replay because the current crew does not match the stored task outputs."
|
||||
)
|
||||
|
||||
def replay(self, task_id: str, inputs: dict[str, Any] | None = None) -> CrewOutput:
|
||||
"""Replay the crew execution from a specific task."""
|
||||
stored_outputs = self._task_output_handler.load()
|
||||
@@ -2042,6 +2099,8 @@ class Crew(FlowTrackable, BaseModel):
|
||||
if start_index is None:
|
||||
raise ValueError(f"Task with id {task_id} not found in the crew's tasks.")
|
||||
|
||||
self._validate_replay_tasks(stored_outputs, start_index)
|
||||
|
||||
replay_inputs = (
|
||||
inputs if inputs is not None else stored_outputs[start_index]["inputs"]
|
||||
)
|
||||
|
||||
@@ -1439,7 +1439,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
|
||||
return "agent_finished"
|
||||
|
||||
formatted_answer = handle_max_iterations_exceeded(
|
||||
formatted_answer=None,
|
||||
printer=PRINTER,
|
||||
messages=list(self.state.messages),
|
||||
llm=self.llm,
|
||||
|
||||
@@ -929,13 +929,13 @@ class LiteAgent(FlowTrackable, BaseModel):
|
||||
try:
|
||||
if has_reached_max_iterations(self._iterations, self.max_iterations):
|
||||
formatted_answer = handle_max_iterations_exceeded(
|
||||
formatted_answer,
|
||||
printer=PRINTER,
|
||||
messages=self._messages,
|
||||
llm=cast(LLM, self.llm),
|
||||
callbacks=self._callbacks,
|
||||
verbose=self.verbose,
|
||||
)
|
||||
break
|
||||
|
||||
enforce_rpm_limit(self.request_within_rpm_limit)
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ class KickoffTaskOutputsSQLiteStorage:
|
||||
"""Initialize the SQLite database and create the latest_kickoff_task_outputs table.
|
||||
|
||||
This method sets up the database schema for storing task outputs. It creates
|
||||
a table with columns for task_id, expected_output, output (as JSON),
|
||||
task_index, inputs (as JSON), was_replayed flag, and timestamp.
|
||||
a table with columns for task_id, task_key, expected_output, output (as
|
||||
JSON), task_index, inputs (as JSON), was_replayed flag, and timestamp.
|
||||
|
||||
Raises:
|
||||
DatabaseOperationError: If database initialization fails due to SQLite errors.
|
||||
@@ -47,6 +47,7 @@ class KickoffTaskOutputsSQLiteStorage:
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS latest_kickoff_task_outputs (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
task_key TEXT,
|
||||
expected_output TEXT,
|
||||
output JSON,
|
||||
task_index INTEGER,
|
||||
@@ -56,6 +57,16 @@ class KickoffTaskOutputsSQLiteStorage:
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
row[1]
|
||||
for row in cursor.execute(
|
||||
"PRAGMA table_info(latest_kickoff_task_outputs)"
|
||||
)
|
||||
}
|
||||
if "task_key" not in columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE latest_kickoff_task_outputs ADD COLUMN task_key TEXT"
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
@@ -92,11 +103,12 @@ class KickoffTaskOutputsSQLiteStorage:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO latest_kickoff_task_outputs
|
||||
(task_id, expected_output, output, task_index, inputs, was_replayed)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
(task_id, task_key, expected_output, output, task_index, inputs, was_replayed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(task.id),
|
||||
task.key,
|
||||
task.expected_output,
|
||||
json.dumps(output, cls=CrewJSONEncoder),
|
||||
task_index,
|
||||
@@ -174,7 +186,7 @@ class KickoffTaskOutputsSQLiteStorage:
|
||||
with sqlite3.connect(self.db_path, timeout=30) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
SELECT task_id, task_key, expected_output, output, task_index, inputs, was_replayed, timestamp
|
||||
FROM latest_kickoff_task_outputs
|
||||
ORDER BY task_index
|
||||
""")
|
||||
@@ -184,12 +196,13 @@ class KickoffTaskOutputsSQLiteStorage:
|
||||
for row in rows:
|
||||
result = {
|
||||
"task_id": row[0],
|
||||
"expected_output": row[1],
|
||||
"output": json.loads(row[2]),
|
||||
"task_index": row[3],
|
||||
"inputs": json.loads(row[4]),
|
||||
"was_replayed": row[5],
|
||||
"timestamp": row[6],
|
||||
"task_key": row[1],
|
||||
"expected_output": row[2],
|
||||
"output": json.loads(row[3]),
|
||||
"task_index": row[4],
|
||||
"inputs": json.loads(row[5]),
|
||||
"was_replayed": row[6],
|
||||
"timestamp": row[7],
|
||||
}
|
||||
results.append(result)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -77,14 +78,15 @@ class LanceDBStorage:
|
||||
self._table_name = table_name
|
||||
self._db = lancedb.connect(str(self._path))
|
||||
|
||||
try:
|
||||
import resource
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
import resource
|
||||
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
if soft < 4096:
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 4096), hard))
|
||||
except Exception: # noqa: S110
|
||||
pass # Windows or already at the max hard limit — safe to ignore
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
if soft < 4096:
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 4096), hard))
|
||||
except Exception: # noqa: S110
|
||||
pass # Already at the max hard limit — safe to ignore
|
||||
|
||||
self._compact_every = compact_every
|
||||
self._save_count = 0
|
||||
|
||||
@@ -1820,6 +1820,9 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li
|
||||
)
|
||||
if not tool_def:
|
||||
continue
|
||||
if tool_def.startswith("platform:"):
|
||||
tools.extend(_resolve_platform_tools(tool_def.removeprefix("platform:")))
|
||||
continue
|
||||
if tool_def.startswith("custom:"):
|
||||
tools.append(_resolve_custom_tool(tool_def[7:], project_root=project_root))
|
||||
continue
|
||||
@@ -1847,6 +1850,30 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li
|
||||
return tools
|
||||
|
||||
|
||||
def _resolve_platform_tools(selector: str) -> list[Any]:
|
||||
"""Materialize an AMP application selector into CrewAI tools."""
|
||||
if not selector:
|
||||
raise JSONProjectError(
|
||||
"Invalid platform tool reference 'platform:': expected "
|
||||
"'platform:<application>'"
|
||||
)
|
||||
|
||||
try:
|
||||
from crewai_tools import CrewaiPlatformTools
|
||||
except ImportError as e:
|
||||
raise JSONProjectError(
|
||||
"Platform tools require the 'crewai-tools' package. "
|
||||
"Install CrewAI with the tools extra."
|
||||
) from e
|
||||
|
||||
try:
|
||||
return CrewaiPlatformTools(apps=[selector])
|
||||
except Exception as e:
|
||||
raise JSONProjectError(
|
||||
f"Failed to initialize platform tool 'platform:{selector}': {e}"
|
||||
) from e
|
||||
|
||||
|
||||
def _instantiate_tool_import_ref(ref: str) -> Any:
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
@@ -1990,19 +2017,25 @@ def _tool_definition_errors(
|
||||
f"got {type(tool_def).__name__}"
|
||||
)
|
||||
continue
|
||||
if not tool_def.startswith("custom:"):
|
||||
if tool_def.startswith("platform:"):
|
||||
if not tool_def.removeprefix("platform:"):
|
||||
errors.append(
|
||||
f"{source}: invalid platform tool reference 'platform:': "
|
||||
"expected 'platform:<application>'"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
tool_file = _custom_tool_file(tool_def[7:], project_root)
|
||||
except JSONProjectError as exc:
|
||||
errors.append(f"{source}: {exc}")
|
||||
continue
|
||||
if not tool_file.exists():
|
||||
errors.append(
|
||||
f"{source}: custom tool '{tool_def}' not found: expected "
|
||||
f"{tool_file}. Create the file with a BaseTool subclass, or "
|
||||
f"remove the tool from your crew JSON."
|
||||
)
|
||||
if tool_def.startswith("custom:"):
|
||||
try:
|
||||
tool_file = _custom_tool_file(tool_def[7:], project_root)
|
||||
except JSONProjectError as exc:
|
||||
errors.append(f"{source}: {exc}")
|
||||
continue
|
||||
if not tool_file.exists():
|
||||
errors.append(
|
||||
f"{source}: custom tool '{tool_def}' not found: expected "
|
||||
f"{tool_file}. Create the file with a BaseTool subclass, or "
|
||||
f"remove the tool from your crew JSON."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class InstructorProvider(BaseEmbeddingsProvider[InstructorEmbeddingFunction]):
|
||||
)
|
||||
device: str = Field(
|
||||
default="cpu",
|
||||
description="Device to run model on (cpu or cuda)",
|
||||
description="Device to run model on (e.g., cpu, cuda, mps, xpu)",
|
||||
validation_alias=AliasChoices(
|
||||
"EMBEDDINGS_INSTRUCTOR_DEVICE", "INSTRUCTOR_DEVICE"
|
||||
),
|
||||
|
||||
@@ -28,7 +28,7 @@ class SentenceTransformerProvider(
|
||||
)
|
||||
device: str = Field(
|
||||
default="cpu",
|
||||
description="Device to run model on (cpu or cuda)",
|
||||
description="Device to run model on (e.g., cpu, cuda, mps, xpu)",
|
||||
validation_alias=AliasChoices(
|
||||
"EMBEDDINGS_SENTENCE_TRANSFORMER_DEVICE", "SENTENCE_TRANSFORMER_DEVICE"
|
||||
),
|
||||
|
||||
@@ -374,7 +374,6 @@ def has_reached_max_iterations(iterations: int, max_iterations: int) -> bool:
|
||||
|
||||
|
||||
def handle_max_iterations_exceeded(
|
||||
formatted_answer: AgentAction | AgentFinish | None,
|
||||
printer: Printer,
|
||||
messages: list[LLMMessage],
|
||||
llm: LLM | BaseLLM,
|
||||
@@ -384,9 +383,9 @@ def handle_max_iterations_exceeded(
|
||||
"""Handles the case when the maximum number of iterations is exceeded. Performs one more LLM call to get the final answer.
|
||||
|
||||
Args:
|
||||
formatted_answer: The last formatted answer from the agent.
|
||||
printer: Printer instance for output.
|
||||
messages: List of messages to send to the LLM.
|
||||
messages: Conversation so far; the forced-answer instruction is appended
|
||||
to it as a user turn.
|
||||
llm: The LLM instance to call.
|
||||
callbacks: List of callbacks for the LLM call.
|
||||
verbose: Whether to print output.
|
||||
@@ -400,14 +399,11 @@ def handle_max_iterations_exceeded(
|
||||
color="yellow",
|
||||
)
|
||||
|
||||
if formatted_answer and hasattr(formatted_answer, "text"):
|
||||
assistant_message = (
|
||||
formatted_answer.text + f"\n{I18N_DEFAULT.errors('force_final_answer')}"
|
||||
)
|
||||
else:
|
||||
assistant_message = I18N_DEFAULT.errors("force_final_answer")
|
||||
|
||||
messages.append(format_message_for_llm(assistant_message, role="assistant"))
|
||||
# A trailing assistant turn is a prefill request, which current Claude
|
||||
# models reject with a 400; every provider accepts a trailing user turn.
|
||||
messages.append(
|
||||
format_message_for_llm(I18N_DEFAULT.errors("force_final_answer"), role="user")
|
||||
)
|
||||
|
||||
answer = llm.call(
|
||||
messages,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import contextvars
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
@@ -175,11 +174,11 @@ def create_tool_function(crew: Crew, messages: list[LLMMessage]) -> Any:
|
||||
|
||||
def flush_input() -> None:
|
||||
"""Flush any pending input from the user."""
|
||||
if platform.system() == "Windows":
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
while msvcrt.kbhit(): # type: ignore[attr-defined]
|
||||
msvcrt.getch() # type: ignore[attr-defined]
|
||||
while msvcrt.kbhit():
|
||||
msvcrt.getch()
|
||||
else:
|
||||
import termios
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class TaskOutputStorageHandler:
|
||||
if log.get("was_replayed", False):
|
||||
replayed = {
|
||||
"task_id": str(log["task"].id),
|
||||
"task_key": log["task"].key,
|
||||
"expected_output": log["task"].expected_output,
|
||||
"output": log["output"],
|
||||
"was_replayed": log["was_replayed"],
|
||||
|
||||
@@ -2889,3 +2889,101 @@ class TestSharedLLMStopWords:
|
||||
|
||||
assert seen == [{"Original:", "Observation:"}]
|
||||
assert shared.stop == ["Original:"]
|
||||
|
||||
|
||||
class TestMaxIterationsForcedAnswer:
|
||||
"""Both sync loops request the forced final answer with a trailing user turn.
|
||||
|
||||
Current Claude models reject a request that ends on an assistant message,
|
||||
so the nudge must never be sent as assistant prefill.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _make_executor(llm: MagicMock, original_tools: list) -> CrewAgentExecutor:
|
||||
from crewai.agents.tools_handler import ToolsHandler
|
||||
|
||||
agent = Agent(role="r", goal="g", backstory="b", llm=llm, verbose=False)
|
||||
task = Task(description="d", expected_output="o", agent=agent)
|
||||
executor = CrewAgentExecutor(
|
||||
agent=agent,
|
||||
task=task,
|
||||
llm=llm,
|
||||
crew=None,
|
||||
prompt={"prompt": "p {input} {tool_names} {tools}"},
|
||||
max_iter=1,
|
||||
tools=[],
|
||||
original_tools=original_tools,
|
||||
tools_names="",
|
||||
stop_words=[],
|
||||
tools_description="",
|
||||
tools_handler=ToolsHandler(),
|
||||
)
|
||||
executor.iterations = 1
|
||||
return executor
|
||||
|
||||
def test_react_loop_forces_final_answer_with_user_turn(self) -> None:
|
||||
from crewai.utilities.i18n import I18N_DEFAULT
|
||||
|
||||
llm = MagicMock(spec=LLM)
|
||||
llm.stop = []
|
||||
llm.supports_stop_words.return_value = True
|
||||
llm.supports_function_calling.return_value = False
|
||||
llm.call.return_value = "Final Answer: forced"
|
||||
executor = self._make_executor(llm, original_tools=[])
|
||||
executor.messages = [
|
||||
{"role": "user", "content": "Collect all the data."},
|
||||
{"role": "assistant", "content": "Thought: I need data\nObservation: partial"},
|
||||
]
|
||||
|
||||
with patch.object(executor, "_show_logs"):
|
||||
result = executor._invoke_loop()
|
||||
|
||||
sent = llm.call.call_args.args[0]
|
||||
assert sent[-1] == {
|
||||
"role": "user",
|
||||
"content": I18N_DEFAULT.errors("force_final_answer"),
|
||||
}
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == "forced"
|
||||
|
||||
def test_native_tools_loop_forces_final_answer_with_user_turn(self) -> None:
|
||||
from crewai.utilities.i18n import I18N_DEFAULT
|
||||
|
||||
@tool
|
||||
def get_data(step: str) -> str:
|
||||
"""Get data for a step."""
|
||||
return f"data for {step}"
|
||||
|
||||
llm = MagicMock(spec=LLM)
|
||||
llm.stop = []
|
||||
llm.supports_stop_words.return_value = True
|
||||
llm.supports_function_calling.return_value = True
|
||||
llm.call.return_value = "Final Answer: forced"
|
||||
executor = self._make_executor(llm, original_tools=[get_data])
|
||||
executor.messages = [
|
||||
{"role": "user", "content": "Collect all the data."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_data", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "name": "get_data", "content": "partial"},
|
||||
{"role": "user", "content": I18N_DEFAULT.slice("post_tool_reasoning")},
|
||||
]
|
||||
|
||||
with patch.object(executor, "_show_logs"):
|
||||
result = executor._invoke_loop()
|
||||
|
||||
sent = llm.call.call_args.args[0]
|
||||
assert sent[-1] == {
|
||||
"role": "user",
|
||||
"content": I18N_DEFAULT.errors("force_final_answer"),
|
||||
}
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == "forced"
|
||||
|
||||
@@ -80,6 +80,7 @@ from crewai.utilities.step_execution_context import StepExecutionContext
|
||||
from crewai.utilities.planning_types import TodoItem, TodoList
|
||||
from crewai.utilities.prompts import StandardPromptResult, SystemPromptResult
|
||||
from crewai.utilities.file_store import clear_files, clear_task_files, store_files
|
||||
from crewai.utilities.i18n import I18N_DEFAULT
|
||||
from crewai_files import TextFile
|
||||
|
||||
class TestAgentExecutorState:
|
||||
@@ -2640,3 +2641,40 @@ class TestVisionImageFormatContract:
|
||||
assert hasattr(AnthropicCompletion, "_convert_image_blocks"), (
|
||||
"Anthropic provider must have _convert_image_blocks for auto-conversion"
|
||||
)
|
||||
|
||||
|
||||
class TestEnsureForceFinalAnswer:
|
||||
"""The forced final answer is requested with a trailing user turn."""
|
||||
|
||||
def test_forced_answer_request_ends_on_a_user_turn(self):
|
||||
llm = Mock()
|
||||
llm.call.return_value = "Final Answer: forced"
|
||||
executor = _build_executor(
|
||||
llm=llm, agent=SimpleNamespace(verbose=False), callbacks=[]
|
||||
)
|
||||
executor.state.messages = [
|
||||
{"role": "user", "content": "Collect all the data."},
|
||||
{"role": "assistant", "content": "Thought: I need data\nObservation: partial"},
|
||||
]
|
||||
|
||||
result = AgentExecutor.ensure_force_final_answer(executor)
|
||||
|
||||
assert result == "agent_finished"
|
||||
sent = llm.call.call_args.args[0]
|
||||
assert sent[-1] == {
|
||||
"role": "user",
|
||||
"content": I18N_DEFAULT.errors("force_final_answer"),
|
||||
}
|
||||
assert isinstance(executor.state.current_answer, AgentFinish)
|
||||
assert executor.state.current_answer.output == "forced"
|
||||
assert executor.state.is_finished is True
|
||||
|
||||
def test_skips_the_llm_call_once_finished(self):
|
||||
llm = Mock()
|
||||
executor = _build_executor(
|
||||
llm=llm, agent=SimpleNamespace(verbose=False), callbacks=[]
|
||||
)
|
||||
executor.state.is_finished = True
|
||||
|
||||
assert AgentExecutor.ensure_force_final_answer(executor) == "agent_finished"
|
||||
llm.call.assert_not_called()
|
||||
|
||||
@@ -10,9 +10,12 @@ from crewai.agent import Agent
|
||||
from crewai.agents.crew_agent_executor import CrewAgentExecutor
|
||||
from crewai.agents.parser import AgentAction, AgentFinish
|
||||
from crewai.agents.tools_handler import ToolsHandler
|
||||
from crewai.llm import LLM
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
from crewai.task import Task
|
||||
from crewai.tools import tool
|
||||
from crewai.tools.tool_types import ToolResult
|
||||
from crewai.utilities.i18n import I18N_DEFAULT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -451,4 +454,90 @@ class TestAsyncLLMResponseHelper:
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
callbacks=[],
|
||||
printer=Printer(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestAsyncMaxIterationsForcedAnswer:
|
||||
"""Both async loops request the forced final answer with a trailing user turn."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_react_loop_forces_final_answer_with_user_turn(
|
||||
self, executor: CrewAgentExecutor, mock_llm: MagicMock
|
||||
) -> None:
|
||||
mock_llm.call.return_value = "Final Answer: forced"
|
||||
executor.iterations = executor.max_iter
|
||||
executor.messages = [
|
||||
{"role": "user", "content": "Collect all the data."},
|
||||
{"role": "assistant", "content": "Thought: I need data\nObservation: partial"},
|
||||
]
|
||||
|
||||
with patch.object(executor, "_show_logs"):
|
||||
result = await executor._ainvoke_loop()
|
||||
|
||||
sent = mock_llm.call.call_args.args[0]
|
||||
assert sent[-1] == {
|
||||
"role": "user",
|
||||
"content": I18N_DEFAULT.errors("force_final_answer"),
|
||||
}
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == "forced"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_tools_loop_forces_final_answer_with_user_turn(
|
||||
self,
|
||||
test_agent: Agent,
|
||||
test_task: Task,
|
||||
mock_tools_handler: MagicMock,
|
||||
) -> None:
|
||||
@tool
|
||||
def get_data(step: str) -> str:
|
||||
"""Get data for a step."""
|
||||
return f"data for {step}"
|
||||
|
||||
# BaseLLM has no supports_function_calling; the native loop needs it.
|
||||
llm = MagicMock(spec=LLM)
|
||||
llm.stop = []
|
||||
llm.supports_function_calling.return_value = True
|
||||
llm.call.return_value = "Final Answer: forced"
|
||||
executor = CrewAgentExecutor(
|
||||
llm=llm,
|
||||
task=test_task,
|
||||
crew=None,
|
||||
agent=test_agent,
|
||||
prompt={"prompt": "Test prompt {input} {tool_names} {tools}"},
|
||||
max_iter=1,
|
||||
tools=[],
|
||||
original_tools=[get_data],
|
||||
tools_names="get_data",
|
||||
stop_words=[],
|
||||
tools_description="",
|
||||
tools_handler=mock_tools_handler,
|
||||
)
|
||||
executor.iterations = 1
|
||||
executor.messages = [
|
||||
{"role": "user", "content": "Collect all the data."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_data", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "name": "get_data", "content": "partial"},
|
||||
{"role": "user", "content": I18N_DEFAULT.slice("post_tool_reasoning")},
|
||||
]
|
||||
|
||||
with patch.object(executor, "_show_logs"):
|
||||
result = await executor._ainvoke_loop()
|
||||
|
||||
sent = llm.call.call_args.args[0]
|
||||
assert sent[-1] == {
|
||||
"role": "user",
|
||||
"content": I18N_DEFAULT.errors("force_final_answer"),
|
||||
}
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == "forced"
|
||||
|
||||
@@ -1295,3 +1295,44 @@ class TestUsageMetricsDeltaSince:
|
||||
baseline = UsageMetrics(total_tokens=100, prompt_tokens=90, successful_requests=2)
|
||||
delta = UsageMetrics().delta_since(baseline)
|
||||
assert delta == UsageMetrics()
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:LiteAgent is deprecated")
|
||||
def test_lite_agent_forces_final_answer_with_user_turn():
|
||||
"""The forced final answer is requested with a trailing user turn, not assistant prefill."""
|
||||
from crewai.utilities.i18n import I18N_DEFAULT
|
||||
|
||||
requests: list[list[dict]] = []
|
||||
|
||||
def record_request(messages, **_kwargs):
|
||||
# Snapshot: the agent keeps appending to this same list after the call.
|
||||
requests.append([dict(message) for message in messages])
|
||||
return "Final Answer: forced"
|
||||
|
||||
mock_llm = Mock(spec=LLM)
|
||||
mock_llm.call.side_effect = record_request
|
||||
mock_llm.stop = []
|
||||
mock_llm.get_token_usage_summary.return_value = UsageMetrics(
|
||||
total_tokens=10,
|
||||
prompt_tokens=5,
|
||||
completion_tokens=5,
|
||||
cached_prompt_tokens=0,
|
||||
successful_requests=1,
|
||||
)
|
||||
agent = LiteAgent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
llm=mock_llm,
|
||||
max_iterations=0,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
result = agent.kickoff("Collect all the data.")
|
||||
|
||||
assert mock_llm.call.call_count == 1
|
||||
assert requests[0][-1] == {
|
||||
"role": "user",
|
||||
"content": I18N_DEFAULT.errors("force_final_answer"),
|
||||
}
|
||||
assert result.raw == "forced"
|
||||
|
||||
@@ -1955,3 +1955,72 @@ def test_tool_fallback_still_used_for_models_without_native_support():
|
||||
assert kwargs["tool_choice"] == {"type": "tool", "name": "structured_output"}
|
||||
assert "betas" not in kwargs
|
||||
mock_client.beta.messages.create.assert_not_called()
|
||||
|
||||
|
||||
def _final_answer_response(text: str):
|
||||
from anthropic.types import TextBlock
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [TextBlock(type="text", text=text, citations=None)]
|
||||
mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
|
||||
mock_response.stop_reason = "end_turn"
|
||||
mock_response.id = "msg_forced_answer"
|
||||
return mock_response
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"history_tail",
|
||||
[
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_data", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "toolu_1", "name": "get_data", "content": "partial"},
|
||||
{"role": "user", "content": "Analyze the tool result."},
|
||||
],
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Thought: I need data\nAction: get_data\nAction Input: {}\nObservation: partial",
|
||||
},
|
||||
],
|
||||
],
|
||||
ids=["native-tools", "react"],
|
||||
)
|
||||
def test_max_iterations_request_ends_on_a_user_turn(history_tail):
|
||||
"""Claude 4.6+ rejects a request whose last message is an assistant turn (no prefill).
|
||||
|
||||
Regression for the max_iter path: the forced final-answer instruction must
|
||||
reach the Anthropic API as the trailing user message, on both loop shapes.
|
||||
"""
|
||||
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
|
||||
from crewai.utilities.agent_utils import handle_max_iterations_exceeded
|
||||
from crewai.utilities.i18n import I18N_DEFAULT
|
||||
|
||||
llm = AnthropicCompletion(model="claude-opus-5")
|
||||
mock_client = MagicMock()
|
||||
mock_client.messages.create.return_value = _final_answer_response("Final Answer: 42")
|
||||
llm._client = mock_client
|
||||
|
||||
history = [
|
||||
{"role": "system", "content": "You are an agent."},
|
||||
{"role": "user", "content": "Collect all the data."},
|
||||
*history_tail,
|
||||
]
|
||||
|
||||
result = handle_max_iterations_exceeded(
|
||||
printer=MagicMock(), messages=history, llm=llm, callbacks=[], verbose=False
|
||||
)
|
||||
|
||||
mock_client.messages.create.assert_called_once()
|
||||
sent = mock_client.messages.create.call_args.kwargs["messages"]
|
||||
assert sent[-1] == {"role": "user", "content": I18N_DEFAULT.errors("force_final_answer")}
|
||||
assert result.output == "42"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
@@ -8,6 +6,7 @@ from crewai.llm import LLM
|
||||
from crewai.crew import Crew
|
||||
from crewai.agent import Agent
|
||||
from crewai.task import Task
|
||||
from crewai.llms.providers.bedrock import completion as bedrock_completion
|
||||
|
||||
|
||||
def _create_bedrock_mocks():
|
||||
@@ -134,25 +133,6 @@ def test_bedrock_completion_is_used_when_bedrock_provider():
|
||||
assert llm.model == "anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||
|
||||
|
||||
def test_bedrock_completion_module_is_imported(monkeypatch):
|
||||
"""
|
||||
Test that the completion module is properly imported when using Bedrock provider
|
||||
"""
|
||||
module_name = "crewai.llms.providers.bedrock.completion"
|
||||
|
||||
# Restore the original module after this test so collected class references
|
||||
# still match the provider returned by LLM in subsequent tests.
|
||||
monkeypatch.delitem(sys.modules, module_name, raising=False)
|
||||
|
||||
LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
assert module_name in sys.modules
|
||||
completion_mod = sys.modules[module_name]
|
||||
assert isinstance(completion_mod, types.ModuleType)
|
||||
|
||||
assert hasattr(completion_mod, 'BedrockCompletion')
|
||||
|
||||
|
||||
def test_native_bedrock_raises_error_when_initialization_fails():
|
||||
"""
|
||||
Test that LLM raises ImportError when native Bedrock completion fails.
|
||||
@@ -602,24 +582,32 @@ def test_bedrock_tool_conversion():
|
||||
assert "inputSchema" in bedrock_tools[0]["toolSpec"]
|
||||
|
||||
|
||||
def test_bedrock_environment_variable_credentials(bedrock_mocks):
|
||||
"""
|
||||
Test that AWS credentials are properly loaded from environment
|
||||
"""
|
||||
mock_session_class, _ = bedrock_mocks
|
||||
def test_bedrock_environment_variable_credentials(monkeypatch):
|
||||
"""Pass AWS credentials and region from the environment to boto3."""
|
||||
monkeypatch.delenv("AWS_SESSION_TOKEN", raising=False)
|
||||
|
||||
mock_session_class.reset_mock()
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"AWS_ACCESS_KEY_ID": "test-access-key-123",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret-key-456"
|
||||
}):
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-access-key-123",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret-key-456",
|
||||
"AWS_DEFAULT_REGION": "eu-west-1",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
patch.object(bedrock_completion, "Session") as mock_session_class,
|
||||
):
|
||||
mock_session_class.return_value.client.return_value = MagicMock()
|
||||
llm = LLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
assert mock_session_class.called
|
||||
call_kwargs = mock_session_class.call_args[1] if mock_session_class.call_args else {}
|
||||
assert call_kwargs.get('aws_access_key_id') == "test-access-key-123"
|
||||
assert call_kwargs.get('aws_secret_access_key') == "test-secret-key-456"
|
||||
assert type(llm) is bedrock_completion.BedrockCompletion
|
||||
mock_session_class.assert_called_once_with(
|
||||
aws_access_key_id="test-access-key-123",
|
||||
aws_secret_access_key="test-secret-key-456",
|
||||
aws_session_token=None,
|
||||
region_name="eu-west-1",
|
||||
)
|
||||
|
||||
|
||||
def test_bedrock_token_usage_tracking():
|
||||
|
||||
@@ -403,6 +403,52 @@ class TestLoadAgentFromDefinition:
|
||||
|
||||
|
||||
class TestResolveTools:
|
||||
def test_platform_tool_refs_materialize_multiple_application_tools(
|
||||
self, monkeypatch
|
||||
):
|
||||
from crewai.project.json_loader import _resolve_tools
|
||||
|
||||
github_tools = [object()]
|
||||
linear_tools = [object(), object()]
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def build_platform_tools(apps: list[str]):
|
||||
calls.append(apps)
|
||||
return {
|
||||
"github": github_tools,
|
||||
"linear": linear_tools,
|
||||
}[apps[0]]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_tools.CrewaiPlatformTools", build_platform_tools
|
||||
)
|
||||
|
||||
tools = _resolve_tools(["platform:github", "platform:linear"])
|
||||
|
||||
assert tools == github_tools + linear_tools
|
||||
assert calls == [["github"], ["linear"]]
|
||||
|
||||
def test_empty_platform_tool_ref_raises_with_guidance(self):
|
||||
from crewai.project.json_loader import JSONProjectError, _resolve_tools
|
||||
|
||||
with pytest.raises(JSONProjectError, match="platform:<application>"):
|
||||
_resolve_tools(["platform:"])
|
||||
|
||||
def test_platform_tool_discovery_errors_are_json_project_errors(
|
||||
self, monkeypatch
|
||||
):
|
||||
from crewai.project.json_loader import JSONProjectError, _resolve_tools
|
||||
|
||||
def fail_platform_tool_discovery(apps: list[str]):
|
||||
raise RuntimeError("discovery unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_tools.CrewaiPlatformTools", fail_platform_tool_discovery
|
||||
)
|
||||
|
||||
with pytest.raises(JSONProjectError, match="discovery unavailable"):
|
||||
_resolve_tools(["platform:github"])
|
||||
|
||||
def test_import_ref_tool_resolves(self, tmp_path, monkeypatch):
|
||||
from crewai.project.json_loader import _resolve_tools
|
||||
|
||||
@@ -614,6 +660,19 @@ class TestValidationDoesNotExecuteTools:
|
||||
|
||||
assert "Invalid custom tool name" in str(exc_info.value)
|
||||
|
||||
def test_validate_rejects_empty_platform_tool_ref(self, tmp_path):
|
||||
from crewai.project.json_loader import (
|
||||
JSONProjectValidationError,
|
||||
validate_crew_project,
|
||||
)
|
||||
|
||||
crew_path = self._write_project(tmp_path, tool_line='"platform:"')
|
||||
|
||||
with pytest.raises(JSONProjectValidationError) as exc_info:
|
||||
validate_crew_project(crew_path, tmp_path / "agents")
|
||||
|
||||
assert "platform:<application>" in str(exc_info.value)
|
||||
|
||||
def test_validate_rejects_deep_python_ref_nesting(self, tmp_path):
|
||||
from crewai.project.json_loader import validate_crew_project
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for backward compatibility of embedding provider configurations."""
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.rag.embeddings.factory import build_embedder, PROVIDER_PATHS
|
||||
from crewai.rag.embeddings.providers.openai.openai_provider import OpenAIProvider
|
||||
from crewai.rag.embeddings.providers.cohere.cohere_provider import CohereProvider
|
||||
@@ -357,15 +359,19 @@ class TestDocumentationCodeSnippets:
|
||||
)
|
||||
assert provider.model_name == "jina-embeddings-v3"
|
||||
|
||||
def test_ragtool_sentence_transformer_config(self):
|
||||
"""Test RagTool SentenceTransformer config from ragtool.mdx."""
|
||||
@pytest.mark.parametrize("device", ["cpu", "cuda", "mps", "xpu"])
|
||||
def test_ragtool_sentence_transformer_config(self, device: str):
|
||||
"""Test RagTool SentenceTransformer config from ragtool.mdx.
|
||||
|
||||
Parametrized over documented device strings to confirm each
|
||||
value is preserved."""
|
||||
provider = SentenceTransformerProvider(
|
||||
model_name="all-mpnet-base-v2",
|
||||
device="cuda",
|
||||
device=device,
|
||||
normalize_embeddings=True,
|
||||
)
|
||||
assert provider.model_name == "all-mpnet-base-v2"
|
||||
assert provider.device == "cuda"
|
||||
assert provider.device == device
|
||||
assert provider.normalize_embeddings is True
|
||||
|
||||
|
||||
|
||||
@@ -3045,16 +3045,38 @@ def test_replay_feature(researcher, writer):
|
||||
)
|
||||
|
||||
with patch.object(Task, "execute_sync") as mock_execute_task:
|
||||
mock_execute_task.return_value = TaskOutput(
|
||||
description="Mock description",
|
||||
raw="Mocked output for list of ideas",
|
||||
agent="Researcher",
|
||||
json_dict=None,
|
||||
output_format=OutputFormat.RAW,
|
||||
pydantic=None,
|
||||
summary="Mocked output for list of ideas",
|
||||
messages=[],
|
||||
)
|
||||
mock_execute_task.side_effect = [
|
||||
TaskOutput(
|
||||
description=list_ideas.description,
|
||||
raw="Mocked output for list of ideas",
|
||||
agent="Researcher",
|
||||
json_dict=None,
|
||||
output_format=OutputFormat.RAW,
|
||||
pydantic=None,
|
||||
summary="Mocked output for list of ideas",
|
||||
messages=[],
|
||||
),
|
||||
TaskOutput(
|
||||
description=write.description,
|
||||
raw="Mocked output for list of ideas",
|
||||
agent="Researcher",
|
||||
json_dict=None,
|
||||
output_format=OutputFormat.RAW,
|
||||
pydantic=None,
|
||||
summary="Mocked output for list of ideas",
|
||||
messages=[],
|
||||
),
|
||||
TaskOutput(
|
||||
description=write.description,
|
||||
raw="Mocked output for list of ideas",
|
||||
agent="Researcher",
|
||||
json_dict=None,
|
||||
output_format=OutputFormat.RAW,
|
||||
pydantic=None,
|
||||
summary="Mocked output for list of ideas",
|
||||
messages=[],
|
||||
),
|
||||
]
|
||||
|
||||
crew.kickoff()
|
||||
crew.replay(str(write.id))
|
||||
@@ -3062,6 +3084,154 @@ def test_replay_feature(researcher, writer):
|
||||
assert mock_execute_task.call_count == 3
|
||||
|
||||
|
||||
def test_replay_rejects_changed_task_order(researcher):
|
||||
"""Replay must not restore a saved output onto a different current task."""
|
||||
research = Task(
|
||||
description="Research the topic",
|
||||
expected_output="Research notes",
|
||||
agent=researcher,
|
||||
)
|
||||
write = Task(
|
||||
description="Write the article",
|
||||
expected_output="An article",
|
||||
agent=researcher,
|
||||
)
|
||||
plan = Task(
|
||||
description="Plan the article",
|
||||
expected_output="An outline",
|
||||
agent=researcher,
|
||||
)
|
||||
crew = Crew(agents=[researcher], tasks=[plan, research, write])
|
||||
|
||||
stored_outputs = [
|
||||
{
|
||||
"task_id": str(research.id),
|
||||
"expected_output": research.expected_output,
|
||||
"output": {"description": research.description},
|
||||
"inputs": {},
|
||||
},
|
||||
{
|
||||
"task_id": str(write.id),
|
||||
"expected_output": write.expected_output,
|
||||
"output": {"description": write.description},
|
||||
"inputs": {},
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"crewai.utilities.task_output_storage_handler.TaskOutputStorageHandler.load",
|
||||
return_value=stored_outputs,
|
||||
):
|
||||
with pytest.raises(ValueError, match="current crew does not match"):
|
||||
crew.replay(str(write.id))
|
||||
|
||||
|
||||
def test_replay_rejects_reordered_tasks_with_matching_expected_output(researcher):
|
||||
"""Task descriptions keep replay from confusing tasks with the same expected output."""
|
||||
research = Task(
|
||||
description="Research the topic",
|
||||
expected_output="A report",
|
||||
agent=researcher,
|
||||
)
|
||||
write = Task(
|
||||
description="Write the article",
|
||||
expected_output="A report",
|
||||
agent=researcher,
|
||||
)
|
||||
crew = Crew(agents=[researcher], tasks=[write, research])
|
||||
|
||||
stored_outputs = [
|
||||
{
|
||||
"task_id": str(research.id),
|
||||
"expected_output": research.expected_output,
|
||||
"output": {"description": research.description},
|
||||
"inputs": {},
|
||||
},
|
||||
{
|
||||
"task_id": str(write.id),
|
||||
"expected_output": write.expected_output,
|
||||
"output": {"description": write.description},
|
||||
"inputs": {},
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"crewai.utilities.task_output_storage_handler.TaskOutputStorageHandler.load",
|
||||
return_value=stored_outputs,
|
||||
):
|
||||
with pytest.raises(ValueError, match="current crew does not match"):
|
||||
crew.replay(str(write.id))
|
||||
|
||||
|
||||
def test_replay_rejects_ambiguous_task_identities(researcher):
|
||||
"""Replay must fail loud when persisted task details cannot identify a task."""
|
||||
first_task = Task(
|
||||
description="Write a report",
|
||||
expected_output="A report",
|
||||
agent=researcher,
|
||||
)
|
||||
second_task = Task(
|
||||
description="Write a report",
|
||||
expected_output="A report",
|
||||
agent=researcher,
|
||||
)
|
||||
crew = Crew(agents=[researcher], tasks=[second_task, first_task])
|
||||
|
||||
stored_outputs = [
|
||||
{
|
||||
"task_id": str(first_task.id),
|
||||
"expected_output": first_task.expected_output,
|
||||
"output": {"description": first_task.description},
|
||||
"inputs": {},
|
||||
},
|
||||
{
|
||||
"task_id": str(second_task.id),
|
||||
"expected_output": second_task.expected_output,
|
||||
"output": {"description": second_task.description},
|
||||
"inputs": {},
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"crewai.utilities.task_output_storage_handler.TaskOutputStorageHandler.load",
|
||||
return_value=stored_outputs,
|
||||
):
|
||||
with pytest.raises(ValueError, match="task identities are ambiguous"):
|
||||
crew.replay(str(second_task.id))
|
||||
|
||||
|
||||
def test_replay_uses_task_key_before_interpolating_new_inputs(researcher):
|
||||
"""A replayed template remains identifiable when the new inputs differ."""
|
||||
task = Task(
|
||||
description="Say hello to {name}",
|
||||
expected_output="A greeting for {name}",
|
||||
agent=researcher,
|
||||
)
|
||||
task.interpolate_inputs_and_add_conversation_history({"name": "John"})
|
||||
stored_output = {
|
||||
"task_id": str(task.id),
|
||||
"task_key": task.key,
|
||||
"expected_output": task.expected_output,
|
||||
"output": {"description": task.description},
|
||||
"inputs": {"name": "John"},
|
||||
}
|
||||
replay_task = Task(
|
||||
description="Say hello to {name}",
|
||||
expected_output="A greeting for {name}",
|
||||
agent=researcher,
|
||||
)
|
||||
crew = Crew(agents=[researcher], tasks=[replay_task])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"crewai.utilities.task_output_storage_handler.TaskOutputStorageHandler.load",
|
||||
return_value=[stored_output],
|
||||
),
|
||||
patch.object(crew, "_execute_tasks"),
|
||||
):
|
||||
crew.replay(str(task.id), inputs={"name": "Maria"})
|
||||
|
||||
assert replay_task.description == "Say hello to Maria"
|
||||
assert replay_task.expected_output == "A greeting for Maria"
|
||||
|
||||
|
||||
@pytest.mark.vcr()
|
||||
def test_crew_replay_error(researcher, writer):
|
||||
task = Task(
|
||||
@@ -3292,7 +3462,7 @@ def test_replay_with_context():
|
||||
)
|
||||
|
||||
context_output = TaskOutput(
|
||||
description="Context Task Output",
|
||||
description=task1.description,
|
||||
agent="test_agent",
|
||||
raw="context raw output",
|
||||
pydantic=None,
|
||||
@@ -3309,6 +3479,7 @@ def test_replay_with_context():
|
||||
return_value=[
|
||||
{
|
||||
"task_id": str(task1.id),
|
||||
"expected_output": task1.expected_output,
|
||||
"output": {
|
||||
"description": context_output.description,
|
||||
"summary": context_output.summary,
|
||||
@@ -3322,8 +3493,9 @@ def test_replay_with_context():
|
||||
},
|
||||
{
|
||||
"task_id": str(task2.id),
|
||||
"expected_output": task2.expected_output,
|
||||
"output": {
|
||||
"description": "Test Task Output",
|
||||
"description": task2.description,
|
||||
"summary": None,
|
||||
"raw": "test raw output",
|
||||
"pydantic": None,
|
||||
@@ -3394,6 +3566,7 @@ def test_replay_with_invalid_task_id():
|
||||
return_value=[
|
||||
{
|
||||
"task_id": str(task1.id),
|
||||
"task_key": task1.key,
|
||||
"output": {
|
||||
"description": context_output.description,
|
||||
"summary": context_output.summary,
|
||||
@@ -3407,6 +3580,7 @@ def test_replay_with_invalid_task_id():
|
||||
},
|
||||
{
|
||||
"task_id": str(task2.id),
|
||||
"task_key": task2.key,
|
||||
"output": {
|
||||
"description": "Test Task Output",
|
||||
"summary": None,
|
||||
@@ -3460,6 +3634,7 @@ def test_replay_interpolates_inputs_properly(mock_interpolate_inputs):
|
||||
return_value=[
|
||||
{
|
||||
"task_id": str(task1.id),
|
||||
"task_key": task1.key,
|
||||
"output": {
|
||||
"description": context_output.description,
|
||||
"summary": context_output.summary,
|
||||
@@ -3473,6 +3648,7 @@ def test_replay_interpolates_inputs_properly(mock_interpolate_inputs):
|
||||
},
|
||||
{
|
||||
"task_id": str(task2.id),
|
||||
"task_key": task2.key,
|
||||
"output": {
|
||||
"description": "Test Task Output",
|
||||
"summary": None,
|
||||
@@ -3501,7 +3677,7 @@ def test_replay_setup_context():
|
||||
agent=agent,
|
||||
)
|
||||
context_output = TaskOutput(
|
||||
description="Context Task Output",
|
||||
description=task1.description,
|
||||
agent="test_agent",
|
||||
raw="context raw output",
|
||||
pydantic=None,
|
||||
@@ -3525,12 +3701,13 @@ def test_replay_setup_context():
|
||||
"output_format": context_output.output_format,
|
||||
"agent": context_output.agent,
|
||||
},
|
||||
"expected_output": task1.expected_output,
|
||||
"inputs": {"name": "John"},
|
||||
},
|
||||
{
|
||||
"task_id": str(task2.id),
|
||||
"output": {
|
||||
"description": "Test Task Output",
|
||||
"description": task2.description,
|
||||
"summary": None,
|
||||
"raw": "test raw output",
|
||||
"pydantic": None,
|
||||
@@ -3538,6 +3715,7 @@ def test_replay_setup_context():
|
||||
"output_format": "json",
|
||||
"agent": "test_agent",
|
||||
},
|
||||
"expected_output": task2.expected_output,
|
||||
"inputs": {"name": "John"},
|
||||
},
|
||||
],
|
||||
@@ -3546,7 +3724,7 @@ def test_replay_setup_context():
|
||||
|
||||
assert crew.tasks[0].output is not None
|
||||
assert isinstance(crew.tasks[0].output, TaskOutput)
|
||||
assert crew.tasks[0].output.description == "Context Task Output"
|
||||
assert crew.tasks[0].output.description == task1.description
|
||||
assert crew.tasks[0].output.agent == "test_agent"
|
||||
assert crew.tasks[0].output.raw == "context raw output"
|
||||
assert crew.tasks[0].output.output_format == OutputFormat.RAW
|
||||
|
||||
@@ -16,6 +16,7 @@ from crewai.hooks.tool_hooks import (
|
||||
clear_before_tool_call_hooks,
|
||||
register_after_tool_call_hook,
|
||||
)
|
||||
from crewai.agents.parser import AgentFinish
|
||||
from crewai.tools.base_tool import BaseTool
|
||||
from crewai.llm import CONTEXT_WINDOW_USAGE_RATIO
|
||||
from crewai.utilities.agent_utils import (
|
||||
@@ -30,6 +31,7 @@ from crewai.utilities.agent_utils import (
|
||||
_split_text_by_token_limit,
|
||||
format_message_for_llm,
|
||||
convert_tools_to_openai_schema,
|
||||
handle_max_iterations_exceeded,
|
||||
execute_single_native_tool_call,
|
||||
extract_tool_call_info,
|
||||
is_tool_call_list,
|
||||
@@ -1652,3 +1654,107 @@ class TestResolvePlusResponse:
|
||||
resolve_plus_response(future)
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
_FORCE_FINAL_ANSWER = I18N_DEFAULT.errors("force_final_answer")
|
||||
|
||||
|
||||
def _native_tool_history() -> list[dict[str, Any]]:
|
||||
"""History as the native tool-calling loop leaves it: ends on a user prompt."""
|
||||
return [
|
||||
{"role": "system", "content": "You are an agent."},
|
||||
{"role": "user", "content": "Collect all the data."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_data", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "name": "get_data", "content": "partial"},
|
||||
{"role": "user", "content": I18N_DEFAULT.slice("post_tool_reasoning")},
|
||||
]
|
||||
|
||||
|
||||
def _react_history() -> list[dict[str, Any]]:
|
||||
"""History as the ReAct loop leaves it: ends on the assistant turn with the observation."""
|
||||
return [
|
||||
{"role": "system", "content": "You are an agent."},
|
||||
{"role": "user", "content": "Collect all the data."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Thought: I need data\nAction: get_data\nAction Input: {}\nObservation: partial",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestHandleMaxIterationsExceeded:
|
||||
"""The forced final answer is requested with a user turn, never assistant prefill.
|
||||
|
||||
Current Claude models reject a request whose last message is an assistant
|
||||
turn ("This model does not support assistant message prefill"), so the
|
||||
nudge must go out as the user's instruction on every loop shape.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"make_history", [_native_tool_history, _react_history], ids=["native-tools", "react"]
|
||||
)
|
||||
def test_appends_the_instruction_as_a_user_turn(self, make_history) -> None:
|
||||
history = make_history()
|
||||
before = [dict(message) for message in history]
|
||||
llm = MagicMock()
|
||||
llm.call.return_value = "Final Answer: 42"
|
||||
|
||||
result = handle_max_iterations_exceeded(
|
||||
printer=MagicMock(), messages=history, llm=llm, callbacks=[], verbose=False
|
||||
)
|
||||
|
||||
assert history[:-1] == before
|
||||
assert history[-1] == {"role": "user", "content": _FORCE_FINAL_ANSWER}
|
||||
llm.call.assert_called_once_with(history, callbacks=[])
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == "42"
|
||||
|
||||
def test_action_shaped_reply_still_becomes_a_final_answer(self) -> None:
|
||||
reply = "Thought: one more\nAction: get_data\nAction Input: {}"
|
||||
llm = MagicMock()
|
||||
llm.call.return_value = reply
|
||||
|
||||
result = handle_max_iterations_exceeded(
|
||||
printer=MagicMock(), messages=_react_history(), llm=llm, callbacks=[], verbose=False
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.text == reply
|
||||
assert result.output == reply
|
||||
|
||||
@pytest.mark.parametrize("reply", [None, ""], ids=["none", "empty"])
|
||||
def test_empty_reply_raises(self, reply: str | None) -> None:
|
||||
llm = MagicMock()
|
||||
llm.call.return_value = reply
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid response from LLM call - None or empty."):
|
||||
handle_max_iterations_exceeded(
|
||||
printer=MagicMock(), messages=_native_tool_history(), llm=llm, callbacks=[], verbose=False
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("verbose", [True, False])
|
||||
def test_notice_is_printed_only_when_verbose(self, verbose: bool) -> None:
|
||||
printer = MagicMock()
|
||||
llm = MagicMock()
|
||||
llm.call.return_value = "Final Answer: 42"
|
||||
|
||||
handle_max_iterations_exceeded(
|
||||
printer=printer, messages=_native_tool_history(), llm=llm, callbacks=[], verbose=verbose
|
||||
)
|
||||
|
||||
if verbose:
|
||||
printer.print.assert_called_once_with(
|
||||
content="Maximum iterations reached. Requesting final answer.", color="yellow"
|
||||
)
|
||||
else:
|
||||
printer.print.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user