Merge branch 'main' into danielfsbarreto/mongodb-persist

This commit is contained in:
Vidit Ostwal
2026-09-14 18:11:50 +05:30
committed by GitHub
38 changed files with 3495 additions and 248 deletions

View File

@@ -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",

View 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
]

View File

@@ -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:

View File

@@ -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] = []

View 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),)

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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

View File

@@ -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),

View File

@@ -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"]
)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"
),

View File

@@ -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"
),

View File

@@ -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,

View File

@@ -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

View File

@@ -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"],

View File

@@ -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"

View File

@@ -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()

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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():

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()