mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-19 07:08:10 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8153b67f5d | ||
|
|
c226722e22 | ||
|
|
b5e23a87f2 | ||
|
|
504c5c9b04 |
@@ -4,6 +4,28 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="18 يونيو 2026">
|
||||
## v1.14.8a1
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الميزات
|
||||
- إضافة تعبير if اختياري إلى خطوات each.do
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- إصلاح مشكلات JSON crew
|
||||
|
||||
### الوثائق
|
||||
- تحديث snapshot و changelog للإصدار v1.14.8a
|
||||
|
||||
## المساهمون
|
||||
|
||||
@joaomdmoura, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="17 يونيو 2026">
|
||||
## v1.14.8a
|
||||
|
||||
|
||||
@@ -4,6 +4,28 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="Jun 18, 2026">
|
||||
## v1.14.8a1
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Add optional if expression to each.do steps
|
||||
|
||||
### Bug Fixes
|
||||
- Fix JSON crew issues
|
||||
|
||||
### Documentation
|
||||
- Update snapshot and changelog for v1.14.8a
|
||||
|
||||
## Contributors
|
||||
|
||||
@joaomdmoura, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Jun 17, 2026">
|
||||
## v1.14.8a
|
||||
|
||||
|
||||
@@ -28,6 +28,60 @@ mode: "wide"
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center' }}>
|
||||
<a className="button button-primary" href="/en/quickstart">Get started</a>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={async (event) => {
|
||||
const prompt = `Set up this environment so I can build with CrewAI.
|
||||
|
||||
First install the official CrewAI coding-agent skills if this environment supports npx:
|
||||
|
||||
npx skills add crewaiinc/skills
|
||||
|
||||
If npx is missing or the current agent cannot load skills, do not fail the whole setup. Report the exact issue and continue using the CrewAI docs directly.
|
||||
|
||||
Use these CrewAI docs as source of truth before making assumptions:
|
||||
- https://skills.crewai.com
|
||||
- https://docs.crewai.com/llms.txt
|
||||
- https://docs.crewai.com/en/installation
|
||||
- https://docs.crewai.com/en/guides/coding-tools/build-with-ai
|
||||
|
||||
Setup steps:
|
||||
1. Check python3 --version. CrewAI requires Python >=3.10 and <3.14.
|
||||
2. Install uv if missing:
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
3. Source the uv environment if needed:
|
||||
source "$HOME/.local/bin/env"
|
||||
4. Install the CrewAI CLI:
|
||||
uv tool install crewai
|
||||
5. Verify the CLI:
|
||||
crewai version
|
||||
crewai create --help
|
||||
6. Create a project:
|
||||
CREWAI_DMN=true crewai create
|
||||
7. After project creation, inspect the generated files before editing.
|
||||
8. Run:
|
||||
crewai install
|
||||
crewai run
|
||||
|
||||
Do not hardcode API keys. Use .env.
|
||||
Do not invent CLI flags. Validate with crewai --help or crewai create --help.
|
||||
If a command fails, show the exact command and error, explain the likely cause, fix what you can safely fix, and retry once.`;
|
||||
const button = event.currentTarget;
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
button.textContent = "Copied";
|
||||
} catch {
|
||||
button.textContent = "Copy failed";
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
button.textContent = "Copy instructions for coding agents";
|
||||
}, 1600);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy instructions for coding agents
|
||||
</button>
|
||||
<a className="button" href="/en/changelog">View changelog</a>
|
||||
<a className="button" href="/en/api-reference/introduction">API Reference</a>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,60 @@ mode: "wide"
|
||||
|
||||
Install our coding agent skills (Claude Code, Codex, ...) to quickly get your coding agents up and running with CrewAI.
|
||||
|
||||
You can install it with `npx skills add crewaiinc/skills`
|
||||
<button
|
||||
type="button"
|
||||
className="button button-primary"
|
||||
onClick={async (event) => {
|
||||
const prompt = `Set up this environment so I can build with CrewAI.
|
||||
|
||||
First install the official CrewAI coding-agent skills if this environment supports npx:
|
||||
|
||||
npx skills add crewaiinc/skills
|
||||
|
||||
If npx is missing or the current agent cannot load skills, do not fail the whole setup. Report the exact issue and continue using the CrewAI docs directly.
|
||||
|
||||
Use these CrewAI docs as source of truth before making assumptions:
|
||||
- https://skills.crewai.com
|
||||
- https://docs.crewai.com/llms.txt
|
||||
- https://docs.crewai.com/en/installation
|
||||
- https://docs.crewai.com/en/guides/coding-tools/build-with-ai
|
||||
|
||||
Setup steps:
|
||||
1. Check python3 --version. CrewAI requires Python >=3.10 and <3.14.
|
||||
2. Install uv if missing:
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
3. Source the uv environment if needed:
|
||||
source "$HOME/.local/bin/env"
|
||||
4. Install the CrewAI CLI:
|
||||
uv tool install crewai
|
||||
5. Verify the CLI:
|
||||
crewai version
|
||||
crewai create --help
|
||||
6. Create a project:
|
||||
CREWAI_DMN=true crewai create
|
||||
7. After project creation, inspect the generated files before editing.
|
||||
8. Run:
|
||||
crewai install
|
||||
crewai run
|
||||
|
||||
Do not hardcode API keys. Use .env.
|
||||
Do not invent CLI flags. Validate with crewai --help or crewai create --help.
|
||||
If a command fails, show the exact command and error, explain the likely cause, fix what you can safely fix, and retry once.`;
|
||||
const button = event.currentTarget;
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
button.textContent = "Copied";
|
||||
} catch {
|
||||
button.textContent = "Copy failed";
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
button.textContent = "Copy instructions for coding agents";
|
||||
}, 1600);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy instructions for coding agents
|
||||
</button>
|
||||
|
||||
<iframe src="https://www.loom.com/embed/befb9f68b81f42ad8112bfdd95a780af" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style={{width: "100%", height: "400px"}}></iframe>
|
||||
|
||||
|
||||
@@ -4,6 +4,28 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="2026년 6월 18일">
|
||||
## v1.14.8a1
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- 각 do 단계에 선택적 if 표현식을 추가
|
||||
|
||||
### 버그 수정
|
||||
- JSON 크루 문제 수정
|
||||
|
||||
### 문서
|
||||
- v1.14.8a의 스냅샷 및 변경 로그 업데이트
|
||||
|
||||
## 기여자
|
||||
|
||||
@joaomdmoura, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 6월 17일">
|
||||
## v1.14.8a
|
||||
|
||||
|
||||
@@ -4,6 +4,28 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="18 jun 2026">
|
||||
## v1.14.8a1
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Recursos
|
||||
- Adicionar expressão if opcional aos passos each.do
|
||||
|
||||
### Correções de Bugs
|
||||
- Corrigir problemas de JSON da equipe
|
||||
|
||||
### Documentação
|
||||
- Atualizar snapshot e changelog para v1.14.8a
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@joaomdmoura, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="17 jun 2026">
|
||||
## v1.14.8a
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.8a",
|
||||
"crewai-core==1.14.8a1",
|
||||
"click>=8.1.7,<9",
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"pydantic-settings~=2.10.1",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.8a"
|
||||
__version__ = "1.14.8a1"
|
||||
|
||||
@@ -89,13 +89,16 @@ description = "{name} using crewAI"
|
||||
authors = [{{ name = "Your Name", email = "you@example.com" }}]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]>=1.14.7"
|
||||
"crewai[tools]==1.14.8a1"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
|
||||
|
||||
[tool.crewai]
|
||||
type = "crew"
|
||||
"""
|
||||
|
||||
@@ -34,6 +34,25 @@ _C_MUTED = "#666666" # dimmer than _C_DIM for past timeline
|
||||
_STEP_NUMBER_RE = re.compile(r"\bstep\s+(\d+)\b", re.IGNORECASE)
|
||||
_REFINEMENT_RE = re.compile(r"^\s*step\s+(\d+)\s*:\s*(.+)\s*$", re.IGNORECASE)
|
||||
_INTERNAL_TOOL_NAMES = {"create_reasoning_plan"}
|
||||
_LOG_ARGS_TEXT_LIMIT = 3_000
|
||||
_LOG_RESULT_TEXT_LIMIT = 5_000
|
||||
_LOG_TRUNCATION_SUFFIX = "... [truncated]"
|
||||
# Background memory saves can emit their start event just after kickoff returns.
|
||||
_MEMORY_SAVE_DRAIN_GRACE_SECONDS = 2.0
|
||||
|
||||
|
||||
def _is_save_to_memory_tool(tool_name: str | None) -> bool:
|
||||
return (tool_name or "").replace(" ", "_").lower() == "save_to_memory"
|
||||
|
||||
|
||||
def _truncate_log_text(value: Any, limit: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
suffix = _LOG_TRUNCATION_SUFFIX
|
||||
return f"{text[: max(0, limit - len(suffix))]}{suffix}"
|
||||
|
||||
|
||||
def _enable_tracing_in_dotenv() -> None:
|
||||
@@ -519,6 +538,8 @@ FooterKey .footer-key--key {
|
||||
self._log_expanded: set[int] = set()
|
||||
self._log_scroll_needed: bool = False
|
||||
self._log_line_map: list[tuple[int, int, int]] = []
|
||||
self._suppressed_memory_save_event_ids: set[str] = set()
|
||||
self._memory_save_drain_timer: Any = None
|
||||
|
||||
self._event_handlers: list[tuple[type, Any]] = []
|
||||
|
||||
@@ -633,7 +654,6 @@ FooterKey .footer-key--key {
|
||||
self.call_from_thread(self._on_crew_failed, str(e))
|
||||
|
||||
def _on_crew_done(self, output: str | None) -> None:
|
||||
self._unsubscribe()
|
||||
with self._lock:
|
||||
self._status = "completed"
|
||||
self._final_output = output
|
||||
@@ -649,6 +669,8 @@ FooterKey .footer-key--key {
|
||||
now = time.time()
|
||||
for entry in self._log_entries:
|
||||
if entry["status"] == "running":
|
||||
if entry["tool_name"] == "memory_save":
|
||||
continue
|
||||
entry["status"] = "timeout"
|
||||
entry["error"] = "No result received before crew completed"
|
||||
entry["duration"] = now - entry["start_time"]
|
||||
@@ -680,9 +702,9 @@ FooterKey .footer-key--key {
|
||||
self.call_later(self._focus_activity_log)
|
||||
self._tick_timer.stop()
|
||||
self._tick_timer = self.set_interval(1 / 2, self._tick)
|
||||
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
|
||||
|
||||
def _on_crew_failed(self, error: str) -> None:
|
||||
self._unsubscribe()
|
||||
with self._lock:
|
||||
self._status = "failed"
|
||||
self._error = error
|
||||
@@ -692,12 +714,16 @@ FooterKey .footer-key--key {
|
||||
now = time.time()
|
||||
for entry in self._log_entries:
|
||||
if entry["status"] == "running":
|
||||
if entry["tool_name"] == "memory_save":
|
||||
continue
|
||||
entry["status"] = "error"
|
||||
entry["error"] = "No result received before crew failed"
|
||||
entry["duration"] = now - entry["start_time"]
|
||||
self._tick()
|
||||
self.call_later(self._focus_activity_log)
|
||||
self._tick_timer.stop()
|
||||
self._tick_timer = self.set_interval(1 / 2, self._tick)
|
||||
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
|
||||
|
||||
# ── Actions ─────────────────────────────────────────────
|
||||
|
||||
@@ -1514,6 +1540,53 @@ FooterKey .footer-key--key {
|
||||
pass
|
||||
self._event_handlers.clear()
|
||||
|
||||
def _has_running_memory_save_locked(self) -> bool:
|
||||
return any(
|
||||
entry["tool_name"] == "memory_save" and entry["status"] == "running"
|
||||
for entry in self._log_entries
|
||||
)
|
||||
|
||||
def _on_memory_save_drain_elapsed(self) -> None:
|
||||
self._memory_save_drain_timer = None
|
||||
self._unsubscribe_if_no_running_memory_save()
|
||||
|
||||
def _schedule_memory_save_drain_unsubscribe(self) -> bool:
|
||||
loop = getattr(self, "_loop", None)
|
||||
if loop is None:
|
||||
return False
|
||||
if getattr(self, "_thread_id", None) != threading.get_ident():
|
||||
try:
|
||||
loop.call_soon_threadsafe(self._schedule_memory_save_drain_unsubscribe)
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
if self._memory_save_drain_timer is not None:
|
||||
self._memory_save_drain_timer.stop()
|
||||
self._memory_save_drain_timer = self.set_timer(
|
||||
_MEMORY_SAVE_DRAIN_GRACE_SECONDS,
|
||||
self._on_memory_save_drain_elapsed,
|
||||
name="memory-save-drain",
|
||||
)
|
||||
return True
|
||||
|
||||
def _unsubscribe_if_no_running_memory_save(
|
||||
self, *, wait_for_queued: bool = False
|
||||
) -> None:
|
||||
with self._lock:
|
||||
should_unsubscribe = (
|
||||
self._status
|
||||
in {
|
||||
"completed",
|
||||
"failed",
|
||||
}
|
||||
and not self._has_running_memory_save_locked()
|
||||
)
|
||||
|
||||
if should_unsubscribe:
|
||||
if wait_for_queued and self._schedule_memory_save_drain_unsubscribe():
|
||||
return
|
||||
self._unsubscribe()
|
||||
|
||||
def _subscribe(self) -> None:
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.crew_events import CrewKickoffStartedEvent
|
||||
@@ -1802,6 +1875,8 @@ FooterKey .footer-key--key {
|
||||
entry["status"] == "running"
|
||||
and entry["tool_name"] != event.tool_name
|
||||
):
|
||||
if entry["tool_name"] == "memory_save":
|
||||
continue
|
||||
entry["status"] = "timeout"
|
||||
entry["error"] = (
|
||||
"No result received before the next tool started"
|
||||
@@ -1830,6 +1905,7 @@ FooterKey .footer-key--key {
|
||||
"duration": None,
|
||||
"task_idx": self._current_task_idx,
|
||||
"plan_step_number": plan_step_number,
|
||||
"event_id": event.event_id,
|
||||
}
|
||||
)
|
||||
self._complete_step("teal", f"⚡ {event.tool_name}…")
|
||||
@@ -1923,8 +1999,178 @@ FooterKey .footer-key--key {
|
||||
MemoryRetrievalCompletedEvent,
|
||||
MemoryRetrievalFailedEvent,
|
||||
MemoryRetrievalStartedEvent,
|
||||
MemorySaveCompletedEvent,
|
||||
MemorySaveFailedEvent,
|
||||
MemorySaveStartedEvent,
|
||||
)
|
||||
|
||||
def is_nested_save_to_memory_event(event: Any) -> bool:
|
||||
if event.parent_event_id is None:
|
||||
return False
|
||||
state = crewai_event_bus.runtime_state
|
||||
if state is None:
|
||||
return False
|
||||
parent_node = state.event_record.nodes.get(event.parent_event_id)
|
||||
parent_event = getattr(parent_node, "event", None)
|
||||
return getattr(
|
||||
parent_event, "type", None
|
||||
) == "tool_usage_started" and _is_save_to_memory_tool(
|
||||
getattr(parent_event, "tool_name", None)
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(MemorySaveStartedEvent)
|
||||
def on_memory_save_started(source: Any, event: MemorySaveStartedEvent) -> None:
|
||||
with self._lock:
|
||||
if is_nested_save_to_memory_event(event):
|
||||
self._suppressed_memory_save_event_ids.add(event.event_id)
|
||||
return
|
||||
for entry in reversed(self._log_entries):
|
||||
if (
|
||||
_is_save_to_memory_tool(entry["tool_name"])
|
||||
and entry.get("event_id") == event.parent_event_id
|
||||
):
|
||||
self._suppressed_memory_save_event_ids.add(event.event_id)
|
||||
return
|
||||
for entry in reversed(self._log_entries):
|
||||
if (
|
||||
entry["tool_name"] == "memory_save"
|
||||
and entry.get("started_event_id") == event.event_id
|
||||
):
|
||||
entry["args"] = _truncate_log_text(
|
||||
event.value, _LOG_ARGS_TEXT_LIMIT
|
||||
)
|
||||
return
|
||||
self._log_entries.append(
|
||||
{
|
||||
"tool_name": "memory_save",
|
||||
"status": "running",
|
||||
"args": _truncate_log_text(event.value, _LOG_ARGS_TEXT_LIMIT),
|
||||
"result": None,
|
||||
"error": None,
|
||||
"start_time": time.time(),
|
||||
"duration": None,
|
||||
"task_idx": self._current_task_idx,
|
||||
"event_id": event.event_id,
|
||||
}
|
||||
)
|
||||
|
||||
self._register_handler(MemorySaveStartedEvent, on_memory_save_started)
|
||||
|
||||
@crewai_event_bus.on(MemorySaveCompletedEvent)
|
||||
def on_memory_save_completed(
|
||||
source: Any, event: MemorySaveCompletedEvent
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if (
|
||||
event.started_event_id in self._suppressed_memory_save_event_ids
|
||||
or is_nested_save_to_memory_event(event)
|
||||
):
|
||||
if event.started_event_id is not None:
|
||||
self._suppressed_memory_save_event_ids.discard(
|
||||
event.started_event_id
|
||||
)
|
||||
else:
|
||||
for entry in reversed(self._log_entries):
|
||||
has_started_event_match = (
|
||||
event.started_event_id is not None
|
||||
and (
|
||||
entry.get("event_id") == event.started_event_id
|
||||
or entry.get("started_event_id")
|
||||
== event.started_event_id
|
||||
)
|
||||
)
|
||||
has_running_event_without_id = (
|
||||
event.started_event_id is None
|
||||
and entry["status"] == "running"
|
||||
)
|
||||
if entry["tool_name"] == "memory_save" and (
|
||||
has_running_event_without_id or has_started_event_match
|
||||
):
|
||||
entry["status"] = "success"
|
||||
entry["duration"] = event.save_time_ms / 1000
|
||||
entry["result"] = _truncate_log_text(
|
||||
event.value, _LOG_RESULT_TEXT_LIMIT
|
||||
)
|
||||
entry["error"] = None
|
||||
entry["started_event_id"] = event.started_event_id
|
||||
break
|
||||
else:
|
||||
self._log_entries.append(
|
||||
{
|
||||
"tool_name": "memory_save",
|
||||
"status": "success",
|
||||
"args": None,
|
||||
"result": _truncate_log_text(
|
||||
event.value, _LOG_RESULT_TEXT_LIMIT
|
||||
),
|
||||
"error": None,
|
||||
"start_time": time.time(),
|
||||
"duration": event.save_time_ms / 1000,
|
||||
"task_idx": self._current_task_idx,
|
||||
"started_event_id": event.started_event_id,
|
||||
}
|
||||
)
|
||||
|
||||
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
|
||||
|
||||
self._register_handler(MemorySaveCompletedEvent, on_memory_save_completed)
|
||||
|
||||
@crewai_event_bus.on(MemorySaveFailedEvent)
|
||||
def on_memory_save_failed(source: Any, event: MemorySaveFailedEvent) -> None:
|
||||
with self._lock:
|
||||
if (
|
||||
event.started_event_id in self._suppressed_memory_save_event_ids
|
||||
or is_nested_save_to_memory_event(event)
|
||||
):
|
||||
if event.started_event_id is not None:
|
||||
self._suppressed_memory_save_event_ids.discard(
|
||||
event.started_event_id
|
||||
)
|
||||
else:
|
||||
for idx, entry in reversed(list(enumerate(self._log_entries))):
|
||||
has_started_event_match = (
|
||||
event.started_event_id is not None
|
||||
and (
|
||||
entry.get("event_id") == event.started_event_id
|
||||
or entry.get("started_event_id")
|
||||
== event.started_event_id
|
||||
)
|
||||
)
|
||||
has_running_event_without_id = (
|
||||
event.started_event_id is None
|
||||
and entry["status"] == "running"
|
||||
)
|
||||
if entry["tool_name"] == "memory_save" and (
|
||||
has_running_event_without_id or has_started_event_match
|
||||
):
|
||||
entry["status"] = "error"
|
||||
entry["error"] = event.error
|
||||
entry["duration"] = time.time() - entry["start_time"]
|
||||
entry["started_event_id"] = event.started_event_id
|
||||
self._log_expanded.add(idx)
|
||||
break
|
||||
else:
|
||||
self._log_entries.append(
|
||||
{
|
||||
"tool_name": "memory_save",
|
||||
"status": "error",
|
||||
"args": _truncate_log_text(
|
||||
event.value, _LOG_ARGS_TEXT_LIMIT
|
||||
),
|
||||
"result": None,
|
||||
"error": event.error,
|
||||
"start_time": time.time(),
|
||||
"duration": 0,
|
||||
"task_idx": self._current_task_idx,
|
||||
"started_event_id": event.started_event_id,
|
||||
}
|
||||
)
|
||||
self._log_expanded.add(len(self._log_entries) - 1)
|
||||
|
||||
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
|
||||
|
||||
self._register_handler(MemorySaveFailedEvent, on_memory_save_failed)
|
||||
|
||||
@crewai_event_bus.on(MemoryRetrievalStartedEvent)
|
||||
def on_memory_retrieval_started(
|
||||
source: Any, event: MemoryRetrievalStartedEvent
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from enum import Enum
|
||||
import os
|
||||
@@ -7,10 +8,9 @@ from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import click
|
||||
from crewai.project.json_loader import find_crew_json_file
|
||||
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
||||
from packaging import version
|
||||
|
||||
@@ -38,6 +38,15 @@ class CrewType(Enum):
|
||||
_INPUT_PLACEHOLDER_RE = re.compile(r"(?<!{){([A-Za-z_][A-Za-z0-9_\-]*)}(?!})")
|
||||
_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV = "CREWAI_CLI_RUNNER_PACKAGE_DIR"
|
||||
_CREWAI_RUNNER_SOURCE_DIR_ENV = "CREWAI_RUNNER_SOURCE_DIR"
|
||||
_FULL_CREWAI_INSTALL_MESSAGE = """\
|
||||
CrewAI CLI is installed without the `crewai` package required to run crews.
|
||||
|
||||
Install the full CrewAI prerelease package:
|
||||
|
||||
uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'
|
||||
|
||||
The quotes are required in zsh so `crewai[tools]` is not treated as a glob.
|
||||
"""
|
||||
_JSON_CREW_RUNNER_CODE = """
|
||||
import importlib.util
|
||||
import os
|
||||
@@ -72,12 +81,39 @@ module_spec.loader.exec_module(module)
|
||||
|
||||
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
||||
|
||||
module._run_json_crew(
|
||||
trained_agents_file=os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV)
|
||||
)
|
||||
try:
|
||||
module._run_json_crew(
|
||||
trained_agents_file=os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV)
|
||||
)
|
||||
except module.click.ClickException as exc:
|
||||
exc.show()
|
||||
raise SystemExit(exc.exit_code)
|
||||
""".strip()
|
||||
|
||||
|
||||
def _import_find_crew_json_file() -> Callable[[], Path | None]:
|
||||
from crewai.project.json_loader import find_crew_json_file as _find_crew_json_file
|
||||
|
||||
return cast("Callable[[], Path | None]", _find_crew_json_file)
|
||||
|
||||
|
||||
def _is_missing_crewai_package(exc: ModuleNotFoundError) -> bool:
|
||||
return bool(exc.name and exc.name.startswith("crewai"))
|
||||
|
||||
|
||||
def _full_crewai_install_error() -> click.ClickException:
|
||||
return click.ClickException(_FULL_CREWAI_INSTALL_MESSAGE)
|
||||
|
||||
|
||||
def find_crew_json_file() -> Path | None:
|
||||
try:
|
||||
return _import_find_crew_json_file()()
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_crewai_package(exc):
|
||||
raise _full_crewai_install_error() from exc
|
||||
raise
|
||||
|
||||
|
||||
def _has_json_crew() -> bool:
|
||||
"""Check if this is a JSON-defined crew project.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
|
||||
authors = [{ name = "Your Name", email = "you@example.com" }]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.8a"
|
||||
"crewai[tools]==1.14.8a1"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
|
||||
authors = [{ name = "Your Name", email = "you@example.com" }]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.8a"
|
||||
"crewai[tools]==1.14.8a1"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.8a"
|
||||
"crewai[tools]==1.14.8a1"
|
||||
]
|
||||
|
||||
[tool.crewai]
|
||||
|
||||
@@ -5,7 +5,10 @@ from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import tomli
|
||||
from click.testing import CliRunner
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.version import Version
|
||||
import crewai_cli.create_json_crew as json_crew
|
||||
import crewai_cli.tui_picker as tui_picker
|
||||
from crewai_cli.create_crew import create_crew, create_folder_structure
|
||||
@@ -712,6 +715,14 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
|
||||
assert not (tmp_path / "json_crew" / "tests").exists()
|
||||
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
|
||||
|
||||
pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text())
|
||||
dependency = pyproject["project"]["dependencies"][0]
|
||||
assert dependency == "crewai[tools]==1.14.8a1"
|
||||
assert Version("1.14.8a1") in Requirement(dependency).specifier
|
||||
assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
|
||||
"only-include"
|
||||
] == ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
|
||||
|
||||
crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text()
|
||||
assert (
|
||||
'"guardrail": "Every factual claim needs context support."'
|
||||
|
||||
@@ -4,6 +4,11 @@ import time
|
||||
import pytest
|
||||
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.memory_events import (
|
||||
MemorySaveCompletedEvent,
|
||||
MemorySaveFailedEvent,
|
||||
MemorySaveStartedEvent,
|
||||
)
|
||||
from crewai.events.types.observation_events import (
|
||||
GoalAchievedEarlyEvent,
|
||||
PlanRefinementEvent,
|
||||
@@ -21,7 +26,12 @@ from crewai.events.types.tool_usage_events import (
|
||||
)
|
||||
from crewai_cli.command import AuthenticationRequiredError
|
||||
from crewai_cli import run_crew
|
||||
from crewai_cli.crew_run_tui import CrewRunApp
|
||||
from crewai_cli.crew_run_tui import (
|
||||
CrewRunApp,
|
||||
_LOG_ARGS_TEXT_LIMIT,
|
||||
_LOG_RESULT_TEXT_LIMIT,
|
||||
_LOG_TRUNCATION_SUFFIX,
|
||||
)
|
||||
|
||||
|
||||
def _app_with_plan() -> CrewRunApp:
|
||||
@@ -335,6 +345,396 @@ def test_internal_reasoning_function_call_is_hidden_from_activity_log() -> None:
|
||||
assert app._current_task_steps == []
|
||||
|
||||
|
||||
def test_memory_save_events_are_shown_in_activity_log() -> None:
|
||||
app = _app_with_plan()
|
||||
app._current_task_idx = 1
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value="2 memories (background)",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
MemorySaveCompletedEvent(
|
||||
value="2 memories saved",
|
||||
metadata={},
|
||||
save_time_ms=123,
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert len(app._log_entries) == 1
|
||||
assert app._log_entries[0]["tool_name"] == "memory_save"
|
||||
assert app._log_entries[0]["status"] == "success"
|
||||
assert app._log_entries[0]["args"] == "2 memories (background)"
|
||||
assert app._log_entries[0]["result"] == "2 memories saved"
|
||||
assert app._log_entries[0]["error"] is None
|
||||
assert app._log_entries[0]["duration"] == 0.123
|
||||
assert app._log_entries[0]["task_idx"] == 1
|
||||
|
||||
|
||||
def test_nested_memory_save_event_is_hidden_for_save_to_memory_tool() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
tool_args = {"contents": ["Fact to remember."]}
|
||||
_emit_event(
|
||||
ToolUsageStartedEvent(
|
||||
tool_name="save_to_memory",
|
||||
tool_args=tool_args,
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value="Fact to remember.",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
MemorySaveCompletedEvent(
|
||||
value="Fact to remember.",
|
||||
metadata={},
|
||||
save_time_ms=123,
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
now = datetime.now()
|
||||
_emit_event(
|
||||
ToolUsageFinishedEvent(
|
||||
tool_name="save_to_memory",
|
||||
tool_args=tool_args,
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
output="Saved to memory.",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert len(app._log_entries) == 1
|
||||
assert app._log_entries[0]["tool_name"] == "save_to_memory"
|
||||
assert app._log_entries[0]["status"] == "success"
|
||||
assert app._log_entries[0]["result"] == "Saved to memory."
|
||||
|
||||
|
||||
def test_memory_save_failure_is_shown_in_activity_log() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value="background save",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
MemorySaveFailedEvent(
|
||||
value="background save",
|
||||
metadata={},
|
||||
error="embedding connection failed",
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._log_entries[0]["tool_name"] == "memory_save"
|
||||
assert app._log_entries[0]["status"] == "error"
|
||||
assert app._log_entries[0]["error"] == "embedding connection failed"
|
||||
assert app._log_expanded == {0}
|
||||
|
||||
|
||||
def test_memory_save_completion_updates_timed_out_row() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value="9 memories (background)",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
|
||||
app._log_entries[0]["status"] = "timeout"
|
||||
app._log_entries[0]["error"] = "No result received before crew completed"
|
||||
app._log_entries[0]["duration"] = 8.3
|
||||
|
||||
_emit_event(
|
||||
MemorySaveCompletedEvent(
|
||||
value="9 memories saved",
|
||||
metadata={},
|
||||
save_time_ms=8300,
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert len(app._log_entries) == 1
|
||||
assert app._log_entries[0]["tool_name"] == "memory_save"
|
||||
assert app._log_entries[0]["status"] == "success"
|
||||
assert app._log_entries[0]["result"] == "9 memories saved"
|
||||
assert app._log_entries[0]["error"] is None
|
||||
assert app._log_entries[0]["duration"] == 8.3
|
||||
|
||||
|
||||
def test_memory_save_completion_with_unmatched_id_does_not_update_running_row() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value="first background save",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value="second background save",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
)
|
||||
)
|
||||
|
||||
_emit_event(
|
||||
MemorySaveCompletedEvent(
|
||||
value="orphan save completed",
|
||||
metadata={},
|
||||
save_time_ms=2800,
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
started_event_id="missing-memory-save-start",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert [entry["status"] for entry in app._log_entries] == [
|
||||
"running",
|
||||
"running",
|
||||
"success",
|
||||
]
|
||||
assert app._log_entries[0]["args"] == "first background save"
|
||||
assert app._log_entries[1]["args"] == "second background save"
|
||||
assert app._log_entries[2]["result"] == "orphan save completed"
|
||||
assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start"
|
||||
|
||||
|
||||
def test_memory_save_failure_with_unmatched_id_does_not_update_running_row() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value="first background save",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value="second background save",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
)
|
||||
)
|
||||
|
||||
_emit_event(
|
||||
MemorySaveFailedEvent(
|
||||
value="orphan save failed",
|
||||
metadata={},
|
||||
error="embedding connection failed",
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
started_event_id="missing-memory-save-start",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert [entry["status"] for entry in app._log_entries] == [
|
||||
"running",
|
||||
"running",
|
||||
"error",
|
||||
]
|
||||
assert app._log_entries[0]["args"] == "first background save"
|
||||
assert app._log_entries[1]["args"] == "second background save"
|
||||
assert app._log_entries[2]["args"] == "orphan save failed"
|
||||
assert app._log_entries[2]["error"] == "embedding connection failed"
|
||||
assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start"
|
||||
assert app._log_expanded == {2}
|
||||
|
||||
|
||||
def test_memory_save_completion_without_id_does_not_update_stale_row() -> None:
|
||||
app = _app_with_plan()
|
||||
now = time.time()
|
||||
app._log_entries = [
|
||||
{
|
||||
"tool_name": "memory_save",
|
||||
"status": "running",
|
||||
"args": "current background save",
|
||||
"result": None,
|
||||
"error": None,
|
||||
"start_time": now,
|
||||
"duration": None,
|
||||
"task_idx": 1,
|
||||
},
|
||||
{
|
||||
"tool_name": "memory_save",
|
||||
"status": "success",
|
||||
"args": "stale background save",
|
||||
"result": "stale save completed",
|
||||
"error": None,
|
||||
"start_time": now - 10,
|
||||
"duration": 1.0,
|
||||
"task_idx": 1,
|
||||
},
|
||||
]
|
||||
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
MemorySaveCompletedEvent(
|
||||
value="current save completed",
|
||||
metadata={},
|
||||
save_time_ms=2800,
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert [entry["status"] for entry in app._log_entries] == [
|
||||
"success",
|
||||
"success",
|
||||
]
|
||||
assert app._log_entries[0]["args"] == "current background save"
|
||||
assert app._log_entries[0]["result"] == "current save completed"
|
||||
assert app._log_entries[1]["args"] == "stale background save"
|
||||
assert app._log_entries[1]["result"] == "stale save completed"
|
||||
|
||||
|
||||
def test_memory_save_failure_without_id_does_not_update_stale_row() -> None:
|
||||
app = _app_with_plan()
|
||||
now = time.time()
|
||||
app._log_entries = [
|
||||
{
|
||||
"tool_name": "memory_save",
|
||||
"status": "running",
|
||||
"args": "current background save",
|
||||
"result": None,
|
||||
"error": None,
|
||||
"start_time": now,
|
||||
"duration": None,
|
||||
"task_idx": 1,
|
||||
},
|
||||
{
|
||||
"tool_name": "memory_save",
|
||||
"status": "success",
|
||||
"args": "stale background save",
|
||||
"result": "stale save completed",
|
||||
"error": None,
|
||||
"start_time": now - 10,
|
||||
"duration": 1.0,
|
||||
"task_idx": 1,
|
||||
},
|
||||
]
|
||||
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
MemorySaveFailedEvent(
|
||||
value="current save failed",
|
||||
metadata={},
|
||||
error="embedding connection failed",
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert [entry["status"] for entry in app._log_entries] == ["error", "success"]
|
||||
assert app._log_entries[0]["args"] == "current background save"
|
||||
assert app._log_entries[0]["error"] == "embedding connection failed"
|
||||
assert app._log_entries[1]["args"] == "stale background save"
|
||||
assert app._log_entries[1]["result"] == "stale save completed"
|
||||
assert app._log_entries[1]["error"] is None
|
||||
assert app._log_expanded == {0}
|
||||
|
||||
|
||||
def test_memory_save_payloads_are_truncated_in_activity_log() -> None:
|
||||
app = _app_with_plan()
|
||||
long_args = "a" * (_LOG_ARGS_TEXT_LIMIT + 10)
|
||||
long_result = "r" * (_LOG_RESULT_TEXT_LIMIT + 10)
|
||||
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value=long_args,
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
MemorySaveCompletedEvent(
|
||||
value=long_result,
|
||||
metadata={},
|
||||
save_time_ms=8300,
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert len(app._log_entries[0]["args"]) == _LOG_ARGS_TEXT_LIMIT
|
||||
assert app._log_entries[0]["args"].endswith(_LOG_TRUNCATION_SUFFIX)
|
||||
assert len(app._log_entries[0]["result"]) == _LOG_RESULT_TEXT_LIMIT
|
||||
assert app._log_entries[0]["result"].endswith(_LOG_TRUNCATION_SUFFIX)
|
||||
|
||||
|
||||
def test_starting_next_tool_does_not_timeout_memory_save() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
MemorySaveStartedEvent(
|
||||
value="9 memories (background)",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
ToolUsageStartedEvent(
|
||||
tool_name="read_website_content",
|
||||
tool_args={"url": "https://example.com"},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._log_entries[0]["tool_name"] == "memory_save"
|
||||
assert app._log_entries[0]["status"] == "running"
|
||||
assert app._log_entries[0]["error"] is None
|
||||
assert app._log_entries[1]["tool_name"] == "read_website_content"
|
||||
assert app._log_entries[1]["status"] == "running"
|
||||
|
||||
|
||||
def test_tool_failure_does_not_override_successful_plan_step_completion() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
@@ -480,6 +880,187 @@ async def test_crew_done_does_not_mark_unfinished_tool_successful() -> None:
|
||||
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crew_done_does_not_timeout_memory_save() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
app._log_entries = [
|
||||
{
|
||||
"tool_name": "memory_save",
|
||||
"status": "running",
|
||||
"args": "9 memories (background)",
|
||||
"result": None,
|
||||
"error": None,
|
||||
"start_time": time.time() - 8,
|
||||
"duration": None,
|
||||
"task_idx": 1,
|
||||
},
|
||||
{
|
||||
"tool_name": "search",
|
||||
"status": "running",
|
||||
"args": '{"query": "CrewAI"}',
|
||||
"result": None,
|
||||
"error": None,
|
||||
"start_time": time.time() - 2,
|
||||
"duration": None,
|
||||
"task_idx": 1,
|
||||
},
|
||||
]
|
||||
|
||||
app._on_crew_done("final output")
|
||||
await pilot.pause()
|
||||
|
||||
assert app._log_entries[0]["status"] == "running"
|
||||
assert app._log_entries[0]["error"] is None
|
||||
assert app._log_entries[1]["status"] == "timeout"
|
||||
assert app._log_entries[1]["error"] == "No result received before crew completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crew_done_keeps_memory_save_subscription_until_completion(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05
|
||||
)
|
||||
app = _app_with_plan()
|
||||
auto_unsubscribed = False
|
||||
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
try:
|
||||
assert app._event_handlers
|
||||
started_event = MemorySaveStartedEvent(
|
||||
value="9 memories (background)",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
)
|
||||
_emit_event(started_event)
|
||||
|
||||
app._on_crew_done("final output")
|
||||
await pilot.pause()
|
||||
|
||||
assert app._log_entries[0]["status"] == "running"
|
||||
assert app._event_handlers
|
||||
|
||||
_emit_event(
|
||||
MemorySaveCompletedEvent(
|
||||
value="9 memories saved",
|
||||
metadata={},
|
||||
save_time_ms=8300,
|
||||
source_type="unified_memory",
|
||||
started_event_id=started_event.event_id,
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
assert app._event_handlers
|
||||
await pilot.pause(0.08)
|
||||
auto_unsubscribed = not app._event_handlers
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._log_entries[0]["tool_name"] == "memory_save"
|
||||
assert app._log_entries[0]["status"] == "success"
|
||||
assert app._log_entries[0]["result"] == "9 memories saved"
|
||||
assert app._log_entries[0]["error"] is None
|
||||
assert app._log_entries[0]["duration"] == 8.3
|
||||
assert auto_unsubscribed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crew_done_waits_for_queued_memory_save_events(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05
|
||||
)
|
||||
app = _app_with_plan()
|
||||
auto_unsubscribed = False
|
||||
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
try:
|
||||
assert app._event_handlers
|
||||
|
||||
app._on_crew_done("final output")
|
||||
|
||||
assert app._event_handlers
|
||||
started_event = MemorySaveStartedEvent(
|
||||
value="9 memories (background)",
|
||||
metadata={},
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
)
|
||||
_emit_event(started_event)
|
||||
await pilot.pause()
|
||||
|
||||
assert app._log_entries[0]["tool_name"] == "memory_save"
|
||||
assert app._log_entries[0]["status"] == "running"
|
||||
|
||||
_emit_event(
|
||||
MemorySaveCompletedEvent(
|
||||
value="9 memories saved",
|
||||
metadata={},
|
||||
save_time_ms=8300,
|
||||
source_type="unified_memory",
|
||||
parent_event_id="manual-parent",
|
||||
started_event_id=started_event.event_id,
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
assert app._event_handlers
|
||||
await pilot.pause(0.08)
|
||||
auto_unsubscribed = not app._event_handlers
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._log_entries[0]["tool_name"] == "memory_save"
|
||||
assert app._log_entries[0]["status"] == "success"
|
||||
assert app._log_entries[0]["args"] == "9 memories (background)"
|
||||
assert app._log_entries[0]["result"] == "9 memories saved"
|
||||
assert app._log_entries[0]["error"] is None
|
||||
assert app._log_entries[0]["duration"] == 8.3
|
||||
assert auto_unsubscribed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crew_failed_does_not_timeout_memory_save() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
app._log_entries = [
|
||||
{
|
||||
"tool_name": "memory_save",
|
||||
"status": "running",
|
||||
"args": "9 memories (background)",
|
||||
"result": None,
|
||||
"error": None,
|
||||
"start_time": time.time() - 8,
|
||||
"duration": None,
|
||||
"task_idx": 1,
|
||||
},
|
||||
{
|
||||
"tool_name": "search",
|
||||
"status": "running",
|
||||
"args": '{"query": "CrewAI"}',
|
||||
"result": None,
|
||||
"error": None,
|
||||
"start_time": time.time() - 2,
|
||||
"duration": None,
|
||||
"task_idx": 1,
|
||||
},
|
||||
]
|
||||
|
||||
app._on_crew_failed("boom")
|
||||
await pilot.pause()
|
||||
|
||||
assert app._log_entries[0]["status"] == "running"
|
||||
assert app._log_entries[0]["error"] is None
|
||||
assert app._log_entries[1]["status"] == "error"
|
||||
assert app._log_entries[1]["error"] == "No result received before crew failed"
|
||||
|
||||
|
||||
def test_streamed_step_observation_updates_named_step_only() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
|
||||
@@ -5,12 +5,33 @@ from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
||||
|
||||
import crewai_cli.run_crew as run_crew_module
|
||||
|
||||
|
||||
def test_missing_crewai_package_shows_full_install_hint(monkeypatch):
|
||||
def missing_crewai_package():
|
||||
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "_import_find_crew_json_file", missing_crewai_package
|
||||
)
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
run_crew_module.find_crew_json_file()
|
||||
|
||||
message = exc_info.value.message
|
||||
assert "CrewAI CLI is installed without the `crewai` package" in message
|
||||
assert (
|
||||
"uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'"
|
||||
in message
|
||||
)
|
||||
assert "quotes are required in zsh" in message
|
||||
|
||||
|
||||
def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
|
||||
"""crewai run -f must reach JSON crews, not only classic subprocess crews."""
|
||||
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.8a"
|
||||
__version__ = "1.14.8a1"
|
||||
|
||||
@@ -9,7 +9,7 @@ authors = [
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"Pillow~=12.1.1",
|
||||
"pypdf~=6.10.0",
|
||||
"pypdf~=6.13.3",
|
||||
"python-magic>=0.4.27",
|
||||
"aiocache~=0.12.3",
|
||||
"aiofiles~=24.1.0",
|
||||
@@ -19,6 +19,8 @@ dependencies = [
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "3 days"
|
||||
# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
|
||||
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.8a"
|
||||
__version__ = "1.14.8a1"
|
||||
|
||||
@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"pytube~=15.0.0",
|
||||
"requests>=2.33.0,<3",
|
||||
"crewai==1.14.8a",
|
||||
"crewai==1.14.8a1",
|
||||
"tiktoken>=0.8.0,<0.13",
|
||||
"beautifulsoup4~=4.13.4",
|
||||
"python-docx~=1.2.0",
|
||||
|
||||
@@ -330,4 +330,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.14.8a"
|
||||
__version__ = "1.14.8a1"
|
||||
|
||||
@@ -8,8 +8,8 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.8a",
|
||||
"crewai-cli==1.14.8a",
|
||||
"crewai-core==1.14.8a1",
|
||||
"crewai-cli==1.14.8a1",
|
||||
# Core Dependencies
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"openai>=2.30.0,<3",
|
||||
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tools = [
|
||||
"crewai-tools==1.14.8a",
|
||||
"crewai-tools==1.14.8a1",
|
||||
]
|
||||
embeddings = [
|
||||
"tiktoken>=0.8.0,<0.13"
|
||||
|
||||
@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
|
||||
|
||||
_suppress_pydantic_deprecation_warnings()
|
||||
|
||||
__version__ = "1.14.8a"
|
||||
__version__ = "1.14.8a1"
|
||||
|
||||
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"Memory": ("crewai.memory.unified_memory", "Memory"),
|
||||
|
||||
@@ -18,7 +18,6 @@ from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
RootModel,
|
||||
field_serializer,
|
||||
model_validator,
|
||||
)
|
||||
@@ -38,6 +37,7 @@ _STEP_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
__all__ = [
|
||||
"FlowActionDefinition",
|
||||
"FlowAtomicActionDefinition",
|
||||
"FlowCodeActionDefinition",
|
||||
"FlowConfigDefinition",
|
||||
"FlowConversationalDefinition",
|
||||
@@ -47,7 +47,7 @@ __all__ = [
|
||||
"FlowDefinitionCondition",
|
||||
"FlowDictStateDefinition",
|
||||
"FlowEachActionDefinition",
|
||||
"FlowEachInnerActionDefinition",
|
||||
"FlowEachStepDefinition",
|
||||
"FlowExpressionActionDefinition",
|
||||
"FlowHumanFeedbackDefinition",
|
||||
"FlowJsonSchemaStateDefinition",
|
||||
@@ -466,38 +466,48 @@ class FlowScriptActionDefinition(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
FlowInnerActionDefinition = (
|
||||
FlowAtomicActionDefinition: TypeAlias = Annotated[
|
||||
FlowCodeActionDefinition
|
||||
| FlowToolActionDefinition
|
||||
| FlowCrewActionDefinition
|
||||
| FlowExpressionActionDefinition
|
||||
| FlowScriptActionDefinition
|
||||
)
|
||||
| FlowScriptActionDefinition,
|
||||
Field(discriminator="call"),
|
||||
]
|
||||
|
||||
|
||||
class FlowEachInnerActionDefinition(RootModel[dict[str, FlowInnerActionDefinition]]):
|
||||
"""One named action inside an ``each`` composite action."""
|
||||
class FlowEachStepDefinition(BaseModel):
|
||||
"""One named step inside an ``each`` composite action."""
|
||||
|
||||
root: dict[str, FlowInnerActionDefinition] = Field(
|
||||
description="Single-entry mapping from an inner action name to its action.",
|
||||
examples=[{"clean": {"call": "script", "code": "return item.strip()"}}],
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
name: str = Field(
|
||||
description="Step name used to reference this step's output.",
|
||||
examples=["clean"],
|
||||
)
|
||||
if_: str | None = Field(
|
||||
default=None,
|
||||
alias="if",
|
||||
description=(
|
||||
"Optional CEL expression evaluated against state, outputs, and local "
|
||||
"context. When present, the step runs only if the expression evaluates "
|
||||
"to true."
|
||||
),
|
||||
examples=["item.kind == 'invoice'"],
|
||||
)
|
||||
action: FlowAtomicActionDefinition = Field(
|
||||
description="Atomic action to run for this step.",
|
||||
examples=[{"call": "script", "code": "return item.strip()"}],
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_action_mapping(self) -> FlowEachInnerActionDefinition:
|
||||
if len(self.root) != 1:
|
||||
raise ValueError("each.do entries must be one-key mappings")
|
||||
_validate_step_name(self.name, field="each.do action names")
|
||||
def _validate_step_name(self) -> FlowEachStepDefinition:
|
||||
_validate_step_name(self.name, field="each.do step names")
|
||||
return self
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return next(iter(self.root))
|
||||
|
||||
@property
|
||||
def action(self) -> FlowInnerActionDefinition:
|
||||
return next(iter(self.root.values()))
|
||||
|
||||
|
||||
class FlowEachActionDefinition(BaseModel):
|
||||
"""A composite action that runs a sequential mini-pipeline for each item."""
|
||||
@@ -519,35 +529,36 @@ class FlowEachActionDefinition(BaseModel):
|
||||
description="CEL expression that must evaluate to the list to iterate.",
|
||||
examples=["state.rows"],
|
||||
)
|
||||
do: list[FlowEachInnerActionDefinition] = Field(
|
||||
do: list[FlowEachStepDefinition] = Field(
|
||||
description=(
|
||||
"Ordered inner actions to run for each item. Each entry must be a "
|
||||
"single-key mapping naming that inner action."
|
||||
"Ordered steps to run for each item. Each step has a name, optional "
|
||||
"if expression, and atomic action."
|
||||
),
|
||||
examples=[
|
||||
[
|
||||
{"clean": {"call": "script", "code": "return item.strip()"}},
|
||||
{"tag": {"call": "expression", "expr": "outputs.clean"}},
|
||||
{
|
||||
"name": "clean",
|
||||
"action": {"call": "script", "code": "return item.strip()"},
|
||||
},
|
||||
{
|
||||
"name": "tag",
|
||||
"if": "outputs.clean != ''",
|
||||
"action": {"call": "expression", "expr": "outputs.clean"},
|
||||
},
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_inner_action_list(self) -> FlowEachActionDefinition:
|
||||
def _validate_step_list(self) -> FlowEachActionDefinition:
|
||||
if not self.do:
|
||||
raise ValueError("each.do must contain at least one action")
|
||||
|
||||
seen: set[str] = set()
|
||||
for inner_action in self.do:
|
||||
name = inner_action.name
|
||||
if name in seen:
|
||||
raise ValueError(f"each.do action names must be unique: {name!r}")
|
||||
seen.add(name)
|
||||
raise ValueError("each.do must contain at least one step")
|
||||
|
||||
_validate_step_list(self.do, field="each.do")
|
||||
return self
|
||||
|
||||
|
||||
FlowActionDefinition = (
|
||||
FlowActionDefinition: TypeAlias = (
|
||||
FlowCodeActionDefinition
|
||||
| FlowToolActionDefinition
|
||||
| FlowCrewActionDefinition
|
||||
@@ -733,6 +744,15 @@ def _validate_step_name(name: str, *, field: str) -> None:
|
||||
raise ValueError(f"{field} must match {_STEP_NAME_PATTERN.pattern}")
|
||||
|
||||
|
||||
def _validate_step_list(steps: list[FlowEachStepDefinition], *, field: str) -> None:
|
||||
seen: set[str] = set()
|
||||
for step in steps:
|
||||
name = step.name
|
||||
if name in seen:
|
||||
raise ValueError(f"{field} step names must be unique: {name!r}")
|
||||
seen.add(name)
|
||||
|
||||
|
||||
def log_flow_definition_issues(definition: FlowDefinition) -> None:
|
||||
for method_name, method in definition.methods.items():
|
||||
path = f"methods.{method_name}"
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
import contextvars
|
||||
import inspect
|
||||
import os
|
||||
@@ -15,7 +15,7 @@ from crewai.flow.flow_definition import (
|
||||
FlowCodeActionDefinition,
|
||||
FlowCrewActionDefinition,
|
||||
FlowEachActionDefinition,
|
||||
FlowEachInnerActionDefinition,
|
||||
FlowEachStepDefinition,
|
||||
FlowExpressionActionDefinition,
|
||||
FlowScriptActionDefinition,
|
||||
FlowToolActionDefinition,
|
||||
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
|
||||
__all__ = ["FlowScriptExecutionDisabledError", "build_action"]
|
||||
|
||||
LocalContext = dict[str, Any]
|
||||
NestedStepRunner = Callable[[LocalContext], Awaitable[Any]]
|
||||
NestedStep = tuple[str, str | None, NestedStepRunner]
|
||||
_LOCAL_CONTEXT_KWARG = "__flow_definition_local_context"
|
||||
_ALLOW_SCRIPT_EXECUTION_ENV_VAR = "CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION"
|
||||
_TRUSTED_SCRIPT_EXECUTION_VALUES = frozenset({"1", "true", "yes"})
|
||||
@@ -217,9 +219,9 @@ class EachAction:
|
||||
def __init__(self, flow: Flow[Any], definition: FlowEachActionDefinition) -> None:
|
||||
self.flow = flow
|
||||
self.definition = definition
|
||||
self.inner_actions = [
|
||||
(inner_action.name, self._build_inner_action(inner_action))
|
||||
for inner_action in definition.do
|
||||
self.steps: list[NestedStep] = [
|
||||
(step.name, step.if_, self._build_step_action(step))
|
||||
for step in definition.do
|
||||
]
|
||||
|
||||
async def run(self, *_args: Any, **_kwargs: Any) -> list[Any]:
|
||||
@@ -231,22 +233,30 @@ class EachAction:
|
||||
|
||||
for item in items:
|
||||
local_outputs: dict[str, Any] = {}
|
||||
local_context = {"item": item, "outputs": local_outputs}
|
||||
last_output: Any = None
|
||||
for name, run_inner_action in self.inner_actions:
|
||||
last_output = await run_inner_action(
|
||||
{"item": item, "outputs": local_outputs}
|
||||
)
|
||||
for name, condition, run_step_action in self.steps:
|
||||
if condition is not None and not self._condition_matches(
|
||||
condition, local_context
|
||||
):
|
||||
continue
|
||||
|
||||
last_output = await run_step_action(local_context)
|
||||
local_outputs[name] = last_output
|
||||
results.append(last_output)
|
||||
|
||||
return results
|
||||
|
||||
def _build_inner_action(
|
||||
self, inner_action: FlowEachInnerActionDefinition
|
||||
) -> Callable[[LocalContext], Any]:
|
||||
run_action = build_action(self.flow, inner_action.action)
|
||||
def _condition_matches(self, condition: str, local_context: LocalContext) -> bool:
|
||||
result = evaluate_expression(self.flow, condition, local_context=local_context)
|
||||
if not isinstance(result, bool):
|
||||
raise ValueError("if expression must evaluate to a boolean")
|
||||
return result
|
||||
|
||||
async def run_inner_action(local_context: LocalContext) -> Any:
|
||||
def _build_step_action(self, step: FlowEachStepDefinition) -> NestedStepRunner:
|
||||
run_action = build_action(self.flow, step.action)
|
||||
|
||||
async def run_step_action(local_context: LocalContext) -> Any:
|
||||
kwargs = {_LOCAL_CONTEXT_KWARG: local_context}
|
||||
if inspect.iscoroutinefunction(run_action):
|
||||
result = run_action(**kwargs)
|
||||
@@ -261,7 +271,7 @@ class EachAction:
|
||||
result = await result
|
||||
return result
|
||||
|
||||
return run_inner_action
|
||||
return run_step_action
|
||||
|
||||
|
||||
_ACTION_TYPES: tuple[_ActionType, ...] = (
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from contextlib import suppress
|
||||
import contextvars
|
||||
import copy
|
||||
from datetime import datetime
|
||||
import threading
|
||||
import time
|
||||
@@ -53,6 +55,24 @@ def _default_embedder() -> OpenAIEmbeddingFunction:
|
||||
return build_embedder(spec)
|
||||
|
||||
|
||||
def _non_streaming_analysis_llm(llm: Any) -> Any:
|
||||
"""Return an isolated non-streaming LLM for internal memory analysis."""
|
||||
if not isinstance(llm, BaseLLM):
|
||||
return llm
|
||||
|
||||
try:
|
||||
analysis_llm = copy.copy(llm)
|
||||
except Exception:
|
||||
try:
|
||||
analysis_llm = llm.model_copy(deep=False)
|
||||
except Exception:
|
||||
return llm
|
||||
|
||||
with suppress(Exception):
|
||||
analysis_llm.stream = False
|
||||
return analysis_llm
|
||||
|
||||
|
||||
class Memory(BaseModel):
|
||||
"""Unified memory: standalone, LLM-analyzed, with intelligent recall flow.
|
||||
|
||||
@@ -200,7 +220,9 @@ class Memory(BaseModel):
|
||||
query_analysis_threshold=self.query_analysis_threshold,
|
||||
)
|
||||
|
||||
self._llm_instance = None if isinstance(self.llm, str) else self.llm
|
||||
self._llm_instance = (
|
||||
None if isinstance(self.llm, str) else _non_streaming_analysis_llm(self.llm)
|
||||
)
|
||||
self._embedder_instance = (
|
||||
self.embedder
|
||||
if (self.embedder is not None and not isinstance(self.embedder, dict))
|
||||
|
||||
@@ -19,6 +19,39 @@ from crewai.memory.types import (
|
||||
)
|
||||
|
||||
|
||||
def test_memory_analysis_llm_is_isolated_from_streaming_agent_llm(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Memory analysis should not share a mutable streaming LLM with the agent UI."""
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
from crewai.memory.unified_memory import Memory
|
||||
from crewai.utilities.types import LLMMessage
|
||||
|
||||
class FakeStreamingLLM(BaseLLM):
|
||||
def call(
|
||||
self,
|
||||
messages: str | list[LLMMessage],
|
||||
tools: list[dict] | None = None,
|
||||
callbacks: list | None = None,
|
||||
available_functions: dict | None = None,
|
||||
from_task: object | None = None,
|
||||
from_agent: object | None = None,
|
||||
response_model: type | None = None,
|
||||
) -> str:
|
||||
return ""
|
||||
|
||||
agent_llm = FakeStreamingLLM(model="fake-model", stream=True)
|
||||
mem = Memory(
|
||||
storage=str(tmp_path / "db"),
|
||||
llm=agent_llm,
|
||||
embedder=lambda texts: [[0.1] for _ in texts],
|
||||
)
|
||||
|
||||
assert mem._llm is not agent_llm
|
||||
assert mem._llm.stream is False
|
||||
|
||||
agent_llm.stream = True
|
||||
assert mem._llm.stream is False
|
||||
|
||||
|
||||
def test_memory_record_defaults() -> None:
|
||||
|
||||
@@ -37,6 +37,7 @@ def test_flow_public_exports_are_explicit():
|
||||
}
|
||||
assert set(flow_definition.__all__) == {
|
||||
"FlowActionDefinition",
|
||||
"FlowAtomicActionDefinition",
|
||||
"FlowCodeActionDefinition",
|
||||
"FlowConfigDefinition",
|
||||
"FlowConversationalDefinition",
|
||||
@@ -46,7 +47,7 @@ def test_flow_public_exports_are_explicit():
|
||||
"FlowDefinitionCondition",
|
||||
"FlowDictStateDefinition",
|
||||
"FlowEachActionDefinition",
|
||||
"FlowEachInnerActionDefinition",
|
||||
"FlowEachStepDefinition",
|
||||
"FlowExpressionActionDefinition",
|
||||
"FlowHumanFeedbackDefinition",
|
||||
"FlowJsonSchemaStateDefinition",
|
||||
@@ -107,7 +108,10 @@ def test_flow_definition_json_schema_carries_reference_descriptions():
|
||||
|
||||
each_properties = defs["FlowEachActionDefinition"]["properties"]
|
||||
assert "list to iterate" in each_properties["in"]["description"]
|
||||
assert "Ordered inner actions" in each_properties["do"]["description"]
|
||||
assert "Ordered steps" in each_properties["do"]["description"]
|
||||
|
||||
step_properties = defs["FlowEachStepDefinition"]["properties"]
|
||||
assert "runs only if" in step_properties["if"]["description"]
|
||||
|
||||
|
||||
def test_flow_definition_json_schema_carries_field_examples_only():
|
||||
@@ -122,6 +126,7 @@ def test_flow_definition_json_schema_carries_field_examples_only():
|
||||
"FlowExpressionActionDefinition",
|
||||
"FlowScriptActionDefinition",
|
||||
"FlowEachActionDefinition",
|
||||
"FlowEachStepDefinition",
|
||||
"FlowMethodDefinition",
|
||||
"FlowDictStateDefinition",
|
||||
"FlowJsonSchemaStateDefinition",
|
||||
@@ -154,7 +159,12 @@ def test_flow_definition_json_schema_carries_field_examples_only():
|
||||
|
||||
each_properties = defs["FlowEachActionDefinition"]["properties"]
|
||||
assert each_properties["in"]["examples"] == ["state.rows"]
|
||||
assert each_properties["do"]["examples"][0][0]["clean"]["call"] == "script"
|
||||
assert each_properties["do"]["examples"][0][0]["name"] == "clean"
|
||||
assert each_properties["do"]["examples"][0][0]["action"]["call"] == "script"
|
||||
assert each_properties["do"]["examples"][0][1]["if"] == "outputs.clean != ''"
|
||||
|
||||
step_properties = defs["FlowEachStepDefinition"]["properties"]
|
||||
assert step_properties["if"]["examples"] == ["item.kind == 'invoice'"]
|
||||
|
||||
method_properties = defs["FlowMethodDefinition"]["properties"]
|
||||
assert method_properties["listen"]["examples"] == [
|
||||
@@ -584,14 +594,16 @@ def test_each_action_round_trips_json_and_yaml():
|
||||
"in": "state.rows",
|
||||
"do": [
|
||||
{
|
||||
"normalize": {
|
||||
"name": "normalize",
|
||||
"action": {
|
||||
"call": "tool",
|
||||
"ref": "my_tools:NormalizeRowTool",
|
||||
"with": {"row": "${ item }"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"save": {
|
||||
"name": "save",
|
||||
"action": {
|
||||
"call": "code",
|
||||
"ref": "my_flow:save_row",
|
||||
"with": {
|
||||
|
||||
@@ -114,7 +114,7 @@ class EachActionFlow(Flow):
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise RuntimeError("inner action ran on the event loop")
|
||||
raise RuntimeError("each step ran on the event loop")
|
||||
|
||||
from crewai.flow.flow_context import current_flow_method_name
|
||||
|
||||
@@ -1081,7 +1081,8 @@ methods:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- normalize:
|
||||
- name: normalize
|
||||
action:
|
||||
call: code
|
||||
ref: {__name__}:EachActionFlow.normalize_row
|
||||
with:
|
||||
@@ -1097,7 +1098,7 @@ methods:
|
||||
]
|
||||
|
||||
|
||||
def test_each_action_runs_sync_inner_actions_off_event_loop_with_context():
|
||||
def test_each_action_runs_sync_steps_off_event_loop_with_context():
|
||||
yaml_str = f"""
|
||||
schema: crewai.flow/v1
|
||||
name: EachFlow
|
||||
@@ -1107,7 +1108,8 @@ methods:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- threaded:
|
||||
- name: threaded
|
||||
action:
|
||||
call: code
|
||||
ref: {__name__}:EachActionFlow.require_threaded_context
|
||||
with:
|
||||
@@ -1123,7 +1125,7 @@ methods:
|
||||
assert flow.inner_thread_id != caller_thread_id
|
||||
|
||||
|
||||
def test_each_action_runs_async_tool_results_from_sync_inner_actions():
|
||||
def test_each_action_runs_async_tool_results_from_sync_steps():
|
||||
yaml_str = f"""
|
||||
schema: crewai.flow/v1
|
||||
name: EachFlow
|
||||
@@ -1133,7 +1135,8 @@ methods:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- async_tool:
|
||||
- name: async_tool
|
||||
action:
|
||||
call: tool
|
||||
ref: {__name__}:AsyncResultTool
|
||||
with:
|
||||
@@ -1222,7 +1225,7 @@ methods:
|
||||
assert flow.state["input_matches_output"] is True
|
||||
|
||||
|
||||
def test_script_each_action_reads_item_and_inner_outputs(
|
||||
def test_script_each_action_reads_item_and_step_outputs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
|
||||
@@ -1241,11 +1244,13 @@ methods:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- clean:
|
||||
- name: clean
|
||||
action:
|
||||
call: script
|
||||
code: |
|
||||
return item.strip()
|
||||
- tag:
|
||||
- name: tag
|
||||
action:
|
||||
call: script
|
||||
code: |
|
||||
return f"{outputs['seed']}:{outputs['clean']}"
|
||||
@@ -1257,7 +1262,7 @@ methods:
|
||||
assert flow.kickoff(inputs={"rows": [" a ", " b "]}) == ["global:a", "global:b"]
|
||||
|
||||
|
||||
def test_each_action_uses_iteration_outputs_between_nested_actions():
|
||||
def test_each_action_uses_iteration_outputs_between_steps():
|
||||
yaml_str = f"""
|
||||
schema: crewai.flow/v1
|
||||
name: EachFlow
|
||||
@@ -1267,13 +1272,15 @@ methods:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- normalize:
|
||||
- name: normalize
|
||||
action:
|
||||
call: code
|
||||
ref: {__name__}:EachActionFlow.normalize_row
|
||||
with:
|
||||
row: "${{item}}"
|
||||
prefix: saved
|
||||
- save:
|
||||
- name: save
|
||||
action:
|
||||
call: code
|
||||
ref: {__name__}:EachActionFlow.save_row
|
||||
with:
|
||||
@@ -1290,7 +1297,7 @@ methods:
|
||||
]
|
||||
|
||||
|
||||
def test_each_action_resets_inner_outputs_between_iterations():
|
||||
def test_each_action_resets_step_outputs_between_iterations():
|
||||
yaml_str = """
|
||||
schema: crewai.flow/v1
|
||||
name: EachFlow
|
||||
@@ -1300,10 +1307,12 @@ methods:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- leak_check:
|
||||
- name: leak_check
|
||||
action:
|
||||
call: expression
|
||||
expr: "has(outputs.previous) ? outputs.previous : 'empty'"
|
||||
- previous:
|
||||
- name: previous
|
||||
action:
|
||||
call: expression
|
||||
expr: item
|
||||
start: true
|
||||
@@ -1317,7 +1326,7 @@ methods:
|
||||
]
|
||||
|
||||
|
||||
def test_each_action_preserves_flow_outputs_and_prefers_inner_outputs():
|
||||
def test_each_action_preserves_flow_outputs_and_prefers_step_outputs():
|
||||
yaml_str = """
|
||||
schema: crewai.flow/v1
|
||||
name: EachFlow
|
||||
@@ -1332,13 +1341,16 @@ methods:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- before_shadow:
|
||||
- name: before_shadow
|
||||
action:
|
||||
call: expression
|
||||
expr: "outputs.seed + ':' + item"
|
||||
- seed:
|
||||
- name: seed
|
||||
action:
|
||||
call: expression
|
||||
expr: "'local:' + item"
|
||||
- after_shadow:
|
||||
- name: after_shadow
|
||||
action:
|
||||
call: expression
|
||||
expr: "outputs.seed"
|
||||
listen: seed
|
||||
@@ -1356,6 +1368,103 @@ methods:
|
||||
]
|
||||
|
||||
|
||||
def test_each_action_runs_simple_if_clauses():
|
||||
yaml_str = """
|
||||
schema: crewai.flow/v1
|
||||
name: EachIfFlow
|
||||
methods:
|
||||
process_rows:
|
||||
do:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- name: kind
|
||||
action:
|
||||
call: expression
|
||||
expr: item.kind
|
||||
- name: kept
|
||||
if: "outputs.kind == 'keep'"
|
||||
action:
|
||||
call: expression
|
||||
expr: "'kept:' + item.value"
|
||||
- name: skipped
|
||||
if: "outputs.kind != 'keep'"
|
||||
action:
|
||||
call: expression
|
||||
expr: "'skipped:' + item.value"
|
||||
start: true
|
||||
"""
|
||||
|
||||
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
|
||||
|
||||
assert flow.kickoff(
|
||||
inputs={
|
||||
"rows": [
|
||||
{"kind": "keep", "value": "a"},
|
||||
{"kind": "drop", "value": "b"},
|
||||
]
|
||||
}
|
||||
) == ["kept:a", "skipped:b"]
|
||||
|
||||
|
||||
def test_each_action_skipped_if_keeps_previous_output():
|
||||
yaml_str = """
|
||||
schema: crewai.flow/v1
|
||||
name: EachIfFlow
|
||||
methods:
|
||||
process_rows:
|
||||
do:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- name: original
|
||||
action:
|
||||
call: expression
|
||||
expr: item.value
|
||||
- name: maybe_included
|
||||
if: item.include
|
||||
action:
|
||||
call: expression
|
||||
expr: "'included:' + item.value"
|
||||
start: true
|
||||
"""
|
||||
|
||||
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
|
||||
|
||||
assert flow.kickoff(
|
||||
inputs={
|
||||
"rows": [
|
||||
{"include": True, "value": "a"},
|
||||
{"include": False, "value": "b"},
|
||||
]
|
||||
}
|
||||
) == ["included:a", "b"]
|
||||
|
||||
|
||||
def test_each_action_if_condition_must_be_boolean():
|
||||
yaml_str = """
|
||||
schema: crewai.flow/v1
|
||||
name: EachIfFlow
|
||||
methods:
|
||||
process_rows:
|
||||
do:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- name: value
|
||||
if: item.value
|
||||
action:
|
||||
call: expression
|
||||
expr: item.value
|
||||
start: true
|
||||
"""
|
||||
|
||||
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
|
||||
|
||||
with pytest.raises(ValueError, match="if expression must evaluate to a boolean"):
|
||||
flow.kickoff(inputs={"rows": [{"value": "truthy"}]})
|
||||
|
||||
|
||||
def test_each_action_empty_list_returns_empty_and_listener_runs_once():
|
||||
yaml_str = f"""
|
||||
schema: crewai.flow/v1
|
||||
@@ -1366,7 +1475,8 @@ methods:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- normalize:
|
||||
- name: normalize
|
||||
action:
|
||||
call: code
|
||||
ref: {__name__}:EachActionFlow.normalize_row
|
||||
with:
|
||||
@@ -1415,7 +1525,12 @@ def test_each_action_rejects_non_list_inputs(expr, inputs):
|
||||
"do": {
|
||||
"call": "each",
|
||||
"in": expr,
|
||||
"do": [{"value": {"call": "expression", "expr": "item"}}],
|
||||
"do": [
|
||||
{
|
||||
"name": "value",
|
||||
"action": {"call": "expression", "expr": "item"},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -1431,15 +1546,25 @@ def test_each_action_rejects_non_list_inputs(expr, inputs):
|
||||
"action_do",
|
||||
[
|
||||
[],
|
||||
[{"first": {"call": "expression", "expr": "item"}, "second": {"call": "expression", "expr": "item"}}],
|
||||
[{"1bad": {"call": "expression", "expr": "item"}}],
|
||||
[{"value": {"call": "expression", "expr": "item"}}],
|
||||
[{"name": "1bad", "action": {"call": "expression", "expr": "item"}}],
|
||||
[{"name": "missing_action"}],
|
||||
[{"action": {"call": "expression", "expr": "item"}}],
|
||||
[
|
||||
{"same": {"call": "expression", "expr": "item"}},
|
||||
{"same": {"call": "expression", "expr": "item"}},
|
||||
{
|
||||
"name": "value",
|
||||
"if": "true",
|
||||
"then": [],
|
||||
"action": {"call": "expression", "expr": "item"},
|
||||
}
|
||||
],
|
||||
[
|
||||
{"name": "same", "action": {"call": "expression", "expr": "item"}},
|
||||
{"name": "same", "action": {"call": "expression", "expr": "item"}},
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_each_action_validates_inner_action_shape(action_do):
|
||||
def test_each_action_validates_step_shape(action_do):
|
||||
with pytest.raises(ValidationError):
|
||||
FlowDefinition.from_dict(
|
||||
{
|
||||
@@ -1459,6 +1584,26 @@ def test_each_action_validates_inner_action_shape(action_do):
|
||||
)
|
||||
|
||||
|
||||
def test_if_clauses_are_rejected_at_method_level():
|
||||
with pytest.raises(ValidationError):
|
||||
FlowDefinition.from_dict(
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "TopLevelIfFlow",
|
||||
"methods": {
|
||||
"process": {
|
||||
"start": True,
|
||||
"do": {
|
||||
"call": "expression",
|
||||
"if": "true",
|
||||
"expr": "'ok'",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_each_action_rejects_nested_each_actions():
|
||||
with pytest.raises(ValidationError):
|
||||
FlowDefinition.from_dict(
|
||||
@@ -1473,12 +1618,14 @@ def test_each_action_rejects_nested_each_actions():
|
||||
"in": "state.rows",
|
||||
"do": [
|
||||
{
|
||||
"nested": {
|
||||
"name": "nested",
|
||||
"action": {
|
||||
"call": "each",
|
||||
"in": "state.children",
|
||||
"do": [
|
||||
{
|
||||
"child": {
|
||||
"name": "child",
|
||||
"action": {
|
||||
"call": "expression",
|
||||
"expr": "item",
|
||||
}
|
||||
@@ -1504,7 +1651,8 @@ methods:
|
||||
call: each
|
||||
in: state.rows
|
||||
do:
|
||||
- validate:
|
||||
- name: validate
|
||||
action:
|
||||
call: code
|
||||
ref: {__name__}:EachActionFlow.fail_on_bad_row
|
||||
with:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.8a"
|
||||
__version__ = "1.14.8a1"
|
||||
|
||||
@@ -171,6 +171,8 @@ info = "Commits must follow Conventional Commits 1.0.0."
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "3 days"
|
||||
# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
|
||||
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
|
||||
|
||||
# composio-core pins rich<14 but textual requires rich>=14.
|
||||
# onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10.
|
||||
@@ -180,7 +182,8 @@ exclude-newer = "3 days"
|
||||
# langchain-text-splitters <1.1.2 has GHSA-fv5p-p927-qmxr (SSRF bypass in split_text_from_url).
|
||||
# transformers 4.57.6 has CVE-2026-1839; force 5.4+ (docling 2.84 allows huggingface-hub>=1).
|
||||
# cryptography 46.0.6 has CVE-2026-39892; force 46.0.7+.
|
||||
# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p; force 6.10.2+.
|
||||
# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p.
|
||||
# pypdf <6.13.3 has GHSA-jm82-fx9c-mx94; force 6.13.3+.
|
||||
# uv <0.11.15 has GHSA-4gg8-gxpx-9rph (and earlier GHSA-pjjw-68hj-v9mw); force 0.11.15+.
|
||||
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
|
||||
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
|
||||
@@ -205,7 +208,7 @@ override-dependencies = [
|
||||
"urllib3>=2.7.0",
|
||||
"transformers>=5.4.0; python_version >= '3.10'",
|
||||
"cryptography>=46.0.7",
|
||||
"pypdf>=6.10.2,<7",
|
||||
"pypdf>=6.13.3,<7",
|
||||
"uv>=0.11.15,<1",
|
||||
"python-multipart>=0.0.27,<1",
|
||||
"gitpython>=3.1.50,<4",
|
||||
|
||||
13
uv.lock
generated
13
uv.lock
generated
@@ -16,6 +16,9 @@ resolution-markers = [
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[options.exclude-newer-package]
|
||||
pypdf = "2026-06-18T00:00:00Z"
|
||||
|
||||
[manifest]
|
||||
members = [
|
||||
"crewai",
|
||||
@@ -40,7 +43,7 @@ overrides = [
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "pip", specifier = ">=26.1.2" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.14.0" },
|
||||
{ name = "pypdf", specifier = ">=6.10.2,<7" },
|
||||
{ name = "pypdf", specifier = ">=6.13.3,<7" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27,<1" },
|
||||
{ name = "rich", specifier = ">=13.7.1" },
|
||||
{ name = "starlette", specifier = ">=1.3.1" },
|
||||
@@ -1584,7 +1587,7 @@ requires-dist = [
|
||||
{ name = "aiofiles", specifier = "~=24.1.0" },
|
||||
{ name = "av", specifier = "~=13.0.0" },
|
||||
{ name = "pillow", specifier = "~=12.1.1" },
|
||||
{ name = "pypdf", specifier = "~=6.10.0" },
|
||||
{ name = "pypdf", specifier = "~=6.13.3" },
|
||||
{ name = "python-magic", specifier = ">=0.4.27" },
|
||||
{ name = "tinytag", specifier = "~=2.2.1" },
|
||||
]
|
||||
@@ -7188,14 +7191,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.13.1"
|
||||
version = "6.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/d9/9d12fa0d9660d03320725ff686c961b645a4218940a82296e1272d9e1ff0/pypdf-6.13.1.tar.gz", hash = "sha256:4841d8a4c1589e5833915dc0c7ddfacff80a2e0bcbeb5d1e681fecaa1674b03a", size = 6477811, upload-time = "2026-06-08T11:01:49.344Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/18/9947cc201af9ccf76720fd3347bf4f70eb882ce3fcf4cb05f7443e4cf871/pypdf-6.13.3.tar.gz", hash = "sha256:f3cb822769725f1bac658c406cfc9460399043f3750c2d3e4650e0a85eacabd7", size = 6484063, upload-time = "2026-06-17T15:22:00.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/dd/8f03e0a5788a5d1feb4550617c3e6db5e9099eaee248a3e482ddaeacbbb0/pypdf-6.13.1-py3-none-any.whl", hash = "sha256:e555e4ce3f561ef069307622f1374136ba964ca6ca24f24158701decaf83ed9b", size = 346259, upload-time = "2026-06-08T11:01:47.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/56/2967e621598987905fb8cdfadd8f8de6b5c68c9351f0523c4df8409f28f1/pypdf-6.13.3-py3-none-any.whl", hash = "sha256:c6e3f86afb625791510b02ad5480e94b63970bb957df75d44657c282ecc52224", size = 347288, upload-time = "2026-06-17T15:21:59.512Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user